Туториали

Laravel: Weekly Web Page Screenshots for Small Business Owners

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

Веб-страницата може тивко да се расипе. Ажурирањето на темата поместува повик за акција под преклопот, страница за резервации го губи стилизирањето или сезонска промоција останува активна со недели по истекот на рокот. Следењето на достапноста сè уште пријавува успех бидејќи серверот продолжува да враќа HTTP 200.

Неделната историја на слики од екранот открива поинаква класа проблеми: несакана визуелна промена. Ова упатство гради Laravel апликација ориентирана кон продукциска употреба што снима важни страници секој понеделник, складира приватни PNG-датотеки, бележи оперативни метаподатоци и повторува само неуспеси што може да се опорават.

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

Регистрирајте се на https://ai.mihajlo.mk/register или најавете се преку https://ai.mihajlo.mk/login.

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

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

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

curl --fail-with-body \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://example.com/' \
  --output screenshot.png \
  --dump-header screenshot.headers

Телото на успешен одговор е image/png. Одговорот содржи и информации за кешот и квотата во заглавијата. Апликацијата ќе ги задржи тие заглавија како метаподатоци без да претпоставува недокументирани полиња во одговорот.

Складирајте го токенот во конфигурацијата на околината на Laravel пред да ја изградите функционалноста:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
BUSINESS_SITE_URL=https://www.example.com
VISUAL_HISTORY_DISK=local
QUEUE_CONNECTION=database

Никогаш не правете commit на .env. Тајните за продукција треба да доаѓаат од шифрираното складиште за тајни на платформата за хостирање, додека .env.example содржи само местодржачи.

Архитектура што одговара на мал бизнис

Проектот бара PHP 8.3 или понов, одржувана Laravel апликација, база на податоци, конфигурирана редица и приватно складирање со дозвола за запишување. Не е потребен пакет за автоматизација на прелистувач бидејќи API-то го обезбедува рендерираниот PNG.

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

  • Командата одлучува што треба да се сними.
  • Редицата ги контролира повторните обиди и спречува истекување на времето на распоредувачот.
  • Клиентот ги разбира HTTP, автентикацијата и валидацијата на PNG.
  • Записот за снимката обезбедува проверлива историја на успеси и неуспеси.

Релевантните датотеки се config/services.php, config/visual-history.php, app/Services/ScreenshotClient.php, app/Jobs/CapturePageSnapshot.php, app/Console/Commands/CaptureVisualHistory.php, app/Models/PageSnapshot.php, миграција и routes/console.php.

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

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

'screenshot' => [
    'base_url' => 'https://ai.mihajlo.mk/api/screenshot-api',
    'token' => env('SCREENSHOT_API_TOKEN'),
    'connect_timeout' => 5,
    'timeout' => 45,
],

Создадете config/visual-history.php. Почетната листа нека биде кратка: почетната страница, страницата за контакт и страницата што е најтесно поврзана со приходот обично се покорисни од исцрпно пребарување.

<?php

$site = rtrim(env('BUSINESS_SITE_URL', 'https://example.com'), '/');

return [
    'disk' => env('VISUAL_HISTORY_DISK', 'local'),
    'pages' => [
        ['key' => 'home', 'url' => $site],
        ['key' => 'contact', 'url' => $site.'/contact'],
        ['key' => 'booking', 'url' => $site.'/booking'],
    ],
];

Овие URL-адреси се доверлива конфигурација при распоредување, а не внес од посетители. Таа разлика е важна: прифаќањето произволни URL-адреси би создало површина за server-side request forgery и во вашата апликација и во услугата за снимање надолу по текот.

Зачувајте идемпотентна неделна историја

Создадете ги моделот и миграцијата со Artisan:

php artisan make:model PageSnapshot -m
php artisan make:job CapturePageSnapshot
php artisan make:command CaptureVisualHistory
php artisan queue:table
php artisan migrate

Дефинирајте ја табелата со снимки во генерираната миграција:

Schema::create('page_snapshots', function (Blueprint $table) {
    $table->id();
    $table->string('page_key', 80);
    $table->text('url');
    $table->date('period_start');
    $table->timestamp('captured_at')->nullable();
    $table->string('status', 20)->default('pending');
    $table->string('disk')->nullable();
    $table->string('path')->nullable();
    $table->unsignedSmallInteger('http_status')->nullable();
    $table->json('response_headers')->nullable();
    $table->text('error')->nullable();
    $table->timestamps();

    $table->unique(['page_key', 'period_start']);
});

Уникатното ограничување го прави распоредувањето идемпотентно. Ако распоредувачот се изврши двапати, не може да создаде две „неделни“ снимања за истата страница.

Во PageSnapshot, дозволете ги горенаведените полиња преку $fillable и конвертирајте period_start во date, captured_at во datetime, а response_headers во array.

Изградете одбранбена API граница

Создадете мал непроменлив објект за одговор и две класи на исклучоци во app/Services:

<?php

namespace App\Services;

final readonly class CaptureImage
{
    public function __construct(
        public string $bytes,
        public int $status,
        public array $operationalHeaders,
    ) {}
}

class PermanentCaptureException extends \RuntimeException {}

final class RetryableCaptureException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct($message);
    }
}

Клиентот го користи вградениот HTTP-клиент на Laravel, ограничени временски ограничувања и експлицитна класификација на неуспесите. Не повторува автентикација, валидација или други нетранзиторни грешки на клиентот.

<?php

namespace App\Services;

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

final class ScreenshotClient
{
    public function capture(string $url): CaptureImage
    {
        if (! filter_var($url, FILTER_VALIDATE_URL)
            || ! in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
            throw new PermanentCaptureException('Configured capture URL is invalid.');
        }

        $token = config('services.screenshot.token');

        if (! is_string($token) || $token === '') {
            throw new PermanentCaptureException('Screenshot API token is missing.');
        }

        try {
            $response = Http::baseUrl(config('services.screenshot.base_url'))
                ->withToken($token)
                ->accept('image/png')
                ->connectTimeout(config('services.screenshot.connect_timeout'))
                ->timeout(config('services.screenshot.timeout'))
                ->get('/v1/capture', ['url' => $url]);
        } catch (ConnectionException $e) {
            throw new RetryableCaptureException('Screenshot service connection failed.');
        }

        if ($response->status() === 429 || $response->serverError()) {
            $retryAfter = filter_var(
                $response->header('Retry-After'),
                FILTER_VALIDATE_INT
            );

            throw new RetryableCaptureException(
                'Screenshot service returned a transient response.',
                $retryAfter === false ? null : min(max($retryAfter, 1), 900),
            );
        }

        if ($response->failed()) {
            throw new PermanentCaptureException(
                'Screenshot request rejected with HTTP '.$response->status().'.'
            );
        }

        $contentType = strtolower($response->header('Content-Type', ''));

        if (! str_starts_with($contentType, 'image/png')
            || ! str_starts_with($response->body(), "\x89PNG\r\n\x1a\n")) {
            throw new RetryableCaptureException('Response was not a valid PNG.');
        }

        $headers = [];
        foreach ($response->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')
                || $normalized === 'retry-after') {
                $headers[$name] = implode(', ', $values);
            }
        }

        return new CaptureImage($response->body(), $response->status(), $headers);
    }
}

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

Снимајте во задача во редица што е свесна за повторни обиди

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

<?php

namespace App\Jobs;

use App\Models\PageSnapshot;
use App\Services\PermanentCaptureException;
use App\Services\RetryableCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

final class CapturePageSnapshot implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 60;

    public function __construct(public int $snapshotId) {}

    public function handle(ScreenshotClient $client): void
    {
        $snapshot = PageSnapshot::findOrFail($this->snapshotId);

        try {
            $image = $client->capture($snapshot->url);
        } catch (PermanentCaptureException $e) {
            $this->recordFailure($snapshot, $e);
            $this->fail($e);
            return;
        } catch (RetryableCaptureException $e) {
            Log::warning('Weekly screenshot will be retried', [
                'snapshot_id' => $snapshot->id,
                'page_key' => $snapshot->page_key,
                'attempt' => $this->attempts(),
            ]);

            if ($this->attempts() >= $this->tries) {
                $this->recordFailure($snapshot, $e);
                $this->fail($e);
                return;
            }

            $fallback = [60, 300, 900][$this->attempts() - 1] ?? 900;
            $this->release($e->retryAfter ?? $fallback);
            return;
        }

        $disk = config('visual-history.disk');
        $path = 'screenshots/'.$snapshot->period_start->format('Y/m/d')
            .'/'.$snapshot->id.'.png';

        if (! Storage::disk($disk)->put($path, $image->bytes)) {
            throw new \RuntimeException('Could not store captured PNG.');
        }

        $snapshot->update([
            'status' => 'succeeded',
            'captured_at' => now(),
            'disk' => $disk,
            'path' => $path,
            'http_status' => $image->status,
            'response_headers' => $image->operationalHeaders,
            'error' => null,
        ]);
    }

    private function recordFailure(PageSnapshot $snapshot, Throwable $e): void
    {
        $snapshot->update([
            'status' => 'failed',
            'error' => mb_substr($e->getMessage(), 0, 1000),
        ]);

        Log::error('Weekly screenshot failed', [
            'snapshot_id' => $snapshot->id,
            'page_key' => $snapshot->page_key,
            'exception' => $e::class,
        ]);
    }
}

Испратете и закажете го неделното извршување

Во методот handle() на командата, создадете го секој запис за периодот атомски и испраќајте само новосоздадена работа:

public function handle(): int
{
    $period = now()->startOfWeek()->toDateString();

    foreach (config('visual-history.pages', []) as $page) {
        $snapshot = PageSnapshot::firstOrCreate(
            ['page_key' => $page['key'], 'period_start' => $period],
            ['url' => $page['url'], 'status' => 'pending'],
        );

        if ($snapshot->wasRecentlyCreated) {
            CapturePageSnapshot::dispatch($snapshot->id);
        }
    }

    return self::SUCCESS;
}

Поставете го потписот на командата на visual-history:capture. Потоа закажете ја во routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('visual-history:capture')
    ->weeklyOn(1, '06:00')
    ->withoutOverlapping();

Тестирајте без да ја контактирате услугата

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

public function test_job_stores_a_valid_png(): void
{
    Storage::fake('local');
    config()->set('visual-history.disk', 'local');
    config()->set('services.screenshot.token', 'test-token');

    Http::fake([
        '*' => Http::response(
            "\x89PNG\r\n\x1a\nfake-payload",
            200,
            ['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
        ),
    ]);

    $snapshot = PageSnapshot::create([
        'page_key' => 'home',
        'url' => 'https://example.com/',
        'period_start' => now()->startOfWeek()->toDateString(),
        'status' => 'pending',
    ]);

    (new CapturePageSnapshot($snapshot->id))
        ->handle(app(ScreenshotClient::class));

    $snapshot->refresh();

    $this->assertSame('succeeded', $snapshot->status);
    $this->assertSame('HIT', $snapshot->response_headers['X-Cache']);
    Storage::disk('local')->assertExists($snapshot->path);
}

public function test_authentication_error_is_permanent(): void
{
    config()->set('services.screenshot.token', 'invalid-test-token');
    Http::fake(['*' => Http::response('', 401)]);

    try {
        app(ScreenshotClient::class)->capture('https://example.com/');
        $this->fail('Expected a permanent failure.');
    } catch (PermanentCaptureException) {
        Http::assertSentCount(1);
    }
}

Распоредете, набљудувајте и решавајте проблеми

Извршете php artisan config:cache по внесувањето на вредностите на продукциската околина. Чувајте го storage/app приватен или користете диск за приватно складирање на објекти. Ако сликите од екранот се изложени во административен интерфејс, авторизирајте го секое преземање и издавајте краткотрајни врски наместо да ја објавувате директоријата за складирање.

На серверот му е потребен еден cron-запис што извршува php artisan schedule:run секоја минута, плус надгледуван процес php artisan queue:work. Поставете предупредување за неуспешни задачи и за записи за снимки што остануваат во pending подолго од очекуваниот прозорец за снимање. Логовите треба да ги идентификуваат снимката и клучот на страницата, но никогаш токенот или телото на PNG.

Вообичаените неуспеси имаат различни решенија:

  • 401 или 403: проверете го активниот токен со опсег на услугата и повторно распоредете по ротацијата.
  • 429: проверете ги зачуваните заглавија за квотата, намалете го следениот сет или преиспитајте го избраниот план.
  • Истекувања на времето или одговори 5xx: дозволете да се изврши ограниченото повлекување на редицата; не создавајте агресивна јамка за повторни обиди.
  • Невалиден PNG: проверете ги статусот и оперативните заглавија, но не зачувувајте HTML-страница со грешка како слика од екранот.
  • Нема неделни записи: потврдете ги cron-от на распоредувачот, временската зона на апликацијата, кешираната конфигурација и регистрацијата на командата.
  • Записите во чекање никогаш не завршуваат: проверете дали worker-от на редицата ја користи истата околина и база на податоци како веб-апликацијата.

Конечна контролна листа за верификација

  1. Извршете php artisan test и потврдете дека ниту еден тест не стигнува до надворешното API.
  2. Извршете php artisan visual-history:capture.
  3. Стартувајте worker со php artisan queue:work --stop-when-empty.
  4. Потврдете по еден успешен запис за секоја конфигурирана страница и неделен период.
  5. Отворете ги зачуваните датотеки и проверете дали се валидни PNG слики од екранот.
  6. Потврдете дека се присутни заглавија поврзани со кешот и квотата кога се доставени од услугата.
  7. Извршете ја командата повторно и потврдете дека уникатното ограничување на периодот спречува дупликати.
  8. Тестирајте намерно невалиден токен надвор од продукција и потврдете дека барањето не успева еднаш, без слепи повторни обиди.

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

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

Mihajlo

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