Vodiči

Symfony CRM: Auto-Tag Leads with Website Tech Stack Insights

Symfony CRM: Automatski označavajte potencijalne klijente uvidima u tehnološki sklop web-stranice

Web-mjesto potencijalnog klijenta često otkriva više nego što će ikada otkriti slobodno polje „bilješke”. Mala agencija može koristiti njegov tehnološki skup kako bi uočila vjerojatan posao održavanja, prilagodila pitanja za otkrivanje potreba i usmjerila prilike pravom stručnjaku. Koristan ishod nije popis otisaka; to je sažeto CRM polje poput „WordPress 6.x (98%), WooCommerce (94%), PHP (88%).”

Ovaj vodič izrađuje tu značajku u Symfony aplikaciji. Novi potencijalni klijenti asinkrono se skeniraju putem API-ja Website Technology Detector, normaliziraju na granici aplikacije i ažuriraju čitljivim sažetkom. Implementacija uključuje ograničena vremenska prekoračenja, ponovne pokušaje Messengera, strukturirana stanja neuspjeha, sigurno zapisivanje u logove i determinističke testove.

Dobijte pristup usluzi i token ograničenog opsega

Prije pisanja integracijskog koda izradite račun ili mu pristupite:

  1. Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
  2. Otvorite stranicu usluge Website Technology Detector.
  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 tu uslugu.

Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer oblik jer ga Symfony izravno podržava i jer vjerodajnicu drži izvan URL-ova, povijesti preglednika, zapisnika pristupa proxyju i analitičkih sustava.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao koordiniranu implementaciju: ažurirajte tajnu u svakom okruženju, ponovno pokrenite workere, provjerite zahtjev i tek tada smatrajte uvođenje dovršenim.

Potvrdite točan API zahtjev

Integracija poziva POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies s JSON objektom koji sadrži url. Testirajte token bez uključivanja Symfonyja:

curl --fail-with-body \
  --request POST \
  'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Nemojte lijepiti stvarni token u povijest ljuske na zajedničkim računalima ili u CI zapisnike. Lokalna varijabla okruženja ili pokretač naredbi koji koristi tajne sigurniji je za rutinsku dijagnostiku.

Pohranite vjerodajnicu u .env.local, koji treba ostati nedodijeljen verzioniranju:

WEBSITE_TECH_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=technology_scan

U produkciji definirajte iste varijable putem upravitelja tajnama hosting platforme. U repozitoriju zadržite samo zamjenske vrijednosti koje nisu tajne.

Odaberite namjerno malu arhitekturu

Otkrivanje tehnologije obavlja vanjski mrežni posao, stoga ne bi trebalo odgoditi zahtjev koji stvara potencijalnog klijenta. Kontroler sprema potencijalnog klijenta i šalje malu Messenger poruku koja sadrži njegov identifikator. Worker učitava trenutačnog potencijalnog klijenta, poziva namjenski API klijent, preslikava odgovor u vrijednosti domene i ažurira sažetak.

Ovo dodaje red, ali kompromis je vrijedan toga: stvaranje potencijalnog klijenta ostaje responzivno, prolazni uzvodni neuspjesi mogu se ponovno pokušati, a konkurentnost workera može se neovisno ograničiti. Doctrine transport temeljen na bazi podataka primjeren je za mali CRM. Namjenski broker postaje koristan tek kada to opravdaju promet ili operativni zahtjevi.

Relevantna struktura projekta je:

  • src/Technology/WebsiteTechnologyClient.php za HTTP granicu
  • src/Technology/DetectionResult.php za obrambeno preslikavanje odgovora
  • src/Message/EnrichLeadTechnology.php za poruku reda
  • src/MessageHandler/EnrichLeadTechnologyHandler.php za orkestraciju
  • tests/Technology/WebsiteTechnologyClientTest.php za determinističke testove transporta

Instalirajte i konfigurirajte Symfony komponente

Primjer pretpostavlja PHP 8.3 ili noviji, funkcionalnu Symfony aplikaciju, Doctrine ORM i postojeći entitet Lead s identifikatorom i URL-om web-mjesta.

composer require symfony/http-client symfony/messenger symfony/doctrine-messenger
composer require --dev symfony/test-pack

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
php bin/console messenger:setup-transports

Dodajte polja sažetka i pogreške koja dopuštaju null te status u Lead. Enum poduprt stringom sprječava kontrolere i workere da stvaraju nekompatibilne vrijednosti statusa:

<?php
// src/Entity/LeadTechnologyStatus.php
namespace App\Entity;

enum LeadTechnologyStatus: string
{
    case Pending = 'pending';
    case Ready = 'ready';
    case Rejected = 'rejected';
    case Failed = 'failed';
}

// Relevant additions to src/Entity/Lead.php
#[ORM\Column(length: 20, enumType: LeadTechnologyStatus::class)]
private LeadTechnologyStatus $technologyStatus = LeadTechnologyStatus::Pending;

#[ORM\Column(length: 1000, nullable: true)]
private ?string $technologySummary = null;

#[ORM\Column(length: 255, nullable: true)]
private ?string $technologyError = null;

public function markTechnologyReady(string $summary): void
{
    $this->technologyStatus = LeadTechnologyStatus::Ready;
    $this->technologySummary = $summary;
    $this->technologyError = null;
}

public function markTechnologyRejected(string $reason): void
{
    $this->technologyStatus = LeadTechnologyStatus::Rejected;
    $this->technologyError = mb_substr($reason, 0, 255);
}

Registrirajte token putem ubrizgavanja ovisnosti i konfigurirajte ograničene Messenger ponovne pokušaje:

# config/services.yaml
services:
  App\Technology\WebsiteTechnologyClient:
    arguments:
      $token: '%env(WEBSITE_TECH_TOKEN)%'

# config/packages/messenger.yaml
framework:
  messenger:
    failure_transport: failed
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 3
          delay: 1000
          multiplier: 2
          max_delay: 8000
      failed: 'doctrine://default?queue_name=failed'
    routing:
      App\Message\EnrichLeadTechnology: async

Normalizirajte API odgovor na granici

API pruža otkrivanja tehnologija s ocjenom pouzdanosti, dokaze, verzije i informacije o preusmjeravanju. Vanjske vrijednosti i dalje trebaju obrambenu validaciju: polja mogu nedostajati, dopuštati null, biti neočekivanog tipa ili biti dodana u kasnijim verzijama usluge. CRM bi trebao ovisiti o stabilnom objektu umjesto da kroz domenu prosljeđuje proizvoljne nizove.

<?php
// src/Technology/DetectionResult.php
namespace App\Technology;

final readonly class TechnologyDetection
{
    public function __construct(
        public string $name,
        public ?string $version,
        public float $confidence,
        public array $evidence,
    ) {}
}

final readonly class DetectionResult
{
    public function __construct(
        public array $technologies,
        public array $redirects,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $rows = $payload['technologies'] ?? $payload['detections'] ?? null;

        if (!is_array($rows)) {
            throw new PermanentTechnologyApiException(
                'The API response contains no technology detection list.'
            );
        }

        $technologies = [];

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }

            $name = $row['name'] ?? $row['technology'] ?? null;
            $confidence = $row['confidence'] ?? null;

            if (!is_string($name) || trim($name) === '' || !is_numeric($confidence)) {
                continue;
            }

            $version = $row['version'] ?? null;
            $version = is_string($version) && trim($version) !== ''
                ? mb_substr(trim($version), 0, 80)
                : null;

            $evidence = is_array($row['evidence'] ?? null)
                ? array_values($row['evidence'])
                : [];

            $technologies[] = new TechnologyDetection(
                mb_substr(trim($name), 0, 120),
                $version,
                max(0.0, min(100.0, (float) $confidence)),
                $evidence,
            );
        }

        $redirects = $payload['redirects'] ?? $payload['redirect_information'] ?? [];

        return new self(
            $technologies,
            is_array($redirects) ? array_values($redirects) : [],
        );
    }

    public function summary(int $limit = 8): string
    {
        $items = $this->technologies;
        usort(
            $items,
            static fn ($a, $b) => $b->confidence <=> $a->confidence
        );

        $labels = array_map(
            static fn (TechnologyDetection $item): string => sprintf(
                '%s%s (%d%%)',
                $item->name,
                $item->version === null ? '' : ' '.$item->version,
                (int) round($item->confidence),
            ),
            array_slice($items, 0, $limit),
        );

        return $labels === [] ? 'No technologies detected' : implode(', ', $labels);
    }
}

Maper čuva podatke o dokazima i preusmjeravanjima za buduće odluke domene bez smještanja potencijalno velikih ili nestabilnih struktura u sažetak namijenjen ljudima. Ako službena dokumentacija promijeni omotnicu odgovora, ovo je jedina klasa koja bi se trebala promijeniti.

Izradite HTTP klijent s ograničenjima

Klijent koristi vremensko ograničenje neaktivnosti od 5 sekundi i ukupni proračun zahtjeva od 15 sekundi. Razlikuje trajne neuspjehe od onih za koje se može ponovno pokušati. Neuspjesi validacije i autentikacije ne smiju se ponovno pokušavati; ograničenja brzine, vremenska prekoračenja i privremeni neuspjesi poslužitelja mogu se.

<?php
// src/Technology/PermanentTechnologyApiException.php
namespace App\Technology;
final class PermanentTechnologyApiException extends \RuntimeException {}

// src/Technology/TransientTechnologyApiException.php
namespace App\Technology;
final class TransientTechnologyApiException extends \RuntimeException {}

// src/Technology/WebsiteTechnologyClient.php
namespace App\Technology;

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class WebsiteTechnologyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    public function __construct(
        private HttpClientInterface $httpClient,
        private string $token,
    ) {
        if (trim($token) === '') {
            throw new \InvalidArgumentException('Website technology token is missing.');
        }
    }

    public function detect(string $url): DetectionResult
    {
        try {
            $response = $this->httpClient->request('POST', self::ENDPOINT, [
                'auth_bearer' => $this->token,
                'json' => ['url' => $url],
                'timeout' => 5.0,
                'max_duration' => 15.0,
                'headers' => ['Accept' => 'application/json'],
            ]);

            $status = $response->getStatusCode();
            $body = $response->getContent(false);
        } catch (TransportExceptionInterface $exception) {
            throw new TransientTechnologyApiException(
                'Technology service transport failure.',
                previous: $exception,
            );
        }

        if (in_array($status, [408, 425, 429], true) || $status >= 500) {
            throw new TransientTechnologyApiException(
                sprintf('Technology service temporarily returned HTTP %d.', $status)
            );
        }

        if ($status < 200 || $status >= 300) {
            throw new PermanentTechnologyApiException(
                sprintf('Technology service rejected the request with HTTP %d.', $status)
            );
        }

        try {
            $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $exception) {
            throw new PermanentTechnologyApiException(
                'Technology service returned invalid JSON.',
                previous: $exception,
            );
        }

        if (!is_array($payload)) {
            throw new PermanentTechnologyApiException(
                'Technology service returned an unexpected JSON value.'
            );
        }

        return DetectionResult::fromPayload($payload);
    }
}

Otpremajte i obrađujte skeniranja asinkrono

Poruka nosi samo identifikator baze podataka. Time se serijalizirane poruke održavaju malima i osigurava se da worker vidi trenutačni URL umjesto zastarjele snimke entiteta.

<?php
// src/Message/EnrichLeadTechnology.php
namespace App\Message;

final readonly class EnrichLeadTechnology
{
    public function __construct(public int $leadId) {}
}

// In the controller, after validating and persisting a new Lead:
$entityManager->persist($lead);
$entityManager->flush();

$messageBus->dispatch(new EnrichLeadTechnology($lead->getId()));

Handler provjerava je li poslana vrijednost uobičajeni HTTP ili HTTPS URL s nazivom hosta. Budući da CRM sam ne dohvaća cilj, vanjska usluga ostaje odgovorna za sigurno pregledavanje javnih web-mjesta. Odbacivanje vjerodajnica i IP literala i dalje smanjuje slučajnu zloupotrebu.

<?php
// src/MessageHandler/EnrichLeadTechnologyHandler.php
namespace App\MessageHandler;

use App\Message\EnrichLeadTechnology;
use App\Repository\LeadRepository;
use App\Technology\PermanentTechnologyApiException;
use App\Technology\WebsiteTechnologyClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class EnrichLeadTechnologyHandler
{
    public function __construct(
        private LeadRepository $leads,
        private WebsiteTechnologyClient $client,
        private EntityManagerInterface $entityManager,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(EnrichLeadTechnology $message): void
    {
        $lead = $this->leads->find($message->leadId);

        if ($lead === null) {
            $this->logger->notice('Technology scan skipped: lead no longer exists.', [
                'lead_id' => $message->leadId,
            ]);
            return;
        }

        $url = $lead->getWebsiteUrl();

        if (!$this->isAcceptableUrl($url)) {
            $lead->markTechnologyRejected('Website must be a public HTTP(S) hostname.');
            $this->entityManager->flush();
            return;
        }

        try {
            $result = $this->client->detect($url);
            $lead->markTechnologyReady($result->summary());
            $this->entityManager->flush();

            $this->logger->info('Lead technology summary updated.', [
                'lead_id' => $lead->getId(),
                'technology_count' => count($result->technologies),
                'redirect_count' => count($result->redirects),
            ]);
        } catch (PermanentTechnologyApiException $exception) {
            $lead->markTechnologyRejected($exception->getMessage());
            $this->entityManager->flush();

            $this->logger->warning('Technology scan permanently rejected.', [
                'lead_id' => $lead->getId(),
                'error_class' => $exception::class,
            ]);
        }
    }

    private function isAcceptableUrl(?string $url): bool
    {
        if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = $parts['host'] ?? null;

        return in_array($scheme, ['http', 'https'], true)
            && is_string($host)
            && $host !== ''
            && !isset($parts['user'], $parts['pass'])
            && filter_var($host, FILTER_VALIDATE_IP) === false;
    }
}

Prolazne iznimke namjerno se ne hvataju. Messenger ih ponovno pokušava s ograničenim eksponencijalnim odgodama i na kraju poruku smješta u transport neuspjeha. Time se izbjegavaju ugniježđene petlje ponovnih pokušaja koje bi tijekom prekida rada mogle umnožiti promet.

Testirajte bez pozivanja aktivne usluge

MockHttpClient pruža deterministički transport. Testovi trebaju potvrditi i odlazni ugovor i klasifikaciju neuspjeha; nikada ne smiju sadržavati produkcijski token.

<?php
// tests/Technology/WebsiteTechnologyClientTest.php
namespace App\Tests\Technology;

use App\Technology\PermanentTechnologyApiException;
use App\Technology\WebsiteTechnologyClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteTechnologyClientTest extends TestCase
{
    public function testItBuildsAReadableSummary(): void
    {
        $http = new MockHttpClient(function ($method, $url, $options) {
            self::assertSame('POST', $method);
            self::assertStringEndsWith('/v1/detect-technologies', $url);
            self::assertSame(
                ['url' => 'https://example.com'],
                json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR)
            );

            return new MockResponse(json_encode([
                'technologies' => [
                    [
                        'name' => 'Example CMS',
                        'version' => '4.2',
                        'confidence' => 97,
                        'evidence' => ['response marker'],
                    ],
                    [
                        'name' => 'Example Runtime',
                        'confidence' => 81.4,
                        'evidence' => [],
                    ],
                ],
                'redirects' => [],
            ], JSON_THROW_ON_ERROR), [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]);
        });

        $result = (new WebsiteTechnologyClient($http, 'test-token'))
            ->detect('https://example.com');

        self::assertSame(
            'Example CMS 4.2 (97%), Example Runtime (81%)',
            $result->summary()
        );
    }

    public function testAuthenticationFailureIsPermanent(): void
    {
        $http = new MockHttpClient(new MockResponse('{}', ['http_code' => 401]));
        $client = new WebsiteTechnologyClient($http, 'invalid-test-token');

        $this->expectException(PermanentTechnologyApiException::class);
        $client->detect('https://example.com');
    }
}
php bin/phpunit
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
php bin/console messenger:failed:show

Sigurnost, vidljivost i implementacija

Nikada nemojte zapisivati Bearer token, potpuni payload odgovora, vrijednosti dokaza ili zaglavlja. Dokazi mogu sadržavati sadržaj web-mjesta, a URL-ovi mogu sadržavati podatke o korisnicima. Dajte prednost identifikatorima potencijalnih klijenata, statusnim kodovima, klasama iznimki, trajanjima i brojevima otkrivanja. Konfigurirajte produkcijsko zapisivanje kako bi upozoravalo na trajne odgovore ograničenja brzine, neuspjehe autentikacije i rast neuspjelog reda.

Ograničite konkurentnost workera prema aktiviranom planu umjesto da maksimalno povećavate propusnost. 429 je signal kapaciteta, a ne poziv na trenutačni ponovni pokušaj. Konfigurirane odgode raspoređuju pokušaje kroz vrijeme, dok transport neuspjeha sprječava beskrajnu obradu.

Lokalno generirajte i pregledajte Doctrine migraciju, predajte je u repozitorij i tijekom implementacije pokrenite doctrine:migrations:migrate --no-interaction. Ubrizgajte token prije pokretanja workera. Ponovno pokrenite workere nakon promjena koda ili tajni kako bi dugotrajni procesi primili novi spremnik i okruženje.

Uobičajeni neuspjesi obično su jednostavni: 401 ili 403 označava nedostajući, opozvani ili pogrešan token usluge; 400 ili 422 upućuje na neprihvatljiv URL; 429 znači da plan ili stopa zahtjeva zahtijevaju pažnju; ponovljeni odgovori 5xx pripadaju redovima za ponovni pokušaj i neuspjehe. Neispravan JSON ili promijenjen oblik odgovora trebao bi vidljivo zakazati na granici umjesto da tiho proizvodi obmanjujuće CRM podatke.

Završni kontrolni popis za provjeru

  • Plan usluge je aktivan, a token ograničenog opsega dostavlja se putem konfiguracije temeljene na okruženju.
  • Minimalni autentificirani POST zahtjev uspijeva prema točnom krajnjem odredištu za otkrivanje.
  • Stvaranje potencijalnog klijenta predaje ga u bazu prije slanja Messenger poruke.
  • Worker zapisuje sažet, tehnologijski sažetak koji uzima u obzir pouzdanost.
  • Neuspjesi autentikacije i validacije ne pokušavaju se ponovno.
  • Vremenska prekoračenja, ograničenja brzine i privremeni neuspjesi poslužitelja primaju ograničene ponovne pokušaje.
  • Neuspjele poruke vidljive su i mogu se oporaviti putem transporta neuspjeha.
  • Logovi sadrže operativni kontekst, ali ne i vjerodajnice, payloadove dokaza ili osjetljiva zaglavlja.
  • Testovi prolaze s MockHttpClient i nikada ne kontaktiraju aktivnu uslugu.

Najjači dio ovog dizajna jest njegova suzdržanost. Jedna API granica razumije vanjski odgovor, jedan pozadinski handler upravlja tijekom rada, a CRM pohranjuje mali dio informacije koji je njegovim korisnicima doista potreban. Rezultat pretvara URL web-mjesta iz pasivnog kontaktnog podatka u praktičan početak razgovora, bez pretvaranja skromne agencijske aplikacije u labirint integracija.

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.