Vodiči

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

Zabilježite vizualno stanje klijentove web-stranice za revizije pomoću izvornih PHP snimki zaslona

Ažuriranje web-mjesta može izgledati ispravno u zahtjevu za povlačenje, a ipak stići s fontom koji nedostaje, neočekivanim natpisom za privolu ili neispravnim responzivnim rasporedom. Par snimaka zaslona prije i poslije daje slobodnom suradniku ili malom timu trajan vizualni zapis revizije bez potrebe da itko održava Chromium, upravljačke programe preglednika ili radnik za snimke zaslona.

Ovaj vodič izrađuje taj tijek rada kao produkcijski orijentiranu aplikaciju Native PHP 8.3. Naredba za implementaciju snima javno web-mjesto neposredno prije i nakon izdanja, provjerava PNG odgovor, zapisuje ga atomski te bilježi zaglavlja povezana s predmemorijom i kvotom radi kasnije dijagnostike.

Dobijte pristup i izradite servisni token

Postavljanje pristupa odvija se prije bilo kakvog integracijskog koda:

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Screenshot API.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token kako se vjerodajnica ne bi pojavila u URL-ovima, zapisima pristupa proxyja ili povijesti preglednika.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu implementacije: instalirajte novu vrijednost svugdje gdje se izvršava naredba za reviziju, provjerite je i uklonite svaku zastarjelu tajnu s platforme za implementaciju.

Potvrdite točnu krajnju točku

Ovdje korišteni API ugovor je:

GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Required query parameter: url
Successful body: image/png

Pokrenite jedan minimalni zahtjev lokalno, zapisujući zaglavlja i slikovne podatke u zasebne datoteke:

export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'

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

file /tmp/screenshot.png

Ne predajte ni token ni stvarnu snimku zaslona klijenta u repozitorij. API može poslužiti snimku iz predmemorije, stoga pri dijagnosticiranju svježine ili kapaciteta pregledajte vraćena zaglavlja predmemorije i kvote, umjesto da pretpostavite da svaki zahtjev pokreće novu snimku.

Arhitektura i kompromisi

Najmanji pouzdani dizajn ima četiri granice: cURL transport obavlja HTTP, klijent za snimke zaslona upravlja ponovnim pokušajima i provjerom odgovora, DTO izlaže PNG uz operativne metapodatke, a CLI naredba upravlja imenovanjem i pohranjivanjem revizije.

Naredba je namjerno sinkrona. Implementacija se ne smije označiti kao vizualno provjerena dok je njezino snimanje još negdje u redu čekanja. Kompromis je dodatno vrijeme implementacije, ovdje ograničeno izričitim vremenskim ograničenjima i malim proračunom ponovnih pokušaja.

Raspored projekta namjerno je skroman:

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

Konfigurirajte projekt Native PHP

Preduvjeti su PHP 8.3 ili noviji, proširenja cURL i JSON, Composer te PHPUnit za testove. Izradite projekt i instalirajte razvojnu ovisnost:

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

Dodajte PSR-4 automatsko učitavanje u composer.json i ponovno generirajte automatski učitavač:

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

Postavite rezervirane vrijednosti u .env.example, a stvarni token smjestite samo u nepraćenu datoteku .env ili upravitelj tajni u produkciji:

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

Dodajte .env i var/audits/ u .gitignore. Bootstrap može učitati jednostavne datoteke okruženja bez uvođenja paketa u vrijeme izvršavanja:

<?php
declare(strict_types=1);

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

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

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

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

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

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

    return $value;
}

Izgradite obrambenu granicu API-ja

Transport vraća status, normalizirana zaglavlja i bajtove. Održavanje ovog sučelja malim čini testove determinističkima i sprječava da detalji cURL-a procure u naredbu za implementaciju.

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

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

// src/Transport.php
namespace App;

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

// src/CurlTransport.php
namespace App;

use RuntimeException;

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

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

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

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

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

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

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

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

Ograničenje od 20 MiB sprječava da neuobičajen odgovor iscrpi PHP memoriju. Preusmjeravanja su onemogućena jer je krajnja točka usluge fiksna; URL ciljne stranice ostaje vrijednost upita koju udaljena usluga obrađuje.

Preslikajte uspjeh i neuspjeh u objekte domene

Metapodacima odgovora treba pristupati obrambeno. Standardna zaglavlja predmemorije izričito se čuvaju. Zaglavlja povezana s kvotom prikupljaju se prema semantičkom nazivu, umjesto pretpostavljanja da nedokumentirana polja postoje.

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

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

// src/ScreenshotException.php
namespace App;

use RuntimeException;

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

// src/ScreenshotClient.php
namespace App;

use Closure;
use Throwable;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

U produkcijskom kodu inicijalizirajte uspavljivač izričito s Closure::fromCallable('usleep'); time odgoda ostaje injektabilna u testovima. Ponovno se pokušavaju samo neuspjesi transporta, HTTP 429 i pogreške poslužitelja. Neuspjesi autentikacije i zahtjeva odmah se zaustavljaju jer ih ponavljanje ne može popraviti.

Izradite naredbu za reviziju

Naredba prihvaća fazu, identifikator izdanja i HTTPS URL. Popis dopuštenih hostova sprječava operatera ili kompromitiranu varijablu cjevovoda da uslugu snimanja zaslona pretvori u sondu za proizvoljne ciljeve.

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

use App\CurlTransport;
use App\ScreenshotClient;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

JSON zapisan na standardni izlaz prikladan je za strukturirane zapise implementacije. Namjerno isključuje token i tijelo slike. Susjedna datoteka metapodataka čuva kontrolne zbrojeve, informacije predmemorije i sve vraćene signale kvote zajedno sa samim dokazom.

Automatizirajte tijek rada prije i poslije

Omotajte postojeću radnju izdanja s dva snimanja. Upotrijebite isti stabilni javni URL za oba kako bi usporedba mjerila stanje implementacije, a ne razlike između ruta:

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

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

./deploy-existing-release.sh

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

Ako prvo snimanje ne uspije, zaustavite se prije implementacije osim ako tim nije izričito klasificirao snimke zaslona kao neblokirajuće. Ako implementacija uspije, ali drugo snimanje ne uspije, prijavite djelomičnu reviziju i ponovno pokrenite samo fazu after. Nikada ne prikrivajte nedostajuću sliku kopiranjem druge faze.

Testirajte bez pozivanja usluge

Lažni transport čini ponašanje ponovnog pokušaja i preslikavanje odgovora ponovljivima. Nijedan testni primjer ne sadrži stvarnu vjerodajnicu.

<?php
namespace Tests;

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

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

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

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

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

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

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

    public function __construct(private array $responses) {}

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

Sigurnost, vidljivost i pojedinosti implementacije

Ograničite token na konfiguraciju potkrijepljenu okruženjem, zaštitite datoteku okruženja dozvolama operacijskog sustava i nikada ne ispisujte zaglavlja zahtjeva. Ako se token ponovno generira, atomski ga zamijenite u svim okruženjima implementacije jer stari token prestaje raditi.

Snimke zaslona klijenta mogu sadržavati imena, podatke o računu, neobjavljene ponude ili stanje privole. Držite var/audits izvan javnog korijena dokumenta, definirajte pravilo zadržavanja i pristup omogućite samo osobama kojima je revizija potrebna. Šifrirajte volumen za pohranu ili odredište objektne pohrane kada su slike osjetljive.

Zapisujte izdanje, fazu, kategoriju HTTP statusa, broj pokušaja, trajanje i konačni ishod. Ne zapisujte Bearer token, PNG bajtove ni neograničeni URL koji sadrži osjetljive vrijednosti upita. Upozorite na ponovljene neuspjehe autentikacije, iscrpljene ponovne pokušaje i odsutne slike after.

Produkcijski spremnici trebaju PHP-ovo proširenje cURL, upisiv trajni direktorij za reviziju, pouzdana tijela za izdavanje certifikata, odlazni HTTPS pristup do ai.mihajlo.mk i dovoljno memorije za konfiguriranu gornju granicu od 20 MiB. Pokrenite probno snimanje nakon implementacije i nakon rotacije tajne.

Uobičajeni načini neuspjeha

  • HTTP 401 ili 403: provjerite aktivaciju i trenutačni token ograničen na uslugu. Ne pokušavajte naslijepo ponovno.
  • HTTP 429: dosegnuta je kvota ili ograničenje brzine. Poštujte brojčani Retry-After unutar ograničene odgode, zatim prijavite neuspjeh ako su ponovni pokušaji iscrpljeni.
  • HTTP 5xx ili mrežno vremensko ograničenje: kratko pokušajte ponovno s eksponencijalnim povećanjem odgode; zadržite ishod implementacije izričitim ako oporavak ne uspije.
  • Uspješan status s tijelom koje nije PNG: odbijte ga. Sam status nije dovoljan; provjerite i Content-Type i PNG potpis.
  • Slika izgleda neočekivano staro: pregledajte zabilježena zaglavlja predmemorije prije nego što okrivite implementaciju. Ponašanje snimki iz predmemorije dio je svrhe usluge.
  • Obje slike izgledaju jednako: usporedite njihove vrijednosti SHA-256, potvrdite da je izdanje doista stiglo do javnog hosta i provjerite poslužuju li predmemorije aplikacije ili rubne predmemorije još raniju verziju.

Kontrolni popis završne provjere

  • Aktivni je plan omogućen, a trenutačni servisni token pohranjen je izvan kontrole izvornog koda.
  • Naredba poziva točno GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture s obaveznim parametrom upita url.
  • Moguće je snimiti samo odobreni HTTPS host klijenta.
  • Ograničeni su povezivanje, ukupno trajanje, veličina odgovora, ponovni pokušaji i ograničenja povećanja odgode.
  • Neuspjesi autentikacije i provjere ne pokušavaju se ponovno.
  • Odgovor se provjerava kao PNG prije nego što se atomski pohrani.
  • Zaglavlja odgovora povezana s predmemorijom i kvotom zadržavaju se bez pretpostavljanja nedokumentiranih polja.
  • Testovi prolaze kroz deterministički lažni transport bez vanjskih zahtjeva.
  • Direktorij stvarnog izdanja sadrži različite datoteke before.png, after.png i metapodataka.

Par snimaka zaslona jednostavan je dokaz, ali upravo je ta jednostavnost njegova snaga. Pretvara “izdanje je izgledalo dobro” u datirani artefakt s kontrolnim zbrojem povezan s određenom implementacijom. Uz infrastrukturu preglednika delegiranu Screenshot API-ju i integraciju ograničenu pažljivom provjerom, ponovnim pokušajima, sigurnosnim kontrolama i testovima, vizualna revizija postaje uobičajen dio isporuke umjesto nepouzdanog zadatka kojeg se netko sjeti naknadno.

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.