Symfony: Откривајте промени во технолошкиот стек на клиентите и добивајте известувања веднаш
Веб-страницата на клиентот може да се промени без предупредување. Редизајн може да го замени CMS-от, миграција на хостирањето може да воведе нов CDN, или приклучок за оптимизација може тивко да додаде инфраструктура што сега треба да ја поддржувате. Чекањето нешто да се расипе претвора промена што може да се открие во инцидент што може да се избегне.
Ова упатство изработува Symfony набљудувач ориентиран кон продукција, кој анкетира важна јавна веб-страница, го евидентира откриениот технолошки стек и му испраќа е-пошта на програмер кога тој стек ќе се промени. Го користи API-то Website Technology Detector како граница за откривање, HTTP клиентот на Symfony за контролирани барања, мал доменски модел за дефанзивно парсирање и атомска локална снимка за споредба.
Дизајнот намерно останува скромен. За една или неколку веб-страници, закажана команда е полесна за распоредување и управување од редица. „Инстантно“ значи следниот интервал на анкетирање; распоред на секои пет минути често е разумна почетна точка, во зависност од квотата на избраниот план.
Добијте пристап и копирајте го сервисниот токен
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Website Technology Detector.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
Оваа услуга бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе користиме Bearer токен за акредитивот да остане надвор од URL-то и поврзаните дневници за пристап. Повторното генерирање на сервисниот токен го поништува претходно активниот токен, затоа ротирајте ја конфигурацијата на апликацијата веднаш по повторното генерирање.
Потврдете ја крајната точка пред да пишувате Symfony код
Точното барање е 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 "Content-Type: application/json" \
--data '{"url":"https://client.example"}'
Одговорот содржи детекции со оценета доверливост, докази, верзии и информации за пренасочување. Зачувајте примерок од одговорот приватно при интеграцијата, но никогаш не го предавајте во складиштето ако неговите докази содржат детали специфични за клиентот.
Создајте ја конфигурацијата на Symfony проектот
Оваа имплементација е наменета за PHP 8.3 или понов и постоечка Symfony апликација со достапни Console и dependency injection. Инсталирајте ги HTTP клиентот од прва страна, компонентите за пошта и заклучување, како и Symfony test pack:
composer require symfony/http-client symfony/mailer symfony/lock
composer require --dev symfony/test-pack
mkdir -p var/technology-watch
Чувајте ги локалните вредности во .env.local, кој не треба да се предава во складиштето. Продукцијата треба да ги внесе истите променливи преку платформата за хостирање или менаџерот на тајни:
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITE_URL=https://client.example
[email protected]
[email protected]
MAILER_DSN=smtp://user:[email protected]:587
Проектот има четири одговорности: DetectorClient е сопственик на договорот со далечинското API, DetectorResult го валидира и нормализира одговорот, SnapshotStore го зачувува последното набљудување, а WatchTechnologyCommand ја координира споредбата и известувањето.
Мапирајте го далечинскиот одговор на границата
Далечинскиот JSON не смее да протекува низ апликацијата како невалидирана низа. Следниот модел бара секоја детекција да има име, нумеричка доверливост, доказ и низа од верзии. Исто така, ги валидира записите за пренасочување наместо да претпостави дека секој одговор е целосен и правилно типизиран.
<?php
// src/Technology/DetectorResult.php
namespace App\Technology;
final readonly class Detection
{
public function __construct(
public string $name,
public float $confidence,
public array $versions,
public array|string $evidence,
) {}
}
final readonly class DetectorResult
{
/** @param list<Detection> $detections @param list<string> $redirects */
public function __construct(
public array $detections,
public array $redirects,
) {}
public static function fromPayload(array $payload): self
{
if (!isset($payload['detections']) || !is_array($payload['detections'])) {
throw new \UnexpectedValueException('Missing detections array.');
}
$detections = [];
foreach ($payload['detections'] as $row) {
if (!is_array($row)
|| !is_string($row['name'] ?? null)
|| $row['name'] === ''
|| !is_numeric($row['confidence'] ?? null)
|| !is_array($row['versions'] ?? null)
|| (!is_array($row['evidence'] ?? null)
&& !is_string($row['evidence'] ?? null))
) {
throw new \UnexpectedValueException('Malformed detection.');
}
$versions = array_values(array_filter(
$row['versions'],
static fn (mixed $v): bool => is_string($v) && $v !== ''
));
$detections[] = new Detection(
$row['name'],
(float) $row['confidence'],
$versions,
$row['evidence'],
);
}
$redirects = $payload['redirects'] ?? [];
if (!is_array($redirects)) {
throw new \UnexpectedValueException('Malformed redirect information.');
}
$redirects = array_values(array_filter(
$redirects,
static fn (mixed $url): bool => is_string($url) && $url !== ''
));
return new self($detections, $redirects);
}
public function fingerprint(): string
{
$stable = array_map(
static fn (Detection $d): array => [
'name' => $d->name,
'versions' => $d->versions,
],
$this->detections,
);
usort($stable, static fn (array $a, array $b): int =>
[$a['name'], $a['versions']] <=> [$b['name'], $b['versions']]
);
return hash('sha256', json_encode(
['detections' => $stable, 'redirects' => $this->redirects],
JSON_THROW_ON_ERROR
));
}
}
Отпечатокот намерно ги исклучува доверливоста и доказите. Тие може да станат попрецизни без да претставуваат вистинска миграција на стекот. Имињата на технологиите, пријавените верзии и патеките за пренасочување се сигнали за промена.
Изградете ограничен API клиент свесен за повторни обиди
HTTP клиентот дозволува три обиди за неуспеси при транспортот, ограничување на стапката и неуспеси на серверот. Неуспесите при автентикација и валидација се конечни: повторниот обид со истиот токен или товар само троши време и квота.
<?php
// src/Technology/DetectorClient.php
namespace App\Technology;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class DetectorException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
?\Throwable $previous = null,
) {
parent::__construct("Technology detection failed: {$kind}", 0, $previous);
}
}
final readonly class DetectorClient
{
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 $detectorToken,
) {}
public function detect(string $url): DetectorResult
{
for ($attempt = 1; $attempt <= 3; ++$attempt) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->detectorToken,
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
return DetectorResult::fromPayload($response->toArray(false));
}
if (in_array($status, [400, 401, 403, 422], true)) {
$kind = in_array($status, [401, 403], true)
? 'authentication'
: 'request_rejected';
throw new DetectorException($kind, $status);
}
if (($status === 429 || $status >= 500) && $attempt < 3) {
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$seconds = ctype_digit((string) $retryAfter)
? min(5, max(1, (int) $retryAfter))
: 1 << ($attempt - 1);
$this->logger->warning('Detector request will be retried.', [
'status' => $status,
'attempt' => $attempt,
'delay_seconds' => $seconds,
]);
usleep($seconds * 1_000_000);
continue;
}
throw new DetectorException(
$status === 429 ? 'rate_limited' : 'remote_error',
$status
);
} catch (DecodingExceptionInterface|\UnexpectedValueException $e) {
throw new DetectorException('invalid_response', null, $e);
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new DetectorException('transport', null, $e);
}
usleep((1 << ($attempt - 1)) * 1_000_000);
}
}
throw new DetectorException('exhausted');
}
}
Неактивниот тајмаут ја ограничува застојаната активност на врска или одговор, додека max_duration го ограничува целосното барање. Одложувањето за ограничување на стапката почитува едноставна нумеричка вредност Retry-After, но ја ограничува на пет секунди, одржувајќи го закажаниот процес ограничен.
Зачувајте атомска основа
База на податоци би била непотребна за еден набљудувач. Чувајте по една JSON-датотека за секое URL и заменете ја атомски. Корисникот на веб-серверот или распоредувачот има потреба од пристап за запишување во var/technology-watch.
<?php
// src/Technology/SnapshotStore.php
namespace App\Technology;
final readonly class SnapshotStore
{
public function __construct(private string $directory) {}
public function load(string $url): ?array
{
$path = $this->path($url);
if (!is_file($path)) {
return null;
}
$data = json_decode(
(string) file_get_contents($path),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($data) || !is_string($data['fingerprint'] ?? null)) {
throw new \RuntimeException('Stored snapshot is invalid.');
}
return $data;
}
public function save(string $url, DetectorResult $result): void
{
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)
) {
throw new \RuntimeException('Cannot create snapshot directory.');
}
$path = $this->path($url);
$temporary = $path.'.tmp';
$payload = json_encode([
'url' => $url,
'observed_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
'fingerprint' => $result->fingerprint(),
'detections' => array_map(
static fn (Detection $d): array => [
'name' => $d->name,
'confidence' => $d->confidence,
'versions' => $d->versions,
'evidence' => $d->evidence,
],
$result->detections
),
'redirects' => $result->redirects,
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (file_put_contents($temporary, $payload, LOCK_EX) === false
|| !rename($temporary, $path)
) {
throw new \RuntimeException('Cannot write snapshot.');
}
}
private function path(string $url): string
{
return $this->directory.'/'.hash('sha256', $url).'.json';
}
}
Координирајте ги откривањето и известувањето
Првото успешно извршување воспоставува основа без да предизвика лажна тревога. Подоцнежните промени се испраќаат по е-пошта пред да се ажурира снимката. Ако испораката на поштата не успее, старата состојба останува и следното извршување се обидува повторно. Ова обезбедува семантика на известување барем еднаш, па пад по испраќањето, но пред зачувувањето, може да создаде дупликат порака.
<?php
// src/Command/WatchTechnologyCommand.php
namespace App\Command;
use App\Technology\DetectorClient;
use App\Technology\DetectorException;
use App\Technology\SnapshotStore;
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\Lock\LockFactory;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
#[AsCommand(
name: 'app:watch-technology',
description: 'Detect and report changes to a client website technology stack.'
)]
final class WatchTechnologyCommand extends Command
{
public function __construct(
private readonly DetectorClient $detector,
private readonly SnapshotStore $store,
private readonly LockFactory $locks,
private readonly MailerInterface $mailer,
private readonly string $clientSiteUrl,
private readonly string $alertFrom,
private readonly string $alertTo,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$lock = $this->locks->createLock(
'technology-watch-'.hash('sha256', $this->clientSiteUrl),
60
);
if (!$lock->acquire()) {
$output->writeln('Another detection is already running.');
return Command::SUCCESS;
}
try {
$current = $this->detector->detect($this->clientSiteUrl);
$previous = $this->store->load($this->clientSiteUrl);
if ($previous === null) {
$this->store->save($this->clientSiteUrl, $current);
$output->writeln('Baseline created; no alert sent.');
return Command::SUCCESS;
}
if (hash_equals($previous['fingerprint'], $current->fingerprint())) {
$this->store->save($this->clientSiteUrl, $current);
$output->writeln('No technology change detected.');
return Command::SUCCESS;
}
$summary = array_map(
static fn ($d): string => sprintf(
'%s [%s], confidence %s',
$d->name,
implode(', ', $d->versions) ?: 'version unavailable',
$d->confidence
),
$current->detections
);
$this->mailer->send(
(new Email())
->from($this->alertFrom)
->to($this->alertTo)
->subject('Client website technology stack changed')
->text(
"A public stack change was detected for "
.$this->clientSiteUrl.".\n\n"
.implode("\n", $summary)
."\n\nRedirects:\n"
.implode("\n", $current->redirects)
)
);
$this->store->save($this->clientSiteUrl, $current);
$output->writeln('Change detected and notification sent.');
return Command::SUCCESS;
} catch (DetectorException $e) {
$output->writeln(sprintf(
'Detection failed: %s%s',
$e->kind,
$e->status === null ? '' : " (HTTP {$e->status})"
));
return Command::FAILURE;
} finally {
$lock->release();
}
}
}
Поврзете ги вредностите од околината со услугите
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$detectorToken: '%env(string:WEBSITE_DETECTOR_TOKEN)%'
$clientSiteUrl: '%env(string:CLIENT_SITE_URL)%'
$alertFrom: '%env(string:TECH_ALERT_FROM)%'
$alertTo: '%env(string:TECH_ALERT_TO)%'
App\:
resource: '../src/'
App\Technology\SnapshotStore:
arguments:
$directory: '%kernel.project_dir%/var/technology-watch'
Тестирајте ја API границата без пристап до мрежа
MockHttpClient ја прави надворешната интеракција детерминистичка. Тестирајте и валидно мапирање и отфрлање на неисправни податоци; тестовите на командата можат одделно да ги имитираат клиентот, складиштето и испраќачот на пошта за да ги покријат патеките за основа, непроменета состојба, променета состојба и неуспешно испраќање пошта.
<?php
// tests/Technology/DetectorClientTest.php
namespace App\Tests\Technology;
use App\Technology\DetectorClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class DetectorClientTest extends TestCase
{
public function testMapsSuccessfulDetection(): void
{
$response = new MockResponse(json_encode([
'detections' => [[
'name' => 'Symfony',
'confidence' => 0.98,
'versions' => ['7'],
'evidence' => ['public-signal'],
]],
'redirects' => [],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
$client = new DetectorClient(
new MockHttpClient([$response]),
new NullLogger(),
'test-token'
);
$result = $client->detect('https://client.example');
self::assertSame('Symfony', $result->detections[0]->name);
self::assertSame(['7'], $result->detections[0]->versions);
self::assertSame([], $result->redirects);
}
public function testRejectsMalformedResponse(): void
{
$response = new MockResponse(
'{"detections":[{"name":"Symfony"}]}',
['http_code' => 200]
);
$client = new DetectorClient(
new MockHttpClient([$response]),
new NullLogger(),
'test-token'
);
$this->expectException(\App\Technology\DetectorException::class);
$client->detect('https://client.example');
}
}
php bin/phpunit
php bin/console app:watch-technology -vv
Распоредете и управувајте со набљудувачот
Извршувајте ја командата под истиот Unix идентитет при секое повикување за да може да ја чита и заменува својата снимка. Со повеќе примероци на апликацијата, локалните датотеки и локалното заклучување се недоволни; или прицврстете ја закажаната задача на еден примерок или заменете ги двете со споделено складиште и споделено Symfony Lock складиште.
Запис во cron на секои пет минути е едноставен:
*/5 * * * * cd /srv/site-watch && APP_ENV=prod php bin/console app:watch-technology --no-interaction >> var/log/technology-watch-cron.log 2>&1
Следете ненулти излези и повторени неуспеси rate_limited, transport или invalid_response. Дневниците треба да ги содржат URL-то, статусот, обидот и категоријата на неуспех, но никогаш токенот или целосните заглавија за авторизација. Изберете интервал на анкетирање што ја почитува квотата на активираниот план.
Ограничете го CLIENT_SITE_URL на прегледано јавно URL на клиентот. Не ја претворајте оваа команда во неограничен прокси за URL-адреси доставени од корисници. Заштитете ги променливите од околината, чувајте ги датотеките со снимки надвор од јавниот корен на документи и погрижете се акредитивите за пошта да се управуваат како токенот на детекторот.
Вообичаени начини на неуспех
- HTTP 401 или 403: потврдете го сервисниот токен и дали некој повторно го генерирал, со што го поништил претходниот токен.
- HTTP 400 или 422: проверете дали JSON содржи валидно јавно
url; менувањето на повторните обиди нема да го поправи барањето. - HTTP 429: намалете ја фреквенцијата на анкетирање или прегледајте го активниот план. Клиентот прави кратки повторни обиди, а потоа враќа структуриран неуспех.
- Неважечки одговор: зачувајте ја постојната основа и истражете пред да го промените маперот. Никогаш не презапишувајте позната добра состојба со невалидирани податоци.
- Нема е-пошта: извршете ја командата интерактивно, потврдете го
MAILER_DSNи тестирајте ја испораката на поштата независно. Неуспешното испраќање намерно ја остава претходната снимка недопрена. - Повторени предупредувања: потврдете дека директориумот за снимки е траен и достапен за запишување низ распоредувања и рестартирања на контејнери.
Конечна листа за потврда
- Планот на услугата е активен и тековниот токен ограничен на услугата е достапен само преку конфигурација на околината.
- Минималното POST барање успева кон точната крајна точка за откривање.
php bin/phpunitпоминува без да направи вистинско мрежно барање.- Првото извршување на командата создава основа и не испраќа предупредување.
- Непроменет одговор ги ажурира податоците за набљудување без испраќање пошта.
- Променет отпечаток на технологија, верзија или пренасочување испраќа пошта пред да ја предаде новата снимка.
- Неуспесите при автентикација, валидација, квота, транспорт, неисправен одговор и пошта ја зачувуваат претходната основа.
- Распоредувачот пријавува ненулти излези, избегнува преклопени извршувања и работи во рамките на ограничувањата на планот.
Корисен набљудувач не е само API повик на тајмер. Неговата вредност произлегува од третирањето на далечинскиот одговор како недоверлив, разликувањето на неуспеси за кои може да се направи повторен обид од трајните и зачувувањето на состојбата додека известувањето не успее. Со овие граници, тивката промена на јавната технологија станува навремен, применлив сигнал наместо утрешно изненадување при дебагирање.