Vodiči

Native PHP 8.3: Enrich Leads with Website Tech Insights Using the Detector API

Nativni PHP 8.3: Obogatite potencijalne klijente uvidima u tehnologije web-stranica pomoću Detector API-ja

Web-mjesto potencijalnog klijenta često otkriva više nego što to čini oskudan obrazac za kontakt. Sažetak tehnologija može maloj agenciji reći koristi li potencijalni klijent WordPress, određenu analitičku platformu ili aplikacijski skup koji odgovara stručnosti tima. Korisna verzija ove značajke nije proširenje preglednika ni ručna provjera: to je obogaćivanje koje se pojavljuje izravno uz svakog potencijalnog klijenta.

Ovaj vodič gradi taj tijek rada u izvornom PHP-u 8.3. Naredba čita neobogaćene potencijalne klijente iz postojećeg CRM-a sa SQLite podrškom, poziva API Website Technology Detector, pretvara njegove rezultate s ocjenom pouzdanosti u sažet prikaz te bilježi strukturirana stanja uspjeha ili neuspjeha. Granica API-ja koristi izvorni cURL, ograničena vremenska ograničenja, selektivne ponovne pokušaje, obrambeno mapiranje JSON-a i apstrakciju prijenosa koja PHPUnit testove čini determinističkima.

Dobijte pristup i izradite token usluge

  1. Otvorite stranicu za registraciju i izradite račun ili upotrijebite stranicu za prijavu ako ga već imate.
  2. Posjetite stranicu usluge Website Technology Detector. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge, pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
  4. Spremite tu vrijednost u konfiguraciju varijabli okruženja projekta. Nikada je nemojte predati u kontrolu izvornog koda.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu u implementaciji: ažurirajte svako okruženje koje koristi staru vrijednost, ponovno pokrenite relevantne PHP radnike ili procese naredbi te provjerite autentikaciju prije uklanjanja putanje za povratak.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi oblik Bearer jer vjerodajnicu zadržava izvan URL-ova, zapisnika pristupa, povijesti preglednika i proxy analitike.

Najprije provjerite točnu krajnju točku

Poziv detektora je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Njegovo tijelo JSON zahtjeva sadrži url. Prije pisanja aplikacijskog koda, pošaljite jedan minimalni zahtjev iz pouzdanog terminala:

export WEBSITE_DETECTOR_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 20 \
  -X POST \
  'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies' \
  -H "Authorization: Bearer ${WEBSITE_DETECTOR_TOKEN}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Za lokalni razvoj izradite nepraćenu datoteku .env i predanu datoteku rezerviranog mjesta nazvanu .env.example:

# .env.example
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CRM_DATABASE_PATH=var/crm.sqlite

cp .env.example .env
# Replace only the placeholder in .env, then load it into the process:
set -a
. ./.env
set +a

Dodajte .env i var/ u .gitignore. Izvorni PHP ne čita automatski dotenv datoteke, stoga ljuska izričito izvozi datoteku prije pokretanja naredbe. U produkciji umjesto učitavanja datoteke repozitorija umetnite iste varijable putem upravitelja procesa ili spremišta tajni.

Arhitektura prikladna za mali CRM

Obogaćivanje potencijalnih klijenata vanjski je U/I, pa ne bi trebalo odgađati zaslon na kojem korisnik stvara potencijalnog klijenta. Planirana naredba dovoljna je za mali CRM i izbjegava uvođenje sustava redova samo zbog jedne značajke. Ona odabire ograničenu skupinu, provjerava svako web-mjesto, poziva detektor i zapisuje čitljiv sažetak.

API klijent upravlja HTTP ponašanjem i autentikacijom. Mapper upravlja granicom nepouzdanog odgovora. Naredba upravlja CRM pravilima: koji potencijalni klijenti ispunjavaju uvjete, što se događa nakon privremenog neuspjeha i treba li pogreška autentikacije zaustaviti skupinu.

bin/enrich-leads
src/Detector/CurlTransport.php
src/Detector/DetectorClient.php
src/Detector/DetectionReport.php
src/Detector/HttpResponse.php
src/Detector/HttpTransport.php
src/Detector/ServiceFailure.php
tests/DetectorClientTest.php
composer.json
.env.example

Instalirajte samo ovisnost za testiranje; produkcija koristi PHP-ova proširenja cURL i PDO:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}

Izradite zamjenjivi izvorni cURL prijenos

Zadržavanje cURL-a iza malog sučelja sprječava prodiranje mrežnih poziva u testove. Također daje različita značenja vremenskom ograničenju povezivanja i ukupnog odgovora: prvo ograničava uspostavljanje veze, dok drugo ograničava cijelu razmjenu.

<?php
// src/Detector/HttpTransport.php
namespace App\Detector;

interface HttpTransport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs
    ): HttpResponse;
}

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body
    ) {}
}

final class TransportFailure extends \RuntimeException {}

// src/Detector/CurlTransport.php
final class CurlTransport implements HttpTransport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs
    ): HttpResponse {
        $responseHeaders = [];
        $handle = curl_init($url);

        if ($handle === false) {
            throw new TransportFailure('Unable to initialize cURL');
        }

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$responseHeaders): int {
                $separator = strpos($line, ':');
                if ($separator !== false) {
                    $name = strtolower(trim(substr($line, 0, $separator)));
                    $responseHeaders[$name] = trim(substr($line, $separator + 1));
                }
                return strlen($line);
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportFailure($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $responseHeaders, $bodyText);
    }
}

Mapirajte odgovor API-ja na jednoj obrambenoj granici

Udaljeni JSON nepouzdan je čak i nakon uspješnog HTTP statusa. Stavke tehnologije mogu sadržavati pouzdanost, dokaze i informacije o verziji, dok podaci o preusmjeravanju opisuju kako se zatražena adresa razriješila. Neobavezne ili neispravne vrijednosti ne smiju proizvesti obavijesti ni oštetiti CRM.

Mapper u nastavku prihvaća dokumentirane koncepte tehnologije samo kada su njihovi tipovi tijekom izvođenja upotrebljivi. Zadržava normalizirane dokaze za buduću dijagnostiku, ali sažetak vidljiv uz potencijalnog klijenta ostaje sažet.

<?php
// src/Detector/DetectionReport.php
namespace App\Detector;

final readonly class DetectionReport
{
    public function __construct(
        public array $technologies,
        public array $redirects
    ) {}

    public static function fromJson(array $document): self
    {
        $payload = is_array($document['data'] ?? null)
            ? $document['data']
            : $document;

        $rawTechnologies = $payload['technologies'] ?? [];
        $technologies = [];

        if (is_array($rawTechnologies)) {
            foreach ($rawTechnologies as $item) {
                if (!is_array($item) || !is_string($item['name'] ?? null)) {
                    continue;
                }

                $confidence = $item['confidence'] ?? null;
                $technologies[] = [
                    'name' => trim($item['name']),
                    'version' => is_string($item['version'] ?? null)
                        ? trim($item['version'])
                        : null,
                    'confidence' => is_numeric($confidence)
                        ? max(0.0, min(100.0, (float) $confidence))
                        : null,
                    'evidence' => is_array($item['evidence'] ?? null)
                        ? $item['evidence']
                        : [],
                ];
            }
        }

        usort(
            $technologies,
            static fn (array $a, array $b): int =>
                ($b['confidence'] ?? -1) <=> ($a['confidence'] ?? -1)
        );

        $redirects = is_array($payload['redirects'] ?? null)
            ? $payload['redirects']
            : [];

        return new self($technologies, $redirects);
    }

    public function summary(int $limit = 6): string
    {
        if ($this->technologies === []) {
            return 'No technologies detected';
        }

        $parts = array_map(
            static function (array $technology): string {
                $label = $technology['name'];

                if ($technology['version'] !== null &&
                    $technology['version'] !== '') {
                    $label .= ' ' . $technology['version'];
                }

                if ($technology['confidence'] !== null) {
                    $label .= sprintf(
                        ' (%.0f%% confidence)',
                        $technology['confidence']
                    );
                }

                return $label;
            },
            array_slice($this->technologies, 0, $limit)
        );

        $suffix = count($this->technologies) > $limit
            ? sprintf(' and %d more', count($this->technologies) - $limit)
            : '';

        return implode(', ', $parts) . $suffix;
    }
}

Zadržite neobrađene dokaze u memoriji ili namjenski izrađenom spremištu za reviziju samo kada ih CRM treba. Dokazi mogu biti opsežni, a neograničeno spremanje cijelog odgovora pružatelja stvara nepotrebne obveze pohrane i privatnosti.

Dodajte selektivne ponovne pokušaje i strukturirane neuspjehe

Ponovni pokušaj prikladan je za neuspjeh povezivanja, HTTP 429 ili odgovor 5xx na strani poslužitelja. Nije prikladan za neispravan unos ili odbijene vjerodajnice. Ponovni pokušaji tih odgovora samo troše kvotu i skrivaju nedostatke konfiguracije.

<?php
// src/Detector/DetectorClient.php
namespace App\Detector;

final class ServiceFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly bool $retryable,
        public readonly ?int $status = null
    ) {
        parent::__construct($reason);
    }
}

final readonly class DetectorClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    public function __construct(
        private HttpTransport $transport,
        private string $token,
        private \Closure $sleep
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Detector token is missing');
        }
    }

    public function detect(string $url): DetectionReport
    {
        $body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->post(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    $body,
                    3000,
                    20000
                );
            } catch (TransportFailure $exception) {
                if ($attempt === 3) {
                    throw new ServiceFailure('transport_error', true);
                }
                ($this->sleep)($attempt === 1 ? 250 : 750);
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $json = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException) {
                    throw new ServiceFailure(
                        'invalid_response',
                        false,
                        $response->status
                    );
                }

                if (!is_array($json)) {
                    throw new ServiceFailure(
                        'invalid_response',
                        false,
                        $response->status
                    );
                }

                return DetectionReport::fromJson($json);
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ServiceFailure(
                    'authentication_failed',
                    false,
                    $response->status
                );
            }

            if (in_array($response->status, [400, 422], true)) {
                throw new ServiceFailure(
                    'invalid_request',
                    false,
                    $response->status
                );
            }

            $retryable = $response->status === 429
                || $response->status >= 500;

            if (!$retryable || $attempt === 3) {
                throw new ServiceFailure(
                    $response->status === 429
                        ? 'rate_limited'
                        : 'upstream_error',
                    $retryable,
                    $response->status
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delayMs = ctype_digit((string) $retryAfter)
                ? min(10000, (int) $retryAfter * 1000)
                : ($attempt === 1 ? 250 : 750);

            ($this->sleep)($delayMs);
        }

        throw new ServiceFailure('unexpected_state', false);
    }
}

Obogatite potencijalne klijente bez gubitka dobrih podataka

Dodajte četiri nullable stupca u postojeću tablicu leads CRM-a. Prilagodite sintaksu migracije ako vaša aplikacija koristi drugu bazu podataka:

ALTER TABLE leads ADD COLUMN tech_summary TEXT NULL;
ALTER TABLE leads ADD COLUMN tech_status TEXT NULL;
ALTER TABLE leads ADD COLUMN tech_checked_at TEXT NULL;
ALTER TABLE leads ADD COLUMN tech_error TEXT NULL;

Naredba obrađuje ograničenu skupinu. Privremeni neuspjeh bilježi stanje koje se može ponoviti, ali čuva svaki prethodni sažetak. Neuspjeh autentikacije zaustavlja se odmah jer bi svaki kasniji zahtjev propao iz istog razloga.

<?php
// bin/enrich-leads
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use App\Detector\CurlTransport;
use App\Detector\DetectorClient;
use App\Detector\ServiceFailure;

$token = getenv('WEBSITE_DETECTOR_TOKEN');
$databasePath = getenv('CRM_DATABASE_PATH') ?: 'var/crm.sqlite';

$client = new DetectorClient(
    new CurlTransport(),
    is_string($token) ? $token : '',
    static fn (int $milliseconds) => usleep($milliseconds * 1000)
);

$pdo = new PDO('sqlite:' . $databasePath, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$leads = $pdo->query(
    "SELECT id, website FROM leads
     WHERE website IS NOT NULL
       AND website != ''
       AND (tech_status IS NULL OR tech_status = 'retry')
     ORDER BY id
     LIMIT 50"
)->fetchAll(PDO::FETCH_ASSOC);

$success = $pdo->prepare(
    "UPDATE leads
     SET tech_summary = :summary,
         tech_status = 'complete',
         tech_checked_at = :checked,
         tech_error = NULL
     WHERE id = :id"
);

$failure = $pdo->prepare(
    "UPDATE leads
     SET tech_status = :status,
         tech_checked_at = :checked,
         tech_error = :error
     WHERE id = :id"
);

foreach ($leads as $lead) {
    $url = filter_var($lead['website'], FILTER_VALIDATE_URL);
    $scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;

    if ($url === false || !in_array($scheme, ['http', 'https'], true)) {
        $failure->execute([
            'status' => 'invalid_url',
            'checked' => gmdate(DATE_ATOM),
            'error' => 'Website must be an HTTP or HTTPS URL',
            'id' => $lead['id'],
        ]);
        continue;
    }

    try {
        $report = $client->detect($url);
        $success->execute([
            'summary' => $report->summary(),
            'checked' => gmdate(DATE_ATOM),
            'id' => $lead['id'],
        ]);

        error_log(json_encode([
            'event' => 'lead_technology_enriched',
            'lead_id' => $lead['id'],
            'technology_count' => count($report->technologies),
            'redirect_count' => count($report->redirects),
        ], JSON_THROW_ON_ERROR));
    } catch (ServiceFailure $exception) {
        if ($exception->reason === 'authentication_failed') {
            throw $exception;
        }

        $failure->execute([
            'status' => $exception->retryable ? 'retry' : 'failed',
            'checked' => gmdate(DATE_ATOM),
            'error' => $exception->reason,
            'id' => $lead['id'],
        ]);

        error_log(json_encode([
            'event' => 'lead_technology_enrichment_failed',
            'lead_id' => $lead['id'],
            'reason' => $exception->reason,
            'http_status' => $exception->status,
            'retryable' => $exception->retryable,
        ], JSON_THROW_ON_ERROR));
    }
}

Testirajte bez kontaktiranja usluge

Lažni prijenos vraća odgovore u redu čekanja, dok umetnuti uspavljivač bilježi odgodu umjesto da zaustavlja testni paket. Fiksni podaci ne sadrže vjerodajnice.

<?php
// tests/DetectorClientTest.php
declare(strict_types=1);

use App\Detector\DetectorClient;
use App\Detector\HttpResponse;
use App\Detector\HttpTransport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function post(
        string $url,
        array $headers,
        string $body,
        int $connectTimeoutMs,
        int $timeoutMs
    ): HttpResponse {
        $response = $this->responses[$this->calls] ?? null;
        $this->calls++;

        if (!$response instanceof HttpResponse) {
            throw new RuntimeException('Fake response queue exhausted');
        }

        return $response;
    }
}

final class DetectorClientTest extends TestCase
{
    public function testMapsAndSummarizesDetections(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(200, [], json_encode([
                'data' => [
                    'technologies' => [
                        [
                            'name' => 'Example CMS',
                            'version' => '8',
                            'confidence' => 96,
                            'evidence' => ['header marker'],
                        ],
                    ],
                    'redirects' => [['status' => 301]],
                ],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new DetectorClient(
            $transport,
            'test-token',
            static fn (int $milliseconds) => null
        );

        $report = $client->detect('https://example.com');

        self::assertSame(
            'Example CMS 8 (96% confidence)',
            $report->summary()
        );
        self::assertCount(1, $report->redirects);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '1'], '{}'),
            new HttpResponse(200, [], '{"technologies":[]}'),
        ]);
        $delays = [];

        $client = new DetectorClient(
            $transport,
            'test-token',
            static function (int $milliseconds) use (&$delays): void {
                $delays[] = $milliseconds;
            }
        );

        self::assertSame(
            'No technologies detected',
            $client->detect('https://example.com')->summary()
        );
        self::assertSame(2, $transport->calls);
        self::assertSame([1000], $delays);
    }
}

Pokrenite composer install, zatim composer test. Dodajte zasebne testove za neispravan JSON, nedostajuća polja tehnologije, 401 bez ponovnog pokušaja, tri uzastopna odgovora 5xx i iznimku prijenosa. Ti slučajevi štite pravila koja će najvjerojatnije nazadovati tijekom održavanja.

Sigurnost, operacije i implementacija

Budući da ova značajka dohvaća URL koji je unio potencijalni klijent putem vanjske usluge, prije slanja provjerite shemu. Izbjegavajte bilježenje cijelih URL-ova: mogu sadržavati nizove upita, identifikatore kupaca ili ugrađene vjerodajnice. Zapisnici trebaju sadržavati ID potencijalnog klijenta, ishod, HTTP status, mogućnost ponovnog pokušaja, broj detekcija i broj preusmjeravanja — ne token, zaglavlja zahtjeva, tijelo odgovora ili neobrađene dokaze.

Primijenite promjenu sheme prije naredbe. Umetnite WEBSITE_DETECTOR_TOKEN i CRM_DATABASE_PATH, potvrdite da su proširenja cURL i PDO SQLite omogućena, pokrenite testove i izvršite kanarinski test s jednim potencijalnim klijentom. Planirajte naredbu tek nakon što taj kanarinski test proizvede čitljiv sažetak.

Upotrijebite zaključavanje planera kako se dvije instance naredbe ne bi istodobno obogaćivale istu skupinu. Počnite s konzervativnom učestalošću i veličinom skupine prikladnima za aktivirani plan. Ponavljane odgovore 429 tretirajte kao signal kapaciteta: smanjite pritisak planiranja ili pregledajte plan umjesto povećavanja broja ponovnih pokušaja.

Uobičajeni obrasci neuspjeha

  • 401 ili 403: token nedostaje, opozvan je, netočno kopiran ili nije valjan za uslugu. Zamijenite ga i ponovno pokrenite proces; ne pokušavajte automatski ponovno.
  • 400 ili 422: provjerite validaciju URL-a i izradu JSON-a. Naredba namjerno označava te neuspjehe kao one koji se ne mogu ponovno pokušati.
  • 429: poštujte numeričku vrijednost Retry-After unutar konfiguriranog ograničenja od deset sekundi, a zatim ostavite potencijalnog klijenta podobnim za kasnije planirano pokretanje ako su pokušaji iscrpljeni.
  • 5xx ili neuspjeh povezivanja: kratko pokušajte ponovno, nakon iscrpljenja zabilježite retry i sačuvajte postojeći sažetak.
  • Uspješan status s neispravnim JSON-om: klasificirajte ga kao neispravan odgovor. Ne nagađajte podatke ni ne zamjenjujte prethodno koristan sažetak.
  • Prazne detekcije: spremite “No technologies detected.” Prazan rezultat valjan je poslovni rezultat, a ne automatski neuspjeh prijenosa.

Završni kontrolni popis za provjeru

  • Plan usluge je aktivan, a token ograničen na uslugu dolazi iz ploče Service token na stranici dokumentacije.
  • Token je podržan varijablama okruženja i nije prisutan u kontroli izvornog koda, fiksnim podacima, zapisnicima ni URL-ovima.
  • Klijent šalje POST na točnu krajnju točku detektora s JSON-om url i Bearer autentikacijom.
  • Vremenska ograničenja povezivanja i ukupnog trajanja su ograničena, a ponovno se pokušavaju samo prolazni neuspjesi.
  • Informacije o pouzdanosti, verziji, dokazima i preusmjeravanju prelaze obrambenu granicu mapiranja.
  • Uspješan potencijalni klijent prikazuje sažet pregled tehnologija; privremeni neuspjesi čuvaju prethodne dobre podatke.
  • Automatizirani testovi koriste deterministički lažni prijenos i nikada ne pozivaju stvarni API.
  • Planiranje u produkciji sprječava preklapanje pokretanja i izlaže strukturirane događaje uspjeha, neuspjeha i ograničenja stope.

Uglancani dio ove integracije nije HTTP zahtjev. To je disciplina oko njega: vjerodajnice ostaju zaštićene, nesigurni JSON normalizira se jednom, privremeni neuspjesi ne uništavaju korisne podatke, a CRM dobiva jezik koji osoba može brzo pregledati. To pretvara detekciju web-mjesta iz zanimljivog API odgovora u pouzdanu značajku obogaćivanja potencijalnih klijenata.

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.