Symfony: Неделни слики од страници за веб-страници на мали бизниси преку Screenshot API
Веб-страницата може да биде технички исправна, а сепак незабележливо визуелно да стане погрешна. Ажурирање на тема го поместува копчето за резервација под преклопот. Недостасувачки ресурс ја остава страницата за услуги полупразна. Уредување на содржината изгледа добро на лаптоп, но го нарушува распоредот во продукција.
За сопственик на мал бизнис, неделната архива на слики од екранот нуди едноставен одговор на важно прашање: што навистина видоа клиентите? Овој туторијал ја гради таа архива како Symfony команда за продукција. Таа ги снима конфигурираните страници преку Screenshot API, го проверува вратениот PNG, зачувува метаподатоци за кешот и квотата и запишува една идемпотентна снимка за секоја ISO недела.
Добијте пристап пред да напишете код за интеграција
Започнете со регистрирање сметка. Ако веќе имате, користете ја страницата за најава.
- Отворете ја страницата на услугата Screenshot API.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Screenshot API.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Чувајте го во конфигурација поддржана од променливи на околината, никогаш во PHP изворен код, фикстури, логови или комитирана конфигурациска датотека.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме формата Bearer бидејќи поверојатно е ингеренциите во низи за барање да се појават во логови за пристап и системи за мониторинг.
Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Третирајте ја ротацијата како операција за распоредување: ажурирајте ја тајната на апликацијата, рестартирајте долготрајни процеси ако е применливо, потврдете снимање и дури потоа сметајте дека ротацијата е завршена.
Потврдете го HTTP договорот
Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Неговиот задолжителен параметар за барање е url, а успешниот одговор содржи тело image/png плус заглавија за одговор поврзани со кешот и квотата.
Пред да ја изградите функционалноста, направете едно минимално барање. Командата ги зачувува заглавијата одделно за да можете да ги прегледате метаподатоците на услугата без да печатите бинарни PNG податоци во терминалот.
export SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
curl --get --silent --show-error --fail \
--header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
--header "Accept: image/png" \
--data-urlencode "url=https://www.example.com/" \
--dump-header smoke.headers \
--output smoke.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file smoke.png
Услугата постои за да снима кеширани PNG слики од екранот за десктоп или мобилен уред, без вашата апликација да мора да инсталира, закрпува и надгледува Chromium. Оваа имплементација намерно го испраќа само гарантираниот параметар url. Ако ви треба одреден режим на снимање, користете само опции документирани на страницата со официјалната документација, наместо да нагаѓате имиња на параметри.
Инсталирајте ги Symfony зависностите
composer require symfony/http-client symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack
Проектот ќе содржи посебна API граница, доменски објект за одговор, атомско складиште во датотечниот систем и конзолна команда:
src/
Command/CaptureWeeklySnapshotsCommand.php
Screenshot/CapturedScreenshot.php
Screenshot/CaptureFailed.php
Screenshot/ScreenshotApiClient.php
Screenshot/SnapshotStore.php
tests/
Screenshot/ScreenshotApiClientTest.php
var/
snapshots/ generated; never committed
Конфигурирајте ги ингеренциите и важните страници
Ставете ги локалните тајни во .env.local, кој треба да остане надвор од контрола на верзии. Во продукција, дајте предност на можноста за тајни или променливи на околината на вашата платформа за хостирање. Трите цели подолу одговараат на типичен мал бизнис со резервации по термин.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
BUSINESS_HOME_URL=https://www.example.com/
BUSINESS_BOOKING_URL=https://www.example.com/book
BUSINESS_CONTACT_URL=https://www.example.com/contact
Мапирајте ги тие променливи во аргументи на конструкторот со Symfony dependency injection:
# config/services.yaml
parameters:
app.snapshot_targets:
homepage: '%env(BUSINESS_HOME_URL)%'
booking: '%env(BUSINESS_BOOKING_URL)%'
contact: '%env(BUSINESS_CONTACT_URL)%'
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $screenshotToken: '%env(SCREENSHOT_API_TOKEN)%'
array $snapshotTargets: '%app.snapshot_targets%'
string $snapshotDirectory: '%kernel.project_dir%/var/snapshots'
App\:
resource: '../src/'
URL-адресите се контролирани од распоредувањето наместо да се прифаќаат преку јавен контролер. Тоа е намерна безбедносна граница: неограничен крај за снимање слики од екранот може да стане скап прокси за произволни URL-адреси.
Изградете дефанзивна граница за Screenshot API
Клиентот подолу ги проверува конфигурираните URL-адреси, применува ограничени лимити за неактивност на врската и вкупно времетраење и повторува само привремени транспортни грешки, HTTP 429 и грешки на серверот. Неуспесите при автентикација и валидација на барањето се враќаат веднаш бидејќи повторните обиди не можат да ги поправат.
Имињата на заглавијата за одговор може да се развиваат, па апликацијата не измислува фиксна шема за квота. Таа ги зачувува стандардните заглавија за кеш, заглавијата што содржат cache и заглавијата чии имиња идентификуваат метаподатоци за квота или ограничување на стапка. Таа ги проверува и декларираниот тип на медиум и PNG потписот пред да му верува на телото.
<?php
// src/Screenshot/CapturedScreenshot.php
namespace App\Screenshot;
final readonly class CapturedScreenshot
{
public function __construct(
public string $png,
public array $cacheHeaders,
public array $quotaHeaders,
public int $attempts,
) {}
}
// src/Screenshot/CaptureFailed.php
namespace App\Screenshot;
final class CaptureFailed extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null,
) {
parent::__construct($message);
}
}
// src/Screenshot/ScreenshotApiClient.php
namespace App\Screenshot;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ScreenshotApiClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';
public function __construct(
private readonly HttpClientInterface $http,
private readonly string $screenshotToken,
private readonly LoggerInterface $logger,
) {}
public function capture(string $url): CapturedScreenshot
{
$parts = parse_url($url);
if (
filter_var($url, FILTER_VALIDATE_URL) === false ||
($parts['scheme'] ?? null) !== 'https' ||
empty($parts['host'])
) {
throw new CaptureFailed('configuration', 'Target must be an HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => ['url' => $url],
'headers' => [
'Authorization' => 'Bearer '.$this->screenshotToken,
'Accept' => 'image/png',
],
'timeout' => 5.0,
'max_duration' => 35.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if ($status >= 200 && $status < 300) {
$body = $response->getContent(false);
$type = strtolower(trim(explode(
';',
$headers['content-type'][0] ?? ''
)[0]));
if (
$type !== 'image/png' ||
strncmp($body, "\x89PNG\r\n\x1a\n", 8) !== 0
) {
throw new CaptureFailed(
'invalid_response',
'The service returned a non-PNG response.',
$status
);
}
[$cache, $quota] = $this->classifyHeaders($headers);
return new CapturedScreenshot(
$body,
$cache,
$quota,
$attempt
);
}
if (in_array($status, [401, 403], true)) {
throw new CaptureFailed(
'authentication',
'The service rejected its token.',
$status
);
}
if ($status >= 400 && $status < 500 && $status !== 429) {
throw new CaptureFailed(
'request',
'The capture request was rejected.',
$status
);
}
if ($attempt === 3) {
throw new CaptureFailed(
$status === 429 ? 'quota' : 'upstream',
'The screenshot service remained unavailable.',
$status
);
}
$this->logger->warning('screenshot.retry', [
'attempt' => $attempt,
'status' => $status,
'target_host' => $parts['host'],
]);
sleep($this->retryDelay($headers, $attempt));
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new CaptureFailed(
'transport',
'The screenshot service could not be reached.'
);
}
$this->logger->warning('screenshot.transport_retry', [
'attempt' => $attempt,
'target_host' => $parts['host'],
'exception' => $exception::class,
]);
sleep($attempt === 1 ? 1 : 3);
}
}
throw new CaptureFailed('internal', 'Capture attempts were exhausted.');
}
private function classifyHeaders(array $headers): array
{
$cache = [];
$quota = [];
foreach ($headers as $name => $values) {
$name = strtolower($name);
if (
str_contains($name, 'cache') ||
in_array($name, ['age', 'etag', 'expires', 'vary'], true)
) {
$cache[$name] = $values;
}
$compact = str_replace('-', '', $name);
if (str_contains($name, 'quota') || str_contains($compact, 'ratelimit')) {
$quota[$name] = $values;
}
}
return [$cache, $quota];
}
private function retryDelay(array $headers, int $attempt): int
{
$value = $headers['retry-after'][0] ?? null;
if (is_string($value) && ctype_digit($value)) {
return max(1, min(30, (int) $value));
}
if (is_string($value) && ($time = strtotime($value)) !== false) {
return max(1, min(30, $time - time()));
}
return $attempt === 1 ? 1 : 3;
}
}
Ограничувањето од триесет секунди спречува злонамерна или погрешна вредност Retry-After да го врзува неделниот процес на неодредено време. 429 сепак станува структурирана грешка quota ако сите обиди се исцрпени, овозможувајќи им на оперативните алатки да ја разликуваат од невалидни ингеренции или неправилна конфигурација.
Складирајте една атомска снимка неделно
Складиштето користи ISO идентификатор за недела, како 2026-W34. Повторното извршување на командата во истата недела ги заменува датотеките за таа недела, наместо да создава дупликати. Компонентата за датотечен систем на Symfony ја запишува секоја датотека преку привремена датотека и преименување, додека ограничените дозволи ја чуваат архивата приватна по подразбирање.
<?php
// src/Screenshot/SnapshotStore.php
namespace App\Screenshot;
use Symfony\Component\Filesystem\Filesystem;
final class SnapshotStore
{
public function __construct(
private readonly string $snapshotDirectory,
private readonly Filesystem $filesystem,
) {}
public function save(
string $name,
string $url,
CapturedScreenshot $capture,
\DateTimeImmutable $capturedAt,
): string {
if (preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
throw new \InvalidArgumentException('Invalid snapshot name.');
}
$week = $capturedAt->format('o-\WW');
$base = rtrim($this->snapshotDirectory, '/').'/'.$name.'/'.$week;
$this->filesystem->mkdir(dirname($base), 0700);
$this->filesystem->dumpFile($base.'.png', $capture->png);
$this->filesystem->dumpFile($base.'.json', json_encode([
'captured_at' => $capturedAt->format(DATE_ATOM),
'url_sha256' => hash('sha256', $url),
'attempts' => $capture->attempts,
'cache_headers' => $capture->cacheHeaders,
'quota_headers' => $capture->quotaHeaders,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
$this->filesystem->chmod([$base.'.png', $base.'.json'], 0600);
return $base.'.png';
}
}
Во метаподатоците влегува само хаш од целната URL-адреса. Тоа избегнува зачувување чувствителни низи за барање, а сепак овозможува откривање на промени во конфигурацијата.
Извршувајте снимања преку Symfony команда
<?php
// src/Command/CaptureWeeklySnapshotsCommand.php
namespace App\Command;
use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotApiClient;
use App\Screenshot\SnapshotStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:snapshots:capture',
description: 'Capture this week’s configured business pages.'
)]
final class CaptureWeeklySnapshotsCommand extends Command
{
public function __construct(
private readonly ScreenshotApiClient $api,
private readonly SnapshotStore $store,
private readonly array $snapshotTargets,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
$failed = false;
$capturedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
foreach ($this->snapshotTargets as $name => $url) {
try {
$capture = $this->api->capture($url);
$path = $this->store->save($name, $url, $capture, $capturedAt);
$this->logger->info('snapshot.captured', [
'target' => $name,
'week' => $capturedAt->format('o-\WW'),
'attempts' => $capture->attempts,
'quota_headers' => $capture->quotaHeaders,
]);
$output->writeln(sprintf('%s: %s', $name, $path));
} catch (CaptureFailed $exception) {
$failed = true;
$this->logger->error('snapshot.failed', [
'target' => $name,
'kind' => $exception->kind,
'status' => $exception->status,
]);
$output->writeln(sprintf(
'<error>%s: %s</error>',
$name,
$exception->getMessage()
));
} catch (\Throwable $exception) {
$failed = true;
$this->logger->error('snapshot.storage_failed', [
'target' => $name,
'exception' => $exception::class,
]);
}
}
return $failed ? Command::FAILURE : Command::SUCCESS;
}
}
Една неуспешна страница не спречува снимање на преостанатите страници, но командата враќа статус различен од нула ако нешто не успеало. Таа рамнотежа ја создава најкорисната архива, а сепак го известува распоредувачот дека можеби е потребна интервенција.
Тестирајте ја границата без да правите мрежни барања
MockHttpClient обезбедува детерминистички транспорт. Овие тестови докажуваат дека валидните PNG податоци се мапираат правилно и дека неуспех при автентикација не се повторува.
<?php
// tests/Screenshot/ScreenshotApiClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotApiClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ScreenshotApiClientTest extends TestCase
{
public function testItMapsPngAndServiceHeaders(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$response = new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'cache-control: public, max-age=60',
'x-quota-remaining: 9',
],
]);
$api = new ScreenshotApiClient(
new MockHttpClient($response),
'test-token',
new NullLogger()
);
$capture = $api->capture('https://www.example.com/');
self::assertSame($png, $capture->png);
self::assertArrayHasKey('cache-control', $capture->cacheHeaders);
self::assertArrayHasKey('x-quota-remaining', $capture->quotaHeaders);
self::assertSame(1, $capture->attempts);
self::assertSame('GET', $response->getRequestMethod());
}
public function testAuthenticationFailureIsNotRetried(): void
{
$calls = 0;
$transport = new MockHttpClient(
function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('denied', ['http_code' => 401]);
}
);
$api = new ScreenshotApiClient(
$transport,
'invalid-token',
new NullLogger()
);
try {
$api->capture('https://www.example.com/');
self::fail('Expected CaptureFailed.');
} catch (CaptureFailed $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(401, $exception->status);
}
self::assertSame(1, $calls);
}
}
php bin/phpunit
php bin/console app:snapshots:capture -vv
find var/snapshots -type f -maxdepth 3 -print
Безбедно распоредете го неделниот распоред
Направете var/snapshots траен низ распоредувањата; ефемерен датотечен систем на контејнер би ја избришал историјата при распоредување. Направете резервна копија според потребите за задржување на бизнисот, чувајте го надвор од јавниот веб-корен и експлицитно одлучете колку години слики треба да останат.
На традиционален Linux домаќин, овој cron запис се извршува рано секој понеделник. flock спречува преклопувачки извршувања да трошат дуплирана квота. Осигурете се дека cron околината ги добива SCREENSHOT_API_TOKEN и трите целни променливи преку механизмот за распоредување; не го додавајте токенот во самата cron команда.
17 3 * * 1 cd /srv/business-site && /usr/bin/flock -n var/weekly-snapshots.lock /usr/bin/php bin/console app:snapshots:capture --env=prod
Поставете предупредување за излезен статус различен од нула и за повторени записи snapshot.failed. Корисни димензии се името на целта, видот на неуспех, HTTP статусот, бројот на обиди и пријавените заглавија за квота. Никогаш не ги логирајте токенот, телото на одговорот или целосната целна URL-адреса.
Вообичаени неуспеси во продукција
- HTTP 401 или 403: токенот недостига, е неточен, е поништен или припаѓа на погрешна услуга. Заменете ја тајната во околината и потврдете ја активацијата.
- HTTP 429: услугата ги ограничува барањата или е достигната достапната квота. Прегледајте ги зачуваните метаподатоци за квота и избраниот план, наместо да создавате неограничена јамка за повторни обиди.
- Невалиден одговор: посредник или неуспех нагоре по текот вратил нешто различно од PNG податоци. Задржете ја структурираната грешка, но не го зачувувајте телото како слика од екранот.
- Транспортен неуспех: проверете ги DNS, политиката за излезен HTTPS и конфигурацијата на проксито. Ограниченото повторување покрива кратки прекини, а не трајни грешки во мрежната политика.
- Празна историја по распоредување: потврдете дека
var/snapshotsе запишлив и траен и дека распоредувачот започнува во наменетиот директориум на изданието.
Конечна листа за проверка
- Токенот доаѓа од панелот Service token на страницата со документација и се доставува преку конфигурација на околината.
- Ниту една ингеренција не се појавува во контрола на изворниот код, фикстури, аргументи на процеси, логови или складирани метаподатоци.
- Секоја конфигурирана URL-адреса користи HTTPS и е контролирана од конфигурацијата за распоредување.
- Рачно извршена команда создава и PNG и JSON датотеки за секоја цел.
- PNG-то се отвора правилно и неговите соодветни метаподатоци содржат заглавија за кеш и квота кога услугата ги обезбедува.
- Тестовите поминуваат без да ја контактираат надворешната услуга.
- Распоредувачот зачувува излезен статус различен од нула, спречува преклопување и запишува во трајно складиште.
- Мониторингот разликува неуспеси при автентикација, барање, квота, нагорна услуга, транспорт и складирање.
Вредноста на овој систем не е само во тоа што прави слики од екранот. Тој ја претвора визуелната состојба на веб-страницата во одговорен неделен запис, додека прелистувачите, ингеренциите, повторните обиди и справувањето со неуспеси ги држи надвор од патот на сопственикот на бизнисот. Месеци подоцна, кога некој ќе праша кога се променила страница, одговорот повеќе не е претпоставка скриена во лог од распоредување. Тоа е слика.