# Product Distribution Checker — EAN Coverage EU Sites (`studio-amba/product-distribution-checker`) Actor

Give a brand's EAN/product list and target countries. Runs our Geizhals and Skroutz price-comparison scrapers (Ceneo, Prisjakt, Idealo, Heureka optional), returns every matched listing per EAN per country, plus an explicit absence row when nothing matched.

- **URL**: https://apify.com/studio-amba/product-distribution-checker.md
- **Developed by:** [Studio Amba](https://apify.com/studio-amba) (community)
- **Categories:** E-commerce, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 sku-country checks

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Product Distribution Checker — EAN Coverage Across EU Price Sites

Give this actor a brand's **EAN/product list** and the **countries** you care about. It runs our own European price-comparison scrapers in parallel and tells you, per EAN per country: is this product listed, where, and at what price — and just as importantly, **where it is confirmed NOT listed**.

Channel managers, distributors, and brand protection teams pay agencies for exactly this check. This actor runs it on demand or on a schedule instead.

### What it does

- **Runs our own price-comparison scrapers.** Geizhals (AT/DE) and Skroutz (GR) by default — the two engines cloud-verified working end-to-end on a plain residential proxy. Ceneo (PL), Prisjakt (SE), Idealo (DE) and Heureka (CZ) are all available opt-in; see the reliability table below before adding them.
- **Matches each engine's result back to your product.** EAN/barcode match when an engine exposes one (none currently do — see Honest Limitations), fuzzy product-name match otherwise.
- **Returns every matching listing**, not just the cheapest one — if an engine's search turns up three variants of your product (different colors, bundle sizes), you get all three rows.
- **Emits an explicit absence row when a country was actually checked and nothing matched.** `present: false` is not a missing row, it's a returned row — the proof that the engine ran, searched, and found nothing for your EAN in that market.
- **Never fakes an absence from a failed check.** If the underlying engine's own run failed or timed out, no row is emitted for that product/country/engine at all — a real infrastructure failure and a confirmed "not listed" are different facts, and this actor keeps them separate rather than silently treating a broken check as a distribution gap.

### How to scrape product distribution data across Europe

1. Enter your **products** — each one needs an `ean` (barcode) and a `productName` (used as the search query, since none of the bundled engines support searching by EAN directly). `brand` is optional and carried through to the output.
2. Pick your **countries** — `AT`, `DE`, `GR` are covered by the cloud-verified healthy default engines. Add `PL`, `SE`, `CZ` and opt in the matching engine via `engines` if you need attempted coverage there.
3. Run it. Each row is one product/country/engine check: a matched listing (`present: true`, with price and URL) or a confirmed gap (`present: false`).
4. Group the output by `ean` + `country` downstream to build a coverage matrix — which markets carry the SKU, which don't, and at what price where it does.
5. Schedule it to catch new listings or new gaps as resellers add or drop a SKU.

### Input

| Field | Type | Description |
|---|---|---|
| `products` | array | `{ ean, productName, brand? }` objects to check. Defaults to a demo Sony product. |
| `countries` | array | ISO country codes to check. Default `["AT", "DE", "GR"]` (the cloud-verified healthy default). |
| `engines` | array | Opt-in engines beyond the default: `ceneo`, `prisjakt`, `idealo`, `heureka`. See reliability table. |
| `matchThreshold` | number | Minimum fuzzy name-similarity (0-1) to count as a match. Default `0.35`. |
| `maxItemsPerSource` | integer | Search results pulled per engine per product before matching. Default `8`. |
| `timeoutPerSourceSecs` | integer | Per-engine call timeout before treating that check as failed. Default `150`. |
| `proxyConfiguration` | object | Overrides the built-in per-engine RESIDENTIAL default. |

### Output fields

| Field | Type | Description |
|---|---|---|
| `ean` | string | Product EAN/barcode from your input |
| `productName` | string | Product name from your input |
| `brand` | string | null | Brand from your input, if provided |
| `country` | string | ISO country code this row checks |
| `engine` | string | `geizhals`, `skroutz`, `ceneo`, `prisjakt`, `idealo`, or `heureka` |
| `sellerName` | string | The comparison site itself (see Honest Limitations — not a named reseller) |
| `price` | number | null | Listed price in the local currency; `null` on an absence row |
| `currency` | string | null | Local market currency, populated even on an absence row |
| `url` | string | Link to the matched listing; empty string on an absence row |
| `present` | boolean | `true` = matched listing found. `false` = engine checked, nothing matched |
| `matchType` | string | null | `ean`, `name`, or `null` on an absence row |
| `matchScore` | number | null | 0-1 fuzzy match confidence; `1` for an EAN match; `null` on absence |
| `offerCount` | number | null | Resellers the engine aggregated this price from, where exposed |
| `scrapedAt` | string | ISO timestamp of the run |

### Example output

Present row:

```json
{
  "ean": "4548736112212",
  "productName": "Sony WH-1000XM5",
  "brand": "Sony",
  "country": "AT",
  "engine": "geizhals",
  "sellerName": "Geizhals",
  "price": 189,
  "currency": "EUR",
  "url": "https://geizhals.at/sony-wh-1000xm5-v101824.html",
  "present": true,
  "matchType": "name",
  "matchScore": 1,
  "offerCount": 80,
  "scrapedAt": "2026-07-20T08:35:35.179Z"
}
```

Confirmed-absence row (cloud-verified — a genuinely nonexistent SKU checked against Geizhals AT/DE):

```json
{
  "ean": "9999999999999",
  "productName": "Zorvexnaught Widget XQ7000-Nonexistent",
  "brand": "Zorvexnaught",
  "country": "AT",
  "engine": "geizhals",
  "sellerName": "Geizhals",
  "price": null,
  "currency": "EUR",
  "url": "",
  "present": false,
  "matchType": null,
  "matchScore": null,
  "offerCount": null,
  "scrapedAt": "2026-07-20T08:39:42.770Z"
}
```

### Honest limitation: no per-seller data

None of the bundled engines expose a per-seller/per-merchant breakdown — each returns a **single aggregate "lowest price found"** per matched product, plus (on some) a count of how many resellers that price was drawn from. There is no seller name, no per-offer list.

So `sellerName` is honestly **the comparison engine itself** (e.g. "Geizhals"), not a named reseller. `offerCount` tells you how many resellers that price was pulled from, where the engine exposes it. Reseller-level attribution would require scrapers built against individual retailer sites, not price-comparison aggregators — a different, much larger build.

### Honest limitation: EAN matching is architecturally ready, not yet live

Matching checks an exact EAN first, then falls back to fuzzy name matching. As of this actor's cloud verification (2026-07-20), **none of the 6 bundled engines return an EAN/barcode field in their own output**, so every match today is a `name` match — never hidden, always visible in `matchType`. If a child scraper adds EAN extraction later, this actor picks it up automatically with no code change on this end.

### Engines and reliability (method disclosure)

Cloud-verified 2026-07-20 — every engine below was actually run with a real query ("iphone 15") right before this actor shipped, not just read from its source code.

| Engine | Market | Currency | Method | Reliability | Notes |
|---|---|---|---|---|---|
| **Geizhals** | AT/DE | EUR | Residential proxy + parsing | High | Cloud-verified: real products, real prices, real matches. Default. |
| **Skroutz** | GR | EUR | Residential proxy + parsing | High | Cloud-verified: real products, real prices (4 iPhone 15 variants returned in the live test). Default. |
| **Idealo** | DE | EUR | Bright Data Scraping Browser | Broken | Live test hit "A global adaptive rate limit has been applied" then an anti-bot block — 0 products. Opt-in. |
| **Ceneo** | PL | PLN | Residential proxy + parsing | Broken | Live test TIMED OUT. Opt-in. |
| **Heureka** | CZ | CZK | Residential proxy + parsing | Broken | Live test returned only nav/category chrome ("Speciální nabídky", "Elektronika", "Letní akce") — a parser bug on the child, not real product data. Opt-in. |
| **Prisjakt** | SE | SEK | Residential proxy + parsing | Blocked | Live test returned 0 items — Cloudflare "Just a moment..." challenge even through a residential Swedish IP. Opt-in. |

All engines get a RESIDENTIAL proxy scoped to their market by default. The actor degrades gracefully: if an engine's run fails, the run continues with the others and no row is emitted for that failed check (see "failed vs. absent" below) — it never gets silently counted as a confirmed gap.

### Failed check vs. confirmed absence — why this matters

This is the core design decision in this actor, and it's worth being explicit about:

- **Confirmed absence (`present: false`, row emitted):** the child engine's run SUCCEEDED and returned data, but nothing in it matched your EAN/product. The check happened; the answer is "not found here."
- **Failed check (no row emitted at all):** the child engine's run errored, timed out, or hit its own zero-results guard and failed itself. We do not know whether the product is listed or not — we just couldn't check. Emitting a `present: false` row here would be a false negative dressed up as data.

Cloud-verified case (2026-07-20): checking a nonexistent product against Geizhals (AT/DE) and Skroutz (GR) — Geizhals's run succeeded, returned 5 unrelated products, none matched, so 2 confirmed-absence rows were pushed (AT, DE). Skroutz's own run failed with its own zero-results guard ("Zero results scraped... Check your search query"), so **zero rows were pushed for GR** — not a false absence claim. Check the run summary's `checksFailed` count if you need to know which product/engine pairs couldn't be checked.

A run with zero present rows because nothing matched is a **success**, not a failure — see the run summary. The actor only calls `Actor.fail()` when every single engine call across the whole run failed, meaning nothing could be checked at all.

### Documented limits

- **Query is by product name, not EAN.** None of the bundled engines support searching by barcode directly.
- **Fuzzy name matching can miss or mismatch.** A generic product name risks matching the wrong SKU or matching too many variants. Use a specific model name and tune `matchThreshold` as needed.
- **Country coverage is limited to what the default 2 engines serve** (AT, DE, GR) unless you opt in the broken/blocked engines and accept a real chance of failed checks until they're healed.
- **This is presence/absence on comparison ENGINES, not exhaustive retail coverage.** A product genuinely sold direct-to-consumer only, or through retailers this actor's 6 engines don't aggregate, will show as absent even if it's sold somewhere.

### Who uses this

- **Brand channel managers** verifying which markets and resellers actually carry a SKU before a launch or a distribution audit.
- **Distributors** proving coverage gaps to a brand, or proving a claimed distributor relationship is actually live in-market.
- **Pricing teams** building a coverage-by-country matrix without manually searching six sites per SKU.

### Pricing

Pay per run start and per row returned — present or absent, each row is one sku-country check, and a confirmed gap is exactly as much of a deliverable as a confirmed listing. See the pricing tab for current rates.

### FAQ

**Does an absence row mean the product definitely isn't sold in that country?**
It means the engines this actor checks didn't have a matching listing at the time of the run. See "Documented limits" — this is engine-level coverage, not exhaustive retail coverage.

**Why isn't every country in my request necessarily covered?**
Only Geizhals (AT/DE) and Skroutz (GR) are cloud-verified healthy by default. If you request PL/SE/CZ without opting in the matching engine, the run summary lists them as `countriesUnsupported` rather than silently returning nothing or faking an absence.

**Do I need a proxy?**
No — every engine defaults to a RESIDENTIAL proxy scoped to its market. Supply your own `proxyConfiguration` only if you want to override that.

**Is this legal?**
The actor collects publicly advertised listings from public comparison sites. You are responsible for how you use the output.

# Actor input Schema

## `products` (type: `array`):

List of products to check distribution for. Each product needs an ean (barcode) and a productName (used as the search query on every price-comparison engine, since none of the 6 engines support searching by EAN directly). brand is optional and passed through to the output for grouping.

## `countries` (type: `array`):

ISO country codes to check distribution in. AT/DE (Geizhals) and GR (Skroutz) are covered by the two engines that are cloud-verified healthy on a plain residential proxy. PL (Ceneo), SE (Prisjakt) and CZ (Heureka) are only checked if you also opt in that engine via `engines` below — otherwise they're silently skipped and listed as unsupported in the run summary, never silently faked as absent.

## `engines` (type: `array`):

Geizhals and Skroutz always run when their country is requested — they're cloud-verified working. Add Ceneo, Prisjakt, Idealo or Heureka here to attempt checking PL/SE/DE/CZ through them too, accepting a real chance the check comes back FAILED (no row) until that child is healed. See README for the reliability table.

## `matchThreshold` (type: `number`):

Minimum fuzzy name-similarity score (0-1) required to count an engine result as a match for your product, when no EAN match is available (which is every match today — see README). Lower catches more listings but risks matching the wrong SKU.

## `maxItemsPerSource` (type: `integer`):

Cap on search results pulled from each engine per product before matching.

## `timeoutPerSourceSecs` (type: `integer`):

How long to wait for each engine's child-actor run before treating that check as FAILED (no row emitted — see README on the difference between a failed check and a confirmed absence).

## `proxyConfiguration` (type: `object`):

Passed through to the child price-comparison scrapers, overriding the actor's own per-engine default (a RESIDENTIAL proxy scoped to each engine's market — all 6 engines return 403/empty/blocked on a plain or datacenter proxy). Leave empty to use the built-in per-engine residential defaults.

## Actor input object example

```json
{
  "products": [
    {
      "ean": "4548736112212",
      "productName": "Sony WH-1000XM5",
      "brand": "Sony"
    }
  ],
  "countries": [
    "AT",
    "DE",
    "GR"
  ],
  "engines": [],
  "matchThreshold": 0.35,
  "maxItemsPerSource": 8,
  "timeoutPerSourceSecs": 150,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "products": [
        {
            "ean": "4548736112212",
            "productName": "Sony WH-1000XM5",
            "brand": "Sony"
        }
    ],
    "countries": [
        "AT",
        "DE",
        "GR"
    ],
    "engines": [],
    "matchThreshold": 0.35,
    "maxItemsPerSource": 8,
    "timeoutPerSourceSecs": 150,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("studio-amba/product-distribution-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "products": [{
            "ean": "4548736112212",
            "productName": "Sony WH-1000XM5",
            "brand": "Sony",
        }],
    "countries": [
        "AT",
        "DE",
        "GR",
    ],
    "engines": [],
    "matchThreshold": 0.35,
    "maxItemsPerSource": 8,
    "timeoutPerSourceSecs": 150,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("studio-amba/product-distribution-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "products": [
    {
      "ean": "4548736112212",
      "productName": "Sony WH-1000XM5",
      "brand": "Sony"
    }
  ],
  "countries": [
    "AT",
    "DE",
    "GR"
  ],
  "engines": [],
  "matchThreshold": 0.35,
  "maxItemsPerSource": 8,
  "timeoutPerSourceSecs": 150,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call studio-amba/product-distribution-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=studio-amba/product-distribution-checker",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/952pdzpkCUy4PgHFX/builds/UMbwfAKWume3dVbJG/openapi.json
