# Harvey Norman Scraper - Products, Prices, Specs & Reviews (`abotapi/harvey-norman-scraper`) Actor

Scrape Harvey Norman Australia products: name, brand, price, was-price / discount, specifications, image gallery, GTIN, department, stock, dimensions, rating and customer reviews. Search by keyword with brand, price and rating filters, or paste product / listing URLs.

- **URL**: https://apify.com/abotapi/harvey-norman-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** E-commerce, Automation, Developer tools
- **Features:** Limited permissions
- **Stats:** 5 total users, 0 monthly users, 90.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

## Harvey Norman Australia Scraper: Products, Prices & Reviews

Harvey Norman Australia Scraper turns harveynorman.com.au into a clean product data API. Get canonical title, brand, current and was-price, discount amount and percent, specifications, image gallery, GTIN, department, stock status, dimensions, rating and customer reviews for electrical, computer, furniture and bedding products. Search by keyword with filters, scrape the Super Deals or Clearance collections directly, or paste product and listing URLs, then export to JSON, CSV or Excel, or pull the results straight into your app through the API.

### Why This Scraper?

- **Two ways to find products.** Search by keyword with brand, price and rating filters, or paste product and listing URLs directly. Pick one per run; keywords and a Specials category can be combined within search mode.
- **Store-wide specials on demand.** Scrape the Super Deals or Clearance collections directly, alongside or instead of your keywords, or paste any department-scoped sale listing URL.
- **One flat record per product.** Identity, price, discount, rating and review count come back on every product, list view included, with no detail fetch required.
- **Full detail on demand.** Switch on full specifications, image gallery, description, department, dimensions and reviews per run, and pay the enrichment surcharge only for products actually enriched. Pasted product URLs always get full detail; the toggle there only adds reviews.
- **Reviews with an aggregate summary.** Per-review rating, body, author, date, verified-purchase flag and helpful votes, plus a star distribution and recommended count.
- **Unlimited catalogue walks.** Set both Max pages per listing and Max products to 0 to walk an entire department; the run stops naturally once the storefront's own result total is reached, never on an artificial page cap.
- **Built for schedules.** Incremental mode tracks a search across runs and returns only new and updated products by default, and a fully refused run fails loudly instead of returning an empty dataset.

### Use Cases

- **Price monitoring and deal alerts:** track current and was-prices, discount amounts and the on-special flag across a department or the Clearance range.
- **Competitive and assortment research:** compare specifications, stock status and ratings across brands within a category.
- **Review and reputation analysis:** collect per-review ratings, pros and cons and verified-purchase flags to spot recurring product issues.
- **Catalogue monitoring:** schedule Incremental mode against a department or keyword to get only new and updated products every day (and removed ones too, with Emit expired on and no caps).

### Data You Get

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

| Field | Example |
| --- | --- |
| `productId` | `481027` |
| `sku` | `OLED55ACM2026` |
| `title` | `"Acme 55-inch 4K OLED Smart TV"` |
| `name` | `"Acme 55-inch 4K OLED Smart TV"` |
| `brand` | `"Acme"` (detail enrichment, or pasted product URLs; `null` otherwise) |
| `url` | `"https://www.harveynorman.com.au/acme-55-inch-4k-oled-smart-tv.html"` |
| `imageUrl` | `"https://www.harveynorman.com.au/media/catalog/product/a/c/acme-55-oled-front.jpg"` |
| `price` / `currency` | `2895.0` / `"AUD"` |
| `wasPrice` | `3295.0` |
| `discountAmount` / `discountPercent` | `400.0` / `12.14` |
| `isOnSpecial` / `promotion` | `true` / `"Save $400"` |
| `rating` / `reviewCount` | `4.64` / `87` |
| `brandUrl` | `"https://www.harveynorman.com.au/brands/acme"` (detail enrichment, or pasted product URLs) |
| `description` / `shortDescription` | full copy / one-line summary (detail enrichment, or pasted product URLs) |
| `specifications` | `{ "Screen Size": "55 inch", "Resolution": "4K Ultra HD", "Barcode (GTIN)": "9312345678901" }` (detail enrichment, or pasted product URLs) |
| `images` | array of image URLs on the retailer's own site (detail enrichment, or pasted product URLs) |
| `stockStatus` | `"IN_STOCK"` |
| `gtin` | `"9312345678901"` (detail enrichment, or pasted product URLs) |
| `department` / `category` / `categoryPath` | `"Audio Visual"` / `"OLED TVs"` / `"tv-audio/tvs/oled-tvs"` (detail enrichment, or pasted product URLs) |
| `dimensions` | `{ "height": 82.6, "depth": 4.5, "width": 124.4, "weight": 21.3, "colour": "Black" }` (detail enrichment, or pasted product URLs) |
| `mainFeatures` | array of feature-bullet strings (detail enrichment, or pasted product URLs) |
| `ratingDistribution` / `recommendedCount` | `{ "5": 66, "4": 15, "3": 3, "2": 2, "1": 1 }` / `80` |
| `reviewsCollected` / `reviews` | `20` / array of per-review objects (when detail enrichment is on, capped by Max reviews per product) |

Enriched products may also carry `offerType`, `cashback` and `specialToDate` when the source page has them, plus the internal `searchMode` and `source` fields recording how the record was found. Each review inside `reviews[]` carries its own `reviewId`, `rating`, `title`, `body`, `author`, `location`, `date`, `verifiedPurchase`, `isRecommended`, `helpfulCount`, `notHelpfulCount`, `isSyndicated`, `pros` and `cons`.

### How to Use

1. Pick a **mode**: `search` (keywords and filters) or `url` (paste product or listing links).
2. In search mode, add keywords and/or pick a **Specials / offers category**, then narrow with **brand**, **minimum / maximum price** and **minimum rating**, and order with **sort by**.
3. Turn on **Fetch full product detail + reviews** if you need specifications, images, description and reviews; set **Max reviews per product** and **Max products** to control cost.
4. Click **Start**, then download the dataset as JSON, CSV or Excel, or read it through the API.

**Search by keyword with filters:**

```json
{
  "mode": "search",
  "queries": ["oled tv"],
  "brand": "Acme",
  "minPrice": 1000,
  "maxPrice": 6000,
  "sortBy": "priceDesc",
  "maxItems": 50,
  "proxy": { "useApifyProxy": true, "apifyProxyCountry": "AU" }
}
```

**Scrape the store-wide Clearance range with full detail and reviews:**

```json
{
  "mode": "search",
  "specialsCategory": "clearance",
  "detailEnrichment": true,
  "maxReviewsPerProduct": 10,
  "maxItems": 50,
  "proxy": { "useApifyProxy": true, "apifyProxyCountry": "AU" }
}
```

**Supported URL shapes (URL mode):**

| Shape | Example | Result |
|---|---|---|
| Product page (`.html`) | `.../lg-65-inch-g6-evo-ai-4k-oled-smart-tv-2026.html` | One product, full detail |
| Department listing | `.../computers-tablets/computers/laptops` | Every page of the listing |
| Paginated listing | `.../laptops?p=2` | Starts at that page (partial scope for incremental mode) |
| Search results | `.../catalogsearch/result/?q=oled+tv` | Handled exactly like the keyword `oled tv` in search mode |
| Specials | `.../super-deals`, `.../clearance` | The specials collection |

Any other link (another site, the home page) is skipped with a warning in the run log, and a search link whose keyword finds nothing logs a warning. **Behaviour change:** when `urls` is left out in URL mode, the input now defaults to one example per shape above instead of an empty list.

**Paste a product URL and a department listing URL:**

```json
{
  "mode": "url",
  "urls": [
    "https://www.harveynorman.com.au/acme-55-inch-4k-oled-smart-tv.html",
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops"
  ],
  "detailEnrichment": true,
  "maxItems": 100
}
```

**Daily monitoring of a search, changes only (uncapped so a complete scan is possible):**

```json
{
  "mode": "search",
  "queries": ["oled tv"],
  "incrementalMode": true,
  "emitExpired": true,
  "maxItems": 0
}
```

#### Run it from your code

Python:

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("abotapi/harvey-norman-scraper").call(run_input={"mode": "search", "queries": ["oled tv"]})
for product in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(product["title"], product["price"])
```

JavaScript:

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

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('abotapi/harvey-norman-scraper').call({ mode: 'search', queries: ['oled tv'] });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

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

#### Walking a whole catalogue

**Max pages per listing** defaults to `0`, which walks every page of a keyword's resolved department or a pasted listing URL, with no artificial page cap; the walk stops naturally once the storefront's own page or result total is reached, or once a page repeats with no new products. **Max products** still defaults to 20 and bounds the whole run separately, so set it to `0` as well if you actually want a full department. Set Max pages per listing to a positive number only to cap how deep an individual search goes.

#### Resume and recurring updates

- **Resume** (`resumeFromRunId`) skips products already collected in a previous run's dataset, so you don't pay twice for them. It works best when the listing order has changed since that run, or for a pasted list of product URLs; because a fresh Resume run starts its walk again from page 1, and the repeat-page guard checks the same seeded set, a listing whose order hasn't changed can end the walk almost immediately without returning the products you were hoping to continue collecting. A separate, automatic same-run checkpoint also protects against a platform migration or a Resurrect of a failed run, continuing the same run without re-pushing already-collected products.
- **Incremental mode** (`incrementalMode`) is for a schedule (for example, daily) over the same search, specials category or URL set. The actor remembers each search's state itself, keyed on your search, filter and detail-enrichment setup (or on `stateKey` if you set one), so you never paste a run ID. Every returned record then also carries `changeType`, `changedFields`, `firstSeenAt` and `lastSeenAt`. The first run returns everything as `NEW`. Later runs return `NEW` and `UPDATED` products by default; `UNCHANGED` products are suppressed and not billed unless `emitUnchanged` is on. `REAPPEARED` only fires for a product that a prior run had already tombstoned `EXPIRED`; `EXPIRED` itself is only produced once a run has fully scanned every keyword, specials category and URL in scope with `emitExpired` on, and that run was not capped by Max products or Max pages, did not use Resume, and did not continue from a saved checkpoint. With incremental mode off, output is exactly as before.

**What counts as a change.** Every output field is compared (price, was price, discount, special flag, stock status, name, brand, description, specifications, categories) except these always-ignored fields: `rating`, `reviewCount`, `ratingDistribution`, `recommendedCount`, `reviews`, `reviewsCollected`, `imageUrl`, `images`, `smallImage`, `promotion`, `promotionData`, `cashback`, `offers`, `priceRange`, `startingFromText`, `hidePrice`, `callToAction`, `callToActionUrl`, `urlKey`, `urlSuffix`, `metaTitle`, `metaDescription`, `metaKeyword` and `searchMode`. Those stay in the output but never create an UPDATED row or appear in `changedFields`. Use **Ignore fields when detecting changes (incremental mode only)** (`ignoreFieldsForChanges`) to leave more fields out; dotted paths such as `specifications.Colour` drop a nested key.

**Price and stock monitoring (all optional, off by default).** Set `changeScope` to `priceAndStock` so a product is UPDATED only when its price, was price, discount, special flag or stock status moves. Turn on `compactUpdates` for slim UPDATED and EXPIRED rows (ids, url, name, current price and stock status, and `priceChanges` with old and new values); compact UPDATED rows carry no detail or reviews, so they carry no detail-enrichment charge. `expireAfterMissedScans` (default 1) marks a product EXPIRED only after it was missing from that many consecutive complete scans. EXPIRED means the product dropped out of the scanned listing, which a delisting and a temporary drop both cause; read `stockStatus` for availability. EXPIRED is also never produced for a pasted listing URL that starts after page 1, or when filters are combined with a shared `stateKey`, and EXPIRED rows never exceed Max products. Changing `changeScope` or `ignoreFieldsForChanges` keeps the same saved state: the stored products are compared again under the new rules, so it does not re-return the whole catalogue as NEW.

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

Pipe results straight into the tools you already use via Model Context Protocol (MCP) connectors. This is an extra delivery step **after** the scrape: the Apify dataset is never changed.

**What gets written to the connector:** a condensed, human-readable **summary** of each product, not the full JSON. Each item becomes one entry with a **title** and its key fields flattened to plain text. The **complete record always stays in the Apify dataset**.

1. Authorize a connector once under **Apify, Settings, Integrations** (Notion, Linear, Airtable, or Apify).
2. Select it in the **"Pipe results into your apps"** input field.
3. For **Notion**, also set `notionParentPageUrl` to the page where product pages should be created.

The connection is mediated by Apify's MCP proxy, so this actor never sees your third-party credentials. Leave the field empty to skip.

### Input Parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | string | `search` | `search` (keywords and filters) or `url` (scrape pasted URLs). |
| `queries` | array | prefill `["oled tv"]` | Keywords to search (search mode). Each keyword resolves to its best-matching department listing. |
| `specialsCategory` | string | (none) | Optional: scrape the `super-deals` or `clearance` collection instead of, or alongside, your keywords. |
| `urls` | array | one example per URL shape | Product or listing URLs to scrape (url mode). |
| `brand` | string | (none) | Keeps products whose name contains this text (case-insensitive substring match, not a structured brand field). Applied before detail enrichment, to both modes; dropped products are never collected or charged. |
| `minPrice` | integer | (none) | Only keep products priced at or above this amount (AUD). |
| `maxPrice` | integer | (none) | Only keep products priced at or below this amount (AUD). |
| `minRating` | integer | (none) | Only keep products with an average rating at or above this value (1 to 5). |
| `sortBy` | string | `relevance` | (relevance, priceAsc, priceDesc, ratingDesc, nameAsc, nameDesc; the aliases `sort_by` with values like `price-ASC` and `keyword` for `queries` are also accepted.) Ordering within each keyword or listing, not across the whole run. Any order other than relevance reads the whole listing first before applying the cap. |
| `detailEnrichment` | boolean | `false` | Open each product page for full specifications, image gallery, description and reviews. Adds one request per product and bills the detail-enrichment event for each row that reaches the dataset with its detail page read: one detail-enrichment unit per started block of 10 reviews delivered on the row, minimum 1 unit (the page read is included in the first block): 0 to 10 reviews = 1 unit, 11 to 20 = 2, 21 to 30 = 3. The count is the reviews actually returned on the row (capped by Max reviews per product), not the product's total review count. Rows that are suppressed, deduplicated, compact UPDATED or whose page could not be read add no units. |
| `maxReviewsPerProduct` | integer | `20` | Cap on reviews collected per product when detail enrichment is on. `0` = all available, capped at 1000 per product. |
| `maxItems` | integer | `20` | Maximum number of products to return across the whole run. `0` = unlimited. |
| `maxPages` | integer | `0` | Cap on result pages walked per keyword or URL. `0` = unlimited: walk every page until the storefront's own total is reached or a page repeats with no new products. |
| `resumeFromRunId` | string | (none) | ID of a previous run of this actor, or a dataset ID. Products already in that dataset are skipped. Best for a pasted list of product URLs, or a listing whose order has changed since that run, see "Resume and recurring updates" above. |
| `incrementalMode` | boolean | `false` | Turn on for daily or recurring monitoring. Returns only new, updated and (when eligible) reappeared or expired products on later runs. |
| `stateKey` | string | (none) | Name this monitoring campaign, or deliberately share state across differently-configured runs. Leave empty to derive a key automatically from your search and filter setup. |
| `emitUnchanged` | boolean | `false` | Incremental mode only. Also return, and bill, products unchanged since the last run. |
| `emitExpired` | boolean | `false` | Incremental mode only. Also return, and bill, products no longer found, once a run has fully scanned the tracked search. |
| `changeScope` | string | `all` | Incremental mode only. `priceAndStock` marks a product UPDATED only when price or stock status moves. |
| `compactUpdates` | boolean | `false` | Incremental mode only. Slim UPDATED and EXPIRED rows with `priceChanges`. |
| `expireAfterMissedScans` | integer | `1` | Incremental mode only. Consecutive complete scans a product must be missing before it is EXPIRED. |
| `ignoreFieldsForChanges` | array | (none) | Incremental mode only. Fields (dotted paths allowed) left out of change detection. |
| `proxy` | object | Apify Proxy (standard pool, AU) | Connection settings. Residential (AU) is optional. |
| `mcpConnectors` | array | (none) | Optional: send a summary of each product to apps you authorized under Integrations. |
| `notionParentPageUrl` | string | (none) | Notion connector only: page under which product pages are created. Ignored by other connectors. |
| `maxNotifyListings` | integer | `50` | Cap on items written to each connector per run. Does not affect the dataset. |

### Output Example

> Sample shape: values are illustrative placeholders, not from a live record. `reviews` is shown truncated to one entry for brevity; `reviewsCollected` reflects the full count.

```json
{
  "productId": 481027,
  "sku": "OLED55ACM2026",
  "title": "Acme 55-inch 4K OLED Smart TV",
  "name": "Acme 55-inch 4K OLED Smart TV",
  "brand": "Acme",
  "url": "https://www.harveynorman.com.au/acme-55-inch-4k-oled-smart-tv.html",
  "imageUrl": "https://www.harveynorman.com.au/media/catalog/product/a/c/acme-55-oled-front.jpg",
  "price": 2895.0,
  "wasPrice": 3295.0,
  "discountAmount": 400.0,
  "discountPercent": 12.14,
  "isOnSpecial": true,
  "promotion": "Save $400",
  "currency": "AUD",
  "rating": 4.64,
  "reviewCount": 87,
  "brandUrl": "https://www.harveynorman.com.au/brands/acme",
  "description": "Key features: vivid OLED panel with deep blacks and wide viewing angles...",
  "shortDescription": "A 55-inch 4K OLED television with smart features and low-latency gaming.",
  "specifications": {
    "Product Type": "OLED TVs",
    "Screen Size": "55 inch",
    "Resolution": "4K Ultra HD",
    "Barcode (GTIN)": "9312345678901"
  },
  "images": [
    "https://www.harveynorman.com.au/media/catalog/product/a/c/acme-55-oled-front.jpg",
    "https://www.harveynorman.com.au/media/catalog/product/a/c/acme-55-oled-side.jpg"
  ],
  "stockStatus": "IN_STOCK",
  "gtin": "9312345678901",
  "department": "Audio Visual",
  "category": "OLED TVs",
  "categoryPath": "tv-audio/tvs/oled-tvs",
  "dimensions": { "height": 82.6, "depth": 4.5, "width": 124.4, "weight": 21.3, "colour": "Black" },
  "ratingDistribution": { "5": 66, "4": 15, "3": 3, "2": 2, "1": 1 },
  "recommendedCount": 80,
  "reviewsCollected": 20,
  "reviews": [
    {
      "reviewId": "700000001",
      "rating": 5,
      "title": "Great picture for the price",
      "body": "Deep blacks and vivid colour, very happy with this TV.",
      "author": "SampleShopper",
      "location": "VIC",
      "date": "2026-04-12T10:30:00.000+00:00",
      "verifiedPurchase": true,
      "isRecommended": true,
      "helpfulCount": 3,
      "notHelpfulCount": 0,
      "isSyndicated": false,
      "pros": ["Picture quality"],
      "cons": null
    }
  ]
}
```

#### Output views

The dataset has four views in the Console: **Products** (price, was-price, stock, rating, change columns), **Product details** (specifications, gallery, dimensions, features, rating distribution), **Reviews** (one row per review, expanded from `reviews[]`) and **Changes (incremental mode)** (change type, changed fields, price changes). A summary of the run is saved to the key-value store under `OUTPUT`.

### Plan Requirement

The storefront sits behind an anti-bot interstitial that answers with an ordinary 200 status instead of an error, so a plain datacenter connection is not reliable here and the run needs a clearance step for every connection it uses. Apify Proxy (AU) is selected by default under Connection; the run opens the standard pool first and, if the platform refuses that connection for your account, moves on to Apify Residential (AU) and then to a backup connection. Residential access requires a paid Apify plan; free plans do not include it. Custom proxy URLs are also supported, in which case no backup connections are used.

What you will see in the run log:

- When the selected connection is refused: "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. For residential access, upgrade at https://apify.com/pricing or supply your own proxy URLs."
- When the backup connection served data: "Served over the backup connection (the default connection was unavailable)."
- When nothing could be read, the run fails with one of three messages instead of returning an empty dataset: the proxy connection was refused for this account and a paid plan is required (all connections refused); no results because residential proxies are unavailable on this run and the backup connection could not load the requested Harvey Norman pages (free plan); or "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."

#### Pricing

Pay per event. Two events exist: one charge per product row delivered to the dataset, and the detail-enrichment charge for products whose full detail page was read and merged. Detail enrichment bills one detail-enrichment unit per started block of 10 reviews delivered on the row, minimum 1 unit (the page read is included in the first block): 0 to 10 reviews = 1 unit, 11 to 20 = 2, 21 to 30 = 3. The count is the reviews actually returned on the row (capped by Max reviews per product), not the product's total review count, only for rows actually pushed. Unchanged, suppressed, deduplicated and failed products are never charged. Current rates are on the **Pricing** tab. Cap any run with **Max products**.

### FAQ

#### How much does it cost?

You pay per product returned, plus the detail-enrichment surcharge for each product whose page was actually read and merged into the dataset. That surcharge is one detail-enrichment unit per started block of 10 reviews delivered on the row, minimum 1 unit (the page read is included in the first block): 0 to 10 reviews = 1 unit, 11 to 20 = 2, 21 to 30 = 3. The count is the reviews actually returned on the row (capped by Max reviews per product), not the product's total review count, so a product with 25 reviews delivered bills 3 units and one with 8 bills 1, whatever its total review count; a product whose page could not be read is not charged for it. The **Pricing** tab shows the current rates. Use **Max products** to cap the cost of any run.

#### Is it legal to scrape Harvey Norman?

This actor collects only publicly available product data. You are responsible for how you use it: follow Harvey Norman's terms and the laws that apply to you, and get legal advice if you plan commercial redistribution. Product images, descriptions and customer reviews may be subject to third-party rights.

#### 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 and updated products; unchanged ones are neither returned nor billed unless you turn on **Emit unchanged**.

#### What's the difference between Resume and Incremental mode?

**Resume** (`resumeFromRunId`) continues one specific interrupted or earlier crawl by skipping products already in a pasted run or dataset. **Incremental mode** is for running the exact same search again and again on a schedule; it remembers the state itself, so you never paste a run ID.

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

If every request to Harvey Norman was refused, the run stops with a clear connection message instead of completing with zero products, so "no matches" is never confused with "nothing could be read". Run it again in a few minutes, or check the connection warnings in the run log.

#### 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 results 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/bigw-marketplace-scraper"><b>BIG W Marketplace Scraper</b></a><br>Scrape BIG W Marketplace seller listings from bigw.com.au: name, brand, price, was-price...</td><td>🛒 <a href="https://apify.com/abotapi/target-au-scraper"><b>Target AU Scraper</b></a><br>Scrape products and customer reviews from Target.com.au. Search by keyword or use...</td></tr>
<tr><td>🛒 <a href="https://apify.com/abotapi/amazon-au-scraper"><b>Amazon Australia Product &amp; Reviews Scraper</b></a><br>Scrape Amazon Australia (amazon.com.au) products and customer reviews. Search by keyword...</td><td>🛒 <a href="https://apify.com/abotapi/officeworks-scraper"><b>Officeworks Scraper</b></a><br>Scrape Officeworks products by keyword, category, or URL. Extract names, brands, prices...</td></tr>
<tr><td>🛒 <a href="https://apify.com/abotapi/costco-au-scraper"><b>Costco AU Scraper</b></a><br>Scrape Costco.com.au products by keyword or product, search and category URLs. Extract...</td><td>🛒 <a href="https://apify.com/abotapi/bunnings-com-au-scraper"><b>Bunnings Scraper</b></a><br>Scrape bunnings.com.au products with full specifications, price, brand, stock, image...</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/harvey-norman-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`):

Choose 'search' to use keywords and filters, or 'url' to scrape specific product or listing URLs you paste below.

## `queries` (type: `array`):

One or more keywords to search, for example 'oled tv' or 'gaming laptop'. Each keyword resolves to its best-matching department listing and is walked page by page.

## `specialsCategory` (type: `string`):

Optional. Scrape a Harvey Norman specials collection instead of (or in addition to) your keywords: 'Super Deals' is the store-wide hottest-deals grid and 'Clearance' is the run-out / reduced-to-clear range. Discounted products carry the was-price, discount amount and percent, and the promotion label. Leave empty to search by keyword only. To scrape a department-scoped sale (e.g. TV sale, laptop sale), use URL mode and paste the sale listing URL.

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

Product, listing, search-results or specials URLs to scrape, for example a product page ending in .html, a department listing (add ?p=2 to start at a later page), a /catalogsearch/result/?q=keyword search page, or /super-deals and /clearance. Multiple URLs supported.

## `brand` (type: `string`):

Optional. Keep only products whose brand or name matches this value, for example 'LG' or 'Samsung'.

## `minPrice` (type: `integer`):

Optional. Only keep products priced at or above this amount (AUD).

## `maxPrice` (type: `integer`):

Optional. Only keep products priced at or below this amount (AUD).

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

Optional. Only keep products with an average customer rating at or above this value (1 to 5).

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

Ordering applied to the collected results.

## `detailEnrichment` (type: `boolean`):

For each product, also open its page to collect the full specification set, image gallery, description, department, dimensions and an aggregate review summary plus a reviews\[] array. Adds one request per product. Off by default (list view returns identity, price and aggregate rating only). Each row that reaches the dataset with its page read bills the detail-enrichment event: one detail-enrichment unit per started block of 10 reviews delivered on the row, minimum 1 unit (the page read is included in the first block): 0 to 10 reviews = 1 unit, 11 to 20 = 2, 21 to 30 = 3. The count is the reviews actually returned on the row (capped by Max reviews per product), not the product's total review count (see the Pricing tab).

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

Cap on reviews collected per product when detail enrichment is on. Use 0 for all available reviews, up to 1000 per product.

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

Maximum number of products to return across the whole run. Use 0 for unlimited.

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

Cap on result pages walked per keyword / URL. 0 = unlimited (default): walk every page — the run still stops naturally once the storefront's own result total is reached or a page repeats with no new products, so there is no artificial page cap. Set a positive number to cap how deep each individual search goes.

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

Apify Proxy (standard pool by default; Australian residential is available as an option). On the free plan, coverage may be limited and some runs may return no results.

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

Optional. ID of a previous run of this actor (or a dataset ID). Products already in that dataset are skipped, so this run returns only NEW products (a delta). Combine both runs' datasets for the full set. Max products then counts only the new products. 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. Compared: every output field, including price, was price, discount, special flag, stock status, name, brand, description, specifications and categories. Never compared (still in the output): rating, reviewCount, ratingDistribution, recommendedCount, reviews, reviewsCollected, imageUrl, images, smallImage, promotion, promotionData, cashback, offers, priceRange, startingFromText, hidePrice, callToAction, callToActionUrl, urlKey, urlSuffix, metaTitle, metaDescription, metaKeyword, and searchMode (how a row was reached), plus anything listed in 'Ignore fields when detecting changes'. Turn on "Emit unchanged" or "Emit expired" only when you also want those products returned (and billed). State is kept separately for each distinct search/filter setup (or by State key below).

## `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 your search/filter/detail 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: never when Max products or Max pages capped it, when Resume was used, when a pasted listing URL started after page 1, or when filters are combined with a shared State key. EXPIRED rows are limited to Max products minus the rows already returned. This returns, and bills, extra synthetic rows.

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

Incremental mode only. 'all' compares every field except the volatile ones. 'priceAndStock' marks a product UPDATED only when its price, was price, discount, special flag or stock status moves; descriptions, images, reviews and promotion text never create an UPDATED row. Changing this keeps the saved state; stored products are compared again under the new scope.

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

Incremental mode only. UPDATED and EXPIRED rows carry only ids, url, name, current price and stock status, and priceChanges {field: {old, new}} instead of the full record. NEW rows stay full. Compact UPDATED rows carry no detail or reviews, so they carry no detail-enrichment charge.

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

Incremental mode only, with 'Emit expired products'. A product is marked EXPIRED only after it was missing from this many consecutive complete scans (1 = the first). EXPIRED means the product dropped out of the scanned listing; without a detail check a listing drop and a delisting look the same, so use the stockStatus field for availability.

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

Incremental mode only. Field names to leave out when deciding whether a product changed (kept in the output, excluded from the comparison and from changedFields). Dotted paths such as specifications.Colour drop that nested key. Always ignored: rating, reviewCount, ratingDistribution, recommendedCount, reviews, reviewsCollected, imageUrl, images, smallImage, promotion, promotionData, cashback, offers, priceRange, startingFromText, hidePrice, callToAction, callToActionUrl, urlKey, urlSuffix, metaTitle, metaDescription, metaKeyword and searchMode. Changing this keeps the saved state; stored products are compared again under the new list.

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

Optionally send results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize one under Apify, Settings, API & Integrations, then select it here. Notion gets a rich page-per-item export; other connectors get a best-effort write/digest. Leave empty to skip; never changes the dataset output. 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",
  "queries": [
    "oled tv"
  ],
  "urls": [
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops",
    "https://www.harveynorman.com.au/lg-65-inch-g6-evo-ai-4k-oled-smart-tv-2026.html",
    "https://www.harveynorman.com.au/catalogsearch/result/?q=oled+tv",
    "https://www.harveynorman.com.au/super-deals",
    "https://www.harveynorman.com.au/clearance",
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops?p=2",
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops?product_list_order=price_asc"
  ],
  "sortBy": "relevance",
  "detailEnrichment": false,
  "maxReviewsPerProduct": 20,
  "maxItems": 20,
  "maxPages": 0,
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyCountry": "AU"
  },
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "changeScope": "all",
  "compactUpdates": false,
  "expireAfterMissedScans": 1,
  "ignoreFieldsForChanges": [],
  "maxNotifyListings": 50
}
```

# Actor output Schema

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

No description

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

No description

## `reviews` (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",
    "queries": [
        "oled tv"
    ],
    "urls": [
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops",
        "https://www.harveynorman.com.au/lg-65-inch-g6-evo-ai-4k-oled-smart-tv-2026.html",
        "https://www.harveynorman.com.au/catalogsearch/result/?q=oled+tv",
        "https://www.harveynorman.com.au/super-deals",
        "https://www.harveynorman.com.au/clearance",
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops?p=2",
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops?product_list_order=price_asc"
    ],
    "proxy": {
        "useApifyProxy": true,
        "apifyProxyCountry": "AU"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/harvey-norman-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",
    "queries": ["oled tv"],
    "urls": [
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops",
        "https://www.harveynorman.com.au/lg-65-inch-g6-evo-ai-4k-oled-smart-tv-2026.html",
        "https://www.harveynorman.com.au/catalogsearch/result/?q=oled+tv",
        "https://www.harveynorman.com.au/super-deals",
        "https://www.harveynorman.com.au/clearance",
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops?p=2",
        "https://www.harveynorman.com.au/computers-tablets/computers/laptops?product_list_order=price_asc",
    ],
    "proxy": {
        "useApifyProxy": True,
        "apifyProxyCountry": "AU",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/harvey-norman-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",
  "queries": [
    "oled tv"
  ],
  "urls": [
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops",
    "https://www.harveynorman.com.au/lg-65-inch-g6-evo-ai-4k-oled-smart-tv-2026.html",
    "https://www.harveynorman.com.au/catalogsearch/result/?q=oled+tv",
    "https://www.harveynorman.com.au/super-deals",
    "https://www.harveynorman.com.au/clearance",
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops?p=2",
    "https://www.harveynorman.com.au/computers-tablets/computers/laptops?product_list_order=price_asc"
  ],
  "proxy": {
    "useApifyProxy": true,
    "apifyProxyCountry": "AU"
  }
}' |
apify call abotapi/harvey-norman-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/harvey-norman-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/Xy8YanyYVzp9BS2h5/builds/nGHFpcDrBj9BN5guy/openapi.json
