Туториали

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

Native PHP 8.3: Автоматско збогатување на CRM потенцијални клиенти со API за податоци од веб-страници

Продавачот не треба да мора да копира име на компанија, телефонски број, јавна е-поштa адреса и податоци за вработените од веб-страница пред да креира потенцијален клиент. Веб-страницата веќе содржи голем дел од тие информации; корисниот инженерски проблем е нивното претворање во сигурен нацрт што може да се прегледа.

Овој туторијал го гради тој работен тек во нативен PHP 8.3. CRM испраќа една URL-адреса на веб-страница до интерна крајна точка, која ја повикува услугата за податоци Website to Company, го валидира нејзиниот одговор и враќа пет полиња за збогатување: company, contact, email, phone и people. Продавачот го прегледува резултатот пред да го зачува, така што збогатувањето го подобрува внесувањето податоци без тивко да ги презапишува човечките одлуки.

Предуслови

  • PHP 8.3 или понов со екстензиите cURL и JSON
  • Composer
  • Јавна веб-страница на компанија за користење при рачна верификација
  • Постоечки CRM-екран што може да испрати веб-страница и да го примени вратениот нацрт

Имплементацијата користи нативен cURL наместо рамка или HTTP-пакет за општа намена. Нејзиниот транспорт останува зад интерфејс, што му дава на продукцискиот код експлицитна контрола на временските ограничувања, а на тестовите детерминистички лажен објект.

Добијте пристап и копирајте го токенот за услугата

Прво, регистрирајте сметка, или најавете се ако веќе имате.

Отворете ја страницата на услугата Website to Company data. Изберете го достапниот Free, Plus или Pro план што одговара на планираниот обем на работа, па завршете ја неговата активација.

Потоа, отворете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот за услугата што е прикажан таму. Повторното генерирање на овој токен го поништува претходно активниот токен, па ротацијата на токенот мора да ја ажурира секоја распоредена инстанца што го користи.

Оваа услуга навистина бара токен; нема режим на повикување без токен. Автентикацијата го користи параметарот за пребарување token={serviceToken}, а не заглавие Authorization.

Потврдете го точниот повик

Дадениот договор користи GET на https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Испратете ги и token и website како параметри за пребарување. Пред да напишете апликациски код, направете едно ограничено тест-барање:

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"

Заменете ја примерната веб-страница со јавна страница на компанија при верификација на вистинско збогатување. Задржете го местодржачот во документацијата, фикстурите, сликите од екранот и контролата на изворниот код.

Обликувајте ја границата на апликацијата

CRM никогаш не треба директно да зависи од невалидизиран оддалечен JSON-документ. Нашата граница прифаќа само JSON-објект и ги мапира петте именувани полиња од договорот во доменски DTO. Вредностите може да бидат скаларни, структурирани или null; маперот намерно избегнува да нагаѓа недокументирани вгнездени полиња.

Проектот е намерно мал:

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

Креирајте ја конфигурацијата и инсталирајте PHPUnit 11, кој го поддржува овој PHP 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

Ставете го копираниот акредитив во .env. PHP не ја вчитува оваа датотека автоматски; контролерот на предниот дел подолу ја вчитува за овој самостоен проект. Во продукција, истата променлива може наместо тоа да биде внесена од управувачот со процеси или складиштето за тајни.

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN

Изградете ограничен нативен cURL транспорт

Транспортот ги оневозможува пренасочувањата, применува одделни рокови за поврзување и вкупен одговор, ги снима заглавијата на одговорот и фрла исклучок специфичен за транспортот без да ја вклучи URL-адресата што содржи акредитив во својата порака.

<?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);
        }
    }
}

Мапирајте го одговорот и класифицирајте ги неуспесите

Клиентот ги повторува мрежните неуспеси, HTTP 429 одговорите и 5xx одговорите од серверската страна најмногу трипати. Ја почитува нумеричката вредност Retry-After, но чекањето го ограничува на две секунди. Неуспесите при автентикација и обичните неуспеси при валидација никогаш не се повторуваат: уште едно идентично барање само би трошело квота и би го одложило корисникот.

<?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 ги зачувува петте документирани вредности од највисоко ниво. Адаптер специфичен за CRM може потоа да претвори структурирана вредност за компанија или лица во свои полиња. Задржувањето на тоа толкување надвор од API-клиентот спречува ширење на недокументирани претпоставки низ апликацијата.

Изложете интерна CRM крајна точка

Контролерот на предниот дел прифаќа POST /lead/enrich со {"website":"https://..."}. Враќа нацрт наместо да запишува во базата на податоци. Тоа раздвојување ги прави прегледот, откажувањето и исправката вообичаени дејства во корисничкиот интерфејс.

<?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-страницата сега ѝ треба само да го испрати внесот на веб-страницата од продавачот, да ги поврзе data.company, data.contact, data.email и data.phone со полиња што може да се уредуваат и да го прикаже data.people како предлози што може да се изберат. Постоечките кориснички вредности треба да имаат предност, освен ако продавачот изречно не прифати замена.

Тестирајте повторувања без мрежни повици

Лажен транспорт ги прави повторливи секвенците на статус и патеките на неуспех. Овој тест докажува дека 429 се повторува, сите пет полиња ја преминуваат границата и неуспехот при автентикација не се повторува.

<?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"

Безбедност, набљудливост и распоредување

Бидејќи задолжителниот параметар за автентикација се појавува во низата за пребарување, никогаш не ја евидентирајте целосната URL-адреса нагоре по текот. Имплементацијата евидентира само категорија на неуспех, статус нагоре по текот и генериран ID на барање. Конфигурирајте ги обратните проксија и алатките за перформанси на апликацијата да ги редигираат низите за пребарување како дополнителна заштита.

Заштитете ја /lead/enrich со постоечките контроли за автентикација, авторизација и CSRF на CRM. Применете ограничување на апликацијата по корисник, покрај обработката на HTTP 429 одговорите нагоре по текот. Избегнувајте складирање необработени одговори од збогатување, освен ако бизнисот навистина не ги бара; податоците за контакти и лица може да бараат правила за задржување, пристап и бришење.

За распоредување, поставете го коренот на документи на веб-серверот на public/, извршете composer install --no-dev --classmap-authoritative и внесете WEBSITE_COMPANY_SERVICE_TOKEN преку управувачот со тајни на платформата или околината PHP-FPM. Не го вградувајте во слика. При ротација, ажурирајте го распоредувањето веднаш по повторното генерирање бидејќи стариот активен токен е поништен.

Следете го бројот на барања, латентноста, исходите според видот на неуспех и бројот на повторувања. Не означувајте секое празно поле како неуспех: јавна страница едноставно може да изостави телефонски број или именуван контакт. Проверките на здравјето треба локално да ја верификуваат апликацијата без да трошат квота за збогатување.

Чести неуспеси

  • Неуспеси при автентикација: потврдете дека планот за услугата е активен и дека распоредениот токен е тековниот токен со опсег на услугата.
  • HTTP 429: зачувајте ја веб-страницата внесена од продавачот, прикажете состојба што може да се повтори и избегнувајте непосредни циклуси на повторување во прелистувачот.
  • Неисправен JSON или променети типови на вредности: третирајте го одговорот како неуспех нагоре по текот наместо делумно да нагаѓате за неговото значење.
  • Повторени 5xx или мрежни неуспеси: запрете по ограничениот буџет за повторување и дозволете му на корисникот да се обиде повторно подоцна.
  • Празни полиња за збогатување: оставете ги празни и можни за уредување; отсуството не е дозвола за измислување податоци.

Конечна контролна листа за верификација

  • Активниот Free, Plus или Pro план е овозможен.
  • Токенот за услугата постои само во конфигурација поддржана од околината.
  • Барањето ја користи точната GET крајна точка со параметрите за пребарување token и website.
  • Временските ограничувања за поврзување и вкупен одговор се ограничени.
  • Само мрежните, 429 и 5xx неуспеси добиваат ограничени повторувања.
  • Границата ги мапира company, contact, email, phone и people.
  • Автоматизираните тестови поминуваат без контактирање на вистинската услуга.
  • Интерната рута е заштитена со CRM-автентикација и CSRF-контроли.
  • Дневниците содржат ID на барања и категории на неуспех, но нема токен или целосна URL-адреса нагоре по текот.
  • Продавачот може да ја прегледа секоја претходно пополнета вредност пред да го зачува потенцијалниот клиент.

Најсилниот работен тек за збогатување не е оној што пополнува најмногу полиња без надзор. Тој е оној што претвора една веб-страница во корисен нацрт, неуспева предвидливо, го заштитува својот акредитив и ја остава конечната деловна одлука на лицето што го креира потенцијалниот клиент.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.