Автоматизација на Symfony снимки: визуелни ревизии на веб-страница пред ажурирање со Screenshot API
Ажурирањето на веб-страница може да помине unit тестови, да врати здрави HTTP одговори, а сепак да испорача скршена главна слика, недостасувачки фонт или погрешно поставено копче за наплата. Овие дефекти се наоѓаат на рендерираната страница, па затоа продукциското издание заслужува доказ од рендерираната страница.
Овој туторијал изработува Symfony автоматизација што снима една PNG-слика пред распоредувањето и друга потоа. Секоја слика се складира со SHA-256 отпечаток, како и метаподатоци за кешот и квотата вратени од Screenshot API. Цевководот за распоредување добива излезен код различен од нула кога снимањето не успее, додека развивачите избегнуваат одржување на Chromium, драјвери за прелистувачи и работници за слики од екранот.
Добијте пристап до Screenshot API
Регистрирајте се на https://ai.mihajlo.mk/register, или најавете се преку https://ai.mihajlo.mk/login.
Отворете ја страницата на услугата Screenshot API, изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација. Потоа посетете ја официјалната документација за Screenshot API. Најдете го панелот Service token и копирајте го таму прикажаниот токен ограничен на услугата.
Услугата бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе користиме Bearer токен бидејќи е поверојатно акредитивите во низите за барање да се појават во прокси и дневниците за пристап.
Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Третирајте ја ротацијата како координирано распоредување: ажурирајте ја тајната во околината за распоредување и веднаш рестартирајте или повторно распоредете го секој процес што ја користи.
Потврдете ја точната крајна точка
Операцијата за снимање е:
GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Required query parameter: url
Successful body: image/png
Направете едно минимално барање пред да пишувате код за апликацијата. Датотеката со заглавија ви овозможува да ги проверите тековните заглавија за кеш и квота на услугата без да го испечатите бинарниот одговор:
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
Штом тоа успее, ставете го акредитивот во тајна поддржана од променливи на околината. За локален Symfony развој, користете ја непредадената датотека .env.local:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_AUDIT_HOSTS=www.example.com,preview.example.com
Поставете ги истите променливи преку вашиот продукциски управувач со тајни или платформа за хостирање. Не предавајте .env.local, вистински токен, снимени приватни страници или исфрлања на заглавија.
Изберете намерно мала архитектура
Синхрона конзолна команда е подобро соодветна од Messenger тука. На распоредувањето му треба непосреден сигнал за успех или неуспех, а додавањето редица би го одвоило тој сигнал од цевководот што мора да постапи според него.
Резултирачкиот проект има четири одговорности:
ScreenshotClientуправува со автентикацијата, временските ограничувања, повторните обиди, PNG-валидацијата и мапирањето на заглавијата од одговорот.ScreenshotCaptureе валидираниот доменски резултат.SnapshotStoreатомски запишува PNG и JSON артефакти.WebsiteSnapshotCommandго валидира влезот за распоредување и враќа корисен излезен код на процесот.
Инсталирајте ги првостранските Symfony компоненти користени подолу:
composer require symfony/http-client symfony/console symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack
Релевантните датотеки ќе бидат src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotApiException.php, src/Screenshot/ScreenshotClient.php, src/Screenshot/SnapshotStore.php, src/Command/WebsiteSnapshotCommand.php и tests/Screenshot/ScreenshotClientTest.php.
Конфигурирајте вбризгување на зависности
Чувајте ја крајната точка во конфигурацијата, но не затоа што се очекува лежерно да се менува. Една авторитативна вредност ги прави тестовите и прегледите појасни.
# 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/'
Изградете одбранбена API граница
API-то враќа бајтови наместо JSON. Тоа ја прави валидацијата особено важна: HTML-страница со грешка од нагорниот систем никогаш не смее да биде прифатена како слика од екранот само затоа што посредникот вратил статус 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;
}
}
Се прават повторни обиди само за транспортни неуспеси, HTTP 429 и серверски грешки. Неуспесите во автентикацијата и валидацијата бараат човечки или конфигурациски промени, па нивното повторување би трошело квота и би ја одложило јасната дијагноза. Retry-After се почитува кога е цел број, но е ограничен за поединечен цевковод да не може да запре на неодредено време.
Маперот на заглавија намерно избегнува претпоставување недокументирани полиња на одговорот. Тој ги задржува заглавијата поврзани со кешот и квотата под нивните оригинални нормализирани имиња, овозможувајќи ѝ на апликацијата да ги зачува заглавијата што услугата тековно ги враќа.
Складирајте артефакти и изложете ја командата
Секое издание добива директориум што содржи before.png, after.png и соодветни датотеки со метаподатоци. Symfony-овиот dumpFile() ги заменува датотеките атомски, спречувајќи читателите да набљудуваат делумно запишана 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;
}
}
}
Дозволената листа на имиња на домаќини спречува компромитиран параметар на цевководот да троши квота на произволни цели. Запишувањето само на името на домаќинот исто така избегнува откривање потпишани URL-адреси за преглед и нивните параметри за барање.
Тестирајте повторни обиди без мрежни повици
MockHttpClient ѝ дава детерминистички одговори на границата. Вбризгувањето на спиење без дејство ги одржува тестовите за повторни обиди брзи.
<?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);
}
}
}
Извршете го пакетот со php bin/phpunit. Корисни дополнителни случаи вклучуваат низа HTTP 429, HTML-тело погрешно означено како успешно, преголема слика и одбивање од командата на неодобрено име на домаќин.
Поврзете го со распоредувањето
Користете го истиот непроменлив идентификатор на издание за двете слики. Додајте ги овие команди околу постојниот чекор за распоредување:
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/
Неуспехот на командата „before“ треба да го запре распоредувањето бидејќи споредбата би била нецелосна. Неуспехот по распоредувањето треба да ја неуспее фазата на визуелна ревизија без да се преправа дека е извршено враќање на апликацијата. Дали тој неуспех иницира враќање е посебна одлука за политиката на издание.
На еден извршувач на распоредувања, var/audits е доволен. Во ефемерни контејнери, отпремете го тој директориум како заштитен артефакт на цевководот или заменете го SnapshotStore со постојната апстракција за трајно складирање на проектот. Чувајте ги снимките под контролиран пристап: страниците на клиентите може да содржат имиња, информации за сметки, содржина за преглед или комерцијално чувствителни дизајни.
Вообичаени продукциски неуспеси
- Автентикација: HTTP 401 или 403 обично значи дека тајната недостасува, е погрешно форматирана или е поништена со повторно генерирање на токенот. Потврдете го вбризгувањето на тајната без да ја испечатите нејзината вредност.
- Квота или ограничување: HTTP 429 се обидува повторно само во рамките на ограничената политика. Зачувајте ги вратените заглавија за квота во метаподатоците и намалете ги дупликатните снимања во цевководот.
- Невалидно барање: Одговор 4xx, освен за автентикација или ограничување, не треба повторно да се обидува. Проверете дали
urlе апсолутна, достапна HTTPS-адреса. - Неочекувана слика: Успешен статус со погрешен тип на содржина или PNG-потпис се одбива. Ова фаќа прокси-страници со грешка и неправилно форматирани одговори.
- Заблудувачки споредби: Банери за колачиња, ротирачки промоции, анимации и персонализирани страници создаваат легитимни визуелни разлики. Претпочитајте стабилна URL-адреса за ревизија и детерминистичка содржина.
Следете го бројот на неуспеси според category и HTTP-статус, но никогаш не прикачувајте го токенот или целосната целна URL-адреса во дневниците. Веднаш алармирајте за повторени неуспеси во автентикацијата; тие нема да се поправат со повторни обиди. Набљудувајте ги неуспесите поврзани со квотата како сигнал за капацитет наместо како исклучок на апликацијата што треба да се потисне.
Конечна контролна листа за верификација
- Планот за услугата е активен, а токенот ограничен на услугата се чува надвор од изворната контрола.
- Точната GET-крајна точка успева со задолжителниот параметар
url. - Може да се снимаат само одобрени HTTPS имиња на домаќини.
- Временските ограничувања и повторните обиди се ограничени, а неуспесите во автентикацијата не се повторуваат.
- Одговорот се валидира како PNG пред складирањето.
- Заглавијата за кешот и квотата се зачувуваат без претпоставување недокументирани полиња.
- Тестовите поминуваат без надворешен мрежен сообраќај.
- Цевководот создава соодветни артефакти
before.pngиafter.pngза еден идентификатор на издание.
Визуелната ревизија треба да остави доказ, а не само зелена ознака за проверка. Со два автентицирани API-повици и тесна Symfony граница, секое ажурирање на клиентот добива траен приказ на она што корисниците можеле да го видат непосредно пред и по изданието — без одржувањето на прелистувачот да се претвори во уште еден продукциски систем.