Vodiči

Symfony: AI Pre-fills CRM Leads by Extracting Company Data from Websites

Symfony: AI unaprijed ispunjava CRM potencijalne klijente izvlačenjem podataka o tvrtkama s web-stranica

Prodavač ne bi trebao morati kopirati naziv tvrtke, telefonski broj, e-adresu i podatke za kontakt s pola tuceta stranica prije stvaranja potencijalnog klijenta. URL web-mjesta već je koristan signal identiteta. Praktični inženjerski izazov jest pretvoriti taj signal u pouzdane prijedloge bez izlaganja vjerodajnica, neograničenog blokiranja ili dopuštanja da ispad uzvodne usluge naruši CRM.

Ovaj vodič izrađuje Symfony krajnju točku spremnu za produkciju za taj posao. Prodavač unosi web-mjesto tvrtke, Symfony traži strukturirane podatke o tvrtki, mapira odgovor na strogoj granici aplikacije i vraća stabilan sadržaj koji CRM obrazac može upotrijebiti za unaprijed popunjavanje polja potencijalnog klijenta. Korisnik ostaje pod kontrolom: obogaćivanje predlaže vrijednosti; ne sprema ih potajno.

Dobijte pristup usluzi

Započnite registracijom na https://ai.mihajlo.mk/register. Ako već imate račun, prijavite se na https://ai.mihajlo.mk/login.

Otvorite stranicu usluge podataka Website to Company na https://ai.mihajlo.mk/api/website-to-company-data. Odaberite dostupni Free, Plus ili Pro plan koji odgovara očekivanom korištenju i dovršite njegovu aktivaciju.

Zatim posjetite službenu dokumentaciju na https://ai.mihajlo.mk/api/website-to-company-data/documentation. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu. Ova usluga nije bez tokena: svaki zahtjev za izvlačenje zahtijeva tu vjerodajnicu putem parametra upita token.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte produkcijsku tajnu, implementirajte ili ponovno pokrenite svaku instancu aplikacije koja je čita, potvrdite novu vjerodajnicu i tek tada uklonite svaku zastarjelu konfiguraciju.

Potvrdite točan HTTP ugovor

Integracija koristi GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Šalje točno dva parametra upita: token za autentikaciju i website za javno web-mjesto tvrtke.

Pokrenite jedan minimalni zahtjev s rezerviranim mjestom prije pisanja koda aplikacije:

curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN' \
  --data-urlencode 'website=https://example.com'

Lokalno zamijenite rezervirano mjesto, ali nemojte predati dobivenu naredbu u repozitorij, lijepiti je u zahtjeve za podršku niti ostaviti stvarni token u povijesti ljuske. Odgovor pruža ugovorna polja company, contact, email, phone i people. Njihov sadržaj mora se obrambeno provjeravati, umjesto slijepog pouzdavanja u uzvodni odgovor.

Pohranite vjerodajnicu u konfiguraciju okruženja

Stavite token u .env.local, koji Symfony projekti obično isključuju iz kontrole verzija:

# .env.local
WEBSITE_COMPANY_DATA_TOKEN=YOUR_SERVICE_TOKEN

U produkciji unesite istu varijablu putem platforme za hosting ili upravitelja tajnama. Nemojte je ugrađivati u sliku spremnika, izvornu datoteku, fixture ili frontend paket.

Stvorite Symfony projekt

Ova implementacija cilja PHP 8.3 ili noviji te podržanu Symfony aplikaciju. Za novi projekt instalirajte okvir, HTTP klijent, integraciju zapisivanja i alate za testiranje:

composer create-project symfony/skeleton crm-enrichment
cd crm-enrichment
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack

Relevantna struktura projekta ostaje namjerno mala:

config/services.yaml
src/CompanyData/CompanyEnrichment.php
src/CompanyData/EnrichmentException.php
src/CompanyData/CompanyDataClient.php
src/Controller/LeadPrefillController.php
tests/CompanyData/CompanyDataClientTest.php

Odaberite sinkronu granicu, a ne red

Unaprijed popunjavanje je interaktivno: prodavač očekuje prijedloge dok je obrazac potencijalnog klijenta još otvoren. Sinkroni HTTP zahtjev stoga daje jednostavniji i korisniji rezultat od uvođenja Messengera, perzistencije, anketiranja i zastarjelih zadataka.

Takav izbor treba čvrste granice. Klijent u nastavku koristi ograničeno trajanje povezivanja i ukupno trajanje, ne pokušava više od tri puta i ponovno pokušava samo kod transportnih kvarova, ograničenja stope i odabranih prolaznih odgovora poslužitelja. Autentikacija i uobičajene pogreške klijenta ne pokušavaju se ponovno jer ponavljanje istog nevaljanog zahtjeva troši kvotu i odgađa korisne povratne informacije.

Preglednik nikada ne prima token usluge. Poziva Symfony rutu istog izvora, dok poslužitelj upravlja autentikacijom, provjerom odgovora, ponovnim pokušajima, zapisivanjem i prevođenjem pogrešaka.

Mapirajte odgovor na granici aplikacije

Ugovor usluge navodi pet polja, ali otporna aplikacija ne bi trebala pretpostavljati nedokumentirane ugniježđene ključeve. Objekt domene stoga prihvaća nizove, polja ili null za opća polja te zahtijeva da people bude polje kada je prisutno. Odgovor koji ne sadrži nijedno ugovorno polje tretira se kao odstupanje od ugovora, a ne kao prazna tvrtka.

<?php
// src/CompanyData/CompanyEnrichment.php
namespace App\CompanyData;

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

    public static function fromPayload(array $payload): self
    {
        $keys = ['company', 'contact', 'email', 'phone', 'people'];

        if (array_intersect($keys, array_keys($payload)) === []) {
            throw new EnrichmentException('invalid_payload');
        }

        $people = $payload['people'] ?? [];
        if (!is_array($people)) {
            throw new EnrichmentException('invalid_payload');
        }

        return new self(
            self::field($payload, 'company'),
            self::field($payload, 'contact'),
            self::field($payload, 'email'),
            self::field($payload, 'phone'),
            $people,
        );
    }

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

    private static function field(array $payload, string $key): string|array|null
    {
        $value = $payload[$key] ?? null;

        if ($value === null || is_string($value) || is_array($value)) {
            return $value;
        }

        throw new EnrichmentException('invalid_payload');
    }
}

Upotrijebite tipiziranu iznimku kako biste kategorije uzvodnih kvarova odvojili od poruka namijenjenih korisnicima:

<?php
// src/CompanyData/EnrichmentException.php
namespace App\CompanyData;

final class EnrichmentException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $retryAfterSeconds = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($kind, 0, $previous);
    }
}

Izradite ograničeni, vidljivi HTTP klijent

Konfigurirajte namjenski transport i umetnite token prema nazivu argumenta. Stvaranje ovog transporta bez zapisivača okvira namjerno je: budući da se autentikacija mora pojaviti u nizu upita, generički HTTP zapisi inače bi mogli zabilježiti vjerodajnicu. Klijent aplikacije emitira vlastite sanitizirane događaje.

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true

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

  app.company_data_http_client:
    class: Symfony\Contracts\HttpClient\HttpClientInterface
    factory: ['Symfony\Component\HttpClient\HttpClient', 'create']
    arguments:
      - { timeout: 3.0, max_duration: 8.0 }

  App\CompanyData\CompanyDataClient:
    arguments:
      $http: '@app.company_data_http_client'
      $serviceToken: '%env(WEBSITE_COMPANY_DATA_TOKEN)%'

Klijent zapisuje samo naziv glavnog računala odredišta, broj pokušaja, status i kategoriju kvara. Nikada ne zapisuje puni URI zahtjeva, token ni vraćene osobne podatke.

<?php
// src/CompanyData/CompanyDataClient.php
namespace App\CompanyData;

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

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

    private readonly \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $serviceToken,
        private readonly LoggerInterface $logger,
        ?\Closure $sleep = null,
    ) {
        if (trim($serviceToken) === '') {
            throw new \LogicException('The company-data service token is missing.');
        }

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

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

        for ($attempt = 0; $attempt < 3; ++$attempt) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'website' => $website,
                    ],
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 2) {
                    $this->retry($host, $attempt, null, null);
                    continue;
                }

                $this->logger->error('company_enrichment.failed', [
                    'host' => $host,
                    'kind' => 'transport',
                ]);

                throw new EnrichmentException(
                    'temporarily_unavailable',
                    previous: $exception,
                );
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $result = CompanyEnrichment::fromPayload(
                        $response->toArray(false),
                    );
                } catch (DecodingExceptionInterface|TransportExceptionInterface $e) {
                    throw new EnrichmentException('invalid_payload', previous: $e);
                }

                $this->logger->info('company_enrichment.succeeded', [
                    'host' => $host,
                    'attempt' => $attempt + 1,
                ]);

                return $result;
            }

            $retryable = $status === 429
                || in_array($status, [502, 503, 504], true);

            if ($retryable && $attempt < 2) {
                $this->retry($host, $attempt, $status, $response);
                continue;
            }

            $kind = match (true) {
                $status === 401 || $status === 403 => 'authentication',
                $status === 429 => 'rate_limited',
                $status >= 400 && $status < 500 => 'request_rejected',
                default => 'temporarily_unavailable',
            };

            $retryAfter = $status === 429
                ? $this->retryAfterSeconds($response)
                : null;

            $this->logger->error('company_enrichment.failed', [
                'host' => $host,
                'status' => $status,
                'kind' => $kind,
            ]);

            throw new EnrichmentException($kind, $retryAfter);
        }

        throw new EnrichmentException('temporarily_unavailable');
    }

    private function retry(
        string $host,
        int $attempt,
        ?int $status,
        ?ResponseInterface $response,
    ): void {
        $retryAfter = $response
            ? $this->retryAfterSeconds($response)
            : null;

        $delay = $retryAfter !== null
            ? min(5000, $retryAfter * 1000)
            : min(2000, 250 * (2 ** $attempt) + random_int(0, 100));

        $this->logger->warning('company_enrichment.retry', [
            'host' => $host,
            'attempt' => $attempt + 1,
            'status' => $status,
            'delay_ms' => $delay,
        ]);

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

    private function retryAfterSeconds(ResponseInterface $response): ?int
    {
        $value = $response->getHeaders(false)['retry-after'][0] ?? null;

        return is_string($value) && ctype_digit($value)
            ? (int) $value
            : null;
    }
}

Izložite CRM rutu za unaprijed popunjavanje

Kontroler prihvaća puni URL ili naziv glavnog računala te nedostajuće sheme prema zadanim postavkama postavlja na HTTPS. Odbija vjerodajnice ugrađene u URL-ove, localhost, neispravna glavna računala te doslovne privatne ili rezervirane IP adrese. Time se sprječava očita zlouporaba krajnje točke koja troši kvotu, uz zadržavanje praktičnosti obrasca.

<?php
// src/Controller/LeadPrefillController.php
namespace App\Controller;

use App\CompanyData\CompanyDataClient;
use App\CompanyData\EnrichmentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

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

        $website = $this->normalizeWebsite($input['website'] ?? null);
        if ($website === null) {
            return $this->json(['error' => 'invalid_website'], 422);
        }

        try {
            $enrichment = $client->extract($website);
        } catch (EnrichmentException $exception) {
            $status = match ($exception->kind) {
                'request_rejected' => 422,
                'rate_limited' => 429,
                default => 503,
            };

            $response = $this->json([
                'error' => $exception->kind,
            ], $status);

            if ($exception->retryAfterSeconds !== null) {
                $response->headers->set(
                    'Retry-After',
                    (string) $exception->retryAfterSeconds,
                );
            }

            return $response;
        }

        return $this->json([
            'data' => [
                'website' => $website,
                ...$enrichment->toArray(),
            ],
        ]);
    }

    private function normalizeWebsite(mixed $value): ?string
    {
        if (!is_string($value) || trim($value) === '') {
            return null;
        }

        $website = trim($value);
        if (!preg_match('~^https?://~i', $website)) {
            $website = 'https://' . $website;
        }

        if (filter_var($website, FILTER_VALIDATE_URL) === false) {
            return null;
        }

        $parts = parse_url($website);
        $host = strtolower($parts['host'] ?? '');

        if ($host === '' || $host === 'localhost'
            || isset($parts['user']) || isset($parts['pass'])) {
            return null;
        }

        if (filter_var($host, FILTER_VALIDATE_IP) !== false
            && filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
            ) === false) {
            return null;
        }

        return $website;
    }
}

Postojeća CRM stranica može pozvati ovu rutu kada kontrola web-mjesta izgubi fokus, a zatim smjestiti vraćene prijedloge u odgovarajuće kontrole. Zadržite korisnikovu mogućnost uređivanja svake vrijednosti i zahtijevajte uobičajenu radnju spremanja; obogaćivanje ne bi smjelo stvoriti potencijalnog klijenta samo zato što je unesen URL.

const form = document.querySelector('[data-lead-form]');
const website = form.elements.namedItem('website');

website.addEventListener('blur', async () => {
  const response = await fetch('/crm/leads/prefill', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ website: website.value })
  });

  if (!response.ok) return;

  const lead = (await response.json()).data;

  for (const key of ['company', 'contact', 'email', 'phone', 'people']) {
    const control = form.elements.namedItem(key);
    if (!control || lead[key] == null || control.value !== '') continue;

    control.value = typeof lead[key] === 'string'
      ? lead[key]
      : JSON.stringify(lead[key]);
  }
});

Produkcijski obrasci također bi trebali zaštititi ovu POST rutu uobičajenom strategijom autentikacije i CSRF-a aplikacije. Dodajte ograničavanje po korisniku kako autentificirani račun ne bi slučajno ili namjerno trošio kvotu usluge.

Deterministički testirajte uspjeh, mapiranje i ponovne pokušaje

MockHttpClient omogućuje testu provjeru metode, krajnje točke, parametara upita, mapiranja i ponašanja pri ponovnim pokušajima bez mrežnog pristupa. Umetanje zatvaranja za čekanje održava testove ponovnih pokušaja brzim.

<?php
// tests/CompanyData/CompanyDataClientTest.php
namespace App\Tests\CompanyData;

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

final class CompanyDataClientTest extends TestCase
{
    public function testMapsSuccessfulResponse(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url): MockResponse {
                self::assertSame('GET', $method);
                self::assertStringContainsString(
                    '/api/website-to-company-data/v1/extract',
                    $url,
                );
                self::assertStringContainsString('token=TEST_TOKEN', $url);
                self::assertStringContainsString('website=', $url);

                return new MockResponse(json_encode([
                    'company' => 'Example Company',
                    'contact' => 'General contact',
                    'email' => '[email protected]',
                    'phone' => '+1 555 0100',
                    'people' => [],
                ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
            },
        );

        $client = new CompanyDataClient(
            $http,
            'TEST_TOKEN',
            new NullLogger(),
            static fn (int $milliseconds) => null,
        );

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

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

    public function testRetriesATransientResponse(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse(json_encode([
                'company' => null,
                'contact' => null,
                'email' => null,
                'phone' => null,
                'people' => [],
            ], JSON_THROW_ON_ERROR), ['http_code' => 200]),
        ]);

        $delays = [];
        $client = new CompanyDataClient(
            $http,
            'TEST_TOKEN',
            new NullLogger(),
            function (int $milliseconds) use (&$delays): void {
                $delays[] = $milliseconds;
            },
        );

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

        self::assertCount(1, $delays);
        self::assertSame(2, $http->getRequestsCount());
    }
}

Pokrenite paket s php bin/phpunit. Dodajte testove kontrolera za neispravan JSON, neispravne URL-ove, neautentificirane pozivatelje i svaki stabilni odgovor pogreške prije povezivanja krajnje točke sa stvarnim CRM obrascem.

Sigurnosne i operativne pojedinosti koje su važne

  • Redigirajte nizove upita. Obavezni format autentikacije postavlja token u URL. Konfigurirajte proxyje, nadzor performansi aplikacije, izvještavanje o iznimkama i instrumentaciju odlaznog HTTP-a tako da uklanjaju parametar token.
  • Smanjite zadržane podatke na najmanju mjeru. Kontaktni podaci tvrtke i podaci o osobama i dalje mogu biti osobni podaci. Pohranite samo polja koja CRM zahtijeva, primijenite uobičajene kontrole pristupa i slijedite politiku zadržavanja organizacije.
  • Odvojite prijedlog od perzistencije. Nikada nemojte prepisati vrijednosti koje je prodavač već unio. Jasno prikažite izvor unaprijed popunjenih podataka i omogućite korisniku da ih ispravi.
  • Zaštitite kvotu. Autentificirajte CRM rutu, ograničite njezinu stopu po korisniku te uklonite višak poziva ili je pokrećite pri gubitku fokusa umjesto pri svakom pritisku tipke.
  • Rotirajte sigurno. Ponovno generirani token usluge odmah poništava prethodni aktivni token, stoga koordinirajte uvođenje konfiguracije kroz sve instance.

Vidljivost, implementacija i česti kvarovi

Nadzirite broj uspješnih obogaćivanja, sanitizirane vrste kvarova, pokušaje ponavljanja i krajnju latenciju. Upozorite na trajne pogreške autentikacije jer one obično ukazuju na istekao, ponovno generiran ili nepravilno umetnut token. Kratak porast prolaznih kvarova ne bi trebao nikoga pozvati ako ih ograničeni ponovni pokušaji oporave.

Izgradite produkcijske ovisnosti, pružite token okruženja i zagrijte predmemoriju koristeći isto okruženje koje će pokretati aplikaciju:

composer install --no-dev --classmap-authoritative
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
php bin/phpunit

Ponovno pokrenite dugotrajne PHP radnike ili spremnike aplikacije nakon promjene tokena. Nemojte zahtjev za izvlačenje koji troši kvotu učiniti dijelom visokofrekventne provjere stanja. Provjerite ga jednom tijekom implementacije ili kroz kontrolirani test ispravnosti.

Najčešće kvarove lako je dijagnosticirati:

  • authentication: potvrdite da je plan aktivan i da je implementirani token trenutačan. Nemojte ponovno pokušavati dok se konfiguracija ne promijeni.
  • rate_limited: poštujte Retry-After kada je naveden, smanjite duplicirane pozive preglednika i pregledajte kapacitet plana.
  • request_rejected: provjerite poslani javni URL i pravila provjere. Ponavljanje bez promjene neće pomoći.
  • invalid_payload: zadržite sanitizirane metapodatke, usporedite odgovor sa službenom dokumentacijom i namjerno ažurirajte mapper granice.
  • temporarily_unavailable: zadržite obrazac upotrebljivim, prikažite radnju ponovnog pokušaja i dopustite ručni unos umjesto blokiranja stvaranja potencijalnog klijenta.

Završni kontrolni popis za provjeru

  • Korisnik može unijeti goli naziv glavnog računala ili HTTP/HTTPS URL tvrtke.
  • Preglednik poziva samo lokalnu rutu /crm/leads/prefill i nikada ne prima token usluge.
  • Poslužitelj šalje točnu GET krajnju točku s parametrima upita token i website.
  • Aplikacija mapira company, contact, email, phone i people na jednoj obrambenoj granici.
  • Vremenska ograničenja i ponovni pokušaji su ograničeni, a kvarovi autentikacije ili provjere ne ponavljaju se slijepo.
  • Dnevnici, tragovi, fixturei i izvješća o pogreškama ne sadrže ni token ni sadržaj odgovora.
  • Automatizirani testovi prolaze bez slanja vanjskog zahtjeva.
  • Uzvodni kvar ostavlja ručni unos u CRM-u dostupnim.

Najjača značajka obogaćivanja nije ona koja popunjava najviše polja. To je ona koja štedi rutinski rad, a ostaje predvidljiva kada je web-mjesto oskudno, odgovor se promijeni, kvota dosegne granicu ili mreža jednostavno ima lošu minutu. Uz usku Symfony granicu, oprezne ponovne pokušaje, sigurno zapisivanje i ljudsku potvrdu, jedan URL tvrtke postaje korisna prednost umjesto još jedne krhke ovisnosti.

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.