Vodiči

Symfony Snapshot Automation: Pre-Update Website Visual Audits with Screenshot API

Symfony automatizacija snimki: vizualne revizije web-stranice prije ažuriranja uz Screenshot API

Ažuriranje web-mjesta može proći jedinične testove, vraćati ispravne HTTP odgovore, a ipak biti objavljeno s neispravnom hero slikom, fontom koji nedostaje ili pogrešno postavljenim gumbom za naplatu. Ti nedostaci postoje na prikazanoj stranici, stoga izdanje za produkciju zaslužuje dokaz s prikazane stranice.

Ovaj vodič izrađuje Symfony automatizaciju koja snima jedan PNG prije implementacije, a drugi nakon nje. Svaka se slika pohranjuje s SHA-256 sažetkom te metapodacima predmemorije i kvote koje vraća Screenshot API. Cjevovod implementacije prima izlazni kod različit od nule kada snimanje ne uspije, dok programeri izbjegavaju održavanje Chromiuma, upravljačkih programa preglednika i radnika za snimke zaslona.

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 dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju Screenshot API-ja. Pronađite ploču Service token i kopirajte tamo prikazani token ograničen na uslugu.

Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer je vjerojatnije da će se vjerodajnice u nizovima upita pojaviti u zapisnicima proxyja i pristupa.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao koordiniranu implementaciju: ažurirajte tajnu u okruženju implementacije i odmah ponovno pokrenite ili ponovno implementirajte svaki proces koji ga koristi.

Potvrdite točnu krajnju točku

Operacija snimanja jest:

GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Required query parameter: url
Successful body: image/png

Izvršite jedan minimalni zahtjev prije pisanja aplikacijskog koda. Datoteka zaglavlja omogućuje vam pregled trenutačnih zaglavlja predmemorije i kvote usluge bez ispisivanja binarnog odgovora:

export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

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

Kada to uspije, stavite vjerodajnicu u tajnu podržanu varijablom okruženja. Za lokalni Symfony razvoj upotrijebite nepraćenu datoteku .env.local:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_AUDIT_HOSTS=www.example.com,preview.example.com

Iste varijable postavite putem upravitelja tajnama za produkciju ili hosting platforme. Nemojte predati u repozitorij .env.local, stvarni token, snimljene privatne stranice ni ispise zaglavlja.

Odaberite namjerno malu arhitekturu

Sinkrona konzolna naredba ovdje bolje odgovara od Messengera. Implementaciji je potreban trenutačni signal uspjeha ili neuspjeha, a dodavanje reda čekanja odvojilo bi taj signal od cjevovoda koji mora postupiti prema njemu.

Rezultirajući projekt ima četiri odgovornosti:

  • ScreenshotClient upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, PNG validacijom i preslikavanjem zaglavlja odgovora.
  • ScreenshotCapture je validirani rezultat domene.
  • SnapshotStore atomski zapisuje PNG i JSON artefakte.
  • WebsiteSnapshotCommand validira ulaz implementacije i vraća koristan izlazni kod procesa.

Instalirajte Symfony komponente prve strane koje se koriste u nastavku:

composer require symfony/http-client symfony/console symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack

Relevantne datoteke bit će src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotApiException.php, src/Screenshot/ScreenshotClient.php, src/Screenshot/SnapshotStore.php, src/Command/WebsiteSnapshotCommand.php i tests/Screenshot/ScreenshotClientTest.php.

Konfigurirajte ubrizgavanje ovisnosti

Zadržite krajnju točku u konfiguraciji, ali ne zato što se očekuje da će se neformalno mijenjati. Jedna mjerodavna vrijednost čini testove i revizije jasnijima.

# config/services.yaml
parameters:
    screenshot.endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'

services:
    _defaults:
        autowire: true
        autoconfigure: true
        bind:
            $screenshotEndpoint: '%screenshot.endpoint%'
            $screenshotToken: '%env(SCREENSHOT_API_TOKEN)%'
            $snapshotDirectory: '%kernel.project_dir%/var/audits'
            $allowedHosts: '%env(WEBSITE_AUDIT_HOSTS)%'

    App\:
        resource: '../src/'

Izgradite obrambenu API granicu

API vraća bajtove umjesto JSON-a. To validaciju čini posebno važnom: uzvodna HTML stranica s pogreškom nikada se ne smije prihvatiti kao snimka zaslona samo zato što je posrednik vratio status 200.

<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;

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

// src/Screenshot/ScreenshotApiException.php
namespace App\Screenshot;

final class ScreenshotApiException extends \RuntimeException
{
    public function __construct(
        public readonly string $category,
        public readonly ?int $status = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct('Screenshot capture failed: '.$category, 0, $previous);
    }
}

// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotClient
{
    private \Closure $sleep;

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $screenshotEndpoint,
        private string $screenshotToken,
        ?callable $sleep = null,
    ) {
        $this->sleep = $sleep
            ? \Closure::fromCallable($sleep)
            : static fn (int $seconds) => sleep($seconds);
    }

    public function capture(string $url): ScreenshotCapture
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET', $this->screenshotEndpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->screenshotToken,
                        'Accept' => 'image/png',
                    ],
                    'query' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 45.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $response->cancel();
                    ($this->sleep)($this->retryDelay($headers, $attempt));
                    continue;
                }

                if ($status === 401 || $status === 403) {
                    throw new ScreenshotApiException('authentication', $status);
                }
                if ($status === 429) {
                    throw new ScreenshotApiException('quota_or_rate_limit', $status);
                }
                if ($status >= 400 && $status < 500) {
                    throw new ScreenshotApiException('invalid_request', $status);
                }
                if ($status >= 500) {
                    throw new ScreenshotApiException('upstream_unavailable', $status);
                }

                $png = $response->getContent(false);
                $type = strtolower($headers['content-type'][0] ?? '');

                if (!str_starts_with($type, 'image/png')
                    || !str_starts_with($png, "\x89PNG\r\n\x1a\n")) {
                    throw new ScreenshotApiException('invalid_png', $status);
                }
                if (strlen($png) > 15_000_000) {
                    throw new ScreenshotApiException('image_too_large', $status);
                }

                return new ScreenshotCapture(
                    $png,
                    $this->operationalHeaders($headers),
                );
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotApiException(
                        'transport',
                        null,
                        $exception,
                    );
                }
                ($this->sleep)(min(2 ** ($attempt - 1), 4));
            }
        }

        throw new ScreenshotApiException('unexpected');
    }

    private function retryDelay(array $headers, int $attempt): int
    {
        $value = $headers['retry-after'][0] ?? null;

        return is_string($value) && ctype_digit($value)
            ? min((int) $value, 10)
            : min(2 ** ($attempt - 1), 4);
    }

    private function operationalHeaders(array $headers): array
    {
        $kept = [];

        foreach ($headers as $name => $values) {
            $lower = strtolower($name);
            if (in_array($lower, ['content-type', 'cache-control', 'age', 'expires'], true)
                || str_contains($lower, 'cache')
                || str_contains($lower, 'quota')
                || str_contains($lower, 'ratelimit')
                || str_contains($lower, 'rate-limit')) {
                $kept[$lower] = array_values($values);
            }
        }

        return $kept;
    }
}

Ponovno se pokušavaju samo transportni neuspjesi, HTTP 429 i pogreške poslužitelja. Neuspjesi autentikacije i validacije zahtijevaju promjene od strane osobe ili konfiguracije, stoga bi njihovo ponovno pokušavanje trošilo kvotu i odgodilo jasnu dijagnozu. Retry-After se poštuje kada je cijeli broj, ali je ograničen kako se pojedinačni cjevovod ne bi mogao neograničeno zaustaviti.

Mapiranje zaglavlja namjerno izbjegava pretpostavljanje nedokumentiranih polja odgovora. Zadržava zaglavlja povezana s predmemorijom i kvotom pod njihovim izvornim normaliziranim nazivima, omogućujući aplikaciji da sačuva koja god zaglavlja usluga trenutačno vraća.

Pohranite artefakte i izložite naredbu

Svako izdanje dobiva direktorij koji sadrži before.png, after.png i odgovarajuće datoteke metapodataka. Symfonyjev dumpFile() atomski zamjenjuje datoteke, sprječavajući čitatelje da vide djelomično zapisani PNG.

<?php
// src/Screenshot/SnapshotStore.php
namespace App\Screenshot;

use Symfony\Component\Filesystem\Filesystem;

final class SnapshotStore
{
    public function __construct(private string $snapshotDirectory) {}

    public function save(
        string $release,
        string $phase,
        ScreenshotCapture $capture,
    ): string {
        $directory = $this->snapshotDirectory.'/'.$release;
        $filesystem = new Filesystem();
        $filesystem->mkdir($directory, 0750);

        $image = $directory.'/'.$phase.'.png';
        $metadata = $directory.'/'.$phase.'.json';

        $filesystem->dumpFile($image, $capture->png);
        $filesystem->dumpFile($metadata, json_encode([
            'release' => $release,
            'phase' => $phase,
            'captured_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
            'bytes' => strlen($capture->png),
            'sha256' => hash('sha256', $capture->png),
            'service_headers' => $capture->operationalHeaders,
        ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));

        return $image;
    }
}

// src/Command/WebsiteSnapshotCommand.php
namespace App\Command;

use App\Screenshot\ScreenshotApiException;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\SnapshotStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'app:website-snapshot',
    description: 'Capture a before or after deployment snapshot.',
)]
final class WebsiteSnapshotCommand extends Command
{
    public function __construct(
        private ScreenshotClient $client,
        private SnapshotStore $store,
        private LoggerInterface $logger,
        private string $allowedHosts,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('release', InputArgument::REQUIRED)
            ->addArgument('phase', InputArgument::REQUIRED)
            ->addArgument('url', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $release = (string) $input->getArgument('release');
        $phase = (string) $input->getArgument('phase');
        $url = (string) $input->getArgument('url');
        $host = strtolower((string) parse_url($url, PHP_URL_HOST));
        $hosts = array_map(
            static fn (string $value) => strtolower(trim($value)),
            explode(',', $this->allowedHosts),
        );

        if (!preg_match('/^[A-Za-z0-9._-]{1,100}$/', $release)
            || !in_array($phase, ['before', 'after'], true)
            || parse_url($url, PHP_URL_SCHEME) !== 'https'
            || !in_array($host, $hosts, true)) {
            $output->writeln('<error>Invalid release, phase, or URL.</error>');
            return Command::INVALID;
        }

        try {
            $path = $this->store->save(
                $release,
                $phase,
                $this->client->capture($url),
            );
            $this->logger->info('Website snapshot captured', [
                'release' => $release,
                'phase' => $phase,
                'host' => $host,
            ]);
            $output->writeln('Saved '.$path);
            return Command::SUCCESS;
        } catch (ScreenshotApiException $exception) {
            $this->logger->error('Website snapshot failed', [
                'release' => $release,
                'phase' => $phase,
                'host' => $host,
                'category' => $exception->category,
                'status' => $exception->status,
            ]);
            $output->writeln('<error>Capture failed: '
                .$exception->category.'</error>');
            return Command::FAILURE;
        }
    }
}

Popis dopuštenih naziva hostova sprječava da kompromitirani parametar cjevovoda troši kvotu na proizvoljne ciljeve. Zapisivanje samo naziva hosta također izbjegava otkrivanje potpisanih URL-ova za pregled i njihovih parametara upita.

Testirajte ponovne pokušaje bez mrežnih poziva

MockHttpClient granici pruža determinističke odgovore. Ubrizgavanje uspavljivača koji ništa ne radi održava testove ponovnih pokušaja brzima.

<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\ScreenshotApiException;
use App\Screenshot\ScreenshotClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testRetriesServerFailureAndReturnsPng(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse($png, [
                'http_code' => 200,
                'response_headers' => [
                    'Content-Type: image/png',
                    'Cache-Control: max-age=60',
                ],
            ]),
        ]);

        $client = new ScreenshotClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
            'YOUR_SERVICE_TOKEN',
            static fn (int $seconds) => null,
        );

        $capture = $client->capture('https://www.example.com/');
        self::assertSame($png, $capture->png);
        self::assertSame(
            ['max-age=60'],
            $capture->operationalHeaders['cache-control'],
        );
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $client = new ScreenshotClient(
            new MockHttpClient([
                new MockResponse('', ['http_code' => 401]),
            ]),
            new NullLogger(),
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
            'YOUR_SERVICE_TOKEN',
            static fn (int $seconds) => null,
        );

        try {
            $client->capture('https://www.example.com/');
            self::fail('An authentication exception was expected.');
        } catch (ScreenshotApiException $exception) {
            self::assertSame('authentication', $exception->category);
            self::assertSame(401, $exception->status);
        }
    }
}

Pokrenite skup testova s pomoću php bin/phpunit. Korisni dodatni slučajevi uključuju niz HTTP 429 odgovora, HTML tijelo pogrešno označeno kao uspjeh, preveliku sliku i odbijanje naredbe za neodobreni naziv hosta.

Povežite ga s implementacijom

Upotrijebite isti nepromjenjivi identifikator izdanja za obje slike. Dodajte ove naredbe oko postojećeg koraka implementacije:

php bin/console app:website-snapshot release-2026-09-02 before \
  https://www.example.com/

# Run the existing deployment and health-check steps here.

php bin/console app:website-snapshot release-2026-09-02 after \
  https://www.example.com/

Neuspjeh naredbe prije implementacije trebao bi zaustaviti implementaciju jer bi usporedba bila nepotpuna. Neuspjeh nakon implementacije trebao bi označiti neuspjeh faze vizualne revizije bez pretvaranja da je došlo do vraćanja aplikacije na prethodnu verziju. Hoće li taj neuspjeh pokrenuti vraćanje, zasebna je odluka o politici izdanja.

Na jednom pokretaču implementacije dovoljan je var/audits. U efemernim spremnicima prenesite taj direktorij kao zaštićeni artefakt cjevovoda ili zamijenite SnapshotStore postojećom apstrakcijom trajne pohrane projekta. Zadržite kontrolu pristupa snimkama: stranice klijenata mogu sadržavati imena, podatke o računima, sadržaj za pregled ili komercijalno osjetljive dizajne.

Uobičajeni neuspjesi u produkciji

  • Autentikacija: HTTP 401 ili 403 obično znači da tajna nedostaje, nepravilno je oblikovana ili je opozvana ponovnim generiranjem tokena. Potvrdite ubrizgavanje tajne bez ispisivanja njezine vrijednosti.
  • Kvota ili ograničavanje: HTTP 429 ponovno se pokušava samo unutar ograničene politike. Sačuvajte vraćena zaglavlja kvote u metapodacima i smanjite duplicirana snimanja u cjevovodu.
  • Neispravan zahtjev: Odgovor 4xx, osim autentikacije ili ograničavanja, ne bi se trebao ponovno pokušavati. Provjerite je li url apsolutna, dostupna HTTPS adresa.
  • Neočekivana slika: Uspješan status s pogrešnom vrstom sadržaja ili PNG potpisom odbija se. Time se otkrivaju proxy stranice s pogreškom i neispravno oblikovani odgovori.
  • Obmanjujuće usporedbe: Obavijesti o kolačićima, rotirajuće promocije, animacije i personalizirane stranice stvaraju legitimne vizualne razlike. Dajte prednost stabilnom URL-u za reviziju i determinističkom sadržaju.

Pratite broj neuspjeha prema category i HTTP statusu, ali nikada nemojte zapisnicima priložiti token ili potpuni ciljni URL. Odmah upozorite na ponovljene neuspjehe autentikacije; oni se neće riješiti ponovnim pokušajima. Neuspjehe povezane s kvotom promatrajte kao signal kapaciteta, a ne kao iznimku aplikacije koju treba potisnuti.

Završni popis za provjeru

  1. Plan usluge je aktivan, a token ograničen na uslugu čuva se izvan kontrole izvornog koda.
  2. Točna GET krajnja točka uspijeva s obaveznim parametrom url.
  3. Mogu se snimati samo odobreni HTTPS nazivi hostova.
  4. Vremenska ograničenja i ponovni pokušaji su ograničeni, a neuspjesi autentikacije se ne pokušavaju ponovno.
  5. Odgovor se prije pohrane validira kao PNG.
  6. Zaglavlja predmemorije i kvote čuvaju se bez pretpostavljanja nedokumentiranih polja.
  7. Testovi prolaze bez vanjskog mrežnog prometa.
  8. Cjevovod proizvodi odgovarajuće artefakte before.png i after.png za jedan identifikator izdanja.

Vizualna revizija trebala bi ostaviti dokaz, a ne samo zelenu kvačicu. S dva autentificirana API poziva i uskom Symfony granicom, svako ažuriranje klijenta dobiva trajni prikaz onoga što su korisnici mogli vidjeti neposredno prije i nakon izdanja — bez pretvaranja održavanja preglednika u još jedan produkcijski sustav.

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.