Laravel: Automatizirajte snimke stanja web-stranice klijenta prije i poslije ažuriranja pomoću Screenshot API-ja
Ažuriranje web-mjesta može tehnički uspjeti, a ipak uvesti vizualnu regresiju: nedostajuću hero sliku, neočekivani zamjenski font ili navigacijsku traku koja se prelama na pogrešnoj prijelomnoj točki. Bez pouzdane slike stanja „prije”, dokazivanje onoga što se promijenilo svodi se na nagađanje.
Ovaj vodič izrađuje produkcijski usmjeren Laravel tijek rada koji snima PNG snimke zaslona neposredno prije i nakon ažuriranja klijentova web-mjesta. Proces implementacije poziva sinkronu Artisan naredbu, dok namjenska usluga upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, provjerom odgovora, signalima kvote i pohranom.
Dobijte pristup Screenshot API-ju
Najprije registrirajte račun ili se prijavite. Otvorite stranicu usluge Screenshot API, odaberite dostupan plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
Zatim otvorite službenu dokumentaciju Screenshot API-ja. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu. Ova usluga zahtijeva autentikaciju; nije API bez tokena.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: ažurirajte svako implementirano okruženje koje koristi staru vrijednost, ponovno izgradite predmemoriranu konfiguraciju Laravela, provjerite integraciju i tek tada rotaciju smatrajte dovršenom.
Potvrdite točan ugovor zahtjeva
Operacija snimanja je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Zahtijeva parametar upita url i prihvaća autentikaciju putem Bearer tokena, zaglavlja X-API-Token ili parametra upita token. Upotrijebit ćemo oblik Bearer kako se vjerodajnice nikada ne bi pojavile u URL-ovima ili zapisnicima pristupa posrednika.
Pokrenite jedan minimalni test prije pisanja integracijskog koda:
export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
curl --get --silent --show-error --fail-with-body \
--dump-header response-headers.txt \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--header "Accept: image/png" \
--data-urlencode "url=https://www.example.com" \
--output screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Tijelo uspješnog odgovora je image/png. Pregledajte response-headers.txt, kao i sliku: zaglavlja predmemorije i kvote operativni su podaci, a ne ukrasni metapodaci. Njihova točna imena i dostupnost treba obrađivati defenzivno, a ne pretpostavljati.
Sada postavite vjerodajnicu i zadane postavke implementacije u Laravelovu konfiguraciju okruženja. Nikada ne predajte stvarni token u repozitorij.
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://www.example.com
CLIENT_SITE_HOST=www.example.com
SCREENSHOT_DISK=local
Dodajte ovaj unos u polje koje vraća config/services.php:
'screenshot_api' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
'site_url' => env('CLIENT_SITE_URL'),
'allowed_host' => env('CLIENT_SITE_HOST'),
'disk' => env('SCREENSHOT_DISK', 'local'),
],
Ako produkcija koristi predmemoriranu konfiguraciju, tijekom implementacije nakon ažuriranja vrijednosti okruženja pokrenite php artisan config:cache.
Odaberite arhitekturu sigurnu za implementaciju
Projekt koristi sinkronu naredbu umjesto zadatka u redu čekanja. Snimanje stanja „prije” mora završiti prije nego što se izdanje promijeni, dok se snimanje stanja „nakon” smije dogoditi tek kada novo izdanje prođe provjeru spremnosti. Red čekanja oslabio bi taj redoslijed osim ako implementacija također ne bi čekala završetak zadatka.
Rezultirajuća struktura namjerno je mala:
app/Data/ScreenshotCapture.php
app/Exceptions/ScreenshotApiException.php
app/Services/ScreenshotApi.php
app/Console/Commands/CaptureWebsiteSnapshot.php
config/services.php
tests/Feature/CaptureWebsiteSnapshotTest.php
Koristite PHP 8.3 ili noviji, Laravel aplikaciju s ugrađenim HTTP klijentom, zapisivi disk Laravelova datotečnog sustava i identifikator implementacije, poput nepromjenjivog broja izdanja ili hash-a predaje.
Preslikajte udaljeni odgovor u domenu
Ostatak aplikacije ne bi trebao rukovati proizvoljnim HTTP odgovorom. Izradite app/Data/ScreenshotCapture.php:
<?php
namespace App\Data;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public string $contentType,
public array $operationalHeaders,
) {}
}
Izradite app/Exceptions/ScreenshotApiException.php. Njegova stabilna kategorija neuspjeha omogućuje naredbama, zapisnicima i upozorenjima razlikovanje neuspjeha konfiguracije, autentikacije, kvote, zahtjeva, prijenosa, uzvodne usluge i neispravnog odgovora.
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
final class ScreenshotApiException extends RuntimeException
{
public function __construct(
public readonly string $failure,
string $message,
public readonly ?int $status = null,
public readonly ?int $retryAfterSeconds = null,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Izradite ograničeni HTTP klijent
Izradite app/Services/ScreenshotApi.php. Klijent ponavlja pokušaje kod neuspjeha povezivanja i pogrešaka poslužitelja uz kratko eksponencijalno čekanje. Odgovor kvote pokušava se ponovno samo jednom, i to samo kada sadrži malu brojčanu vrijednost Retry-After. Autentikacija i druge pogreške klijenta nikada se ne pokušavaju ponovno naslijepo.
<?php
namespace App\Services;
use App\Data\ScreenshotCapture;
use App\Exceptions\ScreenshotApiException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
final class ScreenshotApi
{
private Closure $sleep;
public function __construct(?Closure $sleep = null)
{
$this->sleep = $sleep ?? static fn (int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function capture(string $url): ScreenshotCapture
{
$token = (string) config('services.screenshot_api.token');
$endpoint = (string) config('services.screenshot_api.endpoint');
if ($token === '' || $endpoint === '') {
throw new ScreenshotApiException(
'configuration',
'Screenshot API configuration is incomplete.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::accept('image/png')
->withToken($token)
->connectTimeout(5)
->timeout(45)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotApiException(
'transport',
'Screenshot API connection failed.',
previous: $exception
);
}
$this->pause($url, $attempt, null, 250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->successful()) {
return $this->mapSuccess($response);
}
if ($response->status() === 429) {
$retryAfter = $this->numericRetryAfter($response);
if ($attempt === 1 && $retryAfter !== null && $retryAfter <= 10) {
$this->pause($url, $attempt, 429, $retryAfter * 1000);
continue;
}
throw new ScreenshotApiException(
'quota',
'Screenshot API quota or rate limit prevented capture.',
429,
$retryAfter
);
}
if ($response->serverError() && $attempt < 3) {
$this->pause(
$url,
$attempt,
$response->status(),
250 * (2 ** ($attempt - 1))
);
continue;
}
$status = $response->status();
$failure = in_array($status, [401, 403], true)
? 'authentication'
: ($response->clientError() ? 'request' : 'upstream');
throw new ScreenshotApiException(
$failure,
'Screenshot API returned HTTP '.$status.'.',
$status
);
}
throw new ScreenshotApiException('upstream', 'Capture attempts were exhausted.');
}
private function mapSuccess(Response $response): ScreenshotCapture
{
$contentType = strtolower(trim(explode(
';',
(string) $response->header('Content-Type')
)[0]));
$body = $response->body();
if ($contentType !== 'image/png' ||
! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotApiException(
'invalid_response',
'Successful response was not a valid PNG.',
$response->status()
);
}
$headers = [];
foreach ($response->headers() as $name => $values) {
$key = strtolower($name);
$compact = str_replace('-', '', $key);
if (str_contains($key, 'cache') ||
str_contains($key, 'quota') ||
str_contains($compact, 'ratelimit') ||
$key === 'retry-after') {
$headers[$name] = implode(', ', (array) $values);
}
}
return new ScreenshotCapture($body, $contentType, $headers);
}
private function numericRetryAfter(Response $response): ?int
{
$value = $response->header('Retry-After');
return is_string($value) && ctype_digit($value)
? (int) $value
: null;
}
private function pause(
string $url,
int $attempt,
?int $status,
int $milliseconds
): void {
Log::warning('Screenshot API retry scheduled.', [
'host' => parse_url($url, PHP_URL_HOST),
'attempt' => $attempt,
'status' => $status,
'delay_ms' => $milliseconds,
]);
($this->sleep)($milliseconds);
}
}
Preslikivač odgovora provjerava i deklariranu vrstu medija i PNG potpis. Zadržava zaglavlja povezana s predmemorijom i kvotom bez oslanjanja na nedokumentirana imena zaglavlja. Namjerno ne zapisuje tijela odgovora ni vjerodajnice u zapisnike.
Izradite naredbu za snimku
Izradite app/Console/Commands/CaptureWebsiteSnapshot.php:
<?php
namespace App\Console\Commands;
use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class CaptureWebsiteSnapshot extends Command
{
protected $signature = 'snapshots:capture
{release : Immutable release identifier}
{phase : before or after}
{--url= : Override the configured website URL}
{--force : Replace an existing phase snapshot}';
protected $description = 'Capture a deployment website snapshot';
public function handle(ScreenshotApi $api): int
{
$release = (string) $this->argument('release');
$phase = (string) $this->argument('phase');
$url = (string) ($this->option('url')
?: config('services.screenshot_api.site_url'));
$allowedHost = (string) config('services.screenshot_api.allowed_host');
if (strlen($release) > 100 ||
! preg_match('/\A[A-Za-z0-9._-]+\z/', $release)) {
$this->error('Release identifier contains invalid characters.');
return self::FAILURE;
}
$host = parse_url($url, PHP_URL_HOST);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($phase, ['before', 'after'], true) ||
! filter_var($url, FILTER_VALIDATE_URL) ||
$scheme !== 'https' ||
! is_string($host) ||
strcasecmp($host, $allowedHost) !== 0) {
$this->error('Phase or target URL is not allowed.');
return self::FAILURE;
}
$disk = Storage::disk(
(string) config('services.screenshot_api.disk', 'local')
);
$base = "snapshots/{$release}/{$phase}";
$imagePath = "{$base}.png";
$metadataPath = "{$base}.json";
if ($disk->exists($imagePath) && ! $this->option('force')) {
$this->error('Snapshot already exists; use --force to replace it.');
return self::FAILURE;
}
try {
$capture = $api->capture($url);
$metadata = json_encode([
'release' => $release,
'phase' => $phase,
'url' => $url,
'captured_at' => now()->toIso8601String(),
'bytes' => strlen($capture->png),
'sha256' => hash('sha256', $capture->png),
'content_type' => $capture->contentType,
'operational_headers' => $capture->operationalHeaders,
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (! $disk->put($imagePath, $capture->png) ||
! $disk->put($metadataPath, $metadata)) {
$disk->delete([$imagePath, $metadataPath]);
throw new \RuntimeException('Snapshot storage write failed.');
}
} catch (ScreenshotApiException $exception) {
Log::error('Snapshot capture failed.', [
'release' => $release,
'phase' => $phase,
'host' => $host,
'failure' => $exception->failure,
'status' => $exception->status,
'retry_after' => $exception->retryAfterSeconds,
]);
$this->error("Capture failed: {$exception->failure}.");
return self::FAILURE;
} catch (Throwable $exception) {
Log::error('Snapshot persistence failed.', [
'release' => $release,
'phase' => $phase,
'exception' => $exception::class,
]);
$this->error('Snapshot could not be stored.');
return self::FAILURE;
}
Log::info('Deployment snapshot stored.', [
'release' => $release,
'phase' => $phase,
'path' => $imagePath,
'operational_headers' => $capture->operationalHeaders,
]);
$this->info("Stored {$imagePath}");
return self::SUCCESS;
}
}
Popis dopuštenih naziva hostova sprječava da argument implementacije pretvori naredbu u mehanizam za snimanje proizvoljnih URL-ova. Svaki PNG prati JSON koji sadrži njegov kontrolni zbroj, veličinu, vrijeme snimanja i dostupne metapodatke predmemorije ili kvote.
Smjestite je oko prebacivanja izdanja
Omotajte postojeću operaciju izdanja s provjerom stanja dvjema naredbama. Prebacivanje izdanja mora pri neuspjehu vratiti status koji nije nula:
set -euo pipefail
: "${RELEASE_ID:?Set an immutable RELEASE_ID}"
: "${RELEASE_SWITCH:?Set the release-switch executable}"
php artisan snapshots:capture "$RELEASE_ID" before
"$RELEASE_SWITCH" "$RELEASE_ID"
php artisan snapshots:capture "$RELEASE_ID" after
Budući da se ljuska zaustavlja pri prvom neuspjehu, nedostajuća slika stanja „nakon” znači da implementacija ili snimanje nakon implementacije nije uspjelo. Nemojte snimati stanje „nakon” dok provjera spremnosti aplikacije ne potvrdi da ažurirano izdanje poslužuje promet.
Testirajte uspjeh, ponovne pokušaje i neuspjeh autentikacije
Laravelov Http::fake() održava testove determinističkima i sprječava stvarnu upotrebu API-ja. Izradite tests/Feature/CaptureWebsiteSnapshotTest.php:
<?php
namespace Tests\Feature;
use App\Exceptions\ScreenshotApiException;
use App\Services\ScreenshotApi;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class CaptureWebsiteSnapshotTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.screenshot_api', [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => 'test-token',
'site_url' => 'https://www.example.com',
'allowed_host' => 'www.example.com',
'disk' => 'local',
]);
}
public function test_command_stores_png_and_metadata(): void
{
Storage::fake('local');
$png = "\x89PNG\r\n\x1a\npayload";
Http::fake(fn () => Http::response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private',
]));
$this->artisan('snapshots:capture release-42 before')
->assertSuccessful();
Storage::disk('local')
->assertExists('snapshots/release-42/before.png');
Storage::disk('local')
->assertExists('snapshots/release-42/before.json');
Http::assertSent(fn ($request) =>
$request->url() ===
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?url='.
urlencode('https://www.example.com') &&
$request->hasHeader('Authorization', 'Bearer test-token')
);
}
public function test_server_error_is_retried_then_succeeds(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
Http::fakeSequence()
->push('', 503)
->push($png, 200, ['Content-Type' => 'image/png']);
$service = new ScreenshotApi(static fn (int $milliseconds) => null);
$this->assertSame($png, $service->capture(
'https://www.example.com'
)->png);
Http::assertSentCount(2);
}
public function test_authentication_failure_is_not_retried(): void
{
Http::fake(fn () => Http::response('', 401));
try {
app(ScreenshotApi::class)->capture('https://www.example.com');
$this->fail('Expected authentication failure.');
} catch (ScreenshotApiException $exception) {
$this->assertSame('authentication', $exception->failure);
}
Http::assertSentCount(1);
}
}
Pokrenite skup testova s php artisan test. U produkciji postavite upozorenja za neuspjehe naredbi, ponavljane pogreške prijenosa ili uzvodne usluge, signale niske kvote izložene vraćenim zaglavljima i nedostajuće parove snimaka.
Česti produkcijski neuspjesi
- HTTP 401 ili 403: provjerite token ograničen na uslugu, osobito nakon ponovnog generiranja, a zatim ponovno izgradite predmemoriju konfiguracije Laravela.
- HTTP 429: pregledajte zabilježene metapodatke kvote i ponovnog pokušaja. Nadogradite plan ili smanjite učestalost snimanja umjesto dodavanja agresivnih ponovnih pokušaja.
- Neispravan PNG: uspješan status s neočekivanim tijelom odbacuje se na granici API-ja umjesto da se pohrani kao obmanjujuća slika.
- Podudarne slike stanja prije i nakon: potvrdite da je novo izdanje prošlo provjere spremnosti. Također pregledajte zaglavlja predmemorije jer usluga namjerno podržava predmemorirana snimanja.
- Neuspjesi cilja: provjerite javni DNS, TLS, preusmjeravanja, autentikacijske barijere i je li web-mjesto dostupno vanjskoj usluzi snimanja.
- Neuspjesi pohrane: provjerite vjerodajnice diska, dozvole, kapacitet, politiku zadržavanja i trebaju li datoteke snimaka ostati privatne.
Završni popis za provjeru
- Potvrdite da stvarni token postoji samo u zaštićenoj konfiguraciji okruženja.
- Pokrenite ručno snimanje stanja
beforei otvorite pohranjeni PNG. - Provjerite sadrži li JSON metapodatke kontrolni zbroj i sva vraćena zaglavlja predmemorije ili kvote.
- Potvrdite da se neispravni hostovi i duplicirane putanje snimaka odbacuju.
- Pokrenite automatizirane testove bez vanjskih zahtjeva.
- Izvršite neprodukcijsko izdanje i provjerite dijele li datoteke obje faze isti identifikator izdanja.
- Testirajte neuspjeh implementacije i osigurajte da se naredba „after” ne pokrene.
- Definirajte postupke privatnog zadržavanja pohrane, nadzora i rotacije tokena.
Koristan sustav snimaka više je od API poziva. To je uređena granica implementacije s provjerenim binarnim podacima, ograničenim ponašanjem pri neuspjehu, zaštićenim vjerodajnicama, trajnim dokazima i dovoljno metapodataka za kasnije objašnjenje onoga što se dogodilo. Kada su ti dijelovi postavljeni, svako ažuriranje klijentova web-mjesta ostavlja jednostavan, pouzdan vizualni zapis: ono što su korisnici vidjeli prije i ono što su dobili nakon.