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);
}
}
