Vodiči

Native PHP 8.3: Monitor Client Sites for Tech Stack Changes with Website Detector API

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

  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 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.
  4. Otvorite službenu dokumentaciju usluge.
  5. 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:

  1. Traži od detektora podatke za svaki konfigurirani URL.
  2. Preslikava odgovor u provjerena otkrivanja i metapodatke preusmjeravanja.
  3. Uspoređuje samo nazive tehnologija i verzije s prethodnom uspješnom snimkom.
  4. 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.

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.