Туториали

Laravel: Weekly Page Snapshots for Business Owners with Screenshot API

Laravel: Неделни снимки од страници за сопственици на бизниси со Screenshot API

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

Овој туторијал ја создава таа архива во Laravel. Закажана команда испраќа една задача во редица за секоја важна страница, посветен клиент ја презема секоја PNG-слика, а Laravel ја складира сликата покрај датотека со метаподатоци што ја содржи нејзината контролна сума и релевантните заглавија за кешот или квотата. Апликацијата никогаш не мора да инсталира, закрпува или управува со Chromium.

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

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Screenshot API. Изберете го достапниот план Free, Plus или Pro и завршете ја неговата активација.
  3. Посетете ја официјалната документација. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
  4. Чувајте го тој токен во конфигурација поддржана од околински променливи. Неговото регенерирање го поништува претходно активниот токен, па при ротација мора да се ажурира секоја поставена апликација што го користи.

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

Потврдете го точниот HTTP договор

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

Направете едно минимално барање пред да напишете код за интеграцијата:

curl --get \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: image/png" \
  --data-urlencode "url=https://example.com/" \
  --dump-header screenshot-headers.txt \
  --output screenshot.png \
  https://ai.mihajlo.mk/api/screenshot-api/v1/capture

Проверете ги HTTP-статусот и заглавијата, како и отворањето на PNG-сликата. Датотека со име screenshot.png не е доказ дека одговорот бил слика; неуспешна крајна точка може да врати тело со грешка што невнимателен клиент го зачувува под истата наставка.

Сега поставете ги акредитивот и URL-адресите на страниците во сопственост на бизнисот во околината за поставување:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_DISK=local
SNAPSHOT_HOME_URL=https://www.example.com/
SNAPSHOT_BOOKING_URL=https://www.example.com/book

Не ги поставувајте вистинските вредности во репозиториумот. Ако Laravel-конфигурацијата веќе е кеширана, менувањето само на .env нема да ажурира активно поставување; повторно изградете го кешот на конфигурацијата при издавање.

Архитектура и структура на проектот

Дизајнот намерно има четири граници:

  • ScreenshotClient е одговорен за автентикација, временски ограничувања, повторни обиди, мапирање на статусите, валидација на PNG и нормализација на заглавијата на одговорот.
  • ScreenshotCapture е доменскиот резултат. Остатокот од апликацијата не зависи од објектот за HTTP-одговор на Laravel.
  • CapturePageSnapshot извршува бавна мрежна и складишна работа во редицата.
  • snapshots:capture испраќа конфигурирани страници, додека распоредувачот на Laravel ја повикува командата неделно.

Создадете app/Services/Screenshots, app/Jobs и app/Console/Commands. Релевантните датотеки се config/services.php, config/snapshots.php, ScreenshotCapture.php, ScreenshotApiException.php, ScreenshotClient.php, CapturePageSnapshot.php, CaptureSnapshots.php и routes/console.php.

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

Конфигурирајте страници и акредитиви

Додајте го овој запис во низата што ја враќа config/services.php:

'screenshot_api' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
    'token' => env('SCREENSHOT_API_TOKEN'),
],

Создадете config/snapshots.php:

<?php

return [
    'disk' => env('SNAPSHOT_DISK', 'local'),

    'pages' => [
        ['key' => 'home', 'url' => env('SNAPSHOT_HOME_URL')],
        ['key' => 'booking', 'url' => env('SNAPSHOT_BOOKING_URL')],
    ],
];

Клучевите стануваат компоненти на патеката за складирање, затоа нека бидат со мали букви и стабилни. Третирајте ја оваа конфигурација како листа на дозволени. Не ја претворајте функцијата во јавна крајна точка „направи слика од екранот од која било URL-адреса“; прифаќањето произволни URL-адреси би создало површина за злоупотреба и фалсификување барања од страна на серверот.

Мапирајте го API-одговорот на границата

Создадете ги класите за резултат и исклучок:

<?php
// app/Services/Screenshots/ScreenshotCapture.php

namespace App\Services\Screenshots;

final readonly class ScreenshotCapture
{
    public function __construct(
        public string $png,
        public array $operationalHeaders,
    ) {}
}

// app/Services/Screenshots/ScreenshotApiException.php

namespace App\Services\Screenshots;

use RuntimeException;
use Throwable;

final class ScreenshotApiException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        public readonly ?int $retryAfterSeconds = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct("Screenshot capture failed: {$kind}", 0, $previous);
    }
}

Исклучокот изложува стабилен вид неуспех на ниво на апликација без да складира тело од надворешниот сервис, URL-адреса или токен. Затоа задачата може да разликува траен проблем со автентикацијата од привремен прекин.

Изградете одбранбен Laravel HTTP-клиент

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

<?php

namespace App\Services\Screenshots;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use LogicException;

final class ScreenshotClient
{
    public function capture(string $url): ScreenshotCapture
    {
        $token = config('services.screenshot_api.token');
        $endpoint = config('services.screenshot_api.endpoint');

        if (! is_string($token) || $token === '') {
            throw new LogicException('SCREENSHOT_API_TOKEN is not configured.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->accept('image/png')
                    ->connectTimeout(5)
                    ->timeout(30)
                    ->get($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotApiException(
                        'transport', previous: $exception
                    );
                }

                usleep([250, 1000][$attempt - 1] * 1000);
                continue;
            }

            if ($response->successful()) {
                return $this->mapSuccessfulResponse($response);
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                usleep($this->retryDelayMilliseconds($response, $attempt) * 1000);
                continue;
            }

            $kind = match (true) {
                in_array($status, [401, 403], true) => 'authentication',
                in_array($status, [400, 422], true) => 'invalid_request',
                $status === 429 => 'quota',
                $status >= 500 => 'upstream_unavailable',
                default => 'upstream_error',
            };

            throw new ScreenshotApiException(
                $kind,
                $status,
                $this->retryAfterSeconds($response),
            );
        }

        throw new ScreenshotApiException('unexpected_state');
    }

    private function mapSuccessfulResponse(Response $response): ScreenshotCapture
    {
        $body = $response->body();
        $type = strtolower((string) $response->header('Content-Type'));

        if (! str_starts_with($type, 'image/png')
            || ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
            throw new ScreenshotApiException(
                'invalid_response',
                $response->status(),
            );
        }

        return new ScreenshotCapture($body, $this->operationalHeaders($response));
    }

    private function retryDelayMilliseconds(Response $response, int $attempt): int
    {
        $seconds = $this->retryAfterSeconds($response);

        if ($seconds !== null) {
            return min($seconds * 1000, 10000);
        }

        return [250, 1000][$attempt - 1];
    }

    private function retryAfterSeconds(Response $response): ?int
    {
        $value = $response->header('Retry-After');

        return is_string($value) && ctype_digit($value)
            ? min((int) $value, 3600)
            : null;
    }

    private function operationalHeaders(Response $response): array
    {
        $kept = [];
        $standardCacheHeaders = [
            'age', 'cache-control', 'etag', 'expires', 'last-modified', 'vary',
        ];

        foreach ($response->headers() as $name => $values) {
            $lower = strtolower($name);

            $relevant = in_array($lower, $standardCacheHeaders, true)
                || $lower === 'retry-after'
                || str_contains($lower, 'cache')
                || str_contains($lower, 'quota')
                || str_contains($lower, 'ratelimit')
                || str_contains($lower, 'rate-limit');

            if ($relevant) {
                $kept[$lower] = implode(', ', (array) $values);
            }
        }

        return $kept;
    }
}

Маперот на заглавија намерно ги зачувува имињата и вредностите наместо да претпоставува недокументирана шема за квота. Така метаподатоците можат да ги задржат сигналите на услугата за кешот и квотата без лажно да третираат одредено заглавие како загарантирано. Проверката на PNG-потписот исто така спречува HTML или JSON-документ со грешка да влезе во визуелната архива.

Складирајте една идемпотентна снимка неделно

Задачата во редицата користи ISO-недела како 2026-W41 како клуч за архивата. Повторното извршување на задачата за таа недела го поправа или заменува истиот објект наместо да создава дупликати.

<?php

namespace App\Jobs;

use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

final class CapturePageSnapshot implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;
    public array $backoff = [300, 1800];
    public int $uniqueFor = 7200;

    public function __construct(
        public readonly string $key,
        public readonly string $url,
        public readonly string $period,
    ) {}

    public function uniqueId(): string
    {
        return "{$this->key}:{$this->period}";
    }

    public function handle(ScreenshotClient $client): void
    {
        try {
            $capture = $client->capture($this->url);
        } catch (ScreenshotApiException $exception) {
            Log::warning('Weekly screenshot capture failed.', [
                'page' => $this->key,
                'period' => $this->period,
                'kind' => $exception->kind,
                'status' => $exception->status,
            ]);

            if (in_array($exception->kind, [
                'authentication', 'invalid_request',
            ], true)) {
                $this->fail($exception);
                return;
            }

            if ($exception->kind === 'quota') {
                $delay = $exception->retryAfterSeconds ?? 900;
                $this->release(min(max($delay, 60), 3600));
                return;
            }

            throw $exception;
        }

        $base = "snapshots/{$this->key}/{$this->period}";
        $disk = Storage::disk(config('snapshots.disk'));

        if (! $disk->put("{$base}.png", $capture->png)) {
            throw new RuntimeException('Could not store screenshot.');
        }

        $metadata = json_encode([
            'page' => $this->key,
            'period' => $this->period,
            'captured_at' => now('UTC')->toIso8601String(),
            'sha256' => hash('sha256', $capture->png),
            'response_headers' => $capture->operationalHeaders,
        ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

        if (! $disk->put("{$base}.json", $metadata)) {
            throw new RuntimeException('Could not store snapshot metadata.');
        }

        Log::info('Weekly screenshot stored.', [
            'page' => $this->key,
            'period' => $this->period,
            'bytes' => strlen($capture->png),
        ]);
    }
}

Дневникот содржи идентификатори и оперативна состојба, но никогаш токен, тело на одговор или целосна целна URL-адреса. Контролната сума во придружната датотека ги прави подоцнежните проверки на интегритетот едноставни.

Испраќајте и закажувајте снимања

Создадете app/Console/Commands/CaptureSnapshots.php:

<?php

namespace App\Console\Commands;

use App\Jobs\CapturePageSnapshot;
use Illuminate\Console\Command;

final class CaptureSnapshots extends Command
{
    protected $signature = 'snapshots:capture';
    protected $description = 'Queue weekly screenshots of configured pages';

    public function handle(): int
    {
        $period = now('UTC')->format('o-\WW');
        $dispatched = 0;

        foreach (config('snapshots.pages', []) as $page) {
            $key = $page['key'] ?? null;
            $url = $page['url'] ?? null;
            $scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;

            if (! is_string($key)
                || preg_match('/^[a-z0-9-]+$/', $key) !== 1
                || ! filter_var($url, FILTER_VALIDATE_URL)
                || $scheme !== 'https') {
                $this->error('Snapshot configuration contains an invalid page.');
                return self::FAILURE;
            }

            CapturePageSnapshot::dispatch($key, $url, $period);
            $dispatched++;
        }

        $this->info("Queued {$dispatched} weekly snapshots.");

        return self::SUCCESS;
    }
}

Потоа додајте го неделниот распоред во routes/console.php:

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('snapshots:capture')
    ->weeklyOn(1, '06:00')
    ->timezone('UTC')
    ->onOneServer()
    ->withoutOverlapping();

Ова поставува снимки во редицата секој понеделник во 06:00 UTC. onOneServer() и withoutOverlapping() бараат функционални заклучувања на кешот; користете споделен кеш што поддржува заклучувања кога повеќе јазли го извршуваат распоредувачот.

Тестирајте успех и траен неуспех

Http::fake() на Laravel ги одржува тестовите детерминистички и спречува случајно користење на квота. Следниве функционални тестови го проверуваат складирањето, метаподатоците и правилото дека неуспесите при автентикација не се повторуваат:

<?php

namespace Tests\Feature;

use App\Jobs\CapturePageSnapshot;
use App\Services\Screenshots\ScreenshotApiException;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class CapturePageSnapshotTest extends TestCase
{
    public function test_it_stores_a_png_and_metadata(): void
    {
        Storage::fake('local');
        config([
            'snapshots.disk' => 'local',
            'services.screenshot_api.token' => 'test-token',
        ]);

        $png = "\x89PNG\r\n\x1a\n" . 'deterministic-test-content';

        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response($png, 200, [
                    'Content-Type' => 'image/png',
                    'Cache-Control' => 'public, max-age=60',
                ]),
        ]);

        $job = new CapturePageSnapshot(
            'home',
            'https://example.com/',
            '2026-W41',
        );

        $job->handle(app(ScreenshotClient::class));

        Storage::disk('local')->assertExists(
            'snapshots/home/2026-W41.png'
        );
        Storage::disk('local')->assertExists(
            'snapshots/home/2026-W41.json'
        );

        Http::assertSentCount(1);
    }

    public function test_authentication_failure_is_not_retried(): void
    {
        config(['services.screenshot_api.token' => 'invalid-test-token']);

        Http::fake([
            'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
                Http::response('Unauthorized', 401),
        ]);

        try {
            app(ScreenshotClient::class)->capture('https://example.com/');
            $this->fail('Expected ScreenshotApiException.');
        } catch (ScreenshotApiException $exception) {
            $this->assertSame('authentication', $exception->kind);
            $this->assertSame(401, $exception->status);
        }

        Http::assertSentCount(1);
    }
}

Поставете и управувајте со архивата

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

php artisan config:cache
php artisan test
php artisan snapshots:capture
php artisan queue:work --queue=default --tries=3 --timeout=120

* * * * * cd /path/to/application && php artisan schedule:run >> /dev/null 2>&1

Извршувајте го работникот под надзорникот на процеси на оперативниот систем, така што ќе се рестартира по поставувања и падови. Ако изданијата користат ефемерни контејнери или неколку јазли на апликацијата, не ја оставајте архивата на локален диск на јазолот. Поставете SNAPSHOT_DISK на конфигуриран траен Laravel-диск на датотечен систем.

Поставете предупредувања за неуспешни задачи и повторени настани authentication, quota или invalid_response. Запишувајте ја длабочината на редицата и времетраењето на снимањето во постојниот систем на апликацијата за набљудливост. Одлучете за политика на задржување врз основа на потребите на сопственикот; неделните слики се мали поединечно, но формираат неограничена колекција.

Чести неуспеси

  • Секое барање враќа 401 или 403: проверете го токенот ограничен на услугата, повторно изградете ја кешираната конфигурација и запомнете дека регенерирањето на токенот го поништило неговиот претходник.
  • Командата не поставува ништо корисно во редицата: проверете дали околинските променливи на страницата постојат во околината во време на извршување, не само во локална школка.
  • Задачите остануваат во чекање: потврдете дека работник за редицата работи со истата врска за редицата како веб-апликацијата.
  • Само еден сервер има слики: преместете ги снимките во споделено трајно складирање и осигурете се дека секој јазол користи иста конфигурација на диск.
  • Распоредот се извршува повеќе од еднаш: проверете го споделениот кеш и неговата поддршка за заклучувања, потоа проверете го распоредувачот на секој јазол.
  • PNG-слика е одбиена: проверете ги статусот и типот на содржина без да ги евидентирате токенот или телото. Услугата можеби вратила документ со грешка наместо слика.

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

  • Планот е активен, а тековниот токен за услугата е присутен само во тајни поддржани од околински променливи.
  • Минималното барање враќа успешен HTTP-одговор, image/png и PNG-слика што може да се прикаже.
  • php artisan snapshots:capture ја испраќа секоја конфигурирана HTTPS-страница.
  • Редицата запишува соодветни објекти .png и .json под очекуваната ISO-недела.
  • Контролната сума во метаподатоците одговара на складираната слика и ги задржува релевантните заглавија за кешот и квотата дословно.
  • Неуспесите при автентикација и неважечко барање запираат веднаш; неуспесите при поврзување, квота и сервер добиваат ограничени повторни обиди.
  • Распоредувачот има заклучување за еден сервер, работникот е надгледуван, а неуспешните задачи создаваат предупредување.

Завршениот систем е намерно скромен: распоред, редица, две датотеки по страница и строга API-граница. Сепак, секој понеделник создава нешто невообичаено корисно — визуелна временска линија што сопственик на бизнис може да ја разбере на прв поглед. Добрите продукциски интеграции често изгледаат вака: мали по површина, јасни за неуспехот и тивко сигурни долго по првото успешно барање.

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

Mihajlo

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