Туториали

Build a Rich Contact Research List: Laravel + Website to Company Data API

Изградете богата листа за истражување контакти: Laravel + API за податоци од веб-страница до компанија

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

Овој туторијал го гради тој работен тек во Laravel. Конзолна команда увезува CSV-датотека, задачи во редица ја збогатуваат секоја веб-страница преку API-то Website to Company data, а втора команда извезува CSV што може да се прегледа и содржи податоци за компанија, контакт, е-пошта, телефон и лица. Дизајнот намерно останува скромен: вградениот HTTP-клиент на Laravel, редица во база на податоци, структурирано мапирање на границата на апликацијата и без непотребни пакети.

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

Пред да напишете интеграциски код, креирајте или пристапете до вашата сметка. Регистрирајте се тука, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Website to Company data.
  2. Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација на услугата.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

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

Точното барање е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Проверете го пристапот со јавна веб-страница на компанија пред да ја изградите апликацијата:

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

Не внесувајте вистински токен во контрола на изворниот код, историја на школ споделена со други, логови, слики од екран или фикстури. Ставете го во датотеката со околински променливи на Laravel и додајте само резервирано место во .env.example:

# .env
WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

# .env.example
WEBSITE_TO_COMPANY_TOKEN=

Изберете мала, отпорна архитектура

Влезната табела треба да се извезе како CSV со заглавие website. CSV избегнува поврзување на апликацијата со одреден канцелариски формат. Секоја нормализирана веб-страница станува запис во базата на податоци, а една задача во редица го извршува нејзиното збогатување. Записот во базата е и трајна состојба на работниот тек и извор за конечниот извоз.

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

Релевантната структура на проектот е:

app/
  Console/Commands/ImportCompanyResearch.php
  Console/Commands/ExportCompanyResearch.php
  Data/CompanyResearchData.php
  Exceptions/WebsiteDataFailure.php
  Jobs/EnrichCompanyWebsite.php
  Models/CompanyResearchItem.php
  Services/WebsiteToCompanyClient.php
config/services.php
database/migrations/..._create_company_research_items_table.php
tests/Feature/EnrichCompanyWebsiteTest.php
tests/Unit/WebsiteToCompanyClientTest.php

Креирајте Laravel-апликација конфигурирана за PHP 8.3 или понова верзија, потоа генерирајте ги класите со вообичаените Artisan-команди. Ако апликацијата веќе не содржи миграција за табелата jobs за редицата во базата, генерирајте ја пред мигрирањето.

php artisan make:model CompanyResearchItem -m
php artisan make:job EnrichCompanyWebsite
php artisan make:command ImportCompanyResearch
php artisan make:command ExportCompanyResearch
php artisan make:test WebsiteToCompanyClientTest --unit
php artisan make:test EnrichCompanyWebsiteTest
php artisan make:queue-table
php artisan migrate

Зачувајте ја состојбата на работниот тек и API-полињата

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

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('company_research_items', function (Blueprint $table) {
            $table->id();
            $table->string('source_url')->unique();
            $table->string('status')->default('pending')->index();
            $table->json('company')->nullable();
            $table->json('contact')->nullable();
            $table->json('email')->nullable();
            $table->json('phone')->nullable();
            $table->json('people')->nullable();
            $table->string('failure_code')->nullable();
            $table->text('failure_message')->nullable();
            $table->timestamp('reviewed_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('company_research_items');
    }
};

Во CompanyResearchItem, направете ги овие колони пополнливи и претворете ги петте API-полиња во низи. Претворете го reviewed_at во datetime. Задржувањето на сурови, но валидирани структури ги зачувува корисните детали од услугата без транспортните грижи да се шират низ апликацијата.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class CompanyResearchItem extends Model
{
    protected $fillable = [
        'source_url', 'status', 'company', 'contact', 'email',
        'phone', 'people', 'failure_code', 'failure_message',
        'reviewed_at',
    ];

    protected function casts(): array
    {
        return [
            'company' => 'array',
            'contact' => 'array',
            'email' => 'array',
            'phone' => 'array',
            'people' => 'array',
            'reviewed_at' => 'datetime',
        ];
    }
}

Мапирајте ги неизвесните податоци на границата

На оддалечениот JSON не треба да му се верува само затоа што успешно се декодирал. Маперот подолу ги прифаќа само документираните области од највисоко ниво: company, contact, email, phone и people. Низите се задржуваат; скаларните вредности доследно се обвиткуваат; објекти или ресурси не можат да влезат во доменскиот модел.

<?php

namespace App\Data;

use UnexpectedValueException;

final readonly class CompanyResearchData
{
    public function __construct(
        public ?array $company,
        public ?array $contact,
        public ?array $email,
        public ?array $phone,
        public ?array $people,
    ) {}

    public static function fromResponse(array $payload): self
    {
        $keys = ['company', 'contact', 'email', 'phone', 'people'];

        if (! array_any($keys, fn (string $key) => array_key_exists($key, $payload))) {
            throw new UnexpectedValueException('The response contains no recognized data fields.');
        }

        $field = static function (string $key) use ($payload): ?array {
            if (! array_key_exists($key, $payload) || $payload[$key] === null) {
                return null;
            }

            if (is_array($payload[$key])) {
                return $payload[$key];
            }

            if (is_scalar($payload[$key])) {
                return ['value' => (string) $payload[$key]];
            }

            throw new UnexpectedValueException("Invalid {$key} field.");
        };

        return new self(...array_map($field, $keys));
    }
}

array_any е достапно во PHP 8.4, а не во PHP 8.3, па проект со PHP 8.3 треба да го замени тој услов со мала јамка. Така се задржува точноста на наведеното извршно опкружување во туторијалот:

$recognized = false;

foreach ($keys as $key) {
    if (array_key_exists($key, $payload)) {
        $recognized = true;
        break;
    }
}

if (! $recognized) {
    throw new UnexpectedValueException(
        'The response contains no recognized data fields.'
    );
}

Изградете ограничен HTTP-клиент

Додајте ја конфигурацијата на услугата во низата што ја враќа config/services.php:

'website_to_company' => [
    'token' => env('WEBSITE_TO_COMPANY_TOKEN'),
],

Клиентот користи кратки временски ограничувања за поврзување и одговор. Тој повторува при неуспеси на поврзувањето, HTTP 429 и неуспеси на серверот, но никогаш не повторува одговори за автентикација или валидација. Одложувањето е ограничено, а нумеричката вредност Retry-After се почитува до пет секунди.

<?php

namespace App\Services;

use App\Data\CompanyResearchData;
use App\Exceptions\WebsiteDataFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class WebsiteToCompanyClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract';

    public function extract(string $website): CompanyResearchData
    {
        $token = config('services.website_to_company.token');

        if (! is_string($token) || $token === '') {
            throw new WebsiteDataFailure('configuration', 'Service token is missing.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(3)
                    ->timeout(15)
                    ->get(self::ENDPOINT, [
                        'website' => $website,
                        'token' => $token,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new WebsiteDataFailure(
                        'connection',
                        'The service could not be reached.',
                        $exception
                    );
                }

                $this->pause($attempt);
                continue;
            }

            if ($response->successful()) {
                try {
                    return CompanyResearchData::fromResponse($response->json());
                } catch (Throwable $exception) {
                    throw new WebsiteDataFailure(
                        'invalid_response',
                        'The service returned an unusable response.',
                        $exception
                    );
                }
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new WebsiteDataFailure('authentication', 'Service authentication failed.');
            }

            if (in_array($response->status(), [400, 404, 422], true)) {
                throw new WebsiteDataFailure('validation', 'The website was rejected.');
            }

            if ($response->status() === 429 || $response->serverError()) {
                if ($attempt < 3) {
                    $this->pause($attempt, $response->header('Retry-After'));
                    continue;
                }

                $code = $response->status() === 429 ? 'rate_limit' : 'upstream';
                throw new WebsiteDataFailure($code, 'The service is temporarily unavailable.');
            }

            throw new WebsiteDataFailure(
                'http_error',
                'Unexpected service response: '.$response->status()
            );
        }

        throw new WebsiteDataFailure('unknown', 'Enrichment did not complete.');
    }

    private function pause(int $attempt, ?string $retryAfter = null): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(5000, (int) $retryAfter * 1000)
            : 250 * (2 ** ($attempt - 1));

        usleep($milliseconds * 1000);
    }
}

WebsiteDataFailure е мала приспособена исклучок-класа со јавно својство со низа kind и опционален претходен исклучок. Нејзините пораки се намерно безбедни: ниту URL-то на барањето ниту токенот не се копираат во логови или редови во базата на податоци.

Увезете, збогатете и извезете

Командата за увоз треба да го прочита заглавието со fgetcsv, да ја пронајде колоната website и да отфрли невалидни редови. Прифаќајте само http и https, барајте име на домаќин и отфрлајте ингеренции, IP-литерали, localhost, фрагменти и нестандардни порти. Нормализирајте ги вообичаените внесови за компании во мали букви за шемата и името на домаќинот пред да користите firstOrCreate.

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

<?php

namespace App\Jobs;

use App\Exceptions\WebsiteDataFailure;
use App\Models\CompanyResearchItem;
use App\Services\WebsiteToCompanyClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class EnrichCompanyWebsite implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;
    public int $timeout = 40;

    public function __construct(public int $itemId)
    {
        $this->onQueue('research');
    }

    public function handle(WebsiteToCompanyClient $client): void
    {
        $item = CompanyResearchItem::findOrFail($this->itemId);
        $item->update(['status' => 'processing']);

        $started = hrtime(true);

        try {
            $data = $client->extract($item->source_url);

            $item->update([
                'status' => 'completed',
                'company' => $data->company,
                'contact' => $data->contact,
                'email' => $data->email,
                'phone' => $data->phone,
                'people' => $data->people,
                'failure_code' => null,
                'failure_message' => null,
            ]);

            Log::info('Company research completed', [
                'item_id' => $item->id,
                'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
            ]);
        } catch (WebsiteDataFailure $exception) {
            $item->update([
                'status' => 'failed',
                'failure_code' => $exception->kind,
                'failure_message' => $exception->getMessage(),
            ]);

            Log::warning('Company research failed', [
                'item_id' => $item->id,
                'failure_code' => $exception->kind,
            ]);
        }
    }

    public function failed(?Throwable $exception): void
    {
        CompanyResearchItem::whereKey($this->itemId)->update([
            'status' => 'failed',
            'failure_code' => 'job_failure',
            'failure_message' => 'The background job failed unexpectedly.',
        ]);
    }
}

Командата за извоз треба да запишува на локалниот диск за складирање на Laravel со fputcsv. Испратете ги колоните source_url, status, company, contact, email, phone, people и failure_code. Кодирајте го секое структурирано поле со json_encode. Тоа создава артефакт за ревизија погоден за табеларни пресметки без израмнување или тивко отфрлање на вгнездени податоци.

php artisan research:import storage/app/imports/websites.csv
php artisan queue:work --queue=research --sleep=1 --tries=1 --timeout=45
php artisan research:export company-research.csv

Добиената датотека се наоѓа во storage/app. Прегледувачите можат да ги филтрираат неуспесите, да ги разгледуваат структурираните ќелии, да ги поправат изворните веб-страници и да означат прифатени редови преку подоцнежен работен тек на апликацијата што користи reviewed_at.

Тестирајте ги границата и задачата

Користете Http::fake() за тестовите никогаш да не трошат квота или да не зависат од достапноста на мрежата. Еден unit-тест треба да врати репрезентативни вредности за company, contact, email, phone и people, да го потврди нивното мапирање и да го провери излезното барање за да ги потврди двата задолжителни параметри. Втор тест треба да врати 401, да потврди неуспех authentication и да провери дека се случило само едно барање.

public function test_it_maps_the_service_response(): void
{
    config(['services.website_to_company.token' => 'test-token']);

    Http::fake([
        'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*' =>
            Http::response([
                'company' => ['name' => 'Example Company'],
                'contact' => ['page' => '/contact'],
                'email' => ['[email protected]'],
                'phone' => ['+1 555 0100'],
                'people' => [['name' => 'Alex Example']],
            ], 200),
    ]);

    $data = app(WebsiteToCompanyClient::class)->extract('https://example.com');

    $this->assertSame('Example Company', $data->company['name']);

    Http::assertSent(function (Request $request): bool {
        parse_str(parse_url($request->url(), PHP_URL_QUERY), $query);

        return $request->method() === 'GET'
            && $query['website'] === 'https://example.com'
            && $query['token'] === 'test-token';
    });
}

Функционалниот тест треба да креира запис во чекање, да симулира успешен одговор, да го повика методот handle на задачата преку контејнерот и да потврди дека редот во базата е завршен со мапиран JSON. Покријте и одговор 422 што станува неуспешен ред со validation. Овие тестови ги проверуваат транспортот, мапирањето и перзистенцијата без да го тестираат самиот Laravel.

Управувајте со него безбедно во продукција

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

Кеширајте ја конфигурацијата само откако продукцискиот токен е присутен со php artisan config:cache. Ротирајте го токенот со ажурирање на околината, повторно градење на кешот на конфигурацијата и рестартирање на работниците. Бидејќи повторното генерирање на сервисниот токен веднаш го поништува неговиот претходник, извршете ги овие чекори како една контролирана промена.

Следете ги бројките на записи во чекање, во обработка, завршени и неуспешни. Поставете предупредувања за трајни неуспеси authentication, rate_limit или invalid_response. Избегнувајте логирање на телата на одговорите: истражувањето на контакти може да содржи лични податоци, а оперативните логови обично имаат поширок пристап и подолго задржување од базата на податоци на апликацијата.

Чести неуспеси

  • Секое барање не успева при автентикација: проверете ги активацијата, токенот ограничен на услугата, кеширањето на конфигурацијата и дали некој го регенерирал токенот.
  • Редовите остануваат во чекање: потврдете дека работник работи на редицата research и дека неговата врска со базата на податоци може да ја види табелата jobs.
  • Редовите остануваат во обработка: прегледајте ги неуспешните задачи и настаните на прекинување на работниците; безбедно повторно ставете ги засегнатите записи во редицата преку експлицитната патека за освежување.
  • Се појавуваат многу неуспеси поради ограничување на стапката: намалете ја конкурентноста на работниците и повторете ги неуспешните редови подоцна наместо да продолжувате со неограничени спиења.
  • Одговорот е означен како невалиден: задржете ја класификацијата на неуспехот, споредете ја тековната официјална документација и ажурирајте го само граничниот мапер.

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

  • Сметката и Free, Plus или Pro планот се активни.
  • Сервисниот токен постои само во конфигурација поткрепена со околински променливи.
  • Минималното GET-барање успева со website и token.
  • Заглавието на CSV содржи website, а небезбедните URL-адреси се отфрлаат.
  • Работниците на редицата ја обработуваат редицата research со ограничена конкурентност.
  • Податоците за company, contact, email, phone и people се мапираат пред перзистирање.
  • Неуспесите на автентикација и валидација не се повторуваат.
  • Ограничувањата на стапката, неуспесите на поврзувањето и грешките на серверот добиваат ограничено одложување.
  • Тестовите користат Http::fake() и не содржат вистински ингеренции.
  • Извезениот CSV содржи завршени редови и видливи состојби на неуспех за преглед.

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

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

Mihajlo

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