# Rohlik.cz Scraper — API & Data Notes Co víme o rohlik.cz scrapingu k 2026-06-01. Tento dokument shrnuje endpointy, tvary odpovědí, login flow a poznámky pro návrh databáze. --- ## 1. Login / session ### 1.1 API login (bez UI) Stránka má klasický JSON endpoint, který se chová stejně jako přihlášení přes formulář: ``` POST https://www.rohlik.cz/services/frontend-service/login Content-Type: application/json Accept: application/json { "email": "...", "password": "..." } ``` Odpověď (status 200): ```json { "status": 200, "messages": [], "data": { "status": ..., "fbLoginUrl": "...", "uniqsid": "...", "user": { ... }, "address": { ... }, "zoneId": ..., "availableStores": [...], "features": [...], "deliveryPoint": { ... }, "segment": "...", "personalizationConsent": ..., "newUserCreated": false, "session": { ... }, "admin": false, "isAuthenticated": true, "store": ..., "isAdmin": false } } ``` Po úspěšném loginu sedí v contextu cookie `PHPSESSION` na `.rohlik.cz`, která drží přihlášení pro všechny další API calls. ### 1.2 Cloudflare cookies První GET na `https://www.rohlik.cz` vyrobí cookie `cf_clearance` (Cloudflare challenge JS běží automaticky v headful Playwrightu). Bez ní login API nereaguje. Proto skript **nejdřív** otevře homepage, **pak** posílá login POST. ### 1.3 Cookie consent banner (Usercentrics) - Banner se renderuje přes web-component `#usercentrics-cmp-ui` se shadow DOM. - Pokus o klikání přes DOM selektory zvenku **nefunguje** — shadow root blokuje pointer events i pro elementy pod ním. - Funkční cesta: oficiální JS API ```js await window.UC_UI.acceptAllConsents(); await window.UC_UI.closeCMP(); ``` - Banner mizí s ~1 s animací, takže po close je potřeba `wait_for_selector('#usercentrics-cmp-ui', state='detached')`. - Souhlas se ukládá do `localStorage` (klíče `uc_user_interaction`, `uc_settings`, …) + cookie `consentTracked=true`. ### 1.4 Reuse: `auth_state.json` `context.storage_state(path=...)` uloží cookies + localStorage. Při příštím běhu se to nahraje přes `browser.new_context(storage_state=...)` a uživatel je: - už přihlášený (login API se neopakuje), - už má souhlas s cookies (banner se vůbec nezobrazí). Implementace flow viz `test_login.py::ensure_logged_in()`: 1. načti `auth_state.json` pokud existuje, 2. otevři `BASE_URL`, zkontroluj `text="Přihlásit se"` (přítomné → nepřihlášen), 3. když nepřihlášen → `POST /services/frontend-service/login`, accept cookies, ulož state, 4. když přihlášen → rovnou jeď dál. --- ## 2. Kategorie ### 2.1 Hlavní kategorie ``` GET /api/v5/navigation/components/navigation-tabs/categories ``` Vrací list 17 hlavních kategorií. Každá obsahuje: ```json { "id": 300102000, "name": "Ovoce a zelenina", "link": "/c300102000-ovoce-a-zelenina", "image": "/images/.../fruits-and-veggies.png", "imageType": "rich", ... } ``` Aktuální seznam (k dnešku): | ID | Název | |------------|----------------------| | 300102000 | Ovoce a zelenina | | 300105000 | Mléčné a chlazené | | 300103000 | Maso a ryby | | 300117503 | Grilování | | 300101000 | Pekárna a cukrárna | | 300104000 | Uzeniny a lahůdky | | 300107000 | Mražené | | 300121429 | Plant Based | | 300106000 | Trvanlivé | | 300108000 | Nápoje | | 300112393 | Speciální výživa | | 300124206 | Kosmetika | | 300109000 | Drogerie | | 300111000 | Domácnost a zahrada | | 300110000 | Dítě | | 300112000 | Zvíře | | 300112985 | Lékárna | > Hardcoded strom v `categories.py` je zastaralý (chybí Dítě, Zvíře, Lékárna). > Doporučeno přejít na živé tahání z API. ### 2.2 Subkategorie (rekurzivně) ``` GET /api/v4/navigation/components/navigation-tabs/subcategories?categoryIds= ``` Vrací **flat list** dětí dané kategorie. Příklad jednoho prvku: ```json { "id": 300112001, "name": "Pes", "image": "/images/.../1342001-1531397856.jpg", "imageColor": "var(--green-60)", "link": "/c300112001-pes", "imageLink": null, "imageType": "rich", "subcategoryIds": [300112002, 300112003, 300112004, 300112008, 300112009, 300118461, 300124184, 300124185] } ``` Klíčový moment: pole `subcategoryIds` říká, že tento uzel má další děti. Pro získání těch dětí musíme **opět zavolat stejný endpoint** s tímto ID jako parentem. #### Rekurzivní algoritmus ```python def fetch_children(parent_id, visited, depth=1, max_depth=6): if str(parent_id) in visited or depth > max_depth: return [] visited.add(str(parent_id)) subs = GET /api/v4/.../subcategories?categoryIds={parent_id} out = [] for s in subs: node = {id, name, url, children: []} if s.subcategoryIds: node.children = fetch_children(s.id, visited, depth+1) out.append(node) return out ``` Implementace v `scrape_categories.py`. Výstup uložen v `categories_live.json` jako `{tree: [{id, name, url, children: [...]}], raw_main: ...}`. --- ## 3. Listing produktů v kategorii ### 3.1 Endpoint ``` GET /api/v1/categories/normal//products ?page= # 0-based &size=50 # max items per page &sort=recommended &filter= &excludeProductIds= ``` ### 3.2 Odpověď — jen IDs ```json { "categoryId": 300102013, "categoryType": "normal", "productIds": [1407650, 1354613, 1350461, ...], "productsWithType": [{"id": 1407650, "type": "PRODUCT"}, ...], "impressions": [], "interactiveProductCardAds": [], "pageable": { "pageNumber": 0, "pageSize": 50, "sort": {...}, "offset": 0, "unpaged": false, "paged": true } } ``` Listing **nevrací detaily** — jen ID. Detail produktů se musí dotáhnout přes 5 batch endpointů (níže). ### 3.3 Stránkování - `size=50` se chová jako horní limit; pokud kategorie má méně, vrátí všechno najednou. - Konec stránek = první stránka, která vrátí prázdný `productIds`, **nebo** stránka s méně než `size` items. --- ## 4. Detail produktů — 5 paralelních batch endpointů Stránka pro každou sadu ID volá **5 batch endpointů paralelně**, vždy s opakovaným query parametrem `?products=ID1&products=ID2&...`: ``` GET /api/v1/products?products=... GET /api/v1/products/prices?products=... GET /api/v1/products/stock?products=... GET /api/v1/products/categories?products=... GET /api/v1/products/user-data?products=... ``` > ⚠ `categoryType=normal` parametr stránka taky posílá — bezpečnější ho přidat. > ⚠ Syntaxe je **opakovaný klíč**, ne čárka. `?products=1&products=2`, ne `?products=1,2`. > ⚠ Existuje i `/api/v1/products/card?products=...` — listing ho **nepoužívá**. Vyhnout se. ### 4.1 `/api/v1/products` — základní info ```json [ { "id": 1407650, "name": "Čerstvě utrženo – Okurka hadovka, bez folie", "slug": "cerstve-utrzeno-okurka-hadovka-bez-folie", "mainCategoryId": 300102013, "unit": "kg", "textualAmount": "cca 380 g", "weightedItem": true, "packageRatio": null, "brand": null, "sellerId": 1, "flag": "cz", "archived": false, "premiumOnly": false, "type": "PRODUCT", "images": [ "https://cdn.rohlik.cz/images/grocery/products/1407650/1407650-...jpg", ... ], "countries": [ { "name": "Česká republika", "nameId": "ceska-republika", "code": "CZ" } ], "countryOfOriginFlagIcon": "https://cdn.rohlik.cz/images/countryFlags/cz.svg", "badges": [ { "type": "freshly-harvested", "title": "Čerstvě sklizeno", "subtitle": null, "tooltip": "" } ], "filters": [], "information": [], "attachments": [], "image3dData": null, "adviceForSafeUse": null, "productStory": null, "canBeFavorite": true, "canBeRated": true } ] ``` | Pole | Typ | Popis | |--------------------|----------|-------| | `id` | int | Product ID | | `name` | string | Plný název | | `slug` | string | URL slug (`/{slug}-c{id}` nebo přes `/products/{id}-{slug}`) | | `mainCategoryId` | int | ID kategorie kam patří | | `unit` | string | "kg" / "ks" / "l" / ... — jednotka ceny | | `textualAmount` | string | "cca 380 g" / "1 ks" / "500 ml" — pro zobrazení | | `weightedItem` | bool | true = vážené (variabilní hmotnost), false = kusové | | `brand` | string? | Značka nebo null | | `flag` | string? | Země původu kód ("cz", "it", ...) | | `images` | string[] | URL obrázků (první je hlavní) | | `countries` | object[] | Strukturovaná země původu | | `badges` | object[] | Štítky (bio, čerstvě sklizeno, …) | | `archived` | bool | True = produkt už nabídku opustil | | `premiumOnly` | bool | Jen pro Xtra členy | ### 4.2 `/api/v1/products/prices` — ceny Bez slevy: ```json [ { "productId": 1407650, "price": { "amount": 34.16, "currency": "CZK" }, "pricePerUnit": { "amount": 89.9, "currency": "CZK" }, "sales": [], "lastMinuteTitle": null } ] ``` Se slevou: ```json { "productId": 1437841, "price": { "amount": 65.69, "currency": "CZK" }, "pricePerUnit": { "amount": 429.9, "currency": "CZK" }, "sales": [ { "id": 12988802, "type": "premium", // "premium" / "sale" / ... "triggerAmount": 1, "price": { "amount": 55.83, "currency": "CZK" }, "pricePerUnit": { "amount": 365.38, "currency": "CZK" }, "originalPrice": { "amount": 65.69, "currency": "CZK" }, "originalPricePerUnit": null, "badges": [{ "type": "premium-discount", "title": "-15 %", "subtitle": null }], "validTill": "2029-01-02T23:59:00+01:00", "active": true, "silent": false, "bundleId": null } ], "lastMinuteTitle": null } ``` | Pole | Cesta | Popis | |------|-------|-------| | Cena | `price.amount` | Aktuální cena za balení (Kč) | | Cena/jednotku | `pricePerUnit.amount` | Cena za `unit` z `/products` | | Akce | `sales[0].price.amount` | Pokud `sales` neprázdné | | Typ akce | `sales[0].type` | `premium` (Xtra), `sale`, … | | Štítek | `sales[0].badges[0].title` | "-10 %", "-15 %", ... | | Platnost | `sales[0].validTill` | ISO datetime | ### 4.3 `/api/v1/products/stock` — skladovost ```json [ { "productId": 1407650, "warehouseId": 8799, "packageInfo": { "amount": 0.38, "unit": "kg" }, "inStock": false, "maxBasketAmount": 0, "maxBasketAmountReason": "AVAILABLE", // "ALLOWED" když lze koupit "preorderEnabled": false, "unavailabilityReason": null, "deliveryRestriction": null, "expectedReplenishment": null, "availabilityDimension": 0, "shelfLife": null, // { value, unit } "billablePackaging": null, // záloha (lahve) "freshness": null, "premiumOnly": false, "tooltips": [], "sales": [] } ] ``` | Pole | Popis | |------|-------| | `inStock` | bool — skladem ano/ne | | `maxBasketAmount` | int — max kusů do košíku | | `packageInfo.amount` + `.unit` | Reálná hmotnost/objem balení (oproti `textualAmount` z base) | | `warehouseId` | ID skladu (může se lišit podle adresy) | | `shelfLife` | Trvanlivost (pokud uvedena) | | `billablePackaging` | Zálohovaný obal (lahev atd.) | ### 4.4 `/api/v1/products/categories` ```json [ { "productId": 1407650, "categories": [ { "id": 300102000, "type": "normal", "name": "Ovoce a zelenina", "slug": "ovoce-a-zelenina", "level": 0 }, { "id": 300102008, "type": "normal", "name": "Zelenina", "slug": "zelenina", "level": 1 }, { "id": 300102013, "type": "normal", "name": "Okurky, cukety a lilky", "slug": "okurky-cukety-a-lilky", "level": 2 } ] } ] ``` Plný strom kategorií od kořene k listu, `level=0` = hlavní. Užitečné, protože produkt může patřit do více kategorií (např. „Grilování" duplikuje listy z masa). ### 4.5 `/api/v1/products/user-data` Per-user data (oblíbené, naposled koupeno…). Pro scraping cen **nepotřebujeme**, ale stránka to volá, takže když to vynecháme, vypadáme méně jako frontend. --- ## 5. Sample merged record Po zavolání všech 5 endpointů a merge podle `productId`: ```json { "productId": 1407650, "base": { ... pole z /products ... }, "prices": { ... pole z /products/prices ... }, "stock": { ... pole z /products/stock ... }, "categories": { ... pole z /products/categories ... }, "user_data": { ... pole z /products/user-data ... } } ``` Reálná tabulka prvního leafu (Okurky, cukety a lilky → 17 produktů): ``` ID Skladem Cena Za jedn. Akce Název (balení) 1407650 ne 34.16 89.90/kg Čerstvě utrženo – Okurka hadovka (cca 380 g) 1354613 ano 31.87 109.90/kg Okurka polní 1 ks (cca 290 g) 1294911 ano 49.90 49.90/ks 44.91 -10 % BIO Okurka hadovka 1 ks (1 ks) ... ``` --- ## 6. Číselníky / enumy které jsme viděli ### Typ slevy (`sales[].type`) - `"premium"` — Xtra members discount - (`"sale"` — klasická akce, ne vlastní pozorování ale dle označení) ### `badges[].type` (base) - `"freshly-harvested"`, `"bio"`, `"low-price"`, ... ### `maxBasketAmountReason` - `"ALLOWED"` — normálně lze koupit - `"AVAILABLE"` — vidíme když `inStock=false` (out of stock) ### `flag` (base) — kód země původu - `"cz"`, `"it"`, `"de"`, ... ### `unit` (base) - `"kg"`, `"l"`, `"ks"`, `"g"`, `"ml"`, ... ### `categoryType` (listing) - `"normal"` — běžné kategorie - (existují i `"premium"`, `"recipes"` aj., nepoužíváme) --- ## 7. Postup scrapingu (high level) ``` ensure_logged_in() └─ načte auth_state.json NEBO se přihlásí přes API a uloží state get_category_tree() └─ rekurzivně přes /navigation-tabs/categories + /subcategories └─ vrátí strom uzlů {id, name, url, children} for each leaf in tree (without children): page = 0 while True: ids = GET /api/v1/categories/normal/{leaf.id}/products?page={page}&size=50 if not ids: break all_ids += ids if len(ids) < 50: break page += 1 for chunk in chunks(all_ids, 30): base = GET /api/v1/products?products=... prices = GET /api/v1/products/prices?products=... stock = GET /api/v1/products/stock?products=... categories = GET /api/v1/products/categories?products=... merged = merge by productId upsert to MongoDB ``` --- ## 8. Důležité poznámky / gotchas - **Cloudflare**: vždy nejdřív otevřít homepage v Playwright contextu, pak teprve API. - **Cookie consent**: pro pokud možno nenápadné chování přijmout cookies přes `UC_UI.acceptAllConsents()`. Uložený state ho už neukazuje. - **Headers**: zatím nepotřebujeme posílat speciální `User-Agent` ani `X-...` — Playwright context cookies stačí. - **Rate**: zatím netestováno. Stránka sama posílá 5 paralelních requestů per chunk + listing. Ne víc. - **Velikost chunků**: 30 ID per batch nám prošlo bez problémů. URL délka by zvládla i víc, ale držme se toho, co reálně chrome dělá. - **Identita produktu**: `id` v base / `productId` v ostatních endpointech — totéž. Není garantována stálost ID napříč warehouses (ale `warehouseId=8799` je nás stabilní zóna). - **Sklad-specifická data**: cena, dostupnost i `warehouseId` se odvíjí od `zoneId` v session. Pokud měníme adresu, měníme i ceny → držet jednu doručovací adresu pro reprodukovatelnost. - **Kategorie ne-listy**: hlavní kategorie zobrazují jen "Doporučujeme" (cca 5 produktů). Pro úplný katalog scrapovat **jen listy** stromu (uzly bez `children`). - **Archived products**: `archived: true` znamená, že produkt už není v nabídce — uložit historicky, ale nemarkovat jako aktivní. --- ## 9. Soubory v projektu | Soubor | Co dělá | |--------|---------| | `config.py` | Cesty + creds z `.env` | | `test_login.py` | `ensure_logged_in()` — session reuse + API login + accept cookies | | `scrape_categories.py` | Stáhne živý strom kategorií → `categories_live.json` | | `scrape_first_leaf.py` | Demo: stáhne první leaf a vypíše produkty | | `auth_state.json` | Cookies + localStorage (gitignored) | | `categories_live.json` | Aktuální strom kategorií | | `products_.json` | Demo dump produktů z jedné kategorie | | `scraper.py` | (zastaralý) původní DOM scraping přes Playwright | | `categories.py` | (zastaralý) hardcoded strom kategorií | | `db.py` | MongoDB ops — bude potřeba upravit pro nový tvar dat |