Vodiči

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

Symfony: Pratite promjene tehnološkog paketa klijentskih web-mjesta pomoću API-ja Website Detector

Web-mjesto klijenta može se promijeniti bez implementacije: nadogradnja CMS-a zamjenjuje dodatke, pojavljuje se CDN, analitika nestaje ili redizajn neprimjetno premješta stranicu na drugu platformu. Te promjene mogu utjecati na integracije, pretpostavke o izvedbi, sigurnosne provjere i procjene održavanja.

Ovaj vodič izrađuje Symfony monitor za produkciju koji provjerava javno web-mjesto pomoću API-ja Website Technology Detector, pohranjuje normaliziranu osnovnu vrijednost i šalje e-poruku razvojnom programeru samo kada se otkriveni tehnološki skup promijeni. Izvršava se kao zakazana konzolna naredba, koristi ograničeni broj ponovnih pokušaja i udaljeni odgovor tretira kao nepouzdane podatke.

Dobijte pristup i kopirajte servisni token

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Website Technology Detector.
  3. Odaberite dostupan Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer vjerodajnice u nizovima upita mogu dospjeti u zapisnike pristupa, povijest preglednika i sustave za nadzor.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Koordinirajte rotaciju s implementacijom kako bi aplikacija dobila novu vrijednost prije ili neposredno nakon ponovnog generiranja.

Potvrdite krajnju točku prije pisanja koda aplikacije

Točan zahtjev je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Njegovo JSON tijelo sadrži 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"}'

Uspješan odgovor sadrži otkrivanja tehnologija s ocjenom pouzdanosti, dokaze, informacije o verziji kada su dostupne i informacije o preusmjeravanju. Integracija u nastavku namjerno izbjegava ovisnost o jednoj nedokumentiranoj vanjskoj ovojnici. Prepoznaje dokumentirane koncepte na granici aplikacije i odbacuje neispravan JSON.

Za lokalni razvoj stavite vjerodajnicu u .env.local, koji mora ostati nepredan u repozitorij. U produkciji unesite istu varijablu putem hosting platforme ili Symfony secretsa umjesto da je ugrađujete u sliku.

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

Arhitektura i postavljanje projekta

Zakazana Symfony naredba ovdje je bolji izbor od HTTP kontrolera. Provjere se trebaju nastaviti bez zahtjeva preglednika, a privremeni kvarovi detektora ili e-pošte trebaju proizvesti neuspješan proces koji cron ili platforma za implementaciju mogu pratiti.

Naredba poziva namjenski API klijent, preslikava odgovor u stabilnu snimku domene i prosljeđuje je spremištu stanja temeljenom na datotekama. Prvo uspješno pokretanje stvara osnovnu vrijednost bez slanja upozorenja. Kasnija pokretanja šalju e-poruku prije potvrđivanja promijenjene snimke, tako da neuspjeh e-pošte ne potroši obavijest bez vidljivosti.

Ovo spremište datoteka namjerno je malo i praktično za jednu instancu aplikacije. Implementacija s više replika trebala bi ga zamijeniti spremištem temeljenim na bazi podataka ili zajedničkom trajnom pohranom te zadržati ekvivalentnu semantiku zaključavanja.

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 bi dodao malo vrijednosti za jednu zakazanu mrežnu operaciju. Ako stotine nepovezanih web-mjesta s vremenom trebaju neovisne rasporede i konkurentnost, slanje jedne poruke po web-mjestu postaje isplativo. Do tada je naredbu jednostavnije implementirati i dijagnosticirati.

Defenzivno preslikajte udaljeni odgovor

Preslikavač pretražuje rekurzivno jer se transportne ovojnice mogu razvijati. Čvor se tretira kao tehnologija samo kada ima neprazan naziv i barem jedan signal otkrivanja, kao što su pouzdanost, dokazi ili podaci o verziji. Nepoznata polja ostaju bezopasna, nedostajuća neobavezna polja postaju null ili prazna polja, a dokazi nalik popisima sortiraju se kako bi se spriječila upozorenja koja proizlaze samo iz redoslijeda.

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

Uključivanje preusmjeravanja u snimku korisno je jer premještanje na drugi host može objasniti prividnu zamjenu skupa tehnologija. ID-ovi zahtjeva, vremenske oznake i nepovezani metapodaci isključeni su kako bi se izbjegla bučna upozorenja.

Izradite otporan HTTP klijent

Neuspjesi trebaju kategorije, a ne nerazlučivu iznimku. Pogreške autentikacije i validacije zahtijevaju intervenciju i ne smiju se ponovno pokušavati. Transportni neuspjesi, ograničenja brzine i odabrane pogreške uzvodnog sustava mogu biti privremeni, pa klijent izvodi najviše tri pokušaja s ograničenim odgodama.

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

Klijent nikada ne zapisuje token ni tijelo odgovora. Tijelo odgovora može sadržavati dokaze korisne aplikaciji, ali neprikladne za produkcijski zapisnik opće namjene.

Pohranite osnovnu vrijednost bez gubitka upozorenja

Spremište koristi isključivo zaključavanje datoteke. Ako isporuka obavijesti izbaci iznimku, stara snimka ostaje netaknuta, zbog čega sljedeće zakazano pokretanje pokušava ponovno.

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

Izradite naredbu za nadzor

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

Povežite vrijednosti temeljene na okruženju u 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)%'

Testirajte granicu pomoću MockHttpClienta

Testovi nikada ne smiju pozivati aktivnu uslugu ni sadržavati stvarni token. Prvi test provjerava izradu zahtjeva i preslikavanje. Drugi potvrđuje da je neuspjeh autentikacije klasificiran i da se pokušava samo jednom.

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

Prvo pokretanje naredbe trebalo bi prijaviti baseline; drugo bi trebalo prijaviti unchanged. Da biste potvrdili isporuku e-pošte bez krivotvorenja API odgovora, kopirajte generiranu snimku, izmijenite jednu spremljenu vrijednost tehnologije, ponovno pokrenite naredbu, a zatim vratite ili uklonite testnu snimku.

Implementirajte, zakažite i pratite

Učinite var/technology-monitor zapisivim za korisnika aplikacije i trajnim između izdanja. Pokrenite planer kao jednu instancu. Ovaj cron unos provjerava svakih šest sati i sprječava preklapanje izvršavanja:

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

Postavite upozorenja za izlazne kodove koji nisu nula ili ponovljene događaje zapisnika s failure_kind. Neuspjeh rate_limit sugerira smanjenje učestalosti ili provjeru aktivnog plana. Neuspjeh authentication obično znači da je token pogrešan, istekao ili je opozvan ponovnim generiranjem. Neuspjeh validation upućuje na poslani URL. Ponovljeni neuspjesi transport ili upstream zaslužuju istragu, ali ne bi smjeli prepisati posljednju dobru osnovnu vrijednost.

Ne izlažite ovu naredbu putem neautentificirane web rute. Držite tokene izvan kontrole izvornog koda, poruka iznimki, fixturea i zapisnika implementacije. Ograničite pristup okruženju, namjerno rotirajte token i dokaze vraćene o web-mjestu klijenta tretirajte kao operativne podatke.

Kontrolni popis za završnu provjeru

  • Račun i Free, Plus ili Pro plan aktivni su, a token ograničen na uslugu dolazi sa stranice dokumentacije.
  • WEBSITE_DETECTOR_TOKEN postoji samo u konfiguraciji temeljenoj na okruženju.
  • Klijent koristi točnu POST krajnju točku, šalje samo dokumentirani ulaz url i primjenjuje ograničena vremenska ograničenja.
  • Neuspjesi autentikacije i validacije ne pokušavaju se ponovno; privremeni neuspjesi dobivaju najviše tri pokušaja.
  • Prvo uspješno pokretanje stvara osnovnu vrijednost bez slanja obmanjujućeg upozorenja.
  • Promijenjena snimka šalje e-poruku prije potvrđivanja novog stanja.
  • Testovi koriste MockHttpClient i nikada ne kontaktiraju aktivnu uslugu.
  • Direktorij snimki je zapisiv, trajan, primjereno sigurnosno kopiran i dijeljen ako naredbu može pokrenuti više od jedne instance.
  • Produkcijski nadzor bilježi neuspješne izlazne kodove i strukturirane kategorije neuspjeha bez evidentiranja vjerodajnica.

Korisni monitor web-mjesta nije samo zakazani API poziv. To je pažljivo povučena granica između vanjskog opažanja i odluke zbog koje vrijedi nekoga prekinuti. Normalizirajte dokaze, sačuvajte posljednje pouzdano stanje, ponovno pokušajte samo neuspjehe koji se mogu oporaviti i učinite svako upozorenje objašnjivim. Tada tiha promjena na web-mjestu klijenta postaje upravljiv inženjerski događaj umjesto iznenađenja otkrivenog tijekom sljedećeg incidenta.

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.