Native PHP: Екстрактор на комплет за бренд за лесни предлози и извештаи
Предлогот може да биде технички совршен, а сепак да изгледа недовршен кога неговото лого, бои и типографија се погрешни. Рачното копирање на тие детали е заморно, додека прифаќањето непроверени податоци за дизајнот од далечна услуга е ризично. Корисната средина е мала граница за увоз: извлечете го визуелниот идентитет на јавна веб-страница, потврдете го секој задолжителен дел и објавете непроменлива снимка на брендот што може да ја користи генератор за предлози или извештаи.
Овој туторијал ја гради таа граница со Native PHP 8.3, cURL и PHPUnit. Резултатот е команда што увезува податоци за бренд засновани на докази, без да го поврзува остатокот од апликацијата со одговор од надворешен API.
Добијте пристап пред да напишете код за интеграција
Прво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Зачувајте го во околината на вашиот проект, никогаш во контрола на изворниот код.
Регенерирањето на токенот за услугата го поништува претходно активниот токен, па ротацијата мора да ја ажурира секоја распоредена инстанца што го користи. Оваа услуга не е без токени: секое барање мора да се автентицира со Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен бидејќи ингеренциите во низа за пребарување почесто се појавуваат во дневници за пристап, историја на прелистувачот и системи за надзор.
Потврдете ја крајната точка со минимално барање
Точната операција е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Нејзиното JSON тело содржи url, кое упатува на јавната веб-страница чиј идентитет треба да се извлече.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}'
Овој тест за основна проверка треба да произведе JSON. Сè уште не го зачувувајте: успешниот HTTP пренос не е доказ дека неговата структура е безбедна за вашата апликација.
Поставете го Native PHP проектот
Увозникот ги раздвојува преносот, API политиката, мапирањето на доменот и складирањето. Тоа ги прави повторните обиди тестираливи и спречува деталите од надворешниот одговор да протечат во шаблоните за предлози.
brand-proposals/
├── bin/import-brand
├── config/bootstrap.php
├── src/
│ ├── BrandKit.php
│ ├── BrandKitClient.php
│ ├── BrandKitMapper.php
│ ├── CurlTransport.php
│ ├── HttpResponse.php
│ ├── Transport.php
│ └── BrandKitRepository.php
├── storage/brands/
├── tests/BrandKitClientTest.php
├── .env
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml
Користете cURL за мрежната граница и vlucas/phpdotenv само за вчитување локална конфигурација на околината. PHPUnit обезбедува детерминистички тест-појас.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
Ставете го местодржачот во .env.example, копирајте ја таа датотека во .env и локално заменете го местодржачот. Додајте /.env и /storage/brands/*.tmp во .gitignore.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Датотеката за подигање вчитува локална конфигурација и неуспева рано кога во распоредувањето недостигаат ингеренции.
<?php
// config/bootstrap.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$token = $_ENV['BRAND_KIT_TOKEN'] ?? getenv('BRAND_KIT_TOKEN') ?: null;
if (!is_string($token) || trim($token) === '') {
throw new RuntimeException('BRAND_KIT_TOKEN is not configured.');
}
return ['brand_kit_token' => $token];
Изградете ограничен, заменлив пренос
Преносот е одговорен за cURL механиката, не за политиката на апликацијата. Тој применува временско ограничување од три секунди за поврзување и вкупно временско ограничување од петнаесет секунди, ги снима заглавијата на одговорот и го враќа телото без да го евидентира.
<?php
// src/Transport.php
namespace App;
interface Transport
{
public function post(string $url, array $headers, string $body): HttpResponse;
}
// src/HttpResponse.php
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/CurlTransport.php
namespace App;
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
if ($handle === false) {
throw new \RuntimeException('Unable to initialize cURL.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 15,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$responseHeaders = [];
} elseif (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Brand service transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $bodyResult);
}
}
Применете намерна политика за повторни обиди
Привремените грешки при пренос, HTTP 429 одговорите и серверските 5xx одговори може да успеат подоцна. Неуспесите на автентикација и неважечките барања нема да се подобрат со повторување, па клиентот веднаш ги отфрла. Три вкупни обиди ги ограничуваат и латентноста и потрошувачката на квота.
<?php
// src/BrandKitClient.php
namespace App;
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 $sleep,
) {}
public function extract(string $sourceUrl): array
{
$body = json_encode(
['url' => $sourceUrl],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(self::ENDPOINT, [
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
], $body);
} catch (\RuntimeException $exception) {
if ($attempt === 3) {
throw $exception;
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new \UnexpectedValueException(
'Brand service returned invalid JSON.',
0,
$exception
);
}
if (!is_array($decoded)) {
throw new \UnexpectedValueException(
'Brand service response must be a JSON object.'
);
}
return $decoded;
}
if (in_array($response->status, [400, 401, 403, 404, 422], true)) {
throw new \DomainException(
'Brand extraction was rejected with HTTP '
. $response->status . '.'
);
}
$retryable = $response->status === 429
|| $response->status >= 500;
if (!$retryable || $attempt === 3) {
throw new \RuntimeException(
'Brand extraction failed with HTTP '
. $response->status . '.'
);
}
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8000));
}
throw new \LogicException('Unreachable retry state.');
}
}
Зависноста за спиење се инјектира како closure. Во продукција се користи usleep($milliseconds * 1000); тестовите ја заменуваат со no-op. HTTP-date облик на Retry-After се враќа на експоненцијално доцнење наместо да се потпира на несигурна синхронизација на часовникот.
Мапирајте го одговорот во доменски објект
Задолжителната граница ги прифаќа вратеното име на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS променливите само кога секој дел го има очекуваниот широк JSON тип. Вгнездените структури на средства остануваат недопрени бидејќи измислувањето недокументирани полиња за лого или фонт би ја направило интеграцијата кревка.
<?php
// src/BrandKit.php
namespace App;
final readonly class BrandKit implements \JsonSerializable
{
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 function jsonSerialize(): 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,
];
}
}
// src/BrandKitMapper.php
namespace App;
final class BrandKitMapper
{
public function map(array $payload): BrandKit
{
$name = $payload['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '') {
throw new \UnexpectedValueException(
'Response has no valid brand_name.'
);
}
$sections = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($sections as $section) {
if (!isset($payload[$section])
|| !is_array($payload[$section])) {
throw new \UnexpectedValueException(
"Response has no valid {$section} section."
);
}
}
return new BrandKit(
trim($name),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$payload['css_variables'],
);
}
}
Овде, „потврдено“ значи дека увезената снимка ја поминала шемската граница на апликацијата. Тоа не потврдува сопственост на трговска марка ниту независно го сертифицира операторот на веб-страницата.
Објавете атомска снимка
Рендерирањето на предлог никогаш не треба да набљудува делумно запишана JSON датотека. Репозиториумот запишува покрај одредиштето, поставува ограничувачки дозволи, а потоа атомски ја преименува завршената датотека.
<?php
// src/BrandKitRepository.php
namespace App;
final class BrandKitRepository
{
public function __construct(private readonly string $directory) {}
public function save(string $key, BrandKit $kit): void
{
if (!preg_match('/\A[a-z0-9][a-z0-9-]{0,63}\z/', $key)) {
throw new \InvalidArgumentException('Invalid brand key.');
}
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)) {
throw new \RuntimeException('Cannot create brand storage.');
}
$temporary = tempnam($this->directory, $key . '-');
if ($temporary === false) {
throw new \RuntimeException('Cannot allocate temporary file.');
}
$json = json_encode(
$kit,
JSON_THROW_ON_ERROR
| JSON_PRETTY_PRINT
| JSON_UNESCAPED_SLASHES
);
try {
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write brand snapshot.');
}
chmod($temporary, 0660);
if (!rename($temporary, $this->directory . '/' . $key . '.json')) {
throw new \RuntimeException('Cannot publish brand snapshot.');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
}
Генераторот на предлози сега може да чита storage/brands/acme.json при рендерирање. Чувајте го складираното извлекување одделно од интерпретацијата специфична за шаблонот: PDF адаптер може да избере примарно лого и конзервативна палета на бои, додека HTML адаптер за извештај може да користи одобрен подзбир од CSS променливи.
Поврзете ја командата за увоз
Командата отфрла URL-адреси што не се HTTPS и вгнездени ингеренции пред да ја повика услугата. Таа евидентира компактен настан без токенот, телото на одговорот или извлечените средства.
<?php
#!/usr/bin/env php
// bin/import-brand
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\BrandKitRepository;
use App\CurlTransport;
$config = require dirname(__DIR__) . '/config/bootstrap.php';
[$script, $key, $url] = $argv + [null, null, null];
$parts = is_string($url) ? parse_url($url) : false;
$validUrl = filter_var($url, FILTER_VALIDATE_URL) !== false
&& is_array($parts)
&& ($parts['scheme'] ?? '') === 'https'
&& !isset($parts['user'], $parts['pass']);
if (!is_string($key) || !$validUrl) {
fwrite(STDERR, "Usage: php bin/import-brand BRAND_KEY HTTPS_URL\n");
exit(64);
}
try {
$client = new BrandKitClient(
new CurlTransport(),
$config['brand_kit_token'],
static fn (int $milliseconds) =>
usleep($milliseconds * 1000),
);
$kit = (new BrandKitMapper())->map($client->extract($url));
(new BrandKitRepository(
dirname(__DIR__) . '/storage/brands'
))->save($key, $kit);
fwrite(STDOUT, json_encode([
'event' => 'brand_kit.imported',
'brand_key' => $key,
'status' => 'ok',
], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'brand_kit.import_failed',
'brand_key' => $key,
'error_type' => $exception::class,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
chmod +x bin/import-brand
php bin/import-brand acme https://www.example.com
Тестирајте повторни обиди без мрежни повици
Лажен пренос го прави однесувањето при неуспех детерминистичко. Овој тест докажува дека барање ограничено по стапка се обидува повторно еднаш и дека мапираниот резултат го содржи секој задолжителен дел.
<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsResponse(): void
{
$transport = new class implements Transport {
public int $calls = 0;
public function post(
string $url,
array $headers,
string $body
): HttpResponse {
$this->calls++;
if ($this->calls === 1) {
return new HttpResponse(429, ['retry-after' => '0'], '{}');
}
return new HttpResponse(200, [], json_encode([
'brand_name' => 'Example',
'logos' => [],
'colors' => [],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [],
], JSON_THROW_ON_ERROR));
}
};
$client = new BrandKitClient(
$transport,
'test-token',
static fn (int $milliseconds) => null,
);
$kit = (new BrandKitMapper())->map(
$client->extract('https://www.example.com')
);
self::assertSame(2, $transport->calls);
self::assertSame('Example', $kit->brandName);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true">
<testsuites>
<testsuite name="Brand importer">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
vendor/bin/phpunit
Безбедност, операции и распоредување
Извршувајте увози како автентицирано административно дејство или команда за закажано одржување, никогаш како анонимна јавна крајна точка. Иако извлекувачот пристапува до јавната веб-страница, ограничете ги поднесените URL-адреси на HTTPS и разгледајте allowlist кога вашиот бизнис веќе ги знае домените на своите клиенти.
Не ставајте вратен CSS директно во страница. Дозволете само променливи и формати на вредности што ги разбира вашиот рендерер. Исто така, преземајте далечни логоа преку контролиран цевковод за средства што ја ограничува големината, типот на содржина, пренасочувањата и времето за преземање пред да ги вградите во HTML или PDF-датотеки.
Во продукција, инјектирајте BRAND_KIT_TOKEN преку управувачот со тајни на хостинг-платформата наместо да распоредувате .env. Осигурете се дека корисникот за извршување може да запишува во storage/brands и чувајте го тој директориум на споделено или трајно складиште ако повеќе инстанци рендерираат извештаи.
Надгледувајте структурирани броења за успешни увози, неуспеси при автентикација, ограничувања на стапка, неуспеси на серверот, неважечки шеми и вкупно времетраење. Алармирајте при трајни неуспеси наместо да евидентирате тела на одговори. 401 или 403 обично укажува на недостасувачки, поништен или неправилно распореден токен. 400 или 422 укажува на отфрлена URL-адреса или барање. Повторените 429 одговори бараат намалена фреквенција на увоз или преглед на планот; повторените 5xx одговори треба да ја зачуваат последната позната добра снимка.
Конечна контролна листа за потврда
- Сметката и Free, Plus или Pro планот се активни.
- Токенот ограничен на услугата се вчитува од околината и не е присутен во Git историјата.
- Тестот за основна проверка успешно стигнува до точната POST крајна точка.
- Командата прифаќа само важечки HTTPS изворни URL-адреси.
- Сите седум задолжителни делови на брендот поминуваат мапирање пред складирањето.
- Неуспесите при автентикација и валидација никогаш не се повторуваат слепо.
- Ограничувањата на стапка, неуспесите при пренос и неуспесите на серверот добиваат ограничени повторни обиди.
- PHPUnit поминува без контакт со надворешната услуга.
- Успешен увоз ја создава очекуваната JSON снимка на брендот.
- Рендерерот за предлози или извештаи користи само одобрени средства и CSS вредности.
Важниот артефакт не е само успешен API повик. Тој е сигурно предавање меѓу јавна веб-страница, потврдена снимка на брендот и документите што луѓето ги создаваат секој ден. Штом таа граница е стабилна, предлозите и извештаите можат да останат препознатливо во согласност со брендот без секое рендерирање да зависи од мрежата — или секој развивач да зависи од рачно копирање и вметнување.