Vodiči

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

Nativni PHP 8.3: Arhivirajte važne web-stranice tjedno pomoću Screenshot API-ja

Web-stranica se može neprimjetno promijeniti. Dobavljač izmijeni stranicu proizvoda, gumb za rezervaciju nestane ili redizajn promijeni promociju koja je trebala ostati vidljiva cijeli mjesec. Sigurnosne kopije čuvaju datoteke i baze podataka, ali ne pokazuju što je kupac zapravo vidio.

Ovaj projekt izrađuje malu arhivu spremnu za produkciju koja snima svaku važnu stranicu jednom u svakom ISO tjednu. Upotrebljava izvorni PHP 8.3, izvorni cURL, deterministička ponavljanja pokušaja, atomsku pohranu, strukturirane metapodatke i idempotentnu zakazanu naredbu. Rezultat je pregledna vizualna povijest bez pokretanja Chromiuma, upravljačkih programa preglednika ili klastera za renderiranje.

Dobijte pristup Screenshot API-ju

Započnite registracijom računa ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Screenshot API.
  2. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Pohranite taj token u konfiguraciju okruženja projekta, nikada u izvorni PHP kôd.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi Bearer token kako se vjerodajnica ne bi pojavila u URL-ovima, zapisnicima pristupa proxyju ili povijesti ljuske. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga se implementacije tijekom rotacije moraju ažurirati zajedno.

Potvrdite krajnju točku prije pisanja aplikacije

Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, pri čemu se ciljana stranica navodi u obveznom parametru upita url. Uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.

Napravite minimalni test, zadržavajući token u varijabli okruženja:

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

Pregledajte response-headers.txt umjesto da pretpostavljate određene nazive zaglavlja predmemorije ili kvote. Dokumentirani ugovor zahtijeva rukovanje tim zaglavljima, ali aplikacijski kôd trebao bi normalizirati i sačuvati ono što usluga stvarno vrati.

Izradite lokalnu datoteku .env, isključite je iz kontrole verzija i ograničite je na račun koji pokreće naredbu:

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

Odaberite namjerno malu arhitekturu

CLI naredba ovdje je bolja granica od web-kontrolera. Snimanja su zakazani poslovi, mogu trajati dulje od interaktivnog zahtjeva i ne smiju postati javni proxy za snimke zaslona. Namjenski API klijent upravlja autentikacijom, vremenskim ograničenjima, ponavljanjem pokušaja, provjerom odgovora i mapiranjem domene. Naredba upravlja odabirom stranica, tjednom idempotentnošću, zapisivanjem i pohranom.

Arhiva koristi jedan PNG i jedan JSON manifest po stranici i ISO tjednu. Manifest bilježi status, broj pokušaja i vraćene metapodatke predmemorije ili kvote. Nikada ne pohranjuje token ni tijelo neuspjelog odgovora.

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

Composer upotrebljavajte samo za automatsko učitavanje i PHPUnit. HTTP tijekom rada ostaje izvorni:

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

Izradite zamjenjivu HTTP granicu

Transport obavlja jedan mrežni pokušaj. Politika ponavljanja pokušaja pripada klijentu više razine, gdje su dostupni HTTP status i značenje domene. Onemogućavanje preusmjeravanja sprječava da neočekivano preusmjeravanje krajnje točke prenese zaglavlje Authorization na drugo mjesto.

<?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,
        );
    }
}

Mapirajte HTTP odgovore u ishode domene

Klijent provjerava i Content-Type i PNG potpis. To je važno jer se HTML stranica pogreške uzvodne usluge inače može arhivirati s obmanjujućim nastavkom .png.

Ponavljaju se samo mrežne pogreške, HTTP 429 i odgovori 5xx. Autentikacija, provjera valjanosti i druge klijentske pogreške vraćaju se odmah. Odgode su ograničene, a cjelobrojna vrijednost Retry-After poštuje se do pet sekundi.

<?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,
        );
    }
}

Napišite jednu arhivu po stranici i tjednu

Inventar stranica držite u pouzdanoj konfiguraciji. Nemojte prihvaćati proizvoljne URL-ove iz HTTP zahtjeva jer bi to izložilo kvotu računa i moglo pretvoriti aplikaciju u proxy za renderiranje.

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

Naredba učitava jednostavne lokalne vrijednosti okruženja kada ih upravitelj procesa već nije postavio. Dobiva isključivu blokadu, preskače dovršene tjedne, nastavlja nakon neuspjeha pojedinačnih stranica i završava s kodom različitim od nule ako bilo koje snimanje ne uspije. Datoteke se zapisuju preko privremenih datoteka u odredišnom direktoriju, pa je svako preimenovanje atomsko na istom datotečnom sustavu.

<?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);

Testirajte ponavljanja pokušaja bez vanjskih poziva

Lažni transport čini putanje neuspjeha brzima i determinističkima. Ovi testovi dokazuju da se prolazni neuspjeh poslužitelja ponavlja, dok se neuspjeh autentikacije ne ponavlja. Također provjeravaju PNG granicu umjesto da samo potvrđuju statusni kôd.

<?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);
    }
}

Pokrenite composer install, zatim composer test. Testovi sadrže očito sintetički token i nikada ne trebaju stvarnu vjerodajnicu usluge.

Rasporedite za oporavak, ne samo za točnost vremena

Dnevni mjerač vremena može zvučati neobično za tjednu arhivu, ali ISO-tjedni ključ naredbe čini ga korisnim: nakon prvog uspješnog pokretanja, sljedeća izvršavanja preskaču stranicu. Ako ponedjeljkovo snimanje ne uspije zbog privremenog prekida rada ili iscrpljene kvote, utorak može automatski popraviti nedostajući tjedan.

# /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

Pošaljite JSON zapisnike naredbe u uobičajeni sakupljač zapisnika platforme i postavite upozorenja za izlaze usluge s kodom različitim od nule ili ponovljene događaje capture_failed. Pratite veličinu pohranjenih slika i metapodatke kvote radi neočekivanih promjena, ali nemojte bilježiti autorizacijska zaglavlja, tijela odgovora ni token.

Uobičajeni neuspjesi i operativne zaštitne mjere

  • 401 ili 403: provjerite token ograničen na uslugu i okruženje implementacije. Ako je ponovno generiran, stari token već je opozvan. Nemojte automatski ponavljati te odgovore.
  • 429: pregledajte sačuvane metapodatke kvote i ponovnog pokušaja, provjerite kapacitet plana i izbjegavajte dodavanje agresivnih ponavljanja pokušaja.
  • pogreške provjere valjanosti klase 400: provjerite konfigurirani URL i njegovo kodiranje. Klijent već koristi kodiranje upita RFC 3986.
  • Nevaljani PNG: tretirajte ga kao neuspjeh ugovora uzvodne usluge. Nikada ne arhivirajte HTML ili JSON tijelo kao sliku.
  • Vremenska ograničenja ili odgovori 5xx: ograničena ponavljanja pokušaja prikladna su, ali trajni neuspjesi trebaju ostati vidljivi kroz izlazni status i manifest.
  • Nedostajuće datoteke: provjerite vlasništvo direktorija, slobodan prostor i systemd popis dopuštenog pisanja. Pohranu arhive držite izvan javnog korijena weba.

Zaštitite .env restriktivnim dozvolama, rotirajte tokene putem ploče usluge i spremišta tajni implementacije te čuvajte snimke zaslona prema stvarnim potrebama poslovanja. Snimke zaslona mogu sadržavati imena kupaca, neobjavljene ponude, stanja računa ili drugi osjetljivi materijal čak i kada se izvorna stranica činila bezopasnom.

Završni kontrolni popis za provjeru

  • Stvarni token postoji samo u konfiguraciji podržanoj okruženjem.
  • Minimalni zahtjev daje valjani PNG i izlaže zaglavlja odgovora za pregled.
  • composer test prolazi bez pristupa mreži.
  • Ručno pokretanje naredbe izrađuje odgovarajuće tjedne datoteke .png i .json za svaku konfiguriranu stranicu.
  • Drugo pokretanje preskače dovršena snimanja umjesto da troši dodatnu kvotu.
  • Namjerno nevaljani testni token proizvodi strukturirani neuspjeh autentikacije bez ponavljanja pokušaja ili otkrivenog sadržaja odgovora.
  • Mjerač vremena je omogućen, zapisnici se prikupljaju, a izlazi s kodom različitim od nule nadziru se.
  • Datoteke arhive čitljive su samo ovlaštenim operaterima i sigurnosno se kopiraju prema svojoj vrijednosti.

Najkorisnija arhiva nije najrazrađenija. To je ona koja radi tiho, dokazuje što su kupci mogli vidjeti i ne uspijeva dovoljno glasno da se može popraviti. Uz usku API granicu, obrambenu provjeru valjanosti PNG-a, metapodatke svjesne kvote i idempotentni tjedni ključ, nekoliko stranica postaje pouzdan vizualni zapis umjesto još jednog krhkog projekta automatizacije preglednika.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.