Laravel: Автоматизирајте ги снимките пред и по ажурирањето на веб-страниците на клиентите со Screenshot API
Ажурирањето на веб-страница може да успее технички, а сепак да воведе визуелна регресија: недостасувачка главна слика, неочекуван резервен фонт или навигациска лента што се прекршува на погрешна пресвртна точка. Без сигурна слика „пред“, докажувањето што се променило станува нагаѓање.
Овој туторијал изработува работен тек на Laravel ориентиран кон продукциска употреба, кој снима PNG слики од екранот непосредно пред и по ажурирањето на веб-страницата на клиентот. Процесот на распоредување повикува синхрона Artisan команда, додека посветена услуга се справува со автентикација, истекувања на време, повторни обиди, валидација на одговори, сигнали за квота и складирање.
Добијте пристап до Screenshot API
Прво, регистрирајте сметка или најавете се. Отворете ја страницата на услугата Screenshot API, изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
Потоа, отворете ја официјалната документација за Screenshot API. Најдете го панелот Service token и копирајте го токенот со опсег на услуга што е прикажан таму. Оваа услуга бара автентикација; не е API без токен.
Повторното генерирање на сервисниот токен го повлекува претходно активниот токен. Третирајте го повторното генерирање како ротација на акредитиви: ажурирајте ја секоја распоредена околина што ја користи старата вредност, повторно изградете ја кешираната Laravel конфигурација, потврдете ја интеграцијата и дури тогаш сметајте дека ротацијата е завршена.
Потврдете го точниот договор за барањето
Операцијата за снимање е HTTP GET барање до https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Потребен е параметар за барање url и прифаќа автентикација преку Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме Bearer формата, така што акредитивите никогаш нема да се појават во URL-адреси или посреднички дневници за пристап.
Извршете еден минимален тест пред да напишете интеграциски код:
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
Телото на успешен одговор е image/png. Проверете го response-headers.txt, како и сликата: заглавијата за кеш и квота се оперативни податоци, а не украсни метаподатоци. Нивните точни имиња и достапност треба да се користат дефанзивно, наместо да се претпоставуваат.
Сега ставете ги акредитивот и стандардните вредности за распоредување во конфигурацијата на околината на Laravel. Никогаш не го зачувувајте вистинскиот токен во репозиториум.
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://www.example.com
CLIENT_SITE_HOST=www.example.com
SCREENSHOT_DISK=local
Додајте го овој запис во низата што ја враќа 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'),
],
Ако продукцијата користи кеширана конфигурација, извршете php artisan config:cache за време на распоредувањето, откако ќе ги ажурирате вредностите на околината.
Изберете архитектура безбедна за распоредување
Проектот користи синхрона команда наместо задача во редица. Снимката „пред“ мора да заврши пред да се променат изданијата, додека снимката „по“ мора да се направи само откако новото издание ќе ја помине проверката на подготвеност. Редицата би го ослабнала тој редослед, освен ако распоредувањето не чека и завршување на задачата.
Добиената структура е намерно мала:
app/Data/ScreenshotCapture.php
app/Exceptions/ScreenshotApiException.php
app/Services/ScreenshotApi.php
app/Console/Commands/CaptureWebsiteSnapshot.php
config/services.php
tests/Feature/CaptureWebsiteSnapshotTest.php
Користете PHP 8.3 или понов, Laravel апликација со нејзиниот вграден HTTP клиент, Laravel диск на датотечниот систем со можност за запишување и идентификатор за распоредување, како што е непроменлив број на издание или хеш на commit.
Пресликајте го далечинскиот одговор во доменот
Остатокот од апликацијата не треба да манипулира со произволен HTTP одговор. Создадете app/Data/ScreenshotCapture.php:
<?php
namespace App\Data;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public string $contentType,
public array $operationalHeaders,
) {}
}
Создадете app/Exceptions/ScreenshotApiException.php. Неговата стабилна категорија на неуспех им овозможува на командите, дневниците и известувањата да разликуваат неуспеси во конфигурацијата, автентикацијата, квотата, барањето, транспортот, надворешната услуга и невалидниот одговор.
<?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);
}
}
Изградете го ограничениот HTTP клиент
Создадете app/Services/ScreenshotApi.php. Клиентот повторува обиди за неуспеси во поврзувањето и серверски грешки со кратко експоненцијално одложување. Одговор за квота се повторува само еднаш, само кога содржи мала нумеричка вредност Retry-After. Автентикацијата и другите клиентски грешки никогаш не се повторуваат безусловно.
<?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);
}
}
Пресликувачот на одговори ги проверува и декларираниот тип на медиум и PNG потписот. Ги зачувува заглавијата поврзани со кешот и квотата без да зависи од недокументирани имиња на заглавија. Намерно не ги евидентира телата на одговорите или акредитивите.
Создадете ја командата за снимка
Создадете 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;
}
}
Листата на дозволени имиња на хостови спречува аргумент од распоредувањето да ја претвори командата во механизам за снимање произволни URL-адреси. Секој PNG е придружен со JSON што ги содржи неговата контролна сума, големина, време на снимање и достапни метаподатоци за кеш или квота.
Поставете ја околу префрлањето на изданието
Обвијте ја вашата постоечка операција за издание, проверена со здравствена проверка, со двете команди. Префрлањето на изданието мора да врати ненулта состојба при неуспех:
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
Бидејќи школката запира при првиот неуспех, недостасувачка слика „по“ значи дека не успеало или распоредувањето или снимањето по распоредувањето. Не снимајте „по“ додека проверката на подготвеност на апликацијата не потврди дека ажурираното издание опслужува сообраќај.
Тестирајте успех, повторни обиди и неуспех на автентикација
Http::fake() на Laravel ги одржува тестовите детерминистички и спречува реално користење на API. Создадете 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);
}
}
Извршете го пакетот тестови со php artisan test. Во продукција, поставете известувања за неуспеси на командите, повторени транспортни грешки или грешки од надворешната услуга, сигнали за ниска квота изложени преку вратените заглавија и недостасувачки парови снимки.
Вообичаени продукциски неуспеси
- HTTP 401 или 403: потврдете го токенот со опсег на услуга, особено по повторно генерирање, а потоа повторно изградете го кешот на конфигурацијата на Laravel.
- HTTP 429: проверете ги евидентираните метаподатоци за квота и повторен обид. Надградете го планот или намалете ја зачестеноста на снимање, наместо да додавате агресивни повторни обиди.
- Невалиден PNG: успешен статус со неочекувано тело се отфрла на границата на API, наместо да се складира како погрешно наведувачка слика.
- Совпаѓачки слики „пред“ и „по“: потврдете дека новото издание ги поминало проверките на подготвеност. Проверете ги и заглавијата за кеш, бидејќи услугата намерно поддржува кеширани снимања.
- Неуспеси на целта: проверете ги јавниот DNS, TLS, пренасочувањата, ѕидовите за автентикација и дали веб-страницата е достапна од надворешна услуга за снимање.
- Неуспеси при складирање: проверете ги акредитивите за дискот, дозволите, капацитетот, политиката за задржување и дали датотеките со снимки треба да останат приватни.
Конечна контролна листа за потврда
- Потврдете дека вистинскиот токен постои само во заштитената конфигурација на околината.
- Извршете рачно снимање
beforeи отворете го складираниот PNG. - Потврдете дека JSON метаподатоците содржат контролна сума и сите вратени заглавија за кеш или квота.
- Потврдете дека невалидните хостови и дупликатните патеки на снимки се отфрлаат.
- Извршете ги автоматизираните тестови без да правите надворешни барања.
- Извршете непродукциско издание и потврдете дека датотеките од двете фази го делат истиот идентификатор на издание.
- Тестирајте неуспех при распоредување и осигурете се дека командата „по“ не се извршува.
- Дефинирајте процедури за приватно задржување во складиштето, надгледување и ротација на токени.
Корисен систем за снимки е повеќе од API повик. Тој е подредена граница на распоредување со валидирани бинарни податоци, ограничено однесување при неуспех, заштитени акредитиви, трајни докази и доволно метаподатоци за подоцна да се објасни што се случило. Откако овие делови ќе бидат поставени, секое ажурирање на веб-страницата на клиентот остава зад себе едноставен, доверлив визуелен запис: што виделе корисниците претходно и што добиле потоа.