Izradite istraživač poslovnih kontakata s izvornim PHP-om i API-jem za podatke o web-mjestu
A spreadsheet full of company websites looks like a useful lead list, but it is still several research steps away from being actionable. Someone must visit each site, identify the organization, find public contact details, and capture the result consistently. That work is repetitive, difficult to review, and easy to lose when it lives in browser tabs.
This tutorial builds a Native PHP 8.3 command-line application that reads websites from a CSV spreadsheet, sends each one to a Website to Company data service, and writes an atomic, spreadsheet-safe CSV containing company, contact, email, phone, and people data. The integration uses native cURL, explicit domain mapping, bounded retries, structured errors, deterministic tests, and no framework.
Prerequisites
You need PHP 8.3 or newer with the cURL and JSON extensions, Composer, and a CSV exported from Excel, Google Sheets, LibreOffice, or another spreadsheet application. The input must contain a header named website and one absolute HTTP or HTTPS URL per row.
The application intentionally processes rows sequentially. That is slower than unrestricted parallel requests, but it gives a small team predictable memory use, straightforward failure reporting, and much better control over service quotas.
Get access and copy the service token
- Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
- Open the Website to Company data service page.
- Choose the available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
This service is not tokenless. Every request requires the token through the token query parameter. Regenerating the service token revokes the previously active token, so rotate the application configuration immediately after regeneration and never retain the old value as a fallback.
Confirm the exact API contract
The integration uses exactly this request:
GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
It sends two query parameters: token for authentication and website for the public company website. Before writing application code, place the copied token into a temporary shell variable and make one minimal request:
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
Use a website you are permitted to research when performing the real check. On shared systems, prefer running the PHP client because command-line arguments can be visible to other processes.
Now store the credential in an environment file that is excluded from version control:
mkdir -p src bin tests
printf '%s\n' '.env.local' >> .gitignore
chmod 600 .env.local
# .env.local
export WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
The file uses shell syntax so the runtime can source it without adding a dotenv package. Production deployments should inject the same variable through the process manager, container secret configuration, or hosting platform rather than copying this file onto a server.
Architecture and project structure
There are four boundaries worth preserving even in a small command-line tool:
- The command owns CSV input, output, and row-level reporting.
- A client owns request construction, response classification, retry policy, and logging.
- A mapper converts uncertain external JSON into stable application values.
- A transport interface keeps cURL replaceable with a deterministic fake during tests.
The output columns are status, website, company, contact, email, phone, people, error_code, and error_message. Structured API values are retained as compact JSON inside their CSV cell instead of being guessed into an undocumented schema.
company-research/
├── .env.local
├── .gitignore
├── composer.json
├── src/App.php
├── bin/research.php
└── tests/WebsiteCompanyClientTest.php
Composer supplies autoloading and PHPUnit only. The production HTTP path remains native 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
Build the transport, mapper, and API client
Create src/App.php. Connection and total request timeouts prevent a stalled endpoint from occupying the process indefinitely. Redirects are disabled so the token is not forwarded to an unexpected location.
<?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));
}
}
The client retries transport failures, HTTP 429 responses, and server failures. It does not retry authentication or validation failures because another identical request will not repair them. Numeric Retry-After values are honored with a ten-second ceiling; otherwise the client uses bounded exponential backoff with small jitter.
Turn the spreadsheet into a review file
Create bin/research.php. Every input row produces an output row, including failures. Potential spreadsheet formulas are prefixed with an apostrophe, and the completed file is renamed from a temporary path only after the batch finishes.
<?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);
An exit code of 2 means the review file was produced but contains failed rows. That distinction is useful in scheduled jobs: operational tooling can retain the artifact while still alerting someone to inspect it.
Test without calling the service
The fake transport queues complete responses, making mapping, retry timing, and non-retriable failures deterministic. Create 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
Run and verify the batch
Create an input file with representative public company websites:
website
https://example.com
https://www.example.org
Load the environment configuration and run the command:
set -a
. ./.env.local
set +a
php bin/research.php companies.csv company-research.csv
Review both successful and failed rows. Empty fields are not automatically errors: a public website may simply lack a particular contact detail. An invalid_response result is different because it indicates that the service response could not be safely mapped.
Security, observability, and deployment
The token appears in the query string because that is the service authentication contract. Do not log the constructed URL, cURL verbose output, raw exception dumps, or proxy access URLs. The client logs only the website host, event, attempt, and status. Configure infrastructure logs to redact the token query parameter as an additional safeguard.
Only process public websites for a legitimate research purpose. Treat the resulting contact data as sensitive business data: restrict output-file permissions, define retention rules, and avoid emailing unencrypted CSV files around a team.
Deploy with composer install --no-dev --classmap-authoritative, inject WEBSITE_COMPANY_TOKEN through the runtime environment, and run the command under a dedicated operating-system account. Schedule overlapping batches only if the active plan and workload allow it. The sequential default is deliberately conservative.
Useful operational measurements include processed rows, successful rows, each failure code, retry count, elapsed batch time, and final exit code. Avoid labels containing full websites or contact values in metrics systems; high-cardinality data is expensive and unnecessarily revealing.
Common failure modes
- Authentication errors: confirm that the service-scoped token is active. If it was regenerated, the previous token has already been revoked.
- Request rejection: inspect the input for missing schemes, malformed URLs, or websites the service cannot accept. These failures should not be retried unchanged.
- Rate limiting: keep the generated CSV, reduce invocation frequency, and resume failed rows later. Increasing concurrency will usually make this condition worse.
- Invalid responses: retain the structured error, but do not silently reinterpret unknown payload shapes. Update the mapper only after checking the official documentation.
- Partial process interruption: the published output remains untouched because work is written to a process-specific temporary file first. Remove abandoned
.partfiles through a narrowly scoped maintenance policy.
Final verification checklist
- The active plan is enabled and the service token comes only from environment-backed configuration.
- The request uses
GET, the exact/v1/extractendpoint, and thetokenandwebsitequery parameters. - Company, contact, email, phone, and people values are mapped at the API boundary.
- Timeouts and retries are bounded, while authentication and validation failures are never blindly retried.
- Logs and test fixtures contain no real token or returned contact data.
- PHPUnit passes, the output opens cleanly in a spreadsheet, and failed rows remain reviewable.
The important result is not merely a script that calls an endpoint. It is a small, dependable research pipeline: uncertain external data enters through one guarded boundary, every spreadsheet row receives an accountable outcome, and operational failures remain visible without destroying useful work. That is the difference between an API demonstration and a tool a team can safely run again tomorrow.