ecommerce13 min read

How to Monitor Competitor Prices With n8n (Free Template)

Last tested on n8n 2.45, November 2026.

Checking competitor prices manually in a spreadsheet goes stale by Tuesday, while dedicated price-monitoring apps charge US$99 to US$399 a month and still make you paste in every competitor URL yourself. Even most scraping templates get the economics backwards by routing every check through a paid headless browser API when a plain HTTP request can read the structured data on most stores for free.

By the end of this guide, you’ll have a four-lane n8n workflow that searches Google for your product model numbers to discover competitor listings automatically, re-reads their prices politely on a schedule using JSON-LD structured data (with Firecrawl only as a fallback for blocked pages), and sends a weekly Slack report of every product where you’re being undercut.

What you’ll build

Every Monday morning, the workflow posts a compact Slack digest listing the products where a verified competitor is cheaper than you beyond your threshold, showing your price, the competitor’s price, the amount and percentage gap, and a direct link to their page.

The weekly Slack report: ten products where a competitor is cheaper, each with the seller, both prices and the gap, and the health line at the bottom

Under the hood, the workflow separates discovery (finding which stores sell your exact model number) from recapture (re-reading known URLs). Searching once every two to four weeks and re-reading known URLs with a plain HTTP request is what keeps the monthly bill down to a few dollars.

The full template canvas: the overview note, then the Setup, Discover, Recapture and Report lanes, each in its own section

What you need

  • n8n with Data Tables: the free self-hosted Community Edition (recommended) or n8n Cloud.
  • A Shopify app with the read_products scope, made in the Shopify Dev Dashboard, plus its client ID and secret. Shopify stopped issuing permanent admin tokens for new custom apps in January 2026, so the workflow requests a fresh 24-hour token on each run.
  • A DataForSEO account for automated Google SERP discovery (US$0.002 per 10 results, US$50 minimum payment), or skip DataForSEO entirely and paste known competitor URLs into the table for the free path.
  • Firecrawl (optional, free tier includes 1,000 credits a month) as a fallback for pages behind bot walls, or Crawl4AI if you self-host.
  • Slack (or Gmail) for the weekly undercut digest. No AI model is required.

How it works

  1. Setup (Manual Trigger): Creates competitor_listings, competitor_prices, and competitor_searches Data Tables.
  2. Discover (Schedule Trigger, daily batch): Pulls active products and MPNs from Shopify, searches Google via DataForSEO every 14 to 30 days, filters for whole-token MPN matches (rejecting multi-packs and lookalike suffixes), and upserts verified listings.
  3. Recapture (Schedule Trigger, every 6 hours): Checks robots.txt, re-reads known product URLs politely with a Wait node, parses JSON-LD Product/Offer prices, and uses Firecrawl only for blocked pages.
  4. Report (Schedule Trigger, weekly): Compares verified competitor prices against yours and posts the top 20 undercuts to Slack.

Step 1: Create the tables and settings

In the Setup lane, add a Manual Trigger connected to three Data table nodes set to Table -> Create (Reuse Existing Tables enabled). Remember that n8n Data Tables share a 200 MiB default limit per instance, so we only insert a row into competitor_prices when a price actually changes:

TableOne row perColumns
competitor_listingsproduct per competitor pageString: listing_key, product_key, sku, mpn, gtin, product_title, url, domain, seller, listing_title, matched_by, currency, status, method
Number: confidence, last_price, last_change, pending_price
Boolean: in_stock
Date: last_checked_at
competitor_pricesprice change (the price history)String: listing_key, product_key, domain, method
Number: price
Boolean: in_stock
Date: captured_at
competitor_searchesproduct, with when it was last searchedString: product_key, query
Number: listings_found, rejected, cost_usd
Date: last_searched_at

Next, add an Edit Fields (Set) node in JSON mode at the start of each operational lane, named Discover settings, Recapture settings and Report settings:

{
  "store_domain": "your-store.myshopify.com",
  "own_domain": "yourstore.com",
  "exclude_domains": [],
  "currency": "USD",
  "locale": "en-US",
  "country": "US",
  "search_location": "United States",
  "language_code": "en",
  "search_depth": 20,
  "prices_include_tax": false,
  "market_shows_tax": false,
  "tax_rate": 0,
  "tax_exclusive_domains": [],
  "mpn_source": "metafield",
  "mpn_metafield_namespace": "custom",
  "mpn_metafield_key": "mpn",
  "user_agent": "YourStorePriceCheck/1.0 (+https://yourstore.com/price-check)",
  "robots_agent": "YourStorePriceCheck",
  "discovery_batch": 100,
  "research_with_listings_days": 30,
  "research_without_listings_days": 14,
  "recapture_every_hours": 120,
  "recapture_batch": 300,
  "min_delay_seconds": 10,
  "max_price_move": 0.6,
  "use_firecrawl": true,
  "firecrawl_per_run": 8,
  "min_confidence": 0.9,
  "max_price_age_days": 7,
  "min_price_ratio": 0.5,
  "undercut_threshold": 0.02,
  "report_limit": 20,
  "slack_channel": "#pricing"
}

Set market_shows_tax to true where shoppers see tax-inclusive prices, and list any trade or B2B competitors whose JSON-LD prices exclude tax in tax_exclusive_domains.

The Data tables tab listing competitor_listings, competitor_prices and competitor_searches

Step 2: Pull your products and their identifiers

Searching Google by your full product title brings back noisy results and blog posts, whereas searching by the manufacturer part number (MPN) or GTIN surfaces the exact product pages on rival stores. In the Discover lane, add a daily Schedule Trigger and Discover settings, then an HTTP Request node called Get Shopify token that swaps your client ID and secret for a 24-hour token through the client credentials grant:

SettingValue
MethodPOST
URLhttps://{{ $json.store_domain }}/admin/oauth/access_token
AuthenticationGeneric Credential Type > Custom Auth: {"body": {"client_id": "<your client ID>", "client_secret": "<your client secret>"}}
Send BodyForm Urlencoded, grant_type = client_credentials
Settings > Retry On FailOn

Because n8n’s built-in Shopify node doesn’t return metafields, use a second HTTP Request node, Get my products, with that token calling Admin GraphQL API 2026-10 (productVariants query):

SettingValue
MethodPOST
URLhttps://{{ $('Discover settings').first().json.store_domain }}/admin/api/2026-10/graphql.json
Send HeadersX-Shopify-Access-Token = {{ $('Get Shopify token').first().json.access_token }}
Send BodyJSON (Using JSON)
Pagination > ModeUpdate a Parameter in Each Request
Pagination > TypeBody
Pagination > Namevariables
Pagination > Value{{ { cursor: $response.body.data.productVariants.pageInfo.endCursor, ns: $('Discover settings').first().json.mpn_metafield_namespace, key: $('Discover settings').first().json.mpn_metafield_key } }}
Pagination Complete WhenOther
Complete Expression{{ !$response.body.data.productVariants.pageInfo.hasNextPage }}

Its JSON body carries the query and the variables the pagination updates:

{{ JSON.stringify({
  query: `query Variants($cursor: String, $ns: String, $key: String!) {
    productVariants(first: 100, after: $cursor, query: "product_status:active") {
      pageInfo { hasNextPage endCursor }
      nodes {
        id
        sku
        price
        title
        barcodes(first: 5) { nodes { value } }
        mpn: metafield(namespace: $ns, key: $key) { value }
        product {
          title
          vendor
          mpn: metafield(namespace: $ns, key: $key) { value }
        }
      }
    }
  }`,
  variables: { cursor: null, ns: $('Discover settings').first().json.mpn_metafield_namespace, key: $('Discover settings').first().json.mpn_metafield_key }
}) }}

The pagination options on Get my products: Update a Parameter in Each Request, a Body parameter named variables, and a complete expression on hasNextPage

Connect a Data table Get node for competitor_searches (Get search log, with Execute Once and Always Output Data on so the first, empty run doesn’t stop the lane), then a Code node that keeps variants with an MPN or barcode, filters for items not searched within research_with_listings_days (30 days, or 14 for products with no listings), prioritises never-searched items, and slices to discovery_batch (100):

// One item per variant that has a model number or barcode and is due a search.
const s = $('Discover settings').first().json;
const day = 864e5;
const log = {};
for (const r of $('Get search log').all()) log[r.json.product_key] = r.json;

const due = [];
for (const page of $('Get my products').all()) {
  for (const v of page.json.data.productVariants.nodes) {
    const mpn = String((s.mpn_source === 'sku' ? v.sku : (v.mpn?.value || v.product.mpn?.value)) || '').trim();
    const gtin = v.barcodes.nodes.map(b => String(b.value).replace(/\D/g, '')).find(b => b.length >= 8) || '';
    if (!mpn && gtin.length < 8) continue;                       // nothing to match on: see "Make it your own"
    const last = log[v.id];
    const age = last ? (Date.now() - new Date(last.last_searched_at)) / day : 9999;
    const every = last?.listings_found > 0 ? s.research_with_listings_days : s.research_without_listings_days;
    if (age < every) continue;
    const strong = /\d/.test(mpn) && !/\s/.test(mpn) && mpn.replace(/[^A-Za-z0-9]/g, '').length >= 6;
    due.push({ json: { product_key: v.id, sku: v.sku, mpn, gtin, brand: v.product.vendor, age,
      title: v.product.title + (v.title === 'Default Title' ? '' : ' ' + v.title),
      query: mpn ? (strong ? mpn : `${v.product.vendor} ${mpn}`) : gtin } });
  }
}
return due.sort((a, b) => b.json.age - a.json.age).slice(0, s.discovery_batch);

Step 3: Search for each model number

To use the free path, paste competitor URLs into competitor_listings (with your variant’s gid://shopify/ProductVariant/… ID as product_key, status ok and confidence 1) and skip to Step 5. Otherwise, call DataForSEO’s Google Organic Live Advanced endpoint (POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced) in an HTTP Request node:

SettingValue
MethodPOST
URLhttps://api.dataforseo.com/v3/serp/google/organic/live/advanced
AuthenticationGeneric Credential Type > Basic Auth (your DataForSEO API login and password)
Send BodyJSON
Options > Batching > Items per Batch1
Options > Response > Never ErrorOn
Options > Timeout60000
Settings > On ErrorContinue

with this body:

{{ JSON.stringify([{
  keyword: $json.query,
  location_name: $('Discover settings').first().json.search_location,
  language_code: $('Discover settings').first().json.language_code,
  depth: $('Discover settings').first().json.search_depth
}]) }}

DataForSEO returns both organic results (often carrying price.current and price.currency) and Google Shopping panel offers, giving many listings their first price reading at no extra cost. Never Error turns an empty balance’s 402 into a message the next node reads, rather than a stopped run.

Step 4: Keep only exact matches

Searching for a Hikvision DS-2CD2366G2-I also surfaces DS-2CD2366G2-IU (built-in mic, higher price), searching RLC-810A surfaces RLC-810A 2-Pack bundles, and wordy names like Argus 4 Pro need the brand (Reolink) in the title. Add a Code node named Keep exact matches to enforce whole-token MPN matching, brand presence on short or spaced MPNs, and pack rejection:

// Turn each search into competitor listings, keeping only exact matches.
const s = $('Discover settings').first().json;
const sent = $('Pick products to search').all();
const results = $input.all();
const skip = [s.own_domain, ...s.exclude_domains].map(d => d.toLowerCase().replace(/^www\./, ''));
const host = u => (String(u).match(/^https?:\/\/([^/?#:]+)/i) || [])[1]?.toLowerCase().replace(/^www\./, '');
const DROP = /^(srsltid|utm_[a-z]+|gclid|variant|currency|country)$/i;
const cleanUrl = u => {
  const [base, q = ''] = String(u).split('#')[0].split('?');
  const kept = q.split('&').filter(kv => kv && !DROP.test(kv.split('=')[0]));
  return base + (kept.length ? '?' + kept.join('&') : '');
};

// Model number as a whole token, any separator: DS-2CD2366G2-I matches "ds 2cd2366g2 i", not DS-2CD2366G2-IU.
const chars = m => String(m).toUpperCase().replace(/[^A-Z0-9]/g, '').split('');
const titleRe = m => new RegExp('(^|[^A-Z0-9])' + chars(m).join('[\\s\\-_/.]?') + '(?![A-Z0-9]|-[A-Z0-9])');
const slugRe = m => new RegExp('(^|[^A-Z0-9])' + chars(m).join('[\\-_.]?') + '(?![A-Z0-9])');
const PACK = /\b(\d+|two|three|four|twin)[\s-]*(pack|pk)\b|\bpack of \d+|\bbundle\b|\bkit\b/i;

function match(q, title, url) {
  const T = String(title || '').toUpperCase(), U = String(url || '').split('?')[0].toUpperCase(); // path only: Google links carry the query
  if (PACK.test(title) && !PACK.test(q.title)) return null;          // a 2-pack isn't your single camera
  if (q.gtin && (T + ' ' + U).includes(q.gtin)) return { by: 'gtin', confidence: 1 };
  if (!q.mpn) return null;
  const strong = /\d/.test(q.mpn) && !/\s/.test(q.mpn) && q.mpn.replace(/[^A-Za-z0-9]/g, '').length >= 6;
  if (!strong && !T.includes(String(q.brand).toUpperCase())) return null; // short names need the brand
  if (titleRe(q.mpn).test(T)) return { by: 'mpn', confidence: strong ? 1 : 0.9 };
  if (slugRe(q.mpn).test(U)) return { by: 'mpn_url', confidence: strong ? 0.85 : 0.75 };
  return null;
}

function offersFrom(items = []) {
  const out = [];
  for (const it of items) {
    if (it.type === 'organic' && it.price?.current)
      out.push({ seller: it.website_name || it.domain, url: it.url, title: it.title, price: it.price.current, currency: it.price.currency });
    if (it.type === 'knowledge_graph')
      for (const sub of it.items || []) for (const el of sub.items || [])
        if (el.type === 'knowledge_graph_shopping_element' && el.price?.current)
          out.push({ seller: el.source, url: el.url, title: el.title, price: el.price.current, currency: el.price.currency });
    if ((it.type === 'popular_products' || it.type === 'shopping') && Array.isArray(it.items))
      for (const el of it.items) if (el.price?.current)
        out.push({ seller: el.seller || el.source, url: el.url, title: el.title, price: el.price.current, currency: el.price.currency });
  }
  return out;
}

const out = [];
const now = new Date().toISOString();
sent.forEach((p, i) => {
  const q = p.json;
  const task = results[i]?.json?.tasks?.[0];
  if (task?.status_code !== 20000) {
    out.push({ json: { kind: 'failed', query: q.query, error: task?.status_message || String(results[i]?.json?.error?.message || 'no response').slice(0, 200) } });
    return;
  }
  let kept = 0, rejected = 0;
  for (const o of offersFrom(task.result?.[0]?.items)) {
    const d = host(o.url);
    const m = d && o.currency === s.currency && !skip.some(x => d === x || d.endsWith('.' + x)) && match(q, o.title, o.url);
    if (!m) { rejected++; continue; }
    kept++;
    const url = cleanUrl(o.url);
    out.push({ json: { kind: 'listing', listing_key: q.product_key + '|' + url, product_key: q.product_key,
      sku: q.sku, mpn: q.mpn, gtin: q.gtin, product_title: q.title, url, domain: d,
      seller: String(o.seller || d).slice(0, 80), listing_title: o.title, matched_by: m.by, confidence: m.confidence,
      last_price: Number(o.price), currency: o.currency, in_stock: null, status: 'ok', method: 'search',
      last_checked_at: now, last_change: 0, pending_price: 0 } });
  }
  out.push({ json: { kind: 'search', product_key: q.product_key, query: q.query, last_searched_at: now,
    listings_found: kept, rejected, cost_usd: Number(task.cost || 0) } });
});
return out;

The URL check reads only the path, because Google Shopping links carry your search in their query string. Connect a Switch node, Route by kind, on {{ $json.kind }}: listing goes to a Data table Upsert on competitor_listings (matching on listing_key), search to an Upsert on competitor_searches (matching on product_key), and failed to a Slack alert with Execute Once on.

Keep exact matches output in JSON view: a search item with its query, listings_found, rejected and cost_usd, followed by the first listing

Step 5: Re-read each listing politely

In the Recapture lane, add a Schedule Trigger (every 6 hours) followed by a Data table Get node that pulls rows from competitor_listings where status is not 'robots_disallowed' and last_checked_at is older than recapture_every_hours (120 hours):

SettingValue
Must MatchAll Conditions
Condition 1last_checked_at Less Than {{ $now.minus({ hours: $json.recapture_every_hours }).toISO() }}
Condition 2status Not Equals robots_disallowed
Order Bylast_checked_at, Ascending
Limit{{ $json.recapture_batch }}

The Get listings due a check node: All Conditions, last_checked_at Less Than the expression, status Not Equals robots_disallowed, and Order By last_checked_at Ascending

Before fetching product pages, check each domain’s robots.txt once per run and space out requests to the same domain so you never hammer a small store’s server. Following RFC 9309 (the Robots Exclusion Protocol), a 4xx response on /robots.txt means you may crawl, a 5xx response means assume disallow, and the longest matching path wins. Add a Code node named Check robots.txt:

// Fetch robots.txt once per site, drop what it disallows, and plan a polite gap per site.
// RFC 9309: a 4xx means no rules; a 5xx or no answer means treat the whole site as disallowed.
const s = $('Recapture settings').first().json;
const token = s.robots_agent.toLowerCase();
const originOf = u => (String(u).match(/^(https?:\/\/[^/?#]+)/i) || [])[1]?.toLowerCase();
const pathOf = u => String(u).replace(/^https?:\/\/[^/?#]+/i, '').split('#')[0] || '/';

function parseRobots(txt) {
  const groups = [];
  let cur = null, lastWasAgent = false;
  for (const raw of String(txt).split(/\r?\n/)) {
    const m = raw.replace(/#.*$/, '').trim().match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
    if (!m) continue;
    const key = m[1].toLowerCase(), val = m[2].trim();
    if (key === 'user-agent') {
      if (!lastWasAgent) groups.push(cur = { agents: [], rules: [], delay: null });
      cur.agents.push(val.toLowerCase());
      lastWasAgent = true;
      continue;
    }
    lastWasAgent = false;
    if (!cur) continue;
    if ((key === 'allow' || key === 'disallow') && val) cur.rules.push({ allow: key === 'allow', path: val });
    if (key === 'crawl-delay' && !isNaN(Number(val))) cur.delay = Number(val);
  }
  const mine = groups.filter(g => g.agents.some(a => a !== '*' && token.includes(a)));
  const chosen = mine.length ? mine : groups.filter(g => g.agents.includes('*'));
  return { rules: chosen.flatMap(g => g.rules), delay: chosen.map(g => g.delay).find(d => d != null) ?? null };
}
const toRegex = p => {
  const end = p.endsWith('$');
  const body = (end ? p.slice(0, -1) : p).split('*').map(x => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
  return new RegExp('^' + body + (end ? '$' : ''));
};
function allowed(rules, path) {
  let best = null;                       // the longest matching rule wins; Allow wins a tie
  for (const r of rules) if (toRegex(r.path).test(path) &&
      (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow))) best = r;
  return !best || best.allow;
}

const rows = $input.all().map(i => i.json);
const robots = {};
for (const origin of [...new Set(rows.map(r => originOf(r.url)))]) {
  try {
    const res = await this.helpers.httpRequest({ url: origin + '/robots.txt', returnFullResponse: true,
      ignoreHttpStatusErrors: true, timeout: 15000, headers: { 'User-Agent': s.user_agent } });
    robots[origin] = res.statusCode >= 500 ? { rules: [{ allow: false, path: '/' }] }
      : res.statusCode >= 400 ? { rules: [] } : parseRobots(res.body);
  } catch (e) {
    robots[origin] = { rules: [{ allow: false, path: '/' }] };
  }
}

// Interleave sites so one site is never hit twice inside its gap (min_delay_seconds or its Crawl-delay).
const queues = {};
const out = [];
for (const r of rows) {
  const origin = originOf(r.url);
  if (allowed(robots[origin].rules, pathOf(r.url))) (queues[origin] ||= []).push(r);
  else out.push({ json: { ...r, fetch: false } });
}
let clock = 0;
const lastHit = {};
while (Object.values(queues).some(q => q.length)) {
  for (const [origin, q] of Object.entries(queues)) {
    if (!q.length) continue;
    const gap = Math.max(s.min_delay_seconds, robots[origin].delay || 0);
    const ready = origin in lastHit ? Math.max(clock, lastHit[origin] + gap) : clock;
    out.push({ json: { ...q.shift(), fetch: true, wait_seconds: Math.ceil(ready - clock) } });
    clock = lastHit[origin] = ready;
  }
}
return out;

Route disallowed items with an If node (Allowed by robots.txt?) to a Data table Update that sets only their status to 'robots_disallowed', so a Google Shopping offer keeps its last price until it ages out. Pass allowed items into a Loop Over Items node (batch size 1), a Wait node set to {{ Math.max(1, $json.wait_seconds) }} seconds, and an HTTP Request node called Fetch page:

SettingValue
MethodGET
URL{{ $json.url }}
HeadersUser-Agent = {{ $('Recapture settings').first().json.user_agent }}
Options > Response > Include Response Headers and StatusOn
Options > Response > Never ErrorOn
Options > Response > Response FormatText
Options > Redirects > Follow RedirectsOff
Options > Timeout30000
Settings > On ErrorContinue

Step 6: Read the price from the page

Most stores output Google Merchant Listing JSON-LD (Product and Schema.org Offer) in the initial HTML. On a store I run, 26 of 29 prices on the first run came from JSON-LD.

Because blocked requests often look like normal 200 pages, add a Code node called Read price that detects bot walls, reads JSON-LD (@graph and nested offers) then meta/itemprop tags, and holds moves over max_price_move (60%) as 'suspect' until a second reading agrees:

// Read the price from one page: JSON-LD Product offers first, then price meta tags, then itemprop.
// Reads a plain HTTP Request response or a Firecrawl /v2/scrape response.
const s = $('Recapture settings').first().json;
const src = $('Loop Over Items').first().json;           // the listing being read
const r = $input.first().json;
const fc = $prevNode.name === 'Firecrawl scrape';        // which request fed this run
const via = fc ? 'firecrawl' : 'http';
const html = String((fc ? r.data?.rawHtml : (r.body ?? r.data)) || '');
const status = Number(fc ? r.data?.metadata?.statusCode : r.statusCode) || 0;
const errMsg = String(r.error?.message ?? r.error ?? '');
const num = v => { const n = Number(String(v ?? '').replace(/[^0-9.]/g, '')); return n > 0 ? n : null; };
const clean = v => String(v).toUpperCase().replace(/[^A-Z0-9]/g, '');
const ours = [src.mpn, src.gtin, src.sku].filter(Boolean).map(clean);
const idsOf = o => ['sku', 'mpn', 'gtin', 'gtin8', 'gtin12', 'gtin13', 'gtin14'].map(k => o?.[k]).filter(Boolean).map(clean);

function fromJsonLd(h) {
  const re = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  const found = [];
  let m;
  while ((m = re.exec(h))) {
    let data;
    try { data = JSON.parse(m[1].trim()); } catch (e) { continue; }
    for (const n of [].concat(data).flatMap(d => d?.['@graph'] || [d])) {
      if (![].concat(n?.['@type'] || []).some(t => /Product/i.test(t))) continue;
      for (const o of [].concat(n.offers || [])) {
        for (const x of o?.offers ? [].concat(o.offers) : [o]) {      // AggregateOffer holds nested offers
          const price = num(x?.price ?? x?.lowPrice ?? x?.priceSpecification?.price);
          if (!price) continue;
          const avail = String(x?.availability || o?.availability || '');
          found.push({ price, how: 'jsonld', ids: [...idsOf(x), ...idsOf(n)],
            currency: x?.priceCurrency || o?.priceCurrency || x?.priceSpecification?.priceCurrency || null,
            in_stock: avail ? /InStock|LimitedAvailability|PreOrder|BackOrder/i.test(avail) : null });
        }
      }
    }
  }
  // On a page with several variants, prefer the offer that carries your model number or barcode.
  return found.find(f => f.ids.some(id => ours.includes(id))) || found[0] || null;
}
function fromMeta(h) {
  const p = h.match(/<meta[^>]+(?:property|name)=["'](?:product|og):price:amount["'][^>]*content=["']([^"']+)["']/i)
         || h.match(/<meta[^>]+content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:product|og):price:amount["']/i);
  if (p && num(p[1])) {
    const c = h.match(/(?:product|og):price:currency["'][^>]*content=["']([^"']+)["']/i);
    return { price: num(p[1]), currency: c ? c[1] : null, in_stock: null, how: 'meta' };
  }
  const ip = h.match(/itemprop=["']price["'][^>]*content=["']([^"']+)["']/i);
  return ip && num(ip[1]) ? { price: num(ip[1]), currency: null, in_stock: null, how: 'itemprop' } : null;
}

const found = html ? (fromJsonLd(html) || fromMeta(html)) : null;
const head = html.slice(0, 6000).toLowerCase();
const walls = ['just a moment', 'verify you are human', 'checking your browser', 'access denied', 'captcha',
               'cf-chl', 'attention required', 'request unsuccessful', 'are you a robot', 'pardon our interruption'];
const blocked = !found && (fc || [302, 303, 307, 401, 403, 503].includes(status) || /403|access denied|ECONNRESET/i.test(errMsg)
                           || walls.some(w => head.includes(w)));

// Fetch page doesn't follow redirects. A permanent one (301, 308) saves the new address, which goes back through
// the robots check on the next run; a temporary one (302, 303, 307) is usually a bot check, so it counts as blocked.
const loc = !fc && [301, 308].includes(status) && (r.headers?.location || r.headers?.Location);
const moved = loc && (/^https?:/i.test(loc) ? loc : src.url.match(/^https?:\/\/[^/]+/i)[0] + (loc.startsWith('/') ? '' : '/') + loc);
// A 429 asks you to slow down: wait out Retry-After (seconds or a date; an hour if it's missing), no Firecrawl.
const ra = r.headers?.['retry-after'];
const raSec = ra ? (isNaN(Number(ra)) ? (Date.parse(ra) - Date.now()) / 1000 : Number(ra)) : NaN;
const retrySec = Number.isFinite(raSec) ? raSec : 3600;

// Start from what the table already holds, so a failed read never wipes the last good price.
const row = { status: 'ok', method: via, url: src.url, last_checked_at: new Date().toISOString(), last_price: src.last_price,
              in_stock: src.in_stock, last_change: 0, pending_price: src.pending_price || 0 };
const last = Number(src.last_price) || 0;
if (moved) Object.assign(row, { status: 'moved', url: moved, last_checked_at: new Date(0).toISOString() });
else if (!fc && status === 429) Object.assign(row, { status: 'rate_limited',
  last_checked_at: new Date(Date.now() + Math.max(retrySec, 60) * 1000 - s.recapture_every_hours * 36e5).toISOString() });
else if (blocked) row.status = 'blocked';
else if (!found) row.status = html ? 'no_price' : 'error';
else if (found.currency && found.currency !== s.currency) row.status = 'wrong_currency';
else {
  row.method = via + ':' + found.how;
  const jump = last && Math.abs(found.price - last) / last > s.max_price_move;
  const confirmed = row.pending_price && Math.abs(found.price - row.pending_price) / row.pending_price < 0.02;
  if (jump && !confirmed) Object.assign(row, { status: 'suspect', pending_price: found.price }); // wait for a second reading
  else Object.assign(row, { last_price: found.price, in_stock: found.in_stock, pending_price: 0,
                            last_change: last ? Math.round((found.price - last) * 100) / 100 : 0 });
}

// Firecrawl only for blocked pages, and only up to firecrawl_per_run pages a run.
let try_firecrawl = false;
if (row.status === 'blocked' && !fc && s.use_firecrawl) {
  const st = $getWorkflowStaticData('global');
  if (st.run !== $execution.id) Object.assign(st, { run: $execution.id, firecrawl: 0 });
  try_firecrawl = st.firecrawl++ < s.firecrawl_per_run;
}
return [{ json: { listing_key: src.listing_key, row, try_firecrawl } }];

A 429 marks the listing rate_limited and waits out the site’s Retry-After time rather than going to Firecrawl. A permanent redirect (301, 308) saves the new address as moved so it passes the robots check before it’s read, while a temporary one (302, 303, 307) counts as blocked, because it’s usually a bot check.

competitor_listings after a Recapture run: matched_by, confidence, last_price, in_stock, status and method, with most rows read from http:jsonld and three through firecrawl:jsonld

Step 7: Use Firecrawl for pages that block you

Add an If node after Read price (Try Firecrawl?) checking {{ $json.try_firecrawl }}, which is only true for blocked pages and only up to firecrawl_per_run (8) a run. The true branch goes to an HTTP Request node calling Firecrawl’s /v2/scrape endpoint (POST https://api.firecrawl.dev/v2/scrape):

SettingValue
MethodPOST
URLhttps://api.firecrawl.dev/v2/scrape
AuthenticationGeneric Credential Type > Header Auth (Name Authorization, Value Bearer <your API key>)
Options > Response > Never ErrorOn
Options > Timeout90000
Settings > Always Output DataOn
Settings > On ErrorContinue

with this body:

{{ JSON.stringify({
  url: $('Loop Over Items').first().json.url,
  formats: ['rawHtml'],
  onlyMainContent: false,
  location: { country: $('Recapture settings').first().json.country },
  proxy: 'auto',
  maxAge: 3600000
}) }}

Route Firecrawl’s response back into the same parser. The false branch updates competitor_listings (a Data table Update on listing_key, mapping each column, including url, from $json.row, with Always Output Data on), and an If node, Price changed?, inserts into competitor_prices when row.status is ok and row.last_change isn’t zero. Self-hosters can also run Crawl4AI (unclecode/crawl4ai on port 11235) for free JS rendering.

The Recapture loop: Loop Over Items, Wait between requests, Fetch page, Read price, Try Firecrawl? with Firecrawl scrape looping back into Read price, Update listing, Price changed? and Save price change

Step 8: Find undercuts and send the report

In the Report lane, add a weekly Schedule Trigger (Mondays at 08:00) that reads current Shopify prices (copies of Step 2’s two requests) and competitor_listings, then a Code node that keeps in-stock listings matched at 0.9 or higher and above min_price_ratio, evens out tax, calculates the gap, and flags health warnings if zero prices were read or more than half are blocked:

// Cheapest trusted competitor per product, compared with your current price, plus a health check.
const s = $('Report settings').first().json;
const day = 864e5, now = Date.now();
const mine = {};
for (const page of $('Get my prices').all())
  for (const v of page.json.data.productVariants.nodes) mine[v.id] = { price: Number(v.price), title: v.product.title };
// Compare like with like: tax-inclusive against tax-inclusive.
const comparable = p => s.market_shows_tax === s.prices_include_tax ? p
  : s.market_shows_tax ? p * (1 + s.tax_rate) : p / (1 + s.tax_rate);
const money = n => new Intl.NumberFormat(s.locale, { style: 'currency', currency: s.currency }).format(n);
// Trade and B2B sites often put ex-tax prices in their JSON-LD even where shoppers see tax included.
const theirs = l => s.market_shows_tax && (s.tax_exclusive_domains || []).includes(l.domain)
  ? l.last_price * (1 + s.tax_rate) : l.last_price;

const listings = $input.all().map(i => i.json);
const best = {}, odd = [];
for (const l of listings) {
  const me = mine[l.product_key];
  // robots_disallowed keeps its last price (often a Google Shopping offer) until it's too old to trust.
  if (!me || !['ok', 'robots_disallowed'].includes(l.status) || l.in_stock === false || l.confidence < s.min_confidence) continue;
  if (now - new Date(l.last_checked_at) > s.max_price_age_days * day) continue;
  const price = theirs(l);
  if (price < comparable(me.price) * s.min_price_ratio) { odd.push(l); continue; } // too cheap to be the same thing
  if (!best[l.product_key] || price < best[l.product_key].price) best[l.product_key] = { ...l, price };
}
const undercuts = Object.values(best).map(l => {
  const my = comparable(mine[l.product_key].price);
  return { ...l, title: mine[l.product_key].title, my, gap: my - l.price, pct: (my - l.price) / my };
}).filter(u => u.pct >= s.undercut_threshold).sort((a, b) => b.gap - a.gap);

const week = listings.filter(l => now - new Date(l.last_checked_at) < 7 * day);
const count = st => week.filter(l => l.status === st).length;
const unhealthy = count('ok') === 0 || count('blocked') > week.length / 2;

const blocks = [{ type: 'header', text: { type: 'plain_text', text: `${undercuts.length} products where a competitor is cheaper` } }];
if (unhealthy) blocks.push({ type: 'section', text: { type: 'mrkdwn',
  text: `:warning: Only ${count('ok')} of ${week.length} pages read a price this week (${count('blocked')} blocked). Check the Recapture lane before trusting this report.` } });
for (const u of undercuts.slice(0, s.report_limit)) blocks.push({ type: 'section', text: { type: 'mrkdwn',
  text: `*${u.title}*\n<${u.url}|${u.seller}> ${money(u.price)} vs yours ${money(u.my)}: ${money(u.gap)} (${Math.round(u.pct * 100)}%) cheaper` } });
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn',
  text: `${week.length} pages checked this week: ${count('ok')} ok, ${count('robots_disallowed')} search only, ${count('blocked')} blocked, ${count('no_price')} no price, ${count('suspect')} held back. ${odd.length} prices under ${s.min_price_ratio * 100}% of yours skipped as likely mismatches.` }] });
return [{ json: { blocks, text: `${undercuts.length} competitor undercuts`, undercuts: undercuts.length } }];

Send the output to Slack using a Slack node with Block Kit:

SettingValue
ResourceMessage
OperationSend
Send Message ToChannel, By Name: {{ $('Report settings').first().json.slack_channel }}
Message TypeBlocks
Blocks{{ JSON.stringify({ blocks: $json.blocks }) }}
Notification Text{{ $json.text }}
OptionsInclude Link to Workflow, Unfurl Links and Unfurl Media all off

Test the Discover and Recapture lanes on 10 products first (discovery_batch: 10), including a lookalike suffix and a common 2-pack to confirm both are rejected, verify the matched URLs in competitor_listings, and then activate the schedule.

What it costs

By searching for each model number once every two to four weeks and re-reading known URLs with plain HTTP requests, your costs stay tiny compared to SaaS price-tracking tools. All prices below were checked November 2026, and the DataForSEO line assumes about 1.5 searches per product a month:

ComponentUnit price500 products2,000 products10,000 products
n8nFree self-hosted, or Starter €24 or Pro €60 a month (€20 and €50 billed annually)0 or €240 or €600 or €60
DataForSEO SERP APIup to US$0.004 a 20-result searchUS$3US$12US$60
Plain HTTP recaptureBuilt-in n8n HTTP Request000
Firecrawl fallbackFree up to 1,000 credits a month; Hobby US$19 for 5,000000 to US$19
Total, self-hostedUS$3US$12US$60 to US$79
Prisync, URL-basedUS$99 to US$399 a monthUS$199 (up to 1,000)US$399 (up to 5,000)Quote

PageCrawl charges per page, so four competitors on 500 products takes twice its US$99 top plan. I’d self-host n8n: the Community Edition is free and has no execution limits. On n8n Cloud, Starter stops a run after 5 minutes and Pro after 40 (Wait time doesn’t count), so keep batches small. Two levers move your cost: increasing research_with_listings_days from 30 to 60 days, or pasting competitor URLs directly to skip DataForSEO altogether.

Common mistakes

Searching by title instead of model number

Searching a 12-word title brings back category pages and reviews. Search the MPN (DS-2CD2366G2-I) to surface exact product listings.

Substring matching that catches lookalike suffixes

title.includes("DS-2CD2366G2-I") falsely matches DS-2CD2366G2-IU. Match the MPN as a whole token in Step 4.

Matching bundles or multi-packs against single units

Competitor RLC-810A 2-Pack pages contain the single unit’s MPN. Reject pack or bundle titles unless your own title is a pack.

Mixing tax-inclusive and tax-exclusive prices or currencies

B2B sites often publish ex-tax prices in JSON-LD. Check priceCurrency and list known ex-tax domains in tax_exclusive_domains when market_shows_tax is on.

Searching from the wrong country

Querying without a location returns foreign currencies from multi-region stores. Set search_location in DataForSEO and country for Firecrawl.

Searching every product every day

The stores selling a product change slowly, so search every two to four weeks and spend the frequent runs re-reading known pages.

Reacting to a single price jump

Pages occasionally render an accessory variant or glitch for one day. Hold moves over max_price_move (60%) as 'suspect' until a second reading confirms them.

Ignoring robots.txt and hammering one domain

Parallel requests trigger IP blocks quickly. Check robots.txt once per domain, honour Crawl-delay and Retry-After, and space requests with a Wait node.

Paying a scraping API for every single page

Routing every check through a paid browser wastes credits on static HTML. Only call Firecrawl when Step 6 marks a page 'blocked'.

Blocked fetches failing quietly

Bot walls can make prices look unchanged for months. Always show the blocked count and the health alert in your weekly Slack report.

Ignoring shipping costs

A US$5 cheaper listing with US$19 shipping isn’t an undercut. Few stores put shipping in their JSON-LD, so check the competitor’s delivery cost before you reprice, or raise undercut_threshold.

Make it your own

Matching products with no identifiers

For fashion or private-label items without MPNs, swap the Step 4 regex for a Basic LLM Chain node with a Structured Output Parser that compares title, brand and image and answers same, different or unsure, using an Ollama Chat Model on Ollama Cloud (base URL https://ollama.com) or any chat model. Save those matches at 0.8, under the report’s 0.9, until you’ve checked a few dozen by hand.

An instant alert for key products

Add an If node after Read price that posts to Slack straight away when a competitor drops one of your key products by more than 10%.

Tracking marketplaces and acting on the report

Read Amazon and eBay through a marketplace data API (DataForSEO has one for Amazon) and write the results into competitor_listings, and add a repricing lane that reads the same table and adjusts your prices inside floors you set.

When Data Tables aren’t enough

Past the 200 MiB default limit, data table inserts and updates fail. At four listings per product and one price change a week, 2,000 products add about 100 MB a year, and 10,000 products reach the limit in about five months. Self-hosters can raise it with N8N_DATA_TABLES_MAX_SIZE_BYTES; otherwise, swap the Data table nodes for n8n’s Postgres node on Supabase’s free plan, which gives you a 500 MB database.

Frequently asked questions

The rules vary by country and by each site’s terms, so treat this as general information rather than legal advice. Robots.txt states a site’s preference rather than granting permission; this workflow uses a transparent user_agent, honours robots.txt per RFC 9309, never logs in, and spaces requests politely. Use what you collect for your own pricing, and don’t republish a competitor’s catalogue.

How often should I check?

Re-reading known URLs every few days and running discovery every 14 to 30 days balances freshness and cost for most stores. During major sales, lower recapture_every_hours to 24 or 12.

Why not use AI to match products?

An exact model number does the job better and costs nothing, whereas a language model shown DS-2CD2366G2-I and DS-2CD2366G2-IU will often call them the same camera.

Does it work without barcodes or part numbers?

Yes: either paste your known competitor URLs into competitor_listings manually, or use the optional AI attribute matcher in Make it your own.

Will competitors see or block it?

They’ll see your store’s name in their server logs, because the user_agent names it on purpose. Most stores serve JSON-LD to a polite plain request, and for sites behind bot walls the Firecrawl fallback (or self-hosted Crawl4AI) can often read the page; set use_firecrawl to false if you’d rather not.

Next steps

Import the template into n8n, run the Setup lane once, and test Discover and Recapture with discovery_batch: 10 to check your first matched URLs.

reganmcgregor.com.au/competitor-price-monitoring-n8n/