Vodiči

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

Symfony: Trenutačno unaprijed popunite CRM potencijalne klijente pomoću API-ja za podatke od web-mjesta do tvrtke

Prazan CRM potencijalni klijent mala je zamka za produktivnost. Prodavač zna web-mjesto tvrtke, ali i dalje mora kopirati njezin naziv, podatke za kontakt i osobe u zasebna polja prije nego što stvarni posao može započeti.

Ovaj vodič zamjenjuje to trenje jednom Symfony krajnjom točkom. Preglednik šalje web-mjesto tvrtke, Symfony poziva uslugu podataka Website to Company, provjerava odgovor na granici integracije i vraća sigurnu skicu potencijalnog klijenta za pregled. Dizajn je sinkron jer prodavač čeka obrazac, ali i dalje uključuje ograničena vremenska ograničenja, selektivne ponovne pokušaje, strukturirane pogreške, testove i produkcijsko bilježenje.

Dobijte pristup i kopirajte servisni token

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge podataka Website to Company.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Ova usluga zahtijeva token. Njegova ponovna generacija opoziva prethodno aktivni token, zato uskladite rotaciju s implementacijom: najprije ažurirajte produkcijsku tajnu, implementirajte ili ponovno pokrenite aplikaciju te provjerite integraciju prije uklanjanja privremenih operativnih zaštitnih mjera.

Točan zahtjev je HTTP GET na https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija koristi parametar upita token, dok se web-mjesto tvrtke dostavlja kao website. Potvrdite pristup jednokratnom varijablom ljuske kako vjerodajnica ne bi ušla u kontrolu izvornog koda:

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'

Imajte na umu da povijest naredbi i pregled procesa mogu otkriti vrijednosti naredbenog retka. Ovo upotrebljavajte samo kao lokalnu metodu provjere uz odgovarajuće kontrole ljuske. Aplikacija će čitati token iz konfiguracije podržane varijablama okruženja.

Stvorite Symfony projekt

Implementaciji su potrebni Symfonyjev HTTP klijent, podrška za validaciju, paket za bilježenje i alati za testiranje. PHP 8.3 ili noviji te Composer jedini su lokalni preduvjeti.

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

Rezultirajuća funkcionalnost ima tri namjerna sloja:

  • CompanyEnrichment pretvara nepouzdani JSON u stabilan oblik aplikacije.
  • WebsiteCompanyClient upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima i neuspjesima uzvodne usluge.
  • LeadPrefillController provjerava unos preglednika i pretvara ishode integracije u HTTP odgovore.

Ovo je dovoljno arhitekture za mali CRM bez uvođenja Messengera, baze podataka ili reda čekanja. Pozadinski zadatak imao bi smisla za skupne uvoze, ali bi interaktivni obrazac učinio sporijim i složenijim.

Konfigurirajte okruženje i ubrizgavanje ovisnosti

Stavite bezopasni rezervirani tekst u .env, koji dokumentira potrebnu varijablu, a stvarni razvojni token držite u .env.local. Symfony isključuje .env.local iz uobičajenih tijekova kontrole izvornog koda.

# .env
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

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

Izričito povežite vjerodajnicu i fiksnu krajnju točku u 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'

Mapirajte vanjske podatke na granici

Ugovor usluge izlaže podatke company, contact, email, phone i people. Ne dopustite da kontroler ili CRM predložak ovise o neprovjerenim vrijednostima odgovora. Polja koja nedostaju i nisu obavezna trebala bi proizvesti prazne vrijednosti; neispravan korijen odgovora trebao bi izričito ne uspjeti.

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

Ovaj mapper namjerno ne pretpostavlja ništa o nedokumentiranim ugniježđenim poljima tvrtke ili osobe. Korisničko sučelje može pregledavati mapirana polja, ali ih treba tretirati kao prijedloge za obogaćivanje, a ne kao provjerene činjenice.

Izgradite otporan HTTP klijent

Ponovni pokušaji korisni su samo za prolazne neuspjehe. Klijent ponavlja pokušaj kod problema s transportom i HTTP odgovora 429, 502, 503 ili 504. Ne ponavlja pokušaj kod neuspjeha autentikacije ili validacije zahtjeva. Tri ukupna pokušaja, kratko odgađanje, trominutno ograničenje neaktivnosti i ukupno ograničenje zahtjeva od osam sekundi ograničavaju čekanje prodavača.

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

Zapisnik bilježi naziv hosta, pokušaj i status, ali nikada token, puni niz upita, tijelo odgovora, e-poštu, telefon ili podatke o osobama. Budući da ugovor o autentikaciji stavlja token u upit, redakcija nizova upita u proxyjima i zapisnicima pristupa osobito je važna.

Izložite krajnju točku za prethodno popunjavanje potencijalnog klijenta

Kontroler prihvaća JSON poput {"website":"https://example.com"}. Prije pozivanja usluge provjerava shemu, naziv hosta, vjerodajnice i duljinu.

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

Zadržite potencijalnog klijenta kao skicu dok ga prodavač ne potvrdi. Obogaćivanje bi trebalo unaprijed popuniti polja, a ne tiho prepisati izmjene osobe ili podatke treće strane pretvoriti u mjerodavan CRM zapis.

Testirajte bez stvarnih API poziva

MockHttpClient integraciji daje deterministički transport. Test također provjerava da oba potrebna parametra upita dosežu ispravnu krajnju točku.

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

Sigurnost, vidljivost i implementacija

Zaštitite CRM rutu uobičajenim pravilima autentikacije i autorizacije aplikacije. Dodajte ograničavanje po korisniku na razini aplikacije ili pristupnika te razmotrite CSRF zaštitu ako HTML obrazac šalje podatke kroz sesiju autentificiranu kolačićem. Nikada nemojte vraćati uzvodna tijela izravno pregledniku.

Za vidljivost prikažite grafikone trajanja zahtjeva i brojeva prema ishodu: uspjeh, neispravan unos, neuspjeh autentikacije, ograničenje stope, neispravan odgovor i nedostupna uzvodna usluga. Upozorite na trajne neuspjehe autentikacije jer oni često ukazuju na istekao, ponovno generiran ili pogrešno implementiran token. Izbjegavajte oznake velike kardinalnosti, poput punih URL-ova, i nikada ne prilažite obogaćene osobne podatke tragovima.

U produkciji umetnite WEBSITE_COMPANY_TOKEN putem upravitelja tajni platforme za hosting ili zaštićene konfiguracije okruženja. Nemojte ga ugraditi u sliku ni u datoteku okruženja predanu u repozitorij. Nakon promjene vrijednosti ponovno pokrenite dugotrajne PHP radnike ili spremnike aplikacije kako bi preuzeli novo okruženje.

Uobičajeni putovi neuspjeha

  • 401 ili 403: provjerite token ograničen na uslugu i aktivaciju plana. Nemojte naslijepo ponavljati pokušaje.
  • 400 ili 422: potvrdite da je website javni HTTP ili HTTPS URL i da zahtjev koristi dokumentirane nazive parametara.
  • 429: poštujte ograničeno ponašanje ponovnih pokušaja, pregledajte korištenje plana i zamolite korisnike da pokušaju ponovno kasnije.
  • 502, 503, 504 ili transportne pogreške: sačuvajte CRM obrazac i ponudite ručni ponovni pokušaj umjesto gubitka unesenih podataka.
  • Neispravan JSON ili neočekivani tipovi polja: tretirajte uzvodni odgovor kao neispravan; nemojte nagađati ni spremati djelomično pouzdane sirove podatke.

Završni kontrolni popis za provjeru

  • Prodavač može poslati samo web-mjesto tvrtke i primiti skicu potencijalnog klijenta.
  • Točna krajnja točka GET prima parametre upita token i website.
  • Podaci o tvrtki, kontaktu, e-pošti, telefonu i osobama mapiraju se na jednoj granici aplikacije.
  • Vremenska ograničenja i ponovni pokušaji su ograničeni, dok se neuspjesi autentikacije i validacije ne pokušavaju ponovno.
  • Testovi se izvode bez mrežnog pristupa ili stvarnih vjerodajnica.
  • Zapisnici, tragovi, fiksni testni podaci i izvorni kod ne sadrže servisni token ni obogaćene osobne podatke.
  • CRM zahtijeva ljudsku potvrdu prije spremanja unaprijed popunjenih vrijednosti.

Najbolja funkcionalnost obogaćivanja ne djeluje kao podatkovni kanal. Djeluje kao da obrazac već razumije tvrtku koju prodavač namjerava kontaktirati. Uska Symfony granica, defenzivno mapiranje i disciplinirano postupanje s neuspjesima čine tu trenutačnu pogodnost dovoljno sigurnom za rad dugo nakon prve uspješne demonstracije.

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.