{"name":"PM Competitor News Digest","nodes":[{"parameters":{"rule":{"interval":[{"triggerAtHour":8,"triggerAtMinute":0}]}},"id":"every-morning-8:00-ist","name":"Every morning 8:00 IST","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,0]},{"parameters":{},"id":"run-now-(demo)","name":"Run now (demo)","type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[-460,200]},{"parameters":{"assignments":{"assignments":[{"id":"a1","name":"company","value":"Zepto","type":"string"},{"id":"a2","name":"competitors","value":"Blinkit, Swiggy Instamart, BigBasket, Flipkart Minutes","type":"string"},{"id":"a3","name":"recipient","value":"talktodeepak123@gmail.com","type":"string"},{"id":"a4","name":"minRelevance","value":6,"type":"number"},{"id":"a5","name":"sheetId","value":"REPLACE_WITH_SHEET_ID","type":"string"}]},"options":{}},"id":"settings","name":"Settings","type":"n8n-nodes-base.set","typeVersion":3.4,"position":[-240,100]},{"parameters":{"jsCode":"// Competitors live in one Settings field so there is a single place to edit them.\n// Swap this node for a Google Sheets read if you would rather manage the list in a spreadsheet.\nconst s = $input.first().json;\nreturn s.competitors.split(',').map(c => ({ json: { ...s, competitor: c.trim() } }));"},"id":"one-item-per-competitor","name":"One item per competitor","type":"n8n-nodes-base.code","typeVersion":2,"position":[-20,100]},{"parameters":{"method":"POST","url":"https://google.serper.dev/news","sendHeaders":true,"headerParameters":{"parameters":[{"name":"X-API-KEY","value":"={{ $env.SERPER_API_KEY }}"},{"name":"Content-Type","value":"application/json"}]},"sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ q: $json.competitor, gl: \"in\", hl: \"en\", tbs: \"qdr:d\", num: 10 }) }}","options":{}},"id":"serper:-google-news","name":"Serper: Google News","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[200,100]},{"parameters":{"jsCode":"// One story reaches us through many outlets: a single FDA raid produced nine different\n// headlines, so exact-title matching is useless. Compare the significant words instead, and keep a\n// count of how many outlets ran it — that count is itself a signal of how big the story is.\nconst settings = $('Settings').first().json;\nconst STOP = new Set(['the','a','an','of','in','on','at','to','for','and','is','as','by','with',\n                      'from','after','over','its','amid','says','said','new']);\nconst tokens = t => new Set((String(t||'').toLowerCase().match(/[a-z0-9]+/g) || [])\n                            .filter(w => w.length > 2 && !STOP.has(w)));\nconst latinShare = t => {\n  const letters = [...String(t||'')].filter(c => /\\p{L}/u.test(c));\n  if (!letters.length) return 1;\n  return letters.filter(c => /[a-zA-Z]/.test(c)).length / letters.length;\n};\n\nconst seen = new Set(); const raw = [];\nfor (const item of $input.all()) {\n  const competitor = item.json.searchParameters?.q || 'unknown';\n  for (const a of (item.json.news || [])) {\n    const key = (a.title || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 60);\n    if (!key || seen.has(key)) continue;\n    seen.add(key);\n    raw.push({ competitor, title: a.title, link: a.link, source: a.source,\n               date: a.date, snippet: a.snippet || '' });\n  }\n}\n\nconst groups = [];\nfor (const a of raw) {\n  const t = tokens(a.title);\n  let placed = false;\n  for (const g of groups) {\n    const smallest = Math.min(t.size, g.tokens.size);\n    const shared = [...t].filter(x => g.tokens.has(x)).length;\n    // a headline in Gujarati or Marathi yields only the Latin brand names, so one shared word\n    // reads as 50% agreement; when there is that little to compare, demand near-identical titles\n    const needed = smallest < 4 ? 0.8 : 0.3;\n    if (shared / Math.max(1, smallest) >= needed) {\n      g.members.push(a); t.forEach(x => g.tokens.add(x)); placed = true; break;\n    }\n  }\n  if (!placed) groups.push({ tokens: t, members: [a] });\n}\n\nconst articles = groups.map(g => {\n  const best = g.members.slice().sort((x, y) =>\n    (latinShare(y.title) - latinShare(x.title)) || ((y.snippet||'').length - (x.snippet||'').length))[0];\n  best.also_covered_by = g.members.length - 1;\n  return best;\n});\nreturn [{ json: { ...settings, articles: articles.slice(0, 40), fetched: raw.length,\n                  clustered: articles.length } }];"},"id":"collect-and-de-duplicate","name":"Collect and de-duplicate","type":"n8n-nodes-base.code","typeVersion":2,"position":[420,100]},{"parameters":{"method":"POST","url":"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent","authentication":"predefinedCredentialType","nodeCredentialType":"googlePalmApi","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ contents: [{ parts: [{ text: $json.analysePrompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 0.2 } }) }}","options":{"timeout":120000}},"id":"gemini:-score,-summarise,-categorise","name":"Gemini: score, summarise, categorise","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[640,100],"credentials":{"googlePalmApi":{"id":"6Xh6zSnQMO2zTzBr","name":"Google Gemini(PaLM) Api account"}}},{"parameters":{"jsCode":"// One Gemini call for the whole batch: cheaper, faster, and it keeps the free tier's rate\n// limit out of trouble compared with one call per article.\nconst s = $input.first().json;\nconst list = s.articles.map((a, i) =>\n  `${i + 1}. [${a.competitor}] ${a.title}\\n   source: ${a.source} | ${a.date}\\n   ${a.snippet}`\n).join('\\n\\n');\nconst prompt = `You are a research assistant for a Product Manager at {company}, an Indian quick-commerce company.\n\nBelow are news articles about competitors. For EACH article, judge it from the PM's point of view.\n\nReturn a JSON array with one object per article, in the same order, each with:\n  \"relevance\": 1-10. How useful is this to a {company} PM deciding what to build or worry about?\n      10 = a competitor shipped a feature, raised money, entered a category or changed strategy.\n      7  = a material operational or regulatory event at a competitor.\n      5  = general industry or market news that sets context.\n      3  = a passing mention of the competitor in a story that is really about something else.\n      1  = irrelevant. Local crime, celebrity gossip, unrelated use of the brand name, pure stock\n           tips, or an article not really about the competitor at all.\n      Use the full range. If everything looks like a 10 you are not discriminating: reserve 9 and 10\n      for news that would genuinely change what a PM does this quarter.\n  \"category\": exactly one of {categories}\n  \"summary\": 2-3 lines, plain English, what happened and why a PM should care. No preamble, no\n      repeating the headline verbatim.\n\nJudge only what the title and snippet actually say. Do not invent details. If an article is too thin\nto tell, give it a low relevance rather than guessing.\n\nArticles:\n{articles}\n\nReply with the JSON array only.`\n  .replace('{company}', s.company)\n  .replace('{categories}', [\"Product Launch\", \"Funding\", \"Partnership\", \"Expansion\", \"Leadership\", \"Regulation\", \"Financials\", \"Marketing\", \"Other\"])\n  .replace('{articles}', list);\nreturn [{ json: { ...s, analysePrompt: prompt } }];"},"id":"build-the-prompt","name":"Build the prompt","type":"n8n-nodes-base.code","typeVersion":2,"position":[640,-80]},{"parameters":{"jsCode":"// Gemini returns one object per article in order. Anything below the relevance threshold is\n// dropped here — without this the digest fills with local crime stories that merely mention a\n// competitor's name, which is exactly what the raw Serper results looked like.\nconst s = $('Collect and de-duplicate').first().json;\nlet scored = [];\ntry {\n  const text = $input.first().json.candidates[0].content.parts[0].text;\n  scored = JSON.parse(text);\n} catch (e) {\n  throw new Error('Gemini did not return usable JSON: ' + e.message);\n}\nconst latinShare = t => {\n  const letters = [...String(t||'')].filter(c => /\\p{L}/u.test(c));\n  if (!letters.length) return 1;\n  return letters.filter(c => /[a-zA-Z]/.test(c)).length / letters.length;\n};\nconst merged = s.articles.map((a, i) => {\n  let relevance = scored[i]?.relevance ?? 0;\n  // legitimate news, but unreadable in an English digest, so it should not take a top slot\n  if (latinShare(a.title) < 0.5) relevance = Math.max(1, relevance - 4);\n  return { ...a, relevance, category: scored[i]?.category || 'Other',\n           summary: scored[i]?.summary || '' };\n});\nconst kept = merged.filter(a => a.relevance >= s.minRelevance)\n                   .sort((a, b) => b.relevance - a.relevance);\nreturn [{ json: { ...s, all: merged, kept, keptCount: kept.length,\n                  droppedCount: merged.length - kept.length } }];"},"id":"keep-what-matters","name":"Keep what matters","type":"n8n-nodes-base.code","typeVersion":2,"position":[860,100]},{"parameters":{"jsCode":"// Grouped by category so the PM can skim; each item keeps its source and a link out.\nconst s = $input.first().json;\nconst today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric',\n  month: 'long', year: 'numeric', timeZone: 'Asia/Kolkata' });\nconst byCat = {};\nfor (const a of s.kept) (byCat[a.category] ||= []).push(a);\n\nconst esc = t => String(t || '').replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\nlet body = '';\nif (!s.kept.length) {\n  body = '<p style=\"color:#666\">No competitor news cleared the relevance bar today. ' +\n         `${s.droppedCount} article(s) were fetched and judged not worth your time.</p>`;\n} else {\n  for (const [cat, items] of Object.entries(byCat)) {\n    body += `<h3 style=\"margin:22px 0 8px;font:600 15px system-ui;color:#111\">${esc(cat)}` +\n            ` <span style=\"font-weight:400;color:#999\">(${items.length})</span></h3>`;\n    for (const a of items) {\n      body += `<div style=\"margin:0 0 14px;padding:12px 14px;border-left:3px solid #2979FF;background:#f7f9fc\">\n        <div style=\"font:600 14px system-ui;color:#111\">${esc(a.title)}</div>\n        <div style=\"font:13px/1.5 system-ui;color:#333;margin:6px 0\">${esc(a.summary)}</div>\n        <div style=\"font:12px system-ui;color:#888\">${esc(a.competitor)} &middot; ${esc(a.source)} &middot; ${esc(a.date)}\n          &middot; relevance ${a.relevance}/10${a.also_covered_by ? ` &middot; also covered by ${a.also_covered_by} other outlet${a.also_covered_by > 1 ? 's' : ''}` : ''} &middot; <a href=\"${esc(a.link)}\" style=\"color:#2979FF\">read</a></div>\n      </div>`;\n    }\n  }\n}\nconst html = `<div style=\"max-width:680px;margin:0 auto;font-family:system-ui,sans-serif\">\n  <div style=\"border-bottom:2px solid #111;padding-bottom:10px;margin-bottom:6px\">\n    <div style=\"font:700 20px system-ui\">Competitor Digest</div>\n    <div style=\"font:13px system-ui;color:#666\">${today} &middot; ${esc(s.company)} &middot; tracking ${esc(s.competitors)}</div>\n  </div>\n  <p style=\"font:13px system-ui;color:#666\">${s.keptCount} stories worth reading, ${s.droppedCount} filtered out as noise.</p>\n  ${body}\n  <div style=\"margin-top:26px;padding-top:12px;border-top:1px solid #eee;font:12px system-ui;color:#999\">\n    Sources: Google News via Serper &middot; summarised and scored by Gemini 2.5 Flash &middot; sent automatically at 08:00 IST\n  </div></div>`;\nreturn [{ json: { ...s, html, subject: `Competitor Digest — ${today} (${s.keptCount} stories)` } }];"},"id":"compose-the-digest","name":"Compose the digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[1080,100]},{"parameters":{"sendTo":"={{ $json.recipient }}","subject":"={{ $json.subject }}","message":"={{ $json.html }}","options":{}},"id":"email-the-digest","name":"Email the digest","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1300,100],"credentials":{"gmailOAuth2":{"id":"MMJTLRIfotcajuZz","name":"Gmail account"}}},{"parameters":{"jsCode":"// Every judged article is stored, not just the ones that made the digest — the dropped ones\n// are how you check later whether the relevance threshold is set right.\nconst s = $input.first().json;\nconst stamp = new Date().toISOString();\nreturn s.all.map(a => ({ json: {\n  date: stamp.slice(0, 10), fetched_at: stamp, competitor: a.competitor, title: a.title,\n  category: a.category, relevance: a.relevance, summary: a.summary,\n  source: a.source, published: a.date, link: a.link,\n  in_digest: a.relevance >= s.minRelevance ? 'yes' : 'no',\n} }));"},"id":"rows-for-history","name":"Rows for history","type":"n8n-nodes-base.code","typeVersion":2,"position":[1300,280]},{"parameters":{"operation":"append","documentId":{"__rl":true,"value":"={{ $('Settings').first().json.sheetId }}","mode":"id"},"sheetName":{"__rl":true,"value":"History","mode":"name"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}},"id":"append-to-sheets-(history)","name":"Append to Sheets (history)","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1520,280],"credentials":{"googleSheetsOAuth2Api":{"id":"jscGF0D7KLoA5M1h","name":"Google Sheets account"}}},{"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"conditions":[{"id":"fri","operator":{"type":"number","operation":"equals"},"leftValue":"={{ $now.setZone('Asia/Kolkata').weekday }}","rightValue":5}],"combinator":"and"},"options":{}},"id":"is-it-friday?","name":"Is it Friday?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[1300,460]},{"parameters":{"documentId":{"__rl":true,"value":"={{ $('Settings').first().json.sheetId }}","mode":"id"},"sheetName":{"__rl":true,"value":"History","mode":"name"},"options":{}},"id":"read-the-week","name":"Read the week","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1520,460],"credentials":{"googleSheetsOAuth2Api":{"id":"jscGF0D7KLoA5M1h","name":"Google Sheets account"}}},{"parameters":{"jsCode":"// Only the last seven days, and only what made the daily digests.\nconst s = $('Settings').first().json;\nconst cutoff = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);\nconst rows = $input.all().map(i => i.json)\n  .filter(r => r.date >= cutoff && r.in_digest === 'yes');\nconst list = rows.map(r => `[${r.competitor}] (${r.category}) ${r.title} - ${r.summary}`).join('\\n');\nconst prompt = `You are briefing a Product Manager at {company} on the competitive week just gone.\n\nHere are the competitor stories collected this week, already summarised:\n\n{articles}\n\nWrite a short weekly rollup:\n  - 3 to 5 bullets on what actually mattered, naming the competitor each time\n  - one line on any pattern across the week (a category several rivals moved in, a repeated theme)\n  - one line on what you would watch next week\n\nBe concrete and brief. If the week was quiet, say so rather than padding.`.replace('{company}', s.company).replace('{articles}', list || 'Nothing this week.');\nreturn [{ json: { ...s, weeklyPrompt: prompt, weekCount: rows.length } }];"},"id":"weekly-prompt","name":"Weekly prompt","type":"n8n-nodes-base.code","typeVersion":2,"position":[1740,460]},{"parameters":{"method":"POST","url":"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent","authentication":"predefinedCredentialType","nodeCredentialType":"googlePalmApi","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ contents: [{ parts: [{ text: $json.weeklyPrompt }] }], generationConfig: { temperature: 0.3 } }) }}","options":{"timeout":120000}},"id":"gemini:-weekly-rollup","name":"Gemini: weekly rollup","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1960,460],"credentials":{"googlePalmApi":{"id":"6Xh6zSnQMO2zTzBr","name":"Google Gemini(PaLM) Api account"}}},{"parameters":{"sendTo":"={{ $('Settings').first().json.recipient }}","subject":"=Weekly Competitor Rollup — {{ $now.setZone('Asia/Kolkata').toFormat('d LLL yyyy') }}","message":"={{ '<div style=\"max-width:680px;margin:0 auto;font-family:system-ui,sans-serif\"><h2>Weekly Competitor Rollup</h2><pre style=\"white-space:pre-wrap;font:14px/1.6 system-ui\">' + $json.candidates[0].content.parts[0].text + '</pre></div>' }}","options":{}},"id":"email-the-weekly-rollup","name":"Email the weekly rollup","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[2180,460],"credentials":{"gmailOAuth2":{"id":"MMJTLRIfotcajuZz","name":"Gmail account"}}}],"connections":{"Every morning 8:00 IST":{"main":[[{"node":"Settings","type":"main","index":0}]]},"Run now (demo)":{"main":[[{"node":"Settings","type":"main","index":0}]]},"Settings":{"main":[[{"node":"One item per competitor","type":"main","index":0}]]},"One item per competitor":{"main":[[{"node":"Serper: Google News","type":"main","index":0}]]},"Serper: Google News":{"main":[[{"node":"Collect and de-duplicate","type":"main","index":0}]]},"Collect and de-duplicate":{"main":[[{"node":"Build the prompt","type":"main","index":0}]]},"Build the prompt":{"main":[[{"node":"Gemini: score, summarise, categorise","type":"main","index":0}]]},"Gemini: score, summarise, categorise":{"main":[[{"node":"Keep what matters","type":"main","index":0}]]},"Keep what matters":{"main":[[{"node":"Compose the digest","type":"main","index":0}]]},"Compose the digest":{"main":[[{"node":"Email the digest","type":"main","index":0},{"node":"Rows for history","type":"main","index":0},{"node":"Is it Friday?","type":"main","index":0}]]},"Rows for history":{"main":[[{"node":"Append to Sheets (history)","type":"main","index":0}]]},"Is it Friday?":{"main":[[{"node":"Read the week","type":"main","index":0}]]},"Read the week":{"main":[[{"node":"Weekly prompt","type":"main","index":0}]]},"Weekly prompt":{"main":[[{"node":"Gemini: weekly rollup","type":"main","index":0}]]},"Gemini: weekly rollup":{"main":[[{"node":"Email the weekly rollup","type":"main","index":0}]]}},"settings":{"executionOrder":"v1","timezone":"Asia/Kolkata"},"active":false}