Vodiči

Laravel: Automate Client Website Pre-Post Update Snapshots with Screenshot API

Laravel: Automatizirajte snimke stanja web-stranice klijenta prije i poslije ažuriranja pomoću Screenshot API-ja

Ažuriranje web-mjesta može tehnički uspjeti, a ipak uvesti vizualnu regresiju: nedostajuću hero sliku, neočekivani zamjenski font ili navigacijsku traku koja se prelama na pogrešnoj prijelomnoj točki. Bez pouzdane slike stanja „prije”, dokazivanje onoga što se promijenilo svodi se na nagađanje.

Ovaj vodič izrađuje produkcijski usmjeren Laravel tijek rada koji snima PNG snimke zaslona neposredno prije i nakon ažuriranja klijentova web-mjesta. Proces implementacije poziva sinkronu Artisan naredbu, dok namjenska usluga upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, provjerom odgovora, signalima kvote i pohranom.

Dobijte pristup Screenshot API-ju

Najprije registrirajte račun ili se prijavite. Otvorite stranicu usluge Screenshot API, odaberite dostupan plan Free, Plus ili Pro i dovršite njegovu aktivaciju.

Zatim otvorite službenu dokumentaciju Screenshot API-ja. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu. Ova usluga zahtijeva autentikaciju; nije API bez tokena.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: ažurirajte svako implementirano okruženje koje koristi staru vrijednost, ponovno izgradite predmemoriranu konfiguraciju Laravela, provjerite integraciju i tek tada rotaciju smatrajte dovršenom.

Potvrdite točan ugovor zahtjeva

Operacija snimanja je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Zahtijeva parametar upita url i prihvaća autentikaciju putem Bearer tokena, zaglavlja X-API-Token ili parametra upita token. Upotrijebit ćemo oblik Bearer kako se vjerodajnice nikada ne bi pojavile u URL-ovima ili zapisnicima pristupa posrednika.

Pokrenite jedan minimalni test prije pisanja integracijskog koda:

export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

curl --get --silent --show-error --fail-with-body \
  --dump-header response-headers.txt \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --header "Accept: image/png" \
  --data-urlencode "url=https://www.example.com" \
  --output screenshot.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Tijelo uspješnog odgovora je image/png. Pregledajte response-headers.txt, kao i sliku: zaglavlja predmemorije i kvote operativni su podaci, a ne ukrasni metapodaci. Njihova točna imena i dostupnost treba obrađivati defenzivno, a ne pretpostavljati.

Sada postavite vjerodajnicu i zadane postavke implementacije u Laravelovu konfiguraciju okruženja. Nikada ne predajte stvarni token u repozitorij.

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://www.example.com
CLIENT_SITE_HOST=www.example.com
SCREENSHOT_DISK=local

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'),
    'site_url' => env('CLIENT_SITE_URL'),
    'allowed_host' => env('CLIENT_SITE_HOST'),
    'disk' => env('SCREENSHOT_DISK', 'local'),
],

Ako produkcija koristi predmemoriranu konfiguraciju, tijekom implementacije nakon ažuriranja vrijednosti okruženja pokrenite php artisan config:cache.

Odaberite arhitekturu sigurnu za implementaciju

Projekt koristi sinkronu naredbu umjesto zadatka u redu čekanja. Snimanje stanja „prije” mora završiti prije nego što se izdanje promijeni, dok se snimanje stanja „nakon” smije dogoditi tek kada novo izdanje prođe provjeru spremnosti. Red čekanja oslabio bi taj redoslijed osim ako implementacija također ne bi čekala završetak zadatka.

Rezultirajuća struktura namjerno je mala:

app/Data/ScreenshotCapture.php
app/Exceptions/ScreenshotApiException.php
app/Services/ScreenshotApi.php
app/Console/Commands/CaptureWebsiteSnapshot.php
config/services.php
tests/Feature/CaptureWebsiteSnapshotTest.php

Koristite PHP 8.3 ili noviji, Laravel aplikaciju s ugrađenim HTTP klijentom, zapisivi disk Laravelova datotečnog sustava i identifikator implementacije, poput nepromjenjivog broja izdanja ili hash-a predaje.

Preslikajte udaljeni odgovor u domenu

Ostatak aplikacije ne bi trebao rukovati proizvoljnim HTTP odgovorom. Izradite app/Data/ScreenshotCapture.php:

<?php

namespace App\Data;

final readonly class ScreenshotCapture
{
    public function __construct(
        public string $png,
        public string $contentType,
        public array $operationalHeaders,
    ) {}
}

Izradite app/Exceptions/ScreenshotApiException.php. Njegova stabilna kategorija neuspjeha omogućuje naredbama, zapisnicima i upozorenjima razlikovanje neuspjeha konfiguracije, autentikacije, kvote, zahtjeva, prijenosa, uzvodne usluge i neispravnog odgovora.

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class ScreenshotApiException extends RuntimeException
{
    public function __construct(
        public readonly string $failure,
        string $message,
        public readonly ?int $status = null,
        public readonly ?int $retryAfterSeconds = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Izradite ograničeni HTTP klijent

Izradite app/Services/ScreenshotApi.php. Klijent ponavlja pokušaje kod neuspjeha povezivanja i pogrešaka poslužitelja uz kratko eksponencijalno čekanje. Odgovor kvote pokušava se ponovno samo jednom, i to samo kada sadrži malu brojčanu vrijednost Retry-After. Autentikacija i druge pogreške klijenta nikada se ne pokušavaju ponovno naslijepo.

<?php

namespace App\Services;

use App\Data\ScreenshotCapture;
use App\Exceptions\ScreenshotApiException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

final class ScreenshotApi
{
    private Closure $sleep;

    public function __construct(?Closure $sleep = null)
    {
        $this->sleep = $sleep ?? static fn (int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function capture(string $url): ScreenshotCapture
    {
        $token = (string) config('services.screenshot_api.token');
        $endpoint = (string) config('services.screenshot_api.endpoint');

        if ($token === '' || $endpoint === '') {
            throw new ScreenshotApiException(
                'configuration',
                'Screenshot API configuration is incomplete.'
            );
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::accept('image/png')
                    ->withToken($token)
                    ->connectTimeout(5)
                    ->timeout(45)
                    ->get($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotApiException(
                        'transport',
                        'Screenshot API connection failed.',
                        previous: $exception
                    );
                }

                $this->pause($url, $attempt, null, 250 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->successful()) {
                return $this->mapSuccess($response);
            }

            if ($response->status() === 429) {
                $retryAfter = $this->numericRetryAfter($response);

                if ($attempt === 1 && $retryAfter !== null && $retryAfter <= 10) {
                    $this->pause($url, $attempt, 429, $retryAfter * 1000);
                    continue;
                }

                throw new ScreenshotApiException(
                    'quota',
                    'Screenshot API quota or rate limit prevented capture.',
                    429,
                    $retryAfter
                );
            }

            if ($response->serverError() && $attempt < 3) {
                $this->pause(
                    $url,
                    $attempt,
                    $response->status(),
                    250 * (2 ** ($attempt - 1))
                );
                continue;
            }

            $status = $response->status();
            $failure = in_array($status, [401, 403], true)
                ? 'authentication'
                : ($response->clientError() ? 'request' : 'upstream');

            throw new ScreenshotApiException(
                $failure,
                'Screenshot API returned HTTP '.$status.'.',
                $status
            );
        }

        throw new ScreenshotApiException('upstream', 'Capture attempts were exhausted.');
    }

    private function mapSuccess(Response $response): ScreenshotCapture
    {
        $contentType = strtolower(trim(explode(
            ';',
            (string) $response->header('Content-Type')
        )[0]));
        $body = $response->body();

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

        $headers = [];

        foreach ($response->headers() as $name => $values) {
            $key = strtolower($name);
            $compact = str_replace('-', '', $key);

            if (str_contains($key, 'cache') ||
                str_contains($key, 'quota') ||
                str_contains($compact, 'ratelimit') ||
                $key === 'retry-after') {
                $headers[$name] = implode(', ', (array) $values);
            }
        }

        return new ScreenshotCapture($body, $contentType, $headers);
    }

    private function numericRetryAfter(Response $response): ?int
    {
        $value = $response->header('Retry-After');

        return is_string($value) && ctype_digit($value)
            ? (int) $value
            : null;
    }

    private function pause(
        string $url,
        int $attempt,
        ?int $status,
        int $milliseconds
    ): void {
        Log::warning('Screenshot API retry scheduled.', [
            'host' => parse_url($url, PHP_URL_HOST),
            'attempt' => $attempt,
            'status' => $status,
            'delay_ms' => $milliseconds,
        ]);

        ($this->sleep)($milliseconds);
    }
}

Preslikivač odgovora provjerava i deklariranu vrstu medija i PNG potpis. Zadržava zaglavlja povezana s predmemorijom i kvotom bez oslanjanja na nedokumentirana imena zaglavlja. Namjerno ne zapisuje tijela odgovora ni vjerodajnice u zapisnike.

Izradite naredbu za snimku

Izradite app/Console/Commands/CaptureWebsiteSnapshot.php:

<?php

namespace App\Console\Commands;

use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class CaptureWebsiteSnapshot extends Command
{
    protected $signature = 'snapshots:capture
        {release : Immutable release identifier}
        {phase : before or after}
        {--url= : Override the configured website URL}
        {--force : Replace an existing phase snapshot}';

    protected $description = 'Capture a deployment website snapshot';

    public function handle(ScreenshotApi $api): int
    {
        $release = (string) $this->argument('release');
        $phase = (string) $this->argument('phase');
        $url = (string) ($this->option('url')
            ?: config('services.screenshot_api.site_url'));
        $allowedHost = (string) config('services.screenshot_api.allowed_host');

        if (strlen($release) > 100 ||
            ! preg_match('/\A[A-Za-z0-9._-]+\z/', $release)) {
            $this->error('Release identifier contains invalid characters.');
            return self::FAILURE;
        }

        $host = parse_url($url, PHP_URL_HOST);
        $scheme = parse_url($url, PHP_URL_SCHEME);

        if (! in_array($phase, ['before', 'after'], true) ||
            ! filter_var($url, FILTER_VALIDATE_URL) ||
            $scheme !== 'https' ||
            ! is_string($host) ||
            strcasecmp($host, $allowedHost) !== 0) {
            $this->error('Phase or target URL is not allowed.');
            return self::FAILURE;
        }

        $disk = Storage::disk(
            (string) config('services.screenshot_api.disk', 'local')
        );
        $base = "snapshots/{$release}/{$phase}";
        $imagePath = "{$base}.png";
        $metadataPath = "{$base}.json";

        if ($disk->exists($imagePath) && ! $this->option('force')) {
            $this->error('Snapshot already exists; use --force to replace it.');
            return self::FAILURE;
        }

        try {
            $capture = $api->capture($url);
            $metadata = json_encode([
                'release' => $release,
                'phase' => $phase,
                'url' => $url,
                'captured_at' => now()->toIso8601String(),
                'bytes' => strlen($capture->png),
                'sha256' => hash('sha256', $capture->png),
                'content_type' => $capture->contentType,
                'operational_headers' => $capture->operationalHeaders,
            ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

            if (! $disk->put($imagePath, $capture->png) ||
                ! $disk->put($metadataPath, $metadata)) {
                $disk->delete([$imagePath, $metadataPath]);
                throw new \RuntimeException('Snapshot storage write failed.');
            }
        } catch (ScreenshotApiException $exception) {
            Log::error('Snapshot capture failed.', [
                'release' => $release,
                'phase' => $phase,
                'host' => $host,
                'failure' => $exception->failure,
                'status' => $exception->status,
                'retry_after' => $exception->retryAfterSeconds,
            ]);
            $this->error("Capture failed: {$exception->failure}.");
            return self::FAILURE;
        } catch (Throwable $exception) {
            Log::error('Snapshot persistence failed.', [
                'release' => $release,
                'phase' => $phase,
                'exception' => $exception::class,
            ]);
            $this->error('Snapshot could not be stored.');
            return self::FAILURE;
        }

        Log::info('Deployment snapshot stored.', [
            'release' => $release,
            'phase' => $phase,
            'path' => $imagePath,
            'operational_headers' => $capture->operationalHeaders,
        ]);
        $this->info("Stored {$imagePath}");

        return self::SUCCESS;
    }
}

Popis dopuštenih naziva hostova sprječava da argument implementacije pretvori naredbu u mehanizam za snimanje proizvoljnih URL-ova. Svaki PNG prati JSON koji sadrži njegov kontrolni zbroj, veličinu, vrijeme snimanja i dostupne metapodatke predmemorije ili kvote.

Smjestite je oko prebacivanja izdanja

Omotajte postojeću operaciju izdanja s provjerom stanja dvjema naredbama. Prebacivanje izdanja mora pri neuspjehu vratiti status koji nije nula:

set -euo pipefail

: "${RELEASE_ID:?Set an immutable RELEASE_ID}"
: "${RELEASE_SWITCH:?Set the release-switch executable}"

php artisan snapshots:capture "$RELEASE_ID" before
"$RELEASE_SWITCH" "$RELEASE_ID"
php artisan snapshots:capture "$RELEASE_ID" after

Budući da se ljuska zaustavlja pri prvom neuspjehu, nedostajuća slika stanja „nakon” znači da implementacija ili snimanje nakon implementacije nije uspjelo. Nemojte snimati stanje „nakon” dok provjera spremnosti aplikacije ne potvrdi da ažurirano izdanje poslužuje promet.

Testirajte uspjeh, ponovne pokušaje i neuspjeh autentikacije

Laravelov Http::fake() održava testove determinističkima i sprječava stvarnu upotrebu API-ja. Izradite tests/Feature/CaptureWebsiteSnapshotTest.php:

<?php

namespace Tests\Feature;

use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class CaptureWebsiteSnapshotTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config()->set('services.screenshot_api', [
            'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
            'token' => 'test-token',
            'site_url' => 'https://www.example.com',
            'allowed_host' => 'www.example.com',
            'disk' => 'local',
        ]);
    }

    public function test_command_stores_png_and_metadata(): void
    {
        Storage::fake('local');
        $png = "\x89PNG\r\n\x1a\npayload";

        Http::fake(fn () => Http::response($png, 200, [
            'Content-Type' => 'image/png',
            'Cache-Control' => 'private',
        ]));

        $this->artisan('snapshots:capture release-42 before')
            ->assertSuccessful();

        Storage::disk('local')
            ->assertExists('snapshots/release-42/before.png');
        Storage::disk('local')
            ->assertExists('snapshots/release-42/before.json');

        Http::assertSent(fn ($request) =>
            $request->url() ===
                'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?url='.
                urlencode('https://www.example.com') &&
            $request->hasHeader('Authorization', 'Bearer test-token')
        );
    }

    public function test_server_error_is_retried_then_succeeds(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";

        Http::fakeSequence()
            ->push('', 503)
            ->push($png, 200, ['Content-Type' => 'image/png']);

        $service = new ScreenshotApi(static fn (int $milliseconds) => null);

        $this->assertSame($png, $service->capture(
            'https://www.example.com'
        )->png);
        Http::assertSentCount(2);
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        Http::fake(fn () => Http::response('', 401));

        try {
            app(ScreenshotApi::class)->capture('https://www.example.com');
            $this->fail('Expected authentication failure.');
        } catch (ScreenshotApiException $exception) {
            $this->assertSame('authentication', $exception->failure);
        }

        Http::assertSentCount(1);
    }
}

Pokrenite skup testova s php artisan test. U produkciji postavite upozorenja za neuspjehe naredbi, ponavljane pogreške prijenosa ili uzvodne usluge, signale niske kvote izložene vraćenim zaglavljima i nedostajuće parove snimaka.

Česti produkcijski neuspjesi

  • HTTP 401 ili 403: provjerite token ograničen na uslugu, osobito nakon ponovnog generiranja, a zatim ponovno izgradite predmemoriju konfiguracije Laravela.
  • HTTP 429: pregledajte zabilježene metapodatke kvote i ponovnog pokušaja. Nadogradite plan ili smanjite učestalost snimanja umjesto dodavanja agresivnih ponovnih pokušaja.
  • Neispravan PNG: uspješan status s neočekivanim tijelom odbacuje se na granici API-ja umjesto da se pohrani kao obmanjujuća slika.
  • Podudarne slike stanja prije i nakon: potvrdite da je novo izdanje prošlo provjere spremnosti. Također pregledajte zaglavlja predmemorije jer usluga namjerno podržava predmemorirana snimanja.
  • Neuspjesi cilja: provjerite javni DNS, TLS, preusmjeravanja, autentikacijske barijere i je li web-mjesto dostupno vanjskoj usluzi snimanja.
  • Neuspjesi pohrane: provjerite vjerodajnice diska, dozvole, kapacitet, politiku zadržavanja i trebaju li datoteke snimaka ostati privatne.

Završni popis za provjeru

  1. Potvrdite da stvarni token postoji samo u zaštićenoj konfiguraciji okruženja.
  2. Pokrenite ručno snimanje stanja before i otvorite pohranjeni PNG.
  3. Provjerite sadrži li JSON metapodatke kontrolni zbroj i sva vraćena zaglavlja predmemorije ili kvote.
  4. Potvrdite da se neispravni hostovi i duplicirane putanje snimaka odbacuju.
  5. Pokrenite automatizirane testove bez vanjskih zahtjeva.
  6. Izvršite neprodukcijsko izdanje i provjerite dijele li datoteke obje faze isti identifikator izdanja.
  7. Testirajte neuspjeh implementacije i osigurajte da se naredba „after” ne pokrene.
  8. Definirajte postupke privatnog zadržavanja pohrane, nadzora i rotacije tokena.

Koristan sustav snimaka više je od API poziva. To je uređena granica implementacije s provjerenim binarnim podacima, ograničenim ponašanjem pri neuspjehu, zaštićenim vjerodajnicama, trajnim dokazima i dovoljno metapodataka za kasnije objašnjenje onoga što se dogodilo. Kada su ti dijelovi postavljeni, svako ažuriranje klijentova web-mjesta ostavlja jednostavan, pouzdan vizualni zapis: ono što su korisnici vidjeli prije i ono što su dobili nakon.

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.