{
  "name": "Compare Amazon prices against Google Shopping with Bright Data, GPT-5.6 and Slack",
  "nodes": [
    {
      "id": "sticky-main",
      "name": "Sticky Note main",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -700,
        301
      ],
      "parameters": {
        "color": 1,
        "width": 560,
        "height": 878,
        "content": "## Compare Amazon prices against Google Shopping every morning\n\nWatch a list of Amazon products. When one moves, find out whether anyone else is selling the same thing cheaper, and get a Slack brief that says so.\n\n### How it works\n1. A daily schedule fires and reads your watchlist from a single config node.\n2. Bright Data's Web Scraper API returns today's price, list price, stock, buy-box seller, rating, review count and bestseller rank for every product.\n3. The run reads the history sheet, keeps the newest earlier row per ASIN, and compares. It reports a price move past your percentage threshold, a stock flip in either direction, and a buy box changing hands.\n4. Today's snapshot is appended to the history sheet, so the next run has a baseline. The first run logs and reports nothing, which is expected.\n5. For products that moved, Bright Data's SERP API searches Google Shopping and returns the offers that undercut your Amazon price.\n6. GPT-5.6 Terra writes the brief and Slack gets it. A quiet day sends nothing.\n\n### Setup\n- [ ] Bright Data account, then a **SERP API** zone. Put its name in `serp_zone`.\n- [ ] Add a **Header Auth** credential named `Authorization` with the value `Bearer YOUR_KEY`, and attach it to both HTTP Request nodes.\n- [ ] Create a sheet with a `history` tab and paste its URL into `history_sheet`.\n- [ ] Add OpenAI and Slack credentials.\n- [ ] Paste your product URLs into `product_urls`, one per line."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec1",
      "name": "Sticky Note sec1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        301
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 375,
        "content": "## 1. The watchlist\nEverything you tune lives in one node: the product URLs, how many to check, and the percentage move worth an alert."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec2",
      "name": "Sticky Note sec2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1056,
        301
      ],
      "parameters": {
        "color": 7,
        "width": 696,
        "height": 375,
        "content": "## 2. Today's prices\nBright Data's Amazon scraper returns price, stock, buy-box seller, rating and bestseller rank for every product in one call."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec3",
      "name": "Sticky Note sec3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1932,
        301
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 615,
        "content": "## 3. Compare and log\nThe newest earlier row per product is the baseline. Today's snapshot is appended after the comparison, never before it."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec4",
      "name": "Sticky Note sec4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3048,
        301
      ],
      "parameters": {
        "color": 7,
        "width": 936,
        "height": 615,
        "content": "## 4. Who sells it cheaper\nOnly products that moved get a Google Shopping lookup, so a quiet day costs one Bright Data call instead of twenty."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-sec5",
      "name": "Sticky Note sec5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4164,
        301
      ],
      "parameters": {
        "color": 7,
        "width": 824,
        "height": 599,
        "content": "## 5. Brief and alert\nThe model may only use numbers that appear in the data. Nothing moved means nothing is sent, so the channel stays worth reading."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-warn",
      "name": "Sticky Note warn",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1056,
        736
      ],
      "parameters": {
        "color": 3,
        "width": 380,
        "height": 225,
        "content": "## First run is quiet\nWith an empty history sheet there is nothing to compare against, so the first run logs the snapshot and sends no alert. That is correct. The second run is the one that reports."
      },
      "typeVersion": 1
    },
    {
      "id": "t1",
      "name": "Daily Price Check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        460
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 7,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "c1",
      "name": "Set Watchlist Config",
      "type": "n8n-nodes-base.set",
      "position": [
        240,
        460
      ],
      "parameters": {
        "mode": "manual",
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c0",
              "name": "product_urls",
              "type": "string",
              "value": "https://www.amazon.com/dp/B09XS7JWHH\nhttps://www.amazon.com/dp/B0863TXGM3\nhttps://www.amazon.com/dp/B0CHWRXH8B"
            },
            {
              "id": "c1",
              "name": "max_products",
              "type": "number",
              "value": 10
            },
            {
              "id": "c2",
              "name": "alert_drop_pct",
              "type": "number",
              "value": 5
            },
            {
              "id": "c3",
              "name": "alert_rise_pct",
              "type": "number",
              "value": 5
            },
            {
              "id": "c4",
              "name": "country",
              "type": "string",
              "value": "us"
            },
            {
              "id": "c5",
              "name": "serp_zone",
              "type": "string",
              "value": "serp_api1"
            },
            {
              "id": "c6",
              "name": "history_sheet",
              "type": "string",
              "value": "https://docs.google.com/spreadsheets/d/REPLACE_WITH_YOUR_SHEET_ID/edit"
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "n1",
      "name": "Split Watchlist Into Items",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One product per line. The Code node sandbox has no URL constructor, so the\n// ASIN comes out with a regex rather than URL parsing.\nconst cfg = $('Set Watchlist Config').first().json;\nconst lines = String(cfg.product_urls || '')\n  .split(/[\\n,]/)\n  .map((s) => s.trim())\n  .filter(Boolean);\n\nif (!lines.length) {\n  throw new Error(\n    'No products to watch. Paste at least one Amazon product URL into product_urls in Set Watchlist Config.'\n  );\n}\n\nconst seen = new Set();\nconst out = [];\nfor (const line of lines) {\n  const m = line.match(/\\/(?:dp|gp\\/product)\\/([A-Z0-9]{10})/i);\n  if (!m) {\n    throw new Error(\n      `\"${line}\" is not an Amazon product URL. Expected something like https://www.amazon.com/dp/B09XS7JWHH`\n    );\n  }\n  const asin = m[1].toUpperCase();\n  if (seen.has(asin)) continue;\n  seen.add(asin);\n  out.push({ json: { asin, url: line.split('?')[0] } });\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "n2",
      "name": "Limit Watched Products",
      "type": "n8n-nodes-base.limit",
      "position": [
        720,
        460
      ],
      "parameters": {
        "maxItems": "={{ $('Set Watchlist Config').first().json.max_products }}"
      },
      "typeVersion": 1
    },
    {
      "id": "n3",
      "name": "Aggregate URLs for Scraper",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1116,
        460
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData",
        "destinationFieldName": "products"
      },
      "typeVersion": 1
    },
    {
      "id": "n4",
      "name": "Post to Bright Data Amazon Scraper",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1356,
        460
      ],
      "parameters": {
        "url": "https://api.brightdata.com/datasets/v3/scrape?dataset_id=gd_l7q7dkf244hwjntr0&format=json",
        "method": "POST",
        "options": {
          "timeout": 180000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json.products.map(p => ({ url: p.url }))) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "n5",
      "name": "Flatten Product Snapshots",
      "type": "n8n-nodes-base.code",
      "position": [
        1596,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Bright Data may hand back one item per product (n8n split the array) or a\n// single item holding the whole array. Read every input item, or we keep only\n// the first product and silently drop the rest.\nconst rows = [];\nconst errors = [];\n\nfor (const item of $input.all()) {\n  let payload = item.json.body ?? item.json;\n  if (typeof payload === 'string') {\n    try { payload = JSON.parse(payload); } catch { continue; }\n  }\n  if (payload && payload.snapshot_id && !Array.isArray(payload)) {\n    throw new Error(\n      `Bright Data returned snapshot_id ${payload.snapshot_id} instead of rows. ` +\n      'The sync scrape endpoint gives up after about a minute - lower max_products, ' +\n      'or switch to the async trigger endpoint.'\n    );\n  }\n  // Bright Data answers validation errors with HTTP 200, so a bad request looks\n  // like a successful call. Surface the real message instead of an empty result.\n  if (payload && payload.error) {\n    const detail = Array.isArray(payload.details)\n      ? payload.details.map((d) => d.message).join('; ')\n      : '';\n    errors.push(payload.error + (detail ? ': ' + detail : ''));\n    continue;\n  }\n  if (Array.isArray(payload)) rows.push(...payload);\n  else if (payload) rows.push(payload);\n}\n\nif (!rows.length) {\n  throw new Error(\n    errors.length\n      ? `Bright Data rejected the scrape: ${errors[0]}`\n      : 'Bright Data returned no product rows. Check that the URLs are live Amazon product pages.'\n  );\n}\n\nconst checkedAt = new Date().toISOString();\nconst out = [];\nfor (const r of rows) {\n  if (!r || !r.asin) continue;\n  const price = Number(r.final_price ?? r.initial_price ?? 0) || null;\n  out.push({\n    json: {\n      checked_at: checkedAt,\n      asin: r.asin,\n      title: String(r.title || '').slice(0, 180),\n      brand: r.brand || '',\n      price,\n      list_price: Number(r.initial_price ?? 0) || null,\n      currency: r.currency || '',\n      in_stock: r.is_available === true || /in stock/i.test(String(r.availability || '')),\n      availability: r.availability || '',\n      rating: Number(r.rating ?? 0) || null,\n      reviews_count: Number(r.reviews_count ?? 0) || null,\n      buybox_seller: r.buybox_seller || r.seller_name || '',\n      sellers: Number(r.number_of_sellers ?? 0) || null,\n      coupon: r.coupon || '',\n      bestseller_rank: Number(r.root_bs_rank ?? 0) || null,\n      url: (r.input && r.input.url) || r.url || '',\n    },\n  });\n}\n\nif (!out.length) {\n  throw new Error('Bright Data returned rows but none carried an ASIN.');\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "n6",
      "name": "Read Price History",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        1992,
        460
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "history"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Set Watchlist Config').first().json.history_sheet }}"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "n7",
      "name": "Compare Against Last Snapshot",
      "type": "n8n-nodes-base.code",
      "position": [
        2232,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The history sheet holds one row per product per run. Today's rows are appended\n// on the other branch, after this node, so everything read here is older.\nconst cfg = $('Set Watchlist Config').first().json;\nconst dropPct = Number(cfg.alert_drop_pct ?? 5);\nconst risePct = Number(cfg.alert_rise_pct ?? 5);\nconst today = $('Flatten Product Snapshots').all().map((i) => i.json);\n\nconst history = [];\nfor (const item of $input.all()) {\n  const r = item.json || {};\n  // Blank rows, header rows and a sheet that does not exist yet all land here.\n  if (!r.asin || !r.checked_at) continue;\n  history.push(r);\n}\n\nconst baseline = {};\nfor (const r of history) {\n  const prev = baseline[r.asin];\n  if (!prev || String(r.checked_at) > String(prev.checked_at)) baseline[r.asin] = r;\n}\n\nconst truthy = (v) => v === true || String(v).toLowerCase() === 'true';\nconst changes = [];\n\nfor (const cur of today) {\n  const prev = baseline[cur.asin];\n  if (!prev) continue; // first run for this product - nothing to compare against\n  const prevPrice = Number(prev.price) || null;\n  const events = [];\n  let pct = null;\n\n  if (prevPrice && cur.price) {\n    pct = ((cur.price - prevPrice) / prevPrice) * 100;\n    if (pct <= -dropPct) {\n      events.push(`price dropped ${Math.abs(pct).toFixed(1)}% (${prevPrice} to ${cur.price} ${cur.currency})`);\n    } else if (pct >= risePct) {\n      events.push(`price rose ${pct.toFixed(1)}% (${prevPrice} to ${cur.price} ${cur.currency})`);\n    }\n  }\n\n  const wasInStock = truthy(prev.in_stock);\n  if (wasInStock && !cur.in_stock) events.push('went out of stock');\n  if (!wasInStock && cur.in_stock) events.push('came back in stock');\n\n  if (prev.buybox_seller && cur.buybox_seller && prev.buybox_seller !== cur.buybox_seller) {\n    events.push(`buy box moved from ${prev.buybox_seller} to ${cur.buybox_seller}`);\n  }\n\n  if (!events.length) continue;\n  changes.push({\n    ...cur,\n    previous_price: prevPrice,\n    previous_seller: prev.buybox_seller || '',\n    previous_checked_at: prev.checked_at,\n    change_pct: pct === null ? null : Number(pct.toFixed(1)),\n    events: events.join('; '),\n  });\n}\n\nreturn [{\n  json: {\n    has_changes: changes.length > 0,\n    products_checked: today.length,\n    baseline_rows: history.length,\n    changes,\n    snapshot: today,\n  },\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "n8",
      "name": "Split Out Snapshot Rows",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        2472,
        700
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "snapshot"
      },
      "typeVersion": 1
    },
    {
      "id": "n9",
      "name": "Append Snapshot to History",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        2712,
        700
      ],
      "parameters": {
        "columns": {
          "value": {},
          "mappingMode": "autoMapInputData",
          "matchingColumns": []
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "history"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Set Watchlist Config').first().json.history_sheet }}"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "n10",
      "name": "Any Price Moves to Report?",
      "type": "n8n-nodes-base.if",
      "position": [
        3108,
        460
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.has_changes }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "n11",
      "name": "Split Out Changed Products",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        3348,
        460
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "changes"
      },
      "typeVersion": 1
    },
    {
      "id": "n12",
      "name": "Post to Bright Data Shopping SERP",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        3588,
        460
      ],
      "parameters": {
        "url": "https://api.brightdata.com/request",
        "method": "POST",
        "options": {
          "timeout": 120000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ zone: $('Set Watchlist Config').first().json.serp_zone, url: 'https://www.google.com/search?q=' + encodeURIComponent($json.title.split(',')[0].slice(0, 80)) + '&gl=' + encodeURIComponent($('Set Watchlist Config').first().json.country) + '&brd_json=1&tbm=shop', format: 'json' }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth"
      },
      "retryOnFail": true,
      "typeVersion": 4.4,
      "waitBetweenTries": 2000
    },
    {
      "id": "n13",
      "name": "Extract Cheaper Offers Elsewhere",
      "type": "n8n-nodes-base.code",
      "position": [
        3828,
        460
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Runs once per changed product. Google Shopping rows carry base64 logos and\n// images that would blow up the prompt, and Google repeats the product title\n// twice inside one string - strip both here, not downstream.\nconst changed = $('Split Out Changed Products').item.json;\n\nlet payload = $json.body ?? $json;\nif (typeof payload === 'string') {\n  try { payload = JSON.parse(payload); } catch { payload = {}; }\n}\nif (payload && payload.error) {\n  const detail = Array.isArray(payload.details)\n    ? payload.details.map((d) => d.message).join('; ')\n    : '';\n  return { json: { ...changed, cheaper_offers: [], offers_error: payload.error + (detail ? ': ' + detail : '') } };\n}\n\nconst undouble = (s) => {\n  const t = String(s || '').trim();\n  const half = t.length / 2;\n  return t.length % 2 === 0 && t.slice(0, half) === t.slice(half) ? t.slice(0, half) : t;\n};\nconst toNumber = (s) => {\n  const m = String(s || '').replace(/,/g, '').match(/[\\d.]+/);\n  return m ? Number(m[0]) : null;\n};\n\nconst USED = /pre-?owned|used|refurb|open box|renewed/i;\n\nconst offers = [];\nfor (const r of Array.isArray(payload.shopping) ? payload.shopping : []) {\n  const price = toNumber(r.price);\n  if (!price) continue;\n  offers.push({\n    shop: r.shop || '',\n    price,\n    used: USED.test(String(r.tag || '')),\n    title: undouble(r.title).slice(0, 120),\n  });\n}\noffers.sort((a, b) => a.price - b.price);\n\n// Google Shopping matches on the product name, so a query for a $250 headphone\n// also returns cases, cables and outright mismatches for $20. Anything under\n// 40% of the Amazon price is treated as a different product, not a bargain.\nconst mine = Number(changed.price) || null;\nconst floor = mine ? mine * 0.4 : 0;\nconst plausible = offers.filter((o) => o.price >= floor);\nconst newOffers = plausible.filter((o) => !o.used);\nconst usedOffers = plausible.filter((o) => o.used);\n\nreturn {\n  json: {\n    ...changed,\n    offers_seen: offers.length,\n    offers_ignored_as_mismatch: offers.length - plausible.length,\n    market_low_new: newOffers.length ? newOffers[0].price : null,\n    market_low_used: usedOffers.length ? usedOffers[0].price : null,\n    cheaper_offers: (mine ? newOffers.filter((o) => o.price < mine) : newOffers)\n      .slice(0, 5)\n      .map((o) => ({ shop: o.shop, price: o.price, title: o.title })),\n  },\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "n14",
      "name": "No Moves Worth Reporting",
      "type": "n8n-nodes-base.noOp",
      "position": [
        3348,
        700
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "n15",
      "name": "Aggregate Change Report",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4224,
        460
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData",
        "destinationFieldName": "report"
      },
      "typeVersion": 1
    },
    {
      "id": "n16",
      "name": "Write Price Movement Brief",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "onError": "continueRegularOutput",
      "position": [
        4464,
        460
      ],
      "parameters": {
        "text": "=You are a pricing analyst writing the morning note for an ecommerce team.\n\nBelow is every price or stock move on the watched Amazon products since the last run, plus the cheapest offers found for the same product elsewhere on Google Shopping.\n\nWrite a Slack message of at most 200 words:\n- open with the single most important move and why it matters commercially\n- then one line per product: what changed, the actual numbers, and whether someone else is selling it cheaper right now\n- close with one concrete action worth taking today\n\nPlain text only, no markdown headings, no preamble. Every number you write must appear in the data below. If a product has no cheaper offer elsewhere, say so rather than leaving it out. `market_low_used` is a second-hand price, so never present it as a competitor undercutting a new product.\n\nDATA:\n{{ JSON.stringify($json.report, null, 2).slice(0, 12000) }}",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "m1",
      "name": "OpenAI GPT-5.6 Terra Analyst",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        4472,
        700
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.6-terra"
        },
        "options": {
          "temperature": 0.2
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "n17",
      "name": "Post Price Alert to Slack",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "position": [
        4832,
        460
      ],
      "parameters": {
        "text": "={{ 'Price watch - ' + $('Compare Against Last Snapshot').first().json.changes.length + ' of ' + $('Compare Against Last Snapshot').first().json.products_checked + ' watched products moved\\n\\n' + $json.text }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#price-watch"
        },
        "operation": "post",
        "otherOptions": {}
      },
      "typeVersion": 2.3
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Daily Price Check": {
      "main": [
        [
          {
            "node": "Set Watchlist Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Price History": {
      "main": [
        [
          {
            "node": "Compare Against Last Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Watchlist Config": {
      "main": [
        [
          {
            "node": "Split Watchlist Into Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Limit Watched Products": {
      "main": [
        [
          {
            "node": "Aggregate URLs for Scraper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Change Report": {
      "main": [
        [
          {
            "node": "Write Price Movement Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Snapshot Rows": {
      "main": [
        [
          {
            "node": "Append Snapshot to History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten Product Snapshots": {
      "main": [
        [
          {
            "node": "Read Price History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate URLs for Scraper": {
      "main": [
        [
          {
            "node": "Post to Bright Data Amazon Scraper",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any Price Moves to Report?": {
      "main": [
        [
          {
            "node": "Split Out Changed Products",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Moves Worth Reporting",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Changed Products": {
      "main": [
        [
          {
            "node": "Post to Bright Data Shopping SERP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Watchlist Into Items": {
      "main": [
        [
          {
            "node": "Limit Watched Products",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Price Movement Brief": {
      "main": [
        [
          {
            "node": "Post Price Alert to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI GPT-5.6 Terra Analyst": {
      "ai_languageModel": [
        [
          {
            "node": "Write Price Movement Brief",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Compare Against Last Snapshot": {
      "main": [
        [
          {
            "node": "Split Out Snapshot Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Any Price Moves to Report?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Cheaper Offers Elsewhere": {
      "main": [
        [
          {
            "node": "Aggregate Change Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Bright Data Shopping SERP": {
      "main": [
        [
          {
            "node": "Extract Cheaper Offers Elsewhere",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Bright Data Amazon Scraper": {
      "main": [
        [
          {
            "node": "Flatten Product Snapshots",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}