Symfony: Automatsko snimanje snimki klijentskih web-stranica prije i nakon ažuriranja
Ažuriranje web-stranice može biti tehnički uspješno, a ipak vizualno pogrešno. Font koji nedostaje, neočekivani banner o kolačićima, neispravna hero slika ili CSS paket poslužen iz stare predmemorije mogu proći provjere ispravnosti, a klijentu i dalje biti očiti.
Ovaj vodič izrađuje produkcijski orijentiran Symfony tijek rada koji snima PNG neposredno prije implementacije i još jedan neposredno nakon nje. Snimke su grupirane prema izdanju, popraćene metapodacima o predmemoriji i kvoti te prikladne za čuvanje kao CI artefakti. Aplikacija delegira prikazivanje u pregledniku Screenshot API-ju, pa ne mora instalirati, krpati ni upravljati Chromiumom.
Dobijte pristup prije pisanja integracijskog koda
- 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.
- Otvorite službenu dokumentaciju Screenshot API-ja.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi Bearer token jer je vjerojatnije da će se vjerodajnice u nizovima upita pojaviti u zapisnicima pristupa, povijesti preglednika i sustavima za nadzor.
Ponovno generiranje servisnog tokena opoziva prethodno aktivan token. Rotaciju tretirajte kao koordiniranu implementaciju: instalirajte novu vrijednost svugdje gdje se izvršava naredba za snimanje, provjerite je i povucite svaki proces koji je još konfiguriran sa starom vrijednošću.
Potvrdite krajnju točku jednim minimalnim zahtjevom
Točna operacija je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njezin obavezni parametar upita url određuje stranicu za prikaz. Uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora koja opisuju ponašanje predmemorije i kvote.
curl --fail-with-body --silent --show-error \
--get \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://client.example/' \
--dump-header /tmp/screenshot.headers \
--output /tmp/screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file /tmp/screenshot.png
Zadržite spremljena zaglavlja tijekom ove provjere. Dostupnost i nazive zaglavlja treba pročitati iz stvarnog odgovora i službene dokumentacije, a ne nagađati u aplikacijskom kodu.
Struktura projekta i operativni odabiri
Primjer cilja na PHP 8.3 i aktualnu Symfony aplikaciju s komponentama Console, HttpClient i loggerom kompatibilnim s PSR-om. Instalirajte potrebne komponente prve strane i podršku za testiranje:
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
mkdir -p var/snapshots
Relevantnih je datoteka namjerno malo:
src/Screenshot/ScreenshotCapture.phppreslikava uspješan udaljeni odgovor u domenu.src/Screenshot/ScreenshotException.phpizlaže stabilne kategorije neuspjeha.src/Screenshot/ScreenshotClient.phpupravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, PNG provjerom i preslikavanjem zaglavlja odgovora.src/Command/CaptureSnapshotCommand.phpprovjerava ulaz implementacije i atomski zapisuje artefakte izdanja.tests/Screenshot/ScreenshotClientTest.phptestira granicu determinističkim lažnim transportom.
Sinkrona konzolna naredba ovdje je poželjnija od Messengera. Snimanje prije implementacije mora završiti prije njezina početka, a snimanje nakon implementacije smije se pokrenuti tek nakon što nova stranica prođe provjeru spremnosti. Pozadinski red čekanja oslabio bi taj redoslijed, a donio bi malu vrijednost.
Konfigurirajte tajne i ubrizgavanje ovisnosti
Stavite lokalnu vjerodajnicu u .env.local, koji treba ostati izvan kontrole verzija:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOST=client.example
Produkcija bi trebala ubrizgati iste varijable putem mogućnosti platforme za implementaciju namijenjene tajnama. Ne stavljajte stvarni token u .env, kontrolu izvornog koda, fixture podatke, argumente naredbi, zapisnike ni pohranjene metapodatke snimki.
Dodajte skalarna povezivanja u config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(SCREENSHOT_API_TOKEN)%'
App\Command\CaptureSnapshotCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$allowedHost: '%env(SNAPSHOT_ALLOWED_HOST)%'
Izgradite obrambenu granicu Screenshot API-ja
Aplikacija ne bi trebala prosljeđivati sirove HTTP odgovore ostatku sustava. Preslikajte uspješne odgovore u nepromjenjivi objekt, a neuspjehe u kategorije koje naredba, test ili pravilo nadzora mogu razumjeti.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
/**
* @param array<string, list<string>> $serviceHeaders
*/
public function __construct(
public string $png,
public array $serviceHeaders,
) {}
}
// src/Screenshot/ScreenshotException.php
namespace App\Screenshot;
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Klijent dopušta tri pokušaja za prolazne kvarove transporta i poslužitelja. Neuspjesi autentikacije i provjere nikada se ne pokušavaju ponovno. Odgovor 429 ponovno se pokušava samo kada pruža kratki numerički Retry-After; iscrpljeno ili dugo odgađanje zbog kvote treba prijaviti sustavu implementacije umjesto da posao neograničeno čeka.
<?php
// 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 const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
/** @var \Closure(int): void */
private \Closure $sleep;
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $token,
?\Closure $sleep = null,
) {
$this->sleep = $sleep ?? static fn (int $microseconds) =>
usleep($microseconds);
}
public function capture(string $url): ScreenshotCapture
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'auth_bearer' => $this->token,
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if ($status === 429) {
$retryAfter = $headers['retry-after'][0] ?? null;
if (
$attempt < 3
&& is_string($retryAfter)
&& ctype_digit($retryAfter)
&& (int) $retryAfter <= 2
) {
($this->sleep)((int) $retryAfter * 1_000_000);
continue;
}
throw new ScreenshotException(
'quota',
'Screenshot quota or rate limit prevented capture.'
);
}
if ($status >= 500 && $attempt < 3) {
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
continue;
}
if ($status === 401 || $status === 403) {
throw new ScreenshotException(
'authentication',
'Screenshot API authentication was rejected.'
);
}
if ($status < 200 || $status >= 300) {
throw new ScreenshotException(
'request',
sprintf('Screenshot API returned HTTP %d.', $status)
);
}
$body = $response->getContent(false);
$contentType = strtolower(
explode(';', $headers['content-type'][0] ?? '')[0]
);
if (
$contentType !== 'image/png'
|| !str_starts_with($body, "\x89PNG\r\n\x1a\n")
) {
throw new ScreenshotException(
'invalid_response',
'Screenshot API did not return a valid PNG response.'
);
}
$metadata = array_filter(
$headers,
static fn (string $name): bool =>
str_contains($name, 'cache')
|| str_contains($name, 'quota')
|| str_contains($name, 'rate'),
ARRAY_FILTER_USE_KEY
);
return new ScreenshotCapture($body, $metadata);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'transport',
'Screenshot API could not be reached.',
$exception
);
}
$this->logger->warning('Screenshot transport retry.', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
}
}
throw new ScreenshotException('internal', 'Capture loop ended unexpectedly.');
}
}
Odabir metapodataka namjerno je obramben: zadržava zaglavlja čiji nazivi ukazuju na podatke o predmemoriji, kvoti ili ograničenju stope bez izmišljanja ugovora o zaglavljima specifičnog za dobavljača. Token i tijelo odgovora nikada se ne zapisuju u zapisnike.
Izradite naredbu za implementaciju
Naredba prihvaća identifikator izdanja, fazu i javni HTTPS URL. Ograničava snimanja na jedan konfigurirani host klijenta, sprječavajući da slučajno izložena naredba troši kvotu na proizvoljne ciljeve. Identifikatori izdanja također su ograničeni prije nego što postanu nazivi direktorija.
<?php
// src/Command/CaptureSnapshotCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:snapshots:capture')]
final class CaptureSnapshotCommand extends Command
{
public function __construct(
private readonly ScreenshotClient $screenshots,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly string $allowedHost,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('release', null, InputOption::VALUE_REQUIRED)
->addOption('phase', null, InputOption::VALUE_REQUIRED)
->addOption('url', null, InputOption::VALUE_REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
$release = (string) $input->getOption('release');
$phase = (string) $input->getOption('phase');
$url = (string) $input->getOption('url');
$host = parse_url($url, PHP_URL_HOST);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!preg_match('/\A[a-zA-Z0-9._-]{1,80}\z/', $release)) {
$output->writeln('<error>Invalid release identifier.</error>');
return Command::INVALID;
}
if (!in_array($phase, ['before', 'after'], true)) {
$output->writeln('<error>Phase must be before or after.</error>');
return Command::INVALID;
}
if ($scheme !== 'https' || $host !== $this->allowedHost) {
$output->writeln('<error>URL must use the allowed HTTPS host.</error>');
return Command::INVALID;
}
try {
$capture = $this->screenshots->capture($url);
$directory = $this->projectDir.'/var/snapshots/'.$release;
if (!is_dir($directory) && !mkdir($directory, 0770, true)) {
throw new \RuntimeException('Could not create snapshot directory.');
}
$this->atomicWrite($directory.'/'.$phase.'.png', $capture->png);
$this->atomicWrite(
$directory.'/'.$phase.'.json',
json_encode([
'release' => $release,
'phase' => $phase,
'captured_at' => gmdate(DATE_ATOM),
'service_headers' => $capture->serviceHeaders,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)
);
$this->logger->info('Website snapshot captured.', [
'release' => $release,
'phase' => $phase,
'host' => $host,
]);
$output->writeln('Snapshot saved for '.$release.' '.$phase.'.');
return Command::SUCCESS;
} catch (ScreenshotException $exception) {
$this->logger->error('Website snapshot failed.', [
'release' => $release,
'phase' => $phase,
'kind' => $exception->kind,
]);
$output->writeln('<error>Capture failed: '.$exception->kind.'</error>');
return Command::FAILURE;
}
}
private function atomicWrite(string $path, string $contents): void
{
$temporary = tempnam(dirname($path), 'snapshot-');
if ($temporary === false || file_put_contents($temporary, $contents) === false) {
throw new \RuntimeException('Could not write temporary snapshot.');
}
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new \RuntimeException('Could not publish snapshot.');
}
}
}
Testirajte bez pozivanja stvarne usluge
MockHttpClient čini redoslijed i sadržaj odgovora determinističkima. Ubrizgavanje no-op čekanja održava testove ponovnih pokušaja brzima.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
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 testMapsPngAndServiceHeaders(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$http = new MockHttpClient([
new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'x-cache: HIT',
'x-quota-remaining: 9',
],
]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'test-token',
static fn (int $microseconds) => null
);
$result = $client->capture('https://client.example/');
self::assertSame($png, $result->png);
self::assertArrayHasKey('x-cache', $result->serviceHeaders);
self::assertArrayHasKey('x-quota-remaining', $result->serviceHeaders);
}
public function testAuthenticationFailureIsStructured(): void
{
$http = new MockHttpClient([
new MockResponse('Unauthorized', ['http_code' => 401]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'invalid-token',
static fn (int $microseconds) => null
);
try {
$client->capture('https://client.example/');
self::fail('Expected authentication failure.');
} catch (ScreenshotException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(1, $http->getRequestsCount());
}
}
}
Ilustrativni nazivi predmemorije i kvote iznad postoje samo u lažnom odgovoru; produkcijski kod ne ovisi o njima. Dodajte testove za tijelo koje nije PNG, iznimku transporta, 500 koji se može ponovno pokušati te 429 sa i bez prihvatljivog Retry-After.
Povežite ga s implementacijom
Pokrenite prvu naredbu prije promjene aktivnog izdanja. Nakon implementacije pričekajte da prođe uobičajena provjera spremnosti stranice, a zatim pokrenite drugu:
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=before \
--url='https://client.example/?visual_check=2026-10-12.1-before'
# Run the existing deployment and readiness check here.
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=after \
--url='https://client.example/?visual_check=2026-10-12.1-after'
Budući da usluga snima predmemorirane snimke zaslona, različiti bezopasni ciljni URL-ovi pomažu izbjeći zamjenu prethodne snimke s novim izdanjem. Potvrdite da klijentska aplikacija zanemaruje parametar visual_check i da ga analitika isključuje. Ako parametri upita utječu na prikazivanje, umjesto toga upotrijebite stabilne URL-ove pretpregleda s verzijom.
Neuspjelo snimanje prije implementacije obično bi trebalo zaustaviti implementaciju: ne postoji valjana osnovna snimka. Neuspjeh snimanja nakon implementacije trebao bi označiti posao izdanja neuspješnim i sačuvati implementirano stanje za istragu; ne bi trebao automatski vratiti ispravnu stranicu samo zato što vizualni dokaz nije mogao biti prikupljen.
Sigurnost, vidljivost i pojedinosti implementacije
- Ograničite tko može izvršavati naredbu i zadržite konfiguraciju dopuštenog hosta uskom.
- Nikada ne prihvaćajte proizvoljne URL-ove snimki iz javnog kontrolera. To poziva na zlouporabu kvote i može udaljenoj usluzi za snimanje izložiti nenamjerne stranice.
- Zapisujte izdanje, fazu, host, vrstu neuspjeha i pokušaj ponavljanja, ali ne tokene, potpune URL-ove s osjetljivim parametrima ni PNG tijela.
- Upozoravajte zasebno na ponovljene neuspjehe
authentication,quota,transportiinvalid_response; zahtijevaju različita rješenja. - Osigurajte da je
var/snapshotszapisiv. U efemernim spremnicima objavite direktorij izdanja kao zaštićeni CI artefakt prije završetka posla. - Primijenite izričitu politiku zadržavanja jer se PNG-ovi i njihove popratne datoteke s vremenom nakupljaju.
Uobičajeni neuspjesi i završna provjera
401 ili 403 obično znači da token nedostaje, neispravno je oblikovan, opozvan je ili nije servisni token koji ova integracija očekuje. 429 znači da snimanje treba pričekati prema razumnom savjetu poslužitelja ili jasno ne uspjeti radi pregleda kvote. Vremensko ograničenje može ukazivati na nedostupan API ili ciljnu stranicu koja se prikazuje presporo. Naizgled nepromijenjena slika nakon implementacije može biti učinak predmemorije, nepotpuna implementacija ili ciljni URL čiji je niz upita normaliziran uzvodno.
Prije nego što tijek rada proglasite dovršenim, provjerite sve sljedeće:
- Minimalni zahtjev vraća PNG i vidljiva zaglavlja predmemorije i kvote.
- Stvarni token postoji samo u konfiguraciji tajni podržanoj okruženjem.
- Naredba odbija URL-ove koji nisu HTTPS, neodobrene hostove, nevaljane faze i nesigurne nazive izdanja.
- Pogreške autentikacije i provjere ne pokušavaju se ponovno.
- Prolazni ponovni pokušaji ograničeni su brojem pokušaja, odgodom i ukupnim trajanjem zahtjeva.
- Obje PNG datoteke ispravno se otvaraju, a njihove JSON popratne datoteke ne sadrže vjerodajnice.
- Implementacija čeka snimanje prije implementacije i spremnost stranice prije snimanja nakon implementacije.
- Artefakti snimki preživljavaju izvršitelja implementacije dovoljno dugo za pregled.
Korisni ishod nisu samo dvije slikovne datoteke. To je trajna vizualna granica oko svakog ažuriranja klijenta: jedan pouzdan zapis onoga što je bilo prisutno, jedan zapis onoga što se promijenilo i model neuspjeha koji se predvidljivo ponaša kada mreža, vjerodajnice, kvota ili implementacija zakažu.