{
  "nodes": [
    {
      "id": "783632e4-4c50-484d-b951-d3eb5af286ff",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1712,
        368
      ],
      "parameters": {
        "width": 480,
        "height": 720,
        "content": "## Track Google Shopping prices over time in Airtable via ScraperAPI\n\n### How it works\n\n1. The workflow starts with a daily schedule trigger, reading a list from Airtable.\n2. Queries are iterated, fetching data from Google Shopping.\n3. The results are shaped and appended back to Airtable.\n4. Alerts are read and a digest is built for the day's results.\n5. If there are alerts, an email is sent out to notify users.\n\n### Setup steps\n\n- [ ] Ensure Airtable API credentials are configured for accessing the necessary tables.\n- [ ] Set up Scraper API credentials to fetch Google Shopping data.\n- [ ] Configure Gmail account for sending alert emails.\n\n### Customization\n\nYou can customize the alert conditions in the 'Has Alerts?' node or adjust which results are kept in 'Build Alert Digest'."
      },
      "typeVersion": 1
    },
    {
      "id": "6955cb96-d32d-4fd2-a497-a19c1a160c7e",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1152,
        368
      ],
      "parameters": {
        "color": 7,
        "width": 736,
        "height": 272,
        "content": "## Trigger and fetch watchlist\n\nInitiates workflow based on schedule and fetches watchlist from Airtable."
      },
      "typeVersion": 1
    },
    {
      "id": "32fc960e-8d5d-4417-8314-5c6d32006b4e",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -304,
        368
      ],
      "parameters": {
        "color": 7,
        "width": 1040,
        "height": 272,
        "content": "## Iterate and scrape data\n\nIterates over queries to scrape data from Google Shopping."
      },
      "typeVersion": 1
    },
    {
      "id": "7924c1d7-de44-4fb8-8b92-23a9d1610541",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -304,
        672
      ],
      "parameters": {
        "color": 7,
        "width": 752,
        "height": 272,
        "content": "## Fetch and process alerts\n\nReads alerts from Airtable and processes them into a digest."
      },
      "typeVersion": 1
    },
    {
      "id": "2ff99092-4477-4b62-ab56-9cd9d5e96b54",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        528,
        672
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 352,
        "content": "## Send alert notifications\n\nSends alert notifications via email if conditions are met."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "Every Morning at 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1100,
        480
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "read-watchlist",
      "name": "Fetch Watchlist from Airtable",
      "type": "n8n-nodes-base.airtable",
      "position": [
        -560,
        480
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {},
        "resource": "record",
        "operation": "search",
        "returnAll": true,
        "filterByFormula": "{active} = TRUE()"
      },
      "typeVersion": 2.2
    },
    {
      "id": "iterate-queries",
      "name": "Loop Over Watchlist Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -260,
        480
      ],
      "parameters": {
        "options": {},
        "batchSize": 1
      },
      "typeVersion": 3
    },
    {
      "id": "scrape-google-shopping",
      "name": "Fetch Google Shopping Results",
      "type": "n8n-nodes-scraperapi-official.scraperApi",
      "maxTries": 2,
      "position": [
        20,
        480
      ],
      "parameters": {
        "resource": "sde",
        "sdeQuery": "={{ $('Loop Over Watchlist Items').item.json.fields.query }}",
        "operation": "googleShopping",
        "sdePlatform": "google",
        "sdeGoogleOptions": {}
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "shape-results",
      "name": "Flatten Shopping Offers",
      "type": "n8n-nodes-base.code",
      "position": [
        300,
        480
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Flatten Google Shopping response into one item per offer, tagging\n// each row with the current scrape timestamp and an is_alert flag when\n// price <= target_price.\n\nconst numOrNull = (v) => {\n  if (v === null || v === undefined || v === '') return null;\n  const s = typeof v === 'string' ? v.replace(/[^0-9.]/g, '') : v;\n  const n = parseFloat(s);\n  return Number.isFinite(n) ? n : null;\n};\n\nconst getBody = (item) => {\n  const resp = item && item.json && item.json.response;\n  if (!resp || resp.body === undefined) return {};\n  if (typeof resp.body === 'string') {\n    try { return JSON.parse(resp.body); } catch (e) { return {}; }\n  }\n  return resp.body;\n};\n\nconst row = $('Loop Over Watchlist Items').item.json.fields || {};\nconst query = row.query || '';\nconst targetPrice = numOrNull(row.target_price);\nconst hasTarget = targetPrice !== null && targetPrice > 0;\nconst scrapedAt = new Date().toISOString();\n\nconst body = getBody($input.first());\nconst offers = body.shopping_results || body.results || [];\n\nif (!Array.isArray(offers) || offers.length === 0) {\n  return [];\n}\n\nreturn offers.map((r) => {\n  const price = r.extracted_price !== undefined\n    ? numOrNull(r.extracted_price)\n    : numOrNull(r.price);\n  return {\n    json: {\n      scraped_at: scrapedAt,\n      query,\n      product_title: r.title || '',\n      price,\n      currency: r.currency || 'USD',\n      merchant: r.source || r.seller || r.merchant || '',\n      link: r.link || r.product_link || '',\n      product_id: r.product_id || r.id || '',\n      is_alert: hasTarget && price !== null && price <= targetPrice,\n    },\n    pairedItem: { item: 0 },\n  };\n});\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "append-results",
      "name": "Append Offer to Airtable",
      "type": "n8n-nodes-base.airtable",
      "position": [
        580,
        480
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "link": "={{ $json.link }}",
            "price": "={{ $json.price }}",
            "query": "={{ $json.query }}",
            "currency": "={{ $json.currency }}",
            "is_alert": "={{ $json.is_alert }}",
            "merchant": "={{ $json.merchant }}",
            "product_id": "={{ $json.product_id }}",
            "scraped_at": "={{ $json.scraped_at }}",
            "product_title": "={{ $json.product_title }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "resource": "record",
        "operation": "create"
      },
      "typeVersion": 2.2
    },
    {
      "id": "read-todays-alerts",
      "name": "Fetch Today's Alerts",
      "type": "n8n-nodes-base.airtable",
      "position": [
        -260,
        780
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {},
        "resource": "record",
        "operation": "search",
        "returnAll": true,
        "filterByFormula": "AND({is_alert} = TRUE(), DATETIME_FORMAT({scraped_at}, 'YYYY-MM-DD') = DATETIME_FORMAT(NOW(), 'YYYY-MM-DD'))"
      },
      "executeOnce": true,
      "typeVersion": 2.2
    },
    {
      "id": "build-digest",
      "name": "Build Alert Summary",
      "type": "n8n-nodes-base.code",
      "position": [
        20,
        780
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Group today's alert rows by query and keep the cheapest offer per query\n// so a noisy SERP doesn't produce a digest with 40 rows for one product.\n\nconst alerts = $input.all()\n  .map((i) => i.json.fields || {})\n  .filter((r) => r && r.query);\n\nif (alerts.length === 0) {\n  return [{ json: { alertCount: 0, subject: '', html: '' } }];\n}\n\nconst byQuery = new Map();\nfor (const a of alerts) {\n  const key = a.query;\n  const cur = byQuery.get(key);\n  const price = typeof a.price === 'number' ? a.price : Number(a.price);\n  if (!cur || (Number.isFinite(price) && price < (cur.price ?? Infinity))) {\n    byQuery.set(key, { ...a, price });\n  }\n}\n\nconst rows = [...byQuery.values()].sort((a, b) => (a.price ?? 0) - (b.price ?? 0));\n\nconst esc = (s) => String(s == null ? '' : s).replace(/[&<>\"']/g, (c) => ({\n  '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;',\n}[c]));\nconst fmt = (n, c) => {\n  if (n == null || n === '' || !Number.isFinite(Number(n))) return '\u2014';\n  const sym = (c || 'USD') === 'USD' ? '$' : (c + ' ');\n  return sym + Number(n).toFixed(2);\n};\n\nconst rowsHtml = rows.map((r) => `<tr>\n  <td>${esc(r.query)}</td>\n  <td>${esc(r.product_title)}</td>\n  <td>${esc(r.merchant)}</td>\n  <td style=\"text-align:right;color:#0a7d27;font-weight:600;\">${fmt(r.price, r.currency)}</td>\n  <td><a href=\"${esc(r.link)}\">View</a></td>\n</tr>`).join('');\n\nconst subject = `Google Shopping price alert \u2014 ${rows.length} ${rows.length === 1 ? 'query' : 'queries'} at or below target`;\nconst html = `<div style=\"font-family:-apple-system,Segoe UI,sans-serif;max-width:680px;\">\n  <h2 style=\"margin:0 0 12px;\">${esc(subject)}</h2>\n  <p style=\"color:#555;margin:0 0 16px;\">These offers were spotted on Google Shopping today at or below your target price. Full history lives in your Airtable Results table.</p>\n  <table cellpadding=\"8\" cellspacing=\"0\" style=\"border-collapse:collapse;width:100%;font-size:14px;\">\n    <thead><tr style=\"background:#f4f4f6;text-align:left;\">\n      <th>Query</th><th>Product</th><th>Merchant</th><th style=\"text-align:right;\">Price</th><th>Link</th>\n    </tr></thead>\n    <tbody>${rowsHtml}</tbody>\n  </table>\n</div>`;\n\nreturn [{ json: { alertCount: rows.length, subject, html } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "has-alerts",
      "name": "If Alerts Exist",
      "type": "n8n-nodes-base.if",
      "position": [
        300,
        780
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "alert-count-gt-0",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.alertCount }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "send-alert-email",
      "name": "Send Alerts via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        576,
        864
      ],
      "parameters": {
        "sendTo": "",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "emailType": "html"
      },
      "typeVersion": 2.2
    }
  ],
  "connections": {
    "If Alerts Exist": {
      "main": [
        [
          {
            "node": "Send Alerts via Gmail",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Build Alert Summary": {
      "main": [
        [
          {
            "node": "If Alerts Exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every Morning at 8am": {
      "main": [
        [
          {
            "node": "Fetch Watchlist from Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Today's Alerts": {
      "main": [
        [
          {
            "node": "Build Alert Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten Shopping Offers": {
      "main": [
        [
          {
            "node": "Append Offer to Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Offer to Airtable": {
      "main": [
        [
          {
            "node": "Loop Over Watchlist Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Watchlist Items": {
      "main": [
        [
          {
            "node": "Fetch Today's Alerts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Google Shopping Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Google Shopping Results": {
      "main": [
        [
          {
            "node": "Flatten Shopping Offers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Watchlist from Airtable": {
      "main": [
        [
          {
            "node": "Loop Over Watchlist Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}