Туториали

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

Laravel: Откријте промени во технолошкиот стек на веб-страниците на клиентите со ВИ и останете чекор напред

Веб-страницата на клиент може да се промени без распоредување од вашиот тим. Редизајнот може да го замени CMS-от, миграцијата на хостингот може да воведе нов CDN, или предавањето на работата на агенција може тивко да отстрани аналитика, безбедносни или framework-компоненти на кои се потпирате.

Ова упатство создава Laravel монитор што периодично повикува детерминистички API за детекција на веб-технологии, складира нормализирана снимка и испраќа е-пошта до развивач кога тој јавен технолошки стек ќе се промени. Првата успешна проверка воспоставува основа; подоцнежните проверки известуваат само кога добиениот отпечаток се разликува.

Дизајнот намерно дава предност на закажана команда наместо на контролери и редици. За умерена колекција важни веб-страници на клиенти, последователните проверки се полесни за управување, не можат да пристигнат во погрешен редослед и бараат помалку подвижни делови. Ограничените HTTP повици го одржуваат распоредувачот предвидлив.

Добијте API пристап пред да пишувате интеграциски код

Регистрирајте се преку страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка. Отворете ја страницата на услугата Website Technology Detector, изберете достапен Free, Plus или Pro план и завршете ја неговата активација.

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

Оваа услуга не е без токен. Секое барање мора да се автентицира со Bearer токен, заглавие X-API-Token или параметар за барање token. Имплементацијата подолу ја користи Bearer формата бидејќи ги чува акредитивите надвор од URL-адресите и дневниците за пристап.

Точната операција е POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Тестирајте ги акредитивите со јавна веб-страница што сте овластени да ја следите:

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"}'

Одговорот содржи детекции со оценки за доверба и поврзани докази, верзии и информации за пренасочување. Овие детали треба да се третираат како податоци од услугата, а не да се реконструираат од претпоставки во апликацискиот код.

По успешно барање, ставете ги акредитивите во некомитираната .env датотека на Laravel проектот:

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"

Предуслови и структура на проектот

Ви треба PHP 8.3 или понов, Composer, Laravel апликација, поддржана база на податоци и функционален Laravel транспорт за пошта. Ако е потребно, создадете нова апликација, па генерирајте ги главните framework-артефакти:

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

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

Додадете ја конфигурацијата на услугата и известувањата:

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

Зачувајте го последниот познат стек

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

Дефинирајте ја миграцијата создадена за 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');
    }
};

Конфигурирајте го моделот во 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',
        ];
    }
}

Канонизирајте го доменскиот одговор

Редоследот на клучевите во JSON објект нема семантичко значење, но наивното хеширање на текстот на одговорот би ги третирало преуредените клучеви како промена. Доменскиот мапер рекурзивно ги подредува асоцијативните клучеви, а потоа го хешира нормализираниот документ. Редоследот на листите останува недопрен бидејќи низите на пренасочување и други подредени докази може да бидат значајни.

Создадете 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;
    }
}

Оваа граница намерно избегнува да потврдува имиња на полиња што не се дел од доставениот договор. Ако официјалната документација подоцна идентификува променливи метаподатоци што се менуваат при секој повик, исклучете го тоа конкретно поле тука само откако ќе ја потврдите неговата семантика.

Изградете ограничен API клиент свесен за повторни обиди

Создадете app/Exceptions/DetectorException.php за да им дадете на повикувачите структурирани состојби на неуспех без изложување тела на одговори или акредитиви:

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

Сега создадете app/Services/WebsiteTechnologyDetector.php. Неуспеси на конекцијата, неуспеси на серверот и одговори за квота добиваат ограничени повторни обиди. Неуспесите на автентикација и валидација не добиваат: повторувањето неважечки токен или URL само троши време и може да го зголеми притисокот врз ограничувањето на стапката.

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

Повторниот обид е соодветен бидејќи детекцијата е набљудувачка: таа не ја изменува веб-страницата на клиентот. Максималното време на чекање за одговор, времето на чекање за конекција, бројот на повторни обиди и ограничениот backoff спречуваат една нездрава зависност да го зафаќа распоредувачот неограничено.

Известувајте само по значајна споредба

Создадете 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.');
    }
}

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

Заменете го app/Console/Commands/DetectSiteStacks.php со:

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

Закажете и иницијализирајте го мониторот

Додадете го распоредот во routes/console.php:

<?php

use Illuminate\Support\Facades\Schedule;

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

onOneServer() бара сите инстанци на апликацијата да делат компатибилен централен кеш. Кај распоредување на еден сервер може да се изостави. withoutOverlapping() штити од судир на бавно извршување со следното закажано повикување.

Извршете ја миграцијата и додадете намерно избрана URL-адреса на клиент:

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

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

Тестирајте ја патеката на промена без повикување на услугата

Laravel-овите HTTP и notification fake-ови го прават тестот детерминистички. Примерот подолу ги третира деталите за детекција како нетранспарентни податоци од услугата, истовремено докажувајќи дека промените во верзијата и доказите го менуваат отпечатокот.

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

Додадете придружни тестови за основниот случај, непроменет одговор, HTTP 401, HTTP 422, исцрпени повторни обиди за 429, неуспеси на серверот, неважечки JSON и исклучоци на конекцијата. Тестот за основниот случај треба да потврди дека извештајот се зачувува без испраќање известување.

Распоредете со зачувани безбедност и набљудливост

Распоредете го кодот, внесете ги TECH_DETECTOR_TOKEN и акредитивите за пошта преку управувачот со тајни на платформата, мигрирајте и повторно изградете го кешот за конфигурација на Laravel:

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

Конфигурирајте еден системски cron запис за повикување на Laravel-овиот распоредувач:

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

Никогаш не го запишувајте во дневник токенот, заглавијата за авторизација или целиот одговор од upstream. Структурираните дневници веќе го изложуваат идентификаторот на страницата, категоријата на неуспех, HTTP статусот, насоките за повторен обид и дали се случила промена. Оперативно предупредувајте за повторени неуспеси authentication, rate_limit или remote, како и за страници чиј last_checked_at станал застарен.

При ротирање на токенот за услугата, ажурирајте ја секоја активна инстанца пред повторно да го генерирате, ако процесот на распоредување го дозволува тој редослед. Бидејќи повторното генерирање го поништува стариот активен токен, мешаните распоредувања во спротивно создаваат привремени одговори 401.

Вообичаени неуспеси за дијагностицирање

  • Секој повик враќа 401 или 403: потврдете дека активниот токен со опсег на услугата стигнал до runtime-околината, па повторно изградете го кешот за конфигурација.
  • Командата враќа 429: повторните обиди веќе се ограничени. Намалете ја честотата на следење, распоредете ги проверките низ времето или прегледајте го активираниот план, наместо да додавате неограничена јамка за повторни обиди.
  • Не пристигнува е-пошта: проверете ги Laravel конфигурацијата за пошта и дневниците, па испратете контролиран тест преку конфигурираниот транспорт.
  • Секое извршување пријавува промена: споредете два зачувани извештаи и консултирајте ја официјалната документација за одговорите. Отстранете само потврдено променливо поле на метаподатоци во канонизаторот.
  • Распоредот се извршува двапати: потврдете дека постојат само предвидените cron записи и дека распоредувањата со повеќе инстанци го делат кешот потребен за onOneServer().

Конечна листа за проверка

  1. Планот на услугата е активен и токенот се чува само во конфигурација поддржана од околината.
  2. Минималното POST барање успева со задолжителното JSON тело url.
  3. Првото закажано извршување зачувува основа без да извести никого.
  4. Непроменет fake одговор не создава известување.
  5. Променета верзија, ставка со доказ, вредност на доверба, детекција или детал за пренасочување создава е-пошта и нов отпечаток.
  6. Одговорите за автентикација и валидација не се повторуваат слепо.
  7. Неуспесите на конекцијата, ограничувањето на стапката и серверот имаат ограничени повторни обиди и структурирани дневници.
  8. Распоредувачот работи при распоредување, заштитата од преклопување работи и застарените проверки се видливи.

Корисен монитор прави повеќе од тоа да објави дека „нешто се променило“. Тој воспоставува одбранлива основа, ги задржува доказите зад секој резултат, го разликува неуспехот на услугата од движењето на стекот и прави пропуштените предупредувања да бидат помалку веројатни од дупликатите. Со овие својства на место, тивка миграција на страната на клиентот станува применлив инженерски сигнал наместо непријатно изненадување.

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

Mihajlo

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