Symfony: Tjedne snimke stranica za web-mjesta malih poduzeća putem Screenshot API-ja
Web-mjesto može biti tehnički ispravno, a istodobno neprimjetno postajati vizualno pogrešno. Ažuriranje teme pomakne gumb za rezervaciju ispod prijeloma stranice. Resurs koji nedostaje ostavi stranicu usluga napola praznom. Uređivanje sadržaja izgleda dobro na prijenosnom računalu, ali naruši produkcijski raspored.
Za vlasnika male tvrtke tjedna arhiva snimaka zaslona daje jednostavan odgovor na važno pitanje: što su kupci zapravo vidjeli? Ovaj vodič izgrađuje tu arhivu kao produkcijsku Symfony naredbu. Snima konfigurirane stranice putem Screenshot API-ja, provjerava vraćeni PNG, čuva metapodatke predmemorije i kvote te zapisuje jedan idempotentan snimak po ISO tjednu.
Pribavite pristup prije pisanja integracijskog koda
Započnite registracijom računa. Ako ga već imate, upotrijebite stranicu za prijavu.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju Screenshot API-ja.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
- Pohranite ga u konfiguraciju koja se oslanja na okruženje, nikada u PHP izvorni kôd, fixtureove, zapisnike ili predanu konfiguracijsku datoteku.
Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer je vjerojatnije da će se vjerodajnice u nizovima upita pojaviti u zapisnicima pristupa i sustavima za nadzor.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte tajnu aplikacije, ponovno pokrenite dugotrajne procese ako je primjenjivo, provjerite snimanje i tek tada smatrajte rotaciju dovršenom.
Potvrdite HTTP ugovor
Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita je url, a uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.
Prije izgradnje značajke napravite jedan minimalni zahtjev. Naredba zasebno sprema zaglavlja kako biste mogli pregledati metapodatke usluge bez ispisivanja binarnih PNG podataka u terminal.
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
Usluga postoji kako bi snimala predmemorirane PNG snimke zaslona za stolna računala ili mobilne uređaje, bez potrebe da vaša aplikacija instalira, zakrpava i nadzire Chromium. Ova implementacija namjerno šalje samo zajamčeni parametar url. Ako trebate određeni način snimanja, upotrijebite samo opcije dokumentirane na službenoj stranici dokumentacije umjesto nagađanja naziva parametara.
Instalirajte Symfony ovisnosti
composer require symfony/http-client symfony/filesystem symfony/monolog-bundle
composer require --dev symfony/test-pack
Projekt će sadržavati namjensku API granicu, objekt odgovora domene, atomsko spremište datotečnog sustava i konzolnu naredbu:
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
Konfigurirajte vjerodajnice i važne stranice
Lokalne tajne stavite u .env.local, koji bi trebao ostati izvan kontrole verzija. U produkciji radije upotrijebite mogućnost tajni ili varijabli okruženja svoje hosting platforme. Tri cilja u nastavku odgovaraju tipičnoj maloj tvrtki koja posluje po terminima.
# .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
Mapirajte te varijable u argumente konstruktora pomoću Symfony ubrizgavanja ovisnosti:
# 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-ovima upravlja implementacija, umjesto da se prihvaćaju putem javnog kontrolera. To je namjerna sigurnosna granica: neograničena krajnja točka za snimke zaslona može postati skup proxy za proizvoljne URL-ove.
Izgradite obrambenu granicu Screenshot API-ja
Klijent u nastavku provjerava konfigurirane URL-ove, primjenjuje ograničena ograničenja neaktivnosti veze i ukupnog trajanja te ponovno pokušava samo prolazne transportne pogreške, HTTP 429 i pogreške poslužitelja. Pogreške autentifikacije i provjere zahtjeva vraćaju se odmah jer ih ponovni pokušaji ne mogu popraviti.
Nazivi zaglavlja odgovora mogu se mijenjati, stoga aplikacija ne izmišlja fiksnu shemu kvote. Čuva standardna zaglavlja predmemorije, zaglavlja koja sadrže cache i zaglavlja čiji nazivi identificiraju metapodatke kvote ili ograničenja stope. Također provjerava i deklariranu vrstu medija i PNG potpis prije nego što vjeruje tijelu.
<?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;
}
}
Ograničenje od trideset sekundi sprječava da zlonamjerna ili pogrešna vrijednost Retry-After neograničeno blokira tjedni proces. HTTP 429 i dalje postaje strukturirana pogreška quota ako su svi pokušaji iscrpljeni, što alatima za operacije omogućuje da je razlikuju od nevažećih vjerodajnica ili neispravne konfiguracije.
Pohranite jedan atomski snimak po tjednu
Spremište koristi identifikator ISO tjedna poput 2026-W34. Ponovno pokretanje naredbe tijekom istog tjedna zamjenjuje datoteke tog tjedna umjesto stvaranja duplikata. Symfonyjeva komponenta datotečnog sustava zapisuje svaku datoteku putem privremene datoteke i preimenovanja, dok restriktivne dozvole arhivu prema zadanim postavkama održavaju privatnom.
<?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';
}
}
U metapodatke ulazi samo hash ciljnog URL-a. Time se izbjegava čuvanje osjetljivih nizova upita, a istodobno se promjene konfiguracije i dalje mogu otkriti.
Pokrenite snimanja putem Symfony naredbe
<?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;
}
}
Jedna neuspješna stranica ne sprječava snimanje preostalih stranica, ali naredba vraća status različit od nule ako je bilo što neuspješno. Ta ravnoteža stvara najkorisniju arhivu, a istodobno obavještava raspoređivač da bi mogla biti potrebna intervencija.
Testirajte granicu bez mrežnih zahtjeva
MockHttpClient pruža deterministički transport. Ovi testovi dokazuju da se valjani PNG podaci ispravno mapiraju i da se pogreška autentifikacije ne pokušava ponovno.
<?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
Sigurno implementirajte tjedni raspored
Učinite var/snapshots trajnim između implementacija; efemerni datotečni sustav spremnika izbrisao bi povijest tijekom implementacije. Izradite sigurnosnu kopiju prema potrebama zadržavanja tvrtke, držite ga izvan javnog web-korijena i izričito odlučite koliko godina slike trebaju ostati.
Na tradicionalnom Linux poslužitelju ovaj cron unos pokreće se rano svakog ponedjeljka. flock sprječava da preklapajuća izvršavanja troše dvostruku kvotu. Osigurajte da cron okruženje dobiva SCREENSHOT_API_TOKEN i tri ciljne varijable putem mehanizma implementacije; nemojte dodavati token samoj cron naredbi.
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
Upozorite na izlazni status različit od nule i na ponovljene zapise snapshot.failed. Korisne dimenzije su naziv cilja, vrsta neuspjeha, HTTP status, broj pokušaja i prijavljena zaglavlja kvote. Nikada ne zapisujte token, tijelo odgovora ni puni ciljni URL.
Uobičajeni produkcijski kvarovi
- HTTP 401 ili 403: token nedostaje, netočan je, opozvan je ili pripada pogrešnoj usluzi. Zamijenite tajnu okruženja i provjerite aktivaciju.
- HTTP 429: usluga ograničava zahtjeve ili je dostupna kvota dosegnuta. Pregledajte sačuvane metapodatke kvote i odabrani plan umjesto stvaranja neograničene petlje ponovnih pokušaja.
- Nevaljan odgovor: posrednička ili uzvodna pogreška vratila je nešto drugo osim PNG podataka. Zadržite strukturiranu pogrešku, ali nemojte spremati tijelo kao snimku zaslona.
- Transportna pogreška: provjerite DNS, pravila za odlazni HTTPS i konfiguraciju proxyja. Ograničeni ponovni pokušaj rješava kratke prekide, a ne trajne pogreške mrežnih pravila.
- Prazna povijest nakon implementacije: potvrdite da je
var/snapshotszapisiv i trajan te da raspoređivač počinje u predviđenom direktoriju izdanja.
Završni kontrolni popis za provjeru
- Token dolazi s ploče Service token na stranici dokumentacije i dostavlja se putem konfiguracije okruženja.
- Nijedna vjerodajnica ne pojavljuje se u kontroli izvornog koda, fixtureovima, argumentima procesa, zapisnicima ni pohranjenim metapodacima.
- Svaki konfigurirani URL koristi HTTPS i njime upravlja konfiguracija implementacije.
- Ručna naredba stvara i PNG i JSON datoteke za svaki cilj.
- PNG se ispravno otvara, a njegovi odgovarajući metapodaci sadrže zaglavlja predmemorije i kvote kada ih usluga pruža.
- Testovi prolaze bez kontakta s vanjskom uslugom.
- Raspoređivač čuva izlazni status različit od nule, sprječava preklapanje i zapisuje u trajnu pohranu.
- Nadzor razlikuje neuspjehe autentifikacije, zahtjeva, kvote, uzvodne usluge, transporta i pohrane.
Vrijednost ovog sustava nije samo u tome što izrađuje snimke zaslona. On vizualno stanje web-mjesta pretvara u provjerljiv tjedni zapis, dok preglednike, vjerodajnice, ponovne pokušaje i rukovanje neuspjesima drži podalje od vlasnika tvrtke. Mjesecima kasnije, kada netko pita kada se stranica promijenila, odgovor više nije nagađanje skriveno u zapisniku implementacije. To je slika.