Туториали

Native PHP 8.3: Archive Important Web Pages Weekly with Screenshot API

Нативен PHP 8.3: Архивирајте важни веб-страници неделно со Screenshot API

Веб-страницата може тивко да се промени. Добавувач може да уреди страница за производ, копче за резервација може да исчезне или редизајн може да измени промоција што требало да остане видлива цел месец. Резервните копии зачувуваат датотеки и бази на податоци, но не покажуваат што навистина видел клиентот.

Овој проект гради мала архива подготвена за продукциска употреба, која ја снима секоја важна страница еднаш неделно според ISO. Користи Native PHP 8.3, изворен cURL, детерминистички повторни обиди, атомско складирање, структурирани метаподатоци и идемпотентна закажана команда. Резултатот е прелистлива визуелна историја без користење на Chromium, двигатели за прелистувачи или кластер за рендерирање.

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

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

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

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

Потврдете ја крајната точка пред да ја напишете апликацијата

Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, при што целната страница се доставува во задолжителниот параметар за барање url. Успешниот одговор содржи тело 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' \
  --data-urlencode 'url=https://www.example.com/' \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --dump-header response-headers.txt \
  --output example.png

file example.png

Проверете го response-headers.txt наместо да претпоставувате одредени имиња на заглавија за кеш или квота. Документираниот договор бара обработка на тие заглавија, но кодот на апликацијата треба да го нормализира и зачува она што услугата навистина го враќа.

Создадете локална датотека .env, исклучете ја од контрола на верзии и ограничете ја на сметката што ја извршува командата:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_ARCHIVE_DIR=/srv/page-archive/data

Изберете намерно мала архитектура

CLI-командата е подобра граница од веб-контролер во овој случај. Снимањата се закажана работа, може да траат подолго од интерактивно барање и не смеат да станат јавен прокси за снимки од екранот. Посветен API-клиент е задолжен за автентикација, временски ограничувања, повторни обиди, валидација на одговорите и мапирање на доменот. Командата е задолжена за избор на страници, неделна идемпотентност, евидентирање и складирање.

Архивата користи една PNG-датотека и еден JSON-манифест по страница и ISO-недела. Манифестот ги запишува статусот, бројот на обиди и вратените метаподатоци за кешот или квотата. Никогаш не ги складира токенот или телото на неуспешен одговор.

page-archive/
├── bin/archive.php
├── config/pages.php
├── src/Http.php
├── src/ScreenshotClient.php
├── tests/ScreenshotClientTest.php
├── .env
└── composer.json

Користете Composer само за автоматско вчитување и PHPUnit. HTTP при извршување останува изворен:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}

Изградете заменлива HTTP-граница

Транспортот извршува еден мрежен обид. Политиката за повторни обиди припаѓа на клиентот на повисоко ниво, каде што се достапни HTTP-статусот и значењето во доменот. Оневозможувањето на пренасочувањата спречува неочекувано пренасочување на крајна точка да го пренесе заглавието Authorization на друго место.

<?php
// src/Http.php
declare(strict_types=1);

namespace App;

final readonly class HttpResponse
{
    /** @param array<string, list<string>> $headers */
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

final class TransportException extends \RuntimeException {}

interface Transport
{
    /** @param list<string> $headers */
    public function get(
        string $url,
        array $headers,
        float $connectTimeout,
        float $timeout,
    ): HttpResponse;
}

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

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

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
            CURLOPT_TIMEOUT_MS => (int) ($timeout * 1000),
            CURLOPT_HEADERFUNCTION => static function (
                \CurlHandle $handle,
                string $line
            ) use (&$received): int {
                $length = strlen($line);

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

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            throw new TransportException(
                'Screenshot transport failed: ' . curl_error($handle)
            );
        }

        return new HttpResponse(
            (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $received,
            $body,
        );
    }
}

Мапирајте ги HTTP-одговорите во исходи на доменот

Клиентот ги проверува и Content-Type и PNG-потписот. Тоа е важно бидејќи HTML-страница со грешка од надреден систем инаку би можела да се архивира со погрешна наставка .png.

Се повторуваат само мрежни грешки, HTTP 429 и одговори 5xx. Грешките при автентикација, валидација и другите клиентски грешки се враќаат веднаш. Одложувањата се ограничени, а целобројната вредност Retry-After се почитува до пет секунди.

<?php
// src/ScreenshotClient.php
declare(strict_types=1);

namespace App;

enum FailureKind: string
{
    case Authentication = 'authentication';
    case Validation = 'validation';
    case Quota = 'quota';
    case Upstream = 'upstream';
    case Network = 'network';
    case InvalidResponse = 'invalid_response';
}

final readonly class CaptureOutcome
{
    /** @param array<string, list<string>> $metadata */
    public function __construct(
        public ?string $png,
        public ?FailureKind $failure,
        public ?int $status,
        public int $attempts,
        public array $metadata,
        public string $message,
    ) {}

    public function succeeded(): bool
    {
        return $this->png !== null;
    }
}

final class ScreenshotClient
{
    private \Closure $sleep;

    public function __construct(
        private readonly string $token,
        private readonly Transport $transport,
        ?\Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Screenshot token is missing');
        }

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

    public function capture(string $pageUrl): CaptureOutcome
    {
        $parts = parse_url($pageUrl);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));

        if (
            filter_var($pageUrl, FILTER_VALIDATE_URL) === false
            || !in_array($scheme, ['http', 'https'], true)
            || isset($parts['user'])
            || isset($parts['pass'])
        ) {
            return new CaptureOutcome(
                null,
                FailureKind::Validation,
                null,
                0,
                [],
                'Configured page URL is invalid'
            );
        }

        $endpoint = 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?'
            . http_build_query(['url' => $pageUrl], '', '&', PHP_QUERY_RFC3986);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    $endpoint,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: image/png',
                    ],
                    5.0,
                    30.0,
                );
            } catch (TransportException $exception) {
                if ($attempt < 3) {
                    ($this->sleep)($attempt === 1 ? 250000 : 1000000);
                    continue;
                }

                return new CaptureOutcome(
                    null,
                    FailureKind::Network,
                    null,
                    $attempt,
                    [],
                    $exception->getMessage()
                );
            }

            $metadata = $this->responseMetadata($response->headers);

            if ($response->status >= 200 && $response->status < 300) {
                $type = strtolower($response->headers['content-type'][0] ?? '');
                $isPng = str_starts_with($type, 'image/png')
                    && str_starts_with($response->body, "\x89PNG\r\n\x1a\n");

                return $isPng
                    ? new CaptureOutcome(
                        $response->body,
                        null,
                        $response->status,
                        $attempt,
                        $metadata,
                        'capture_complete'
                    )
                    : new CaptureOutcome(
                        null,
                        FailureKind::InvalidResponse,
                        $response->status,
                        $attempt,
                        $metadata,
                        'Successful response was not a valid PNG'
                    );
            }

            $retryable = $response->status === 429
                || $response->status >= 500;

            if ($retryable && $attempt < 3) {
                $seconds = $attempt === 1 ? 0.25 : 1.0;
                $retryAfter = $response->headers['retry-after'][0] ?? null;

                if (is_string($retryAfter) && ctype_digit($retryAfter)) {
                    $seconds = min(5.0, (float) $retryAfter);
                }

                ($this->sleep)((int) ($seconds * 1000000));
                continue;
            }

            $kind = match (true) {
                in_array($response->status, [401, 403], true)
                    => FailureKind::Authentication,
                $response->status === 429 => FailureKind::Quota,
                $response->status >= 500 => FailureKind::Upstream,
                $response->status >= 400 && $response->status < 500
                    => FailureKind::Validation,
                default => FailureKind::InvalidResponse,
            };

            return new CaptureOutcome(
                null,
                $kind,
                $response->status,
                $attempt,
                $metadata,
                'Screenshot request failed'
            );
        }

        throw new \LogicException('Retry loop ended unexpectedly');
    }

    /** @return array<string, list<string>> */
    private function responseMetadata(array $headers): array
    {
        return array_filter(
            $headers,
            static fn (string $name): bool =>
                preg_match(
                    '/cache|quota|rate.?limit|^age$|^etag$|^expires$|^retry-after$/i',
                    $name
                ) === 1,
            ARRAY_FILTER_USE_KEY,
        );
    }
}

Запишете една архива по страница и недела

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

<?php
// config/pages.php
return [
    'home' => 'https://www.example.com/',
    'services' => 'https://www.example.com/services',
    'booking' => 'https://www.example.com/book',
];

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

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

use App\CurlTransport;
use App\ScreenshotClient;

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

$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
    $values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
    if ($values === false) {
        throw new RuntimeException('Cannot parse .env');
    }
    foreach ($values as $name => $value) {
        if (getenv((string) $name) === false) {
            putenv($name . '=' . $value);
        }
    }
}

$token = (string) getenv('SCREENSHOT_API_TOKEN');
$root = (string) getenv('SCREENSHOT_ARCHIVE_DIR');
if ($token === '' || $root === '') {
    throw new RuntimeException('Required environment configuration is missing');
}

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

$lock = fopen($root . '/.weekly.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
    throw new RuntimeException('Another archive process is running');
}

$writeAtomic = static function (string $path, string $contents): void {
    $directory = dirname($path);
    if (!is_dir($directory) && !mkdir($directory, 0750, true)) {
        throw new RuntimeException('Cannot create page directory');
    }

    $temporary = tempnam($directory, '.capture-');
    if ($temporary === false) {
        throw new RuntimeException('Cannot allocate temporary file');
    }

    if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
        throw new RuntimeException('Cannot write temporary file');
    }

    chmod($temporary, 0640);
    if (!rename($temporary, $path)) {
        throw new RuntimeException('Cannot commit archive file');
    }
};

$log = static function (array $context): void {
    $record = ['time' => gmdate(DATE_ATOM)] + $context;
    fwrite(STDERR, json_encode(
        $record,
        JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
    ) . PHP_EOL);
};

$client = new ScreenshotClient($token, new CurlTransport());
$pages = require dirname(__DIR__) . '/config/pages.php';
$week = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
    ->format('o-\WW');
$failed = false;

foreach ($pages as $slug => $url) {
    if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) {
        throw new RuntimeException('Unsafe page slug in configuration');
    }

    $base = $root . '/' . $slug . '/' . $week;
    $pngPath = $base . '.png';
    $manifestPath = $base . '.json';

    if (is_file($pngPath) && is_file($manifestPath)) {
        $log(['event' => 'capture_skipped', 'page' => $slug, 'week' => $week]);
        continue;
    }

    $outcome = $client->capture($url);
    $manifest = [
        'state' => $outcome->succeeded() ? 'complete' : 'failed',
        'page' => $slug,
        'url' => $url,
        'week' => $week,
        'captured_at' => gmdate(DATE_ATOM),
        'http_status' => $outcome->status,
        'attempts' => $outcome->attempts,
        'failure' => $outcome->failure?->value,
        'response_metadata' => $outcome->metadata,
    ];

    $writeAtomic(
        $manifestPath,
        json_encode(
            $manifest,
            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
        )
    );

    if ($outcome->succeeded()) {
        $writeAtomic($pngPath, $outcome->png);
        $log([
            'event' => 'capture_complete',
            'page' => $slug,
            'week' => $week,
            'bytes' => strlen($outcome->png),
            'attempts' => $outcome->attempts,
        ]);
    } else {
        $failed = true;
        $log([
            'event' => 'capture_failed',
            'page' => $slug,
            'week' => $week,
            'kind' => $outcome->failure?->value,
            'status' => $outcome->status,
            'attempts' => $outcome->attempts,
        ]);
    }
}

exit($failed ? 1 : 0);

Тестирајте ги повторните обиди без да правите надворешни повици

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

<?php
// tests/ScreenshotClientTest.php
declare(strict_types=1);

use App\CaptureOutcome;
use App\FailureKind;
use App\HttpResponse;
use App\ScreenshotClient;
use App\Transport;
use PHPUnit\Framework\TestCase;

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

    /** @param list<HttpResponse> $responses */
    public function __construct(private array $responses) {}

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

final class ScreenshotClientTest extends TestCase
{
    public function testRetriesServerFailureAndReturnsPng(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";
        $transport = new FakeTransport([
            new HttpResponse(503, ['retry-after' => ['0']], ''),
            new HttpResponse(
                200,
                [
                    'content-type' => ['image/png'],
                    'cache-control' => ['public'],
                ],
                $png
            ),
        ]);

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

        self::assertTrue($result->succeeded());
        self::assertSame($png, $result->png);
        self::assertSame(2, $result->attempts);
        self::assertSame(2, $transport->calls);
        self::assertArrayHasKey('cache-control', $result->metadata);
    }

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

        $client = new ScreenshotClient(
            'expired-token',
            $transport,
            static fn (int $microseconds) => null
        );
        $result = $client->capture('https://www.example.com/');

        self::assertSame(FailureKind::Authentication, $result->failure);
        self::assertSame(1, $transport->calls);
    }
}

Извршете composer install, потоа composer test. Тестовите содржат очигледно синтетички токен и никогаш не им е потребен вистинскиот акредитив за услугата.

Закажете за опоравување, а не само за точност

Дневен тајмер може да звучи необично за неделна архива, но ISO-неделниот клуч на командата го прави корисен: по првото успешно извршување, следните извршувања ја прескокнуваат страницата. Ако снимањето во понеделник не успее поради привремен прекин или исцрпена квота, вторник може автоматски да ја поправи недостасувачката недела.

# /etc/systemd/system/page-archive.service
[Unit]
Description=Capture weekly page archive

[Service]
Type=oneshot
User=pagearchive
Group=pagearchive
WorkingDirectory=/srv/page-archive
EnvironmentFile=/srv/page-archive/.env
ExecStart=/usr/bin/php /srv/page-archive/bin/archive.php
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/page-archive/data

# /etc/systemd/system/page-archive.timer
[Unit]
Description=Check weekly page archive daily

[Timer]
OnCalendar=*-*-* 03:15:00 UTC
Persistent=true
RandomizedDelaySec=20m

[Install]
WantedBy=timers.target

Испраќајте ги JSON-дневниците на командата до вообичаениот собирач на дневници на платформата и поставете предупредување за излези на услугата различни од нула или повторени настани capture_failed. Следете ја големината на зачуваните слики и метаподатоците за квотата за неочекувани промени, но не евидентирајте заглавија за авторизација, тела на одговори или токенот.

Вообичаени неуспеси и оперативни заштитни мерки

  • 401 или 403: проверете ги токенот ограничен на услугата и околината на распоредувањето. Ако бил повторно генериран, стариот токен е веќе поништен. Не ги повторувајте автоматски овие одговори.
  • 429: проверете ги зачуваните метаподатоци за квотата и повторниот обид, разгледајте го капацитетот на планот и избегнувајте додавање агресивни повторни обиди.
  • Грешки при валидација од класата 400: проверете ја конфигурираната URL-адреса и нејзиното кодирање. Клиентот веќе користи RFC 3986 кодирање на параметри за барање.
  • Невалиден PNG: третирајте го како неуспех на договорот со надредениот систем. Никогаш не архивирајте HTML или JSON тело како слика.
  • Временски ограничувања или 5xx одговори: ограничените повторни обиди се соодветни, но трајните неуспеси треба да останат видливи преку излезниот статус и манифестот.
  • Недостасувачки датотеки: проверете ги сопственоста на директориумот, слободниот простор и дозволената листа за запишување во systemd. Чувајте го складиштето на архивата надвор од јавниот веб-корен.

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

Конечна контролна листа за верификација

  • Вистинскиот токен постои само во конфигурација поддржана од околината.
  • Минималното барање произведува валиден PNG и ги изложува заглавијата на одговорот за проверка.
  • composer test поминува без мрежен пристап.
  • Рачно извршување на командата создава соодветни неделни датотеки .png и .json за секоја конфигурирана страница.
  • Второто извршување ги прескокнува завршените снимања наместо да троши повеќе квота.
  • Намерно невалиден тест-токен создава структурирана грешка при автентикација без повторни обиди или откриена содржина на одговор.
  • Тајмерот е овозможен, дневниците се собираат и излезите различни од нула се надгледуваат.
  • Архивските датотеки се читливи само за овластени оператори и имаат резервни копии според нивната вредност.

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

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

Mihajlo

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