Туториали

Native PHP 8.3: Turn Website Lists into Reviewable Contact Research with AI

Native PHP 8.3: Претворете ги листите на веб-страници во прегледливо истражување на контакти со ВИ

Табела полна со веб-страници на компании изгледа како мала истражувачка задача сè додека некој не мора да ја отвори секоја страница, да пронајде корисни податоци за контакт и да ги одржи резултатите конзистентни. Овој туторијал ја заменува таа повторувачка работа со Native PHP 8.3 команда наменета за продукциска употреба. Таа чита CSV извезен од Excel или Google Sheets, ја збогатува секоја веб-страница преку услугата за податоци Website to Company и запишува редица за преглед што содржи податоци за компанија, контакт, е-пошта, телефон и лица.

Дизајнот намерно останува скромен: еден синхрон CLI процес, посветена API граница, експлицитни редови за неуспех, ограничени повторни обиди и PHPUnit тестови поткрепени со детерминистички лажен транспорт. Тоа е доволно архитектура за фриленсер, развивач или мал тим, без воведување база на податоци или редица пред да бидат потребни.

Добијте пристап и направете едно потврдено барање

Прво, регистрирајте сметка, или најавете се ако веќе имате. Отворете ја страницата на услугата Website to Company data, изберете го достапниот Free, Plus или Pro план и завршете ја активацијата.

Потоа, отворете ја официјалната документација за услугата. Пронајдете го панелот Service token и копирајте го неговиот токен ограничен на услугата. Оваа услуга не е без токен: на секое барање му е потребен тој акредитив во параметарот за барање token. Регенерирањето на токенот го поништува претходно активниот токен, затоа третирајте ја ротацијата како оперативна промена и ажурирајте ја секоја распоредена околина што го користи.

Точната API операција е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Испратете ја целната URL-адреса преку параметарот за барање website. Пред да го изградите увозникот, потврдете ја сметката и планот со едно минимално барање:

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"

Не ставајте вистински токен во историјата на школка на споделена машина. За проектот, создадете локална датотека .env што е исклучена од контрола на верзии:

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN

Зачувајте со commit соодветна .env.example што го содржи само местодржачот. Продукцијата треба да ја вметне истата променлива преку својата платформа за распоредување или менаџер за тајни, наместо да испорачува пополнета датотека.

Изберете архитектура за сериска обработка што го зачувува човечкиот преглед

Влезната датотека ќе има заглавие со име website и една веб-страница по ред. Командата ги проверува и нормализира тие вредности, ја повикува услугата последователно, го мапира одговорот на границата на апликацијата и запишува нов CSV по секој обид.

Последователната обработка е намерна. Таа создава предвидлив притисок врз барањата, ги прави неуспесите поради квота разбирливи и избегнува координирање на истовремени повторни обиди. Ако обемот на работа подоцна стане преголем за закажан процес, истиот клиент и мапер на записи може да стојат зад worker за редица.

Излезот ги задржува структурираните низи или објекти како JSON во CSV ќелиите. Исто така, додава колони research_status, review_status и error. Затоа API успехот не се претставува како одобрен контакт: лице сè уште го прегледува резултатот пред да го користи.

Користете го следниов распоред на проектот:

contact-research/
├── bin/research.php
├── src/Boundary.php
├── src/CurlTransport.php
├── src/WebsiteCompanyClient.php
├── tests/WebsiteCompanyClientTest.php
├── .env
├── .env.example
└── composer.json

Инсталирајте ја малата површина на зависности

Composer обезбедува вчитување на околината и потребниот извршувач на тестови. HTTP имплементацијата во време на извршување останува природен 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

Додајте ги .env, генерираните истражувачки датотеки и сите привремени CSV извези во .gitignore. Податоците за контакт ги заслужуваат истите контроли за пристап и одлуки за задржување како и другите деловни записи, дури и кога нивниот извор е јавна веб-страница.

Дефинирајте ги HTTP и доменските граници

Граничните објекти спречуваат неизвесен надворешен JSON да се рашири низ апликацијата. Документираните полиња се мапираат експлицитно, додека недостасувачките вредности стануваат празни низи, а вгнездените вредности стануваат 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
        );
    }
}

Користете природен cURL со строги временски ограничувања

Транспортот ја поседува мрежната механика, а не деловните одлуки. TLS потврдата останува овозможена, пренасочувањата се оневозможени, а времињата за поврзување и вкупен одговор се ограничени. Само минливите грешки при поврзување, разрешување на име, истек на време, испраќање и примање се означени како погодни за повторен обид.

<?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);
    }
}

Додајте селективно повторување и структурирано евидентирање

Клиентот повторно се обидува при минливи неуспеси на транспортот, HTTP 429 одговори и серверски 5xx одговори. Не се обидува повторно при неуспеси на автентикација, авторизација, валидација или неформатиран JSON. Три вкупни обиди и ограничени одложувања спречуваат еден ред да ја блокира серијата на неодредено време.

<?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;
    }
}

Евидентаторот ги бележи веб-страницата, исходот, обидот и одложувањето, но никогаш токенот, URL-адресата на барањето, суровиот одговор или извлечените податоци за контакт. Во продукција, препратете ги овие JSON линии до собирачот на логови на платформата и поставете предупредувања за трајни неуспеси на автентикацијата, исцрпување на ограничувањето на стапката или невообичаен дел од неуспешни редови.

Изградете ја CSV командата за истражување

Командата прифаќа URL-адреси со или без шема, отфрла протоколи што не се HTTP и ги неутрализира префиксите на формули за табеларни пресметки во генерираните ќелии. Таа запишува експлицитен ред за грешка наместо тивко да изгуби компанија.

<?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);

Извршете ја со CSV извезување:

website
example.com
https://www.example.org
php bin/research.php companies.csv storage/contact-research.csv

Тестирајте без да правите мрежни барања

Лажниот транспорт ги прави детерминистички обработката на статусот, составувањето параметри, повторните обиди и мапирањето. Тој исто така потврдува дека неуспесите на автентикацијата не се повторуваат.

<?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

Намерно справувајте се со продукциски неуспеси

  • HTTP 401 или 403: потврдете ја активацијата и заменете го токенот на околината. Ако бил регенериран, старата вредност веќе е поништена. Не ги повторувајте овие одговори.
  • HTTP 400, 404 или 422: проверете ја вредноста на изворната веб-страница и тековната официјална документација. Овие укажуваат на проблем со барањето, а не на минлив прекин.
  • HTTP 429: клиентот почитува целобројна вредност Retry-After во ограничување од десет секунди, а потоа емитува неуспех rate_limit по последниот обид. Намалете ја зачестеноста на сериите или прегледајте го капацитетот на планот, наместо да создавате бесконечна јамка за повторни обиди.
  • HTTP 5xx или истекувања на време: ограниченото постепено одложување се справува со кратки прекини. Трајните неуспеси остануваат видливи во излезот и логовите.
  • Празни полиња: недостасувачките вредности за компанија или контакт се валидни гранични исходи, а не падови на парсерот. Означете го редот за човечки преглед наместо да нагаѓате.

Распоредете ја командата со PHP 8.3, cURL, инсталирани Composer зависности, директориум за излез со дозвола за запишување и вметнат сервисен токен. Закажете ја со вообичаениот извршувач на задачи на хостот, спречете преклопувачки извршувања со можноста за заклучување на распоредувачот и задржете го излезот само онолку долго колку што бара деловниот тек на работа. Ротирајте го токенот со ажурирање на тајната, рестартирање или повторно распоредување на процесот и потврдување мала серија пред следното целосно извршување.

Конечна контролна листа за потврда

  1. Активирајте го наменетиот Free, Plus или Pro план и копирајте го токенот ограничен на услугата од страницата со документација.
  2. Потврдете ја точната GET крајна точка со параметрите за барање website и token.
  3. Чувајте го вистинскиот токен надвор од контрола на изворен код, фикстури, логови, снимки од екран и CSV излез.
  4. Извршете PHPUnit и потврдете дека ниту еден тест не пристапува до мрежата.
  5. Обработете мал CSV што содржи валидна URL-адреса, домен без шема и невалидна вредност.
  6. Отворете ја генерираната датотека и потврдете дека успешните редови се pending, неуспесите се експлицитни, а структурираните полиња остануваат читлив JSON.
  7. Прегледајте ги логовите за обиди и одложувања без откриени акредитиви или податоци за контакт.

Корисниот резултат не е само пополнета табела. Тој е истражувачки процес со искрена граница меѓу автоматското извлекување и човечкото расудување. API го врши повторувачкото читање; PHP обезбедува предвидливо однесување; а последната колона pending ја зачувува одлуката што сè уште му припаѓа на човек.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.