Туториали

Native PHP 8.3: Capture Website Update Before/After Snapshots Automatically

Native PHP 8.3: Автоматски снимајте слики пред/потоа од ажурирање на веб-страница

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

Овој туторијал го создава тој запис со Native PHP 8.3, native cURL и мала апликација од командна линија. Снима PNG непосредно пред распоредувањето, снима друг откако ажурираната страница ќе ја помине проверката на здравјето и ги зачувува двете слики со метаподатоци од одговорот за отстранување проблеми и можност за ревизија.

Добијте пристап до Screenshot API

Прво, регистрирајте сметка или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Screenshot API.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата.
  4. Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
  5. Чувајте го во конфигурација поддржана од околински променливи, никогаш во PHP изворниот код или скриптите за распоредување.

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Координирајте ја ротацијата така што новата вредност ќе стигне до секоја активна околина пред старите процеси да направат ново барање.

Услугата бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Овој проект користи Bearer заглавие бидејќи акредитивите во query string може да се појават во дневниците за пристап и системите за мониторинг.

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

Операцијата за снимање е HTTP GET барање до https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Нејзиниот задолжителен параметар за пребарување е url, а успешното снимање враќа тело image/png заедно со заглавија на одговорот поврзани со кешот и квотата.

export SCREENSHOT_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_TOKEN}" \
  --header 'Accept: image/png' \
  --data-urlencode 'url=https://client.example/' \
  --dump-header smoke.headers \
  --output smoke.png

file smoke.png

Проверете ги smoke.headers, како и PNG-датотеката. Имињата на заглавијата треба да се користат според тековната официјална документација. Имплементацијата подолу го зачувува секое вратено заглавие на одговорот наместо да измислува фиксни имиња на полиња за кеш или квота.

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

Цевководот за распоредување повикува една команда двапати: еднаш пред промена на страницата и еднаш откако ажурираната страница е во добра состојба. Идентификатор на изданието ги поврзува двете снимања. Границата кон API е изолирана зад транспортен интерфејс, што му овозможува на PHPUnit да тестира повторни обиди и неуспеси без мрежни повици.

website-snapshots/
├── bin/snapshot
├── src/
│   ├── CurlTransport.php
│   ├── HttpResponse.php
│   ├── ScreenshotClient.php
│   └── Transport.php
├── tests/ScreenshotClientTest.php
├── var/snapshots/
├── .env
├── .env.example
├── composer.json
└── phpunit.xml

Далечинското снимање ја избегнува потребата од одржување на Chromium, закрпи за прелистувач, фонтови и инфраструктура за песочник. Компромисот е мрежна зависност и квота на планот, па снимањата имаат потреба од ограничени временски ограничувања, намерни повторни обиди и трајни метаподатоци.

Инсталирајте и конфигурирајте го проектот

Апликацијата бара PHP 8.3, екстензијата cURL, Composer и PHPUnit 11. Единствениот пакет за извршување е vlucas/phpdotenv 5.6, кој се користи за доследно вчитување на локалната конфигурација на околината.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}
composer install
mkdir -p var/snapshots
chmod 750 var var/snapshots
cp .env.example .env
chmod 600 .env

Ставете ги местодржачите во .env.example, а потоа ставете го вистинскиот токен само во игнорираната датотека .env. Продукцијата треба да ги внесе истите променливи преку својот механизам за управување со тајни.

SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOSTS=client.example,www.client.example

Белата листа на хостови е важна граница на ниво на апликација. Таа спречува грешка во аргумент — или напаѓач кој ќе добие пристап до командата — да ја претвори вашата сметка во прокси за снимање URL-адреси за општа намена. Овој пример дозволува само HTTPS URL-адреси чијшто hostname точно се совпаѓа со конфигурираната листа.

Изградете дефанзивна API граница

Типовите за одговор и транспорт ја држат HTTP-механиката надвор од командата за распоредување. Заглавијата на одговорот се нормализираат во низи со мали букви, така што повторените заглавија не се отфрлаат.

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

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

final class TransportException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $transient
    ) {
        parent::__construct($message);
    }
}
<?php
// src/HttpResponse.php
namespace App;

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

    public function header(string $name): ?string
    {
        $values = $this->headers[strtolower($name)] ?? null;
        return $values === null ? null : implode(', ', $values);
    }
}

Транспортот cURL го потврдува TLS со задржување на безбедните стандардни поставки на cURL, одбива пренасочувања на API границата и го раздвојува временското ограничување за поврзување од вкупното временско ограничување за одговор. Неговиот callback за заглавија ги ресетира собраните заглавија кога ќе се појави нова HTTP статусна линија, спречувајќи привремениот одговор да ги контаминира конечните метаподатоци.

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

final class CurlTransport implements Transport
{
    public function get(
        string $url,
        array $headers,
        int $connectTimeout,
        int $responseTimeout
    ): HttpResponse {
        $responseHeaders = [];
        $handle = curl_init($url);

        if ($handle === false) {
            throw new TransportException('Unable to initialize cURL', false);
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT => $connectTimeout,
            CURLOPT_TIMEOUT => $responseTimeout,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
                use (&$responseHeaders): int {
                $length = strlen($line);
                $trimmed = trim($line);

                if (str_starts_with($trimmed, 'HTTP/')) {
                    $responseHeaders = [];
                } elseif ($trimmed !== '' && str_contains($trimmed, ':')) {
                    [$name, $value] = explode(':', $trimmed, 2);
                    $responseHeaders[strtolower(trim($name))][] = trim($value);
                }

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $number = curl_errno($handle);
            $message = curl_error($handle);
            curl_close($handle);

            $transient = in_array($number, [
                CURLE_OPERATION_TIMEDOUT,
                CURLE_COULDNT_CONNECT,
                CURLE_COULDNT_RESOLVE_HOST,
                CURLE_SEND_ERROR,
                CURLE_RECV_ERROR,
            ], true);

            throw new TransportException(
                "Screenshot transport failed: {$message}",
                $transient
            );
        }

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

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

Мапирајте PNG одговори и повторувајте безбедно

Клиентот прави најмногу три обиди. Повторува при привремени транспортни грешки, HTTP 429 одговори и серверски 5xx одговори. Неуспесите при валидација и автентикација се враќаат веднаш бидејќи друго идентично барање нема да ги поправи.

Нумеричко заглавие Retry-After се почитува кога е присутно и се ограничува на десет секунди. Во спротивно, повторните обиди користат ограничено експоненцијално повлекување. Успешните тела мора да имаат и тип на содржина image/png и PNG потпис; ова спречува HTML-страница со грешка да биде архивирана како доказ.

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

final readonly class CaptureResult
{
    public function __construct(
        public string $png,
        public array $headers,
        public int $attempts
    ) {}
}

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

final class ScreenshotClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';

    private \Closure $sleep;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly array $allowedHosts,
        ?\Closure $sleep = null
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Missing screenshot token');
        }

        $this->sleep = $sleep ?? static fn(int $microseconds) =>
            usleep($microseconds);
    }

    public function capture(string $target): CaptureResult
    {
        $parts = parse_url($target);
        $host = strtolower($parts['host'] ?? '');

        if (
            filter_var($target, FILTER_VALIDATE_URL) === false ||
            ($parts['scheme'] ?? '') !== 'https' ||
            !in_array($host, $this->allowedHosts, true) ||
            isset($parts['user']) ||
            isset($parts['pass'])
        ) {
            throw new \InvalidArgumentException('Target URL is not allowed');
        }

        $url = self::ENDPOINT . '?' . http_build_query(
            ['url' => $target],
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get($url, [
                    'Authorization: Bearer ' . $this->token,
                    'Accept: image/png',
                ], 5, 30);
            } catch (TransportException $error) {
                if (!$error->transient || $attempt === 3) {
                    throw new ScreenshotException($error->getMessage());
                }

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

            if ($response->status === 200) {
                $type = strtolower($response->header('content-type') ?? '');
                $signature = "\x89PNG\r\n\x1a\n";

                if (
                    !str_starts_with($type, 'image/png') ||
                    !str_starts_with($response->body, $signature)
                ) {
                    throw new ScreenshotException(
                        'Capture returned an invalid PNG',
                        200
                    );
                }

                return new CaptureResult(
                    $response->body,
                    $response->headers,
                    $attempt
                );
            }

            $retryable = $response->status === 429 ||
                ($response->status >= 500 && $response->status <= 599);

            if (!$retryable || $attempt === 3) {
                throw new ScreenshotException(
                    "Capture failed with HTTP {$response->status}",
                    $response->status
                );
            }

            $retryAfter = trim($response->header('retry-after') ?? '');
            $delay = ctype_digit($retryAfter)
                ? min(10, (int) $retryAfter) * 1_000_000
                : min(2_000_000, 250_000 * (2 ** ($attempt - 1)));

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

        throw new ScreenshotException('Capture attempts exhausted');
    }
}

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

Командата користи идентификатор на изданието и фаза before или after. Секоја фаза се запишува во привремен директориум и се преименува на своето место само откако и сликата и метаподатоците се трајно зачувани. Постоечка фаза никогаш не се презапишува.

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

use App\CurlTransport;
use App\ScreenshotClient;

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

Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
umask(0027);

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

if (
    !in_array($phase, ['before', 'after'], true) ||
    !is_string($release) ||
    preg_match('/^[a-zA-Z0-9._-]{1,80}$/', $release) !== 1 ||
    !is_string($target)
) {
    fwrite(STDERR, "Usage: php bin/snapshot before|after RELEASE URL\n");
    exit(64);
}

$hosts = array_values(array_filter(array_map(
    static fn(string $host): string => strtolower(trim($host)),
    explode(',', $_ENV['SNAPSHOT_ALLOWED_HOSTS'] ?? '')
)));

try {
    $client = new ScreenshotClient(
        new CurlTransport(),
        $_ENV['SCREENSHOT_TOKEN'] ?? '',
        $hosts
    );

    $capture = $client->capture($target);
    $base = dirname(__DIR__) . "/var/snapshots/{$release}";
    $final = "{$base}/{$phase}";

    if (file_exists($final)) {
        throw new RuntimeException("Snapshot phase already exists: {$phase}");
    }

    if (!is_dir($base) && !mkdir($base, 0750, true) && !is_dir($base)) {
        throw new RuntimeException('Cannot create snapshot directory');
    }

    $temporary = $base . '/.' . $phase . '-' . bin2hex(random_bytes(6));

    if (!mkdir($temporary, 0750)) {
        throw new RuntimeException('Cannot create staging directory');
    }

    $metadata = [
        'release' => $release,
        'phase' => $phase,
        'target' => $target,
        'captured_at' => gmdate(DATE_ATOM),
        'sha256' => hash('sha256', $capture->png),
        'bytes' => strlen($capture->png),
        'attempts' => $capture->attempts,
        'response_headers' => $capture->headers,
    ];

    file_put_contents(
        "{$temporary}/image.png",
        $capture->png,
        LOCK_EX | FILE_BINARY
    );
    file_put_contents(
        "{$temporary}/metadata.json",
        json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
        LOCK_EX
    );
    chmod("{$temporary}/image.png", 0640);
    chmod("{$temporary}/metadata.json", 0640);

    if (!rename($temporary, $final)) {
        throw new RuntimeException('Cannot publish snapshot atomically');
    }

    fwrite(STDOUT, json_encode([
        'event' => 'snapshot_captured',
        'release' => $release,
        'phase' => $phase,
        'host' => parse_url($target, PHP_URL_HOST),
        'attempts' => $capture->attempts,
        'sha256' => $metadata['sha256'],
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $error) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot_failed',
        'release' => $release,
        'phase' => $phase,
        'error_type' => $error::class,
        'message' => $error->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

Дневниците намерно ги исклучуваат токенот, заглавието за авторизација и телото на одговорот. Метаподатоците ги задржуваат заглавијата на одговорот за да може однесувањето на кешот и преостанатата квота да се проверат користејќи ги имињата документирани за активниот сервисен план.

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

Детерминистички лажен транспорт ги прави патеките на неуспех брзи и повторливи. Овие тестови докажуваат дека неуспех на серверот се повторува, неуспех на автентикацијата не се повторува и одговор што не е PNG се отфрла.

<?php
// tests/ScreenshotClientTest.php
namespace Tests;

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

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

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $headers,
        int $connectTimeout,
        int $responseTimeout
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class ScreenshotClientTest extends TestCase
{
    private const PNG = "\x89PNG\r\n\x1a\nfake";

    public function testRetriesServerFailureThenMapsPng(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(503, '', []),
            new HttpResponse(200, self::PNG, [
                'content-type' => ['image/png'],
                'x-cache' => ['HIT'],
            ]),
        ]);
        $sleeps = [];

        $client = new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (int $delay) use (&$sleeps): void {
                $sleeps[] = $delay;
            }
        );

        $result = $client->capture('https://client.example/');

        self::assertInstanceOf(CaptureResult::class, $result);
        self::assertSame(2, $result->attempts);
        self::assertSame(2, $transport->calls);
        self::assertSame([250_000], $sleeps);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(401, 'unauthorized', []),
        ]);
        $client = new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (): void {}
        );

        try {
            $client->capture('https://client.example/');
            self::fail('Expected ScreenshotException');
        } catch (ScreenshotException $error) {
            self::assertSame(401, $error->status);
            self::assertSame(1, $transport->calls);
        }
    }

    public function testRejectsNonPngSuccessBody(): void
    {
        $this->expectException(ScreenshotException::class);

        $client = new ScreenshotClient(
            new FakeTransport([
                new HttpResponse(200, '<html>error</html>', [
                    'content-type' => ['text/html'],
                ]),
            ]),
            'test-token',
            ['client.example']
        );

        $client->capture('https://client.example/');
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="snapshot-tests">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

Поврзете го со распоредувањето

composer test

php bin/snapshot before release-2026-08-29 https://client.example/

# Run the existing deployment and wait for its health check to pass.

php bin/snapshot after release-2026-08-29 https://client.example/

Направете го првото снимање предуслов за распоредувањето. Ако не успее, одлучете изречно дали изданието смее да продолжи. Извршете го второто снимање само откако јавната URL-адреса е подготвена; во спротивно, тоа документира меѓусостојба наместо завршеното ажурирање.

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

Следете структурирани настани snapshot_failed, повторени обиди, HTTP 429 одговори и промени во зачуваните заглавија за кеш или квота. Алармирањето за секое промашување на кешот обично е шум; постојан притисок врз квотата или недостиг од снимања по промената има оперативно значење.

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

  • HTTP 401 или 403: потврдете го токенот ограничен на услугата и проверете дека повторно генерираниот токен е распореден насекаде. Не повторувајте автоматски.
  • HTTP 429: проверете ги заглавијата за квота, почитувајте го Retry-After кога е доставен и намалете ги дупликатните снимања наместо да додавате неограничени повторни обиди.
  • Неважечки PNG: зачувајте го настанот за неуспех и истражете ги статусот, типот на содржина, пристапноста на целта и тековната документација за услугата. Никогаш не го зачувувајте телото како слика.
  • Целната URL-адреса не е дозволена: додајте го точно предвидениот hostname во SNAPSHOT_ALLOWED_HOSTS; не ја оневозможувајте валидацијата.
  • Снимката по промената неочекувано се разликува: потврдете дека проверката на здравјето чекала за кешовите, средствата и предвидениот продукциски hostname пред снимањето.

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

  • Токенот постои само во конфигурација поддржана од околински променливи и складиште за тајни.
  • Точната GET крајна точка прима еден кодиран параметар за пребарување url.
  • Двете снимања се отвораат како важечки PNG-датотеки.
  • Директориумите пред и по промената го делат истиот идентификатор на изданието.
  • Метаподатоците содржат временски ознаки, хешови, обиди и вратени заглавија на одговорот.
  • Неуспесите на автентикацијата и валидацијата не се повторуваат.
  • Временските ограничувања, 429 одговорите и 5xx одговорите имаат ограничено однесување при повторување.
  • Складиштето за снимки и дневниците не откриваат сервисен токен.
  • Продукциските артефакти преживуваат замена на хостот за распоредување.

Пар снимки од екранот е едноставен, но неговата вредност произлегува од дисциплината: снимајте ја вистинската јавна страница во вистинските моменти, зачувајте доволно докази за да ги објасните неуспесите и направете го процесот повторлив. Со овие заштитни мерки, секое ажурирање на веб-страница добива визуелна потврда — таква на која е многу полесно да ѝ се верува отколку на меморијата, набрзина направена проверка во прелистувач или порака со надеж „изгледа добро“.

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

Mihajlo

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