Туториали

Build a Company Contact Researcher with Native PHP and Website Data API

Изградете истражувач за контакти на компании со изворен PHP и Website Data API

Табела полна со веб-страници на компании изгледа како корисна листа на потенцијални клиенти, но сè уште е неколку чекори на истражување далеку од тоа да биде употреблива. Некој мора да ја посети секоја страница, да ја идентификува организацијата, да пронајде јавни контакт-детали и доследно да го евидентира резултатот. Таа работа е повторлива, тешка за проверка и лесно се губи кога се чува во јазичиња на прелистувачот.

Ова упатство создава Native PHP 8.3 апликација за командна линија која чита веб-страници од CSV табела, ја испраќа секоја до услугата за податоци Website to Company и запишува атомска CSV-датотека безбедна за табели, што содржи податоци за компанија, контакт, е-пошта, телефон и лица. Интеграцијата користи вграден cURL, експлицитно мапирање на доменот, ограничени повторни обиди, структурирани грешки, детерминистички тестови и без рамка.

Предуслови

Потребни ви се PHP 8.3 или понов со екстензиите cURL и JSON, Composer и CSV извезен од Excel, Google Sheets, LibreOffice или друга апликација за табели. Влезот мора да содржи заглавие со име website и по една апсолутна HTTP или HTTPS URL-адреса во секој ред.

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

Добијте пристап и копирајте го токенот на услугата

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или најавете се на https://ai.mihajlo.mk/login.
  2. Отворете ја страницата на услугата за податоци Website to Company.
  3. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.

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

Потврдете го точниот API-договор

Интеграцијата го користи точно ова барање:

GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract

Испраќа два параметри за пребарување: token за автентикација и website за јавната веб-страница на компанијата. Пред да напишете код за апликацијата, ставете го копираниот токен во привремена променлива на школката и направете едно минимално барање:

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

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

Сега зачувајте ги ингеренциите во датотека со променливи на околината што е исклучена од контрола на верзии:

mkdir -p src bin tests

printf '%s\n' '.env.local' >> .gitignore
chmod 600 .env.local
# .env.local
export WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN

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

Архитектура и структура на проектот

Постојат четири граници што вреди да се зачуваат дури и во мала алатка за командна линија:

  • Командата е одговорна за CSV-влезот, излезот и известувањето на ниво на ред.
  • Клиентот е одговорен за составувањето на барањата, класификацијата на одговорите, политиката за повторни обиди и евидентирањето.
  • Маперот го претвора неизвесниот надворешен JSON во стабилни вредности на апликацијата.
  • Интерфејсот за транспорт го прави cURL заменлив со детерминистичка лажна имплементација за време на тестовите.

Излезните колони се status, website, company, contact, email, phone, people, error_code и error_message. Структурираните API-вредности се задржуваат како компактен JSON во нивната CSV-ќелија, наместо да се нагаѓаат во недокументирана шема.

company-research/
├── .env.local
├── .gitignore
├── composer.json
├── src/App.php
├── bin/research.php
└── tests/WebsiteCompanyClientTest.php

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

Изградете ги транспортот, маперот и API-клиентот

Создадете src/App.php. Временските ограничувања за поврзување и за вкупното барање спречуваат закочена крајна точка да го зафаќа процесот неограничено. Пренасочувањата се оневозможени за токенот да не се препраќа на неочекувана локација.

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

Клиентот повторно се обидува при неуспеси во транспортот, HTTP 429 одговори и неуспеси на серверот. Не се обидува повторно при неуспеси на автентикацијата или валидацијата, бидејќи друго идентично барање нема да ги поправи. Нумеричките вредности Retry-After се почитуваат со ограничување од десет секунди; во спротивно, клиентот користи ограничен експоненцијален повратен интервал со мало случајно отстапување.

Претворете ја табелата во датотека за преглед

Создадете bin/research.php. Секој влезен ред создава излезен ред, вклучувајќи ги и неуспесите. Потенцијалните формули во табели добиваат префикс со апостроф, а завршената датотека се преименува од привремена патека само откако ќе заврши групната обработка.

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

Излезниот код 2 значи дека датотеката за преглед е создадена, но содржи неуспешни редови. Таа разлика е корисна во закажани задачи: оперативните алатки можат да го задржат артефактот, а сепак да известат некого да го провери.

Тестирајте без повикување на услугата

Лажниот транспорт поставува во ред целосни одговори, со што мапирањето, времето на повторни обиди и неуспесите што не се повторуваат стануваат детерминистички. Создадете 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

Извршете и проверете ја групната обработка

Создадете влезна датотека со репрезентативни јавни веб-страници на компании:

website
https://example.com
https://www.example.org

Вчитајте ја конфигурацијата на околината и извршете ја командата:

set -a
. ./.env.local
set +a

php bin/research.php companies.csv company-research.csv

Прегледајте ги и успешните и неуспешните редови. Празните полиња не се автоматски грешки: на јавна веб-страница можеби едноставно недостига одреден контакт-детал. Резултатот invalid_response е различен бидејќи покажува дека одговорот на услугата не можел безбедно да се мапира.

Безбедност, набљудливост и распоредување

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

Обработувајте само јавни веб-страници за легитимна цел на истражување. Третирајте ги добиените контакт-податоци како чувствителни деловни податоци: ограничете ги дозволите за излезната датотека, дефинирајте правила за задржување и избегнувајте да испраќате нешифрирани CSV-датотеки по е-пошта низ тимот.

Распоредете со composer install --no-dev --classmap-authoritative, внесете WEBSITE_COMPANY_TOKEN преку околината за извршување и извршувајте ја командата под посебна сметка на оперативниот систем. Закажувајте преклопувачки групни обработки само ако активниот план и обемот на работа го дозволуваат тоа. Последователниот стандард намерно е конзервативен.

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

Вообичаени начини на неуспех

  • Грешки при автентикација: потврдете дека токенот ограничен на услугата е активен. Ако бил повторно генериран, претходниот токен веќе е поништен.
  • Одбивање на барање: проверете го влезот за недостасувачки шеми, неправилно форматирани URL-адреси или веб-страници што услугата не може да ги прифати. Овие неуспеси не треба непроменето да се повторуваат.
  • Ограничување на стапката: зачувајте го генерираниот CSV, намалете ја зачестеноста на повикувањето и подоцна продолжете со неуспешните редови. Зголемувањето на конкурентноста обично ќе ја влоши оваа состојба.
  • Невалидни одговори: задржете ја структурираната грешка, но не ги преинтерпретирајте тивко непознатите облици на товарот. Ажурирајте го маперот само по проверка на официјалната документација.
  • Делумен прекин на процесот: објавениот излез останува недопрен бидејќи работата прво се запишува во привремена датотека специфична за процесот. Отстранувајте напуштени .part датотеки преку тесно ограничена политика за одржување.

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

  • Активниот план е овозможен, а токенот на услугата доаѓа само од конфигурација поддржана од околината.
  • Барањето користи GET, точната крајна точка /v1/extract и параметрите за пребарување token и website.
  • Вредностите за компанија, контакт, е-пошта, телефон и лица се мапирани на API-границата.
  • Временските ограничувања и повторните обиди се ограничени, додека неуспесите на автентикацијата и валидацијата никогаш не се повторуваат слепо.
  • Логовите и тест-фикстурите не содржат вистински токен или вратени контакт-податоци.
  • PHPUnit поминува, излезот се отвора чисто во табела, а неуспешните редови остануваат достапни за преглед.

Важниот резултат не е само скрипта што повикува крајна точка. Тоа е мал, сигурен истражувачки цевковод: неизвесните надворешни податоци влегуваат преку една заштитена граница, секој ред во табелата добива одговорен исход, а оперативните неуспеси остануваат видливи без да се уништи корисната работа. Тоа е разликата помеѓу API-демонстрација и алатка што тимот може безбедно повторно да ја изврши утре.

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

Mihajlo

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