Туториали

Symfony: Turn Website Lists into Reviewable Contacts with Company Data API

Symfony: Претворете ги листите на веб-страници во контакти за преглед со API за податоци за компании

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

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

Имплементацијата е наменета за PHP 8.3 или понова верзија и ги користи HTTP-клиентот од прва страна на Symfony, вбризгување зависности, поддршка за конзола и алатки за тестирање. Намерно избегнува пакет за читање Excel: повеќето апликации за табеларни пресметки можат да извезуваат CSV, а прифаќањето на тој едноставен разменски формат ја одржува оперативната површина мала.

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

Завршете го поставувањето на пристапот пред да пишувате интеграциски код:

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

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

Потврдете го HTTP договорот

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

Направете едно минимално барање пред да ја изградите групната обработка:

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'

Бидејќи акредитивот патува во стрингот за пребарување, користете HTTPS токму како што е прикажано. Конфигурирајте ги обратните проксија и системите за следење барања да не запишуваат целосни upstream URL-адреси или стрингови за пребарување.

Зачувајте го токенот надвор од контрола на изворниот код

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

WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

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

Подигнете го Symfony проектот

Во нов проект, инсталирајте ги HTTP, конзолските, логирачките и тест-компонентите на Symfony од прва страна:

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

Релевантната структура на проектот ќе биде:

contact-research/
├── config/services.yaml
├── src/Command/ResearchWebsitesCommand.php
├── src/CompanyData/CompanyResearch.php
├── src/CompanyData/ServiceFailure.php
├── src/CompanyData/WebsiteCompanyClient.php
└── tests/CompanyData/WebsiteCompanyClientTest.php

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

services:
    _defaults:
        autowire: true
        autoconfigure: true
        bind:
            string $websiteToCompanyToken: '%env(WEBSITE_TO_COMPANY_TOKEN)%'

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

Изберете граница што апсорбира неизвесни податоци

Командата е намерно синхрона. За вообичаената табела што ја користи фриленсер или мал тим, една команда е полесна за распоредување, набљудување и повторно извршување од редица. Многу голем или континуирано пристигнувачки обем на работа би го оправдал Symfony Messenger, една порака по веб-страница и трајно складирање на резултатите. Неговото додавање тука би создало повеќе состојби на неуспех без да го подобри основниот исход.

Границата на API има три одговорности: да направи автентицирано барање, да ги класифицира неуспесите и да ги мапира само документираните полиња од највисоко ниво. Не смее да расфрла претпоставки за облиците на одговорите низ командата.

Создадете src/CompanyData/CompanyResearch.php:

<?php

namespace App\CompanyData;

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

    public static function fromPayload(array $payload): self
    {
        $people = $payload['people'] ?? [];

        if (!is_array($people)) {
            $people = $people === null ? [] : [$people];
        }

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

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

        if (is_scalar($value)) {
            $text = trim((string) $value);

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

        if (is_array($value)) {
            $json = json_encode(
                $value,
                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
            );

            return $json === false ? null : $json;
        }

        return null;
    }
}

Овој мапер прифаќа скаларни или структурирани вредности без да измислува недокументирани вгнездени својства. Структурираните податоци за компанијата или контактот остануваат видливи како JSON во датотеката за преглед; отсутните или неупотребливите вредности стануваат празни ќелии.

Додадете типизиран интеграциски неуспех во src/CompanyData/ServiceFailure.php:

<?php

namespace App\CompanyData;

use RuntimeException;
use Throwable;

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

Изградете отпорен HTTP-клиент

Клиентот користи ограничени временски ограничувања и најмногу три обиди. Повторува по неуспеси во транспортот, HTTP 429 и привремени одговори од gateway или за достапност. Не повторува при погрешно форматиран влез, неуспеси во автентикацијата или произволни грешки на клиентот. Таа разлика е важна: повторувањето со поништен токен само троши време и го замаглува вистинскиот проблем.

Создадете src/CompanyData/WebsiteCompanyClient.php:

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

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

    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        private string $websiteToCompanyToken,
    ) {}

    public function extract(string $website): CompanyResearch
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', self::ENDPOINT, [
                    'query' => [
                        'token' => $this->websiteToCompanyToken,
                        'website' => $website,
                    ],
                    'timeout' => 10.0,
                    'max_duration' => 25.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    try {
                        return CompanyResearch::fromPayload(
                            $response->toArray(false)
                        );
                    } catch (DecodingExceptionInterface $e) {
                        throw new ServiceFailure(
                            'invalid_response',
                            'The service returned invalid JSON.',
                            $e
                        );
                    }
                }

                if (in_array($status, [400, 422], true)) {
                    throw new ServiceFailure(
                        'validation',
                        'The service rejected the website value.'
                    );
                }

                if (in_array($status, [401, 403], true)) {
                    throw new ServiceFailure(
                        'authentication',
                        'The service token was rejected.'
                    );
                }

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

                if (!$retryable || $attempt === 3) {
                    $kind = $status === 429
                        ? 'rate_limited'
                        : 'upstream_error';

                    throw new ServiceFailure(
                        $kind,
                        sprintf('The service returned HTTP %d.', $status)
                    );
                }

                $headers = $response->getHeaders(false);
                $retryAfter = $headers['retry-after'][0] ?? null;
                $delayMs = ctype_digit((string) $retryAfter)
                    ? min(5000, (int) $retryAfter * 1000)
                    : 250 * (2 ** ($attempt - 1));

                $this->logger->warning('Company lookup will be retried.', [
                    'host' => parse_url($website, PHP_URL_HOST),
                    'status' => $status,
                    'attempt' => $attempt,
                    'delay_ms' => $delayMs,
                ]);

                usleep($delayMs * 1000);
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new ServiceFailure(
                        'transport',
                        'The service could not be reached.',
                        $e
                    );
                }

                $delayMs = 250 * (2 ** ($attempt - 1));

                $this->logger->warning('Company lookup transport retry.', [
                    'host' => parse_url($website, PHP_URL_HOST),
                    'attempt' => $attempt,
                    'delay_ms' => $delayMs,
                ]);

                usleep($delayMs * 1000);
            }
        }

        throw new ServiceFailure('internal', 'Lookup attempts were exhausted.');
    }
}

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

Претворете извоз од табела во редица за преглед

Извезете го изворниот лист како UTF-8 CSV со колона именувана website. Командата задржува еден излезен ред по секој непразен влезен ред и евидентира ok, invalid_input или структуриран неуспех на услугата.

Создадете src/Command/ResearchWebsitesCommand.php:

<?php

namespace App\Command;

use App\CompanyData\ServiceFailure;
use App\CompanyData\WebsiteCompanyClient;
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:research-websites',
    description: 'Enrich a CSV containing a website column.'
)]
final class ResearchWebsitesCommand extends Command
{
    public function __construct(private WebsiteCompanyClient $client)
    {
        parent::__construct();
    }

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

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

        if ($inputPath === $outputPath) {
            $output->writeln('<error>Input and output must differ.</error>');
            return Command::INVALID;
        }

        $source = fopen($inputPath, 'rb');
        if ($source === false) {
            $output->writeln('<error>Cannot open input CSV.</error>');
            return Command::FAILURE;
        }

        $header = fgetcsv($source);
        $websiteIndex = is_array($header)
            ? array_search('website', $header, true)
            : false;

        if ($websiteIndex === false) {
            fclose($source);
            $output->writeln('<error>Missing website header.</error>');
            return Command::INVALID;
        }

        $temporaryPath = $outputPath . '.part';
        $target = fopen($temporaryPath, 'wb');

        if ($target === false) {
            fclose($source);
            $output->writeln('<error>Cannot open temporary output.</error>');
            return Command::FAILURE;
        }

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

        while (($row = fgetcsv($source)) !== false) {
            $website = trim((string) ($row[$websiteIndex] ?? ''));

            if (!$this->isPublicWebUrl($website)) {
                $this->write($target, [
                    $website, 'invalid_input', '', '', '', '', '',
                    'Expected an absolute HTTP or HTTPS website URL.',
                ]);
                continue;
            }

            try {
                $result = $this->client->extract($website);

                $this->write($target, [
                    $website,
                    'ok',
                    $result->company ?? '',
                    $result->contact ?? '',
                    $result->email ?? '',
                    $result->phone ?? '',
                    json_encode(
                        $result->people,
                        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
                    ) ?: '[]',
                    '',
                ]);
            } catch (ServiceFailure $e) {
                $this->write($target, [
                    $website, $e->kind, '', '', '', '', '', $e->getMessage(),
                ]);
            }
        }

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

        if (!rename($temporaryPath, $outputPath)) {
            $output->writeln('<error>Cannot publish output CSV.</error>');
            return Command::FAILURE;
        }

        $output->writeln(sprintf('Review file written to %s', $outputPath));
        return Command::SUCCESS;
    }

    private function isPublicWebUrl(string $website): bool
    {
        if (filter_var($website, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        $scheme = strtolower((string) parse_url($website, PHP_URL_SCHEME));

        return in_array($scheme, ['http', 'https'], true)
            && parse_url($website, PHP_URL_HOST) !== null;
    }

    private function write($stream, array $cells): void
    {
        fputcsv($stream, array_map(
            static function (mixed $value): string {
                $text = (string) $value;
                return preg_match('/^[=+\-@]/', $text) === 1
                    ? "'" . $text
                    : $text;
            },
            $cells
        ));
    }
}

Привремената датотека ги спречува потрошувачите да видат делумно запишан конечен CSV. Излезниот запишувач, исто така, ги неутрализира формулите за табеларни пресметки, бидејќи на јавната содржина на веб-страници не смее да ѝ се дозволи да создава извршливи ќелии во табеларни пресметки.

Извршете ја функционалноста со:

php bin/console app:research-websites var/import/websites.csv var/export/contact-research.csv

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

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

Создадете tests/CompanyData/WebsiteCompanyClientTest.php:

<?php

namespace App\Tests\CompanyData;

use App\CompanyData\ServiceFailure;
use App\CompanyData\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 testItMapsTheDocumentedFields(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('GET', $method);
                self::assertSame(
                    'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
                    $url
                );
                self::assertSame('test-token', $options['query']['token']);
                self::assertSame(
                    'https://example.com',
                    $options['query']['website']
                );

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

        $client = new WebsiteCompanyClient(
            $http,
            new NullLogger(),
            'test-token'
        );

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

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

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

        $client = new WebsiteCompanyClient(
            $http,
            new NullLogger(),
            'revoked-token'
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected ServiceFailure.');
        } catch (ServiceFailure $e) {
            self::assertSame('authentication', $e->kind);
            self::assertSame(1, $http->getRequestsCount());
        }
    }
}

Извршете го пакетот тестови со php bin/phpunit. Посебен тест на командата може да користи привремени CSV-датотеки и Symfony console tester, но најзначајната граница — надворешниот повик и неговото однесување при автентикација — веќе е детерминистичка тука.

Управувајте безбедно во продукција

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

Следете ги броевите на структурирани неуспеси по статус. Наглото зголемување на authentication обично укажува на недостасувачки, ротиран или поништен токен. Трајните редови rate_limited сугерираат дека треба да се намали големината или распоредот на групната обработка, или да се прегледа активниот план. invalid_response и повторените upstream_error заслужуваат истрага наместо неограничени повторувања.

Вообичаените оперативни неуспеси се едноставни:

  • Секој ред пријавува автентикација: проверете ја вбризганата променлива на околината и ажурирајте ја по повторното генерирање на токенот.
  • Командата пријавува дека недостига заглавие: преименувајте ја изворната колона точно во website и извезете го првиот ред како заглавија.
  • Редовите пријавуваат невалиден влез: користете апсолутни URL-адреси како https://example.com, а не само домени.
  • Многу редови се ограничени по стапка: зачувајте го излезот, намалете ја зачестеноста на барањата и подоцна повторно извршете ги само засегнатите веб-страници.
  • Останува застарена датотека .part: претходниот процес запрел пред објавувањето. Потврдете дека нема активна команда пред да ја замените со ново извршување.

Конечна контролна листа за верификација

  • Сметката и Free, Plus или Pro планот се активирани.
  • Токенот со опсег на услуга доаѓа од панелот Service token на страницата со документација.
  • Вистинскиот токен постои само во конфигурација поткрепена со променливи на околината.
  • Минималното GET барање успева со параметрите за пребарување token и website.
  • CSV има заглавие website чувствително на големи и мали букви и апсолутни URL-адреси.
  • Автоматизираните тестови поминуваат без контакт со активната услуга.
  • Командата произведува еден прегледлив ред со резултат или неуспех по секој непразен влезен ред.
  • Дневниците и проксијата не го откриваат токенот или целосната автентицирана URL-адреса.
  • Распоредувањето може да ги запише привремените и конечните излезни датотеки.

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

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

Mihajlo

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