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

17 KiB
Raw Blame History

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.

  • 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
    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:

{
  "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:

{
  "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=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

[
  {
    "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-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