AutomationFlowsAI & RAG › Detect Competitor Product Launches From Ads with Google Sheets and Openai

Detect Competitor Product Launches From Ads with Google Sheets and Openai

ByZain Khan @zain on n8n.io

This scheduled workflow scans competitor ads via Adyntel (Meta, Google, and LinkedIn), extracts newly appearing terms from ad copy, and uses OpenAI to classify potential launch signals, then logs results and updates baselines in Google Sheets. Runs on a schedule and reads…

Cron / scheduled trigger★★★★★ complexityAI-powered30 nodesGoogle SheetsN8N Nodes AdyntelAgentOpenAI Chat
AI & RAG Trigger: Cron / scheduled Nodes: 30 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #16307 — we link there as the canonical source.

This workflow follows the Agent → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "TxOaGI5QohrvLeG1",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "New Product Launch Sniffer",
  "tags": [],
  "nodes": [
    {
      "id": "1314336b-034b-47bb-9252-98ceb312a16e",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -896,
        48
      ],
      "parameters": {
        "rule": {
          "interval": [
            {}
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "a05a869d-21a5-44fa-a0a7-8df86815eb75",
      "name": "Read Competitors",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -672,
        48
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "Pending",
              "lookupColumn": "status"
            }
          ]
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=0",
          "cachedResultName": "competitors"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "c17003ff-2e98-45e3-8dad-6f64a0b1d518",
      "name": "Loop Over Competitors",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -448,
        48
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "aa98457e-2551-4f05-8a34-18f098630bef",
      "name": "Search LinkedIn Ads",
      "type": "n8n-nodes-adyntel.adyntel",
      "position": [
        -160,
        256
      ],
      "parameters": {
        "resource": "linkedInAds",
        "companyDomain": "={{ $json.domain }}",
        "requestOptions": {}
      },
      "credentials": {
        "adyntelApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "814665ab-ba19-45f2-ba94-328d98f27527",
      "name": "Search Google Ads",
      "type": "n8n-nodes-adyntel.adyntel",
      "position": [
        -160,
        64
      ],
      "parameters": {
        "resource": "googleAds",
        "companyDomain": "={{ $json.domain }}",
        "requestOptions": {}
      },
      "credentials": {
        "adyntelApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "33fbad36-e941-4daa-b891-5a5294e4c020",
      "name": "Search Facebook Ads",
      "type": "n8n-nodes-adyntel.adyntel",
      "position": [
        -160,
        -128
      ],
      "parameters": {
        "companyDomain": "={{ $json.domain }}",
        "requestOptions": {}
      },
      "credentials": {
        "adyntelApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7e38cbbc-5091-4ada-a65a-824a037fad55",
      "name": "Merge All Platform Results",
      "type": "n8n-nodes-base.merge",
      "position": [
        64,
        48
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition",
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "cf491c61-afa9-48bb-84ff-da0a906a15a2",
      "name": "Extract All Ad Copy",
      "type": "n8n-nodes-base.code",
      "position": [
        288,
        64
      ],
      "parameters": {
        "jsCode": "// ===== EXTRACT ALL AD COPY \u2014 TITLES + BODY =====\n\nfunction extractTitle(ad) {\n  if (ad.headline && typeof ad.headline === 'object') {\n    const t = ad.headline.title || ad.headline.description;\n    if (t && !t.includes('{{')) return t;\n  }\n  if (ad.commentary && ad.commentary.text && !ad.commentary.text.includes('{{')) {\n    return ad.commentary.text.substring(0, 150);\n  }\n  if (ad.snapshot) {\n    if (Array.isArray(ad.snapshot.cards) && ad.snapshot.cards.length > 0) {\n      for (const card of ad.snapshot.cards) {\n        if (card.title && !card.title.includes('{{')) return card.title;\n        if (card.body && typeof card.body === 'string' && !card.body.includes('{{')) return card.body.substring(0, 150);\n      }\n    }\n    if (ad.snapshot.title && !ad.snapshot.title.includes('{{')) return ad.snapshot.title;\n    if (ad.snapshot.body) {\n      const bodyText = typeof ad.snapshot.body === 'object' ? ad.snapshot.body.text : ad.snapshot.body;\n      if (bodyText && !bodyText.includes('{{')) return bodyText.substring(0, 150);\n    }\n  }\n  if (typeof ad.title === 'string' && !ad.title.includes('{{')) return ad.title;\n  if (typeof ad.ad_title === 'string' && ad.ad_title !== 'N/A') return ad.ad_title;\n  return null;\n}\n\nfunction extractBody(ad) {\n  if (ad.snapshot) {\n    if (ad.snapshot.body) {\n      const b = typeof ad.snapshot.body === 'object' ? ad.snapshot.body.text : ad.snapshot.body;\n      if (b && b.length > 0) return b.substring(0, 400);\n    }\n    if (Array.isArray(ad.snapshot.cards) && ad.snapshot.cards.length > 0) {\n      const bodies = ad.snapshot.cards\n        .map(c => c.body || c.description || '')\n        .filter(Boolean)\n        .join(' | ');\n      if (bodies) return bodies.substring(0, 400);\n    }\n  }\n  if (ad.commentary && ad.commentary.text) return ad.commentary.text.substring(0, 400);\n  if (ad.description) return String(ad.description).substring(0, 400);\n  return '';\n}\n\nfunction getMonday(d) {\n  const date = new Date(d);\n  const day  = date.getDay();\n  const diff = date.getDate() - day + (day === 0 ? -6 : 1);\n  date.setDate(diff);\n  return date.toISOString().split('T')[0];\n}\n\nconst items = $input.all();\nconst weekOf = getMonday(new Date());\nconst adRows = [];\n\n// Get domain + competitor_name from Loop node\nlet domain = '';\nlet competitorName = '';\ntry {\n  const loopItem = $('Loop Over Competitors').item.json;\n  domain         = loopItem.domain          || loopItem.Domain          || '';\n  competitorName = loopItem.competitor_name || loopItem.Competitor_Name || loopItem.name || loopItem.Name || '';\n} catch(e) {\n  for (const item of items) {\n    if (item.json.domain)          { domain         = item.json.domain; }\n    if (item.json.competitor_name) { competitorName  = item.json.competitor_name; }\n  }\n}\n\nfor (const item of items) {\n  const data = item.json;\n\n  // Facebook/Meta\n  if (data.results && Array.isArray(data.results)) {\n    const fbAds = data.results.flat();\n    for (const ad of fbAds) {\n      const title = extractTitle(ad);\n      if (!title) continue;\n      adRows.push({\n        domain,\n        competitor_name: competitorName,\n        week_of:         weekOf,\n        platform:        'Meta',\n        ad_title:        title,\n        ad_body:         extractBody(ad)\n      });\n    }\n  }\n\n  // LinkedIn / Google\n  if (data.ads && Array.isArray(data.ads)) {\n    for (const ad of data.ads) {\n      let platform = 'Google';\n      if ((ad.view_details_link && ad.view_details_link.includes('linkedin.com')) || ad.headline || ad.commentary) {\n        platform = 'LinkedIn';\n      }\n      const title = extractTitle(ad);\n      if (!title) continue;\n      adRows.push({\n        domain,\n        competitor_name: competitorName,\n        week_of:         weekOf,\n        platform,\n        ad_title:        title,\n        ad_body:         extractBody(ad)\n      });\n    }\n  }\n}\n\n// Deduplicate by platform + title\nconst seen  = new Set();\nconst deduped = adRows.filter(r => {\n  const key = `${r.platform}::${r.ad_title}`;\n  if (seen.has(key)) return false;\n  seen.add(key);\n  return true;\n});\n\nif (deduped.length === 0) {\n  return [{\n    json: {\n      domain,\n      competitor_name: competitorName,\n      week_of:         weekOf,\n      ad_rows:         [],\n      all_copy_text:   '',\n      _no_ads:         true\n    }\n  }];\n}\n\n// Build a single aggregated copy blob for term extraction\nconst allCopyText = deduped\n  .map(r => `${r.ad_title} ${r.ad_body}`)\n  .join(' | ');\n\nreturn [{\n  json: {\n    domain,\n    competitor_name: competitorName,\n    week_of:         weekOf,\n    ad_rows:         deduped,\n    all_copy_text:   allCopyText,\n    _no_ads:         false\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "39fdf4d9-dd4a-45e1-9148-a6d02b7ad330",
      "name": "Read Known Terms",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        608,
        64
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "={{ $json.domain }}",
              "lookupColumn": "domain"
            }
          ]
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1081446382,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=1081446382",
          "cachedResultName": "known_terms"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "bd0a0c4e-c4b5-44e7-8557-1a7dd5658d6c",
      "name": "Extract New Terms",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        64
      ],
      "parameters": {
        "jsCode": "// ===== TERM EXTRACTION & DIFF AGAINST KNOWN TERMS =====\n// Pure code \u2014 no AI. Fast, deterministic, cheap.\n\n// ---------- CONFIG ----------\nconst MIN_ADS_FOR_SIGNAL    = 2;   // new term must appear in this many ads to be worth classifying\nconst MIN_TERM_LENGTH       = 3;   // ignore single chars / 2-char tokens\nconst MAX_TERM_WORDS        = 5;   // max words in a meaningful phrase\n\n// Stopwords \u2014 extend this list freely\nconst STOPWORDS = new Set([\n  'a','an','the','and','or','but','in','on','at','to','for','of','with',\n  'by','from','is','are','was','were','be','been','being','have','has',\n  'had','do','does','did','will','would','could','should','may','might',\n  'shall','can','need','dare','ought','used','get','got','let','make',\n  'your','our','their','its','my','his','her','we','you','they','it',\n  'this','that','these','those','what','which','who','how','when','where',\n  'why','all','more','most','some','any','each','every','both','few',\n  'free','new','now','just','also','even','than','then','so','up','out',\n  'about','into','through','during','before','after','above','below',\n  'between','into','through','off','over','under','again','further',\n  'here','there','once','s','t','re','ll','ve','d','m'\n]);\n\nfunction normalise(str) {\n  return str\n    .toLowerCase()\n    .replace(/[^a-z0-9\\s'\\-]/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction tokenise(text) {\n  const words = normalise(text).split(' ').filter(w => w.length >= MIN_TERM_LENGTH && !STOPWORDS.has(w));\n  const terms  = new Set();\n\n  // Single meaningful words\n  for (const w of words) {\n    if (w.length >= MIN_TERM_LENGTH) terms.add(w);\n  }\n\n  // 2 to MAX_TERM_WORDS ngrams from original words array\n  const allWords = normalise(text).split(' ');\n  for (let n = 2; n <= MAX_TERM_WORDS; n++) {\n    for (let i = 0; i <= allWords.length - n; i++) {\n      const phrase = allWords.slice(i, i + n).join(' ');\n      // Only keep phrases where at least one word is not a stopword\n      const meaningful = allWords.slice(i, i + n).some(w => !STOPWORDS.has(w) && w.length >= MIN_TERM_LENGTH);\n      if (meaningful && phrase.length >= MIN_TERM_LENGTH) {\n        terms.add(phrase);\n      }\n    }\n  }\n\n  return terms;\n}\n\n// ---------- MAIN ----------\n\nconst allItems = $input.all();\n\n// Get ad copy data from Extract All Ad Copy node\nlet adData = {};\ntry {\n  adData = $('Extract All Ad Copy').first().json;\n} catch(e) {\n  return [{ json: { error: 'Could not read ad copy data', _skip: true } }];\n}\n\nconst domain          = adData.domain          || '';\nconst competitorName  = adData.competitor_name || '';\nconst weekOf          = adData.week_of         || '';\nconst adRows          = adData.ad_rows         || [];\n\n// If no ads this week, pass through skip sentinel\nif (adData._no_ads || adRows.length === 0) {\n  return [{\n    json: {\n      domain,\n      competitor_name:  competitorName,\n      week_of:          weekOf,\n      new_terms:        [],\n      term_ad_counts:   {},\n      ad_rows:          [],\n      _no_ads:          true,\n      _skip:            true\n    }\n  }];\n}\n\n// Build known terms set from sheet rows\nconst knownTermsRows = allItems.map(i => i.json).filter(r => r.domain === domain && r.term);\nconst knownTermsSet  = new Set(knownTermsRows.map(r => String(r.term).toLowerCase().trim()));\n\n// Extract all terms from this week's ads, track which ad each appears in\nconst termAdMap = {}; // term -> Set of ad_titles it appeared in\n\nfor (const ad of adRows) {\n  const fullText = `${ad.ad_title} ${ad.ad_body}`;\n  const terms    = tokenise(fullText);\n  for (const term of terms) {\n    if (!knownTermsSet.has(term)) {\n      if (!termAdMap[term]) termAdMap[term] = new Set();\n      termAdMap[term].add(ad.ad_title);\n    }\n  }\n}\n\n// Filter: only keep new terms appearing in MIN_ADS_FOR_SIGNAL or more ads\nconst qualifyingTerms = Object.entries(termAdMap)\n  .filter(([term, adSet]) => adSet.size >= MIN_ADS_FOR_SIGNAL)\n  .map(([term, adSet]) => ({\n    term,\n    times_seen:   adSet.size,\n    seen_in_ads:  [...adSet].join('; ')\n  }))\n  .sort((a, b) => b.times_seen - a.times_seen);\n\n// Build term ad counts map for downstream\nconst termAdCounts = {};\nfor (const t of qualifyingTerms) {\n  termAdCounts[t.term] = t.times_seen;\n}\n\n// Total ads that contain at least one new qualifying term\nconst adsWithNewTerms = new Set();\nfor (const t of qualifyingTerms) {\n  for (const adTitle of (termAdMap[t.term] || [])) {\n    adsWithNewTerms.add(adTitle);\n  }\n}\n\n// Build a text summary for the AI prompt\nconst termsSummary = qualifyingTerms.length > 0\n  ? qualifyingTerms.map(t => `\"${t.term}\" (in ${t.times_seen} ads)`).join(', ')\n  : 'None';\n\nconst thisWeekAdTitles = [...new Set(adRows.map(r => r.ad_title))].join('; ');\n\nreturn [{\n  json: {\n    domain,\n    competitor_name:          competitorName,\n    week_of:                  weekOf,\n    new_terms:                qualifyingTerms,\n    term_ad_counts:           termAdCounts,\n    ads_containing_new_terms: adsWithNewTerms.size,\n    terms_summary:            termsSummary,\n    this_week_ad_titles:      thisWeekAdTitles,\n    all_copy_text:            adData.all_copy_text || '',\n    ad_rows:                  adRows,\n    known_terms_count:        knownTermsSet.size,\n    _no_ads:                  false,\n    _skip:                    qualifyingTerms.length === 0\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "37a4bd43-509c-4f2f-ad89-d345c267feef",
      "name": "Any New Terms?",
      "type": "n8n-nodes-base.if",
      "position": [
        1008,
        80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "263691a5-2d81-44ec-8314-50d03857414f",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json._skip }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "668290ad-28db-4904-8534-f0f4c5b4e6a1",
      "name": "AI Agent \u2014 Classify Terms",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        1152,
        -224
      ],
      "parameters": {
        "text": "=You are analyzing new terms detected in a competitor's paid ad copy this week.\n\nCOMPETITOR: {{ $json.competitor_name }} ({{ $json.domain }})\nWEEK: {{ $json.week_of }}\n\nNEW TERMS DETECTED (not seen in any previous ad copy):\n{{ $json.terms_summary }}\n\nTOTAL ADS CONTAINING NEW TERMS: {{ $json.ads_containing_new_terms }}\n\nALL THIS WEEK'S AD TITLES:\n{{ $json.this_week_ad_titles }}\n\nFULL AD COPY CONTEXT:\n{{ $json.all_copy_text }}\n\nFor each new term, classify it and then determine if the overall pattern constitutes a product launch signal.\n\nReturn ONLY this raw JSON object with no markdown, no backticks, no explanation:\n{\"term_classifications\":[{\"term\":\"exact term here\",\"category\":\"product or feature or audience or benefit or noise\",\"reasoning\":\"one sentence\"}],\"signal_detected\":true,\"signal_strength\":\"High or Medium or Low\",\"likely_launch_type\":\"New Product or New Feature or New Audience or Rebranding or Promotion or Noise\",\"launch_name\":\"your best guess at the product or feature name being launched, or NA\",\"evidence\":\"one sentence describing the specific evidence for this signal\",\"recommended_action\":\"one sentence on what the reader should do in response\",\"signal_summary\":\"2-3 sentence plain-English summary of what is likely being launched and why this matters\"}",
        "options": {
          "systemMessage": "You are a competitive intelligence analyst specializing in detecting product launches from paid advertising signals.\n\nYour job is to classify new terms appearing in competitor ad copy and determine whether the pattern constitutes a product launch signal.\n\nKey rules:\n- A new proper noun appearing in multiple ads simultaneously is almost certainly a product or feature name\n- Technical terms and acronyms that are new are high-value signals\n- Generic benefit language is usually low signal\n- If the same new term appears in 3+ ads, the signal strength is likely High\n- Consider the full ad copy context to understand what is being sold\n\nFor signal_detected: set true if at least one term is classified as product, feature, or represents a meaningful strategic shift. Set false if all terms are noise or generic benefits.\n\nReturn ONLY a raw JSON object. No markdown. No backticks. No code fences. No preamble. No explanation after. If you cannot determine a value use the string NA."
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "b88ca827-badb-4951-8263-095da882995a",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1152,
        -64
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini",
          "cachedResultName": "gpt-4.1-mini"
        },
        "options": {
          "temperature": 0.1
        },
        "responsesApiEnabled": false
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "8f12306a-d796-4fdc-bae4-3dfa7eae1f34",
      "name": "Parse Classification Result",
      "type": "n8n-nodes-base.code",
      "position": [
        1504,
        -224
      ],
      "parameters": {
        "jsCode": "// ===== PARSE AI CLASSIFICATION RESULT =====\n// 3-layer fallback: JSON parse \u2192 regex extraction \u2192 plain-text inference\n\nconst items = $input.all();\nconst data  = items[0].json;\n\nlet aiResult = {\n  term_classifications: [],\n  signal_detected:      false,\n  signal_strength:      'Low',\n  likely_launch_type:   'N/A',\n  launch_name:          'N/A',\n  evidence:             'N/A',\n  recommended_action:   'N/A',\n  signal_summary:       'N/A'\n};\n\nlet rawText = '';\nif (typeof data.output === 'string')     rawText = data.output;\nelse if (typeof data.text === 'string')  rawText = data.text;\nelse if (typeof data === 'string')       rawText = data;\nelse                                     rawText = JSON.stringify(data);\n\n// Attempt 1: full JSON parse\nlet parsedOk = false;\ntry {\n  const cleaned = rawText\n    .replace(/```json\\s*/gi, '')\n    .replace(/```\\s*/gi, '')\n    .trim();\n  const start = cleaned.indexOf('{');\n  const end   = cleaned.lastIndexOf('}');\n  if (start !== -1 && end !== -1) {\n    const parsed = JSON.parse(cleaned.substring(start, end + 1));\n    if (parsed.signal_summary || parsed.signal_detected !== undefined) {\n      aiResult.term_classifications = parsed.term_classifications || [];\n      aiResult.signal_detected      = parsed.signal_detected      === true || parsed.signal_detected === 'true';\n      aiResult.signal_strength      = parsed.signal_strength      || 'Low';\n      aiResult.likely_launch_type   = parsed.likely_launch_type   || 'N/A';\n      aiResult.launch_name          = parsed.launch_name          || 'N/A';\n      aiResult.evidence             = parsed.evidence             || 'N/A';\n      aiResult.recommended_action   = parsed.recommended_action   || 'N/A';\n      aiResult.signal_summary       = parsed.signal_summary       || 'N/A';\n      parsedOk = true;\n    }\n  }\n} catch(e) {}\n\n// Attempt 2: regex field extraction\nif (!parsedOk) {\n  function extract(text, field) {\n    const re = new RegExp('\"' + field + '\"\\\\s*:\\\\s*\"([^\"]+)\"');\n    const m  = text.match(re);\n    return m ? m[1] : null;\n  }\n  function extractBool(text, field) {\n    const re = new RegExp('\"' + field + '\"\\\\s*:\\\\s*(true|false)');\n    const m  = text.match(re);\n    return m ? m[1] === 'true' : null;\n  }\n\n  aiResult.signal_detected    = extractBool(rawText, 'signal_detected')  ?? aiResult.signal_detected;\n  aiResult.signal_strength    = extract(rawText, 'signal_strength')       || aiResult.signal_strength;\n  aiResult.likely_launch_type = extract(rawText, 'likely_launch_type')    || aiResult.likely_launch_type;\n  aiResult.launch_name        = extract(rawText, 'launch_name')           || aiResult.launch_name;\n  aiResult.evidence           = extract(rawText, 'evidence')              || aiResult.evidence;\n  aiResult.recommended_action = extract(rawText, 'recommended_action')    || aiResult.recommended_action;\n  aiResult.signal_summary     = extract(rawText, 'signal_summary')        || aiResult.signal_summary;\n\n  // Try to parse term_classifications array\n  try {\n    const arrMatch = rawText.match(/\"term_classifications\"\\s*:\\s*(\\[.*?\\])/s);\n    if (arrMatch) {\n      aiResult.term_classifications = JSON.parse(arrMatch[1]);\n    }\n  } catch(e) {}\n}\n\n// Attempt 3: plain-text fallback \u2014 use full output as signal_summary\nif (aiResult.signal_summary === 'N/A' && rawText.length > 10) {\n  aiResult.signal_summary = rawText.trim().substring(0, 400);\n  const lower = rawText.toLowerCase();\n  if (lower.includes('product') || lower.includes('launch') || lower.includes('new feature')) {\n    aiResult.signal_detected    = true;\n    aiResult.likely_launch_type = lower.includes('product') ? 'New Product' : 'New Feature';\n  }\n  if (lower.includes('high')) {\n    aiResult.signal_strength = 'High';\n  } else if (lower.includes('medium') || lower.includes('moderate')) {\n    aiResult.signal_strength = 'Medium';\n  }\n}\n\n// Pull context from Extract New Terms node\nlet termData = {};\ntry {\n  termData = $('Extract New Terms').first().json;\n} catch(e) {}\n\nconst domain          = termData.domain          || '';\nconst competitorName  = termData.competitor_name || '';\nconst weekOf          = termData.week_of         || '';\nconst newTerms        = (termData.new_terms || []).map(t => t.term).join('; ') || 'None';\nconst termCount       = (termData.new_terms || []).length;\nconst adsWithTerms    = termData.ads_containing_new_terms || 0;\n\n// Build enriched term rows for known_terms sheet\n// Use AI classifications where available, fallback to 'unclassified'\nconst classMap = {};\nfor (const tc of (aiResult.term_classifications || [])) {\n  if (tc.term) classMap[tc.term.toLowerCase().trim()] = tc.category || 'unclassified';\n}\n\nconst knownTermRows = (termData.new_terms || []).map(t => ({\n  domain,\n  competitor_name: competitorName,\n  term:            t.term,\n  first_seen:      weekOf,\n  times_seen:      t.times_seen,\n  category:        classMap[t.term.toLowerCase().trim()] || 'unclassified',\n  added_at:        new Date().toISOString()\n}));\n\nreturn [{\n  json: {\n    // Signal data for launch_signals sheet\n    domain,\n    competitor_name:          competitorName,\n    week_of:                  weekOf,\n    new_terms:                newTerms,\n    term_count:               termCount,\n    ads_containing_new_terms: adsWithTerms,\n    signal_strength:          aiResult.signal_strength,\n    likely_launch_type:       aiResult.likely_launch_type,\n    launch_name:              aiResult.launch_name,\n    evidence:                 aiResult.evidence,\n    recommended_action:       aiResult.recommended_action,\n    signal_summary:           aiResult.signal_summary,\n    analyzed_at:              new Date().toISOString(),\n    // Routing flags\n    signal_detected:          aiResult.signal_detected,\n    // Known terms rows to write\n    known_term_rows:          knownTermRows\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "16020eee-ea6f-4fc1-97f3-a00a57adbe3d",
      "name": "Signal Detected?",
      "type": "n8n-nodes-base.if",
      "position": [
        1712,
        -224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "8bfbb52b-686f-4c93-b608-4b5201ffc725",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.signal_detected }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "a2a2de2a-bc4f-4cb6-8a32-1d08234a358a",
      "name": "Prep New Known Terms",
      "type": "n8n-nodes-base.code",
      "position": [
        2400,
        -208
      ],
      "parameters": {
        "jsCode": "// ===== PREPARE KNOWN TERM ROWS FROM PARSE RESULT =====\n// Works for both signal and no-signal paths\n\nlet parseData = {};\ntry {\n  parseData = $('Parse Classification Result').first().json;\n} catch(e) {\n  return [{ json: { _nothing_to_write: true } }];\n}\n\nconst rows = parseData.known_term_rows || [];\nif (rows.length === 0) return [{ json: { _nothing_to_write: true } }];\n\nreturn rows.map(r => ({ json: r }));"
      },
      "typeVersion": 2
    },
    {
      "id": "99cbb732-b7fc-4429-b801-f9a51cefc424",
      "name": "Append to Launch Signals",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2176,
        -320
      ],
      "parameters": {
        "columns": {
          "value": {
            "domain": "={{ $json.domain }}",
            "week_of": "={{ $json.week_of }}",
            "evidence": "={{ $json.evidence }}",
            "new_terms": "={{ $json.new_terms }}",
            "term_count": "={{ $json.term_count }}",
            "analyzed_at": "={{ $json.analyzed_at }}",
            "launch_name": "={{ $json.launch_name }}",
            "signal_summary": "={{ $json.signal_summary }}",
            "competitor_name": "={{ $json.competitor_name }}",
            "signal_strength": "={{ $json.signal_strength }}",
            "likely_launch_type": "={{ $json.likely_launch_type }}",
            "recommended_action": "={{ $json.recommended_action }}",
            "ads_containing_new_terms": "={{ $json.ads_containing_new_terms }}"
          },
          "schema": [
            {
              "id": "domain",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "competitor_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "competitor_name",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "week_of",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "week_of",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "new_terms",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "new_terms",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "term_count",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "term_count",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "ads_containing_new_terms",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ads_containing_new_terms",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "signal_strength",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "signal_strength",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "likely_launch_type",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "likely_launch_type",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "launch_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "launch_name",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "evidence",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "evidence",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "recommended_action",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "recommended_action",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "signal_summary",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "signal_summary",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "analyzed_at",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "analyzed_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1035421942,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=1035421942",
          "cachedResultName": "launch_signals"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "3b7b0128-c630-4b7a-a168-471db0c45681",
      "name": "Append New Known Terms",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2592,
        -208
      ],
      "parameters": {
        "columns": {
          "value": {
            "term": "={{ $json.term }}",
            "domain": "={{ $json.domain }}",
            "added_at": "={{ $json.added_at }}",
            "category": "={{ $json.category }}",
            "first_seen": "={{ $json.first_seen }}",
            "times_seen": "={{ $json.times_seen }}",
            "competitor_name": "={{ $json.competitor_name }}"
          },
          "schema": [
            {
              "id": "domain",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "competitor_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "competitor_name",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "term",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "term",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "first_seen",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "first_seen",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "times_seen",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "times_seen",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "category",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "category",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "added_at",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "added_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1081446382,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=1081446382",
          "cachedResultName": "known_terms"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "2c18ac6b-77ba-4a50-85b9-85d6418dc96c",
      "name": "Update Compatitor status",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2800,
        -208
      ],
      "parameters": {
        "columns": {
          "value": {
            "domain": "={{ $('Extract All Ad Copy').first().json.domain }}",
            "status": "Done"
          },
          "schema": [
            {
              "id": "competitor_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "competitor_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "domain",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": true,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "domain"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=0",
          "cachedResultName": "competitors"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "9bb0d719-7ea7-49ab-8da0-1ca83e8b766b",
      "name": "Format Slack Alert",
      "type": "n8n-nodes-base.code",
      "position": [
        3008,
        -208
      ],
      "parameters": {
        "jsCode": "// ===== FORMAT SLACK LAUNCH ALERT =====\n\nlet signalData = {};\ntry {\n  signalData = $('Parse Classification Result').first().json;\n} catch(e) {\n  signalData = $input.first().json;\n}\n\nconst strengthEmoji = {\n  'High':   '\ud83d\udea8',\n  'Medium': '\u26a0\ufe0f',\n  'Low':    '\ud83d\udce1'\n};\nconst typeEmoji = {\n  'New Product':  '\ud83c\udd95',\n  'New Feature':  '\u2728',\n  'New Audience': '\ud83c\udfaf',\n  'Rebranding':   '\ud83d\udd04',\n  'Promotion':    '\ud83d\udce3'\n};\n\nconst sEmoji = strengthEmoji[signalData.signal_strength] || '\ud83d\udce1';\nconst tEmoji = typeEmoji[signalData.likely_launch_type]  || '\ud83d\udd0d';\n\nconst message = [\n  `${sEmoji} *Product Launch Signal Detected* \u2014 ${signalData.signal_strength} Confidence`,\n  `${tEmoji} *Type:* ${signalData.likely_launch_type}`,\n  `*Competitor:* ${signalData.competitor_name} (${signalData.domain})`,\n  `*Week:* ${signalData.week_of}`,\n  ``,\n  `*What's launching:* ${signalData.launch_name}`,\n  ``,\n  `*Summary:* ${signalData.signal_summary}`,\n  ``,\n  `*Evidence:* ${signalData.evidence}`,\n  `*New terms detected:* ${signalData.new_terms}`,\n  `*Ads with new terms:* ${signalData.ads_containing_new_terms}`,\n  ``,\n  `*Recommended action:* ${signalData.recommended_action}`\n].join('\\n');\n\nreturn [{\n  json: {\n    slack_message:   message,\n    signal_strength: signalData.signal_strength,\n    competitor_name: signalData.competitor_name\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8407716b-690b-4396-82df-d0d0e5d9bcb2",
      "name": "Prep Baseline Terms",
      "type": "n8n-nodes-base.code",
      "position": [
        1280,
        208
      ],
      "parameters": {
        "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n  item.json.myNew// ===== LOG NO-NEW-TERMS \u2014 write to known_terms sheet anyway to keep baseline fresh =====\n// We still want to add any single-occurrence terms to the baseline\n// so they don't stay perpetually 'new' in future weeks\n\nconst data = $input.first().json;\n\n// If truly no ads, nothing to write\nif (data._no_ads) {\n  return [{ json: { _nothing_to_write: true } }];\n}\n\n// Get ad data from Extract All Ad Copy\nlet adData = {};\ntry {\n  adData = $('Extract All Ad Copy').first().json;\n} catch(e) {}\n\nconst adRows = adData.ad_rows || [];\n\n// Re-extract all terms (including single-occurrence ones) to add to baseline\nconst STOPWORDS = new Set([\n  'a','an','the','and','or','but','in','on','at','to','for','of','with',\n  'by','from','is','are','was','were','be','been','being','have','has',\n  'had','do','does','did','will','would','could','should','may','might',\n  'shall','can','need','dare','ought','used','get','got','let','make',\n  'your','our','their','its','my','his','her','we','you','they','it',\n  'this','that','these','those','what','which','who','how','when','where',\n  'why','all','more','most','some','any','each','every','both','few',\n  'free','new','now','just','also','even','than','then','so','up','out',\n  'about','into','through','during','before','after','above','below',\n  'between','off','over','under','again','further','here','there','once',\n  's','t','re','ll','ve','d','m'\n]);\n\nfunction normalise(str) {\n  return str.toLowerCase().replace(/[^a-z0-9\\s'\\-]/g, ' ').replace(/\\s+/g, ' ').trim();\n}\n\n// We only write title-level unigrams and bigrams to the baseline here (not full ngrams)\n// to avoid polluting it with noise\nconst termSet = new Set();\nfor (const ad of adRows) {\n  const words = normalise(ad.ad_title).split(' ').filter(w => w.length >= 3 && !STOPWORDS.has(w));\n  for (const w of words) termSet.add(w);\n  for (let i = 0; i < words.length - 1; i++) termSet.add(`${words[i]} ${words[i+1]}`);\n}\n\nconst rows = [...termSet].map(term => ({\n  domain:          data.domain          || adData.domain          || '',\n  competitor_name: data.competitor_name || adData.competitor_name || '',\n  term,\n  first_seen:      data.week_of || adData.week_of || '',\n  times_seen:      1,\n  category:        'baseline',\n  added_at:        new Date().toISOString()\n}));\n\nif (rows.length === 0) return [{ json: { _nothing_to_write: true } }];\n\nreturn rows.map(r => ({ json: r }));Field = 1;\n}\n\nreturn $input.all();"
      },
      "typeVersion": 2
    },
    {
      "id": "45623495-d810-407a-87e7-aeb776e22e6b",
      "name": "Has Baseline Terms?",
      "type": "n8n-nodes-base.if",
      "position": [
        1488,
        208
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "790c774e-5958-4cd7-9e39-5ee2b4832c30",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json._nothing_to_write }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "c3fd7c6a-a526-4336-aba9-deaf766b9117",
      "name": "Append Baseline Terms",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1696,
        224
      ],
      "parameters": {
        "columns": {
          "value": {
            "term": "={{ $json.term }}",
            "domain": "={{ $json.domain }}",
            "added_at": "={{ $json.added_at }}",
            "category": "={{ $json.category }}",
            "first_seen": "={{ $json.first_seen }}",
            "times_seen": "={{ $json.times_seen }}",
            "competitor_name": "={{ $json.competitor_name }}"
          },
          "schema": [
            {
              "id": "domain",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "competitor_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "competitor_name",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "term",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "term",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "first_seen",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "first_seen",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "times_seen",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "times_seen",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "category",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "category",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "added_at",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "added_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1081446382,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=1081446382",
          "cachedResultName": "known_terms"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "9fc00854-9751-4236-aea8-9a4cc14abd6a",
      "name": "Update competitor status",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2112,
        224
      ],
      "parameters": {
        "columns": {
          "value": {
            "domain": "={{ $('Extract All Ad Copy').first().json.domain }}",
            "status": "Done"
          },
          "schema": [
            {
              "id": "competitor_name",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "competitor_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "domain",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "domain",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": true,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "domain"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit#gid=0",
          "cachedResultName": "competitors"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1T77bgEd1Omyk8g4PkQqPrMKuF1I22XMR7zaEug5pGbo/edit?usp=drivesdk",
          "cachedResultName": "5th Template"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "typeVersion": 4.7
    },
    {
      "id": "042bc48a-a93b-4d65-93bd-fabcccdbb995",
      "name": "Loop Back (no new terms)",
      "type": "n8n-nodes-base.noOp",
      "position": [
        2416,
        192
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "b112c07b-7348-43d6-aa95-95fe1d82ffdb",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1696,
        -272
      ],
      "parameters": {
        "width": 720,
        "height": 640,
        "content": "# \ud83d\ude80 New Product Launch Sniffer\n\n## \ud83d\udd0d How it works\nMonitors competitor advertising activity to detect potential product launches. The workflow collects ad copy across multiple platforms, extracts new terms and messaging patterns, compares them against historical data, and uses AI to identify launch signals. Confirmed signals are logged, stored, and reported for ongoing competitive intelligence.\n\n## \u2699\ufe0f How to set up\n- [ ]  Connect Google Sheets credentials\n- [ ]   Configure Adyntel API credentials\n- [ ]   Add OpenAI credentials\n - [ ]  Populate competitor names and domains\n- [ ]   Ensure required sheets exist (competitors, known_terms, launch_signals)\n - [ ]  Set competitor status to Pending\n - [ ]  Enable the weekly schedule trigger\n- [ ]   Test with a small competitor list\n\n\ud83d\udee0\ufe0f Customization\n- Change scan frequency\n- Add additional ad platforms\n- Adjust term extraction rules\n- Modify AI launch classification logic\n- Add Slack, Email, or Discord notifications\n- Store intelligence in Airtable, Notion, or a database"
      },
      "typeVersion": 1
    },
    {
      "id": "6e0e1cf7-2911-4e4a-af81-b05d50d04ffb",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -912,
        -176
      ],
      "parameters": {
        "color": 6,
        "width": 576,
        "height": 496,
        "content": "## \ud83d\udcc5 Workflow Trigger & Competitor Processing\n\nPurpose\nStarts the workflow on schedule, loads competitors awaiting analysis, and processes each competitor individually to maintain clean and isolated results."
      },
      "typeVersion": 1
    },
    {
      "id": "3858e1fe-943b-4184-9cb5-d004adb3f76f",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -256,
        -320
      ],
      "parameters": {
        "color": 5,
        "width": 704,
        "height": 720,
        "content": "## \ud83d\udce2 Multi-Platform Ad Collection & Content Extraction\n\nPurpose\nCollects active advertisements from multiple platforms, extracts ad headlines and descriptions, removes duplicates, and prepares a unified content dataset for analysis."
      },
      "typeVersion": 1
    },
    {
      "id": "450514c6-f5bb-4fc7-9bac-23987e245971",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        528,
        -384
      ],
      "parameters": {
        "color": 4,
        "width": 1344,
        "height": 784,
        "content": "## \ud83d\udd0d Term Discovery & Signal Detection\n\nPurpose\nCompares current ad messaging against historical knowledge, identifies previously unseen terms, updates baseline intelligence, and determines whether meaningful launch signals exist."
      },
      "typeVersion": 1
    },
    {
      "id": "9489e231-4ec6-4de4-a3ef-3d41cda4d9a9",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1920,
        -512
      ],
      "parameters": {
        "color": 2,
        "width": 1312,
        "height": 912,
        "content": "## \ud83e\udde0 AI Launch Analysis & Intelligence Reporting\n\nPurpose\nUses AI to evaluate newly discovered terms, determines launch likelihood, stores confirmed intelligence, updates historical knowledge, sends alerts, marks processing complete, and continues the workflow for remaining competitors."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "6c4dd3b3-0074-4302-9419-ff01166cae38",
  "connections": {
    "Any New Terms?": {
      "main": [
        [
          {
            "node": "Prep Baseline Terms",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI Agent \u2014 Classify Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Competitors": {
      "main": [
        [
          {
            "node": "Loop Over Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Known Terms": {
      "main": [
        [
          {
            "node": "Extract New Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Read Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signal Detected?": {
      "main": [
        [
          {
            "node": "Append to Launch Signals",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prep New Known Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract New Terms": {
      "main": [
        [
          {
            "node": "Any New Terms?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent \u2014 Classify Terms",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Search Google Ads": {
      "main": [
        [
          {
            "node": "Merge All Platform Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Format Slack Alert": {
      "main": [
        [
          {
            "node": "Loop Over Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract All Ad Copy": {
      "main": [
        [
          {
            "node": "Read Known Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Baseline Terms?": {
      "main": [
        [
          {
            "node": "Loop Back (no new terms)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Append Baseline Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Baseline Terms": {
      "main": [
        [
          {
            "node": "Has Baseline Terms?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Facebook Ads": {
      "main": [
        [
          {
            "node": "Merge All Platform Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search LinkedIn Ads": {
      "main": [
        [
          {
            "node": "Merge All Platform Results",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Prep New Known Terms": {
      "main": [
        [
          {
            "node": "Append New Known Terms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Baseline Terms": {
      "main": [
        [
          {
            "node": "Update competitor status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Competitors": {
      "main": [
        [],
        [
          {
            "node": "Search Facebook Ads",
            "type": "main",
            "index": 0
          },
          {
            "node": "Search Google Ads",
            "type": "main",
            "index": 0
          },
          {
            "node": "Search LinkedIn Ads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append New Known Terms": {
      "main": [
        [
          {
            "node": "Update Compatitor status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to Launch Signals": {
      "main": [
        [
          {
            "node": "Prep New Known Terms

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This scheduled workflow scans competitor ads via Adyntel (Meta, Google, and LinkedIn), extracts newly appearing terms from ad copy, and uses OpenAI to classify potential launch signals, then logs results and updates baselines in Google Sheets. Runs on a schedule and reads…

Source: https://n8n.io/workflows/16307/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

This scheduled workflow reads competitor domains from Google Sheets, pulls their current ads from Meta, Google, and LinkedIn via Adyntel, compares this week’s messaging to last week’s snapshot with Op

Google Sheets, N8N Nodes Adyntel, Agent +1
AI & RAG

⚠️ DISCLAIMER: This workflow uses the AnySite LinkedIn community node, which is only available on self-hosted n8n instances. It will not work on n8n.cloud.

OpenAI Chat, Output Parser Structured, Google Sheets +6
AI & RAG

This n8n automation workflow automates the creation, scripting, production, and posting of YouTube videos. It leverages AI (OpenAI), image generation (PIAPI), video rendering (Shotstack), and platform

Agent, OpenAI Chat, Airtable Tool +7
AI & RAG

This workflow is designed for: Content creators and marketers E-commerce and product-based businesses Agencies producing social media visuals and videos Automation builders looking for AI-powered crea

HTTP Request, Edit Image, Google Drive +7
AI & RAG

Generate product images with NanoBanana Pro to Veo videos and Blotato - vide 2 ok. Uses httpRequest, editImage, googleDrive, googleSheets. Scheduled trigger; 76 nodes.

HTTP Request, Edit Image, Google Drive +7