Туториали

Laravel: Transform Website Lists into Actionable Company Insights

Laravel: Претворете ги листите на веб-страници во практични увиди за компаниите

Табела полна со веб-страници на компании изгледа корисно, но сè уште не е истражувачка алатка. Некој сè уште мора да ја отвори секоја страница, да пронајде детали за контакт, да копира информации за компанијата и да одлучи на кои записи треба да им се посвети внимание. Таа повторлива работа станува особено незгодна кога списокот се менува секоја недела.

Ова упатство создава Laravel процес наменет за продукција што увезува URL-адреси на веб-страници од CSV, ги дополнува со структурирани податоци за компании и контакти, безбедно обработува барања во заднина и извезува табела што може да се прегледа. Дизајнот е намерно скромен: HTTP клиентот на Laravel, database queue, query builder и алатките за автоматско тестирање се доволни.

Обезбедете пристап пред да напишете интеграциски код

Започнете со создавање сметка на страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Website to Company data.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата.
  4. Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
  5. Зачувајте го во конфигурацијата на околината на вашиот проект. Никогаш не го предавајте во source control.

Повторното генерирање на service token го поништува претходно активниот токен. Третирајте го повторното генерирање како ротација на ингеренции: веднаш ажурирајте ја секоја пуштена околина, рестартирајте ги долготрајните workers и потврдете едно барање пред да сметате дека ротацијата е завршена.

Потврдете го точниот HTTP договор

Услугата користи автентицирано GET барање до https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Автентикацијата се доставува преку параметарот за пребарување token, додека јавниот URL на компанијата се доставува преку website.

Направете едно минимално барање од доверлив терминал. Имајте предвид дека командите што содржат ингеренции во query string може да влезат во shell history, затоа отстранете го записот од историјата кога е соодветно и никогаш не ја лепете командата во тикети или логови.

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'

Штом тоа успее, поставете ја ингеренцијата во непредадената .env датотека на Laravel:

WEBSITE_COMPANY_SERVICE_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Изложете ја преку config/services.php. Кодот на апликацијата треба да чита конфигурација, а никогаш директно да не повикува env(), бидејќи пуштените Laravel апликации најчесто кешираат конфигурација.

<?php

return [
    // Existing services...

    'website_company' => [
        'token' => env('WEBSITE_COMPANY_SERVICE_TOKEN'),
        'endpoint' => 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
    ],
];

Изберете мала, отпорна архитектура

Влезот ќе биде CSV извезен од Excel, Google Sheets или друга табела. Мора да содржи колона website. Поддршката на изворни XLSX датотеки би барала дополнителен пакет без да го подобри работниот тек на дополнувањето, па CSV е почиста граница.

Командата за увоз ги проверува и дедуплицира редовите, зачувува работа на чекање и испраќа една queue job за секоја веб-страница. Посветен клиент го поседува договорот со надворешниот API. Job-от зачувува или нормализирани податоци или структурирана грешка. На крај, командата за извоз создава CSV што лице може да го сортира, означува и прегледува.

app/
  Console/Commands/ImportCompanyResearch.php
  Console/Commands/ExportCompanyResearch.php
  Data/CompanyResearch.php
  Exceptions/ServiceFailure.php
  Jobs/ResearchWebsite.php
  Services/WebsiteCompanyClient.php
database/migrations/
  xxxx_xx_xx_create_company_research_table.php
tests/Feature/
  WebsiteCompanyIntegrationTest.php

Овој асинхрон дизајн е важен поради повеќе од брзината. Табелата може да содржи лоши URL-адреси, привремени мрежни неуспеси или повеќе редови отколку што тековниот план може веднаш да обработи. Ставањето во queue го одржува увозот одзивен и им дава на привремените неуспеси ограничена втора шанса.

Создадете граница за перзистентност

Создадете ја queue migration, application migration, класите и командите со Artisan. Во зависност од верзијата на Laravel апликацијата, migration за jobs-table во queue можеби веќе постои; генерирајте ја само ако недостасува.

php artisan make:queue-table
php artisan make:migration create_company_research_table
php artisan make:job ResearchWebsite
php artisan make:command ImportCompanyResearch
php artisan make:command ExportCompanyResearch
php artisan migrate

Табелата за истражување го задржува изворниот URL, состојбата на обработка, успешниот payload и безбедните метаподатоци за грешка. Уникатното ограничување за website исто така штити од дупликат редови во табелата.

<?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', function (Blueprint $table): void {
            $table->id();
            $table->string('website', 2048)->unique();
            $table->string('status', 24)->index();
            $table->json('data')->nullable();
            $table->string('error_kind', 40)->nullable();
            $table->string('error_message')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('company_research');
    }
};

Мапирајте ги неизвесните податоци на границата на апликацијата

Документираната граница на апликацијата содржи податоци за company, contact, email, phone и people. Поединечните вредности може да ја променат својата форма, па доменскиот објект зачувува скаларни вредности, низи или null наместо да претпоставува недокументирани вгнездени полиња.

<?php
// app/Data/CompanyResearch.php

namespace App\Data;

use JsonException;
use UnexpectedValueException;

final readonly class CompanyResearch
{
    public function __construct(
        public mixed $company,
        public mixed $contact,
        public mixed $email,
        public mixed $phone,
        public mixed $people,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $fields = ['company', 'contact', 'email', 'phone', 'people'];

        if (array_intersect($fields, array_keys($payload)) === []) {
            throw new UnexpectedValueException('Expected research fields are absent.');
        }

        foreach ($fields as $field) {
            $value = $payload[$field] ?? null;

            if (! is_null($value) && ! is_scalar($value) && ! is_array($value)) {
                throw new UnexpectedValueException("Invalid {$field} value.");
            }
        }

        return new self(
            $payload['company'] ?? null,
            $payload['contact'] ?? null,
            $payload['email'] ?? null,
            $payload['phone'] ?? null,
            $payload['people'] ?? null,
        );
    }

    public function toArray(): array
    {
        return [
            'company' => $this->company,
            'contact' => $this->contact,
            'email' => $this->email,
            'phone' => $this->phone,
            'people' => $this->people,
        ];
    }
}

Ова е важна навика за продукција: мапирајте само договор што го поседувате. Не дозволувајте controllers и jobs да навлегуваат во шпекулативни структури, како претпоставено име на компанија или поле за примарна е-пошта.

Изградете ограничен HTTP клиент

Клиентот подолу применува одделни временски ограничувања за поврзување и за целокупниот одговор. Повторува само неуспеси во поврзувањето, HTTP 429 одговори и серверски грешки. Неуспесите во автентикацијата и валидацијата се враќаат веднаш бидејќи повторувањето на истото одбиено барање троши квота и ги замаглува проблемите со конфигурацијата.

<?php
// app/Exceptions/ServiceFailure.php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class ServiceFailure extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct("Website research failed: {$kind}", 0, $previous);
    }
}

// app/Services/WebsiteCompanyClient.php

namespace App\Services;

use App\Data\CompanyResearch;
use App\Exceptions\ServiceFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class WebsiteCompanyClient
{
    public function extract(string $website): CompanyResearch
    {
        $token = (string) config('services.website_company.token');
        $endpoint = (string) config('services.website_company.endpoint');

        if ($token === '') {
            throw new ServiceFailure('configuration');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->connectTimeout(3)
                    ->timeout(15)
                    ->get($endpoint, [
                        'token' => $token,
                        'website' => $website,
                    ]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new ServiceFailure('transient', null, $exception);
                }

                usleep((250 * (2 ** ($attempt - 1))) * 1000);
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (! is_array($payload)) {
                    throw new ServiceFailure('malformed_response', $response->status());
                }

                try {
                    return CompanyResearch::fromPayload($payload);
                } catch (Throwable $exception) {
                    throw new ServiceFailure(
                        'malformed_response',
                        $response->status(),
                        $exception,
                    );
                }
            }

            $status = $response->status();
            $retryable = $status === 429 || $status >= 500;

            if ($retryable && $attempt < 3) {
                $retryAfter = trim($response->header('Retry-After', ''));
                $delay = ctype_digit($retryAfter)
                    ? min(10_000, ((int) $retryAfter) * 1000)
                    : 250 * (2 ** ($attempt - 1));

                usleep(($delay + random_int(0, 150)) * 1000);
                continue;
            }

            $kind = match (true) {
                $status === 401 || $status === 403 => 'authentication',
                $status === 429 => 'rate_limited',
                $status === 400 || $status === 422 => 'validation',
                $status >= 500 => 'transient',
                default => 'service_response',
            };

            throw new ServiceFailure($kind, $status);
        }

        throw new ServiceFailure('transient');
    }
}

Телото на одговорот намерно отсуствува од пораките за исклучоци. Телата од upstream може да содржат податоци за контакт или детали за имплементацијата и не треба да навлезат во queue dashboards и централизирани логови.

Обработете ја секоја веб-страница како идемпотентен job

Job-от има два queue обиди покрај кратките повторувања на ниво на барање од клиентот. Трајните неуспеси се евидентираат веднаш. Исцрпените неуспеси поради rate limit и привремените неуспеси стигнуваат до failed(), создавајќи запис што може да се прегледа наместо да исчезнат.

<?php

namespace App\Jobs;

use App\Exceptions\ServiceFailure;
use App\Services\WebsiteCompanyClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ResearchWebsite implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 2;
    public int $timeout = 30;
    public int $uniqueFor = 3600;
    public bool $failOnTimeout = true;

    public function __construct(public readonly string $website) {}

    public function uniqueId(): string
    {
        return hash('sha256', $this->website);
    }

    public function backoff(): array
    {
        return [120];
    }

    public function handle(WebsiteCompanyClient $client): void
    {
        try {
            $research = $client->extract($this->website);
        } catch (ServiceFailure $failure) {
            if (in_array($failure->kind, ['rate_limited', 'transient'], true)) {
                throw $failure;
            }

            DB::table('company_research')
                ->where('website', $this->website)
                ->update([
                    'status' => 'failed',
                    'error_kind' => $failure->kind,
                    'error_message' => 'The service rejected or could not map this request.',
                    'updated_at' => now(),
                ]);

            Log::warning('Company research rejected', [
                'website' => $this->website,
                'kind' => $failure->kind,
                'status' => $failure->status,
            ]);

            return;
        }

        DB::table('company_research')
            ->where('website', $this->website)
            ->update([
                'status' => 'complete',
                'data' => json_encode($research->toArray(), JSON_THROW_ON_ERROR),
                'error_kind' => null,
                'error_message' => null,
                'updated_at' => now(),
            ]);

        Log::info('Company research completed', ['website' => $this->website]);
    }

    public function failed(?Throwable $exception): void
    {
        DB::table('company_research')
            ->where('website', $this->website)
            ->update([
                'status' => 'failed',
                'error_kind' => 'retries_exhausted',
                'error_message' => 'Temporary failure persisted after bounded retries.',
                'updated_at' => now(),
            ]);
    }
}

Увезете ја табелата и извезете го резултатот

Командата за увоз очекува заглавие со име website. Додава https:// кога ред содржи hostname без протокол, одбива не-HTTP шеми, прескокнува дупликати и ги остава невалидните редови надвор од queue.

<?php

namespace App\Console\Commands;

use App\Jobs\ResearchWebsite;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

final class ImportCompanyResearch extends Command
{
    protected $signature = 'research:import {file}';
    protected $description = 'Import company websites from a CSV file';

    public function handle(): int
    {
        $path = realpath((string) $this->argument('file'));

        if ($path === false || ! is_readable($path)) {
            $this->error('The CSV file is not readable.');
            return self::FAILURE;
        }

        $stream = fopen($path, 'rb');
        $header = fgetcsv($stream);

        if ($header === false) {
            fclose($stream);
            $this->error('The CSV file is empty.');
            return self::FAILURE;
        }

        $header = array_map(fn ($value) => trim((string) $value), $header);
        $websiteColumn = array_search('website', $header, true);

        if ($websiteColumn === false) {
            fclose($stream);
            $this->error('A website column is required.');
            return self::FAILURE;
        }

        $seen = [];
        $queued = 0;

        while (($row = fgetcsv($stream)) !== false) {
            $website = trim((string) ($row[$websiteColumn] ?? ''));

            if ($website !== '' && ! str_contains($website, '://')) {
                $website = 'https://' . $website;
            }

            $parts = parse_url($website);
            $scheme = strtolower((string) ($parts['scheme'] ?? ''));

            if (
                filter_var($website, FILTER_VALIDATE_URL) === false ||
                ! in_array($scheme, ['http', 'https'], true) ||
                isset($parts['user'], $parts['pass']) ||
                isset($seen[$website])
            ) {
                continue;
            }

            $seen[$website] = true;
            $now = now();

            DB::table('company_research')->upsert(
                [[
                    'website' => $website,
                    'status' => 'pending',
                    'data' => null,
                    'error_kind' => null,
                    'error_message' => null,
                    'created_at' => $now,
                    'updated_at' => $now,
                ]],
                ['website'],
                ['status', 'data', 'error_kind', 'error_message', 'updated_at'],
            );

            ResearchWebsite::dispatch($website);
            $queued++;
        }

        fclose($stream);
        $this->info("Queued {$queued} unique websites.");

        return self::SUCCESS;
    }
}

Командата за извоз треба да итерира со cursor() за голем сет резултати да не се вчита во меморијата. Секое договорено поле се кодира како JSON кога е структурирано, со што се зачувуваат податоците без измислување колони од недокументирана форма.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

final class ExportCompanyResearch extends Command
{
    protected $signature = 'research:export';
    protected $description = 'Export the reviewable company research CSV';

    public function handle(): int
    {
        $path = storage_path('app/contact-research.csv');
        $stream = fopen($path, 'wb');

        if ($stream === false) {
            $this->error('Could not create the export.');
            return self::FAILURE;
        }

        fputcsv($stream, [
            'website', 'status', 'company', 'contact',
            'email', 'phone', 'people', 'error_kind',
        ]);

        foreach (DB::table('company_research')->orderBy('website')->cursor() as $row) {
            $data = is_string($row->data)
                ? json_decode($row->data, true)
                : (array) ($row->data ?? []);

            $cell = static fn (mixed $value): string =>
                is_null($value) ? '' :
                (is_scalar($value) ? (string) $value :
                    json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));

            fputcsv($stream, [
                $row->website,
                $row->status,
                $cell($data['company'] ?? null),
                $cell($data['contact'] ?? null),
                $cell($data['email'] ?? null),
                $cell($data['phone'] ?? null),
                $cell($data['people'] ?? null),
                $row->error_kind ?? '',
            ]);
        }

        fclose($stream);
        $this->info("Exported {$path}");

        return self::SUCCESS;
    }
}

Докажете ја границата со детерминистички тестови

Http::fake() на Laravel спречува вистински мрежни повици и му овозможува на тестот да потврди дека се испратени двата задолжителни query параметри. Втор тест потврдува дека неуспесите во автентикацијата стануваат структурирани записи во базата на податоци наместо слепо да се повторуваат.

<?php

namespace Tests\Feature;

use App\Jobs\ResearchWebsite;
use App\Services\WebsiteCompanyClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WebsiteCompanyIntegrationTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_maps_the_documented_boundary(): void
    {
        config()->set('services.website_company.token', 'test-token');
        config()->set(
            'services.website_company.endpoint',
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract',
        );

        Http::fake([
            'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract*' =>
                Http::response([
                    'company' => ['name' => 'Example'],
                    'contact' => null,
                    'email' => ['[email protected]'],
                    'phone' => null,
                    'people' => [],
                ], 200),
        ]);

        $result = app(WebsiteCompanyClient::class)
            ->extract('https://example.com');

        $this->assertSame(['[email protected]'], $result->email);

        Http::assertSent(fn (Request $request): bool =>
            $request['token'] === 'test-token' &&
            $request['website'] === 'https://example.com'
        );
    }

    public function test_authentication_failure_is_recorded(): void
    {
        config()->set('services.website_company.token', 'expired-token');
        Http::fake([
            '*' => Http::response(['message' => 'Unauthorized'], 401),
        ]);

        DB::table('company_research')->insert([
            'website' => 'https://example.com',
            'status' => 'pending',
            'created_at' => now(),
            'updated_at' => now(),
        ]);

        ResearchWebsite::dispatchSync('https://example.com');

        $this->assertDatabaseHas('company_research', [
            'website' => 'https://example.com',
            'status' => 'failed',
            'error_kind' => 'authentication',
        ]);
    }
}

Пуштете, набљудувајте и управувајте со процесот

При пуштање, доставете го service token преку secret manager на hosting платформата, потоа извршете php artisan config:cache и php artisan migrate --force. Рестартирајте ги queue workers по промена на конфигурацијата за да го вчитаат новиот токен.

php artisan test
php artisan config:cache
php artisan migrate --force
php artisan queue:work --queue=default --tries=2 --timeout=35
php artisan research:import storage/app/companies.csv
php artisan research:export

Извршувајте го queue worker под process monitor што го рестартира по падови и пуштања. Временското ограничување на worker-от е малку подолго од временското ограничување на job-от, кое самото е подолго од едно временско ограничување за HTTP одговор. Тие граници спречуваат заглавено барање да зафаќа worker неограничено долго.

Следете ги броевите на записи pending, complete, failed, authentication, rate-limited и exhausted. Поставете известување за невообичаени соодноси на неуспеси или растечки backlog на pending записи. Логовите треба да ја вклучуваат изворната веб-страница, категоријата на неуспех, HTTP статусот и идентитетот на job-от, но никогаш service token, целосниот URL на барањето, телото на одговорот, адресите на е-пошта, телефонските броеви или податоците за people.

Бидејќи ингеренцијата се пренесува во query parameter, HTTPS е неопходен. Прегледајте ги поставките за proxy и HTTP instrumentation за да обезбедите дека query strings се редигираат. Ограничете го пристапот до извезениот CSV бидејќи содржи истражување на контакти, воспоставете политика за задржување и собирајте само информации соодветни за вашата законска деловна цел.

Вообичаени неуспеси што вреди брзо да се препознаат

  • Секој ред пријавува authentication: потврдете ги активацијата на планот, конфигурацијата на токенот и освежувањето на configuration cache. Повторно генериран токен го поништува претходниот.
  • Редовите остануваат pending: queue worker-от веројатно е запрен, слуша друга connection или користи застарена конфигурација.
  • Многу редови се rate-limited: намалете ја паралелноста на workers и потврдете го капацитетот на планот. Не ги зголемувајте агресивно повторувањата.
  • Успешен одговор е означен како malformed: безбедно проверете го одговорот надвор од вообичаените логови и споредете го со официјалната документација пред да го промените boundary mapper.
  • Не се увезуваат редови: проверете дали CSV има точно заглавие website и содржи важечки HTTP или HTTPS одредишта.
  • Истата компанија се појавува повеќепати: канонизирајте ги влезните URL-адреси според сопствените деловни правила пред увоз; тековното уникатно ограничување разликува различни URL-низи.

Конечна листа за верификација

  1. Токенот постои само во конфигурација поддржана од околината и отсуствува од source control.
  2. Минималното GET барање стигнува до точниот endpoint /v1/extract со token и website.
  3. php artisan test поминува без контактирање на вистинската услуга.
  4. Применети се database queue migration и research migration.
  5. Надгледуван queue worker работи со ограничени временски ограничувања и обиди.
  6. Мал CSV увоз напредува од pending до complete или структурирана failed состојба.
  7. storage/app/contact-research.csv се отвора како табела што може да се прегледа.
  8. Логовите, мониторингот и proxy-ите не изложуваат токени или вратени податоци за контакт.

Завршениот систем не се преправа дека истражувањето е целосно автоматско. Тој го отстранува механичкото прелистување и препишување, притоа зачувувајќи го човечкото расудување што ја прави листата на контакти корисна. Тоа е правилната поделба на трудот: софтверот ги претвора расфрланите јавни веб-страници во доследни докази, а човекот одлучува што заслужува дејство.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.