Туториали

Symfony: Build an AI-Powered Website to Company Contact List from Spreadsheets

Symfony: Изградете листа со контакти на компании од табеларни пресметки со помош на ВИ

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

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

Предуслови

Ќе ви требаат PHP 8.3 или понова верзија, Composer и постоечка Symfony апликација. Создадете мал проект и инсталирајте ги официјалните HTTP и console компоненти, заедно со поддршка за евидентирање и тестирање:

composer create-project symfony/skeleton contact-research
cd contact-research
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack

Влезот ќе биде обичен CSV со задолжително заглавие website. Извозот во CSV ја задржува оваа имплементација независна од добавувачите на табели и избегнува додавање XLSX парсер само за пренос.

website
https://example.com
https://www.example.org
company.example

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

  1. Регистрирајте се преку страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.
  2. Отворете ја страницата на услугата за податоци Website to Company.
  3. Изберете го достапниот Free, Plus или Pro план што одговара на вашиот обем на работа и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Пронајдете го панелот Service token и копирајте го токенот со опфат на услугата прикажан таму.

Оваа услуга бара токен. Повторното генерирање на токенот го поништува претходно активниот токен, па ротацијата на токени мора да ја ажурира секоја распоредена апликација што го користи. Чувајте го во конфигурација поддржана од околината, никогаш во PHP изворен код, тест-фикстури, слики од екранот или предадена конфигурација.

Потврдете го точниот барање

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

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

export WEBSITE_TO_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'

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

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

Зачувајте ги ингеренциите во Symfony конфигурацијата

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

WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

Поврзете ја вредноста од околината со конструкторот на клиентот во config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\ContactResearch\WebsiteToCompanyClient:
        arguments:
            $serviceToken: '%env(WEBSITE_TO_COMPANY_TOKEN)%'

Архитектура: издржлива серија, а не скриена јамка

Проектот намерно користи console команда наместо крајна точка за веб-прикачување. Истражувањето на многу страници може да го надмине рокот на барање во прелистувачот, додека командата може да работи под распоредувач, CI задача или процесен супервизор. Symfony Messenger станува вреден кога редовите мора да се распределат меѓу workers, но би додал семантика на редица без да го подобри овој умерен последователен работен процес.

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

src/
  Command/BuildContactResearchCommand.php
  ContactResearch/ApiFailure.php
  ContactResearch/CompanyResearch.php
  ContactResearch/WebsiteToCompanyClient.php
tests/
  ContactResearch/WebsiteToCompanyClientTest.php
var/
  imports/companies.csv
  exports/contact-research.csv

API границата е одговорна за транспорт, повторни обиди, JSON декодирање и валидација на одговорот. Командата е одговорна за CSV прашањата. Задржувањето на овие одговорности одвоени ги прави двата дела независно тестирабилни.

Мапирајте несигурен JSON во стабилен доменски објект

Документираните полиња на апликацијата се company, contact, email, phone и people. Поединечните вредности може да бидат скаларни или структурирани, па маперот прифаќа вредности компатибилни со JSON и серилизира сложени податоци наместо да нагаѓа недокументирана вгнездена шема.

<?php
// src/ContactResearch/ApiFailure.php
namespace App\ContactResearch;

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        string $message = 'Website research failed.',
    ) {
        parent::__construct($message);
    }
}

// src/ContactResearch/CompanyResearch.php
namespace App\ContactResearch;

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

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

        $hasKnownField = false;
        foreach ($fields as $field) {
            $hasKnownField = $hasKnownField || array_key_exists($field, $payload);
        }

        if (!$hasKnownField) {
            throw new ApiFailure(
                'invalid_response',
                null,
                'The response contained none of the expected fields.',
            );
        }

        return new self(
            self::normalize($payload['company'] ?? null),
            self::normalize($payload['contact'] ?? null),
            self::normalize($payload['email'] ?? null),
            self::normalize($payload['phone'] ?? null),
            self::normalize($payload['people'] ?? null),
        );
    }

    private static function normalize(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }

        if (is_string($value)) {
            $value = trim($value);

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

        if (is_int($value) || is_float($value) || is_bool($value)) {
            return (string) $value;
        }

        if (is_array($value)) {
            try {
                return json_encode(
                    $value,
                    JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
                );
            } catch (\JsonException) {
                throw new ApiFailure('invalid_response');
            }
        }

        throw new ApiFailure('invalid_response');
    }
}

Успешен HTTP статус не е доволен: неправилен JSON и payload-и на кои им недостига секое очекувано поле стануваат експлицитни неуспеси invalid_response.

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

Клиентот користи Symfony-ов HttpClientInterface, 20-секундно ограничување за неактивност и вкупно времетраење од 30 секунди. Повторно се обидува при транспортни неуспеси, HTTP 408, HTTP 429 и серверски грешки до три вкупни обиди. Автентикацијата и другите грешки на клиентот откажуваат веднаш.

<?php
// src/ContactResearch/WebsiteToCompanyClient.php
namespace App\ContactResearch;

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

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

    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly LoggerInterface $logger,
        private readonly string $serviceToken,
        ?callable $sleep = null,
    ) {
        if ($serviceToken === '' || $serviceToken === 'YOUR_SERVICE_TOKEN') {
            throw new \InvalidArgumentException('A service token is required.');
        }

        $this->sleep = $sleep === null
            ? static function (int $microseconds): void {
                usleep($microseconds);
            }
            : \Closure::fromCallable($sleep);
    }

    public function research(string $website): CompanyResearch
    {
        $host = (string) parse_url($website, PHP_URL_HOST);

        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->httpClient->request('GET', self::ENDPOINT, [
                    'query' => [
                        'token' => $this->serviceToken,
                        'website' => $website,
                    ],
                    'timeout' => 20.0,
                    'max_duration' => 30.0,
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                $this->logger->warning('Company research transport failure.', [
                    'host' => $host,
                    'attempt' => $attempt,
                    'exception_class' => $exception::class,
                ]);

                if ($attempt === 3) {
                    throw new ApiFailure(
                        'transport',
                        null,
                        'The research service could not be reached.',
                    );
                }

                $this->pause($attempt, null);
                continue;
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (\JsonException) {
                    throw new ApiFailure(
                        'invalid_response',
                        $status,
                        'The service returned invalid JSON.',
                    );
                }

                if (!is_array($payload)) {
                    throw new ApiFailure('invalid_response', $status);
                }

                return CompanyResearch::fromPayload($payload);
            }

            $retryable = $status === 408 || $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                $headers = $response->getHeaders(false);

                $this->logger->warning('Company research request will retry.', [
                    'host' => $host,
                    'status' => $status,
                    'attempt' => $attempt,
                ]);

                $this->pause($attempt, $headers['retry-after'][0] ?? null);
                continue;
            }

            if ($status === 401 || $status === 403) {
                throw new ApiFailure(
                    'authentication',
                    $status,
                    'The service token was rejected.',
                );
            }

            if ($status === 429) {
                throw new ApiFailure(
                    'rate_limited',
                    $status,
                    'The service rate limit remained active.',
                );
            }

            if ($status >= 400 && $status < 500) {
                throw new ApiFailure(
                    'request_rejected',
                    $status,
                    'The website request was rejected.',
                );
            }

            throw new ApiFailure(
                'service_unavailable',
                $status,
                'The research service remained unavailable.',
            );
        }

        throw new ApiFailure('service_unavailable');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            $seconds = min(5, max(0, (int) $retryAfter));
            ($this->sleep)($seconds * 1_000_000);

            return;
        }

        $microseconds = min(2_000_000, 250_000 * (2 ** ($attempt - 1)));
        ($this->sleep)($microseconds);
    }
}

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

Претворете ја табелата во листа што може да се прегледа

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

<?php
// src/Command/BuildContactResearchCommand.php
namespace App\Command;

use App\ContactResearch\ApiFailure;
use App\ContactResearch\WebsiteToCompanyClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'app:build-contact-research',
    description: 'Enrich a CSV of company websites.',
)]
final class BuildContactResearchCommand extends Command
{
    public function __construct(
        private readonly WebsiteToCompanyClient $client,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('input', InputArgument::REQUIRED, 'Source CSV')
            ->addArgument('output', InputArgument::REQUIRED, 'Result CSV');
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $inputPath = (string) $input->getArgument('input');
        $outputPath = (string) $input->getArgument('output');

        $source = @fopen($inputPath, 'rb');
        if ($source === false) {
            throw new \RuntimeException('The input CSV cannot be opened.');
        }

        $directory = dirname($outputPath);
        $temporaryPath = tempnam($directory, '.contact-research-');
        if ($temporaryPath === false) {
            fclose($source);
            throw new \RuntimeException('A temporary output cannot be created.');
        }

        $target = @fopen($temporaryPath, 'wb');
        if ($target === false) {
            fclose($source);
            @unlink($temporaryPath);
            throw new \RuntimeException('The temporary output cannot be opened.');
        }

        $failures = 0;

        try {
            $header = fgetcsv($source, 0, ',', '"', '\\');
            if (!is_array($header)) {
                throw new \RuntimeException('The input CSV is empty.');
            }

            $header = array_map(
                static fn (string $value): string =>
                    strtolower(trim($value, "\xEF\xBB\xBF \t\n\r\0\x0B")),
                $header,
            );

            $websiteColumn = array_search('website', $header, true);
            if ($websiteColumn === false) {
                throw new \RuntimeException(
                    'The input CSV needs a website column.',
                );
            }

            fputcsv($target, [
                'source_website', 'status', 'company', 'contact',
                'email', 'phone', 'people', 'error',
            ]);

            while (($row = fgetcsv($source, 0, ',', '"', '\\')) !== false) {
                $original = trim((string) ($row[$websiteColumn] ?? ''));

                try {
                    $website = $this->normalizeWebsite($original);
                    $result = $this->client->research($website);

                    $values = [
                        $website, 'ready', $result->company ?? '',
                        $result->contact ?? '', $result->email ?? '',
                        $result->phone ?? '', $result->people ?? '', '',
                    ];
                } catch (\InvalidArgumentException $exception) {
                    ++$failures;
                    $values = [
                        $original, 'invalid_input', '', '', '', '', '',
                        $exception->getMessage(),
                    ];
                } catch (ApiFailure $exception) {
                    ++$failures;
                    $values = [
                        $original, $exception->kind, '', '', '', '', '',
                        $exception->getMessage(),
                    ];
                }

                fputcsv($target, array_map($this->csvCell(...), $values));
            }
        } catch (\Throwable $exception) {
            fclose($source);
            fclose($target);
            @unlink($temporaryPath);
            throw $exception;
        }

        fclose($source);
        fclose($target);

        if (!@rename($temporaryPath, $outputPath)) {
            @unlink($temporaryPath);
            throw new \RuntimeException('The final output cannot be replaced.');
        }

        $output->writeln(sprintf(
            'Contact research written with %d failed row(s).',
            $failures,
        ));

        return $failures === 0 ? Command::SUCCESS : Command::FAILURE;
    }

    private function normalizeWebsite(string $website): string
    {
        if ($website === '') {
            throw new \InvalidArgumentException('Website is empty.');
        }

        if (!str_contains($website, '://')) {
            $website = 'https://' . $website;
        }

        if (filter_var($website, FILTER_VALIDATE_URL) === false) {
            throw new \InvalidArgumentException('Website is not a valid URL.');
        }

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

        if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
            throw new \InvalidArgumentException('Only HTTP websites are accepted.');
        }

        if (isset($parts['user']) || isset($parts['pass']) || $host === 'localhost') {
            throw new \InvalidArgumentException('Local or credentialed URLs are rejected.');
        }

        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
        ) {
            throw new \InvalidArgumentException('Private IP addresses are rejected.');
        }

        return $website;
    }

    private function csvCell(string $value): string
    {
        return preg_match('/^[=+\-@]/u', $value) === 1
            ? "'" . $value
            : $value;
    }
}

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

Тестирајте ја API границата без мрежни повици

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

<?php
// tests/ContactResearch/WebsiteToCompanyClientTest.php
namespace App\Tests\ContactResearch;

use App\ContactResearch\ApiFailure;
use App\ContactResearch\WebsiteToCompanyClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteToCompanyClientTest extends TestCase
{
    public function testItRetriesRateLimitAndMapsResponse(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', [
                'http_code' => 429,
                'response_headers' => ['retry-after: 1'],
            ]),
            new MockResponse(json_encode([
                'company' => 'Example Ltd',
                'contact' => 'Sales',
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex']],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $client = new WebsiteToCompanyClient(
            $http,
            new NullLogger(),
            'test-service-token',
            static function (int $microseconds): void {},
        );

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

        self::assertSame('Example Ltd', $result->company);
        self::assertSame('[{"name":"Alex"}]', $result->people);
        self::assertSame(2, $http->getRequestsCount());
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $http = new MockHttpClient(
            new MockResponse('', ['http_code' => 401]),
        );

        $client = new WebsiteToCompanyClient(
            $http,
            new NullLogger(),
            'rejected-token',
        );

        try {
            $client->research('https://example.com');
            self::fail('An ApiFailure was expected.');
        } catch (ApiFailure $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $http->getRequestsCount());
        }
    }
}
php bin/phpunit
mkdir -p var/imports var/exports
php bin/console app:build-contact-research \
  var/imports/companies.csv \
  var/exports/contact-research.csv

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

Внесете го WEBSITE_TO_COMPANY_TOKEN од управувачот со тајни на платформата за распоредување, наместо да испорачувате .env.local. За време на ротацијата, ажурирајте ја тајната и веднаш рестартирајте го процесот што ја користи, бидејќи повторното генерирање го поништува претходниот активен токен.

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

Следете ги броевите по конечен статус на ред, времетраење на командата, HTTP статус и број на повторни обиди. Примерот ги евидентира имињата на домаќини наместо целосни URL-адреси и никогаш не запишува токени или тела. Поставете одделни предупредувања за authentication, бидејќи повторувањето на серијата не може да поправи поништена ингеренција, како и за постојани резултати rate_limited или service_unavailable.

Извршувајте една серија одеднаш, освен ако активираниот план и оперативните ограничувања оправдуваат поголема конкурентност. Закажете ја командата под процесен супервизор со ограничување на времетраењето подолго од најлошиот случај на ограничени повторни обиди за серијата. Најпрво распоредете ги кодот на апликацијата и конфигурацијата, внесете го токенот, исчистете или загрејте го продукцискиот кеш на Symfony како што вообичаено бара вашето распоредување, а потоа извршете мало canary CSV пред обработка на целата датотека.

Вообичаени неуспеси и конечна проверка

  • Секој ред вели authentication: проверете ги токенот со опфат на услугата, активацијата на планот и тајната за распоредување. Ако токенот бил повторно генериран, заменете ја поништената вредност.
  • Редовите остануваат ограничени по стапка: намалете ја фреквенцијата на сериите или обемот на работа. Командата веќе ги почитува нумеричките вредности Retry-After во ограничување од пет секунди.
  • Одговорот е невалиден: прегледајте ги статусот и информациите за корелација достапни преку контролирана дијагностика, но не копирајте ингеренции или сурови лични податоци во споделени дневници.
  • Излезот не може да се запише: создадете ја одредишната папка и дајте му дозвола за запишување на корисникот под кој работи командата.
  • Веб-страница е одбиена локално: користете јавна HTTP или HTTPS URL-адреса без вградени ингеренции; по потреба кодирајте ги интернационализираните домени во нивната ASCII форма.

Пред да го сметате работниот процес за завршен, потврдете дека:

  • Токенот постои само во тајна конфигурација поддржана од околината.
  • Минималното GET барање успева со двата задолжителни параметри за барање.
  • Автоматизираните тестови поминуваат без пристап до вистинската услуга.
  • Canary CSV создава колони за компанија, контакт, е-пошта, телефон и лица.
  • Невалидните страници и API неуспесите остануваат видливи како редови што може да се прегледаат.
  • Дневниците содржат корисен контекст за статусот, но немаат токен, низа за барање или тело на одговор.
  • Процесот враќа неуспех кога на кој било ред му треба внимание.

Важниот резултат не е само збогатена табела. Тоа е контролиран истражувачки канал: влезовите се валидираат, надворешниот договор има една граница, минливите неуспеси добиваат ограничени повторни обиди, трајните неуспеси остануваат експлицитни и секој извезен ред е безбеден за отворање при човечки преглед. Тоа е она што го претвора удобниот API повик во продукциска функционалност на која мал тим може да ѝ верува.

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

Mihajlo

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