# OBI.de Scraper - Prices, Offers & Reviews (`abotapi/obi-de-scraper`) Actor

Scrape OBI (obi.de) DIY/home-improvement products: current + strike-through original price with discount, per-unit pricing (per m2/kg), specials/offers listings, brand, EAN, multi-seller stock, tech specs, and full reviews with rating breakdown. Search or paste links.

- **URL**: https://apify.com/abotapi/obi-de-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** E-commerce, Automation, Developer tools
- **Features:** Limited permissions
- **Stats:** 4 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 product results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## OBI Scraper: Prices, Specials, Stock & Reviews

OBI Scraper turns obi.de, the German DIY, home improvement and garden retailer, into structured product data. Search by keyword or category slug, narrow to the site's own currently discounted specials, or paste product and listing links directly. Every record from search, category or listing pages carries the current price, the strike-through original price with a computed discount when a product is marked down, per-unit pricing per square metre or per kilogram where the site shows it, and an online availability status, plus on request the full breadcrumb category path, technical specifications, media gallery, EAN and multi-seller pricing, and a complete customer review history with a full rating breakdown. A pasted single product link reads name, brand, description, images, category and reviews only, since it never visits a listing page. Export to JSON, CSV or Excel, pull results through the API, or send them into apps you already use.

### Why This Scraper?

- **Was-price and discount, structured, not read off a badge.** When a product carries a reduced or recommended-retail original price, both prices are captured plus the computed discount amount and percentage.
- **Real per-unit pricing.** Building-material products such as tiles, flooring and timber return their per-square-metre or per-kilogram price separately from the pack or sale-unit price, exactly as the site distinguishes the two.
- **Specials as a first-class filter.** A "Specials / offers only" toggle narrows any search or category listing to only products currently showing a reduced price. OBI has no separate "on sale" category route, so this filters the site's own live discount signal instead of guessing at a category that does not exist.
- **Full review history.** Overall rating, review count, a full 1 to 5 star rating breakdown, and every individual review with author, date, rating, title and body text.
- **Rich detail on demand.** Full breadcrumb category path, flattened technical specifications that vary per product category and are never hardcoded, media gallery, EAN and multi-seller pricing, stock and shipping.
- **Two ways in.** Keyword or category search with filters for brand, rating, price range and specials plus a sort order (the site ignores sort parameters, so sorting is applied by the actor to the fetched result set), or paste any product or listing link and let pagination continue forward on its own.
- **Optional export to your apps.** Send results into Notion, Linear, Airtable or any Apify MCP connector alongside the dataset.

### Use Cases

- **Price trackers and comparison sites:** monitor OBI's current and strike-through prices, discount percentages and per-unit pricing across chosen categories.
- **DIY retail and category managers:** watch how an assortment's pricing, ratings and stock status move over a search or category on a recurring schedule.
- **Consumer and product researchers:** collect full review text, rating breakdowns and technical specifications for comparison or sentiment studies.
- **Marketing and promotions teams:** track which products currently carry a reduced price with the specials filter, to plan campaigns around active discount windows.
- **Data teams building historical DIY pricing datasets:** run Incremental mode on a schedule to build a change log of new, updated and no-longer-listed products over time.

### Data You Get

> Sample shape: values below are illustrative placeholders, not from a live product.

| Field | Example |
| --- | --- |
| `productId` / `sku` | `"3345566"` |
| `name` | `"Voltago Akku-Laubbläser LB-320"` |
| `brand` | `"Voltago"` |
| `category` / `categoryPath` | `"Gartengeräte"`, `[{"name": "Garten", "url": "..."}, {"name": "Gartengeräte", "url": "..."}]` |
| `url` | `"https://www.obi.de/p/3345566/voltago-akku-laubblaeser-lb-320"` |
| `price` / `currency` | `79.99`, `"EUR"` |
| `originalPrice` / `originalPriceType` / `discountAmount` / `discountPercent` | `99.99`, `"STRIKE_PRICE"`, `20.00`, `20.0` |
| `unitPrice` / `unitPriceUnit` | `14.50`, `"m²"` |
| `isOnSpecial` | `true` |
| `rating` / `reviewCount` | `4.6`, `34` |
| `onlineAvailable` / `onlineAvailabilityStatus` | `true`, `"BESTELLBAR"` |
| `inStock` | `true` (same signal as `onlineAvailable`: whether the product can currently be ordered online) |
| `scrapedAt` | `"2026-11-04T10:15:00Z"` (when this run read the record) |
| `storeAvailabilityStatus` | `"KEIN_MARKT_AUSGEWAEHLT"` (the actor never picks a store, so this field usually just says none is selected) |
| `image` / `images` | `"https://www.obi.de/sample/image.png"`, `["https://www.obi.de/sample/image.png"]` |
| `ean` / `eans` | `"4001234567890"`, `["4001234567890"]` |
| `description` | full product description text |
| `technicalData` | `{"Weight": "2.4 kg", "Battery type": "Li-Ion", "Voltage": "18 V"}` (flattened per category) |
| `media` | `[{"type": "image", "url": "...", "thumbnail": "..."}]` |
| `variants` | `[]` (populated when the product has size or colour options) |
| `sellers` | `[{"sellerName": "Sample Seller", "price": 79.99, "shippingCost": 4.95, "stock": 12}]` |
| `manufacturerName` / `manufacturerCountry` / `taxPercent` | `"Sample Manufacturer GmbH"`, `"DE"`, `19` |
| `reviews.averageRating` / `reviews.totalReviewCount` | `4.6`, `34` |
| `reviews.ratingDistribution` | `[{"rating": 5, "count": 22}, {"rating": 4, "count": 8}, {"rating": 3, "count": 2}]` |
| `reviews.items[]` | `[{"rating": 5, "title": "Works well in the garden", "body": "Sample review text.", "author": "Sample Buyer", "date": "2026-03-14T10:22:00.000+00:00", "isRecommended": true, "verifiedPurchaser": true, "helpfulVotes": 3, "unhelpfulVotes": 0, "replies": []}]` |

OBI does not expose a separate "discontinued" flag. `onlineAvailable` / `onlineAvailabilityStatus` describe whether a still-listed product can currently be ordered online, passed straight through from the site. `storeAvailabilityStatus` is the site's own per-store status code, but this actor never selects a specific store, so it typically just reports that no store is selected rather than real local stock. A product that disappears from a tracked search or category altogether is what Incremental mode's `EXPIRED` classification is designed to catch, and only once "Emit expired products" is turned on (see How to Use below).

### How to Use

1. Pick a **mode**: `search` (a keyword or a real category slug, with real site filters) or `url` (paste product or listing links).
2. Fill in the search term or the URLs, then narrow with the specials, brand, rating and price filters if you want a subset.
3. Turn on **Fetch full product detail** and/or **Fetch reviews** for the extra fields you want, then set **Max products total** and **Max pages per search** to bound the run.
4. Start the run, then download the dataset as JSON, CSV or Excel, or read it through the API.

**Keyword search, first page, with reviews (the review toggle default):**

```json
{
  "mode": "search",
  "searchTerm": "akku ventilator",
  "maxItems": 20
}
```

**Category browse, filtered to a brand and only currently-discounted items:**

```json
{
  "mode": "search",
  "searchTerm": "werkzeuge",
  "brands": ["Bosch"],
  "specialsOnly": true,
  "sortBy": "PRICE_ASC",
  "maxItems": 50,
  "maxPages": 5
}
```

**Supported URL shapes** (url mode with the `urls` field left untouched now scrapes one example of each shape below, instead of one sample search; clear the field and paste your own links to scrape only those):

| Shape | Example |
|---|---|
| Keyword search listing | `https://www.obi.de/search/akku%20ventilator/` |
| Paginated search (starts at `?page=N`; a partial scope, so it never produces `EXPIRED`) | `https://www.obi.de/search/werkzeuge/?page=3` |
| Category slug | `https://www.obi.de/search/werkzeuge/` |
| Specials listing | `https://www.obi.de/search/aktion/` |
| Sorted query (the site ignores `?sort=`; the actor logs a warning and orders within each fetched page only) | `https://www.obi.de/search/akku%20ventilator/?sort=price_asc` |
| Product detail | `https://www.obi.de/p/6929863/einhell-werkzeugkoffer-universal-80tlg-e-case` |
| Product by article number only | `https://www.obi.de/p/6929863` |
| Search without trailing slash | `https://www.obi.de/search/akku%20ventilator` |
| Apex host | `https://obi.de/search/akku%20ventilator/` |
| Brand shop (mapped to that brand's search listing) | `https://www.obi.de/marken/eufy` |

**Paste a single product link (full detail is always included; reviews on request):**

```json
{
  "mode": "url",
  "urls": ["https://www.obi.de/p/1409713/einhell-akku-ventilator-gc-cf-18-110-li-solo"],
  "fetchReviews": true,
  "maxReviewsPerProduct": 0
}
```

**Paste a listing link and keep paginating forward:**

```json
{
  "mode": "url",
  "urls": ["https://www.obi.de/search/schrauben/"],
  "maxItems": 100,
  "maxPages": 10
}
```

#### Run it from your code

Python:

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("abotapi/obi-de-scraper").call(run_input={"mode": "search", "searchTerm": "akku ventilator", "maxItems": 20})
for product in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(product["name"], product["price"], product["currency"])
```

JavaScript:

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

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('abotapi/obi-de-scraper').call({ mode: 'search', searchTerm: 'akku ventilator', maxItems: 20 });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Or connect it to Make, Zapier, n8n, Google Sheets or webhooks from the **Integrations** tab.

#### A note on pasted single product links

A bare product link has no search-result card to read anything from, so this actor always reads the full product detail page for a pasted product link, regardless of the **Fetch full product detail** toggle. That toggle only controls whether search and category results also get that same detail fetch. Reviews still follow the **Fetch reviews** toggle in every mode. A pasted product record never carries `price`, `currency`, `originalPrice`, `originalPriceType`, `discountAmount`, `discountPercent`, `isOnSpecial`, `unitPrice`, `onlineAvailable`, `onlineAvailabilityStatus`, `storeAvailabilityStatus`, `image` or `images`, since none of that comes from the product detail page this actor reads for it; it does still carry `categoryPath`, `description`, `technicalData`, `media`, `variants`, `sellers`, `ean`, `eans`, `manufacturerName`, `manufacturerCountry`, `taxPercent`, `rating`, `reviewCount` and `reviews`. `specialsOnly`, `minPrice` and `maxPrice` will always filter a pasted product out if set, since it has no price or discount data to check against.

#### Resume and recurring updates

- **Resume** (`resumeFromRunId`) continues one interrupted or oversized run: paste its run or dataset ID and this run skips every product already collected there, returning only the remaining new products.
- **Incremental mode** (`incrementalMode`) is for a schedule, for example daily, against the same search: the actor remembers the previous run itself, so you never paste a run ID. The first run returns everything as `NEW`. Later runs classify each product as `NEW`, `UPDATED` (with `changedFields`), `UNCHANGED` (suppressed and not billed unless `emitUnchanged` is on), `REAPPEARED` or `EXPIRED`. State is isolated per search term or URL, mode, filter and detail setting automatically; set `stateKey` to name or deliberately share a monitoring campaign. The nested review list itself is excluded from change detection, but the top-level `rating` and `reviewCount` fields are not, so a new review that moves either of those still turns the product `UPDATED` on the next run. What is compared: every returned field except the nested `reviews` block and the `scrapedAt` timestamp, which are always excluded (they stay in the returned row). Use `ignoreFieldsForChanges` to exclude more fields (a dotted path such as `images.url` drops a nested key), or set `changeScope` to `priceAndStock` so only price, original price, discount, special flag and availability decide `UPDATED`. `compactUpdates` turns `UPDATED` and `EXPIRED` rows into slim rows (ids, url, name, current price and availability, `priceChanges`); a compact row carries no reviews or detail fields, so it does not trigger the detail-enrichment charge. Switching `changeScope` or `ignoreFieldsForChanges` with the automatic state key starts a fresh baseline (every product `NEW` once); set `stateKey` to keep the current one.
- `EXPIRED` products (present before, no longer found) are only produced once `emitExpired` is on **and** a run completes a full, uncapped scan of the tracked search, never on a run cut short by Resume or by Max products total. `expireAfterMissedScans` (default 1) delays the mark until a product was missing from that many consecutive complete scans. `EXPIRED` means the product dropped out of the listing; it does not prove it was delisted, so read `onlineAvailable` for availability. A pasted listing URL that starts at `?page=N` with N above 1 never walked the earlier pages, so it never produces `EXPIRED`. Turning `emitExpired` on for the first time is also what starts marking a missing product as gone in the stored state.
- `REAPPEARED` can only ever follow, on a later run, for a product that was previously marked gone that way. With `emitExpired` left off, which is the default, nothing is ever marked gone, so `REAPPEARED` never fires either.

#### Dataset views

The dataset has four views in the Output tab: **Products** (the main listing), **Reviews** (one row per review, with author, rating, date, text, helpful and unhelpful votes and any seller replies), **Product detail** (the fetchDetails fields) and **Changes** (the incremental `changeType`, `changedFields` and `priceChanges`). Each review also carries `helpfulVotes`, `unhelpfulVotes` and `replies`, as shown in the Data You Get table.

### Send results into your apps (MCP connectors)

Optionally pipe results into the apps you already use through Model Context Protocol (MCP) connectors. Authorize a connector once under Apify, Settings, Integrations, then select it in the `mcpConnectors` field. Each connector receives a condensed, human-readable summary per product (a title plus key fields), while the complete record always stays in the Apify dataset. For Notion, set `notionParentPageUrl` to the page the item pages should be created under, and use `maxNotifyListings` to cap how many items are exported per connector per run. Supported connectors: Notion, Linear, Airtable and Apify. Leave the field empty to skip; it never changes the dataset output.

### Input Parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | string | `search` | `search` finds products by keyword, category or specials; `url` scrapes pasted product, search or category links. |
| `searchTerm` | string | (prefill `akku ventilator`) | Keyword or a real OBI category slug, used in search mode only. With no keyword and no specials filter, a search-mode run searches the example keyword `akku ventilator` so a default run still returns rows (logged at the start of the run, and never used for EXPIRED detection or when you set your own State key). Specials only with no keyword browses the site's specials listing. |
| `urls` | array | (one example of each supported URL shape) | Product, listing or brand shop URLs to scrape, used in url mode only. Pagination continues forward from any `?page=` already in the link. Leaving the field untouched scrapes one example of each shape (see the table above). |
| `specialsOnly` | boolean | `false` | Keep only currently-discounted products. Applies to search mode and a pasted listing/category link; a pasted single product link never carries discount data, so it is always filtered out when this is on. |
| `brands` | array | (none) | Exact brand names to keep. Applies to both search and url mode. |
| `minRating` | integer | (none) | 1 to 5, minimum rating to keep. Applies to both search and url mode. |
| `minPrice` | number | (none) | Minimum price in EUR to keep. Applies to search mode and a pasted listing/category link; a pasted single product link has no price to filter on, so it is always filtered out when this is set. |
| `maxPrice` | number | (none) | Maximum price in EUR to keep. Applies to search mode and a pasted listing/category link; a pasted single product link has no price to filter on, so it is always filtered out when this is set. |
| `sortBy` | string | `RELEVANCE` | `RELEVANCE`, `PRICE_ASC`, `PRICE_DESC` or `RATING`, applied to the fetched result set. |
| `fetchDetails` | boolean | `false` | Adds breadcrumb category path, description, flattened technical specifications, media gallery, EAN and multi-seller pricing and stock from the product detail page, for search and listing results. A pasted single product link always includes this detail regardless of this toggle. |
| `fetchReviews` | boolean | `true` | Fetches each product's full review history: overall rating, review count, a full rating breakdown, and per-review author, date, rating, title and body text. |
| `maxReviewsPerProduct` | integer | `20` | Cap on reviews fetched per product when Fetch reviews is on. `0` means all available reviews, capped at 5000 per product. |
| `maxPages` | integer | (none; hard limit 1000) | Cap on result pages per search, category or URL entry. Leave empty for the built-in hard limit of 1000 pages per entry; the run also stops at Max products total. |
| `maxItems` | integer | `20` | Hard cap on total products returned across every search, category or URL entry. `0` means the maximum of 100000, still bounded by Max pages per search. |
| `resumeFromRunId` | string | (none) | Paste a previous run ID or dataset ID to continue one interrupted or oversized crawl without returning or charging for products already collected there. |
| `incrementalMode` | boolean | `false` | Turn on for recurring monitoring of the same search. Later runs return only new and changed products. |
| `stateKey` | string | (none) | Optional name for an incremental-mode monitoring campaign; derived automatically from the search and filters when left empty. |
| `emitUnchanged` | boolean | `false` | Incremental mode only: also return, and bill for, products unchanged since the last run. |
| `emitExpired` | boolean | `false` | Incremental mode only: also return, and bill for, products no longer found, once a run fully scans the tracked search. |
| `changeScope` | string | `all` | Incremental mode only: `priceAndStock` makes only price, discount, special flag and availability decide `UPDATED`. |
| `compactUpdates` | boolean | `false` | Incremental mode only: `UPDATED` and `EXPIRED` rows become slim (ids, current price and availability, `priceChanges`). |
| `expireAfterMissedScans` | integer | `1` | Incremental mode only: consecutive complete scans a product must be missing before it is marked `EXPIRED`. |
| `ignoreFieldsForChanges` | array | (none) | Incremental mode only: field names or dotted paths that never decide `UPDATED`. Reviews and `scrapedAt` are always ignored. |
| `proxy` | object | `{"useApifyProxy": true}` | Connection configuration for the run. |
| `mcpConnectors` | array | (none) | Optional MCP connectors to export results into (Notion, Linear, Airtable, Apify). |
| `notionParentPageUrl` | string | (none) | Notion connector only: page under which item pages are created. |
| `maxNotifyListings` | integer | `50` | Cap on items exported to each connector per run. Does not affect the dataset. |

### Output Example

> Sample shape: values below are illustrative placeholders, not from a live product.

```json
{
  "productId": "3345566",
  "sku": "3345566",
  "name": "Voltago Akku-Laubbläser LB-320",
  "brand": "Voltago",
  "category": "Gartengeräte",
  "url": "https://www.obi.de/p/3345566/voltago-akku-laubblaeser-lb-320",
  "price": 79.99,
  "currency": "EUR",
  "originalPrice": 99.99,
  "originalPriceType": "STRIKE_PRICE",
  "discountAmount": 20.00,
  "discountPercent": 20.0,
  "isOnSpecial": true,
  "unitPrice": null,
  "unitPriceUnit": null,
  "rating": 4.6,
  "reviewCount": 34,
  "onlineAvailable": true,
  "onlineAvailabilityStatus": "BESTELLBAR",
  "storeAvailabilityStatus": "KEIN_MARKT_AUSGEWAEHLT",
  "image": "https://www.obi.de/sample/image.png",
  "reviews": {
    "averageRating": 4.6,
    "totalReviewCount": 34,
    "ratingDistribution": [
      { "rating": 5, "count": 22 },
      { "rating": 4, "count": 8 },
      { "rating": 3, "count": 2 }
    ],
    "items": [
      {
        "rating": 5,
        "title": "Works well in the garden",
        "body": "Sample review text.",
        "author": "Sample Buyer",
        "date": "2026-03-14T10:22:00.000+00:00",
        "isRecommended": true,
        "verifiedPurchaser": true
      }
    ]
  }
}
```

When `fetchDetails` is on for search or category results, the record also carries `categoryPath`, `description`, `technicalData`, `media`, `variants`, `sellers`, `ean`, `eans`, `manufacturerName`, `manufacturerCountry` and `taxPercent`. A pasted single product link (always detail-fetched) carries those same fields but never `price`, `currency`, `originalPrice`, discount fields, `isOnSpecial`, `unitPrice`, `onlineAvailable`, either availability status, `image` or `images`, since none of that lives on the product detail page; see "A note on pasted single product links" above. When `incrementalMode` is on, every returned record also carries `changeType` (`NEW`, `UPDATED`, `UNCHANGED`, `REAPPEARED` or `EXPIRED`), `changedFields`, `firstSeenAt` and `lastSeenAt`.

### Plan Requirement

The default connection (Apify datacenter proxy, country not pinned) loaded listing pages, the product-detail data and the review data in testing, and obi.de shows no challenge page, so no residential proxy is needed and the default works without extra configuration on a plan that includes proxy access. Apify free plans do not include proxy access, so on a free plan the run uses a backup connection instead; if the default connection is refused the run says so in the log: "The selected connection is unavailable to this run. Apify free plans do not include residential proxies. Continuing on a backup connection; some pages may still be unavailable." and, once a page is served that way, "Served over the backup connection (the default connection was unavailable)." A residential proxy under Proxy & connection is optional and only worth turning on for very large monitoring campaigns. If you supply your own proxy URLs, no backup connection is used.

A run that could not load any usable page ends as FAILED with a status message, never as a successful empty run. The three messages are: no proxy access for the account ("The proxy connection was refused for this account, so no page could be read. Your Apify plan does not include proxy access; a paid plan is required"), backup connection also failed ("No results: residential proxies are unavailable on this run (Apify free plans do not include them), and the backup connection could not load the requested OBI pages"), and an ordinary connection failure ("Could not load any results page (the requests failed), so nothing was collected. This is a connection problem, not an empty search. Please try again later.").

### FAQ

#### How much does it cost?

Three events are charged, and the Pricing tab shows the current rates. (1) `apify-default-dataset-item`: once per product row pushed to the dataset. (2) `detail-enrichment`: a surcharge for each row where full detail and/or the review history was actually fetched, charged after the row is in the dataset: one unit per started block of 10 reviews returned, with the product read folded into the first block (minimum 1 unit per enriched row): 8 reviews bill 1 unit, 11 bill 2, 25 bill 3. A row whose reads all failed, an `UNCHANGED` row that is suppressed, and a compact `UPDATED` or `EXPIRED` row carry no surcharge. (3) `apify-actor-start`: once per run. A pasted single product link always fetches full detail, so it always carries the surcharge when the fetch succeeds. Fetch reviews is on by default, so the default input includes the surcharge; turn it off for listing-only output. Use Max products total to cap the cost of any run.

#### Is it legal to scrape OBI?

This actor collects only publicly available product and review information from obi.de pages already visible to a shopper. You are responsible for how you use the data: follow OBI's terms and the laws that apply to you, and get legal advice before any commercial redistribution. Review text in particular may carry rights held by its authors.

#### Can I get only new or changed products on a schedule?

Yes. Schedule the actor from the Schedules tab and turn on Incremental mode. Later runs then return only new, updated and reappeared products for the same tracked search, and unchanged ones are suppressed and not billed unless Emit unchanged is on.

#### Does Incremental mode report REAPPEARED or EXPIRED products by default?

No. EXPIRED products are only produced once Emit expired is turned on and a run completes a full, uncapped scan of the tracked search, never on a run cut short by Resume or by Max products total. Turning Emit expired on is also what first lets the stored state mark a missing product as gone; REAPPEARED can then only follow, on a later run, for a product that was marked gone that way and is found again. With Emit expired left off, which is the default, nothing is ever marked gone, so REAPPEARED never fires either.

#### Why did my run fail instead of returning an empty dataset?

If no page can be loaded at all (for example the proxy is refused for the account, or every request fails), the run ends as FAILED with one of the status messages quoted under Plan Requirement, so a connection problem is never mistaken for an empty search. A search that loads normally but matches nothing finishes successfully with no rows. If a run loads some pages and then meets refusals, it stops retrying and keeps what was already collected. A pasted single product link whose detail page is refused still pushes a stub row (`productId`, `sku`, `url` only), unless `specialsOnly`, `minPrice` or `maxPrice` excludes it first. An unreadable Resume run or dataset ID, or Incremental mode and Resume together against a search that already has tracked state, also stop the run with a clear failure message. So do URL mode with no URLs, search mode with pasted URLs but no keyword (the URLs are only used in URL mode), and Incremental mode with your own State key but no keyword, specials filter or URLs; nothing is collected or charged in those cases.

#### Can I use it with AI agents or MCP?

Yes. Call it from any Apify integration or MCP client, and use the connector field to push a summary of each result into Notion, Linear or Airtable.

### 🔗 Want more e-commerce data?

Pair this actor with these related scrapers from the same team:

<table>
<tr><td>🛒 <a href="https://apify.com/abotapi/hornbach-de-scraper"><b>HORNBACH Products, Prices &amp; Reviews Scraper</b></a><br>Scrape HORNBACH (hornbach.de) DIY, building &amp; garden products: current + strike-through...</td><td>🛒 <a href="https://apify.com/abotapi/dm-de-scraper"><b>dm.de</b></a><br>Scrape dm-drogerie markt (dm.de) products: current + strike-through Ausverkauf price with...</td></tr>
<tr><td>🛒 <a href="https://apify.com/abotapi/rossmann-de-scraper"><b>ROSSMANN.de Scraper</b></a><br>Scrape ROSSMANN (rossmann.de) drugstore products: current + original strike-through price...</td><td>🥦 <a href="https://apify.com/abotapi/kaufland-de-scraper"><b>Kaufland.de Scraper</b></a><br>Scrape Kaufland.de, Germany's hypermarket and online marketplace: keyword/category/brand...</td></tr>
<tr><td>🛒 <a href="https://apify.com/abotapi/mediamarkt-de-scraper"><b>MediaMarkt Germany Scraper</b></a><br>Scrape MediaMarkt.de products by keyword or URL. Extract current and original prices...</td><td>🛒 <a href="https://apify.com/abotapi/thalia-de-scraper"><b>Thalia.de Scraper</b></a><br>Scrape Thalia.de books, eBooks, audiobooks, toys and stationery. Search by keyword or...</td></tr>
</table>

👉 [Browse all abotapi scrapers](https://apify.com/abotapi)

### 💬 Support

- 🐞 **Found a bug or a missing field?** Open a ticket on the [Issues tab](https://apify.com/abotapi/obi-de-scraper/issues/open). We usually reply within hours.
- 🛠️ **Need another site, extra fields or a private build?** Email <contact@abotapi.com>.
- ⭐ **Enjoying it?** A quick review on the actor page helps other users find it.

# Actor input Schema

## `mode` (type: `string`):

'search' finds products by keyword, category, and/or the site's own currently-discounted specials, with real site filters. 'url' scrapes any product, search-result, or category page URL you paste, walking pagination forward automatically.

## `searchTerm` (type: `string`):

Free-text keyword (e.g. 'akku ventilator') or a known OBI category slug (e.g. 'werkzeuge', 'garten', 'bad', 'kueche') to browse a whole listing. Leave empty and turn on Specials / offers only to browse current offers. With no keyword and no specials filter, the actor searches the example keyword 'akku ventilator' so a default run still returns rows.

## `urls` (type: `array`):

Paste OBI product pages (.../p/<articleNumber>\[/<slug>]), search/category/specials listing pages (.../search/<term>/, with or without trailing slash, with or without ?page=), or brand shop pages (.../marken/<brand>, mapped to the brand listing). Pagination continues forward from any ?page= already in the URL. The site ignores ?sort=: the actor warns and orders within each fetched page only. The default list holds one example per distinct target; the prefill additionally shows the redirect and sort variants, which resolve to the same targets.

## `specialsOnly` (type: `boolean`):

OBI has no separate 'on sale' category route -- it marks currently-reduced products with a strike-through original price inline in every listing. Turn this on to keep only products that currently show a reduced price and discount (narrows the same search/category listing, verified live on 'aktion').

## `brands` (type: `array`):

Only return products from these brands, e.g. 'Bosch', 'Einhell', 'Makita'. Match the exact brand name as shown on the site. Leave empty for all brands.

## `minRating` (type: `integer`):

Only return products rated at or above this many stars, 1-5.

## `minPrice` (type: `number`):

Only return products priced at or above this amount, in euros.

## `maxPrice` (type: `number`):

Only return products priced at or below this amount, in euros.

## `sortBy` (type: `string`):

Order of the returned results (applied to the fetched result set; for a fully sorted view across a large catalog, combine with a larger Max products total).

## `fetchDetails` (type: `boolean`):

Adds the full breadcrumb category path, description, technical data/specifications (flattened), media gallery, variants, EAN, multi-seller pricing/stock, and manufacturer info from the product detail page. Current price, was-price/discount, unit price and rating are already included without this toggle.

## `fetchReviews` (type: `boolean`):

Fetch each product's full review history: overall rating, review count, a full 1-5 rating breakdown, and per-review author/date/rating/title/body. Products with no reviews simply return an empty list. The detail-enrichment charge is one unit per started block of 10 reviews returned, with the product read folded into the first block (minimum 1 unit per enriched row; see the Pricing tab).

## `maxReviewsPerProduct` (type: `integer`):

Cap on reviews fetched per product when 'Fetch reviews' is on. 0 = all available reviews, up to 5000 per product.

## `maxPages` (type: `integer`):

Stop after this many result pages per search/category/URL entry. Leave empty for the built-in hard limit of 1000 pages per entry; the run also stops at Max products total.

## `maxItems` (type: `integer`):

Hard cap on total products returned across every search/category/URL entry. 0 = the maximum of 100000 (still bounded by Max pages per search).

## `proxy` (type: `object`):

The default connection works on every Apify plan, including the free tier. A residential connection is optional and only needed for very large or sustained runs.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue a large crawl of products without returning or charging for products already collected there. Use this after an interrupted run, or when continuing a product catalogue pull in another run. For recurring daily monitoring of the same search, use Incremental mode below instead.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or recurring monitoring. The first run returns all matching products as NEW. Later runs normally return only NEW, UPDATED, and REAPPEARED products. UPDATED means a compared field changed: price, original price, discount, special flag, availability, rating, review count, name, brand, category, images, detail fields and so on. Reviews (the nested reviews block) and the scrapedAt timestamp are never compared, so a new review never creates a billable row; they stay in every returned row. Use "Ignore fields when detecting changes" and "Change scope" below to narrow what counts. Turn on "Emit unchanged" or "Emit expired" only when you also want those products returned (and billed). State is kept separately for each search term/URL and mode/filter/detail setup; use State key when you want to name or deliberately share a monitoring campaign. To continue one specific interrupted run instead, use Resume from a previous run above.

## `stateKey` (type: `string`):

Optional. Name this monitoring campaign to keep its state stable, or to deliberately share state across differently-configured runs. Leave empty to let the actor derive a key automatically from the search term/URLs and filter settings -- different searches then never mix state with each other.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return products that have not changed since the last run, marked UNCHANGED. This returns -- and bills -- extra rows you already have, so leave it off unless you specifically want the full snapshot every run.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return products that were present in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked search -- not when Max products capped it or when Resume was used. This returns -- and bills -- extra synthetic rows, so leave it off unless you need expiry tracking. EXPIRED means the product was not found on a complete scan; it does not prove the product was delisted.

## `changeScope` (type: `string`):

All (default): any compared field that changes makes a product UPDATED. Price and stock only: only price, original price, discount, special flag and availability decide UPDATED; name, images, category and other metadata never create a row. Changing this with the automatic state key starts a fresh baseline (everything NEW once); set a State key to keep the current baseline.

## `compactUpdates` (type: `boolean`):

Off by default. When on, UPDATED and EXPIRED rows are slim: ids, url, name, current price and availability plus priceChanges (old and new value per moved field). NEW rows stay full. A compact row carries no reviews or detail fields, so no detail-enrichment charge is made for it.

## `expireAfterMissedScans` (type: `integer`):

With Emit expired on, a product is marked EXPIRED only after it was missing from this many consecutive complete scans (default 1). EXPIRED means the product dropped out of the listing; it does not prove it was delisted or is out of stock.

## `ignoreFieldsForChanges` (type: `array`):

Field names that never decide UPDATED (they stay in every returned row). A dotted path such as images.url or reviews.items drops that key inside nested values. Reviews and scrapedAt are always ignored. Example: rating, reviewCount.

## `mcpConnectors` (type: `array`):

Optionally send the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize a connector once under Apify -> Settings -> Integrations, then select it here. The connector receives a condensed, human-readable summary per item (title + key fields), not the full JSON; the complete record stays in the dataset. Leave empty to skip. Supported: Notion (https://mcp.notion.com/mcp), Linear (https://mcp.linear.app/sse), Airtable (https://mcp.airtable.com/mcp), Apify (https://mcp.apify.com).

## `notionParentPageUrl` (type: `string`):

URL (or id) of the Notion page under which item pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on items written to each connector per run. Does not affect the dataset.

## Actor input object example

```json
{
  "mode": "search",
  "searchTerm": "akku ventilator",
  "urls": [
    "https://www.obi.de/search/akku%20ventilator/",
    "https://www.obi.de/search/werkzeuge/?page=3",
    "https://www.obi.de/search/werkzeuge/",
    "https://www.obi.de/search/aktion/",
    "https://www.obi.de/search/akku%20ventilator/?sort=price_asc",
    "https://www.obi.de/p/6929863/einhell-werkzeugkoffer-universal-80tlg-e-case",
    "https://www.obi.de/p/6929863",
    "https://www.obi.de/search/akku%20ventilator",
    "https://obi.de/search/akku%20ventilator/",
    "https://www.obi.de/marken/eufy"
  ],
  "specialsOnly": false,
  "brands": [],
  "sortBy": "RELEVANCE",
  "fetchDetails": false,
  "fetchReviews": true,
  "maxReviewsPerProduct": 20,
  "maxItems": 20,
  "proxy": {
    "useApifyProxy": true
  },
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "changeScope": "all",
  "compactUpdates": false,
  "expireAfterMissedScans": 1,
  "maxNotifyListings": 50
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

## `reviews` (type: `string`):

No description

## `detail` (type: `string`):

No description

## `changes` (type: `string`):

No description

# 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 = {
    "mode": "search",
    "searchTerm": "akku ventilator",
    "urls": [
        "https://www.obi.de/search/akku%20ventilator/",
        "https://www.obi.de/search/werkzeuge/?page=3",
        "https://www.obi.de/search/werkzeuge/",
        "https://www.obi.de/search/aktion/",
        "https://www.obi.de/search/akku%20ventilator/?sort=price_asc",
        "https://www.obi.de/p/6929863/einhell-werkzeugkoffer-universal-80tlg-e-case",
        "https://www.obi.de/p/6929863",
        "https://www.obi.de/search/akku%20ventilator",
        "https://obi.de/search/akku%20ventilator/",
        "https://www.obi.de/marken/eufy"
    ],
    "brands": [],
    "proxy": {
        "useApifyProxy": true
    },
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "compactUpdates": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/obi-de-scraper").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 = {
    "mode": "search",
    "searchTerm": "akku ventilator",
    "urls": [
        "https://www.obi.de/search/akku%20ventilator/",
        "https://www.obi.de/search/werkzeuge/?page=3",
        "https://www.obi.de/search/werkzeuge/",
        "https://www.obi.de/search/aktion/",
        "https://www.obi.de/search/akku%20ventilator/?sort=price_asc",
        "https://www.obi.de/p/6929863/einhell-werkzeugkoffer-universal-80tlg-e-case",
        "https://www.obi.de/p/6929863",
        "https://www.obi.de/search/akku%20ventilator",
        "https://obi.de/search/akku%20ventilator/",
        "https://www.obi.de/marken/eufy",
    ],
    "brands": [],
    "proxy": { "useApifyProxy": True },
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "compactUpdates": False,
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/obi-de-scraper").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 '{
  "mode": "search",
  "searchTerm": "akku ventilator",
  "urls": [
    "https://www.obi.de/search/akku%20ventilator/",
    "https://www.obi.de/search/werkzeuge/?page=3",
    "https://www.obi.de/search/werkzeuge/",
    "https://www.obi.de/search/aktion/",
    "https://www.obi.de/search/akku%20ventilator/?sort=price_asc",
    "https://www.obi.de/p/6929863/einhell-werkzeugkoffer-universal-80tlg-e-case",
    "https://www.obi.de/p/6929863",
    "https://www.obi.de/search/akku%20ventilator",
    "https://obi.de/search/akku%20ventilator/",
    "https://www.obi.de/marken/eufy"
  ],
  "brands": [],
  "proxy": {
    "useApifyProxy": true
  },
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "compactUpdates": false
}' |
apify call abotapi/obi-de-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/obi-de-scraper"
        }
    }
}
```

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/Pk81tZ7fcR0bzfJJL/builds/nVaOHoMOG8gIeB8K5/openapi.json
