Native PHP 8.3: Pretvorite web-stranice u bogat popis kontakata za istraživanje uz AI
A spreadsheet of company websites looks like a useful lead list, but it is only a list of URLs. The practical work begins when someone must visit every site, identify the company, locate public contact details, and organize the results for review.
This tutorial builds that workflow as a production-minded Native PHP 8.3 command-line application. It reads a CSV file, calls the Website to Company data service, maps uncertain external data into a stable domain object, and writes a resumable research CSV. It also records failures without exposing credentials, applies bounded retries, and includes deterministic PHPUnit tests.
Get access and copy the service token
- Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Website to Company data service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy its service-scoped token.
This service requires a token. Regenerating it revokes the previously active token, so treat rotation as an operational change: update every deployed environment that uses the old value, verify the replacement, and then remove any stale local copies.
The exact request is an HTTP GET to https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Authentication uses the token query parameter, while the target site goes in the website query parameter.
Make one minimal request before writing integration code. Supplying the values through environment variables keeps the literal token out of the command itself:
export WEBSITE_TO_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'
curl --get \
--data-urlencode "token=${WEBSITE_TO_COMPANY_TOKEN}" \
--data-urlencode "website=https://example.com" \
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
Query parameters can appear in proxy access logs, debugging output, and shell process inspection. Use HTTPS, never print the completed request URL, and configure infrastructure to redact the token parameter.
Store the credential in the project environment configuration, not in PHP source code:
# .env
WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
Commit an .env.example containing only the placeholder, and exclude .env from version control.
Set up the Native PHP project
You need PHP 8.3 or later with the cURL and JSON extensions, plus Composer. The only runtime package is vlucas/phpdotenv in the compatible ^5.6 range. PHPUnit ^11.0 provides the test runner.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
composer install
mkdir -p src/Domain src/Http tests bin var
printf '%s\n' 'website' 'https://example.com' > websites.csv
The architecture deliberately stays small. A cURL transport owns network mechanics. A service client owns authentication, retry policy, JSON decoding, and response mapping. The command owns CSV input, checkpoints, and operator-facing output. That separation makes the API boundary testable without making real requests.
The project contains src/Domain/CompanyResearch.php, src/Http/Transport.php, src/WebsiteCompanyClient.php, bin/enrich.php, and tests/WebsiteCompanyClientTest.php. Generated files live under var/.
Map external data at the boundary
The documented application contract exposes company, contact, email, phone, and people. Their exact contents may vary, so application code should not assume that every value is a non-empty string. This DTO preserves scalar or structured values and converts them safely when exporting CSV.
<?php
// src/Domain/CompanyResearch.php
declare(strict_types=1);
namespace App\Domain;
final readonly class CompanyResearch
{
public function __construct(
public string|array|null $company,
public string|array|null $contact,
public string|array|null $email,
public string|array|null $phone,
public array $people,
) {}
public static function fromApi(array $payload): self
{
$people = $payload['people'] ?? [];
if (!is_array($people)) {
$people = [$people];
}
return new self(
self::value($payload, 'company'),
self::value($payload, 'contact'),
self::value($payload, 'email'),
self::value($payload, 'phone'),
$people,
);
}
private static function value(array $payload, string $key): string|array|null
{
$value = $payload[$key] ?? null;
if (is_string($value)) {
$value = trim($value);
return $value === '' ? null : $value;
}
return is_array($value) ? $value : null;
}
public static function csv(string|array|null $value): string
{
if ($value === null) {
return '';
}
return is_string($value)
? $value
: json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
}
}
This is an anti-corruption layer: changes or irregularities in an external response do not leak throughout the batch workflow. Structured fields remain JSON inside one CSV cell, which keeps information available for human review without inventing undocumented subfields.
Build a bounded native cURL transport
The transport uses a five-second connection timeout and a twenty-second total timeout. Redirects are disabled because automatically following a redirect could disclose the token to another host.
<?php
// src/Http/Transport.php
declare(strict_types=1);
namespace App\Http;
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 CurlTransport implements Transport
{
public function get(string $url): HttpResponse
{
$headers = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'contact-research/1.0',
CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
$length = strlen($line);
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $body);
}
}
Add retries, backoff, and structured failures
Transient transport errors and HTTP 408, 429, 500, 502, 503, and 504 receive at most three attempts. Authentication, authorization, and validation failures are returned immediately. A valid bounded Retry-After value takes precedence; otherwise the client uses exponential backoff.
<?php
// src/WebsiteCompanyClient.php
declare(strict_types=1);
namespace App;
use App\Domain\CompanyResearch;
use App\Http\Transport;
use App\Http\TransportException;
final class ApiFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status,
public readonly bool $retryable,
string $message,
) {
parent::__construct($message);
}
}
final class WebsiteCompanyClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';
public function __construct(
private readonly string $token,
private readonly Transport $transport,
private readonly \Closure $sleep,
private readonly \Closure $log,
) {}
public function research(string $website): CompanyResearch
{
$query = http_build_query(
['token' => $this->token, 'website' => $website],
'',
'&',
PHP_QUERY_RFC3986,
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(self::ENDPOINT . '?' . $query);
} catch (TransportException $exception) {
($this->log)('transport_failure', [
'website' => $website,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new ApiFailure(
'transport',
null,
true,
$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,
);
} catch (\JsonException $exception) {
throw new ApiFailure(
'invalid_response',
$response->status,
false,
'The service returned invalid JSON.',
);
}
if (!is_array($payload)) {
throw new ApiFailure(
'invalid_response',
$response->status,
false,
'The service response was not an object.',
);
}
return CompanyResearch::fromApi($payload);
}
$retryable = in_array(
$response->status,
[408, 429, 500, 502, 503, 504],
true,
);
($this->log)('api_failure', [
'website' => $website,
'attempt' => $attempt,
'status' => $response->status,
'retryable' => $retryable,
]);
if (!$retryable || $attempt === 3) {
throw new ApiFailure(
'http',
$response->status,
$retryable,
'Website data request failed.',
);
}
$retryAfter = filter_var(
$response->headers['retry-after'] ?? null,
FILTER_VALIDATE_INT,
);
$delay = $retryAfter !== false
? min(30_000, max(0, $retryAfter * 1000))
: 250 * (2 ** ($attempt - 1));
($this->sleep)($delay);
}
throw new \LogicException('Retry loop ended unexpectedly.');
}
}
The logger receives the website, status, attempt, and event type, but never the completed URL, response body, or token. Response bodies may contain contact information and should not be copied into routine logs.
Turn the spreadsheet into a reviewable list
Export the source spreadsheet as UTF-8 CSV with a website header. The command appends successful rows to var/contact-research.csv and failures to var/failures.jsonl. Existing successful websites are skipped, making a rerun safe after interruption.
<?php
// bin/enrich.php
declare(strict_types=1);
use App\ApiFailure;
use App\Domain\CompanyResearch;
use App\Http\CurlTransport;
use App\WebsiteCompanyClient;
use Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$token = $_ENV['WEBSITE_TO_COMPANY_TOKEN'] ?? '';
$inputPath = $argv[1] ?? '';
if ($token === '' || $inputPath === '' || !is_readable($inputPath)) {
fwrite(STDERR, "Usage: php bin/enrich.php websites.csv\n");
exit(2);
}
$logger = static function (string $event, array $context): void {
fwrite(STDERR, json_encode(
['event' => $event] + $context,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
) . PHP_EOL);
};
$client = new WebsiteCompanyClient(
$token,
new CurlTransport(),
static fn (int $milliseconds) => usleep($milliseconds * 1000),
$logger,
);
$outputPath = dirname(__DIR__) . '/var/contact-research.csv';
$failurePath = dirname(__DIR__) . '/var/failures.jsonl';
$completed = [];
if (is_readable($outputPath)) {
$existing = fopen($outputPath, 'rb');
fgetcsv($existing);
while (($row = fgetcsv($existing)) !== false) {
$completed[strtolower(rtrim($row[0], '/'))] = true;
}
fclose($existing);
}
$input = fopen($inputPath, 'rb');
$output = fopen($outputPath, 'ab');
$failures = fopen($failurePath, 'ab');
$header = fgetcsv($input);
$websiteColumn = is_array($header) ? array_search('website', $header, true) : false;
if ($websiteColumn === false) {
throw new RuntimeException('Input CSV requires a website header.');
}
if (filesize($outputPath) === 0) {
fputcsv($output, ['website', 'company', 'contact', 'email', 'phone', 'people']);
}
while (($row = fgetcsv($input)) !== false) {
$website = trim($row[$websiteColumn] ?? '');
$key = strtolower(rtrim($website, '/'));
if (isset($completed[$key])) {
continue;
}
$scheme = parse_url($website, PHP_URL_SCHEME);
if (!filter_var($website, FILTER_VALIDATE_URL)
|| !in_array($scheme, ['http', 'https'], true)) {
$failure = ['website' => $website, 'kind' => 'validation'];
fwrite($failures, json_encode($failure, JSON_THROW_ON_ERROR) . PHP_EOL);
continue;
}
try {
$result = $client->research($website);
fputcsv($output, [
$website,
CompanyResearch::csv($result->company),
CompanyResearch::csv($result->contact),
CompanyResearch::csv($result->email),
CompanyResearch::csv($result->phone),
json_encode($result->people, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES),
]);
fflush($output);
$completed[$key] = true;
} catch (ApiFailure $exception) {
$failure = [
'website' => $website,
'kind' => $exception->kind,
'status' => $exception->status,
'retryable' => $exception->retryable,
];
fwrite($failures, json_encode($failure, JSON_THROW_ON_ERROR) . PHP_EOL);
fflush($failures);
}
}
fclose($input);
fclose($output);
fclose($failures);
Run it with php bin/enrich.php websites.csv. Opening the resulting CSV in a spreadsheet produces the intended review queue: every row retains its source website beside the mapped company and contact data.
Test without making network calls
A deterministic fake transport tests retries and mapping without consuming quota or depending on network availability.
<?php
// tests/WebsiteCompanyClientTest.php
declare(strict_types=1);
namespace Tests;
use App\ApiFailure;
use App\Http\HttpResponse;
use App\Http\Transport;
use App\WebsiteCompanyClient;
use PHPUnit\Framework\TestCase;
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 testRetriesRateLimitThenMapsResponse(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], '{}'),
new HttpResponse(200, [], json_encode([
'company' => 'Example Ltd',
'contact' => 'General enquiries',
'email' => '[email protected]',
'phone' => null,
'people' => [['name' => 'Public Contact']],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new WebsiteCompanyClient(
'test-token',
$transport,
static function (int $delay) use (&$delays): void {
$delays[] = $delay;
},
static function (): void {},
);
$result = $client->research('https://example.com');
self::assertSame('Example Ltd', $result->company);
self::assertSame(2, $transport->calls);
self::assertSame([1000], $delays);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$transport = new FakeTransport([
new HttpResponse(401, [], '{}'),
]);
$client = new WebsiteCompanyClient(
'invalid',
$transport,
static function (): void {},
static function (): void {},
);
try {
$client->research('https://example.com');
self::fail('Expected ApiFailure.');
} catch (ApiFailure $failure) {
self::assertSame(401, $failure->status);
self::assertFalse($failure->retryable);
self::assertSame(1, $transport->calls);
}
}
}
composer dump-autoload
vendor/bin/phpunit tests
php bin/enrich.php websites.csv
Production security and operations
Contact research data deserves the same care as other business data. Restrict access to the input, output, and failure files; establish a retention period; and use the results only for legitimate, compliant purposes. Public availability does not remove privacy, consent, or communication-law obligations.
In deployment, inject WEBSITE_TO_COMPANY_TOKEN through the hosting platform’s secret manager or protected environment configuration. Run the command as a dedicated unprivileged user, keep var/ outside any public web root, and schedule only one writer per output file. For concurrent workers, replace CSV append operations with a database or explicit file locking.
Emit structured events to standard error and monitor completion counts, failure counts, HTTP status distribution, and retry frequency. A rise in 401 or 403 responses suggests token or plan trouble. Persistent 429 responses indicate that the batch should run more slowly or that plan limits need review.
Common failure patterns
- Every request returns an authentication error: confirm the service-scoped token, then check whether regeneration revoked the deployed value.
- Rows land in the validation file: ensure cells contain complete
http://orhttps://URLs rather than bare domain names. - Successful JSON has missing fields: preserve nulls and arrays as designed; do not manufacture contact information.
- The same rows run repeatedly: keep the output file between executions and avoid changing URL spelling unnecessarily.
- Rate limiting persists: do not raise the retry count indefinitely. Reduce batch frequency, inspect plan capacity, and resume from the checkpoint later.
Final verification checklist
- The account and Free, Plus, or Pro plan are active.
- The service token is loaded from environment-backed configuration and absent from source control and logs.
- The request uses
GET, the exact extraction endpoint, and thetokenandwebsitequery parameters. - The input CSV has a
websiteheader and valid absolute URLs. - Company, contact, email, phone, and people data are mapped only at the API boundary.
- Timeouts are bounded, transient failures are retried, and authentication or validation failures are not.
- PHPUnit passes with the fake transport.
- The successful and failed output files survive restarts and are protected from public access.
The important result is not merely an API call. It is a controlled research pipeline: credentials remain contained, external data cannot destabilize the application, interrupted work can resume, and every result remains reviewable by a person. That is the difference between a clever script and a dependable production tool.