Symfony: Pretvorite popise web-mjesta u pronicljivo istraživanje kontakata uz pomoć umjetne inteligencije
Proračunska tablica puna web-mjesta tvrtki izgleda korisno sve dok netko ne mora otvoriti svaki redak, tražiti podatke za kontakt i pretvoriti nedosljedne stranice u usporedivo istraživanje. Ovaj vodič izrađuje Symfony aplikaciju koja obavlja taj repetitivni posao, a pritom čovjek zadržava kontrolu nad rezultatom.
Dovršena naredba čita CSV izvezen iz proračunske tablice, šalje svako javno web-mjesto podatkovnoj usluzi Website to Company, preslikava vraćene podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama u domenski objekt te zapisuje CSV pogodan za pregled. Nastavlja prekinuta izvođenja, ograničava mrežnu aktivnost, ponavlja samo prolazne neuspjehe i sprječava da nepouzdane vrijednosti postanu formule u proračunskoj tablici.
Dobijte pristup i kopirajte token usluge
Ova usluga zahtijeva token ograničen na uslugu; u isporučenom API ugovoru ne postoji način rada bez tokena.
- Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
- Otvorite stranicu podatkovne usluge Website to Company.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu koji je ondje prikazan.
Ponovno generiranje tog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu pri implementaciji: odmah ažurirajte okruženje aplikacije, a zatim ponovno pokrenite svaki dugotrajni proces koji je predmemorirao staro okruženje.
Potvrdite točan API poziv
Integracija koristi GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija se isporučuje putem parametra upita token, dok javni URL tvrtke ide u website.
Za minimalni test upotrijebite privremenu varijablu ljuske kako token ne bi bio zalijepljen u samu naredbu:
read -rsp "Service token: " SERVICE_TOKEN
curl --get "https://ai.mihajlo.mk/api/website-to-company-data/v1/extract" \
--data-urlencode "token=${SERVICE_TOKEN}" \
--data-urlencode "website=https://example.com"
unset SERVICE_TOKEN
Uspješan odgovor trebao bi biti JSON. Njegove se točne vrijednosti mogu razlikovati ovisno o web-mjestu, pa će aplikacija validirati i normalizirati dokumentirana polja company, contact, email, phone i people umjesto da pretpostavlja da svako web-mjesto pruža svaku vrijednost.
Pohranite vjerodajnicu izvan kontrole izvornog koda
Izradite Symfony projekt i najprije instalirajte komponente prve strane:
composer create-project symfony/skeleton contact-research
cd contact-research
composer require symfony/http-client symfony/console symfony/dotenv psr/log
composer require --dev symfony/test-pack
Stavite vjerodajnicu u .env.local, koju Symfony projekti obično isključuju iz kontrole verzija:
WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
Stvarni token koristite samo u lokalnom ili implementiranom okruženju. Zadržite rezervirano mjesto u dokumentaciji, primjerima, fikturama i snimkama zaslona.
Odaberite namjerno malu arhitekturu
Ovaj projekt koristi sinkronu konzolnu naredbu umjesto Messengera. Za uobičajenu istraživačku proračunsku tablicu sekvencijalna je obrada jednostavnija za upravljanje, prirodno ograničava istodobnost i stvara upotrebljivu djelomičnu datoteku nakon svakog dovršenog retka. Red postaje vrijedan kada se uvozi moraju izvoditi istodobno ili neovisno o sesiji naredbe, ali uvodi i pitanja isporuke, idempotentnosti i koordinacije kvota.
Važne datoteke su:
src/Research/CompanyResearch.php: DTO na granici aplikacije i obrambeni preslikavač.src/Research/CompanyDataClient.php: HTTP prijenos, politika ponovnih pokušaja i klasifikacija neuspjeha.src/Command/ResearchWebsitesCommand.php: unos CSV-a, validacija, nastavljanje i izvoz.tests/Research/CompanyDataClientTest.php: deterministički HTTP testovi.
Ulaz je običan UTF-8 CSV sa zaglavljem website:
website
https://example.com
https://www.example.org
Preslikajte neizvjestan JSON na granici aplikacije
Ne dopustite da se slabo tipizirani udaljeni JSON proširi naredbom. Ovaj DTO čuva strukturirane vrijednosti kao JSON kada polje nije jednostavan niz, zadržava zbirku osoba i izričito predstavlja neuspjehe.
<?php
// src/Research/CompanyResearch.php
namespace App\Research;
final readonly class CompanyResearch
{
public function __construct(
public string $website,
public string $status,
public ?string $company,
public ?string $contact,
public ?string $email,
public ?string $phone,
public array $people,
public ?string $error,
) {
}
public static function fromPayload(string $website, array $payload): self
{
$people = $payload['people'] ?? [];
if (!is_array($people)) {
$people = [$people];
} elseif (!array_is_list($people)) {
$people = [$people];
}
return new self(
$website,
'ok',
self::text($payload['company'] ?? null),
self::text($payload['contact'] ?? null),
self::text($payload['email'] ?? null),
self::text($payload['phone'] ?? null),
array_values($people),
null,
);
}
public static function failure(
string $website,
string $status,
string $error,
): self {
return new self(
$website,
$status,
null,
null,
null,
null,
[],
$error,
);
}
private static function text(mixed $value): ?string
{
if (is_string($value)) {
$value = trim($value);
return $value === '' ? null : $value;
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_array($value)) {
return json_encode(
$value,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
);
}
return null;
}
}
Nedostajuća polja ostaju prazna umjesto da postanu izmišljene zadane vrijednosti. Preslikavač također izbjegava tvrditi nedokumentirane unutarnje strukture za company, contact ili people.
Izgradite ograničeni HTTP klijent koji je svjestan ponovnih pokušaja
Klijent dopušta ukupno tri pokušaja. Ponovno pokušava neuspjehe prijenosa i uobičajene prolazne statuse 408, 429, 500, 502, 503 i 504. Ne pokušava ponovno neuspjehe autentikacije ili validacije. Numeričke vrijednosti Retry-After poštuju se, ali su ograničene na 30 sekundi.
<?php
// src/Research/CompanyDataClient.php
namespace App\Research;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class CompanyDataClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $serviceToken,
private array $retryDelaysUs = [250_000, 1_000_000],
) {
}
public function research(string $website): CompanyResearch
{
$lastAttempt = count($this->retryDelaysUs);
for ($attempt = 0; $attempt <= $lastAttempt; ++$attempt) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'query' => [
'token' => $this->serviceToken,
'website' => $website,
],
'timeout' => 10.0,
'max_duration' => 20.0,
'headers' => ['Accept' => 'application/json'],
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
try {
return CompanyResearch::fromPayload(
$website,
$response->toArray(false),
);
} catch (DecodingExceptionInterface | \JsonException) {
return CompanyResearch::failure(
$website,
'invalid_response',
'The service returned invalid JSON.',
);
}
}
if ($this->isRetryable($status) && $attempt < $lastAttempt) {
$this->logger->warning('Company lookup will be retried.', [
'host' => parse_url($website, PHP_URL_HOST),
'http_status' => $status,
'attempt' => $attempt + 1,
]);
$this->pause($response, $this->retryDelaysUs[$attempt]);
continue;
}
return CompanyResearch::failure(
$website,
match ($status) {
401, 403 => 'authentication_error',
400, 422 => 'invalid_request',
429 => 'rate_limited',
default => $status >= 500
? 'upstream_error'
: 'http_error',
},
"The service returned HTTP {$status}.",
);
} catch (TransportExceptionInterface) {
if ($attempt < $lastAttempt) {
$this->logger->warning('Transport failure; lookup will be retried.', [
'host' => parse_url($website, PHP_URL_HOST),
'attempt' => $attempt + 1,
]);
usleep($this->retryDelaysUs[$attempt]);
continue;
}
return CompanyResearch::failure(
$website,
'transport_error',
'The service could not be reached within the retry budget.',
);
}
}
throw new \LogicException('Unreachable retry state.');
}
private function isRetryable(int $status): bool
{
return in_array($status, [408, 429, 500, 502, 503, 504], true);
}
private function pause(ResponseInterface $response, int $fallbackUs): void
{
$value = $response->getHeaders(false)['retry-after'][0] ?? null;
$delayUs = is_string($value) && ctype_digit($value)
? min((int) $value, 30) * 1_000_000
: $fallbackUs;
usleep($delayUs);
}
}
Primijetite što zapisi izostavljaju: token, potpuni URL zahtjeva, tijelo odgovora, e-poštu, telefon i podatke o osobama. Budući da se autentikacija nužno nalazi u nizu upita, ne koristite stvarne vjerodajnice s detaljnim HTTP praćenjem ili razvojnim profilerom koji bilježi odlazne URL-ove.
Povežite ubrizgavanje ovisnosti koje se oslanja na okruženje
Povežite argument konstruktora u config/services.yaml. Autopovezivanje pruža HTTP klijent i zapisivač, a okruženje pruža tajnu.
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$serviceToken: '%env(string:WEBSITE_COMPANY_TOKEN)%'
App\:
resource: '../src/'
Pretvorite proračunsku tablicu u popis za pregled koji se može nastaviti
Naredba zaključava izlaz tako da dva operatera ne mogu istodobno dodavati retke. Postojeći izlazni retci tretiraju se kao dovršeni, što ponovno pokretanje nakon prekida čini sigurnim. Svaki se redak odmah ispire.
Također neutralizira ćelije koje počinju s =, +, - ili @. To je važno jer je sadržaj udaljenih web-mjesta nepouzdan, a aplikacije za proračunske tablice mogu te prefikse tumačiti kao formule.
<?php
// src/Command/ResearchWebsitesCommand.php
namespace App\Command;
use App\Research\CompanyDataClient;
use App\Research\CompanyResearch;
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: 'Build a reviewable company contact-research CSV.',
)]
final class ResearchWebsitesCommand extends Command
{
private const COLUMNS = [
'website', 'status', 'company', 'contact',
'email', 'phone', 'people', 'error',
];
public function __construct(private CompanyDataClient $client)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('input', InputArgument::REQUIRED, 'Input CSV')
->addArgument('output', InputArgument::REQUIRED, 'Output 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("Cannot open {$inputPath}.");
}
$target = fopen($outputPath, 'c+b');
if ($target === false || !flock($target, LOCK_EX | LOCK_NB)) {
fclose($source);
throw new \RuntimeException("Cannot lock {$outputPath}.");
}
$errors = 0;
$processed = 0;
try {
$completed = $this->completedWebsites($target);
$header = fgetcsv($source, null, ',', '"', '');
if (!is_array($header)) {
throw new \RuntimeException('The input CSV is empty.');
}
$header = array_map(
static fn (mixed $value): string => trim((string) $value),
$header,
);
$websiteColumn = array_search('website', $header, true);
if ($websiteColumn === false) {
throw new \RuntimeException('The input needs a website header.');
}
fseek($target, 0, SEEK_END);
if (ftell($target) === 0) {
fputcsv($target, self::COLUMNS, ',', '"', '');
}
while (($row = fgetcsv($source, null, ',', '"', '')) !== false) {
$website = trim((string) ($row[$websiteColumn] ?? ''));
if ($website === '' || isset($completed[$website])) {
continue;
}
$result = $this->validPublicUrl($website)
? $this->client->research($website)
: CompanyResearch::failure(
$website,
'invalid_website',
'Expected a public HTTP or HTTPS domain URL.',
);
$this->writeResult($target, $result);
$completed[$website] = true;
++$processed;
if ($result->status !== 'ok') {
++$errors;
}
}
} finally {
fflush($target);
flock($target, LOCK_UN);
fclose($target);
fclose($source);
}
$output->writeln("Processed {$processed}; failures {$errors}.");
return $errors === 0 ? Command::SUCCESS : Command::FAILURE;
}
private function completedWebsites($target): array
{
rewind($target);
$completed = [];
$header = fgetcsv($target, null, ',', '"', '');
if (!is_array($header)) {
return [];
}
$column = array_search('website', $header, true);
if ($column === false) {
throw new \RuntimeException('Output CSV has an unexpected schema.');
}
while (($row = fgetcsv($target, null, ',', '"', '')) !== false) {
$website = (string) ($row[$column] ?? '');
$completed[ltrim($website, "'")] = true;
}
return $completed;
}
private function validPublicUrl(string $url): bool
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
return filter_var($url, FILTER_VALIDATE_URL) !== false
&& in_array($scheme, ['http', 'https'], true)
&& $host !== ''
&& str_contains($host, '.')
&& filter_var($host, FILTER_VALIDATE_IP) === false
&& !isset($parts['user'], $parts['pass']);
}
private function writeResult($target, CompanyResearch $result): void
{
$people = json_encode(
$result->people,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
);
$row = [
$result->website,
$result->status,
$result->company,
$result->contact,
$result->email,
$result->phone,
$people,
$result->error,
];
$row = array_map([$this, 'spreadsheetSafe'], $row);
if (fputcsv($target, $row, ',', '"', '') === false) {
throw new \RuntimeException('Could not write an output row.');
}
fflush($target);
}
private function spreadsheetSafe(mixed $value): string
{
$value = (string) ($value ?? '');
return preg_match('/^[=+\-@]/u', $value) === 1
? "'".$value
: $value;
}
}
Pokrenite je s nepromjenjivom ulaznom datotekom i namjenskom izlaznom putanjom:
mkdir -p var/imports var/research
php bin/console app:research-websites \
var/imports/companies.csv \
var/research/contact-research.csv
Izlaz koji nije nula pokazuje da je barem jedan redak proizveo strukturirani neuspjeh, dok uspješni i neuspješni retci ostaju dostupni za pregled. Da biste kasnije ponovno pokušali neuspješne retke, uklonite te određene retke iz kopije izlaza ili započnite novu izlaznu datoteku; time se izbjegava tiho dupliciranje zapisa istraživanja.
Testirajte ponašanje prijenosa bez pozivanja usluge
MockHttpClient čini ugovor determinističkim. Prvi test provjerava točnu metodu, krajnju točku, parametre upita i preslikavanje. Drugi dokazuje da se prolazan odgovor pokušava ponovno.
<?php
// tests/Research/CompanyDataClientTest.php
namespace App\Tests\Research;
use App\Research\CompanyDataClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class CompanyDataClientTest extends TestCase
{
public function testItSendsAndMapsTheDocumentedContract(): void
{
$http = new MockHttpClient(
function (string $method, string $url): MockResponse {
self::assertSame('GET', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
strtok($url, '?'),
);
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
self::assertSame('secret-test-token', $query['token']);
self::assertSame('https://example.com', $query['website']);
return new MockResponse(json_encode([
'company' => 'Example Company',
'contact' => 'General enquiries',
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Alex Example']],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
},
);
$result = (new CompanyDataClient(
$http,
new NullLogger(),
'secret-test-token',
[],
))->research('https://example.com');
self::assertSame('ok', $result->status);
self::assertSame('Example Company', $result->company);
self::assertSame('[email protected]', $result->email);
self::assertCount(1, $result->people);
}
public function testItRetriesAServiceUnavailableResponse(): void
{
$http = new MockHttpClient([
new MockResponse('{}', ['http_code' => 503]),
new MockResponse(
'{"company":"Recovered Company"}',
['http_code' => 200],
),
]);
$result = (new CompanyDataClient(
$http,
new NullLogger(),
'secret-test-token',
[0],
))->research('https://example.com');
self::assertSame('ok', $result->status);
self::assertSame('Recovered Company', $result->company);
self::assertSame(2, $http->getRequestsCount());
}
}
php bin/phpunit
Sigurno upravljajte njime u produkciji
Postavite WEBSITE_COMPANY_TOKEN putem spremišta tajni platforme za implementaciju, a ne datoteke produkcije predane u repozitorij. Procesu dajte pristup za čitanje samo direktoriju za uvoz i pristup za pisanje samo direktoriju za istraživanje. Sačuvajte taj direktorij ako vrijeme izvođenja koristi efemerne spremnike.
Pokrenite jedan proces po izlaznoj datoteci. Zaključavanje odbija slučajnu istodobnost, ali zasebne izlazne datoteke i dalje mogu istodobno trošiti istu dopuštenu kvotu usluge. Držite istodobnost usklađenom s aktiviranim planom bez pretpostavljanja nedokumentiranih kvota.
Pratite izlazni status naredbe i brojanja koja ispisuje. Upozorite na ponovljene retke authentication_error, rate_limited, transport_error ili upstream_error. Izbjegavajte bilježenje vraćenih kontakata: CSV je već kontrolirani zapis koji sadržava te podatke.
Uobičajeni načini neuspjeha
- Svaki redak prijavljuje neuspjeh autentikacije: potvrdite aktivaciju i zamijenite implementirani token. Ako je ponovno generiran, stari token više nije aktivan.
- Retci su ograničeni stopom: smanjite učestalost izvođenja ili istodobnost i pregledajte aktivni plan. Klijent već poštuje ograničene numeričke smjernice za ponovni pokušaj.
- Izlaz se odmah preskače: ista web-mjesta već postoje u toj izlaznoj datoteci. Odaberite novu datoteku kada započinjete novu snimku istraživanja.
- Web-mjesto se odbija lokalno: navedite potpuni javni URL domene
http://ilihttps://bez ugrađenih vjerodajnica. - Polja su prazna unatoč uspjehu: javno ih web-mjesto možda ne izlaže ili vraćeno polje možda nedostaje. Prazni podaci bolji su od izmišljene sigurnosti.
Završni kontrolni popis za provjeru
- Račun i plan Free, Plus ili Pro su aktivni.
- Token ograničen na uslugu pohranjen je samo u konfiguraciji koja se oslanja na okruženje.
- Zahtjev je
GETprema točnoj krajnjoj točki/v1/extracts parametrima upitatokeniwebsite. - Podaci o tvrtki, kontaktu, e-pošti, telefonu i osobama preslikani su na granici.
- Omogućeni su vremenska ograničenja, ograničeni ponovni pokušaji, rukovanje ograničenjem stope i strukturirani neuspjesi.
- Testovi prolaze bez vanjskih poziva.
- Produkcijski izlazni direktorij može se zapisivati i trajan je.
- Rezultirajući CSV otvara se kao popis pogodan za pregled, s vidljivim umjesto skrivenim neuspjesima.
Vrijedan rezultat nije samo veća proračunska tablica. To je popis istraživanja s podrijetlom, izričitom neizvjesnošću, obradom koja se može nastaviti i predvidljivim ponašanjem pri neuspjehu. Ta kombinacija pretvara repetitivan zadatak pretraživanja u integraciju kojoj mali tim može vjerovati, pregledati je i njome upravljati bez prepuštanja prosudbe automatizaciji.