Symfony: Tjedna vizualna povijest web-stranice za male tvrtke pomoću Screenshot API-ja
Web-mjesto se može neprimjetno promijeniti: pogrešno postavljen banner, neispravan stilski predložak, istekla promocija ili implementacija koja promijeni važnu stranicu, a da to nitko ne primijeti. Za malo poduzeće tjedne snimke zaslona pružaju jednostavan vizualni zapis koji odgovara na praktično pitanje: „Što su kupci vidjeli tog tjedna?”
Ovaj vodič izrađuje taj zapis kao funkcionalnost Symfony aplikacije usmjerenu na produkciju. Konzolna naredba snima odabrane javne stranice, sprema svaki PNG u direktorij prema tjednu, bilježi operativne metapodatke te se predvidljivo ponaša pri mrežnim neuspjesima, ograničenjima kvote, dupliciranim pokretanjima i preklapajućim rasporedima. Udaljeni Screenshot API pruža infrastrukturu preglednika, pa aplikacija ne mora upravljati Chromiumom.
Dobijte pristup Screenshot API-ju
Počnite tako da registrirate račun ili upotrijebite stranicu za prijavu ako ga već imate.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni Free, Plus ili Pro paket 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.
Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer zaglavlje jer vjerodajnice u nizu upita mogu procuriti u zapisnike pristupa, povijest preglednika i sustave za nadzor.
Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte svako izvršno okruženje koje koristi staru vrijednost, implementirajte novu konfiguraciju okruženja, provjerite snimanje i tek tada smatrajte rotaciju dovršenom.
Potvrdite krajnju točku prije pisanja aplikacijskog koda
Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Obavezni parametar upita url određuje stranicu za snimanje. Uspješan odgovor sadržava tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.
curl --fail-with-body --silent --show-error --get \
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Accept: image/png' \
--data-urlencode 'url=https://example.com/' \
--dump-header /tmp/screenshot-headers.txt \
--output /tmp/home.png
Naredba namjerno sprema zaglavlja kao i PNG. Time je moguće pregledati informacije o predmemoriji i kvoti bez ispisivanja binarnih podataka u terminal.
Sada stvarnu vjerodajnicu smjestite u .env.local, koji treba ostati izvan kontrole verzija. Produkcijske platforme trebaju unijeti istu varijablu putem svojeg mehanizma za upravljanje tajnama ili okruženjem.
# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
Arhitektura za skromnu, pouzdanu arhivu
Aplikacija koristi sinkronu Symfony naredbu umjesto Messengera. Jedno tjedno pokretanje nad kratkim, fiksnim popisom stranica ne opravdava dodatni radnički proces. Sustavski raspoređivač poziva naredbu, dok Symfony Lock sprječava da dvije kopije istodobno zapisuju isti tjedan.
Svaka se snimka sprema pod var/screenshots/YYYY-Www/. PNG prati JSON popratna datoteka koja sadržava URL, vremensku oznaku, kontrolni zbroj, broj HTTP pokušaja i odabrana zaglavlja predmemorije ili kvote. Popratna datoteka pretvara direktorij slika u arhivu pogodnu za reviziju bez uvođenja baze podataka.
src/
Command/CaptureWeeklyScreenshotsCommand.php
Screenshot/ScreenshotCapture.php
Screenshot/ScreenshotClient.php
Screenshot/ScreenshotFailure.php
tests/
Screenshot/ScreenshotClientTest.php
var/
screenshots/
2026-W41/
home.png
home.json
Ovaj model pohrane namjerno je lokalni i jednostavan. Prikladan je za jednog domaćina aplikacije malog poduzeća, ali direktorij mora biti na trajnoj pohrani i uključen u sigurnosne kopije. Više potrošnih replika aplikacije treba zapisivati u zajedničku trajnu pohranu ili učitavati dovršene datoteke putem namjenskog prilagodnika pohrane.
Instalirajte i konfigurirajte Symfony komponente
Projekt zahtijeva PHP 8.3 ili noviji te postojeću Symfony aplikaciju s dostupnim Consoleom i ubacivanjem ovisnosti. Instalirajte HTTP klijent, datotečni sustav, komponentu zaključavanja i alate za testiranje:
composer require symfony/http-client symfony/filesystem symfony/lock
composer require --dev symfony/test-pack
Definirajte važne javne stranice u konfiguraciji. Zadržavanje ovog popisa pod kontrolom poslužitelja sprječava pretvaranje naredbe u dohvaćač URL-ova opće namjene.
# config/services.yaml
parameters:
app.screenshot_pages:
- { name: 'home', url: 'https://www.example.com/' }
- { name: 'services', url: 'https://www.example.com/services' }
- { name: 'contact', url: 'https://www.example.com/contact' }
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
Symfony\Component\Filesystem\Filesystem: ~
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(string:SCREENSHOT_API_TOKEN)%'
$endpoint: 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
App\Command\CaptureWeeklyScreenshotsCommand:
arguments:
$pages: '%app.screenshot_pages%'
$projectDir: '%kernel.project_dir%'
Za jednog domaćina konfigurirajte zaključavanje podržano datotečnim sustavom:
# config/packages/lock.yaml
framework:
lock: 'flock'
Mapirajte udaljeni odgovor u domenske objekte
Granica API-ja trebala bi vratiti nešto smislenije od HTTP odgovora. Ove male klase razlikuju uspješna snimanja od strukturiranih neuspjeha bez izlaganja pojedinosti prijenosa naredbi.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
public function __construct(
public string $png,
public array $serviceHeaders,
public int $attempts,
) {
}
}
// src/Screenshot/ScreenshotFailure.php
namespace App\Screenshot;
final class ScreenshotFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Izgradite obrambeni HTTP klijent
Klijent provodi HTTPS, primjenjuje ograničena vremenska ograničenja za povezivanje i cjelokupni odgovor te ponovno pokušava samo pri neuspjesima prijenosa i prolaznim odgovorima poslužitelja. Neuspjesi autentifikacije, validacije i kvote ne pokušavaju se naslijepo ponovno. Ponavljanje zahtjeva ograničenog kvotom obično troši vrijeme bez poboljšanja ishoda.
Budući da aplikacijski kod ne bi trebao nagađati nedokumentirana polja odgovora, granica normalizira i zadržava zaglavlja čiji nazivi označavaju informacije o predmemoriji, kvoti, ograničenju stope ili ponovnom pokušaju. Arhiva stoga može sačuvati metapodatke usluge bez povezivanja domenskog sloja s izmišljenim nazivima zaglavlja.
<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class ScreenshotClient
{
public function __construct(
private HttpClientInterface $http,
private string $token,
private string $endpoint,
private LoggerInterface $logger,
) {
}
public function capture(string $url): ScreenshotCapture
{
$parts = parse_url($url);
if (
false === $parts
|| 'https' !== ($parts['scheme'] ?? null)
|| !isset($parts['host'])
|| isset($parts['user'])
) {
throw new ScreenshotFailure('validation', 'Capture URL must be a public HTTPS URL.');
}
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('GET', $this->endpoint, [
'query' => ['url' => $url],
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'image/png',
],
'timeout' => 20.0,
'max_duration' => 45.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if (in_array($status, [500, 502, 503, 504], true) && $attempt < 3) {
$this->logger->warning('Transient screenshot response; retrying.', [
'host' => $parts['host'],
'status' => $status,
'attempt' => $attempt,
]);
$this->pause($attempt);
continue;
}
if (401 === $status || 403 === $status) {
throw new ScreenshotFailure('authentication', 'Screenshot authentication failed.');
}
if (400 === $status || 422 === $status) {
throw new ScreenshotFailure('validation', 'Screenshot request was rejected.');
}
if (429 === $status) {
throw new ScreenshotFailure('quota', 'Screenshot quota or rate limit was reached.');
}
if ($status < 200 || $status >= 300) {
throw new ScreenshotFailure('http', 'Screenshot service returned HTTP '.$status.'.');
}
$body = $response->getContent(false);
$contentType = strtolower($headers['content-type'][0] ?? '');
if (!str_starts_with($contentType, 'image/png')) {
throw new ScreenshotFailure('protocol', 'Expected an image/png response.');
}
if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotFailure('protocol', 'Response does not have a PNG signature.');
}
if (strlen($body) > 20 * 1024 * 1024) {
throw new ScreenshotFailure('protocol', 'Screenshot exceeds the 20 MiB application limit.');
}
return new ScreenshotCapture(
$body,
$this->operationalHeaders($headers),
$attempt,
);
} catch (TransportExceptionInterface $exception) {
if ($attempt >= 3) {
throw new ScreenshotFailure('network', 'Screenshot transport failed.', $exception);
}
$this->logger->warning('Screenshot transport failed; retrying.', [
'host' => $parts['host'],
'attempt' => $attempt,
]);
$this->pause($attempt);
}
}
throw new ScreenshotFailure('network', 'Screenshot attempts were exhausted.');
}
private function pause(int $attempt): void
{
$milliseconds = min(2000, 250 * (2 ** ($attempt - 1))) + random_int(0, 100);
usleep($milliseconds * 1000);
}
private function operationalHeaders(array $headers): array
{
$selected = [];
foreach ($headers as $name => $values) {
$normalized = strtolower($name);
if (
str_contains($normalized, 'cache')
|| str_contains($normalized, 'quota')
|| str_contains($normalized, 'rate-limit')
|| str_contains($normalized, 'ratelimit')
|| 'retry-after' === $normalized
) {
$selected[$normalized] = implode(', ', $values);
}
}
return $selected;
}
}
Implementirajte naredbu za tjednu arhivu
Naredba je idempotentna: stranica za koju već postoje i PNG i metapodaci preskače se. Atomsko zapisivanje u datotečni sustav smanjuje mogućnost ostavljanja skraćene slike. Ako jedna stranica ne uspije, naredba nastavlja snimati ostale, ali na kraju vraća izlazni kod neuspjeha kako bi sustavi za raspoređivanje i nadzor mogli podići upozorenje.
<?php
// src/Command/CaptureWeeklyScreenshotsCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
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;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Lock\LockFactory;
#[AsCommand(
name: 'app:screenshots:capture-weekly',
description: 'Archive weekly screenshots of configured business pages.',
)]
final class CaptureWeeklyScreenshotsCommand extends Command
{
public function __construct(
private readonly ScreenshotClient $client,
private readonly Filesystem $filesystem,
private readonly LockFactory $locks,
private readonly LoggerInterface $logger,
private readonly array $pages,
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$lock = $this->locks->createLock('weekly-screenshot-archive', 900);
if (!$lock->acquire()) {
$output->writeln('<comment>Another capture is already running.</comment>');
return Command::SUCCESS;
}
$failed = false;
try {
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$week = $now->format('o-\WW');
$directory = $this->projectDir.'/var/screenshots/'.$week;
$this->filesystem->mkdir($directory, 0750);
foreach ($this->pages as $page) {
$name = (string) ($page['name'] ?? '');
$url = (string) ($page['url'] ?? '');
if (1 !== preg_match('/\A[a-z0-9][a-z0-9-]*\z/D', $name)) {
$failed = true;
$this->logger->error('Invalid screenshot page name.', ['name' => $name]);
continue;
}
$pngPath = $directory.'/'.$name.'.png';
$jsonPath = $directory.'/'.$name.'.json';
if (is_file($pngPath) && is_file($jsonPath)) {
$output->writeln('Skipping existing capture: '.$name);
continue;
}
try {
$capture = $this->client->capture($url);
$this->filesystem->dumpFile($pngPath, $capture->png);
$metadata = [
'page' => $name,
'url' => $url,
'captured_at' => $now->format(DATE_ATOM),
'sha256' => hash('sha256', $capture->png),
'attempts' => $capture->attempts,
'service_headers' => $capture->serviceHeaders,
];
$this->filesystem->dumpFile(
$jsonPath,
json_encode(
$metadata,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)."\n"
);
$output->writeln('<info>Captured '.$name.'</info>');
} catch (ScreenshotFailure $exception) {
$failed = true;
$this->logger->error('Weekly screenshot failed.', [
'page' => $name,
'kind' => $exception->kind,
'exception' => $exception,
]);
$output->writeln('<error>Failed '.$name.': '.$exception->kind.'</error>');
}
}
} finally {
$lock->release();
}
return $failed ? Command::FAILURE : Command::SUCCESS;
}
}
Testirajte integraciju bez pozivanja usluge
MockHttpClient pruža deterministički prijenos. Jedan test dokazuje uspješno mapiranje PNG-a i operativnih zaglavlja; drugi osigurava da neočekivano tijelo ne može neprimjetno ući u arhivu.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ScreenshotClientTest extends TestCase
{
public function testMapsPngAndOperationalHeaders(): void
{
$png = "\x89PNG\r\n\x1a\nfake-payload";
$response = new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'x-cache-test: HIT',
'x-quota-test: 42',
],
]);
$client = new ScreenshotClient(
new MockHttpClient($response),
'TEST_TOKEN',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
new NullLogger(),
);
$result = $client->capture('https://www.example.com/');
self::assertSame($png, $result->png);
self::assertSame(1, $result->attempts);
self::assertSame('HIT', $result->serviceHeaders['x-cache-test']);
}
public function testRejectsNonPngResponse(): void
{
$response = new MockResponse('not an image', [
'http_code' => 200,
'response_headers' => ['content-type: text/plain'],
]);
$client = new ScreenshotClient(
new MockHttpClient($response),
'TEST_TOKEN',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
new NullLogger(),
);
try {
$client->capture('https://www.example.com/');
self::fail('Expected a protocol failure.');
} catch (ScreenshotFailure $exception) {
self::assertSame('protocol', $exception->kind);
}
}
}
php bin/phpunit
php bin/console app:screenshots:capture-weekly --env=prod
Sigurnost, vidljivost i implementacija
Snimajte samo izričit popis dopuštenih javnih HTTPS stranica. Ne prihvaćajte proizvoljne URL-ove iz kontrolera ili argumenta naredbe: udaljene usluge snimanja zaslona inače mogu postati put do internih sustava. Izbjegavajte autentificirane administratorske stranice, URL-ove koji sadržavaju tokene za poništavanje i stranice koje prikazuju podatke kupaca.
Nikada ne bilježite servisni token, zaglavlje Authorization ni potpune opcije zahtjeva iznimke. Implementacija bilježi konfigurirani naziv stranice, ciljni host, kategoriju neuspjeha, status kada je koristan te pokušaj ponavljanja. Postavite upozorenje za izlaz naredbe različit od nule, ponovljene neuspjehe authentication ili neuspjehe quota. Spremljena zaglavlja predmemorije i kvote pružaju dodatni operativni kontekst bez smještanja vjerodajnica u metapodatke.
Pokrenite naredbu tjedno iz jednog raspoređivača. Primjerice, cron unos za ponedjeljak po UTC-u može biti:
17 3 * * 1 cd /srv/business-site && php bin/console app:screenshots:capture-weekly --env=prod
Osigurajte da je var/screenshots trajan, da u njega može pisati korisnik aplikacije, da je isključen iz javnog posluživanja weba i obuhvaćen sigurnosnim kopijama. Na više replika koristite zajedničko spremište zaključavanja koje podržava Symfony i zajedničko odredište arhive. Namjerno uspostavite pravilo zadržavanja; neprimjetno brisanje starih snimki zaslona poništava svrhu vizualne povijesti.
Uobičajeni neuspjesi koje vrijedi planirati
- Neuspjeh autentifikacije: potvrdite da je varijabla okruženja dostupna zakazanom procesu, a ne samo interaktivnoj ljusci. Ponovno generirani token poništava prethodni.
- Kvota ili ograničenje stope: nemojte agresivno ponavljati u petlji. Pregledajte zabilježena zaglavlja odgovora, planirajte upotrebu prema odabranom paketu i ostavite neuspjeli tjedan vidljivim nadzoru.
- HTML umjesto PNG-a: usluga ili posrednik vratili su neočekivan odgovor. Provjere vrste sadržaja i PNG potpisa sprječavaju oštećene datoteke arhive.
- Nedostajuća sredstva na snimci zaslona: provjerite jesu li ciljna stranica i njezina sredstva javno dostupni te ne ovise o privatnoj sesiji.
- Duplicirano izvršavanje raspoređivača: zadržite zaključavanje i idempotentne provjere naziva datoteka. U implementacijama s više domaćina zamijenite lokalno zaključavanje zajedničkim spremištem zaključavanja.
- Prazna povijest nakon implementacije: potvrdite montiranje trajnog volumena, vlasništvo direktorija, radni direktorij raspoređivača i naziv produkcijskog okruženja.
Završni kontrolni popis za provjeru
- Paket usluge je aktivan, a token ograničen na uslugu dostavlja se kroz okruženje.
- Minimalni GET zahtjev vraća PNG i izlaže zaglavlja odgovora za pregled.
- Svaki konfigurirani cilj namjerna je javna HTTPS stranica.
- Automatizirani testovi prolaze bez kontaktiranja stvarnog API-ja.
- Pokretanje naredbe u produkciji stvara odgovarajuće datoteke
.pngi.json. - Ponovno pokretanje naredbe u istom ISO tjednu preskače dovršena snimanja.
- Raspoređivač prijavljuje izlaze različite od nule, a direktorij arhive sigurnosno se kopira.
- Nijedan token, zaglavlje Authorization ni sadržaj privatne stranice ne pojavljuje se u zapisnicima ili datotekama za testiranje.
Dovršeni sustav namjerno je neupadljiv: jedna ograničena HTTP granica, jedna zakazana naredba, trajne datoteke, kontrolni zbrojevi, zaključavanja i korisne kategorije neuspjeha. Ta je suzdržanost njegova snaga. Iz tjedna u tjedan tiho gradi pouzdano vizualno sjećanje na stranice o kojima kupci doista ovise.