Vodiči

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

Symfony: Pretvorite popise web-mjesta u kontakte za pregled pomoću API-ja za podatke o tvrtkama

Proračunska tablica puna web-mjesta tvrtki izgleda kao koristan popis potencijalnih klijenata, ali i dalje je nekoliko koraka udaljena od mogućnosti pregleda. Netko mora posjetiti svako web-mjesto, identificirati tvrtku, pronaći javne podatke za kontakt i dosljedno zabilježiti rezultat. Taj je posao spor, teško ga je nastaviti, a lako ga je obavljati različito od retka do retka.

Ovaj vodič izrađuje Symfony naredbu usmjerenu na produkciju koja prihvaća izvoz UTF-8 CSV-a, šalje svako web-mjesto servisu podataka Website to Company i zapisuje novi CSV koji sadrži podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Pojedinačni neuspjesi postaju retci za pregled umjesto da prekidaju cijelu obradu.

Implementacija cilja na PHP 8.3 ili noviji i koristi Symfonyjev HTTP klijent prve strane, ubrizgavanje ovisnosti, podršku za konzolu i uslužne programe za testiranje. Namjerno izbjegava paket za čitanje Excela: većina aplikacija za proračunske tablice može izvesti CSV, a prihvaćanje tog jednostavnog formata za razmjenu održava operativnu površinu malom.

Dobijte pristup i kopirajte token servisa

Dovršite postavljanje pristupa prije pisanja integracijskog koda:

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

Ovaj servis zahtijeva token. Njegova ponovna generacija opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje svakog implementiranog okruženja koje pokreće ovu integraciju. Nikada nemojte predati vrijednost u repozitorij, staviti je u fixture ili zalijepiti u zapisnik podrške.

Potvrdite HTTP ugovor

Točan je zahtjev GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija koristi parametar upita token, dok se javno web-mjesto dostavlja putem parametra upita website.

Pošaljite jedan minimalni zahtjev prije izrade skupne obrade:

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'

Budući da vjerodajnica putuje u nizu upita, upotrijebite HTTPS točno kako je prikazano. Konfigurirajte reverzne proxyje i sustave za praćenje zahtjeva tako da ne bilježe potpune uzvodne URL-ove ni nizove upita.

Pohranite token izvan kontrole izvornog koda

Za lokalni razvoj u Symfonyju stavite vjerodajnicu u .env.local, koji treba ostati nepredan:

WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

U produkciji ubrizgajte istu varijablu putem upravitelja tajnama platforme za hosting. Nemojte stavljati stvarnu vrijednost u predanu datoteku .env.

Pokrenite Symfony projekt

U novom projektu instalirajte Symfonyjeve komponente prve strane za HTTP, konzolu, 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

Relevantna struktura projekta bit će:

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

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

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

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

Odaberite granicu koja apsorbira neizvjesne podatke

Naredba je namjerno sinkrona. Za uobičajenu proračunsku tablicu koju koristi freelancer ili mali tim, jedna je naredba jednostavnija za implementaciju, nadzor i ponovno pokretanje od reda čekanja. Vrlo veliko ili kontinuirano pristizalo radno opterećenje opravdalo bi Symfony Messenger, jednu poruku po web-mjestu i trajnu pohranu rezultata. Njegovo dodavanje ovdje stvorilo bi više stanja neuspjeha bez poboljšanja osnovnog ishoda.

Granica API-ja ima tri odgovornosti: poslati autentificirani zahtjev, klasificirati neuspjehe i mapirati samo dokumentirana polja najviše razine. Ne smije raspršiti pretpostavke o oblicima odgovora kroz naredbu.

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

Ovaj mapper prihvaća skalarne ili strukturirane vrijednosti bez izmišljanja nedokumentiranih ugniježđenih svojstava. Strukturirani podaci o tvrtki ili kontaktu ostaju vidljivi kao JSON u datoteci za pregled; nedostajuće ili neupotrebljive vrijednosti postaju prazne ćelije.

Dodajte tipizirani neuspjeh integracije u 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);
    }
}

Izradite otporan HTTP klijent

Klijent koristi ograničena vremenska ograničenja i najviše tri pokušaja. Ponovno pokušava kod transportnih neuspjeha, HTTP-a 429 te privremenih odgovora pristupnika ili dostupnosti. Ne pokušava ponovno kod neispravnog unosa, neuspjeha autentikacije ili proizvoljnih pogrešaka klijenta. Ta je razlika važna: ponovno pokušavanje s opozvanim tokenom samo troši vrijeme i prikriva stvarni problem.

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

Token se nikada ne uključuje u zapisnike aplikacije. Čak je i web-mjesto svedeno na svoj host, čime se ograničava slučajno otkrivanje putanja ili parametara upita.

Pretvorite izvoz proračunske tablice u red za pregled

Izvezite izvornu tablicu kao UTF-8 CSV sa stupcem nazvanim website. Naredba zadržava jedan izlazni redak za svaki neprazan ulazni redak i bilježi ok, invalid_input ili strukturirani neuspjeh servisa.

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

Privremena datoteka sprječava korisnike da vide napola zapisani završni CSV. Izlazni zapisivač također neutralizira formule proračunskih tablica jer javni sadržaj web-mjesta ne smije moći stvarati izvršive ćelije proračunske tablice.

Pokrenite funkcionalnost ovime:

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

Testirajte ugovor bez mrežnih poziva

Symfonyjev MockHttpClient pruža deterministički transport. Testovi mogu provjeriti metodu, krajnju točku, parametre upita, mapiranje odgovora i put autentikacije koji se ne pokušava ponovno bez otkrivanja vjerodajnice.

Stvorite 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());
        }
    }
}

Pokrenite skup testova s php bin/phpunit. Zaseban test naredbe može koristiti privremene CSV datoteke i Symfonyjev tester konzole, ali ovdje je već deterministička najvažnija granica — vanjski poziv i njegovo ponašanje autentikacije.

Sigurno upravljajte njime u produkciji

Dodijelite izvršnom okruženju pristup za pisanje samo predviđenim direktorijima za uvoz i izvoz. Generirani CSV tretirajte kao potencijalno osjetljive poslovne podatke: ograničite pristup, odredite razdoblje zadržavanja i izbjegavajte ga priložiti široko dostupnim kanalima za razgovor ili javnim prijavama.

Pratite broj strukturiranih neuspjeha prema statusu. Nagli porast authentication obično upućuje na token koji nedostaje, koji je rotiran ili opozvan. Trajni retci rate_limited sugeriraju da treba smanjiti veličinu skupne obrade ili raspored, odnosno pregledati aktivni plan. invalid_response i ponovljeni upstream_error zahtijevaju istragu, a ne neograničene ponovne pokušaje.

Uobičajeni operativni neuspjesi jednostavni su:

  • Svaki redak prijavljuje autentikaciju: provjerite ubrizganu varijablu okruženja i ažurirajte je nakon ponovne generacije tokena.
  • Naredba prijavljuje zaglavlje koje nedostaje: preimenujte izvorni stupac točno u website i izvezite prvi redak kao zaglavlja.
  • Retci prijavljuju neispravan unos: koristite apsolutne URL-ove poput https://example.com, a ne gole domene.
  • Mnogi su retci ograničeni brzinom: sačuvajte izlaz, smanjite učestalost zahtjeva i kasnije ponovno pokrenite samo zahvaćena web-mjesta.
  • Ostala je zastarjela datoteka .part: prethodni se proces zaustavio prije objave. Potvrdite da nijedna naredba nije aktivna prije nego što je zamijenite novim pokretanjem.

Završni kontrolni popis za provjeru

  • Račun i plan Free, Plus ili Pro su aktivirani.
  • Token ograničen na servis dolazi s ploče Service token na stranici dokumentacije.
  • Stvarni token postoji samo u konfiguraciji podržanoj varijablama okruženja.
  • Minimalni GET zahtjev uspijeva s parametrima upita token i website.
  • CSV ima zaglavlje website osjetljivo na velika i mala slova te apsolutne URL-ove.
  • Automatizirani testovi prolaze bez kontakta s aktivnim servisom.
  • Naredba proizvodi jedan rezultat za pregled ili redak neuspjeha za svaki neprazan ulazni redak.
  • Zapisnici i proxyji ne otkrivaju token ni potpuni autentificirani URL.
  • Implementacija može zapisivati privremene i završne izlazne datoteke.

Korisni artefakt nije samo obogaćeni podatak. To je red za pregled s podrijetlom, ograničenim ponašanjem pri neuspjehu i dovoljno strukturom da osoba može s pouzdanjem donijeti sljedeću odluku. To je razlika između demonstracije API-ja i pouzdanog produkcijskog alata: sretan put samo je jedan redak, dok je stvarni dizajn sve ono što ostatak proračunske tablice održava razumljivim.

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.