Laravel: Tjedne snimke stranice za vlasnike poduzeća uz Screenshot API
Web-mjesto se može promijeniti a da nitko to ne primijeti: promocija nestane, gumb za rezervaciju pomakne se ispod prijeloma stranice ili implementacija tiho pokvari mobilni raspored. Za vlasnika male tvrtke tjedna arhiva snimki zaslona pruža jednostavan vizualni zapis koji je lakše pregledati nego napomene o izdanju ili grafikone nadzora.
Ovaj vodič gradi tu arhivu u Laravelu. Zakazana naredba šalje jedan posao u redu čekanja za svaku važnu stranicu, namjenski klijent preuzima svaki PNG, a Laravel pohranjuje sliku uz datoteku metapodataka koja sadrži njezin kontrolni zbroj i relevantna zaglavlja predmemorije ili kvote. Aplikacija nikada ne mora instalirati, zakrpati ni upravljati Chromiumom.
Dobijte pristup Screenshot API-ju
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Screenshot API. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Posjetite službenu dokumentaciju. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
- Čuvajte taj token u konfiguraciji podržanoj varijablama okruženja. Njegova regeneracija opoziva prethodno aktivni token, stoga rotacija mora ažurirati svaku implementiranu aplikaciju koja ga koristi.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Bearer token je najsigurniji zadani izbor jer se parametri upita često pojavljuju u zapisnicima proxyja i pristupa.
Potvrdite točan HTTP ugovor
Operacija snimanja je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njezin obavezni parametar upita je url, a uspješan odgovor sadrži tijelo image/png uz zaglavlja odgovora za predmemoriju i kvotu.
Pošaljite jedan minimalni zahtjev prije pisanja integracijskog koda:
curl --get \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: image/png" \
--data-urlencode "url=https://example.com/" \
--dump-header screenshot-headers.txt \
--output screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Pregledajte HTTP status i zaglavlja, kao i otvorite PNG. Datoteka nazvana screenshot.png nije dokaz da je odgovor bio slika; neuspješna krajnja točka može vratiti tijelo pogreške koje neoprezan klijent sprema pod istom ekstenzijom.
Sada postavite vjerodajnicu i URL-ove stranica u vlasništvu tvrtke u okruženje implementacije:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_DISK=local
SNAPSHOT_HOME_URL=https://www.example.com/
SNAPSHOT_BOOKING_URL=https://www.example.com/book
Nemojte predavati stvarne vrijednosti u repozitorij. Ako je Laravel konfiguracija već predmemorirana, promjena samo datoteke .env neće ažurirati aktivnu implementaciju; tijekom izdanja ponovno izgradite predmemoriju konfiguracije.
Arhitektura i struktura projekta
Dizajn namjerno ima četiri granice:
ScreenshotClientupravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, mapiranjem statusa, validacijom PNG-a i normalizacijom zaglavlja odgovora.ScreenshotCaptureje rezultat domene. Ostatak aplikacije ne ovisi o Laravelovu HTTP objektu odgovora.CapturePageSnapshotobavlja spor mrežni rad i rad pohrane u redu čekanja.snapshots:capturešalje konfigurirane stranice, dok Laravelov raspoređivač poziva naredbu tjedno.
Stvorite app/Services/Screenshots, app/Jobs i app/Console/Commands. Relevantne datoteke su config/services.php, config/snapshots.php, ScreenshotCapture.php, ScreenshotApiException.php, ScreenshotClient.php, CapturePageSnapshot.php, CaptureSnapshots.php i routes/console.php.
Redovi čekanja ovdje su korisni jer snimka zaslona može trajati nekoliko sekundi, a više stranica ne bi smjelo zadržavati raspoređivač otvorenim. Svaki posao ostaje neovisno ponovno pokušiv. Kompromis je operativan: produkcija sada treba radnik reda čekanja i zajedničku predmemoriju sposobnu za zaključavanje ako nekoliko čvorova aplikacije pokreće raspoređivač.
Konfigurirajte stranice i vjerodajnice
Dodajte ovaj unos u polje koje vraća config/services.php:
'screenshot_api' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
],
Stvorite config/snapshots.php:
<?php
return [
'disk' => env('SNAPSHOT_DISK', 'local'),
'pages' => [
['key' => 'home', 'url' => env('SNAPSHOT_HOME_URL')],
['key' => 'booking', 'url' => env('SNAPSHOT_BOOKING_URL')],
],
];
Ključevi postaju komponente putanje pohrane, stoga ih držite malim slovima i stabilnima. Tretirajte ovu konfiguraciju kao popis dopuštenih stavki. Nemojte značajku pretvoriti u javnu krajnju točku „snimi bilo koji URL”; prihvaćanje proizvoljnih URL-ova stvorilo bi površinu za zlouporabu i krivotvorenje zahtjeva na strani poslužitelja.
Mapirajte API odgovor na granici
Stvorite klase rezultata i iznimke:
<?php
// app/Services/Screenshots/ScreenshotCapture.php
namespace App\Services\Screenshots;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public array $operationalHeaders,
) {}
}
// app/Services/Screenshots/ScreenshotApiException.php
namespace App\Services\Screenshots;
use RuntimeException;
use Throwable;
final class ScreenshotApiException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
public readonly ?int $retryAfterSeconds = null,
?Throwable $previous = null,
) {
parent::__construct("Screenshot capture failed: {$kind}", 0, $previous);
}
}
Iznimka izlaže stabilnu vrstu neuspjeha na razini aplikacije bez pohranjivanja uzlaznog tijela, URL-a ili tokena. Posao stoga može razlikovati trajni problem autentikacije od privremenog prekida rada.
Izgradite obrambeni Laravel HTTP klijent
Klijent u nastavku koristi ograničena vremenska ograničenja povezivanja i odgovora. Ponovno pokušava samo neuspjehe povezivanja, HTTP 429 i pogreške poslužitelja. Neuspjesi autentikacije i validacije vraćaju se odmah jer ponavljanje istog nevaljanog zahtjeva troši kvotu i odgađa dijagnozu.
<?php
namespace App\Services\Screenshots;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use LogicException;
final class ScreenshotClient
{
public function capture(string $url): ScreenshotCapture
{
$token = config('services.screenshot_api.token');
$endpoint = config('services.screenshot_api.endpoint');
if (! is_string($token) || $token === '') {
throw new LogicException('SCREENSHOT_API_TOKEN is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(5)
->timeout(30)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotApiException(
'transport', previous: $exception
);
}
usleep([250, 1000][$attempt - 1] * 1000);
continue;
}
if ($response->successful()) {
return $this->mapSuccessfulResponse($response);
}
$status = $response->status();
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < 3) {
usleep($this->retryDelayMilliseconds($response, $attempt) * 1000);
continue;
}
$kind = match (true) {
in_array($status, [401, 403], true) => 'authentication',
in_array($status, [400, 422], true) => 'invalid_request',
$status === 429 => 'quota',
$status >= 500 => 'upstream_unavailable',
default => 'upstream_error',
};
throw new ScreenshotApiException(
$kind,
$status,
$this->retryAfterSeconds($response),
);
}
throw new ScreenshotApiException('unexpected_state');
}
private function mapSuccessfulResponse(Response $response): ScreenshotCapture
{
$body = $response->body();
$type = strtolower((string) $response->header('Content-Type'));
if (! str_starts_with($type, 'image/png')
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotApiException(
'invalid_response',
$response->status(),
);
}
return new ScreenshotCapture($body, $this->operationalHeaders($response));
}
private function retryDelayMilliseconds(Response $response, int $attempt): int
{
$seconds = $this->retryAfterSeconds($response);
if ($seconds !== null) {
return min($seconds * 1000, 10000);
}
return [250, 1000][$attempt - 1];
}
private function retryAfterSeconds(Response $response): ?int
{
$value = $response->header('Retry-After');
return is_string($value) && ctype_digit($value)
? min((int) $value, 3600)
: null;
}
private function operationalHeaders(Response $response): array
{
$kept = [];
$standardCacheHeaders = [
'age', 'cache-control', 'etag', 'expires', 'last-modified', 'vary',
];
foreach ($response->headers() as $name => $values) {
$lower = strtolower($name);
$relevant = in_array($lower, $standardCacheHeaders, true)
|| $lower === 'retry-after'
|| str_contains($lower, 'cache')
|| str_contains($lower, 'quota')
|| str_contains($lower, 'ratelimit')
|| str_contains($lower, 'rate-limit');
if ($relevant) {
$kept[$lower] = implode(', ', (array) $values);
}
}
return $kept;
}
}
Mapirač zaglavlja namjerno čuva nazive i vrijednosti umjesto da pretpostavlja nedokumentiranu shemu kvote. To omogućuje metapodacima da zadrže signale predmemorije i kvote usluge bez pogrešnog tretiranja određenog zaglavlja kao zajamčenog. Provjera PNG potpisa također sprječava ulazak HTML ili JSON dokumenta pogreške u vizualnu arhivu.
Pohranite jednu idempotentnu snimku po tjednu
Posao u redu čekanja koristi ISO tjedan poput 2026-W41 kao ključ arhive. Ponovno pokretanje posla za taj tjedan popravlja ili zamjenjuje isti objekt umjesto stvaranja duplikata.
<?php
namespace App\Jobs;
use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
final class CapturePageSnapshot implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public array $backoff = [300, 1800];
public int $uniqueFor = 7200;
public function __construct(
public readonly string $key,
public readonly string $url,
public readonly string $period,
) {}
public function uniqueId(): string
{
return "{$this->key}:{$this->period}";
}
public function handle(ScreenshotClient $client): void
{
try {
$capture = $client->capture($this->url);
} catch (ScreenshotApiException $exception) {
Log::warning('Weekly screenshot capture failed.', [
'page' => $this->key,
'period' => $this->period,
'kind' => $exception->kind,
'status' => $exception->status,
]);
if (in_array($exception->kind, [
'authentication', 'invalid_request',
], true)) {
$this->fail($exception);
return;
}
if ($exception->kind === 'quota') {
$delay = $exception->retryAfterSeconds ?? 900;
$this->release(min(max($delay, 60), 3600));
return;
}
throw $exception;
}
$base = "snapshots/{$this->key}/{$this->period}";
$disk = Storage::disk(config('snapshots.disk'));
if (! $disk->put("{$base}.png", $capture->png)) {
throw new RuntimeException('Could not store screenshot.');
}
$metadata = json_encode([
'page' => $this->key,
'period' => $this->period,
'captured_at' => now('UTC')->toIso8601String(),
'sha256' => hash('sha256', $capture->png),
'response_headers' => $capture->operationalHeaders,
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (! $disk->put("{$base}.json", $metadata)) {
throw new RuntimeException('Could not store snapshot metadata.');
}
Log::info('Weekly screenshot stored.', [
'page' => $this->key,
'period' => $this->period,
'bytes' => strlen($capture->png),
]);
}
}
Zapisnik sadrži identifikatore i operativno stanje, nikada token, tijelo odgovora ni potpuni ciljni URL. Popratni kontrolni zbroj olakšava kasnije provjere integriteta.
Pošaljite i rasporedite snimanja
Stvorite app/Console/Commands/CaptureSnapshots.php:
<?php
namespace App\Console\Commands;
use App\Jobs\CapturePageSnapshot;
use Illuminate\Console\Command;
final class CaptureSnapshots extends Command
{
protected $signature = 'snapshots:capture';
protected $description = 'Queue weekly screenshots of configured pages';
public function handle(): int
{
$period = now('UTC')->format('o-\WW');
$dispatched = 0;
foreach (config('snapshots.pages', []) as $page) {
$key = $page['key'] ?? null;
$url = $page['url'] ?? null;
$scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;
if (! is_string($key)
|| preg_match('/^[a-z0-9-]+$/', $key) !== 1
|| ! filter_var($url, FILTER_VALIDATE_URL)
|| $scheme !== 'https') {
$this->error('Snapshot configuration contains an invalid page.');
return self::FAILURE;
}
CapturePageSnapshot::dispatch($key, $url, $period);
$dispatched++;
}
$this->info("Queued {$dispatched} weekly snapshots.");
return self::SUCCESS;
}
}
Zatim dodajte tjedni raspored u routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('snapshots:capture')
->weeklyOn(1, '06:00')
->timezone('UTC')
->onOneServer()
->withoutOverlapping();
Ovo stavlja snimke u red čekanja svakog ponedjeljka u 06:00 UTC. onOneServer() i withoutOverlapping() zahtijevaju funkcionalne zaključavanja predmemorije; upotrijebite zajedničku predmemoriju sposobnu za zaključavanje kada više čvorova pokreće raspoređivač.
Testirajte uspjeh i trajni neuspjeh
Laravelov Http::fake() održava testove determinističkima i sprječava slučajno korištenje kvote. Sljedeći testovi značajki provjeravaju pohranu, metapodatke i pravilo da se neuspjesi autentikacije ne pokušavaju ponovno:
<?php
namespace Tests\Feature;
use App\Jobs\CapturePageSnapshot;
use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class CapturePageSnapshotTest extends TestCase
{
public function test_it_stores_a_png_and_metadata(): void
{
Storage::fake('local');
config([
'snapshots.disk' => 'local',
'services.screenshot_api.token' => 'test-token',
]);
$png = "\x89PNG\r\n\x1a\n" . 'deterministic-test-content';
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=60',
]),
]);
$job = new CapturePageSnapshot(
'home',
'https://example.com/',
'2026-W41',
);
$job->handle(app(ScreenshotClient::class));
Storage::disk('local')->assertExists(
'snapshots/home/2026-W41.png'
);
Storage::disk('local')->assertExists(
'snapshots/home/2026-W41.json'
);
Http::assertSentCount(1);
}
public function test_authentication_failure_is_not_retried(): void
{
config(['services.screenshot_api.token' => 'invalid-test-token']);
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response('Unauthorized', 401),
]);
try {
app(ScreenshotClient::class)->capture('https://example.com/');
$this->fail('Expected ScreenshotApiException.');
} catch (ScreenshotApiException $exception) {
$this->assertSame('authentication', $exception->kind);
$this->assertSame(401, $exception->status);
}
Http::assertSentCount(1);
}
}
Implementirajte i upravljajte arhivom
Produkcija treba raspoređivač, trajnog radnika reda čekanja, dugotrajnu pohranu i osvježavanja konfiguracije:
php artisan config:cache
php artisan test
php artisan snapshots:capture
php artisan queue:work --queue=default --tries=3 --timeout=120
* * * * * cd /path/to/application && php artisan schedule:run >> /dev/null 2>&1
Pokrenite radnika pod nadzorom procesa operacijskog sustava kako bi se ponovno pokrenuo nakon implementacija i rušenja. Ako izdanja koriste efemerne kontejnere ili nekoliko čvorova aplikacije, nemojte ostavljati arhivu na disku lokalnom za čvor. Postavite SNAPSHOT_DISK na konfigurirani trajni Laravelov disk datotečnog sustava.
Postavite upozorenja za neuspjele poslove i ponovljene događaje authentication, quota ili invalid_response. Bilježite dubinu reda čekanja i trajanje snimanja u postojeći sustav promatranja aplikacije. Odlučite o politici zadržavanja na temelju potreba vlasnika; tjedne slike pojedinačno su male, ali čine neograničenu zbirku.
Česti neuspjesi
- Svaki zahtjev vraća 401 ili 403: provjerite token ograničen na uslugu, ponovno izgradite predmemoriranu konfiguraciju i zapamtite da je regeneriranje tokena opozvalo njegovog prethodnika.
- Naredba ne stavlja ništa korisno u red čekanja: provjerite postoje li varijable okruženja stranica u izvođenom okruženju, a ne samo u lokalnoj ljusci.
- Poslovi ostaju na čekanju: potvrdite da radnik reda čekanja radi uz istu vezu reda čekanja kao web-aplikacija.
- Samo jedan poslužitelj ima slike: premjestite snimke u zajedničku trajnu pohranu i osigurajte da svaki čvor koristi istu konfiguraciju diska.
- Raspored se izvršava više puta: provjerite zajedničku predmemoriju i njezinu podršku za zaključavanje, zatim pregledajte raspoređivač na svakom čvoru.
- PNG je odbijen: pregledajte status i vrstu sadržaja bez bilježenja tokena ili tijela. Usluga je možda vratila dokument pogreške umjesto slike.
Završni kontrolni popis za provjeru
- Plan je aktivan, a trenutni token usluge prisutan je samo u tajnama podržanim varijablama okruženja.
- Minimalni zahtjev vraća uspješan HTTP odgovor,
image/pngi PNG koji se može pregledati. php artisan snapshots:capturešalje svaku konfiguriranu HTTPS stranicu.- Red čekanja zapisuje odgovarajuće objekte
.pngi.jsonpod očekivanim ISO tjednom. - Kontrolni zbroj metapodataka odgovara pohranjenoj slici i doslovno zadržava relevantna zaglavlja predmemorije i kvote.
- Neuspjesi autentikacije i nevaljanog zahtjeva odmah se zaustavljaju; neuspjesi povezivanja, kvote i poslužitelja dobivaju ograničene ponovne pokušaje.
- Raspoređivač ima zaključavanje za jedan poslužitelj, radnik je nadziran, a neuspjeli poslovi stvaraju upozorenje.
Dovršeni sustav namjerno je skroman: raspored, red čekanja, dvije datoteke po stranici i stroga API granica. Ipak, svakog ponedjeljka stvara nešto neobično korisno — vizualnu vremensku crtu koju vlasnik tvrtke može razumjeti na prvi pogled. Dobre produkcijske integracije često izgledaju ovako: male po površini, izričite u vezi s neuspjehom i tiho pouzdane dugo nakon prvog uspješnog zahtjeva.