Од PHP табела до список со контакти: Збогатете ги веб-страниците со податоци за компаниите
Табела со веб-страници на компании изгледа како листа на потенцијални клиенти сè додека некој не мора да го истражи секој ред. Имињата на компаниите се разликуваат, страниците за контакт се преместуваат, а општо сандаче лесно може да се помеша со адреса на конкретно лице.
Во ова упатство се изработува апликација во чист PHP 8.3 која ги збогатува податоците за тие веб-страници преку Website to Company data и создава CSV за преглед од човек. Таа ја зачувува неизвесноста, го бележи напредокот во SQLite и ги остава неуспешните барања видливи. Успешното извлекување значи „подготвено за преглед“, а не „потврден контакт“.
Добијте пристап и копирајте го токенот за услугата
- Регистрирајте се или најавете се.
- Отворете ја страницата за услугата Website to Company data и нејзините планови, изберете достапен план Free, Plus или Pro и завршете ја активацијата.
- Отворете ја официјалната документација. Пронајдете го панелот Service token и копирајте го токенот наменет за услугата.
За оваа услуга е потребен токен. Повторното генерирање го отповикува претходниот активен токен, па ажурирајте ја конфигурацијата во продукција пред повторно да ги стартувате работните процеси.
Точното барање е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract, со параметрите за пребарување token и website. За основен тест, заменете ги двете примерни вредности, вклучително и примерната веб-страница:
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'
Сега создајте .env во директориумот на проектот:
WEBSITE_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'
PHP не ја вчитува автоматски оваа датотека. Нашиот механизам за стартување ги извезува нејзините вредности. Бидејќи вчитувањето преку школка ја извршува нејзината содржина, треба да ја уредуваат само доверливи оператори.
Одржувајте ја архитектурата едноставна и погодна за опоравување
Користете PHP 8.3 со cURL, PDO SQLite, Composer и екстензиите што ги бара PHPUnit, вклучително DOM, XML, XMLWriter и mbstring. Создајте ги src/, tests/ и var/. Проектот има четири изворни датотеки: composer.json, src/App.php, run.php и tests/AppTest.php.
SQLite обезбедува трајни контролни точки без сервер за редици. Во даден момент само еден процес управува со листата. Ова е соодветно за хонорарен работник или мал тим; за повеќе реплики на апликацијата би била потребна заедничка координација.
Зачувајте го ова како composer.json:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"files": ["src/App.php"]
}
}
Исклучете ги .env, vendor/ и var/ од Git. Комитирајте ја генерираната Composer lock-датотека.
Изолирајте ги преносот и мапирањето на одговорот
Граничниот слој ги мапира само доставените категории за компанија, контакт, е-пошта, телефон и лица. Вгнездените вредности остануваат структуриран JSON; не претпоставуваме дека постојат недокументирани полиња за лица или адреси. Вредностите што недостигаат се бележат како предупредувања. Непрепознаена обвивка на одговорот предизвикува видлива грешка.
Зачувајте го следново како 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;
}
Нормализацијата на потеклото намерно ги отфрла патеките, параметрите за пребарување и фрагментите. Дупликатите со исто потекло делат еден запис за истражување. Одбивањето буквално наведени IP-адреси и акредитиви е правило за внес, а не доказ дека адресата е достапна преку јавен DNS. Оваа апликација се поврзува само со фиксниот API-домаќин.
Увезете, зачувајте контролни точки и извезете
Извезете ја изворната табела како UTF-8 CSV со една колона именувана website. Зачувајте ја како var/input.csv. Неважечките веб-страници остануваат видливи во излезот.
Зачувајте ја оваа CLI влезна точка како 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);
}
Повторните обиди имаат вкупно ограничување од три обиди и зачувано експоненцијално зголемување на времето на чекање со случајно отстапување. Грешка при автентикација и HTTP 429 ја паузираат целата листа сè додека операторот не ги реши ограничувањата за пристап или квота и не изврши resume. Проверете ги сите инструкции Retry-After добиени од серверот пред да продолжите.
Обидите се бележат пред испраќањето на барањата. Затоа, пад на системот може да потроши еден обид; пад по одговорот, но пред зачувувањето, може да предизвика уште едно извлекување. Контролните точки не можат да гарантираат наплата точно еднаш без договор за идемпотентност од надворешната услуга.
Тестирајте без да трошите барања
Зачувајте го tests/AppTest.php. Симулираниот пренос ги прави класификацијата, мапирањето и кодирањето на параметрите за пребарување детерминистички:
<?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');
}
}
Стартувајте и распоредете внимателно
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
Закажете work да се извршува секоја минута со истата околина и сервисна сметка. Заклучувањето спречува преклопување на извршувањата; повторните обиди на чекање се одложуваат додека не дојде време за нив. Пауза од една секунда ги ублажува наглите серии успешни барања, но не гарантира усогласеност со ограничувањата на кој било план.
Распоредете ја апликацијата надвор од јавниот коренски директориум за документи, инсталирајте ги продукциските зависности со composer install --no-dev --classmap-authoritative и зачувајте ја базата на податоци при новите изданија. Заштитете ги CSV-датотеките и резервните копии, ограничете го пристапот и поставете период на задржување.
Дневниците содржат хеширани идентификатори на записи, број на обиди и фиксни категории на грешки. Следете го бројот на неуспешни задачи и задачите што долго чекаат. Бидејќи податоците за автентикација се наоѓаат во параметар за пребарување, отстранувајте ги целосните URL-адреси на барањата од системите за следење на HTTP-сообраќајот и пријавување грешки.
За auth, проверете ги активацијата и промената на токенот. За quota, проверете ја искористеноста на планот пред да продолжите. За schema или invalid_json, споредете одговор што сте го прегледале приватно со официјалната документација; никогаш не измислувајте обвивка на одговорот. За преголеми одговори потребно е да се преиспита изрично поставеното ограничување од еден мегабајт.
Проверете ја листата за преглед
- Увезете дупликати со исто потекло и неважечка URL-адреса; потврдете дека дупликатите се отстранети и дека има ред означен како неважечки.
- Збогатете ги податоците за една вистинска компанија и споредете ја секоја мапирана категорија со нејзиниот одговор.
- Рестартирајте го работниот процес; завршените редови мора да останат завршени.
- Отворете го
var/review.csv; проверете ги предупредувањата, вгнездениот JSON и ќелиите со буквална содржина што наликува на формула.
Корисниот резултат е листа за истражување во која доказите и празнините се видливи. Задржете го човечкиот преглед меѓу извлекувањето и контактирањето: откриената адреса е појдовна точка за истражување, а не доказ за точност, сопственост или дозвола да се контактира некого.