Туториали

Capture Client Website's Visual State for Audits with Native PHP Screenshots

Снимете ја визуелната состојба на веб-страницата на клиентот за ревизии со природни PHP слики од екранот

Ажурирањето на веб-страница може да изгледа исправно во pull request, а сепак да пристигне со недостасувачки фонт, неочекуван банер за согласност или скршен респонзивен распоред. Пар снимки од екранот пред и по промената му дава на фриленсер или мал тим траен визуелен запис за ревизија, без никој да мора да одржува Chromium, драјвери за прелистувач или worker за снимки од екранот.

Ова упатство го гради тој работен тек како продукциски ориентирана Native PHP 8.3 апликација. Команда за распоредување ја снима јавната страница непосредно пред и по издавањето, го валидира PNG-одговорот, го запишува атомски и ги бележи заглавијата поврзани со кешот и квотите за подоцнежна дијагностика.

Добијте пристап и создајте сервисен токен

Поставувањето пристап се прави пред каков било интеграциски код:

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Screenshot API.
  3. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација.
  5. Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.

Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен за акредитивот да не се појавува во URL-адреси, логови за пристап до прокси или историја на прелистувачот.

Регенерирањето на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како промена во распоредувањето: инсталирајте ја новата вредност секаде каде што се извршува командата за ревизија, проверете ја и отстранете ја секоја застарена тајна од вашата платформа за распоредување.

Потврдете ја точната крајна точка

API-договорот што се користи тука е:

GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Задолжителен параметар за пребарување: url
Успешно тело: image/png

Извршете едно минимално барање локално, запишувајќи ги заглавијата и податоците за сликата во посебни датотеки:

export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 5 \
  --max-time 45 \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --data-urlencode 'url=https://client.example/' \
  --dump-header /tmp/screenshot-headers.txt \
  --output /tmp/screenshot.png

file /tmp/screenshot.png

Не внесувајте во commit ниту токен ниту вистинска снимка од екранот на клиент. API-то може да сервира кеширана снимка, затоа при дијагностицирање на свежината или капацитетот проверете ги вратените заглавија за кеш и квота, наместо да претпоставите дека секое барање започнува ново снимање.

Архитектура и компромиси

Најмалиот сигурен дизајн има четири граници: cURL транспорт извршува HTTP, клиентот за снимки ги поседува повторните обиди и валидацијата на одговорите, DTO го изложува PNG-то заедно со оперативните метаподатоци, а CLI-команда ги поседува именувањето и зачувувањето на ревизијата.

Командата е синхрона по дизајн. Распоредувањето не смее да се означи како визуелно потврдено додека неговото снимање сè уште чека во ред на друго место. Компромисот е дополнително време за распоредување, ограничено тука со експлицитни тајм-аути и мал буџет за повторни обиди.

Распоредот на проектот е намерно скромен:

website-audit/
├── bin/capture.php
├── config/bootstrap.php
├── src/HttpResponse.php
├── src/Transport.php
├── src/CurlTransport.php
├── src/Screenshot.php
├── src/ScreenshotException.php
├── src/ScreenshotClient.php
├── tests/ScreenshotClientTest.php
├── var/audits/
├── .env
├── .env.example
├── .gitignore
└── composer.json

Конфигурирајте го Native PHP проектот

Предуслови се PHP 8.3 или понов, екстензиите cURL и JSON, Composer и PHPUnit за тестови. Создадете го проектот и инсталирајте ја развојната зависност:

composer init --name=example/website-audit --no-interaction
composer require --dev phpunit/phpunit:^11.0

Додајте PSR-4 autoloading во composer.json и регенерирајте го autoloader-от:

{
  "name": "example/website-audit",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer dump-autoload
cp .env.example .env
chmod 600 .env

Ставете placeholder-и во .env.example, а потоа сместете го вистинскиот токен само во датотеката .env што не се следи или во вашиот production secret manager:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_ALLOWED_HOST=client.example
AUDIT_DIRECTORY=var/audits

Додајте ги .env и var/audits/ во .gitignore. Bootstrap-от може да вчитува едноставни environment-датотеки без воведување runtime пакет:

<?php
declare(strict_types=1);

$envFile = dirname(__DIR__) . '/.env';

if (is_file($envFile)) {
    $values = parse_ini_file($envFile, false, INI_SCANNER_RAW);

    if ($values === false) {
        throw new RuntimeException('Unable to parse .env');
    }

    foreach ($values as $name => $value) {
        if (getenv((string) $name) === false) {
            putenv($name . '=' . $value);
        }
    }
}

function requiredEnv(string $name): string
{
    $value = getenv($name);

    if ($value === false || trim($value) === '') {
        throw new RuntimeException("Missing environment variable: {$name}");
    }

    return $value;
}

Изградете одбранбена API-граница

Транспортот враќа статус, нормализирани заглавија и бајти. Одржувањето на овој интерфејс мал ги прави тестовите детерминистички и спречува cURL-деталите да протечат во командата за распоредување.

<?php
// src/HttpResponse.php
namespace App;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/Transport.php
namespace App;

interface Transport
{
    public function get(string $url, array $headers): HttpResponse;
}

// src/CurlTransport.php
namespace App;

use RuntimeException;

final class CurlTransport implements Transport
{
    public function get(string $url, array $headers): HttpResponse
    {
        $received = [];
        $body = '';
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => false,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 45,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $received[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
            CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$body): int {
                if (strlen($body) + strlen($chunk) > 20 * 1024 * 1024) {
                    return 0;
                }

                $body .= $chunk;
                return strlen($chunk);
            },
        ]);

        if (curl_exec($handle) === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Screenshot transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $received, $body);
    }
}

Ограничувањето од 20 MiB спречува невообичаен одговор да ја исцрпи PHP-меморијата. Пренасочувањата се оневозможени бидејќи крајната точка на услугата е фиксна; URL-адресата на целната страница останува вредност за пребарување што далечинската услуга треба да ја обработи.

Мапирајте успех и неуспех во доменски објекти

Со метаподатоците од одговорот треба да се постапува одбранбено. Стандардните кеш-заглавија се зачувуваат експлицитно. Заглавијата поврзани со квотите се собираат според семантичко име, наместо да се претпоставува дека постојат недокументирани полиња.

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

final readonly class Screenshot
{
    public function __construct(
        public string $png,
        public array $cacheHeaders,
        public array $quotaHeaders,
    ) {}
}

// src/ScreenshotException.php
namespace App;

use RuntimeException;

final class ScreenshotException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status = null,
    ) {
        parent::__construct($message);
    }
}

// src/ScreenshotClient.php
namespace App;

use Closure;
use Throwable;

final class ScreenshotClient
{
    public function __construct(
        private Transport $transport,
        private string $token,
        private Closure $sleep = new Closure(),
    ) {
        if ($this->sleep === new Closure()) {
            $this->sleep = static fn(int $microseconds) => usleep($microseconds);
        }
    }

    public function capture(string $targetUrl): Screenshot
    {
        $endpoint = 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
            . '?url=' . rawurlencode($targetUrl);

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->transport->get($endpoint, [
                    'Accept: image/png',
                    'Authorization: Bearer ' . $this->token,
                ]);
            } catch (Throwable $error) {
                if ($attempt === 2) {
                    throw new ScreenshotException('Transport failed after retries.');
                }

                ($this->sleep)(250_000 * (2 ** $attempt));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 2) {
                    throw new ScreenshotException('Temporary API failure.', $response->status);
                }

                ($this->sleep)($this->delay($response, $attempt));
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ScreenshotException('Authentication was rejected.', $response->status);
            }

            if ($response->status < 200 || $response->status >= 300) {
                throw new ScreenshotException('Capture request was rejected.', $response->status);
            }

            $type = strtolower(explode(';', $response->headers['content-type'] ?? '')[0]);

            if ($type !== 'image/png' || !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")) {
                throw new ScreenshotException('API returned an invalid PNG.', $response->status);
            }

            return new Screenshot(
                $response->body,
                array_intersect_key($response->headers, array_flip([
                    'cache-control', 'age', 'etag', 'expires',
                ])),
                array_filter(
                    $response->headers,
                    static fn(string $name): bool =>
                        $name === 'retry-after'
                        || str_contains($name, 'rate')
                        || str_contains($name, 'quota'),
                    ARRAY_FILTER_USE_KEY,
                ),
            );
        }

        throw new ScreenshotException('Capture failed.');
    }

    private function delay(HttpResponse $response, int $attempt): int
    {
        $retryAfter = $response->headers['retry-after'] ?? null;

        if (is_string($retryAfter) && ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 5) * 1_000_000;
        }

        return 250_000 * (2 ** $attempt);
    }
}

Во продукциски код, иницијализирајте го sleeper-от експлицитно со Closure::fromCallable('usleep'); ова го одржува доцнењето инјектабилно во тестови. Се повторуваат само неуспеси на транспортот, HTTP 429 и грешки на серверот. Неуспесите при автентикација и барање запираат веднаш бидејќи повторувањето не може да ги поправи.

Создадете ја командата за ревизија

Командата прифаќа фаза, идентификатор на издавање и HTTPS URL-адреса. Листата на дозволени хостови спречува оператор или компромитирана pipeline-променлива да ја претвори услугата за снимки во алатка за испитување произволни цели.

<?php
// bin/capture.php
declare(strict_types=1);

use App\CurlTransport;
use App\ScreenshotClient;

require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/config/bootstrap.php';

[$script, $phase, $release, $url] = $argv + [null, null, null, null];

if (!in_array($phase, ['before', 'after'], true)) {
    throw new InvalidArgumentException('Phase must be before or after.');
}

if (!is_string($release) || !preg_match('/\A[a-zA-Z0-9._-]{1,80}\z/', $release)) {
    throw new InvalidArgumentException('Invalid release identifier.');
}

$parts = is_string($url) ? parse_url($url) : false;
$allowedHost = requiredEnv('CLIENT_ALLOWED_HOST');

if (
    $parts === false
    || ($parts['scheme'] ?? null) !== 'https'
    || strcasecmp($parts['host'] ?? '', $allowedHost) !== 0
    || isset($parts['user'])
    || isset($parts['pass'])
) {
    throw new InvalidArgumentException('URL must use HTTPS on the allowed client host.');
}

$client = new ScreenshotClient(
    new CurlTransport(),
    requiredEnv('SCREENSHOT_API_TOKEN'),
    Closure::fromCallable('usleep'),
);

$screenshot = $client->capture($url);
$root = dirname(__DIR__) . '/' . trim(requiredEnv('AUDIT_DIRECTORY'), '/');
$directory = $root . '/' . $release;

if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
    throw new RuntimeException('Could not create audit directory.');
}

$imagePath = $directory . '/' . $phase . '.png';
$tempPath = $imagePath . '.tmp-' . bin2hex(random_bytes(6));

if (file_put_contents($tempPath, $screenshot->png, LOCK_EX) === false
    || !rename($tempPath, $imagePath)) {
    @unlink($tempPath);
    throw new RuntimeException('Could not persist screenshot.');
}

$metadata = [
    'phase' => $phase,
    'release' => $release,
    'url' => $url,
    'captured_at' => gmdate(DATE_ATOM),
    'sha256' => hash('sha256', $screenshot->png),
    'cache_headers' => $screenshot->cacheHeaders,
    'quota_headers' => $screenshot->quotaHeaders,
];

file_put_contents(
    $directory . '/' . $phase . '.json',
    json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL,
    LOCK_EX,
);

fwrite(STDOUT, json_encode([
    'event' => 'screenshot.saved',
    'phase' => $phase,
    'release' => $release,
    'path' => $imagePath,
], JSON_THROW_ON_ERROR) . PHP_EOL);

JSON-от запишан на стандарден излез е погоден за структурирани логови за распоредување. Намерно ги исклучува токенот и телото на сликата. Соседната датотека со метаподатоци ги чува контролните суми, кеш-информациите и сите вратени сигнали за квоти заедно со самиот доказ.

Автоматизирајте го работниот тек пред и по промената

Обвиткајте ја постојната акција за издавање со двете снимања. Користете ја истата стабилна јавна URL-адреса за двете, за споредбата да ја мери состојбата на распоредувањето наместо разликите меѓу рути:

RELEASE_ID="release-2026-10-10-1"
AUDIT_URL="https://client.example/"

php bin/capture.php before "$RELEASE_ID" "$AUDIT_URL"

./deploy-existing-release.sh

php bin/capture.php after "$RELEASE_ID" "$AUDIT_URL"

Ако првото снимање не успее, запрете пред распоредувањето освен ако тимот експлицитно не ги класифицирал снимките од екранот како неблокирачки. Ако распоредувањето успее, но второто снимање не успее, пријавете делумна ревизија и повторно извршете ја само фазата after. Никогаш не прикривајте недостасувачка слика со копирање на другата фаза.

Тестирајте без да ја повикувате услугата

Лажен транспорт ги прави однесувањето при повторни обиди и мапирањето на одговорите репродуцибилни. Ниту една тест-фигура не содржи вистински акредитив.

<?php
namespace Tests;

use App\HttpResponse;
use App\ScreenshotClient;
use App\ScreenshotException;
use App\Transport;
use PHPUnit\Framework\TestCase;

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndOperationalHeaders(): void
    {
        $fake = new SequenceTransport([
            new HttpResponse(200, [
                'content-type' => 'image/png',
                'cache-control' => 'public, max-age=60',
                'x-rate-limit-remaining' => '9',
            ], "\x89PNG\r\n\x1a\npayload"),
        ]);

        $result = (new ScreenshotClient(
            $fake,
            'test-token',
            static fn(int $delay) => null,
        ))->capture('https://client.example/');

        self::assertSame('public, max-age=60', $result->cacheHeaders['cache-control']);
        self::assertSame('9', $result->quotaHeaders['x-rate-limit-remaining']);
        self::assertSame(1, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new SequenceTransport([
            new HttpResponse(401, ['content-type' => 'application/json'], '{}'),
        ]);

        try {
            (new ScreenshotClient($fake, 'bad-token', static fn(int $delay) => null))
                ->capture('https://client.example/');
            self::fail('Expected ScreenshotException');
        } catch (ScreenshotException $error) {
            self::assertSame(401, $error->status);
            self::assertSame(1, $fake->calls);
        }
    }
}

final class SequenceTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(string $url, array $headers): HttpResponse
    {
        return $this->responses[$this->calls++];
    }
}
vendor/bin/phpunit tests

Безбедност, набљудливост и детали за распоредување

Ограничете го токенот на конфигурација поддржана од околината, заштитете ја environment-датотеката со дозволи на оперативниот систем и никогаш не печатете заглавија на барања. Ако токенот се регенерира, заменете го атомски низ околините за распоредување бидејќи стариот токен престанува да работи.

Снимките од екранот на клиентот може да содржат имиња, информации за сметки, необјавени понуди или состојба на согласност. Чувајте го var/audits надвор од јавниот document root, дефинирајте политика за задржување и дајте пристап само на луѓето на кои им е потребна ревизијата. Шифрирајте го волуменот за складирање или одредиштето во object storage кога сликите се чувствителни.

Логирајте ги издавањето, фазата, категоријата HTTP-статус, бројот на обиди, траењето и конечниот исход. Не логирајте го Bearer токенот, PNG-бајти или неограничена URL-адреса што содржи чувствителни вредности за пребарување. Поставете предупредувања за повторени неуспеси при автентикација, исцрпени повторни обиди и отсутни слики after.

На продукциските контејнери им се потребни PHP-екстензијата cURL, запишлив траен директориум за ревизии, доверливи certificate authorities, излезен HTTPS-пристап до ai.mihajlo.mk и доволно меморија за конфигурираниот плафон од 20 MiB. Извршете smoke capture по распоредување и по ротација на тајна.

Вообичаени начини на неуспех

  • HTTP 401 или 403: проверете ја активацијата и тековниот токен ограничен на услугата. Не обидувајте се повторно наслепо.
  • HTTP 429: достигнато е ограничувањето на квотата или стапката. Почитувајте нумерички Retry-After во рамки на ограничено доцнење, а потоа прикажете го неуспехот ако се исцрпат повторните обиди.
  • HTTP 5xx или мрежен тајм-аут: обидете се повторно накратко со експоненцијално повлекување; задржете го исходот од распоредувањето експлицитен ако опоравувањето не успее.
  • Успешен статус со тело што не е PNG: отфрлете го. Само статусот не е доволен; валидирајте ги и Content-Type и PNG-потписот.
  • Неочекувано стара слика: прегледајте ги зачуваните кеш-заглавија пред да го обвините распоредувањето. Однесувањето на кешираните снимки е дел од намената на услугата.
  • Двете слики изгледаат идентично: споредете ги нивните SHA-256 вредности, потврдете дека издавањето навистина стигнало до јавниот хост и проверете дали кешовите на апликацијата или edge-кешовите сè уште ја сервираат претходната верзија.

Конечна листа за проверка

  • Активниот план е овозможен, а тековниот сервисен токен е зачуван надвор од source control.
  • Командата повикува точно GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture со задолжителниот параметар за пребарување url.
  • Може да се снима само одобрениот HTTPS клиентски хост.
  • Ограничувањата за поврзување, вкупно траење, големина на одговор, повторни обиди и повлекување се ограничени.
  • Неуспесите при автентикација и валидација не се повторуваат.
  • Одговорот се потврдува како PNG пред да се зачува атомски.
  • Кеш- и заглавијата поврзани со квоти се задржуваат без претпоставка за недокументирани полиња.
  • Тестовите поминуваат преку детерминистички лажен транспорт без надворешни барања.
  • Вистински директориум за издавање содржи различни before.png, after.png и датотеки со метаподатоци.

Пар снимки од екранот е едноставен доказ, но токму таа едноставност е неговата сила. Тој го претвора „издавањето изгледаше добро“ во датиран артефакт со контролна сума, поврзан со конкретно распоредување. Со инфраструктурата на прелистувачот делегирана на Screenshot API и интеграцијата ограничена со внимателна валидација, повторни обиди, безбедносни контроли и тестови, визуелната ревизија станува обичен дел од испораката наместо несигурна задача на која некој се сеќава подоцна.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.