Туториали

Symfony: Detect Tech Stack Changes on Client Websites with Website Technology Detector API

Symfony: Откријте промени во технолошкиот стек на веб-страниците на клиентите со API за детектор на веб-технологии

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

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

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

Регистрирајте се преку страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Website Technology Detector.
  2. Изберете го достапниот Free, Plus или Pro план и завршете ја активацијата.
  3. Отворете ја официјалната документација за услугата.
  4. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.

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

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како координирано распоредување: ажурирајте ја тајната на апликацијата веднаш по повторното генерирање, па извршете скенирање за проверка.

Потврдете го точниот API повик

Интеграцијата испраќа 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 "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://client.example"}'

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

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

За локален развој, ставете го токенот во .env.local, кој треба да остане надвор од контрола на верзии. Продукцијата треба да ги внесува истите променливи преку хостинг-платформата или менаџерот за тајни.

TECH_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
TECH_DETECTOR_ENDPOINT=https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies
CLIENT_WEBSITE_URL=https://client.example
[email protected]
[email protected]
MAILER_DSN=smtp://USERNAME:[email protected]:587

URL-кодирајте ги резервираните знаци во SMTP акредитивите. Никогаш не ставајте го вистинскиот сервисен токен во изворен код, фикстури, дневници, слики од екран или пораки за исклучоци.

Создадете го Symfony проектот

Оваа имплементација е наменета за PHP 8.3 или понов и користи Symfony компоненти од прва страна. Закажана команда е попогодна од Messenger овде: секое скенирање е мала, периодична единица работа, па постојано активен worker би додал оперативен трошок без да го подобри исходот.

composer create-project symfony/skeleton tech-watch
cd tech-watch
composer require symfony/console symfony/http-client symfony/mailer symfony/monolog-bundle
composer require --dev symfony/test-pack
mkdir -p var/data var/lock

Релевантната структура на проектот е намерно компактна:

src/
  Command/WatchTechnologyStackCommand.php
  Domain/DetectionReport.php
  Domain/StackDiff.php
  Infrastructure/TechnologyDetector.php
  Infrastructure/ReportStore.php
  Infrastructure/DeveloperNotifier.php
tests/
  Infrastructure/TechnologyDetectorTest.php
config/
  services.yaml
var/data/
  technology-report.json

Конфигурирајте ги скаларните аргументи на конструкторот, додека му дозволувате на Symfony автоматски да ги поврзе рамковните услуги:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Infrastructure\TechnologyDetector:
        arguments:
            $apiToken: '%env(TECH_DETECTOR_TOKEN)%'
            $endpoint: '%env(TECH_DETECTOR_ENDPOINT)%'

    App\Infrastructure\DeveloperNotifier:
        arguments:
            $recipient: '%env(DEVELOPER_EMAIL)%'
            $sender: '%env(NOTIFICATION_FROM)%'

    App\Command\WatchTechnologyStackCommand:
        arguments:
            $websiteUrl: '%env(CLIENT_WEBSITE_URL)%'

Мапирајте го одговорот на границата на апликацијата

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

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

final readonly class DetectionReport
{
    public function __construct(
        public array $detections,
        public array $redirectInfo,
    ) {}

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

        if (!is_array($rows)) {
            throw new \UnexpectedValueException(
                'The API response has no valid detections collection.'
            );
        }

        $detections = [];

        foreach ($rows as $key => $row) {
            if (!is_array($row)) {
                continue;
            }

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

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

            $confidence = $row['confidence'] ?? null;
            $confidence = is_int($confidence) || is_float($confidence)
                ? $confidence
                : null;

            $versionInput = $row['versions'] ?? ($row['version'] ?? []);
            $versions = is_array($versionInput)
                ? $versionInput
                : [$versionInput];

            $versions = array_values(array_unique(array_filter(
                array_map(
                    static fn (mixed $value): string =>
                        is_scalar($value) ? trim((string) $value) : '',
                    $versions
                ),
                static fn (string $value): bool => $value !== ''
            )));
            sort($versions, SORT_NATURAL | SORT_FLAG_CASE);

            $evidence = $row['evidence'] ?? [];
            $evidence = is_array($evidence) ? array_values($evidence) : [$evidence];

            $detections[] = [
                'name' => $name,
                'confidence' => $confidence,
                'versions' => $versions,
                'evidence' => $evidence,
            ];
        }

        $redirectInfo = [];
        foreach ($payload as $key => $value) {
            if (is_string($key)
                && str_contains(strtolower($key), 'redirect')) {
                $redirectInfo[$key] = $value;
            }
        }

        return new self($detections, $redirectInfo);
    }

    public function toArray(): array
    {
        return [
            'detections' => $this->detections,
            'redirect_info' => $this->redirectInfo,
            'observed_at' => gmdate(DATE_ATOM),
        ];
    }
}

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

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

Клиентот користи ограничени временски истекувања за поврзување и за целокупен одговор. Повторно се обидува при неуспеси во транспортот, HTTP 429 одговори и серверски 5xx одговори до три обиди. Автентикацијата и другите грешки на клиентот откажуваат веднаш, бидејќи повторувањето на неважечко барање само троши време и квота.

<?php
// src/Infrastructure/TechnologyDetector.php
namespace App\Infrastructure;

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

final class DetectorException extends \RuntimeException {}

final class TechnologyDetector
{
    private \Closure $sleep;

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $apiToken,
        private string $endpoint,
        ?callable $sleeper = null,
    ) {
        $this->sleep = $sleeper === null
            ? static fn (int $microseconds) => usleep($microseconds)
            : \Closure::fromCallable($sleeper);
    }

    public function detect(string $url): DetectionReport
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
            throw new \InvalidArgumentException('A valid HTTP(S) URL is required.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', $this->endpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->apiToken,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException(
                            'The API response is not a JSON object.'
                        );
                    }

                    return DetectionReport::fromPayload($payload);
                }

                $retryable = $status === 429 || $status >= 500;

                if (!$retryable || $attempt === 3) {
                    throw new DetectorException(
                        sprintf('Technology detection failed with HTTP %d.', $status)
                    );
                }

                $this->logger->warning('technology_detector.retry', [
                    'status' => $status,
                    'attempt' => $attempt,
                    'host' => parse_url($url, PHP_URL_HOST),
                ]);

                $this->pause($response, $attempt);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new DetectorException(
                        'Technology detection failed after transport retries.',
                        0,
                        $exception
                    );
                }

                $this->logger->warning('technology_detector.transport_retry', [
                    'attempt' => $attempt,
                    'host' => parse_url($url, PHP_URL_HOST),
                ]);

                ($this->sleep)((2 ** ($attempt - 1)) * 1_000_000);
            } catch (\JsonException|\UnexpectedValueException $exception) {
                throw new DetectorException(
                    'Technology detector returned an invalid response.',
                    0,
                    $exception
                );
            }
        }

        throw new \LogicException('Retry loop terminated unexpectedly.');
    }

    private function pause(ResponseInterface $response, int $attempt): void
    {
        $header = $response->getHeaders(false)['retry-after'][0] ?? null;
        $seconds = is_string($header) && ctype_digit($header)
            ? max(1, min(15, (int) $header))
            : 2 ** ($attempt - 1);

        ($this->sleep)($seconds * 1_000_000);
    }
}

Исклучокот намерно ги изоставува телата на одговорите и акредитивите. Дневниците го идентификуваат настанот, обидот, статусот и целниот хост без да го откријат токенот.

Трајно зачувајте и споредете снимки

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

<?php
// src/Infrastructure/ReportStore.php
namespace App\Infrastructure;

final class ReportStore
{
    private string $path;

    public function __construct(string $projectDir)
    {
        $this->path = $projectDir.'/var/data/technology-report.json';
    }

    public function load(): ?array
    {
        if (!is_file($this->path)) {
            return null;
        }

        $data = json_decode(
            (string) file_get_contents($this->path),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        if (!is_array($data)) {
            throw new \UnexpectedValueException('Stored report is invalid.');
        }

        return $data;
    }

    public function save(array $report): void
    {
        $directory = dirname($this->path);
        if (!is_dir($directory) && !mkdir($directory, 0770, true)) {
            throw new \RuntimeException('Cannot create report directory.');
        }

        $temporary = tempnam($directory, 'report-');
        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary report.');
        }

        $json = json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

        if (file_put_contents($temporary, $json, LOCK_EX) === false
            || !rename($temporary, $this->path)) {
            @unlink($temporary);
            throw new \RuntimeException('Cannot save technology report.');
        }
    }
}
<?php
// src/Domain/StackDiff.php
namespace App\Domain;

final class StackDiff
{
    public static function between(array $before, array $after): array
    {
        $old = self::index($before);
        $new = self::index($after);

        $added = array_values(array_diff_key($new, $old));
        $removed = array_values(array_diff_key($old, $new));
        $versionChanges = [];

        foreach (array_intersect_key($new, $old) as $key => $current) {
            if ($old[$key]['versions'] !== $current['versions']) {
                $versionChanges[] = [
                    'name' => $current['name'],
                    'before' => $old[$key]['versions'],
                    'after' => $current['versions'],
                ];
            }
        }

        return array_filter([
            'added' => $added,
            'removed' => $removed,
            'version_changes' => $versionChanges,
        ]);
    }

    private static function index(array $detections): array
    {
        $indexed = [];

        foreach ($detections as $detection) {
            if (!is_array($detection)
                || !is_string($detection['name'] ?? null)) {
                continue;
            }

            $key = strtolower(trim($detection['name']));
            if ($key !== '') {
                $indexed[$key] = [
                    'name' => trim($detection['name']),
                    'versions' => is_array($detection['versions'] ?? null)
                        ? array_values($detection['versions'])
                        : [],
                ];
            }
        }

        ksort($indexed);
        return $indexed;
    }
}

Испратете го известувањето и извршете ја командата

<?php
// src/Infrastructure/DeveloperNotifier.php
namespace App\Infrastructure;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class DeveloperNotifier
{
    public function __construct(
        private MailerInterface $mailer,
        private string $recipient,
        private string $sender,
    ) {}

    public function send(string $url, array $changes, array $report): void
    {
        $body = "Public technology stack changes were detected for {$url}.\n\n"
            ."Changes:\n"
            .json_encode($changes, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
            ."\n\nCurrent validated report:\n"
            .json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

        $this->mailer->send(
            (new Email())
                ->from($this->sender)
                ->to($this->recipient)
                ->subject('Client website technology stack changed')
                ->text($body)
        );
    }
}
<?php
// src/Command/WatchTechnologyStackCommand.php
namespace App\Command;

use App\Domain\StackDiff;
use App\Infrastructure\DeveloperNotifier;
use App\Infrastructure\ReportStore;
use App\Infrastructure\TechnologyDetector;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:watch-tech-stack',
    description: 'Detects and reports client website technology changes.'
)]
final class WatchTechnologyStackCommand extends Command
{
    public function __construct(
        private TechnologyDetector $detector,
        private ReportStore $store,
        private DeveloperNotifier $notifier,
        private LoggerInterface $logger,
        private string $websiteUrl,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output
    ): int {
        $io = new SymfonyStyle($input, $output);

        try {
            $current = $this->detector->detect($this->websiteUrl)->toArray();
            $previous = $this->store->load();

            if ($previous === null) {
                $this->store->save($current);
                $io->success('Initial technology baseline stored.');
                return Command::SUCCESS;
            }

            $changes = StackDiff::between(
                $previous['detections'] ?? [],
                $current['detections']
            );

            if ($changes !== []) {
                $this->notifier->send(
                    $this->websiteUrl,
                    $changes,
                    $current
                );

                $this->logger->notice('technology_stack.changed', [
                    'host' => parse_url($this->websiteUrl, PHP_URL_HOST),
                    'change_groups' => array_keys($changes),
                ]);
            }

            // Save after notification so a mail failure is retried next run.
            $this->store->save($current);
            $io->success(
                $changes === [] ? 'No stack change detected.' : 'Developer notified.'
            );

            return Command::SUCCESS;
        } catch (\Throwable $exception) {
            $this->logger->error('technology_watch.failed', [
                'exception' => $exception,
                'host' => parse_url($this->websiteUrl, PHP_URL_HOST),
            ]);
            $io->error('Technology watch failed; inspect application logs.');
            return Command::FAILURE;
        }
    }
}

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

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

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

<?php
// tests/Infrastructure/TechnologyDetectorTest.php
namespace App\Tests\Infrastructure;

use App\Infrastructure\TechnologyDetector;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class TechnologyDetectorTest extends TestCase
{
    public function testItSendsTheContractAndMapsTheReport(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame('https://service.test/detect', $url);
                self::assertSame(
                    ['url' => 'https://client.example'],
                    json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR)
                );

                return new MockResponse(json_encode([
                    'detections' => [[
                        'name' => 'Example CMS',
                        'confidence' => 0.95,
                        'versions' => ['6.1'],
                        'evidence' => ['public marker'],
                    ]],
                    'redirects' => ['https://client.example/'],
                ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
            }
        );

        $detector = new TechnologyDetector(
            $http,
            new NullLogger(),
            'test-token',
            'https://service.test/detect',
            static fn (int $microseconds) => null
        );

        $report = $detector->detect('https://client.example');

        self::assertSame('Example CMS', $report->detections[0]['name']);
        self::assertSame(['6.1'], $report->detections[0]['versions']);
        self::assertArrayHasKey('redirects', $report->redirectInfo);
    }

    public function testItRetriesAServiceFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse('{"detections":[]}', ['http_code' => 200]),
        ]);

        $detector = new TechnologyDetector(
            $http,
            new NullLogger(),
            'test-token',
            'https://service.test/detect',
            static fn (int $microseconds) => null
        );

        self::assertSame(
            [],
            $detector->detect('https://client.example')->detections
        );
        self::assertSame(2, $http->getRequestsCount());
    }
}
php bin/phpunit
php bin/console app:watch-tech-stack -vv

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

Распоредете со продукциски зависности, обезбедете променливи на околината преку механизмот за тајни на платформата, проверете дали var/data е траен и запишлив и конфигурирајте вистински транспорт за е-пошта. Контејнерите со ефемерен датотечен систем имаат потреба од монтиран волумен или складиште за извештаи поддржано од база на податоци.

На вообичаен Linux хост, извршувајте ја командата од cron и користете flock за да спречите преклопувачки скенирања:

17 6 * * * cd /srv/tech-watch && /usr/bin/flock -n var/lock/tech-watch.lock /usr/bin/php bin/console app:watch-tech-stack --env=prod >> var/log/tech-watch-cron.log 2>&1

Следете ги излезните кодови на командата и структурираните настани technology_detector.retry, technology_detector.transport_retry, technology_stack.changed и technology_watch.failed. Успешното извршување „без промена“ исто така треба да биде видливо во историјата на распоредувачот, за тишината да не се помеша со здравје.

Вообичаени продукциски неуспеси

  • HTTP 401 или 403: потврдете го токенот ограничен на услугата и дали неодамна бил повторно генериран. Овие неуспеси намерно не се повторуваат.
  • HTTP 429: намалете ја фреквенцијата на скенирање или прегледајте го активниот план. Клиентот почитува нумеричка вредност Retry-After во рамки на ограничено доцнење.
  • Повторени 5xx или транспортни неуспеси: проверете ги појдовниот HTTPS, DNS, политиката за прокси и достапноста на услугата. Командата излегува неуспешно по исцрпување на буџетот за повторни обиди.
  • Неважечки JSON или недостасуваат детекции: зачувајте го дневникот за неуспехот и споредете го договорот на одговорот со официјалната документација. Не зачувувајте тивко празна почетна состојба.
  • Нема е-пошта: тестирајте го Symfony транспортот за е-пошта независно и прегледајте ги дневниците на испраќачот. Бидејќи снимката не се ажурира по неуспех на е-поштата, следното извршување повторно ќе се обиде да го испрати известувањето.
  • Почетната состојба исчезнува: направете var/data да биде траен низ изданија и замени на контејнери.

Завршна контролна листа за проверка

  • Планот на услугата е активен и токенот потекнува од панелот Service token на страницата со документација.
  • Токенот постои само во конфигурација поддржана од променливи на околината.
  • Минималното автентицирано барање успева за конфигурираната јавна URL-адреса.
  • php bin/phpunit поминува без мрежен пристап.
  • Првото извршување на командата создава var/data/technology-report.json.
  • Контролирана промена во фикстура или тест-снимка создава една е-пошта до развивачот.
  • Неуспесите при автентикација не се повторуваат; 429, 5xx и транспортните неуспеси имаат ограничени повторни обиди.
  • Распоредувачот спречува преклопувачки извршувања и пријавува излезни кодови различни од нула.
  • Дневниците содржат оперативен контекст, но никогаш токенот или необработеното тело на одговорот.

Корисен монитор за технологии не е само HTTP барање на тајмер. Неговата вредност доаѓа од стабилни правила за споредба, внимателно ракување со неизвесни надворешни податоци, сигурна семантика за известување и доволна набљудливост за да се разликува „ништо не се промени“ од „ништо не се изврши“. Со поставени овие граници, тивката команда станува систем за рано предупредување за промени на веб-страницата што инаку подоцна би ве изненадиле.

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

Mihajlo

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