Vodiči

Symfony: Chronicle Key Pages Weekly with Automated Screenshots

Symfony: Chronicle ključne stranice tjedno s automatiziranim snimkama zaslona

Web-mjesto se može neprimjetno promijeniti. Promocija istekne, gumb za rezervaciju nestane, ažuriranje teme pomakne raspored ili se widget treće strane prestane prikazivati. Dok vlasnik male tvrtke to primijeti, nitko se više ne sjeća kako je stranica izgledala prošlog ponedjeljka.

Ovaj vodič izrađuje Symfony aplikaciju usmjerenu na produkciju koja jednom tjedno snima važne stranice i pohranjuje povijest PNG datoteka s datumima. Koristi hostirani Screenshot API, pa aplikacija ne mora instalirati Chromium, održavati upravljačke programe preglednika ni upravljati procesima preglednika bez grafičkog sučelja.

Dovršeni sustav ima namjerno malu arhitekturu: Symfony konzolna naredba čita odobreni popis stranica, namjenski API klijent dohvaća svaki PNG, a atomski zapisivač datoteka sprema sliku s operativnim metapodacima. Cron osigurava tjedni raspored. Neuspjesi su izolirani po stranici, sigurno se bilježe i izloženi su putem izlaznog koda naredbe.

Preduvjeti i struktura projekta

Potrebni su vam PHP 8.3 ili noviji, Composer, Symfony aplikacija i trajna pohrana u koju može pisati PHP proces naredbenog retka. Instalirajte Symfonyjev HTTP klijent, integraciju konzole, paket za zapisivanje logova i alate za testiranje:

composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack

Relevantne datoteke bit će:

  • src/Screenshot/CaptureResult.php za rezultat API-ja na razini domene
  • src/Screenshot/CaptureFailed.php za strukturirane neuspjehe
  • src/Screenshot/ScreenshotClient.php za granicu prema vanjskom API-ju
  • src/Command/CaptureWeeklyScreenshotsCommand.php za orkestraciju i pohranu
  • tests/Screenshot/ScreenshotClientTest.php za deterministička transportna testiranja
  • var/visual-history/ za rezultirajuću arhivu

Red čekanja ovdje bi dodao malu vrijednost. Malo, sekvencijalno tjedno pokretanje jednostavnije je za održavanje, dok svaka stranica i dalje neovisno može zakazati. Ako popis stranica s vremenom postane velik ili se pojave strogi vremenski prozori izvršavanja, isti klijent može raditi iza Symfony Messengera bez promjene svojeg API ugovora.

Dobijte pristup Screenshot API-ju

Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate. Otvorite stranicu usluge Screenshot API, odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.

Zatim otvorite službenu dokumentaciju. Pronađite ploču Service token i kopirajte tamo prikazani token ograničen na uslugu. Ponovno generiranje tog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora odmah ažurirati postavljenu aplikaciju.

Ova usluga nije bez tokena: svaki zahtjev mora biti autentificiran. Podržani su Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi Bearer token jer vjerodajnicu drži izvan URL-ova, povijesti preglednika i uobičajenih zapisa pristupa proxy poslužitelja.

Potvrdite točan HTTP ugovor

Operacija snimanja HTTP je GET zahtjev prema https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita url određuje stranicu za prikaz. Uspješan odgovor sadrži tijelo image/png zajedno sa zaglavljima odgovora povezanima s predmemorijom i kvotom.

Prije dodavanja aplikacijskog koda testirajte token iz privatne ljuske. Prva naredba ga čita bez prikazivanja; zahtjev zapisuje binarni odgovor i zaglavlja u odvojene privremene datoteke.

read -rsp "Screenshot API token: " SCREENSHOT_API_TOKEN
export SCREENSHOT_API_TOKEN

curl --fail-with-body --get \
  "https://ai.mihajlo.mk/api/screenshot-api/v1/capture" \
  --header "Authorization: Bearer ${SCREENSHOT_API_TOKEN}" \
  --data-urlencode "url=https://example.com/" \
  --dump-header /tmp/screenshot.headers \
  --output /tmp/screenshot.png

file /tmp/screenshot.png

Pregledajte spremljena zaglavlja pri dijagnosticiranju ponašanja predmemorije ili kvote, ali ih nemojte neselektivno objavljivati. Aplikacija će zadržati samo zaglavlja čiji nazivi označavaju informacije o predmemoriji, kvoti ili ograničenju brzine, umjesto da bilježi cijeli odgovor.

Pohranite konfiguraciju izvan kontrole izvornog koda

Vrijednosti za lokalni razvoj stavite u .env.local, koji ne treba predati u repozitorij. U produkciji unesite ista imena kroz platformu za postavljanje ili Symfonyjeve mogućnosti upravljanja tajnama.

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PAGES='{"home":"https://example.com/","booking":"https://example.com/book"}'

Za nazive stranica upotrebljavajte stabilne ključeve sigurne za datotečni sustav. Izbjegavajte potpisane URL-ove, poveznice za poništavanje lozinke, stranice specifične za korisnike i druge osjetljive ciljeve. Konfigurirane stranice moraju biti dostupne na način koji očekuje usluga za snimanje zaslona; ovaj vodič ne izmišlja nepodržani mehanizam za prijavu na zaštićene stranice.

Povežite token, dekodiranu mapu stranica i direktorij za pohranu u Symfony usluge:

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

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

    App\Screenshot\ScreenshotClient:
        arguments:
            $token: '%env(SCREENSHOT_API_TOKEN)%'

    App\Command\CaptureWeeklyScreenshotsCommand:
        arguments:
            $pages: '%env(json:SCREENSHOT_PAGES)%'
            $storageRoot: '%kernel.project_dir%/var/visual-history'

Mapirajte API odgovor na jednoj granici

Kontroleri i naredbe ne bi trebali prosljeđivati sirove HTTP odgovore kroz aplikaciju. Mali objekt rezultata čini valjano stanje izričitim: provjerene PNG bajtove i operativna zaglavlja relevantna za odluke o predmemoriji i kvoti.

<?php
// src/Screenshot/CaptureResult.php
namespace App\Screenshot;

final readonly class CaptureResult
{
    public function __construct(
        public string $png,
        public array $operationalHeaders,
    ) {
    }
}

Iznimka bilježi HTTP status kada postoji, može li stanje biti prolazno i sigurno filtrirana zaglavlja. Nikada ne sadrži token usluge ni tijelo odgovora.

<?php
// src/Screenshot/CaptureFailed.php
namespace App\Screenshot;

final class CaptureFailed extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?int $status = null,
        public readonly bool $retryable = false,
        public readonly array $operationalHeaders = [],
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Izradite obrambeni Symfony HTTP klijent

Klijent u nastavku ograničava vrijeme neaktivne veze i ukupno trajanje zahtjeva. Ponovno pokušava transportne neuspjehe i pogreške poslužitelja uz kratka eksponencijalna kašnjenja. Odgovor 429 ponovno se pokušava samo kada numerička standardna vrijednost Retry-After zahtijeva kašnjenje od najviše pet sekundi. Dulja ili neodređena čekanja kvote vraćaju se raspoređivaču umjesto da se odmah opetovano opterećuju.

Neuspjesi autentifikacije i validacije nikada se ne pokušavaju ponovno. Klijent također provjerava i vrstu medija i PNG potpis, primjenjuje ograničenje veličine na razini aplikacije te neispravne uspješne odgovore tretira kao neuspjehe.

<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $token,
        private readonly int $maxAttempts = 3,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Screenshot API token is empty.');
        }
    }

    public function capture(string $url): CaptureResult
    {
        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'image/png',
                    ],
                    'query' => ['url' => $url],
                    'timeout' => 15.0,
                    'max_duration' => 45.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $response->getHeaders(false);
                $operational = $this->operationalHeaders($headers);

                if ($status !== 200) {
                    $delay = $this->retryDelay($status, $headers, $attempt);

                    if ($delay !== null && $attempt < $this->maxAttempts) {
                        $this->logger->warning('Screenshot request will retry.', [
                            'attempt' => $attempt,
                            'status' => $status,
                            'headers' => $operational,
                        ]);
                        usleep($delay * 1000);
                        continue;
                    }

                    throw new CaptureFailed(
                        'Screenshot service returned HTTP '.$status.'.',
                        $status,
                        $status === 429 || $status >= 500,
                        $operational,
                    );
                }

                $body = $response->getContent(false);
                $contentType = strtolower(trim(explode(
                    ';',
                    $headers['content-type'][0] ?? ''
                )[0]));

                if ($contentType !== 'image/png') {
                    throw new CaptureFailed(
                        'Screenshot response was not image/png.',
                        $status,
                        false,
                        $operational,
                    );
                }

                if (!str_starts_with($body, "\x89PNG\r\n\x1a\n")) {
                    throw new CaptureFailed(
                        'Screenshot response has an invalid PNG signature.',
                        $status,
                        false,
                        $operational,
                    );
                }

                if (strlen($body) > 15 * 1024 * 1024) {
                    throw new CaptureFailed(
                        'Screenshot exceeded the 15 MiB application limit.',
                        $status,
                        false,
                        $operational,
                    );
                }

                return new CaptureResult($body, $operational);
            } catch (TransportExceptionInterface $error) {
                if ($attempt === $this->maxAttempts) {
                    throw new CaptureFailed(
                        'Screenshot transport failed after bounded retries.',
                        null,
                        true,
                        [],
                        $error,
                    );
                }

                $this->logger->warning('Screenshot transport will retry.', [
                    'attempt' => $attempt,
                ]);
                usleep((250 * (2 ** ($attempt - 1))) * 1000);
            }
        }

        throw new \LogicException('Unreachable retry state.');
    }

    private function retryDelay(
        int $status,
        array $headers,
        int $attempt,
    ): ?int {
        if ($status === 429) {
            $value = $headers['retry-after'][0] ?? null;

            if (is_string($value) && ctype_digit($value)) {
                $seconds = (int) $value;
                return $seconds <= 5 ? $seconds * 1000 : null;
            }

            return null;
        }

        return $status >= 500 && $status <= 599
            ? 250 * (2 ** ($attempt - 1))
            : null;
    }

    private function operationalHeaders(array $headers): array
    {
        $selected = [];

        foreach ($headers as $name => $values) {
            $normalized = strtolower((string) $name);

            if (
                str_contains($normalized, 'cache')
                || str_contains($normalized, 'quota')
                || str_contains($normalized, 'rate')
            ) {
                $selected[$normalized] = implode(', ', $values);
            }
        }

        return $selected;
    }
}

Mapiranje zaglavlja namjerno je tolerantno na nazive. Obrađuje dokumentirane kategorije bez pretvaranja da postoji nedokumentirani pravopis specifičan za dobavljača. Budući da API može vratiti snimku iz predmemorije, te su vrijednosti vrijedan dokaz: arhiva bilježi što je usluga vratila tog tjedna, dok njezini metapodaci pomažu razlikovati kronologiju snimanja od svježine predmemorije.

Atomski snimite svaku konfiguriranu stranicu

Naredba validira ključeve stranica i zahtijeva HTTPS ciljeve. Neblokirajuće zaključavanje datotečnog sustava sprječava preklapanje cron pokretanja. Svako uspješno snimanje stvara PNG i susjedne JSON metapodatke koji sadrže UTC vremensku oznaku, broj bajtova, SHA-256 sažetak i filtrirana zaglavlja usluge.

<?php
// src/Command/CaptureWeeklyScreenshotsCommand.php
namespace App\Command;

use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotClient;
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:screenshots:capture-weekly',
    description: 'Archive configured business pages as PNG screenshots.'
)]
final class CaptureWeeklyScreenshotsCommand extends Command
{
    public function __construct(
        private readonly ScreenshotClient $client,
        private readonly LoggerInterface $logger,
        private readonly array $pages,
        private readonly string $storageRoot,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $io = new SymfonyStyle($input, $output);
        $this->ensureDirectory($this->storageRoot);

        $lock = fopen($this->storageRoot.'/.capture.lock', 'c');
        if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
            $io->warning('Another screenshot run is active.');
            return Command::SUCCESS;
        }

        $failures = 0;
        $capturedAt = new \DateTimeImmutable(
            'now',
            new \DateTimeZone('UTC')
        );

        try {
            foreach ($this->pages as $slug => $url) {
                try {
                    $this->assertPageConfiguration($slug, $url);
                    $result = $this->client->capture($url);

                    $directory = sprintf(
                        '%s/%s/%s',
                        $this->storageRoot,
                        $slug,
                        $capturedAt->format('Y')
                    );
                    $this->ensureDirectory($directory);

                    $base = $directory.'/'.$capturedAt->format(
                        'Y-m-d\TH-i-s\Z'
                    );

                    $this->writeAtomically($base.'.png', $result->png);
                    $metadata = [
                        'captured_at' => $capturedAt->format(DATE_ATOM),
                        'bytes' => strlen($result->png),
                        'sha256' => hash('sha256', $result->png),
                        'service_headers' => $result->operationalHeaders,
                    ];
                    $this->writeAtomically(
                        $base.'.json',
                        json_encode(
                            $metadata,
                            JSON_PRETTY_PRINT
                            | JSON_UNESCAPED_SLASHES
                            | JSON_THROW_ON_ERROR
                        )."\n"
                    );

                    $this->logger->info('Weekly screenshot stored.', [
                        'page' => $slug,
                        'bytes' => strlen($result->png),
                        'headers' => $result->operationalHeaders,
                    ]);
                    $io->writeln('Captured '.$slug);
                } catch (\Throwable $error) {
                    $failures++;
                    $this->logger->error('Weekly screenshot failed.', [
                        'page' => (string) $slug,
                        'status' => $error instanceof CaptureFailed
                            ? $error->status
                            : null,
                        'retryable' => $error instanceof CaptureFailed
                            ? $error->retryable
                            : false,
                        'headers' => $error instanceof CaptureFailed
                            ? $error->operationalHeaders
                            : [],
                        'exception' => $error::class,
                    ]);
                    $io->error('Failed to capture '.$slug);
                }
            }
        } finally {
            flock($lock, LOCK_UN);
            fclose($lock);
        }

        return $failures === 0
            ? Command::SUCCESS
            : Command::FAILURE;
    }

    private function assertPageConfiguration(mixed $slug, mixed $url): void
    {
        if (
            !is_string($slug)
            || preg_match('/^[a-z0-9-]+$/D', $slug) !== 1
            || !is_string($url)
            || filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https'
        ) {
            throw new \InvalidArgumentException(
                'Page configuration requires a safe slug and HTTPS URL.'
            );
        }
    }

    private function ensureDirectory(string $directory): void
    {
        if (
            !is_dir($directory)
            && !mkdir($directory, 0770, true)
            && !is_dir($directory)
        ) {
            throw new \RuntimeException(
                'Cannot create screenshot directory.'
            );
        }
    }

    private function writeAtomically(string $path, string $contents): void
    {
        $temporary = tempnam(dirname($path), '.capture-');
        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary file.');
        }

        try {
            if (
                file_put_contents($temporary, $contents, LOCK_EX)
                !== strlen($contents)
            ) {
                throw new \RuntimeException('Incomplete screenshot write.');
            }

            chmod($temporary, 0640);

            if (!rename($temporary, $path)) {
                throw new \RuntimeException('Cannot publish capture file.');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }
}

Jedna pokvarena stranica ne sprječava ostale, ali svaki neuspjeh uzrokuje neuspjeh cijele naredbe. Ta ravnoteža čuva koristan rad, a sustavima za nadzor i dalje daje signal na koji mogu djelovati.

Testirajte granicu bez mrežnog pristupa

MockHttpClient čini API testove determinističkima. Prvi test provjerava točnu metodu, krajnju točku, obavezni parametar upita, zaglavlje autentifikacije, PNG mapiranje i filtriranje operativnih zaglavlja. Njegovo zaglavlje nazvano po kvoti namjerno je sintetičko; test provjerava ponašanje tolerantno na nazive bez tvrdnje o nedokumentiranom nazivu usluge. Drugi test potvrđuje da se neuspjesi autentifikacije prikazuju bez ponovnih pokušaja.

<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\CaptureFailed;
use App\Screenshot\ScreenshotClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testItMapsAValidPngResponse(): void
    {
        $png = "\x89PNG\r\n\x1a\npayload";

        $http = new MockHttpClient(
            function (string $method, string $url, array $options) use ($png) {
                self::assertSame('GET', $method);
                self::assertStringStartsWith(
                    'https://ai.mihajlo.mk/api/screenshot-api/v1/capture?',
                    $url
                );
                parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
                self::assertSame('https://example.com/', $query['url']);
                self::assertStringContainsString(
                    'Authorization: Bearer test-token',
                    implode("\n", $options['headers'])
                );

                return new MockResponse($png, [
                    'http_code' => 200,
                    'response_headers' => [
                        'Content-Type: image/png',
                        'Cache-Control: max-age=60',
                        'X-Test-Quota: 3',
                    ],
                ]);
            }
        );

        $result = (new ScreenshotClient(
            $http,
            new NullLogger(),
            'test-token',
            1
        ))->capture('https://example.com/');

        self::assertSame($png, $result->png);
        self::assertArrayHasKey(
            'cache-control',
            $result->operationalHeaders
        );
        self::assertArrayHasKey(
            'x-test-quota',
            $result->operationalHeaders
        );
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 401]),
        ]);

        $client = new ScreenshotClient(
            $http,
            new NullLogger(),
            'bad-token',
            3
        );

        $this->expectException(CaptureFailed::class);
        $client->capture('https://example.com/');
    }
}

Pokrenite testove, a zatim izvedite stvarno snimanje aplikacije:

php bin/phpunit
APP_ENV=prod php bin/console app:screenshots:capture-weekly --no-interaction
find var/visual-history -type f -maxdepth 3 -print

Postavite tjedni raspored

Postavite token putem zaštićene konfiguracije okruženja, dodijelite korisniku naredbenog retka pristup pisanju u var/visual-history i smjestite taj direktorij u trajnu pohranu. Datotečni sustavi spremnika i direktoriji izdanja često se zamjenjuju tijekom postavljanja; arhiva pohranjena samo tamo nije povijest.

Uobičajeni cron unos može se pokretati svakog ponedjeljka u 03:17 prema vremenu poslužitelja:

17 3 * * 1 cd /srv/shop-history && APP_ENV=prod /usr/bin/php bin/console app:screenshots:capture-weekly --no-interaction >> /var/log/shop-history.log 2>&1

Pošaljite izlaze koji nisu nula mehanizmu za nadzor koji tvrtka već koristi. Upozoravajte na ponovljene neuspjehe, trajne odgovore 429, smanjivanje vrijednosti kvote, neispravne PNG odgovore i nedostajuću tjednu datoteku. Izradite sigurnosnu kopiju arhive, definirajte politiku zadržavanja i testirajte vraćanje podataka umjesto da pretpostavljate kako je montirana pohrana trajna.

Uobičajeni produkcijski neuspjesi

  • HTTP 401 ili 403: provjerite postavljeni token i je li ga netko ponovno generirao. Nemojte nastaviti ponovno pokušavati s nevažećom vjerodajnicom.
  • HTTP 400 ili drugi odgovor validacije: pregledajte konfigurirani ciljni URL. Potvrdite da je obavezni parametar url potpuni, ispravno kodiran URL.
  • HTTP 429: sačuvajte zaglavlja povezana s kvotom i stopom, dopustite sljedećem zakazanom pokretanju da se oporavi ili prilagodite plan i popis snimanja. Trenutačni neograničeni ponovni pokušaji samo troše dodatni kapacitet.
  • Valjan odgovor koji izgleda staro: pregledajte arhivirana zaglavlja predmemorije. Izbjegavajte dodavanje nasumičnih nizova upita samo radi zaobilaženja predmemoriranja, osim ako su ti URL-ovi valjani, sigurni i usklađeni sa semantikom web-mjesta.
  • Nema datoteka nakon postavljanja: provjerite varijable CLI okruženja, vlasništvo direktorija, montiranje trajnog volumena i radni direktorij crona.
  • Djelomična povijest: upotrijebite JSON metapodatke i zapise naredbe za prepoznavanje neuspjele stranice. Uspješna snimanja ostaju dostupna čak i kada naredba završi neuspješno.

Završni kontrolni popis za provjeru

  1. Račun i odabrani plan aktivni su, a token ograničen na uslugu dolazi iz ploče Service token na stranici dokumentacije.
  2. Nijedan stvarni token ne pojavljuje se u Gitu, zapisima, testnim podacima, povijesti naredbi ili pohranjenim snimkama zaslona.
  3. Minimalni zahtjev vraća tijelo image/png s točne dokumentirane GET krajnje točke.
  4. SCREENSHOT_PAGES sadrži samo odobrene HTTPS stranice sa sigurnim, stabilnim slugovima.
  5. PHPUnit paket testova prolazi bez vanjskog mrežnog pristupa.
  6. Ručna naredba stvara čitljive PNG i JSON datoteke u trajnoj arhivi.
  7. Drugo istovremeno pokretanje završava bez dupliciranja rada.
  8. Cron koristi predviđenu PHP binarnu datoteku, produkcijsko okruženje, radni direktorij i nadzor neuspjeha.
  9. Rotacija tokena, sigurnosna kopija pohrane, zadržavanje i vraćanje podataka imaju imenovane operativne vlasnike.

Tjedna arhiva snimaka zaslona skromna je infrastruktura, ali upravo zato dobro funkcionira za malu tvrtku. Pretvara nejasna sjećanja u dokaz s datumom, a da aplikaciju ne pretvara u platformu za automatizaciju preglednika. Granicu držite strogom, ponovne pokušaje suzdržanima, vjerodajnice nevidljivima, a pohranu trajnom; svakog ponedjeljka sustav će neprimjetno dodati još jednu pouzdanu stranicu vizualnom pamćenju tvrtke.

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.