Vodiči

Laravel + AI: Transform Website Lists into Actionable Contact Research

Laravel + AI: Pretvorite popise web-stranica u primjenjiva istraživanja kontakata

Proračunska tablica puna web-stranica tvrtki izgleda korisno sve dok netko ne mora otvoriti svaki redak, pronaći podatke za kontakt i pretvoriti nedosljedan sadržaj stranica u strukturirano istraživanje. Taj je ručni postupak spor, teško ga je revidirati, a osobito je frustrirajući kada se popis mijenja svaki tjedan.

Ovaj vodič izrađuje produkcijski usmjeren Laravelov cjevovod koji uvozi URL-ove web-stranica iz CSV-a, obogaćuje svaku web-stranicu putem podatkovnog API-ja Website to Company, pohranjuje normalizirane rezultate i izvozi proračunsku tablicu za istraživanje kontakata koja se može pregledati. Radnici reda čekanja drže velike uvoze podalje od vremenskih ograničenja zahtjeva, dok eksplicitna stanja neuspjeha čine nepotpune zapise vidljivima umjesto da se tiho izgube.

Dobijte pristup prije pisanja integracijskog koda

Izradite račun na https://ai.mihajlo.mk/register, ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.

  1. Otvorite stranicu usluge Website to Company data.
  2. Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token za tu uslugu.
  5. Pohranite ga u Laravelovu konfiguraciju koja se oslanja na varijable okruženja. Nemojte ga predavati u repozitorij, ispisivati ni uključivati u testne fixturee.

Ova usluga zahtijeva token. Njegova regeneracija opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje svakog implementiranog okruženja koje koristi staru vrijednost.

Točan je poziv GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Autentikacija upotrebljava parametar upita token, a web-stranica se dostavlja putem parametra upita website. Testirajte vjerodajnicu javnom web-stranicom za čije ste istraživanje ovlašteni:

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"

Stavite token u projektovu nepredanu datoteku .env:

MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MIHAJLO_WEBSITE_COMPANY_URL=https://ai.mihajlo.mk/api/website-to-company-data/v1/extract

Izložite te vrijednosti putem config/services.php. Čitanje env() samo iz konfiguracijskih datoteka osigurava da integracija nastavi raditi nakon što se Laravelova konfiguracija predmemorira.

<?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'
        ),
    ],
];

Odaberite malu, trajnu arhitekturu

Ulaz će biti UTF-8 CSV datoteka čije zaglavlje sadrži website. CSV se namjerno upotrebljava umjesto dodavanja XLSX paketa: svaka glavna aplikacija za proračunske tablice može ga izvesti, PHP ga može izvorno raščlaniti, a rezultirajući je uvoz lako testirati.

Cjevovod ima četiri odgovornosti:

  • Artisan naredba provjerava retke i izrađuje jedan zapis baze podataka po normaliziranoj web-stranici.
  • Posao reda čekanja obavlja obogaćivanje izvan procesa naredbe.
  • Namjenski API klijent upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima i mapiranjem odgovora.
  • Druga naredba izvozi dovršene i neuspjele retke za ljudski pregled.

Izradite projekt s PHP-om 8.3 ili novijim i trenutačno podržanim izdanjem Laravela. Konfigurirajte vezu s bazom podataka i redom čekanja, zatim pokrenite php artisan queue:table ako vaša aplikacija upotrebljava red čekanja u bazi podataka. Generirajte migraciju, model, naredbe, posao i klase usluge uobičajenim Laravelovim naredbama make:, ili izravno izradite sljedeće datoteke.

Ustrajte na stanjima koja se mogu pregledati, a ne samo na uspješnim podacima

Zapis istraživanja mora razlikovati rad na čekanju, u obradi, dovršen i neuspješan rad. Također treba zadržati strojno čitljiv kod neuspjeha i sigurnu dijagnostičku poruku.

<?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',
        ];
    }
}

Pokrenite php artisan migrate nakon pregleda migracije za svoju platformu baze podataka.

Izgradite obrambenu API granicu

Vanjski JSON ne bi smio izravno prodirati u ostatak aplikacije. Isporučeni ugovor navodi company, contact, email, phone i people, no integracije i dalje trebaju odbiti odgovor koji nije objekt te tolerirati pojedinačne vrijednosti koje mogu biti null ili neočekivanog oblika.

<?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 klijent upotrebljava ograničena vremenska ograničenja za povezivanje i odgovor. Ponovno pokušava nakon neuspjeha povezivanja, ograničenja brzine i pogrešaka poslužitelja uz ograničeno odgađanje, ali nikad ne pokušava ponovno nakon neuspjeha autentikacije ili validacije zahtjeva. Također izbjegava zapisivanje URL-a zahtjeva jer se potrebna vjerodajnica pojavljuje u njegovu nizu upita.

<?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.');
    }
}

Uvezite web-stranice i obogatite ih u redu čekanja

Naredba za uvoz neispravne URL-ove tretira kao pogreške na razini retka. Prihvaća samo apsolutne HTTP ili HTTPS URL-ove s hostom, čime sprječava da vrijednosti poput lokalnih putanja datoteka uđu u tijek rada.

<?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,
            ]);
        }
    }
}

Budući da klijent već obavlja ograničene ponovne pokušaje, posao ima jedan pokušaj u redu čekanja. Time se sprječava da množitelj ponovnih pokušaja neočekivano pretvori tri HTTP pokušaja u devet. Ponovno pokretanje uvoza ponovno šalje neuspjele zapise, a dovršena istraživanja ostavlja netaknutima.

Izvezite popis za ljudski pregled

Izvezite pohranjene zapise malom naredbom koja zapisuje status i strukturirana polja u CSV. Prije zapisivanja ćelija vrijednostima koje počinju s =, +, - ili @ dodajte apostrof kao prefiks. To sprječava ubacivanje formula u proračunske tablice kada sadržaj vanjske web-stranice dosegne radnu površinu analitičara.

<?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);

Testirajte ugovor bez pozivanja usluge

Laravelov HTTP lažni klijent čini testove determinističkima i osigurava da nijedan token ne napusti testni proces. Testirajte i mapiranje i put autentikacije koji se ne može ponovno pokušati.

<?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);
    }
}

Implementirajte i upravljajte cjevovodom

Pokrenite php artisan test, zatim predmemorirajte produkcijsku konfiguraciju s php artisan config:cache. Pokrenite nadzirani radnik reda čekanja pomoću php artisan queue:work --timeout=90. Nadglednik procesa trebao bi ponovno pokrenuti radnike nakon implementacija, a php artisan queue:restart omogućuje postojećim radnicima siguran izlaz nakon trenutačnih poslova.

Pratite broj zapisa grupiranih prema statusu, starost redaka na čekanju ili u obradi, propusnost poslova, HTTP kodove neuspjeha i rezultate povezane s kvotom rate_limited. Upozoravajte na trajne neuspjehe, a ne na jedan prolazan odgovor. Zapis koji ostane u stanju processing nakon prekida radnika treba eksplicitna naredba održavanja ili operativni priručnik vratiti u stanje pending.

Zaštitite token kao tajnu, ograničite pristup izvezenim podacima o kontaktima, definirajte razdoblje zadržavanja i istražujte samo javne web-stranice u legitimnu svrhu. Izbjegavajte zapisivanje sirovih odgovora: podaci o kontaktima mogu biti osjetljivi, a token autentificiran upitom nikada se ne smije pojaviti u zapisima aplikacije ni nadzornim tragovima.

Uobičajeni neuspjesi

  • Neuspjesi autentikacije: potvrdite aktivaciju paketa i ažurirajte okruženje nakon regeneracije tokena.
  • Neuspjesi validacije: provjerite je li ulaz apsolutni javni HTTP ili HTTPS URL.
  • Ograničenja brzine: smanjite istodobnost radnika ili pauzirajte uvoze; nemojte stvarati neograničenu petlju ponovnih pokušaja.
  • Neispravni odgovori: zadržite neuspjeli zapis i istražite problem bez slabljenja validacije granice.
  • Nijedan se posao ne izvršava: provjerite vezu reda čekanja, proces radnika, pozadinski sustav predmemorije koji se koristi za zaključavanja jedinstvenih poslova i nadglednik implementacije.

Završni popis za provjeru

  • Paket usluge je aktivan, a token za tu uslugu dostupan je samo putem konfiguracije okruženja.
  • Minimalni GET zahtjev uspijeva s obaveznim parametrima upita token i website.
  • php artisan migrate i php artisan test uspješno se dovršavaju.
  • CSV sa zaglavljem website uvozi se pomoću php artisan research:import companies.csv.
  • Radnik reda čekanja premješta zapise iz stanja na čekanju u dovršeno stanje ili u vidljiv strukturirani neuspjeh.
  • Izvezeni CSV sadrži tvrtku, kontakt, e-poštu, telefon, osobe i status pregleda bez otkrivanja tokena.

Važan rezultat nije samo to što je API vratio podatke za kontakt. Riječ je o tome da svaki redak proračunske tablice sada ima sljediv životni ciklus: validirani ulaz, ograničeni rad obogaćivanja, obrambeno mapirani izlaz, objašnjivo stanje neuspjeha i siguran artefakt koji osoba može pregledati. To je razlika između obećavajuće demonstracije integracije i istraživačkog alata kojim mali tim može pouzdano upravljati.

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.