Vodiči

PHP Spreadsheet to Contact List: Enrich Websites with Company Data

PHP proračunska tablica u popis kontakata: Obogatite web-stranice podacima o tvrtkama

Proračunska tablica s web-stranicama tvrtki izgleda kao popis potencijalnih klijenata sve dok netko ne mora istražiti svaki redak. Nazivi tvrtki razlikuju se, stranice za kontakt mijenjaju lokaciju, a opću adresu e-pošte lako je zamijeniti za adresu određene osobe.

U ovom vodiču izradit ćete aplikaciju u izvornom PHP-u 8.3 koja obogaćuje podatke o tim web-stranicama putem usluge Website to Company data i izrađuje CSV za ljudsku provjeru. Aplikacija čuva informacije o nesigurnosti podataka, sprema kontrolne točke napretka u SQLite i prikazuje neuspjele zahtjeve. Uspješno izdvajanje znači „spremno za provjeru”, a ne „provjeren kontakt”.

Pristupite usluzi i kopirajte token usluge

  1. Registrirajte se ili se prijavite.
  2. Otvorite stranicu usluge Website to Company data i njezinih planova, odaberite dostupni plan Free, Plus ili Pro i dovršite aktivaciju.
  3. Otvorite službenu dokumentaciju. Pronađite ploču Service token i kopirajte token namijenjen toj usluzi.

Za ovu je uslugu potreban token. Ponovno generiranje opoziva prethodni aktivni token, stoga ažurirajte konfiguraciju u produkciji prije ponovnog pokretanja radnih procesa.

Točan zahtjev glasi GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract, s parametrima upita token i website. Za osnovni test zamijenite oba primjera vrijednosti, uključujući primjer web-stranice:

curl --get --connect-timeout 5 --max-time 25 \
  'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN' \
  --data-urlencode 'website=https://example.com'

Sada izradite .env u direktoriju projekta:

WEBSITE_COMPANY_TOKEN='YOUR_SERVICE_TOKEN'

PHP ne učitava ovu datoteku automatski. Naš pokretač izvozi njezine vrijednosti. Budući da učitavanje datoteke u ljusku izvršava njezin sadržaj, trebale bi je uređivati samo pouzdane osobe koje upravljaju sustavom.

Neka arhitektura bude jednostavna i omogućuje oporavak

Upotrijebite PHP 8.3 s cURL-om, PDO SQLiteom, Composerom i proširenjima potrebnima za PHPUnit, uključujući DOM, XML, XMLWriter i mbstring. Izradite src/, tests/ i var/. Projekt ima četiri izvorne datoteke: composer.json, src/App.php, run.php i tests/AppTest.php.

SQLite osigurava trajne kontrolne točke bez poslužitelja za red čekanja. Popisom u jednom trenutku upravlja jedan proces. To odgovara samostalnom stručnjaku ili malom timu; više replika aplikacije zahtijevalo bi zajedničku koordinaciju.

Spremite ovo kao composer.json:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.5"
  },
  "autoload": {
    "files": ["src/App.php"]
  }
}

Isključite .env, vendor/ i var/ iz Gita. Uključite generiranu Composerovu datoteku zaključavanja u commit.

Odvojite prijenos i mapiranje odgovora

Ovaj sloj mapira samo dostavljene kategorije tvrtke, kontakta, e-pošte, telefona i osoba. Ugniježđene vrijednosti ostaju u strukturiranom JSON-u; ne pretpostavljamo postojanje nedokumentiranih polja za osobe ili adrese. Vrijednosti koje nedostaju bilježe se kao upozorenja. Nepoznata ovojnica odgovora uzrokuje vidljiv neuspjeh.

Spremite sljedeće kao src/App.php:

<?php
declare(strict_types=1);

final class ApiFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable = false,
    ) {
        parent::__construct($kind);
    }
}

final readonly class Reply
{
    public function __construct(public int $status, public string $body) {}
}

interface Transport
{
    public function get(string $url): Reply;
}

final class CurlTransport implements Transport
{
    public function get(string $url): Reply
    {
        $body = '';
        $oversized = false;
        $curl = curl_init($url);
        if ($curl === false) {
            throw new ApiFailure('transport_setup');
        }
        curl_setopt_array($curl, [
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 25,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_WRITEFUNCTION => static function (
                $handle, string $chunk
            ) use (&$body, &$oversized): int {
                if (strlen($body) + strlen($chunk) > 1048576) {
                    $oversized = true;
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);
        $ok = curl_exec($curl);
        $errno = curl_errno($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        if ($ok === false) {
            $transient = in_array($errno, [
                CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT,
                CURLE_OPERATION_TIMEDOUT, CURLE_RECV_ERROR,
                CURLE_SEND_ERROR, CURLE_GOT_NOTHING,
            ], true);
            throw new ApiFailure(
                $oversized ? 'response_too_large' : 'transport',
                !$oversized && $transient,
            );
        }
        return new Reply($status, $body);
    }
}

final class CompanyApi
{
    public function __construct(
        private Transport $transport,
        private string $token,
    ) {
        if ($token === '') {
            throw new InvalidArgumentException('Service token missing.');
        }
    }

    public function extract(string $website): array
    {
        $query = http_build_query([
            'token' => $this->token, 'website' => $website,
        ], '', '&', PHP_QUERY_RFC3986);
        $reply = $this->transport->get(
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract?'
            . $query
        );

        if (in_array($reply->status, [401, 403], true)) {
            throw new ApiFailure('auth');
        }
        if ($reply->status === 429) {
            throw new ApiFailure('quota');
        }
        if ($reply->status < 200 || $reply->status >= 300) {
            throw new ApiFailure('http_' . $reply->status,
                in_array($reply->status, [408, 500, 502, 503, 504], true));
        }
        try {
            $root = json_decode($reply->body, false, 64, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            throw new ApiFailure('invalid_json');
        }
        if (!$root instanceof stdClass) {
            throw new ApiFailure('schema');
        }

        $out = [];
        $warnings = [];
        $recognized = false;
        foreach (['company', 'contact', 'email', 'phone', 'people'] as $key) {
            if (!property_exists($root, $key)) {
                $out[$key] = null;
                $warnings[] = "missing:$key";
                continue;
            }
            $recognized = true;
            $value = $root->$key;
            if ($value !== null && !is_string($value)
                && !is_array($value) && !$value instanceof stdClass) {
                $warnings[] = "type:$key";
                $value = null;
            }
            if ($value === null || $value === '' || $value === []) {
                $warnings[] = "empty:$key";
            }
            $out[$key] = $value;
        }
        if (!$recognized) {
            throw new ApiFailure('schema');
        }
        $out['warnings'] = $warnings;
        return $out;
    }
}

function website(string $input): string
{
    $input = trim($input);
    if (!str_contains($input, '://')) {
        $input = 'https://' . $input;
    }
    $parts = parse_url($input);
    if (!is_array($parts)) {
        throw new InvalidArgumentException('Invalid website.');
    }
    $scheme = strtolower($parts['scheme'] ?? '');
    $host = strtolower($parts['host'] ?? '');
    if (strlen($input) > 2048
        || !in_array($scheme, ['http', 'https'], true)
        || isset($parts['user']) || isset($parts['pass'])
        || isset($parts['port']) || !str_contains($host, '.')
        || filter_var($host, FILTER_VALIDATE_IP)
        || !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
        throw new InvalidArgumentException('Invalid website.');
    }
    return "$scheme://$host";
}

function csvCell(mixed $value): string
{
    $text = $value === null ? '' : (is_string($value)
        ? $value : json_encode($value, JSON_THROW_ON_ERROR));
    return preg_match('/^[\x00-\x20]*[=+@-]/', $text)
        ? "'" . $text : $text;
}

Normalizacija izvorišta namjerno odbacuje putanje, upite i fragmente. Duplikati izvorišta dijele jedan zapis o istraživanju. Odbacivanje IP adresa navedenih doslovno i vjerodajnica pravilo je za ulazne podatke, a ne dokaz da je domena javno dostupna putem DNS-a. Ova se aplikacija povezuje samo s fiksnim API poslužiteljem.

Uvezite podatke, spremite kontrolne točke i izvezite rezultate

Izvezite izvornu proračunsku tablicu kao UTF-8 CSV s jednim stupcem nazvanim website. Spremite je kao var/input.csv. Nevaljane web-adrese ostaju vidljive u izlaznoj datoteci.

Spremite ovu ulaznu točku za CLI kao run.php:

<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';

umask(0077);
try {
    $dir = __DIR__ . '/var';
    if (!is_dir($dir)) {
        mkdir($dir, 0700, true);
    }
    $lock = fopen($dir . '/worker.lock', 'c');
    if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
        exit(75);
    }
    $db = new PDO('sqlite:' . $dir . '/research.sqlite', null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $db->exec("CREATE TABLE IF NOT EXISTS jobs (
        website TEXT PRIMARY KEY, state TEXT NOT NULL,
        attempts INTEGER NOT NULL DEFAULT 0,
        next_at INTEGER NOT NULL DEFAULT 0, data TEXT, error TEXT
    );
    CREATE TABLE IF NOT EXISTS control (
        id INTEGER PRIMARY KEY, blocked INTEGER NOT NULL
    );
    INSERT OR IGNORE INTO control VALUES (1, 0)");

    $sql = static function (string $query, array $args = []) use ($db) {
        $statement = $db->prepare($query);
        $statement->execute($args);
        return $statement;
    };
    $command = $argv[1] ?? '';

    if ($command === 'import') {
        $input = fopen($dir . '/input.csv', 'rb');
        if (!$input) {
            throw new InvalidArgumentException('Cannot open var/input.csv.');
        }
        $header = fgetcsv($input, 0, ',', '"', '');
        if (is_array($header) && is_string($header[0])) {
            $header[0] = preg_replace('/^\xEF\xBB\xBF/', '', $header[0]);
        }
        if ($header !== ['website']) {
            throw new InvalidArgumentException('Expected one website column.');
        }
        while (($row = fgetcsv($input, 0, ',', '"', '')) !== false) {
            if ($row === [null]) {
                continue;
            }
            if (count($row) !== 1) {
                throw new InvalidArgumentException('Unexpected column count.');
            }
            $value = trim((string) $row[0]);
            if ($value === '') {
                continue;
            }
            $state = 'pending';
            $error = null;
            try {
                $value = website($value);
            } catch (InvalidArgumentException) {
                $state = 'invalid';
                $error = 'invalid_url';
            }
            $sql('INSERT OR IGNORE INTO jobs (website,state,error)
                  VALUES (?,?,?)', [$value, $state, $error]);
        }
        fclose($input);
    } elseif ($command === 'work') {
        if ((int) $db->query('SELECT blocked FROM control')->fetchColumn()) {
            throw new InvalidArgumentException(
                'Paused: resolve authentication or quota, then run resume.'
            );
        }
        $api = new CompanyApi(new CurlTransport(),
            trim((string) getenv('WEBSITE_COMPANY_TOKEN')));
        $sql("UPDATE jobs SET state='failed', error='attempt_limit'
              WHERE state IN ('pending','retry') AND attempts >= 3");

        while ($job = $sql("SELECT * FROM jobs
            WHERE state IN ('pending','retry') AND next_at <= ?
            AND attempts < 3 ORDER BY website LIMIT 1", [time()])->fetch()) {
            $attempt = (int) $job['attempts'] + 1;
            $sql('UPDATE jobs SET attempts=? WHERE website=?',
                [$attempt, $job['website']]);
            $state = 'review';
            $error = null;
            $data = null;
            $next = 0;
            try {
                $data = json_encode($api->extract($job['website']),
                    JSON_THROW_ON_ERROR);
            } catch (ApiFailure $failure) {
                $error = $failure->kind;
                $state = $failure->retryable && $attempt < 3
                    ? 'retry' : 'failed';
                $next = time() + (2 ** $attempt) + random_int(0, 3);
            }
            $sql('UPDATE jobs SET state=?,error=?,data=?,next_at=?
                  WHERE website=?',
                [$state, $error, $data, $next, $job['website']]);
            fwrite(STDOUT, json_encode([
                'id' => substr(hash('sha256', $job['website']), 0, 12),
                'state' => $state, 'attempt' => $attempt, 'error' => $error,
            ], JSON_THROW_ON_ERROR) . PHP_EOL);

            if (in_array($error, ['auth', 'quota'], true)) {
                $sql('UPDATE control SET blocked=1');
            }
            if ($error !== null) {
                break;
            }
            sleep(1);
        }
    } elseif ($command === 'resume') {
        $db->beginTransaction();
        $sql("UPDATE jobs SET state='pending',attempts=0,next_at=0,error=NULL
              WHERE error IN ('auth','quota')");
        $sql('UPDATE control SET blocked=0');
        $db->commit();
    } elseif ($command === 'export') {
        $output = fopen($dir . '/review.csv.tmp', 'wb');
        if (!$output) {
            throw new RuntimeException('Export unavailable.');
        }
        $fields = ['company','contact','email','phone','people','warnings'];
        $write = static function (array $cells) use ($output): void {
            if (fputcsv($output, array_map('csvCell', $cells),
                ',', '"', '') === false) {
                throw new RuntimeException('Export write failed.');
            }
        };
        $write(array_merge(['website','state','attempts','error'], $fields));
        foreach ($db->query('SELECT * FROM jobs ORDER BY website') as $job) {
            $data = json_decode($job['data'] ?? '{}', false, 64,
                JSON_THROW_ON_ERROR);
            $write(array_merge([
                $job['website'], $job['state'],
                (string) $job['attempts'], $job['error'],
            ], array_map(static fn($key) => $data->$key ?? null, $fields)));
        }
        if (!fflush($output) || !fclose($output)
            || !rename($dir . '/review.csv.tmp', $dir . '/review.csv')) {
            throw new RuntimeException('Export publication failed.');
        }
    } else {
        throw new InvalidArgumentException('Use import, work, export, or resume.');
    }
    fwrite(STDOUT, json_encode(['states' => $db->query(
        'SELECT state,COUNT(*) AS count FROM jobs GROUP BY state'
    )->fetchAll()], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $exception) {
    fwrite(STDERR, ($exception instanceof InvalidArgumentException
        ? $exception->getMessage() : 'Command failed; inspect configuration and input.')
        . PHP_EOL);
    exit(1);
}

Za svaki su zahtjev dopuštena najviše tri pokušaja, a eksponencijalno vrijeme čekanja između ponovnih pokušaja, uz nasumično odstupanje, trajno se pohranjuje. Pogreška autentikacije i HTTP 429 zaustavljaju obradu cijelog popisa dok osoba koja upravlja sustavom ne riješi ograničenja pristupa ili kvote i ne pokrene resume. Prije nastavka provjerite eventualnu uputu Retry-After koju je poslužitelj dostavio.

Pokušaji se bilježe prije slanja zahtjeva. Zbog toga pad sustava može potrošiti jedan pokušaj; pad nakon odgovora, ali prije spremanja, također može uzrokovati još jedno izdvajanje. Kontrolne točke ne mogu jamčiti naplatu točno jednom bez ugovora o idempotentnosti s pružateljem usluge.

Testirajte bez trošenja zahtjeva

Spremite tests/AppTest.php. Simulirani prijenos osigurava determinističku klasifikaciju, mapiranje i kodiranje upita:

<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public string $url = '';
    public function __construct(private Reply $reply) {}
    public function get(string $url): Reply
    {
        $this->url = $url;
        return $this->reply;
    }
}

final class AppTest extends TestCase
{
    public function testMappingAndQuery(): void
    {
        $fake = new FakeTransport(new Reply(200,
            '{"company":{"label":"Example"},"email":["[email protected]"],"people":[]}'));
        $data = (new CompanyApi($fake, 'YOUR_SERVICE_TOKEN'))
            ->extract('https://example.com');
        parse_str((string) parse_url($fake->url, PHP_URL_QUERY), $query);
        self::assertSame('YOUR_SERVICE_TOKEN', $query['token']);
        self::assertSame('https://example.com', $query['website']);
        self::assertInstanceOf(stdClass::class, $data['company']);
        self::assertSame(['[email protected]'], $data['email']);
        self::assertContains('missing:phone', $data['warnings']);
    }

    public function testFailureClassification(): void
    {
        foreach ([
            [401, '', 'auth', false],
            [429, '', 'quota', false],
            [503, '', 'http_503', true],
            [200, '{}', 'schema', false],
            [200, 'broken', 'invalid_json', false],
        ] as [$status, $body, $kind, $retryable]) {
            $api = new CompanyApi(
                new FakeTransport(new Reply($status, $body)),
                'YOUR_SERVICE_TOKEN'
            );
            try {
                $api->extract('https://example.com');
                self::fail('Expected failure.');
            } catch (ApiFailure $failure) {
                self::assertSame($kind, $failure->kind);
                self::assertSame($retryable, $failure->retryable);
            }
        }
    }

    public function testNormalizationAndSpreadsheetSafety(): void
    {
        self::assertSame('https://example.com',
            website('https://EXAMPLE.com/contact?tracking=discarded'));
        self::assertSame("' \t=1+1", csvCell(" \t=1+1"));
        $this->expectException(InvalidArgumentException::class);
        website('http://127.0.0.1');
    }
}

Pažljivo pokrenite i postavite aplikaciju

composer install
chmod 700 var
chmod 600 .env
vendor/bin/phpunit --bootstrap vendor/autoload.php tests

set -a
. ./.env
set +a
php run.php import
php run.php work
php run.php export

Zakažite pokretanje naredbe work svake minute s istim okruženjem i servisnim računom. Zaključavanje sprječava preklapanje pokretanja; ponovni pokušaji čekaju dok ne ispune uvjete za izvršavanje. Razmak od jedne sekunde ublažava nizove uspješnih zahtjeva, ali ne jamči usklađenost s ograničenjima bilo kojeg plana.

Postavite aplikaciju izvan javnog korijenskog direktorija za dokumente, instalirajte produkcijske ovisnosti s pomoću composer install --no-dev --classmap-authoritative i sačuvajte bazu podataka između izdanja. Zaštitite CSV datoteke i sigurnosne kopije, ograničite pristup i odredite razdoblje čuvanja.

Zapisnici sadrže raspršene identifikatore zapisa, broj pokušaja i fiksne kategorije pogrešaka. Pratite broj neuspjelih zadataka i zadatke koji dugo čekaju. Budući da se podaci za autentikaciju nalaze u parametru upita, uklonite cjelovite URL-ove zahtjeva iz sustava za praćenje HTTP prometa i prijavu pogrešaka.

Za auth provjerite aktivaciju i zamjenu tokena. Za quota prije nastavka provjerite potrošnju plana. Za schema ili invalid_json usporedite odgovor, pregledan u privatnom okruženju, sa službenom dokumentacijom; nikada nemojte izmišljati ovojnicu odgovora. Kod prevelikih odgovora potrebno je preispitati izričito postavljeno ograničenje od jednog megabajta.

Provjerite popis za pregled

  • Uvezite duplicirana izvorišta i nevaljan URL; potvrdite uklanjanje duplikata i prisutnost nevaljanog retka.
  • Obogatite podatke za jednu stvarnu tvrtku i usporedite svaku mapiranu kategoriju s odgovarajućim podacima u odgovoru.
  • Ponovno pokrenite radni proces; dovršeni redci moraju ostati dovršeni.
  • Otvorite var/review.csv; pregledajte upozorenja, ugniježđeni JSON i ćelije čiji se sadržaj nalik formulama prikazuje kao doslovni tekst.

Korisni rezultat jest popis za istraživanje na kojem su vidljivi dokazi i nedostaci. Osigurajte ljudsku provjeru između izdvajanja podataka i uspostavljanja kontakta: pronađena adresa povod je za daljnju provjeru, a ne dokaz točnosti, vlasništva ili dopuštenja za kontaktiranje osobe.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.