Vodiči

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

Izradite bogat popis za istraživanje kontakata: Laravel + API za podatke s web-stranice na podatke o tvrtki

Proračunska tablica puna web-mjesta tvrtki izgleda kao korisna imovina za pronalaženje potencijalnih klijenata, ali je još uvijek nekoliko koraka udaljena od upotrebljivog popisa za istraživanje. Netko mora posjetiti svaku stranicu, identificirati tvrtku, pronaći dostupne podatke za kontakt, zabilježiti relevantne osobe i sačuvati dovoljno konteksta da čovjek može pregledati rezultat.

Ovaj vodič izrađuje taj tijek rada u Laravelu. Konzolna naredba uvozi CSV datoteku, poslovi u redu obogaćuju svako web-mjesto putem API-ja podataka Website to Company, a druga naredba izvozi CSV pogodan za pregled koji sadrži podatke o tvrtki, kontaktu, e-pošti, telefonu i osobama. Dizajn ostaje namjerno skroman: Laravelov ugrađeni HTTP klijent, red u bazi podataka, strukturirano mapiranje na granici aplikacije i bez nepotrebnih paketa.

Dobijte pristup i kopirajte token usluge

Prije pisanja integracijskog koda izradite račun ili pristupite postojećem. Registrirajte se ovdje ili upotrijebite stranicu za prijavu ako već imate račun.

  1. Otvorite stranicu usluge Website to Company data.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite panel Service token i kopirajte token ograničen na uslugu.

Ova usluga zahtijeva autentifikaciju. Njezin token pripada parametru upita token. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom i ažurirajte svako okruženje koje ga koristi.

Točan zahtjev je GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Provjerite pristup s javnim web-mjestom tvrtke prije izrade aplikacije:

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'

Nemojte lijepiti stvarni token u kontrolu izvornog koda, povijest ljuske dijeljenu s drugima, zapisnike, snimke zaslona ili fixture datoteke. Stavite ga u Laravelovu datoteku okruženja, a u .env.example dodajte samo rezervirano mjesto:

# .env
WEBSITE_TO_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

# .env.example
WEBSITE_TO_COMPANY_TOKEN=

Odaberite malu, otpornu arhitekturu

Ulaznu proračunsku tablicu treba izvesti kao CSV sa zaglavljem website. CSV izbjegava povezivanje aplikacije s određenim uredskim formatom. Svako normalizirano web-mjesto postaje zapis u bazi podataka, a jedan posao u redu izvršava njegovo obogaćivanje. Zapis baze podataka istodobno je trajno stanje tijeka rada i izvor za konačni izvoz.

Pozadinski poslovi ovdje su korisni jer su mrežni pozivi sporiji i manje predvidljivi od parsiranja CSV-a. Također omogućuju operaterima upravljanje propusnošću promjenom broja radnika bez redizajniranja uvoznika. Kompromis je postupno dovršavanje: korisnici prvo uvoze, prate red i izvoze tek nakon što se poslovi ustale.

Relevantna struktura projekta je:

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

Izradite Laravel aplikaciju konfiguriranu za PHP 8.3 ili noviji, zatim generirajte klase uobičajenim Artisan naredbama. Ako aplikacija već ne sadrži migraciju tablice poslova za red u bazi podataka, generirajte je prije migracije.

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

Pohranite stanje tijeka rada i API polja

Migracija pohranjuje svako obavezno područje odgovora kao JSON jer usluga može vratiti strukturirane vrijednosti umjesto jednog niza znakova. Također drži neuspjehe odvojeno od uspješnih podataka, što olakšava pregled djelomičnih izvođenja.

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

U CompanyResearchItem učinite ove stupce popunjivima i pretvorite pet API polja u nizove. Pretvorite reviewed_at u datetime. Zadržavanje neobrađenih, ali validiranih struktura čuva korisne detalje usluge bez propuštanja transportnih pitanja kroz cijelu aplikaciju.

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

Mapirajte nesigurne podatke na granici

Udaljenom JSON-u ne treba vjerovati samo zato što se uspješno dekodirao. Mapper u nastavku prihvaća samo dokumentirana područja najviše razine: company, contact, email, phone i people. Nizovi se zadržavaju; skalarne vrijednosti dosljedno se omataju; objekti ili resursi ne mogu ući u domenski model.

<?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 dostupan je u PHP-u 8.4, a ne u PHP-u 8.3, stoga bi projekt za PHP 8.3 taj uvjet trebao zamijeniti malom petljom. Time se održava iskrenost vodiča o navedenom runtimeu:

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

Izradite ograničeni HTTP klijent

Dodajte konfiguraciju usluge u niz koji vraća config/services.php:

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

Klijent koristi kratka vremenska ograničenja povezivanja i odgovora. Ponovno pokušava nakon neuspjeha povezivanja, HTTP-a 429 i pogrešaka poslužitelja, ali nikada ne pokušava ponovno odgovore autentifikacije ili validacije. Odgoda je ograničena, a brojčana vrijednost Retry-After poštuje se do pet sekundi.

<?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 je mala prilagođena iznimka s javnim svojstvom niza kind i neobaveznom prethodnom iznimkom. Njezine su poruke namjerno sigurne: ni URL upita ni token ne kopiraju se u zapisnike ili retke baze podataka.

Uvezite, obogatite i izvezite

Naredba za uvoz trebala bi pročitati zaglavlje pomoću fgetcsv, pronaći stupac website i odbiti neispravne retke. Prihvatite samo http i https, zahtijevajte naziv glavnog računala i odbijte vjerodajnice, IP literale, localhost, fragmente i nestandardne portove. Normalizirajte uobičajene unose tvrtki u mala slova za shemu i naziv glavnog računala prije upotrebe firstOrCreate.

Pošaljite jedan posao EnrichCompanyWebsite za svaki novi ili prethodno neuspješni zapis. Dodajte opciju --refresh za namjerno ponovno obogaćivanje; u suprotnom bi dovršene i aktivne zapise trebalo preskočiti kako bi se spriječila dvostruka upotreba API-ja.

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

Naredba za izvoz trebala bi zapisivati na Laravelov lokalni disk za pohranu koristeći fputcsv. Ispišite stupce source_url, status, company, contact, email, phone, people i failure_code. Kodirajte svako strukturirano polje pomoću json_encode. Tako nastaje artefakt za reviziju prilagođen proračunskim tablicama bez izravnavanja ili tihog odbacivanja ugniježđenih podataka.

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

Rezultirajuća datoteka nalazi se u mapi storage/app. Pregledavatelji mogu filtrirati neuspjehe, pregledavati strukturirane ćelije, ispraviti izvorna web-mjesta i označiti prihvaćene retke kroz kasniji tijek rada aplikacije koristeći reviewed_at.

Testirajte granicu i posao

Upotrijebite Http::fake() kako testovi nikada ne bi trošili kvotu niti ovisili o dostupnosti mreže. Jedan jedinični test trebao bi vratiti reprezentativne vrijednosti za tvrtku, kontakt, e-poštu, telefon i osobe, potvrditi njihovo mapiranje te pregledati odlazni upit kako bi potvrdio oba obavezna parametra. Drugi test trebao bi vratiti 401, potvrditi neuspjeh authentication i provjeriti da se dogodio samo jedan zahtjev.

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

Funkcijski test trebao bi stvoriti stavku na čekanju, simulirati uspješan odgovor, pozvati metodu handle posla putem spremnika i potvrditi da je red baze podataka dovršen s mapiranim JSON-om. Također obuhvatite odgovor 422 koji postaje neuspješni red s validation. Ovi testovi provjeravaju transport, mapiranje i postojanost bez testiranja samog Laravela.

Sigurno upravljajte njime u produkciji

Pokrenite radnik reda pod upraviteljem procesa koji već upotrebljava platforma za implementaciju i ponovno pokrenite radnike tijekom implementacije kako bi učitali novi kod. Održavajte konzervativnu istodobnost radnika dok se ne razumiju kvota plana i ponašanje ograničenja brzine. Klijentova tri interna pokušaja jedina su automatska ponavljanja; postavljanje posla na jedan pokušaj sprječava ugniježđene oluje ponavljanja.

Predmemorirajte konfiguraciju tek nakon što je produkcijski token prisutan pomoću php artisan config:cache. Rotirajte token ažuriranjem okruženja, ponovnom izgradnjom predmemorije konfiguracije i ponovnim pokretanjem radnika. Budući da ponovno generiranje tokena usluge odmah opoziva njegov prethodnik, provedite te korake kao jednu kontroliranu promjenu.

Pratite broj zapisa na čekanju, u obradi, dovršenih i neuspješnih zapisa. Upozorite na trajne neuspjehe authentication, rate_limit ili invalid_response. Izbjegavajte zapisivanje tijela odgovora: istraživanje kontakata može sadržavati osobne podatke, a operativni zapisnici obično imaju širi pristup i dulje zadržavanje od baze podataka aplikacije.

Česti neuspjesi

  • Svaki zahtjev ne prolazi autentifikaciju: provjerite aktivaciju, token ograničen na uslugu, predmemoriranje konfiguracije i je li netko ponovno generirao token.
  • Reci ostaju na čekanju: potvrdite da radnik radi na redu research i da njegova veza s bazom podataka može vidjeti tablicu poslova.
  • Reci ostaju u obradi: pregledajte neuspješne poslove i događaje prekida radnika; sigurno ponovno stavite pogođene zapise u red putem eksplicitne putanje osvježavanja.
  • Pojavljuje se mnogo neuspjeha ograničenja brzine: smanjite istodobnost radnika i ponovite neuspješne retke kasnije umjesto produljivanja neograničenih mirovanja.
  • Odgovor je označen kao neispravan: zadržite klasifikaciju neuspjeha, usporedite trenutačnu službenu dokumentaciju i ažurirajte samo mapper na granici.

Završni kontrolni popis za provjeru

  • Račun i plan Free, Plus ili Pro aktivni su.
  • Token usluge postoji samo u konfiguraciji potpomognutoj okruženjem.
  • Minimalni GET zahtjev uspijeva s parametrima website i token.
  • Zaglavlje CSV-a sadrži website, a nesigurni URL-ovi se odbijaju.
  • Radnici reda obrađuju red research s ograničenom istodobnošću.
  • Podaci o tvrtki, kontaktu, e-pošti, telefonu i osobama mapiraju se prije pohrane.
  • Neuspjesi autentifikacije i validacije ne ponavljaju se.
  • Ograničenja brzine, neuspjesi povezivanja i pogreške poslužitelja dobivaju ograničenu odgodu.
  • Testovi koriste Http::fake() i ne sadrže stvarne vjerodajnice.
  • Izvezeni CSV sadrži dovršene retke i vidljiva stanja neuspjeha za pregled.

Važan rezultat nije samo funkcionalan API zahtjev. To je kontrolirani istraživački cjevovod: unosi se validiraju, udaljeno ponašanje je ograničeno, neuspjesi ostaju razumljivi, vjerodajnice ostaju zaštićene, a svaki obogaćeni red vraća se u proračunsku tablicu čitljivu čovjeku. To je razlika između domišljate skripte i produkcijske integracije koju mali tim može sigurno nastaviti koristiti.

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.