Vodiči

Native PHP 8.3: Automate Client Website Before/After Snapshots with Screenshot API

Izvorni PHP 8.3: Automatizirajte snimke zaslona web-mjesta klijenata prije/poslije pomoću Screenshot API-ja

Ažuriranje web-stranice može skripti za implementaciju izgledati potpuno ispravno, a pritom tiho pokvariti stranicu koju korisnici zapravo vide. Nedostajući stilski predložak, prevelik banner ili regresija mobilne navigacije i dalje mogu vratiti HTTP 200. Vizualna kontrolna točka zatvara taj jaz.

Ovaj vodič izrađuje produkcijski orijentiranu naredbu u izvornom PHP-u 8.3 koja snima web-stranicu klijenta neposredno prije i nakon ažuriranja. Koristi Screenshot API za dobivanje predmemoriranih PNG snimki zaslona za stolna računala ili mobilne uređaje bez upravljanja infrastrukturom Chromiuma. Snimke se provjeravaju, atomski zapisuju, prate metapodacima te su zaštićene ograničenim ponovnim pokušajima i popisom dopuštenih naziva hostova.

Dobijte pristup Screenshot API-ju

Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.

  1. Otvorite stranicu usluge Screenshot API.
  2. Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju Screenshot API-ja.
  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.

Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer je vjerojatnije da će se vjerodajnice u nizovima upita pojaviti u zapisnicima pristupa i sustavima za nadzor.

Ponovno generiranje tokena usluge opoziva prethodno aktivan token. Rotaciju tretirajte kao koordiniranu implementaciju: ažurirajte tajnu tijekom izvođenja, ponovno pokrenite ili ponovno implementirajte aplikaciju, provjerite snimku i tek tada smatrajte rotaciju dovršenom.

Potvrdite točan HTTP ugovor

Operacija snimanja je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njezin obvezni parametar upita url određuje stranicu za snimanje. Uspješan odgovor sadržava tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.

Testirajte token prije pisanja aplikacijskog kôda:

curl --fail-with-body --silent --show-error --get \
  --url 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://www.client.example/' \
  --dump-header capture.headers \
  --output capture.png

file capture.png

Umjesto primjera upotrijebite stvarni, javno dostupan URL klijenta. Tijekom testiranja zadržite capture.headers kako biste mogli pregledati zaglavlja predmemorije i kvote vraćena za vaš paket, bez pretpostavljanja nedokumentiranih naziva.

Pohranite konfiguraciju izvan baze kôda

Izradite lokalnu datoteku .env i isključite je iz kontrole verzija. Izvorni PHP ne učitava automatski ovu datoteku; pokretač ljuske u nastavku izvozi je prije pokretanja PHP-a.

SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'
SCREENSHOT_TARGET_URL='https://www.client.example/'
SCREENSHOT_ALLOWED_HOSTS='www.client.example'
SCREENSHOT_OUTPUT_DIR='var/snapshots'

U produkciji iste varijable unesite putem upravitelja procesa, mehanizma tajni spremnika ili platforme za implementaciju umjesto da predajete datoteku okruženja.

Arhitektura i struktura projekta

Omotač implementacije izvršava snimanje prije implementacije, pokreće postojeću naredbu za implementaciju, a zatim izvršava snimanje nakon implementacije. PHP granica namjerno je uska: transport upravlja cURL-om, klijent preslikava HTTP odgovore u rezultat domene, a CLI naredba upravlja trajnim spremanjem u datotečni sustav.

website-snapshots/
├── bin/
│   ├── snapshot.php
│   └── release-with-snapshots
├── src/
│   └── Screenshot.php
├── tests/
│   └── ScreenshotClientTest.php
├── var/
│   └── snapshots/
├── composer.json
└── .env

Ovaj sinkroni dizajn čini sliku prije implementacije uvjetom implementacije: ako se ne može snimiti, ažuriranje ne počinje. Cijena toga je nekoliko sekundi kašnjenja implementacije. Za uobičajenu stranicu klijenta to je obično bolje od tihog stvaranja nepotpunog vizualnog zapisa.

Instalirajte minimalne ovisnosti

Produkcijska implementacija koristi izvorni cURL. PHPUnit 11 pruža determinističke testove i podržava PHP 8.3.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload

Izradite API granicu

Izradite src/Screenshot.php. Transport je zamjenjiv, što testove drži izvan mreže. Klijent provjerava ciljni host, primjenjuje vremenska ograničenja, ponovno pokušava samo prolazne neuspjehe, provjerava i vrstu medija i PNG potpis te zadržava metapodatke predmemorije i kvote bez oslanjanja na nedokumentirane nazive zaglavlja.

<?php
declare(strict_types=1);

namespace App;

use Closure;
use RuntimeException;
use Throwable;

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

final readonly class ScreenshotResult
{
    public function __construct(
        public string $png,
        public int $status,
        public array $serviceHeaders,
    ) {}
}

final class TransportException extends RuntimeException {}

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

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

final class CurlTransport implements Transport
{
    public function get(
        string $url,
        array $headers,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): 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_HTTPHEADER => $headers,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$received): int {
                $length = strlen($line);
                $trimmed = trim($line);

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

                return $length;
            },
        ]);

        $body = curl_exec($handle);
        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportException($message);
        }

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

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

    private Closure $pause;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly array $allowedHosts,
        ?callable $pause = null,
    ) {
        if ($token === '') {
            throw new ScreenshotFailure('configuration', 'Missing service token.');
        }

        $this->pause = $pause === null
            ? static fn (int $milliseconds) => usleep($milliseconds * 1000)
            : Closure::fromCallable($pause);
    }

    public function capture(string $targetUrl): ScreenshotResult
    {
        $this->assertAllowedUrl($targetUrl);

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

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    $requestUrl,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: image/png',
                    ],
                    3000,
                    30000,
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'transport',
                        'Screenshot service could not be reached.',
                        null,
                        $exception,
                    );
                }

                ($this->pause)($this->backoffMs($attempt));
                continue;
            }

            if ($response->status === 200) {
                return $this->mapSuccess($response);
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ScreenshotFailure(
                    'authentication',
                    'The Screenshot API rejected the service token.',
                    $response->status,
                );
            }

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

            if ($transient && $attempt < 3) {
                ($this->pause)($this->retryDelayMs($response, $attempt));
                continue;
            }

            $kind = $response->status === 429 ? 'quota' : 'request_rejected';

            throw new ScreenshotFailure(
                $kind,
                'Screenshot capture failed with HTTP ' . $response->status . '.',
                $response->status,
            );
        }

        throw new ScreenshotFailure('internal', 'Capture loop ended unexpectedly.');
    }

    private function assertAllowedUrl(string $url): void
    {
        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower((string) ($parts['host'] ?? ''));
        $allowed = array_map('strtolower', $this->allowedHosts);

        if (!filter_var($url, FILTER_VALIDATE_URL)
            || !in_array($scheme, ['http', 'https'], true)
            || $host === ''
            || !in_array($host, $allowed, true)
        ) {
            throw new ScreenshotFailure(
                'invalid_input',
                'Target URL is invalid or its host is not allowed.',
            );
        }
    }

    private function mapSuccess(HttpResponse $response): ScreenshotResult
    {
        $contentType = strtolower(
            trim(explode(';', $this->header($response, 'content-type') ?? '')[0])
        );

        if ($contentType !== 'image/png'
            || !str_starts_with($response->body, "\x89PNG\r\n\x1a\n")
        ) {
            throw new ScreenshotFailure(
                'invalid_response',
                'Successful response did not contain a valid PNG.',
                $response->status,
            );
        }

        $metadata = [];
        $standardCacheHeaders = [
            'cache-control', 'age', 'expires', 'etag', 'last-modified', 'vary',
        ];

        foreach ($response->headers as $name => $values) {
            if (in_array($name, $standardCacheHeaders, true)
                || str_contains($name, 'cache')
                || str_contains($name, 'quota')
                || preg_match('/rate-?limit/', $name) === 1
                || $name === 'retry-after'
            ) {
                $metadata[$name] = $values;
            }
        }

        return new ScreenshotResult(
            $response->body,
            $response->status,
            $metadata,
        );
    }

    private function retryDelayMs(HttpResponse $response, int $attempt): int
    {
        $retryAfter = $this->header($response, 'retry-after');

        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            return min(30000, (int) $retryAfter * 1000);
        }

        return $this->backoffMs($attempt);
    }

    private function backoffMs(int $attempt): int
    {
        return min(5000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }

    private function header(HttpResponse $response, string $name): ?string
    {
        $values = $response->headers[strtolower($name)] ?? [];
        return $values === [] ? null : $values[array_key_last($values)];
    }
}

Greške klijenta, neuspjesi autentikacije i neispravni uspješni odgovori ne pokušavaju se naslijepo ponovno. Mrežni neuspjesi, HTTP 429 i pogreške poslužitelja dobivaju najviše tri pokušaja. Numerička vrijednost Retry-After poštuje se, ali je ograničena, čime se sprječava da radnik implementacije spava neograničeno dugo.

Izradite naredbu za snimke

Izradite bin/snapshot.php. Prihvaća before ili after te identifikator izdanja. I PNG i njegov manifest koriste privremene datoteke nakon kojih slijede preimenovanja, tako da čitatelji nikada ne vide djelomično zapisane datoteke.

<?php
declare(strict_types=1);

use App\CurlTransport;
use App\ScreenshotClient;
use App\ScreenshotFailure;

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

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

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

    return $value;
}

function atomicWrite(string $path, string $contents): void
{
    $temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
    $written = file_put_contents($temporary, $contents, LOCK_EX);

    if ($written !== strlen($contents) || !rename($temporary, $path)) {
        @unlink($temporary);
        throw new RuntimeException("Could not write {$path}");
    }
}

try {
    $stage = $argv[1] ?? '';
    $release = $argv[2] ?? '';

    if (!in_array($stage, ['before', 'after'], true)) {
        throw new RuntimeException('Stage must be before or after.');
    }

    if (preg_match('/^[A-Za-z0-9._-]{1,80}$/', $release) !== 1) {
        throw new RuntimeException('Release identifier is invalid.');
    }

    $targetUrl = environment('SCREENSHOT_TARGET_URL');
    $hosts = array_values(array_filter(array_map(
        'trim',
        explode(',', environment('SCREENSHOT_ALLOWED_HOSTS')),
    )));

    $client = new ScreenshotClient(
        new CurlTransport(),
        environment('SCREENSHOT_API_TOKEN'),
        $hosts,
    );

    $result = $client->capture($targetUrl);
    $directory = rtrim(environment('SCREENSHOT_OUTPUT_DIR'), '/')
        . '/' . $release;

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

    $imagePath = $directory . '/' . $stage . '.png';
    $manifestPath = $directory . '/' . $stage . '.json';

    atomicWrite($imagePath, $result->png);
    atomicWrite($manifestPath, json_encode([
        'release' => $release,
        'stage' => $stage,
        'target_host' => parse_url($targetUrl, PHP_URL_HOST),
        'captured_at' => gmdate(DATE_ATOM),
        'bytes' => strlen($result->png),
        'sha256' => hash('sha256', $result->png),
        'service_headers' => $result->serviceHeaders,
    ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));

    fwrite(STDOUT, json_encode([
        'event' => 'snapshot.captured',
        'release' => $release,
        'stage' => $stage,
        'bytes' => strlen($result->png),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (ScreenshotFailure $failure) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot.failed',
        'kind' => $failure->kind,
        'status' => $failure->status,
        'message' => $failure->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(2);
} catch (Throwable $failure) {
    fwrite(STDERR, json_encode([
        'event' => 'snapshot.failed',
        'kind' => 'local',
        'message' => $failure->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

Zapisnici namjerno isključuju token, puni ciljni URL, tijelo odgovora i zaglavlja zahtjeva odgovora. Manifest bilježi sažetak slike i samo zaglavlja usluge povezana s predmemorijom ili kvotom.

Omotajte stvarnu implementaciju

Izradite izvršnu datoteku bin/release-with-snapshots. Omotač prihvaća stvarnu naredbu za implementaciju i provjeru stanja kao svoje argumente. Odbija implementaciju ako snimanje prije implementacije ne uspije. Nakon što implementacija započne, pokušava snimanje nakon implementacije čak i kada naredba za implementaciju završi neuspješno, čuvajući dokaz o djelomičnom ažuriranju.

#!/usr/bin/env bash
set -u

if [ "$#" -eq 0 ]; then
  echo "Usage: RELEASE_ID=id bin/release-with-snapshots command [args...]" >&2
  exit 64
fi

: "${RELEASE_ID:?RELEASE_ID must be set}"

set -a
. ./.env
set +a

php bin/snapshot.php before "$RELEASE_ID" || exit $?

"$@"
deploy_status=$?

php bin/snapshot.php after "$RELEASE_ID"
after_status=$?

if [ "$deploy_status" -ne 0 ]; then
  exit "$deploy_status"
fi

exit "$after_status"

Učinite je izvršnom naredbom chmod 750 bin/release-with-snapshots. Pokrenite je iz korijena repozitorija, prosljeđujući postojeći tijek implementacije kao preostale argumente. Taj tijek trebao bi sadržavati uobičajenu provjeru spremnosti ili stanja kako bi slika nakon implementacije predstavljala objavljenu stranicu, a ne zaslon tijekom ponovnog pokretanja.

Testirajte bez pozivanja usluge

Izradite tests/ScreenshotClientTest.php. Lažni transport pruža točne nizove odgovora i bilježi pozive, čineći ponašanje ponovnih pokušaja determinističkim i brzim.

<?php
declare(strict_types=1);

namespace Tests;

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

final class FakeTransport implements Transport
{
    public array $calls = [];

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $headers,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls[] = compact(
            'url', 'headers', 'connectTimeoutMs', 'timeoutMs'
        );

        return array_shift($this->responses);
    }
}

final class ScreenshotClientTest extends TestCase
{
    private string $png = "\x89PNG\r\n\x1a\nfake-png-data";

    public function testMapsPngAndCacheMetadata(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(200, [
                'content-type' => ['image/png'],
                'cache-control' => ['public, max-age=60'],
                'x-unrelated' => ['ignored'],
            ], $this->png),
        ]);

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

        self::assertSame($this->png, $result->png);
        self::assertArrayHasKey('cache-control', $result->serviceHeaders);
        self::assertArrayNotHasKey('x-unrelated', $result->serviceHeaders);
    }

    public function testRetriesQuotaResponseThenSucceeds(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => ['0']], ''),
            new HttpResponse(
                200,
                ['content-type' => ['image/png']],
                $this->png,
            ),
        ]);
        $delays = [];

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

        $client->capture('https://client.example/');
        self::assertCount(2, $transport->calls);
        self::assertSame([0], $delays);
    }

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

        try {
            $this->client($transport)->capture('https://client.example/');
            self::fail('Expected an authentication failure.');
        } catch (ScreenshotFailure $failure) {
            self::assertSame('authentication', $failure->kind);
        }

        self::assertCount(1, $transport->calls);
    }

    private function client(FakeTransport $transport): ScreenshotClient
    {
        return new ScreenshotClient(
            $transport,
            'test-token',
            ['client.example'],
            static function (int $milliseconds): void {},
        );
    }
}
vendor/bin/phpunit tests

Sigurnost, operacije i uobičajeni neuspjesi

Nemojte ovu naredbu pretvoriti u neograničeni proxy za snimanje zaslona. Popis dopuštenih naziva hostova sprječava pozivatelja da zamijeni proizvoljne URL-ove, uključujući interne usluge. Upotrebljavajte točne javne nazive hostova, pregledajte preusmjeravanja na odredištu i zadržite autentikaciju za pripremno okruženje izvan ove integracije, osim ako je dokumentirani API ugovor izričito podržava.

Ograničite pristup direktoriju snimki jer slike mogu sadržavati imena kupaca, neobjavljene dizajne ili podatke o računima. Definirajte razdoblje zadržavanja, sigurnosno kopirajte samo ono što poslovanje treba i osigurajte da korijeni dokumenata web-poslužitelja ne izlažu var/snapshots.

Pošaljite strukturirane JSON događaje u sustav zapisivanja koji već upotrebljava proces implementacije. Postavite upozorenja za ponovljene neuspjehe authentication, quota i invalid_response. Usporedite sažetke manifesta kao brzi signal, ali imajte na umu da identični sažeci dokazuju identične PNG bajtove, a ne da je stranica semantički ispravna.

  • HTTP 401 ili 403: provjerite je li okruženje primilo aktualni token ograničen na uslugu. Ponovno generirani token čini stari neupotrebljivim.
  • HTTP 429: pregledajte sačuvana zaglavlja povezana s kvotom, potvrdite kapacitet paketa i izbjegavajte paralelne dvostruke snimke.
  • Neispravan PNG: zadržite status i vrstu sadržaja u operativnoj dijagnostici, ali nikada ne spremajte neočekivano tijelo kao sliku.
  • Vremenska ograničenja: potvrdite da je cilj javno dostupan i stabilan. Nemojte uklanjati ograničenja vremenskog čekanja da biste prikrili sporu stranicu.
  • Neočekivani sadržaj prije/nakon: osigurajte da su DNS, poništavanje CDN predmemorije i provjera stanja implementacije dovršeni prije snimanja nakon implementacije.

Završni popis za provjeru

  • Paket usluge je aktivan, a aktualni token usluge unosi se tijekom izvođenja.
  • Minimalni cURL zahtjev vraća PNG i izlaže zaglavlja odgovora za pregled.
  • Konfigurirani ciljni naziv hosta točno odgovara popisu dopuštenih hostova.
  • PHPUnit prolazi bez vanjskog mrežnog zahtjeva.
  • Probno pokretanje stvara before.png, after.png i dva JSON manifesta u jednom direktoriju izdanja.
  • Neuspješna autentikacija ne pokušava se ponovno, dok mrežni neuspjesi, neuspjesi kvote i poslužitelja dobivaju ograničene ponovne pokušaje.
  • Zapisnici i pohranjeni metapodaci ne sadržavaju token ni puni URL.
  • Produkcijska naredba za implementaciju uključuje provjeru spremnosti prije snimanja nakon implementacije.

Vrijedan artefakt nije samo snimka zaslona. To je pouzdana vizualna granica oko promjene: što su kupci mogli vidjeti prije izdanja, što su mogli vidjeti nakon njega i dovoljno operativnog konteksta za objašnjenje nedostajuće snimke. Kada je ta granica automatizirana, vizualni dokaz postaje dio isporuke umjesto lako zaboravljene završne provjere.

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.