Туториали

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

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

A spreadsheet full of company websites looks like a small research task until somebody has to open every site, find useful contact details, and keep the results consistent. This tutorial replaces that repetitive work with a production-minded Native PHP 8.3 command. It reads a CSV exported from Excel or Google Sheets, enriches each website through the Website to Company data service, and writes a review queue containing company, contact, email, phone, and people data.

The design stays deliberately modest: one synchronous CLI process, a dedicated API boundary, explicit failure rows, bounded retries, and PHPUnit tests backed by a deterministic fake transport. That is enough architecture for a freelancer, developer, or small team without introducing a database or queue before either is needed.

Get access and make one verified request

First, register an account, or sign in if you already have one. Open the Website to Company data service page, select the available Free, Plus, or Pro plan, and complete activation.

Next, open the official service documentation. Find the Service token panel and copy its service-scoped token. This service is not token-free: every request needs that credential in the token query parameter. Regenerating the token revokes the previously active token, so treat rotation as an operational change and update every deployed environment that uses it.

The exact API operation is GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Send the target URL through the website query parameter. Before building the importer, verify the account and plan with one minimal request:

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"

Do not paste a real token into shell history on a shared machine. For the project, create a local .env file that is excluded from version control:

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN

Commit a matching .env.example containing only the placeholder. Production should inject the same variable through its deployment platform or secret manager rather than shipping a populated file.

Choose a batch architecture that preserves human review

The input file will have a header named website and one website per row. The command validates and normalizes those values, calls the service sequentially, maps the response at the application boundary, and writes a new CSV after every attempt.

Sequential processing is intentional. It produces predictable request pressure, makes quota failures understandable, and avoids coordinating concurrent retries. If the workload later becomes too large for a scheduled process, the same client and record mapper can sit behind a queue worker.

The output retains structured arrays or objects as JSON inside CSV cells. It also adds research_status, review_status, and error columns. An API success is therefore not presented as an approved contact: a person still reviews the result before using it.

Use this project layout:

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

Install the small dependency surface

Composer supplies environment loading and the required test runner. The runtime HTTP implementation remains native 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

Add .env, generated research files, and any temporary CSV exports to .gitignore. Contact data deserves the same access controls and retention decisions as other business records, even when its source is a public website.

Define the HTTP and domain boundaries

The boundary objects prevent uncertain external JSON from spreading through the application. The documented fields are mapped explicitly, while missing values become empty strings and nested values become 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
        );
    }
}

Use native cURL with hard time limits

The transport owns network mechanics, not business decisions. TLS verification remains enabled, redirects are disabled, and connection and total response times are bounded. Only transient connection, name-resolution, timeout, send, and receive errors are marked retryable.

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

Add selective retry and structured logging

The client retries transient transport failures, HTTP 429 responses, and server-side 5xx responses. It does not retry authentication, authorization, validation, or malformed JSON failures. Three total attempts and capped delays prevent one row from stalling the batch indefinitely.

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

The logger records the website, outcome, attempt, and delay, but never the token, query URL, raw response, or extracted contact data. In production, forward these JSON lines to the platform’s log collector and alert on sustained authentication failures, rate-limit exhaustion, or an unusual proportion of failed rows.

Build the CSV research command

The command accepts URLs with or without a scheme, rejects non-HTTP protocols, and neutralizes spreadsheet-formula prefixes in generated cells. It writes an explicit error row instead of silently losing a company.

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

Run it against a CSV export:

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

Test without making network requests

A fake transport makes status handling, parameter construction, retries, and mapping deterministic. It also verifies that authentication failures are not retried.

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

Handle production failures deliberately

  • HTTP 401 or 403: confirm activation and replace the environment token. If it was regenerated, the old value is already revoked. Do not retry these responses.
  • HTTP 400, 404, or 422: inspect the source website value and the current official documentation. These indicate a request problem, not a transient outage.
  • HTTP 429: the client honors an integer Retry-After value within a ten-second cap, then emits a rate_limit failure after the final attempt. Reduce batch frequency or review plan capacity instead of creating an endless retry loop.
  • HTTP 5xx or timeouts: bounded backoff handles brief disruption. Persistent failures remain visible in the output and logs.
  • Empty fields: missing company or contact values are valid boundary outcomes, not parser crashes. Mark the row for human review rather than guessing.

Deploy the command with PHP 8.3, cURL, installed Composer dependencies, a writable output directory, and an injected service token. Schedule it with the host’s normal task runner, prevent overlapping executions with the scheduler’s locking facility, and retain output only as long as the business workflow requires. Rotate the token by updating the secret, restarting or redeploying the process, and verifying a small batch before the next full run.

Final verification checklist

  1. Activate the intended Free, Plus, or Pro plan and copy the service-scoped token from the documentation page.
  2. Verify the exact GET endpoint with website and token query parameters.
  3. Keep the real token out of source control, fixtures, logs, screenshots, and CSV output.
  4. Run PHPUnit and confirm that no test reaches the network.
  5. Process a small CSV containing a valid URL, a scheme-less domain, and an invalid value.
  6. Open the generated file and confirm that successful rows are pending, failures are explicit, and structured fields remain readable JSON.
  7. Review logs for attempts and delays without exposed credentials or contact data.

The useful result is not merely a populated spreadsheet. It is a research pipeline with an honest boundary between automated extraction and human judgment. The API does the repetitive reading; PHP enforces predictable behavior; and the final pending column preserves the decision that still belongs to a person.

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

Mihajlo

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