Izvorni PHP 8.3: nadzirite promjene tehnološkog stoga klijentskih web-mjesta pomoću Website Detector API-ja
Web-mjesto klijenta može se promijeniti bez implementacije: migracija CDN-a mijenja zaglavlja, redizajn zamjenjuje CMS ili marketinški dodatak neprimjetno uvodi novu ovisnost. Te su promjene javne, operativno relevantne i lako ih je propustiti sve dok se nešto ne pokvari.
Ovaj vodič izrađuje produkcijski orijentiran nadzor u izvornom PHP-u 8.3 oko API-ja Website Technology Detector. Provjerava kontrolirani popis web-mjesta klijenata, pretvara odgovor API-ja u stabilnu snimku domene, pohranjuje posljednji uspješan rezultat i šalje e-poruku razvojnom inženjeru kada se tehnologije dodaju, uklone ili prijave drugu verziju.
Dizajn namjerno razdvaja otkrivanje, usporedbu, pohranu i obavještavanje. To sprječava da prolazni kvarovi API-ja izgledaju kao promjene tehnološkog stoga i čini svaku granicu neovisno testabilnom.
Dobijte pristup i kopirajte token ograničen na uslugu
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Website Technology Detector.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite aktivaciju. Odaberite plan čija kvota zahtjeva odgovara broju vaših web-mjesta i učestalosti nadzora.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte tamo prikazani token ograničen na uslugu.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi Bearer token jer je manja vjerojatnost da će se zaglavlja, za razliku od nizova upita, pojaviti u zapisnicima proxyja i analitičkim sustavima.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: ažurirajte okruženje implementacije, ponovno pokrenite nadzor i provjerite jedan zahtjev prije nego što rotaciju smatrate dovršenom.
Potvrdite krajnju točku prije pisanja nadzora
Točan poziv je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Njegovo JSON tijelo zahtjeva sadrži url.
Pošaljite jedan minimalni zahtjev s testnim ciljem koji nije osjetljiv:
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"}'
Nemojte lijepiti stvarni token u povijest ljuske na zajedničkom računalu. Za projekt izradite datoteku okruženja implementacije isključenu iz kontrole verzija:
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
Postavite način rada datoteke na 0600. Ciljni URL-ovi su konfiguracija, a ne proizvoljan web unos: navedite samo web-mjesta za čiji ste nadzor odgovorni.
Arhitektura i struktura projekta
Ovdje je dovoljna sinkrona zakazana naredba. Red bi dodao infrastrukturu bez poboljšanja malog nadzora koji se pokreće svakog sata. Naredba obavlja četiri operacije:
- Traži od detektora podatke za svaki konfigurirani URL.
- Preslikava odgovor u provjerena otkrivanja i metapodatke preusmjeravanja.
- Uspoređuje samo nazive tehnologija i verzije s prethodnom uspješnom snimkom.
- Pohranjuje snimku i šalje obavijest o značajnoj razlici.
Pouzdanost i dokazi ostaju u pohranjenim snimkama radi dijagnostike, ali su isključeni iz potpisa promjene. U suprotnom bi bezopasne promjene pouzdanosti mogle generirati bučna upozorenja.
Izradite ovu strukturu:
site-stack-monitor/
├── bin/
│ └── check.php
├── src/
│ └── Monitor.php
├── tests/
│ └── DetectorClientTest.php
├── composer.json
└── phpunit.xml
Composer koristite samo za automatsko učitavanje i 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/"
}
}
}
Instalirajte ovisnosti s composer install, zatim pokrenite composer dump-autoload nakon dodavanja izvorne datoteke.
Izgradite ograničenu HTTP i domensku granicu
API vraća otkrivanja s ocjenom pouzdanosti, dokazima i verzijama, uz informacije o preusmjeravanju. Preslikavač u nastavku provjerava te koncepte uz toleriranje ugniježđivanja i dviju prirodnih oznaka kolekcije, detections i technologies. Nepoznata polja ostaju izvan modela usporedbe umjesto da se šire kroz aplikaciju.
<?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');
}
}
Klijent ponovno pokušava mrežne kvarove, HTTP 429 i odabrane privremene pogreške poslužitelja. Nikada naslijepo ne ponavlja pogreške autentikacije, dozvola ili provjere valjanosti. Brojčana vrijednost Retry-After poštuje se, ali je ograničena na deset sekundi kako jedno pokretanje ne bi zastalo na neodređeno vrijeme.
Usporedite snimke, pohranite stanje i pošaljite obavijest
Naredba koristi isključivo zaključavanje pokretanja, čime sprječava preklapajuća pozivanja raspoređivača. Stanje zapisuje preko privremene datoteke i atomskog preimenovanja. Neuspjelo web-mjesto zadržava prethodno uspješno stanje, pa se prekid rada ne može prikazati kao prazan tehnološki stog.
<?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);
Prvo uspješno pokretanje stvara osnovnu vrijednost bez slanja upozorenja. To je važno: početno otkrivanje nije promjena. PHP-ov mail() zahtijeva ispravno konfiguriran lokalni agent za prijenos pošte. Ako ga vaš host ne pruža, zamijenite tu jedinu granicu obavještavanja svojim postojećim sustavom za slanje pošte, a logiku detektora i usporedbe ostavite nepromijenjenom.
Testirajte bez stvarnih API poziva
Lažni transport čini ponovne pokušaje i putanje neuspjeha determinističkima. Također provjerava ostaju li autentikacija i serijalizacija zahtjeva ispravne.
<?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>
Pokrenite vendor/bin/phpunit. Fixtureovi ne sadrže stvarne vjerodajnice, a lažni transport nikada ne otvara mrežnu vezu.
Implementirajte pomoću systemd timera
Unaprijed izradite direktorij stanja, dodijelite ga namjenskom neprivilegiranom računu i osigurajte da datoteku okruženja može čitati samo taj račun. Usluga systemd pruža pouzdano učitavanje okruženja i šalje strukturirane zapisnike u dnevnik.
# /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
Nakon instaliranja jedinica pokrenite systemctl daemon-reload, systemctl enable --now site-stack-monitor.timer i jedno ručno systemctl start site-stack-monitor.service. Pregledajte rezultate pomoću journalctl -u site-stack-monitor.service.
Sigurnost, vidljivost i česti kvarovi
Nikada nemojte zapisivati token, zaglavlje Authorization ni neobrađeno tijelo odgovora. Gore navedeni strukturirani događaji bilježe web-mjesto, ishod i HTTP status bez izlaganja vjerodajnica ili pretjeranih dokaza. Zasebno nadzirite izlaze usluge koji nisu nula; e-poruka o promjeni stoga ne bi trebala služiti i kao nadzor prekida rada.
- HTTP 401 ili 403: provjerite datoteku okruženja i aktivaciju usluge. Ako je token ponovno generiran, stara je vrijednost odmah zastarjela.
- HTTP 429: smanjite učestalost raspoređivanja, provjerite kapacitet plana i zadržite ograničeni odmak prije ponovnog pokušaja. Nemojte pokretati paralelne ponovne pokušaje.
- HTTP 400: provjerite konfigurirani URL i JSON tijelo. Ponavljanje istog neispravnog zahtjeva troši kvotu.
- Istek vremena ili odgovori 5xx: klijent pokušava tri ograničena puta, a zatim čuva posljednju uspješnu snimku.
- Nema e-pošte: potvrdite lokalni MTA, PHP
sendmail_path, adresu primatelja i dozvole računa usluge. - Ponovljena lažna upozorenja: pregledajte normalizirane nazive i verzije. Pouzdanost i dokaze držite izvan potpisa, osim ako ih vaša operativna politika izričito smatra promjenama.
- Pogreške zapisivanja stanja: provjerite postoji li direktorij stanja i je li u njega moguće pisati, dok datoteke okruženja i stanja ostaju nedostupne drugim korisnicima.
Završni kontrolni popis provjere
- Plan usluge je aktivan, a trenutačni token ograničen na uslugu dostupan je kroz okruženje.
- Minimalni POST zahtjev uspijeva prema točnoj krajnjoj točki detektora.
- Instalirani su PHP 8.3, cURL, JSON, Composer ovisnosti i funkcionalan transport pošte.
- Svi PHPUnit testovi prolaze bez mrežnog pristupa.
- Prvo pokretanje stvara osnovnu vrijednost i ne šalje upozorenje o promjeni.
- Kontrolirani fixture ili privremena izmjena stanja dokazuje da dodana, uklonjena ili tehnologija s promijenjenom verzijom pokreće jednu e-poruku.
- HTTP 401 se ne pokušava ponovno, dok HTTP 429 i privremeni kvarovi poslužitelja koriste ograničene ponovne pokušaje.
- Istodobna pokretanja se odbijaju, neuspjele provjere čuvaju prethodno stanje, a zapisnici ne sadrže tajnu ni neobrađeno tijelo odgovora.
- Timer se pokreće kao neprivilegirani račun, a datoteke tokena i stanja koriste restriktivne dozvole.
Vrijedan dio ovog nadzora nije samo pozivanje detektora. To je odlučivanje o tome što predstavlja značajnu promjenu, čuvanje dokaza bez stvaranja buke u upozorenjima te osiguravanje da kvarovi ostanu kvarovi umjesto da postanu lažni poslovni signali. S tom granicom na mjestu, tihi PHP proces koji se pokreće svakog sata postaje sustav ranog upozoravanja za javne tehnološke izbore koje web-mjesta vaših klijenata izlažu svijetu.