Exact-Variant Competitor Price & Stock Monitor avatar

Exact-Variant Competitor Price & Stock Monitor

Pricing

from $10.00 / 1,000 offer observeds

Go to Apify Store
Exact-Variant Competitor Price & Stock Monitor

Exact-Variant Competitor Price & Stock Monitor

Check an approved competitor URL against one catalog variant. Pack size is normalized, price and stock changes are reconfirmed, and a URL that no longer matches the variant is flagged instead of compared.

Pricing

from $10.00 / 1,000 offer observeds

Rating

0.0

(0)

Developer

Olalekan Johnson

Olalekan Johnson

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 days ago

Last modified

Share

A paid check for brands, specialist retailers, and ecommerce agencies that already know which competitor URL belongs to which sellable unit.

You supply the catalog item (GTIN, MPN, or SKU, plus pack size) and the competitor product URL you approve. The Actor reads the selected variant and tells you whether that offer is the same unit, what it costs per unit, whether it is publicly in stock, and whether a price or stock change survived a second fetch. A URL that no longer points at the same variant is flagged as mapping drift. The previous baseline stays in place.

Unofficial, and not affiliated with Shopify, Allbirds, Death Wish Coffee, or Brooklinen. Those names appear because the built-in demo reads their public product pages. Product text and prices remain the stores' data. This Actor stores a short evidence line and a hash, not a copy of the page.

Who it is for

Use it when a similar title is not good enough. Replacement filters, coffee bags, shoes, and bedding regularly share a name across sizes and pack counts. Comparing a 5-bag price with a 1-bag price as if they were the same offer is a bad purchasing decision. This Actor will not do that.

It is a monitor for a list you already trust. It does not discover competitor sites, place orders, or change your prices.

What you provide

InputRequiredWhat it means
Catalog itemsYes, unless you use the built-in democatalogItemId, title, brand, gtin, mpn, sku, packQuantity
Catalog CSV or catalog file URLNoThe same columns, as pasted CSV or an HTTPS JSON/CSV file
Approved competitor URLsYes, unless you use the demourl, catalogItemId, optional expectedVariantId and expectedGtin
Your currencyNoISO code used only to flag a mismatch. It never fills in a missing page currency
Market labelNoStored on the row. It does not switch the storefront country
OutputNoall (default) or changes_only
Reconfirm changesNoOn by default
State store nameNoNamed key-value store for baselines. Default exact-variant-monitor-state
Maximum offersNoDefault 50, maximum 500

A variant id in the URL (?variant=) selects that variant. If the id is gone, the Actor falls back to GTIN, MPN, SKU, and pack size.

Leave the input empty and the run uses five public product URLs: two Allbirds shoes, one Death Wish Coffee product checked as both a 1-bag and a 5-bag, and one Brooklinen sheet set. The coffee product publishes the same GTIN on three pack sizes, which is the case this Actor is built to separate.

You can also paste a catalog:

catalogItemId,title,brand,gtin,mpn,sku,packQuantity
filter-6,Acme filter 6 pack,Acme,012345678905,AC-6,AC-6,6

What you get

Each dataset row is one flat observation. The important fields:

  • matchStatus and matchMethod say whether the row is a GTIN, MPN, or SKU match, a title candidate, an ambiguous page, or mapping drift.
  • acceptedForComparison is true only when the identifier matches and the pack context can be compared.
  • price is the absolute sticker price of the selected variant. perUnitPrice is that price divided by the observed pack. It is null when the pack was not stated.
  • absolutePricesComparable is true only when your pack quantity and the page pack quantity are known and equal. A 1-pack and a 5-pack never get an absolute priceDelta.
  • availability is the public stock state. visibleInventoryQuantity is included only when the storefront publishes a number. inventoryCountExact is always false.
  • shippingQualifier and shippingPrice stay empty unless the page states them. taxIncluded stays null unless the page says tax is included or excluded. A Shopify taxable: true flag is not treated as tax-included.
  • changeStatus is baseline_established on the first good read, unchanged when the baseline still holds, change_confirmed when a second fetch agrees, and change_unconfirmed when it does not. The saved baseline is not replaced on an unconfirmed change or a failed fetch.
  • alert is true only for a reconfirmed price, stock, or pack change, or for a newly confirmed mapping drift.
  • evidenceSnippet, evidenceHash, sourceUrl, and observedAt are the evidence for the row.
  • charged is true when that delivered row was billed as one offer-observed event.

The key-value record OUTPUT is the run summary: coverage counts, alerts, baselines kept, and chargedUnits.

Example row

Illustrative numbers, not a live quote:

{
"observationId": "obs_0123456789abcdef01234567",
"catalogItemId": "coconut-caramel-5-bag",
"sourceUrl": "https://www.deathwishcoffee.com/products/coconut-caramel-coffee",
"observedAt": "2026-10-23T12:00:00Z",
"extractor": "shopify_product_json",
"coverageStatus": "complete",
"confidence": 0.97,
"confidenceLabel": "high",
"matchStatus": "matched",
"matchMethod": "gtin",
"acceptedForComparison": true,
"mappingDrift": false,
"changeStatus": "baseline_established",
"alert": false,
"charged": true,
"productTitle": "Coconut Caramel Coffee",
"variantTitle": "5 bags",
"seller": "Death Wish Coffee Company",
"gtin": "810063344313",
"sku": "LGCCML45K",
"packQuantity": 5,
"catalogPackQuantity": 5,
"packMismatch": false,
"price": 69.95,
"currency": "USD",
"perUnitPrice": 13.99,
"absolutePricesComparable": true,
"perUnitComparable": true,
"availability": "in_stock",
"visibleInventoryQuantity": null,
"inventoryCountExact": false,
"shippingQualifier": null,
"shippingPrice": null,
"shippingKnown": false,
"taxIncluded": null,
"taxKnown": false,
"priceDelta": null,
"compareNote": "GTIN 810063344313 matches and SKU LGCCML45K matches. Pack quantity 5 selects this variant among others with the same identifier. Pack quantities match, so the absolute price refers to the same sellable unit. No shipping price or qualifier was stated. Shipping stays unknown. Tax treatment was not stated and stays unknown."
}

A title-only row is matchStatus: candidate_for_review, acceptedForComparison: false, and it is not charged. An ambiguous pack, a blocked store, or an empty page is delivered so you can see the gap, and it is not charged.

How a match is decided

  1. GTIN / UPC / EAN, compared after normalizing GTIN-8, GTIN-12, GTIN-13, and GTIN-14.
  2. MPN, then SKU. Codes are compared without spaces or hyphens.
  3. Pack quantity, when one identifier matches several variants. "5 bags" and "6-pack" are read from the variant title. A bare size such as "8" or "Queen" is one sellable unit, not an 8-pack.
  4. The variant id in the URL, when you supplied one.

If the SKU and the pack size point at different variants, no price is selected. Title similarity is only a candidate for a person to review. It never becomes an accepted comparison, even when the titles are close and the brand matches.

If your GTIN is on the page but the pack size is not stated, the absolute price is reported as evidence and then withheld from comparison. No per-unit price is invented.

State, reconfirm, and drift

Baselines live in the named key-value store, one record per catalog item and URL. The first good, comparable observation is baseline_established. It is not an alert.

On the next run, a change in price, per-unit price, currency, pack, or availability is fetched a second time before any alert. The two reads must agree. If they do, the baseline updates and alert is true. If they disagree, or the second fetch fails, the old baseline stays and changeStatus is change_unconfirmed.

Mapping drift is separate from a price change. It is raised when the GTIN, variant id, or (when there is no GTIN) SKU no longer matches the saved baseline, or when the page contradicts the GTIN you approved. The new price is shown so you can see it. It is not written over the approved baseline, and no price delta is calculated. The same drift on later runs is reported without a new alert.

A blocked, empty, or crashed URL does not write the baseline. An errored run cannot replace a good record with an empty one, because each mapping is its own key and failed mappings are left untouched.

outputMode: changes_only writes baselines, confirmed changes, unconfirmed changes, and new drift. Unchanged checks are left out of the dataset and are not charged. Use all when you want every check on the record and on the bill. Blocked and ambiguous rows are still written in both modes.

Scheduled runs should keep the same stateStoreName. Different clients or markets should use different store names.

Scope and limits

Reads public pages with a normal HTTP client. No browser and no login.

Supported sources, in order:

  • Shopify product JSON at /products/{handle}.js (price, variant, SKU, barcode, and availability).
  • JSON-LD Product / Offer on the HTML page.
  • Microdata.
  • Open Graph product price tags, which are partial: they rarely identify a variant.

A low-to-high AggregateOffer is not treated as a selected price.

Other limits:

  • You must supply the URL. There is no site-wide crawl.
  • Pack size comes from the variant and product titles. A pack that is only in a photograph will be unknown.
  • The currency is the currency this fetch actually received. Shopify can show a different presentment currency based on where the run is located. The row records that currency. It does not convert it, and your expectedCurrency is only a mismatch flag.
  • Shipping text is a phrase seen on the page, such as "Free shipping" or "Shipping calculated at checkout". It is not a checkout quote. If the phrase is missing, shipping stays unknown.
  • Public availability is not a warehouse count, even when a quantity is visible.
  • Condition stays unknown unless the page says new, used, or refurbished.
  • Requests are sequential, with about one second between calls to the same host, a 25 second timeout, and retries on 429 and 5xx. Private IPs, localhost, and file:// URLs are rejected.
  • At most 500 mappings per run.

Pricing

This Actor uses pay-per-event pricing. You pay $0.01 per delivered, identifier-backed offer (offer-observed), plus $0.005 once per run (apify-actor-start).

You are charged once per delivered matched or mapping-drift row. Blocked, empty, ambiguous, unverified, and title-only rows are not charged. The same observation is not charged again on a retry inside one run, and the second fetch used to reconfirm a change is part of that one observation, not a second event.

This version does not render pages in a browser. There is no separate per-result charge.

RunWhat happensOffer eventsOffer totalWith the $0.005 start event
Demo5 public URLs, all matched5$0.05$0.055
Weekly watch40 mappings, 4 weeks, output all160$1.60about $1.62
Daily agency list200 mappings every day for 30 days6,000$60.00about $60.15
One capped runmaxOffers 500, every row matched500$5.00$5.005

Set the run's maximum charge in Apify. When that limit is reached the Actor stops, writes the rows already billed, and still saves OUTPUT. A 200-URL daily list is the workload behind the $60 example. It is not a forecast of how many URLs a buyer will submit.

changes_only is cheaper because unchanged rows are not delivered and not billed. The audit trail is the all mode.

Integrations

Rows are flat, so a spreadsheet, a webhook, and an automation can all read them without unpacking nested objects.

  • Webhook. Schedule the Actor. On the run-succeeded webhook, read OUTPUT for chargedUnits and alerts, then read the dataset. Post to Slack or email when alert is true or coverageStatus is blocked.
  • n8n. Use the Apify node to run the Actor, then an HTTP Request node on the dataset items URL. A Filter node keeps alert equals true. Map catalogItemId, perUnitPrice, availability, and compareNote into the rest of the workflow.
  • Make. The Apify module runs the Actor and watches the dataset. Route on changeStatus.
  • Google Sheets. Export the dataset as CSV, or append dataset items with the columns above. compareNote is the cell a buyer can read without opening the source page.

Run it locally

pip install -r requirements.txt
python -m exact_variant
pytest

An empty input uses the built-in demo. Results land in storage/datasets/default and the summary in storage/key_value_stores/default/OUTPUT.json. Baselines land in storage/key_value_stores/exact-variant-monitor-state.

Deploy with apify push from this directory when you are ready. The Actor asks for 512 MB and will not start above 1024 MB. No proxy and no API key are required for the demo.

FAQ

Why was a price change not alerted? The second fetch did not match the first, or the pack size made the absolute prices incomparable. change_unconfirmed means the old baseline was kept on purpose.

Why is perUnitPrice empty? The page did not state a pack quantity. The absolute price is still in price, and it is not compared.

The same GTIN is on three variants. Which price did you use? The SKU, MPN, or pack quantity has to select one. If they disagree, the row is ambiguous, the price is empty, and the row is not charged.

Is the stock count exact? No. availability is the public state. A visible quantity is the figure the storefront chose to print.

Why is tax or shipping null? The page did not say. Unknown stays unknown.

The product at that URL changed. mappingDrift is true and the approved baseline is still the previous variant. Approve the new mapping before those prices are compared.

Does it work on any store? It reads Shopify product JSON and pages that publish JSON-LD, microdata, or Open Graph. A store that only paints the price with JavaScript after load will come back empty or partial. That is a blocked or empty result, not a guessed price.

Can I send a private URL? No. Localhost, private networks, link-local addresses, and non-HTTP URLs are rejected.

Support

Questions and reproducible reports: plusonecares@gmail.com. Include the input mapping (without secrets), the observationId, and whether coverageStatus was complete, partial, empty, blocked, or unsupported.