Files
rohlik/10PriceScraping/Rohlik/API_NOTES.md
T
2026-06-01 07:24:46 +02:00

513 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |