Laravel: Чувајте неделна визуелна архива на клучните деловни страници со Screenshot API
Неделната слика од екранот е извонредно корисен деловен запис. Таа покажува што навистина виделе клиентите: објавената цена, сезонскиот банер, работното време, текот на резервацијата или промоцијата што исчезнала при брзо ажурирање. За разлика од контролата на изворниот код или историјата на базата на податоци, визуелната архива го зачувува прикажаниот резултат.
Ова упатство ја гради таа архива како продукциска функција во Laravel. Закажана команда испраќа по една задача во редица за секоја важна страница, задачата повикува Screenshot API, го валидира PNG-одговорот, го складира на приватен диск на датотечниот систем и запишува оперативни метаподатоци во базата на податоци. Надворешната услуга обезбедува кеширани слики од екранот за десктоп или мобилен уред, така што апликацијата не мора да работи со Chromium, двигатели за прелистувач или флота работници за слики од екранот.
Добијте пристап пред да пишувате код за интеграција
- Регистрирајте се на https://ai.mihajlo.mk/register, или најавете се преку https://ai.mihajlo.mk/login.
- Отворете ја страницата на услугата Screenshot API. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Screenshot API.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата. Повторното генерирање на овој токен го поништува претходно активниот токен, затоа усогласете ја ротацијата со распоредувањето наместо неформално да го генерирате повторно.
- Ставете го токенот во конфигурација поддржана од околински променливи. Никогаш не го зачувувајте во верзионирана контрола, не го копирајте во тест-податок и не го вклучувајте во порака во дневникот.
API-то прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Оваа имплементација ја користи Bearer-формата бидејќи таа ги задржува акредитивите надвор од URL-адресите, дневниците за пристап на прокси-серверите, историјата на прелистувачот и вообичаената HTTP-дијагностика.
Проверете ја крајната точка со едно мало барање
Точниот повик е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Неговиот задолжителен параметар за пребарување е url, а успешниот одговор е тело image/png, наместо JSON.
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
Проверете ги и заглавијата на одговорот при оваа прва проверка. Заглавијата за кеш и квота се дел од оперативниот резултат иако не се дел од PNG-датотеката. Апликацијата подолу ги зачувува без да претпоставува дека опционалните заглавија секогаш ќе бидат присутни.
Зачувајте ги акредитивите и URL-адресите на страниците во сопственост на бизнисот во .env:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
ARCHIVE_HOME_URL=https://example.com/
ARCHIVE_BOOKING_URL=https://example.com/book
VISUAL_ARCHIVE_DISK=local
Чувајте ги вистинските продукциски тајни во управувачот со тајни на платформата за распоредување. Датотеката .env е соодветна за локален развој, но мора да остане надвор од контролата на верзии.
Архитектура и распоред на проектот
Неделната команда е намерно брза: таа испраќа задачи и завршува. Секоја страница се снима независно, така што една бавна или невалидна страница не може да ги блокира останатите. Повторувањата во редицата се справуваат со привремени транспортни и серверски неуспеси, додека неуспесите при автентикација, валидација и квота стануваат експлицитни состојби во базата на податоци наместо бури од повторни обиди.
config/services.phpги поседува крајната точка и токенот.config/visual-archive.phpги дефинира приватниот диск и одобрените страници.app/Services/ScreenshotClient.phpго изолира HTTP-договорот и го пресликува во доменски резултат.app/Jobs/CapturePageScreenshot.phpги складира PNG-датотеката и записот за снимањето.app/Console/Commands/CaptureWeeklyArchive.phpсоздава по една задача за секоја страница.routes/console.phpго дефинира неделниот распоред.
Записот во базата на податоци ги прави неуспесите и сигналите за квота достапни за пребарување, додека апстракцијата на датотечниот систем на Laravel овозможува локално складирање при развој и диск за складирање објекти во продукција. Чувањето на списокот со страници во конфигурацијата е добар компромис за мал бизнис: промените се прегледуваат и распоредуваат, а произволни URL-адреси доставени од корисници никогаш не стигнуваат до услугата за снимање.
Конфигурирајте го Laravel и создајте регистар на снимања
Додајте ги овие записи без да ја заменувате неповрзаната конфигурација на услуги:
<?php
// config/services.php
return [
// Existing services...
'screenshot_api' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
],
];
// config/visual-archive.php
return [
'disk' => env('VISUAL_ARCHIVE_DISK', 'local'),
'pages' => [
'home' => env('ARCHIVE_HOME_URL'),
'booking' => env('ARCHIVE_BOOKING_URL'),
],
];
Создадете миграција за еден запис по страница и недела:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('visual_snapshots', function (Blueprint $table): void {
$table->id();
$table->string('page_key');
$table->date('captured_week');
$table->string('status');
$table->string('path')->nullable();
$table->unsignedSmallInteger('http_status')->nullable();
$table->json('response_headers')->nullable();
$table->json('cache_headers')->nullable();
$table->json('quota_headers')->nullable();
$table->string('error')->nullable();
$table->timestamps();
$table->unique(['page_key', 'captured_week']);
});
}
public function down(): void
{
Schema::dropIfExists('visual_snapshots');
}
};
Извршете php artisan migrate. Единственото ограничување обезбедува трајна идемпотентност дури и ако распоредувачот се изврши двапати или работникот се рестартира.
Изградете строга граница со API-то
Клиентот мора да го третира одговорот како недоверливи бајти. Само успешен статус не е доволен: посреднички портал нагоре во системот може да врати HTML-страница со грешка. Валидирајте ги и типот на медиум и PNG-потписот пред да зачувате што било.
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final readonly class ScreenshotResult
{
public function __construct(
public string $png,
public int $status,
public array $headers,
public array $cacheHeaders,
public array $quotaHeaders,
) {}
}
final class ScreenshotCaptureException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
public readonly ?int $status = null,
public readonly array $headers = [],
public readonly array $cacheHeaders = [],
public readonly array $quotaHeaders = [],
) {
parent::__construct($message);
}
}
final class ScreenshotClient
{
public function capture(string $url): ScreenshotResult
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new ScreenshotCaptureException(
'Archive URL must be a valid HTTPS URL.',
false,
);
}
$token = (string) config('services.screenshot_api.token');
if ($token === '') {
throw new ScreenshotCaptureException(
'Screenshot API token is not configured.',
false,
);
}
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(5)
->timeout(45)
->get(
(string) config('services.screenshot_api.endpoint'),
['url' => $url],
);
} catch (ConnectionException $exception) {
throw new ScreenshotCaptureException(
'Screenshot API connection failed.',
true,
);
}
$headers = $response->headers();
$cacheHeaders = $this->matchingHeaders(
$headers,
fn (string $name): bool =>
str_contains($name, 'cache')
|| in_array($name, ['age', 'etag', 'expires'], true),
);
$quotaHeaders = $this->matchingHeaders(
$headers,
fn (string $name): bool =>
str_contains($name, 'quota')
|| str_contains($name, 'rate-limit')
|| $name === 'retry-after',
);
if ($response->status() === 429) {
throw new ScreenshotCaptureException(
'Screenshot API quota or rate limit was reached.',
false,
429,
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if (in_array($response->status(), [400, 401, 403, 422], true)) {
throw new ScreenshotCaptureException(
'Screenshot API rejected the request.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if ($response->serverError()) {
throw new ScreenshotCaptureException(
'Screenshot API returned a temporary server error.',
true,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
if (! $response->successful()) {
throw new ScreenshotCaptureException(
'Screenshot API returned an unexpected status.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
$body = $response->body();
$mediaType = strtolower(trim(explode(
';',
$response->header('Content-Type', ''),
)[0]));
if ($mediaType !== 'image/png'
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotCaptureException(
'Screenshot API response was not a valid PNG.',
false,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
return new ScreenshotResult(
$body,
$response->status(),
$headers,
$cacheHeaders,
$quotaHeaders,
);
}
private function matchingHeaders(array $headers, callable $match): array
{
return array_filter(
$headers,
fn (string $name): bool => $match(strtolower($name)),
ARRAY_FILTER_USE_KEY,
);
}
}
Ниту еден извадок од телото на одговорот не влегува во исклучок или дневник. Така се избегнува случајно задржување страници од прокси-сервери или друга неочекувана содржина. Клиентот исто така не се обидува слепо повторно: ги класифицира неуспесите и ја остава политиката за повторување на редицата.
Снимете ја секоја страница во идемпотентна задача во редица
<?php
// app/Jobs/CapturePageScreenshot.php
namespace App\Jobs;
use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
final class CapturePageScreenshot implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $uniqueFor = 86400;
public array $backoff = [60, 300, 900];
public function __construct(
public readonly string $pageKey,
public readonly string $url,
public readonly string $week,
) {}
public function uniqueId(): string
{
return $this->pageKey.':'.$this->week;
}
public function handle(ScreenshotClient $client): void
{
$identity = [
'page_key' => $this->pageKey,
'captured_week' => $this->week,
];
DB::table('visual_snapshots')->updateOrInsert(
$identity,
[...$identity, 'status' => 'pending',
'updated_at' => now(), 'created_at' => now()],
);
try {
$result = $client->capture($this->url);
} catch (ScreenshotCaptureException $exception) {
DB::table('visual_snapshots')->where($identity)->update([
'status' => $exception->status === 429
? 'quota_limited'
: ($exception->retryable
? 'transient_failure'
: 'permanent_failure'),
'http_status' => $exception->status,
'response_headers' => json_encode($exception->headers),
'cache_headers' => json_encode($exception->cacheHeaders),
'quota_headers' => json_encode($exception->quotaHeaders),
'error' => $exception->getMessage(),
'updated_at' => now(),
]);
if ($exception->retryable) {
throw $exception;
}
return;
}
$path = "visual-archive/{$this->pageKey}/{$this->week}.png";
if (! Storage::disk(config('visual-archive.disk'))
->put($path, $result->png)) {
throw new \RuntimeException('Unable to store screenshot.');
}
DB::table('visual_snapshots')->where($identity)->update([
'status' => 'captured',
'path' => $path,
'http_status' => $result->status,
'response_headers' => json_encode($result->headers),
'cache_headers' => json_encode($result->cacheHeaders),
'quota_headers' => json_encode($result->quotaHeaders),
'error' => null,
'updated_at' => now(),
]);
Log::info('Weekly visual archive captured.', [
'page_key' => $this->pageKey,
'week' => $this->week,
]);
}
}
Дневникот вклучува стабилен клуч на страницата, а не целосната URL-адреса. Тоа е важно кога архивираните URL-адреси со време ќе содржат параметри на кампањи или други чувствителни податоци за пребарување.
Испратете и закажете ја неделната архива
<?php
// app/Console/Commands/CaptureWeeklyArchive.php
namespace App\Console\Commands;
use App\Jobs\CapturePageScreenshot;
use Illuminate\Console\Command;
final class CaptureWeeklyArchive extends Command
{
protected $signature = 'archive:capture';
protected $description = 'Queue the weekly visual page archive';
public function handle(): int
{
$week = now()->startOfWeek()->toDateString();
foreach (config('visual-archive.pages', []) as $key => $url) {
if (is_string($url) && $url !== '') {
CapturePageScreenshot::dispatch($key, $url, $week);
}
}
return self::SUCCESS;
}
}
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('archive:capture')
->weeklyOn(1, '03:15')
->withoutOverlapping();
Во продукција се потребни и активаторот на распоредувачот на Laravel и континуирано надгледуван работник за редицата. Извршувајте php artisan schedule:run секоја минута преку распоредувачот на платформата или cron, и работете со php artisan queue:work --tries=3 --timeout=60 под надзор на процес. Осигурете се дека истекувањето на времето на работникот го надминува HTTP-истекувањето, потоа рестартирајте ги работниците при распоредување за да вчитаат нов код и конфигурација.
Тестирајте ја границата без да ја повикувате услугата
Http::fake() ги прави тестовите детерминистички и докажува дека токенот, методот, параметарот за пребарување, бинарната валидација и пресликувањето на неуспеси се однесуваат како што е предвидено.
<?php
namespace Tests\Feature;
use App\Services\ScreenshotCaptureException;
use App\Services\ScreenshotClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ScreenshotClientTest extends TestCase
{
public function test_it_captures_and_maps_a_png(): void
{
config()->set('services.screenshot_api.token', 'test-token');
config()->set('services.screenshot_api.endpoint',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture');
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response(
"\x89PNG\r\n\x1a\nfixture",
200,
['Content-Type' => 'image/png',
'Cache-Control' => 'private'],
),
]);
$result = app(ScreenshotClient::class)
->capture('https://example.com/book');
$this->assertSame(200, $result->status);
$this->assertArrayHasKey('Cache-Control', $result->cacheHeaders);
Http::assertSent(fn (Request $request): bool =>
$request->method() === 'GET'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com/book'
);
}
public function test_it_rejects_a_non_png_success_response(): void
{
config()->set('services.screenshot_api.token', 'test-token');
Http::fake([
'*' => Http::response('not an image', 200,
['Content-Type' => 'text/plain']),
]);
$this->expectException(ScreenshotCaptureException::class);
app(ScreenshotClient::class)->capture('https://example.com/');
}
public function test_authentication_failure_is_not_retryable(): void
{
config()->set('services.screenshot_api.token', 'test-token');
Http::fake(['*' => Http::response('', 401)]);
try {
app(ScreenshotClient::class)->capture('https://example.com/');
$this->fail('Expected capture exception.');
} catch (ScreenshotCaptureException $exception) {
$this->assertFalse($exception->retryable);
$this->assertSame(401, $exception->status);
}
}
}
Безбедност, набљудливост и вообичаени неуспеси
Чувајте го архивскиот диск приватен и изложувајте слики само преку автентициран контролер или краткотрајна потпишана URL-адреса за складирање. Сликата од екранот може да открие необјавени цени, грешки видливи за клиентите или оперативни детали. Применувајте ги истите правила за задржување и пристап што би ги користеле за интерни деловни документи.
Поставете известувања за permanent_failure, quota_limited и записи што останале во transient_failure по повторните обиди во редицата. Следете го времетраењето на снимањето и успешните снимања по секое закажано извршување. Зачувајте релевантни заглавија на одговорот, но никогаш не го запишувајте заглавието за авторизација или токенот во дневник. При ротација на сервисниот токен, прво ажурирајте ја тајната и веднаш рестартирајте ги работниците бидејќи повторното генерирање го поништува претходниот токен.
- 401 или 403: проверете ја активацијата на планот, токенот ограничен на услугата и дали неодамна бил повторно генериран. Не обидувајте се автоматски повторно.
- 400 или 422: проверете ја конфигурираната URL-адреса на страницата и споредете го барањето со официјалната документација.
- 429: проверете ги зачуваните заглавија поврзани со квота и повторен обид, потоа приспособете ја употребата на планот или распоредот. Не правете брзи повторни обиди.
- Невалиден PNG: истражете ги одговорите од системите нагоре или од прокси-серверите; никогаш не го зачувувајте телото со наставка
.png. - Нема неделен запис: проверете дали платформата го повикува
schedule:run, дали работникот во редицата работи и дали кешовите на конфигурацијата биле повторно изградени по распоредувањето.
Конечна контролна листа за проверка
- Извршете
php artisan testи потврдете дека секое HTTP-барање е лажирано. - Извршете
php artisan archive:capture, потоа обработете ги задачите соphp artisan queue:work --stop-when-empty. - Потврдете дека постојат по еден запис во базата на податоци и еден валиден PNG за секоја конфигурирана страница.
- Извршете ја командата повторно и проверете дека единствените записи по страница и недела не се дуплирани.
- Потврдете дека архивскиот диск е приватен и дека токенот отсуствува од контролата на изворниот код и дневниците.
- Проверете ги продукцискиот распоредувач, надзорникот на редицата, известувањата за неуспех, задржувањето во складиштето и постапката за ротација на тајните.
Добиената архива е намерно скромна: неколку одобрени URL-адреси, еден неделен распоред, приватни PNG-објекти и оперативен регистар што може да се пребарува. Токму таа воздржаност е нејзината сила. Таа му дава на сопственикот на мал бизнис сигурна визуелна меморија за веб-страницата, додека одржувањето на прелистувачите, инфраструктурата за прикажување и закрепнувањето од привремени неуспеси остануваат надвор од секојдневниот обем на работа.