# Lowe's Scraper - Products, Prices, Stock & Reviews (`abotapi/lowes-com-scraper`) Actor

Scrape Lowe's (lowes.com) home improvement, tools, appliances and building supplies by keyword search. Extract product names, brands, prices, descriptions, specifications, stock availability, ratings and customer reviews. Ideal for price monitoring, competitor analysis and retail market research.

- **URL**: https://apify.com/abotapi/lowes-com-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.70 / 1,000 product records

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

## Lowe's Product Scraper

Scrape Lowes.com product listings: keyword search, full product details, prices, stock, ratings and customer reviews. Lowe's is the second-largest US home-improvement retailer (tools, decor, building supplies, hardware), and this actor turns its catalog into clean structured rows for price monitoring, assortment analytics and competitor research.

### Why This Scraper?

- **Two ways in.** Point it at keyword searches, or paste storefront links - search pages, category pages and product pages all walk with the same grammar.
- **Deep product records.** Every listing row carries title, brand, price (with was-price), the per-unit price when the storefront publishes one, stock quantity, rating, review count, badges, promotions and the spec table; detail enrichment adds the description, model number, barcode, availability, variants and the full image gallery.
- **Customer reviews included.** Rating summary, star distribution and up to 20 review texts per product, with verified-purchaser, recommendation and helpful-vote fields.
- **Scheduled-run friendly.** Incremental mode classifies every product NEW / UPDATED / UNCHANGED / EXPIRED across runs, so a daily monitor pushes only what moved; a product whose listing data did not change is neither read again nor charged. A price-and-availability scope, compact change rows and a grace period before EXPIRED keep a price monitor cheap.
- **Resume that never double-charges.** Paste the ID of an interrupted run and the products it already collected are skipped (and not billed again); a run the platform restarts continues by itself.
- **Built for the wall.** The storefront's edge refuses most scripted readers, so the actor reads the storefront's own listing and product data over residential US exits and moves to a fresh exit automatically whenever one is refused.

### Use Cases

- **Who:** pricing teams tracking Lowe's promos on competitive assortments - price and was-price land per row (plus a per-unit price where the storefront lists one), updated per scheduled run.
- **Who:** sellers and brands auditing their shelf - badges, promotions, stock quantities and review velocity per product.
- **Who:** analysts building home-improvement datasets - walk any keyword search with the same grammar and export straight to CSV/Excel or a warehouse.

### Data You Get

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

| Field | Example |
| --- | --- |
| `title` / `brand` / `brandId` | 20-volt 1/2-in Keyless Brushless Cordless Drill / CRAFTSMAN / 722 |
| `price` / `wasPrice` | 129.0 / null (a marked-down product shows its reference price) |
| `priceUnavailableReason` | null (set only when `price` is null: `no_standalone_offer` or `not_published`, see Limitations) |
| `savingsAmount` / `savingsPercent` / `saleEndsAt` | 50.0 / 27.0 / 2026-10-31T23:59:59.000Z (marked-down products only) |
| `priceInfo` | The storefront's price block: display type, selling and was price, savings, sale window and every price row |
| `pricePerUnit` | null (filled only when the storefront lists a unit price; the drill is each-priced) |
| `stockQuantity` / `inStock` | 423 / true |
| `pickupEligible` / `parcelEligible` / `freeShipping` | true / true / true |
| `inventory` | Availability per delivery method (parcel, pickup, truck, same day): status, units, lead time |
| `promotions` | Product, finance and shipping promotions attached to the product, with ids, names and dates |
| `modelId` / `itemNumber` / `lin` / `productId` | CMCD714D1 / 6812916 / 1000742101 / 5016412045 |
| `barcode` | 198706013438 |
| `rating` / `reviewsCount` / `badges` | 4.9 / 96 / \["LOWES EXCLUSIVE"] |
| `specs` / `measurements` | \[{key: Cordless, value: Yes}, ...] / depth, width, height, weights with metric conversions |
| `description` / `marketingBullets` / `availability` | Full product description text / highlights / In Stock |
| `images` / `videos` / `guides` | Full image gallery, product videos, manuals and compliance documents |
| `variants` | Every option of the variant selector (finish, size...) with the product it leads to |
| `categories` / `merchandisingHierarchy` / `breadcrumbs` | Category ids, the business-area to assortment hierarchy, the shopper's category trail |
| `groups` / `collection` | Collections and bundles the product belongs to, with links |
| `status` / `lowesExclusive` / `energyStar` | ACTIVE / false / false |
| `storeNumber` / `storeInfo` | 1539 (the store nearest the connection) / the store the prices were read for |
| `reviewsAverage` / `reviewsRecommended` / `ratingDistribution` | 4.9 / 70 / \[{stars: 5, count: 89}, ...] |
| `reviews` | \[{rating, title, text, author, verifiedPurchaser, ...}] |
| `changeType` / `changedFields` | NEW / UPDATED / \[price] (incremental mode) |
| `priceChanges` / `variantChanges` | Old and new value of what moved (compact UPDATED and EXPIRED rows) |

### How to Use

#### Run it from your code

Python:

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("abotapi/lowes-com-scraper").call(run_input={
    "mode": "search",
    "searchTerms": ["cordless drill"],
    "maxItems": 50,
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["title"], item["price"])
```

JavaScript:

```javascript
import { Actor } from 'apify';

const run = await Actor.call('abotapi/lowes-com-scraper', {
    mode: 'urls',
    urls: ['https://www.lowes.com/search?searchTerm=drill'],
    maxItems: 50,
});
const { default: dataset } = await Actor.openDataset(run.defaultDatasetId);
await dataset.forEach((item) => console.log(item.title, item.price));
```

**Search by keyword:**

```json
{
  "mode": "search",
  "searchTerms": ["cordless drill", "ceiling fan"],
  "maxItems": 50
}
```

**Default links:** leaving the Storefront links field unchanged scrapes one example of a keyword search page and a product page. Category pages (the `/pl/<name>/<id>` links in the storefront's menus) are supported too: paste one into Storefront links.

**Walk a search page, a category page and one product (reviews capped):**

```json
{
  "mode": "urls",
  "urls": [
    "https://www.lowes.com/search?searchTerm=drill",
    "https://www.lowes.com/pl/christmas-decorations/christmas-trees/4294417432",
    "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill-1-Batteries-Included-Soft-Bag-Included/5016412045"
  ],
  "maxItems": 100,
  "maxReviews": 5
}
```

**Scheduled price and availability monitor (slim change rows, two-scan grace before EXPIRED):**

```json
{
  "mode": "search",
  "searchTerms": ["cordless drill"],
  "maxItems": 0,
  "fetchDetails": false,
  "fetchReviews": false,
  "incrementalMode": true,
  "changeScope": "priceAndStock",
  "compactUpdates": true,
  "emitExpired": true,
  "expireAfterMissedScans": 2
}
```

**Continue an interrupted run (nothing already collected is returned or billed again):**

```json
{
  "mode": "search",
  "searchTerms": ["cordless drill"],
  "maxItems": 0,
  "resumeFromRunId": "ID_OF_THE_INTERRUPTED_RUN"
}
```

#### Resume and scheduled monitoring

- **Resume from a previous run** continues ONE interrupted run. Paste its run ID or dataset ID: the products it already collected are skipped and not billed again. A run that the platform restarts (for example after a server move) continues by itself with no ID. If the ID cannot be read the run stops with a message instead of collecting everything again. Resume never produces EXPIRED rows, because a resumed walk does not prove the whole scope was seen.
- **Incremental mode** remembers the previous run of the same scope. The first run returns every product as NEW; later runs return NEW, UPDATED and REAPPEARED products only. Unchanged products are decided from the listing data alone, so they are not read again and not charged. The state is kept per scope (search terms or links, and the detail and review settings); caps, proxy and the incremental controls never start a new baseline. A State key names or deliberately shares a campaign.
- **Resume together with Incremental mode** finishes an interrupted monitoring run: the products it already returned are not returned again and are tracked as seen. A monitoring run that ends early (time limit, abort, server move) remembers the rows it already returned, so the next run does not return them again either.
- **What counts as a change:** price, was-price, savings and sale end, unit price, title, brand, model, in-stock, pickup and parcel availability, free shipping, packaging, badges, highlights, specifications, categories, collections and promotions. **Never counted** (still returned in the row): scrape time, the product's rank, the store nearest the connection, per-delivery-method availability detail, the category trail, and the counters stock units, review count and average rating, which tick between two reads of an unchanged product (turn on Count stock unit changes to compare the stock counter).
- **Changes to watch** `priceAndStock` narrows the comparison to price, was-price, savings, sale end, unit price, availability, free shipping and per-option price and stock, so a title, description or specification edit never produces a billable UPDATED row.
- **Compact UPDATED rows** carry ids, link, title, current price and availability plus `priceChanges` and `variantChanges` (old and new). They carry no product-page detail or reviews, so no detail or review charge applies and the product page is not read. NEW rows stay full.
- **EXPIRED rows** appear only after a run that read the whole scope to its end: never when Max items capped it, a page could not be read, the time or spending limit stopped it, or Resume was used. A product dropping out of a result list is not necessarily unavailable: set Expire after missed scans to 2 or more to hold it until it has been missing from that many complete scans in a row. EXPIRED rows count against Max items.
- **Emit unchanged products** returns the unchanged rows with the product-page details the last run collected for them. Each returned row is billed like any other row.

#### Tips

- The storefront re-ranks results between runs - incremental mode treats a moving `position` as noise and never re-bills it.
- Search rows carry the storefront's network price (`storeNumber` is null); product-page rows name the store nearest the connection in `storeNumber`, which incremental mode never counts as a change.
- Flooring and other area-priced products show the storefront's per-square-foot price in `price`, exactly as the listing displays it.
- A product can show no price at all (roughly one product in four on a broad search). These are items the storefront does not offer on their own: the listing card says "See Product Page for Pickup and Delivery Options" and the product record marks the item unavailable, typically because it is a component of a kit or collection or is not sold individually. The product page, the product's data record and selecting another store or ZIP code were all checked on several stores across the country and none of them carries a price for such an item, so `price` stays null rather than borrowing a kit's price. `priceUnavailableReason` says why: `no_standalone_offer` (the storefront flags the item as not offered on its own) or `not_published` (no price and no such flag). It is null whenever `price` is set. The field is not part of the incremental comparison.
- Every run stores a short summary under the key `OUTPUT` of its key-value store: rows returned by change type, products scanned, whether every scope was read to its end, and whether the time or spending limit stopped it.
- Max items counts the rows a run RETURNS, not the products it scans: with Incremental mode a run keeps walking the listing past unchanged products until it has returned that many changed rows (or reached the end), and Max items 0 means no limit.
- `breadcrumbs` / `categoryPath` come from the product page itself. When only the product's data record can be read, the trail stays empty, or keeps the one the previous incremental run stored for that product.

### Input Parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | select | urls | `search` walks keyword searches; `urls` walks pasted storefront links. |
| `searchTerms` | string list | drill | Keyword searches to walk (search mode). Sending search terms while leaving the example links untouched runs the search terms only. |
| `urls` | URL list | 2 example links | Search pages, category pages (`/pl/<name>/<id>`) and product pages (links mode). |
| `maxItems` | integer | 20 | Maximum records per run (up to 100000). 0 means no limit. |
| `fetchDetails` | checkbox | true | Read each product's detail page (description, model, barcode, gallery, breadcrumbs). |
| `fetchReviews` | checkbox | true | Collect rating summary and review texts per product. |
| `maxReviews` | integer | 20 | Review texts collected per product, 0 to 100. 0 skips texts. |
| `resumeFromRunId` | string | (empty) | Run ID or dataset ID of an interrupted run to continue; its collected products are skipped and not billed again. |
| `incrementalMode` | checkbox | false | Classify rows NEW/UPDATED/UNCHANGED/EXPIRED across scheduled runs. |
| `stateKey` | string | (empty) | Names the incremental baseline; empty derives it from the scope. |
| `emitUnchanged` | checkbox | false | Also return products that did not change, with their stored details. |
| `emitExpired` | checkbox | false | Return EXPIRED rows for products that left a fully scanned scope. |
| `changeScope` | select | all | `priceAndStock` compares only price, sale and availability. |
| `compactUpdates` | checkbox | false | Slim UPDATED and EXPIRED rows with old and new values. |
| `expireAfterMissedScans` | integer | 1 | Complete scans a product must be missing before it is EXPIRED (1 to 30). |
| `watchStockQuantity` | checkbox | false | Also compare the stock-units counter. |
| `ignoreFieldsForChanges` | list | (empty) | Field names (or dotted paths such as `variants.image`) that never count as a change. |
| `zipCode` | string | (empty) | Not applied in this version: prices and stock come from the storefront's default store regardless of the connection country (see `storeInfo`). Leave empty. |
| `mcpConnectors` | string list | (hidden) | Internal: connector ids that receive records as they push. |
| `notionParentPageUrl` | string | (hidden) | Internal: parent page for the Notion connector. |
| `maxNotifyListings` | integer | 50 (hidden) | Internal: cap on records sent to connectors per run. |
| `proxyConfiguration` | proxy | Residential US | The storefront refuses scripted readers on datacenter exits; residential is the supported lane. When the selected country's exits keep being refused, the run continues on the same proxy group in other countries (see Plan Requirement). |

All fields are also accepted through the API exactly as documented, including the hidden internal ones.

### Output Example

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

```json
{
  "recordId": "lowes:5016412045",
  "productId": "5016412045",
  "title": "20-volt 1/2-in Keyless Brushless Cordless Drill",
  "brand": "CRAFTSMAN",
  "price": 129.0,
  "wasPrice": null,
  "currency": "USD",
  "stockQuantity": 423,
  "rating": 4.9,
  "reviewsCount": 96,
  "badges": ["LOWES EXCLUSIVE"],
  "availability": "In Stock",
  "url": "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill/5016412045"
}
```

Product rows with the detail enrichment on, from a live search walk.

### Plan Requirement

This actor requires an Apify paid plan: the storefront's edge refuses datacenter exits, so every run reads pages through residential proxies, which are not available on the Apify Free plan.

Runs start on the proxy country you select (US by default). The storefront's refusals come in waves per country, so when the selected country's exits keep being refused, the run continues on residential exits of the same proxy group in other countries; the storefront answers those with the same US catalog, prices and stock, and the run log says "Served over the backup connection" when that happened. Proxy usage is billed by your Apify plan exactly as for the selected country.

### FAQ

#### Is it legal to scrape Lowes.com?

The actor collects publicly available product data from the storefront's own pages, at a polite pace, without logging into any account. Review your use case and the site's terms before running at scale.

#### Why does a run fail with a connection problem?

The storefront denies individual proxy exits intermittently, in waves. The actor moves to a fresh exit automatically, falls back to residential exits in other countries, and waits through short deny windows; a run that still reports a connection problem hit a fully closed window - retry later, the same input usually succeeds on the next run.

#### How much does a run cost?

You pay the actor's per-event pricing, plus your Apify plan's proxy usage. The charged events, exactly as the code bills them:

- **Product record** - once per product row pushed to the dataset.
- **Detail enrichment** - once per pushed row whose product page was read (`fetchDetails` on).
- **Customer reviews** - once per pushed row that delivered at least one review text (not per review).

Every row the run returns bills the product record, whatever its kind: a NEW, UPDATED, compact UPDATED, UNCHANGED (only when you ask for them) or EXPIRED row. Rows that are not returned bill nothing: unchanged products in incremental mode are decided from the listing alone, so they cost no product-page read and no charge, and products skipped by Resume are not billed again. A compact UPDATED row carries no product-page detail or reviews, so it bills the record only. A run that reaches your spending limit stops before the next row is stored and says so; nothing is charged for a row that was not stored. Card-only rows (details off) and rows without reviews bill no enrichment event. Product details load on most but not all connections: when neither the product page nor the product's data record can be read after several fresh connections, a search-result row still ships with its listing fields only and no detail enrichment event is charged; a pasted product link has no listing row to fall back to, so it ships nothing and charges nothing. See the actor's Pricing tab for the registered event prices.

#### Can I track price changes daily?

Yes - schedule the run with `incrementalMode` on and the same inputs. Only NEW and UPDATED rows push, each with `changedFields` naming what moved. For a pure price monitor set `changeScope` to `priceAndStock` and `compactUpdates` on: a title or description edit then never produces a billable row, and each UPDATED row shows the old and new price.

#### What happens if a monitoring run is interrupted?

The rows a run returned before it ended are remembered (a run that reaches its time limit finishes cleanly with them, and a run that is aborted or moved by the platform saves them as it ends), so the next run does not return them again, and Resume skips the products an interrupted run already collected. If a run is killed outright without notice (for example out of memory), the last few rows it returned can come back once more on the next run; pasting that run's ID into Resume closes even that gap.

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

Connect the actor to Notion, Google Sheets and other MCP-capable apps from the Apify Console: records this run pushed are delivered as they land, capped per run. Connector settings ship hidden in the input and need no attention unless you use connectors.

### 🔗 More scrapers you might like

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

<table>
<tr><td>👗 <a href="https://apify.com/abotapi/kohls-com-scraper"><b>Kohl's Scraper</b></a><br>Scrape Kohl's (kohls.com) products by keyword search or category browsing. Extract...</td><td>🧩 <a href="https://apify.com/abotapi/cvs-product-scraper"><b>CVS Scraper</b></a><br>Scrape CVS.com pharmacy, beauty, health and personal care products by keyword search...</td></tr>
<tr><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><td>🛒 <a href="https://apify.com/abotapi/thewarehouse-co-nz-scraper"><b>The Warehouse NZ Scraper</b></a><br>Scrape The Warehouse New Zealand search results, category pages, and direct product URLs...</td></tr>
<tr><td>🧩 <a href="https://apify.com/abotapi/joom-com-scraper"><b>Joom Scraper</b></a><br>Scrape Joom products by keyword or URL. Extract product names, prices, discounts...</td><td>🧩 <a href="https://apify.com/abotapi/jbhifi-com-au-product-scraper"><b>JB Hi-Fi AU Scraper</b></a><br>Scrape JB Hi-Fi Australia (jbhifi.com.au) products from the public storefront. Extract...</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/lowes-com-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 mode walks keyword search result pages. Links mode walks the storefront links you paste: search pages, category pages and product pages.

## `searchTerms` (type: `array`):

Keyword searches to walk (search mode). Each term walks its result pages in order.

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

Links to walk (links mode): keyword search pages, category pages (/pl/<name>/<id> links from the storefront's menus) and product pages.

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

Read each product's detail JSON: description, model, availability, full spec table, image gallery and breadcrumbs. Billed per enriched record.

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

Collect customer reviews per product (rating summary, distribution and up to Max reviews review texts). Billed per record that delivered reviews.

## `maxReviews` (type: `integer`):

Cap on review texts collected per product. Values above 100 are treated as 100. 0 skips review texts.

## `zipCode` (type: `string`):

Not applied in this version: prices and stock come from the storefront's default store regardless of the connection country, and the row's storeNumber names it. Leave empty.

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

Maximum records this run may push (up to 100000). 0 means no limit.

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

Residential proxies are required (US by default): the storefront's edge refuses scripted readers on datacenter exits. When the selected country's exits keep being refused, the run continues on the same proxy group in other countries, which the storefront serves the same US catalog and prices.

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

Paste the run ID or dataset ID of an interrupted run to continue it without returning, or charging for, the products it already collected. A run that is restarted by the platform (for example after a server move) continues by itself and needs no ID. Combine it with Incremental mode to finish an interrupted monitoring run: the products it already returned are not returned again and are tracked as seen. EXPIRED detection never runs on a resumed run. For recurring monitoring of the same scope use Incremental mode below instead.

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

Turn this on for daily or recurring monitoring. The first run returns every matching product as NEW. Later runs return only what changed (NEW, UPDATED, REAPPEARED), and a product whose listing data did not change is neither read again nor charged. What is compared: price, was-price, savings and sale end, unit price, title, brand, model, ids, in-stock, pickup and parcel availability, free shipping, packaging, badges, highlights, specifications, categories, collections and promotions. What is excluded by default (still returned in the row, never a change): scrape time, the product's rank on the page, the store nearest the connection, per-delivery-method availability detail, and the counters stock units, review count and average rating (they tick between two reads of an unchanged product; turn on 'Count stock unit changes' to compare the stock counter). Lists whose order carries no meaning (badges, collections, categories, promotions) are compared regardless of order. 'Changes to watch' narrows the comparison to price, sale and availability only. The state is kept per scope: search terms, links and the detail and review settings, so two different scopes never mix; caps, proxy and the incremental controls themselves never start a new baseline. Use State key to name or deliberately share a campaign. A run that ends early (time limit, abort, server move) remembers the rows it already returned, so the next run does not return them again; after a run is killed without notice the last few rows can return once, and Resume closes that gap. 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 derive a key automatically from the scope, so two different scopes never mix state. A shared key with a different scope never produces EXPIRED rows.

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

Off by default. Turn on to also return products that have not changed since the last run, marked UNCHANGED, with the product-page details the last run collected for them. This returns, and bills, rows you already have, so leave it off unless you want a 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 in the results, marked EXPIRED. Only produced after a run that read the whole scope to its end: never when Max items capped it, a page could not be read, the time or spending limit stopped it, or Resume was used. A product that left the results is not necessarily unavailable, so consider 'Expire after missed scans' above 1. EXPIRED rows count against Max items (a run returns at most the room left under it; the rest follow on a later complete run) and are returned and billed like any row, so leave it off unless you need expiry tracking.

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

All fields (default): any change to the compared product data marks a product UPDATED. Price and availability only: just price, was-price, savings, sale end, unit price, in-stock, pickup and parcel availability, free shipping and per-option price and stock, so title, description or specification edits never produce a billable UPDATED row. Changing this never starts a new baseline: the saved state is kept and each stored product is compared under the new setting.

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

Off by default. When on, UPDATED and EXPIRED rows are slim: ids, link, title, current price and availability, plus priceChanges (old and new value of each field that moved) and variantChanges (per-option ADDED, REMOVED or CHANGED). NEW rows stay full. A compact row carries no product-page detail or reviews, so no detail or review charge applies to it and the product page is not read.

## `watchStockQuantity` (type: `boolean`):

Off by default. The units-in-stock counter ticks as the product sells, so counting it marks fast sellers UPDATED, and bills them, on most scans. Turn on to compare it anyway. In-stock, pickup and parcel availability are always compared.

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

Used with 'Emit expired products'. A product is marked EXPIRED only after it was missing from this many COMPLETE scans in a row (default 1). Dropping out of a result list is not the same as being unavailable, so 2 or more avoids false expiries; a product that returns resets the count.

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

A list of field names that must not count as a change, for example \["title", "badges"]. Use a dotted path to ignore a key inside every element of a list, for example variants.label. Ignored fields are still returned in the row. Scrape time, rank, nearest store, delivery detail, stock units, review count and average rating are already ignored by default. Changing this list never starts a new baseline: the saved state is kept and every stored product is re-compared under the new list.

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

Internal: connector ids that receive the run's records as they push.

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

Internal: parent page for the Notion connector.

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

Internal: cap on records sent to connectors per run.

## Actor input object example

```json
{
  "mode": "urls",
  "searchTerms": [
    "drill"
  ],
  "urls": [
    {
      "url": "https://www.lowes.com/search?searchTerm=drill"
    },
    {
      "url": "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill-1-Batteries-Included-Soft-Bag-Included/5016412045"
    }
  ],
  "fetchDetails": true,
  "fetchReviews": true,
  "maxReviews": 20,
  "maxItems": 20,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "US"
  },
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "changeScope": "all",
  "compactUpdates": false,
  "watchStockQuantity": false,
  "expireAfterMissedScans": 1,
  "ignoreFieldsForChanges": [],
  "maxNotifyListings": 50
}
```

# Actor output Schema

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

All records this run produced.

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

Records enriched from their product detail JSON.

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

One row per review collected (unwrapped from the parent products).

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

Incremental mode: NEW, UPDATED and EXPIRED rows from this run.

# 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": "urls",
    "searchTerms": [
        "drill"
    ],
    "urls": [
        {
            "url": "https://www.lowes.com/search?searchTerm=drill"
        },
        {
            "url": "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill-1-Batteries-Included-Soft-Bag-Included/5016412045"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "US"
    },
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "compactUpdates": false,
    "watchStockQuantity": false,
    "ignoreFieldsForChanges": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/lowes-com-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": "urls",
    "searchTerms": ["drill"],
    "urls": [
        { "url": "https://www.lowes.com/search?searchTerm=drill" },
        { "url": "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill-1-Batteries-Included-Soft-Bag-Included/5016412045" },
    ],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "US",
    },
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "compactUpdates": False,
    "watchStockQuantity": False,
    "ignoreFieldsForChanges": [],
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/lowes-com-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": "urls",
  "searchTerms": [
    "drill"
  ],
  "urls": [
    {
      "url": "https://www.lowes.com/search?searchTerm=drill"
    },
    {
      "url": "https://www.lowes.com/pd/CRAFTSMAN-20-volt-Max-1-2-in-Keyless-Brushless-Cordless-Drill-1-Batteries-Included-Soft-Bag-Included/5016412045"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "US"
  },
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "compactUpdates": false,
  "watchStockQuantity": false,
  "ignoreFieldsForChanges": []
}' |
apify call abotapi/lowes-com-scraper --silent --output-dataset

```

## MCP server setup

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