Vodiči

Native PHP 8.3: Safely Generate Visual Link Previews for Bookmarks with Screenshot API

Izvorni PHP 8.3: sigurno generirajte vizualne pretpreglede poveznica za oznake pomoću Screenshot API-ja

Oznaka postaje mnogo korisnija kada je prepoznatljiva na prvi pogled. Naslovi pomažu, ali vizualni pregled često razlikuje stranicu proizvoda, dizajnersku referencu ili istraživački članak brže nego još jedan red teksta. Neugodan dio je pouzdano generiranje tih pregleda: pokretanje Chromiuma uvodi ažuriranja preglednika, izolaciju, skokove potrošnje memorije, vremenska ograničenja i privlačnu metu za zlonamjerne URL-ove.

Ovaj vodič izrađuje mali Native PHP 8.3 API za oznake koji delegira renderiranje Screenshot API-ju, validira vraćeni PNG, pohranjuje ga izvan javnog direktorija i izlaže putem kontrolirane rute. Integracija uključuje ograničena vremenska ograničenja, selektivne ponovne pokušaje, upravljanje kvotama, strukturirane neuspjehe, determinističke testove i zaštitne mjere pri implementaciji.

Dobijte pristup Screenshot API-ju

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

Zatim otvorite službenu dokumentaciju. Pronađite ploču Service token i kopirajte token ograničen na uslugu. Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotacija mora ažurirati svaku implementiranu instancu koja ga koristi.

Ova usluga nije bez tokena. Svako snimanje mora se autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Koristit ćemo Bearer zaglavlje jer vjerodajnice u upitu mogu procuriti u URL-ove, zapisnike pristupa, analitiku i povijest preglednika.

Potvrdite API ugovor

Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita url određuje stranicu za snimanje. Uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.

Nakon privremenog izvoza kopiranog tokena, napravite jedan minimalni zahtjev:

export SCREENSHOT_API_TOKEN='YOUR_SERVICE_TOKEN'

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

file preview.png

Pregledajte response.headers kao i datoteku. Aplikacija neće pretpostavljati nedokumentirane nazive zaglavlja predmemorije ili kvote; bilježi zaglavlja odgovora i obrambeno mapira relevantne skupine.

Pohranite vjerodajnicu u .env datoteku na razini projekta, nikada u PHP izvorni kod:

SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_PREVIEW_DIR=var/previews

Dodajte .env, var/bookmarks.sqlite i var/previews/ u .gitignore. U kontroli verzija zadržite samo .env.example bez tokena.

Odaberite namjerno malu arhitekturu

Aplikacija ima tri granice: SQLite pohranjuje metapodatke oznaka, ScreenshotClient prevodi udaljeni HTTP ugovor u domenske rezultate, a prednji kontroler obrađuje JSON i slikovne rute. PNG datoteke nalaze se izvan web korijena i mogu se čitati samo putem pretraživanja oznake.

Snimanje je sinkrono kako bi ovaj vodič bio pokretljiv bez radnika. To je razumno za osobnu aplikaciju ili aplikaciju malog tima s ograničenim prometom. Ako izrada oznake mora odmah vratiti odgovor, zadržite isti klijent i model stanja, ali pokrenite snimanje iz nadziranog pozadinskog radnika.

Izradite ovu strukturu:

bookmarks/
├── composer.json
├── .env
├── public/index.php
├── src/
│   ├── Http.php
│   └── ScreenshotClient.php
├── tests/ScreenshotClientTest.php
└── var/previews/

Koristite Composer za automatsko učitavanje, dotenv konfiguraciju i PHPUnit:

{
  "name": "example/safe-bookmarks",
  "type": "project",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Bookmarks\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Bookmarks\\Tests\\": "tests/"
    }
  }
}
mkdir -p bookmarks/{public,src,tests,var/previews}
cd bookmarks
composer install

Izolirajte HTTP i normalizirajte odgovore

Usko transportno sučelje čini cURL zamjenjivim u testovima. Produkcijski transport odbija preusmjeravanja na API granici, primjenjuje zasebna vremenska ograničenja povezivanja i ukupna vremenska ograničenja te zadržava ponovljena zaglavlja.

<?php
// src/Http.php
namespace Bookmarks;

interface Transport
{
    public function get(string $url, array $headers): HttpResult;
}

final readonly class HttpResult
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers
    ) {}

    public function firstHeader(string $name): ?string
    {
        return $this->headers[strtolower($name)][0] ?? null;
    }
}

final class CurlTransport implements Transport
{
    public function __construct(
        private int $connectTimeoutMs = 1500,
        private int $timeoutMs = 12000
    ) {}

    public function get(string $url, array $headers): HttpResult
    {
        $responseHeaders = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $this->timeoutMs,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_HEADERFUNCTION => static function (
                $curl,
                string $line
            ) use (&$responseHeaders): int {
                $length = strlen($line);

                if (str_starts_with($line, 'HTTP/')) {
                    $responseHeaders = [];
                    return $length;
                }

                if (str_contains($line, ':')) {
                    [$name, $value] = explode(':', $line, 2);
                    $responseHeaders[strtolower(trim($name))][] = trim($value);
                }

                return $length;
            },
        ]);

        $body = curl_exec($handle);

        if ($body === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new \RuntimeException('Screenshot transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResult($status, $body, $responseHeaders);
    }
}

Mapirajte PNG-ove, neuspjehe, podatke predmemorije i kvote

Klasa usluge validira URL-ove prije trošenja kvote, ponovno pokušava samo prolazne neuspjehe transporta, HTTP 429 i odgovore 5xx te odbacuje neočekivana tijela. Neuspjesi autentifikacije i validacije nikada se ne pokušavaju ponovno naslijepo.

<?php
// src/ScreenshotClient.php
namespace Bookmarks;

final readonly class Preview
{
    public function __construct(
        public string $png,
        public array $cacheHeaders,
        public array $quotaHeaders
    ) {}
}

final class ScreenshotException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        public readonly array $metadata = [],
        string $message = 'Screenshot capture failed'
    ) {
        parent::__construct($message);
    }
}

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

    private \Closure $pause;

    public function __construct(
        private Transport $transport,
        private string $token,
        ?\Closure $pause = null
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('Screenshot token is missing');
        }

        $this->pause = $pause ?? static fn (int $ms) => usleep($ms * 1000);
    }

    public function capture(string $url): Preview
    {
        $this->assertPublicHttpUrl($url);
        $endpoint = self::ENDPOINT . '?' . http_build_query(
            ['url' => $url],
            '',
            '&',
            PHP_QUERY_RFC3986
        );

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->transport->get($endpoint, [
                    'Authorization: Bearer ' . $this->token,
                    'Accept: image/png',
                ]);
            } catch (\RuntimeException $exception) {
                if ($attempt === 2) {
                    throw new ScreenshotException(
                        'transport_error',
                        true,
                        [],
                        $exception->getMessage()
                    );
                }

                ($this->pause)(250 * (2 ** $attempt));
                continue;
            }

            $transient = $response->status === 429 ||
                $response->status >= 500;

            if ($transient && $attempt < 2) {
                $retryAfter = (int) ($response->firstHeader('retry-after') ?? 0);
                $delay = $retryAfter > 0
                    ? min(2000, $retryAfter * 1000)
                    : 250 * (2 ** $attempt);
                ($this->pause)($delay);
                continue;
            }

            break;
        }

        [$cache, $quota] = $this->operationalHeaders($response->headers);

        if ($response->status === 401 || $response->status === 403) {
            throw new ScreenshotException('authentication_error', false);
        }

        if ($response->status === 429) {
            throw new ScreenshotException('quota_limited', true, $quota);
        }

        if ($response->status < 200 || $response->status >= 300) {
            throw new ScreenshotException(
                'upstream_error',
                $response->status >= 500,
                ['status' => $response->status]
            );
        }

        $type = strtolower($response->firstHeader('content-type') ?? '');
        $isPng = str_starts_with($type, 'image/png') &&
            str_starts_with($response->body, "\x89PNG\r\n\x1a\n");

        if (!$isPng || strlen($response->body) > 8 * 1024 * 1024) {
            throw new ScreenshotException('invalid_image', false);
        }

        return new Preview($response->body, $cache, $quota);
    }

    private function assertPublicHttpUrl(string $url): void
    {
        $parts = parse_url($url);
        $scheme = strtolower($parts['scheme'] ?? '');
        $host = strtolower($parts['host'] ?? '');

        if (
            !filter_var($url, FILTER_VALIDATE_URL) ||
            !in_array($scheme, ['http', 'https'], true) ||
            $host === '' ||
            isset($parts['user']) ||
            isset($parts['pass']) ||
            $host === 'localhost' ||
            str_ends_with($host, '.local')
        ) {
            throw new ScreenshotException('invalid_url', false);
        }

        if (
            filter_var($host, FILTER_VALIDATE_IP) &&
            !filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            )
        ) {
            throw new ScreenshotException('invalid_url', false);
        }
    }

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

        foreach ($headers as $name => $values) {
            if (str_contains($name, 'cache')) {
                $cache[$name] = $values;
            }

            if (
                str_contains($name, 'quota') ||
                str_contains($name, 'rate') ||
                $name === 'retry-after'
            ) {
                $quota[$name] = $values;
            }
        }

        return [$cache, $quota];
    }
}

Ograničenje od osam megabajta sigurnosno je ograničenje aplikacije, a ne tvrdnja o usluzi. Namjerno ga prilagodite ako vaš plan ili slučaj uporabe zahtijeva veće snimke zaslona. Za implementacije većeg rizika primijenite izričit popis dopuštenih naziva hostova ili pouzdanu DNS/IP politiku prije slanja; jednostavne tekstualne provjere URL-a ne mogu ukloniti svaki trik razrješavanja naziva hosta.

Povežite rute oznaka

Prednji kontroler podržava popisivanje i stvaranje oznaka te čitanje pregleda. Neuspjesi postaju trajna domenska stanja, tako da se privremeni prekid rada ne predstavlja kao slika koja nedostaje.

<?php
// public/index.php
declare(strict_types=1);

use Bookmarks\CurlTransport;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotException;
use Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();

$root = dirname(__DIR__);
$previewDir = $root . '/' . ($_ENV['SCREENSHOT_PREVIEW_DIR'] ?? 'var/previews');
if (!is_dir($previewDir)) {
    mkdir($previewDir, 0750, true);
}

$db = new PDO('sqlite:' . $root . '/var/bookmarks.sqlite', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$db->exec(
    'CREATE TABLE IF NOT EXISTS bookmarks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        url TEXT NOT NULL,
        preview_path TEXT,
        preview_state TEXT NOT NULL,
        upstream_meta TEXT NOT NULL DEFAULT "{}",
        created_at TEXT NOT NULL
    )'
);

$client = new ScreenshotClient(
    new CurlTransport(),
    $_ENV['SCREENSHOT_API_TOKEN'] ?? ''
);

function respond(array $data, int $status = 200): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    exit;
}

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($method === 'GET' && $path === '/bookmarks') {
    $rows = $db->query(
        'SELECT id, title, url, preview_state, created_at
         FROM bookmarks ORDER BY id DESC'
    )->fetchAll();

    foreach ($rows as &$row) {
        $row['preview_url'] = $row['preview_state'] === 'ready'
            ? '/previews/' . $row['id']
            : null;
    }
    respond(['bookmarks' => $rows]);
}

if ($method === 'POST' && $path === '/bookmarks') {
    try {
        $input = json_decode(
            file_get_contents('php://input'),
            true,
            flags: JSON_THROW_ON_ERROR
        );
    } catch (JsonException) {
        respond(['error' => 'invalid_json'], 400);
    }

    $title = trim((string) ($input['title'] ?? ''));
    $url = trim((string) ($input['url'] ?? ''));

    if ($title === '' || strlen($title) > 200 || $url === '') {
        respond(['error' => 'invalid_bookmark'], 422);
    }

    $insert = $db->prepare(
        'INSERT INTO bookmarks(title, url, preview_state, created_at)
         VALUES (?, ?, "pending", ?)'
    );
    $insert->execute([$title, $url, gmdate(DATE_ATOM)]);
    $id = (int) $db->lastInsertId();

    try {
        $preview = $client->capture($url);
        $filename = $id . '.png';
        $temporary = tempnam($previewDir, 'capture-');

        if ($temporary === false ||
            file_put_contents($temporary, $preview->png, LOCK_EX) === false ||
            !rename($temporary, $previewDir . '/' . $filename)) {
            throw new RuntimeException('Could not persist preview');
        }

        chmod($previewDir . '/' . $filename, 0640);
        $metadata = json_encode([
            'cache' => $preview->cacheHeaders,
            'quota' => $preview->quotaHeaders,
        ], JSON_THROW_ON_ERROR);

        $update = $db->prepare(
            'UPDATE bookmarks
             SET preview_path = ?, preview_state = "ready", upstream_meta = ?
             WHERE id = ?'
        );
        $update->execute([$filename, $metadata, $id]);
        respond(['id' => $id, 'preview_state' => 'ready',
            'preview_url' => '/previews/' . $id], 201);
    } catch (ScreenshotException $exception) {
        $update = $db->prepare(
            'UPDATE bookmarks SET preview_state = ?, upstream_meta = ?
             WHERE id = ?'
        );
        $update->execute([
            $exception->kind,
            json_encode($exception->metadata, JSON_THROW_ON_ERROR),
            $id,
        ]);

        error_log(json_encode([
            'event' => 'screenshot_failed',
            'bookmark_id' => $id,
            'kind' => $exception->kind,
            'retryable' => $exception->retryable,
        ], JSON_THROW_ON_ERROR));

        respond(['id' => $id, 'preview_state' => $exception->kind], 201);
    }
}

if ($method === 'GET' && preg_match('#^/previews/(\d+)$#', $path, $match)) {
    $query = $db->prepare(
        'SELECT preview_path FROM bookmarks
         WHERE id = ? AND preview_state = "ready"'
    );
    $query->execute([(int) $match[1]]);
    $filename = $query->fetchColumn();
    $file = $filename ? $previewDir . '/' . basename($filename) : '';

    if (!$filename || !is_file($file)) {
        respond(['error' => 'preview_not_found'], 404);
    }

    header('Content-Type: image/png');
    header('X-Content-Type-Options: nosniff');
    header("Content-Security-Policy: default-src 'none'; sandbox");
    header('Cache-Control: private, max-age=3600');
    readfile($file);
    exit;
}

respond(['error' => 'not_found'], 404);

Testirajte bez kontaktiranja usluge

Deterministička lažna implementacija dokazuje mapiranje odgovora i politiku ponovnih pokušaja bez trošenja kvote ili ovisnosti o mreži.

<?php
// tests/ScreenshotClientTest.php
namespace Bookmarks\Tests;

use Bookmarks\HttpResult;
use Bookmarks\ScreenshotClient;
use Bookmarks\ScreenshotException;
use Bookmarks\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function get(string $url, array $headers): HttpResult
    {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class ScreenshotClientTest extends TestCase
{
    public function testMapsPngAndOperationalHeaders(): void
    {
        $fake = new FakeTransport([
            new HttpResult(200, "\x89PNG\r\n\x1a\npayload", [
                'content-type' => ['image/png'],
                'cache-control' => ['public, max-age=60'],
                'retry-after' => ['10'],
            ]),
        ]);

        $preview = (new ScreenshotClient(
            $fake,
            'test-token',
            static fn (int $ms) => null
        ))->capture('https://example.com/');

        self::assertStringStartsWith("\x89PNG", $preview->png);
        self::assertArrayHasKey('cache-control', $preview->cacheHeaders);
    }

    public function testRetriesServerFailureThenSucceeds(): void
    {
        $fake = new FakeTransport([
            new HttpResult(503, '', []),
            new HttpResult(200, "\x89PNG\r\n\x1a\nok", [
                'content-type' => ['image/png'],
            ]),
        ]);

        (new ScreenshotClient(
            $fake,
            'test-token',
            static fn (int $ms) => null
        ))->capture('https://example.com/');

        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([new HttpResult(401, '', [])]);
        $client = new ScreenshotClient(
            $fake,
            'test-token',
            static fn (int $ms) => null
        );

        try {
            $client->capture('https://example.com/');
            self::fail('Expected ScreenshotException');
        } catch (ScreenshotException $exception) {
            self::assertSame('authentication_error', $exception->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
composer dump-autoload
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public public/index.php

curl --request POST 'http://127.0.0.1:8080/bookmarks' \
  --header 'Content-Type: application/json' \
  --data '{"title":"Example","url":"https://example.com/"}'

curl 'http://127.0.0.1:8080/bookmarks'

Sigurnost, vidljivost i implementacija

Tretirajte snimke zaslona kao nepouzdan binarni ulaz iako bi trebale biti PNG-ovi. Implementacija provjerava HTTP vrstu sadržaja, PNG potpis i veličinu; dodjeljuje vlastiti naziv datoteke; pohranjuje datoteke izvan public; te vraća nosniff uz restriktivnu politiku sigurnosti sadržaja. Dodajte autentifikaciju i provjere vlasništva nad oznakama prije izlaganja ovih ruta većem broju korisnika.

Nikada nemojte bilježiti tokene, autorizacijska zaglavlja, potpuna uzvodna tijela ili URL-ove koji mogu sadržavati tajne. Primjer zapisnika bilježi identifikator oznake, kategoriju neuspjeha i mogućnost ponovnog pokušaja. U produkciji brojite ishode prema stanju, pratite latenciju i odgovore 429 te postavite upozorenje za trajne neuspjehe autentifikacije jer oni često upućuju na istekao ili rotiran token.

Nemojte koristiti PHP-ov razvojni poslužitelj u produkciji. Pokrenite aplikaciju iza PHP-FPM-a i održavanog web poslužitelja s public/ kao korijenom dokumenta. PHP radniku dodijelite pristup za pisanje samo SQLite bazi podataka i direktoriju pregleda. Izradite sigurnosne kopije metapodataka oznaka prema njihovoj vrijednosti, primijenite pravila zadržavanja na stare PNG-ove i osigurajte da svaka instanca prima token putem upravitelja tajni za implementaciju.

Ponovni pokušaji umnožavaju promet, stoga ih ograničite. Ovaj klijent radi najviše tri pokušaja, poštuje kratka kašnjenja Retry-After i nakon iscrpljivanja prikazuje trajno stanje quota_limited. Za asinkrone implementacije zakažite kasnije ponovne pokušaje umjesto uspavljivanja radnika tijekom dugih kašnjenja koja je zatražio poslužitelj.

Česti neuspjesi i završna provjera

  • 401 ili 403: provjerite token ograničen na uslugu, aktivaciju plana i je li netko ponovno generirao token.
  • 429: pregledajte zabilježene metapodatke kvote, smanjite nepotrebna ponovna snimanja i odgodite rad umjesto stvaranja oluje ponovnih pokušaja.
  • Neočekivani sadržaj: zadržite stanje invalid_image, ali nikada nemojte spremiti ili poslužiti tijelo kao PNG.
  • Vremenska ograničenja ili odgovori 5xx: dopustite ograničene ponovne pokušaje, zatim sačuvajte transport_error ili upstream_error za kasniji oporavak.
  • Pregled nedostaje nakon uspjeha: provjerite vlasništvo nad direktorijem, slobodan prostor na disku, dozvole za atomsko preimenovanje i dosljednost baze podataka s datotekama.
  1. Potvrdite da su .env i generirane datoteke isključeni iz kontrole verzija.
  2. Pokrenite PHPUnit i provjerite da neuspjesi autentifikacije stvaraju točno jedan zahtjev.
  3. Izradite oznaku i potvrdite da njezino stanje postaje ready.
  4. Otvorite njezin preview_url i provjerite PNG odgovor sa sigurnosnim zaglavljima.
  5. Testirajte neispravan URL, literal privatne IP adrese, lažni token i simulirani 429.
  6. Provjerite sadrže li zapisnici korisne kategorije, ali ne token, autorizacijsko zaglavlje ili tijelo odgovora.
  7. Jednom rotirajte token u neprodukcijskom okruženju i potvrdite da stara vrijednost prestaje raditi prije ažuriranja tajne za implementaciju.

Najvažniji rezultat nije samo snimka zaslona na kartici oznake. To je uska granica koju je moguće testirati oko radnog opterećenja sličnog pregledniku, a koje vaša PHP aplikacija ne bi trebala morati posjedovati. Uz validaciju prije snimanja, obrambeno mapiranje odgovora nakon njega i izričita stanja neuspjeha posvuda, vizualni pregledi postaju obična značajka aplikacije umjesto skrivenog projekta upravljanja radom preglednika.

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.