Vodiči

Laravel CRM: Add Website Tech Insights to Leads with the AI Detector API

Laravel CRM: Dodajte uvide u tehnologiju web-stranica potencijalnim klijentima pomoću API-ja AI Detector

Web-mjesto potencijalnog klijenta često otkriva više nego oskudan obrazac za kontakt. Njegovi javno vidljivi tehnološki odabiri mogu ukazati na posao koji bi agencija razumno mogla predložiti: nadogradnju radnog okvira, čišćenje analitike, poboljšanja performansi ili migraciju CMS-a.

Ovaj vodič dodaje taj kontekst malom Laravel CRM-u. Kada se potencijalni klijent stvori ili izričito ponovno skenira, Laravel stavlja pozadinski zadatak u red čekanja, poziva API Website Technology Detector, provjerava odgovor na granici aplikacije i uz potencijalnog klijenta pohranjuje sažetak poput „Laravel, Nginx i Google Analytics”.

Dizajn namjerno zadržava udaljeno otkrivanje izvan putanje zahtjev–odgovor. Spora uzvodna usluga trebala bi odgoditi obogaćivanje, a ne spriječiti prodavača da spremi potencijalnog klijenta.

Preduvjeti i oblik projekta

Trebat će vam PHP 8.3 ili noviji, podržana Laravel aplikacija s uobičajenom infrastrukturom reda čekanja i baze podataka te model Lead koji sadrži javni URL web-mjesta. Primjeri pretpostavljaju da je taj atribut nazvan website_url.

Integracija ima četiri odgovornosti:

  • Laravelov HTTP klijent autentificira se, primjenjuje stroga vremenska ograničenja i klasificira uzvodne neuspjehe.
  • Mapper domene pretvara otkrivanja s ocjenom pouzdanosti, verzije, dokaze i preusmjeravanja u kontrolirane podatke aplikacije.
  • Zadatak reda čekanja obavlja obogaćivanje bez usporavanja stvaranja potencijalnog klijenta.
  • Baza podataka pohranjuje čitljiv sažetak i operativno stanje, ali ne i cjelokupni uzvodni sadržaj odgovora.

Ovo je namjerno skromna arhitektura. Zaseban mikroservis dodao bi troškove implementacije i praćenja bez koristi za tipičan agencijski CRM. Namjenski API klijent i mapper pružaju potrebnu granicu, a pritom ostaju jednostavni za testiranje.

Prije pisanja integracijskog koda pribavite pristup

  1. Otvorite stranicu za registraciju i izradite račun ili upotrijebite stranicu za prijavu ako ga već imate.
  2. Otvorite stranicu usluge Website Technology Detector.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte ondje prikazani token za tu uslugu.

Ponovno generiranje tog tokena opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnice: odmah ažurirajte svako implementirano okruženje, a zatim ponovno pokrenite dugotrajne workere kako bi ponovno učitali konfiguraciju.

Točna API operacija je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Prihvaća JSON koji sadrži url. Autentifikacija može koristiti Bearer token, zaglavlje X-API-Token ili parametar upita token. Ovaj projekt koristi oblik Bearer jer vjerodajnicu zadržava izvan URL-ova, zapisnika pristupa i kopiranih poveznica zahtjeva.

Provjerite pristup jednim minimalnim zahtjevom:

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Sada stavite token u datoteku .env projekta. Nikada ne predajte tu datoteku u repozitorij niti kopirajte stvarni token u fixtureove, snimke zaslona, zapisnike ili izvorni kôd.

WEBSITE_TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_TECH_CONNECT_TIMEOUT=3
WEBSITE_TECH_TIMEOUT=12

Izložite te vrijednosti putem config/services.php. Čitanje env() samo iz konfiguracije održava integraciju kompatibilnom s Laravelovom predmemorijom konfiguracije.

'website_technology_detector' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies',
    'token' => env('WEBSITE_TECH_DETECTOR_TOKEN'),
    'connect_timeout' => (int) env('WEBSITE_TECH_CONNECT_TIMEOUT', 3),
    'timeout' => (int) env('WEBSITE_TECH_TIMEOUT', 12),
],

Pohranite korisno stanje, a ne neproziran odgovor

Dodajte polja za ljudima čitljiv rezultat, informacije o preusmjeravanju, stanje obrade, klasifikaciju neuspjeha i vrijeme skeniranja. Čuvanje cijelog odgovora obično nije potrebno te može povećati rizike zadržavanja podataka i povezanosti sa shemom.

<?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::table('leads', function (Blueprint $table): void {
            $table->text('technology_summary')->nullable();
            $table->json('technology_redirects')->nullable();
            $table->string('technology_scan_status', 24)->default('pending');
            $table->string('technology_scan_error', 64)->nullable();
            $table->timestamp('technology_scanned_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('leads', function (Blueprint $table): void {
            $table->dropColumn([
                'technology_summary',
                'technology_redirects',
                'technology_scan_status',
                'technology_scan_error',
                'technology_scanned_at',
            ]);
        });
    }
};

Dodajte technology_redirects kao pretvorbu array i technology_scanned_at kao pretvorbu datetime u modelu Lead. Pokrenite php artisan migrate nakon pregleda generiranog SQL-a za svoju bazu podataka.

Mapirajte odgovor na granici domene

Udaljeni JSON je nepouzdan ulaz čak i kada dolazi iz usluge kojom upravljate. Mapper u nastavku zahtijeva polje detections, prihvaća zapise otkrivanja u obliku ključa ili popisa, zanemaruje neupotrebljive unose, ograničava prikazane dokaze i čuva samo skalarne pojedinosti preusmjeravanja.

<?php

namespace App\Domain\Leads;

use UnexpectedValueException;

final readonly class TechnologyReport
{
    public function __construct(
        public string $summary,
        public array $redirects,
        public int $detectionCount,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $detections = $payload['detections'] ?? null;

        if (! is_array($detections)) {
            throw new UnexpectedValueException('Missing detections array.');
        }

        $parts = [];

        foreach ($detections as $key => $detection) {
            if (is_string($detection)) {
                $parts[] = trim($detection);
                continue;
            }

            if (! is_array($detection)) {
                continue;
            }

            $name = is_string($detection['name'] ?? null)
                ? trim($detection['name'])
                : (is_string($key) ? trim($key) : '');

            if ($name === '') {
                continue;
            }

            $details = [];

            if (is_numeric($detection['confidence'] ?? null)) {
                $score = (float) $detection['confidence'];
                $percentage = $score <= 1 ? $score * 100 : $score;
                $details[] = round(max(0, min(100, $percentage))).'% confidence';
            }

            $versions = self::scalarStrings($detection['versions'] ?? []);
            if ($versions !== []) {
                $details[] = 'version '.implode(', ', array_slice($versions, 0, 3));
            }

            $evidence = self::scalarStrings($detection['evidence'] ?? []);
            if ($evidence !== []) {
                $details[] = 'evidence: '.implode(', ', array_slice($evidence, 0, 3));
            }

            $parts[] = $details === []
                ? $name
                : $name.' ('.implode('; ', $details).')';
        }

        $redirects = self::scalarStrings($payload['redirects'] ?? []);

        return new self(
            summary: $parts === []
                ? 'No public website technologies were detected.'
                : implode('; ', $parts),
            redirects: array_slice($redirects, 0, 20),
            detectionCount: count($parts),
        );
    }

    private static function scalarStrings(mixed $value): array
    {
        if (is_scalar($value)) {
            $text = trim((string) $value);
            return $text === '' ? [] : [mb_substr($text, 0, 300)];
        }

        if (! is_array($value)) {
            return [];
        }

        $result = [];
        array_walk_recursive($value, function (mixed $item) use (&$result): void {
            if (is_scalar($item)) {
                $text = trim((string) $item);
                if ($text !== '') {
                    $result[] = mb_substr($text, 0, 300);
                }
            }
        });

        return array_values(array_unique($result));
    }
}

Vrijednosti pouzdanosti normaliziraju se bez obzira na to jesu li predstavljene kao rezultati od nula do jedan ili kao postoci. Još važnije, neočekivane strukture nikada ne prolaze izravno u CRM prikaze ili stupce baze podataka.

Izradite API klijent svjestan neuspjeha

Klijent razlikuje trajne pogreške pozivatelja od uzvodnih uvjeta koji se mogu ponoviti. Neuspjesi autentifikacije i validacije ne pokušavaju se naslijepo ponovno. Mrežne pogreške i pogreške poslužitelja dobivaju jedan kratki trenutačni ponovni pokušaj; kasniji oporavak pripada redu čekanja, gdje čekanje ne zauzima web-zahtjev.

<?php

namespace App\Services;

use App\Domain\Leads\TechnologyReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class WebsiteTechnologyDetector
{
    public function detect(string $url): DetectionOutcome
    {
        $token = config('services.website_technology_detector.token');

        if (! is_string($token) || $token === '') {
            return DetectionOutcome::failure('configuration', false);
        }

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->withToken($token)
                    ->connectTimeout(config('services.website_technology_detector.connect_timeout'))
                    ->timeout(config('services.website_technology_detector.timeout'))
                    ->post(
                        config('services.website_technology_detector.endpoint'),
                        ['url' => $url],
                    );
            } catch (ConnectionException) {
                if ($attempt === 1) {
                    usleep(200_000);
                    continue;
                }

                return DetectionOutcome::failure('connection', true);
            }

            if (in_array($response->status(), [401, 403], true)) {
                return DetectionOutcome::failure('authentication', false, $response->status());
            }

            if ($response->status() === 422) {
                return DetectionOutcome::failure('invalid_request', false, 422);
            }

            if ($response->status() === 429) {
                $delay = filter_var($response->header('Retry-After'), FILTER_VALIDATE_INT);
                return DetectionOutcome::failure(
                    'rate_limited',
                    true,
                    429,
                    max(10, min(300, $delay ?: 60)),
                );
            }

            if ($response->serverError()) {
                if ($attempt === 1) {
                    usleep(500_000);
                    continue;
                }

                return DetectionOutcome::failure('upstream', true, $response->status());
            }

            if ($response->clientError()) {
                return DetectionOutcome::failure('request_rejected', false, $response->status());
            }

            try {
                $payload = $response->json();

                if (! is_array($payload)) {
                    return DetectionOutcome::failure('invalid_payload', false);
                }

                return DetectionOutcome::success(
                    TechnologyReport::fromPayload($payload),
                );
            } catch (Throwable) {
                return DetectionOutcome::failure('invalid_payload', false);
            }
        }

        return DetectionOutcome::failure('unknown', true);
    }
}

final readonly class DetectionOutcome
{
    private function __construct(
        public ?TechnologyReport $report,
        public ?string $error,
        public bool $retryable,
        public ?int $status,
        public int $retryAfter,
    ) {}

    public static function success(TechnologyReport $report): self
    {
        return new self($report, null, false, 200, 0);
    }

    public static function failure(
        string $error,
        bool $retryable,
        ?int $status = null,
        int $retryAfter = 30,
    ): self {
        return new self(null, $error, $retryable, $status, $retryAfter);
    }
}

Obogatite potencijalne klijente zadatkom reda čekanja

Prije spremanja potencijalnih klijenata validirajte URL-ove Laravelovim pravilom url i dopustite samo javna odredišta http ili https u skladu s pravilima svojeg CRM-a. Detektor je namijenjen javnim web-mjestima; nemojte ga koristiti kao put prema internim hostovima, loopback adresama ili krajnjim točkama metapodataka u oblaku.

Zadatak ne izvodi transakciju baze podataka oko mrežnog poziva. Također zapisuje identifikatore i klasifikacije, a ne tokene, tijela odgovora ili potpune URL-ove.

<?php

namespace App\Jobs;

use App\Models\Lead;
use App\Services\WebsiteTechnologyDetector;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class DetectLeadTechnologies implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;

    public function __construct(public readonly int $leadId) {}

    public function handle(WebsiteTechnologyDetector $detector): void
    {
        $lead = Lead::find($this->leadId);

        if (! $lead || ! is_string($lead->website_url) || $lead->website_url === '') {
            return;
        }

        $started = hrtime(true);
        $outcome = $detector->detect($lead->website_url);

        if ($outcome->report) {
            $lead->forceFill([
                'technology_summary' => $outcome->report->summary,
                'technology_redirects' => $outcome->report->redirects,
                'technology_scan_status' => 'complete',
                'technology_scan_error' => null,
                'technology_scanned_at' => now(),
            ])->save();

            Log::info('Lead technology scan completed', [
                'lead_id' => $lead->id,
                'detections' => $outcome->report->detectionCount,
                'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
            ]);

            return;
        }

        if ($outcome->retryable && $this->attempts() < $this->tries) {
            Log::warning('Lead technology scan will retry', [
                'lead_id' => $lead->id,
                'failure' => $outcome->error,
                'http_status' => $outcome->status,
            ]);

            $this->release($outcome->retryAfter);
            return;
        }

        $lead->forceFill([
            'technology_scan_status' => 'failed',
            'technology_scan_error' => $outcome->error,
            'technology_scanned_at' => now(),
        ])->save();
    }
}

Pošaljite zadatak nakon uspješnog umetanja potencijalnog klijenta, po mogućnosti nakon potvrde okolne transakcije baze podataka:

$lead = Lead::create($validated);

DetectLeadTechnologies::dispatch($lead->id)->afterCommit();

Za izričitu radnju ponovnog skeniranja zaštitite rutu autentifikacijom i ograničavanjem broja zahtjeva:

Route::post('/leads/{lead}/technology-scan', function (Lead $lead) {
    $lead->update([
        'technology_scan_status' => 'pending',
        'technology_scan_error' => null,
    ]);

    DetectLeadTechnologies::dispatch($lead->id)->afterCommit();

    return back();
})->middleware(['auth', 'throttle:10,1']);

U većoj aplikaciji premjestite ovo zatvaranje u radnju autoriziranog kontrolera. Ograničavanje zahtjeva štiti od slučajnih ponovljenih klikova, dok autorizacija mora osigurati da trenutačni korisnik smije izmijeniti tog potencijalnog klijenta.

Testirajte uspjeh i neuspjeh bez pozivanja usluge

Http::fake() čini testove determinističkima i provjerava stvarnu granicu zahtjeva. Upotrijebite konfiguriranu krajnju točku kako kasnija promjena URL-a ne bi tiho poništila tvrdnju.

<?php

use App\Jobs\DetectLeadTechnologies;
use App\Models\Lead;
use Illuminate\Support\Facades\Http;

it('stores a readable technology report', function () {
    config(['services.website_technology_detector.token' => 'test-token']);

    Http::fake([
        config('services.website_technology_detector.endpoint') => Http::response([
            'detections' => [
                [
                    'name' => 'Laravel',
                    'confidence' => 0.98,
                    'versions' => ['11'],
                    'evidence' => ['response signature'],
                ],
            ],
            'redirects' => ['https://www.example.com'],
        ], 200),
    ]);

    $lead = Lead::factory()->create([
        'website_url' => 'https://example.com',
    ]);

    app(DetectLeadTechnologies::class, ['leadId' => $lead->id])
        ->handle(app(\App\Services\WebsiteTechnologyDetector::class));

    expect($lead->fresh()->technology_summary)
        ->toContain('Laravel')
        ->and($lead->fresh()->technology_scan_status)->toBe('complete');

    Http::assertSent(fn ($request) =>
        $request->method() === 'POST'
        && $request->url() === config('services.website_technology_detector.endpoint')
        && $request['url'] === 'https://example.com'
        && $request->hasHeader('Authorization', 'Bearer test-token')
    );
});

it('classifies authentication failures without retrying', function () {
    config(['services.website_technology_detector.token' => 'expired-token']);

    Http::fake([
        '*' => Http::response([], 401),
    ]);

    $outcome = app(\App\Services\WebsiteTechnologyDetector::class)
        ->detect('https://example.com');

    expect($outcome->error)->toBe('authentication')
        ->and($outcome->retryable)->toBeFalse();

    Http::assertSentCount(1);
});

Dodajte popratne slučajeve za neispravan JSON, nedostajuće polje detections, prazna otkrivanja, HTTP 429, pogreške poslužitelja i iznimke veze. Ti su testovi važniji od još jednog fixturea za sretan put jer štite kapacitet reda čekanja i semantiku neuspjeha.

Implementacija, vidljivost i uobičajeni neuspjesi

Postavite vrijednosti tokena i vremenskog ograničenja u svakom implementacijskom okruženju, pokrenite migracije, ponovno izgradite konfiguraciju pomoću php artisan config:cache i ponovno pokrenite workere reda čekanja pomoću php artisan queue:restart. Osigurajte da worker obrađuje red čekanja koji koristi zadatak.

Pratite polja dovršetka, neuspjeha, ponovnog pokušaja, trajanja i broja otkrivanja iz strukturiranih zapisnika. Upozorite na trajne neuspjehe autentifikacije jer oni obično ukazuju na nedostajući, opozvani ili zastarjeli token. Ograničavanje broja zahtjeva pratite zasebno; ono može ukazivati na nalet koji zahtijeva tempo reda čekanja ili pregled kapaciteta plana.

Uobičajeni problemi obično su operativni, a ne algoritamski:

  • Svako skeniranje ostaje na čekanju: potvrdite da worker reda čekanja radi i da produkcija ne koristi nenamjernu vezu reda čekanja.
  • Trenutačni neuspjesi autentifikacije: provjerite token za konkretnu uslugu, osvježite predmemoriranu konfiguraciju i ponovno pokrenite workere. Ako je token ponovno generiran, prethodna vrijednost više ne radi.
  • Neuspjesi validacije: potvrdite da je tijelo JSON i da sadrži url s potpunim javnim URL-om.
  • Ponovljeno ograničavanje broja zahtjeva: poštujte ograničeno odgađanje ponovnog pokušaja, smanjite konkurentnost i pregledajte aktivirani plan umjesto stvaranja agresivne petlje ponovnih pokušaja.
  • Neuspjesi zbog neispravnog sadržaja odgovora: usporedite odgovor sa službenom dokumentacijom, a zatim prilagodite samo mapper. Nemojte širiti pretpostavke o udaljenom odgovoru kroz kontrolere i prikaze.
  • Neočekivano spori workeri: zadržite ograničena vremenska ograničenja veze i ukupnog trajanja te ispitajte uzvodnu latenciju prije njihova povećanja.

Završni kontrolni popis za provjeru

  • Točna krajnja točka detektora prima JSON POST koji sadrži url.
  • Bearer token dolazi iz konfiguracije podržane varijablama okruženja i nikada ne ulazi u zapisnike ili kontrolu izvornog koda.
  • Stvaranje potencijalnog klijenta uspijeva čak i kada obogaćivanje nije dostupno.
  • Uspješna skeniranja pohranjuju čitljiv sažetak, preusmjeravanja, stanje dovršenosti i vremensku oznaku.
  • Pogreške autentifikacije i validacije ne pokušavaju se ponovno.
  • Ograničenja broja zahtjeva, mrežni neuspjesi i pogreške poslužitelja koriste ograničene ponovne pokušaje.
  • Testovi lažiraju svaki vanjski poziv i pokrivaju mapiranje i klasifikaciju neuspjeha.
  • Produkcijski workeri ponovno su pokrenuti nakon implementacije konfiguracije.

Korisni CRM ne prikuplja samo polja; dostupne signale pretvara u kontekst prema kojem ljudi mogu djelovati. Zadržavanjem asinkronog otkrivanja, izoliranih vjerodajnica, obrambenog mapiranja odgovora i vidljivih neuspjeha, ova integracija dodaje taj kontekst bez stvaranja ovisnosti tijeka rada s potencijalnim klijentima o udaljenoj usluzi. Sažetak je vidljiva značajka, ali pažljivo dizajnirana granica ono je što je čini spremnom za produkciju.

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.