Izvorni PHP 8.3: Automatski snimajte snimke stanja web-mjesta prije/nakon ažuriranja
Ažuriranje web-mjesta može izgledati savršeno u diffu, a ipak u produkciju poslati pokvarenu hero sliku, neočekivani banner s kolačićima ili raspored koji se ruši samo pri produkcijskoj širini. Par snimaka zaslona prije i poslije daje programerima, freelancerima i klijentima trajan vizualni zapis onoga što se doista promijenilo.
Ovaj vodič izrađuje taj zapis uz Native PHP 8.3, izvorni cURL i malu aplikaciju naredbenog retka. Snima PNG neposredno prije implementacije, snima drugi nakon što ažurirano web-mjesto prođe provjeru ispravnosti te pohranjuje obje slike s metapodacima odgovora radi otklanjanja poteškoća i mogućnosti revizije.
Dobijte pristup Screenshot API-ju
Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupan Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite panel Service token i kopirajte token ograničen na uslugu.
- Pohranite ga u konfiguraciju podržanu varijablama okruženja, nikada u PHP izvorni kod ili skripte za implementaciju.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Koordinirajte rotaciju tako da nova vrijednost stigne u svako pokrenuto okruženje prije nego što stari procesi pošalju novi zahtjev.
Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ovaj projekt upotrebljava Bearer zaglavlje jer se vjerodajnice u nizu upita mogu pojaviti u zapisnicima pristupa i sustavima za nadzor.
Provjerite točnu krajnju točku
Operacija snimanja jest HTTP GET zahtjev na https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita je url, a uspješno snimanje vraća tijelo image/png uz zaglavlja odgovora povezana s predmemorijom i kvotom.
export SCREENSHOT_TOKEN='YOUR_SERVICE_TOKEN'
curl --fail-with-body --silent --show-error \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header "Authorization: Bearer ${SCREENSHOT_TOKEN}" \
--header 'Accept: image/png' \
--data-urlencode 'url=https://client.example/' \
--dump-header smoke.headers \
--output smoke.png
file smoke.png
Pregledajte smoke.headers kao i PNG. Nazive zaglavlja treba koristiti u skladu s aktualnom službenom dokumentacijom. Implementacija u nastavku čuva svako vraćeno zaglavlje odgovora umjesto da izmišlja fiksne nazive polja za predmemoriju ili kvotu.
Arhitektura i raspored projekta
Cjevovod implementacije poziva jednu naredbu dvaput: jednom prije promjene web-mjesta i jednom nakon što je ažurirano web-mjesto ispravno. Identifikator izdanja povezuje dva snimanja. API granica izdvojena je iza transportnog sučelja, što PHPUnitu omogućuje testiranje ponovnih pokušaja i neuspjeha bez mrežnih poziva.
website-snapshots/
├── bin/snapshot
├── src/
│ ├── CurlTransport.php
│ ├── HttpResponse.php
│ ├── ScreenshotClient.php
│ └── Transport.php
├── tests/ScreenshotClientTest.php
├── var/snapshots/
├── .env
├── .env.example
├── composer.json
└── phpunit.xml
Udaljeno snimanje izbjegava održavanje Chromiuma, zakrpa preglednika, fontova i infrastrukture za sandboxing. Kompromis su mrežna ovisnost i kvota plana, stoga snimanja trebaju ograničena vremenska ograničenja, promišljene ponovne pokušaje i trajne metapodatke.
Instalirajte i konfigurirajte projekt
Aplikacija zahtijeva PHP 8.3, proširenje cURL, Composer i PHPUnit 11. Jedini paket tijekom izvođenja je vlucas/phpdotenv 5.6, koji se upotrebljava za dosljedno učitavanje lokalne konfiguracije okruženja.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"scripts": {
"test": "phpunit"
}
}
composer install
mkdir -p var/snapshots
chmod 750 var var/snapshots
cp .env.example .env
chmod 600 .env
Postavite zamjenske vrijednosti u .env.example, a zatim stvarni token smjestite samo u zanemarenu datoteku .env. Produkcija treba ubrizgati iste varijable putem svojeg sustava za upravljanje tajnama.
SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOSTS=client.example,www.client.example
Popis dopuštenih hostova važna je granica na razini aplikacije. Sprječava da pogreška u argumentu — ili napadač koji dobije pristup naredbi — vaš račun pretvori u proxy opće namjene za snimanje URL-ova. Ovaj primjer dopušta samo HTTPS URL-ove čiji naziv hosta točno odgovara konfiguriranom popisu.
Izgradite obrambenu API granicu
Tipovi odgovora i transporta zadržavaju HTTP mehaniku izvan naredbe za implementaciju. Zaglavlja odgovora normalizirana su u nizove s malim slovima kako se ponovljena zaglavlja ne bi odbacila.
<?php
// src/Transport.php
namespace App;
interface Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $responseTimeout
): HttpResponse;
}
final class TransportException extends \RuntimeException
{
public function __construct(
string $message,
public readonly bool $transient
) {
parent::__construct($message);
}
}
<?php
// src/HttpResponse.php
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers
) {}
public function header(string $name): ?string
{
$values = $this->headers[strtolower($name)] ?? null;
return $values === null ? null : implode(', ', $values);
}
}
cURL transport provjerava TLS zadržavanjem sigurnih zadanih postavki cURL-a, odbija preusmjeravanja na API granici i odvaja vremensko ograničenje povezivanja od ukupnog vremenskog ograničenja odgovora. Njegov povratni poziv za zaglavlja poništava prikupljena zaglavlja kada se pojavi novi redak HTTP statusa, sprječavajući da privremeni odgovor onečisti konačne metapodatke.
<?php
// src/CurlTransport.php
namespace App;
final class CurlTransport implements Transport
{
public function get(
string $url,
array $headers,
int $connectTimeout,
int $responseTimeout
): HttpResponse {
$responseHeaders = [];
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Unable to initialize cURL', false);
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_TIMEOUT => $responseTimeout,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
$trimmed = trim($line);
if (str_starts_with($trimmed, 'HTTP/')) {
$responseHeaders = [];
} elseif ($trimmed !== '' && str_contains($trimmed, ':')) {
[$name, $value] = explode(':', $trimmed, 2);
$responseHeaders[strtolower(trim($name))][] = trim($value);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$number = curl_errno($handle);
$message = curl_error($handle);
curl_close($handle);
$transient = in_array($number, [
CURLE_OPERATION_TIMEDOUT,
CURLE_COULDNT_CONNECT,
CURLE_COULDNT_RESOLVE_HOST,
CURLE_SEND_ERROR,
CURLE_RECV_ERROR,
], true);
throw new TransportException(
"Screenshot transport failed: {$message}",
$transient
);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body, $responseHeaders);
}
}
Mapirajte PNG odgovore i sigurno ponavljajte pokušaje
Klijent izvodi najviše tri pokušaja. Ponovno pokušava prolazne transportne pogreške, HTTP 429 odgovore i 5xx odgovore na strani poslužitelja. Neuspjesi provjere valjanosti i autentikacije vraćaju se odmah jer ih drugi identični zahtjev neće popraviti.
Brojčano zaglavlje Retry-After poštuje se kada je prisutno i ograničeno je na deset sekundi. U suprotnom, ponovni pokušaji koriste ograničeno eksponencijalno odgađanje. Uspješna tijela moraju imati i vrstu sadržaja image/png i PNG potpis; to sprječava arhiviranje HTML stranice s pogreškom kao dokaza.
<?php
// src/ScreenshotClient.php
namespace App;
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $headers,
public int $attempts
) {}
}
final class ScreenshotException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null
) {
parent::__construct($message);
}
}
final class ScreenshotClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
private \Closure $sleep;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly array $allowedHosts,
?\Closure $sleep = null
) {
if ($token === '') {
throw new \InvalidArgumentException('Missing screenshot token');
}
$this->sleep = $sleep ?? static fn(int $microseconds) =>
usleep($microseconds);
}
public function capture(string $target): CaptureResult
{
$parts = parse_url($target);
$host = strtolower($parts['host'] ?? '');
if (
filter_var($target, FILTER_VALIDATE_URL) === false ||
($parts['scheme'] ?? '') !== 'https' ||
!in_array($host, $this->allowedHosts, true) ||
isset($parts['user']) ||
isset($parts['pass'])
) {
throw new \InvalidArgumentException('Target URL is not allowed');
}
$url = self::ENDPOINT . '?' . http_build_query(
['url' => $target],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get($url, [
'Authorization: Bearer ' . $this->token,
'Accept: image/png',
], 5, 30);
} catch (TransportException $error) {
if (!$error->transient || $attempt === 3) {
throw new ScreenshotException($error->getMessage());
}
($this->sleep)(250_000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status === 200) {
$type = strtolower($response->header('content-type') ?? '');
$signature = "\x89PNG\r\n\x1a\n";
if (
!str_starts_with($type, 'image/png') ||
!str_starts_with($response->body, $signature)
) {
throw new ScreenshotException(
'Capture returned an invalid PNG',
200
);
}
return new CaptureResult(
$response->body,
$response->headers,
$attempt
);
}
$retryable = $response->status === 429 ||
($response->status >= 500 && $response->status <= 599);
if (!$retryable || $attempt === 3) {
throw new ScreenshotException(
"Capture failed with HTTP {$response->status}",
$response->status
);
}
$retryAfter = trim($response->header('retry-after') ?? '');
$delay = ctype_digit($retryAfter)
? min(10, (int) $retryAfter) * 1_000_000
: min(2_000_000, 250_000 * (2 ** ($attempt - 1)));
($this->sleep)($delay);
}
throw new ScreenshotException('Capture attempts exhausted');
}
}
Izradite naredbu prije i poslije
Naredba koristi identifikator izdanja i fazu before ili after. Svaka se faza zapisuje u privremeni direktorij i preimenuje na konačno mjesto tek nakon što su i slika i metapodaci trajno zapisani. Postojeća faza nikada se ne prepisuje.
<?php
// bin/snapshot
declare(strict_types=1);
use App\CurlTransport;
use App\ScreenshotClient;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
umask(0027);
[$script, $phase, $release, $target] = $argv + [null, null, null, null];
if (
!in_array($phase, ['before', 'after'], true) ||
!is_string($release) ||
preg_match('/^[a-zA-Z0-9._-]{1,80}$/', $release) !== 1 ||
!is_string($target)
) {
fwrite(STDERR, "Usage: php bin/snapshot before|after RELEASE URL\n");
exit(64);
}
$hosts = array_values(array_filter(array_map(
static fn(string $host): string => strtolower(trim($host)),
explode(',', $_ENV['SNAPSHOT_ALLOWED_HOSTS'] ?? '')
)));
try {
$client = new ScreenshotClient(
new CurlTransport(),
$_ENV['SCREENSHOT_TOKEN'] ?? '',
$hosts
);
$capture = $client->capture($target);
$base = dirname(__DIR__) . "/var/snapshots/{$release}";
$final = "{$base}/{$phase}";
if (file_exists($final)) {
throw new RuntimeException("Snapshot phase already exists: {$phase}");
}
if (!is_dir($base) && !mkdir($base, 0750, true) && !is_dir($base)) {
throw new RuntimeException('Cannot create snapshot directory');
}
$temporary = $base . '/.' . $phase . '-' . bin2hex(random_bytes(6));
if (!mkdir($temporary, 0750)) {
throw new RuntimeException('Cannot create staging directory');
}
$metadata = [
'release' => $release,
'phase' => $phase,
'target' => $target,
'captured_at' => gmdate(DATE_ATOM),
'sha256' => hash('sha256', $capture->png),
'bytes' => strlen($capture->png),
'attempts' => $capture->attempts,
'response_headers' => $capture->headers,
];
file_put_contents(
"{$temporary}/image.png",
$capture->png,
LOCK_EX | FILE_BINARY
);
file_put_contents(
"{$temporary}/metadata.json",
json_encode($metadata, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
LOCK_EX
);
chmod("{$temporary}/image.png", 0640);
chmod("{$temporary}/metadata.json", 0640);
if (!rename($temporary, $final)) {
throw new RuntimeException('Cannot publish snapshot atomically');
}
fwrite(STDOUT, json_encode([
'event' => 'snapshot_captured',
'release' => $release,
'phase' => $phase,
'host' => parse_url($target, PHP_URL_HOST),
'attempts' => $capture->attempts,
'sha256' => $metadata['sha256'],
], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $error) {
fwrite(STDERR, json_encode([
'event' => 'snapshot_failed',
'release' => $release,
'phase' => $phase,
'error_type' => $error::class,
'message' => $error->getMessage(),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
Zapisnici namjerno isključuju token, autorizacijsko zaglavlje i tijelo odgovora. Metapodaci zadržavaju zaglavlja odgovora kako bi se ponašanje predmemorije i preostala kvota mogli pregledati upotrebom naziva dokumentiranih za aktivni plan usluge.
Testirajte ponovne pokušaje bez pozivanja usluge
Deterministički lažni transport čini putanje neuspjeha brzim i ponovljivim. Ovi testovi dokazuju da se neuspjeh poslužitelja ponovno pokušava, neuspjeh autentikacije ne pokušava ponovno i odgovor koji nije PNG odbacuje se.
<?php
// tests/ScreenshotClientTest.php
namespace Tests;
use App\CaptureResult;
use App\HttpResponse;
use App\ScreenshotClient;
use App\ScreenshotException;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(
string $url,
array $headers,
int $connectTimeout,
int $responseTimeout
): HttpResponse {
$this->calls++;
return array_shift($this->responses);
}
}
final class ScreenshotClientTest extends TestCase
{
private const PNG = "\x89PNG\r\n\x1a\nfake";
public function testRetriesServerFailureThenMapsPng(): void
{
$transport = new FakeTransport([
new HttpResponse(503, '', []),
new HttpResponse(200, self::PNG, [
'content-type' => ['image/png'],
'x-cache' => ['HIT'],
]),
]);
$sleeps = [];
$client = new ScreenshotClient(
$transport,
'test-token',
['client.example'],
static function (int $delay) use (&$sleeps): void {
$sleeps[] = $delay;
}
);
$result = $client->capture('https://client.example/');
self::assertInstanceOf(CaptureResult::class, $result);
self::assertSame(2, $result->attempts);
self::assertSame(2, $transport->calls);
self::assertSame([250_000], $sleeps);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new HttpResponse(401, 'unauthorized', []),
]);
$client = new ScreenshotClient(
$transport,
'test-token',
['client.example'],
static function (): void {}
);
try {
$client->capture('https://client.example/');
self::fail('Expected ScreenshotException');
} catch (ScreenshotException $error) {
self::assertSame(401, $error->status);
self::assertSame(1, $transport->calls);
}
}
public function testRejectsNonPngSuccessBody(): void
{
$this->expectException(ScreenshotException::class);
$client = new ScreenshotClient(
new FakeTransport([
new HttpResponse(200, '<html>error</html>', [
'content-type' => ['text/html'],
]),
]),
'test-token',
['client.example']
);
$client->capture('https://client.example/');
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="snapshot-tests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Povežite ga s implementacijom
composer test
php bin/snapshot before release-2026-08-29 https://client.example/
# Run the existing deployment and wait for its health check to pass.
php bin/snapshot after release-2026-08-29 https://client.example/
Neka prvo snimanje bude preduvjet implementacije. Ako ne uspije, izričito odlučite smije li se izdanje nastaviti. Drugo snimanje pokrenite tek nakon što je javni URL spreman; u protivnom dokumentira međustanje umjesto dovršenog ažuriranja.
Na hostovima s efemernim datotečnim sustavima kopirajte dovršeni direktorij izdanja u trajnu privatnu pohranu uporabom postojećeg procesa za artefakte. Ograničite pristup snimkama jer mogu sadržavati imena kupaca, neobjavljene ponude, stanje računa ili druge vidljive poslovne podatke.
Nadzirite strukturirane događaje snapshot_failed, ponovljene pokušaje, HTTP 429 odgovore i promjene u sačuvanim zaglavljima predmemorije ili kvote. Upozoravanje na svaki promašaj predmemorije obično je šum; trajni pritisak na kvotu ili nedostajući snimci nakon implementacije operativno su značajni.
Uobičajeni neuspjesi
- HTTP 401 ili 403: provjerite token ograničen na uslugu i potvrdite da je ponovno generirani token implementiran posvuda. Nemojte automatski ponovno pokušavati.
- HTTP 429: pregledajte zaglavlja kvote, poštujte
Retry-Afterkada je naveden i smanjite duplicirana snimanja umjesto dodavanja neograničenih ponovnih pokušaja. - Neispravan PNG: sačuvajte događaj neuspjeha i istražite status, vrstu sadržaja, dostupnost cilja i aktualnu dokumentaciju usluge. Nikada ne spremajte tijelo kao sliku.
- Ciljni URL nije dopušten: dodajte točan namjeravani naziv hosta u
SNAPSHOT_ALLOWED_HOSTS; nemojte onemogućiti provjeru valjanosti. - Snimka nakon implementacije neočekivano se razlikuje: potvrdite da je provjera ispravnosti čekala predmemorije, resurse i namjeravani produkcijski naziv hosta prije snimanja.
Završni kontrolni popis za provjeru
- Token postoji samo u konfiguraciji podržanoj varijablama okruženja i pohrani tajni.
- Točna GET krajnja točka prima jedan kodirani parametar upita
url. - Oba se snimanja otvaraju kao valjane PNG datoteke.
- Direktoriji prije i poslije dijele isti identifikator izdanja.
- Metapodaci sadrže vremenske oznake, hash vrijednosti, pokušaje i vraćena zaglavlja odgovora.
- Neuspjesi autentikacije i provjere valjanosti ne pokušavaju se ponovno.
- Vremenska ograničenja, 429 odgovori i 5xx odgovori imaju ograničeno ponašanje ponovnih pokušaja.
- Pohrana snimaka i zapisnici ne otkrivaju token usluge.
- Produkcijski artefakti preživljavaju zamjenu hosta za implementaciju.
Par snimaka zaslona jednostavan je, ali njegova vrijednost proizlazi iz discipline: snimite stvarno javno web-mjesto u pravim trenucima, sačuvajte dovoljno dokaza za objašnjenje neuspjeha i učinite postupak ponovljivim. Uz te zaštitne mjere, svako ažuriranje web-mjesta dobiva vizualnu potvrdu — onu kojoj je mnogo lakše vjerovati nego sjećanju, užurbanoj provjeri u pregledniku ili poruci punoj nade „izgleda dobro”.