Туториали

Symfony: Prefill CRM Leads Instantly with Website-to-Company Data API

Symfony: Пополнете ги CRM потенцијалните клиенти веднаш со API за податоци од веб-страница до компанија

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

Овој туторијал ја заменува таа пречка со една Symfony крајна точка. Прелистувачот испраќа веб-страница на компанија, Symfony ја повикува услугата за податоци Website to Company, го валидира одговорот на границата на интеграцијата и враќа безбеден нацрт за потенцијален клиент за преглед. Дизајнот е синхрон затоа што продавачот чека на формуларот, но сепак вклучува ограничени тајмаути, селективни повторни обиди, структурирани грешки, тестови и логирање за продукција.

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

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Website to Company data.
  3. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

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

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

export WEBSITE_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body --get \
  'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode "token=${WEBSITE_COMPANY_TOKEN}" \
  --data-urlencode 'website=https://example.com'

Имајте предвид дека историјата на команди и инспекцијата на процеси може да ги откријат вредностите од командната линија. Користете го ова само како метод за локална проверка со соодветни shell-контроли. Апликацијата ќе го чита токенот од конфигурација поддржана од околински променливи.

Креирајте Symfony проект

На имплементацијата ѝ се потребни Symfony HTTP-клиентот, поддршката за валидација, пакетот за логирање и алатките за тестирање. PHP 8.3 или понов и Composer се единствените локални предуслови.

composer create-project symfony/skeleton crm-prefill
cd crm-prefill

composer require symfony/http-client symfony/validator symfony/monolog-bundle
composer require --dev symfony/test-pack

Резултирачката функционалност има три намерни слоја:

  • CompanyEnrichment претвора недоверлив JSON во стабилен облик за апликацијата.
  • WebsiteCompanyClient управува со автентикацијата, тајмаутите, повторните обиди и неуспесите нагоре по текот.
  • LeadPrefillController го валидира внесот од прелистувачот и ги преведува резултатите од интеграцијата во HTTP-одговори.

Ова е доволно архитектура за мал CRM без воведување Messenger, база на податоци или редица. Задача во заднина би имала смисла за сериски увози, но би ја направила интерактивната форма побавна и покомплицирана.

Конфигурирајте ја околината и dependency injection

Ставете безопасен заместител во .env, кој ја документира потребната променлива, и чувајте го вистинскиот развоен токен во .env.local. Symfony го исклучува .env.local од вообичаените работни текови на изворна контрола.

# .env
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

# .env.local
WEBSITE_COMPANY_TOKEN=replace-with-your-development-token

Експлицитно поврзете ги акредитивот и фиксната крајна точка во config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Integration\WebsiteCompanyClient:
        arguments:
            $serviceToken: '%env(string:WEBSITE_COMPANY_TOKEN)%'
            $endpoint: 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'

Мапирајте ги надворешните податоци на границата

Договорот на услугата изложува податоци за company, contact, email, phone и people. Не дозволувајте контролерот или CRM-шаблонот да зависат од непроверени вредности од одговорот. Недостасувачките опционални полиња треба да создадат празни вредности; невалиден корен на одговорот треба експлицитно да не успее.

<?php
// src/Integration/CompanyEnrichment.php

namespace App\Integration;

final readonly class CompanyEnrichment
{
    public function __construct(
        public array $company,
        public array $contact,
        public ?string $email,
        public ?string $phone,
        public array $people,
    ) {
    }

    public static function fromPayload(array $payload): self
    {
        return new self(
            company: self::object($payload['company'] ?? null),
            contact: self::object($payload['contact'] ?? null),
            email: self::text($payload['email'] ?? null),
            phone: self::text($payload['phone'] ?? null),
            people: self::people($payload['people'] ?? null),
        );
    }

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

    private static function object(mixed $value): array
    {
        return is_array($value) && !array_is_list($value) ? $value : [];
    }

    private static function text(mixed $value): ?string
    {
        if (!is_string($value)) {
            return null;
        }

        $value = trim($value);

        return $value === '' ? null : $value;
    }

    private static function people(mixed $value): array
    {
        if (!is_array($value)) {
            return [];
        }

        return array_values(array_filter($value, 'is_array'));
    }
}

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

Изградете отпорен HTTP-клиент

Повторните обиди се корисни само за привремени неуспеси. Клиентот повторно се обидува при транспортни проблеми и HTTP-одговори 429, 502, 503 или 504. Не се обидува повторно при неуспеси на автентикација или валидација на барањето. Три вкупни обиди, кратко чекање, трисекунден тајмаут на неактивност и осумсекундно вкупно ограничување на барањето го ограничуваат чекањето на продавачот.

<?php
// src/Integration/IntegrationException.php

namespace App\Integration;

final class IntegrationException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $upstreamStatus = null,
        string $message = 'Company enrichment failed.',
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
<?php
// src/Integration/WebsiteCompanyClient.php

namespace App\Integration;

use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class WebsiteCompanyClient
{
    private const RETRYABLE = [429, 502, 503, 504];

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $serviceToken,
        private readonly string $endpoint,
    ) {
    }

    public function extract(string $website): CompanyEnrichment
    {
        $host = parse_url($website, PHP_URL_HOST) ?: 'unknown';

        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->http->request('GET', $this->endpoint, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'website' => $website,
                    ],
                    'headers' => ['Accept' => 'application/json'],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    return $this->decode($response);
                }

                $this->logger->warning('Company enrichment rejected', [
                    'host' => $host,
                    'attempt' => $attempt,
                    'upstream_status' => $status,
                ]);

                if (in_array($status, self::RETRYABLE, true) && $attempt < 3) {
                    $this->pause($response, $attempt);
                    continue;
                }

                $kind = match (true) {
                    $status === 429 => 'rate_limited',
                    in_array($status, [401, 403], true) => 'authentication',
                    in_array($status, [400, 422], true) => 'request_rejected',
                    default => 'upstream_failure',
                };

                throw new IntegrationException($kind, $status);
            } catch (TransportExceptionInterface $error) {
                $this->logger->warning('Company enrichment transport failure', [
                    'host' => $host,
                    'attempt' => $attempt,
                ]);

                if ($attempt === 3) {
                    throw new IntegrationException(
                        'unavailable',
                        previous: $error,
                    );
                }

                usleep($attempt === 1 ? 200_000 : 500_000);
            }
        }

        throw new IntegrationException('unavailable');
    }

    private function decode(ResponseInterface $response): CompanyEnrichment
    {
        try {
            $payload = json_decode(
                $response->getContent(false),
                true,
                512,
                JSON_THROW_ON_ERROR,
            );
        } catch (JsonException $error) {
            throw new IntegrationException(
                'invalid_response',
                $response->getStatusCode(),
                previous: $error,
            );
        }

        if (!is_array($payload) || array_is_list($payload)) {
            throw new IntegrationException(
                'invalid_response',
                $response->getStatusCode(),
            );
        }

        return CompanyEnrichment::fromPayload($payload);
    }

    private function pause(ResponseInterface $response, int $attempt): void
    {
        $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;

        if (is_string($retryAfter) && is_numeric($retryAfter)) {
            usleep((int) (min(2.0, max(0.0, (float) $retryAfter)) * 1_000_000));
            return;
        }

        usleep($attempt === 1 ? 200_000 : 500_000);
    }
}

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

Изложете ја крајната точка за претходно пополнување на потенцијален клиент

Контролерот прифаќа JSON како {"website":"https://example.com"}. Пред да ја повика услугата, ги проверува шемата, името на домаќинот, акредитивите и должината.

<?php
// src/Controller/LeadPrefillController.php

namespace App\Controller;

use App\Integration\IntegrationException;
use App\Integration\WebsiteCompanyClient;
use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class LeadPrefillController
{
    #[Route('/crm/leads/prefill', methods: ['POST'])]
    public function __invoke(
        Request $request,
        WebsiteCompanyClient $client,
    ): JsonResponse {
        try {
            $input = json_decode($request->getContent(), true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return new JsonResponse(['error' => 'invalid_json'], 400);
        }

        $website = is_array($input) ? ($input['website'] ?? null) : null;
        $parts = is_string($website) ? parse_url($website) : false;
        $valid = is_string($website)
            && strlen($website) <= 2048
            && filter_var($website, FILTER_VALIDATE_URL) !== false
            && is_array($parts)
            && in_array($parts['scheme'] ?? null, ['http', 'https'], true)
            && isset($parts['host'])
            && !isset($parts['user'], $parts['pass']);

        if (!$valid) {
            return new JsonResponse(['error' => 'invalid_website'], 422);
        }

        try {
            $enrichment = $client->extract($website);
        } catch (IntegrationException $error) {
            $status = match ($error->kind) {
                'request_rejected' => 422,
                'rate_limited', 'unavailable', 'upstream_failure' => 503,
                default => 502,
            };

            return new JsonResponse(['error' => $error->kind], $status);
        }

        return new JsonResponse([
            'website' => $website,
            'prefill' => $enrichment->toArray(),
        ]);
    }
}

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

Тестирајте без реални API-повици

MockHttpClient ѝ дава на интеграцијата детерминистички транспорт. Тестот исто така проверува дали двата потребни параметри на барањето стигнуваат до точната крајна точка.

<?php
// tests/Integration/WebsiteCompanyClientTest.php

namespace App\Tests\Integration;

use App\Integration\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteCompanyClientTest extends TestCase
{
    public function testItMapsACompanyResponse(): void
    {
        $callback = function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
                strtok($url, '?'),
            );

            parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
            self::assertSame('test-token', $query['token']);
            self::assertSame('https://example.com', $query['website']);

            return new MockResponse(json_encode([
                'company' => ['name' => 'Example Company'],
                'contact' => ['location' => 'Example City'],
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex Example']],
            ], JSON_THROW_ON_ERROR), [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]);
        };

        $client = new WebsiteCompanyClient(
            new MockHttpClient($callback),
            new NullLogger(),
            'test-token',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
        );

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

        self::assertSame('Example Company', $result->company['name']);
        self::assertSame('[email protected]', $result->email);
        self::assertCount(1, $result->people);
    }
}
php bin/phpunit
symfony server:start
curl --request POST 'http://127.0.0.1:8000/crm/leads/prefill' \
  --header 'Content-Type: application/json' \
  --data '{"website":"https://example.com"}'

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

Заштитете ја CRM-рутата со вообичаените правила за автентикација и авторизација на апликацијата. Додајте ограничување по корисник на ниво на апликација или gateway и разгледајте CSRF-заштита ако HTML-формулар испраќа преку сесија автентицирана со колачиња. Никогаш не враќајте ги телата одговори од надворешната услуга директно во прелистувачот.

За набљудливост, прикажувајте графикони за траењето и бројот на барања според исходот: успех, невалиден внес, неуспех на автентикација, ограничување на стапка, невалиден одговор и недостапна надворешна услуга. Поставете известување за трајни неуспеси на автентикација, бидејќи тие често укажуваат на истечен, повторно генериран или неправилно распоредeн токен. Избегнувајте ознаки со висока кардиналност, како целосни URL-адреси, и никогаш не прикачувајте збогатени лични податоци во траги.

Во продукција, внесете WEBSITE_COMPANY_TOKEN преку управувачот со тајни на хостинг-платформата или заштитена конфигурација на околината. Не го вградувајте во слика или во зачувана околинска датотека. По промена на вредноста, рестартирајте ги долготрајните PHP-работници или контејнерите на апликацијата за да ја примат новата околина.

Вообичаени патеки на неуспех

  • 401 или 403: проверете ги токенот ограничен на услугата и активацијата на планот. Не обидувајте се повторно наслепо.
  • 400 или 422: потврдете дека website е јавна HTTP или HTTPS URL-адреса и дека барањето ги користи документираните имиња на параметри.
  • 429: почитувајте го ограниченото однесување за повторни обиди, проверете ја употребата на планот и замолете ги корисниците да се обидат повторно подоцна.
  • 502, 503, 504 или транспортни грешки: зачувајте ја CRM-формата и понудете рачен повторен обид наместо да се изгубат внесените податоци.
  • Невалиден JSON или неочекувани типови полиња: третирајте го одговорот од надворешната услуга како невалиден; не погодувајте и не зачувувајте делумно доверливи необработени податоци.

Конечна контролна листа за проверка

  • Продавачот може да испрати само веб-страница на компанија и да добие нацрт за потенцијален клиент.
  • Точната крајна точка GET ги прима параметрите на барањето token и website.
  • Податоците за компанијата, контактот, е-поштата, телефонот и лицата се мапираат на една граница на апликацијата.
  • Тајмаутите и повторните обиди се ограничени, додека неуспесите на автентикација и валидација не се повторуваат.
  • Тестовите се извршуваат без мрежен пристап или вистински акредитиви.
  • Логовите, трагите, фикстурите и изворниот код не содржат токен на услугата или збогатени лични податоци.
  • CRM бара човечка потврда пред зачувување на претходно пополнетите вредности.

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

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

Mihajlo

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