{"name":"Competitor ad spy workflow","nodes":[{"id":"7a1c0001-1111-4222-8333-4444aaaa0001","name":"Every 6 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-480,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":6}]}}},{"id":"7a1c0002-1111-4222-8333-4444aaaa0002","name":"Build Competitor Watchlist","type":"n8n-nodes-base.code","typeVersion":2,"position":[40,300],"parameters":{"jsCode":"// Competitor watchlist -> one item per brand page to poll on the Meta Ad Library API.\n// Baselines are what we consider \"normal\" for that brand so the diff can flag outliers.\nconst WATCHLIST = [\n  { brand: 'Ridge Wallet',  page_id: '1234567890', country: 'US', vertical: 'accessories', baseline_active_ads: 140 },\n  { brand: 'Hexclad',       page_id: '2234567890', country: 'US', vertical: 'cookware',    baseline_active_ads: 220 },\n  { brand: 'Jones Road',    page_id: '3234567890', country: 'US', vertical: 'beauty',      baseline_active_ads: 95  },\n  { brand: 'Dr. Squatch',   page_id: '4234567890', country: 'US', vertical: 'personal',    baseline_active_ads: 310 },\n  { brand: 'Bombas',        page_id: '5234567890', country: 'US', vertical: 'apparel',     baseline_active_ads: 180 }\n];\n\n// Ads that have been live longer than this are treated as proven winners worth copying.\nconst LONG_RUNNER_DAYS = 21;\n// Ads first seen inside this window are \"newly launched\".\nconst NEW_AD_DAYS = 3;\n\nreturn WATCHLIST.map(w => ({\n  json: {\n    ...w,\n    long_runner_days: LONG_RUNNER_DAYS,\n    new_ad_days: NEW_AD_DAYS,\n    polled_at: new Date().toISOString()\n  }\n}));"}},{"id":"7a1c0003-1111-4222-8333-4444aaaa0003","name":"Loop Over Competitors","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[300,300],"parameters":{"batchSize":1,"options":{}}},{"id":"7a1c0004-1111-4222-8333-4444aaaa0004","name":"Fetch Active Ads From Ad Library","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[560,540],"parameters":{"url":"https://graph.facebook.com/v21.0/ads_archive","sendQuery":true,"queryParameters":{"parameters":[{"name":"search_page_ids","value":"={{ $json.page_id }}"},{"name":"ad_reached_countries","value":"={{ $json.country }}"},{"name":"ad_active_status","value":"ACTIVE"},{"name":"fields","value":"id,page_name,ad_creation_time,ad_delivery_start_time,ad_creative_bodies,ad_creative_link_titles,ad_snapshot_url,impressions,spend,currency,publisher_platforms"},{"name":"limit","value":"200"},{"name":"access_token","value":"META_ACCESS_TOKEN"}]},"options":{}}},{"id":"7a1c0005-1111-4222-8333-4444aaaa0005","name":"Normalize Ad Library Payload","type":"n8n-nodes-base.code","typeVersion":2,"position":[820,540],"parameters":{"jsCode":"// Flatten one Ad Library page response into one item per ad, with derived metrics.\n// Graph returns impressions/spend as {lower_bound, upper_bound} ranges - we midpoint them.\nconst ctx = $('Loop Over Competitors').first().json;\nconst now = Date.now();\nconst DAY = 86400000;\n\nconst mid = (range) => {\n  if (!range) return 0;\n  const lo = Number(range.lower_bound || 0);\n  const hi = Number(range.upper_bound || range.lower_bound || 0);\n  return hi > lo ? Math.round((lo + hi) / 2) : lo;\n};\n\n// Strip emoji/punctuation/casing so near-identical copy variants collapse to one hash.\nconst angleKey = (text) => (text || '')\n  .toLowerCase()\n  .replace(/[^a-z0-9 ]/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim()\n  .split(' ')\n  .slice(0, 12)\n  .join('-');\n\nconst out = [];\nfor (const item of $input.all()) {\n  const ads = item.json.data || [];\n  for (const ad of ads) {\n    const started = new Date(ad.ad_delivery_start_time || ad.ad_creation_time || now).getTime();\n    const days_running = Math.max(1, Math.round((now - started) / DAY));\n    const impressions = mid(ad.impressions);\n    const spend = mid(ad.spend);\n    const body = (ad.ad_creative_bodies || [])[0] || '';\n    const headline = (ad.ad_creative_link_titles || [])[0] || '';\n\n    out.push({\n      json: {\n        brand: ctx.brand,\n        vertical: ctx.vertical,\n        page_id: ctx.page_id,\n        baseline_active_ads: ctx.baseline_active_ads,\n        long_runner_days: ctx.long_runner_days,\n        new_ad_days: ctx.new_ad_days,\n        ad_archive_id: ad.id,\n        ad_snapshot_url: ad.ad_snapshot_url || '',\n        started_at: new Date(started).toISOString().slice(0, 10),\n        days_running,\n        impressions,\n        spend,\n        currency: ad.currency || 'USD',\n        // Pacing proxies: the Ad Library gives no daily numbers, so we amortise the range.\n        impressions_per_day: Math.round(impressions / days_running),\n        spend_per_day: Math.round((spend / days_running) * 100) / 100,\n        cpm_estimate: impressions > 0 ? Math.round((spend / impressions) * 1000 * 100) / 100 : 0,\n        platforms: (ad.publisher_platforms || []).join(','),\n        format: ad.media_type || (ad.ad_creative_link_captions ? 'link' : 'video'),\n        headline,\n        body: body.slice(0, 400),\n        angle_key: angleKey(headline + ' ' + body),\n        seen_at: new Date().toISOString()\n      }\n    });\n  }\n}\nreturn out;"}},{"id":"7a1c0006-1111-4222-8333-4444aaaa0006","name":"Read Last Snapshot","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-220,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"COMPETITOR_SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"snapshot"},"options":{}}},{"id":"7a1c0007-1111-4222-8333-4444aaaa0007","name":"Diff Against Last Snapshot","type":"n8n-nodes-base.code","typeVersion":2,"position":[820,300],"parameters":{"jsCode":"// Diff this poll against the last snapshot row set (Google Sheets) and score what matters.\n// $input = the loop's \"done\" output: every normalized ad accumulated across all brands.\nconst current = $input.all().map(i => i.json).filter(r => r && r.ad_archive_id);\nconst snapshot = $('Read Last Snapshot').all().map(i => i.json).filter(r => r && r.ad_archive_id);\n\nconst prevById = new Map(snapshot.map(r => [String(r.ad_archive_id), r]));\nconst prevAngles = new Set(snapshot.map(r => r.brand + '|' + r.angle_key));\n\n// Per-brand volume shift: are they ramping their testing volume this week?\nconst countNow = {}, countPrev = {};\nfor (const c of current) countNow[c.brand] = (countNow[c.brand] || 0) + 1;\nfor (const p of snapshot) countPrev[p.brand] = (countPrev[p.brand] || 0) + 1;\n\n// Dedupe: keep the oldest ad per (brand, angle_key) - copy variants are the same angle.\nconst byAngle = new Map();\nfor (const ad of current) {\n  const key = ad.brand + '|' + ad.angle_key;\n  const kept = byAngle.get(key);\n  if (!kept || ad.days_running > kept.days_running) {\n    byAngle.set(key, { ...ad, variant_count: (kept ? kept.variant_count : 0) + 1 });\n  } else {\n    kept.variant_count = (kept.variant_count || 1) + 1;\n  }\n}\nconst deduped = [...byAngle.values()];\n\nconst results = [];\nfor (const ad of deduped) {\n  const prev = prevById.get(String(ad.ad_archive_id));\n  const isNew = !prev && ad.days_running <= ad.new_ad_days;\n  const isNewAngle = !prevAngles.has(ad.brand + '|' + ad.angle_key);\n  const isLongRunner = ad.days_running >= ad.long_runner_days;\n  // Only report a long runner once: when it crosses the threshold between polls.\n  const crossedThreshold = isLongRunner && (!prev || Number(prev.days_running) < ad.long_runner_days);\n  if (!isNew && !crossedThreshold && !(isNewAngle && ad.variant_count >= 3)) continue;\n\n  // Score 0-100. Longevity is the strongest proxy for \"this ad is profitable\".\n  const longevity = Math.min(40, (ad.days_running / 60) * 40);\n  // Scale: how hard they are pushing it relative to everything else running.\n  const avgSpendPerDay = deduped.reduce((s, a) => s + a.spend_per_day, 0) / (deduped.length || 1);\n  const scale = Math.min(25, avgSpendPerDay > 0 ? (ad.spend_per_day / avgSpendPerDay) * 12.5 : 0);\n  // Conviction: how many creative variants they built around the same angle.\n  const conviction = Math.min(20, (ad.variant_count || 1) * 5);\n  // Freshness bonus so brand-new launches still surface before they have longevity.\n  const freshness = isNew ? 15 : (isNewAngle ? 8 : 0);\n  const score = Math.round(longevity + scale + conviction + freshness);\n\n  const volumeShift = ((countNow[ad.brand] || 0) - (countPrev[ad.brand] || 0));\n  // Projected 30-day spend on this angle if they keep the current pace.\n  const projected_30d_spend = Math.round(ad.spend_per_day * (ad.variant_count || 1) * 30);\n\n  const reasons = [];\n  if (isNew) reasons.push('launched ' + ad.days_running + 'd ago');\n  if (crossedThreshold) reasons.push('crossed ' + ad.long_runner_days + 'd live (proven)');\n  if (isNewAngle) reasons.push('new angle vs last snapshot');\n  if ((ad.variant_count || 1) >= 3) reasons.push(ad.variant_count + ' variants of same angle');\n  if (volumeShift >= 15) reasons.push('brand ad count +' + volumeShift + ' vs last poll');\n\n  results.push({\n    json: {\n      ...ad,\n      status: isNew ? 'new' : (crossedThreshold ? 'long_runner' : 'new_angle'),\n      is_change: true,\n      score,\n      urgent: score >= 75 && (isNew || crossedThreshold),\n      volume_shift: volumeShift,\n      projected_30d_spend,\n      reasons: reasons.join('; ')\n    }\n  });\n}\n\nresults.sort((a, b) => b.json.score - a.json.score);\n\nif (!results.length) {\n  return [{ json: {\n    is_change: false,\n    checked_ads: current.length,\n    unique_angles: deduped.length,\n    brands: Object.keys(countNow).join(', '),\n    checked_at: new Date().toISOString()\n  } }];\n}\nreturn results.slice(0, 25);"}},{"id":"7a1c0008-1111-4222-8333-4444aaaa0008","name":"Any Ad Changes?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1080,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.is_change }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"7a1c0009-1111-4222-8333-4444aaaa0009","name":"Filter Report-Worthy Ads","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1340,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.score }}","rightValue":45,"operator":{"type":"number","operation":"gte"}},{"id":"f2","leftValue":"={{ $json.impressions_per_day }}","rightValue":500,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"7a1c0010-1111-4222-8333-4444aaaa0010","name":"Summarize Ad Angle With LLM","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1600,140],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response strategist. Given a competitor ad, name the marketing ANGLE in one sentence (max 18 words): the promise, the mechanism and the target avatar. No preamble, no quotes."},{"content":"=Brand: {{ $json.brand }} ({{ $json.vertical }})\nStatus: {{ $json.status }} · {{ $json.days_running }} days live · {{ $json.variant_count }} variants\nHeadline: {{ $json.headline }}\nBody: {{ $json.body }}"}]},"options":{}}},{"id":"7a1c0011-1111-4222-8333-4444aaaa0011","name":"Build Slack Digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[1860,140],"parameters":{"jsCode":"// Fold every LLM-summarised ad into one Slack digest message.\nconst ads = $('Filter Report-Worthy Ads').all().map(i => i.json);\n// The LLM node emits one item per input item, in order, so index maps 1:1.\nconst rows = $input.all().map((i, idx) => {\n  const summary = (i.json.message && i.json.message.content) || i.json.content || '';\n  return { ...(ads[idx] || {}), angle_summary: String(summary).trim() };\n});\n\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US');\nconst icon = (s) => (s === 'new' ? '🆕' : s === 'long_runner' ? '🏆' : '🧪');\n\nconst urgent = rows.filter(r => r.urgent);\nconst lines = rows.slice(0, 12).map((r, i) =>\n  `${i + 1}. ${icon(r.status)} *${r.brand}* — score ${r.score}/100 · ${r.days_running}d live · ${r.variant_count || 1} variants\\n` +\n  `   _${r.angle_summary || r.headline}_\\n` +\n  `   ${money(r.spend_per_day)}/day est · ~${money(r.projected_30d_spend)} projected 30d · CPM ~${money(r.cpm_estimate)}\\n` +\n  `   why: ${r.reasons}\\n   <${r.ad_snapshot_url}|view creative>`\n);\n\nconst brands = [...new Set(rows.map(r => r.brand))];\nconst header = urgent.length\n  ? `🚨 ${urgent.length} high-conviction competitor ad(s) — ${brands.join(', ')}`\n  : `🔍 Competitor ad spy — ${rows.length} new signals across ${brands.length} brands`;\n\nreturn [{ json: {\n  urgent_count: urgent.length,\n  total_signals: rows.length,\n  brands: brands.join(', '),\n  top_brand: rows[0] ? rows[0].brand : '',\n  slack_text: header + '\\n\\n' + lines.join('\\n\\n'),\n  digest_rows: rows\n} }];"}},{"id":"7a1c0012-1111-4222-8333-4444aaaa0012","name":"Urgent Signal?","type":"n8n-nodes-base.if","typeVersion":2,"position":[2120,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"u1","leftValue":"={{ $json.urgent_count }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"7a1c0013-1111-4222-8333-4444aaaa0013","name":"Post Urgent Competitor Alert","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2380,20],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-war-room","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"7a1c0014-1111-4222-8333-4444aaaa0014","name":"Post Spy Digest","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2380,260],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#competitor-spy","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"7a1c0015-1111-4222-8333-4444aaaa0015","name":"Write New Snapshot","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2900,140],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"COMPETITOR_SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"snapshot"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"7a1c0016-1111-4222-8333-4444aaaa0016","name":"Build No-Change Heartbeat","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1340,460],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"run_status","value":"no_change","type":"string"},{"id":"h2","name":"checked_ads","value":"={{ $json.checked_ads }}","type":"number"},{"id":"h3","name":"unique_angles","value":"={{ $json.unique_angles }}","type":"number"},{"id":"h4","name":"brands","value":"={{ $json.brands }}","type":"string"},{"id":"h5","name":"checked_at","value":"={{ $json.checked_at }}","type":"string"}]},"options":{}}},{"id":"7a1c0017-1111-4222-8333-4444aaaa0017","name":"Log Heartbeat Row","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1600,460],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"COMPETITOR_SPY_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"runs"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"7a1c0018-1111-4222-8333-4444aaaa0018","name":"Flatten Snapshot Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[2640,140],"parameters":{"jsCode":"// The Slack digest is ONE item, but the snapshot sheet needs one row per ad -\n// otherwise the next run has no ad_archive_id / days_running to diff against and\n// every ad looks brand new forever. Write the FULL current ad set, not just the\n// ads that survived the report filter (per-brand counts and angle history depend on it).\n// all(0) = the loop's \"done\" branch: every normalized ad across every brand.\nlet current = [];\ntry {\n  current = $('Loop Over Competitors').all(0).map(i => i.json);\n} catch (e) {\n  current = [];\n}\nif (!current.length) {\n  current = ($('Build Slack Digest').first().json.digest_rows || []);\n}\n\n// Which ads got reported this run, so the sheet doubles as an audit trail.\nconst reported = new Map(\n  $('Build Slack Digest').first().json.digest_rows.map(r => [String(r.ad_archive_id), r])\n);\n\nconst run_at = new Date().toISOString();\nreturn current\n  .filter(ad => ad && ad.ad_archive_id)\n  .map(ad => {\n    const hit = reported.get(String(ad.ad_archive_id));\n    return { json: {\n      run_at,\n      brand: ad.brand,\n      vertical: ad.vertical,\n      page_id: ad.page_id,\n      ad_archive_id: ad.ad_archive_id,\n      ad_snapshot_url: ad.ad_snapshot_url,\n      started_at: ad.started_at,\n      days_running: ad.days_running,\n      impressions: ad.impressions,\n      spend: ad.spend,\n      impressions_per_day: ad.impressions_per_day,\n      spend_per_day: ad.spend_per_day,\n      cpm_estimate: ad.cpm_estimate,\n      platforms: ad.platforms,\n      headline: ad.headline,\n      angle_key: ad.angle_key,\n      variant_count: hit ? (hit.variant_count || 1) : (ad.variant_count || 1),\n      reported: hit ? 'yes' : 'no',\n      status: hit ? hit.status : 'tracked',\n      score: hit ? hit.score : 0,\n      angle_summary: hit ? (hit.angle_summary || '') : ''\n    } };\n  });"}},{"id":"7a1c00s1-1111-4222-8333-4444aaaa00s1","name":"Note Collection","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[280,-60],"parameters":{"content":"## 1. COLLECT\nEvery 6h, loop the competitor watchlist and pull each brand's ACTIVE ads from the Meta Ad Library (ads_archive). Impressions/spend come back as ranges, so we midpoint them and amortise per day.","height":300,"width":420,"color":4}},{"id":"7a1c00s2-1111-4222-8333-4444aaaa00s2","name":"Note Diff","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[800,-60],"parameters":{"content":"## 2. DIFF + SCORE\nCompare against the last Sheets snapshot. Dedupe copy variants by angle hash, then score 0-100:\nlongevity 40 + spend scale 25 + variant conviction 20 + freshness 15.\nOnly newly launched ads, ads crossing 21 days live, and brand-new angles with 3+ variants get through.","height":320,"width":460,"color":5}},{"id":"7a1c00s3-1111-4222-8333-4444aaaa00s3","name":"Note Report","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1860,-260],"parameters":{"content":"## 3. SUMMARIZE + ALERT\nLLM names the marketing angle of each surviving ad, the digest is folded into one Slack message, urgent finds (score 75+) go to the war room, everything else to the spy channel. The new snapshot is written back so the next run diffs cleanly.","height":300,"width":460,"color":3}}],"connections":{"Every 6 Hours":{"main":[[{"node":"Read Last Snapshot","type":"main","index":0}]]},"Read Last Snapshot":{"main":[[{"node":"Build Competitor Watchlist","type":"main","index":0}]]},"Build Competitor Watchlist":{"main":[[{"node":"Loop Over Competitors","type":"main","index":0}]]},"Loop Over Competitors":{"main":[[{"node":"Diff Against Last Snapshot","type":"main","index":0}],[{"node":"Fetch Active Ads From Ad Library","type":"main","index":0}]]},"Fetch Active Ads From Ad Library":{"main":[[{"node":"Normalize Ad Library Payload","type":"main","index":0}]]},"Normalize Ad Library Payload":{"main":[[{"node":"Loop Over Competitors","type":"main","index":0}]]},"Diff Against Last Snapshot":{"main":[[{"node":"Any Ad Changes?","type":"main","index":0}]]},"Any Ad Changes?":{"main":[[{"node":"Filter Report-Worthy Ads","type":"main","index":0}],[{"node":"Build No-Change Heartbeat","type":"main","index":0}]]},"Filter Report-Worthy Ads":{"main":[[{"node":"Summarize Ad Angle With LLM","type":"main","index":0}]]},"Summarize Ad Angle With LLM":{"main":[[{"node":"Build Slack Digest","type":"main","index":0}]]},"Build Slack Digest":{"main":[[{"node":"Urgent Signal?","type":"main","index":0}]]},"Urgent Signal?":{"main":[[{"node":"Post Urgent Competitor Alert","type":"main","index":0}],[{"node":"Post Spy Digest","type":"main","index":0}]]},"Post Urgent Competitor Alert":{"main":[[{"node":"Flatten Snapshot Rows","type":"main","index":0}]]},"Post Spy Digest":{"main":[[{"node":"Flatten Snapshot Rows","type":"main","index":0}]]},"Flatten Snapshot Rows":{"main":[[{"node":"Write New Snapshot","type":"main","index":0}]]},"Build No-Change Heartbeat":{"main":[[{"node":"Log Heartbeat Row","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}