Laravel: Sigurno pregledajte oznake uz generiranje snimki zaslona pokretano umjetnom inteligencijom
Oznaka je korisnija kada je možete prepoznati na prvi pogled. Naslovi pomažu, ali vizualni pregled često mnogo brže prenosi identitet stranice. Težak dio je pouzdano generirati taj pregled bez pretvaranja Laravel poslužitelja u host za automatizaciju preglednika.
Ovaj vodič izrađuje mali Laravel API za oznake koji prihvaća javne web URL-ove, generira PNG preglede u pozadinskom redu čekanja, pohranjuje ih lokalno i izlaže njihov status kroz krajnju točku za anketiranje. Servis za snimke zaslona pruža infrastrukturu preglednika; Laravel ostaje odgovoran za validaciju, postojanost podataka, rukovanje neuspjesima i sigurnu isporuku.
Dobijte pristup Screenshot API-ju
Registrirajte se putem stranice za registraciju računa ili upotrijebite stranicu za prijavu ako već imate račun.
Otvorite stranicu usluge Screenshot API, odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Zatim posjetite službenu dokumentaciju. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.
Ova usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer shemu kako se vjerodajnica nikada ne bi pojavila u URL-u. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svaku implementiranu aplikaciju koja ga upotrebljava.
Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, s ciljnom adresom u obaveznom parametru upita url. Prije pisanja Laravel koda, pošaljite minimalni zahtjev s rezerviranim tokenom:
curl --get \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: image/png" \
--data-urlencode "url=https://example.com" \
--dump-header preview.headers \
--output preview.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Uspješan odgovor ima tijelo tipa image/png. Pregledajte i preview.headers: zaglavlja odgovora za predmemoriju i kvotu operativni su podaci, a ne ukras. Pomažu razlikovati pogodak predmemorije od svježeg rada i iscrpljenu kvotu od neispravne integracije.
Sada stavite vjerodajnicu u datoteku okruženja projekta. Nikada ne predajte stvarnu vrijednost u repozitorij:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Arhitektura i oblik projekta
Aplikacija odmah stvara oznaku sa statusom pregleda pending. Posao u redu čekanja poziva Screenshot API, validira vraćeni PNG, pohranjuje ga na Laravelov public disk i mijenja status u ready. Preglednik može anketirati resurs oznake umjesto čekanja tijekom vanjskog HTTP zahtjeva.
Ova pozadinska granica je važna. Generiranje snimki zaslona ima mrežnu latenciju, može naići na ograničenja kvote i ne bi trebalo držati otvorenim zahtjev koji stvara oznaku. Dobivena struktura namjerno je mala:
SafePublicUrlodbacuje neprikladna odredišta.ScreenshotClientupravlja vanjskim HTTP ugovorom.GenerateBookmarkPreviewkoordinira postojanost podataka i stanja neuspjeha.BookmarkControllerupravlja stvaranjem, anketiranjem i isporukom PNG-a.
Počnite s Laravel aplikacijom koja koristi PHP 8.3 ili noviji, konfiguriranom bazom podataka i stvarnim upravljačkim programom reda čekanja za produkciju. Generirajte glavne komponente i stvorite tablice reda čekanja ako vaša aplikacija koristi Laravelov red čekanja u bazi podataka:
php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:rule SafePublicUrl
php artisan make:queue-table
php artisan migrate
Konfigurirajte granicu API-ja
Dodajte uslugu u config/services.php. Čitanje env() samo iz konfiguracije zadržava aplikaciju kompatibilnom s Laravelovom predmemorijom konfiguracije.
'screenshot' => [
'token' => env('SCREENSHOT_API_TOKEN'),
'endpoint' => env(
'SCREENSHOT_API_ENDPOINT',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
),
],
Stvorite app/Services/Screenshots/CaptureResult.php i ScreenshotException.php:
<?php
namespace App\Services\Screenshots;
final readonly class CaptureResult
{
public function __construct(
public string $png,
public array $cacheHeaders,
public array $quotaHeaders,
) {}
}
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly array $context = [],
) {
parent::__construct($message);
}
}
Ugovor odgovora je binaran, stoga granica ne bi trebala pokušavati dekodirati JSON. Mora provjeriti i vrstu medija i PNG potpis. Ugovor usluge obećava zaglavlja predmemorije i kvote, ali kod aplikacije ne bi trebao ovisiti o nedokumentiranom nazivu. Ovo mapiranje čuva zaglavlja čiji nazivi označavaju semantiku predmemorije, kvote ili ograničenja brzine.
Stvorite app/Services/Screenshots/ScreenshotClient.php:
<?php
namespace App\Services\Screenshots;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class ScreenshotClient
{
public function capture(string $url): CaptureResult
{
$token = config('services.screenshot.token');
$endpoint = config('services.screenshot.endpoint');
if (! is_string($token) || $token === '') {
throw new ScreenshotException(
'configuration',
'Screenshot API token is not configured.'
);
}
$response = null;
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(3)
->timeout(20)
->get($endpoint, ['url' => $url]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'unavailable',
'Screenshot service could not be reached.'
);
}
usleep(200_000 * $attempt);
continue;
}
if (in_array($response->status(), [500, 502, 503, 504], true)
&& $attempt < 3) {
usleep(200_000 * $attempt);
continue;
}
break;
}
$context = $this->operationalHeaders($response);
if (in_array($response->status(), [401, 403], true)) {
throw new ScreenshotException(
'authentication',
'Screenshot service rejected its token.',
$context
);
}
if (in_array($response->status(), [400, 422], true)) {
throw new ScreenshotException(
'invalid_request',
'Screenshot service rejected the target URL.',
$context
);
}
if ($response->status() === 429) {
throw new ScreenshotException(
'quota_limited',
'Screenshot service quota or rate limit was reached.',
$context
);
}
if (! $response->successful()) {
throw new ScreenshotException(
'unavailable',
'Screenshot service returned HTTP '.$response->status().'.',
$context
);
}
$body = $response->body();
$type = strtolower($response->header('Content-Type', ''));
if (! str_starts_with($type, 'image/png')
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
throw new ScreenshotException(
'invalid_response',
'Screenshot service did not return a valid PNG.',
$context
);
}
return new CaptureResult(
$body,
$context['cache_headers'],
$context['quota_headers']
);
}
private function operationalHeaders(Response $response): array
{
$cache = [];
$quota = [];
foreach ($response->headers() as $name => $values) {
$normalized = strtolower($name);
$value = implode(', ', $values);
if (str_contains($normalized, 'cache')) {
$cache[$name] = $value;
}
if (str_contains($normalized, 'quota')
|| str_contains($normalized, 'ratelimit')
|| str_contains($normalized, 'rate-limit')) {
$quota[$name] = $value;
}
}
return ['cache_headers' => $cache, 'quota_headers' => $quota];
}
}
Samo neuspjesi povezivanja i odabrani neuspjesi poslužitelja dobivaju ograničene ponovne pokušaje. Odgovori za autentifikaciju, validaciju i kvotu vraćaju se odmah jer ponovni pokušaj ne može popraviti zahtjev i može potrošiti dodatnu kvotu.
Prihvaćajte samo prikladne URL-ove oznaka
Iako Laravel sam ne dohvaća stranicu, prihvaćanje lokalnih adresa, ugrađenih vjerodajnica ili privatnih IP adresa nepotreban je rizik. Sljedeće pravilo dopušta samo javna HTTP i HTTPS odredišta. DNS se može promijeniti nakon validacije, stoga ovo ostaje jedan sloj, a ne apsolutno SSRF jamstvo.
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class SafePublicUrl implements ValidationRule
{
public function validate(
string $attribute,
mixed $value,
Closure $fail
): void {
if (! is_string($value) || ! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The URL must be valid.');
return;
}
$parts = parse_url($value);
$scheme = strtolower($parts['scheme'] ?? '');
$host = trim($parts['host'] ?? '', '[]');
if (! in_array($scheme, ['http', 'https'], true)
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])) {
$fail('The URL must be a public HTTP or HTTPS address.');
return;
}
$addresses = filter_var($host, FILTER_VALIDATE_IP)
? [$host]
: array_values(array_filter(array_map(
fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null,
dns_get_record($host, DNS_A | DNS_AAAA) ?: []
)));
if ($addresses === []) {
$fail('The URL host could not be resolved.');
return;
}
foreach ($addresses as $address) {
if (! filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
$fail('Private and reserved destinations are not allowed.');
return;
}
}
}
}
Spremite oznake i generirajte preglede
U migraciji oznake izričito dodajte stanje domene:
$table->id();
$table->string('title', 160);
$table->text('url');
$table->string('preview_status', 32)->default('pending');
$table->string('preview_path')->nullable();
$table->string('preview_failure', 64)->nullable();
$table->timestamps();
Učinite ta polja popunjivima u App\Models\Bookmark. Zatim stvorite posao:
<?php
namespace App\Jobs;
use App\Models\Bookmark;
use App\Services\Screenshots\ScreenshotClient;
use App\Services\Screenshots\ScreenshotException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class GenerateBookmarkPreview implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 70;
public function __construct(public readonly int $bookmarkId) {}
public function handle(ScreenshotClient $client): void
{
$bookmark = Bookmark::find($this->bookmarkId);
if (! $bookmark) {
return;
}
try {
$capture = $client->capture($bookmark->url);
$path = "bookmark-previews/{$bookmark->id}.png";
if (! Storage::disk('public')->put($path, $capture->png)) {
throw new ScreenshotException(
'storage',
'Preview could not be stored.'
);
}
$bookmark->update([
'preview_status' => 'ready',
'preview_path' => $path,
'preview_failure' => null,
]);
Log::info('Bookmark preview generated.', [
'bookmark_id' => $bookmark->id,
'cache_headers' => $capture->cacheHeaders,
'quota_headers' => $capture->quotaHeaders,
]);
} catch (ScreenshotException $exception) {
$bookmark->update([
'preview_status' => $exception->kind === 'quota_limited'
? 'quota_limited'
: 'failed',
'preview_failure' => $exception->kind,
]);
Log::warning('Bookmark preview generation failed.', [
'bookmark_id' => $bookmark->id,
'failure' => $exception->kind,
'service_headers' => $exception->context,
]);
}
}
public function failed(?Throwable $exception): void
{
Bookmark::whereKey($this->bookmarkId)->update([
'preview_status' => 'failed',
'preview_failure' => 'job_failed',
]);
}
}
Zapisnici namjerno isključuju token, tijelo odgovora i potpuni ciljni URL. Identifikatori oznaka i operativna zaglavlja obično su dovoljni za istraživanje neuspjeha bez otkrivanja vjerodajnica ili podataka o pregledavanju.
Izložite stvaranje, anketiranje i isporuku slike
Kontroler pri stvaranju vraća 202 Accepted. Klijenti anketiraju rutu prikaza dok preview_status ne postane ready, failed ili quota_limited.
<?php
namespace App\Http\Controllers;
use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Rules\SafePublicUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
final class BookmarkController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:160'],
'url' => ['required', new SafePublicUrl()],
]);
$bookmark = Bookmark::create($data);
GenerateBookmarkPreview::dispatch($bookmark->id);
return response()->json($this->payload($bookmark), 202);
}
public function show(Bookmark $bookmark): JsonResponse
{
return response()->json($this->payload($bookmark));
}
public function preview(Bookmark $bookmark)
{
abort_unless(
$bookmark->preview_status === 'ready'
&& $bookmark->preview_path
&& Storage::disk('public')->exists($bookmark->preview_path),
404
);
return Storage::disk('public')->response(
$bookmark->preview_path,
null,
[
'Content-Type' => 'image/png',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'public, max-age=3600',
]
);
}
private function payload(Bookmark $bookmark): array
{
return [
'id' => $bookmark->id,
'title' => $bookmark->title,
'url' => $bookmark->url,
'preview_status' => $bookmark->preview_status,
'preview_failure' => $bookmark->preview_failure,
'preview_url' => $bookmark->preview_status === 'ready'
? route('bookmarks.preview', $bookmark)
: null,
];
}
}
Registrirajte rute u routes/web.php, dodajući uobičajeni međuprogram za autentifikaciju i autorizaciju ako oznake pripadaju pojedinačnim korisnicima:
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::get('/bookmarks/{bookmark}/preview', [BookmarkController::class, 'preview'])
->name('bookmarks.preview');
Testirajte bez pozivanja stvarne usluge
Http::fake() čini binarne odgovore determinističkima i sprječava testove da troše kvotu. Donja signalna zaglavlja postoje samo u lažnom odgovoru radi provjere mapiranja granice; ne potvrđuju određene nazive produkcijskih zaglavlja.
<?php
namespace Tests\Feature;
use App\Jobs\GenerateBookmarkPreview;
use App\Models\Bookmark;
use App\Services\Screenshots\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class GenerateBookmarkPreviewTest extends TestCase
{
use RefreshDatabase;
public function test_it_stores_a_valid_png_preview(): void
{
config(['services.screenshot.token' => 'test-token']);
Storage::fake('public');
$png = "\x89PNG\r\n\x1a\nfake-test-payload";
Http::fake([
'*' => Http::response($png, 200, [
'Content-Type' => 'image/png',
'X-Test-Cache' => 'hit',
'X-Test-Quota' => '9',
]),
]);
$bookmark = Bookmark::create([
'title' => 'Example',
'url' => 'https://example.com',
]);
(new GenerateBookmarkPreview($bookmark->id))
->handle(app(ScreenshotClient::class));
$bookmark->refresh();
$this->assertSame('ready', $bookmark->preview_status);
Storage::disk('public')->assertExists(
"bookmark-previews/{$bookmark->id}.png"
);
Http::assertSentCount(1);
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& str_contains($request->url(), 'url=https%3A%2F%2Fexample.com')
);
}
public function test_authentication_failure_is_not_retried(): void
{
config(['services.screenshot.token' => 'expired-token']);
Storage::fake('public');
Http::fake(['*' => Http::response('', 401)]);
$bookmark = Bookmark::create([
'title' => 'Example',
'url' => 'https://example.com',
]);
(new GenerateBookmarkPreview($bookmark->id))
->handle(app(ScreenshotClient::class));
$this->assertSame(
'authentication',
$bookmark->refresh()->preview_failure
);
Http::assertSentCount(1);
}
}
Implementirajte i upravljajte njime
Pokrenite migracije, predmemorirajte konfiguraciju nakon instaliranja tokena okruženja i pokrenite nadzirani radnik reda čekanja. Vremensko ograničenje radnika mora biti dulje od ograničenja posla od 70 sekundi:
php artisan migrate --force
php artisan config:cache
php artisan queue:work --tries=1 --timeout=80 --max-time=3600
Osigurajte da upravitelj procesa ponovno pokreće radnike nakon implementacija i da je konfigurirani javni disk upisiv. Budući da kontroler struji datoteku, storage:link nije potreban za ovu implementaciju. Pri većem opsegu rada, Laravelov disk podržan objektnom pohranom prirodna je zamjena bez mijenjanja API klijenta.
Oznaka koja ostane na pending obično pokazuje da nijedan radnik ne obrađuje red čekanja. Neuspjeh authentication obično znači da token nedostaje, zastario je ili je opozvan ponovnim generiranjem. Neuspjeh invalid_request upućuje na cilj koji je odbijen na granici usluge. Stanje quota_limited trebalo bi ostati vidljivo umjesto da ga se zatrpava automatskim ponovnim pokušajima; pokušajte ponovno kasnije prema planu i vraćenim informacijama o kvoti. Neuspjeh invalid_response znači da nominalno uspješan odgovor zapravo nije bio PNG.
Završni kontrolni popis za provjeru
- Stvarni token postoji samo u konfiguraciji podržanoj okruženjem.
- Javna HTTPS oznaka vraća
202i ulazi u stanjepending. - Radnik reda čekanja proizvodi valjani PNG i mijenja status u
ready. - Ruta pregleda vraća
image/pngsnosniff. - Privatni, rezervirani URL-ovi, URL-ovi s vjerodajnicama i ne-HTTP URL-ovi se odbacuju.
- Neuspjesi autentifikacije, validacije, kvote, neispravne slike, mreže i pohrane postaju strukturirana stanja.
- Zapisnici sadrže ID-ove oznaka i korisna operativna zaglavlja, ali ne token ni tijelo slike.
- Automatizirani testovi ne šalju vanjske zahtjeve i ne troše kvotu usluge.
Važan rezultat nije samo snimka zaslona na kartici oznake. To je čista operativna granica: rad preglednika ostaje izvan vaše aplikacije, binarni podaci obrađuju se obrambeno, spor rad odvija se asinkrono, a svaki predvidivi neuspjeh ima stanje koje programeri i korisnici mogu razumjeti. To je ono što privlačan pregled pretvara u značajku koju možete sigurno zadržati u produkciji.