Туториали

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

Symfony CRM: Автоматско означување на потенцијални клиенти со увид во технолошкиот стек на веб-страницата

Веб-страницата на потенцијален клиент често открива повеќе отколку што некогаш би открило поле за слободни „белешки“. Мала агенција може да го користи нејзиниот технолошки стек за да забележи веројатна работа за одржување, да ги приспособи прашањата за откривање и да ги насочи можностите кон вистинскиот специјалист. Корисниот исход не е исцрпна листа на траги; туку концизно CRM поле како „WordPress 6.x (98%), WooCommerce (94%), PHP (88%).“

Овој туторијал ја гради таа функционалност во Symfony апликација. Новите потенцијални клиенти се скенираат асинхроно преку API-то Website Technology Detector, се нормализираат на границата на апликацијата и се ажурираат со читливо резиме. Имплементацијата вклучува ограничени тајм-аути, Messenger повторни обиди, структурирани состојби на неуспех, безбедно логирање и детерминистички тестови.

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

Пред да напишете интеграциски код, креирајте или пристапете до сметка:

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

Услугата бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар на барање token. Ќе ја користиме Bearer формата бидејќи Symfony ја поддржува директно и ги задржува акредитивите надвор од URL-адресите, историјата на прелистувачот, дневниците за пристап на проксија и аналитичките системи.

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

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

Интеграцијата повикува POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies со JSON објект што содржи url. Тестирајте го токенот без да го вклучите Symfony:

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

Не внесувајте вистински токен во историјата на школката на споделени машини или во CI дневници. Локална променлива на околината или извршувач на команди поддржан од тајни е побезбеден за рутинска дијагностика.

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

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

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

Изберете намерно мала архитектура

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

Ова додава редица, но компромисот вреди: создавањето потенцијални клиенти останува одзивно, привремените неуспеси нагоре по синџирот може повторно да се обидат, а истовременоста на работниците може независно да се ограничи. Doctrine transport поддржан од база на податоци е соодветен за мал CRM. Наменски брокер станува корисен само кога сообраќајот или оперативните барања го оправдуваат тоа.

Релевантната структура на проектот е:

  • src/Technology/WebsiteTechnologyClient.php за HTTP границата
  • src/Technology/DetectionResult.php за дефанзивно мапирање на одговорот
  • src/Message/EnrichLeadTechnology.php за пораката во редицата
  • src/MessageHandler/EnrichLeadTechnologyHandler.php за оркестрација
  • tests/Technology/WebsiteTechnologyClientTest.php за детерминистички тестови на transport-от

Инсталирајте и конфигурирајте Symfony компоненти

Примерот претпоставува PHP 8.3 или понова верзија, функционална Symfony апликација, Doctrine ORM и постоечки ентитет Lead со идентификатор и URL на веб-страница.

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

Додајте nullable полиња за резиме и грешка, како и статус, во Lead. Enum поддржан со низа спречува контролерите и работниците да измислуваат некомпатибилни вредности за статус:

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

Регистрирајте го токенот преку dependency injection и конфигурирајте ограничени Messenger повторни обиди:

# 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

Нормализирајте го API одговорот на границата

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

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

Маперот ги зачувува податоците за докази и пренасочувања за идни доменски одлуки, без да поставува потенцијално големи или нестабилни структури во резимето наменето за луѓе. Ако официјалната документација ја промени обвивката на одговорот, ова е единствената класа што треба да се промени.

Изградете HTTP клиент со ограничувања

Клиентот користи тајм-аут за неактивност од 5 секунди и вкупен буџет за барање од 15 секунди. Разликува трајни неуспеси од оние за кои може повторно да се обиде. Неуспесите при валидација и автентикација не смеат повторно да се обидуваат; ограничувањата на стапка, тајм-аутите и привремените серверски неуспеси може.

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

Испраќајте и обработувајте скенирања асинхроно

Пораката носи само идентификатор на база на податоци. Тоа ги одржува сериски обработените пораки мали и гарантира дека работникот го гледа тековниот URL наместо застарена снимка од ентитетот.

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

Обработувачот потврдува дека доставената вредност е обичен HTTP или HTTPS URL со име на домаќин. Бидејќи CRM-то не ја презема самата цел, надворешната услуга останува одговорна за безбедно испитување јавни веб-страници. Отфрлањето акредитиви и IP литерали сепак ја намалува случајната злоупотреба.

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

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

Тестирајте без повикување на живата услуга

MockHttpClient обезбедува детерминистички transport. Тестовите треба да ги потврдат и излезниот договор и класификацијата на неуспехот; никогаш не смеат да содржат продукциски токен.

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

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

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

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

Генерирајте и прегледајте ја Doctrine миграцијата локално, потврдете ја во репозиториумот и извршете doctrine:migrations:migrate --no-interaction при распоредувањето. Внесете го токенот пред да ги стартувате работниците. Рестартирајте ги работниците по промени во кодот или тајните, за долготрајните процеси да ги добијат новите контејнер и околина.

Вообичаените неуспеси најчесто се јасни: 401 или 403 укажува на недостасувачки, поништен или погрешен сервисен токен; 400 или 422 укажува на неприфатлив URL; 429 значи дека на планот или стапката на барања му треба внимание; повторените одговори 5xx припаѓаат во редиците за повторни обиди и неуспеси. Невалиден JSON или изменета форма на одговор треба видливо да не успее на границата, наместо тивко да произведе погрешни CRM податоци.

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

  • Планот за услугата е активен, а токенот со ограничен опсег е доставен преку конфигурација поддржана од околината.
  • Минимално автентицирано POST барање успева кон точната крајна точка за откривање.
  • Создавањето потенцијален клиент го потврдува во базата пред да ја испрати Messenger пораката.
  • Работникот запишува концизно резиме на технологии што ја зема предвид довербата.
  • Неуспесите при автентикација и валидација не се обидуваат повторно.
  • Тајм-аутите, ограничувањата на стапка и привремените серверски неуспеси добиваат ограничени повторни обиди.
  • Неуспешните пораки се набљудливи и обновливи преку transport-от за неуспеси.
  • Дневниците содржат оперативен контекст, но немаат акредитиви, payload-и со докази или чувствителни заглавија.
  • Тестовите поминуваат со MockHttpClient и никогаш не контактираат со живата услуга.

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

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

Mihajlo

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