Izvorni PHP 8.3: Ekstraktor kompleta brenda za automatizirani dizajn prijedloga
Prijedlog može biti tehnički savršen, a ipak izgledati improvizirano kada se njegov logotip, boje, tipografija i slike sastavljaju ručno. Uobičajeni prečac — kopiranje logotipa s web-mjesta i nagađanje njegove primarne boje — također stvara zastarjele resurse, nedosljedne predloške i upitno podrijetlo.
Ovaj vodič izrađuje integraciju u izvornom PHP-u 8.3 koja izdvaja vizualni identitet web-mjesta, provjerava rezultat na granici aplikacije i pohranjuje nepromjenjivu snimku brenda za svakodnevni generator prijedloga i izvješća. Udaljeno izdvajanje odvija se tijekom izričite naredbe za uvoz, nikada tijekom renderiranja dokumenta namijenjenog korisniku.
Pristupite usluzi i izradite servisni token
Najprije se registrirajte na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Brand Kit Extractor.
- Odaberite dostupni paket Free, Plus ili Pro i dovršite aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
- Pohranite ga u konfiguraciju podržanu varijablama okruženja, nikada u izvorni PHP kôd ili commitani fixture.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer je vjerojatnije da će se vjerodajnice u nizu upita pojaviti u zapisnicima pristupa i sustavima nadzora.
Ponovno generiranje servisnog tokena opoziva prethodni aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte tajnu u svakom pokrenutom okruženju prije nego što uklonite pretpostavke o staroj vrijednosti.
Potvrdite API ugovor prije pisanja aplikacije
Točan zahtjev je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo sadrži url. Započnite s minimalnim zahtjevom prema javnom web-mjestu za čiju ste obradu ovlašteni:
curl --fail-with-body --silent --show-error \
--request POST \
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{"url":"https://example.com"}'
Pregledajte rezultat prema trenutačnoj dokumentaciji. Granica aplikacije mora provjeriti vraćeni naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable prije nego što išta dosegne pohranu ili predložak.
Izradite datoteku .env koja se ne prati za lokalni razvoj:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_DATA_DIR=var/brand-kits
Dodajte .env i var/brand-kits/ u .gitignore. Izvorni PHP ne učitava automatski dotenv datoteke, stoga učitajte ovu lokalnu datoteku u proces prije pokretanja naredbe:
set -a
. ./.env
set +a
php bin/import-brand.php https://example.com
U produkciji ubrizgajte iste varijable putem upravitelja procesa, tajne spremnika ili platforme za implementaciju. Nemojte kopirati lokalnu datoteku na sliku poslužitelja.
Arhitektura: uvezite jednom, renderirajte lokalno
Projekt namjerno odvaja četiri odgovornosti:
- Transport: izvršava jedan ograničeni HTTP zahtjev.
- API klijent: obrađuje autentikaciju, ponovne pokušaje, dekodiranje i klasifikaciju statusa.
- Mapper domene: odbacuje nepotpune ili nesigurne podatke o brendu.
- Pohrana snimki: atomski objavljuje provjerene podatke za renderiranje prijedloga.
Time se mrežna latencija i kvarovi trećih strana zadržavaju izvan puta renderiranja dokumenta. Kompromis je kontrolirana zastarjelost: ažuriranje brenda vidljivo je tek nakon novog uvoza. Za prijedloge i periodična izvješća ta je predvidljivost obično poželjnija od promjene dokumenta usred postupka generiranja.
brand-proposals/
├── bin/import-brand.php
├── src/BrandKit.php
├── src/BrandKitClient.php
├── src/BrandKitStore.php
├── src/Http/CurlTransport.php
├── src/Http/Response.php
├── src/Http/Transport.php
├── tests/BrandKitClientTest.php
├── var/brand-kits/
├── composer.json
└── phpunit.xml
Upotrijebite Composer samo za automatsko učitavanje i pokretač testova:
{
"require": {
"php": "^8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Izgradite ograničeni izvorni cURL transport
Transport upravlja mehanikom povezivanja, a ne poslovnom politikom. Onemogućuje preusmjeravanja, dopušta samo HTTPS, zadržava zaglavlja odgovora i primjenjuje konačna vremenska ograničenja za povezivanje i ukupno trajanje.
<?php
// src/Http/Transport.php
namespace App\Http;
interface Transport
{
public function postJson(string $url, array $headers, array $body): Response;
}
// src/Http/Response.php
namespace App\Http;
final readonly class Response
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/Http/CurlTransport.php
namespace App\Http;
use RuntimeException;
final class CurlTransport implements Transport
{
public function postJson(string $url, array $headers, array $body): Response
{
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
$responseHeaders = [];
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 15000,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => array_merge(
['Content-Type: application/json', 'Accept: application/json'],
$headers
),
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => static function ($handle, string $line)
use (&$responseHeaders): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
try {
$body = curl_exec($handle);
if ($body === false) {
throw new RuntimeException(
'Brand Kit transport failed: ' . curl_error($handle)
);
}
if (strlen($body) > 2_000_000) {
throw new RuntimeException('Brand Kit response exceeds size limit');
}
return new Response(
curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$responseHeaders,
$body
);
} finally {
curl_close($handle);
}
}
}
Nemojte zapisivati zaglavlja zahtjeva: sadrže token. Također izbjegavajte zapisivanje potpunog odgovora jer izdvojeni profili i URL-ovi resursa mogu biti podaci povezani s korisnikom.
Mapirajte odgovor u strogi objekt domene
Mapper je granica povjerenja. Sljedeći kanonski objekt interno koristi nazive u snake_case formatu. Ako trenutačna dokumentacija usluge drugačije omata ili imenuje svojstva, prevedite ta dokumentirana svojstva u fromPayload(); nemojte širiti pretpostavke o sirovom odgovoru kroz renderer.
<?php
// src/BrandKit.php
namespace App;
use DomainException;
final readonly class BrandKit
{
public function __construct(
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
public static function fromPayload(array $data): self
{
$requiredArrays = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
if (!isset($data['brand_name'])
|| !is_string($data['brand_name'])
|| trim($data['brand_name']) === ''
|| strlen($data['brand_name']) > 200
) {
throw new DomainException('Invalid brand name');
}
foreach ($requiredArrays as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new DomainException("Invalid or missing {$field}");
}
}
foreach ($data['css_variables'] as $name => $value) {
if (!is_string($name)
|| preg_match('/^--[a-z0-9-]{1,64}$/i', $name) !== 1
|| !is_string($value)
|| strlen($value) > 200
|| strpbrk($value, ';{}') !== false
) {
throw new DomainException('Unsafe CSS variable');
}
}
self::validateTree($data['logos']);
self::validateTree($data['colors']);
self::validateTree($data['fonts']);
self::validateTree($data['imagery']);
self::validateTree($data['social_profiles']);
return new self(
trim($data['brand_name']),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
private static function validateTree(array $items, int $depth = 0): void
{
if ($depth > 8 || count($items) > 500) {
throw new DomainException('Brand data exceeds structural limits');
}
foreach ($items as $value) {
if (is_array($value)) {
self::validateTree($value, $depth + 1);
} elseif (!is_string($value) && !is_int($value)
&& !is_float($value) && !is_bool($value)
&& $value !== null
) {
throw new DomainException('Unsupported brand data value');
}
if (is_string($value) && strlen($value) > 4096) {
throw new DomainException('Brand data value is too long');
}
}
}
public function toArray(): array
{
return [
'brand_name' => $this->brandName,
'logos' => $this->logos,
'colors' => $this->colors,
'fonts' => $this->fonts,
'imagery' => $this->imagery,
'social_profiles' => $this->socialProfiles,
'css_variables' => $this->cssVariables,
];
}
}
Provjera uspostavlja strukturnu sigurnost, a ne dopuštenje za umetanje proizvoljnih vrijednosti u HTML ili CSS. Predlošci trebaju escapati tekst, dopustiti samo očekivane oblike URL-ova resursa i koristiti odobrena CSS svojstva. Fontovi i udaljene slike ne bi se trebali preuzimati samo zato što se pojavljuju u odgovoru.
Dodajte ponovne pokušaje svjesne statusa i stanja neuspjeha
Klijent ponovno pokušava samo prolazne pogreške transporta i odabrane privremene HTTP odgovore. Pogreške autentikacije i provjere su konačne. Dugi Retry-After postaje strukturirani neuspjeh koji planer može ponovno obraditi kasnije, umjesto da zauzima PHP radnik.
<?php
// src/BrandKitClient.php
namespace App;
use App\Http\Transport;
use RuntimeException;
use Throwable;
final class BrandKitClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
public function __construct(
private readonly Transport $transport,
private readonly string $token,
private readonly ?\Closure $sleeper = null,
) {}
public function extract(string $websiteUrl): BrandKit
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->postJson(
self::ENDPOINT,
['Authorization: Bearer ' . $this->token],
['url' => $websiteUrl]
);
} catch (Throwable $error) {
if ($attempt === 3) {
throw new RuntimeException(
'Brand extraction transport unavailable', 0, $error
);
}
$this->pause(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$payload = json_decode(
$response->body, true, 512, JSON_THROW_ON_ERROR
);
} catch (\JsonException $error) {
throw new RuntimeException('Service returned invalid JSON', 0, $error);
}
if (!is_array($payload)) {
throw new RuntimeException('Service returned an invalid payload');
}
return BrandKit::fromPayload($payload);
}
if (in_array($response->status, [401, 403], true)) {
throw new RuntimeException('Brand Kit authentication rejected');
}
$retryable = $response->status === 429
|| in_array($response->status, [502, 503, 504], true);
if (!$retryable || $attempt === 3) {
throw new RuntimeException(
"Brand extraction failed with HTTP {$response->status}"
);
}
$retryAfter = filter_var(
$response->headers['retry-after'] ?? null,
FILTER_VALIDATE_INT
);
if ($retryAfter !== false && $retryAfter > 5) {
throw new RuntimeException(
"Brand extraction rate limited; retry after {$retryAfter} seconds"
);
}
$this->pause(
$retryAfter !== false
? $retryAfter * 1000
: 250 * (2 ** ($attempt - 1))
);
}
throw new RuntimeException('Unreachable retry state');
}
private function pause(int $milliseconds): void
{
if ($this->sleeper !== null) {
($this->sleeper)($milliseconds);
return;
}
usleep($milliseconds * 1000);
}
}
Ponovni pokušaji mogu trošiti kvotu, a POST kojem je isteklo vrijeme možda je već stigao do usluge. Neka broj pokušaja bude malen, predmemorirajte uspješne snimke i prepustite operateru ili zakazanom procesu rješavanje dugotrajnih prekida.
Objavite atomsku snimku putem CLI naredbe
Pohrana zapisuje privremenu datoteku i preimenuje je tek nakon što je potpuni JSON dokument trajno zapisan. Renderer stoga vidi ili staru snimku ili novu, nikada djelomično zapisanu datoteku.
<?php
// src/BrandKitStore.php
namespace App;
use RuntimeException;
final class BrandKitStore
{
public function __construct(private readonly string $directory) {}
public function save(string $sourceUrl, BrandKit $kit): string
{
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)
) {
throw new RuntimeException('Cannot create brand data directory');
}
$path = $this->directory . '/' . hash('sha256', $sourceUrl) . '.json';
$temporary = tempnam($this->directory, 'brand-');
if ($temporary === false) {
throw new RuntimeException('Cannot create temporary snapshot');
}
$document = [
'source_url' => $sourceUrl,
'imported_at' => gmdate(DATE_ATOM),
'kit' => $kit->toArray(),
];
try {
$written = file_put_contents(
$temporary,
json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
LOCK_EX
);
if ($written === false || !chmod($temporary, 0640)
|| !rename($temporary, $path)
) {
throw new RuntimeException('Cannot publish brand snapshot');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
return $path;
}
}
<?php
// bin/import-brand.php
use App\BrandKitClient;
use App\BrandKitStore;
use App\Http\CurlTransport;
require dirname(__DIR__) . '/vendor/autoload.php';
$url = $argv[1] ?? '';
$token = getenv('BRAND_KIT_TOKEN');
$dataDir = getenv('BRAND_DATA_DIR') ?: 'var/brand-kits';
if ($token === false || $token === '') {
fwrite(STDERR, "BRAND_KIT_TOKEN is not configured\n");
exit(2);
}
$parts = parse_url($url);
if (!filter_var($url, FILTER_VALIDATE_URL)
|| ($parts['scheme'] ?? '') !== 'https'
|| empty($parts['host'])
|| strtolower($parts['host']) === 'localhost'
) {
fwrite(STDERR, "Supply a public HTTPS website URL\n");
exit(2);
}
$started = hrtime(true);
try {
$kit = (new BrandKitClient(new CurlTransport(), $token))->extract($url);
$path = (new BrandKitStore($dataDir))->save($url, $kit);
error_log(json_encode([
'event' => 'brand_kit_imported',
'source_host' => $parts['host'],
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
], JSON_THROW_ON_ERROR));
fwrite(STDOUT, "Imported {$kit->brandName} into {$path}\n");
} catch (Throwable $error) {
error_log(json_encode([
'event' => 'brand_kit_import_failed',
'source_host' => $parts['host'],
'error_type' => $error::class,
], JSON_THROW_ON_ERROR));
fwrite(STDERR, $error->getMessage() . "\n");
exit(1);
}
Generator prijedloga može učitati snimku, rekonstruirati BrandKit iz njezina člana kit i upotrijebiti provjereni naziv brenda i mapu CSS varijabli. Logotipe, slike, boje, fontove i društvene profile zadržite dostupnima kao strukturirane ulaze, ali escapajte svaku HTML vrijednost i dopustite samo popisom odobrena svojstva korištena u generiranom CSS-u. Sa svakim prijedlogom pohranite identifikator snimke kako bi regenerirani dokument mogao koristiti istu reviziju brendiranja.
Testirajte bez kontaktiranja usluge
Lažni transport čini ponovne pokušaje i putanje neuspjeha determinističkima. Također sprječava da vjerodajnice ili aktivne kvote postanu ovisnosti testova.
<?php
// tests/BrandKitClientTest.php
use App\BrandKitClient;
use App\Http\Response;
use App\Http\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(string $url, array $headers, array $body): Response
{
$response = $this->responses[$this->calls] ?? null;
$this->calls++;
if (!$response instanceof Response) {
throw new RuntimeException('No fake response configured');
}
return $response;
}
}
final class BrandKitClientTest extends TestCase
{
private function validPayload(): string
{
return json_encode([
'brand_name' => 'Example',
'logos' => [],
'colors' => ['#123456'],
'fonts' => ['Example Sans'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#123456'],
], JSON_THROW_ON_ERROR);
}
public function testRetriesTemporaryFailureThenMapsBrand(): void
{
$transport = new FakeTransport([
new Response(503, [], '{}'),
new Response(200, [], $this->validPayload()),
]);
$client = new BrandKitClient($transport, 'test-token', static fn () => null);
$kit = $client->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertSame(2, $transport->calls);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([new Response(401, [], '{}')]);
$client = new BrandKitClient($transport, 'expired-token', static fn () => null);
try {
$client->extract('https://example.com');
self::fail('Expected authentication failure');
} catch (RuntimeException $error) {
self::assertSame('Brand Kit authentication rejected', $error->getMessage());
self::assertSame(1, $transport->calls);
}
}
}
Pokrenite composer install, zatim vendor/bin/phpunit tests. Dodajte slučajeve za neispravan JSON, kategorije koje nedostaju, nesigurne CSS varijable, HTTP 429, prekomjerni Retry-After i iscrpljenost transporta.
Sigurnost, vidljivost i implementacija
Prihvaćajte uvoze samo od ovlaštenih korisnika. Za alat s više korisnika održavajte odobrene domene i odbacujte lokalna, privatna ili rezervirana odredišta prije slanja. Lokalna aplikacija ne dohvaća izravno dostavljeno web-mjesto, ali ograničenja domene i dalje sprječavaju zloupotrebu vaše integracije plaćene usluge.
Držite token izvan zapisnika, konteksta iznimki, povijesti naredbi, fixturea i generiranih izvješća. Ograničite dozvole direktorija snimki, šifrirajte pohranu kada to zahtijeva politika korisnika i rotirajte token putem ploče Service token. Ne zaboravite da ponovno generiranje odmah poništava prethodni aktivni token.
Emitirajte strukturirane događaje za uspjeh, kategoriju neuspjeha, naziv izvornog hosta, latenciju i broj ponovnih pokušaja. Nemojte svaki neuspjeh označiti kao prekid rada: razlikujte odbijanje autentikacije, ograničavanje brzine, provjeru odgovora, pogreške transporta i pogreške objavljivanja u datotečnom sustavu. Upozoravajte na trajne stope neuspjeha, a ne na jedan neuspjeli uvoz.
Za implementaciju su potrebni PHP 8.3 CLI, proširenje cURL, CA certifikati, Composerov optimizirani autoloader, zapisivi trajni direktorij snimki i ubrizgani BRAND_KIT_TOKEN. Pokrećite uvoze kao pozadinski CLI rad ili zakazane poslove, s najviše jednim uvozom po brendu istodobno. Renderiranje dokumenata treba ostati samo za čitanje.
Uobičajeni neuspjesi koje vrijedi uvježbati
- 401 ili 403: provjerite aktivaciju i token ograničen na uslugu. Ako je ponovno generiran, implementirajte zamjenu posvuda.
- 429: poštujte kratku odgodu ponovnog pokušaja; dulja čekanja prepustite planeru umjesto blokiranja radnika.
- Neispravan ili nepotpun JSON: zadržite posljednju valjanu snimku i zabilježite neuspjeh provjere bez pohranjivanja novog odgovora.
- Istek vremena ili privremeni odgovor 5xx: upotrijebite ograničenu politiku ponovnih pokušaja, zatim jasno prijavite neuspjeh.
- Pohrana nije zapisiva: popravite vlasništvo ili montirani volumen; nikada se nemojte vratiti na nezaštićeni javni direktorij.
- Izgled brenda je zastario: pokrenite ovlašteni ponovni uvoz i povežite nove prijedloge s novom snimkom.
Kontrolni popis za konačnu provjeru
- Račun i paket Free, Plus ili Pro aktivni su.
- Servisni token dolazi iz ploče Service token na stranici dokumentacije.
- Nijedan token ne postoji u kontroli izvornog koda, zapisnicima, testovima ili generiranim datotekama.
- Naredba šalje samo
urlna točnu HTTPS krajnju točku. - Naziv brenda, logotipi, boje, fontovi, slike, društveni profili i CSS varijable provjereni su prije pohrane.
- Neuspjesi autentikacije i provjere nikada se ne pokušavaju ponovno naslijepo.
- Snimke se zapisuju atomski, a renderiranje dokumenata ne obavlja udaljeni API poziv.
- Testovi pokrivaju uspjeh, ponovne pokušaje, odbijanje autentikacije, neispravne podatke i ograničavanje brzine.
Trajan rezultat više je od praktičnog API poziva. To je mali, provjerljiv sadržajni cjevovod: izdvajanje prikuplja dokaze, granica domene odlučuje što je pouzdano, atomska pohrana čuva poznatu dobru reviziju, a generator prijedloga renderira iz stabilnih lokalnih podataka. To razdvajanje pretvara automatizirano brendiranje iz vizualnog prečaca u pouzdanu produkcijsku infrastrukturu.