Native PHP 8.3: Pretvorite popise web-stranica u provjerljivo istraživanje kontakata uz AI
Proračunska tablica puna web-stranica tvrtki izgleda kao mali istraživački zadatak sve dok netko ne mora otvoriti svaku stranicu, pronaći korisne podatke za kontakt i održati rezultate dosljednima. Ovaj vodič zamjenjuje taj ponavljajući posao produkcijski osmišljenom Native PHP 8.3 naredbom. Čita CSV izvezen iz Excela ili Google Sheetsa, obogaćuje svaku web-stranicu putem usluge podataka Website to Company i zapisuje red za pregled koji sadržava podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama.
Dizajn namjerno ostaje skroman: jedan sinkroni CLI proces, namjenska API granica, eksplicitni redovi za neuspjehe, ograničeni ponovni pokušaji i PHPUnit testovi potkrijepljeni determinističkim lažnim transportom. To je dovoljno arhitekture za freelancera, programera ili mali tim bez uvođenja baze podataka ili reda čekanja prije nego što su potrebni.
Dobijte pristup i izvedite jedan provjereni zahtjev
Najprije registrirajte račun ili se prijavite ako ga već imate. Otvorite stranicu usluge Website to Company data, odaberite dostupni Free, Plus ili Pro plan i dovršite aktivaciju.
Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu. Ova usluga nije bez tokena: svaki zahtjev treba tu vjerodajnicu u parametru upita token. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga rotaciju tretirajte kao operativnu promjenu i ažurirajte svako implementirano okruženje koje ga koristi.
Točna API operacija je GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Ciljni URL pošaljite putem parametra upita website. Prije izrade uvoznika provjerite račun i plan jednim minimalnim zahtjevom:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "website=https://example.com" \
--header "Accept: application/json" \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"
Nemojte lijepiti stvarni token u povijest ljuske na zajedničkom računalu. Za projekt izradite lokalnu datoteku .env koja je isključena iz kontrole verzija:
WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN
Predajte odgovarajuću datoteku .env.example koja sadržava samo rezervirano mjesto. Produkcija bi trebala ubrizgati istu varijablu putem svoje platforme za implementaciju ili upravitelja tajni, umjesto isporuke popunjene datoteke.
Odaberite skupnu arhitekturu koja čuva ljudski pregled
Ulazna datoteka imat će zaglavlje pod nazivom website i jednu web-stranicu po retku. Naredba provjerava i normalizira te vrijednosti, sekvencijalno poziva uslugu, mapira odgovor na granici aplikacije i zapisuje novi CSV nakon svakog pokušaja.
Sekvencijalna obrada je namjerna. Stvara predvidljiv pritisak zahtjeva, čini neuspjehe kvote razumljivima i izbjegava koordiniranje istodobnih ponovnih pokušaja. Ako radno opterećenje kasnije postane preveliko za zakazani proces, isti klijent i mapper zapisa mogu stajati iza radnika reda čekanja.
Izlaz zadržava strukturirana polja ili objekte kao JSON unutar CSV ćelija. Dodaje i stupce research_status, review_status i error. Uspjeh API-ja stoga nije prikazan kao odobren kontakt: osoba i dalje pregledava rezultat prije njegove upotrebe.
Koristite ovaj raspored projekta:
contact-research/
├── bin/research.php
├── src/Boundary.php
├── src/CurlTransport.php
├── src/WebsiteCompanyClient.php
├── tests/WebsiteCompanyClientTest.php
├── .env
├── .env.example
└── composer.json
Instalirajte mali skup ovisnosti
Composer pruža učitavanje okruženja i potrebni pokretač testova. HTTP implementacija za vrijeme izvođenja ostaje izvorni cURL.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"classmap": ["src/"]
}
}
composer install
composer dump-autoload
mkdir -p bin src tests storage
Dodajte .env, generirane istraživačke datoteke i sva privremena CSV izvoženja u .gitignore. Podaci za kontakt zaslužuju iste kontrole pristupa i odluke o zadržavanju kao i drugi poslovni zapisi, čak i kada im je izvor javna web-stranica.
Definirajte HTTP i domenske granice
Granični objekti sprječavaju širenje nesigurnog vanjskog JSON-a kroz aplikaciju. Dokumentirana polja izričito su mapirana, dok nedostajuće vrijednosti postaju prazni nizovi, a ugniježđene vrijednosti postaju JSON.
<?php
// src/Boundary.php
declare(strict_types=1);
namespace Research;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
) {}
}
interface Transport
{
public function get(string $url, array $query): HttpResponse;
}
final class TransportFailure extends \RuntimeException
{
public function __construct(string $message, public readonly bool $retryable)
{
parent::__construct($message);
}
}
final class ApiFailure extends \RuntimeException
{
public function __construct(
public readonly ?int $status,
public readonly string $kind,
string $message,
) {
parent::__construct($message);
}
}
final readonly class ResearchRecord
{
public function __construct(
public string $website,
public string $company,
public string $contact,
public string $email,
public string $phone,
public string $people,
) {}
public static function fromPayload(string $website, array $payload): self
{
$data = isset($payload['data']) && is_array($payload['data'])
? $payload['data']
: $payload;
return new self(
$website,
self::cell($data['company'] ?? null),
self::cell($data['contact'] ?? null),
self::cell($data['email'] ?? null),
self::cell($data['phone'] ?? null),
self::cell($data['people'] ?? null),
);
}
private static function cell(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_scalar($value)) {
return trim((string) $value);
}
return json_encode(
$value,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
Koristite izvorni cURL s čvrstim vremenskim ograničenjima
Transport upravlja mrežnom mehanikom, a ne poslovnim odlukama. TLS provjera ostaje omogućena, preusmjeravanja su onemogućena, a vrijeme povezivanja i ukupnog odgovora ograničeno je. Samo prolazne pogreške povezivanja, razrješavanja imena, vremenskog ograničenja, slanja i primanja označene su kao pogodne za ponovni pokušaj.
<?php
// src/CurlTransport.php
declare(strict_types=1);
namespace Research;
final class CurlTransport implements Transport
{
public function get(string $url, array $query): HttpResponse
{
$headers = [];
$handle = curl_init($url . '?' . http_build_query($query));
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 3000,
CURLOPT_TIMEOUT_MS => 15000,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$body = curl_exec($handle);
if ($body === false) {
$number = curl_errno($handle);
$retryable = in_array($number, [
CURLE_COULDNT_RESOLVE_HOST,
CURLE_COULDNT_CONNECT,
CURLE_OPERATION_TIMEDOUT,
CURLE_SEND_ERROR,
CURLE_RECV_ERROR,
], true);
$message = curl_error($handle);
curl_close($handle);
throw new TransportFailure($message, $retryable);
}
$status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $body, $headers);
}
}
Dodajte selektivne ponovne pokušaje i strukturirano zapisivanje
Klijent ponavlja prolazne neuspjehe transporta, odgovore HTTP 429 i odgovore poslužitelja 5xx. Ne ponavlja neuspjehe autentifikacije, autorizacije, provjere valjanosti ni neispravnog JSON-a. Tri ukupna pokušaja i ograničena kašnjenja sprječavaju da jedan red neograničeno zaustavlja skupnu obradu.
<?php
// src/WebsiteCompanyClient.php
declare(strict_types=1);
namespace Research;
final class WebsiteCompanyClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';
private readonly \Closure $sleep;
private readonly \Closure $log;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?\Closure $sleep = null,
?\Closure $log = null,
) {
$this->sleep = $sleep ?? static fn(int $milliseconds) =>
usleep($milliseconds * 1000);
$this->log = $log ?? static fn(array $event) =>
fwrite(STDERR, json_encode($event, JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
public function research(string $website): ResearchRecord
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(self::ENDPOINT, [
'token' => $this->token,
'website' => $website,
]);
} catch (TransportFailure $failure) {
if (!$failure->retryable || $attempt === 3) {
throw new ApiFailure(null, 'transport', 'API transport failed');
}
$this->retry($website, $attempt, 2 ** ($attempt - 1));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$payload = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new ApiFailure(
$response->status,
'invalid_response',
'API returned invalid JSON'
);
}
if (!is_array($payload)) {
throw new ApiFailure(
$response->status,
'invalid_response',
'API response was not an object'
);
}
($this->log)([
'event' => 'research_succeeded',
'website' => $website,
'attempt' => $attempt,
]);
return ResearchRecord::fromPayload($website, $payload);
}
$transient = $response->status === 429 || $response->status >= 500;
if ($transient && $attempt < 3) {
$seconds = $response->status === 429
? $this->retryAfter($response->headers, 2 ** ($attempt - 1))
: 2 ** ($attempt - 1);
$this->retry($website, $attempt, $seconds);
continue;
}
$kind = match ($response->status) {
401, 403 => 'authentication',
429 => 'rate_limit',
400, 404, 422 => 'request',
default => $response->status >= 500 ? 'server' : 'remote',
};
throw new ApiFailure(
$response->status,
$kind,
"API request failed with HTTP {$response->status}"
);
}
throw new ApiFailure(null, 'internal', 'Retry loop ended unexpectedly');
}
private function retry(string $website, int $attempt, int $seconds): void
{
$seconds = min(10, max(1, $seconds));
($this->log)([
'event' => 'research_retry',
'website' => $website,
'attempt' => $attempt,
'delay_seconds' => $seconds,
]);
($this->sleep)($seconds * 1000);
}
private function retryAfter(array $headers, int $fallback): int
{
$value = $headers['retry-after'] ?? null;
return is_string($value) && ctype_digit($value)
? (int) $value
: $fallback;
}
}
Zapisivač bilježi web-stranicu, ishod, pokušaj i kašnjenje, ali nikada token, URL upita, sirovi odgovor ni izdvojene podatke za kontakt. U produkciji proslijedite te JSON retke platformskom sakupljaču zapisa i postavite upozorenja za trajne neuspjehe autentifikacije, iscrpljivanje ograničenja stope ili neuobičajen udio neuspjelih redaka.
Izradite CSV naredbu za istraživanje
Naredba prihvaća URL-ove sa shemom ili bez nje, odbacuje ne-HTTP protokole i neutralizira prefikse formula proračunskih tablica u generiranim ćelijama. Zapisuje eksplicitan red pogreške umjesto da tiho izgubi tvrtku.
<?php
// bin/research.php
declare(strict_types=1);
use Dotenv\Dotenv;
use Research\ApiFailure;
use Research\CurlTransport;
use Research\WebsiteCompanyClient;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$inputPath = $argv[1] ?? '';
$outputPath = $argv[2] ?? '';
if ($inputPath === '' || $outputPath === '') {
fwrite(STDERR, "Usage: php bin/research.php input.csv output.csv\n");
exit(2);
}
$token = $_ENV['WEBSITE_COMPANY_SERVICE_TOKEN'] ?? '';
if ($token === '') {
fwrite(STDERR, "WEBSITE_COMPANY_SERVICE_TOKEN is missing\n");
exit(2);
}
$input = fopen($inputPath, 'rb');
$output = fopen($outputPath, 'wb');
if ($input === false || $output === false) {
fwrite(STDERR, "Could not open an input or output file\n");
exit(2);
}
$header = fgetcsv($input, null, ',', '"', '');
$websiteColumn = is_array($header)
? array_search('website', array_map('trim', $header), true)
: false;
if ($websiteColumn === false) {
fwrite(STDERR, "Input CSV needs a website header\n");
exit(2);
}
fputcsv($output, [
'source_website', 'research_status', 'review_status',
'company', 'contact', 'email', 'phone', 'people', 'error',
], ',', '"', '');
$client = new WebsiteCompanyClient(new CurlTransport(), $token);
$normalize = static function (string $value): ?string {
$value = trim($value);
if ($value === '') {
return null;
}
if (!str_contains($value, '://')) {
$value = 'https://' . $value;
}
$parts = parse_url($value);
if ($parts === false
|| !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| empty($parts['host'])) {
return null;
}
return $value;
};
$safe = static fn(string $value): string =>
preg_match('/^[=+\-@]/', $value) === 1 ? "'" . $value : $value;
while (($row = fgetcsv($input, null, ',', '"', '')) !== false) {
$raw = (string) ($row[$websiteColumn] ?? '');
$website = $normalize($raw);
if ($website === null) {
fputcsv($output, [
$safe($raw), 'error', 'needs_input',
'', '', '', '', '', 'Invalid website URL',
], ',', '"', '');
fflush($output);
continue;
}
try {
$record = $client->research($website);
fputcsv($output, array_map($safe, [
$record->website, 'ok', 'pending',
$record->company, $record->contact, $record->email,
$record->phone, $record->people, '',
]), ',', '"', '');
} catch (ApiFailure $failure) {
fputcsv($output, [
$safe($website), 'error', 'needs_attention',
'', '', '', '', '',
$safe($failure->kind . ': ' . $failure->getMessage()),
], ',', '"', '');
}
fflush($output);
}
fclose($input);
fclose($output);
Pokrenite je nad CSV izvozom:
website
example.com
https://www.example.org
php bin/research.php companies.csv storage/contact-research.csv
Testirajte bez mrežnih zahtjeva
Lažni transport čini obradu statusa, izgradnju parametara, ponovne pokušaje i mapiranje determinističkima. Također provjerava da se neuspjesi autentifikacije ne ponavljaju.
<?php
// tests/WebsiteCompanyClientTest.php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use Research\ApiFailure;
use Research\HttpResponse;
use Research\Transport;
use Research\WebsiteCompanyClient;
final class WebsiteCompanyClientTest extends TestCase
{
public function testItMapsAResponseAndSendsTheRequiredQuery(): void
{
$fake = new FakeTransport([
new HttpResponse(200, json_encode(['data' => [
'company' => 'Example Ltd',
'contact' => ['name' => 'General enquiries'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Ada', 'role' => 'Director']],
])),
]);
$record = (new WebsiteCompanyClient(
$fake,
'test-token',
static fn(int $milliseconds) => null,
static fn(array $event) => null,
))->research('https://example.com');
self::assertSame('Example Ltd', $record->company);
self::assertSame('[email protected]', $record->email);
self::assertSame('test-token', $fake->calls[0]['token']);
self::assertSame('https://example.com', $fake->calls[0]['website']);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$fake = new FakeTransport([new HttpResponse(401, '{}')]);
$client = new WebsiteCompanyClient(
$fake,
'bad-token',
static fn(int $milliseconds) => null,
static fn(array $event) => null,
);
try {
$client->research('https://example.com');
self::fail('Expected ApiFailure');
} catch (ApiFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertCount(1, $fake->calls);
}
}
}
final class FakeTransport implements Transport
{
public array $calls = [];
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
$this->calls[] = $query;
return array_shift($this->responses);
}
}
vendor/bin/phpunit tests
Promišljeno upravljajte produkcijskim neuspjesima
- HTTP 401 ili 403: potvrdite aktivaciju i zamijenite token okruženja. Ako je ponovno generiran, stara je vrijednost već opozvana. Nemojte ponavljati te odgovore.
- HTTP 400, 404 ili 422: pregledajte vrijednost izvorišne web-stranice i trenutačnu službenu dokumentaciju. To ukazuje na problem sa zahtjevom, a ne na prolazni prekid rada.
- HTTP 429: klijent poštuje cjelobrojnu vrijednost
Retry-Afterunutar ograničenja od deset sekundi, a zatim nakon posljednjeg pokušaja javlja neuspjehrate_limit. Smanjite učestalost skupne obrade ili pregledajte kapacitet plana, umjesto stvaranja beskonačne petlje ponovnih pokušaja. - HTTP 5xx ili vremenska ograničenja: ograničeno odgađanje rješava kratkotrajne prekide. Trajni neuspjesi ostaju vidljivi u izlazu i zapisima.
- Prazna polja: nedostajuće vrijednosti tvrtke ili kontakta valjani su granični ishodi, a ne rušenja parsera. Označite red za ljudski pregled umjesto nagađanja.
Implementirajte naredbu s PHP-om 8.3, cURL-om, instaliranim Composer ovisnostima, direktorijem za izlaz s dozvolom pisanja i ubrizganim tokenom usluge. Zakažite je pomoću uobičajenog pokretača zadataka hosta, spriječite preklapajuća izvršavanja pomoću mogućnosti zaključavanja raspoređivača i zadržavajte izlaz samo onoliko dugo koliko poslovni tijek rada zahtijeva. Rotirajte token ažuriranjem tajne, ponovnim pokretanjem ili ponovnom implementacijom procesa te provjerom male skupne obrade prije sljedećeg potpunog pokretanja.
Završni kontrolni popis za provjeru
- Aktivirajte namjeravani Free, Plus ili Pro plan i kopirajte token ograničen na uslugu sa stranice dokumentacije.
- Provjerite točnu GET krajnju točku s parametrima upita
websiteitoken. - Držite stvarni token izvan kontrole izvornog koda, fixturea, zapisa, snimki zaslona i CSV izlaza.
- Pokrenite PHPUnit i potvrdite da nijedan test ne pristupa mreži.
- Obradite mali CSV koji sadržava valjani URL, domenu bez sheme i nevaljanu vrijednost.
- Otvorite generiranu datoteku i potvrdite da uspješni redovi imaju vrijednost
pending, da su neuspjesi eksplicitni te da strukturirana polja ostaju čitljiv JSON. - Pregledajte zapise za pokušaje i kašnjenja bez izloženih vjerodajnica ili podataka za kontakt.
Koristan rezultat nije samo popunjena proračunska tablica. To je istraživački proces s jasnom granicom između automatiziranog izdvajanja i ljudske prosudbe. API obavlja ponavljajuće čitanje; PHP osigurava predvidljivo ponašanje; a završni stupac pending čuva odluku koja i dalje pripada osobi.