Vodiči

Laravel: Keep a Weekly Visual Archive of Key Business Pages with Screenshot API

Laravel: Održavajte tjednu vizualnu arhivu ključnih poslovnih stranica pomoću Screenshot API-ja

Tjedna snimka zaslona iznimno je koristan poslovni zapis. Ona pokazuje što su kupci zaista vidjeli: objavljenu cijenu, sezonski banner, radno vrijeme, tijek rezervacije ili promociju koja je nestala tijekom užurbanog ažuriranja. Za razliku od kontrole izvornog koda ili povijesti baze podataka, vizualna arhiva čuva prikazani rezultat.

Ovaj vodič izrađuje takvu arhivu kao produkcijsku Laravel značajku. Zakazana naredba šalje jedan zadatak u red čekanja za svaku važnu stranicu, zadatak poziva Screenshot API, provjerava PNG odgovor, pohranjuje ga na privatni disk datotečnog sustava i bilježi operativne metapodatke u bazu podataka. Vanjska usluga pruža predmemorirane snimke zaslona za stolna računala ili mobilne uređaje, tako da aplikacija ne mora upravljati Chromiumom, upravljačkim programima preglednika ni skupom radnika za snimanje zaslona.

Pribavite pristup prije pisanja integracijskog koda

  1. Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite putem https://ai.mihajlo.mk/login.
  2. Otvorite stranicu usluge Screenshot API. Odaberite dostupan plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju Screenshot API-ja.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu. Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom umjesto da ga nepromišljeno ponovno generirate.
  5. Postavite token u konfiguraciju podržanu varijablama okruženja. Nikada ga nemojte uvrstiti u repozitorij, kopirati u testni fixture ni uključiti u poruku zapisnika.

API prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi oblik Bearer jer vjerodajnicu zadržava izvan URL-ova, zapisa pristupa proxyju, povijesti preglednika i uobičajene HTTP dijagnostike.

Provjerite krajnju točku jednim malim zahtjevom

Točan poziv je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita je url, a uspješan odgovor je tijelo image/png, a ne JSON.

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

Tijekom prve provjere pregledajte i zaglavlja odgovora. Zaglavlja predmemorije i kvote dio su operativnog rezultata iako nisu dio PNG datoteke. Donja aplikacija ih čuva bez pretpostavke da će neobavezna zaglavlja uvijek biti prisutna.

Pohranite vjerodajnicu i URL-ove stranica u vlasništvu poslovanja u .env:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
ARCHIVE_HOME_URL=https://example.com/
ARCHIVE_BOOKING_URL=https://example.com/book
VISUAL_ARCHIVE_DISK=local

Stvarne produkcijske tajne čuvajte u upravitelju tajni platforme za implementaciju. Datoteka .env prikladna je za lokalni razvoj, ali mora ostati izvan kontrole verzija.

Arhitektura i raspored projekta

Tjedna naredba namjerno je brza: šalje zadatke i završava. Svaka se stranica snima neovisno, pa jedna spora ili nevaljana stranica ne može blokirati ostale. Ponovni pokušaji reda čekanja obrađuju privremene prijenosne i poslužiteljske pogreške, dok autentifikacijske pogreške, pogreške provjere i pogreške kvote postaju izričita stanja baze podataka umjesto oluja ponovnih pokušaja.

  • config/services.php sadrži krajnju točku i token.
  • config/visual-archive.php definira privatni disk i odobrene stranice.
  • app/Services/ScreenshotClient.php izolira HTTP ugovor i preslikava ga u rezultat domene.
  • app/Jobs/CapturePageScreenshot.php pohranjuje PNG i zapis snimanja.
  • app/Console/Commands/CaptureWeeklyArchive.php stvara jedan zadatak za svaku stranicu.
  • routes/console.php definira tjedni raspored.

Zapis baze podataka čini kvarove i signale kvote dostupnima za upite, dok Laravelova apstrakcija datotečnog sustava omogućuje lokalnu pohranu tijekom razvoja i disk za objektnu pohranu u produkciji. Zadržavanje popisa stranica u konfiguraciji dobar je kompromis za malo poslovanje: promjene se pregledavaju i implementiraju, a proizvoljni URL-ovi koje dostavljaju korisnici nikada ne stižu do usluge snimanja.

Konfigurirajte Laravel i stvorite evidenciju snimanja

Dodajte ove unose bez zamjene nepovezane konfiguracije usluga:

<?php
// config/services.php
return [
    // Existing services...
    'screenshot_api' => [
        'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
        'token' => env('SCREENSHOT_API_TOKEN'),
    ],
];

// config/visual-archive.php
return [
    'disk' => env('VISUAL_ARCHIVE_DISK', 'local'),
    'pages' => [
        'home' => env('ARCHIVE_HOME_URL'),
        'booking' => env('ARCHIVE_BOOKING_URL'),
    ],
];

Stvorite migraciju za jedan zapis po stranici i tjednu:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('visual_snapshots', function (Blueprint $table): void {
            $table->id();
            $table->string('page_key');
            $table->date('captured_week');
            $table->string('status');
            $table->string('path')->nullable();
            $table->unsignedSmallInteger('http_status')->nullable();
            $table->json('response_headers')->nullable();
            $table->json('cache_headers')->nullable();
            $table->json('quota_headers')->nullable();
            $table->string('error')->nullable();
            $table->timestamps();

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

    public function down(): void
    {
        Schema::dropIfExists('visual_snapshots');
    }
};

Pokrenite php artisan migrate. Jedinstveno ograničenje osigurava trajnu idempotentnost čak i ako se planer pokrene dvaput ili se radnik ponovno pokrene.

Izgradite strogu API granicu

Klijent mora tretirati odgovor kao nepouzdane bajtove. Sam uspješan status nije dovoljan: uzlazni pristupnik može vratiti HTML stranicu pogreške. Prije pohrane bilo čega provjerite i vrstu medija i PNG potpis.

<?php
// app/Services/ScreenshotClient.php

namespace App\Services;

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

final readonly class ScreenshotResult
{
    public function __construct(
        public string $png,
        public int $status,
        public array $headers,
        public array $cacheHeaders,
        public array $quotaHeaders,
    ) {}
}

final class ScreenshotCaptureException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $retryable,
        public readonly ?int $status = null,
        public readonly array $headers = [],
        public readonly array $cacheHeaders = [],
        public readonly array $quotaHeaders = [],
    ) {
        parent::__construct($message);
    }
}

final class ScreenshotClient
{
    public function capture(string $url): ScreenshotResult
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new ScreenshotCaptureException(
                'Archive URL must be a valid HTTPS URL.',
                false,
            );
        }

        $token = (string) config('services.screenshot_api.token');

        if ($token === '') {
            throw new ScreenshotCaptureException(
                'Screenshot API token is not configured.',
                false,
            );
        }

        try {
            $response = Http::withToken($token)
                ->accept('image/png')
                ->connectTimeout(5)
                ->timeout(45)
                ->get(
                    (string) config('services.screenshot_api.endpoint'),
                    ['url' => $url],
                );
        } catch (ConnectionException $exception) {
            throw new ScreenshotCaptureException(
                'Screenshot API connection failed.',
                true,
            );
        }

        $headers = $response->headers();
        $cacheHeaders = $this->matchingHeaders(
            $headers,
            fn (string $name): bool =>
                str_contains($name, 'cache')
                || in_array($name, ['age', 'etag', 'expires'], true),
        );
        $quotaHeaders = $this->matchingHeaders(
            $headers,
            fn (string $name): bool =>
                str_contains($name, 'quota')
                || str_contains($name, 'rate-limit')
                || $name === 'retry-after',
        );

        if ($response->status() === 429) {
            throw new ScreenshotCaptureException(
                'Screenshot API quota or rate limit was reached.',
                false,
                429,
                $headers,
                $cacheHeaders,
                $quotaHeaders,
            );
        }

        if (in_array($response->status(), [400, 401, 403, 422], true)) {
            throw new ScreenshotCaptureException(
                'Screenshot API rejected the request.',
                false,
                $response->status(),
                $headers,
                $cacheHeaders,
                $quotaHeaders,
            );
        }

        if ($response->serverError()) {
            throw new ScreenshotCaptureException(
                'Screenshot API returned a temporary server error.',
                true,
                $response->status(),
                $headers,
                $cacheHeaders,
                $quotaHeaders,
            );
        }

        if (! $response->successful()) {
            throw new ScreenshotCaptureException(
                'Screenshot API returned an unexpected status.',
                false,
                $response->status(),
                $headers,
                $cacheHeaders,
                $quotaHeaders,
            );
        }

        $body = $response->body();
        $mediaType = strtolower(trim(explode(
            ';',
            $response->header('Content-Type', ''),
        )[0]));

        if ($mediaType !== 'image/png'
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
            throw new ScreenshotCaptureException(
                'Screenshot API response was not a valid PNG.',
                false,
                $response->status(),
                $headers,
                $cacheHeaders,
                $quotaHeaders,
            );
        }

        return new ScreenshotResult(
            $body,
            $response->status(),
            $headers,
            $cacheHeaders,
            $quotaHeaders,
        );
    }

    private function matchingHeaders(array $headers, callable $match): array
    {
        return array_filter(
            $headers,
            fn (string $name): bool => $match(strtolower($name)),
            ARRAY_FILTER_USE_KEY,
        );
    }
}

Nijedan isječak tijela odgovora ne ulazi u iznimku ni zapisnik. Time se izbjegava slučajno zadržavanje proxy stranica ili drugog neočekivanog sadržaja. Klijent također ne pokušava slijepo ponovno: klasificira kvarove i politiku ponovnih pokušaja prepušta redu čekanja.

Snimite svaku stranicu u idempotentnom zadatku reda čekanja

<?php
// app/Jobs/CapturePageScreenshot.php

namespace App\Jobs;

use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

final class CapturePageScreenshot implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $uniqueFor = 86400;
    public array $backoff = [60, 300, 900];

    public function __construct(
        public readonly string $pageKey,
        public readonly string $url,
        public readonly string $week,
    ) {}

    public function uniqueId(): string
    {
        return $this->pageKey.':'.$this->week;
    }

    public function handle(ScreenshotClient $client): void
    {
        $identity = [
            'page_key' => $this->pageKey,
            'captured_week' => $this->week,
        ];

        DB::table('visual_snapshots')->updateOrInsert(
            $identity,
            [...$identity, 'status' => 'pending',
                'updated_at' => now(), 'created_at' => now()],
        );

        try {
            $result = $client->capture($this->url);
        } catch (ScreenshotCaptureException $exception) {
            DB::table('visual_snapshots')->where($identity)->update([
                'status' => $exception->status === 429
                    ? 'quota_limited'
                    : ($exception->retryable
                        ? 'transient_failure'
                        : 'permanent_failure'),
                'http_status' => $exception->status,
                'response_headers' => json_encode($exception->headers),
                'cache_headers' => json_encode($exception->cacheHeaders),
                'quota_headers' => json_encode($exception->quotaHeaders),
                'error' => $exception->getMessage(),
                'updated_at' => now(),
            ]);

            if ($exception->retryable) {
                throw $exception;
            }

            return;
        }

        $path = "visual-archive/{$this->pageKey}/{$this->week}.png";

        if (! Storage::disk(config('visual-archive.disk'))
            ->put($path, $result->png)) {
            throw new \RuntimeException('Unable to store screenshot.');
        }

        DB::table('visual_snapshots')->where($identity)->update([
            'status' => 'captured',
            'path' => $path,
            'http_status' => $result->status,
            'response_headers' => json_encode($result->headers),
            'cache_headers' => json_encode($result->cacheHeaders),
            'quota_headers' => json_encode($result->quotaHeaders),
            'error' => null,
            'updated_at' => now(),
        ]);

        Log::info('Weekly visual archive captured.', [
            'page_key' => $this->pageKey,
            'week' => $this->week,
        ]);
    }
}

Zapisnik uključuje stabilan ključ stranice, a ne puni URL. To je važno kada arhivirani URL-ovi s vremenom počnu sadržavati parametre kampanje ili druge osjetljive podatke upita.

Pošaljite i rasporedite tjednu arhivu

<?php
// app/Console/Commands/CaptureWeeklyArchive.php

namespace App\Console\Commands;

use App\Jobs\CapturePageScreenshot;
use Illuminate\Console\Command;

final class CaptureWeeklyArchive extends Command
{
    protected $signature = 'archive:capture';
    protected $description = 'Queue the weekly visual page archive';

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

        foreach (config('visual-archive.pages', []) as $key => $url) {
            if (is_string($url) && $url !== '') {
                CapturePageScreenshot::dispatch($key, $url, $week);
            }
        }

        return self::SUCCESS;
    }
}

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('archive:capture')
    ->weeklyOn(1, '03:15')
    ->withoutOverlapping();

Produkcija zahtijeva i Laravelov okidač planera i neprekidno nadziranog radnika reda čekanja. Pokrenite php artisan schedule:run svake minute putem planera platforme ili crona te upravljajte procesom php artisan queue:work --tries=3 --timeout=60 pod nadzornikom procesa. Osigurajte da vremensko ograničenje radnika premašuje HTTP vremensko ograničenje, a zatim ponovno pokrenite radnike tijekom implementacije kako bi učitali novi kôd i konfiguraciju.

Testirajte granicu bez pozivanja usluge

Http::fake() čini testove determinističkima i dokazuje da se token, metoda, parametar upita, binarna provjera i mapiranje kvarova ponašaju prema namjeri.

<?php

namespace Tests\Feature;

use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class ScreenshotClientTest extends TestCase
{
    public function test_it_captures_and_maps_a_png(): void
    {
        config()->set('services.screenshot_api.token', 'test-token');
        config()->set('services.screenshot_api.endpoint',
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');

        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response(
                    "\x89PNG\r\n\x1a\nfixture",
                    200,
                    ['Content-Type' => 'image/png',
                     'Cache-Control' => 'private'],
                ),
        ]);

        $result = app(ScreenshotClient::class)
            ->capture('https://example.com/book');

        $this->assertSame(200, $result->status);
        $this->assertArrayHasKey('Cache-Control', $result->cacheHeaders);

        Http::assertSent(fn (Request $request): bool =>
            $request->method() === 'GET'
            && $request->hasHeader('Authorization', 'Bearer test-token')
            && $request['url'] === 'https://example.com/book'
        );
    }

    public function test_it_rejects_a_non_png_success_response(): void
    {
        config()->set('services.screenshot_api.token', 'test-token');
        Http::fake([
            '*' => Http::response('not an image', 200,
                ['Content-Type' => 'text/plain']),
        ]);

        $this->expectException(ScreenshotCaptureException::class);

        app(ScreenshotClient::class)->capture('https://example.com/');
    }

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

        try {
            app(ScreenshotClient::class)->capture('https://example.com/');
            $this->fail('Expected capture exception.');
        } catch (ScreenshotCaptureException $exception) {
            $this->assertFalse($exception->retryable);
            $this->assertSame(401, $exception->status);
        }
    }
}

Sigurnost, vidljivost i uobičajeni kvarovi

Disk arhive zadržite privatnim i slike izlažite samo putem autentificiranog kontrolera ili kratkotrajnog potpisanog URL-a za pohranu. Snimka zaslona može otkriti neobjavljene cijene, pogreške vidljive kupcima ili operativne pojedinosti. Primijenite ista pravila zadržavanja i pristupa koja biste primijenili na interne poslovne dokumente.

Postavite upozorenja za permanent_failure, quota_limited i zapise koji ostanu u transient_failure nakon ponovnih pokušaja reda čekanja. Pratite trajanje snimanja i uspješna snimanja po svakom zakazanom pokretanju. Čuvajte relevantna zaglavlja odgovora, ali nikada nemojte zapisivati zaglavlje autorizacije ni token. Pri rotaciji tokena usluge najprije ažurirajte tajnu i odmah ponovno pokrenite radnike jer ponovno generiranje opoziva prethodni token.

  • 401 ili 403: provjerite aktivaciju plana, token ograničen na uslugu i je li nedavno ponovno generiran. Nemojte automatski pokušavati ponovno.
  • 400 ili 422: pregledajte konfigurirani URL stranice i usporedite zahtjev sa službenom dokumentacijom.
  • 429: pregledajte pohranjena zaglavlja vezana uz kvotu i ponovne pokušaje, zatim prilagodite korištenje plana ili raspored. Nemojte provoditi brze ponovne pokušaje.
  • Nevaljani PNG: istražite odgovore uzvodnog sustava ili proxyja; nikada nemojte spremiti tijelo s nastavkom .png.
  • Nema tjednog zapisa: provjerite poziva li platforma schedule:run, je li radnik reda čekanja aktivan i jesu li predmemorije konfiguracije ponovno izgrađene nakon implementacije.

Završni popis za provjeru

  1. Pokrenite php artisan test i potvrdite da je svaki HTTP zahtjev lažiran.
  2. Pokrenite php artisan archive:capture, a zatim obradite zadatke pomoću php artisan queue:work --stop-when-empty.
  3. Potvrdite da za svaku konfiguriranu stranicu postoje jedan red baze podataka i jedan valjani PNG.
  4. Ponovno pokrenite naredbu i provjerite da jedinstveni zapisi stranice i tjedna nisu duplicirani.
  5. Potvrdite da je disk arhive privatan te da token nije prisutan u kontroli izvornog koda ni zapisnicima.
  6. Provjerite produkcijski planer, nadzornik reda čekanja, upozorenja o kvarovima, zadržavanje pohrane i postupak rotacije tajni.

Dobivena arhiva namjerno je skromna: nekoliko odobrenih URL-ova, jedan tjedni raspored, privatni PNG objekti i operativna evidencija koja se može pretraživati. Upravo je ta suzdržanost njezina snaga. Malom vlasniku poslovanja pruža pouzdanu vizualnu memoriju web-mjesta, dok održavanje preglednika, infrastruktura za renderiranje i oporavak od prolaznih kvarova ostaju izvan svakodnevnog opterećenja.

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.