# Spanish Pharmacy Price Comparison Scraper (EAN-13 by Barcode) (`steadfetch/farmacias-es-comparator`) Actor

Compare EAN-13 codes across 7 online pharmacies serving Spain: price, availability, brand, SKU and URL per store (Farmaciasdirect, Farmaline, Farmaten, El Negrito, Cuesta La Villa, Vistabella, Farmacia Principal). For price tracking and market research. Sitemap-only; no login, no CAPTCHA bypass.

- **URL**: https://apify.com/steadfetch/farmacias-es-comparator.md
- **Developed by:** [Steadfetch](https://apify.com/steadfetch) (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 $2.50 / 1,000 comparison row delivereds

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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Spanish Pharmacy Price Comparison Scraper (EAN-13 by Barcode)

Compare a list of EAN-13 codes across Spanish online pharmacies and get one row per **(EAN, store)** with price, stock, brand, SKU and the public product URL. v3 roster: **Farmaciasdirect**, **Farmaline**, **Farmaten**, **El Negrito**, **Cuesta La Villa**, **Vistabella** and **Farmacia Principal**. HTTP + JSON-LD only: no browser, no login, no proxy and no CAPTCHA bypass.

### What it does

The Actor resolves each requested EAN against the public catalogue of every selected store:

- **Discovery:** reads each store's `robots.txt` and uses **only the sitemaps declared there**. Farmaciasdirect exposes `/sitemaps/products-index.xml` (8 product shards), Farmaline `/sitemap/sitemap.xml` (31 Dutch product sitemaps), Farmaten `/1_index_sitemap.xml` (the Spanish child is the only one used), the three Shopify storefronts expose `/sitemap/sitemap_web.xml` directly, and Farmacia Principal exposes a PrestaShop `lgsitemaps` index whose product child is the only one used. No on-site search route, no category crawling, no undocumented endpoint.
- **Extraction:** fetches a product page and reads its schema.org `Product` JSON-LD: `gtin13` -> `ean`, `name`, `sku`, `brand.name`, `offers.price`, `offers.priceCurrency` and `offers.availability`. Pages without a valid EAN, name or positive price are skipped, never invented. EAN is mandatory: a store that does not publish a 13-digit EAN or GTIN stays out of the roster.
- **Comparison:** writes one row per (EAN, store). If the same EAN appears twice on a page or an EAN is repeated in the input, it is written once.
- **Schema validation:** every row is validated against `.actor/dataset_schema.json` before reaching the dataset. With `strictSchema: true` (the default) an invalid row fails the run instead of publishing malformed data.
- **Politeness:** concurrency is always 1, every request waits at least `delayMs` (default 1,500 ms) and a custom Steadfetch User-Agent identifies the Actor. A 401/403/451 or a WAF/CAPTCHA challenge page stops the run with a clear error; nothing is evaded.
- **Credit guard:** `maxScanPages` caps how many product pages a run may fetch per store. Product sitemaps can hold tens of thousands of URLs, so a cold lookup of an old product may need a larger budget than the default.

#### v3 roster

| Store | Id | Discovery | Verified on 2026-10-21 |
| --- | --- | --- | --- |
| Farmaciasdirect (ES) | `farmaciasdirect` | `robots.txt` -> `/sitemaps/products-index.xml` -> 8 product shards | robots 200; product page with schema.org `Product` (`gtin13`, `price`, `availability`); legal notice has zero robot/spider/crawler/scraping clauses |
| Farmaline (BE, Dutch) | `farmaline` | `robots.txt` -> `/sitemap/sitemap.xml` -> `products-*-nl-*.xml` | robots 200 (only admin/cart/account and search routes disallowed); `Product` + `Offer` JSON-LD with price, availability and `gtin13`; terms have zero robot/spider/crawler/scraping clauses |
| Farmaten (ES) | `farmaten` | `robots.txt` -> `/1_index_sitemap.xml` -> `1_es_0_sitemap.xml` | robots 200; 12/12 sampled product pages publish `gtin13` in `Product` JSON-LD; terms and purchase conditions have zero robot/spider/crawler/scraping clauses |
| Farmacia El Negrito (ES) | `negrito` | `robots.txt` -> `/sitemap/sitemap_web.xml` (6,981 product URLs) | robots 200; 9/12 sampled product pages publish `gtin13` in `Product` JSON-LD; terms have zero robot/spider/crawler/scraping clauses |
| Farmacia Cuesta La Villa (ES) | `cuestavilla` | `robots.txt` -> `/sitemap/sitemap_web.xml` (2,063 product URLs) | robots 200; 9/12 sampled product pages publish `gtin13` in `Product` JSON-LD; terms have zero robot/spider/crawler/scraping clauses |
| Farmacia Vistabella (ES) | `vistabella` | `robots.txt` -> `/sitemap/sitemap_web.xml` (1,323 product URLs) | robots 200; 12/12 sampled product pages publish `gtin13` in `Product` JSON-LD; terms have zero robot/spider/crawler/scraping clauses |
| Farmacia Principal (ES) | `farmaprincipal` | `robots.txt` -> `/module/lgsitemaps/sitemap?name=sitemap_1` -> `product_1_1` (9,180 product URLs) | robots 200 (product pages and the declared sitemap allowed); 7/7 sampled product pages publish schema.org `Product` with 13-digit `gtin13`, price and availability; legal notice and terms have zero robot/spider/crawler/scraping clauses |

A store enters only if it publishes a real 13-digit EAN or GTIN. Pages that do not publish one are skipped; nothing is invented.

#### Evaluated for v3 and left out

These sources were checked with plain HTTP on 2026-10-22 and are **not** requested at runtime:

- **farmaciaetayo.com** (`etayo`) - out: the declared sitemap is a single urlset with 1,794 URLs (products and categories) and every sampled product page publishes a `Product` JSON-LD node, but it carries `sku` only: 0 of 6 sampled product pages publish a 13-digit EAN/GTIN in the JSON-LD or anywhere in the HTML (`gtin` and `ean` appear zero times). Fails the mandatory-EAN gate, so it is not scraped even though discovery and terms would allow it.
- **farmaciamartinezsegura.es** (`martinez_segura`) - out: `robots.txt` declares no `Sitemap:` line, product discovery would require crawling the `/es/2-inicio` category, and the sampled product page publishes no `Product` JSON-LD and no 13-digit EAN/GTIN (the donated code itself documents that this PrestaShop installation has no EAN available). Fails the mandatory-EAN gate and needs no proxy.

These v2 sources were checked with plain HTTP on 2026-10-21 and are **not** requested at runtime:

- **farmaciasantacruz.es** (`santa_cruz`) - out: 0 of 12 sampled product pages publish a 13-digit EAN/GTIN (JSON-LD only carries `sku`/`mpn`). Fails the EAN gate.
- **farmaciaevoracentro.es** (`evora`) - out: 0 of 10 sampled product pages publish a 13-digit EAN/GTIN. The donated code used the WooCommerce product id as "ean", which is not a barcode and is not accepted.
- **farmaciacanariasonline.com** (`canariasonline`) - out: the sitemap declared in `robots.txt` (113 URLs) contains no product URLs, only categories, blog and information pages, and sampled product pages publish no 13-digit EAN/GTIN in JSON-LD (checked 2026-10-21). Both discovery and the mandatory-EAN gate fail, so the source stays out.
- **farmaciaelcardonal.es** (`cardonal`) - out: product pages publish no EAN/GTIN (only the PrestaShop product id) and category discovery by numeric id returned 404. Fails the EAN gate and the discovery gate.

#### Stores deliberately out of scope

These sources are **not** part of the Actor and are not requested at runtime:

- **farmaciabarata.es** - out under company rule 3 (legal risk). Its legal notice states: *"usted se obliga a indemnizar a la EMPRESA frente a cualesquiera daños ... que se deriven del uso por su parte de "robots", "spiders", "crawlers" o herramientas similares empleadas con el fin de recabar o extraer datos"* (`/content/aviso-legal.html`).
- **Dosfarma, Promofarma, Mifarma/Atida** - out: anti-bot/CAPTCHA or HTTP 405; Steadfetch never evades anti-bot protection.
- **Dr. Max** - out: `drmax.es` does not resolve (NXDOMAIN).
- **Other shops from the donated `reference/farmacias/` folder** - not included until their robots.txt and terms are verified in writing. This Actor does not build or publish that reference folder.

### Example output

```json
[
  {
    "ean": "4015110035732",
    "store": "farmaciasdirect",
    "storeProductUrl": "https://www.farmaciasdirect.es/products/esquiladora-wahl-admire",
    "url": "https://www.farmaciasdirect.es/products/esquiladora-wahl-admire",
    "name": "Esquiladora Wahl Admire",
    "sku": "WA4015110035732",
    "brand": "WAHL",
    "price": 128.9,
    "currency": "EUR",
    "availability": "OutOfStock",
    "scrapedAt": "2026-10-20T23:55:00.000Z"
  },
  {
    "ean": "4031683919460",
    "store": "farmaline",
    "storeProductUrl": "https://www.farmaline.be/nl/artikel/4805261/erbe-klappfeile-lila.htm",
    "url": "https://www.farmaline.be/nl/artikel/4805261/erbe-klappfeile-lila.htm",
    "name": "ERBE Klappfeile lila",
    "sku": "04805261",
    "brand": null,
    "price": 30.99,
    "currency": "EUR",
    "availability": "InStock",
    "scrapedAt": "2026-10-20T23:55:00.000Z"
  }
]
```

`sku` and `brand` are `null` when the page does not publish them; the Actor never invents a value. `url` is the common Steadfetch envelope and is the same public URL as `storeProductUrl`. `availability` uses the schema.org short form (`InStock`, `OutOfStock`, ...); a value outside that list is reported as `Unknown`.

### Input

Minimal run:

```json
{
    "eans": ["4015110035732"],
    "maxResults": 10
}
```

One store only, with a wider scan budget for an older product:

```json
{
    "eans": ["4015110035732", "4031683919460"],
    "stores": ["farmaciasdirect", "vistabella"],
    "maxResults": 100,
    "delayMs": 1500,
    "maxScanPages": 5000
}
```

| Field | Type | Description |
| --- | --- | --- |
| `eans` | array of strings (required) | Non-empty list of 13-digit EAN codes. EAN-13 only; free-text product names are not supported yet. |
| `stores` | array of strings | Subset of `["farmaciasdirect", "farmaline", "farmaten", "negrito", "cuestavilla", "vistabella", "farmaprincipal"]`. Omit it or send an empty list to use the full v3 roster. |
| `maxResults` | integer | Maximum rows (one per EAN and store) saved to the dataset. Default 500, maximum 5,000. |
| `delayMs` | integer | Minimum delay between HTTP requests. Default and minimum 1,000 ms; default 1,500 ms. Concurrency is always 1. |
| `maxScanPages` | integer | Credit guard: maximum product pages fetched per store while looking for the EANs. Default 2,000, maximum 200,000. |
| `strictSchema` | boolean | Default `true`; fail the run on schema violations. |

#### Why a scan budget?

The spec requires discovery only through the sitemaps declared in `robots.txt`. Those sitemaps list the whole catalogue (Farmaciasdirect: 8 shards x 10,000 URLs; Farmaline: 31 product sitemaps), and they do not include the EAN. The Actor therefore opens product pages in sitemap order and stops as soon as every requested EAN is found or `maxScanPages` is reached. For a large batch of EANs, send them in **one** run: the same page scan serves every EAN in the list, and only matching rows are pushed and charged.

### Project structure

```
.actor/actor.json              Actor definition (payPerEvent, dataset schema, output schema)
.actor/input_schema.json       INPUT options (eans, stores, maxResults, delayMs, maxScanPages)
.actor/dataset_schema.json     Item JSON Schema (additionalProperties: false) + table view
.actor/output_schema.json      Output schema: links the default dataset as the results output
.actor/pay_per_event.json      PPE pricing (two synthetic platform events)
src/stores.ts                  v3 roster: origins, robots URLs, declared sitemap URLs and URL patterns
src/sitemaps.ts                Pure robots.txt and sitemap parsing
src/discovery.ts               robots.txt -> declared sitemaps -> product URLs
src/extract.ts                 Pure schema.org JSON-LD extraction and normalization
src/http.ts                    Throttled HTTP client with retries and block detection
src/input.ts                   Input validation and normalization
src/compare.ts                 Framework-free orchestration (also used by the offline demo)
src/main.ts                    Apify entry point: run, validate, push
src/demo.ts                    Offline fixture run (no network, no token, no credit)
src/validate-output.ts         Ajv validator shared by the run and the tests
test/                          Real saved pages/sitemaps and Vitest suites
```

### Pay-per-event pricing

The Actor uses Apify **pay-per-event** pricing with the two synthetic platform events:

| Event | Charged when | Price |
| --- | --- | --- |
| `apify-actor-start` | a run starts (synthetic, automatic) | 0.00005 USD per run (Apify-recommended default; includes 5 seconds of free compute) |
| `apify-default-dataset-item` | a validated (EAN, store) row is saved to the dataset (synthetic, automatic) | 0.0025 USD per row = **2.50 USD per 1,000 rows** |

There is no custom per-item event and no `Actor.charge()` call: the platform already charges `apify-default-dataset-item` per pushed row, so another charge would bill users twice. EANs with no result, 404s, retries and errors are never pushed, so they are never charged.

#### Reference and margin

- Direct single-store competitors on the Apify Store price around **2.00 USD per 1,000 results** (six of eight rivals checked on 2026-10-20). Promofarma is the high outlier at 20.00 USD per 1,000 and is deliberately not imitated.
- This comparator charges **2.50 USD per 1,000 rows**, a small premium for comparing several stores per EAN. It sits inside Steadfetch's 1-10 USD band.
- Estimated cost: **0.01-0.05 USD per 1,000 delivered rows** for HTTP + schema.org extraction (profile A: no proxy, no browser). The dominant cost is the catalogue scan needed to find an EAN; `maxScanPages` bounds it and the run log reports the pages fetched. The platform figure has not been measured yet (no platform run has been spent on this Actor); it will be published here once the first minimal run is done.

### Development

```bash
npm install
npm test                 # lint + typecheck + offline Vitest suites (no credit)
npm run publish:check    # Steadfetch publish gate
npm run schemas:validate # apify-cli schema validation
npm run demo:fixtures    # full discovery + extraction against saved real pages (no network)
```

The default test suite runs against committed real pages and sitemap excerpts and never touches the network or the Apify platform. It covers robots.txt/sitemap parsing, the declared-sitemap gate, JSON-LD extraction for every store, throttling and retries, CAPTCHA/403 stop behaviour, input validation and an end-to-end comparison that validates the OUTPUT schema.

### FAQ

**Does the Actor need a login, API key or paid subscription?**
No. Only public product pages are fetched, with no login and no platform key.

**Which stores are supported?**
Farmaciasdirect, Farmaline, Farmaten, El Negrito, Cuesta La Villa, Vistabella and Farmacia Principal in v3. Stores that forbid scraping, use anti-bot/CAPTCHA, publish no EAN or are pending legal verification are documented above and are not requested at runtime.

**Why does an EAN sometimes come back empty?**
The product sitemaps do not expose the EAN, so the Actor must open product pages in sitemap order. If the EAN is beyond `maxScanPages`, the run reports it as not found and does not charge for it. Raise `maxScanPages` (and keep `delayMs` at 1,500 ms or higher) or batch more EANs into one run.

**When am I charged?**
Once per run start (`apify-actor-start`) and once per validated row saved to the dataset (`apify-default-dataset-item`). EANs without a result do not generate a row and are not charged.

**Is the output schema stable?**
Yes. New fields are added additively, `required` fields never change, and `additionalProperties` is `false` so consumers can rely on the exact shape documented above.

**What about personal data?**
Nothing in this Actor targets people. It only reads product pages: EAN, product name, brand, SKU, price, currency, availability and product URL. No reviews, authors, sellers or contact data are collected.

**Can I request a new store?**
Yes. Open an issue in this repository with the public store URL. A store only enters after its robots.txt and terms of use are verified and documented; anti-bot/CAPTCHA sources are rejected.

### Maintenance

This Actor is **tested daily**: automated runs keep the sources reachable and the output schema valid. Failures open an issue in this repository. Schema changes are announced through repository releases.

### Legal and attribution

Product data is published by each store. Prices are public price information that distance-selling pharmacies must publish (RD 870/2013, Spain). The Actor follows each store's `robots.txt`, sends an identifying User-Agent, keeps concurrency at 1 and honours a delay of at least one second. It does not bypass logins, paywalls, CAPTCHAs or WAF challenges; if a store blocks a request, the run stops and reports it. The Actor code itself is Steadfetch proprietary (UNLICENSED).

# Actor input Schema

## `eans` (type: `array`):

Non-empty list of 13-digit EAN codes to compare. v1 accepts EAN-13 only; free-text product names are not supported yet.

## `stores` (type: `array`):

Subset of the v3 roster: farmaciasdirect, farmaline, farmaten, negrito, cuestavilla, vistabella, farmaprincipal. Omit it to compare against every supported store.

## `maxResults` (type: `integer`):

Maximum number of rows (one per EAN and store) to save to the dataset. Default 500, maximum 5000.

## `delayMs` (type: `integer`):

Minimum delay between HTTP requests. Concurrency is always 1. The default and minimum is 1000 ms; keep it at 1500 ms or higher to be a good citizen.

## `maxScanPages` (type: `integer`):

Credit guard: maximum number of product pages fetched per store while looking for the requested EANs. Product sitemaps can hold tens of thousands of URLs, so a very old or absent EAN may need a larger budget. Default 2000.

## `strictSchema` (type: `boolean`):

Fail the run when a row does not match the OUTPUT schema. Keep enabled: Steadfetch never publishes Actors without output-schema validation.

## Actor input object example

```json
{
  "eans": [
    "4015110035732"
  ],
  "stores": [
    "farmaciasdirect"
  ],
  "maxResults": 10,
  "delayMs": 1000,
  "maxScanPages": 5,
  "strictSchema": true
}
```

# Actor output Schema

## `results` (type: `string`):

Dataset items validated against the item schema in dataset_schema.json (fields).

# 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 = {
    "eans": [
        "4015110035732"
    ],
    "stores": [
        "farmaciasdirect"
    ],
    "maxResults": 10,
    "delayMs": 1000,
    "maxScanPages": 5,
    "strictSchema": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadfetch/farmacias-es-comparator").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 = {
    "eans": ["4015110035732"],
    "stores": ["farmaciasdirect"],
    "maxResults": 10,
    "delayMs": 1000,
    "maxScanPages": 5,
    "strictSchema": True,
}

# Run the Actor and wait for it to finish
run = client.actor("steadfetch/farmacias-es-comparator").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "eans": [
    "4015110035732"
  ],
  "stores": [
    "farmaciasdirect"
  ],
  "maxResults": 10,
  "delayMs": 1000,
  "maxScanPages": 5,
  "strictSchema": true
}' |
apify call steadfetch/farmacias-es-comparator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadfetch/farmacias-es-comparator"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Tgz7N5IRzFPuqbZ8m/builds/3lfFVhrNJCfc39BMZ/openapi.json
