{"name":"Negative keywords autopilot workflow","nodes":[{"id":"00000001-1111-4222-8333-444455556601","name":"Every Morning 07:00","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[40,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"00000002-1111-4222-8333-444455556602","name":"Pull Search Terms Report","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,160],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT search_term_view.search_term, search_term_view.status, campaign.id, campaign.name, ad_group.id, ad_group.name, metrics.cost_micros, metrics.clicks, metrics.impressions, metrics.conversions, metrics.conversions_value FROM search_term_view WHERE segments.date DURING LAST_30_DAYS AND metrics.impressions > 0 ORDER BY metrics.cost_micros DESC LIMIT 2000\" }) }}","options":{}}},{"id":"00000003-1111-4222-8333-444455556603","name":"Pull Existing Negative List","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,440],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT shared_criterion.keyword.text, shared_criterion.keyword.match_type, shared_set.name FROM shared_criterion WHERE shared_set.type = 'NEGATIVE_KEYWORDS'\" }) }}","options":{}}},{"id":"00000014-1111-4222-8333-444455556614","name":"Flatten Search Term Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[520,160],"parameters":{"jsCode":"// searchStream returns an array of chunks, each { results: [...] }.\n// Collapse every chunk into ONE item so the merge pairs cleanly by position.\nconst search_terms = [];\nfor (const item of $input.all()) {\n  for (const r of (item.json.results || [])) search_terms.push(r);\n}\nreturn [{ json: { search_terms } }];"}},{"id":"00000015-1111-4222-8333-444455556615","name":"Flatten Existing Negatives","type":"n8n-nodes-base.code","typeVersion":2,"position":[520,440],"parameters":{"jsCode":"// Collapse the negative shared-set query into ONE item of plain keyword text\n// so already-blocked terms are never proposed again.\nconst existing_negatives = [];\nfor (const item of $input.all()) {\n  for (const r of (item.json.results || [])) {\n    const kw = r.sharedCriterion?.keyword || r.shared_criterion?.keyword || {};\n    const text = kw.text;\n    if (text) {\n      existing_negatives.push({\n        keyword_text: String(text).toLowerCase().trim(),\n        match_type: kw.matchType || kw.match_type || 'BROAD',\n      });\n    }\n  }\n}\nreturn [{ json: { existing_negatives } }];"}},{"id":"00000004-1111-4222-8333-444455556604","name":"Combine Terms With Blocklist","type":"n8n-nodes-base.merge","typeVersion":3,"position":[700,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"00000005-1111-4222-8333-444455556605","name":"Normalize Search Term Rows","type":"n8n-nodes-base.code","typeVersion":2,"position":[820,300],"parameters":{"jsCode":"// ---- Normalize the Google Ads search_term_view payload -------------------\n// The merge hands us ONE item carrying both branches:\n//   { search_terms: [...], existing_negatives: [...] }\nconst merged = $input.first().json;\nconst rows = merged.search_terms || [];\n\n// Already-negated terms, so we never re-propose something that is blocked.\nconst existing = new Set();\nfor (const neg of (merged.existing_negatives || [])) {\n  existing.add(String(neg.keyword_text || neg).toLowerCase().trim());\n}\n\nconst MICROS = 1e6;\n// The REST API answers in camelCase; GAQL aliases are snake_case. Accept both.\nconst out = rows.map(r => {\n  const m = r.metrics || {};\n  const stv = r.searchTermView || r.search_term_view || {};\n  const ag = r.adGroup || r.ad_group || {};\n  const cost = Number(m.costMicros ?? m.cost_micros ?? 0) / MICROS;\n  const conversions = Number(m.conversions || 0);\n  const convValue = Number(m.conversionsValue ?? m.conversions_value ?? 0);\n  const clicks = Number(m.clicks || 0);\n  const impressions = Number(m.impressions || 0);\n  const term = String(stv.searchTerm || stv.search_term || '').toLowerCase().trim();\n  return {\n    search_term: term,\n    campaign_id: r.campaign?.id || null,\n    campaign_name: r.campaign?.name || 'unknown',\n    ad_group_id: ag.id || null,\n    ad_group_name: ag.name || 'unknown',\n    match_type: stv.status || 'NONE',\n    cost,\n    clicks,\n    impressions,\n    conversions,\n    conversions_value: convValue,\n    ctr: impressions ? clicks / impressions : 0,\n    cpc: clicks ? cost / clicks : 0,\n    cvr: clicks ? conversions / clicks : 0,\n    roas: cost ? convValue / cost : 0,\n    already_negative: existing.has(term),\n  };\n}).filter(r => r.search_term && !r.already_negative);\n\n// Account-level CPA baseline — the yardstick every wasteful term is judged by.\nconst totalCost = out.reduce((s, r) => s + r.cost, 0);\nconst totalConv = out.reduce((s, r) => s + r.conversions, 0);\nconst targetCpa = totalConv > 0 ? totalCost / totalConv : 60;\n\nreturn out.map(r => ({ json: { ...r, account_target_cpa: Number(targetCpa.toFixed(2)) } }));"}},{"id":"00000006-1111-4222-8333-444455556606","name":"Keep Statistically Meaningful Terms","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1080,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.clicks }}","rightValue":4,"operator":{"type":"number","operation":"gte"}},{"id":"f2","leftValue":"={{ $json.cost }}","rightValue":5,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"00000007-1111-4222-8333-444455556607","name":"Score Waste And Cluster Themes","type":"n8n-nodes-base.code","typeVersion":2,"position":[1340,300],"parameters":{"jsCode":"// ---- Waste scoring + theme clustering ------------------------------------\nconst rows = $input.all().map(i => i.json);\nconst targetCpa = rows[0]?.account_target_cpa || 60;\n\n// Terms we never negate even if they look wasteful (brand + core intent).\nconst PROTECTED = ['ryze', 'get ryze', 'ryze ai'];\nconst STOP = new Set(['the','a','for','and','to','of','in','with','my','me','is','best','how','vs','on','near']);\n\nconst tokensOf = (t) => t.split(/[^a-z0-9]+/).filter(w => w.length > 2 && !STOP.has(w));\n\n// 1. Wasted-spend score. Three signals, each 0..1, weighted.\n//    - depth:   how many target CPAs of spend burned with zero conversions\n//    - volume:  click volume (statistical confidence the term is truly bad)\n//    - quality: how far below account CVR / ROAS this term sits\nconst accClicks = rows.reduce((s, r) => s + r.clicks, 0);\nconst accConv = rows.reduce((s, r) => s + r.conversions, 0);\nconst accCvr = accClicks ? accConv / accClicks : 0.02;\n\nconst scored = rows.map(r => {\n  const protectedTerm = PROTECTED.some(p => r.search_term.includes(p));\n  const depth = Math.min(1, r.cost / (targetCpa * 1.5));\n  const volume = Math.min(1, r.clicks / 25);\n  // Zero-conversion terms get the full quality penalty; converters get scaled.\n  const quality = r.conversions === 0\n    ? 1\n    : Math.max(0, 1 - (r.cvr / (accCvr || 0.02)));\n  const wasteScore = Number((0.45 * depth + 0.25 * volume + 0.30 * quality).toFixed(3));\n\n  // Confidence uses a Wilson-style shrink: few clicks => low confidence even\n  // if the term burned money, because it may just be unlucky.\n  const n = r.clicks;\n  const expectedConv = n * accCvr;\n  // Poisson tail approximation for \"zero conversions in n clicks was unlucky\".\n  const pZero = Math.exp(-expectedConv);\n  const confidence = Number(Math.min(0.99, (1 - pZero) * (0.6 + 0.4 * Math.min(1, r.cost / targetCpa))).toFixed(3));\n\n  return { ...r, waste_score: wasteScore, confidence, protected: protectedTerm };\n});\n\n// 2. Cluster wasteful terms by shared token so we negate the THEME, not 40\n//    near-duplicate long-tails. A token qualifies as a theme when >= 2 terms\n//    share it and the combined wasted spend clears one target CPA.\nconst themes = new Map();\nfor (const r of scored) {\n  if (r.protected || r.conversions > 0) continue;\n  for (const tok of tokensOf(r.search_term)) {\n    if (!themes.has(tok)) themes.set(tok, { token: tok, terms: [], cost: 0, clicks: 0 });\n    const t = themes.get(tok);\n    t.terms.push(r.search_term);\n    t.cost += r.cost;\n    t.clicks += r.clicks;\n  }\n}\nconst liveThemes = [...themes.values()]\n  .filter(t => t.terms.length >= 2 && t.cost >= targetCpa)\n  .sort((a, b) => b.cost - a.cost);\n\n// 3. Build the candidate list: one broad negative per theme, plus exact\n//    negatives for expensive one-off terms not covered by any theme.\nconst covered = new Set();\nconst candidates = [];\n\nfor (const t of liveThemes) {\n  t.terms.forEach(term => covered.add(term));\n  const members = scored.filter(r => t.terms.includes(r.search_term));\n  const avgConf = members.reduce((s, r) => s + r.confidence, 0) / members.length;\n  const avgWaste = members.reduce((s, r) => s + r.waste_score, 0) / members.length;\n  // Theme-level confidence gets a boost from breadth (more terms = safer).\n  const breadthBoost = Math.min(0.15, 0.03 * t.terms.length);\n  candidates.push({\n    negative_keyword: t.token,\n    match_type: 'BROAD',\n    scope: 'SHARED_LIST',\n    theme_terms: t.terms.slice(0, 8),\n    term_count: t.terms.length,\n    wasted_spend: Number(t.cost.toFixed(2)),\n    clicks: t.clicks,\n    waste_score: Number(avgWaste.toFixed(3)),\n    confidence: Number(Math.min(0.99, avgConf + breadthBoost).toFixed(3)),\n    reason: `theme \"${t.token}\" burned $${t.cost.toFixed(2)} across ${t.terms.length} terms with 0 conversions`,\n  });\n}\n\nfor (const r of scored) {\n  if (r.protected || r.conversions > 0 || covered.has(r.search_term)) continue;\n  if (r.cost < targetCpa * 0.6) continue;\n  candidates.push({\n    negative_keyword: r.search_term,\n    match_type: 'EXACT',\n    scope: 'AD_GROUP',\n    campaign_id: r.campaign_id,\n    ad_group_id: r.ad_group_id,\n    theme_terms: [r.search_term],\n    term_count: 1,\n    wasted_spend: Number(r.cost.toFixed(2)),\n    clicks: r.clicks,\n    waste_score: r.waste_score,\n    confidence: r.confidence,\n    reason: `\"${r.search_term}\" spent $${r.cost.toFixed(2)} over ${r.clicks} clicks with 0 conversions`,\n  });\n}\n\n// 4. Dedupe (a token theme can collide with an exact term) and cap the daily\n//    blast radius so one bad data day cannot gut the account.\nconst seen = new Set();\nconst deduped = candidates.filter(c => {\n  const k = `${c.match_type}:${c.negative_keyword}`;\n  if (seen.has(k)) return false;\n  seen.add(k);\n  return true;\n}).sort((a, b) => b.wasted_spend - a.wasted_spend);\n\nconst DAILY_CAP = 25;\nconst MONTHLY_PROJECTION_DAYS = 30;\nconst window = 30; // report window in days\n\nreturn deduped.slice(0, DAILY_CAP).map(cRow => ({\n  json: {\n    ...cRow,\n    account_target_cpa: targetCpa,\n    projected_monthly_saving: Number(((cRow.wasted_spend / window) * MONTHLY_PROJECTION_DAYS).toFixed(2)),\n    decision: cRow.confidence >= 0.8 && cRow.waste_score >= 0.65 ? 'auto_apply'\n      : cRow.confidence >= 0.55 ? 'review' : 'watch',\n    detected_at: new Date().toISOString(),\n  },\n}));"}},{"id":"00000008-1111-4222-8333-444455556608","name":"Confidence Above Auto-Apply Threshold?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1600,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"i1","leftValue":"={{ $json.decision }}","rightValue":"auto_apply","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"00000009-1111-4222-8333-444455556609","name":"Build Negative Keyword Mutations","type":"n8n-nodes-base.code","typeVersion":2,"position":[1860,100],"parameters":{"jsCode":"// ---- Build Google Ads mutate operations for the auto-approved negatives ---\nconst rows = $input.all().map(i => i.json);\n\nconst esc = (s) => String(s).replace(/[\"\\\\]/g, '');\nconst operations = rows.map(r => {\n  const text = r.match_type === 'BROAD' ? esc(r.negative_keyword) : `[${esc(r.negative_keyword)}]`;\n  return {\n    create: {\n      shared_set: 'customers/CUSTOMER_ID/sharedSets/SHARED_SET_ID',\n      keyword: { text: esc(r.negative_keyword), match_type: r.match_type },\n      display_name: text,\n    },\n    _meta: {\n      negative_keyword: r.negative_keyword,\n      match_type: r.match_type,\n      reason: r.reason,\n      wasted_spend: r.wasted_spend,\n      confidence: r.confidence,\n    },\n  };\n});\n\nconst totalSaved = rows.reduce((s, r) => s + r.projected_monthly_saving, 0);\n\nreturn [{\n  json: {\n    customer_id: 'CUSTOMER_ID',\n    operations,\n    applied_count: operations.length,\n    projected_monthly_saving: Number(totalSaved.toFixed(2)),\n    summary: operations.map(o => `${o._meta.match_type} ${o._meta.negative_keyword} ($${o._meta.wasted_spend})`).join(', '),\n  },\n}];"}},{"id":"0000000a-1111-4222-8333-44445555660a","name":"Apply Negatives To Shared List","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2120,100],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/sharedCriteria:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: $json.operations, partial_failure: true }) }}","options":{}}},{"id":"00000016-1111-4222-8333-444455556616","name":"Expand Applied Rows For Log","type":"n8n-nodes-base.code","typeVersion":2,"position":[2260,100],"parameters":{"jsCode":"// The mutate call sends ONE payload, but the audit log needs ONE ROW PER\n// negative keyword. Re-expand the batch and mark partial-failure rejects.\nconst apiResponse = $input.first().json || {};\nconst built = $('Build Negative Keyword Mutations').first().json;\nconst results = apiResponse.results || [];\nconst failures = apiResponse.partialFailureError?.details || [];\nconst failedIndexes = new Set();\nfor (const d of failures) {\n  for (const e of (d.errors || [])) {\n    const idx = e.location?.fieldPathElements?.find(f => f.fieldName === 'operations')?.index;\n    if (idx !== undefined) failedIndexes.add(Number(idx));\n  }\n}\n\nreturn built.operations.map((op, i) => ({\n  json: {\n    applied_at: new Date().toISOString(),\n    customer_id: built.customer_id,\n    negative_keyword: op._meta.negative_keyword,\n    match_type: op._meta.match_type,\n    wasted_spend: op._meta.wasted_spend,\n    confidence: op._meta.confidence,\n    reason: op._meta.reason,\n    status: failedIndexes.has(i) ? 'rejected' : 'applied',\n    resource_name: results[i]?.resourceName || null,\n    batch_projected_monthly_saving: built.projected_monthly_saving,\n  },\n}));"}},{"id":"0000000b-1111-4222-8333-44445555660b","name":"Log Applied Negatives","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2380,100],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Applied Negatives"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"0000000c-1111-4222-8333-44445555660c","name":"Alert Auto-Applied Negatives","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2640,100],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ppc-autopilot","mode":"name"},"text":"=🧹 Auto-added {{ $('Build Negative Keyword Mutations').first().json.applied_count }} negative keywords. Projected saving ${{ $('Build Negative Keyword Mutations').first().json.projected_monthly_saving }}/mo. {{ $('Build Negative Keyword Mutations').first().json.summary }}","otherOptions":{}}},{"id":"0000000d-1111-4222-8333-44445555660d","name":"Borderline Enough To Review?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1860,500],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"i2","leftValue":"={{ $json.decision }}","rightValue":"review","operator":{"type":"string","operation":"equals"}},{"id":"i3","leftValue":"={{ $json.wasted_spend }}","rightValue":15,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"0000000e-1111-4222-8333-44445555660e","name":"Queue Negative For Human Review","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2120,420],"parameters":{"operation":"create","base":{"__rl":true,"value":"appNEGKW0001","mode":"id"},"table":{"__rl":true,"value":"tblReviewQueue","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"0000000f-1111-4222-8333-44445555660f","name":"Ping PPC Lead For Approval","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2380,420],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ppc-review","mode":"name"},"text":"=👀 Review needed — “{{ $json.negative_keyword }}” ({{ $json.match_type }}) wasted ${{ $json.wasted_spend }} across {{ $json.term_count }} terms. Confidence {{ $json.confidence }}. {{ $json.reason }}","otherOptions":{}}},{"id":"00000010-1111-4222-8333-444455556610","name":"Log Watchlist Terms","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2120,660],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Watchlist"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"00000011-1111-4222-8333-444455556611","name":"Note - Ingest","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[260,-100],"parameters":{"content":"## 1. INGEST\nPull 30 days of search_term_view (cost_micros, clicks, conversions) plus the current negative shared-set so already-blocked terms are never re-proposed.","height":260,"width":460,"color":4}},{"id":"00000012-1111-4222-8333-444455556612","name":"Note - Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1040,-100],"parameters":{"content":"## 2. SCORE + CLUSTER\nWaste score = 0.45·spend-depth + 0.25·click-volume + 0.30·quality gap vs account CVR. Confidence uses a Poisson zero-conversion tail. Shared tokens become BROAD theme negatives; expensive one-offs stay EXACT. Deduped and capped at 25/day.","height":300,"width":470,"color":5}},{"id":"00000013-1111-4222-8333-444455556613","name":"Note - Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1820,860],"parameters":{"content":"## 3. ACT\nconfidence ≥ 0.80 & waste ≥ 0.65 → auto-mutate into the shared negative list + log + Slack.\nconfidence ≥ 0.55 → Airtable review queue + Slack ping.\nEverything else → watchlist sheet for next run.","height":280,"width":460,"color":3}}],"connections":{"Every Morning 07:00":{"main":[[{"node":"Pull Search Terms Report","type":"main","index":0},{"node":"Pull Existing Negative List","type":"main","index":0}]]},"Pull Search Terms Report":{"main":[[{"node":"Flatten Search Term Rows","type":"main","index":0}]]},"Pull Existing Negative List":{"main":[[{"node":"Flatten Existing Negatives","type":"main","index":0}]]},"Flatten Search Term Rows":{"main":[[{"node":"Combine Terms With Blocklist","type":"main","index":0}]]},"Flatten Existing Negatives":{"main":[[{"node":"Combine Terms With Blocklist","type":"main","index":1}]]},"Combine Terms With Blocklist":{"main":[[{"node":"Normalize Search Term Rows","type":"main","index":0}]]},"Normalize Search Term Rows":{"main":[[{"node":"Keep Statistically Meaningful Terms","type":"main","index":0}]]},"Keep Statistically Meaningful Terms":{"main":[[{"node":"Score Waste And Cluster Themes","type":"main","index":0}]]},"Score Waste And Cluster Themes":{"main":[[{"node":"Confidence Above Auto-Apply Threshold?","type":"main","index":0}]]},"Confidence Above Auto-Apply Threshold?":{"main":[[{"node":"Build Negative Keyword Mutations","type":"main","index":0}],[{"node":"Borderline Enough To Review?","type":"main","index":0}]]},"Build Negative Keyword Mutations":{"main":[[{"node":"Apply Negatives To Shared List","type":"main","index":0}]]},"Apply Negatives To Shared List":{"main":[[{"node":"Expand Applied Rows For Log","type":"main","index":0},{"node":"Alert Auto-Applied Negatives","type":"main","index":0}]]},"Expand Applied Rows For Log":{"main":[[{"node":"Log Applied Negatives","type":"main","index":0}]]},"Borderline Enough To Review?":{"main":[[{"node":"Queue Negative For Human Review","type":"main","index":0}],[{"node":"Log Watchlist Terms","type":"main","index":0}]]},"Queue Negative For Human Review":{"main":[[{"node":"Ping PPC Lead For Approval","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}