Native PHP 8.3: Екстрактор на комплет за бренд за автоматизиран дизајн на понуди
Предлогот може да биде технички совршен, а сепак да изгледа импровизирано кога неговото лого, бои, типографија и слики се составени рачно. Вообичаената кратенка — копирање лого од веб-страница и нагаѓање на неговата примарна боја — создава и застарени средства, неконзистентни шаблони и сомнително потекло.
Ова упатство создава Native PHP 8.3 интеграција што го извлекува визуелниот идентитет на веб-страница, го валидира резултатот на границата на апликацијата и складира непроменлива снимка на брендот за секојдневен генератор на предлози и извештаи. Далечинското извлекување се случува при изречна команда за увоз, никогаш при рендерирање документ наменет за клиент.
Добијте пристап и создадете сервисен токен
Прво, регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете го достапниот Free, Plus или Pro план и завршете ја активацијата.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот со опсег за услугата.
- Чувајте го во конфигурација поддржана од променливи на околината, никогаш во PHP изворниот код или во комитирана тест-датотека.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен бидејќи ингеренциите во низа за пребарување поверојатно ќе се појават во дневниците за пристап и системите за следење.
Повторното генерирање на сервисниот токен го поништува претходниот активен токен. Третирајте ја ротацијата како операција за распоредување: ажурирајте ја тајната во секоја активна околина пред да ги отстраните претпоставките за старата вредност.
Потврдете го API-договорот пред да ја напишете апликацијата
Точното барање е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Неговото JSON тело содржи url. Почнете со минимално барање кон јавна страница за која сте овластени да ја обработувате:
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"}'
Проверете го резултатот во однос на тековната документација. Границата на апликацијата мора да ги валидира вратеното име на брендот, логоата, боите, фонтoвите, сликите, социјалните профили и CSS-променливите пред нешто да стигне до складиштето или шаблон.
Создадете непратена .env датотека за локален развој:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_DATA_DIR=var/brand-kits
Додајте .env и var/brand-kits/ во .gitignore. Native PHP не вчитува автоматски dotenv датотеки, па вчитајте ја оваа локална датотека во процесот пред да ја извршите командата:
set -a
. ./.env
set +a
php bin/import-brand.php https://example.com
Во продукција, внесете ги истите променливи преку менаџерот на процеси, тајна на контејнерот или платформата за распоредување. Не ја копирајте локалната датотека во слика на серверот.
Архитектура: увезете еднаш, рендерирајте локално
Проектот намерно раздвојува четири одговорности:
- Транспорт: извршува едно ограничено HTTP-барање.
- API-клиент: управува со автентикација, повторни обиди, декодирање и класификација на статусот.
- Мапер на доменот: отфрла нецелосни или небезбедни податоци за брендот.
- Складиште за снимки: атомски објавува валидирани податоци за рендерирање предлози.
Ова ги задржува мрежната латентност и неуспесите на трети страни надвор од патеката за рендерирање документи. Компромисот е контролирана застареност: ажурирањето на брендот е видливо само по следен увоз. За предлози и периодични извештаи, таа предвидливост обично е попожелна од промена на документ на половина од процесот на генерирање.
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
Користете Composer само за автоматско вчитување и извршувачот на тестови:
{
"require": {
"php": "^8.3"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Изградете ограничен native cURL транспорт
Транспортот е одговорен за механиката на поврзување, не за деловната политика. Тој ги оневозможува пренасочувањата, дозволува само HTTPS, ги задржува заглавијата на одговорот и применува конечни временски ограничувања за поврзување и вкупно траење.
<?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);
}
}
}
Не ги евидентирајте заглавијата на барањето: тие го содржат токенот. Исто така, избегнувајте евидентирање на целосниот одговор бидејќи извлечените профили и URL-адресите на средствата може да бидат податоци поврзани со клиент.
Мапирајте го одговорот во строг доменски објект
Маперот е граница на доверба. Следниот канонски објект користи имиња во snake-case интерно. Ако тековната сервисна документација ги обвиткува или именува својствата поинаку, преведете ги тие документирани својства во fromPayload(); не ширете претпоставки за суровиот одговор низ целиот рендерер.
<?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,
];
}
}
Валидацијата воспоставува структурна безбедност, не дозвола за вметнување произволни вредности во HTML или CSS. Шаблоните треба да избегнуваат текст, да дозволуваат само очекувани форми на URL-адреси на средства и да користат одобрени CSS-својства. Фонтовите и далечинските слики не треба да се преземаат само затоа што се појавуваат во одговорот.
Додајте повторни обиди свесни за статусот и состојби на неуспех
Клиентот повторува само привремени неуспеси на транспортот и избрани привремени HTTP-одговори. Неуспесите на автентикацијата и валидацијата се конечни. Долгото Retry-After станува структуриран неуспех што распоредувачот може повторно да го разгледа подоцна, наместо да зафаќа PHP-работник.
<?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);
}
}
Повторните обиди може да потрошат квота, а POST со истечено време можеби веќе стигнал до услугата. Одржувајте мал број обиди, кеширајте успешни снимки и дозволете оператор или закажан процес да управува со продолжени прекини.
Објавете атомска снимка од CLI-команда
Складиштето запишува привремена датотека и ја преименува само откако целосниот JSON-документ е траен. Затоа, рендерерот гледа или стара снимка или нова, никогаш делумно запишана датотека.
<?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);
}
Генераторот на предлози може да ја вчита снимката, повторно да создаде BrandKit од нејзиниот член kit и да ги користи валидираното име на брендот и мапата на CSS-променливи. Чувајте ги логоата, сликите, боите, фонтовите и социјалните профили достапни како структурирани влезови, но избегнувајте ја секоја HTML-вредност и дозволувајте само одобрени својства што се користат во генерираниот CSS. Зачувајте го идентификаторот на снимката со секој предлог за повторно генерираниот документ да може да ја користи истата ревизија на брендирањето.
Тестирајте без да контактирате со услугата
Лажен транспорт ги прави повторните обиди и патеките на неуспех детерминистички. Исто така спречува ингеренциите или активните квоти да станат зависности на тестовите.
<?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);
}
}
}
Извршете composer install, па vendor/bin/phpunit tests. Додајте случаи за неправилен JSON, категории што недостигаат, небезбедни CSS-променливи, HTTP 429, прекумерно Retry-After и исцрпување на транспортот.
Безбедност, набљудливост и распоредување
Прифаќајте увози само од овластени корисници. За алатка со повеќе клиенти, одржувајте одобрени домени и отфрлајте локални, приватни или резервирани одредишта пред поднесувањето. Локалната апликација не ја презема директно доставената страница, но ограничувањата на домените сепак спречуваат злоупотреба на вашата платена сервисна интеграција.
Чувајте го токенот надвор од дневниците, контекстот на исклучоци, историјата на команди, тест-датотеките и генерираните извештаи. Ограничете ги дозволите за директориумот со снимки, шифрирајте го складиштето кога политиката на клиентот го бара тоа и ротирајте го токенот преку панелот Service token. Запомнете дека повторното генерирање веднаш го поништува поранешниот активен токен.
Емитувајте структурирани настани за успех, категорија на неуспех, име на изворниот хост, латентност и број на повторни обиди. Не означувајте секој неуспех како прекин: разликувајте одбиена автентикација, ограничување на стапката, валидација на одговор, транспортни грешки и грешки при објавување во датотечниот систем. Поставете предупредување за постојани стапки на неуспех наместо за еден неуспешен увоз.
За распоредувањето се потребни PHP 8.3 CLI, cURL-екстензија, CA-сертификати, оптимизиран автоматски вчитувач на Composer, запишлив траен директориум за снимки и внесен BRAND_KIT_TOKEN. Извршувајте увози како CLI-задачи во заднина или закажани задачи, со само еден увоз по бренд во исто време. Рендерирањето документи треба да остане само за читање.
Вообичаени неуспеси што вреди да се вежбаат
- 401 или 403: проверете ги активацијата и токенот со опсег за услугата. Ако бил повторно генериран, распоредете ја замената насекаде.
- 429: почитувајте кратко доцнење за повторен обид; одложете ги подолгите чекања на распоредувачот наместо да блокирате работник.
- Неправилен или нецелосен JSON: задржете ја последната валидна снимка и евидентирајте неуспех на валидацијата без да го складирате новиот одговор.
- Истечено време или привремен одговор 5xx: користете ја ограничената политика за повторни обиди, потоа јасно означете неуспех.
- Незапишливо складиште: поправете ја сопственоста или монтираниот волумен; никогаш не прибегнувајте кон незаштитен јавен директориум.
- Изгледот на брендот е застарен: извршете овластен повторен увоз и поврзете ги новите предлози со новата снимка.
Контролна листа за конечна потврда
- Сметката и Free, Plus или Pro планот се активни.
- Сервисниот токен доаѓа од панелот Service token на страницата за документација.
- Ниту еден токен не постои во изворната контрола, дневниците, тестовите или генерираните датотеки.
- Командата испраќа само
urlдо точната HTTPS-крајна точка. - Името на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS-променливите се валидираат пред складирањето.
- Неуспесите на автентикацијата и валидацијата никогаш не се повторуваат слепо.
- Снимките се запишуваат атомски, а рендерирањето документи не врши далечински API-повик.
- Тестовите покриваат успех, повторни обиди, одбивање на автентикација, неправилни податоци и ограничување на стапката.
Трајниот резултат е повеќе од удобен API-повик. Тој е мал, проверлив канал за содржина: извлекувањето собира докази, доменската граница одлучува што е доверливо, атомското складирање зачувува позната добра ревизија, а генераторот на предлози рендерира од стабилни локални податоци. Тоа раздвојување е она што го претвора автоматизираното брендирање од визуелна кратенка во сигурна продукциска инфраструктура.