Laravel: Pretvorite popise web-stranica u istraživanje kontakata uz pomoć umjetne inteligencije
Proračunska tablica puna web-mjesta tvrtki izgleda kao podaci, ali zapravo je red neodgovorenih pitanja. Tko stoji iza svakog mjesta? Koji su kontaktni podaci javno dostupni? Koji su redci uspjeli, koji nisu i koji zahtijevaju ljudsku procjenu?
Ovaj vodič izrađuje radni tijek Laravel usmjeren na produkciju koji uvozi URL-ove web-mjesta iz CSV-a, obrađuje ih asinkrono putem usluge za podatke od web-mjesta do tvrtke i prikazuje strukturirane rezultate na autentificiranom zaslonu za pregled. Važan dizajnerski odabir jest da se obogaćivanje tretira kao pozadinski rad koji može zakazati, a ne kao HTTP zahtjev koji mora uspjeti dok korisnik čeka.
Pribavite pristup usluzi prije pisanja integracijskog koda
Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate.
Otvorite stranicu usluge Website to Company data. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju. Zatim otvorite službenu dokumentaciju usluge, pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ova usluga nije bez tokena: svaki zahtjev treba tu vjerodajnicu. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje okruženja aplikacije i ponovno pokretanje radnika koji predmemoriraju konfiguraciju.
Točna integracija je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentifikacija upotrebljava parametar upita token, dok se web-mjesto tvrtke navodi kao website. Prije izrade aplikacije provjerite pristup vjerodajnicom rezerviranog mjesta:
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'
Ne stavljajte stvarni token u kontrolu izvornog koda, povijest ljuske dijeljenu s drugima, snimke zaslona, zapisnike ili testne pripreme. Smjestite ga u okruženje implementacije ili u nepredanu Laravel .env datoteku:
MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
Izložite ga aplikaciji putem config/services.php. Zadržavanje pristupa okruženju unutar konfiguracije važno je jer se Laravel konfiguracija može predmemorirati u produkciji.
'website_company' => [
'endpoint' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
'token' => env('MIHAJLO_WEBSITE_COMPANY_TOKEN'),
],
Arhitektura: uvoz, obogaćivanje, zatim pregled
Ulaznu proračunsku tablicu treba izvesti kao UTF-8 CSV sa zaglavljem nazvanim website. Artisan naredba provjerava i kanonikalizira svaki URL, pohranjuje jedan zapis istraživanja i šalje posao u red čekanja. Posao poziva namjenskog API klijenta, mapira odgovor na granici aplikacije i zadržava strukturirane podatke ili strukturirano stanje neuspjeha. Autentificirani kontroler prikazuje zapise za pregled.
Relevantne datoteke su app/Console/Commands/ImportCompanyWebsites.php, app/Jobs/ResearchWebsite.php, app/Services/WebsiteCompanyClient.php, app/Data/CompanyProfile.php, app/Models/ContactResearch.php i app/Http/Controllers/ContactResearchController.php.
Jedan posao u redu čekanja po web-mjestu zahtijeva više mehanizacije od sinkrone petlje, ali izolira spore ili pozivе ograničene stopom, podržava ograničene ponovne pokušaje i sprječava da jedan neispravan red napusti cijelu proračunsku tablicu.
Pohranite i rezultate i stanja neuspjeha
Nemojte pretpostavljati da polja kao što su company ili people uvijek imaju jedan nedokumentirani unutarnji oblik. Dostavljeni ugovor jamči nazive tih polja, a ne svaku ugniježđenu vrijednost. JSON stupci čuvaju obrambeno normaliziran prikaz granice.
<?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',
];
}
}
Mapirajte udaljeni odgovor na jednoj granici
API klijent treba biti jedina komponenta aplikacije koja razumije nazive udaljenih polja, HTTP statuse, vremenska ograničenja i autentifikaciju. Donji DTO pretvara svako obećano polje u niz. Skalari postaju imenovana vrijednost, nizovi zadržavaju svoje ključeve, a nedostajuće ili neupotrebljive vrijednosti postaju prazni nizovi.
<?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);
}
}
Upotrijebite kratke rokove za povezivanje i odgovor. Neuspjesi autentifikacije i validacije deterministički su i ne smiju se ponavljati. Neuspjesi povezivanja i pogreške poslužitelja dobivaju jedan trenutačni ponovni pokušaj; dulji oporavak pripada redu čekanja. Ograničenje stope predaje se izravno redu čekanja, uz moguće poštovanje numeričkog zaglavlja 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);
}
}
Uvezite proračunsku tablicu bez dupliciranja rada
Uvoznik svaku stavku svodi na njezino HTTP ili HTTPS ishodište. To čini otkrivanje duplikata predvidljivim i izbjegava prosljeđivanje putanja, fragmenata, vjerodajnica ili nizova upita. Također odbacuje localhost te doslovne privatne ili rezervirane IP adrese. Ako su CSV prijenosi izloženi kroz preglednik, dodajte autorizaciju, ograničenja veličine datoteke, izolaciju pohrane i stroža pravila domena specifična za organizaciju.
<?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}";
}
}
Pokrenite obogaćivanje kao ograničen posao reda čekanja
Posao bilježi samo identifikator istraživanja, pa korisni tereti u redu čekanja ne sadrže ni token usluge ni zastarjelu snimku modela. Ograničenja stope otpuštaju posao uz ograničeno kašnjenje. Ostali prolazni neuspjesi upotrebljavaju odgodu reda čekanja. Trajni neuspjesi ostaju dostupni za pregled bez trovanja reda čekanja.
<?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.',
]);
}
}
Dodajte autentificirani zaslon za pregled
Kontroler namjerno vraća pohranjene podatke umjesto kontaktiranja usluge tijekom iscrtavanja stranice. Postavite rutu iza postojećeg sustava autentifikacije aplikacije kako istraživanje kontakata ne bi bilo slučajno objavljeno.
<?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() }}
Testirajte granicu bez stvarnih zahtjeva
Laravelov HTTP lažnjak čini mapiranje odgovora i pravilo ponovnog pokušaja determinističkim. Ovi testovi dokazuju da je mapirano svih pet ugovorenih polja i da se neuspjeh autentifikacije ne ponavlja.
<?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);
}
}
Implementirajte i upravljajte radnim tijekom
Primijenite migracije, predmemorirajte konfiguraciju nakon umetanja stvarnog tokena i ponovno pokrenite dugotrajne radnike kad god se promijeni konfiguracija ili kod. Osigurajte da u aplikaciji postoje migracije za poslove i neuspjele poslove reda čekanja baze podataka.
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
Pokrenite radnika pod nadzorom procesa, a ne u interaktivnoj ljusci. Nadzirite dubinu reda čekanja, neuspjele poslove, brojanja prema statusu istraživanja, neuspjehe autentifikacije, ograničenja stope i latenciju. Zapisnici trebaju sadržavati identifikatore zapisa, hostove, klase statusa i kodove neuspjeha, ali nikada tokene, puna tijela odgovora ili sadržaj proračunske tablice.
Uobičajeni neuspjesi
- Svaki red prijavljuje autentifikaciju: potvrdite aktivaciju plana i implementirani token. Ako je ponovno generiran, zamijenite opozvanu vrijednost i ponovno izgradite predmemoriju konfiguracije.
- Redci ostaju na čekanju: provjerite radi li radnik reda čekanja prema istoj vezi reda čekanja i okruženju kao web-aplikacija.
- Samo su neki redci ograničeni stopom: pustite da odgoda reda čekanja radi; nemojte automatski pokretati više radnika kao odgovor.
- CSV ne uvozi ništa: provjerite UTF-8 kodiranje i točno zaglavlje
website. - Uspješan odgovor postaje invalid_response: pregledajte sigurno zabilježen strukturni sažetak, a ne osjetljive vrijednosti odgovora, i prilagodite samo granični mapper nakon savjetovanja sa službenom dokumentacijom.
Završni kontrolni popis za provjeru
- Plan usluge aktivan je, a trenutačni token ograničen na uslugu postoji samo u konfiguraciji podržanoj okruženjem.
- Minimalni zahtjev doseže točnu krajnju točku
GETs parametrima upitatokeniwebsite. - CSV koji sadrži valjana, duplicirana i neispravna web-mjesta daje očekivani broj stavki u redu čekanja i preskočenih stavki.
- Radnici pohranjuju
company,contact,email,phoneipeoplebez pretpostavljanja nedokumentiranih ugniježđenih oblika. - Neuspjesi autentifikacije i validacije odmah se zaustavljaju, dok prolazni neuspjesi upotrebljavaju ograničene ponovne pokušaje i odgodu.
- Autentificirana stranica za pregled prikazuje dovršene retke, retke na čekanju i neuspjele retke bez udaljenih poziva.
- Testovi prolaze s
Http::fake(), a zapisnici ne sadrže vjerodajnice ni korisni teret odgovora.
Proračunska tablica više nije krhki skupni zahtjev. Postala je mali, uočljivi istraživački cjevovod: svako web-mjesto ima trajno stanje, svaki udaljeni odgovor prelazi zaštićenu granicu, a svaki nesiguran rezultat ostaje dostupan za ljudski pregled. Ta kombinacija automatizacije i mogućnosti provjere čini obogaćivanje kontakata korisnim u produkciji, a ne samo dojmljivim u demonstraciji.