Laravel: Претворете ги списоците на веб-страници во истражување на контакти со помош на ВИ
Табела полна со веб-страници на компании изгледа како податоци, но всушност е редица од неодговорени прашања. Кој стои зад секоја страница? Кои контактни детали се јавно достапни? Кои редови успеале, кои не успеале и за кои е потребна човечка проценка?
Овој туторијал гради Laravel работен тек ориентиран кон продукција кој увезува URL-адреси на веб-страници од CSV, ги обработува асинхроно преку услуга за податоци од веб-страница до компанија и ги прикажува структурираните резултати на автентициран екран за преглед. Важниот дизајнерски избор е збогатувањето да се третира како подложна на неуспех позадинска работа, а не како HTTP-барање што мора да успее додека корисникот чека.
Обезбедете пристап до услугата пред да пишувате интеграциски код
Прво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате.
Отворете ја страницата на услугата за податоци од веб-страница до компанија. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација. Потоа отворете ја официјалната документација за услугата, пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
Оваа услуга не е без токен: за секое барање е потребен тој акредитив. Повторното генерирање на токенот го поништува претходно активниот токен, па ротирањето на токени мора да вклучува ажурирање на околината на апликацијата и рестартирање на работниците што ја кешираат конфигурацијата.
Точната интеграција е HTTP GET барање до https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Автентикацијата го користи параметарот за барање token, додека веб-страницата на компанијата се доставува како website. Пред да ја изградите апликацијата, потврдете го пристапот со акредитив со место за замена:
curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN' \
--data-urlencode 'website=https://example.com'
Не ставајте го вистинскиот токен во контрола на изворен код, историја на школка споделена со други, слики од екранот, дневници или тестни фикстури. Ставете го во околината за распоредување или во непредадената Laravel .env датотека:
MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
Изложете го на апликацијата преку config/services.php. Задржувањето на пристапот до околината во конфигурацијата е важно бидејќи Laravel конфигурацијата може да биде кеширана во продукција.
'website_company' => [
'endpoint' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
'token' => env('MIHAJLO_WEBSITE_COMPANY_TOKEN'),
],
Архитектура: увезете, збогатете, па прегледајте
Влезната табела треба да се извезе како UTF-8 CSV со заглавие именувано website. Artisan команда ја проверува и канонизира секоја URL-адреса, зачувува еден запис за истражување и испраќа задача во редица. Задачата повикува наменски API-клиент, го мапира одговорот на границата на апликацијата и зачувува или структурирани податоци или структурирана состојба на неуспех. Автентициран контролер ги прикажува записите за преглед.
Релевантните датотеки се app/Console/Commands/ImportCompanyWebsites.php, app/Jobs/ResearchWebsite.php, app/Services/WebsiteCompanyClient.php, app/Data/CompanyProfile.php, app/Models/ContactResearch.php и app/Http/Controllers/ContactResearchController.php.
Една задача во редица по веб-страница бара повеќе механика отколку синхрона јамка, но изолира бавни или повици со ограничена стапка, поддржува ограничени повторни обиди и спречува еден расипан ред да напушти цела табела.
Зачувајте ги и резултатите и состојбите на неуспех
Не претпоставувајте дека полињата како company или people секогаш имаат една недокументирана внатрешна форма. Дадениот договор ги гарантира имињата на тие полиња, но не и секоја вгнездена вредност. JSON колоните ја зачувуваат одбранбено нормализираната гранична претстава.
<?php
// database/migrations/xxxx_xx_xx_create_contact_research_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_research', function (Blueprint $table): void {
$table->id();
$table->text('website');
$table->char('website_hash', 64)->unique();
$table->string('status', 20)->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', 40)->nullable();
$table->text('failure_detail')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contact_research');
}
};
<?php
// app/Models/ContactResearch.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class ContactResearch extends Model
{
protected $table = 'contact_research';
protected $fillable = [
'website', 'website_hash', 'status', 'company', 'contact',
'email', 'phone', 'people', 'failure_code', 'failure_detail',
];
protected function casts(): array
{
return [
'company' => 'array',
'contact' => 'array',
'email' => 'array',
'phone' => 'array',
'people' => 'array',
];
}
}
Мапирајте го оддалечениот одговор на една граница
API-клиентот треба да биде единствената компонента на апликацијата што ги разбира имињата на оддалечените полиња, HTTP-статусите, временските ограничувања и автентикацијата. DTO подолу го претвора секое ветено поле во низа. Скалари стануваат именувана вредност, низите ги задржуваат своите клучеви, а недостасувачките или неупотребливите вредности стануваат празни низи.
<?php
// app/Data/CompanyProfile.php
namespace App\Data;
final readonly class CompanyProfile
{
public function __construct(
public array $company,
public array $contact,
public array $email,
public array $phone,
public array $people,
) {}
public static function fromApi(array $payload): self
{
$field = static function (mixed $value): array {
if (is_array($value)) {
return $value;
}
if (is_string($value) || is_int($value)
|| is_float($value) || is_bool($value)) {
return ['value' => $value];
}
return [];
};
return new self(
$field($payload['company'] ?? null),
$field($payload['contact'] ?? null),
$field($payload['email'] ?? null),
$field($payload['phone'] ?? null),
$field($payload['people'] ?? null),
);
}
public function toDatabase(): array
{
return get_object_vars($this);
}
}
Користете кратки рокови за поврзување и одговор. Неуспесите при автентикација и валидација се детерминистички и не смеат да се повторуваат. Неуспесите при поврзување и грешките на серверот добиваат еден непосреден повторен обид; подолгото опоравување ѝ припаѓа на редицата. Ограничувањето на стапката се предава директно на редицата, по избор почитувајќи нумеричко заглавие Retry-After.
<?php
// app/Services/WebsiteCompanyClient.php
namespace App\Services;
use App\Data\CompanyProfile;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class ApiFailure extends RuntimeException
{
public function __construct(
public readonly string $failureCode,
public readonly bool $retryable,
public readonly ?int $retryAfter = null,
string $message = 'Website research failed',
) {
parent::__construct($message);
}
}
final class WebsiteCompanyClient
{
public function extract(string $website): CompanyProfile
{
$token = (string) config('services.website_company.token');
$endpoint = (string) config('services.website_company.endpoint');
if ($token === '') {
throw new ApiFailure('configuration', false, null, 'Service token is missing');
}
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(5)
->timeout(20)
->get($endpoint, [
'token' => $token,
'website' => $website,
]);
} catch (ConnectionException $exception) {
if ($attempt === 1) {
usleep(250_000);
continue;
}
throw new ApiFailure('transport', true, null, $exception->getMessage());
}
$status = $response->status();
if (in_array($status, [401, 403], true)) {
throw new ApiFailure('authentication', false);
}
if (in_array($status, [400, 422], true)) {
throw new ApiFailure('invalid_request', false);
}
if ($status === 429) {
$header = $response->header('Retry-After');
$delay = ctype_digit((string) $header)
? min(900, max(30, (int) $header))
: 60;
throw new ApiFailure('rate_limited', true, $delay);
}
if ($status >= 500 && $attempt === 1) {
usleep(250_000);
continue;
}
if ($response->failed()) {
throw new ApiFailure('upstream_http', $status >= 500);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new ApiFailure('invalid_response', false);
}
return CompanyProfile::fromApi($payload);
}
throw new ApiFailure('upstream_http', true);
}
}
Увезете ја табелата без дуплирање работа
Увозникот го сведува секој запис на неговото HTTP или HTTPS потекло. Тоа го прави откривањето дупликати предвидливо и избегнува препраќање патеки, фрагменти, акредитиви или низи за барање. Исто така отфрла localhost и буквални приватни или резервирани IP-адреси. Ако CSV-прикачувањата се изложени преку прелистувач, додадете овластување, ограничувања на големината на датотеката, изолација на складирањето и построги правила за домени специфични за организацијата.
<?php
// app/Console/Commands/ImportCompanyWebsites.php
namespace App\Console\Commands;
use App\Jobs\ResearchWebsite;
use App\Models\ContactResearch;
use Illuminate\Console\Command;
final class ImportCompanyWebsites extends Command
{
protected $signature = 'contacts:import {file}';
protected $description = 'Queue company websites from a UTF-8 CSV file';
public function handle(): int
{
$handle = fopen((string) $this->argument('file'), 'rb');
if ($handle === false || ($header = fgetcsv($handle)) === false) {
$this->error('The CSV cannot be opened or has no header.');
return self::FAILURE;
}
$header = array_map(
fn ($value) => strtolower(trim((string) $value, "\xEF\xBB\xBF \t\n\r\0\x0B")),
$header
);
$index = array_search('website', $header, true);
if ($index === false) {
fclose($handle);
$this->error('A website header is required.');
return self::FAILURE;
}
$queued = 0;
while (($row = fgetcsv($handle)) !== false) {
$website = $this->origin((string) ($row[$index] ?? ''));
if ($website === null) {
$this->warn('Skipped an invalid website value.');
continue;
}
$record = ContactResearch::firstOrCreate(
['website_hash' => hash('sha256', $website)],
['website' => $website, 'status' => 'pending'],
);
if ($record->wasRecentlyCreated || $record->status === 'failed') {
$record->update([
'status' => 'pending',
'failure_code' => null,
'failure_detail' => null,
]);
ResearchWebsite::dispatch($record->id);
$queued++;
}
}
fclose($handle);
$this->info("Queued {$queued} website(s).");
return self::SUCCESS;
}
private function origin(string $input): ?string
{
$input = trim($input);
if ($input !== '' && ! str_contains($input, '://')) {
$input = 'https://'.$input;
}
if (filter_var($input, FILTER_VALIDATE_URL) === false) {
return null;
}
$parts = parse_url($input);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if (! in_array($scheme, ['http', 'https'], true)
|| $host === '' || isset($parts['user']) || isset($parts['pass'])
|| $host === 'localhost' || str_ends_with($host, '.local')) {
return null;
}
if (filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
return null;
}
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
return "{$scheme}://{$host}{$port}";
}
}
Извршувајте го збогатувањето како ограничена задача во редица
Задачата бележи само идентификатор за истражување, па содржините во редицата не содржат ниту токен за услугата ниту застарена снимка на моделот. Ограничувањата на стапката ја ослободуваат задачата со ограничено доцнење. Другите минливи неуспеси користат одложување на редицата. Трајните неуспеси остануваат достапни за преглед без да ја загадуваат редицата.
<?php
// app/Jobs/ResearchWebsite.php
namespace App\Jobs;
use App\Models\ContactResearch;
use App\Services\ApiFailure;
use App\Services\WebsiteCompanyClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ResearchWebsite implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public function __construct(public readonly int $researchId) {}
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(WebsiteCompanyClient $client): void
{
$record = ContactResearch::findOrFail($this->researchId);
if ($record->status === 'complete') {
return;
}
$record->update(['status' => 'processing']);
try {
$profile = $client->extract($record->website);
$record->update($profile->toDatabase() + [
'status' => 'complete',
'failure_code' => null,
'failure_detail' => null,
]);
} catch (ApiFailure $failure) {
$record->update([
'status' => $failure->retryable ? 'pending' : 'failed',
'failure_code' => $failure->failureCode,
'failure_detail' => $failure->getMessage(),
]);
Log::warning('Website research request failed', [
'research_id' => $record->id,
'host' => parse_url($record->website, PHP_URL_HOST),
'failure_code' => $failure->failureCode,
'retryable' => $failure->retryable,
]);
if ($failure->retryable && $failure->retryAfter !== null) {
$this->release($failure->retryAfter);
return;
}
if ($failure->retryable) {
throw $failure;
}
}
}
public function failed(?Throwable $exception): void
{
ContactResearch::whereKey($this->researchId)->update([
'status' => 'failed',
'failure_code' => 'retries_exhausted',
'failure_detail' => 'Transient failure persisted after bounded retries.',
]);
}
}
Додајте автентициран екран за преглед
Контролерот намерно враќа зачувани податоци наместо да контактира со услугата при прикажување на страницата. Ставете ја рутата зад постојниот систем за автентикација на апликацијата, за истражувањето на контакти да не биде случајно објавено.
<?php
// app/Http/Controllers/ContactResearchController.php
namespace App\Http\Controllers;
use App\Models\ContactResearch;
use Illuminate\View\View;
final class ContactResearchController extends Controller
{
public function index(): View
{
return view('contact-research.index', [
'rows' => ContactResearch::query()
->latest('updated_at')
->paginate(50),
]);
}
}
// routes/web.php
use App\Http\Controllers\ContactResearchController;
use Illuminate\Support\Facades\Route;
Route::get('/contact-research', [ContactResearchController::class, 'index'])
->middleware('auth')
->name('contact-research.index');
<!-- resources/views/contact-research/index.blade.php -->
<h2>Contact research</h2>
<ol>
@forelse ($rows as $row)
<li>
<p><strong>{{ $row->website }}</strong>: {{ $row->status }}</p>
<p>Company: {{ json_encode($row->company, JSON_UNESCAPED_SLASHES) }}</p>
<p>Contact: {{ json_encode($row->contact, JSON_UNESCAPED_SLASHES) }}</p>
<p>Email: {{ json_encode($row->email, JSON_UNESCAPED_SLASHES) }}</p>
<p>Phone: {{ json_encode($row->phone, JSON_UNESCAPED_SLASHES) }}</p>
<p>People: {{ json_encode($row->people, JSON_UNESCAPED_SLASHES) }}</p>
@if ($row->failure_code)
<p>Failure: {{ $row->failure_code }}</p>
@endif
</li>
@empty
<li>No research has been imported.</li>
@endforelse
</ol>
{{ $rows->links() }}
Тестирајте ја границата без вистински барања
Laravel HTTP fake ги прави мапирањето на одговорот и политиката за повторни обиди детерминистички. Овие тестови докажуваат дека сите пет договорени полиња се мапирани и дека неуспех при автентикација не се повторува.
<?php
// tests/Unit/WebsiteCompanyClientTest.php
namespace Tests\Unit;
use App\Services\ApiFailure;
use App\Services\WebsiteCompanyClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class WebsiteCompanyClientTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.website_company', [
'endpoint' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
'token' => 'test-token',
]);
}
public function test_it_maps_the_contracted_fields(): void
{
Http::fake([
'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*'
=> Http::response([
'company' => ['name' => 'Example Ltd'],
'contact' => ['page' => '/contact'],
'email' => '[email protected]',
'phone' => null,
'people' => [['name' => 'Alex']],
], 200),
]);
$profile = app(WebsiteCompanyClient::class)
->extract('https://example.com');
$this->assertSame(['name' => 'Example Ltd'], $profile->company);
$this->assertSame(['value' => '[email protected]'], $profile->email);
$this->assertSame([], $profile->phone);
$this->assertSame([['name' => 'Alex']], $profile->people);
Http::assertSentCount(1);
}
public function test_it_does_not_retry_authentication_failures(): void
{
Http::fake(fn () => Http::response([], 401));
try {
app(WebsiteCompanyClient::class)->extract('https://example.com');
$this->fail('Expected an API failure.');
} catch (ApiFailure $failure) {
$this->assertSame('authentication', $failure->failureCode);
$this->assertFalse($failure->retryable);
}
Http::assertSentCount(1);
}
}
Распоредете и управувајте со работниот тек
Применете миграции, кеширајте ја конфигурацијата откако ќе го внесете вистинскиот токен и рестартирајте ги долготрајните работници секогаш кога се менуваат конфигурацијата или кодот. Осигурете се дека миграциите за задачи и неуспешни задачи на редицата во базата на податоци постојат во апликацијата.
php artisan test
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan contacts:import storage/app/imports/companies.csv
php artisan queue:work --queue=default --timeout=60 --tries=4 --max-time=3600
Извршувајте го работникот под надзорник на процеси, наместо во интерактивна школка. Следете ја длабочината на редицата, неуспешните задачи, броевите по статус на истражување, неуспесите при автентикација, ограничувањата на стапката и латентноста. Дневниците треба да содржат идентификатори на записи, домаќини, класи на статус и кодови на неуспех, но никогаш токени, целосни тела на одговори или содржина на табели.
Вообичаени неуспеси
- Секој ред пријавува автентикација: потврдете ја активацијата на планот и распоредениот токен. Ако бил повторно генериран, заменете ја поништената вредност и повторно изградете го кешот на конфигурацијата.
- Редовите остануваат на чекање: потврдете дека работник во редица работи со истата врска на редица и околина како веб-апликацијата.
- Само некои редови се ограничени по стапка: дозволете одложувањето на редицата да работи; не стартувајте повеќе работници како автоматски одговор.
- CSV не увезува ништо: потврдете UTF-8 кодирање и точно заглавие
website. - Успешен одговор станува invalid_response: проверете безбедно снимен структуриран преглед, а не чувствителни вредности на одговорот, и приспособете го само граничниот мапер по консултација со официјалната документација.
Контролна листа за конечна потврда
- Планот за услугата е активен и тековниот токен ограничен на услугата постои само во конфигурација поддржана од околината.
- Минималното барање стигнува до точната крајна точка
GETсо параметрите за барањеtokenиwebsite. - CSV што содржи валидни, дуплирани и невалидно форматирани веб-страници создава очекувани броеви на задачи во редица и прескокнати записи.
- Работниците ги зачувуваат
company,contact,email,phoneиpeopleбез да претпоставуваат недокументирани вгнездени форми. - Неуспесите при автентикација и валидација запираат веднаш, додека минливите неуспеси користат ограничени повторни обиди и одложување.
- Автентицираната страница за преглед прикажува завршени, записи на чекање и неуспешни записи без оддалечени повици.
- Тестовите поминуваат со
Http::fake(), а дневниците не содржат акредитиви или содржина на одговори.
Табелата повеќе не е кревко сериско барање. Таа стана мала, набљудлива истражувачка линија: секоја веб-страница има трајна состојба, секој оддалечен одговор преминува преку заштитена граница и секој неизвесен резултат останува достапен за човечки преглед. Токму таа комбинација од автоматизација и можност за проверка го прави збогатувањето на контактите корисно во продукција, наместо само импресивно во демо.