Vodiči

Native PHP 8.3: Auto-Enrich CRM Leads with Website Data API

Izvorni PHP 8.3: Automatski obogatite CRM potencijalne klijente API-jem za podatke o web-stranici

Prodajni predstavnik ne bi trebao morati kopirati naziv tvrtke, telefonski broj, javnu adresu e-pošte i podatke o osoblju s web-stranice prije izrade potencijalnog klijenta. Web-stranica već sadrži velik dio tih informacija; koristan inženjerski problem jest pretvoriti ih u pouzdan nacrt koji se može pregledati.

Ovaj vodič izrađuje taj tijek rada u nativnom PHP-u 8.3. CRM šalje jedan URL web-stranice internom krajnjem odredištu, koje poziva uslugu podataka Website to Company, provjerava njezin odgovor i vraća pet polja za obogaćivanje: company, contact, email, phone i people. Prodajni predstavnik pregledava rezultat prije spremanja, tako da obogaćivanje poboljšava unos podataka bez tihog prepisivanja ljudskih odluka.

Preduvjeti

  • PHP 8.3 ili noviji s proširenjima cURL i JSON
  • Composer
  • Javna web-stranica tvrtke za upotrebu tijekom ručne provjere
  • Postojeći CRM zaslon koji može poslati web-stranicu i primijeniti vraćeni nacrt

Implementacija koristi nativni cURL umjesto okvira ili HTTP paketa opće namjene. Njegov je prijenosni sloj iza sučelja, što produkcijskom kodu daje izričitu kontrolu vremenskih ograničenja, a testovima deterministički lažni objekt.

Pribavite pristup i kopirajte token usluge

Najprije registrirajte račun ili se prijavite ako ga već imate.

Otvorite stranicu usluge podataka Website to Company. Odaberite dostupni plan Free, Plus ili Pro koji odgovara predviđenom opterećenju, a zatim dovršite njegovu aktivaciju.

Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte tamo prikazani token ograničen na uslugu. Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svaku implementiranu instancu koja ga koristi.

Ova usluga zahtijeva token; nema način pozivanja bez tokena. Autentikacija koristi parametar upita token={serviceToken}, a ne zaglavlje Authorization.

Potvrdite točan zahtjev

Isporučeni ugovor koristi GET na adresi https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Pošaljite i token i website kao parametre upita. Prije pisanja aplikacijskog koda pošaljite jedan ograničeni testni zahtjev:

curl --get \
  --connect-timeout 3 \
  --max-time 15 \
  --data-urlencode "token=YOUR_SERVICE_TOKEN" \
  --data-urlencode "website=https://example.com" \
  "https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"

Pri provjeri stvarnog obogaćivanja zamijenite primjer web-stranice javnom stranicom tvrtke. Zadržite rezervirano mjesto u dokumentaciji, testnim podacima, snimkama zaslona i kontroli izvornog koda.

Oblikujte granicu aplikacije

CRM nikada ne bi trebao izravno ovisiti o neprovjerenom udaljenom JSON dokumentu. Naša granica prihvaća samo JSON objekt i preslikava pet imenovanih polja ugovora u DTO domene. Vrijednosti mogu biti skalarne, strukturirane ili null; preslikavač namjerno izbjegava nagađanje o nedokumentiranim ugniježđenim poljima.

Projekt je namjerno malen:

crm-enrichment/
├── .env
├── .gitignore
├── composer.json
├── public/
│   └── index.php
├── src/
│   └── WebsiteCompany.php
└── tests/
    └── WebsiteCompanyClientTest.php

Izradite konfiguraciju i instalirajte PHPUnit 11, koji podržava ovaj projekt PHP-a 8.3:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  }
}
composer install
composer dump-autoload
printf '%s\n' '.env' >> .gitignore

Stavite kopiranu vjerodajnicu u .env. PHP ovu datoteku ne učitava automatski; donji prednji kontroler učitava je za ovaj samostalni projekt. U produkciji se ista varijabla umjesto toga može ubrizgati putem upravitelja procesa ili spremišta tajni.

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN

Izradite ograničeni nativni cURL prijenosni sloj

Prijenosni sloj onemogućuje preusmjeravanja, primjenjuje odvojene rokove za povezivanje i ukupni odgovor, bilježi zaglavlja odgovora te izbacuje iznimku specifičnu za prijenos bez uključivanja URL-a koji sadrži vjerodajnicu u svojoj poruci.

<?php
// src/WebsiteCompany.php

interface HttpTransport
{
    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse;
}

final readonly class TransportResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers = []
    ) {}
}

final class TransportException extends RuntimeException {}

final class CurlTransport implements HttpTransport
{
    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse {
        $headers = [];
        $requestUrl = $url . '?' . http_build_query(
            $query,
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        $handle = curl_init($requestUrl);
        if ($handle === false) {
            throw new TransportException('Could not initialize HTTP transport');
        }

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
            CURLOPT_TIMEOUT_MS => (int) ($responseTimeout * 1000),
            CURLOPT_USERAGENT => 'crm-website-enrichment/1.0',
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);

                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }

                return $length;
            },
        ]);

        try {
            $body = curl_exec($handle);
            if ($body === false) {
                throw new TransportException(
                    'Remote request failed with cURL error ' . curl_errno($handle)
                );
            }

            return new TransportResponse(
                (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
                $body,
                $headers
            );
        } finally {
            curl_close($handle);
        }
    }
}

Preslikajte odgovor i razvrstajte neuspjehe

Klijent ponovno pokušava mrežne neuspjehe, HTTP 429 odgovore i poslužiteljske 5xx odgovore najviše tri puta. Poštuje numeričku vrijednost Retry-After, ali čekanje ograničava na dvije sekunde. Autentikacija i uobičajeni neuspjesi provjere nikada se ne pokušavaju ponovno: drugi istovjetni zahtjev samo bi potrošio kvotu i odgodio korisnika.

<?php
// Append to src/WebsiteCompany.php

final readonly class LeadEnrichment
{
    public function __construct(
        public mixed $company,
        public mixed $contact,
        public mixed $email,
        public mixed $phone,
        public mixed $people
    ) {}

    public static function fromPayload(array $payload): self
    {
        $read = static function (string $key) use ($payload): mixed {
            $value = $payload[$key] ?? null;

            if (
                $value !== null
                && !is_scalar($value)
                && !is_array($value)
            ) {
                throw new IntegrationFailure(
                    'malformed_response',
                    null,
                    "Unsupported value for {$key}"
                );
            }

            return $value;
        };

        return new self(
            $read('company'),
            $read('contact'),
            $read('email'),
            $read('phone'),
            $read('people')
        );
    }

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }
}

final class IntegrationFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status,
        string $message
    ) {
        parent::__construct($message);
    }
}

final class WebsiteCompanyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';

    private Closure $sleep;

    public function __construct(
        private readonly string $token,
        private readonly HttpTransport $transport,
        ?Closure $sleep = null
    ) {
        if (trim($token) === '') {
            throw new InvalidArgumentException('Service token is missing');
        }

        $this->sleep = $sleep ?? static fn (int $milliseconds) =>
            usleep($milliseconds * 1000);
    }

    public function enrich(string $website): LeadEnrichment
    {
        $website = trim($website);
        $parts = parse_url($website);

        if (
            filter_var($website, FILTER_VALIDATE_URL) === false
            || !is_array($parts)
            || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || empty($parts['host'])
            || isset($parts['user'])
            || isset($parts['pass'])
        ) {
            throw new InvalidArgumentException('Enter a valid HTTP or HTTPS website');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->get(
                    self::ENDPOINT,
                    ['token' => $this->token, 'website' => $website],
                    3.0,
                    15.0
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new IntegrationFailure(
                        'network',
                        null,
                        'Enrichment service is unreachable'
                    );
                }

                ($this->sleep)($this->backoff($attempt));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (JsonException) {
                    throw new IntegrationFailure(
                        'malformed_response',
                        $response->status,
                        'Enrichment service returned invalid JSON'
                    );
                }

                if (!is_array($payload) || array_is_list($payload)) {
                    throw new IntegrationFailure(
                        'malformed_response',
                        $response->status,
                        'Enrichment response must be a JSON object'
                    );
                }

                return LeadEnrichment::fromPayload($payload);
            }

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

            if ($retryable && $attempt < 3) {
                $retryAfter = $response->headers['retry-after'] ?? null;
                $delay = is_string($retryAfter) && ctype_digit($retryAfter)
                    ? min(2000, (int) $retryAfter * 1000)
                    : $this->backoff($attempt);

                ($this->sleep)($delay);
                continue;
            }

            $kind = match (true) {
                in_array($response->status, [401, 403], true) => 'authentication',
                $response->status === 429 => 'rate_limited',
                $response->status >= 500 => 'upstream',
                default => 'invalid_request',
            };

            throw new IntegrationFailure(
                $kind,
                $response->status,
                'Enrichment request was not completed'
            );
        }

        throw new LogicException('Retry loop ended unexpectedly');
    }

    private function backoff(int $attempt): int
    {
        return min(2000, 200 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }
}

DTO čuva pet dokumentiranih vrijednosti najviše razine. Prilagodnik specifičan za CRM može naknadno pretvoriti strukturiranu vrijednost tvrtke ili osoba u vlastita polja. Zadržavanje tog tumačenja izvan API klijenta sprječava širenje nedokumentiranih pretpostavki aplikacijom.

Izložite interno krajnje odredište CRM-a

Prednji kontroler prihvaća POST /lead/enrich s {"website":"https://..."}. Vraća nacrt umjesto pisanja u bazu podataka. To razdvajanje čini pregled, otkazivanje i ispravak uobičajenim radnjama korisničkog sučelja.

<?php
// public/index.php

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

$envFile = dirname(__DIR__) . '/.env';
if (is_file($envFile)) {
    $values = parse_ini_file($envFile, false, INI_SCANNER_RAW) ?: [];
    foreach ($values as $name => $value) {
        if (getenv($name) === false) {
            putenv("{$name}={$value}");
        }
    }
}

header('Content-Type: application/json');
$requestId = bin2hex(random_bytes(8));
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || $path !== '/lead/enrich') {
    http_response_code(404);
    echo json_encode(['error' => ['kind' => 'not_found']]);
    exit;
}

try {
    $input = json_decode(
        file_get_contents('php://input') ?: '',
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (!is_array($input) || !is_string($input['website'] ?? null)) {
        throw new InvalidArgumentException('website must be a string');
    }

    $token = getenv('WEBSITE_COMPANY_SERVICE_TOKEN');
    if (!is_string($token) || $token === '') {
        throw new RuntimeException('Service token is not configured');
    }

    $client = new WebsiteCompanyClient($token, new CurlTransport());
    $draft = $client->enrich($input['website']);

    echo json_encode([
        'data' => $draft->toArray(),
        'request_id' => $requestId,
    ], JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException|JsonException $exception) {
    http_response_code(422);
    echo json_encode([
        'error' => ['kind' => 'invalid_input', 'message' => $exception->getMessage()],
        'request_id' => $requestId,
    ]);
} catch (IntegrationFailure $exception) {
    $status = match ($exception->kind) {
        'rate_limited' => 429,
        'invalid_request' => 422,
        'malformed_response' => 502,
        default => 503,
    };

    http_response_code($status);
    error_log(json_encode([
        'event' => 'lead_enrichment_failed',
        'kind' => $exception->kind,
        'upstream_status' => $exception->status,
        'request_id' => $requestId,
    ]));

    echo json_encode([
        'error' => ['kind' => $exception->kind],
        'request_id' => $requestId,
    ]);
} catch (Throwable) {
    http_response_code(500);
    error_log(json_encode([
        'event' => 'lead_enrichment_failed',
        'kind' => 'internal',
        'request_id' => $requestId,
    ]));

    echo json_encode([
        'error' => ['kind' => 'internal'],
        'request_id' => $requestId,
    ]);
}

CRM stranica sada treba samo poslati unos web-stranice prodajnog predstavnika, povezati data.company, data.contact, data.email i data.phone s poljima koja se mogu uređivati te prikazati data.people kao prijedloge koji se mogu odabrati. Postojeće korisničke vrijednosti trebaju imati prednost osim ako prodajni predstavnik izričito ne prihvati zamjenu.

Testirajte ponovne pokušaje bez mrežnih poziva

Lažni prijenosni sloj čini nizove statusa i putanje neuspjeha ponovljivima. Ovaj test dokazuje da se 429 ponovno pokušava, da svih pet polja prelazi granicu i da se neuspjeh autentikacije ne pokušava ponovno.

<?php
// tests/WebsiteCompanyClientTest.php

use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

    public function get(
        string $url,
        array $query,
        float $connectTimeout,
        float $responseTimeout
    ): TransportResponse {
        $this->calls++;
        $next = array_shift($this->responses);

        if ($next instanceof Throwable) {
            throw $next;
        }

        return $next;
    }
}

final class WebsiteCompanyClientTest extends TestCase
{
    public function testRetriesRateLimitThenMapsContractFields(): void
    {
        $fake = new FakeTransport([
            new TransportResponse(429, '{}', ['retry-after' => '0']),
            new TransportResponse(200, json_encode([
                'company' => 'Example Company',
                'contact' => null,
                'email' => '[email protected]',
                'phone' => null,
                'people' => [],
            ], JSON_THROW_ON_ERROR)),
        ]);

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

        $result = $client->enrich('https://example.com');

        self::assertSame(2, $fake->calls);
        self::assertSame('Example Company', $result->company);
        self::assertSame('[email protected]', $result->email);
        self::assertSame([], $result->people);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new TransportResponse(401, '{}'),
        ]);

        $client = new WebsiteCompanyClient(
            'expired-token',
            $fake,
            static fn (int $milliseconds) => null
        );

        try {
            $client->enrich('https://example.com');
            self::fail('Expected IntegrationFailure');
        } catch (IntegrationFailure $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public

curl --request POST \
  --header "Content-Type: application/json" \
  --data '{"website":"https://example.com"}' \
  "http://127.0.0.1:8080/lead/enrich"

Sigurnost, vidljivost i implementacija

Budući da se obvezni parametar autentikacije pojavljuje u nizu upita, nikada nemojte zapisivati potpuni uzlazni URL. Implementacija bilježi samo kategoriju neuspjeha, uzlazni status i generirani ID zahtjeva. Konfigurirajte obrnute proxyje i alate za nadzor performansi aplikacije da uklanjaju nizove upita kao dodatnu zaštitu.

Zaštitite /lead/enrich postojećim CRM kontrolama za autentikaciju, autorizaciju i CSRF. Primijenite ograničenje aplikacije po korisniku, uz obradu uzlaznih HTTP 429 odgovora. Izbjegavajte spremanje neobrađenih odgovora obogaćivanja osim ako ih poslovanje doista treba; podaci o kontaktima i osobama mogu zahtijevati pravila zadržavanja, pristupa i brisanja.

Za implementaciju usmjerite korijen dokumenta web-poslužitelja na public/, pokrenite composer install --no-dev --classmap-authoritative i ubrizgajte WEBSITE_COMPANY_SERVICE_TOKEN putem upravitelja tajni platforme ili PHP-FPM okruženja. Nemojte ga ugrađivati u sliku. Tijekom rotacije odmah ažurirajte implementaciju nakon ponovnog generiranja jer je stari aktivni token opozvan.

Pratite broj zahtjeva, latenciju, ishode prema vrsti neuspjeha i broj ponovnih pokušaja. Nemojte svako prazno polje označiti kao neuspjeh: javna stranica može jednostavno izostaviti telefonski broj ili imenovani kontakt. Provjere stanja trebaju lokalno provjeravati aplikaciju bez trošenja kvote za obogaćivanje.

Uobičajeni neuspjesi

  • Neuspjesi autentikacije: potvrdite da je plan usluge aktivan i da je implementirani token trenutačni token ograničen na uslugu.
  • HTTP 429: sačuvajte web-stranicu koju je unio prodajni predstavnik, prikažite stanje u kojem je moguće ponovno pokušati i izbjegavajte neposredne petlje ponovnih pokušaja u pregledniku.
  • Neispravan JSON ili promijenjene vrste vrijednosti: tretirajte odgovor kao uzlazni neuspjeh umjesto djelomičnog nagađanja o njegovu značenju.
  • Ponovljeni 5xx ili mrežni neuspjesi: zaustavite se nakon ograničenog proračuna ponovnih pokušaja i dopustite korisniku da pokuša kasnije.
  • Prazna polja obogaćivanja: ostavite ih praznima i mogućima za uređivanje; odsutnost nije dopuštenje za izmišljanje podataka.

Završni kontrolni popis za provjeru

  • აქტivni plan Free, Plus ili Pro je omogućen.
  • Token usluge postoji samo u konfiguraciji potpomognutoj okruženjem.
  • Zahtjev koristi točno GET krajnje odredište s parametrima upita token i website.
  • Vremenska ograničenja povezivanja i ukupnog odgovora su ograničena.
  • Samo mrežni neuspjesi, 429 i 5xx primaju ograničene ponovne pokušaje.
  • Granica preslikava company, contact, email, phone i people.
  • Automatizirani testovi prolaze bez kontaktiranja stvarne usluge.
  • Interna ruta zaštićena je CRM autentikacijom i CSRF kontrolama.
  • Dnevnici sadrže ID-ove zahtjeva i kategorije neuspjeha, ali ne token ni potpuni uzlazni URL.
  • Prodajni predstavnik može pregledati svaku unaprijed popunjenu vrijednost prije spremanja potencijalnog klijenta.

Najjači tijek rada obogaćivanja nije onaj koji bez nadzora popunjava najviše polja. To je onaj koji jednu web-stranicu pretvara u koristan nacrt, predvidljivo ne uspijeva, štiti svoju vjerodajnicu i konačnu poslovnu odluku ostavlja osobi koja izrađuje potencijalnog klijenta.

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.