PHP proračunska tablica u popis kontakata: Obogatite web-stranice podacima o tvrtkama
Proračunska tablica s web-stranicama tvrtki izgleda kao popis potencijalnih klijenata sve dok netko ne mora istražiti svaki redak. Nazivi tvrtki razlikuju se, stranice za kontakt mijenjaju lokaciju, a opću adresu e-pošte lako je zamijeniti za adresu određene osobe.
U ovom vodiču izradit ćete aplikaciju u izvornom PHP-u 8.3 koja obogaćuje podatke o tim web-stranicama putem usluge Website to Company data i izrađuje CSV za ljudsku provjeru. Aplikacija čuva informacije o nesigurnosti podataka, sprema kontrolne točke napretka u SQLite i prikazuje neuspjele zahtjeve. Uspješno izdvajanje znači „spremno za provjeru”, a ne „provjeren kontakt”.
Pristupite usluzi i kopirajte token usluge
- Registrirajte se ili se prijavite.
- Otvorite stranicu usluge Website to Company data i njezinih planova, odaberite dostupni plan Free, Plus ili Pro i dovršite aktivaciju.
- Otvorite službenu dokumentaciju. Pronađite ploču Service token i kopirajte token namijenjen toj usluzi.
Za ovu je uslugu potreban token. Ponovno generiranje opoziva prethodni aktivni token, stoga ažurirajte konfiguraciju u produkciji prije ponovnog pokretanja radnih procesa.
Točan zahtjev glasi GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract, s parametrima upita token i website. Za osnovni test zamijenite oba primjera vrijednosti, uključujući primjer web-stranice:
curl --get --connect-timeout 5 --max-time 25 \
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode 'website=https://example.com'
Sada izradite .env u direktoriju projekta:
WEBSITE_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'
PHP ne učitava ovu datoteku automatski. Naš pokretač izvozi njezine vrijednosti. Budući da učitavanje datoteke u ljusku izvršava njezin sadržaj, trebale bi je uređivati samo pouzdane osobe koje upravljaju sustavom.
Neka arhitektura bude jednostavna i omogućuje oporavak
Upotrijebite PHP 8.3 s cURL-om, PDO SQLiteom, Composerom i proširenjima potrebnima za PHPUnit, uključujući DOM, XML, XMLWriter i mbstring. Izradite src/, tests/ i var/. Projekt ima četiri izvorne datoteke: composer.json, src/App.php, run.php i tests/AppTest.php.
SQLite osigurava trajne kontrolne točke bez poslužitelja za red čekanja. Popisom u jednom trenutku upravlja jedan proces. To odgovara samostalnom stručnjaku ili malom timu; više replika aplikacije zahtijevalo bi zajedničku koordinaciju.
Spremite ovo kao composer.json:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"files": ["src/App.php"]
}
}
Isključite .env, vendor/ i var/ iz Gita. Uključite generiranu Composerovu datoteku zaključavanja u commit.
Odvojite prijenos i mapiranje odgovora
Ovaj sloj mapira samo dostavljene kategorije tvrtke, kontakta, e-pošte, telefona i osoba. Ugniježđene vrijednosti ostaju u strukturiranom JSON-u; ne pretpostavljamo postojanje nedokumentiranih polja za osobe ili adrese. Vrijednosti koje nedostaju bilježe se kao upozorenja. Nepoznata ovojnica odgovora uzrokuje vidljiv neuspjeh.
Spremite sljedeće kao src/App.php:
<?php
declare(strict_types=1);
final class ApiFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable = false,
) {
parent::__construct($kind);
}
}
final readonly class Reply
{
public function __construct(public int $status, public string $body) {}
}
interface Transport
{
public function get(string $url): Reply;
}
final class CurlTransport implements Transport
{
public function get(string $url): Reply
{
$body = '';
$oversized = false;
$curl = curl_init($url);
if ($curl === false) {
throw new ApiFailure('transport_setup');
}
curl_setopt_array($curl, [
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 25,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_WRITEFUNCTION => static function (
$handle, string $chunk
) use (&$body, &$oversized): int {
if (strlen($body) + strlen($chunk) > 1048576) {
$oversized = true;
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
$ok = curl_exec($curl);
$errno = curl_errno($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($ok === false) {
$transient = in_array($errno, [
CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT,
CURLE_OPERATION_TIMEDOUT, CURLE_RECV_ERROR,
CURLE_SEND_ERROR, CURLE_GOT_NOTHING,
], true);
throw new ApiFailure(
$oversized ? 'response_too_large' : 'transport',
!$oversized && $transient,
);
}
return new Reply($status, $body);
}
}
final class CompanyApi
{
public function __construct(
private Transport $transport,
private string $token,
) {
if ($token === '') {
throw new InvalidArgumentException('Service token missing.');
}
}
public function extract(string $website): array
{
$query = http_build_query([
'token' => $this->token, 'website' => $website,
], '', '&', PHP_QUERY_RFC3986);
$reply = $this->transport->get(
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract?'
. $query
);
if (in_array($reply->status, [401, 403], true)) {
throw new ApiFailure('auth');
}
if ($reply->status === 429) {
throw new ApiFailure('quota');
}
if ($reply->status < 200 || $reply->status >= 300) {
throw new ApiFailure('http_' . $reply->status,
in_array($reply->status, [408, 500, 502, 503, 504], true));
}
try {
$root = json_decode($reply->body, false, 64, JSON_THROW_ON_ERROR);
} catch (JsonException) {
throw new ApiFailure('invalid_json');
}
if (!$root instanceof stdClass) {
throw new ApiFailure('schema');
}
$out = [];
$warnings = [];
$recognized = false;
foreach (['company', 'contact', 'email', 'phone', 'people'] as $key) {
if (!property_exists($root, $key)) {
$out[$key] = null;
$warnings[] = "missing:$key";
continue;
}
$recognized = true;
$value = $root->$key;
if ($value !== null && !is_string($value)
&& !is_array($value) && !$value instanceof stdClass) {
$warnings[] = "type:$key";
$value = null;
}
if ($value === null || $value === '' || $value === []) {
$warnings[] = "empty:$key";
}
$out[$key] = $value;
}
if (!$recognized) {
throw new ApiFailure('schema');
}
$out['warnings'] = $warnings;
return $out;
}
}
function website(string $input): string
{
$input = trim($input);
if (!str_contains($input, '://')) {
$input = 'https://' . $input;
}
$parts = parse_url($input);
if (!is_array($parts)) {
throw new InvalidArgumentException('Invalid website.');
}
$scheme = strtolower($parts['scheme'] ?? '');
$host = strtolower($parts['host'] ?? '');
if (strlen($input) > 2048
|| !in_array($scheme, ['http', 'https'], true)
|| isset($parts['user']) || isset($parts['pass'])
|| isset($parts['port']) || !str_contains($host, '.')
|| filter_var($host, FILTER_VALIDATE_IP)
|| !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
throw new InvalidArgumentException('Invalid website.');
}
return "$scheme://$host";
}
function csvCell(mixed $value): string
{
$text = $value === null ? '' : (is_string($value)
? $value : json_encode($value, JSON_THROW_ON_ERROR));
return preg_match('/^[\x00-\x20]*[=+@-]/', $text)
? "'" . $text : $text;
}
Normalizacija izvorišta namjerno odbacuje putanje, upite i fragmente. Duplikati izvorišta dijele jedan zapis o istraživanju. Odbacivanje IP adresa navedenih doslovno i vjerodajnica pravilo je za ulazne podatke, a ne dokaz da je domena javno dostupna putem DNS-a. Ova se aplikacija povezuje samo s fiksnim API poslužiteljem.
Uvezite podatke, spremite kontrolne točke i izvezite rezultate
Izvezite izvornu proračunsku tablicu kao UTF-8 CSV s jednim stupcem nazvanim website. Spremite je kao var/input.csv. Nevaljane web-adrese ostaju vidljive u izlaznoj datoteci.
Spremite ovu ulaznu točku za CLI kao run.php:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
umask(0077);
try {
$dir = __DIR__ . '/var';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$lock = fopen($dir . '/worker.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
exit(75);
}
$db = new PDO('sqlite:' . $dir . '/research.sqlite', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec("CREATE TABLE IF NOT EXISTS jobs (
website TEXT PRIMARY KEY, state TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_at INTEGER NOT NULL DEFAULT 0, data TEXT, error TEXT
);
CREATE TABLE IF NOT EXISTS control (
id INTEGER PRIMARY KEY, blocked INTEGER NOT NULL
);
INSERT OR IGNORE INTO control VALUES (1, 0)");
$sql = static function (string $query, array $args = []) use ($db) {
$statement = $db->prepare($query);
$statement->execute($args);
return $statement;
};
$command = $argv[1] ?? '';
if ($command === 'import') {
$input = fopen($dir . '/input.csv', 'rb');
if (!$input) {
throw new InvalidArgumentException('Cannot open var/input.csv.');
}
$header = fgetcsv($input, 0, ',', '"', '');
if (is_array($header) && is_string($header[0])) {
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', $header[0]);
}
if ($header !== ['website']) {
throw new InvalidArgumentException('Expected one website column.');
}
while (($row = fgetcsv($input, 0, ',', '"', '')) !== false) {
if ($row === [null]) {
continue;
}
if (count($row) !== 1) {
throw new InvalidArgumentException('Unexpected column count.');
}
$value = trim((string) $row[0]);
if ($value === '') {
continue;
}
$state = 'pending';
$error = null;
try {
$value = website($value);
} catch (InvalidArgumentException) {
$state = 'invalid';
$error = 'invalid_url';
}
$sql('INSERT OR IGNORE INTO jobs (website,state,error)
VALUES (?,?,?)', [$value, $state, $error]);
}
fclose($input);
} elseif ($command === 'work') {
if ((int) $db->query('SELECT blocked FROM control')->fetchColumn()) {
throw new InvalidArgumentException(
'Paused: resolve authentication or quota, then run resume.'
);
}
$api = new CompanyApi(new CurlTransport(),
trim((string) getenv('WEBSITE_COMPANY_TOKEN')));
$sql("UPDATE jobs SET state='failed', error='attempt_limit'
WHERE state IN ('pending','retry') AND attempts >= 3");
while ($job = $sql("SELECT * FROM jobs
WHERE state IN ('pending','retry') AND next_at <= ?
AND attempts < 3 ORDER BY website LIMIT 1", [time()])->fetch()) {
$attempt = (int) $job['attempts'] + 1;
$sql('UPDATE jobs SET attempts=? WHERE website=?',
[$attempt, $job['website']]);
$state = 'review';
$error = null;
$data = null;
$next = 0;
try {
$data = json_encode($api->extract($job['website']),
JSON_THROW_ON_ERROR);
} catch (ApiFailure $failure) {
$error = $failure->kind;
$state = $failure->retryable && $attempt < 3
? 'retry' : 'failed';
$next = time() + (2 ** $attempt) + random_int(0, 3);
}
$sql('UPDATE jobs SET state=?,error=?,data=?,next_at=?
WHERE website=?',
[$state, $error, $data, $next, $job['website']]);
fwrite(STDOUT, json_encode([
'id' => substr(hash('sha256', $job['website']), 0, 12),
'state' => $state, 'attempt' => $attempt, 'error' => $error,
], JSON_THROW_ON_ERROR) . PHP_EOL);
if (in_array($error, ['auth', 'quota'], true)) {
$sql('UPDATE control SET blocked=1');
}
if ($error !== null) {
break;
}
sleep(1);
}
} elseif ($command === 'resume') {
$db->beginTransaction();
$sql("UPDATE jobs SET state='pending',attempts=0,next_at=0,error=NULL
WHERE error IN ('auth','quota')");
$sql('UPDATE control SET blocked=0');
$db->commit();
} elseif ($command === 'export') {
$output = fopen($dir . '/review.csv.tmp', 'wb');
if (!$output) {
throw new RuntimeException('Export unavailable.');
}
$fields = ['company','contact','email','phone','people','warnings'];
$write = static function (array $cells) use ($output): void {
if (fputcsv($output, array_map('csvCell', $cells),
',', '"', '') === false) {
throw new RuntimeException('Export write failed.');
}
};
$write(array_merge(['website','state','attempts','error'], $fields));
foreach ($db->query('SELECT * FROM jobs ORDER BY website') as $job) {
$data = json_decode($job['data'] ?? '{}', false, 64,
JSON_THROW_ON_ERROR);
$write(array_merge([
$job['website'], $job['state'],
(string) $job['attempts'], $job['error'],
], array_map(static fn($key) => $data->$key ?? null, $fields)));
}
if (!fflush($output) || !fclose($output)
|| !rename($dir . '/review.csv.tmp', $dir . '/review.csv')) {
throw new RuntimeException('Export publication failed.');
}
} else {
throw new InvalidArgumentException('Use import, work, export, or resume.');
}
fwrite(STDOUT, json_encode(['states' => $db->query(
'SELECT state,COUNT(*) AS count FROM jobs GROUP BY state'
)->fetchAll()], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $exception) {
fwrite(STDERR, ($exception instanceof InvalidArgumentException
? $exception->getMessage() : 'Command failed; inspect configuration and input.')
. PHP_EOL);
exit(1);
}
Za svaki su zahtjev dopuštena najviše tri pokušaja, a eksponencijalno vrijeme čekanja između ponovnih pokušaja, uz nasumično odstupanje, trajno se pohranjuje. Pogreška autentikacije i HTTP 429 zaustavljaju obradu cijelog popisa dok osoba koja upravlja sustavom ne riješi ograničenja pristupa ili kvote i ne pokrene resume. Prije nastavka provjerite eventualnu uputu Retry-After koju je poslužitelj dostavio.
Pokušaji se bilježe prije slanja zahtjeva. Zbog toga pad sustava može potrošiti jedan pokušaj; pad nakon odgovora, ali prije spremanja, također može uzrokovati još jedno izdvajanje. Kontrolne točke ne mogu jamčiti naplatu točno jednom bez ugovora o idempotentnosti s pružateljem usluge.
Testirajte bez trošenja zahtjeva
Spremite tests/AppTest.php. Simulirani prijenos osigurava determinističku klasifikaciju, mapiranje i kodiranje upita:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public string $url = '';
public function __construct(private Reply $reply) {}
public function get(string $url): Reply
{
$this->url = $url;
return $this->reply;
}
}
final class AppTest extends TestCase
{
public function testMappingAndQuery(): void
{
$fake = new FakeTransport(new Reply(200,
'{"company":{"label":"Example"},"email":["[email protected]"],"people":[]}'));
$data = (new CompanyApi($fake, 'YOUR_SERVICE_TOKEN'))
->extract('https://example.com');
parse_str((string) parse_url($fake->url, PHP_URL_QUERY), $query);
self::assertSame('YOUR_SERVICE_TOKEN', $query['token']);
self::assertSame('https://example.com', $query['website']);
self::assertInstanceOf(stdClass::class, $data['company']);
self::assertSame(['[email protected]'], $data['email']);
self::assertContains('missing:phone', $data['warnings']);
}
public function testFailureClassification(): void
{
foreach ([
[401, '', 'auth', false],
[429, '', 'quota', false],
[503, '', 'http_503', true],
[200, '{}', 'schema', false],
[200, 'broken', 'invalid_json', false],
] as [$status, $body, $kind, $retryable]) {
$api = new CompanyApi(
new FakeTransport(new Reply($status, $body)),
'YOUR_SERVICE_TOKEN'
);
try {
$api->extract('https://example.com');
self::fail('Expected failure.');
} catch (ApiFailure $failure) {
self::assertSame($kind, $failure->kind);
self::assertSame($retryable, $failure->retryable);
}
}
}
public function testNormalizationAndSpreadsheetSafety(): void
{
self::assertSame('https://example.com',
website('https://EXAMPLE.com/contact?tracking=discarded'));
self::assertSame("' \t=1+1", csvCell(" \t=1+1"));
$this->expectException(InvalidArgumentException::class);
website('http://127.0.0.1');
}
}
Pažljivo pokrenite i postavite aplikaciju
composer install
chmod 700 var
chmod 600 .env
vendor/bin/phpunit --bootstrap vendor/autoload.php tests
set -a
. ./.env
set +a
php run.php import
php run.php work
php run.php export
Zakažite pokretanje naredbe work svake minute s istim okruženjem i servisnim računom. Zaključavanje sprječava preklapanje pokretanja; ponovni pokušaji čekaju dok ne ispune uvjete za izvršavanje. Razmak od jedne sekunde ublažava nizove uspješnih zahtjeva, ali ne jamči usklađenost s ograničenjima bilo kojeg plana.
Postavite aplikaciju izvan javnog korijenskog direktorija za dokumente, instalirajte produkcijske ovisnosti s pomoću composer install --no-dev --classmap-authoritative i sačuvajte bazu podataka između izdanja. Zaštitite CSV datoteke i sigurnosne kopije, ograničite pristup i odredite razdoblje čuvanja.
Zapisnici sadrže raspršene identifikatore zapisa, broj pokušaja i fiksne kategorije pogrešaka. Pratite broj neuspjelih zadataka i zadatke koji dugo čekaju. Budući da se podaci za autentikaciju nalaze u parametru upita, uklonite cjelovite URL-ove zahtjeva iz sustava za praćenje HTTP prometa i prijavu pogrešaka.
Za auth provjerite aktivaciju i zamjenu tokena. Za quota prije nastavka provjerite potrošnju plana. Za schema ili invalid_json usporedite odgovor, pregledan u privatnom okruženju, sa službenom dokumentacijom; nikada nemojte izmišljati ovojnicu odgovora. Kod prevelikih odgovora potrebno je preispitati izričito postavljeno ograničenje od jednog megabajta.
Provjerite popis za pregled
- Uvezite duplicirana izvorišta i nevaljan URL; potvrdite uklanjanje duplikata i prisutnost nevaljanog retka.
- Obogatite podatke za jednu stvarnu tvrtku i usporedite svaku mapiranu kategoriju s odgovarajućim podacima u odgovoru.
- Ponovno pokrenite radni proces; dovršeni redci moraju ostati dovršeni.
- Otvorite
var/review.csv; pregledajte upozorenja, ugniježđeni JSON i ćelije čiji se sadržaj nalik formulama prikazuje kao doslovni tekst.
Korisni rezultat jest popis za istraživanje na kojem su vidljivi dokazi i nedostaci. Osigurajte ljudsku provjeru između izdvajanja podataka i uspostavljanja kontakta: pronađena adresa povod je za daljnju provjeru, a ne dokaz točnosti, vlasništva ili dopuštenja za kontaktiranje osobe.