Туториали

Symfony: Monitor Client Sites for Tech Stack Changes with Website Detector API

Symfony: Следете ги промените во технолошкиот стек на клиентските сајтови со Website Detector API

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

Ова упатство изработува Symfony монитор наменет за продукција кој проверува јавна веб-страница со API-то Website Technology Detector, складира нормализирана основна состојба и испраќа е-пошта до програмер само кога ќе се промени откриениот технолошки стек. Се извршува како закажана конзолна команда, користи ограничени повторни обиди и оддалечениот одговор го третира како недоверлив податок.

Добијте пристап и копирајте сервисен токен

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Website Technology Detector.
  3. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

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

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Координирајте ја ротацијата со распоредувањето за апликацијата да ја прими новата вредност пред или веднаш по повторното генерирање.

Потврдете ја крајната точка пред да пишувате апликациски код

Точното барање е POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Неговото JSON тело содржи url.

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

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

За локален развој, ставете ја ингеренцијата во .env.local, која мора да остане непотврдена во контролата на верзии. Во продукција, внесете ја истата променлива преку платформата за хостирање или Symfony secrets, наместо да ја вградите во слика.

WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
[email protected]
[email protected]
MAILER_DSN=smtp://USERNAME:[email protected]:587

Архитектура и поставување на проектот

Закажана Symfony команда е подобар избор од HTTP контролер тука. Проверките треба да продолжат без барање од прелистувач, а привремените неуспеси на детекторот или поштата треба да создадат неуспешен процес што cron или платформата за распоредување може да го забележи.

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

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

composer require symfony/http-client symfony/mailer
composer require --dev phpunit/phpunit:^11.0

mkdir -p src/Technology tests/Technology var/technology-monitor
src/
  Command/MonitorTechnologiesCommand.php
  Technology/DetectionReport.php
  Technology/DetectorException.php
  Technology/SnapshotStore.php
  Technology/WebsiteDetector.php
tests/
  Technology/WebsiteDetectorTest.php
var/
  technology-monitor/

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

Мапирајте го оддалечениот одговор дефанзивно

Маперот пребарува рекурзивно бидејќи транспортните обвивки може да еволуираат. Јазол се третира како технологија само кога има непразно име и барем еден сигнал за откривање, како доверба, доказ или податоци за верзија. Непознатите полиња остануваат безопасни, недостасувачките опционални полиња стануваат null или празни низи, а доказите во форма на листа се сортираат за да се спречат предупредувања само поради редоследот.

<?php
// src/Technology/DetectionReport.php
namespace App\Technology;

final class DetectionReport
{
    private function __construct(
        public readonly array $technologies,
        public readonly array $redirects,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $technologies = [];
        $redirects = [];
        self::walk($payload, '$', $technologies, $redirects);

        $unique = [];
        foreach ($technologies as $technology) {
            $unique[hash('sha256', self::encode($technology))] = $technology;
        }

        $technologies = array_values($unique);
        usort(
            $technologies,
            fn (array $a, array $b): int => self::encode($a) <=> self::encode($b)
        );
        ksort($redirects);

        return new self($technologies, $redirects);
    }

    public function snapshot(): array
    {
        return [
            'technologies' => $this->technologies,
            'redirects' => $this->redirects,
        ];
    }

    public function fingerprint(): string
    {
        return hash('sha256', self::encode($this->snapshot()));
    }

    private static function walk(
        mixed $value,
        string $path,
        array &$technologies,
        array &$redirects,
    ): void {
        if (!is_array($value)) {
            return;
        }

        $fields = [];
        foreach ($value as $key => $child) {
            if (is_string($key)) {
                $fields[strtolower(str_replace('-', '_', $key))] = $child;
            }
        }

        $name = $fields['name'] ?? $fields['technology'] ?? null;
        $hasSignal = array_key_exists('confidence', $fields)
            || array_key_exists('evidence', $fields)
            || array_key_exists('version', $fields)
            || array_key_exists('versions', $fields);

        if (is_string($name) && trim($name) !== '' && $hasSignal) {
            $confidence = $fields['confidence'] ?? null;

            $technologies[] = [
                'name' => trim($name),
                'confidence' => is_numeric($confidence) ? (float) $confidence : null,
                'versions' => self::stableList(
                    $fields['versions'] ?? $fields['version'] ?? []
                ),
                'evidence' => self::stableList($fields['evidence'] ?? []),
            ];
        }

        foreach ($value as $key => $child) {
            $segment = is_string($key) ? $key : (string) $key;
            $normalized = strtolower(str_replace('-', '_', $segment));

            if (str_contains($normalized, 'redirect') || $normalized === 'final_url') {
                $redirects[$path.'.'.$segment] = self::canonicalize($child);
            }

            self::walk($child, $path.'.'.$segment, $technologies, $redirects);
        }
    }

    private static function stableList(mixed $value): array
    {
        $items = is_array($value) ? array_values($value) : [$value];
        $items = array_map(self::canonicalize(...), $items);
        usort($items, fn (mixed $a, mixed $b): int => self::encode($a) <=> self::encode($b));

        return $items;
    }

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

        if (!array_is_list($value)) {
            ksort($value);
        }

        return array_map(self::canonicalize(...), $value);
    }

    private static function encode(mixed $value): string
    {
        return json_encode(
            $value,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
    }
}

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

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

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

<?php
// src/Technology/DetectorException.php
namespace App\Technology;

final class DetectorException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
<?php
// src/Technology/WebsiteDetector.php
namespace App\Technology;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class WebsiteDetector
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
    ) {}

    public function detect(string $url): DetectionReport
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Accept' => 'application/json',
                        'Authorization' => 'Bearer '.$this->token,
                    ],
                    'json' => ['url' => $url],
                    'max_duration' => 20.0,
                    'timeout' => 10.0,
                ]);

                $status = $response->getStatusCode();

                if ($this->isRetryable($status) && $attempt < 3) {
                    $this->logger->warning('Technology detector retry scheduled', [
                        'status' => $status,
                        'attempt' => $attempt,
                    ]);
                    usleep($this->delayMicroseconds($response, $attempt));
                    continue;
                }

                if ($status < 200 || $status >= 300) {
                    throw $this->statusException($status);
                }

                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );
                } catch (\JsonException $exception) {
                    throw new DetectorException(
                        'invalid_response',
                        'Detector returned invalid JSON.',
                        $exception
                    );
                }

                if (!is_array($payload)) {
                    throw new DetectorException(
                        'invalid_response',
                        'Detector response must be a JSON object or array.'
                    );
                }

                return DetectionReport::fromPayload($payload);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new DetectorException(
                        'transport',
                        'Detector could not be reached after three attempts.',
                        $exception
                    );
                }

                $this->logger->warning('Technology detector transport retry', [
                    'attempt' => $attempt,
                ]);
                usleep(250_000 * (2 ** ($attempt - 1)));
            }
        }

        throw new DetectorException('transport', 'Detector request did not complete.');
    }

    private function isRetryable(int $status): bool
    {
        return in_array($status, [429, 502, 503, 504], true);
    }

    private function statusException(int $status): DetectorException
    {
        $kind = match (true) {
            $status === 401 || $status === 403 => 'authentication',
            $status === 400 || $status === 422 => 'validation',
            $status === 429 => 'rate_limit',
            $status >= 500 => 'upstream',
            default => 'http',
        };

        return new DetectorException($kind, "Detector returned HTTP {$status}.");
    }

    private function delayMicroseconds(
        ResponseInterface $response,
        int $attempt,
    ): int {
        $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;

        if (is_string($retryAfter) && ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 10) * 1_000_000;
        }

        return 250_000 * (2 ** ($attempt - 1));
    }
}

Клиентот никогаш не ги евидентира токенот или телото на одговорот. Телото на одговорот може да содржи докази корисни за апликацијата, но несоодветни за дневник за продукција со општа намена.

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

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

<?php
// src/Technology/SnapshotStore.php
namespace App\Technology;

final class SnapshotStore
{
    public function __construct(private string $directory) {}

    public function record(
        string $site,
        DetectionReport $report,
        callable $onChange,
    ): string {
        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0770, true)
            && !is_dir($this->directory)) {
            throw new \RuntimeException('Cannot create snapshot directory.');
        }

        $path = $this->directory.'/'.hash('sha256', $site).'.json';
        $handle = fopen($path, 'c+');

        if ($handle === false || !flock($handle, LOCK_EX)) {
            throw new \RuntimeException('Cannot lock technology snapshot.');
        }

        try {
            rewind($handle);
            $stored = stream_get_contents($handle);
            $previous = trim((string) $stored) === ''
                ? null
                : json_decode($stored, true, 512, JSON_THROW_ON_ERROR);
            $current = $report->snapshot();

            if ($previous === null) {
                $status = 'baseline';
            } else {
                $oldFingerprint = hash(
                    'sha256',
                    json_encode($previous, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
                );

                if (hash_equals($oldFingerprint, $report->fingerprint())) {
                    return 'unchanged';
                }

                $onChange($previous, $current);
                $status = 'changed';
            }

            rewind($handle);
            ftruncate($handle, 0);
            fwrite(
                $handle,
                json_encode(
                    $current,
                    JSON_THROW_ON_ERROR
                    | JSON_PRETTY_PRINT
                    | JSON_UNESCAPED_SLASHES
                )
            );
            fflush($handle);

            return $status;
        } finally {
            flock($handle, LOCK_UN);
            fclose($handle);
        }
    }
}

Создадете ја командата за мониторинг

<?php
// src/Command/MonitorTechnologiesCommand.php
namespace App\Command;

use App\Technology\DetectorException;
use App\Technology\SnapshotStore;
use App\Technology\WebsiteDetector;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

#[AsCommand(name: 'app:monitor-technologies')]
final class MonitorTechnologiesCommand extends Command
{
    public function __construct(
        private WebsiteDetector $detector,
        private SnapshotStore $store,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
        private string $recipient,
        private string $sender,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('site', InputArgument::REQUIRED, 'Public HTTP(S) URL');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $site = (string) $input->getArgument('site');
        $scheme = strtolower((string) parse_url($site, PHP_URL_SCHEME));

        if (!filter_var($site, FILTER_VALIDATE_URL)
            || !in_array($scheme, ['http', 'https'], true)) {
            $output->writeln('<error>Provide a valid HTTP(S) URL.</error>');
            return Command::INVALID;
        }

        try {
            $report = $this->detector->detect($site);
            $status = $this->store->record(
                $site,
                $report,
                function (array $before, array $after) use ($site): void {
                    $body = "Technology changes detected for {$site}\n\n"
                        ."Previous:\n".json_encode($before, JSON_PRETTY_PRINT)
                        ."\n\nCurrent:\n".json_encode($after, JSON_PRETTY_PRINT);

                    $this->mailer->send(
                        (new Email())
                            ->from($this->sender)
                            ->to($this->recipient)
                            ->subject('Website technology stack changed')
                            ->text($body)
                    );
                }
            );

            $this->logger->info('Technology monitor completed', [
                'site' => $site,
                'result' => $status,
                'detections' => count($report->technologies),
            ]);
            $output->writeln("Monitor result: {$status}");

            return Command::SUCCESS;
        } catch (DetectorException $exception) {
            $this->logger->error('Technology detector failed', [
                'site' => $site,
                'failure_kind' => $exception->kind,
                'message' => $exception->getMessage(),
            ]);
            $output->writeln('<error>Detector failed: '
                .$exception->kind.'</error>');

            return Command::FAILURE;
        } catch (\Throwable $exception) {
            $this->logger->error('Technology monitor failed', [
                'site' => $site,
                'exception' => $exception::class,
            ]);
            $output->writeln('<error>Monitor failed.</error>');

            return Command::FAILURE;
        }
    }
}

Поврзете ги вредностите поддржани од околината во config/services.yaml:

services:
    App\Technology\WebsiteDetector:
        arguments:
            $token: '%env(WEBSITE_DETECTOR_TOKEN)%'

    App\Technology\SnapshotStore:
        arguments:
            $directory: '%kernel.project_dir%/var/technology-monitor'

    App\Command\MonitorTechnologiesCommand:
        arguments:
            $recipient: '%env(TECH_MONITOR_RECIPIENT)%'
            $sender: '%env(TECH_MONITOR_FROM)%'

Тестирајте ја границата со MockHttpClient

Тестовите никогаш не смеат да ја повикаат активната услуга ниту да содржат вистински токен. Првиот тест ја проверува конструкцијата на барањето и мапирањето. Вториот потврдува дека неуспехот при автентикација е класифициран и дека е направен само еден обид.

<?php
// tests/Technology/WebsiteDetectorTest.php
namespace App\Tests\Technology;

use App\Technology\DetectorException;
use App\Technology\WebsiteDetector;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteDetectorTest extends TestCase
{
    public function testItSendsUrlAndMapsAResponse(): void
    {
        $http = new MockHttpClient(function (
            string $method,
            string $url,
            array $options,
        ): MockResponse {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/website-technology-detector/'
                .'v1/detect-technologies',
                $url
            );
            self::assertSame(['url' => 'https://www.example.com'], $options['json']);

            return new MockResponse(json_encode([
                'technologies' => [[
                    'name' => 'Example CMS',
                    'confidence' => 0.96,
                    'version' => '1.2',
                    'evidence' => ['public marker'],
                ]],
                'redirects' => [],
            ], JSON_THROW_ON_ERROR));
        });

        $report = (new WebsiteDetector(
            $http,
            new NullLogger(),
            'test-token'
        ))->detect('https://www.example.com');

        self::assertSame('Example CMS', $report->technologies[0]['name']);
        self::assertSame(0.96, $report->technologies[0]['confidence']);
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $http = new MockHttpClient(new MockResponse('', ['http_code' => 401]));
        $detector = new WebsiteDetector($http, new NullLogger(), 'test-token');

        try {
            $detector->detect('https://www.example.com');
            self::fail('Expected DetectorException.');
        } catch (DetectorException $exception) {
            self::assertSame('authentication', $exception->kind);
        }

        self::assertSame(1, $http->getRequestsCount());
    }
}
php bin/phpunit
php bin/console app:monitor-technologies https://www.example.com
php bin/console app:monitor-technologies https://www.example.com

Првото извршување на командата треба да пријави baseline; второто треба да пријави unchanged. За да ја потврдите испораката на е-пошта без да фалсификувате API одговор, копирајте ја генерираната снимка, изменете една зачувана вредност на технологија, извршете ја командата повторно и потоа вратете ја или отстранете ја тест-снимката.

Распоредете, закажете и надгледувајте го

Направете var/technology-monitor да биде запишлив од корисникот на апликацијата и траен низ изданијата. Извршувајте го распоредувачот како една инстанца. Овој cron запис проверува на секои шест часа и спречува преклопувачки извршувања:

17 */6 * * * cd /srv/app && flock -n var/client-tech.cron.lock php bin/console app:monitor-technologies https://www.example.com --env=prod >> var/log/technology-monitor.log 2>&1

Поставете предупредување за излезни кодови различни од нула или повторени настани во дневникот со failure_kind. Неуспех rate_limit сугерира намалување на зачестеноста или преглед на активниот план. Неуспех authentication обично значи дека токенот е погрешен, истечен или е поништен со повторно генерирање. Неуспех validation укажува на испратената URL-адреса. Повторените неуспеси transport или upstream заслужуваат истрага, но не треба да ја презапишат последната добра основна состојба.

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

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

  • Сметката и Free, Plus или Pro планот се активни, а токенот ограничен на услугата доаѓа од страницата со документација.
  • WEBSITE_DETECTOR_TOKEN постои само во конфигурација поддржана од околината.
  • Клиентот ја користи точната POST крајна точка, го испраќа само документираниот влез url и применува ограничени временски ограничувања.
  • Неуспесите на автентикација и валидација не се повторуваат; привремените неуспеси добиваат најмногу три обиди.
  • Првото успешно извршување создава основна состојба без да испрати погрешно предупредување.
  • Променетата снимка испраќа е-пошта пред да се потврди новата состојба.
  • Тестовите користат MockHttpClient и никогаш не контактираат со активната услуга.
  • Директориумот за снимки е запишлив, траен, соодветно резервно копиран и споделен ако повеќе од една инстанца може да ја извршува командата.
  • Продукцискиот мониторинг ги снима неуспешните излезни кодови и структурираните категории на неуспех без да бележи ингеренции.

Корисен монитор за страница не е само закажан API повик. Тој е внимателно исцртана граница меѓу надворешно набљудување и одлука вредна некого да биде прекинат за неа. Нормализирајте ги доказите, зачувајте ја последната доверлива состојба, повторувајте само неуспеси што можеби ќе се опорават и направете секое предупредување да биде објасниво. Тогаш тивката промена на веб-страница на клиент станува управлив инженерски настан наместо изненадување откриено при следниот инцидент.

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

Mihajlo

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