Laravel + AI: Претворете ги листите на веб-страници во применливо истражување на контакти
Табела полна со веб-страници на компании изгледа корисно сè додека некој не мора да го отвори секој ред, да пронајде контакт-детали и да ја претвори неконзистентната содржина на страниците во структурирано истражување. Тој рачен циклус е бавен, тешко се ревидира и е особено фрустрирачки кога списокот се менува секоја недела.
Овој туторијал изработува производствено ориентиран Laravel-процес кој увезува URL-адреси на веб-страници од CSV, ја збогатува секоја веб-страница преку API-то Website to Company data, складира нормализирани резултати и извезува табела за контакт-истражување што може да се прегледа. Queue worker-ите ги држат големите увози подалеку од истекување на времето за барања, додека експлицитните состојби на неуспех ги прават нецелосните записи видливи наместо тивко да се изгубат.
Добијте пристап пред да пишувате интеграциски код
Создадете сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.
- Отворете ја страницата на услугата Website to Company data.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Складирајте го во Laravel-конфигурација поддржана од околината. Не го внесувајте во commit, не го печатете и не го вклучувајте во тест-фактури.
Оваа услуга бара токен. Неговото повторно генерирање го поништува претходно активниот токен, па ротацијата на токени мора да вклучи ажурирање на секоја распоредена околина што ја користи старата вредност.
Точниот повик е GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Автентикацијата го користи параметарот за барање token, а веб-страницата се доставува преку параметарот за барање website. Тестирајте ги ингеренциите со јавна веб-страница за која сте овластени да истражувате:
curl --get \
--data-urlencode "token=YOUR_SERVICE_TOKEN" \
--data-urlencode "website=https://example.com" \
"https://ai.mihajlo.mk/api/website-to-company-data/v1/extract"
Ставете го токенот во непратената во commit .env датотека на проектот:
MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MIHAJLO_WEBSITE_COMPANY_URL=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract
Изложете ги тие вредности преку config/services.php. Читањето на env() само од конфигурациските датотеки осигурува дека интеграцијата продолжува да работи откако Laravel-конфигурацијата ќе се кешира.
<?php
return [
// Existing services...
'website_company' => [
'token' => env('MIHAJLO_WEBSITE_COMPANY_TOKEN'),
'url' => env(
'MIHAJLO_WEBSITE_COMPANY_URL',
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract'
),
],
];
Изберете мала, издржлива архитектура
Влезот ќе биде UTF-8 CSV-датотека чиј заглавен ред содржи website. CSV намерно се користи наместо да се додава XLSX-пакет: секоја распространета апликација за табели може да го извезе, PHP може природно да го анализира, а добиениот увоз лесно се тестира.
Процесот има четири одговорности:
- Artisan-команда ги валидира редовите и создава по еден запис во базата на податоци за секоја нормализирана веб-страница.
- Queue job врши збогатување надвор од процесот на командата.
- Посветен API-клиент управува со автентикацијата, истекувањата на времето, повторните обиди и мапирањето на одговорите.
- Втора команда извезува завршени и неуспешни редови за човечки преглед.
Создадете го проектот со PHP 8.3 или понов и моментално поддржано Laravel-издание. Конфигурирајте база на податоци и queue-конекција, а потоа извршете php artisan queue:table ако вашата апликација користи database queue. Генерирајте ги миграцијата, моделот, командите, job-от и service-класите со вообичаените Laravel make: команди или создадете ги следниве датотеки директно.
Зачувајте состојби што може да се прегледаат, а не само успешни податоци
Истражувачкиот запис треба да разликува работа што е на чекање, во обработка, завршена и неуспешна. Исто така, треба да задржи код на неуспех читлив за машина и безбедна дијагностичка порака.
<?php
// database/migrations/xxxx_xx_xx_create_contact_researches_table.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('contact_researches', function (Blueprint $table) {
$table->id();
$table->string('website')->unique();
$table->string('status')->default('pending')->index();
$table->json('company')->nullable();
$table->json('contact')->nullable();
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->json('people')->nullable();
$table->string('failure_code')->nullable();
$table->text('failure_message')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contact_researches');
}
};
<?php
// app/Models/ContactResearch.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class ContactResearch extends Model
{
protected $fillable = [
'website', 'status', 'company', 'contact', 'email', 'phone',
'people', 'failure_code', 'failure_message',
];
protected function casts(): array
{
return [
'company' => 'array',
'contact' => 'array',
'people' => 'array',
];
}
}
Извршете php artisan migrate откако ќе ја прегледате миграцијата за вашата платформа на база на податоци.
Изградете дефанзивна API-граница
Надворешниот JSON не треба директно да протекува во остатокот од апликацијата. Доставениот договор ги именува company, contact, email, phone и people, но интеграциите сепак треба да одбијат одговор што не е објект и да толерираат nullable или неочекувано обликувани поединечни вредности.
<?php
// app/Services/WebsiteCompany/CompanyResearchData.php
namespace App\Services\WebsiteCompany;
use UnexpectedValueException;
final readonly class CompanyResearchData
{
public function __construct(
public array $company,
public array $contact,
public ?string $email,
public ?string $phone,
public array $people,
) {}
public static function fromPayload(mixed $payload): self
{
if (! is_array($payload)) {
throw new UnexpectedValueException('The API response was not a JSON object.');
}
return new self(
self::mapValue($payload['company'] ?? null),
self::mapValue($payload['contact'] ?? null),
self::text($payload['email'] ?? null),
self::text($payload['phone'] ?? null),
is_array($payload['people'] ?? null)
? array_values($payload['people'])
: [],
);
}
private static function mapValue(mixed $value): array
{
if (is_array($value)) {
return $value;
}
return is_string($value) || is_numeric($value)
? ['value' => (string) $value]
: [];
}
private static function text(mixed $value): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
HTTP-клиентот користи ограничени истекувања на времето за конекција и одговор. Тој повторува обиди при неуспеси на конекцијата, ограничувања на стапката и серверски грешки со ограничен backoff, но никогаш не повторува обиди при неуспеси на автентикација или валидација на барањето. Исто така, избегнува да ја евидентира URL-адресата на барањето бидејќи потребната ингеренција се појавува во нејзината низа за барање.
<?php
// app/Services/WebsiteCompany/WebsiteCompanyClient.php
namespace App\Services\WebsiteCompany;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
final class WebsiteCompanyException extends RuntimeException
{
public function __construct(
public readonly string $failureCode,
string $message
) {
parent::__construct($message);
}
}
final class WebsiteCompanyClient
{
public function extract(string $website): CompanyResearchData
{
$token = (string) config('services.website_company.token');
$url = (string) config('services.website_company.url');
if ($token === '') {
throw new WebsiteCompanyException(
'configuration',
'The Website to Company service token is missing.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(5)
->timeout(20)
->get($url, [
'token' => $token,
'website' => $website,
]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new WebsiteCompanyException(
'transport',
'The enrichment service could not be reached.'
);
}
usleep(250_000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->successful()) {
try {
return CompanyResearchData::fromPayload($response->json());
} catch (Throwable $exception) {
throw new WebsiteCompanyException(
'invalid_response',
'The enrichment service returned an unusable response.'
);
}
}
if (in_array($response->status(), [401, 403], true)) {
throw new WebsiteCompanyException(
'authentication',
'The service token was rejected.'
);
}
if (in_array($response->status(), [400, 422], true)) {
throw new WebsiteCompanyException(
'validation',
'The website request was rejected.'
);
}
$retryable = $response->status() === 429 || $response->serverError();
if (! $retryable || $attempt === 3) {
throw new WebsiteCompanyException(
$response->status() === 429 ? 'rate_limited' : 'http_error',
'Enrichment failed with HTTP status '.$response->status().'.'
);
}
$retryAfter = ctype_digit((string) $response->header('Retry-After'))
? min(5, (int) $response->header('Retry-After'))
: 0;
usleep(max($retryAfter * 1_000_000, 250_000 * (2 ** ($attempt - 1))));
}
throw new WebsiteCompanyException('unknown', 'Enrichment did not complete.');
}
}
Увезете веб-страници и збогатете ги во queue
Командата за увоз ги третира неправилно обликуваните URL-адреси како грешки на ниво на ред. Таа прифаќа само апсолутни HTTP или HTTPS URL-адреси со хост, спречувајќи вредности како локални патеки до датотеки да влезат во работниот тек.
<?php
// app/Console/Commands/ImportContactResearch.php
namespace App\Console\Commands;
use App\Jobs\EnrichContactResearch;
use App\Models\ContactResearch;
use Illuminate\Console\Command;
final class ImportContactResearch extends Command
{
protected $signature = 'research:import {path}';
protected $description = 'Import company websites from a CSV file';
public function handle(): int
{
$handle = fopen($this->argument('path'), 'rb');
if ($handle === false) {
$this->error('The CSV file could not be opened.');
return self::FAILURE;
}
$headers = fgetcsv($handle);
$websiteIndex = is_array($headers)
? array_search('website', array_map('trim', $headers), true)
: false;
if ($websiteIndex === false) {
fclose($handle);
$this->error('The CSV header must contain website.');
return self::FAILURE;
}
while (($row = fgetcsv($handle)) !== false) {
$website = trim((string) ($row[$websiteIndex] ?? ''));
$parts = parse_url($website);
if (! is_array($parts)
|| ! in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| empty($parts['host'])) {
$this->warn("Skipped invalid website: {$website}");
continue;
}
$record = ContactResearch::firstOrCreate(
['website' => $website],
['status' => 'pending']
);
if ($record->status !== 'completed') {
$record->update([
'status' => 'pending',
'failure_code' => null,
'failure_message' => null,
]);
EnrichContactResearch::dispatch($record->id);
}
}
fclose($handle);
$this->info('Import accepted; enrichment jobs were dispatched.');
return self::SUCCESS;
}
}
<?php
// app/Jobs/EnrichContactResearch.php
namespace App\Jobs;
use App\Models\ContactResearch;
use App\Services\WebsiteCompany\WebsiteCompanyClient;
use App\Services\WebsiteCompany\WebsiteCompanyException;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class EnrichContactResearch implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 1;
public int $timeout = 90;
public int $uniqueFor = 300;
public function __construct(public readonly int $researchId) {}
public function uniqueId(): string
{
return (string) $this->researchId;
}
public function handle(WebsiteCompanyClient $client): void
{
$record = ContactResearch::findOrFail($this->researchId);
if ($record->status === 'completed') {
return;
}
$record->update(['status' => 'processing']);
try {
$data = $client->extract($record->website);
$record->update([
'status' => 'completed',
'company' => $data->company,
'contact' => $data->contact,
'email' => $data->email,
'phone' => $data->phone,
'people' => $data->people,
'failure_code' => null,
'failure_message' => null,
]);
} catch (WebsiteCompanyException $exception) {
$record->update([
'status' => 'failed',
'failure_code' => $exception->failureCode,
'failure_message' => $exception->getMessage(),
]);
Log::warning('Company research enrichment failed', [
'research_id' => $record->id,
'failure_code' => $exception->failureCode,
]);
}
}
}
Бидејќи клиентот веќе врши ограничени повторни обиди, job-от има еден queue-обид. Ова спречува множителот на повторни обиди неочекувано да претвори три HTTP-обиди во девет. Повторното извршување на увозот повторно испраќа неуспешни записи, додека завршеното истражување останува недопрено.
Извезете го списокот за човечки преглед
Извезете ги зачуваните записи со мала команда што запишува статус и структурирани полиња во CSV. Пред да запишете ќелии, на вредностите што почнуваат со =, +, - или @ додајте апостроф. Тоа спречува вбризгување формули во табеларни пресметки кога надворешната содржина на веб-страници ќе стигне до работната површина на аналитичарот.
<?php
// Core of app/Console/Commands/ExportContactResearch.php
$path = storage_path('app/contact-research.csv');
$handle = fopen($path, 'wb');
fputcsv($handle, [
'website', 'status', 'company', 'contact', 'email',
'phone', 'people', 'failure_code', 'failure_message',
]);
$clean = static function (mixed $value): string {
$text = is_array($value)
? json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: (string) ($value ?? '');
return preg_match('/^[=+\-@]/', $text) ? "'".$text : $text;
};
\App\Models\ContactResearch::query()
->orderBy('id')
->chunk(500, function ($records) use ($handle, $clean): void {
foreach ($records as $record) {
fputcsv($handle, array_map($clean, [
$record->website,
$record->status,
$record->company,
$record->contact,
$record->email,
$record->phone,
$record->people,
$record->failure_code,
$record->failure_message,
]));
}
});
fclose($handle);
Тестирајте го договорот без да ја повикате услугата
Laravel HTTP fake ги прави тестовите детерминистички и осигурува дека ниту еден токен не го напушта тест-процесот. Тестирајте ги и мапирањето и патеката за автентикација што не се повторува.
<?php
// tests/Feature/WebsiteCompanyClientTest.php
namespace Tests\Feature;
use App\Services\WebsiteCompany\WebsiteCompanyClient;
use App\Services\WebsiteCompany\WebsiteCompanyException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class WebsiteCompanyClientTest extends TestCase
{
public function test_it_maps_the_service_response(): void
{
config()->set('services.website_company', [
'token' => 'test-token',
'url' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
]);
Http::fake([
'*' => Http::response([
'company' => ['name' => 'Example Company'],
'contact' => ['page' => '/contact'],
'email' => '[email protected]',
'phone' => '+1 555 0100',
'people' => [['name' => 'Alex Example']],
], 200),
]);
$data = app(WebsiteCompanyClient::class)->extract('https://example.com');
$this->assertSame('[email protected]', $data->email);
$this->assertSame('Example Company', $data->company['name']);
Http::assertSent(fn ($request) =>
$request['token'] === 'test-token'
&& $request['website'] === 'https://example.com'
);
}
public function test_authentication_failure_is_not_retried(): void
{
config()->set('services.website_company', [
'token' => 'invalid-token',
'url' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
]);
Http::fake(['*' => Http::response([], 401)]);
try {
app(WebsiteCompanyClient::class)->extract('https://example.com');
$this->fail('Expected an authentication exception.');
} catch (WebsiteCompanyException $exception) {
$this->assertSame('authentication', $exception->failureCode);
}
Http::assertSentCount(1);
}
}
Распоредете и управувајте со процесот
Извршете php artisan test, а потоа кеширајте ја продукциската конфигурација со php artisan config:cache. Стартувајте надгледуван queue worker со php artisan queue:work --timeout=90. Процесниот супервизор треба да ги рестартира worker-ите по распоредувања, а php artisan queue:restart им овозможува на постојните worker-и безбедно да излезат по нивните тековни job-ови.
Следете ги броевите групирани по статус, староста на редовите што се на чекање или во обработка, пропусната моќ на job-ови, HTTP-кодовите на неуспех и резултатите rate_limited поврзани со квоти. Испраќајте предупредувања за трајни неуспеси наместо за единечен привремен одговор. Запис оставен во processing по прекин на worker треба да се врати во pending со експлицитна команда за одржување или оперативен прирачник.
Заштитете го токенот како тајна, ограничете го пристапот до извезените контакт-податоци, дефинирајте период на задржување и истражувајте само јавни веб-страници за легитимна цел. Избегнувајте евидентирање сурови одговори: контакт-податоците може да се чувствителни, а токенот автентициран преку параметар за барање никогаш не смее да се појави во логови на апликацијата или траги за следење.
Вообичаени неуспеси
- Неуспеси на автентикација: потврдете ја активацијата на планот и ажурирајте ја околината по повторно генерирање на токенот.
- Неуспеси на валидација: проверете дали влезот е апсолутна јавна HTTP или HTTPS URL-адреса.
- Ограничувања на стапката: намалете ја конкурентноста на worker-ите или паузирајте ги увозите; не создавајте неограничен циклус на повторни обиди.
- Неважечки одговори: задржете го неуспешниот запис и истражете без да ја ослабите валидацијата на границата.
- Не се извршуваат job-ови: потврдете ги queue-конекцијата, worker-процесот, кеш-backend-от што се користи за заклучувања на уникатни job-ови и супервизорот за распоредување.
Конечна контролна листа за верификација
- Планот на услугата е активен, а токенот ограничен на услугата е достапен само преку конфигурација на околината.
- Минималното GET-барање успева со потребните параметри за барање
tokenиwebsite. php artisan migrateиphp artisan testзавршуваат успешно.- CSV со заглавен ред
websiteсе увезува соphp artisan research:import companies.csv. - Queue worker-от ги преместува записите од чекање во завршени или во видлив структуриран неуспех.
- Извезениот CSV содржи компанија, контакт, е-пошта, телефон, лица и статус на преглед без да го открива токенот.
Важниот резултат не е само тоа што API-то вратило контакт-податоци. Тоа е дека секој ред во табелата сега има следлив животен циклус: валидиран влез, ограничена работа за збогатување, дефанзивно мапиран излез, објаснива состојба на неуспех и безбеден артефакт што човек може да го прегледа. Тоа е разликата меѓу ветувачко демо за интеграција и истражувачка алатка со која мал тим може самоуверено да управува.