Izradite istraživač poslovnih kontakata s izvornim PHP-om i API-jem za podatke o web-mjestu
Proračunska tablica puna web-stranica tvrtki izgleda kao koristan popis potencijalnih klijenata, ali je i dalje nekoliko koraka istraživanja udaljena od toga da bude upotrebljiva. Netko mora posjetiti svaku stranicu, identificirati organizaciju, pronaći javno dostupne kontaktne podatke i dosljedno zabilježiti rezultat. Taj je posao repetitivan, teško ga je pregledati i lako se izgubi kada ostane u karticama preglednika.
Ovaj vodič izrađuje Native PHP 8.3 aplikaciju naredbenog retka koja čita web-stranice iz CSV proračunske tablice, šalje svaku servisu podataka Website to Company i zapisuje atomarni CSV siguran za proračunske tablice koji sadržava podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Integracija koristi izvorni cURL, eksplicitno mapiranje domene, ograničene ponovne pokušaje, strukturirane pogreške, determinističke testove i ne koristi okvir.
Preduvjeti
Potreban vam je PHP 8.3 ili noviji s proširenjima cURL i JSON, Composer te CSV izvezen iz Excela, Google Sheetsa, LibreOfficea ili druge aplikacije za proračunske tablice. Ulaz mora sadržavati zaglavlje pod nazivom website i po jedan apsolutni HTTP ili HTTPS URL u svakom retku.
Aplikacija namjerno obrađuje retke slijedno. To je sporije od neograničenih paralelnih zahtjeva, ali malom timu pruža predvidljivu upotrebu memorije, jednostavno izvještavanje o neuspjesima i mnogo bolju kontrolu nad kvotama servisa.
Dobijte pristup i kopirajte token servisa
- Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
- Otvorite stranicu servisa podataka Website to Company.
- Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju servisa.
- Pronađite ploču Service token i kopirajte token ograničen na servis.
Ovaj servis nije bez tokena. Svaki zahtjev zahtijeva token putem parametra upita token. Ponovno generiranje tokena servisa opoziva prethodno aktivni token, stoga odmah nakon ponovnog generiranja rotirajte konfiguraciju aplikacije i nikada ne zadržavajte staru vrijednost kao pričuvnu opciju.
Potvrdite točan API ugovor
Integracija koristi točno ovaj zahtjev:
GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
Šalje dva parametra upita: token za autentikaciju i website za javnu web-stranicu tvrtke. Prije pisanja koda aplikacije stavite kopirani token u privremenu varijablu ljuske i uputite jedan minimalni zahtjev:
read -rsp "Service token: " WEBSITE_COMPANY_TOKEN
printf "\n"
curl --get \
--silent --show-error \
--data-urlencode "token=${WEBSITE_COMPANY_TOKEN}" \
--data-urlencode "website=https://example.com" \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"
unset WEBSITE_COMPANY_TOKEN
Pri stvarnoj provjeri upotrijebite web-stranicu koju smijete istraživati. Na zajedničkim sustavima radije pokrenite PHP klijent jer argumenti naredbenog retka mogu biti vidljivi drugim procesima.
Sada pohranite vjerodajnicu u datoteku okruženja koja je isključena iz kontrole verzija:
mkdir -p src bin tests
printf '%s\n' '.env.local' >> .gitignore
chmod 600 .env.local
# .env.local
export WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
Datoteka koristi sintaksu ljuske kako bi je vrijeme izvođenja moglo učitati bez dodavanja dotenv paketa. Produkcijske implementacije trebale bi unijeti istu varijablu putem upravitelja procesa, konfiguracije tajni kontejnera ili hosting platforme, umjesto kopiranja ove datoteke na poslužitelj.
Arhitektura i struktura projekta
Četiri su granice koje vrijedi očuvati čak i u malom alatu naredbenog retka:
- Naredba upravlja CSV ulazom, izlazom i izvještavanjem na razini retka.
- Klijent upravlja izgradnjom zahtjeva, klasifikacijom odgovora, pravilima ponovnih pokušaja i zapisivanjem u dnevnik.
- Mapper pretvara nesiguran vanjski JSON u stabilne vrijednosti aplikacije.
- Transportno sučelje omogućuje zamjenu cURL-a determinističkom imitacijom tijekom testova.
Izlazni stupci su status, website, company, contact, email, phone, people, error_code i error_message. Strukturirane API vrijednosti zadržavaju se kao sažeti JSON unutar svoje CSV ćelije umjesto da se nagađanjem uklapaju u nedokumentiranu shemu.
company-research/
├── .env.local
├── .gitignore
├── composer.json
├── src/App.php
├── bin/research.php
└── tests/WebsiteCompanyClientTest.php
Composer pruža samo automatsko učitavanje i PHPUnit. Produkcijski HTTP put ostaje izvorni PHP:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"files": ["src/App.php"]
},
"scripts": {
"test": "phpunit tests"
}
}
composer install
composer dump-autoload
Izradite transport, mapper i API klijent
Izradite src/App.php. Vremenska ograničenja povezivanja i ukupnog trajanja zahtjeva sprječavaju da zastoj krajnje točke zauzima proces neograničeno dugo. Preusmjeravanja su onemogućena kako se token ne bi proslijedio na neočekivanu lokaciju.
<?php
declare(strict_types=1);
namespace App;
use Closure;
use JsonException;
use RuntimeException;
final class Config
{
public static function token(): string
{
$value = getenv('WEBSITE_COMPANY_TOKEN');
if ($value === false || trim($value) === '') {
throw new RuntimeException('WEBSITE_COMPANY_TOKEN is not configured.');
}
return trim($value);
}
}
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function get(string $url): HttpResponse;
}
final class TransportException extends RuntimeException {}
final class SchemaException extends RuntimeException {}
final class CurlTransport implements Transport
{
public function get(string $url): HttpResponse
{
$headers = [];
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Could not initialize cURL.');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 2_000,
CURLOPT_TIMEOUT_MS => 15_000,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
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) {
$message = curl_error($handle);
$number = curl_errno($handle);
curl_close($handle);
throw new TransportException("cURL error {$number}: {$message}");
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $body);
}
}
final readonly class ResearchRecord
{
public function __construct(
public ?string $company,
public ?string $contact,
public ?string $email,
public ?string $phone,
public ?string $people,
) {}
}
final readonly class ResearchResult
{
private function __construct(
public bool $ok,
public ?ResearchRecord $record,
public ?string $errorCode,
public ?string $errorMessage,
) {}
public static function success(ResearchRecord $record): self
{
return new self(true, $record, null, null);
}
public static function failure(string $code, string $message): self
{
return new self(false, null, $code, $message);
}
}
final class CompanyMapper
{
public static function fromPayload(array $payload): ResearchRecord
{
if (array_is_list($payload)) {
throw new SchemaException('Expected a JSON object at the response root.');
}
return new ResearchRecord(
self::cell($payload, 'company'),
self::cell($payload, 'contact'),
self::cell($payload, 'email'),
self::cell($payload, 'phone'),
self::cell($payload, 'people'),
);
}
private static function cell(array $payload, string $key): ?string
{
if (!array_key_exists($key, $payload) || $payload[$key] === null) {
return null;
}
$value = $payload[$key];
if (is_string($value)) {
return trim($value);
}
if (is_scalar($value) || is_array($value)) {
return json_encode(
$value,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
throw new SchemaException("Unsupported value for {$key}.");
}
}
final class WebsiteCompanyClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';
private Closure $sleep;
private Closure $logger;
public function __construct(
private readonly string $token,
private readonly Transport $transport,
?callable $sleep = null,
?callable $logger = null,
) {
if (trim($token) === '') {
throw new RuntimeException('The service token cannot be empty.');
}
$this->sleep = Closure::fromCallable(
$sleep ?? static fn (int $milliseconds) => usleep($milliseconds * 1_000)
);
$this->logger = Closure::fromCallable(
$logger ?? static fn (string $line) => fwrite(STDERR, $line . PHP_EOL)
);
}
public function research(string $website): ResearchResult
{
$website = trim($website);
$parts = parse_url($website);
$scheme = is_array($parts) ? ($parts['scheme'] ?? null) : null;
$host = is_array($parts) ? ($parts['host'] ?? null) : null;
if (
filter_var($website, FILTER_VALIDATE_URL) === false ||
!in_array($scheme, ['http', 'https'], true) ||
!is_string($host)
) {
return ResearchResult::failure('invalid_website', 'Use an absolute HTTP or HTTPS URL.');
}
$url = self::ENDPOINT . '?' . http_build_query(
['token' => $this->token, 'website' => $website],
'',
'&',
PHP_QUERY_RFC3986
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get($url);
} catch (TransportException $exception) {
$this->log('transport_failure', $host, $attempt);
if ($attempt === 3) {
return ResearchResult::failure('transport_error', $exception->getMessage());
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$payload = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($payload)) {
throw new SchemaException('Expected a JSON object.');
}
return ResearchResult::success(CompanyMapper::fromPayload($payload));
} catch (JsonException | SchemaException $exception) {
return ResearchResult::failure('invalid_response', $exception->getMessage());
}
}
$retriable = $response->status === 429 || $response->status >= 500;
if ($retriable && $attempt < 3) {
$this->log('upstream_retry', $host, $attempt, $response->status);
($this->sleep)($this->retryDelay($response, $attempt));
continue;
}
return match (true) {
in_array($response->status, [401, 403], true) =>
ResearchResult::failure('authentication_error', 'The service token was rejected.'),
in_array($response->status, [400, 422], true) =>
ResearchResult::failure('request_rejected', 'The website request was rejected.'),
$response->status === 429 =>
ResearchResult::failure('rate_limited', 'The retry budget was exhausted.'),
$response->status >= 500 =>
ResearchResult::failure('upstream_error', 'The service remained unavailable.'),
default =>
ResearchResult::failure('http_error', "Unexpected HTTP status {$response->status}."),
};
}
return ResearchResult::failure('internal_error', 'Unexpected retry state.');
}
private function retryDelay(HttpResponse $response, int $attempt): int
{
$header = trim($response->headers['retry-after'] ?? '');
if ($header !== '' && ctype_digit($header)) {
return min(10, (int) $header) * 1_000;
}
return 250 * (2 ** ($attempt - 1)) + random_int(0, 100);
}
private function log(
string $event,
string $host,
int $attempt,
?int $status = null,
): void {
($this->logger)(json_encode([
'event' => $event,
'website_host' => $host,
'attempt' => $attempt,
'http_status' => $status,
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
}
}
Klijent ponovno pokušava kod kvarova transporta, HTTP odgovora 429 i kvarova poslužitelja. Ne pokušava ponovno autentikacijske ni validacijske neuspjehe jer ih drugi istovjetan zahtjev neće otkloniti. Numeričke vrijednosti Retry-After poštuju se uz ograničenje od deset sekundi; u suprotnom klijent koristi ograničeno eksponencijalno odgađanje s malim jitterom.
Pretvorite proračunsku tablicu u datoteku za pregled
Izradite bin/research.php. Svaki ulazni redak proizvodi izlazni redak, uključujući neuspjehe. Potencijalne formule proračunske tablice imaju prefiks apostrofa, a dovršena datoteka preimenuje se s privremene putanje tek nakon završetka obrade skupa.
<?php
declare(strict_types=1);
use App\Config;
use App\CurlTransport;
use App\WebsiteCompanyClient;
require dirname(__DIR__) . '/vendor/autoload.php';
if ($argc !== 3) {
fwrite(STDERR, "Usage: php bin/research.php input.csv output.csv\n");
exit(1);
}
[$script, $inputPath, $outputPath] = $argv;
$input = fopen($inputPath, 'rb');
$tempPath = $outputPath . '.part.' . getmypid();
$output = fopen($tempPath, 'wb');
if ($input === false || $output === false) {
fwrite(STDERR, "Could not open the input or temporary output file.\n");
exit(1);
}
$header = fgetcsv($input, null, ',', '"', '');
$normalized = is_array($header)
? array_map(static fn ($value) => strtolower(trim((string) $value)), $header)
: [];
$websiteColumn = array_search('website', $normalized, true);
if ($websiteColumn === false) {
fclose($input);
fclose($output);
unlink($tempPath);
fwrite(STDERR, "The input CSV needs a website header.\n");
exit(1);
}
$client = new WebsiteCompanyClient(Config::token(), new CurlTransport());
$columns = [
'status', 'website', 'company', 'contact', 'email',
'phone', 'people', 'error_code', 'error_message',
];
fputcsv($output, $columns, ',', '"', '', "\n");
$failures = 0;
while (($row = fgetcsv($input, null, ',', '"', '')) !== false) {
$website = trim((string) ($row[$websiteColumn] ?? ''));
$result = $client->research($website);
$record = $result->record;
if (!$result->ok) {
$failures++;
}
$values = [
$result->ok ? 'ok' : 'error',
$website,
$record?->company,
$record?->contact,
$record?->email,
$record?->phone,
$record?->people,
$result->errorCode,
$result->errorMessage,
];
$safe = array_map(static function ($value): string {
$text = (string) ($value ?? '');
return $text !== '' && str_contains('=+-@', $text[0])
? "'" . $text
: $text;
}, $values);
fputcsv($output, $safe, ',', '"', '', "\n");
}
fclose($input);
fclose($output);
if (!rename($tempPath, $outputPath)) {
unlink($tempPath);
fwrite(STDERR, "Could not publish the output file.\n");
exit(1);
}
fwrite(STDERR, "Finished with {$failures} failed row(s).\n");
exit($failures === 0 ? 0 : 2);
Izlazni kod 2 znači da je datoteka za pregled izrađena, ali sadržava retke s neuspjehom. Ta je razlika korisna u zakazanim poslovima: operativni alati mogu zadržati artefakt, a istodobno nekoga upozoriti da ga pregleda.
Testirajte bez pozivanja servisa
Lažni transport stavlja potpune odgovore u red čekanja, čineći mapiranje, vrijeme ponovnog pokušaja i neuspjehe koji se ne ponavljaju determinističkima. Izradite tests/WebsiteCompanyClientTest.php:
<?php
declare(strict_types=1);
use App\HttpResponse;
use App\Transport;
use App\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;
require_once dirname(__DIR__) . '/vendor/autoload.php';
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class WebsiteCompanyClientTest extends TestCase
{
public function testItMapsDocumentedBoundaryFields(): void
{
$transport = new FakeTransport([
new HttpResponse(200, [], json_encode([
'company' => 'Acme Ltd',
'contact' => ['name' => 'Sales'],
'email' => '[email protected]',
'phone' => null,
'people' => [['name' => 'Ada']],
], JSON_THROW_ON_ERROR)),
]);
$client = new WebsiteCompanyClient(
'test-token',
$transport,
static fn (int $milliseconds) => null,
static fn (string $line) => null,
);
$result = $client->research('https://example.test');
self::assertTrue($result->ok);
self::assertSame('Acme Ltd', $result->record?->company);
self::assertSame('{"name":"Sales"}', $result->record?->contact);
self::assertSame('[{"name":"Ada"}]', $result->record?->people);
}
public function testItHonorsNumericRetryAfter(): void
{
$delays = [];
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], '{"company":"Recovered"}'),
]);
$client = new WebsiteCompanyClient(
'test-token',
$transport,
static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
},
static fn (string $line) => null,
);
self::assertTrue($client->research('https://example.test')->ok);
self::assertSame(2, $transport->calls);
self::assertSame([1000], $delays);
}
public function testItDoesNotRetryAuthenticationFailures(): void
{
$transport = new FakeTransport([new HttpResponse(401, [], '')]);
$client = new WebsiteCompanyClient(
'bad-token',
$transport,
static fn (int $milliseconds) => null,
static fn (string $line) => null,
);
$result = $client->research('https://example.test');
self::assertFalse($result->ok);
self::assertSame('authentication_error', $result->errorCode);
self::assertSame(1, $transport->calls);
}
}
composer test
Pokrenite i provjerite obradu skupa
Izradite ulaznu datoteku s reprezentativnim javnim web-stranicama tvrtki:
website
https://example.com
https://www.example.org
Učitajte konfiguraciju okruženja i pokrenite naredbu:
set -a
. ./.env.local
set +a
php bin/research.php companies.csv company-research.csv
Pregledajte i uspješne retke i retke s neuspjehom. Prazna polja nisu automatski pogreške: javnoj web-stranici može jednostavno nedostajati određeni kontaktni podatak. Rezultat invalid_response razlikuje se jer pokazuje da se odgovor servisa nije mogao sigurno mapirati.
Sigurnost, opažljivost i implementacija
Token se pojavljuje u nizu upita jer je to ugovor autentikacije servisa. Nemojte zapisivati u dnevnik sastavljeni URL, cURL detaljan izlaz, sirove ispise iznimki ni URL-ove pristupa proxyju. Klijent zapisuje samo host web-stranice, događaj, pokušaj i status. Konfigurirajte infrastrukturne dnevnike da redigiraju parametar upita token kao dodatnu zaštitu.
Obrađujte samo javne web-stranice u legitimnu svrhu istraživanja. Dobivene kontaktne podatke tretirajte kao osjetljive poslovne podatke: ograničite dozvole izlazne datoteke, definirajte pravila zadržavanja i izbjegavajte slanje nešifriranih CSV datoteka e-poštom unutar tima.
Implementirajte s composer install --no-dev --classmap-authoritative, unesite WEBSITE_COMPANY_TOKEN putem okruženja izvođenja i pokrenite naredbu pod namjenskim računom operacijskog sustava. Zakazujte preklapajuće obrade skupova samo ako aktivni paket i opterećenje to dopuštaju. Slijedni je zadani način namjerno konzervativan.
Korisna operativna mjerenja uključuju obrađene retke, uspješne retke, svaki kod neuspjeha, broj ponovnih pokušaja, proteklo vrijeme obrade skupa i konačni izlazni kod. Izbjegavajte oznake koje sadržavaju pune web-stranice ili kontaktne vrijednosti u sustavima metrika; podaci visoke kardinalnosti skupi su i nepotrebno otkrivaju podatke.
Uobičajeni načini neuspjeha
- Autentikacijske pogreške: potvrdite da je token ograničen na servis aktivan. Ako je ponovno generiran, prethodni je token već opozvan.
- Odbijanje zahtjeva: pregledajte ulaz radi nedostajućih shema, neispravnih URL-ova ili web-stranica koje servis ne može prihvatiti. Te se neuspjehe ne smije ponovno pokušavati nepromijenjene.
- Ograničavanje stope: zadržite generirani CSV, smanjite učestalost pokretanja i kasnije nastavite s neuspjelim recima. Povećanje konkurentnosti obično će pogoršati ovo stanje.
- Neispravni odgovori: zadržite strukturiranu pogrešku, ali nemojte tiho ponovno tumačiti nepoznate oblike sadržaja. Ažurirajte mapper tek nakon provjere službene dokumentacije.
- Djelomični prekid procesa: objavljeni izlaz ostaje netaknut jer se rad najprije zapisuje u privremenu datoteku specifičnu za proces. Uklonite napuštene datoteke
.partputem usko ograničenog pravila održavanja.
Završni kontrolni popis za provjeru
- Aktivni je paket omogućen, a token servisa dolazi isključivo iz konfiguracije podržane varijablama okruženja.
- Zahtjev koristi
GET, točnu krajnju točku/v1/extractte parametre upitatokeniwebsite. - Vrijednosti tvrtke, kontakta, e-pošte, telefona i osoba mapiraju se na API granici.
- Vremenska ograničenja i ponovni pokušaji ograničeni su, dok se autentikacijski i validacijski neuspjesi nikada ne pokušavaju naslijepo ponovno.
- Dnevnici i testne datoteke ne sadržavaju stvarni token ni vraćene kontaktne podatke.
- PHPUnit prolazi, izlaz se čisto otvara u proračunskoj tablici, a neuspjeli retci ostaju dostupni za pregled.
Važan rezultat nije samo skripta koja poziva krajnju točku. To je mali, pouzdan cjevovod za istraživanje: nesigurni vanjski podaci ulaze kroz jednu zaštićenu granicu, svaki redak proračunske tablice dobiva odgovoran ishod, a operativni neuspjesi ostaju vidljivi bez uništavanja korisnog rada. To je razlika između API demonstracije i alata koji tim može sigurno ponovno pokrenuti sutra.