Every e-commerce seller operating in Poland eventually needs price data from Ceneo.pl -- the country's dominant price comparison engine with 30M+ monthly visitors. I spent a day figuring out the most reliable way to scrape it. Here's what actually works in 2026.
Why JSON-LD, Not CSS Selectors
Most scraping tutorials tell you to inspect elements, grab CSS classes, and parse the DOM. On Ceneo, that's a trap. Their frontend markup changes regularly, class names are semi-randomized, and you'll spend more time fixing broken selectors than doing anything useful.
Instead, Ceneo embeds structured data (JSON-LD) in every product page. This is the data they serve to Google -- they won't break it without breaking their SEO. It looks like this:
{
"@type": "Product",
"name": "Samsung Galaxy S25 Ultra 12/256GB Titanium Black",
"offers": {
"@type": "AggregateOffer",
"lowPrice": 2799.0,
"highPrice": 5499.0,
"offerCount": 18,
"priceCurrency": "PLN"
}
}
You get: product name, lowest price, highest price, number of offers, and currency. No CSS parsing required. This data is machine-readable by design -- Google demands it.
The 2-Request Pipeline
Most products have an EAN (barcode number). Here's the full pipeline -- from EAN to price data in exactly two HTTP requests.
Request 1: EAN to Product ID
import httpx
from selectolax.parser import HTMLParser
def ean_to_pid(ean: str, client: httpx.Client) -> str | None:
"""Search Ceneo by EAN, return the product ID."""
resp = client.get(
"https://www.ceneo.pl/Szukaj.htm",
params={"q": ean}
)
resp.raise_for_status()
# Check for direct redirect (exact match)
if resp.url.path.strip("/").isdigit():
return resp.url.path.strip("/")
# Parse search results -- product IDs are in data-pid attributes
tree = HTMLParser(resp.text)
node = tree.css_first("[data-pid]")
return node.attributes.get("data-pid") if node else None
When Ceneo has an exact EAN match, it often redirects you straight to the product page -- check the URL first. Otherwise, the search results page contains data-pid attributes on product cards.
Request 2: Product ID to Price Data
import json
def get_price(pid: str, client: httpx.Client) -> dict | None:
"""Fetch product page, extract JSON-LD price data."""
resp = client.get(f"https://www.ceneo.pl/{pid}")
resp.raise_for_status()
tree = HTMLParser(resp.text)
for script in tree.css('script[type="application/ld+json"]'):
data = json.loads(script.text())
if data.get("@type") == "Product":
offers = data.get("offers", {})
return {
"name": data.get("name"),
"low_price": offers.get("lowPrice"),
"high_price": offers.get("highPrice"),
"offer_count": offers.get("offerCount"),
"currency": offers.get("priceCurrency"),
"pid": pid,
}
return None
Putting It Together
def check_price(ean: str) -> dict | None:
"""Full pipeline: EAN to product ID to price data."""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
"Accept-Language": "pl-PL,pl;q=0.9",
}
with httpx.Client(headers=headers, follow_redirects=True) as client:
pid = ean_to_pid(ean, client)
if not pid:
return None
return get_price(pid, client)
# Example
result = check_price("8806095372563") # Samsung Galaxy S25 Ultra
if result:
print(f"{result['name']}: {result['low_price']} {result['currency']}")
print(f" {result['offer_count']} offers on Ceneo")
Output:
Samsung Galaxy S25 Ultra 12/256GB Titanium Black: 2799.0 PLN
18 offers on Ceneo
Two requests. No browser automation. No Selenium. No Playwright. Just plain HTTP.
Handling Edge Cases
Real-world EANs throw curveballs. Some things to handle:
Multiple results for one EAN. Ceneo sometimes has duplicate product pages for the same physical product (different color variants, bundle vs standalone, etc.). The first data-pid result is usually the canonical one, but verify by checking offerCount -- the page with more offers is typically the right one.
Missing JSON-LD. Some product pages (especially older or niche ones) don't have JSON-LD at all. You'll need a CSS fallback for those. Keep it minimal:
def get_price_fallback(pid: str, client: httpx.Client) -> dict | None:
"""Fallback: extract price from HTML when JSON-LD is missing."""
resp = client.get(f"https://www.ceneo.pl/{pid}")
tree = HTMLParser(resp.text)
price_el = tree.css_first(".product-offer__price .value")
if not price_el:
return None
price_text = price_el.text(strip=True).replace(",", ".").replace(" ", "")
return {
"low_price": float(price_text),
"pid": pid,
"source": "html_fallback",
}
Redirect loops. Some EANs trigger Ceneo's "did you mean?" page instead of a search results page. Handle this by checking if the response contains data-pid elements -- if not, the EAN wasn't recognized.
Encoding. Polish product names contain diacritics (ą, ę, ś, ź, etc.). httpx handles this correctly if you let it detect the encoding from headers. Don't manually set encoding='utf-8' unless you're sure.
Scaling Up: What You'll Hit
This works perfectly for a few hundred products. At scale, you'll run into issues:
Rate limiting. Ceneo doesn't use Cloudflare or aggressive bot detection (as of March 2026), but hammering it with 1000 requests/minute from one IP will get you blocked. Space your requests -- 1-2 seconds between them is enough for casual use.
IP rotation. For serious monitoring (10K+ products daily), you need rotating proxies. I use ScrapeOps for this -- their proxy aggregator handles rotation and retry logic, and you just change your base URL:
SCRAPEOPS_API_KEY = "your-key"
def get_with_proxy(url: str) -> httpx.Response:
return httpx.get(
"https://proxy.scrapeops.io/v1/",
params={
"api_key": SCRAPEOPS_API_KEY,
"url": url,
"country": "pl",
},
timeout=30,
)
The country=pl parameter matters -- Ceneo serves different content based on geo.
Name-based search. Not every product has an EAN. For name searches, results are fuzzier. If you're storing products in Postgres, pg_trgm with trigram similarity matching works well for reconciling Ceneo results with your product catalog:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products
USING gin (name gin_trgm_ops);
-- Find best match
SELECT name, similarity(name, 'Samsung Galaxy S25 Ultra') AS sim
FROM products
WHERE name % 'Samsung Galaxy S25 Ultra'
ORDER BY sim DESC
LIMIT 1;
What Doesn't Work
Scraping individual offer prices. The JSON-LD gives you the aggregate (lowest/highest), but getting each shop's individual price requires parsing the offer list from HTML. This markup changes more frequently and is harder to maintain.
Historical price charts. Ceneo has price history graphs, but they're rendered client-side from an internal API. You could reverse-engineer the endpoints, but they're not documented and they change.
Logged-in features. Product alerts, wishlists, etc. require session management and CAPTCHA handling. Not worth it -- build your own alert system on top of the price data instead.
Architecture for Daily Monitoring
For a practical price monitoring setup:
- Product catalog -- Postgres table with EAN, name, your current price
- Scraper job -- Cron/scheduled task running the 2-request pipeline for each product
-
Price history -- Append-only table:
(product_id, ceneo_low, ceneo_high, offer_count, checked_at) - Alerts -- Simple query: "where my price > ceneo_low * 1.1" means you're 10%+ above market
The whole thing runs on a single VPS. No Kubernetes, no message queues, no Lambda functions. A loop with time.sleep(2) between requests, a Postgres database, and a cron job.
Here's the price history schema:
CREATE TABLE price_checks (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES products(id),
ceneo_pid TEXT,
low_price NUMERIC(10,2),
high_price NUMERIC(10,2),
offer_count INTEGER,
checked_at TIMESTAMPTZ DEFAULT NOW()
);
-- Useful index for "show me price trends for product X"
CREATE INDEX idx_price_checks_product_time
ON price_checks (product_id, checked_at DESC);
Running the scraper once or twice daily is enough for most use cases. Prices on Ceneo don't change by the minute -- most shops update once a day. A twice-daily check catches 95% of price movements and keeps your request volume low.
Performance Numbers
From testing on a single VPS (2 vCPU, 2GB RAM) with a 2-second delay between requests:
- Throughput: ~1,700 products per hour (2 requests each, 2s delay)
- Daily capacity: ~40K products in a 24-hour window
- Memory usage: Under 50MB (httpx + selectolax are lightweight)
- Success rate: ~97% of EANs resolve to a valid product page
- JSON-LD availability: ~92% of product pages have structured data
The 3% failure rate is mostly discontinued products or EANs that Ceneo doesn't index. The 8% without JSON-LD are older product pages -- the HTML fallback handles those.
Legal Note
Ceneo's data is publicly accessible. The JSON-LD structured data is explicitly published for machine consumption (that's its entire purpose -- search engine indexing). Scraping public data for price comparison is standard industry practice in the EU. That said, respect robots.txt, don't overload their servers, and don't republish their content verbatim.
The full pipeline -- EAN lookup, JSON-LD parsing, and Postgres storage -- is about 150 lines of Python. No frameworks, no dependencies beyond httpx and selectolax. Sometimes the simplest approach is the most reliable one.
Top comments (0)