Native PHP 8.3: Додајте резимеа на веб-технологии во CRM-потенцијални клиенти со AI Detector
Веб-страницата на потенцијален клиент често открива повеќе отколку што открива оскудна форма за контакт. Кратка белешка како „WordPress 6.5, WooCommerce, Cloudflare; докази со висока доверливост; едно пренасочување“ ѝ дава на агенцијата непосреден контекст за повици за откривање потреби, проценки и технички ревизии.
Овој туторијал ја гради таа функционалност за мал CRM со PHP 8.3, изворен cURL, SQLite и команда соодветна за cron. Интеграцијата ја валидира API-границата, повторува само соодветни неуспеси, запишува структурирани состојби и останува тестирабилна без мрежни барања.
Добијте пристап до детекторот
Најпрво, регистрирајте сметка, или најавете се ако веќе имате.
Отворете ја страницата на услугата Website Technology Detector, изберете достапен Free, Plus или Pro план и завршете ја активацијата. Потоа отворете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го неговиот токен со опсег на услугата.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе го користиме заглавието Bearer бидејќи параметрите за барање може да протечат во дневниците за пристап и историјата на прелистувачот.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како операција за распоредување: инсталирајте ја замената насекаде каде што работи worker-от, проверете ја, а потоа повторно генерирајте ја или заменете ја старата акредитива според вашиот план за воведување.
Потврдете ја точната крајна точка
Интеграцијата користи POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Нејзиното JSON барање содржи една задолжителна вредност, url. Тестирајте ја без да го ставите токенот во историјата на shell-от:
read -rsp "Service token: " WEBSITE_DETECTOR_TOKEN
curl --fail-with-body \
--connect-timeout 3 \
--max-time 15 \
-X POST \
-H "Authorization: Bearer ${WEBSITE_DETECTOR_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"url":"https://example.com"}' \
https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
unset WEBSITE_DETECTOR_TOKEN
Успешниот одговор содржи детекции со оценета доверливост, верзии, докази и информации за пренасочувања. Зачувајте го одговорот од овој тест само доволно долго за да го споредите со тековната документација; јавните страници сепак можат да откријат оперативни детали што можеби не сакате да се во дневниците.
Сега зачувајте ја акредитивата во локалната конфигурација на околината. Никогаш не ја предавајте оваа датотека во commit:
# .env
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CRM_DSN=sqlite:/srv/agency-crm/var/crm.sqlite
# .gitignore
.env
.phpunit.cache/
vendor/
Архитектура за мал CRM
Дизајнот е намерно скромен. Постоечка табела leads обезбедува ID на потенцијален клиент и URL на јавна веб-страница. Команда избира работа што чека, го повикува детекторот, го мапира одговорот во доменски објекти, запишува читливо резиме и бележи дали неуспехот може да се повтори.
- API адаптер: управува со автентикација, временски ограничувања, JSON декодирање и политика за повторување.
- Доменски мапер: ги валидира детекциите, верзиите, доказите, доверливоста и пренасочувањата пред CRM-кодот да ги види.
- Batch команда: збогатува ограничен број потенцијални клиенти и може да работи под cron.
- Состојба во базата: ги прави успесите идемпотентни и ги разликува привремените од трајните неуспеси.
Извршувањето во заднина е подобро од збогатување во рамките на поднесување форма. Детекцијата бара надворешно барање, па чекањето на продажниот персонал би ја врзало одзивноста на CRM со мрежната латентност. Ред за чекање би бил оправдан во поголем обем, но заклучена, ограничена cron команда е полесна за управување за мала агенција.
Создадете PHP 8.3 проект
Ви треба PHP 8.3 или понов, Composer, изворен cURL, JSON, PDO и PDO SQLite. Единствениот runtime пакет вчитува локални датотеки со променливи на околината; продукцијата може директно да ги внесе истите променливи.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"AgencyCrm\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"AgencyCrm\\Tests\\": "tests/"
}
}
}
composer install
composer dump-autoload
mkdir -p src/Detector src/Http bin database tests var
Се претпоставува дека CRM има leads(id, website_url). Применете ја оваа миграција еднаш преку вашиот вообичаен процес за миграции:
ALTER TABLE leads ADD COLUMN technology_summary TEXT;
ALTER TABLE leads ADD COLUMN technology_status TEXT;
ALTER TABLE leads ADD COLUMN technology_error TEXT;
ALTER TABLE leads ADD COLUMN technology_checked_at TEXT;
CREATE INDEX leads_technology_status_idx
ON leads (technology_status);
Изградете го ограничениот cURL транспорт
Транспортот ја фиксира крајната точка во кодот на апликацијата, користи кратки временски ограничувања за поврзување и вкупно траење и ги враќа само податоците што му се потребни на клиентот. Задржувањето на произволни крајни точки надвор од конфигурацијата на околината спречува компромитирана конфигурациска вредност да го пренасочи сервисниот токен на друго место.
<?php
// src/Http/HttpResponse.php
declare(strict_types=1);
namespace AgencyCrm\Http;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers
) {}
}
<?php
// src/Http/CurlTransport.php
declare(strict_types=1);
namespace AgencyCrm\Http;
use RuntimeException;
final class CurlTransport
{
public function __invoke(
string $url,
array $headers,
string $json
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Could not initialize cURL');
}
$responseHeaders = [];
$headerLines = [];
foreach ($headers as $name => $value) {
$headerLines[] = $name . ': ' . $value;
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 12000,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HEADERFUNCTION => static function (
$handle,
string $line
) use (&$responseHeaders): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))]
= trim($parts[1]);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new RuntimeException('Detector transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body, $responseHeaders);
}
}
Мапирајте го одговорот на услугата на една граница
На API-одговорот никогаш не смее да му се верува само затоа што JSON декодирањето успеало. Маперот подолу бара листа од detections, прескокнува неправилно форматирани записи, ги валидира скаларните полиња, задржува информации за докази и пренасочувања и ги игнорира непознатите дополнувања. Ако официјалната документација го промени договорот за одговорот, ова е единствената класа што треба да се ажурира.
<?php
// src/Detector/TechnologyReport.php
declare(strict_types=1);
namespace AgencyCrm\Detector;
use UnexpectedValueException;
final readonly class Detection
{
public function __construct(
public string $name,
public ?float $confidence,
public array $versions,
public array $evidence
) {}
}
final readonly class TechnologyReport
{
public function __construct(
public array $detections,
public array $redirects
) {}
public static function fromPayload(array $payload): self
{
$items = $payload['detections'] ?? null;
if (!is_array($items) || !array_is_list($items)) {
throw new UnexpectedValueException(
'Detector response has no valid detections list'
);
}
$detections = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$name = $item['name'] ?? null;
if (!is_string($name) || trim($name) === '') {
continue;
}
$confidence = $item['confidence'] ?? null;
$confidence = is_int($confidence) || is_float($confidence)
? (float) $confidence
: null;
$versions = is_array($item['versions'] ?? null)
? array_values(array_filter(
$item['versions'],
static fn (mixed $value): bool =>
is_string($value) && $value !== ''
))
: [];
$evidence = is_array($item['evidence'] ?? null)
? $item['evidence']
: [];
$detections[] = new Detection(
trim($name),
$confidence,
$versions,
$evidence
);
}
$redirects = is_array($payload['redirects'] ?? null)
? $payload['redirects']
: [];
return new self($detections, $redirects);
}
public function summary(): string
{
if ($this->detections === []) {
return 'No technologies detected';
}
$parts = array_map(
static function (Detection $detection): string {
$text = $detection->name;
if ($detection->versions !== []) {
$text .= ' ' . implode(', ', $detection->versions);
}
$details = [];
if ($detection->confidence !== null) {
$details[] = 'confidence '
. rtrim(rtrim(
number_format($detection->confidence, 2, '.', ''),
'0'
), '.');
}
$details[] = count($detection->evidence)
. ' evidence item(s)';
return $text . ' (' . implode('; ', $details) . ')';
},
$this->detections
);
if ($this->redirects !== []) {
$parts[] = count($this->redirects) . ' redirect(s) observed';
}
return implode('; ', $parts);
}
}
Доверливоста намерно се прикажува без претпоставка дека нејзината скала е процент. Верзиите и доказите се опционални на локалната граница бидејќи неправилните делумни податоци не треба да прекинат цела batch-обработка.
Додајте повторувања без да создадете бура од повторувања
Детекцијата е само за читање, па мал буџет за повторување е разумен за грешки во транспортот, ограничување на стапката и одбрани неуспеси на серверот. Не се повторуваат неуспесите при валидација и автентикација. Вредноста Retry-After се почитува само кога е цел број и е ограничена на пет секунди.
<?php
// src/Detector/WebsiteTechnologyClient.php
declare(strict_types=1);
namespace AgencyCrm\Detector;
use AgencyCrm\Http\HttpResponse;
use Closure;
use JsonException;
use RuntimeException;
use Throwable;
final class DetectorException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable,
public readonly ?int $httpStatus = null
) {
parent::__construct($message);
}
}
final class WebsiteTechnologyClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
public function __construct(
private readonly string $token,
private readonly Closure $transport,
private readonly Closure $sleep
) {}
public function detect(string $url): TechnologyReport
{
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new DetectorException('Lead URL is invalid', false);
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
throw new DetectorException('Lead URL must use HTTP or HTTPS', false);
}
try {
$json = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new DetectorException('Could not encode request', false);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = ($this->transport)(
self::ENDPOINT,
[
'Authorization' => 'Bearer ' . $this->token,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
$json
);
} catch (Throwable $exception) {
if ($attempt === 3) {
throw new DetectorException(
'Detector transport unavailable',
true
);
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
return $this->mapResponse($response);
}
$retryable = $response->status === 429
|| in_array($response->status, [500, 502, 503, 504], true);
if (!$retryable || $attempt === 3) {
throw new DetectorException(
'Detector returned HTTP ' . $response->status,
$retryable,
$response->status
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = is_string($retryAfter) && ctype_digit($retryAfter)
? min(5000, max(250, (int) $retryAfter * 1000))
: 250 * (2 ** ($attempt - 1));
($this->sleep)($delay);
}
throw new DetectorException('Retry loop exhausted', true);
}
private function mapResponse(HttpResponse $response): TechnologyReport
{
try {
$payload = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException $exception) {
throw new DetectorException(
'Detector returned invalid JSON',
false,
$response->status
);
}
if (!is_array($payload)) {
throw new DetectorException(
'Detector returned an invalid document',
false,
$response->status
);
}
try {
return TechnologyReport::fromPayload($payload);
} catch (Throwable $exception) {
throw new DetectorException(
'Detector response did not match its contract',
false,
$response->status
);
}
}
}
Збогатете ги CRM-потенцијалните клиенти што чекаат
Командата го ограничува секое извршување, ажурира по еден потенцијален клиент и евидентира идентификатори и состојби наместо акредитиви, тела на одговори, докази или целосни URL-адреси. Трајните неуспеси остануваат видливи за исправка; привремените неуспеси може повторно да се изберат.
<?php
// bin/enrich-leads.php
declare(strict_types=1);
use AgencyCrm\Detector\DetectorException;
use AgencyCrm\Detector\WebsiteTechnologyClient;
use AgencyCrm\Http\CurlTransport;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$token = $_ENV['WEBSITE_DETECTOR_TOKEN']
?? getenv('WEBSITE_DETECTOR_TOKEN')
?: null;
$dsn = $_ENV['CRM_DSN'] ?? getenv('CRM_DSN') ?: null;
if (!is_string($token) || $token === '' || !is_string($dsn) || $dsn === '') {
fwrite(STDERR, "Missing WEBSITE_DETECTOR_TOKEN or CRM_DSN\n");
exit(1);
}
$transport = new CurlTransport();
$sleep = static fn (int $milliseconds): int =>
usleep($milliseconds * 1000);
$client = new WebsiteTechnologyClient(
$token,
Closure::fromCallable($transport),
$sleep(...)
);
$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$select = $pdo->prepare(
"SELECT id, website_url
FROM leads
WHERE website_url IS NOT NULL
AND (
technology_status IS NULL
OR technology_status IN ('pending', 'retryable')
)
ORDER BY id
LIMIT :batch_size"
);
$select->bindValue(':batch_size', 25, PDO::PARAM_INT);
$select->execute();
$update = $pdo->prepare(
"UPDATE leads
SET technology_summary = :summary,
technology_status = :status,
technology_error = :error,
technology_checked_at = :checked_at
WHERE id = :id"
);
foreach ($select as $lead) {
try {
$report = $client->detect((string) $lead['website_url']);
$update->execute([
'summary' => $report->summary(),
'status' => 'complete',
'error' => null,
'checked_at' => gmdate('c'),
'id' => $lead['id'],
]);
error_log(json_encode([
'event' => 'technology_detection_complete',
'lead_id' => $lead['id'],
'detection_count' => count($report->detections),
], JSON_THROW_ON_ERROR));
} catch (DetectorException $exception) {
$status = $exception->retryable ? 'retryable' : 'failed';
$update->execute([
'summary' => null,
'status' => $status,
'error' => $exception->getMessage(),
'checked_at' => gmdate('c'),
'id' => $lead['id'],
]);
error_log(json_encode([
'event' => 'technology_detection_failed',
'lead_id' => $lead['id'],
'status' => $status,
'http_status' => $exception->httpStatus,
], JSON_THROW_ON_ERROR));
}
}
Тестирајте без да ја повикувате надворешната услуга
Детерминистички лажен транспорт му овозможува на тестот да ги провери мапирањето, времето на повторување и однесувањето при автентикација без трошење квота или зависност од мрежата.
<?php
// tests/WebsiteTechnologyClientTest.php
declare(strict_types=1);
namespace AgencyCrm\Tests;
use AgencyCrm\Detector\DetectorException;
use AgencyCrm\Detector\WebsiteTechnologyClient;
use AgencyCrm\Http\HttpResponse;
use PHPUnit\Framework\TestCase;
final class WebsiteTechnologyClientTest extends TestCase
{
public function testItBuildsReadableSummary(): void
{
$transport = static fn (): HttpResponse => new HttpResponse(
200,
json_encode([
'detections' => [[
'name' => 'Example CMS',
'confidence' => 92,
'versions' => ['6.5'],
'evidence' => ['generator metadata', 'asset path'],
]],
'redirects' => [['from' => 'http', 'to' => 'https']],
], JSON_THROW_ON_ERROR),
[]
);
$client = new WebsiteTechnologyClient(
'test-token',
$transport(...),
static fn (int $milliseconds): null => null
);
$summary = $client->detect('https://example.com')->summary();
self::assertSame(
'Example CMS 6.5 (confidence 92; 2 evidence item(s)); '
. '1 redirect(s) observed',
$summary
);
}
public function testItRetriesRateLimitOnce(): void
{
$responses = [
new HttpResponse(429, '{}', ['retry-after' => '1']),
new HttpResponse(200, '{"detections":[],"redirects":[]}', []),
];
$delays = [];
$transport = static function () use (&$responses): HttpResponse {
return array_shift($responses);
};
$sleep = static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
};
$client = new WebsiteTechnologyClient(
'test-token',
$transport(...),
$sleep(...)
);
$client->detect('https://example.com');
self::assertSame([1000], $delays);
}
public function testItDoesNotRetryAuthenticationFailure(): void
{
$calls = 0;
$transport = static function () use (&$calls): HttpResponse {
$calls++;
return new HttpResponse(401, '{}', []);
};
$client = new WebsiteTechnologyClient(
'test-token',
$transport(...),
static fn (int $milliseconds): null => null
);
try {
$client->detect('https://example.com');
self::fail('Expected DetectorException');
} catch (DetectorException $exception) {
self::assertFalse($exception->retryable);
self::assertSame(401, $exception->httpStatus);
self::assertSame(1, $calls);
}
}
}
vendor/bin/phpunit --testdox tests
Безбедност, набљудливост и распоредување
Испраќајте само јавни веб-страници кои вашиот CRM е овластен да ги обработува. Повторно валидирајте ги URL-адресите кога влегуваат во CRM, ограничете ги шемите на HTTP и HTTPS и не ја изложувајте оваа команда како прокси управуван од корисник. Чувајте ги доказите и необработените одговори надвор од општите дневници на апликацијата.
Доделете му на worker-от само дозволи за базата што му се потребни. Заштитете ја .env со рестриктивни дозволи на датотечниот систем локално; во продукција, претпочитајте складиште за тајни на платформата за распоредување или вметнати променливи на околината. Вредностите на токените никогаш не смеат да се појават во fixtures, пораки за исклучоци, слики од екранот или ознаки за мониторинг.
Следете броеви за завршени, повторливи и трајни неуспеси, како и траење на барањето и класа на HTTP-статус. Поставете предупредување за трајни неуспеси при автентикација, повторувано ограничување на стапката или ненадеен пораст на грешки при мапирање на договорот. Овие сигнали разликуваат истечен токен од притисок врз квотата или промена во upstream одговорот.
Распоредете ги зависностите со composer install --no-dev --classmap-authoritative, применете ја миграцијата, внесете ги токенот и DSN и извршете еден потенцијален клиент рачно. Потоа закажете го worker-от со заклучување на оперативниот систем за преклопувачките cron-повикувања да не можат да ја обработат истата batch-обработка:
flock -n /var/lock/agency-crm-enrichment.lock \
php /srv/agency-crm/bin/enrich-leads.php
Вообичаени неуспеси за кои вреди да се дизајнира
- HTTP 401 или 403: потврдете ја активацијата на планот и токенот со опсег на услугата. Ако е повторно генериран, претходниот токен е поништен. Не повторувајте автоматски.
- HTTP 400 или 422: проверете ја зачуваната URL-адреса на потенцијалниот клиент и тековната документација. Исправете го влезот наместо да го повторувате.
- HTTP 429: почитувајте ограничено повлекување, намалете ја зачестеноста на batch-обработките и проверете ги активниот план или квотата.
- Истекување на времето или одбрани 5xx одговори: дозволете го буџетот од три обиди, а потоа задржете
retryableза подоцнежно закажано извршување. - Грешки при мапирање на договорот: споредете дезинфициран одговор со официјалната документација и ажурирајте го само граничниот мапер.
- Празни детекции: третирајте го ова како валиден резултат, а не како оперативен неуспех. Јавна страница може да обезбеди недоволни детерминистички докази.
Конечна листа за проверка
- Сметката и Free, Plus или Pro сервисниот план се активни.
- Тековниот сервисен токен е зачуван само во конфигурација поддржана од променливи на околината.
- Минималното POST барање успева со JSON тело што содржи
url. - PHPUnit поминува без да направи вистинско мрежно барање.
- Потенцијален клиент што чека станува
completeсо читливо резиме на технологии. - 401 станува
failedпо еден обид, додека привремен прекин стануваretryable. - Дневниците содржат ID на потенцијални клиенти, состојби на исход и статусни кодови, но не и токен, необработени докази, тело на одговор или целосна URL-адреса.
- Продукциската команда има ограничени batch-обработки, временски ограничувања, повторувања и заклучување против преклопување.
Корисниот дел од оваа функционалност не е само тоа што открива CMS или CDN. Таа ги претвора надворешните технички докази во сигурен CRM-контекст: доволно концизен за запис за потенцијален клиент, доволно структуриран за безбедно работење и доволно изолиран за да еволуира кога ќе се промени договорот за услугата.