Vodiči

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

Symfony: Izradite popis kontakata tvrtki sa web-stranica pokretan umjetnom inteligencijom iz proračunskih tablica

Proračunska tablica puna web-mjesta tvrtki izgleda kao koristan popis potencijalnih klijenata, ali zapravo je red nedovršenog istraživanja. Netko i dalje mora posjetiti svako web-mjesto, identificirati tvrtku, pronaći njezine javno dostupne kontaktne podatke i pretvoriti nedosljedne stranice u retke koji se mogu pregledati.

Ovaj vodič gradi taj tijek rada kao Symfony naredbu. Čita CSV izvezen iz proračunske tablice, poziva uslugu podataka Website to Company za svako web-mjesto, mapira odgovor u mali objekt domene i zapisuje obogaćeni CSV koji sadrži podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Neuspjela pretraživanja ostaju vidljiva kao strukturirani retci umjesto da nestanu u zapisniku.

Preduvjeti

Trebat će vam PHP 8.3 ili noviji, Composer i postojeća Symfony aplikacija. Izradite mali projekt i instalirajte službene HTTP i konzolne komponente, zajedno s podrškom za zapisivanje i testiranje:

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

Ulaz će biti običan CSV s obaveznim zaglavljem website. Izvoz u CSV zadržava ovu implementaciju neovisnom o dobavljačima proračunskih tablica i izbjegava dodavanje XLSX parsera samo radi prijenosa.

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

Dobijte pristup i kopirajte token usluge

  1. Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun.
  2. Otvorite stranicu usluge podataka Website to Company.
  3. Odaberite dostupni plan Free, Plus ili Pro primjeren svom opterećenju i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Ova usluga zahtijeva token. Ponovno generiranje tokena opoziva prethodno aktivni token, pa rotacija tokena mora ažurirati svaku implementiranu aplikaciju koja ga koristi. Čuvajte ga u konfiguraciji podržanoj varijablama okruženja, nikada u PHP izvornom kodu, testnim podacima, snimkama zaslona ili predanoj konfiguraciji.

Potvrdite točan zahtjev

API ugovor je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija koristi parametar upita token={serviceToken}, dok se ciljno web-mjesto navodi putem parametra upita website.

Izvršite jedan minimalni zahtjev prije pisanja integracijskog koda. Upotreba varijable okruženja sprječava spremanje samih vjerodajnica u tekst naredbe:

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

Nemojte uključivati opširan HTTP izlaz u zajedničkim zapisnicima jer nizovi upita mogu otkriti token.

Pohranite vjerodajnicu u Symfony konfiguraciju

Za lokalni razvoj dodajte rezerviranu vrijednost u .env.local. Ta datoteka treba ostati nepredana:

WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

Povežite vrijednost okruženja s konstruktorom klijenta u config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

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

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

Arhitektura: trajna skupna obrada, a ne skrivena petlja

Projekt namjerno koristi konzolnu naredbu umjesto krajnje točke za prijenos putem weba. Istraživanje mnogih web-mjesta može premašiti rok web-zahtjeva preglednika, dok naredba može raditi pod raspoređivačem, CI zadatkom ili nadzornikom procesa. Symfony Messenger postaje koristan kada retke treba raspodijeliti među radnicima, ali bi dodao semantiku reda bez poboljšanja ovog umjerenog sekvencijalnog tijeka rada.

Sekvencijalni zahtjevi također čine ponašanje kvote predvidljivim. Brzo istodobno slanje može pretvoriti oporavljivo ograničenje stope u stotine simultanih neuspjeha.

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

Granica API-ja upravlja prijenosom, ponovnim pokušajima, dekodiranjem JSON-a i provjerom odgovora. Naredba upravlja CSV pitanjima. Razdvajanje tih odgovornosti čini oba dijela neovisno testabilnima.

Mapirajte nesiguran JSON u stabilan objekt domene

Dokumentirana polja aplikacije su company, contact, email, phone i people. Pojedinačne vrijednosti mogu biti skalarne ili strukturirane, stoga mapper prihvaća JSON-kompatibilne vrijednosti i serijalizira složene podatke umjesto da nagađa nedokumentiranu ugniježđenu shemu.

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

Uspješan HTTP status nije dovoljan: neispravan JSON i tereti bez ijednog očekivanog polja postaju izričiti neuspjesi invalid_response.

Izradite ograničen HTTP klijent svjestan ponovnih pokušaja

Klijent koristi Symfonyjev HttpClientInterface, vremensko ograničenje neaktivnosti od 20 sekundi i ukupno trajanje od 30 sekundi. Ponovno pokušava prijenosne neuspjehe, HTTP 408, HTTP 429 i pogreške poslužitelja do ukupno tri pokušaja. Autentikacija i druge pogreške klijenta odmah ne uspijevaju.

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

Klijent nikada ne zapisuje tijela odgovora, nizove upita ni poruke prijenosnih iznimki. Oni mogu sadržavati osjetljive informacije, uključujući token parametra upita. Odgode ponovnih pokušaja su ograničene kako jedan redak ne bi neograničeno zadržavao skupnu obradu.

Pretvorite proračunsku tablicu u popis koji se može pregledati

Naredba normalizira gole domene na HTTPS, odbacuje neispravne i očito lokalne ciljeve te zapisuje jedan redak rezultata za svaki ulazni redak. Također štiti od ubrizgavanja formula proračunske tablice dodavanjem prefiksa ćelijama koje počinju s =, +, - ili @.

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

Privremena datoteka i završno preimenovanje sprječavaju čitatelje da vide djelomično zapisani izvoz. Izlazni kod različit od nule automatizaciji prijavljuje nepotpuno obogaćivanje, dok CSV i dalje čuva uspješne retke i kategorije neuspjeha na koje se može djelovati.

Testirajte granicu API-ja bez mrežnih poziva

MockHttpClient pruža deterministički lažni prijenos. Prvi test provjerava izgradnju upita, mapiranje i ponovni pokušaj nakon ograničenja stope. Drugi potvrđuje da se neuspjesi autentikacije ne pokušavaju ponovno.

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

Sigurnost, praćenje i implementacija

Ubrizgajte WEBSITE_TO_COMPANY_TOKEN iz upravitelja tajni platforme za implementaciju umjesto isporuke datoteke .env.local. Tijekom rotacije ažurirajte tajnu i odmah ponovno pokrenite proces koji je koristi jer ponovno generiranje opoziva prethodno aktivni token.

Ograničite vlasništvo ulazne datoteke i pristup izlazu: kontaktni podaci mogu biti javni na izvorištu, ali i dalje zaslužuju kontrolirano rukovanje kada se agregiraju. Primijenite odgovarajuću politiku zadržavanja i osigurajte da je namjeravani tijek obogaćivanja usklađen s primjenjivim obvezama privatnosti, kontaktnog obraćanja i uporabe podataka.

Pratite brojanja prema konačnom statusu retka, trajanje naredbe, HTTP status i broj ponovnih pokušaja. Primjer zapisuje nazive hostova umjesto punih URL-ova i nikada ne bilježi tokene ni tijela. Zasebno upozoravajte na authentication, jer ponavljanje skupne obrade ne može popraviti opozvanu vjerodajnicu, te na trajne rezultate rate_limited ili service_unavailable.

Pokrećite jednu skupnu obradu odjednom, osim ako aktivirani plan i operativna ograničenja opravdavaju veću konkurentnost. Zakažite naredbu pod nadzornikom procesa s ograničenjem trajanja duljim od najgoreg mogućeg slučaja ograničenih ponovnih pokušaja skupne obrade. Najprije implementirajte aplikacijski kod i konfiguraciju, ubrizgajte token, očistite ili zagrijte Symfonyjev produkcijski predmemorijski spremnik kako vaša implementacija uobičajeno zahtijeva, a zatim pokrenite mali probni CSV prije obrade cijele datoteke.

Uobičajeni neuspjesi i završna provjera

  • Svaki redak navodi autentikaciju: provjerite token ograničen na uslugu, aktivaciju plana i tajnu implementacije. Ako je token ponovno generiran, zamijenite opozvanu vrijednost.
  • Retci ostaju ograničeni stopom: smanjite učestalost skupne obrade ili opterećenje. Naredba već poštuje numeričke vrijednosti Retry-After unutar ograničenja od pet sekundi.
  • Odgovor je neispravan: pregledajte status i korelacijske informacije dostupne putem kontrolirane dijagnostike, ali nemojte kopirati vjerodajnice ili neobrađene osobne podatke u zajedničke zapisnike.
  • Izlaz nije moguće zapisati: izradite odredišni direktorij i dodijelite korisniku pod kojim se naredba izvršava dozvolu za pisanje.
  • Web-mjesto je lokalno odbijeno: upotrijebite javni HTTP ili HTTPS URL bez ugrađenih vjerodajnica; prema potrebi kodirajte internacionalizirane domene u njihovu ASCII obliku.

Prije nego što tijek rada proglasite dovršenim, potvrdite sljedeće:

  • Token postoji samo u tajnoj konfiguraciji podržanoj varijablama okruženja.
  • Minimalni GET zahtjev uspijeva s oba obavezna parametra upita.
  • Automatizirani testovi prolaze bez dosezanja stvarne usluge.
  • Probni CSV proizvodi stupce tvrtke, kontakta, e-pošte, telefona i osoba.
  • Neispravna web-mjesta i API neuspjesi ostaju vidljivi kao retci koji se mogu pregledati.
  • Zapisnici sadrže koristan kontekst statusa, ali ne token, niz upita ni tijelo odgovora.
  • Proces vraća neuspjeh kada bilo koji redak zahtijeva pozornost.

Važan rezultat nije samo obogaćena proračunska tablica. To je kontrolirani istraživački cjevovod: ulazi se provjeravaju, vanjski ugovor ima jednu granicu, prolazni neuspjesi dobivaju ograničene ponovne pokušaje, trajni neuspjesi ostaju izričiti, a svaki izvezeni redak siguran je za otvaranje radi ljudskog pregleda. To je ono što praktičan API poziv pretvara u produkcijsku značajku kojoj mali tim može vjerovati.

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.