okay, pokračujeme ďalej v projekte Prometheus, práve sme dokončili parsing Billa letákov v PDF a ich spracovanie do elegantného JSON formátu

okay, pokračujeme ďalej v projekte Prometheus, práve sme dokončili parsing Billa letákov v PDF a ich spracovanie do elegantného JSON formátu

https://hrubos.tech/blogy/content/images/20260911180311-Snímka obrazovky 2026-09-11 o 18.00.40.png

Zverejnené letáky ku dnešnému dňu zo stránok predajcov potravín upravené do JSON formátu zatiaľ vyzerajú:

https://hrubos.tech/simulacie/lidl.json

https://hrubos.tech/simulacie/billa.json

https://hrubos.tech/simulacie/kaufland.json

Bol použitý tento BillaController.php pre Laravel: nastavenia logging.php sú tu https://hrubos.tech/blogy/2026/09/projekt-prometheus-hlasi-uspech

<?php

namespace App\Http\Controllers\Interconnector;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Smalot\PdfParser\Parser;
use Throwable;

class BillaController extends Controller
{
    private string $storagePath;

    private array $sources = [
        [
            'key' => 'letaky',
            'url' => 'https://www.billa.sk/letaky-a-akcie/letaky',
        ],
        [
            'key' => 'billa-bonus-letak',
            'url' => 'https://www.billa.sk/letaky-a-akcie/billa-bonus-letak',
        ],
        [
            'key' => 'katalog-back-to-school',
            'url' => 'https://www.billa.sk/letaky-a-akcie/katalog-back-to-school',
        ],
        [
            'key' => 'katalog-feast-wine',
            'url' => 'https://www.billa.sk/letaky-a-akcie/katalog-feast-wine',
        ],
        [
            'key' => 'katalog-billa-brands-billa',
            'url' => 'https://www.billa.sk/letaky-a-akcie/katalog-billa-brands-billa',
        ],
        [
            'key' => 'letak-premiove-predajne',
            'url' => 'https://www.billa.sk/letaky-a-akcie/letak-premiove-predajne',
        ],
        [
            'key' => 'letak-billa-bratislava-racianska',
            'url' => 'https://www.billa.sk/letaky-a-akcie/letak-billa-bratislava-racianska',
        ],
    ];

    public function index(): JsonResponse
    {
        ini_set('memory_limit', '4G');
        set_time_limit(0);

        $this->storagePath = storage_path('app/prometheus/billa');

        if (!is_dir($this->storagePath)) {
            mkdir($this->storagePath, 0775, true);
        }

        Log::channel('billa')->info(
            'BILLA interconnector started'
        );

        $allProducts = [];
        $sourceResults = [];

        try {
            $parser = new Parser();

            foreach ($this->sources as $source) {
                $result = $this->processSource(
                    $source,
                    $parser
                );

                $sourceResults[] = $result;

                if (!empty($result['products'])) {
                    $allProducts = array_merge(
                        $allProducts,
                        $result['products']
                    );
                }
            }

            $allProducts = $this->deduplicateProducts(
                $allProducts
            );

            $output = [
                'success' => true,
                'shop' => 'billa',
                'scraped_at' => now()->toIso8601String(),
                'sources' => $sourceResults,
                'products_count' => count($allProducts),
                'products' => $allProducts,
            ];

            $jsonPath = $this->storagePath . '/billa.json';

            file_put_contents(
                $jsonPath,
                json_encode(
                    $output,
                    JSON_PRETTY_PRINT
                    | JSON_UNESCAPED_UNICODE
                    | JSON_UNESCAPED_SLASHES
                )
            );

            Log::channel('billa')->info(
                'BILLA interconnector finished',
                [
                    'sources' => count($sourceResults),
                    'products' => count($allProducts),
                    'json' => $jsonPath,
                ]
            );

            return response()->json($output);

        } catch (Throwable $e) {

            Log::channel('billa')->error(
                'BILLA interconnector exception',
                [
                    'message' => $e->getMessage(),
                    'file' => $e->getFile(),
                    'line' => $e->getLine(),
                ]
            );

            return response()->json([
                'success' => false,
                'error' => $e->getMessage(),
            ], 500);
        }
    }

    private function processSource(
        array $source,
        Parser $parser
    ): array {
        $key = $source['key'];
        $pageUrl = $source['url'];

        Log::channel('billa')->info(
            'Processing BILLA source',
            [
                'key' => $key,
                'url' => $pageUrl,
            ]
        );

        /*
         * 1. Načítanie BILLA stránky.
         */
        $response = Http::timeout(60)
            ->retry(2, 1000)
            ->withHeaders([
                'User-Agent' =>
                    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
                    . 'AppleWebKit/537.36 Chrome/140 Safari/537.36',
                'Accept' =>
                    'text/html,application/xhtml+xml',
            ])
            ->get($pageUrl);

        if (!$response->successful()) {
            throw new \RuntimeException(
                "BILLA page failed: {$pageUrl} "
                . "HTTP {$response->status()}"
            );
        }

        $html = $response->body();

        /*
         * 2. Nájdeme PDF odkazy.
         */
        preg_match_all(
            '/href=["\']([^"\']+\.pdf[^"\']*)["\']/i',
            $html,
            $matches
        );

        $pdfUrls = [];

        foreach ($matches[1] ?? [] as $href) {

            $href = html_entity_decode($href);

            if (str_starts_with($href, '//')) {
                $href = 'https:' . $href;
            } elseif (str_starts_with($href, '/')) {
                $href = 'https://www.billa.sk' . $href;
            }

            if (!filter_var($href, FILTER_VALIDATE_URL)) {
                continue;
            }

            $lower = strtolower($href);

            /*
             * Ignorujeme nerelevantné dokumenty.
             */
            if (
                str_contains($lower, 'reklamac')
                || str_contains($lower, 'obchodne-podmienky')
                || str_contains($lower, 'privacy')
                || str_contains($lower, 'ochrana-osobnych')
            ) {
                continue;
            }

            $pdfUrls[] = $href;
        }

        $pdfUrls = array_values(
            array_unique($pdfUrls)
        );

        Log::channel('billa')->info(
            'PDF links found',
            [
                'key' => $key,
                'count' => count($pdfUrls),
                'urls' => $pdfUrls,
            ]
        );

        if (empty($pdfUrls)) {
            throw new \RuntimeException(
                "No PDF found for source: {$key}"
            );
        }

        /*
         * Zatiaľ berieme prvý relevantný PDF.
         */
        $pdfUrl = $pdfUrls[0];

        $pdfPath =
            $this->storagePath . '/' . $key . '.pdf';

        $txtPath =
            $this->storagePath . '/' . $key . '.txt';

        /*
         * 3. Stiahnutie PDF.
         */
        Log::channel('billa')->info(
            'Downloading PDF',
            [
                'key' => $key,
                'pdf_url' => $pdfUrl,
            ]
        );

        $downloadResponse = Http::timeout(180)
            ->retry(2, 2000)
            ->withHeaders([
                'User-Agent' =>
                    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
                    . 'AppleWebKit/537.36 Chrome/140 Safari/537.36',
                'Accept' => 'application/pdf',
            ])
            ->sink($pdfPath)
            ->get($pdfUrl);

        if (!$downloadResponse->successful()) {
            throw new \RuntimeException(
                "PDF download failed for {$key}: "
                . "HTTP {$downloadResponse->status()}"
            );
        }

        if (!file_exists($pdfPath)) {
            throw new \RuntimeException(
                "PDF was not downloaded: {$key}"
            );
        }

        $fileSize = filesize($pdfPath);

        Log::channel('billa')->info(
            'PDF downloaded',
            [
                'key' => $key,
                'bytes' => $fileSize,
                'megabytes' =>
                    round($fileSize / 1024 / 1024, 2),
            ]
        );

        /*
         * 4. Parsovanie PDF.
         */
        Log::channel('billa')->info(
            'Parsing PDF',
            [
                'key' => $key,
            ]
        );

        $pdf = $parser->parseFile($pdfPath);

        /*
         * Dôležité:
         * getPages() nemusí v každom prípade vrátiť array.
         */
        $pages = $pdf->getPages();

        $pageCount = is_array($pages)
            ? count($pages)
            : 0;

        Log::channel('billa')->info(
            'PDF pages detected',
            [
                'key' => $key,
                'pages' => $pageCount,
                'pages_type' => get_debug_type($pages),
            ]
        );

        /*
         * Text získame priamo z dokumentu.
         */
        $text = $pdf->getText();

        if (!is_string($text)) {
            $text = '';
        }

        /*
         * 5. Uloženie TXT.
         */
        file_put_contents(
            $txtPath,
            $text
        );

        Log::channel('billa')->info(
            'PDF parsed',
            [
                'key' => $key,
                'pages' => $pageCount,
                'text_length' => strlen($text),
                'txt' => $txtPath,
            ]
        );

        /*
         * 6. Extrakcia produktov.
         */
        $products = $this->extractProducts(
            $text,
            $key
        );

        /*
         * PDF už nepotrebujeme.
         * TXT ponechávame na kontrolu.
         */
        @unlink($pdfPath);

        Log::channel('billa')->info(
            'Source completed',
            [
                'key' => $key,
                'products' => count($products),
            ]
        );

        return [
            'key' => $key,
            'page_url' => $pageUrl,
            'pdf_url' => $pdfUrl,
            'pages' => $pageCount,
            'text_length' => strlen($text),
            'txt' =>
                'storage/app/prometheus/billa/'
                . $key
                . '.txt',
            'products_count' => count($products),
            'products' => $products,
        ];
    }

    private function extractProducts(
    string $text,
    string $source
): array {
    $lines = preg_split('/\R/u', $text);

    if ($lines === false) {
        Log::channel('billa')->warning(
            'preg_split failed, retrying without Unicode modifier',
            ['source' => $source]
        );

        $lines = preg_split('/\R/', $text);
    }

    if ($lines === false) {
        throw new \RuntimeException(
            "Unable to split PDF text into lines: {$source}"
        );
    }

    $lines = array_values(
        array_filter(
            array_map(
                fn ($line) => trim($line),
                $lines
            ),
            fn ($line) => $line !== ''
        )
    );

    $count = count($lines);

    /*
     * ---------------------------------------------------------
     * PRICE PARSER
     * ---------------------------------------------------------
     */

    $parsePrice = function (
        array $lines,
        int $index
    ): ?array {
        $line = $lines[$index] ?? '';

        /*
         * 1.29
         * 1,29
         */
        if (preg_match(
            '/^(\d{1,2})[,.](\d{2})(.*)$/u',
            $line,
            $m
        )) {
            return [
                'price' => (float) (
                    $m[1] . '.' . $m[2]
                ),
                'end' => $index,
                'attached' => trim($m[3]),
            ];
        }

        /*
         * 1
         * 29
         */
        if (
            preg_match(
                '/^(\d{1,2})$/u',
                $line,
                $m
            )
            && isset($lines[$index + 1])
            && preg_match(
                '/^(\d{2})(.*)$/u',
                $lines[$index + 1],
                $m2
            )
        ) {
            return [
                'price' => (float) (
                    $m[1] . '.' . $m2[1]
                ),
                'end' => $index + 1,
                'attached' => trim($m2[2]),
            ];
        }

        return null;
    };

    /*
     * ---------------------------------------------------------
     * DISCOUNT
     * ---------------------------------------------------------
     */

    $parseDiscount = function (
        string $line
    ): ?string {
        if (preg_match(
            '/^-(\d{1,2})\s*[%%]/u',
            $line,
            $m
        )) {
            return '-' . $m[1] . '%';
        }

        return null;
    };

    /*
     * ---------------------------------------------------------
     * UNIT PRICE
     * ---------------------------------------------------------
     */

    $isUnitPrice = function (
        string $line
    ): bool {
        return (bool) preg_match(
            '/^\d+(?:[,.]\d+)?\s*(?:€)?\s*(?:\/|=)/iu',
            $line
        );
    };

    /*
     * ---------------------------------------------------------
     * PACKAGING
     * ---------------------------------------------------------
     */

    $getPackaging = function (
        string $line
    ): ?string {
        /*
         * 6 × 100 g
         */
        if (preg_match(
            '/^\d+\s*[x×]\s*\d+(?:[,.]\d+)?\s*(?:g|kg|ml|l)$/iu',
            $line,
            $m
        )) {
            return trim($m[0]);
        }

        /*
         * 150 g – 210 g
         */
        if (preg_match(
            '/^\d+(?:[,.]\d+)?\s*(?:g|kg|ml|l)\s*[–-]\s*\d+(?:[,.]\d+)?\s*(?:g|kg|ml|l)$/iu',
            $line,
            $m
        )) {
            return trim($m[0]);
        }

        /*
         * 90 g
         * 0,75 l
         * 1 ks
         */
        if (preg_match(
            '/^\d+(?:[,.]\d+)?\s*(?:kg|g|mg|l|ml|ks|bal\.)$/iu',
            $line,
            $m
        )) {
            return trim($m[0]);
        }

        return null;
    };

    /*
     * ---------------------------------------------------------
     * NAME CLEANUP
     * ---------------------------------------------------------
     */

   $cleanName = function (
    string $name
): string {
    $name = preg_replace(
        '/\s+/u',
        ' ',
        $name
    );

    // OCR / marketingové zvyšky
    $patterns = [
        '/^\s*€\s+/u',
        '/^\s*[\*\-–—]\s+/u',

        '/\bcena za 1 ks pri kúpe od \d+\s*ks\b/iu',
        '/\bcena za 1 ks\b/iu',
        '/\bcena za 1 kg\b/iu',
        '/\bcena za 100 g\b/iu',

        '/\bdo\s+v(?:y|ý)predania\s+zásob\b/iu',
        '/\bdo\s+v\s+y\s+predania\s+zásob\b/iu',
        '/\bdo\s+v\s+y\s+predania\b/iu',

        '/\bbe[zZ]\s+billa\s+bonus\b/iu',
        '/\bxxl ponuka\b/iu',
        '/\bsuper cena\b/iu',
        '/\bnaša cena\b/iu',
        '/\blacnejšie\b/iu',
        '/\bpultový predaj\b/iu',

        '/\+ záloh za plechovku [\d,.]+\s*€\**/iu',
        '/\+ záloh za fľašu [\d,.]+\s*€\**/iu',

        // napr. "1 pranie = 0,15 €"
        '/\b\d+\s+\w+\s*=\s*[\d,.]+\s*€\b/iu',

        // jednotkové ceny, ktoré sa omylom dostali do názvu
        '/\b\d+(?:[,.]\d+)?\s*€\s*[-–]\s*[\d,.]+\s*€\b/iu',
        '/\b\d+(?:[,.]\d+)?\s*[-–]\s*[\d,.]+\s*€\b/iu',

        '/\b\d+(?:[,.]\d+)?\s*(?:kg|g|mg|l|ml|ks)\s*=\s*[\d,.]+\s*€?/iu',
    ];

    foreach ($patterns as $pattern) {
        $name = preg_replace(
            $pattern,
            ' ',
            $name
        );
    }

    // Samostatné € / marketingové pomlčky na začiatku alebo konci
    $name = preg_replace(
        '/(^|\s)€(?=\s|$)/u',
        ' ',
        $name
    );

    $name = preg_replace(
        '/\s+[-–—]\s*(?=\d)/u',
        ' ',
        $name
    );

    // Opakované medzery
    $name = preg_replace(
        '/\s{2,}/u',
        ' ',
        $name
    );

    return trim(
        $name,
        " \t\n\r\0\x0B-–—*€"
    );
};

    /*
     * ---------------------------------------------------------
     * NOISE
     * ---------------------------------------------------------
     */

    $isNoise = function (
        string $line
    ) use (
        $parseDiscount,
        $isUnitPrice,
        $getPackaging
    ): bool {
        $lower = mb_strtolower($line);

        $exactNoise = [
            'super cena',
            'xxl ponuka',
            'bez billa bonus',
            'billa bonus',
            'naša cena',
            'lacnejšie',
            'pultový predaj',
        ];

        if (in_array(
            $lower,
            $exactNoise,
            true
        )) {
            return true;
        }

        if ($parseDiscount($line) !== null) {
            return true;
        }

        if ($isUnitPrice($line)) {
            return true;
        }

        if ($getPackaging($line) !== null) {
            return true;
        }

        /*
         * Čisté čísla.
         */
        if (preg_match(
            '/^\d+(?:[,.]\d+)?$/u',
            $line
        )) {
            return true;
        }
if (preg_match(
    '/^\s*[\*\-–—]?\s*€?\s*$/u',
    $line
)) {
    return true;
}

if (preg_match(
    '/\b\d+\s+\w+\s*=\s*[\d,.]+\s*€\b/iu',
    $line
)) {
    return true;
}

if (preg_match(
    '/\bdo\s+v(?:y|ý)?\s*predania\s+zásob\b/iu',
    $line
)) {
    return true;
}
        return false;
    };

    /*
     * ---------------------------------------------------------
     * PRODUCT NAME VALIDATION
     * ---------------------------------------------------------
     */

    $looksLikeProductName = function (
        string $name
    ): bool {
        $name = trim($name);

        if (mb_strlen($name) < 3) {
            return false;
        }

        if (preg_match(
            '/^\d+(?:[,.]\d+)?$/u',
            $name
        )) {
            return false;
        }

        $lower = mb_strtolower($name);

        $blocked = [
            'akciová ponuka',
            'akciová ponuka platí',
            'sledujte nás',
            'ingrediencie',
            'čas prípravy',
            'postup',
            'suroviny',
            'zaujímavosť',
            'www.billa.sk',
            'viac produktov nájdete',
            'nakúpte svoje obľúbené',
            'platí od',
            'akciová ponuka neplatí',
        ];

        foreach ($blocked as $word) {
            if (str_contains(
                $lower,
                $word
            )) {
                return false;
            }
        }

        return true;
    };

    /*
     * ---------------------------------------------------------
     * PRICE BLOCKS
     * ---------------------------------------------------------
     *
     * Najprv nájdeme všetky ceny.
     *
     * Toto je dôležité kvôli:
     *
     * 1
     * 99
     * 2
     * 79
     *
     * kde 2.79 nesmie byť následne spracované ako nový produkt.
     */

    $priceBlocks = [];

    for ($i = 0; $i < $count; $i++) {
        $priceData = $parsePrice(
            $lines,
            $i
        );

        if (!$priceData) {
            continue;
        }

        $priceBlocks[] = [
            'start' => $i,
            'end' => $priceData['end'],
            'price' => $priceData['price'],
            'attached' => $priceData['attached'],
        ];

        /*
         * Ak ide o rozdelenú cenu:
         *
         * 1
         * 99
         *
         * druhý riadok už nechceme znovu kontrolovať.
         */
        if ($priceData['end'] > $i) {
            $i = $priceData['end'];
        }
    }

    /*
     * ---------------------------------------------------------
     * MAIN PARSER
     * ---------------------------------------------------------
     */

    $products = [];

    $consumedPriceBlocks = [];

    foreach (
        $priceBlocks as $priceIndex => $priceBlock
    ) {
        if (
            isset(
                $consumedPriceBlocks[$priceIndex]
            )
        ) {
            continue;
        }

        $price = $priceBlock['price'];
        $priceStart = $priceBlock['start'];
        $priceEnd = $priceBlock['end'];

        /*
         * -----------------------------------------------------
         * OLD PRICE
         * -----------------------------------------------------
         *
         * Ak je bezprostredne ďalší cenový blok,
         * považujeme ho za starú cenu.
         */

        $oldPrice = null;
        $oldPriceIndex = null;

        if (
            isset($priceBlocks[$priceIndex + 1])
            && $priceBlocks[$priceIndex + 1]['start']
                === $priceEnd + 1
        ) {
            $oldPriceIndex = $priceIndex + 1;
            $oldPrice = $priceBlocks[
                $oldPriceIndex
            ]['price'];

            $consumedPriceBlocks[
                $oldPriceIndex
            ] = true;
        }

        /*
         * -----------------------------------------------------
         * DISCOUNT
         * -----------------------------------------------------
         *
         * Zľava môže byť pred produktom alebo za cenou.
         */

        $discount = null;

        /*
         * Za cenou.
         */
        for (
            $j = $priceEnd + 1;
            $j <= min(
                $priceEnd + 3,
                $count - 1
            );
            $j++
        ) {
            $parsedDiscount = $parseDiscount(
                $lines[$j]
            );

            if ($parsedDiscount !== null) {
                $discount = $parsedDiscount;
                break;
            }
        }

        /*
         * -----------------------------------------------------
         * SEARCH WINDOW
         * -----------------------------------------------------
         *
         * Hľadáme produkt medzi predchádzajúcou cenou
         * a aktuálnou cenou.
         */

        $previousPriceEnd = -1;

        if ($priceIndex > 0) {
            $previousPriceEnd =
                $priceBlocks[$priceIndex - 1]['end'];
        }

        $windowStart = $previousPriceEnd + 1;
        $windowEnd = $priceStart - 1;

        if ($windowStart > $windowEnd) {
            continue;
        }

        $window = array_slice(
            $lines,
            $windowStart,
            $windowEnd - $windowStart + 1
        );

        /*
         * -----------------------------------------------------
         * FIND PACKAGING
         * -----------------------------------------------------
         *
         * Toto je najdôležitejšia zmena.
         *
         * Pri:
         *
         * BOSTAVAN
         * CHARDONNAY
         * rôzne druhy
         * 0,75 l
         * 1 l = ...
         * 2
         * 99
         *
         * nájdeme "0,75 l" a názov hľadáme iba
         * bezprostredne pred ním.
         */

        $packaging = null;
        $packagingIndex = null;

        for (
            $j = count($window) - 1;
            $j >= 0;
            $j--
        ) {
            $candidate = $getPackaging(
                $window[$j]
            );

            if ($candidate === null) {
                continue;
            }

            /*
             * 1 kg / 1 l / 1 ks môže byť skutočné balenie,
             * ale nechceme ho zamieňať s jednotkovou cenou.
             */
            $packaging = $candidate;
            $packagingIndex = $j;
            break;
        }

        /*
         * -----------------------------------------------------
         * UNIT PRICE
         * -----------------------------------------------------
         */

        $unitPrice = null;
        $unitPriceIndex = null;

        for (
            $j = 0;
            $j < count($window);
            $j++
        ) {
            if ($isUnitPrice($window[$j])) {
                $unitPrice = trim(
                    $window[$j]
                );
                $unitPriceIndex = $j;
            }
        }

        /*
         * -----------------------------------------------------
         * NAME WINDOW
         * -----------------------------------------------------
         *
         * Ak máme packaging:
         *
         * názov = riadky bezprostredne pred packagingom.
         *
         * Ak packaging nemáme, použijeme unit price ako hranicu.
         */

        $nameEnd = null;

        if ($packagingIndex !== null) {
            $nameEnd = $packagingIndex - 1;
        } elseif ($unitPriceIndex !== null) {
            $nameEnd = $unitPriceIndex - 1;
        } else {
            /*
             * Bez packagingu/unit price:
             * použijeme posledné riadky pred cenou.
             */
            $nameEnd = count($window) - 1;
        }

        if ($nameEnd < 0) {
            continue;
        }

        /*
         * Hľadáme začiatok názvu smerom dozadu.
         *
         * Maximálne 5 riadkov, aby sme nevtiahli
         * redakčný text alebo predchádzajúci produkt.
         */

        $nameLines = [];

        $maxNameLines = 5;

        for (
            $j = $nameEnd;
            $j >= 0 && count($nameLines) < $maxNameLines;
            $j--
        ) {
            $line = $window[$j];

            if ($isNoise($line)) {
                break;
            }

            /*
             * Marketingové bloky často predstavujú
             * hranicu medzi produktmi.
             */
            $lower = mb_strtolower($line);

            if (
                str_contains(
                    $lower,
                    'akciová ponuka neplatí'
                )
            ) {
                break;
            }

            if (
                str_contains(
                    $lower,
                    'do vypredania zásob'
                )
            ) {
                break;
            }

            $nameLines[] = $line;
        }

        $nameLines = array_reverse(
            $nameLines
        );

        /*
         * Odstránime prípadné noise riadky.
         */

        $nameLines = array_values(
            array_filter(
                $nameLines,
                fn ($line) => !$isNoise($line)
            )
        );

        if (empty($nameLines)) {
            continue;
        }

        $name = implode(
            ' ',
            $nameLines
        );

        $name = $cleanName(
            $name
        );

        if (!$looksLikeProductName($name)) {
            continue;
        }

        /*
         * -----------------------------------------------------
         * PROMOTION
         * -----------------------------------------------------
         *
         * Promotion môže byť pred produktom,
         * preto pozeráme celé okno.
         */

        $promotion = null;
        $promotionCondition = null;

        foreach ($window as $line) {
            $lower = mb_strtolower(
                $line
            );

            if (
                str_contains(
                    $lower,
                    'xxl ponuka'
                )
            ) {
                $promotion = 'XXL PONUKA';
            }

            if (
                str_contains(
                    $lower,
                    'super cena'
                )
            ) {
                $promotion ??= 'SUPER CENA';
            }

            if (
                str_contains(
                    $lower,
                    'bez billa bonus'
                )
            ) {
                $promotion = 'BILLA BONUS';
            }

            if (
                str_contains(
                    $lower,
                    'pultový predaj'
                )
            ) {
                $promotion ??= 'PULTOVÝ PREDAJ';
            }

            if (preg_match(
                '/od\s+(\d+)\s*ks/iu',
                $line,
                $m
            )) {
                $promotionCondition =
                    'od ' . $m[1] . ' ks';
            }
        }

        /*
         * -----------------------------------------------------
         * ATTACHED TEXT AFTER PRICE
         * -----------------------------------------------------
         *
         * napr.:
         *
         * 3
         * 25BILLA slivkové buchty
         *
         * nechceme prilepiť "BILLA..." k cene ani názvu.
         */

        $attached = trim(
            $priceBlock['attached']
        );

        if ($attached !== '') {
            /*
             * Zatiaľ iba logujeme.
             * Nechceme stratiť informáciu pri ďalšom ladení.
             */
            Log::channel('billa')->debug(
                'Price has attached text',
                [
                    'source' => $source,
                    'price' => $price,
                    'attached' => $attached,
                ]
            );
        }

        /*
         * -----------------------------------------------------
         * PRODUCT
         * -----------------------------------------------------
         */

        $products[] = [
            'name' => $name,
            'description' => null,
            'packaging' => $packaging,
            'price' => $price,
            'old_price' => $oldPrice,
            'discount' => $discount,
            'unit_price' => $unitPrice,
            'promotion' => $promotion,
            'promotion_condition' => $promotionCondition,
            'source_type' => $source,
            'source' => 'billa.sk',
        ];
    }

    /*
     * ---------------------------------------------------------
     * FINAL DEDUPLICATION
     * ---------------------------------------------------------
     */

    return $this->deduplicateProducts(
        $products
    );
}

    private function deduplicateProducts(
        array $products
    ): array {
        $unique = [];

        foreach ($products as $product) {

            $key = mb_strtolower(
                trim($product['name'])
                . '|'
                . ($product['packaging'] ?? '')
                . '|'
                . ($product['price'] ?? '')
            );

            if (!isset($unique[$key])) {
                $unique[$key] = $product;
            }
        }

        return array_values($unique);
    }
}


Author: AarNoma

The first Slovak cyborg 1 system