17 KiB
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):
{
"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-uise 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
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íčeuc_user_interaction,uc_settings, …) + cookieconsentTracked=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():
- načti
auth_state.jsonpokud existuje, - otevři
BASE_URL, zkontrolujtext="Přihlásit se"(přítomné → nepřihlášen), - když nepřihlášen →
POST /services/frontend-service/login, accept cookies, ulož state, - 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:
{
"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.pyje 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=<ID>
Vrací flat list dětí dané kategorie. Příklad jednoho prvku:
{
"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
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/<categoryId>/products
?page=<N> # 0-based
&size=50 # max items per page
&sort=recommended
&filter=
&excludeProductIds=
3.2 Odpověď — jen IDs
{
"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=50se 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žsizeitems.
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=normalparametr 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
[
{
"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:
[
{
"productId": 1407650,
"price": { "amount": 34.16, "currency": "CZK" },
"pricePerUnit": { "amount": 89.9, "currency": "CZK" },
"sales": [],
"lastMinuteTitle": null
}
]
Se slevou:
{
"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
[
{
"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
[
{
"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:
{
"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-AgentaniX-...— 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:
idv base /productIdv ostatních endpointech — totéž. Není garantována stálost ID napříč warehouses (alewarehouseId=8799je nás stabilní zóna). - Sklad-specifická data: cena, dostupnost i
warehouseIdse odvíjí odzoneIdv 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: trueznamená, ž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_<id>.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 |