Vodiči

Laravel: Detect Client Website Tech Stack Shifts with AI and Stay Ahead

Laravel: Otkrivajte promjene u tehnološkom sklopu klijentskih web-stranica pomoću AI-ja i budite korak ispred

Web-mjesto klijenta može se promijeniti bez implementacije vašeg tima. Redizajn može zamijeniti CMS, migracija hostinga može uvesti novi CDN ili primopredaja agenciji može neprimjetno ukloniti komponente analitike, sigurnosti ili okvira na koje se oslanjate.

Ovaj vodič izrađuje Laravel nadzornik koji povremeno poziva deterministički API za otkrivanje tehnologija web-mjesta, pohranjuje normalizirani snimak stanja i šalje e-poruku razvojnom programeru kada se taj javni tehnološki skup promijeni. Prva uspješna provjera uspostavlja osnovno stanje; kasnije provjere upozoravaju samo kada se rezultirajući otisak razlikuje.

Dizajn namjerno daje prednost zakazanoj naredbi u odnosu na kontrolere i redove čekanja. Za umjerenu zbirku važnih klijentskih web-mjesta sekvencijalne provjere lakše su za upravljanje, ne mogu stići pogrešnim redoslijedom i zahtijevaju manje pokretnih dijelova. Ograničeni HTTP pozivi održavaju raspoređivač predvidljivim.

Pribavite API pristup prije pisanja integracijskog koda

Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun. Otvorite stranicu usluge Website Technology Detector, 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 njezin token ograničen na uslugu. Ponovno generiranje tog tokena opoziva prethodno aktivni token, stoga rotaciju tretirajte kao koordiniranu implementaciju, a ne kao usputnu radnju na nadzornoj ploči.

Ova usluga nije bez tokena. Svaki zahtjev mora se autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Implementacija u nastavku koristi oblik Bearer jer vjerodajnice zadržava izvan URL-ova i zapisnika pristupa.

Točna operacija je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Testirajte vjerodajnicu s javnim web-mjestom koje ste ovlašteni nadzirati:

curl -sS --fail-with-body \
  --connect-timeout 5 \
  --max-time 20 \
  -X POST \
  "https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies" \
  -H "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://client.example"}'

Odgovor sadrži detekcije s ocjenama pouzdanosti te povezane dokaze, verzije i informacije o preusmjeravanju. Te pojedinosti treba tretirati kao podatke usluge, a ne ih rekonstruirati na temelju pretpostavki u aplikacijskom kodu.

Nakon što zahtjev uspije, smjestite vjerodajnicu u Laravelovu nepredanu datoteku .env projekta:

TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
[email protected]

MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=YOUR_MAIL_USERNAME
MAIL_PASSWORD=YOUR_MAIL_PASSWORD
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Stack Watch"

Preduvjeti i struktura projekta

Potrebni su vam PHP 8.3 ili noviji, Composer, Laravel aplikacija, podržana baza podataka i funkcionalan Laravelov transport pošte. Ako je potrebno, izradite novu aplikaciju, a zatim generirajte glavne artefakte okvira:

composer create-project laravel/laravel stack-watch
cd stack-watch

php artisan make:model MonitoredSite -m
php artisan make:command DetectSiteStacks
php artisan make:notification TechnologyStackChanged
php artisan make:test DetectSiteStacksTest

Rezultirajuća značajka ima četiri granice: model baze podataka upravlja stanjem nadzora, servisna klasa upravlja ugovorom udaljenog HTTP-a, domenski objekt kanonizira uspješne odgovore, a zakazana naredba koordinira usporedbu i obavijest. Nijedna javna ruta ne prihvaća proizvoljne URL-ove.

Dodajte konfiguraciju usluge i upozorenja:

<?php
// Add inside the returned array in config/services.php

'technology_detector' => [
    'base_url' => 'https://ai.mihajlo.mk/api/website-technology-detector',
    'token' => env('TECH_DETECTOR_TOKEN'),
],
<?php
// config/monitoring.php

return [
    'developer_email' => env('STACK_ALERT_EMAIL'),
];

Pohranite posljednji poznati skup

Baza podataka čuva potpuni normalizirani odgovor umjesto projiciranja nedokumentiranih unutarnjih polja odgovora. Time se čuvaju podaci o pouzdanosti, dokazima, verziji i preusmjeravanju, a granici aplikacije omogućuje odbacivanje neispravnog JSON-a. Također omogućuje buduće istrage bez smještanja potencijalno opsežnih dokaza u zapisnike ili e-poštu.

Definirajte migraciju izrađenu za monitored_sites:

<?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('monitored_sites', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('url')->unique();
            $table->string('last_fingerprint', 64)->nullable();
            $table->json('last_report')->nullable();
            $table->timestamp('last_checked_at')->nullable();
            $table->timestamp('last_error_at')->nullable();
            $table->string('last_error')->nullable();
            $table->timestamps();
        });
    }

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

Konfigurirajte model u app/Models/MonitoredSite.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class MonitoredSite extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'last_report' => 'array',
            'last_checked_at' => 'datetime',
            'last_error_at' => 'datetime',
        ];
    }
}

Kanonizirajte domenski odgovor

Redoslijed ključeva JSON objekta nema semantičko značenje, ali bi naivno sažimanje teksta odgovora tretiralo preraspoređene ključeve kao promjenu. Domensko mapiranje rekurzivno sortira asocijativne ključeve, a zatim sažima normalizirani dokument. Redoslijed popisa ostaje netaknut jer nizovi preusmjeravanja i drugi uređeni dokazi mogu biti značajni.

Izradite app/Domain/Technology/DetectionReport.php:

<?php

namespace App\Domain\Technology;

use JsonException;
use UnexpectedValueException;

final readonly class DetectionReport
{
    private function __construct(public array $document)
    {
    }

    public static function fromPayload(mixed $payload): self
    {
        if (! is_array($payload) || $payload === []) {
            throw new UnexpectedValueException(
                'Detector returned an invalid JSON document.'
            );
        }

        $normalized = self::normalize($payload);

        try {
            json_encode(
                $normalized,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
            );
        } catch (JsonException $exception) {
            throw new UnexpectedValueException(
                'Detector response could not be normalized.',
                previous: $exception
            );
        }

        return new self($normalized);
    }

    public function fingerprint(): string
    {
        return hash('sha256', json_encode(
            $this->document,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        ));
    }

    private static function normalize(mixed $value): mixed
    {
        if (! is_array($value)) {
            return $value;
        }

        if (array_is_list($value)) {
            return array_map(self::normalize(...), $value);
        }

        ksort($value, SORT_STRING);

        foreach ($value as $key => $item) {
            $value[$key] = self::normalize($item);
        }

        return $value;
    }
}

Ova granica namjerno izbjegava potvrđivanje naziva polja koja nisu dio dostavljenog ugovora. Ako službena dokumentacija kasnije utvrdi promjenjive metapodatke koji se mijenjaju pri svakom pozivu, isključite to konkretno polje ovdje tek nakon potvrde njegova značenja.

Izradite ograničenog API klijenta svjesnog ponovnih pokušaja

Izradite app/Exceptions/DetectorException.php kako biste pozivateljima dali strukturirana stanja neuspjeha bez otkrivanja tijela odgovora ili vjerodajnica:

<?php

namespace App\Exceptions;

use RuntimeException;
use Throwable;

final class DetectorException extends RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        public readonly ?int $status = null,
        public readonly ?int $retryAfter = null,
        ?Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Sada izradite app/Services/WebsiteTechnologyDetector.php. Neuspjesi povezivanja, neuspjesi poslužitelja i odgovori o kvoti dobivaju ograničene ponovne pokušaje. Ne dobivaju ih neuspjesi autentifikacije i provjere valjanosti: ponavljanje neispravnog tokena ili URL-a samo troši vrijeme i može povećati pritisak na ograničenje stope.

<?php

namespace App\Services;

use App\Domain\Technology\DetectionReport;
use App\Exceptions\DetectorException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;

final class WebsiteTechnologyDetector
{
    public function detect(string $url): DetectionReport
    {
        $parts = parse_url($url);

        if (
            $parts === false
            || ! isset($parts['host'], $parts['scheme'])
            || ! in_array($parts['scheme'], ['http', 'https'], true)
            || isset($parts['user'])
            || isset($parts['pass'])
        ) {
            throw new DetectorException(
                'validation',
                'The monitored URL must be a public HTTP or HTTPS URL.'
            );
        }

        $token = config('services.technology_detector.token');

        if (! is_string($token) || $token === '') {
            throw new DetectorException(
                'configuration',
                'Technology detector token is not configured.'
            );
        }

        try {
            $response = Http::baseUrl(
                config('services.technology_detector.base_url')
            )
                ->withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(5)
                ->timeout(20)
                ->retry(
                    3,
                    function (int $attempt, Throwable $exception): int {
                        if ($exception instanceof RequestException) {
                            $header = $exception->response
                                ->header('Retry-After');

                            if (is_string($header) && ctype_digit($header)) {
                                return min(5000, ((int) $header) * 1000);
                            }
                        }

                        return min(2000, 250 * (2 ** ($attempt - 1)));
                    },
                    function (Throwable $exception): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        if (! $exception instanceof RequestException) {
                            return false;
                        }

                        $status = $exception->response->status();

                        return $status === 429 || $status >= 500;
                    },
                    throw: false,
                )
                ->post('/v1/detect-technologies', ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new DetectorException(
                'network',
                'Technology detector connection failed.',
                previous: $exception
            );
        }

        $status = $response->status();

        if (in_array($status, [401, 403], true)) {
            throw new DetectorException(
                'authentication',
                'Technology detector rejected the service token.',
                $status
            );
        }

        if (in_array($status, [400, 422], true)) {
            throw new DetectorException(
                'validation',
                'Technology detector rejected the URL.',
                $status
            );
        }

        if ($status === 429) {
            $header = $response->header('Retry-After');

            throw new DetectorException(
                'rate_limit',
                'Technology detector quota or rate limit was reached.',
                $status,
                is_string($header) && ctype_digit($header)
                    ? (int) $header
                    : null
            );
        }

        if ($response->serverError()) {
            throw new DetectorException(
                'remote',
                'Technology detector is temporarily unavailable.',
                $status
            );
        }

        if (! $response->successful()) {
            throw new DetectorException(
                'remote',
                'Technology detector returned an unexpected status.',
                $status
            );
        }

        return DetectionReport::fromPayload($response->json());
    }
}

Ponovni pokušaj prikladan je jer je detekcija promatračka: ne mijenja klijentsko web-mjesto. Maksimalno vrijeme odgovora, vrijeme povezivanja, broj ponovnih pokušaja i ograničeni eksponencijalni odmak sprječavaju da jedna nezdrava ovisnost neograničeno zauzima raspoređivač.

Obavijestite samo nakon smislenog uspoređivanja

Izradite app/Notifications/TechnologyStackChanged.php:

<?php

namespace App\Notifications;

use App\Models\MonitoredSite;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

final class TechnologyStackChanged extends Notification
{
    public function __construct(
        private readonly MonitoredSite $site,
        private readonly string $oldFingerprint,
        private readonly string $newFingerprint,
    ) {
    }

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Technology stack changed: {$this->site->name}")
            ->line("A public technology change was detected.")
            ->line("Site: {$this->site->url}")
            ->line("Previous fingerprint: {$this->oldFingerprint}")
            ->line("Current fingerprint: {$this->newFingerprint}")
            ->line('Review the stored reports and confirm whether the change was expected.');
    }
}

Zakazana naredba obavlja usporedbu. Šalje obavijest prije spremanja novog snimka stanja. Ako isporuka pošte baci iznimku, stari otisak ostaje na mjestu i sljedeće pokretanje pokušava ponovno. Rušenje nakon uspješne isporuke, ali prije spremanja, može proizvesti duplikat, što je bolje od neprimjetnog gubitka upozorenja.

Zamijenite app/Console/Commands/DetectSiteStacks.php sljedećim:

<?php

namespace App\Console\Commands;

use App\Exceptions\DetectorException;
use App\Models\MonitoredSite;
use App\Notifications\TechnologyStackChanged;
use App\Services\WebsiteTechnologyDetector;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Throwable;

final class DetectSiteStacks extends Command
{
    protected $signature = 'sites:detect-stack {--site=* : Limit checks to site IDs}';
    protected $description = 'Detect and compare monitored website technology stacks';

    public function handle(WebsiteTechnologyDetector $detector): int
    {
        $email = config('monitoring.developer_email');

        if (! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $this->error('STACK_ALERT_EMAIL is not configured correctly.');

            return self::FAILURE;
        }

        $ids = array_values(array_filter(
            $this->option('site'),
            fn (mixed $id): bool => ctype_digit((string) $id)
        ));

        $failed = false;

        MonitoredSite::query()
            ->when($ids !== [], fn ($query) => $query->whereIn('id', $ids))
            ->orderBy('id')
            ->each(function (MonitoredSite $site) use (
                $detector,
                $email,
                &$failed
            ): void {
                try {
                    $report = $detector->detect($site->url);
                    $fingerprint = $report->fingerprint();
                    $changed = $site->last_fingerprint !== null
                        && ! hash_equals(
                            $site->last_fingerprint,
                            $fingerprint
                        );

                    if ($changed) {
                        Notification::route('mail', $email)->notify(
                            new TechnologyStackChanged(
                                $site,
                                $site->last_fingerprint,
                                $fingerprint
                            )
                        );
                    }

                    $site->forceFill([
                        'last_fingerprint' => $fingerprint,
                        'last_report' => $report->document,
                        'last_checked_at' => now(),
                        'last_error_at' => null,
                        'last_error' => null,
                    ])->save();

                    Log::info('Technology stack check completed.', [
                        'site_id' => $site->id,
                        'changed' => $changed,
                    ]);
                } catch (DetectorException $exception) {
                    $failed = true;

                    $site->forceFill([
                        'last_error_at' => now(),
                        'last_error' => $exception->kind,
                    ])->save();

                    Log::warning('Technology stack check failed.', [
                        'site_id' => $site->id,
                        'kind' => $exception->kind,
                        'status' => $exception->status,
                        'retry_after' => $exception->retryAfter,
                    ]);
                } catch (Throwable $exception) {
                    $failed = true;

                    Log::error('Technology stack monitor failed unexpectedly.', [
                        'site_id' => $site->id,
                        'exception' => $exception::class,
                    ]);
                }
            });

        return $failed ? self::FAILURE : self::SUCCESS;
    }
}

Zakažite i inicijalizirajte nadzornik

Dodajte raspored u routes/console.php:

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('sites:detect-stack')
    ->hourly()
    ->withoutOverlapping(30)
    ->onOneServer();

onOneServer() zahtijeva da sve instance aplikacije dijele kompatibilnu središnju predmemoriju. U implementaciji na jednom poslužitelju može se izostaviti. withoutOverlapping() štiti od sudara sporog pokretanja sa sljedećim zakazanim pozivom.

Pokrenite migraciju i dodajte namjerno odabrani URL klijenta:

php artisan migrate
php artisan tinker
App\Models\MonitoredSite::create([
    'name' => 'Important Client',
    'url' => 'https://client.example',
]);

Nemojte ovo umetanje izlagati kao neautentificirani kontroler. Nadzirite samo javna web-mjesta koja tvrtka ima pravo pregledavati i odbacujte korisničke vjerodajnice ugrađene u URL-ove.

Testirajte put promjene bez pozivanja usluge

Laravelovi lažni HTTP odgovori i obavijesti čine test determinističkim. Donji fixture tretira pojedinosti detekcije kao neprozirne podatke usluge, a istovremeno dokazuje da promjene verzije i dokaza mijenjaju otisak.

<?php

namespace Tests\Feature;

use App\Domain\Technology\DetectionReport;
use App\Models\MonitoredSite;
use App\Notifications\TechnologyStackChanged;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

final class DetectSiteStacksTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_notifies_when_the_report_changes(): void
    {
        config([
            'services.technology_detector.token' => 'test-token',
            'monitoring.developer_email' => '[email protected]',
        ]);

        $old = [
            'detections' => [[
                'confidence' => 0.95,
                'evidence' => ['public-signal-a'],
                'versions' => ['1'],
            ]],
        ];

        $new = [
            'detections' => [[
                'confidence' => 0.95,
                'evidence' => ['public-signal-a'],
                'versions' => ['2'],
            ]],
        ];

        $site = MonitoredSite::create([
            'name' => 'Important Client',
            'url' => 'https://client.example',
            'last_report' => $old,
            'last_fingerprint' => DetectionReport::fromPayload($old)
                ->fingerprint(),
        ]);

        Http::fake([
            'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies'
                => Http::response($new, 200),
        ]);

        Notification::fake();

        $this->artisan('sites:detect-stack')
            ->assertSuccessful();

        Notification::assertSentOnDemand(
            TechnologyStackChanged::class
        );

        $site->refresh();

        $this->assertSame(
            DetectionReport::fromPayload($new)->fingerprint(),
            $site->last_fingerprint
        );

        Http::assertSentCount(1);
    }
}

Dodajte popratne testove za slučaj osnovnog stanja, nepromijenjenog odgovora, HTTP 401, HTTP 422, iscrpljene ponovne pokušaje za 429, neuspjehe poslužitelja, neispravan JSON i iznimke povezivanja. Test osnovnog stanja trebao bi potvrditi da se izvješće pohranjuje bez slanja obavijesti.

Implementirajte uz očuvanu sigurnost i vidljivost

Implementirajte kod, umetnite TECH_DETECTOR_TOKEN i vjerodajnice za poštu putem upravitelja tajni platforme, pokrenite migraciju i ponovno izgradite Laravelovu predmemoriju konfiguracije:

php artisan migrate --force
php artisan config:cache
php artisan schedule:list
php artisan sites:detect-stack --site=1

Konfigurirajte jedan unos sistemskog crona za pozivanje Laravelova raspoređivača:

* * * * * cd /var/www/stack-watch && php artisan schedule:run >> /dev/null 2>&1

Nikada ne zapisujte token, autorizacijska zaglavlja ni cijeli uzlazni odgovor. Strukturirani zapisnici već izlažu identifikator web-mjesta, kategoriju neuspjeha, HTTP status, smjernice za ponovni pokušaj i informaciju o tome je li došlo do promjene. Operativno upozoravajte na ponovljene neuspjehe authentication, rate_limit ili remote, kao i na web-mjesta čiji je last_checked_at zastario.

Pri rotaciji tokena usluge ažurirajte svaku pokrenutu instancu prije njegova ponovnog generiranja ako vaš postupak implementacije dopušta taj redoslijed. Budući da ponovno generiranje opoziva stari aktivni token, mješovite implementacije inače proizvode privremene odgovore 401.

Uobičajeni neuspjesi za dijagnosticiranje

  • Svaki poziv vraća 401 ili 403: provjerite je li aktivni token ograničen na uslugu stigao u izvršno okruženje, a zatim ponovno izgradite predmemoriju konfiguracije.
  • Naredba vraća 429: ponovni pokušaji već su ograničeni. Smanjite učestalost nadzora, raspodijelite provjere kroz vrijeme ili pregledajte aktivirani plan umjesto dodavanja neograničene petlje ponovnih pokušaja.
  • E-poruka ne stiže: provjerite Laravelovu konfiguraciju pošte i zapisnike, zatim pošaljite kontrolirani test putem konfiguriranog transporta.
  • Svako pokretanje prijavljuje promjenu: usporedite dva pohranjena izvješća i konzultirajte službenu dokumentaciju odgovora. U kanonizatoru uklonite samo potvrđeno promjenjivo polje metapodataka.
  • Raspored se pokreće dvaput: potvrdite da postoje samo predviđeni cron unosi i da implementacije s više instanci dijele predmemoriju koju zahtijeva onOneServer().

Kontrolni popis za završnu provjeru

  1. Plan usluge je aktivan, a token je pohranjen samo u konfiguraciji podržanoj okruženjem.
  2. Minimalni POST zahtjev uspijeva s obaveznim JSON tijelom url.
  3. Prvo zakazano pokretanje pohranjuje osnovno stanje bez obavještavanja ikoga.
  4. Nepromijenjeni lažni odgovor ne proizvodi obavijest.
  5. Promijenjena verzija, stavka dokaza, vrijednost pouzdanosti, detekcija ili pojedinost preusmjeravanja proizvodi e-poruku i novi otisak.
  6. Odgovori autentifikacije i provjere valjanosti ne ponavljaju se naslijepo.
  7. Neuspjesi povezivanja, ograničenja stope i poslužitelja imaju ograničene ponovne pokušaje i strukturirane zapisnike.
  8. Raspoređivač radi pri implementaciji, zaštita od preklapanja funkcionira, a zastarjele provjere su vidljive.

Koristan nadzornik čini više od objave da se „nešto promijenilo”. Uspostavlja obranjivo osnovno stanje, zadržava dokaze iza svakog rezultata, razlikuje neuspjeh usluge od promjene skupa i čini propuštena upozorenja manje vjerojatnima od duplikata. Kada su ta svojstva uspostavljena, tiha migracija na strani klijenta postaje djelotvoran inženjerski signal umjesto neugodnog iznenađenja.

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.