Exact-Variant Competitor Price & Stock Monitor
Pricing
from $10.00 / 1,000 offer observeds
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
4 days ago
Last modified
Categories
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
| Input | Required | What it means |
|---|---|---|
| Catalog items | Yes, unless you use the built-in demo | catalogItemId, title, brand, gtin, mpn, sku, packQuantity |
| Catalog CSV or catalog file URL | No | The same columns, as pasted CSV or an HTTPS JSON/CSV file |
| Approved competitor URLs | Yes, unless you use the demo | url, catalogItemId, optional expectedVariantId and expectedGtin |
| Your currency | No | ISO code used only to flag a mismatch. It never fills in a missing page currency |
| Market label | No | Stored on the row. It does not switch the storefront country |
| Output | No | all (default) or changes_only |
| Reconfirm changes | No | On by default |
| State store name | No | Named key-value store for baselines. Default exact-variant-monitor-state |
| Maximum offers | No | Default 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,packQuantityfilter-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:
matchStatusandmatchMethodsay whether the row is a GTIN, MPN, or SKU match, a title candidate, an ambiguous page, or mapping drift.acceptedForComparisonis true only when the identifier matches and the pack context can be compared.priceis the absolute sticker price of the selected variant.perUnitPriceis that price divided by the observed pack. It is null when the pack was not stated.absolutePricesComparableis 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 absolutepriceDelta.availabilityis the public stock state.visibleInventoryQuantityis included only when the storefront publishes a number.inventoryCountExactis always false.shippingQualifierandshippingPricestay empty unless the page states them.taxIncludedstays null unless the page says tax is included or excluded. A Shopifytaxable: trueflag is not treated as tax-included.changeStatusisbaseline_establishedon the first good read,unchangedwhen the baseline still holds,change_confirmedwhen a second fetch agrees, andchange_unconfirmedwhen it does not. The saved baseline is not replaced on an unconfirmed change or a failed fetch.alertis true only for a reconfirmed price, stock, or pack change, or for a newly confirmed mapping drift.evidenceSnippet,evidenceHash,sourceUrl, andobservedAtare the evidence for the row.chargedis true when that delivered row was billed as oneoffer-observedevent.
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
- GTIN / UPC / EAN, compared after normalizing GTIN-8, GTIN-12, GTIN-13, and GTIN-14.
- MPN, then SKU. Codes are compared without spaces or hyphens.
- 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.
- 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/Offeron 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
expectedCurrencyis 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.
| Run | What happens | Offer events | Offer total | With the $0.005 start event |
|---|---|---|---|---|
| Demo | 5 public URLs, all matched | 5 | $0.05 | $0.055 |
| Weekly watch | 40 mappings, 4 weeks, output all | 160 | $1.60 | about $1.62 |
| Daily agency list | 200 mappings every day for 30 days | 6,000 | $60.00 | about $60.15 |
| One capped run | maxOffers 500, every row matched | 500 | $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
OUTPUTforchargedUnitsandalerts, then read the dataset. Post to Slack or email whenalertis true orcoverageStatusisblocked. - n8n. Use the Apify node to run the Actor, then an HTTP Request node on the dataset items URL. A Filter node keeps
alertequals true. MapcatalogItemId,perUnitPrice,availability, andcompareNoteinto 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.
compareNoteis the cell a buyer can read without opening the source page.
Run it locally
pip install -r requirements.txtpython -m exact_variantpytest
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.