AutomationFlowsAI & RAG › Focuskpi — Competitor News Monitor & Weekly Digest

Focuskpi — Competitor News Monitor & Weekly Digest

FocusKPI — Competitor News Monitor & Weekly Digest. Uses dataTable, httpRequest, openAi, slack. Scheduled trigger; 20 nodes.

Cron / scheduled trigger★★★★☆ complexityAI-powered20 nodesData TableHTTP RequestOpenAISlack
AI & RAG Trigger: Cron / scheduled Nodes: 20 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow follows the Datatable → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

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

Download .json
{
  "name": "FocusKPI \u2014 Competitor News Monitor & Weekly Digest",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1"
            }
          ]
        }
      },
      "id": "a1000000-0000-0000-0000-000000000001",
      "name": "Every Monday 9AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1080,
        0
      ]
    },
    {
      "parameters": {
        "operation": "deleteRows",
        "dataTableId": {
          "__rl": true,
          "value": "articles",
          "mode": "name"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "fetched_at",
              "condition": "lt",
              "keyValue": "={{ $now.minus({ days: 30 }).toISO() }}"
            }
          ]
        },
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000020",
      "name": "Data Table: Prune Old Articles",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        -860,
        200
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Watchlist and queries live here as data \u2014 edit this list, never the URLs downstream.\nconst WATCHLIST = [\n  'Fractal Analytics', 'Tiger Analytics', 'Tredence', 'LatentView Analytics',\n  'Course5 Intelligence', 'Mu Sigma', 'ZS Associates', 'Analytic Partners', 'Ipsos MMA'\n];\n\nconst gnews = q =>\n  'https://news.google.com/rss/search?q=' + encodeURIComponent(q + ' when:7d') +\n  '&hl=en-US&gl=US&ceid=US:en';\n\n// Google News truncates long queries \u2014 5 quoted names per URL keeps each one under the limit.\nconst quoted = WATCHLIST.map(n => `\"${n}\"`);\nconst groups = [];\nfor (let i = 0; i < quoted.length; i += 5) groups.push(quoted.slice(i, i + 5));\n\nconst feeds = [\n  ...groups.map((g, i) => ({\n    query_tag: 'competitors_' + (i + 1),\n    url: gnews('(' + g.join(' OR ') + ')')\n  })),\n  { query_tag: 'deal_flow',\n    url: gnews('(\"analytics consulting\" OR \"data science consulting\" OR \"marketing mix modeling\") (acquisition OR funding OR \"Series A\" OR \"Series B\" OR partnership)') },\n  { query_tag: 'industry',\n    url: gnews('(\"customer analytics\" OR \"marketing mix modeling\" OR \"analytics consulting\" OR \"data science consulting\")') }\n];\n\nreturn feeds.map(json => ({ json }));"
      },
      "id": "a1000000-0000-0000-0000-000000000011",
      "name": "Build Feed List",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -860,
        -120
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "a1000000-0000-0000-0000-000000000002",
      "name": "RSS: Google News (5 queries)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -640,
        -120
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "https://www.kdnuggets.com/feed",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "a1000000-0000-0000-0000-000000000003",
      "name": "RSS: KDnuggets (Data Science)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -640,
        120
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "append"
      },
      "id": "a1000000-0000-0000-0000-000000000004",
      "name": "Merge Feeds",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        -420,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// ponytail: regex RSS parse \u2014 feeds are machine-generated & stable; swap to the RSS Read node for prod\nconst decode = s => (s||'')\n  .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g,'$1')\n  .replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>')\n  .replace(/&#39;/g,\"'\").replace(/&quot;/g,'\"').replace(/&apos;/g,\"'\").trim();\n\nconst tag = (block, name) => {\n  const m = block.match(new RegExp(`<${name}[^>]*>([\\\\s\\\\S]*?)</${name}>`));\n  return m ? decode(m[1]) : '';\n};\n\nconst stripTags = s => decode(String(s||'').replace(/<[^>]*>/g,' ')).replace(/\\s+/g,' ').trim();\nconst norm = s => String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim();\n\n// Drop tracking params only \u2014 Google News links are redirectors and must stay otherwise intact.\nconst cleanUrl = u => {\n  const [base, qs] = String(u||'').split('?');\n  if (!qs) return base;\n  const keep = qs.split('&').filter(p => !/^(utm_[^=]*|gclid|fbclid|mc_cid|mc_eid|igshid)=/i.test(p));\n  return keep.length ? base + '?' + keep.join('&') : base;\n};\n\n// Merge appends in input order: the Google News responses (one per query) first, then KDnuggets.\n// That ordering is what lets each article recover the query that found it.\nconst feeds = $('Build Feed List').all().map(f => f.json);\nconst tagFor = i => (feeds[i] && feeds[i].query_tag) || 'kdnuggets';\n\nconst weekAgo = Date.now() - 7*24*60*60*1000;\nconst fetched_at = new Date().toISOString();\nconst seen = new Set();\nconst articles = [];\n\n$input.all().forEach((item, i) => {\n  const query_tag = tagFor(i);\n  const xml = item.json.data || item.json.body || '';\n  const blocks = String(xml).match(/<item>[\\s\\S]*?<\\/item>/g) || [];\n  for (const b of blocks) {\n    const rawTitle = tag(b,'title');\n    if (!rawTitle) continue;\n    const link = cleanUrl(tag(b,'link'));\n    const pub  = tag(b,'pubDate');\n    const ts   = pub ? Date.parse(pub) : Date.now();\n    if (ts && ts < weekAgo) continue;\n\n    // Google News carries the publisher in <source>; otherwise derive it from the link host\n    const host = (link.split('/')[2] || '').replace('www.','');\n    const outlet = tag(b,'source') || host;\n    // Google News appends \" - Publisher\" to every title; drop it, we track outlet separately\n    const title = outlet && rawTitle.endsWith(' - ' + outlet) ? rawTitle.slice(0, -(outlet.length + 3)) : rawTitle;\n\n    // ponytail: the dedup key IS the normalized tuple, not a digest \u2014 the Cloud Code node has no\n    // crypto, and a readable key cannot collide. Swap to a sha only if column length starts to hurt.\n    const day = new Date(ts).toISOString().slice(0,10);\n    const content_hash = `${norm(title)}|${norm(outlet)}|${day}`.slice(0,250);\n    if (seen.has(content_hash)) continue;\n    seen.add(content_hash);\n\n    // Google News descriptions are the headline re-linked \u2014 keep only descriptions that add text\n    let description = stripTags(tag(b,'description')).slice(0,300);\n    if (!description || norm(description).startsWith(norm(title).slice(0,40))) description = '';\n\n    articles.push({\n      content_hash, title, url: link, outlet,\n      published_at: new Date(ts).toISOString(),\n      fetched_at, description, query_tag, status: 'new'\n    });\n  }\n});\n\narticles.sort((a,b) => Date.parse(b.published_at) - Date.parse(a.published_at));\nreturn [{ json: { scanned: articles.length, feedCount: $input.all().length, articles } }];"
      },
      "id": "a1000000-0000-0000-0000-000000000005",
      "name": "Parse & Normalize",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -200,
        0
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "value": "articles",
          "mode": "name"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "content_hash",
              "condition": "isNotEmpty"
            }
          ]
        },
        "returnAll": true
      },
      "id": "a1000000-0000-0000-0000-000000000012",
      "name": "Data Table: Known Articles",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        20,
        0
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// The Data Table returns every stored row as an item. alwaysOutputData is on, so an empty\n// table yields one blank item instead of nothing \u2014 that is what makes the very first run work.\nconst known = new Set($input.all().map(i => i.json.content_hash).filter(Boolean));\n\nconst prep = $('Parse & Normalize').first().json;\nconst fresh = prep.articles.filter(a => !known.has(a.content_hash));\n\n// Watchlist-driven queries name a competitor directly \u2014 surface those ahead of general industry news.\nconst isWatchlist = a => /^competitors/.test(a.query_tag || '');\nfresh.sort((a, b) =>\n  ((isWatchlist(b)?1:0) - (isWatchlist(a)?1:0)) ||\n  (Date.parse(b.published_at) - Date.parse(a.published_at)));\n\n// ponytail: positional ids, not the 60-char content_hash \u2014 a [12] costs ~15 fewer tokens/item,\n// and \\\"id must be in 1..N\\\" turns the hallucination check downstream into a range test.\n// Per-company cap: one company's big-news week (earnings across 20 outlets) must not eat all 60\n// model slots, or the digest collapses to one event. Company is only named by the LLM later, so\n// match the title against the watchlist here to bound each company and leave room for the rest.\nconst WATCHLIST = ['Fractal Analytics', 'Tiger Analytics', 'Tredence', 'LatentView Analytics',\n  'Course5 Intelligence', 'Mu Sigma', 'ZS Associates', 'Analytic Partners', 'Ipsos MMA'];\nconst companyOf = a => {\n  const t = (a.title + ' ' + a.outlet).toLowerCase();\n  return WATCHLIST.find(n => t.includes(n.toLowerCase())) || 'industry';\n};\nconst PER_COMPANY = 6;\nconst perCo = {};\nconst capped = [];\nfor (const a of fresh) {\n  const c = companyOf(a);\n  if ((perCo[c] = (perCo[c] || 0) + 1) <= PER_COMPANY) capped.push(a);\n  if (capped.length >= 60) break;\n}\nconst idMap = {};\nconst itemsBlock = capped.map((a, i) => {\n  const id = i + 1;\n  idMap[id] = { content_hash: a.content_hash, url: a.url, title: a.title, outlet: a.outlet };\n  const desc = a.description ? `: ${a.description}` : '';\n  return `[${id}] ${a.title} \u2014 ${a.outlet} (${a.published_at.slice(0,10)})${desc}`;\n}).join('\\n');\n\nreturn [{ json: {\n  count: fresh.length, scanned: prep.scanned, feedCount: prep.feedCount,\n  newArticles: fresh, itemsBlock, idMap\n} }];"
      },
      "id": "a1000000-0000-0000-0000-000000000013",
      "name": "Filter New Articles",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "leftValue": "={{ $json.count }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "id": "a1000000-0000-0000-0000-000000000006",
      "name": "Any new news this week?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        460,
        0
      ]
    },
    {
      "parameters": {
        "fieldToSplitOut": "newArticles",
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000014",
      "name": "Split Out New Articles",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        700,
        160
      ]
    },
    {
      "parameters": {
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "value": "articles",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000015",
      "name": "Data Table: Insert Articles",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        920,
        160
      ]
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o-mini",
          "mode": "list"
        },
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "You are a competitive-intelligence analyst for FocusKPI, a data-science and analytics consulting firm. You turn a list of news items into 3 to 5 distinct, deduplicated events, each scored for strategic relevance.\n\nFOCUSKPI CONTEXT\nService lines: data-science & ML consulting; BI & advanced analytics; marketing measurement & marketing-mix modeling (MMM); data engineering; GenAI & automation.\nBuyers: mid-market and enterprise marketing, product, operations, and analytics teams.\nPriority signals: new offerings or packaged products; customer wins; vertical or geographic expansion; senior hires; partnerships; M&A; funding; pricing or go-to-market shifts; consulting firms moving into software; software or tech firms moving into advisory and implementation.\n\nTASK\n1. Cluster items covering the SAME underlying event into ONE event; combine their ids into source_ids. Aim for 3-5 events; prefer breadth across different companies and topics over several near-duplicate events about one company.\n2. Score relevance 0-10 against the context above (10 = a direct competitor's material move against a FocusKPI service line; 0 = generic commentary, tutorial, or listicle).\n3. Assign priority low, medium, or high by how much the event should influence FocusKPI's near-term decisions (used only for ordering \u2014 never shown to readers).\n4. Write summary as one or two neutral sentences that state the facts and fold the specific FocusKPI implication in as a natural clause \u2014 e.g. '...report strong Q1 growth on AI demand, which may pressure FocusKPI's positioning in AI consulting.' Do NOT prefix the implication with a label like 'Why it matters'. Name a competitor, service line, or buyer segment in that clause; no vague filler such as 'highlights the importance of'.\n5. Keep every field neutral and factual. Do NOT use threat, enemy, war, or battle language.\n6. NOVELTY: if an item repeats something under PREVIOUSLY REPORTED, suppress it \u2014 unless there is a material new development. If it advances a prior event, include it, set is_update true, and put the prior headline in update_of.\n7. PATTERNS: add 0-2 short, factual observations only when RECENT ACTIVITY shows a real streak (e.g. a third customer win in eight weeks). Otherwise return an empty patterns array.\n\nOUTPUT \u2014 strict JSON only, no prose, no code fences:\n{\"week_theme\": string, \"patterns\": [string], \"events\": [{\"headline\": string, \"company\": string, \"category\": one of [\"launch\",\"funding\",\"m&a\",\"customer_win\",\"hire\",\"partnership\",\"positioning\",\"other\"], \"priority\": one of [\"low\",\"medium\",\"high\"], \"relevance\": integer 0-10, \"is_update\": boolean, \"update_of\": string, \"summary\": string, \"source_ids\": [integer]}]}\nUse only the integer ids shown in brackets as source_ids; never invent an id."
            },
            {
              "role": "user",
              "content": "=NEW ITEMS (id in brackets):\n\n{{ $json.itemsBlock }}\n\nPREVIOUSLY REPORTED (last 30 days \u2014 suppress repeats):\n{{ $json.previouslyReported }}\n\nRECENT ACTIVITY (last 90 days, company \u00d7 category):\n{{ $json.recentActivity }}"
            }
          ]
        },
        "options": {
          "temperature": 0.2
        }
      },
      "id": "a1000000-0000-0000-0000-000000000007",
      "name": "OpenAI: Summarize (gpt-4o-mini)",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        700,
        -140
      ],
      "retryOnFail": true,
      "maxTries": 2
    },
    {
      "parameters": {
        "jsCode": "// Model output field name varies by node version \u2014 probe the common ones\nconst j = items[0].json;\nconst raw = j.message?.content ?? j.text ?? j.output ?? j.content ?? '';\nconst fn = $('Filter New Articles').first().json;\nconst idMap = fn.idMap || {};\n\nlet data;\ntry {\n  data = typeof raw === 'string'\n    ? JSON.parse(String(raw).replace(/```json/gi,'').replace(/```/g,'').trim())\n    : raw;\n} catch (e) { data = null; }\n\nconst today = new Date().toISOString().slice(0,10);\nconst footer = `_Scanned ${fn.scanned} articles across ${fn.feedCount} feeds; ${fn.count} new since the last run._`;\n\nif (!data || !Array.isArray(data.events)) {\n  const md = `*FocusKPI Competitor & Industry Digest* \u2014 ${today}\\n\\n_Model output could not be parsed this week; ${fn.count} new items were collected and stored._\\n\\n${footer}`;\n  return [{ json: { digest: md, publishedEvents: [] } }];\n}\n\n// Grounding gate: keep an event only if EVERY source_id maps to a real article from this run.\n// One bad id drops the whole event \u2014 this is what stops hallucinated citations reaching Slack.\nconst PRIORITY = { low: 0, medium: 1, high: 2 };\nconst grounded = data.events.map(ev => {\n  const declared = Array.isArray(ev.source_ids) ? ev.source_ids : [];\n  const ids = declared.map(Number).filter(n => Number.isInteger(n) && idMap[n]);\n  return { ev, ids, ok: ids.length > 0 && ids.length === declared.length };\n}).filter(x => x.ok);\n\ngrounded.sort((a, b) =>\n  ((Number(b.ev.relevance)||0) - (Number(a.ev.relevance)||0)) ||\n  ((PRIORITY[b.ev.priority]||0) - (PRIORITY[a.ev.priority]||0)));\n\n// Show every event over the relevance bar (up to 5). On a thin week where fewer than 3 clear it,\n// backfill with the next-best grounded events down to a soft floor \u2014 real clustered events, just\n// lower-scored, never fabricated \u2014 so the digest still reads as active.\nconst RELEVANCE_FLOOR = 4, BACKFILL_FLOOR = 2, MIN_EVENTS = 3, MAX_EVENTS = 5;\nconst strong = grounded.filter(x => Number(x.ev.relevance) >= RELEVANCE_FLOOR);\nconst top = (strong.length >= MIN_EVENTS ? strong : grounded.filter(x => Number(x.ev.relevance) >= BACKFILL_FLOOR).slice(0, MIN_EVENTS)).slice(0, MAX_EVENTS);\n\n// One clickable source per event keeps the message under Slack's length limit \u2014 the Google News\n// redirect URLs run ~600 chars each, and a dozen of them split the message and break the link\n// markup. Extra outlets are named, not linked. ponytail: per-event link cap, revisit with Block\n// Kit context blocks if readers want every source clickable.\nconst sourceLine = ids => {\n  const seen = new Set(); const outlets = [];\n  for (const id of ids) {\n    const o = (idMap[id].outlet || 'source').trim();\n    if (!seen.has(o)) { seen.add(o); outlets.push({ o, url: idMap[id].url }); }\n  }\n  if (!outlets.length) return '';\n  let line = `<${outlets[0].url}|${outlets[0].o}>`;\n  if (outlets.length > 1) line += `  +${outlets.length - 1} more`;\n  return line;\n};\n\nlet md = `*FocusKPI Competitor & Industry Digest* \u2014 ${today}\\n\\n`;\nif (data.week_theme) md += `${data.week_theme}\\n\\n`;\n\nconst publishedEvents = [];\ntop.forEach((x, i) => {\n  const ev = x.ev;\n  const upd = ev.is_update ? ' _(update)_' : '';\n  md += `*${i+1}. ${ev.headline}*${upd}\\n`;\n  if (ev.summary) md += `${ev.summary}\\n`;\n  const line = sourceLine(x.ids);\n  if (line) md += `${line}\\n`;\n  md += '\\n';\n  publishedEvents.push({\n    event_id: `${today}-${i+1}`,\n    digest_date: today,\n    headline: ev.headline || '',\n    company: ev.company || '',\n    category: ev.category || 'other',\n    priority: ev.priority || 'low',\n    what_happened: ev.summary || '',\n    why_it_matters: '',\n    source_ids: x.ids.map(id => idMap[id].content_hash).join(',')\n  });\n});\n\nif (Array.isArray(data.patterns) && data.patterns.length) {\n  md += `*Patterns*\\n` + data.patterns.slice(0, 2).map(p => `\u2022 ${p}`).join('\\n') + `\\n\\n`;\n}\n\nif (top.length === 0) md += `_Collected ${fn.count} new items; none cleared the relevance bar this week._\\n\\n`;\nmd += footer;\n\nreturn [{ json: { digest: md, publishedEvents } }];"
      },
      "id": "a1000000-0000-0000-0000-000000000008",
      "name": "Format Digest (Markdown)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        -140
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": ""
        },
        "text": "={{ $json.digest }}",
        "otherOptions": {
          "unfurl_links": false,
          "unfurl_media": false
        }
      },
      "id": "a1000000-0000-0000-0000-000000000009",
      "name": "Slack: Post Digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1140,
        -140
      ]
    },
    {
      "parameters": {
        "fieldToSplitOut": "publishedEvents",
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000016",
      "name": "Split Out Events",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        1140,
        40
      ]
    },
    {
      "parameters": {
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "value": "events",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "a1000000-0000-0000-0000-000000000017",
      "name": "Data Table: Insert Events",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1360,
        40
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "value": "events",
          "mode": "name"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "digest_date",
              "condition": "isNotEmpty"
            }
          ]
        },
        "returnAll": true
      },
      "id": "a1000000-0000-0000-0000-000000000018",
      "name": "Data Table: Recent Events",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        640,
        -280
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Events are the system's memory. The table is tiny, so read it all and window by date here\n// rather than pushing date math into the Data Table filter.\nconst now = Date.now();\nconst DAY = 864e5;\nconst events = $input.all().map(i => i.json).filter(e => e && e.digest_date);\nconst within = d => e => (now - Date.parse(e.digest_date)) <= d * DAY;\n\n// Last 30 days: what we already told the channel \u2014 the model suppresses repeats against this.\nconst prev = events.filter(within(30))\n  .map(e => `- ${e.headline} \u2014 ${e.company || '?'} (${e.digest_date})`);\nconst previouslyReported = prev.length ? prev.join('\\n') : '(nothing reported in the last 30 days)';\n\n// Last 90 days: nonzero company \u00d7 category counts, so the model can spot an actual streak.\nconst counts = {};\nfor (const e of events.filter(within(90))) {\n  const key = `${e.company || '?'} \u00d7 ${e.category || 'other'}`;\n  counts[key] = (counts[key] || 0) + 1;\n}\nconst rollup = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, n]) => `- ${k}: ${n}`);\nconst recentActivity = rollup.length ? rollup.join('\\n') : '(no activity recorded yet)';\n\nconst fn = $('Filter New Articles').first().json;\nreturn [{ json: { itemsBlock: fn.itemsBlock, previouslyReported, recentActivity } }];"
      },
      "id": "a1000000-0000-0000-0000-000000000019",
      "name": "Build Memory Block",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        860,
        -280
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": ""
        },
        "text": "FocusKPI Competitor Digest \u2014 no new competitor or industry news since the last run.",
        "otherOptions": {
          "unfurl_links": false,
          "unfurl_media": false
        }
      },
      "id": "a1000000-0000-0000-0000-000000000010",
      "name": "Slack: No News",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        700,
        360
      ]
    }
  ],
  "connections": {
    "Every Monday 9AM": {
      "main": [
        [
          {
            "node": "Data Table: Prune Old Articles",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Feed List",
            "type": "main",
            "index": 0
          },
          {
            "node": "RSS: KDnuggets (Data Science)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Feed List": {
      "main": [
        [
          {
            "node": "RSS: Google News (5 queries)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS: Google News (5 queries)": {
      "main": [
        [
          {
            "node": "Merge Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS: KDnuggets (Data Science)": {
      "main": [
        [
          {
            "node": "Merge Feeds",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge Feeds": {
      "main": [
        [
          {
            "node": "Parse & Normalize",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Normalize": {
      "main": [
        [
          {
            "node": "Data Table: Known Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Data Table: Known Articles": {
      "main": [
        [
          {
            "node": "Filter New Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter New Articles": {
      "main": [
        [
          {
            "node": "Any new news this week?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any new news this week?": {
      "main": [
        [
          {
            "node": "Data Table: Recent Events",
            "type": "main",
            "index": 0
          },
          {
            "node": "Split Out New Articles",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: No News",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Data Table: Recent Events": {
      "main": [
        [
          {
            "node": "Build Memory Block",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Memory Block": {
      "main": [
        [
          {
            "node": "OpenAI: Summarize (gpt-4o-mini)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out New Articles": {
      "main": [
        [
          {
            "node": "Data Table: Insert Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI: Summarize (gpt-4o-mini)": {
      "main": [
        [
          {
            "node": "Format Digest (Markdown)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Digest (Markdown)": {
      "main": [
        [
          {
            "node": "Slack: Post Digest",
            "type": "main",
            "index": 0
          },
          {
            "node": "Split Out Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Events": {
      "main": [
        [
          {
            "node": "Data Table: Insert Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  }
}
Pro

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

About this workflow

FocusKPI — Competitor News Monitor & Weekly Digest. Uses dataTable, httpRequest, openAi, slack. Scheduled trigger; 20 nodes.

Source: https://gist.github.com/JeffLepp/63a4215eddf707056acc61ab225fda4e — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

A scheduled process aggregates content from eight distinct data sources and standardizes all inputs into a unified format. AI models perform sentiment scoring, detect conspiracy or misinformation sign

HTTP Request, OpenAI, Postgres +2
AI & RAG

Automatically detects missed Zoom demos booked via Calendly and triggers AI-powered follow-up sequences.

HTTP Request, OpenAI, Email Send +3
AI & RAG

Imagine a dedicated financial expert tirelessly working behind the scenes, sifting through every transaction, every investment move, and every accounting entry. That's exactly what this automated syst

HTTP Request, Google Sheets, OpenAI +3
AI & RAG

Runs fetch_cat/linkedin-jobs-scraper daily to collect recent LinkedIn jobs, scores each listing against your candidate profile with OpenAI, saves qualified matches to Google Sheets, and sends the five

Data Table, HTTP Request, OpenAI +2
AI & RAG

News Automation - Main Workflow. Uses rssFeedRead, postgres, openAi, httpRequest. Scheduled trigger; 28 nodes.

RSS Feed Read, Postgres, OpenAI +4