513 lines
17 KiB
Markdown
513 lines
17 KiB
Markdown
# 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=<ID>
|
||
```
|
||
|
||
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/<categoryId>/products
|
||
?page=<N> # 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_<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 |
|