Vodiči

Native PHP 8.3: Brand Kit Extractor for Automated Proposal Design

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.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni paket Free, Plus ili Pro i dovršite aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. 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 url na 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.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.