Native PHP 8.3: Следете ги промените во технолошкиот стек на клиентските веб-страници со Website Detector API
Веб-страницата на клиент може да се промени без распоредување: миграција на CDN ги менува заглавијата, редизајн го заменува CMS-от или маркетинг-приклучок тивко воведува нова зависност. Овие промени се јавни, оперативно релевантни и лесно се пропуштаат сè додека нешто не се расипе.
Овој туторијал изработува Native PHP 8.3 монитор насочен кон продукциска употреба околу API-то Website Technology Detector. Тој проверува контролирана листа на веб-страници на клиенти, го претвора API-одговорот во стабилна доменска снимка, го зачувува последниот успешен резултат и испраќа е-пошта до развивач кога технологии се додадени, отстранети или пријавуваат поинаква верзија.
Дизајнот намерно ги раздвојува откривањето, споредбата, перзистентноста и известувањето. Така, привремените API-неуспеси не изгледаат како промени во стекот и секоја граница може независно да се тестира.
Добијте пристап и копирајте токен ограничен на услугата
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Website Technology Detector.
- Изберете достапен Free, Plus или Pro план и завршете ја активацијата. Изберете план чиј лимит на барања одговара на бројот на ваши веб-страници и честотата на мониторинг.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата што е прикажан таму.
Оваа услуга бара автентикација. Таа прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Имплементацијата подолу користи Bearer токен бидејќи заглавијата имаат помала веројатност од низа за пребарување да се појават во логови на прокси-сервери и аналитички системи.
Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Третирајте го повторното генерирање како ротација на акредитиви: ажурирајте ја околината за распоредување, рестартирајте го мониторот и потврдете едно барање пред да сметате дека ротацијата е завршена.
Потврдете ја крајната точка пред да го напишете мониторот
Точниот повик е POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Неговото JSON-тело на барањето содржи url.
Направете едно минимално барање со нетестна цел што не е чувствителна:
curl --fail-with-body \
--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://example.com"}'
Не го внесувајте вистинскиот токен во историјата на школка на споделена машина. За проектот, создадете датотека со околина за распоредување исклучена од контрола на верзии:
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN
CLIENT_SITES=https://example.com,https://www.example.org
[email protected]
STATE_FILE=/var/lib/site-stack-monitor/state.json
Поставете го режимот на датотеката на 0600. Целните URL-адреси се конфигурација, а не произволен веб-влез: наведувајте само страници за чие следење сте одговорни.
Архитектура и структура на проектот
Синхрона закажана команда е доволна тука. Редицата би додала инфраструктура без да подобри мал монитор што работи на секој час. Командата извршува четири операции:
- Го прашува детекторот за секоја конфигурирана URL-адреса.
- Го мапира одговорот во валидирани откривања и метаподатоци за пренасочување.
- Ги споредува само имињата и верзиите на технологиите со претходната успешна снимка.
- Ја зачувува снимката и известува при значајна разлика.
Довербата и доказите остануваат во зачуваните снимки за дијагноза, но се исклучени од потписот на промената. Во спротивно, безопасните флуктуации на довербата би можеле да генерираат бучни предупредувања.
Создадете ја оваа структура:
site-stack-monitor/
├── bin/
│ └── check.php
├── src/
│ └── Monitor.php
├── tests/
│ └── DetectorClientTest.php
├── composer.json
└── phpunit.xml
Користете Composer само за автоматско вчитување и PHPUnit:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"files": ["src/Monitor.php"]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
Инсталирајте ги зависностите со composer install, а потоа извршете composer dump-autoload по додавањето на изворната датотека.
Изградете ограничена HTTP и доменска граница
API-то враќа откривања оценети според доверба со докази и верзии, како и информации за пренасочување. Маперот подолу ги валидира тие концепти, притоа толерирајќи вгнездување и двете природни ознаки за збирки, detections и technologies. Непознатите полиња остануваат надвор од моделот за споредба наместо да протекуваат низ целата апликација.
<?php
declare(strict_types=1);
namespace SiteMonitor;
use Closure;
use JsonException;
use RuntimeException;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body
) {}
}
interface Transport
{
public function post(string $url, array $headers, string $body): HttpResponse;
}
final class TransportFailure extends RuntimeException {}
final class ApiFailure extends RuntimeException
{
public function __construct(public readonly ?int $status, string $message)
{
parent::__construct($message);
}
}
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$receivedHeaders = [];
$handle = curl_init($url);
if ($handle === false) {
throw new TransportFailure('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 25,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$receivedHeaders): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$receivedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportFailure($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $receivedHeaders, $bodyResult);
}
}
final readonly class DetectionSnapshot
{
public function __construct(
public array $detections,
public array $redirectMetadata
) {}
public static function fromPayload(array $payload): self
{
$collections = [];
self::findCollections($payload, $collections);
if ($collections === []) {
throw new ApiFailure(null, 'Response has no detection collection');
}
$mapped = [];
foreach ($collections as $collection) {
foreach ($collection as $item) {
if (!is_array($item)) {
continue;
}
$name = $item['name'] ?? $item['technology'] ?? null;
$confidence = $item['confidence'] ?? null;
if (!is_string($name) || trim($name) === '' || !is_numeric($confidence)) {
continue;
}
$evidence = $item['evidence'] ?? [];
$evidence = is_string($evidence) ? [$evidence] : $evidence;
if (!is_array($evidence)) {
$evidence = [];
}
$version = $item['version'] ?? null;
$key = strtolower(trim($name));
$mapped[$key] = [
'name' => trim($name),
'version' => is_scalar($version) ? (string) $version : null,
'confidence' => (float) $confidence,
'evidence' => array_values(array_filter(
$evidence,
static fn ($value): bool => is_string($value)
)),
];
}
}
ksort($mapped);
$redirects = [];
self::findRedirectMetadata($payload, '', $redirects);
return new self(array_values($mapped), $redirects);
}
public function stackSignature(): array
{
return array_map(
static fn (array $item): array => [
'name' => $item['name'],
'version' => $item['version'],
],
$this->detections
);
}
private static function findCollections(array $node, array &$found): void
{
foreach ($node as $key => $value) {
if (
is_string($key)
&& in_array(strtolower($key), ['detections', 'technologies'], true)
&& is_array($value)
) {
$found[] = $value;
}
if (is_array($value)) {
self::findCollections($value, $found);
}
}
}
private static function findRedirectMetadata(
array $node,
string $path,
array &$found
): void {
foreach ($node as $key => $value) {
$next = $path === '' ? (string) $key : $path . '.' . $key;
if (is_string($key) && preg_match('/redirect|final[_-]?url/i', $key)) {
$found[$next] = $value;
} elseif (is_array($value)) {
self::findRedirectMetadata($value, $next, $found);
}
}
}
}
final class DetectorClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';
private Closure $sleep;
public function __construct(
private readonly Transport $transport,
private readonly string $token,
?Closure $sleep = null
) {
$this->sleep = $sleep ?? static fn (int $milliseconds) =>
usleep($milliseconds * 1000);
}
public function detect(string $url): DetectionSnapshot
{
$parts = parse_url($url);
if (
$parts === false
|| !isset($parts['scheme'], $parts['host'])
|| !in_array(strtolower($parts['scheme']), ['http', 'https'], true)
) {
throw new ApiFailure(null, 'Configured target is not an HTTP(S) URL');
}
$requestBody = json_encode(['url' => $url], JSON_THROW_ON_ERROR);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(self::ENDPOINT, [
'Authorization: Bearer ' . $this->token,
'Accept: application/json',
'Content-Type: application/json',
], $requestBody);
} catch (TransportFailure $failure) {
if ($attempt === 3) {
throw new ApiFailure(null, 'Transport failed after three attempts');
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException) {
throw new ApiFailure($response->status, 'Invalid JSON response');
}
if (!is_array($decoded)) {
throw new ApiFailure($response->status, 'Unexpected JSON root');
}
return DetectionSnapshot::fromPayload($decoded);
}
$retryable = $response->status === 429
|| in_array($response->status, [500, 502, 503, 504], true);
if (!$retryable || $attempt === 3) {
throw new ApiFailure(
$response->status,
'Detector returned HTTP ' . $response->status
);
}
$retryAfter = $response->headers['retry-after'] ?? null;
$delay = is_string($retryAfter) && ctype_digit($retryAfter)
? min(10_000, (int) $retryAfter * 1000)
: 250 * (2 ** ($attempt - 1));
($this->sleep)($delay);
}
throw new ApiFailure(null, 'Detector request did not complete');
}
}
Клиентот повторно се обидува при мрежни неуспеси, HTTP 429 и избрани привремени серверски грешки. Тој никогаш слепо не се обидува повторно при неуспеси на автентикација, дозволи или валидација. Нумеричка вредност Retry-After се почитува, но е ограничена на десет секунди за едно извршување да не може да заглави на неодредено време.
Споредете снимки, зачувајте состојба и известете
Командата користи ексклузивно заклучување на извршувањето, спречувајќи преклопување на повиците од распоредувачот. Таа ја запишува состојбата преку привремена датотека и атомско преименување. Неуспешна страница ја задржува претходната успешна состојба, така што прекин не може да се претстави како празен стек.
<?php
declare(strict_types=1);
use SiteMonitor\ApiFailure;
use SiteMonitor\CurlTransport;
use SiteMonitor\DetectorClient;
require dirname(__DIR__) . '/vendor/autoload.php';
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return trim($value);
}
function logEvent(string $level, string $event, array $context = []): void
{
fwrite(STDERR, json_encode([
'time' => gmdate(DATE_ATOM),
'level' => $level,
'event' => $event,
] + $context, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);
}
$token = requiredEnv('WEBSITE_DETECTOR_TOKEN');
$email = requiredEnv('ALERT_EMAIL');
$stateFile = requiredEnv('STATE_FILE');
$sites = array_values(array_filter(array_map(
'trim',
explode(',', requiredEnv('CLIENT_SITES'))
)));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('ALERT_EMAIL is invalid');
}
$lock = fopen($stateFile . '.run.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
logEvent('warning', 'run_already_active');
exit(75);
}
$state = [];
if (is_file($stateFile)) {
$decoded = json_decode(
file_get_contents($stateFile),
true,
512,
JSON_THROW_ON_ERROR
);
$state = is_array($decoded) ? $decoded : [];
}
$nextState = $state;
$client = new DetectorClient(new CurlTransport(), $token);
$exitCode = 0;
foreach ($sites as $site) {
$key = hash('sha256', $site);
try {
$snapshot = $client->detect($site);
$signature = $snapshot->stackSignature();
$previous = $state[$key]['signature'] ?? null;
if (is_array($previous) && $previous !== $signature) {
$message = "Public technology stack changed for {$site}\n\n"
. "Previous:\n"
. json_encode($previous, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
. "\n\nCurrent:\n"
. json_encode($signature, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
if (!mail($email, 'Client website technology change', $message)) {
throw new RuntimeException('Local mail transport rejected the alert');
}
logEvent('info', 'change_alert_sent', ['site' => $site]);
} elseif ($previous === null) {
logEvent('info', 'baseline_created', ['site' => $site]);
} else {
logEvent('info', 'stack_unchanged', ['site' => $site]);
}
$nextState[$key] = [
'url' => $site,
'observed_at' => gmdate(DATE_ATOM),
'signature' => $signature,
'detections' => $snapshot->detections,
'redirect_metadata' => $snapshot->redirectMetadata,
];
} catch (ApiFailure | RuntimeException $failure) {
$exitCode = 1;
logEvent('error', 'site_check_failed', [
'site' => $site,
'status' => $failure instanceof ApiFailure ? $failure->status : null,
'message' => $failure->getMessage(),
]);
}
}
$temporary = $stateFile . '.' . getmypid() . '.tmp';
file_put_contents(
$temporary,
json_encode($nextState, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
LOCK_EX
);
chmod($temporary, 0600);
if (!rename($temporary, $stateFile)) {
throw new RuntimeException('Unable to replace state file');
}
flock($lock, LOCK_UN);
fclose($lock);
exit($exitCode);
Првото успешно извршување создава почетна основа без испраќање предупредување. Тоа е важно: првичното откривање не е промена. PHP-овиот mail() бара правилно конфигуриран локален агент за пренос на пошта. Ако вашиот хост не обезбедува таков, заменете ја таа единствена граница за известување со вашиот воспоставен испраќач на пошта, задржувајќи ја логиката на детекторот и споредбата непроменета.
Тестирајте без да правите вистински API-повици
Лажен транспорт ги прави повторните обиди и патеките на неуспех детерминистички. Тој исто така потврдува дека автентикацијата и сериализацијата на барањето остануваат точни.
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use SiteMonitor\ApiFailure;
use SiteMonitor\DetectorClient;
use SiteMonitor\HttpResponse;
use SiteMonitor\Transport;
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function post(string $url, array $headers, string $body): HttpResponse
{
$this->requests[] = compact('url', 'headers', 'body');
return array_shift($this->responses);
}
}
final class DetectorClientTest extends TestCase
{
public function testMapsDetectionAndSendsExpectedRequest(): void
{
$fake = new FakeTransport([
new HttpResponse(200, [], json_encode([
'data' => [
'detections' => [[
'name' => 'Example CMS',
'version' => '2',
'confidence' => 0.95,
'evidence' => ['public marker'],
]],
],
'redirects' => ['final_url' => 'https://example.com/'],
], JSON_THROW_ON_ERROR)),
]);
$result = (new DetectorClient($fake, 'test-token'))->detect(
'https://example.com'
);
self::assertSame('Example CMS', $result->detections[0]['name']);
self::assertSame(
['name' => 'Example CMS', 'version' => '2'],
$result->stackSignature()[0]
);
self::assertContains(
'Authorization: Bearer test-token',
$fake->requests[0]['headers']
);
self::assertSame(
['url' => 'https://example.com'],
json_decode($fake->requests[0]['body'], true)
);
}
public function testRetriesRateLimitUsingRetryAfter(): void
{
$fake = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], '{"detections":[]}'),
]);
$delays = [];
$client = new DetectorClient(
$fake,
'test-token',
static function (int $milliseconds) use (&$delays): void {
$delays[] = $milliseconds;
}
);
$client->detect('https://example.com');
self::assertCount(2, $fake->requests);
self::assertSame([1000], $delays);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$fake = new FakeTransport([
new HttpResponse(401, [], '{"message":"unauthorized"}'),
]);
try {
(new DetectorClient($fake, 'bad-token'))->detect(
'https://example.com'
);
self::fail('Expected ApiFailure');
} catch (ApiFailure $failure) {
self::assertSame(401, $failure->status);
self::assertCount(1, $fake->requests);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="site-stack-monitor">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Извршете vendor/bin/phpunit. Фикстурите не содржат вистински акредитиви и лажниот транспорт никогаш не отвора мрежна врска.
Распоредете со systemd тајмер
Однапред создадете го директориумот за состојбата, доделете го на посебна непривилегирана сметка и осигурете се дека датотеката со околина е читлива само од таа сметка. Услугата systemd обезбедува сигурно вчитување на околината и испраќа структурирани логови во журналот.
# /etc/systemd/system/site-stack-monitor.service
[Unit]
Description=Monitor public client technology stacks
[Service]
Type=oneshot
User=site-monitor
Group=site-monitor
WorkingDirectory=/opt/site-stack-monitor
EnvironmentFile=/etc/site-stack-monitor.env
ExecStart=/usr/bin/php /opt/site-stack-monitor/bin/check.php
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/site-stack-monitor
# /etc/systemd/system/site-stack-monitor.timer
[Unit]
Description=Run the site stack monitor hourly
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
По инсталирањето на единиците, извршете systemctl daemon-reload, systemctl enable --now site-stack-monitor.timer и едно рачно systemctl start site-stack-monitor.service. Проверете ги резултатите со journalctl -u site-stack-monitor.service.
Безбедност, набљудливост и чести неуспеси
Никогаш не ги запишувајте во лог токенот, заглавието Authorization или суровото тело на одговорот. Структурираните настани погоре ги бележат страницата, исходот и HTTP-статусот без да изложат акредитиви или прекумерни докази. Следете ги одделно излезите на услугата различни од нула; е-поштата за промена на стекот не треба да служи и како следење на прекини.
- HTTP 401 или 403: проверете ја датотеката со околина и активацијата на услугата. Ако токенот бил повторно генериран, старата вредност веднаш е застарена.
- HTTP 429: намалете ја честотата на распоредување, проверете го капацитетот на планот и задржете ограничен backoff. Не започнувајте паралелни повторни обиди.
- HTTP 400: валидирајте ги конфигурираната URL-адреса и JSON-телото. Повторното обидување со истото невалидно барање троши квота.
- Истекувања на време или 5xx одговори: клиентот прави три ограничени обиди, а потоа ја зачувува последната успешна снимка.
- Нема е-пошта: потврдете ги локалниот MTA, PHP
sendmail_path, адресата на примачот и дозволите на сметката за услугата. - Повторени лажни предупредувања: проверете ги нормализираните имиња и верзии. Чувајте ги довербата и доказите надвор од потписот освен ако вашата оперативна политика изречно не ги смета за промени.
- Грешки при запишување на состојбата: проверете дали директориумот за состојбата постои и е запишлив, додека датотеките со околината и состојбата остануваат недостапни за други корисници.
Конечна листа за проверка
- Планот на услугата е активен, а тековниот токен ограничен на услугата е поддржан од околината.
- Минималното POST-барање успева кон точната крајна точка на детекторот.
- Инсталирани се PHP 8.3, cURL, JSON, Composer зависностите и работен транспорт за пошта.
- Сите PHPUnit тестови поминуваат без пристап до мрежа.
- Првото извршување создава почетна основа и не испраќа предупредување за промена.
- Контролирана фикстура или привремена измена на состојбата докажува дека додадена, отстранета или технологија со променета верзија активира една е-пошта.
- HTTP 401 не се обидува повторно, додека HTTP 429 и привремените серверски неуспеси користат ограничени повторни обиди.
- Конкурентните извршувања се одбиваат, неуспешните проверки ја зачувуваат претходната состојба, а логовите не содржат тајна или сурово тело на одговор.
- Тајмерот работи како непривилегирана сметка, а датотеките со токенот и состојбата користат рестриктивни дозволи.
Вредниот дел од овој монитор не е само повикувањето на детектор. Тоа е одлучувањето што претставува значајна промена, зачувувањето докази без создавање бучава од предупредувања и обезбедувањето неуспесите да останат неуспеси наместо да станат лажни деловни сигнали. Со поставена таа граница, тивок PHP-процес што работи на секој час станува систем за рано предупредување за јавните технолошки избори што веб-страниците на вашите клиенти му ги изложуваат на светот.