Туториали

Symfony: Weekly Website Page Visual History for Small Businesses with Screenshot API

Symfony: Неделна визуелна историја на веб-страници за мали бизниси со Screenshot API

Веб-страницата може тивко да се промени: погрешно поставен банер, расипан stylesheet, истечена промоција или распоредување што менува важна страница без никој да забележи. За мал бизнис, неделните слики од екранот обезбедуваат едноставен визуелен запис што одговара на практично прашање: „Што гледаа клиентите таа недела?“

Ова упатство го гради тој запис како функционалност на Symfony апликација насочена кон продукција. Конзолна команда снима избрани јавни страници, го зачувува секој PNG во директориум заснован на неделата, бележи оперативни метаподатоци и се однесува предвидливо при мрежни неуспеси, ограничувања на квота, дупликат извршувања и преклопени распореди. Оддалечениот Screenshot API ја обезбедува инфраструктурата на прелистувачот, па апликацијата не мора да управува со Chromium.

Добијте пристап до Screenshot API

Започнете со регистрација на сметка, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Screenshot API.
  2. Изберете достапен Free, Plus или Pro план и завршете го неговото активирање.
  3. Отворете ја официјалната документација за Screenshot API.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Оваа услуга бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе го користиме заглавието Bearer бидејќи акредитивите во низа за барање може да протечат во дневници за пристап, историја на прелистувачот и системи за надзор.

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како операција на распоредување: ажурирајте ја секоја околина што ја користи старата вредност, распоредете ја новата конфигурација на околината, потврдете едно снимање и дури тогаш сметајте ја ротацијата за завршена.

Потврдете ја крајната точка пред да пишувате код за апликацијата

Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Задолжителниот параметар за барање url ја определува страницата што треба да се сними. Успешен одговор содржи тело image/png, како и заглавија на одговор поврзани со кешот и квотата.

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

Командата намерно ги зачувува заглавијата заедно со PNG. Тоа овозможува да се проверат информациите за кешот и квотата без бинарните податоци да се испечатат во терминалот.

Сега поставете ја вистинската акредитива во .env.local, која треба да остане надвор од контрола на верзии. Продукциските платформи треба да ја вбризгаат истата променлива преку нивната функција за управување со тајни или околини.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN

Архитектура за скромна, сигурна архива

Апликацијата користи синхрона Symfony команда наместо Messenger. Едно неделно извршување над кратка, фиксна листа на страници не оправдува уште еден worker процес. Системски распоредувач ја повикува командата, додека Symfony Lock спречува две копии истовремено да ја запишуваат истата недела.

Секое снимање се зачувува во var/screenshots/YYYY-Www/. PNG-датотеката е придружена со JSON придружна датотека што ги содржи нејзините URL, временска ознака, контролна сума, број на HTTP обиди и избрани заглавија за кеш или квота. Придружната датотека претвора директориум со слики во проверлива архива без воведување база на податоци.

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

Овој модел за складирање е намерно локален и едноставен. Одговара за еден хост на апликација за мал бизнис, но директориумот мора да се наоѓа на трајно складиште и да биде вклучен во резервните копии. Повеќе привремени реплики на апликацијата треба да запишуваат во заедничко трајно складиште или да прикачуваат завршени датотеки преку наменски адаптер за складирање.

Инсталирајте и конфигурирајте ги Symfony компонентите

Проектот бара PHP 8.3 или понова верзија и постојна Symfony апликација со достапни Console и вбризгување зависности. Инсталирајте ги HTTP клиентот, датотечниот систем, компонентата за заклучување и алатките за тестирање:

composer require symfony/http-client symfony/filesystem symfony/lock
composer require --dev symfony/test-pack

Дефинирајте ги важните јавни страници во конфигурацијата. Задржувањето на оваа листа под контрола на серверот спречува командата да се претвори во општоприфатлив URL fetcher.

# 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%'

За еден хост, конфигурирајте заклучување поддржано од датотечен систем:

# config/packages/lock.yaml
framework:
  lock: 'flock'

Мапирајте го оддалечениот одговор во доменски објекти

Границата на API треба да враќа нешто позначајно од HTTP одговор. Овие мали класи ги разликуваат успешните снимања од структурираните неуспеси без да ги изложуваат деталите за транспортот на командата.

<?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);
    }
}

Изградете дефанзивен HTTP клиент

Клиентот наметнува HTTPS, применува ограничени временски ограничувања за поврзување и целокупен одговор и повторува само транспортни неуспеси и минливи одговори од серверот. Неуспесите со автентикација, валидација и квота не се повторуваат слепо. Повторувањето на барање ограничено со квота обично троши време без да го подобри исходот.

Бидејќи кодот на апликацијата не треба да нагаѓа недокументирани полиња на одговор, границата нормализира и задржува заглавија чии имиња идентификуваат информации за кеш, квота, ограничување на стапка или повторен обид. Затоа архивата може да ги зачува сервисните метаподатоци без да го поврзува доменскиот слој со измислени имиња на заглавија.

<?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', 'URL-адресата за снимање мора да биде јавна 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('Минлив одговор за слика од екранот; се повторува.', [
                        'host' => $parts['host'],
                        'status' => $status,
                        'attempt' => $attempt,
                    ]);
                    $this->pause($attempt);
                    continue;
                }

                if (401 === $status || 403 === $status) {
                    throw new ScreenshotFailure('authentication', 'Автентикацијата за слика од екранот не успеа.');
                }

                if (400 === $status || 422 === $status) {
                    throw new ScreenshotFailure('validation', 'Барањето за слика од екранот беше одбиено.');
                }

                if (429 === $status) {
                    throw new ScreenshotFailure('quota', 'Достигната е квотата или ограничувањето на стапката за слики од екранот.');
                }

                if ($status < 200 || $status >= 300) {
                    throw new ScreenshotFailure('http', 'Услугата за слики од екранот врати HTTP '.$status.'.');
                }

                $body = $response->getContent(false);
                $contentType = strtolower($headers['content-type'][0] ?? '');

                if (!str_starts_with($contentType, 'image/png')) {
                    throw new ScreenshotFailure('protocol', 'Се очекуваше одговор image/png.');
                }

                if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
                    throw new ScreenshotFailure('protocol', 'Одговорот нема PNG потпис.');
                }

                if (strlen($body) > 20 * 1024 * 1024) {
                    throw new ScreenshotFailure('protocol', 'Сликата од екранот го надминува ограничувањето на апликацијата од 20 MiB.');
                }

                return new ScreenshotCapture(
                    $body,
                    $this->operationalHeaders($headers),
                    $attempt,
                );
            } catch (TransportExceptionInterface $exception) {
                if ($attempt >= 3) {
                    throw new ScreenshotFailure('network', 'Транспортот за сликата од екранот не успеа.', $exception);
                }

                $this->logger->warning('Транспортот за слика од екранот не успеа; се повторува.', [
                    'host' => $parts['host'],
                    'attempt' => $attempt,
                ]);
                $this->pause($attempt);
            }
        }

        throw new ScreenshotFailure('network', 'Обидите за слика од екранот беа исцрпени.');
    }

    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;
    }
}

Имплементирајте ја командата за неделна архива

Командата е идемпотентна: страница што веќе ги има и PNG и метаподатоците се прескокнува. Атомските запишувања во датотечниот систем ја намалуваат веројатноста да остане скратена слика. Ако една страница не успее, командата продолжува да ги снима другите, но на крај враќа излезен код за неуспех за системите за распоредување и надзор да можат да подигнат предупредување.

<?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: 'Архивирај неделни слики од екранот на конфигурираните деловни страници.',
)]
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>Веќе се извршува друго снимање.</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('Неважечко име на страница за слика од екранот.', ['name' => $name]);
                    continue;
                }

                $pngPath = $directory.'/'.$name.'.png';
                $jsonPath = $directory.'/'.$name.'.json';

                if (is_file($pngPath) && is_file($jsonPath)) {
                    $output->writeln('Се прескокнува постојното снимање: '.$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>Снимено '.$name.'</info>');
                } catch (ScreenshotFailure $exception) {
                    $failed = true;
                    $this->logger->error('Неделното снимање на екранот не успеа.', [
                        'page' => $name,
                        'kind' => $exception->kind,
                        'exception' => $exception,
                    ]);
                    $output->writeln('<error>Неуспешно '.$name.': '.$exception->kind.'</error>');
                }
            }
        } finally {
            $lock->release();
        }

        return $failed ? Command::FAILURE : Command::SUCCESS;
    }
}

Тестирајте ја интеграцијата без повикување на услугата

MockHttpClient обезбедува детерминистички транспорт. Еден тест докажува успешно мапирање на PNG и оперативни заглавија; друг осигурува дека неочекувано тело не може тивко да влезе во архивата.

<?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('Се очекуваше неуспех на протокол.');
        } catch (ScreenshotFailure $exception) {
            self::assertSame('protocol', $exception->kind);
        }
    }
}
php bin/phpunit
php bin/console app:screenshots:capture-weekly --env=prod

Безбедност, набљудливост и распоредување

Снимајте само експлицитна листа на дозволени јавни HTTPS страници. Не прифаќајте произволни URL-адреси од контролер или аргумент на команда: оддалечените услуги за слики од екранот инаку може да станат пат до внатрешни системи. Избегнувајте автентицирани администраторски страници, URL-адреси што содржат токени за ресетирање и страници што прикажуваат информации за клиенти.

Никогаш не ги евидентирајте сервисниот токен, заглавието Authorization или целосните опции за барање од исклучоците. Имплементацијата го евидентира конфигурираното име на страницата, целниот хост, категоријата на неуспех, статусот каде што е корисно и обидот за повторување. Испратете предупредување при излез од команда различен од нула, повторени неуспеси authentication или неуспеси quota. Зачуваните заглавија за кеш и квота обезбедуваат дополнителен оперативен контекст без поставување акредитиви во метаподатоците.

Извршувајте ја командата неделно од еден распоредувач. На пример, cron запис за понеделник по UTC може да биде:

17 3 * * 1 cd /srv/business-site && php bin/console app:screenshots:capture-weekly --env=prod

Осигурете се дека var/screenshots е траен, запишлив од корисникот на апликацијата, исклучен од јавно веб-послужување и опфатен со резервни копии. На повеќе реплики, користете Symfony-поддржано заедничко складиште за заклучувања и заедничка архивска дестинација. Воспоставете политика за задржување намерно; тивкото бришење стари слики од екранот ја поништува целта на визуелната историја.

Вообичаени неуспеси за кои вреди да се планира

  • Неуспех на автентикација: потврдете дека променливата на околината е достапна за закажаниот процес, а не само за интерактивна школка. Повторно генериран токен го поништува претходниот.
  • Квота или ограничување на стапка: не повторувајте агресивно во циклус. Прегледајте ги снимените заглавија на одговорот, планирајте ја употребата околу избраниот план и оставете ја неуспешната недела видлива за надзорот.
  • HTML наместо PNG: услугата или посредник вратил неочекуван одговор. Проверките на content-type и PNG-потпис спречуваат оштетени архивски датотеки.
  • Недостигаат средства во сликата од екранот: потврдете дека целната страница и нејзините средства се јавно достапни и не зависат од приватна сесија.
  • Дупликат извршување на распоредувачот: задржете ги заклучувањето и идемпотентните проверки на имиња на датотеки. Во распоредувања со повеќе хостови, заменете го локалното заклучување со заедничко складиште за заклучувања.
  • Празна историја по распоредување: потврдете го монтирањето на трајниот волумен, сопственоста на директориумот, работниот директориум на распоредувачот и името на продукциската околина.

Конечна контролна листа за проверка

  • Планот за услугата е активен и токенот ограничен на услугата се доставува преку околината.
  • Минималното GET барање враќа PNG и изложува заглавија на одговор за проверка.
  • Секоја конфигурирана цел е намерно избрана јавна HTTPS страница.
  • Автоматизираните тестови поминуваат без контакт со вистинскиот API.
  • Едно продукциско извршување на командата создава соодветни датотеки .png и .json.
  • Повторното извршување на командата во истата ISO недела ги прескокнува завршените снимања.
  • Распоредувачот пријавува излези различни од нула и архивскиот директориум има резервна копија.
  • Ниту еден токен, заглавие Authorization или содржина на приватна страница не се појавува во дневници или fixtures.

Завршениот систем е намерно негламурозен: една ограничена HTTP граница, една закажана команда, трајни датотеки, контролни суми, заклучувања и корисни категории на неуспех. Токму таа воздржаност е неговата сила. Недела по недела, тој тивко гради доверлива визуелна меморија на страниците од кои клиентите навистина зависат.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.