Vodiči

Laravel: Weekly Web Page Screenshots for Small Business Owners

Laravel: Tjedne snimke zaslona web-stranice za vlasnike malih poduzeća

Web-stranica može se neprimjetno pokvariti. Ažuriranje teme pomakne poziv na akciju ispod prvog prikaza, stranica za rezervacije izgubi stiliziranje ili sezonska promocija ostane aktivna tjednima nakon isteka roka. Nadzor dostupnosti i dalje prijavljuje uspjeh jer poslužitelj nastavlja vraćati HTTP 200.

Tjedna povijest snimki zaslona otkriva drukčiju vrstu problema: neželjene vizualne promjene. Ovaj vodič izrađuje Laravel aplikaciju spremnu za produkciju koja svakog ponedjeljka snima važne stranice, pohranjuje privatne PNG datoteke, bilježi operativne metapodatke i ponovno pokušava samo neuspjehe koji se mogu oporaviti.

Dobijte pristup Screenshot API-ju

Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite putem https://ai.mihajlo.mk/login.

Otvorite stranicu usluge Screenshot API, odaberite dostupan Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Koristit ćemo Bearer oblik kako se vjerodajnica ne bi pojavila u URL-ovima, zapisima proxyja ili povijesti preglednika. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotaciju tokena planirajte kao implementaciju, a ne kao izdvojenu radnju u nadzornoj ploči.

Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, s obveznim parametrom upita url. Testirajte ga bez zapisivanja slike u terminal:

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://example.com/' \
  --output screenshot.png \
  --dump-header screenshot.headers

Tijelo uspješnog odgovora je image/png. Odgovor također uključuje informacije o predmemoriji i kvoti u zaglavljima. Aplikacija će ta zaglavlja zadržati kao metapodatke bez pretpostavljanja nedokumentiranih polja odgovora.

Prije izrade značajke pohranite token u Laravelovu konfiguraciju okruženja:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
BUSINESS_SITE_URL=https://www.example.com
VISUAL_HISTORY_DISK=local
QUEUE_CONNECTION=database

Nikada nemojte predati .env u repozitorij. Produkcijske tajne trebaju dolaziti iz šifriranog spremišta tajni platforme za hosting, dok .env.example sadrži samo rezervirana mjesta.

Arhitektura prilagođena malom poduzeću

Projekt zahtijeva PHP 8.3 ili noviji, održavanu Laravel aplikaciju, bazu podataka, konfigurirani red čekanja i privatnu pohranu s dopuštenjem za pisanje. Paket za automatizaciju preglednika nije potreban jer API isporučuje renderirani PNG.

Tjedni raspoređivač stvara jedan zapis u bazi podataka za svaku konfiguriranu stranicu i razdoblje. Poslovi reda čekanja izvršavaju sporiji mrežni rad i rad s pohranom. Namjenski klijent provjerava udaljenu granicu i preslikava je u mali objekt domene. Time se razdvajaju četiri pitanja:

  • Naredba odlučuje što treba snimiti.
  • Red čekanja upravlja ponovnim pokušajima i sprječava isteke vremena raspoređivača.
  • Klijent razumije HTTP, autentikaciju i provjeru PNG-a.
  • Zapis snimke pruža provjerljivu povijest uspjeha i neuspjeha.

Relevantne datoteke su config/services.php, config/visual-history.php, app/Services/ScreenshotClient.php, app/Jobs/CapturePageSnapshot.php, app/Console/Commands/CaptureVisualHistory.php, app/Models/PageSnapshot.php, migracija i routes/console.php.

Konfigurirajte uslugu i nadzirane stranice

Dodajte sljedeći unos u polje koje vraća config/services.php:

'screenshot' => [
    'base_url' => 'https://ai.mihajlo.mk/api/screenshot-api',
    'token' => env('SCREENSHOT_API_TOKEN'),
    'connect_timeout' => 5,
    'timeout' => 45,
],

Stvorite config/visual-history.php. Početni popis neka bude kratak: početna stranica, stranica za kontakt i stranica najizravnije povezana s prihodom obično su korisnije od iscrpnog pretraživanja.

<?php

$site = rtrim(env('BUSINESS_SITE_URL', 'https://example.com'), '/');

return [
    'disk' => env('VISUAL_HISTORY_DISK', 'local'),
    'pages' => [
        ['key' => 'home', 'url' => $site],
        ['key' => 'contact', 'url' => $site.'/contact'],
        ['key' => 'booking', 'url' => $site.'/booking'],
    ],
];

Ovi URL-ovi su pouzdana konfiguracija implementacije, a ne unos posjetitelja. Ta je razlika važna: prihvaćanje proizvoljnih URL-ova stvorilo bi površinu za krivotvorenje zahtjeva na strani poslužitelja i u vašoj aplikaciji i u nizvodnoj usluzi snimanja.

Trajno pohranite idempotentnu tjednu povijest

Stvorite model i migraciju pomoću Artisana:

php artisan make:model PageSnapshot -m
php artisan make:job CapturePageSnapshot
php artisan make:command CaptureVisualHistory
php artisan queue:table
php artisan migrate

Definirajte tablicu snimki u generiranoj migraciji:

Schema::create('page_snapshots', function (Blueprint $table) {
    $table->id();
    $table->string('page_key', 80);
    $table->text('url');
    $table->date('period_start');
    $table->timestamp('captured_at')->nullable();
    $table->string('status', 20)->default('pending');
    $table->string('disk')->nullable();
    $table->string('path')->nullable();
    $table->unsignedSmallInteger('http_status')->nullable();
    $table->json('response_headers')->nullable();
    $table->text('error')->nullable();
    $table->timestamps();

    $table->unique(['page_key', 'period_start']);
});

Jedinstveno ograničenje čini raspoređivanje idempotentnim. Ako se raspoređivač pokrene dvaput, ne može stvoriti dva „tjedna” snimanja za istu stranicu.

U PageSnapshot dopustite gore navedena polja putem $fillable te pretvorite period_start u date, captured_at u datetime i response_headers u array.

Izradite obrambenu granicu API-ja

Stvorite mali nepromjenjivi objekt odgovora i dvije klase iznimki u mapi app/Services:

<?php

namespace App\Services;

final readonly class CaptureImage
{
    public function __construct(
        public string $bytes,
        public int $status,
        public array $operationalHeaders,
    ) {}
}

class PermanentCaptureException extends \RuntimeException {}

final class RetryableCaptureException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct($message);
    }
}

Klijent koristi Laravelov ugrađeni HTTP klijent, ograničena vremenska ograničenja i izričitu klasifikaciju neuspjeha. Ne pokušava ponovno autentikaciju, provjeru valjanosti ni druge netrajne pogreške klijenta.

<?php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

final class ScreenshotClient
{
    public function capture(string $url): CaptureImage
    {
        if (! filter_var($url, FILTER_VALIDATE_URL)
            || ! in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
            throw new PermanentCaptureException('Configured capture URL is invalid.');
        }

        $token = config('services.screenshot.token');

        if (! is_string($token) || $token === '') {
            throw new PermanentCaptureException('Screenshot API token is missing.');
        }

        try {
            $response = Http::baseUrl(config('services.screenshot.base_url'))
                ->withToken($token)
                ->accept('image/png')
                ->connectTimeout(config('services.screenshot.connect_timeout'))
                ->timeout(config('services.screenshot.timeout'))
                ->get('/v1/capture', ['url' => $url]);
        } catch (ConnectionException $e) {
            throw new RetryableCaptureException('Screenshot service connection failed.');
        }

        if ($response->status() === 429 || $response->serverError()) {
            $retryAfter = filter_var(
                $response->header('Retry-After'),
                FILTER_VALIDATE_INT
            );

            throw new RetryableCaptureException(
                'Screenshot service returned a transient response.',
                $retryAfter === false ? null : min(max($retryAfter, 1), 900),
            );
        }

        if ($response->failed()) {
            throw new PermanentCaptureException(
                'Screenshot request rejected with HTTP '.$response->status().'.'
            );
        }

        $contentType = strtolower($response->header('Content-Type', ''));

        if (! str_starts_with($contentType, 'image/png')
            || ! str_starts_with($response->body(), "\x89PNG\r\n\x1a\n")) {
            throw new RetryableCaptureException('Response was not a valid PNG.');
        }

        $headers = [];
        foreach ($response->headers() as $name => $values) {
            $normalized = strtolower($name);
            if (str_contains($normalized, 'cache')
                || str_contains($normalized, 'quota')
                || str_contains($normalized, 'rate-limit')
                || str_contains($normalized, 'ratelimit')
                || $normalized === 'retry-after') {
                $headers[$name] = implode(', ', $values);
            }
        }

        return new CaptureImage($response->body(), $response->status(), $headers);
    }
}

Zadržavaju se samo operativna zaglavlja. Token, tijelo odgovora i potencijalno osjetljiv sadržaj stranice nikada ne ulaze u zapise.

Snimajte u poslu reda čekanja svjesnom ponovnih pokušaja

Posao zapisuje na determinističku privatnu putanju. Ponovno izvršavanje prepisuje isti objekt umjesto stvaranja duplikata.

<?php

namespace App\Jobs;

use App\Models\PageSnapshot;
use App\Services\PermanentCaptureException;
use App\Services\RetryableCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class CapturePageSnapshot implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 60;

    public function __construct(public int $snapshotId) {}

    public function handle(ScreenshotClient $client): void
    {
        $snapshot = PageSnapshot::findOrFail($this->snapshotId);

        try {
            $image = $client->capture($snapshot->url);
        } catch (PermanentCaptureException $e) {
            $this->recordFailure($snapshot, $e);
            $this->fail($e);
            return;
        } catch (RetryableCaptureException $e) {
            Log::warning('Weekly screenshot will be retried', [
                'snapshot_id' => $snapshot->id,
                'page_key' => $snapshot->page_key,
                'attempt' => $this->attempts(),
            ]);

            if ($this->attempts() >= $this->tries) {
                $this->recordFailure($snapshot, $e);
                $this->fail($e);
                return;
            }

            $fallback = [60, 300, 900][$this->attempts() - 1] ?? 900;
            $this->release($e->retryAfter ?? $fallback);
            return;
        }

        $disk = config('visual-history.disk');
        $path = 'screenshots/'.$snapshot->period_start->format('Y/m/d')
            .'/'.$snapshot->id.'.png';

        if (! Storage::disk($disk)->put($path, $image->bytes)) {
            throw new \RuntimeException('Could not store captured PNG.');
        }

        $snapshot->update([
            'status' => 'succeeded',
            'captured_at' => now(),
            'disk' => $disk,
            'path' => $path,
            'http_status' => $image->status,
            'response_headers' => $image->operationalHeaders,
            'error' => null,
        ]);
    }

    private function recordFailure(PageSnapshot $snapshot, Throwable $e): void
    {
        $snapshot->update([
            'status' => 'failed',
            'error' => mb_substr($e->getMessage(), 0, 1000),
        ]);

        Log::error('Weekly screenshot failed', [
            'snapshot_id' => $snapshot->id,
            'page_key' => $snapshot->page_key,
            'exception' => $e::class,
        ]);
    }
}

Pošaljite i rasporedite tjedno izvršavanje

U metodi handle() naredbe stvorite svaki zapis razdoblja atomski i pošaljite samo novostvoreni posao:

public function handle(): int
{
    $period = now()->startOfWeek()->toDateString();

    foreach (config('visual-history.pages', []) as $page) {
        $snapshot = PageSnapshot::firstOrCreate(
            ['page_key' => $page['key'], 'period_start' => $period],
            ['url' => $page['url'], 'status' => 'pending'],
        );

        if ($snapshot->wasRecentlyCreated) {
            CapturePageSnapshot::dispatch($snapshot->id);
        }
    }

    return self::SUCCESS;
}

Postavite potpis naredbe na visual-history:capture. Zatim je rasporedite u routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('visual-history:capture')
    ->weeklyOn(1, '06:00')
    ->withoutOverlapping();

Testirajte bez kontaktiranja usluge

Laravelov Http::fake() čini granicu determinističkom. Jedan test treba dokazati ponašanje pohrane i metapodataka; drugi treba dokazati da se neuspjesi autentikacije ne pokušavaju ponovno.

public function test_job_stores_a_valid_png(): void
{
    Storage::fake('local');
    config()->set('visual-history.disk', 'local');
    config()->set('services.screenshot.token', 'test-token');

    Http::fake([
        '*' => Http::response(
            "\x89PNG\r\n\x1a\nfake-payload",
            200,
            ['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
        ),
    ]);

    $snapshot = PageSnapshot::create([
        'page_key' => 'home',
        'url' => 'https://example.com/',
        'period_start' => now()->startOfWeek()->toDateString(),
        'status' => 'pending',
    ]);

    (new CapturePageSnapshot($snapshot->id))
        ->handle(app(ScreenshotClient::class));

    $snapshot->refresh();

    $this->assertSame('succeeded', $snapshot->status);
    $this->assertSame('HIT', $snapshot->response_headers['X-Cache']);
    Storage::disk('local')->assertExists($snapshot->path);
}

public function test_authentication_error_is_permanent(): void
{
    config()->set('services.screenshot.token', 'invalid-test-token');
    Http::fake(['*' => Http::response('', 401)]);

    try {
        app(ScreenshotClient::class)->capture('https://example.com/');
        $this->fail('Expected a permanent failure.');
    } catch (PermanentCaptureException) {
        Http::assertSentCount(1);
    }
}

Implementirajte, promatrajte i rješavajte probleme

Pokrenite php artisan config:cache nakon unosa vrijednosti produkcijskog okruženja. Zadržite storage/app privatnim ili upotrijebite privatni disk objektne pohrane. Ako su snimke zaslona izložene u administratorskom sučelju, autorizirajte svako preuzimanje i izdajte poveznice kratkog trajanja umjesto objavljivanja direktorija pohrane.

Poslužitelj treba jedan cron unos koji svake minute pokreće php artisan schedule:run, uz nadzirani proces php artisan queue:work. Postavite upozorenja za neuspjele poslove i za zapise snimki koji ostanu u stanju pending nakon očekivanog vremenskog okvira snimanja. Zapisi trebaju identificirati snimku i ključ stranice, nikada token ni PNG tijelo.

Uobičajeni neuspjesi imaju različita rješenja:

  • 401 ili 403: provjerite aktivni token ograničen na uslugu i ponovno implementirajte nakon rotacije.
  • 429: pregledajte pohranjena zaglavlja kvote, smanjite skup nadziranih stranica ili ponovno razmotrite odabrani plan.
  • Istek vremena ili odgovori 5xx: dopustite ograničenom odgađanju reda čekanja da se izvrši; nemojte stvarati agresivnu petlju ponovnih pokušaja.
  • Neispravan PNG: pregledajte status i operativna zaglavlja, ali nemojte spremati HTML stranicu s pogreškom kao snimku zaslona.
  • Nema tjednih zapisa: potvrdite cron raspoređivača, vremensku zonu aplikacije, predmemoriranu konfiguraciju i registraciju naredbe.
  • Zapisi na čekanju nikada ne završe: provjerite koristi li radnik reda čekanja isto okruženje i bazu podataka kao web-aplikacija.

Završni kontrolni popis provjere

  1. Pokrenite php artisan test i potvrdite da nijedan test ne doseže vanjski API.
  2. Pokrenite php artisan visual-history:capture.
  3. Pokrenite radnika pomoću php artisan queue:work --stop-when-empty.
  4. Potvrdite jedan uspješan zapis po konfiguriranoj stranici i tjednom razdoblju.
  5. Otvorite pohranjene datoteke i provjerite jesu li valjane PNG snimke zaslona.
  6. Potvrdite da su zaglavlja povezana s predmemorijom i kvotom prisutna kada ih usluga isporučuje.
  7. Ponovno pokrenite naredbu i provjerite da jedinstveno ograničenje razdoblja sprječava duplikate.
  8. Testirajte namjerno neispravan token izvan produkcije i potvrdite da zahtjev ne uspijeva jednom, bez slijepih ponovnih pokušaja.

Rezultat je namjerno skroman: privatni niz tjednih vizualnih dokaza za stranice koje su važne. Ta mala arhiva odgovara na pitanja na koja provjere dostupnosti ne mogu — kada se promijenio raspored, je li se promocija pojavila ispravno i koliko je dugo neispravna stranica ostala vidljiva. Dobre produkcijske integracije često su ovakve: uskog opsega, obrambene na svojim granicama i tiho korisne svakog tjedna.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.