{
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtHour": 18
            }
          ]
        }
      },
      "id": "83ec5907-1e5a-4abd-b0b8-f699cb092e62",
      "name": "Every Sunday 6pm",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        2560,
        5552
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "limit": 100,
        "simple": false,
        "filters": {
          "q": "from:f5bot.com newer_than:7d (\"ai overview\" OR \"ai search\" OR \"google business profile\" OR \"marketing agency\" OR \"local seo\")"
        },
        "options": {}
      },
      "id": "ccb08cbe-20c7-4595-a815-04149882d886",
      "name": "Get F5Bot Emails",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2784,
        5664
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Only F5Bot emails that mention a trend keyword - alerts for your other F5Bot keywords are left out. Add new trend keywords to the OR list in the query. Mixed emails are cleaned in Build Prompt (TREND_KEYWORDS) - keep both lists in sync."
    },
    {
      "parameters": {
        "jsCode": "// Edit this list to change sources. Google News search feeds stand in for niche \"trends\".\n// when:7d keeps them to the last week. hl/gl = Canadian edition.\nconst gn = (q) => 'https://news.google.com/rss/search?q=' + encodeURIComponent(q + ' when:7d') + '&hl=en-CA&gl=CA&ceid=CA:en';\n\nconst feeds = [\n  // Industry news (breaks 3-7 days before LinkedIn catches up)\n  // (Search Engine Land's feed returns 403 to n8n - Search Engine Roundtable covers local/GBP news better anyway)\n  'https://www.seroundtable.com/index.rdf',\n  'https://www.searchenginejournal.com/feed/',\n  'https://developers.google.com/search/blog/feed.xml',\n  'https://status.search.google.com/en/feed.atom',   // Google core/spam update announcements\n\n  // Niche news searches (cheap stand-in for Google Trends on your topics)\n  gn('\"AI Overviews\" OR \"AI Mode\" OR \"ChatGPT search\"'),\n  gn('\"Google Business Profile\" OR \"local SEO\"'),\n  gn('clinic marketing OR physiotherapy clinic OR massage clinic'),\n  gn('small business AI search visibility'),\n\n  // Google Trends - what Canada is searching right now (only used if there's a real tie-in)\n  'https://trends.google.com/trending/rss?geo=CA',\n];\n\nreturn feeds.map(url => ({ json: { url } }));"
      },
      "id": "8f4101b3-adaf-45fc-a3ef-7b4ba9fceb2d",
      "name": "Feed List",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3008,
        5664
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {}
      },
      "id": "ce7003e0-71d7-4b5d-8cf9-02faf5515c4d",
      "name": "Read RSS Feeds",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.1,
      "position": [
        3232,
        5664
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const PILLARS = [\"Show Up in AI Answers\",\"Get Found on Maps\",\"Turn Searches into Bookings\",\"Reviews & Trust\",\"Local SEO for Clinics\",\"Lead Gen Tips for Clinic Owners\",\"Sales Automation\",\"Proof\",\"Behind the Scenes\",\"Personal / Founder Story\",\"Client Results & Proof\",\"Clinic Ops & HR\",\"Team Management\",\"Industry Software & Tools\"];\nconst FORMATS = [\"Insight\",\"Hook + List\",\"Story\",\"Short Tip\",\"Carousel\",\"Case Study\"];\nconst VERTICALS = [\"Healthcare\",\"Local Services\",\"Both\"];\n\n// ---- Settings ----\nconst MODEL = 'deepseek-chat';      // any OpenAI-compatible model name\nconst DAYS = 7;\nconst MAX_PER_SOURCE = 20;\nconst cutoff = Date.now() - DAYS * 864e5;\n\n// ---- 1. F5Bot emails ----\n// Trend keywords - keep in sync with F5Bot and the Gmail node's search.\n// F5Bot can bundle alerts for your other keywords into the same email,\n// so only the hits that mention one of these are kept.\nconst TREND_KEYWORDS = ['ai overview', 'ai search', 'google business profile', 'marketing agency', 'local seo'];\nconst hasKw = (s) => { const l = String(s).toLowerCase(); return TREND_KEYWORDS.some(k => l.includes(k)); };\n\nconst keepTrendHits = (text) => {\n  const kept = [];\n  for (const b of text.split(/\\n\\s*\\n/)) {\n    if (b.length < 1500) { if (hasKw(b)) kept.push(b.trim()); continue; }\n    // Long block with no blank lines: keep a few lines around each match\n    const lines = b.split('\\n');\n    lines.forEach((ln, i) => { if (hasKw(ln)) kept.push(lines.slice(Math.max(0, i - 2), i + 3).join('\\n').trim()); });\n  }\n  return [...new Set(kept)].join('\\n\\n');\n};\n\nconst emails = $('Get F5Bot Emails').all()\n  .map(i => i.json)\n  .filter(j => j && (j.text || j.snippet));\n\nconst f5bot = emails.map(j => {\n  let t = String(j.text || j.snippet || '').replace(/\\r/g, '');\n  t = t.split(/unsubscribe/i)[0];           // drop footer\n  t = keepTrendHits(t);\n  if (!t) return '';\n  return '--- ' + (j.date || '') + '\\n' + t.slice(0, 3500);\n}).filter(Boolean).join('\\n\\n').slice(0, 20000);\n\n// ---- 2. RSS items ----\nconst sourceName = (link) => {\n  try {\n    const h = ((String(link).match(/^https?:\\/\\/([^\\/?#:]+)/i) || [])[1] || '').toLowerCase().replace(/^www\\./, '');\n    if (!h) return 'RSS';\n    return ({\n      'news.google.com': 'Google News',\n      'trends.google.com': 'Google Trends (Canada)',\n      'developers.google.com': 'Google Search Central',\n    })[h] || h;\n  } catch (e) { return 'RSS'; }\n};\n\nconst groups = {};\nfor (const item of $('Filter Out Paid Ads').all()) {\n  const j = item.json || {};\n  if (!j.title || !j.link || j.error) continue;\n  const d = new Date(j.isoDate || j.pubDate || Date.now());\n  if (d.getTime() < cutoff) continue;\n  const src = sourceName(j.link);\n  (groups[src] = groups[src] || []).push({\n    title: String(j.title).trim(),\n    link: j.link,\n    date: d.toISOString().slice(0, 10),\n    snippet: String(j.contentSnippet || '').replace(/\\s+/g, ' ').slice(0, 220),\n  });\n}\n\nlet rss = '';\nfor (const [src, items] of Object.entries(groups)) {\n  items.sort((a, b) => b.date.localeCompare(a.date));\n  rss += '\\n## ' + src + '\\n';\n  for (const it of items.slice(0, MAX_PER_SOURCE)) {\n    rss += '- ' + it.title + ' (' + it.date + ') <' + it.link + '>' + (it.snippet ? '\\n  ' + it.snippet : '') + '\\n';\n  }\n}\n\n// ---- 3. Recent ideas (avoid repeats) ----\n// Rows from the Trend Radar sheet (oldest first) - take the latest 40 hooks\nconst recentHooks = $('Get Recent Ideas1').all()\n  .map(i => String((i.json || {}).Hook || '').trim())\n  .filter(Boolean)\n  .slice(-40);\n\nif (!f5bot && !rss) throw new Error('No signals this week - check the Gmail query and RSS feeds.');\n\n// ---- 3b. Counted mentions + change since last run (from Count Mentions) ----\nconst counted = $('Count Mentions').all().map(i => i.json).filter(r => r.Mentions > 0);\nconst baseline = (counted[0] || {}).BaselineWeek;\nconst byMomentum = (a, b) => baseline ? (b.Change - a.Change) || (b.Mentions - a.Mentions) : (b.Mentions - a.Mentions);\nconst plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');\nconst line = (r) => '- ' + r.Term + ': ' + plural(r.Mentions, 'mention') + ', ' + plural(r.Sources, 'source') +\n  (baseline ? ' (last run ' + r.LastWeek + ', ' + (r.Change >= 0 ? '+' : '') + r.Change + ')' : '');\nconst risingTracked = counted.filter(r => r.Type === 'tracked').sort(byMomentum).slice(0, 15).map(line).join('\\n');\nconst risingPhrases = counted.filter(r => r.Type === 'phrase').sort(byMomentum).slice(0, 10).map(line).join('\\n');\nconst rising = '# Rising topics (counted, ' + (baseline ? 'compared with the run on ' + baseline : 'first run - no baseline yet, raw volumes only') + ')\\n' +\n  (risingTracked || '(none)') + '\\n\\nEmerging phrases from headlines:\\n' + (risingPhrases || '(none)');\n\n// ---- 4. Prompt ----\nconst system = `You are a content strategist for [YOUR NAME], founder of [YOUR BUSINESS], a [WHAT YOU DO] in [YOUR CITY].\nThey post on their personal LinkedIn to build their brand and win clients.\n\nAUDIENCE: [DESCRIBE YOUR IDEAL CLIENTS - who they are, where they are, what they care about].\n\nWHAT YOU KNOW FIRST-HAND: [LIST YOUR AREAS OF EXPERTISE].\n\nYOUR JOB: read this week's signals, find the 5 best topics for the author to post about, and give each a sharp angle.\n\nHow to pick topics:\n1. Momentum - use the \"Rising topics\" counts as your main evidence. Favour the biggest increases since the last run over topics that are always busy. A topic that is big every week is not a trend unless something new happened.\n   Every topic must be backed by at least 2 independent sources (different publishers, or a publisher plus Reddit). One viral thread on its own is not enough.\n2. Audience fit - your ideal client would care. Translate industry news into what it means for their phone ringing.\n3. Gap - what is everyone else saying, and where can the author add something they are not (a local angle, a contrarian take, a \"here's what actually happened when I tried it\").\nNever pick paid advertising topics (Google Ads, Meta ads, PPC, ROAS, bidding, ad platforms) - the author does not sell paid ads. If a story is mainly about ads, skip it.\nSkip celebrity, sports and politics from Google Trends unless there is a genuine, non-cringe link to local business search.\nDo not repeat any of these recent ideas: ${recentHooks.length ? recentHooks.join(' | ') : '(none)'}\n\nVoice for hooks: short sentences, plain words, [YOUR SPELLING, e.g. Canadian English], sentence case, hyphens not em dashes, no emojis, no hashtags. [ANY AUDIENCE WORDING RULES, e.g. words to always use or to avoid]. Never invent statistics, results or client stories. If proof would help, say what kind of proof the author should pull from his own work.\n\nReturn ONLY JSON in this shape:\n{\"topics\":[{\n \"hook\": \"first line of the post, under 150 characters\",\n \"alt_hooks\": [\"two other hook options\"],\n \"pillar\": \"one of: ${PILLARS.join(', ')}\",\n \"format\": \"one of: ${FORMATS.join(', ')}\",\n \"vertical\": \"one of: Healthcare, Local Services, Both\",\n \"evidence\": \"the numbers behind it, e.g. 'AI Mode: 14 mentions across 5 sources, up from 3 last run'. Use the counted figures when the topic matches a row; otherwise say plainly what the evidence is\",\n \"why_now\": \"one sentence - what happened this week\",\n \"consensus_take\": \"what most people will post about this\",\n \"your_angle\": \"the author's different angle in 1-2 sentences\",\n \"proof_to_pull\": \"what evidence from the author's own work would back this up\",\n \"outline\": [\"3-5 short bullet points for the post\"],\n \"sources\": [{\"title\": \"...\", \"url\": \"...\"}],\n \"score\": 1-10\n}]}`;\n\nconst user = 'SIGNALS FROM THE LAST ' + DAYS + ' DAYS\\n\\n' + rising + '\\n\\n# Reddit / Hacker News mentions (F5Bot)\\n' + (f5bot || '(none this week)') + '\\n\\n# News and feeds' + (rss || '\\n(none)');\n\nreturn [{\n  json: {\n    signalCount: { f5botEmails: emails.length, rssItems: Object.values(groups).reduce((n, a) => n + a.length, 0) },\n    requestBody: {\n      model: MODEL,\n      temperature: 0.7,\n      response_format: { type: 'json_object' },\n      messages: [\n        { role: 'system', content: system },\n        { role: 'user', content: user },\n      ],\n    },\n  },\n}];"
      },
      "id": "846ea94a-144f-472b-8f5b-2b691a837eda",
      "name": "Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4320,
        5664
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.deepseek.com/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.requestBody) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "c86dabee-f352-4f2d-a173-e073fc0dbfbf",
      "name": "DeepSeek - Pick Topics",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4544,
        5664
      ],
      "notes": "Header Auth credential: Name = Authorization, Value = Bearer YOUR_DEEPSEEK_KEY. Any OpenAI-compatible endpoint works - change URL + model in Build Prompt."
    },
    {
      "parameters": {
        "jsCode": "const PILLARS = [\"Show Up in AI Answers\",\"Get Found on Maps\",\"Turn Searches into Bookings\",\"Reviews & Trust\",\"Local SEO for Clinics\",\"Lead Gen Tips for Clinic Owners\",\"Sales Automation\",\"Proof\",\"Behind the Scenes\",\"Personal / Founder Story\",\"Client Results & Proof\",\"Clinic Ops & HR\",\"Team Management\",\"Industry Software & Tools\"];\nconst FORMATS = [\"Insight\",\"Hook + List\",\"Story\",\"Short Tip\",\"Carousel\",\"Case Study\"];\nconst VERTICALS = [\"Healthcare\",\"Local Services\",\"Both\"];\n\nconst DB = '';\n\nconst res = $input.first().json || {};\nconst raw = String(((res.choices || [])[0] || {}).message?.content || '').replace(/^\\s*\\`\\`\\`(json)?|\\`\\`\\`\\s*$/g, '');\nlet data;\ntry { data = JSON.parse(raw); }\ncatch (e) { throw new Error('Model did not return valid JSON: ' + raw.slice(0, 400)); }\n\n// Count independent sources per topic (Google News links all share one host, so count each separately)\nconst sourceCount = (t) => new Set((t.sources || []).map(s => {\n  const h = ((String(s.url || '').match(/^https?:\\/\\/([^\\/?#:]+)/i) || [])[1] || '').toLowerCase().replace(/^www\\./, '');\n  return !h ? (s.url || s.title) : (h === 'news.google.com' ? s.url : h);\n})).size;\n\nconst all = (data.topics || [])\n  .filter(t => t && t.hook)\n  .map(t => Object.assign(t, { sourceCount: sourceCount(t) }))\n  .sort((a, b) => (b.score || 0) - (a.score || 0));\n// Prefer topics backed by 2+ sources; only backfill with single-source ones if fewer than 3 qualify\nconst strong = all.filter(t => t.sourceCount >= 2);\nconst topics = (strong.length >= 3 ? strong : strong.concat(all.filter(t => t.sourceCount < 2))).slice(0, 5);\n\n// Notion text helpers (2000-char limit per text object)\nconst chunks = (s) => { s = String(s || ''); const out = []; for (let i = 0; i < s.length; i += 1900) out.push(s.slice(i, i + 1900)); return out.length ? out : ['']; };\nconst rt = (s) => chunks(s).map(c => ({ type: 'text', text: { content: c } }));\nconst block = (type, s) => ({ object: 'block', type, [type]: { rich_text: rt(s) } });\nconst h3 = (s) => block('heading_3', s);\nconst p = (s) => block('paragraph', s);\nconst li = (s) => block('bulleted_list_item', s);\nconst linkLi = (title, url) => ({\n  object: 'block', type: 'bulleted_list_item',\n  bulleted_list_item: { rich_text: [{ type: 'text', text: { content: String(title || url).slice(0, 1900), link: /^https?:\\/\\//.test(url || '') ? { url } : null } }] },\n});\n\nreturn topics.map(t => {\n  const properties = {\n    'Post Hook': { title: rt(String(t.hook).slice(0, 300)) },\n    'Status': { select: { name: 'Idea' } },\n    'Source': { select: { name: 'Research / Cron' } },\n    'Notes': { rich_text: rt('Score ' + (t.score || '?') + '/10 - ' + t.sourceCount + ' sources - ' + (t.why_now || '')) },\n  };\n  if (PILLARS.includes(t.pillar)) properties['Pillar'] = { select: { name: t.pillar } };\n  if (FORMATS.includes(t.format)) properties['Format'] = { select: { name: t.format } };\n  if (VERTICALS.includes(t.vertical)) properties['Vertical'] = { select: { name: t.vertical } };\n\n  const evidence = (t.evidence || 'No counted evidence') + ' - ' + t.sourceCount + ' independent source' + (t.sourceCount === 1 ? '' : 's') + ' linked';\n  const children = [\n    h3('Evidence'), p(evidence),\n    h3('Why now'), p(t.why_now),\n    h3('What everyone else will say'), p(t.consensus_take),\n    h3('My angle'), p(t.your_angle),\n    h3('Proof to pull'), p(t.proof_to_pull),\n    h3('Outline'), ...(t.outline || []).map(li),\n    h3('Other hooks'), ...(t.alt_hooks || []).map(li),\n    h3('Sources'), ...(t.sources || []).map(s => linkLi(s.title, s.url)),\n  ];\n\n  return { json: { hook: t.hook, score: t.score, notionBody: { parent: { database_id: DB }, properties, children } } };\n});"
      },
      "id": "d5ea35ee-37d3-4175-b9b8-f7385a5ba8f3",
      "name": "Parse Topics",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4768,
        5664
      ]
    },
    {
      "parameters": {
        "content": "## LinkedIn Trend Radar\nWeekly: F5Bot + RSS + Google News -> count mentions vs last week -> DeepSeek picks 5 topics (2+ sources each) -> email to your inbox + log to Trend Radar sheet.\n\n**Setup**\n1. Gmail nodes (Get F5Bot Emails, Email Topic Options): pick your Gmail credential.\n2. All 4 Sheets nodes: pick your Google Sheets credential + the Trend Radar spreadsheet. Tabs: Ideas, Mentions.\n3. DeepSeek node: Header Auth credential, `Authorization: Bearer <key>`.\n4. Test run, check inbox + both tabs, then activate. Week 1 has no baseline - \"rising\" kicks in from week 2.\n5. Build Prompt: fill in the [BRACKETED] persona, audience and pillar lists. Feed List: swap in feeds for your industry.",
        "height": 260,
        "width": 760
      },
      "id": "fc54085b-8806-451a-b5e5-9ff553788020",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2560,
        5232
      ]
    },
    {
      "parameters": {
        "jsCode": "// Drops paid-advertising articles before anything is counted or sent to the model.\n// Edit these two lists to change what gets filtered out.\n\n// Words/phrases in the title or summary\nconst EXCLUDE = [\n  'roas', 'ppc', 'cpc', 'cpm', 'cpa', 'ads', 'ad spend', 'ad auction', 'ad copy', 'ad campaign', 'ad platform',\n  'google ads', 'meta ads', 'microsoft ads', 'amazon ads', 'tiktok ads', 'linkedin ads',\n  'performance max', 'pmax', 'demand gen', 'smart bidding', 'bidding', 'bids',\n  'paid search', 'paid social', 'paid media', 'advertiser', 'advertisers', 'advertising',\n  'shopping campaign', 'shopping campaigns', 'quality score', 'conversion value', 'merchant center',\n];\n// Feed categories (Search Engine Journal tags articles like \"PPC\", \"Paid Media Strategy\")\nconst EXCLUDE_CATEGORIES = /ppc|paid|advertis|shopping/i;\n\nconst esc = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst re = new RegExp('\\\\b(' + EXCLUDE.map(esc).join('|') + ')\\\\b', 'i');\n\nconst kept = [];\nlet dropped = 0;\nfor (const item of $input.all()) {\n  const j = item.json || {};\n  const text = (j.title || '') + ' ' + (j.contentSnippet || '');\n  const cats = [].concat(j.categories || []).map(c => (typeof c === 'string' ? c : (c && (c._ || c.term || c.name)) || '')).join(' ');\n  if (re.test(text) || EXCLUDE_CATEGORIES.test(cats)) { dropped++; continue; }\n  kept.push(item);\n}\n\n// Always pass at least one item so the rest of the workflow still runs\nreturn kept.length ? kept : [{ json: { note: 'All ' + dropped + ' items were filtered out as paid ads' } }];\n"
      },
      "id": "7902ea69-1573-4b2d-90a2-f38cf374dfd6",
      "name": "Filter Out Paid Ads",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3440,
        5664
      ],
      "notes": "Drops PPC / ROAS / ads articles. Edit the EXCLUDE list at the top."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "id": "9533d558-d33f-4b2d-ae85-545ac19dcffe",
      "name": "Get Past Counts",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        3888,
        5664
      ],
      "executeOnce": true,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Previous weeks' mention counts from the Mentions tab - the baseline for \"rising\"."
    },
    {
      "parameters": {
        "jsCode": "// Counts how often each topic appears across this week's signals and compares\n// with the last run saved in the Mentions tab. Output: one row per term.\n\n// ---- Terms to always track (lowercase; plurals match automatically) ----\nconst TRACKED = [\n  'ai overview', 'ai mode', 'ai search', 'chatgpt', 'perplexity', 'gemini', 'copilot', 'llm',\n  'generative engine optimization', 'answer engine', 'ai agent', 'agentic',\n  'google business profile', 'local pack', 'map pack', 'google maps', 'apple maps',\n  'google review', 'fake review', 'local seo',\n  'core update', 'spam update', 'algorithm update', 'search console', 'zero-click', 'traffic drop',\n  'schema', 'structured data', 'reddit', 'youtube', 'bing',\n];\n\nconst DAYS = 7;\nconst cutoff = Date.now() - DAYS * 864e5;\nconst week = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Vancouver' }); // yyyy-mm-dd\n\nconst esc = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst trackedRe = TRACKED.map(t => [t, new RegExp('\\\\b' + esc(t) + 's?\\\\b', 'i')]);\nconst STOP = new Set(('a an the and or but of to in on for with at by from as is are was were be been it its this that these those ' +\n  'your you we our us they their how why what when where who which new more most best top vs via about into over after before ' +\n  'just now can will has have had not no do does did get gets says said report reports update updates week today year ' +\n  'guide tips ways things here first one two three all out up than then also may could should would really still').split(' '));\n\n// ---- 1. Collect documents (one per article or Reddit/HN hit) ----\nconst docs = [];\nfor (const it of $('Filter Out Paid Ads').all()) {\n  const j = it.json || {};\n  if (!j.title || !j.link || j.error) continue;\n  if (new Date(j.isoDate || j.pubDate || Date.now()).getTime() < cutoff) continue;\n  // (n8n's Code node has no URL() - pull the hostname out with a regex)\n  const host = ((String(j.link).match(/^https?:\\/\\/([^\\/?#:]+)/i) || [])[1] || '').toLowerCase().replace(/^www\\./, '');\n  if (!host) continue;\n  if (host === 'trends.google.com') continue; // general Canada trends - not counted\n  let title = String(j.title).trim();\n  let source = host;\n  if (host === 'news.google.com') { // \"Headline - Publisher\"\n    const m = title.match(/^(.*) - ([^-]+)$/);\n    if (m) { title = m[1].trim(); source = m[2].trim(); }\n  }\n  docs.push({ title, text: title + ' ' + (j.contentSnippet || ''), source: source.toLowerCase() });\n}\nfor (const it of $('Get F5Bot Emails').all()) {\n  const t = String((it.json || {}).text || '').replace(/\\r/g, '').split(/unsubscribe/i)[0];\n  for (const b of t.split(/\\n\\s*\\n/)) {\n    if (b.trim().length < 20) continue;\n    const sub = (b.match(/\\/r\\/(\\w+)/) || [])[1];\n    // Strip links and F5Bot's own labels so \"reddit\" is only counted when people actually talk about Reddit\n    const text = b.replace(/https?:\\/\\/\\S+/g, ' ').replace(/\\b(reddit|hacker news|lobsters) (posts?|comments?)\\b/gi, ' ');\n    docs.push({ title: '', text, source: sub ? 'r/' + sub.toLowerCase() : 'reddit/hn' });\n  }\n}\n\n// ---- 2. Count ----\nconst counts = {};\nconst add = (term, type, doc) => {\n  const c = counts[term] = counts[term] || { type, n: 0, src: new Set() };\n  c.n++; c.src.add(doc.source);\n};\nfor (const doc of docs) for (const [t, re] of trackedRe) if (re.test(doc.text)) add(t, 'tracked', doc);\n\n// Emerging two-word phrases from headlines (finds topics not on the tracked list)\nconst pubWords = new Set(docs.flatMap(d => d.source.split(/[^a-z0-9]+/)));\nfor (const doc of docs.filter(d => d.title)) {\n  const w = doc.title.toLowerCase().replace(/['\u2019]/g, '').split(/[^a-z0-9]+/).filter(Boolean);\n  const seen = new Set();\n  for (let i = 0; i < w.length - 1; i++) {\n    const a = w[i], b = w[i + 1];\n    if (STOP.has(a) || STOP.has(b) || /^\\d+$/.test(a) || /^\\d+$/.test(b) || a.length < 3 || b.length < 3) continue;\n    if (pubWords.has(a) && pubWords.has(b)) continue; // publisher names\n    const p = a + ' ' + b;\n    if (seen.has(p) || TRACKED.some(t => p.includes(t) || t.includes(p))) continue;\n    seen.add(p);\n    add(p, 'phrase', doc);\n  }\n}\n\n// ---- 3. Compare with the last saved run ----\nconst past = $input.all().map(i => i.json || {})\n  .filter(r => r.Week && r.Term && /^\\d{4}-\\d{2}-\\d{2}$/.test(String(r.Week)) && String(r.Week) < week);\nconst lastWeek = past.reduce((m, r) => (String(r.Week) > m ? String(r.Week) : m), '');\nconst prev = {};\nfor (const r of past) if (String(r.Week) === lastWeek) prev[String(r.Term).toLowerCase()] = Number(r.Mentions) || 0;\n\nconst row = ([t, c]) => ({\n  Week: week, Term: t, Type: c.type, Mentions: c.n, Sources: c.src.size,\n  LastWeek: lastWeek ? (prev[t] || 0) : null,\n  Change: lastWeek ? c.n - (prev[t] || 0) : null,\n  BaselineWeek: lastWeek,\n});\nconst entries = Object.entries(counts);\nconst tracked = entries.filter(([, c]) => c.type === 'tracked').map(row);\nconst phrases = entries.filter(([, c]) => c.type === 'phrase' && c.n >= 3 && c.src.size >= 2)\n  .sort((a, b) => b[1].n - a[1].n).slice(0, 30).map(row);\n\nconst rows = [...tracked, ...phrases];\nif (!rows.length) rows.push({ Week: week, Term: '(no mentions)', Type: 'none', Mentions: 0, Sources: 0, LastWeek: null, Change: null, BaselineWeek: lastWeek });\nreturn rows.map(json => ({ json }));\n"
      },
      "id": "60214f42-67ee-41d9-adab-f278929f3e7c",
      "name": "Count Mentions",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4112,
        5664
      ],
      "notes": "Counts tracked terms + emerging headline phrases, compares with last run. Edit TRACKED at the top."
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Mentions"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Week": "={{ $json.Week }}",
            "Term": "={{ $json.Term }}",
            "Type": "={{ $json.Type }}",
            "Mentions": "={{ $json.Mentions }}",
            "Sources": "={{ $json.Sources }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "Week",
              "displayName": "Week",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Term",
              "displayName": "Term",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Type",
              "displayName": "Type",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Mentions",
              "displayName": "Mentions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Sources",
              "displayName": "Sources",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "6e954191-a930-4a51-bab7-41f6151f41c8",
      "name": "Save Counts",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        4320,
        5872
      ],
      "notes": "Saves this week's counts to the Mentions tab (RAW so dates stay as text). Next run compares against them."
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Ideas"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Date added": "={{ $now.toFormat('yyyy-MM-dd') }}",
            "Status": "Idea",
            "Score": "={{ $json.score }}",
            "Hook": "={{ $json.hook }}",
            "Evidence": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Evidence') a.o.push('' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Pillar": "={{ ($json.notionBody.properties['Pillar'] || { select: {} }).select.name || '' }}",
            "Format": "={{ ($json.notionBody.properties['Format'] || { select: {} }).select.name || '' }}",
            "Vertical": "={{ ($json.notionBody.properties['Vertical'] || { select: {} }).select.name || '' }}",
            "Why now": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Why now') a.o.push('' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Consensus take": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'What everyone else will say') a.o.push('' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "My angle": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'My angle') a.o.push('' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Proof to pull": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Proof to pull') a.o.push('' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Outline": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Outline') a.o.push('- ' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Other hooks": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Other hooks') a.o.push('- ' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}",
            "Sources": "={{ $json.notionBody.children.reduce((a, b) => { const t = b[b.type].rich_text.map(r => r.text.content + (r.text.link ? ' ' + r.text.link.url : '')).join(''); if (b.type === 'heading_3') a.s = t; else if (a.s === 'Sources') a.o.push('- ' + t); return a; }, { s: '', o: [] }).o.join('\\n') }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "Date added",
              "displayName": "Date added",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "displayName": "Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Score",
              "displayName": "Score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Hook",
              "displayName": "Hook",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Evidence",
              "displayName": "Evidence",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Pillar",
              "displayName": "Pillar",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Format",
              "displayName": "Format",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Vertical",
              "displayName": "Vertical",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Why now",
              "displayName": "Why now",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Consensus take",
              "displayName": "Consensus take",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "My angle",
              "displayName": "My angle",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Proof to pull",
              "displayName": "Proof to pull",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Outline",
              "displayName": "Outline",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Other hooks",
              "displayName": "Other hooks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Sources",
              "displayName": "Sources",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "name": "Add Idea to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        4992,
        5776
      ],
      "id": "b1299001-781b-4810-ac71-ceaa9be669c5",
      "notes": "Logs each topic to the Ideas tab of Trend Radar."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      },
      "id": "85ade9fb-b362-48bc-a51a-8ed23b45bdc5",
      "name": "Get Recent Ideas1",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        3664,
        5664
      ],
      "executeOnce": true,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "notes": "Past ideas from the Trend Radar sheet (Ideas tab) so topics are not repeated."
    },
    {
      "parameters": {
        "sendTo": "you@example.com",
        "subject": "=LinkedIn topic options - week of {{ $now.toFormat('MMM d') }}",
        "message": "={{ (() => { const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const rt = b => b[b.type].rich_text.map(r => r.text.link ? '<a href=\"' + esc(r.text.link.url) + '\">' + esc(r.text.content) + '</a>' : esc(r.text.content)).join(''); const topics = $input.all().map((it, i) => { const j = it.json; const p = j.notionBody.properties; const sel = k => (p[k] || { select: {} }).select.name || '-'; const body = j.notionBody.children.map(b => b.type === 'heading_3' ? '<p style=\"margin:14px 0 4px;font-weight:bold;color:#777;font-size:11px;text-transform:uppercase;letter-spacing:.5px\">' + rt(b) + '</p>' : b.type === 'bulleted_list_item' ? '<div style=\"margin:2px 0 2px 10px\">&bull; ' + rt(b) + '</div>' : '<p style=\"margin:0\">' + rt(b) + '</p>').join(''); return '<div style=\"border-top:2px solid #222;padding:18px 0 22px\"><h2 style=\"margin:0 0 6px;font-size:18px;line-height:1.3\">' + (i + 1) + '. ' + esc(j.hook) + '</h2><div style=\"color:#666;font-size:13px\">Score ' + esc(j.score || '?') + '/10 &middot; ' + esc(sel('Pillar')) + ' &middot; ' + esc(sel('Format')) + ' &middot; ' + esc(sel('Vertical')) + '</div>' + body + '</div>'; }).join(''); return '<div style=\"font-family:Arial,sans-serif;font-size:14px;line-height:1.5;color:#222;max-width:640px\"><p>' + $input.all().length + ' topic options from this week\\'s signals, best first.</p>' + topics + '</div>'; })() }}",
        "options": {
          "appendAttribution": false
        }
      },
      "name": "Email Topic Options1",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        4992,
        5552
      ],
      "executeOnce": true,
      "id": "21693b56-0e23-4cc7-ab38-ac8bfa540724",
      "notes": "Sends one email with all topics. Execute Once is on, so it reads every item via $input.all()."
    }
  ],
  "connections": {
    "Every Sunday 6pm": {
      "main": [
        [
          {
            "node": "Get F5Bot Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get F5Bot Emails": {
      "main": [
        [
          {
            "node": "Feed List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Feed List": {
      "main": [
        [
          {
            "node": "Read RSS Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read RSS Feeds": {
      "main": [
        [
          {
            "node": "Filter Out Paid Ads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompt": {
      "main": [
        [
          {
            "node": "DeepSeek - Pick Topics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "DeepSeek - Pick Topics": {
      "main": [
        [
          {
            "node": "Parse Topics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Topics": {
      "main": [
        [
          {
            "node": "Email Topic Options1",
            "type": "main",
            "index": 0
          },
          {
            "node": "Add Idea to Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Out Paid Ads": {
      "main": [
        [
          {
            "node": "Get Recent Ideas1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Past Counts": {
      "main": [
        [
          {
            "node": "Count Mentions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count Mentions": {
      "main": [
        [
          {
            "node": "Build Prompt",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save Counts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Recent Ideas1": {
      "main": [
        [
          {
            "node": "Get Past Counts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "meta": {},
  "name": "SearchFront - LinkedIn Trend Radar"
}
