{"name":"Viral product spy workflow","nodes":[{"id":"11111111-0001-4a00-8a00-000000000001","name":"Every 6 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-560,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":6}]}}},{"id":"11111111-0002-4a00-8a00-000000000002","name":"Load Product Watchlist","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-300,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Watchlist"},"options":{}}},{"id":"11111111-0003-4a00-8a00-000000000003","name":"Load Snapshot History","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-40,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1122334455","mode":"list","cachedResultName":"Snapshots"},"options":{}}},{"id":"11111111-0004-4a00-8a00-000000000004","name":"Loop Over Products","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[220,300],"parameters":{"batchSize":3,"options":{}}},{"id":"11111111-0005-4a00-8a00-000000000005","name":"Fetch Meta Ad Library","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[480,140],"parameters":{"url":"https://graph.facebook.com/v21.0/ads_archive","sendQuery":true,"queryParameters":{"parameters":[{"name":"search_terms","value":"={{ $json.product_name }}"},{"name":"ad_reached_countries","value":"={{ $json.country || 'US' }}"},{"name":"ad_active_status","value":"ACTIVE"},{"name":"fields","value":"id,page_id,page_name,ad_delivery_start_time,ad_delivery_stop_time,impressions"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"11111111-0006-4a00-8a00-000000000006","name":"Fetch TikTok Engagement","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[480,460],"parameters":{"url":"https://api.scrapecreators.com/v1/tiktok/search/keyword","sendQuery":true,"queryParameters":{"parameters":[{"name":"query","value":"={{ $json.product_name }}"},{"name":"period","value":"7"},{"name":"sort_by","value":"like_count"},{"name":"limit","value":"50"}]},"options":{}}},{"id":"11111111-0007-4a00-8a00-000000000007","name":"Merge Product Signals","type":"n8n-nodes-base.merge","typeVersion":3,"position":[740,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"11111111-0008-4a00-8a00-000000000008","name":"Fetch Store Price And Stock","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1000,300],"parameters":{"url":"=https://{{ $('Loop Over Products').all()[$itemIndex].json.store_domain }}/products/{{ $('Loop Over Products').all()[$itemIndex].json.handle }}.json","options":{}}},{"id":"11111111-0009-4a00-8a00-000000000009","name":"Normalize Signal Row","type":"n8n-nodes-base.code","typeVersion":2,"position":[1260,300],"parameters":{"jsCode":"// Fold the three per-product API responses into one flat signal row.\n// Price fetch is the last hop, so pair it with the merged social/ads payload by position.\nconst merged = $('Merge Product Signals').all();\nconst watchlist = $('Loop Over Products').all();\nconst out = [];\n\n$input.all().forEach((item, i) => {\n  const store = item.json || {};\n  const m = (merged[i] && merged[i].json) || {};\n  // watchlist row carries product_id / niche / store_domain - the APIs never echo them back\n  const wl = (watchlist[i] && watchlist[i].json) || {};\n\n  // --- Meta Ad Library block ---\n  const ads = Array.isArray(m.data) ? m.data : (m.ads || []);\n  const activeAds = ads.filter(a => (a.ad_delivery_stop_time == null) || new Date(a.ad_delivery_stop_time) > new Date());\n  const advertisers = new Set(activeAds.map(a => a.page_id || a.page_name).filter(Boolean));\n\n  // --- TikTok / social engagement block ---\n  const posts = m.videos || m.items || [];\n  const engagements = posts.reduce((s, p) =>\n    s + (p.digg_count || 0) + (p.comment_count || 0) + (p.share_count || 0), 0);\n  const views = posts.reduce((s, p) => s + (p.play_count || 0), 0);\n\n  // --- Store block ---\n  const variants = (store.product && store.product.variants) || store.variants || [];\n  const price = variants.length\n    ? Math.min(...variants.map(v => parseFloat(v.price)).filter(n => !isNaN(n)))\n    : parseFloat(store.price || 0);\n  const inStock = variants.some(v => v.available !== false);\n\n  out.push({\n    json: {\n      product_id: wl.product_id || m.product_id || store.id,\n      product_name: wl.product_name || (store.product && store.product.title) || 'unknown',\n      niche: wl.niche || 'general',\n      store_domain: wl.store_domain || '',\n      captured_at: new Date().toISOString(),\n      active_ad_count: activeAds.length,\n      advertiser_count: advertisers.size,\n      post_count: posts.length,\n      engagements,\n      views,\n      engagement_rate: views > 0 ? +(engagements / views).toFixed(5) : 0,\n      price: +(price || 0).toFixed(2),\n      in_stock: inStock,\n    },\n  });\n});\n\nreturn out;"}},{"id":"11111111-0010-4a00-8a00-000000000010","name":"Score Momentum","type":"n8n-nodes-base.code","typeVersion":2,"position":[480,700],"parameters":{"jsCode":"// ---- MOMENTUM SCORING -------------------------------------------------\n// Compare today's snapshot against the most recent prior snapshot per product,\n// convert every raw delta into a per-24h velocity, then blend into 0-100.\nconst HOURS_MIN = 2;            // ignore snapshots taken less than 2h apart\nconst ALERT_COOLDOWN_H = 48;    // don't re-alert the same product inside 48h\nconst BREAKOUT_SCORE = 65;      // score that counts as a breakout\n\nconst WEIGHTS = { engagement: 0.40, ads: 0.30, advertisers: 0.15, price: 0.10, stock: 0.05 };\n\nconst history = $('Load Snapshot History').all().map(i => i.json);\nconst byProduct = {};\nfor (const h of history) {\n  const id = String(h.product_id);\n  (byProduct[id] = byProduct[id] || []).push(h);\n}\nfor (const id of Object.keys(byProduct)) {\n  byProduct[id].sort((a, b) => new Date(b.captured_at) - new Date(a.captured_at));\n}\n\nconst clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\nconst pctChange = (now, prev) => (prev > 0 ? (now - prev) / prev : (now > 0 ? 1 : 0));\n\n// dedupe: one row per product_id, keep the freshest capture\nconst latest = {};\nfor (const item of $input.all()) {\n  const r = item.json;\n  const id = String(r.product_id);\n  if (!latest[id] || new Date(r.captured_at) > new Date(latest[id].captured_at)) latest[id] = r;\n}\n\nconst results = [];\n\nfor (const r of Object.values(latest)) {\n  const id = String(r.product_id);\n  const prior = (byProduct[id] || []).find(h => {\n    const gap = (new Date(r.captured_at) - new Date(h.captured_at)) / 36e5;\n    return gap >= HOURS_MIN;\n  });\n\n  if (!prior) {\n    results.push({ json: { ...r, momentum_score: 0, status: 'baseline',\n      reason: 'first snapshot - no prior data to compute velocity', alert_suppressed: true } });\n    continue;\n  }\n\n  const hours = Math.max(HOURS_MIN, (new Date(r.captured_at) - new Date(prior.captured_at)) / 36e5);\n  const per24 = 24 / hours;\n\n  const engVel   = pctChange(r.engagements, Number(prior.engagements)) * per24;      // % growth / day\n  const adVel    = (r.active_ad_count - Number(prior.active_ad_count || 0)) * per24; // new ads / day\n  const advVel   = (r.advertiser_count - Number(prior.advertiser_count || 0)) * per24;\n  const priceMov = pctChange(r.price, Number(prior.price)) * -1;                     // discounts = bullish\n  const stockFlip = (prior.in_stock === true || prior.in_stock === 'TRUE') && !r.in_stock ? 1 : 0;\n\n  // normalise each signal to 0-1 against what a genuinely viral product looks like\n  const sEng   = clamp(engVel / 0.60, 0, 1);   // +60%/day engagement = maxed\n  const sAds   = clamp(adVel / 12, 0, 1);      // +12 new active ads/day = maxed\n  const sAdv   = clamp(advVel / 4, 0, 1);      // 4 new advertisers/day = maxed\n  const sPrice = clamp(priceMov / 0.25, 0, 1); // 25% price cut = maxed\n  const sStock = stockFlip;                    // sold out = demand spike\n\n  let score = 100 * (\n    WEIGHTS.engagement * sEng + WEIGHTS.ads * sAds + WEIGHTS.advertisers * sAdv +\n    WEIGHTS.price * sPrice + WEIGHTS.stock * sStock\n  );\n\n  // volume gate: tiny absolute numbers make percentages lie\n  if (r.engagements < 5000 && r.active_ad_count < 5) score *= 0.5;\n  score = Math.round(clamp(score, 0, 100));\n\n  // pacing projection: where does ad count land in 7 days at this rate?\n  const projectedAds7d = Math.max(0, Math.round(r.active_ad_count + adVel * 7));\n\n  const lastAlert = (byProduct[id] || []).map(h => h.last_alert_at).filter(Boolean).sort().pop();\n  const hoursSinceAlert = lastAlert ? (Date.now() - new Date(lastAlert)) / 36e5 : Infinity;\n  const cooling = hoursSinceAlert < ALERT_COOLDOWN_H;\n\n  const drivers = [];\n  if (sEng   > 0.3) drivers.push(`engagement +${Math.round(engVel * 100)}%/day`);\n  if (sAds   > 0.3) drivers.push(`+${adVel.toFixed(1)} new ads/day`);\n  if (sAdv   > 0.3) drivers.push(`${advVel.toFixed(1)} new advertisers/day`);\n  if (sPrice > 0.3) drivers.push(`price cut ${Math.round(priceMov * 100)}%`);\n  if (stockFlip)    drivers.push('went out of stock');\n\n  results.push({\n    json: {\n      ...r,\n      window_hours: +hours.toFixed(1),\n      prev_engagements: Number(prior.engagements || 0),\n      prev_ad_count: Number(prior.active_ad_count || 0),\n      prev_price: Number(prior.price || 0),\n      engagement_velocity_pct_day: +(engVel * 100).toFixed(1),\n      ad_velocity_per_day: +adVel.toFixed(2),\n      advertiser_velocity_per_day: +advVel.toFixed(2),\n      price_move_pct: +(pctChange(r.price, Number(prior.price)) * 100).toFixed(1),\n      projected_ad_count_7d: projectedAds7d,\n      momentum_score: score,\n      status: score >= BREAKOUT_SCORE ? 'breakout' : score >= 40 ? 'heating' : 'flat',\n      drivers: drivers.join(' | ') || 'no strong signal',\n      alert_suppressed: cooling,\n      reason: cooling ? `suppressed: alerted ${Math.round(hoursSinceAlert)}h ago` : 'eligible',\n    },\n  });\n}\n\nresults.sort((a, b) => b.json.momentum_score - a.json.momentum_score);\nreturn results.map((r, i) => ({ json: { ...r.json, rank: i + 1 } }));"}},{"id":"11111111-0011-4a00-8a00-000000000011","name":"Drop Suppressed And Baseline","type":"n8n-nodes-base.filter","typeVersion":2,"position":[740,700],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.alert_suppressed }}","rightValue":"","operator":{"type":"boolean","operation":"false"}},{"id":"f2","leftValue":"={{ $json.momentum_score }}","rightValue":20,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"11111111-0012-4a00-8a00-000000000012","name":"Breakout Threshold Crossed?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1000,700],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.momentum_score }}","rightValue":65,"operator":{"type":"number","operation":"gte"}},{"id":"c2","leftValue":"={{ $json.engagement_velocity_pct_day }}","rightValue":25,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"11111111-0013-4a00-8a00-000000000013","name":"Write Breakout Brief","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1260,540],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a dropshipping product scout. Given momentum signals, write a 4-line brief: what the product is, why it is breaking out now, the angle to test first, and the main risk. No fluff, no emoji."},{"content":"=Product: {{ $json.product_name }} ({{ $json.niche }})\nMomentum score: {{ $json.momentum_score }}/100 (rank {{ $json.rank }})\nEngagement velocity: {{ $json.engagement_velocity_pct_day }}%/day ({{ $json.prev_engagements }} -> {{ $json.engagements }})\nActive ads: {{ $json.prev_ad_count }} -> {{ $json.active_ad_count }} ({{ $json.ad_velocity_per_day }}/day, projected {{ $json.projected_ad_count_7d }} in 7 days)\nAdvertisers running it: {{ $json.advertiser_count }}\nPrice move: {{ $json.price_move_pct }}% (now {{ $json.price }})\nDrivers: {{ $json.drivers }}"}]},"options":{}}},{"id":"11111111-0014-4a00-8a00-000000000014","name":"Alert Breakout In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1520,540],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#product-spy","mode":"name"},"text":"=🚀 BREAKOUT #{{ $('Breakout Threshold Crossed?').item.json.rank }} — {{ $('Breakout Threshold Crossed?').item.json.product_name }}\nMomentum {{ $('Breakout Threshold Crossed?').item.json.momentum_score }}/100 · {{ $('Breakout Threshold Crossed?').item.json.drivers }}\nAds {{ $('Breakout Threshold Crossed?').item.json.active_ad_count }} → projected {{ $('Breakout Threshold Crossed?').item.json.projected_ad_count_7d }} in 7d · price {{ $('Breakout Threshold Crossed?').item.json.price }} ({{ $('Breakout Threshold Crossed?').item.json.price_move_pct }}%)\n\n{{ $json.message.content }}","otherOptions":{}}},{"id":"11111111-0015-4a00-8a00-000000000015","name":"Log Breakout Row","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1780,540],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2233445566","mode":"list","cachedResultName":"Breakouts"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"11111111-0016-4a00-8a00-000000000016","name":"Tag As Still Watching","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1260,860],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"product_id","value":"={{ $json.product_id }}","type":"string"},{"id":"a2","name":"product_name","value":"={{ $json.product_name }}","type":"string"},{"id":"a3","name":"captured_at","value":"={{ $json.captured_at }}","type":"string"},{"id":"a4","name":"momentum_score","value":"={{ $json.momentum_score }}","type":"number"},{"id":"a5","name":"active_ad_count","value":"={{ $json.active_ad_count }}","type":"number"},{"id":"a6","name":"advertiser_count","value":"={{ $json.advertiser_count }}","type":"number"},{"id":"a7","name":"engagements","value":"={{ $json.engagements }}","type":"number"},{"id":"a8","name":"price","value":"={{ $json.price }}","type":"number"},{"id":"a9","name":"in_stock","value":"={{ $json.in_stock }}","type":"boolean"},{"id":"a10","name":"status","value":"={{ $json.status }}","type":"string"},{"id":"a11","name":"last_alert_at","value":"","type":"string"}]},"options":{}}},{"id":"11111111-0017-4a00-8a00-000000000017","name":"Append Snapshot History","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1520,860],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1122334455","mode":"list","cachedResultName":"Snapshots"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"11111111-9001-4a00-8a00-000000009001","name":"Note Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-600,0],"parameters":{"content":"## 1. COLLECT\nEvery 6h: read the product watchlist + the snapshot history sheet, then loop the watchlist 3 products at a time and pull three signals per product — Meta Ad Library active ads, TikTok 7-day engagement, and live store price/stock.","height":260,"width":760,"color":4}},{"id":"11111111-9002-4a00-8a00-000000009002","name":"Note Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[420,940],"parameters":{"content":"## 2. SCORE MOMENTUM\nDiff this snapshot against the freshest prior one per product, convert every delta to a per-24h velocity, and blend: engagement 40%, new ads 30%, new advertisers 15%, price cut 10%, stock-out 5%. Low-volume rows are halved. 48h alert cooldown + dedupe by product_id.","height":280,"width":540,"color":5}},{"id":"11111111-9003-4a00-8a00-000000009003","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1240,1080],"parameters":{"content":"## 3. ACT\nScore >= 65 AND engagement growth >= 25%/day = breakout: GPT writes the scouting brief, Slack gets the alert, the row is logged to Breakouts. Everything else is tagged \"watching\" and appended to Snapshots so the next run has a baseline to diff against.","height":260,"width":620,"color":3}}],"connections":{"Every 6 Hours":{"main":[[{"node":"Load Product Watchlist","type":"main","index":0}]]},"Load Product Watchlist":{"main":[[{"node":"Load Snapshot History","type":"main","index":0}]]},"Load Snapshot History":{"main":[[{"node":"Loop Over Products","type":"main","index":0}]]},"Loop Over Products":{"main":[[{"node":"Score Momentum","type":"main","index":0}],[{"node":"Fetch Meta Ad Library","type":"main","index":0},{"node":"Fetch TikTok Engagement","type":"main","index":0}]]},"Fetch Meta Ad Library":{"main":[[{"node":"Merge Product Signals","type":"main","index":0}]]},"Fetch TikTok Engagement":{"main":[[{"node":"Merge Product Signals","type":"main","index":1}]]},"Merge Product Signals":{"main":[[{"node":"Fetch Store Price And Stock","type":"main","index":0}]]},"Fetch Store Price And Stock":{"main":[[{"node":"Normalize Signal Row","type":"main","index":0}]]},"Normalize Signal Row":{"main":[[{"node":"Loop Over Products","type":"main","index":0}]]},"Score Momentum":{"main":[[{"node":"Drop Suppressed And Baseline","type":"main","index":0}]]},"Drop Suppressed And Baseline":{"main":[[{"node":"Breakout Threshold Crossed?","type":"main","index":0}]]},"Breakout Threshold Crossed?":{"main":[[{"node":"Write Breakout Brief","type":"main","index":0}],[{"node":"Tag As Still Watching","type":"main","index":0}]]},"Write Breakout Brief":{"main":[[{"node":"Alert Breakout In Slack","type":"main","index":0}]]},"Alert Breakout In Slack":{"main":[[{"node":"Log Breakout Row","type":"main","index":0}]]},"Log Breakout Row":{"main":[[{"node":"Append Snapshot History","type":"main","index":0}]]},"Tag As Still Watching":{"main":[[{"node":"Append Snapshot History","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}