This workflow corresponds to n8n.io template #16371 — 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 →
{
"id": "4LL2f2uQbCTB2kpR",
"name": "Ad Activity Re-Engagement Trigger",
"tags": [],
"nodes": [
{
"id": "eae78e14-957c-410b-adbb-7cd252f68f38",
"name": "Read Competitors",
"type": "n8n-nodes-base.googleSheets",
"position": [
-1824,
288
],
"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"
}
},
"typeVersion": 4.4
},
{
"id": "6569890a-846e-49ac-b7e4-82b1758b0b04",
"name": "Loop Over Competitors",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-1600,
384
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "bb47bd45-6532-4f58-ba09-7155ddca88e0",
"name": "Search Facebook Ads",
"type": "n8n-nodes-adyntel.adyntel",
"position": [
-1376,
16
],
"parameters": {
"resource": "facebookAds",
"operation": "search"
},
"credentials": {},
"typeVersion": 1
},
{
"id": "89d40de4-d650-46de-934f-28ce52042223",
"name": "Search Google Ads",
"type": "n8n-nodes-adyntel.adyntel",
"position": [
-1376,
208
],
"parameters": {
"resource": "googleAds",
"operation": "search"
},
"credentials": {},
"typeVersion": 1
},
{
"id": "7756beb6-262b-412a-8122-d8617b5b31cb",
"name": "Search LinkedIn Ads",
"type": "n8n-nodes-adyntel.adyntel",
"position": [
-1376,
400
],
"parameters": {
"resource": "linkedInAds",
"operation": "search"
},
"credentials": {},
"typeVersion": 1
},
{
"id": "a320b110-6374-4347-a44e-ec84fbc4959d",
"name": "Merge All Platform Results",
"type": "n8n-nodes-base.merge",
"position": [
-1152,
192
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition",
"numberInputs": 3
},
"typeVersion": 3
},
{
"id": "c616b15f-bc9c-4e95-b6b1-e9bf79fa0311",
"name": "Extract All Ad Copy",
"type": "n8n-nodes-base.code",
"position": [
-928,
208
],
"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": "3632aaca-4248-4418-8efb-5ec9e6917800",
"name": "Read Known Terms",
"type": "n8n-nodes-base.googleSheets",
"position": [
-704,
208
],
"parameters": {
"options": {
"returnAllMatches": "returnAllMatches"
},
"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"
}
},
"typeVersion": 4.4,
"alwaysOutputData": true
},
{
"id": "369e3bad-4865-451a-a8c1-b1b2b5e9ffab",
"name": "Extract New Terms",
"type": "n8n-nodes-base.code",
"position": [
-480,
208
],
"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": "1fe6c2c5-315e-4299-b808-80c5ca36edae",
"name": "Any New Terms?",
"type": "n8n-nodes-base.if",
"position": [
-256,
208
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "or",
"conditions": [
{
"id": "skip-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json._skip }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "45e1d790-beb6-435b-9412-b64b584ffcdd",
"name": "Prep Baseline Terms",
"type": "n8n-nodes-base.code",
"position": [
32,
352
],
"parameters": {
"jsCode": "// ===== 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 }));"
},
"typeVersion": 2
},
{
"id": "3d10eb31-506f-4a45-99db-f0c232cb7fa4",
"name": "Has Baseline Terms?",
"type": "n8n-nodes-base.if",
"position": [
320,
352
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": false,
"typeValidation": "strict"
},
"combinator": "or",
"conditions": [
{
"id": "nothing-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json._nothing_to_write }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "88382446-6baf-4cc2-975a-aee4b73df820",
"name": "Append Baseline Terms",
"type": "n8n-nodes-base.googleSheets",
"position": [
544,
272
],
"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"
}
},
"typeVersion": 4.4
},
{
"id": "e1bf6014-2826-4fe7-8b6e-0d5a30fe486a",
"name": "Loop Back (no new terms)",
"type": "n8n-nodes-base.noOp",
"position": [
992,
352
],
"parameters": {},
"typeVersion": 1
},
{
"id": "0cbfacd1-24dd-4a5f-8972-9a4dafb00815",
"name": "AI Agent \u2014 Classify Terms",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
-32,
-80
],
"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\"}",
"agent": "conversationalAgent",
"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": 1.7
},
{
"id": "b03df0b9-77ec-44e7-acbf-67e9922d6cab",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
48,
144
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4.1-mini",
"cachedResultName": "gpt-4.1-mini"
},
"options": {
"temperature": 0.1
}
},
"typeVersion": 1.2
},
{
"id": "983a006c-b6e2-4bae-ad1c-5c37962da8be",
"name": "Parse Classification Result",
"type": "n8n-nodes-base.code",
"position": [
320,
32
],
"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": "2df37316-d6b2-41ff-a307-32fb17075f39",
"name": "Signal Detected?",
"type": "n8n-nodes-base.if",
"position": [
544,
32
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": false,
"typeValidation": "strict"
},
"combinator": "or",
"conditions": [
{
"id": "signal-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.signal_detected }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "8c586cda-846c-49d7-af9e-4ebbdd8b1545",
"name": "Append to Launch Signals",
"type": "n8n-nodes-base.googleSheets",
"position": [
768,
-48
],
"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"
}
},
"typeVersion": 4.4
},
{
"id": "927e779a-db79-448f-acee-0ec35ab0d567",
"name": "Prep New Known Terms",
"type": "n8n-nodes-base.code",
"position": [
992,
32
],
"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": "f50b6403-d6f4-4698-98be-2aba4bc4808f",
"name": "Append New Known Terms",
"type": "n8n-nodes-base.googleSheets",
"position": [
1216,
32
],
"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"
}
},
"typeVersion": 4.4
},
{
"id": "d3a27acb-4220-4157-be6f-1722a7b44d78",
"name": "Format Slack Alert",
"type": "n8n-nodes-base.code",
"position": [
1664,
32
],
"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": "c331007c-146b-44c9-a115-53bedbd708da",
"name": "Loop Back (signal)",
"type": "n8n-nodes-base.noOp",
"position": [
1888,
368
],
"parameters": {},
"typeVersion": 1
},
{
"id": "cd54fa87-58e2-44b5-925a-cae44fa33611",
"name": "Loop Back (no signal)",
"type": "n8n-nodes-base.noOp",
"position": [
-1824,
480
],
"parameters": {},
"typeVersion": 1
},
{
"id": "96dc1c25-2a3e-4d27-9f98-672d08a8c546",
"name": "Update row in sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
768,
272
],
"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"
}
},
"executeOnce": true,
"typeVersion": 4.7
},
{
"id": "19146897-9081-411d-ad2c-6f4812118538",
"name": "Update row in sheet1",
"type": "n8n-nodes-base.googleSheets",
"position": [
1440,
32
],
"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"
}
},
"executeOnce": true,
"typeVersion": 4.7
},
{
"id": "879c5dd3-8331-4a4b-b9ca-2253f17ed8ec",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2256,
128
],
"parameters": {
"color": 7,
"width": 704,
"height": 368,
"content": "## Weekly Workflow Trigger\nStarts the workflow every Monday to scan competitors for new product launch signals.\nFetches competitors marked as Pending from Google Sheets for analysis.\n\nLoops through each competitor individually to avoid mixing analysis results.\n"
},
"typeVersion": 1
},
{
"id": "e9ca392d-fa78-48c8-8191-f5362c6b836c",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1552,
-128
],
"parameters": {
"color": 7,
"width": 960,
"height": 608,
"content": "## Collect Ads, Combine Ad Sources\n\nRetrieves active Facebook and Instagram ad creatives for the competitor.\nRetrieves active Google advertising campaigns and ad copy.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n"
},
"typeVersion": 1
},
{
"id": "514284a0-a660-4717-96a6-909b61e289df",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"position": [
240,
272
],
"parameters": {
"color": 4,
"width": 816,
"height": 432,
"content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n## \ud83d\udd04 Baseline Validation, Storage & Workflow Continuation\nChecks whether there are baseline terms worth saving. Adds newly observed terms to the historical knowledge base. Updates the competitor status to Done after baseline processing. Returns to the competitor loop when no meaningful signal is found..\n\n\n\n"
},
"typeVersion": 1
},
{
"id": "9af86ddd-af4e-4f75-a9fd-aa4a11bf1eec",
"name": "Sticky Note14",
"type": "n8n-nodes-base.stickyNote",
"position": [
-400,
-160
],
"parameters": {
"color": 7,
"width": 592,
"height": 640,
"content": "## AI Launch Analysis\nUses AI to classify new terms and determine launch likelihood.\n\n"
},
"typeVersion": 1
},
{
"id": "b5c8d137-89a3-4973-aeb9-f71711109bed",
"name": "Sticky Note15",
"type": "n8n-nodes-base.stickyNote",
"position": [
224,
-144
],
"parameters": {
"color": 7,
"width": 1568,
"height": 352,
"content": "## \ud83e\udde0 AI Processing, Signal Evaluation & Reporting Pipeline\nConverts AI output into structured launch intelligence data. Determines whether the detected activity represents a real launch event. Stores confirmed launch signals for reporting and historical tracking. Formats newly discovered terms for insertion into the knowledge base. Adds classified terms to the known terms repository. Updates the competitor status to Done after launch signal analysis. Builds a human-readable launch notification for team review.\n\n\n\n\n\n"
},
"typeVersion": 1
},
{
"id": "72840e83-91cf-4eb5-bbb3-e26a6c30752d",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2992,
-112
],
"parameters": {
"width": 672,
"height": 688,
"content": "## \ud83d\ude80 Weekly Competitor Launch Signal Detector\n\n\ud83d\udd0d How it works\n\nRuns weekly to monitor competitor activity by collecting ads from Facebook, Google, and LinkedIn. It merges and cleans ad data, extracts keywords, compares them with historical terms, and uses AI to detect possible product launch signals. If a signal is found, it logs it and sends a Slack alert; otherwise, it updates the baseline terms for future tracking.\n\n\u2699\ufe0f How to set up\n - [ ] Connect Google Sheets (competitors, logs, known terms)\n - [ ] Add ad data sources (Facebook, Google Ads, LinkedIn APIs/scrapers)\n - [ ] Configure OpenAI API key for AI classification\n - [ ] Set up Slack webhook for alerts\n- [ ] Enable weekly Schedule Trigger (Monday run)\n- [ ] Map sheet columns correctly (status, competitor, logs)\n - [ ] Test workflow with a sample competitor\n\n\ud83d\udee0\ufe0f Customization\nYou can change frequency, add more ad platforms, adjust AI detection logic, modify keyword sensitivity, or replace Slack with other notification tools like email or Notion."
},
"typeVersion": 1
},
{
"id": "5739bdf6-f405-4284-b2c8-794c86f298d1",
"name": "Activity Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-2048,
288
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * 1"
}
]
}
},
"typeVersion": 1.1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "c23cc7e6-9aa7-42c3-831e-5ab0bd7cf77d",
"nodeGroups": [],
"connections": {
"Any New Terms?": {
"main": [
[
{
"node": "Prep Baseline Terms",
"type": "main",
"index": 0
}
],
[
{
"node": "AI Agent \u2014 Classify Terms",
"type": "main",
"index": 0
}
]
]
},
"Activity Trigger": {
"main": [
[
{
"node": "Read Competitors",
"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
}
]
]
},
"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 Back (signal)",
"type": "main",
"index": 0
}
]
]
},
"Loop Back (signal)": {
"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
}
]
]
},
"Update row in sheet": {
"main": [
[
{
"node": "Loop Back (no new terms)",
"type": "main",
"index": 0
}
]
]
},
"Prep New Known Terms": {
"main": [
[
{
"node": "Append New Known Terms",
"type": "main",
"index": 0
}
]
]
},
"Update row in sheet1": {
"main": [
[
{
"node": "Format Slack Alert",
"type": "main",
"index": 0
}
]
]
},
"Append Baseline Terms": {
"main": [
[
{
"node": "Update row in sheet",
"type": "main",
"index": 0
}
]
]
},
"Loop Back (no signal)": {
"main": [
[
{
"node": "Loop Over Competitors",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Competitors": {
"main": [
[],
[
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs weekly to scan competitor ad copy from Meta, Google Ads, and LinkedIn via Adyntel, detects newly emerging terms, uses OpenAI to assess launch signals, and records results in Google Sheets while sending a Slack alert when a launch signal is detected. Runs every…
Source: https://n8n.io/workflows/16371/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
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 resu
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
⚠️ 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.
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
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