{"name":"Search ads automation workflow","nodes":[{"id":"bb000001-1111-4222-8333-444455556601","name":"Every Morning 07:00","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"bb000002-1111-4222-8333-444455556602","name":"Fetch Search Terms Report","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,180],"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.impressions, metrics.clicks, metrics.cost_micros, 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":"bb000003-1111-4222-8333-444455556603","name":"Fetch Existing Keywords","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-220,420],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:searchStream","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.negative, ad_group.id, ad_group.name, campaign.id FROM ad_group_criterion WHERE ad_group_criterion.type = 'KEYWORD' AND ad_group_criterion.status != 'REMOVED' LIMIT 5000\" }) }}","options":{}}},{"id":"bb000004-1111-4222-8333-444455556604","name":"Merge Terms With Keyword Map","type":"n8n-nodes-base.merge","typeVersion":3,"position":[20,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{"includeUnpaired":true}}},{"id":"bb000005-1111-4222-8333-444455556605","name":"Classify Search Terms","type":"n8n-nodes-base.code","typeVersion":2,"position":[260,300],"parameters":{"jsCode":"// ---- Search-term hygiene rules -------------------------------------------\n// One pass over the search terms report. Every term lands in exactly one of\n// three buckets: ADD as exact (it converts), NEGATE (it spends and doesn't),\n// or REVIEW (not enough data to be sure yet).\nconst CFG = {\n  lookbackDays: 30,\n  targetCpa: 60,          // break-even cost per conversion\n  targetRoas: 2.5,        // break-even ROAS for value-based campaigns\n  minClicksToJudge: 8,    // below this a term is just noise\n  minCostToNegate: 45,    // don't negate a term that cost pennies\n  wasteCpaMultiple: 1.75, // negate at 1.75x target CPA\n  minConvToPromote: 2,    // need repeat conversions, not one lucky click\n  maxNewKeywordsPerRun: 25,\n  brandTokens: ['acme', 'acmegear'] // never negate our own brand\n};\n\nconst MICROS = 1e6;\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- 0. what is already in the account ------------------------------------\n// The keywords fetch and the search terms fetch are merged positionally, so a\n// row can carry a keyword that belongs to a different term. Rebuild both sets\n// from their own identifying fields instead of trusting the pairing.\nconst existingKeywords = new Set();\nconst existingNegatives = new Set();\nfor (const r of rows) {\n  const kw = r.adGroupCriterion && r.adGroupCriterion.keyword;\n  if (!kw || !kw.text) continue;\n  const key = norm(kw.text);\n  if (r.adGroupCriterion.negative === true) existingNegatives.add(key);\n  else existingKeywords.add(key);\n}\n\n// ---- 1. roll the report up per search term x ad group ---------------------\nconst agg = new Map();\nfor (const r of rows) {\n  const term = r.searchTermView && r.searchTermView.searchTerm;\n  if (!term) continue;\n  const m = r.metrics || {};\n  const key = norm(term) + '::' + String((r.adGroup && r.adGroup.id) || '');\n  const prev = agg.get(key) || {\n    search_term: String(term),\n    term_key: norm(term),\n    campaign_id: String((r.campaign && r.campaign.id) || ''),\n    campaign_name: (r.campaign && r.campaign.name) || '',\n    ad_group_id: String((r.adGroup && r.adGroup.id) || ''),\n    ad_group_name: (r.adGroup && r.adGroup.name) || '',\n    match_type: (r.searchTermView && r.searchTermView.status) || 'NONE',\n    impressions: 0, clicks: 0, cost_micros: 0, conversions: 0, conversions_value: 0\n  };\n  prev.impressions += num(m.impressions);\n  prev.clicks += num(m.clicks);\n  prev.cost_micros += num(m.costMicros || m.cost_micros);\n  prev.conversions += num(m.conversions);\n  prev.conversions_value += num(m.conversionsValue || m.conversions_value);\n  agg.set(key, prev);\n}\n\nconst out = [];\n\nfor (const t of agg.values()) {\n  const cost = t.cost_micros / MICROS;\n  const conv = t.conversions;\n  const value = t.conversions_value;\n  const cpa = conv > 0 ? cost / conv : null;\n  const roas = cost > 0 ? value / cost : 0;\n  const cvr = t.clicks > 0 ? conv / t.clicks : 0;\n  const cpc = t.clicks > 0 ? cost / t.clicks : 0;\n  const isBrand = CFG.brandTokens.some((b) => t.term_key.includes(b));\n\n  // ---- 2. a single score so the buckets are explainable -------------------\n  // Positive score = efficient traffic worth owning as an exact keyword.\n  // Negative score = spend with nothing to show for it.\n  let score = 0;\n  if (cpa !== null) score += (CFG.targetCpa - cpa) / CFG.targetCpa;   // +1 at free, 0 at target, negative above\n  if (value > 0) score += (roas - CFG.targetRoas) / CFG.targetRoas;\n  if (conv === 0 && cost > 0) score -= cost / CFG.targetCpa;          // pure waste, scaled by how much\n  score += Math.min(0.5, cvr * 10);                                   // reward conversion rate\n  score = Math.round(score * 100) / 100;\n\n  const enoughData = t.clicks >= CFG.minClicksToJudge || cost >= CFG.minCostToNegate || conv > 0;\n  const alreadyKeyword = existingKeywords.has(t.term_key);\n  const alreadyNegative = existingNegatives.has(t.term_key);\n\n  let action = 'review';\n  let reason = '';\n\n  if (alreadyNegative) {\n    action = 'skip';\n    reason = 'already on the negative list';\n  } else if (!enoughData) {\n    action = 'review';\n    reason = 'only ' + t.clicks + ' clicks / $' + cost.toFixed(2) + ' spent — too thin to judge';\n  } else if (conv >= CFG.minConvToPromote && cpa !== null && cpa <= CFG.targetCpa && !alreadyKeyword) {\n    action = 'add_exact';\n    reason = conv + ' conversions at $' + cpa.toFixed(2) + ' CPA vs $' + CFG.targetCpa + ' target';\n  } else if (conv >= CFG.minConvToPromote && alreadyKeyword) {\n    action = 'skip';\n    reason = 'converts, but already an exact keyword in the account';\n  } else if (isBrand) {\n    action = 'review';\n    reason = 'brand term — never auto-negated, check the landing page instead';\n  } else if (conv === 0 && cost >= CFG.minCostToNegate) {\n    action = 'negate';\n    reason = '$' + cost.toFixed(2) + ' on ' + t.clicks + ' clicks with 0 conversions';\n  } else if (cpa !== null && cpa > CFG.targetCpa * CFG.wasteCpaMultiple && cost >= CFG.minCostToNegate) {\n    action = 'negate';\n    reason = 'CPA $' + cpa.toFixed(2) + ' is ' + (cpa / CFG.targetCpa).toFixed(1) + 'x target on $' + cost.toFixed(2) + ' spend';\n  } else {\n    action = 'review';\n    reason = 'inside the guardrails but not a clear winner';\n  }\n\n  // money we stop burning (negate) or expect to control better (add_exact)\n  const monthlyWaste = action === 'negate' ? Math.round((cost / CFG.lookbackDays) * 30 * 100) / 100 : 0;\n\n  out.push({ json: {\n    search_term: t.search_term,\n    term_key: t.term_key,\n    campaign_id: t.campaign_id,\n    campaign_name: t.campaign_name,\n    ad_group_id: t.ad_group_id,\n    ad_group_name: t.ad_group_name,\n    impressions: t.impressions,\n    clicks: t.clicks,\n    cost: Math.round(cost * 100) / 100,\n    cost_micros: Math.round(t.cost_micros),\n    conversions: Math.round(conv * 100) / 100,\n    conversions_value: Math.round(value * 100) / 100,\n    cpa: cpa === null ? null : Math.round(cpa * 100) / 100,\n    roas: Math.round(roas * 100) / 100,\n    cvr: Math.round(cvr * 10000) / 100,\n    cpc: Math.round(cpc * 100) / 100,\n    score,\n    action,\n    reason,\n    is_brand: isBrand,\n    projected_monthly_waste: monthlyWaste,\n    suggested_bid_micros: Math.round(Math.max(cpc * 1.15, 0.2) * MICROS),\n    evaluated_at: new Date().toISOString()\n  } });\n}\n\n// winners first by score, then the worst wasters (most negative) at the bottom\nout.sort((a, b) => b.json.score - a.json.score);\n\n// cap how many brand-new exact keywords one run can push into the account\nlet added = 0;\nfor (const o of out) {\n  if (o.json.action !== 'add_exact') continue;\n  added += 1;\n  if (added > CFG.maxNewKeywordsPerRun) {\n    o.json.action = 'review';\n    o.json.reason = 'qualified but over the ' + CFG.maxNewKeywordsPerRun + '/run add cap — queued for next sweep';\n  }\n}\n\nreturn out;"}},{"id":"bb000006-1111-4222-8333-444455556606","name":"Drop Already Handled Terms","type":"n8n-nodes-base.filter","typeVersion":2,"position":[500,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.action }}","rightValue":"skip","operator":{"type":"string","operation":"notEquals"}}]},"options":{}}},{"id":"bb000007-1111-4222-8333-444455556607","name":"Strong Converter?","type":"n8n-nodes-base.if","typeVersion":2,"position":[740,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.action }}","rightValue":"add_exact","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"bb000008-1111-4222-8333-444455556608","name":"Loop Over Winning Terms","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[980,300],"parameters":{"batchSize":1,"options":{}}},{"id":"bb000009-1111-4222-8333-444455556609","name":"Add Term As Exact Keyword","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1220,480],"parameters":{"method":"POST","url":"=https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/adGroupCriteria:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: [ { create: { adGroup: \"customers/CUSTOMER_ID/adGroups/\" + $json.ad_group_id, status: \"ENABLED\", cpcBidMicros: $json.suggested_bid_micros, keyword: { text: $json.search_term, matchType: \"EXACT\" } } } ], partialFailure: true }) }}","options":{}}},{"id":"bb00000a-1111-4222-8333-44445555660a","name":"Mark Keyword Added","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1460,480],"parameters":{"assignments":{"assignments":[{"id":"s1","name":"search_term","value":"={{ $('Loop Over Winning Terms').item.json.search_term }}","type":"string"},{"id":"s2","name":"campaign_name","value":"={{ $('Loop Over Winning Terms').item.json.campaign_name }}","type":"string"},{"id":"s3","name":"ad_group_name","value":"={{ $('Loop Over Winning Terms').item.json.ad_group_name }}","type":"string"},{"id":"s4","name":"clicks","value":"={{ $('Loop Over Winning Terms').item.json.clicks }}","type":"number"},{"id":"s5","name":"cost","value":"={{ $('Loop Over Winning Terms').item.json.cost }}","type":"number"},{"id":"s6","name":"conversions","value":"={{ $('Loop Over Winning Terms').item.json.conversions }}","type":"number"},{"id":"s7","name":"cpa","value":"={{ $('Loop Over Winning Terms').item.json.cpa }}","type":"number"},{"id":"s8","name":"roas","value":"={{ $('Loop Over Winning Terms').item.json.roas }}","type":"number"},{"id":"s9","name":"score","value":"={{ $('Loop Over Winning Terms').item.json.score }}","type":"number"},{"id":"s10","name":"suggested_bid_micros","value":"={{ $('Loop Over Winning Terms').item.json.suggested_bid_micros }}","type":"number"},{"id":"s11","name":"reason","value":"={{ $('Loop Over Winning Terms').item.json.reason }}","type":"string"},{"id":"s12","name":"keyword_added","value":"={{ true }}","type":"boolean"}]},"options":{}}},{"id":"bb00000b-1111-4222-8333-44445555660b","name":"Log New Keywords To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1700,480],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Added Keywords"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb00000c-1111-4222-8333-44445555660c","name":"Build Daily Summary","type":"n8n-nodes-base.code","typeVersion":2,"position":[1220,300],"parameters":{"jsCode":"// ---- One Slack post that tells the buyer what changed today ---------------\nconst added = $input.all().map((i) => i.json).filter((r) => r.keyword_added === true);\n\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\nif (!added.length) {\n  return [{ json: {\n    added_count: 0,\n    slack_text: '🔍 *Search terms sweep* — no term cleared the promote bar today. Nothing added; check the review queue sheet for the near-misses.'\n  } }];\n}\n\nconst totalConv = added.reduce((s, r) => s + Number(r.conversions || 0), 0);\nconst totalCost = added.reduce((s, r) => s + Number(r.cost || 0), 0);\nconst blendedCpa = totalConv > 0 ? totalCost / totalConv : 0;\n\nconst lines = added\n  .sort((a, b) => Number(b.score) - Number(a.score))\n  .slice(0, 15)\n  .map((r, i) => (i + 1) + '. [' + r.search_term + '] → ' + r.ad_group_name +\n    ' — ' + r.conversions + ' conv, ' + money(r.cost) + ', ' + money(r.cpa) + ' CPA' +\n    ' (bid ' + money(Number(r.suggested_bid_micros || 0) / 1e6) + ')');\n\nconst slack_text = [\n  '🔍 *Search terms sweep — ' + added.length + ' new exact keywords*',\n  totalConv.toFixed(0) + ' conversions behind them at ' + money(blendedCpa) + ' blended CPA',\n  'Negatives from this run are logged in the Negatives tab.',\n  '',\n  ...lines\n].join('\\n');\n\nreturn [{ json: {\n  added_count: added.length,\n  total_conversions: Math.round(totalConv * 100) / 100,\n  total_cost: Math.round(totalCost * 100) / 100,\n  blended_cpa: Math.round(blendedCpa * 100) / 100,\n  slack_text\n} }];"}},{"id":"bb00000d-1111-4222-8333-44445555660d","name":"Post Sweep Summary To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1460,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#search-ads","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"bb00000e-1111-4222-8333-44445555660e","name":"Wasting Enough To Negate?","type":"n8n-nodes-base.if","typeVersion":2,"position":[980,60],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"n1","leftValue":"={{ $json.action }}","rightValue":"negate","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"bb00000f-1111-4222-8333-44445555660f","name":"Build Negative Keyword Batch","type":"n8n-nodes-base.code","typeVersion":2,"position":[1220,-140],"parameters":{"jsCode":"// ---- Build one negative-keyword mutate payload ----------------------------\n// Google Ads takes negatives in bulk, so we batch instead of one call per term.\n// Phrase match for multi-word junk (catches the variants too), exact for\n// single tokens so we don't accidentally block a whole theme.\nconst terms = $input.all().map((i) => i.json);\n\nconst seen = new Set();\nconst operations = [];\nconst detail = [];\nlet totalWaste = 0;\nlet totalMonthly = 0;\n\nfor (const t of terms) {\n  const key = String(t.term_key || t.search_term || '').trim();\n  if (!key || seen.has(key)) continue;   // dedupe: same junk term can appear in several ad groups\n  seen.add(key);\n\n  const words = key.split(' ').filter(Boolean);\n  const matchType = words.length > 1 ? 'PHRASE' : 'EXACT';\n\n  operations.push({\n    create: {\n      keyword: { text: key, matchType },\n      type: 'KEYWORD'\n    }\n  });\n\n  totalWaste += Number(t.cost || 0);\n  totalMonthly += Number(t.projected_monthly_waste || 0);\n  detail.push({\n    search_term: t.search_term,\n    match_type: matchType,\n    campaign_name: t.campaign_name,\n    ad_group_name: t.ad_group_name,\n    clicks: t.clicks,\n    cost: t.cost,\n    conversions: t.conversions,\n    cpa: t.cpa,\n    score: t.score,\n    reason: t.reason,\n    negated_at: new Date().toISOString()\n  });\n}\n\nif (!operations.length) return [];\n\nreturn [{ json: {\n  operations,\n  negated_count: operations.length,\n  wasted_spend: Math.round(totalWaste * 100) / 100,\n  projected_monthly_saving: Math.round(totalMonthly * 100) / 100,\n  detail\n} }];"}},{"id":"bb000010-1111-4222-8333-444455556610","name":"Push To Shared Negative List","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1460,-140],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/sharedCriteria:mutate","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ operations: $json.operations.map(function (op) { return { create: Object.assign({ sharedSet: \"customers/CUSTOMER_ID/sharedSets/SHARED_SET_ID\" }, op.create) }; }), partialFailure: true }) }}","options":{}}},{"id":"bb000017-1111-4222-8333-444455556617","name":"Expand Negated Terms For Log","type":"n8n-nodes-base.code","typeVersion":2,"position":[1580,-140],"parameters":{"jsCode":"// ---- Turn the batched mutate back into one row per negated term ----------\n// The HTTP node's output is Google's mutate response, not our data, so pull\n// the detail rows off the batch builder and stamp them with the API result.\nconst batch = $('Build Negative Keyword Batch').first().json;\nconst detail = Array.isArray(batch.detail) ? batch.detail : [];\nif (!detail.length) return [];\n\nconst resp = $input.first().json || {};\nconst results = Array.isArray(resp.results) ? resp.results : [];\nconst partialError = resp.partialFailureError ? String(resp.partialFailureError.message || 'partial failure') : '';\n\nreturn detail.map((d, i) => ({ json: Object.assign({}, d, {\n  resource_name: (results[i] && results[i].resourceName) || '',\n  pushed: Boolean(results[i] && results[i].resourceName),\n  partial_failure: partialError,\n  batch_size: batch.negated_count,\n  wasted_spend_batch: batch.wasted_spend,\n  projected_monthly_saving_batch: batch.projected_monthly_saving\n}) }));"}},{"id":"bb000011-1111-4222-8333-444455556611","name":"Log Negatives To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1700,-140],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Negatives"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000012-1111-4222-8333-444455556612","name":"Queue Undecided Terms","type":"n8n-nodes-base.code","typeVersion":2,"position":[1220,80],"parameters":{"jsCode":"// ---- Terms that are neither winners nor waste yet -------------------------\n// These go on a review queue with the one number that decides them: how much\n// more spend (or how many more clicks) until the rule can fire.\nconst CFG = { targetCpa: 60, minClicksToJudge: 8, minCostToNegate: 45 };\n\nreturn $input.all().map((i) => i.json).map((t) => {\n  const cost = Number(t.cost || 0);\n  const clicks = Number(t.clicks || 0);\n  const conv = Number(t.conversions || 0);\n  const clicksToDecision = Math.max(0, CFG.minClicksToJudge - clicks);\n  const spendToDecision = Math.round(Math.max(0, CFG.minCostToNegate - cost) * 100) / 100;\n  const trend = conv > 0 ? 'converting, watch CPA' : (cost > CFG.targetCpa ? 'burning, close to a negate' : 'still cheap');\n  return { json: {\n    search_term: t.search_term,\n    campaign_name: t.campaign_name,\n    ad_group_name: t.ad_group_name,\n    impressions: t.impressions,\n    clicks,\n    cost,\n    conversions: conv,\n    cpa: t.cpa,\n    score: t.score,\n    status: 'review',\n    reason: t.reason,\n    clicks_until_decision: clicksToDecision,\n    spend_until_decision: spendToDecision,\n    trend,\n    queued_at: new Date().toISOString()\n  } };\n});"}},{"id":"bb000013-1111-4222-8333-444455556613","name":"Log Review Queue To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1460,80],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Review Queue"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000014-1111-4222-8333-444455556614","name":"Note Pull","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,-60],"parameters":{"content":"## 1. PULL THE ACCOUNT\nLast 30 days of search_term_view (clicks, cost_micros, conversions, conversions_value) merged with every existing keyword + negative, so we never re-add or re-negate something already in the account.","height":280,"width":420,"color":4}},{"id":"bb000015-1111-4222-8333-444455556615","name":"Note Rules","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[240,-60],"parameters":{"content":"## 2. SCORE + BUCKET\nOne score per term from CPA vs $60 target, ROAS vs 2.5x and CVR.\nADD EXACT: 2+ conversions at or under target CPA (max 25/run).\nNEGATE: 0 conversions on $45+ spend, or CPA above 1.75x target. Brand terms are never auto-negated.\\nEverything else goes to the review queue.","height":300,"width":440,"color":3}},{"id":"bb000016-1111-4222-8333-444455556616","name":"Note Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1180,640],"parameters":{"content":"## 3. ACT + LOG\nWinners are added one at a time as EXACT keywords at 1.15x their own CPC. Wasters are batched into one shared negative list mutate (PHRASE for multi-word junk, EXACT for single tokens). Three sheet tabs keep the audit trail, one Slack post says what changed.","height":260,"width":440,"color":5}}],"connections":{"Every Morning 07:00":{"main":[[{"node":"Fetch Search Terms Report","type":"main","index":0},{"node":"Fetch Existing Keywords","type":"main","index":0}]]},"Fetch Search Terms Report":{"main":[[{"node":"Merge Terms With Keyword Map","type":"main","index":0}]]},"Fetch Existing Keywords":{"main":[[{"node":"Merge Terms With Keyword Map","type":"main","index":1}]]},"Merge Terms With Keyword Map":{"main":[[{"node":"Classify Search Terms","type":"main","index":0}]]},"Classify Search Terms":{"main":[[{"node":"Drop Already Handled Terms","type":"main","index":0}]]},"Drop Already Handled Terms":{"main":[[{"node":"Strong Converter?","type":"main","index":0}]]},"Strong Converter?":{"main":[[{"node":"Loop Over Winning Terms","type":"main","index":0}],[{"node":"Wasting Enough To Negate?","type":"main","index":0}]]},"Loop Over Winning Terms":{"main":[[{"node":"Build Daily Summary","type":"main","index":0}],[{"node":"Add Term As Exact Keyword","type":"main","index":0}]]},"Add Term As Exact Keyword":{"main":[[{"node":"Mark Keyword Added","type":"main","index":0}]]},"Mark Keyword Added":{"main":[[{"node":"Log New Keywords To Sheet","type":"main","index":0}]]},"Log New Keywords To Sheet":{"main":[[{"node":"Loop Over Winning Terms","type":"main","index":0}]]},"Build Daily Summary":{"main":[[{"node":"Post Sweep Summary To Slack","type":"main","index":0}]]},"Wasting Enough To Negate?":{"main":[[{"node":"Build Negative Keyword Batch","type":"main","index":0}],[{"node":"Queue Undecided Terms","type":"main","index":0}]]},"Build Negative Keyword Batch":{"main":[[{"node":"Push To Shared Negative List","type":"main","index":0}]]},"Push To Shared Negative List":{"main":[[{"node":"Expand Negated Terms For Log","type":"main","index":0}]]},"Expand Negated Terms For Log":{"main":[[{"node":"Log Negatives To Sheet","type":"main","index":0}]]},"Queue Undecided Terms":{"main":[[{"node":"Log Review Queue To Sheet","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}