Back to n8n Workflows

Monitor RSS Feeds And Simulate Impact On PipeLine

Rhys Fisher Rhys Fisher

Automatically pull Google Alert RSS feeds, extract each article, and forecast its impact on purchase intent in advance. With early visibility into demand drivers, you can trigger playbooks that amplify narratives that move buyers—or neutralize damaging ones—before they disrupt your pipeline.

Monitor RSS Feeds And Simulate Impact On PipeLine n8n workflow diagram

Click to expand

Summarize in
OR

Overview

This workflow turns news monitoring into an early-warning demand engine. It continuously ingests Google Alert RSS feeds, extracts the full text of every article, and runs real-time purchase-intent modeling to predict which stories will sway your buyers—positively or negatively. The moment a spike in intent is detected, it triggers an early warning email so you can run with the right playbooks: amplify favorable narratives to accelerate deal cycles, or counter harmful ones before they dent your pipeline. Ideal for revenue teams that want to harness media signals instead of reacting to them after the fact.

🎯 Pro Tips & Secret Sauce

The magic lies in the three-stage signal-to-action pipeline:

  • Noise-Free Feed Curation – Configure Google Alert RSS feeds with advanced operators (intitle:, site:) and negative keywords to keep irrelevant hits out of the pipeline, so the model isn’t distracted by “false-positive” news.

  • Forward-Looking Intent Scoring – Run simulated impact on buyer intent, calculate impact and detect “spike” thresholds. This helps you predict threats or opportunities the same day the content breaks.

  • Human-in-the-Loop Overrides – Send high-impact spikes to your email. Refine the simulation with iterations to the prompt (or audience) ensuring the nuance that moves your bottom line is'nt lost in the loop.
  • Opportuity For Calibration – Pipe CRM win/loss data back along with confirmed media mentions back into the model weekly. Stories that correlate with won deals get a weight boost; those with no measurable lift are down-ranked, continuously sharpening predictive power.

Apply these tips and the workflow becomes a living, self-tuning radar: it spots demand shocks early, launches the right narrative countermeasures automatically, and grows smarter with every cycle.

📝 Step-by-Step Instructions

  1. Add RSS Triggers - copy paste your RSS urls.
  2. Prep simulation Config - add your industry and product category in the prompt, audience_id, and model
  3. AskRally Auth - Setup your bearer token using your AskRally API key
  4. B2B Marketer Config - Add your product name/desc to prompt
  5. Add email - add your email to recieve alerts
  6. Test- Click play on the email play button (if it works, switch on the automation).

📋 Requirements

Required Integrations

  • Rally API - AI persona testing service for simulating real user ad selection behavior
  • Google Alert - RSS feed link for the keyword your want to ingest media for
  • Gmail - so that you can trigger emails

Required Credentials

  • Rally API Bearer token with voting mode access
  • Gmail oauth configured 

Setup Prerequisites

  • Active Rally account with configured audience personas 
  • n8n instance 
  • Google RSS link

🚀 n8n Workflow Template

{
  "active": false,
  "connections": {
    "Aggregator1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Merge5",
            "type": "main"
          }
        ]
      ]
    },
    "Code3": {
      "main": [
        [
          {
            "index": 0,
            "node": "Message a model4",
            "type": "main"
          }
        ]
      ]
    },
    "Drop failed": {
      "main": [
        [
          {
            "index": 0,
            "node": "to text1",
            "type": "main"
          }
        ]
      ]
    },
    "HTTP: Get Content": {
      "main": [
        [
          {
            "index": 0,
            "node": "Drop failed",
            "type": "main"
          }
        ]
      ]
    },
    "Merge1": {
      "main": [
        [
          {
            "index": 0,
            "node": "filter1",
            "type": "main"
          }
        ]
      ]
    },
    "Merge4": {
      "main": [
        [
          {
            "index": 0,
            "node": "analyze simulation results",
            "type": "main"
          }
        ]
      ]
    },
    "Merge5": {
      "main": [
        [
          {
            "index": 0,
            "node": "Gmail1",
            "type": "main"
          }
        ]
      ]
    },
    "Message a model4": {
      "main": [
        [
          {
            "index": 1,
            "node": "Merge5",
            "type": "main"
          }
        ]
      ]
    },
    "Processor1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Aggregator1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read": {
      "main": [
        [
          {
            "index": 6,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read1": {
      "main": [
        [
          {
            "index": 7,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read10": {
      "main": [
        [
          {
            "index": 4,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read11": {
      "main": [
        [
          {
            "index": 5,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read12": {
      "main": [
        [
          {
            "index": 0,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read2": {
      "main": [
        [
          {
            "index": 8,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read7": {
      "main": [
        [
          {
            "index": 1,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read8": {
      "main": [
        [
          {
            "index": 2,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Read9": {
      "main": [
        [
          {
            "index": 3,
            "node": "Merge1",
            "type": "main"
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "index": 0,
            "node": "RSS Read12",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read7",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read8",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read9",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read10",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read11",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read2",
            "type": "main"
          },
          {
            "index": 0,
            "node": "RSS Read1",
            "type": "main"
          }
        ]
      ]
    },
    "analyze simulation results": {
      "main": [
        [
          {
            "index": 0,
            "node": "Code3",
            "type": "main"
          },
          {
            "index": 0,
            "node": "Processor1",
            "type": "main"
          }
        ]
      ]
    },
    "call AskRally1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Merge4",
            "type": "main"
          }
        ]
      ]
    },
    "filter1": {
      "main": [
        [
          {
            "index": 0,
            "node": "HTTP: Get Content",
            "type": "main"
          }
        ]
      ]
    },
    "prep simulation1": {
      "main": [
        [
          {
            "index": 0,
            "node": "call AskRally1",
            "type": "main"
          },
          {
            "index": 1,
            "node": "Merge4",
            "type": "main"
          }
        ]
      ]
    },
    "to text1": {
      "main": [
        [
          {
            "index": 0,
            "node": "prep simulation1",
            "type": "main"
          }
        ]
      ]
    }
  },
  "id": "S3f2k03ZtAlcsEzl",
  "meta": {
    "instanceId": "7921b3cd29c1121b3ec4f2177acf06fe1f1325838297f593db7db4e9563eb98d"
  },
  "name": "narrative monitoring v2",
  "nodes": [
    {
      "id": "2a6f7531-2019-4fcc-a4db-d1e26fac80b6",
      "name": "Sticky Note",
      "parameters": {
        "color": 3,
        "content": "\u003c- Here is where you config your prompt\n",
        "height": 80,
        "width": 220
      },
      "position": [
        1300,
        1660
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "b9d75ffc-4695-44f4-ba46-5312a1104927",
      "name": "Sticky Note1",
      "parameters": {
        "color": 3,
        "content": "\u003c- add your product name/desc to prompt",
        "height": 80,
        "width": 220
      },
      "position": [
        1960,
        2020
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "a3bd6364-1f86-4925-8507-36156b1cea45",
      "name": "Sticky Note7",
      "parameters": {
        "color": 3,
        "content": "\u003c- Add your email",
        "height": 80,
        "width": 220
      },
      "position": [
        2800,
        1560
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "7e4c515e-de4b-4a18-8c21-de84fb089bcd",
      "name": "RSS Read7",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        1700
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "5dafd732-325a-41cf-bad6-0b730c65bf4e",
      "name": "RSS Read8",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        1860
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "c6e3e95e-2703-41f1-b350-0177dfb41766",
      "name": "RSS Read9",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2020
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "defd9365-768c-4cc7-81db-cd858af8842a",
      "name": "RSS Read10",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2180
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "94e1d874-815e-44bc-970c-26e9b6ca3586",
      "name": "RSS Read11",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2340
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "1f566231-12f0-449a-a8f0-131c9b703593",
      "name": "Code3",
      "parameters": {
        "jsCode": "// Flatten all simulations that arrive on the wire\nconst sims = $input.all().flatMap(item =\u003e {\n  // Each item may already wrap its data under simulation_results\n  const arr = item.json.simulation_results ?? [item.json];\n\n  // Pick just the fields we want from every simulation object\n  return arr.map(({ percentages, summary, sampleResponses }) =\u003e ({\n    percentages,\n    summary,\n    sampleResponses\n  }));\n});\n\n// Return one tidy object\nreturn [\n  {\n    json: { simulation_results: sims }\n  }\n];\n"
      },
      "position": [
        1480,
        1820
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "credentials": {
        "openAiApi": {
          "id": "5LyzIG7SUcgxGxlz",
          "name": "OpenAi account 2"
        }
      },
      "id": "45e22f12-25e4-485d-a313-ba6cbee308c7",
      "name": "Message a model4",
      "parameters": {
        "jsonOutput": true,
        "messages": {
          "values": [
            {
              "content": "You are a B2B GTM strategist.\n\nTASK  \n-----\nParse *every* simulation\u2019s `percentages` and `sampleResponses`.\n\n1. **Theme mining**  \n   \u2022 List the narrative motifs that appear in \u2265 2 \u201cA/B\u201d comments (what excites buyers).  \n   \u2022 List the motifs that appear in \u2265 2 \u201cD/E\u201d comments (what scares buyers aka objections).  \n   \u2022 For each motif give a one-line proof quote (trimmed) and a tally of how many times it shows up.\n\n2. **Counter-intuition hunt**  \n   \u2022 Compare the two lists. Look for a pattern that is *not* obvious from headline voting alone   \n   \u2022 Formulate one commercially valuable insight for ENTER YOUR COMPANY: a sentence that reveals this hidden tension and how to exploit it in messaging.\n\n3. **Craft two memes**  \n   \u2022 *meme_to_boost* \u2013 \u003c60 chars; amplifies a winning motif.  \n   \u2022 *meme_to_nuke* \u2013 \u003c60 chars; disarms a losing motif.\n\nOUTPUT  \n------\nReturn **only** a JSON object that follows exactly this schema \u0026 order\n(keep keys snake_case, no extra keys, max 280 chars total per string):\n\n```json\n{\n  \"insight\": \"string\",\n  \"meme_to_boost\": \"string\",\n  \"meme_to_nuke\": \"string\",\n  \"objections\": \"string\",\n}\n\u2022 Do not summarise anything outside the JSON.\n\n",
              "role": "=system"
            },
            {
              "content": "=\u201csimulation_results\u201d: {{ JSON.stringify(\n     $json.simulation_results.map(\n       ({ percentages, sampleResponses }) =\u003e\n         ({ percentages, sampleResponses })\n     ),\n     null,\n     2\n) }}\n\n"
            }
          ]
        },
        "modelId": {
          "__rl": true,
          "cachedResultName": "O3-MINI",
          "mode": "list",
          "value": "o3-mini"
        },
        "options": {},
        "simplify": false
      },
      "position": [
        1660,
        1940
      ],
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8
    },
    {
      "id": "df2b13c0-daaa-4686-bf76-71b1377f390a",
      "name": "RSS Read",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2520
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "46fb9762-bc29-4852-9c6a-2187ec48b1c4",
      "name": "RSS Read1",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2700
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "16d4adc3-b9d5-4260-9401-56053e1f1c60",
      "name": "RSS Read2",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        2880
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "53b01e50-a414-41d0-beee-3b61768613ae",
      "name": "Drop failed",
      "parameters": {
        "jsCode": "// 1) Grab all incoming items\nconst allItems = $input.all();\n\n// 2) Keep only the HTTP calls that succeeded (2xx) and didn\u2019t throw\nconst items = allItems.filter(item =\u003e {\n  const code = Number(item.json.statusCode ?? 200);\n  return code \u003e= 200 \u0026\u0026 code \u003c 300 \u0026\u0026 !item.json.error;\n});\n\nconsole.log(`\ud83d\udd0d Processing ${items.length} successful items`);\n\n// 3) Your existing extractCleanText() definition goes here\u2026\n\n// 4) Loop over \u201citems\u201d instead of allItems\nconst results = [];\nfor (let i = 0; i \u003c items.length; i++) {\n  const item = items[i];\n  try {\n    const html = item.binary?.data\n      ? Buffer.from(item.binary.data.data, \u0027base64\u0027).toString()\n      : item.json.body || item.json.data || item.json.html;\n    // \u2026 rest of your extraction logic \u2026\n    results.push({\n      // preserve original fields\n      ...item.json,\n      // add your extraction outputs\u2026\n      title: extracted.title,\n      text: extracted.text,\n      success: true,\n      itemIndex: i,\n    });\n  } catch (err) {\n    results.push({\n      ...item.json,\n      success: false,\n      error: err.message,\n      itemIndex: i,\n    });\n  }\n}\n\n// tell n8n \u201chere\u2019s an array of items, each with a .json payload\u201d\nreturn results.map(item =\u003e ({ json: item }));\n\n"
      },
      "position": [
        820,
        1920
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "7e06aa6d-1978-4081-ad57-77bd7f395003",
      "name": "filter1",
      "parameters": {
        "jsCode": "// Multi-item deduplication + time filter + URL extraction (FIXED VERSION)\nconst items = $input.all();\nconst hoursThreshold = 24; // 24 hours\nconst now = new Date();\n\nconsole.log(`Processing ${items.length} RSS items for deduplication, filtering, and URL extraction`);\nconsole.log(`Current time: ${now.toISOString()}`);\n\n// Helper functions\nfunction stripTags(str) {\n  return (str || \u0027\u0027).replace(/\u003c[^\u003e]+\u003e/g, \u0027\u0027).trim();\n}\n\nfunction normalizeTitle(title) {\n  return stripTags(title)\n    .replace(/\\s*\u2013.*$/, \u0027\u0027)  // Remove \" \u2013 AMI\", \" \u2013 Mi3\" suffixes\n    .replace(/\\s*-.*$/, \u0027\u0027)  // Remove \" - AMI\", \" - Mi3\" suffixes  \n    .toLowerCase()\n    .trim();\n}\n\nfunction extractCleanUrl(originalUrl) {\n  let cleanUrl = originalUrl;\n  if (originalUrl \u0026\u0026 originalUrl.includes(\u0027google.com/url\u0027)) {\n    const match = originalUrl.match(/[\u0026?]url=([^\u0026]+)/);\n    if (match) {\n      try {\n        cleanUrl = decodeURIComponent(match[1]);\n      } catch (error) {\n        cleanUrl = originalUrl;\n      }\n    }\n  }\n  return cleanUrl;\n}\n\n// Step 1: Time filtering\nconst timeFilteredItems = items.filter((item, index) =\u003e {\n  try {\n    const pubDate = new Date(item.json.pubDate || item.json.isoDate);\n    const ageInHours = (now - pubDate) / (1000 * 60 * 60);\n    \n    console.log(`Item ${index}: \"${item.json.title}\"`);\n    console.log(`  Published: ${pubDate.toISOString()}`);\n    console.log(`  Age: ${ageInHours.toFixed(1)} hours`);\n    \n    if (ageInHours \u003c= hoursThreshold) {\n      console.log(`\u2705 Item ${index} passed time filter`);\n      return true;\n    } else {\n      console.log(`\u274c Item ${index} filtered out (too old)`);\n      return false;\n    }\n  } catch (error) {\n    console.log(`\u26a0\ufe0f Error processing item ${index}:`, error.message);\n    return false;\n  }\n});\n\nconsole.log(`After time filter: ${timeFilteredItems.length} items remaining`);\n\n// Step 2: Process items with URL extraction first, then deduplicate\nconst processedItems = timeFilteredItems.map((item, index) =\u003e {\n  const originalUrl = item.json.link;\n  const cleanUrl = extractCleanUrl(originalUrl);\n  const normalizedTitle = normalizeTitle(item.json.title);\n  \n  return {\n    json: {\n      ...item.json,\n      cleanUrl: cleanUrl,\n      normalizedTitle: normalizedTitle\n    }\n  };\n});\n\n// Step 3: IMPROVED Deduplication using normalized titles\nconst seenItems = new Set();\nconst deduplicatedItems = processedItems.filter((item, index) =\u003e {\n  const uniqueKey = item.json.normalizedTitle;  // Just use normalized title for dedup\n  \n  console.log(`Checking item ${index} for duplicates:`);\n  console.log(`  Original title: \"${item.json.title}\"`);\n  console.log(`  Normalized title: \"${item.json.normalizedTitle}\"`);\n  console.log(`  Clean URL: \"${item.json.cleanUrl}\"`);\n  \n  if (seenItems.has(uniqueKey)) {\n    console.log(`\ud83d\udd04 Duplicate found: \"${item.json.normalizedTitle}\" - removing`);\n    return false;\n  } else {\n    seenItems.add(uniqueKey);\n    console.log(`\u2705 Item ${index} is unique - keeping`);\n    return true;\n  }\n});\n\nconsole.log(`Final result: ${deduplicatedItems.length} items after time filter + dedup + URL extraction`);\nconsole.log(`Items being returned:`, deduplicatedItems.map(item =\u003e item.json.normalizedTitle));\n\n// Return the processed items\nreturn deduplicatedItems;"
      },
      "position": [
        480,
        1920
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "02b8355b-a306-4c6c-9c26-3baf665e7c17",
      "name": "Merge1",
      "parameters": {
        "numberInputs": 9
      },
      "position": [
        320,
        1860
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "a5f7b6dd-66d5-4aa6-b406-9bb4834370f3",
      "name": "prep simulation1",
      "parameters": {
        "jsCode": "// Extract content and prepare Rally API payload - Using Clean Text Field\nlet rssData = {};\nlet httpData = {};\nlet toTextData = {};\n\ntry {\n  rssData = $(\u0027filter1\u0027).item.json || {};\n} catch (e) {\n  console.log(\u0027No RSS data accessible:\u0027, e.message);\n}\n\ntry {\n  httpData = $(\u0027HTTP: Get Content\u0027).item.json || {};\n} catch (e) {\n  console.log(\u0027No HTTP data accessible:\u0027, e.message);\n}\n\ntry {\n  toTextData = $(\u0027to text1\u0027).item.json || {};\n} catch (e) {\n  console.log(\u0027No \u201cto text\u201d data accessible:\u0027, e.message);\n}\n\nconst sourceText     = toTextData.text || \u0027\u0027;\nconst extractedTitle = httpData.title  || \u0027\u0027;\nconst snippet        = rssData.contentSnippet || rssData.content || rssData.summary || \u0027\u0027;\nconst articleUrl     = rssData.cleanUrl       || rssData.link    || \u0027\u0027;\n\n// Select memory source, falling back to snippet if empty or JS-blocker\nconst badJsMsg    = \u0027JavaScript is not available.\u0027;\nconst memorySource = (!sourceText || sourceText.startsWith(badJsMsg))\n  ? snippet\n  : sourceText;\n\nconst memoryContent = [\n  `You\u0027ve just read this content: ${memorySource}`\n];\n\n// Rally API payload\nconst rallyPayload = {\n  smart: false,\n  provider: \"CHOOSE YOUR LLM PROVIDER\",\n  query: \"After reading with that content, has your willingness to allocate more budget toward ENTER YOUR INDUSTRY\u2014especially ENTER YOUR PRODUCT CATEGORY\u2014changed? If so, how? A)  I\u2019m now actively looking to invest or expand our efforts, B) I\u2019m more open than before, and would consider proposals I might have dismissed, C) No real change\u2014I still hold the same interest (or lack thereof) as before, D)  I\u2019m now more skeptical or cautious about putting resources towards this, E) I\u2019ve become firmly opposed to investing in ENTER YOUR INDUSTRY or PRODUCT CATEGORY at this stage\",\n  audience_id: \"ADD YOUR AUDIENCE ID\",\n  voting_mode: true,\n  mode: \"fast\",\n  manual_memories: memoryContent,\n};\n\n// Return result\nreturn {\n  title:           extractedTitle || rssData.title || \"\",\n  content:         snippet,\n  url:             articleUrl,\n  cleanTextLength: (httpData.text || \"\").length,\n  usedCleanText:   !!(httpData.text \u0026\u0026 httpData.text.length \u003e 200),\n  rallyPayload:    rallyPayload,\n  debug: {\n    sourceTextLength:    sourceText.length,\n    memorySourceFallbacked: memorySource === snippet,\n    toTextDataFound:     Object.keys(toTextData).length \u003e 0,\n    rssDataFound:        Object.keys(rssData).length \u003e 0,\n    httpDataFound:       Object.keys(httpData).length \u003e 0\n  }\n};\n",
        "mode": "runOnceForEachItem"
      },
      "position": [
        1180,
        1580
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "credentials": {
        "httpBearerAuth": {
          "id": "wSoUK2sXm0c8MCMq",
          "name": "Bearer Auth account 2"
        }
      },
      "id": "9a3353d9-8acb-4503-9127-aa5d14b7a263",
      "name": "call AskRally1",
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "jsonBody": "={{$json.rallyPayload}}",
        "method": "POST",
        "options": {
          "batching": {
            "batch": {
              "batchInterval": 13000,
              "batchSize": 1
            }
          }
        },
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "url": "https://api.askrally.com/api/v1/chat"
      },
      "position": [
        1380,
        1480
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "920aea2c-ef34-4418-ba23-28a136bb1987",
      "name": "Sticky Note5",
      "parameters": {
        "content": "# Step 1: Listen for news\n",
        "height": 1680,
        "width": 1000
      },
      "position": [
        -40,
        1400
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "04407713-c7ed-4f04-ac82-62543e6b4f3d",
      "name": "analyze simulation results",
      "parameters": {
        "jsCode": "// Process each Rally API response individually\nconst item = $input.item.json;\n\nlet voteCounts = { A: 0, B: 0, C: 0, D: 0, E: 0 };\nlet totalVoters = 0;\nlet responses = [];\n\ntry {\n  if (item.responses \u0026\u0026 Array.isArray(item.responses)) {\n    totalVoters = item.responses.length;\n    for (const personaResponse of item.responses) {\n      try {\n        const data = JSON.parse(personaResponse.response);\n        const option = data.option;\n        if (option \u0026\u0026 voteCounts.hasOwnProperty(option)) {\n          voteCounts[option]++;\n        }\n        responses.push({\n          persona_id: personaResponse.persona_id,\n          option: option,\n          thinking: data.thinking || \u0027\u0027,\n          thoughts: personaResponse.thoughts\n        });\n      } catch (parseErr) {\n        console.log(`Error parsing persona ${personaResponse.persona_id} response:`, parseErr.message);\n      }\n    }\n  }\n} catch (err) {\n  console.log(\u0027Error processing Rally response:\u0027, err.message);\n}\n\n// Calculate percentages\nconst percentages = {};\nfor (const opt of Object.keys(voteCounts)) {\n  percentages[opt] = totalVoters \u003e 0\n    ? Math.round((voteCounts[opt] / totalVoters) * 100)\n    : 0;\n}\n\nconst proNarrative   = percentages.A + percentages.B;\nconst contraNarrative= percentages.D + percentages.E;\nconst neutral        = percentages.C;\n\n// --- NEW SAMPLING LOGIC: up to 5 from each bucket ---\nconst proGroup     = responses.filter(r =\u003e [\u0027A\u0027,\u0027B\u0027].includes(r.option));\nconst neutralGroup = responses.filter(r =\u003e r.option === \u0027C\u0027);\nconst contraGroup  = responses.filter(r =\u003e [\u0027D\u0027,\u0027E\u0027].includes(r.option));\n\nconst samplePro     = proGroup.sort(() =\u003e 0.5 - Math.random()).slice(0, 5);\nconst sampleNeutral = neutralGroup.sort(() =\u003e 0.5 - Math.random()).slice(0, 5);\nconst sampleContra  = contraGroup.sort(() =\u003e 0.5 - Math.random()).slice(0, 5);\n\nconst sampleResponses = [\n  ...samplePro,\n  ...sampleNeutral,\n  ...sampleContra\n];\n\n// Determine predicted_pipeline_impact as before\nlet predicted_pipeline_impact = \u0027\u0027;\nif (proNarrative \u003e= 75) {\n  predicted_pipeline_impact = \u0027pro-narrative\u0027;\n} else if (contraNarrative \u003e= 75) {\n  predicted_pipeline_impact = \u0027contra-narrative\u0027;\n} else {\n  predicted_pipeline_impact = \u0027mixed\u0027;\n}\n\n// Return individual result for this RSS item\nreturn {\n  // Rally simulation metadata\n  session_id: item.session_id || \u0027\u0027,\n  title:      item.title      || \u0027Interest in Synthetic Research Spending\u0027,\n  url: item.url,\n  \n  // Rally simulation results\n  totalVoters,\n  voteCounts,\n  percentages,\n  proNarrative,\n  contraNarrative,\n  neutral,\n  responses,\n  summary: item.summary || \u0027\u0027,\n  \n  // Pre-selected sample responses for email (up to 15 total)\n  sampleResponses,\n  predicted_pipeline_impact,\n  \n  // Metadata\n  simulationId: item.session_id \n    || `sim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,\n  \n  // Debug info\n  debug: {\n    foundResponses:       !!item.responses,\n    responseCount:        item.responses ? item.responses.length : 0,\n    hasSessionId:         !!item.session_id,\n    proSampleCount:       samplePro.length,\n    neutralSampleCount:   sampleNeutral.length,\n    contraSampleCount:    sampleContra.length,\n    totalSampleResponses: sampleResponses.length\n  }\n};",
        "mode": "runOnceForEachItem"
      },
      "position": [
        1820,
        1560
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "9d784c55-9565-4fb2-b80e-d098eb292569",
      "name": "RSS Read12",
      "parameters": {
        "options": {},
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/4107866991761615820"
      },
      "position": [
        40,
        1540
      ],
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2
    },
    {
      "id": "c7994a08-74fd-43f8-9d08-eed4b3a8574b",
      "name": "Schedule Trigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 6
            }
          ]
        }
      },
      "position": [
        -480,
        1900
      ],
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2
    },
    {
      "id": "00ddd4bd-8587-4d38-9d46-a03ba9ef9dfd",
      "name": "Sticky Note9",
      "parameters": {
        "color": 6,
        "content": "# Step 2: Simulate Pipeline Impact From Industry News",
        "height": 320,
        "width": 900
      },
      "position": [
        980,
        1400
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "executeOnce": false,
      "id": "1dca311b-4588-4045-bae5-32687073fdaa",
      "name": "HTTP: Get Content",
      "parameters": {
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
            },
            {
              "name": "Accept",
              "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
            },
            {
              "name": "Accept-Language",
              "value": "en-US,en;q=0.9"
            },
            {
              "name": "Accept-Encoding",
              "value": "gzip, deflate, br"
            },
            {
              "name": "Connection",
              "value": "keep-alive"
            },
            {
              "name": "Upgrade-Insecure-Requests",
              "value": "1"
            }
          ]
        },
        "options": {
          "redirect": {
            "redirect": {
              "maxRedirects": 2
            }
          },
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "sendHeaders": true,
        "url": "={{ $json.cleanUrl }}"
      },
      "position": [
        640,
        1920
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "392c6a97-3d9c-4a13-9451-c57a152bb6c4",
      "name": "to text1",
      "parameters": {
        "jsCode": "// Simple HTML Text Extractor for n8n Code Node - Process All Items\n// Removes HTML tags and extracts clean text from any webpage\n\n// Get all input items using n8n syntax\nconst items = $input.all();\n\nconsole.log(`\ud83d\udd0d Processing ${items.length} items for text extraction`);\n\n// Simple but effective text extraction function\nfunction extractCleanText(html) {\n  // Remove script and style elements completely\n  let cleanHtml = html\n    .replace(/\u003cscript\\b[^\u003c]*(?:(?!\u003c\\/script\u003e)\u003c[^\u003c]*)*\u003c\\/script\u003e/gi, \u0027\u0027)\n    .replace(/\u003cstyle\\b[^\u003c]*(?:(?!\u003c\\/style\u003e)\u003c[^\u003c]*)*\u003c\\/style\u003e/gi, \u0027\u0027)\n    .replace(/\u003cnav\\b[^\u003c]*(?:(?!\u003c\\/nav\u003e)\u003c[^\u003c]*)*\u003c\\/nav\u003e/gi, \u0027\u0027)\n    .replace(/\u003cheader\\b[^\u003c]*(?:(?!\u003c\\/header\u003e)\u003c[^\u003c]*)*\u003c\\/header\u003e/gi, \u0027\u0027)\n    .replace(/\u003cfooter\\b[^\u003c]*(?:(?!\u003c\\/footer\u003e)\u003c[^\u003c]*)*\u003c\\/footer\u003e/gi, \u0027\u0027)\n    .replace(/\u003caside\\b[^\u003c]*(?:(?!\u003c\\/aside\u003e)\u003c[^\u003c]*)*\u003c\\/aside\u003e/gi, \u0027\u0027);\n  \n  // Extract title\n  const titleMatch = cleanHtml.match(/\u003ctitle[^\u003e]*\u003e([^\u003c]+)\u003c\\/title\u003e/i);\n  const title = titleMatch ? titleMatch[1].trim() : \u0027No title found\u0027;\n  \n  // Remove all HTML tags\n  let text = cleanHtml\n    .replace(/\u003c[^\u003e]+\u003e/g, \u0027 \u0027)  // Remove all HTML tags\n    .replace(/\u0026nbsp;/gi, \u0027 \u0027)  // Replace \u0026nbsp; with space\n    .replace(/\u0026amp;/gi, \u0027\u0026\u0027)   // Replace \u0026amp; with \u0026\n    .replace(/\u0026lt;/gi, \u0027\u003c\u0027)    // Replace \u0026lt; with \u003c\n    .replace(/\u0026gt;/gi, \u0027\u003e\u0027)    // Replace \u0026gt; with \u003e\n    .replace(/\u0026quot;/gi, \u0027\"\u0027)  // Replace \u0026quot; with \"\n    .replace(/\u0026#\\d+;/g, \u0027 \u0027)   // Remove other HTML entities\n    .replace(/\\s+/g, \u0027 \u0027)      // Replace multiple spaces with single space\n    .trim();\n  \n  // Filter out common unwanted text patterns\n  const unwantedPatterns = [\n    /cookie/i, /advertisement/i, /subscribe/i, /newsletter/i,\n    /privacy policy/i, /terms of service/i, /follow us/i, /share this/i\n  ];\n  \n  // Split into sentences and filter\n  const sentences = text.split(/[.!?]+/).filter(sentence =\u003e {\n    const s = sentence.trim();\n    if (s.length \u003c 20) return false;  // Skip very short sentences\n    if (unwantedPatterns.some(pattern =\u003e pattern.test(s))) return false;  // Skip unwanted content\n    return true;\n  });\n  \n  return {\n    title: title,\n    text: sentences.join(\u0027. \u0027).trim(),\n    sentences: sentences,\n    originalLength: html.length,\n    cleanLength: text.length\n  };\n}\n\n// Process all items and return array of results\nconst results = [];\n\nfor (let i = 0; i \u003c items.length; i++) {\n  const item = items[i];\n  \n  try {\n    // Get the HTML content from current item\n    const html = item.binary?.data\n      ? Buffer.from(item.binary.data.data, \u0027base64\u0027).toString()\n      : item.json.body || item.json.data || item.json.html;\n\n    const url = item.json.url || `Item ${i + 1}`;\n    \n    console.log(`\ud83d\udcc4 Processing item ${i + 1}/${items.length}: ${url}`);\n    console.log(`   HTML length: ${html.length} characters`);\n    \n    const extracted = extractCleanText(html);\n    \n    console.log(`   \u2705 Extracted: \"${extracted.title}\"`);\n    console.log(`   \ud83d\udcdd Clean text: ${extracted.cleanLength} characters`);\n    console.log(`   \ud83d\udcc4 Found ${extracted.sentences.length} sentences`);\n    \n    results.push({\n      // Preserve original item data\n      ...item.json,\n      // Add extracted text data\n      title: extracted.title,\n      text: extracted.text,\n      sentences: extracted.sentences,\n      url: url,\n      wordCount: extracted.text.split(/\\s+/).length,\n      sentenceCount: extracted.sentences.length,\n      compressionRatio: Math.round((extracted.cleanLength / extracted.originalLength) * 100),\n      success: true,\n      itemIndex: i\n    });\n    \n  } catch (error) {\n    console.error(`\u274c Error processing item ${i + 1}:`, error.message);\n    \n    results.push({\n      // Preserve original item data\n      ...item.json,\n      // Add error data\n      title: \u0027Error\u0027,\n      text: \u0027\u0027,\n      url: item.json.url || `Item ${i + 1}`,\n      wordCount: 0,\n      success: false,\n      error: error.message,\n      itemIndex: i\n    });\n  }\n}\n\nconsole.log(`\ud83c\udf89 Completed processing ${results.length} items`);\n\nreturn results.map(item =\u003e ({ json: item }));\n"
      },
      "position": [
        1020,
        1920
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "ef06a12b-fe45-4e32-8cd3-22640f8d08e8",
      "name": "Sticky Note10",
      "parameters": {
        "color": 5,
        "content": "# Step 3: Seed Meme Idea",
        "height": 380,
        "width": 1080
      },
      "position": [
        980,
        1740
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "6a8082dd-9bf3-4f39-aaa9-751d711e7572",
      "name": "Sticky Note11",
      "parameters": {
        "color": 5,
        "content": "This step extract insights and think up meme ideas. \n\nTip: tweak the prompts\n",
        "height": 120,
        "width": 200
      },
      "position": [
        1280,
        1820
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "1fa3a0bc-34f9-41a9-b27d-1d496df98d84",
      "name": "Sticky Note16",
      "parameters": {
        "color": 4,
        "content": "## Generate Digest\n",
        "height": 320,
        "width": 980
      },
      "position": [
        1920,
        1400
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "credentials": {
        "gmailOAuth2": {
          "id": "JFRMn1ji8imM26f4",
          "name": "Gmail account"
        }
      },
      "id": "91041689-14b6-4e4b-967d-08bd10c13fda",
      "name": "Gmail1",
      "parameters": {
        "message": "=\u003cdiv style=\"font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background-color: #f8f9fa;\"\u003e\n\n  \u003c!-- Header Section --\u003e\n  \u003cdiv style=\"background-color: #c4d8bb; color: #32466c; padding: 30px; border-radius: 12px; margin-bottom: 30px; text-align: center;\"\u003e\n    \u003ch1 style=\"margin: 0 0 10px 0; font-size: 28px; font-weight: bold;\"\u003e\ud83d\udce3 AskRally Industry Daily Digest \ud83d\uddde\ufe0f\u003c/h1\u003e\n  \u003c/div\u003e\n  \n  \u003c!-- Analytics Section --\u003e\n  \u003cdiv style=\"background-color: #ffffff; border-radius: 12px; padding: 25px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);\"\u003e\n    \u003ch2 style=\"color: #333; margin-top: 0; margin-bottom: 20px; font-size: 24px; border-bottom: 3px solid #667eea; padding-bottom: 10px;\"\u003e\n      \ud83d\udcc8 Analytics\n    \u003c/h2\u003e\n    \n    \u003cdiv style=\"display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px;\"\u003e\n      \n      \u003cdiv style=\"background-color: #f2d091; color: #243B6E; padding: 24px 18px; border-radius: 14px; text-align: center; font-weight: 600; box-shadow: 0 2px 4px rgba(36,59,110,0.08);\"\u003e\n        \u003cdiv style=\"font-size: 32px; font-weight: bold; margin-bottom: 5px;\"\u003e{{ $json.analytics.simulations }}\u003c/div\u003e\n        \u003cdiv style=\"font-size: 14px; text-transform: uppercase; letter-spacing: 1px;\"\u003eMedia Simulated\u003c/div\u003e\n      \u003c/div\u003e\n      \n      \u003cdiv style=\"background: linear-gradient(135deg, #E0EEFC 0%, #B7CFE8 100%); color: #243B6E; padding: 24px 18px; border-radius: 14px; text-align: center; font-weight: 600; box-shadow: 0 2px 4px rgba(36,59,110,0.08);\"\u003e\n        \u003cdiv style=\"font-size: 32px; font-weight: bold; margin-bottom: 5px;\"\u003e{{ $json.analytics.respondents }}\u003c/div\u003e\n        \u003cdiv style=\"font-size: 14px; text-transform: uppercase; letter-spacing: 1px;\"\u003eRespondents\u003c/div\u003e\n      \u003c/div\u003e\n      \n      \u003cdiv style=\"background: linear-gradient(135deg, #E6F5F2 0%, #C4E4DF 100%); color: #243B6E; padding: 24px 18px; border-radius: 14px; text-align: center; font-weight: 600; box-shadow: 0 2px 4px rgba(36,59,110,0.08);\"\u003e\n        \u003cdiv style=\"font-size: 32px; font-weight: bold; margin-bottom: 5px;\"\u003e\n          {{ Math.round(($json.analytics.proVotes / $json.analytics.respondents) * 100) }}%\n        \u003c/div\u003e\n        \u003cdiv style=\"font-size: 14px; text-transform: uppercase; letter-spacing: 1px;\"\u003ePro Narrative Votes (A/B)\u003c/div\u003e\n        \u003cdiv style=\"font-size: 12px; opacity: 0.7; margin-top: 3px;\"\u003e{{ $json.analytics.proVotes }} votes\u003c/div\u003e\n      \u003c/div\u003e\n      \n      \u003cdiv style=\"background: linear-gradient(135deg, #F1F4F7 0%, #D8DEE7 100%); color: #243B6E; padding: 24px 18px; border-radius: 14px; text-align: center; font-weight: 600; box-shadow: 0 2px 4px rgba(36,59,110,0.08);\"\u003e\n        \u003cdiv style=\"font-size: 32px; font-weight: bold; margin-bottom: 5px;\"\u003e\n          {{ Math.round(($json.analytics.contraVotes / $json.analytics.respondents) * 100) }}%\n        \u003c/div\u003e\n        \u003cdiv style=\"font-size: 14px; text-transform: uppercase; letter-spacing: 1px;\"\u003eContra Narrative Votes (D/E)\u003c/div\u003e\n        \u003cdiv style=\"font-size: 12px; opacity: 0.7; margin-top: 3px;\"\u003e{{ $json.analytics.contraVotes }} votes\u003c/div\u003e\n      \u003c/div\u003e\n      \n    \u003c/div\u003e\n  \u003c/div\u003e\n  \u003c!-- Key Takeaway Section --\u003e\n\u003cdiv style=\"background-color: #ffffff; border-radius: 12px; padding: 25px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);\"\u003e\n  \u003ch2 style=\"color: #333; margin-top: 0; margin-bottom: 20px; font-size: 24px; border-bottom: 3px solid #667eea; padding-bottom: 10px;\"\u003e\n    \ud83d\udca1 Key Takeaway\n  \u003c/h2\u003e\n\n  \u003cp style=\"color: #495057; line-height: 1.5; margin-bottom: 15px;\"\u003e\n    {{ $json.choices[0].message.content.insight }}\n  \u003c/p\u003e\n\n  \u003cdiv style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 16px;\"\u003e\n    \u003cdiv style=\"background-color: #e8f5e9; padding: 20px; border-radius: 8px;\"\u003e\n      \u003cstrong style=\"display: block; margin-bottom: 8px;\"\u003eBoost the meme:\u003c/strong\u003e\n      {{ $json.choices[0].message.content.meme_to_boost }}\n      {{ $json.choices[0].message.content.meme_to_nuke }}\n    \u003c/div\u003e\n\n    \u003cdiv style=\"background-color: #ffebee; padding: 20px; border-radius: 8px;\"\u003e\n      \u003cstrong style=\"display: block; margin-bottom: 8px;\"\u003eObjections:\u003c/strong\u003e\n      {{ $json.choices[0].message.content.objections }}\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n  \u003c!-- Simulations Section --\u003e\n  \u003cdiv style=\"background-color: #ffffff; border-radius: 12px; padding: 25px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);\"\u003e\n    \u003ch2 style=\"color: #333; margin-top: 0; margin-bottom: 20px; font-size: 24px; border-bottom: 3px solid #667eea; padding-bottom: 10px;\"\u003e\n      \ud83d\udd2c Simulations\n    \u003c/h2\u003e\n    \n    {{\n      (() =\u003e {\n        const simulations = $json.simulations || [];\n        if (simulations.length === 0) {\n          return \u0027\u003cp style=\"color: #6c757d; font-style: italic;\"\u003eNo simulations found for today.\u003c/p\u003e\u0027;\n        }\n        \n        let html = \u0027\u0027;\n        simulations.forEach((sim, index) =\u003e {\n          // Determine result type styling\n          let resultBadgeStyle = \u0027\u0027;\n          let resultIcon = \u0027\u0027;\n          \n          if (sim.resultType === \u0027pro-narrative\u0027) {\n            resultBadgeStyle = \u0027background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%); color: white;\u0027;\n            resultIcon = \u0027\ud83d\udfe2\u0027;\n          } else if (sim.resultType === \u0027contra-narrative\u0027) {\n            resultBadgeStyle = \u0027background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%); color: white;\u0027;\n            resultIcon = \u0027\ud83d\udd34\u0027;\n          } else {\n            resultBadgeStyle = \u0027background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); color: white;\u0027;\n            resultIcon = \u0027\u26aa\u0027;\n          }\n          \n          // Create the article URL with fallback\n          const articleUrl = sim.articleUrl \u0026\u0026 sim.articleUrl !== \u0027#\u0027 ? sim.articleUrl : \u0027#\u0027;\n          \n          html += `\n          \u003cdiv style=\"border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px; background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);\"\u003e\n            \n            \u003cdiv style=\"display: flex; align-items: center; margin-bottom: 15px;\"\u003e\n              \u003cspan style=\"${resultBadgeStyle} padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; margin-right: 10px;\"\u003e\n                ${resultIcon} ${sim.resultType.toUpperCase().replace(\u0027-\u0027, \u0027 \u0027)}\n              \u003c/span\u003e\n            \u003c/div\u003e\n            \n            \u003ch3 style=\"margin: 0 0 10px 0; font-size: 18px; line-height: 1.4;\"\u003e\n              \ud83d\udcc4 \u003ca href=\"${articleUrl}?utm_source=api\u0026utm_medium=email\u0026utm_campaign=daily-digest\" style=\"color: #495057; text-decoration: none;\"\u003e${sim.title}\u003c/a\u003e\n            \u003c/h3\u003e\n            \n            \u003cdiv style=\"background-color: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 15px;\"\u003e\n              \u003cstrong style=\"color: #495057;\"\u003eResults:\u003c/strong\u003e ${sim.results}\n              \u003cbr\u003e\n              \u003csmall style=\"color: #6c757d; margin-top: 5px; display: block;\"\u003e\n                ${sim.detailedResults.breakdown} | Total Voters: ${sim.detailedResults.totalVoters}\n              \u003c/small\u003e\n            \u003c/div\u003e\n            \n            \u003cdiv style=\"border-left: 4px solid #667eea; padding-left: 15px;\"\u003e\n              \u003cstrong style=\"color: #495057;\"\u003eSummary:\u003c/strong\u003e\n              \u003cp style=\"margin: 8px 0 0 0; color: #495057; line-height: 1.5; font-style: italic;\"\u003e\n                \"${sim.summary}\"\n              \u003c/p\u003e\n            \u003c/div\u003e\n            \n          \u003c/div\u003e`;\n        });\n        \n        return html;\n      })()\n    }}\n  \u003c/div\u003e\n  \n  \u003c!-- Sampled Quotes Section --\u003e\n  \u003cdiv style=\"background-color: #ffffff; border-radius: 12px; padding: 25px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);\"\u003e\n    \u003ch2 style=\"color: #333; margin-top: 0; margin-bottom: 20px; font-size: 24px; border-bottom: 3px solid #667eea; padding-bottom: 10px;\"\u003e\n      \ud83d\udcac Sampled Quotes\n    \u003c/h2\u003e\n    \n    {{\n      (() =\u003e {\n        const sampledQuotes = $json.sampledQuotes || [];\n        if (sampledQuotes.length === 0) {\n          return \u0027\u003cp style=\"color: #6c757d; font-style: italic;\"\u003eNo quotes available for today.\u003c/p\u003e\u0027;\n        }\n        \n        let html = \u0027\u0027;\n        sampledQuotes.forEach((article, index) =\u003e {\n          // Create the article URL with fallback\n          const articleUrl = article.articleUrl \u0026\u0026 article.articleUrl !== \u0027#\u0027 ? article.articleUrl : \u0027#\u0027;\n          \n          html += `\n          \u003cdiv style=\"border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 25px; background-color: #fafafa;\"\u003e\n            \n            \u003ch3 style=\"margin: 0 0 20px 0; font-size: 16px; font-weight: bold;\"\u003e\n              \ud83d\udcc4 \u003ca href=\"${articleUrl}?utm_source=api\u0026utm_medium=email\u0026utm_campaign=daily-digest\" style=\"color: #495057; text-decoration: none;\"\u003e${article.title}\u003c/a\u003e\n            \u003c/h3\u003e\n            \n            \u003cdiv style=\"display: grid; gap: 15px;\"\u003e`;\n            \n            // Pro Narrative Quotes\n            if (article.quotes.pro \u0026\u0026 article.quotes.pro.length \u003e 0) {\n              html += `\n                \u003cdiv style=\"background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%); border-left: 4px solid #28a745; padding: 15px; border-radius: 6px;\"\u003e\n                  \u003ch4 style=\"margin: 0 0 10px 0; color: #155724; font-size: 14px; font-weight: bold;\"\u003e\ud83d\udfe2 Pro Narrative (${article.quotes.pro.length} quotes)\u003c/h4\u003e`;\n                \n                article.quotes.pro.forEach((quote, i) =\u003e {\n                  const optionLabel = {\n                    \u0027A\u0027: \u0027Much more interested\u0027,\n                    \u0027B\u0027: \u0027Somewhat more interested\u0027\n                  }[quote.option] || quote.option;\n                  \n                  html += `\n                    \u003cdiv style=\"background-color: rgba(255,255,255,0.7); padding: 10px; border-radius: 4px; margin-bottom: 8px;\"\u003e\n                      \u003csmall style=\"color: #155724; font-weight: bold;\"\u003e${quote.option} - ${optionLabel}\u003c/small\u003e\n                      \u003cp style=\"margin: 5px 0 0 0; color: #155724; font-size: 13px; line-height: 1.4;\"\u003e\n                        \"${quote.thinking}\"\n                      \u003c/p\u003e\n                    \u003c/div\u003e`;\n                });\n                html += `\u003c/div\u003e`;\n              }\n              \n              // Contra Narrative Quotes  \n              if (article.quotes.contra \u0026\u0026 article.quotes.contra.length \u003e 0) {\n                html += `\n                  \u003cdiv style=\"background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%); border-left: 4px solid #dc3545; padding: 15px; border-radius: 6px;\"\u003e\n                    \u003ch4 style=\"margin: 0 0 10px 0; color: #721c24; font-size: 14px; font-weight: bold;\"\u003e\ud83d\udd34 Contra Narrative (${article.quotes.contra.length} quotes)\u003c/h4\u003e`;\n                \n                article.quotes.contra.forEach((quote, i) =\u003e {\n                  const optionLabel = {\n                    \u0027D\u0027: \u0027Somewhat less interested\u0027,\n                    \u0027E\u0027: \u0027Much less interested\u0027\n                  }[quote.option] || quote.option;\n                  \n                  html += `\n                    \u003cdiv style=\"background-color: rgba(255,255,255,0.7); padding: 10px; border-radius: 4px; margin-bottom: 8px;\"\u003e\n                      \u003csmall style=\"color: #721c24; font-weight: bold;\"\u003e${quote.option} - ${optionLabel}\u003c/small\u003e\n                      \u003cp style=\"margin: 5px 0 0 0; color: #721c24; font-size: 13px; line-height: 1.4;\"\u003e\n                        \"${quote.thinking}\"\n                      \u003c/p\u003e\n                    \u003c/div\u003e`;\n                });\n                html += `\u003c/div\u003e`;\n              }\n              \n              // Neutral Quotes\n              if (article.quotes.neutral \u0026\u0026 article.quotes.neutral.length \u003e 0) {\n                html += `\n                  \u003cdiv style=\"background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%); border-left: 4px solid #ffc107; padding: 15px; border-radius: 6px;\"\u003e\n                    \u003ch4 style=\"margin: 0 0 10px 0; color: #856404; font-size: 14px; font-weight: bold;\"\u003e\u26aa Neutral (${article.quotes.neutral.length} quotes)\u003c/h4\u003e`;\n                \n                article.quotes.neutral.forEach((quote, i) =\u003e {\n                  html += `\n                    \u003cdiv style=\"background-color: rgba(255,255,255,0.7); padding: 10px; border-radius: 4px; margin-bottom: 8px;\"\u003e\n                      \u003csmall style=\"color: #856404; font-weight: bold;\"\u003eC - No change\u003c/small\u003e\n                      \u003cp style=\"margin: 5px 0 0 0; color: #856404; font-size: 13px; line-height: 1.4;\"\u003e\n                        \"${quote.thinking}\"\n                      \u003c/p\u003e\n                    \u003c/div\u003e`;\n                });\n                html += `\u003c/div\u003e`;\n              }\n              \n              html += `\n                \u003c/div\u003e\n              \u003c/div\u003e`;\n        });\n        \n        return html;\n      })()\n    }}\n  \u003c/div\u003e\n  \n  \u003c!-- Footer --\u003e\n  \u003cdiv style=\"background-color: #6c757d; color: white; padding: 20px; border-radius: 12px; text-align: center;\"\u003e\n    \u003cp style=\"margin: 0; font-size: 14px; opacity: 0.8;\"\u003e\n      Powered by \u003ca href=\"https://askrally.com/?utm_source=api\u0026utm_medium=email\u0026utm_campaign=daily-digest\" style=\"color: #ffffff; text-decoration: none; font-weight: bold;\"\u003eAskRally.com\u003c/a\u003e\n    \u003c/p\u003e\n  \u003c/div\u003e\n  \n  \u003c/div\u003e ",
        "options": {},
        "sendTo": "",
        "subject": "=\ud83d\udce3 AskRally Daily Digest \ud83d\uddde\ufe0f | {{ $json.date }}"
      },
      "position": [
        2660,
        1560
      ],
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "webhookId": "d47ea3ca-9545-4638-a6f5-b25374cf0760"
    },
    {
      "id": "380f60f6-4933-4ea1-b4ce-2db61004c315",
      "name": "Aggregator1",
      "parameters": {
        "jsCode": "// Daily Digest Aggregator - Run this on a daily schedule (e.g., 8 PM)\n// This aggregates all the day\u0027s rally results into a digest format\n\n// Get all items from the input (either from database query or storage node)\nconst inputItems = $input.all();\nconst today = new Date().toISOString().split(\u0027T\u0027)[0];\n\n// Debug: Log what we received\nconsole.log(\u0027Input items received:\u0027, inputItems.length);\nconsole.log(\u0027Sample item structure:\u0027, inputItems[0] ? Object.keys(inputItems[0].json || inputItems[0]) : \u0027No items\u0027);\n\nif (!inputItems || inputItems.length === 0) {\n  return {\n    skipEmail: true,\n    message: \"No results for today\",\n    date: today\n  };\n}\n\n// Process the results - handle both raw Rally data and pre-processed data\nconst todaysResults = inputItems.map(item =\u003e {\n  const data = item.json || item; // Handle n8n item format\n  \n  // If this is raw Rally data, process it first\n  if (!data.analytics \u0026\u0026 data.voteCounts) {\n    const voteCounts = data.voteCounts || { A: 0, B: 0, C: 0, D: 0, E: 0 };\n    const totalVoters = data.totalVoters || 0;\n    const responses = data.responses || [];\n    \n    // Calculate percentages\n    const percentages = {};\n    for (const opt of Object.keys(voteCounts)) {\n      percentages[opt] = totalVoters \u003e 0\n        ? Math.round((voteCounts[opt] / totalVoters) * 100)\n        : 0;\n    }\n    \n    // Calculate narrative aggregates\n    const proNarrative = percentages.A + percentages.B;\n    const contraNarrative = percentages.D + percentages.E;\n    const neutral = percentages.C;\n    \n    // Determine result type\n    let resultType;\n    if (proNarrative \u003e= 75) {\n      resultType = \u0027pro-narrative\u0027;\n    } else if (contraNarrative \u003e= 75) {\n      resultType = \u0027contra-narrative\u0027;\n    } else {\n      resultType = \u0027mixed\u0027;\n    }\n    \n    // Extract sample quotes by sentiment\n    const sampleQuotes = {\n      pro: [],\n      contra: [],\n      neutral: []\n    };\n    \n    responses.forEach(response =\u003e {\n      const quote = {\n        persona_id: response.persona_id,\n        option: response.option,\n        thinking: response.thinking,\n        sentiment: null\n      };\n      \n      if ([\u0027A\u0027, \u0027B\u0027].includes(response.option)) {\n        quote.sentiment = \u0027pro\u0027;\n        if (sampleQuotes.pro.length \u003c 5) sampleQuotes.pro.push(quote);\n      } else if ([\u0027D\u0027, \u0027E\u0027].includes(response.option)) {\n        quote.sentiment = \u0027contra\u0027;\n        if (sampleQuotes.contra.length \u003c 5) sampleQuotes.contra.push(quote);\n      } else if (response.option === \u0027C\u0027) {\n        quote.sentiment = \u0027neutral\u0027;\n        if (sampleQuotes.neutral.length \u003c 5) sampleQuotes.neutral.push(quote);\n      }\n    });\n    \n    return {\n      session_id: data.session_id,\n      title: data.title,\n      articleTitle: data.articleTitle || data.article_title || data.title,\n      articleUrl:   data.url || data.article_url || \u0027#\u0027,\n      totalVoters,\n      voteCounts,\n      percentages,\n      proNarrative,\n      contraNarrative: contraNarrative,\n      neutral,\n      resultType,\n      summary: data.summary,\n      sampleQuotes,\n      analytics: {\n        totalVoters,\n        proVotes: voteCounts.A + voteCounts.B,\n        contraVotes: voteCounts.D + voteCounts.E,\n        neutralVotes: voteCounts.C\n      }\n    };\n  }\n  \n  // If already processed, return as-is\n  return data;\n});\n\nconsole.log(\u0027Processed results:\u0027, todaysResults.length);\n\n// ANALYTICS SECTION\nlet totalSimulations = todaysResults.length;\nlet totalRespondents = 0;\nlet totalProVotes = 0;\nlet totalContraVotes = 0;\n\ntodaysResults.forEach(result =\u003e {\n  // Handle both processed and raw data formats safely\n  const analytics = result.analytics || {};\n  const totalVoters = analytics.totalVoters || result.totalVoters || 0;\n  const proVotes = analytics.proVotes || (result.voteCounts ? result.voteCounts.A + result.voteCounts.B : 0);\n  const contraVotes = analytics.contraVotes || (result.voteCounts ? result.voteCounts.D + result.voteCounts.E : 0);\n  \n  totalRespondents += totalVoters;\n  totalProVotes += proVotes;\n  totalContraVotes += contraVotes;\n});\n\nconst analytics = {\n  simulations: totalSimulations,\n  respondents: totalRespondents,\n  proVotes: totalProVotes,\n  contraVotes: totalContraVotes\n};\n\n// SIMULATIONS SECTION\nconst simulations = todaysResults.map(result =\u003e {\n  // Safely access properties with defaults\n  const articleTitle = result.articleTitle || result.title || \u0027Untitled Article\u0027;\n  const articleUrl = result.url || result.articleUrl || \u0027#\u0027;\n  const proNarrative = result.proNarrative || 0;\n  const contraNarrative = result.contraNarrative || result.contra_narrative || 0;\n  const neutral = result.neutral || 0;\n  const totalVoters = result.totalVoters || 0;\n  const percentages = result.percentages || { A: 0, B: 0, C: 0, D: 0, E: 0 };\n  const summary = result.summary || \u0027No summary available\u0027;\n  const resultType = result.resultType || result.result_type || \u0027mixed\u0027;\n  \n  return {\n    title: articleTitle,\n    articleUrl,\n    results: `${proNarrative}% Pro | ${contraNarrative}% Contra | ${neutral}% Neutral`,\n    detailedResults: {\n      totalVoters,\n      proNarrative,\n      contraNarrative,\n      neutral,\n      breakdown: `A: ${percentages.A}%, B: ${percentages.B}%, C: ${percentages.C}%, D: ${percentages.D}%, E: ${percentages.E}%`\n    },\n    summary,\n    resultType\n  };\n});\n\n// SAMPLED QUOTES SECTION\nconst sampledQuotes = todaysResults.map(result =\u003e {\n  // Get 2 from each category, fallback if not enough\n  const getQuotes = (quotes, count) =\u003e {\n    if (!quotes || !Array.isArray(quotes)) return [];\n    return quotes.slice(0, count).map(q =\u003e ({\n      option: q.option || \u0027Unknown\u0027,\n      thinking: q.thinking || \u0027No response available\u0027 // Show full quotes without truncation\n    }));\n  };\n  \n  const sampleQuotes = result.sampleQuotes || { pro: [], contra: [], neutral: [] };\n  \n  return {\n    title: result.articleTitle || result.title || \u0027Untitled Article\u0027,\n    articleUrl: result.articleUrl || \u0027#\u0027,\n    quotes: {\n      pro: getQuotes(sampleQuotes.pro, 2),\n      contra: getQuotes(sampleQuotes.contra, 2),\n      neutral: getQuotes(sampleQuotes.neutral, 2)\n    }\n  };\n});\n\n// Compile the final digest\nconst digest = {\n  date: today,\n  analytics,\n  simulations,\n  sampledQuotes,\n  \n  // Metadata for email\n  skipEmail: false,\n  subject: `Rally Research Daily Digest - ${new Date().toLocaleDateString(\u0027en-US\u0027, { \n    weekday: \u0027long\u0027, \n    year: \u0027numeric\u0027, \n    month: \u0027long\u0027, \n    day: \u0027numeric\u0027 \n  })}`,\n  \n  // Summary stats for subject line\n  summaryStats: {\n    totalSimulations,\n    totalRespondents,\n    avgProNarrative: totalSimulations \u003e 0 ? Math.round(todaysResults.reduce((sum, r) =\u003e sum + (r.proNarrative || 0), 0) / totalSimulations) : 0,\n    topResultType: todaysResults.reduce((acc, result) =\u003e {\n      const resultType = result.resultType || result.result_type || \u0027mixed\u0027;\n      acc[resultType] = (acc[resultType] || 0) + 1;\n      return acc;\n    }, {})\n  }\n};\n\nreturn digest; "
      },
      "position": [
        2180,
        1560
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "3ded54b8-bb5a-4747-b322-d892b3e3cbb3",
      "name": "Processor1",
      "parameters": {
        "jsCode": "// Daily Digest Data Processor - Replace your current trigger\n// This processes each Rally API response and stores it for daily digest\n\nconst item = $input.item.json;\nconst today = new Date().toISOString().split(\u0027T\u0027)[0]; // YYYY-MM-DD\n\n// Process the individual result\nconst voteCounts = item.voteCounts || { A: 0, B: 0, C: 0, D: 0, E: 0 };\nconst totalVoters = item.totalVoters || 0;\nconst responses = item.responses || [];\n\n// Calculate percentages\nconst percentages = {};\nfor (const opt of Object.keys(voteCounts)) {\n  percentages[opt] = totalVoters \u003e 0\n    ? Math.round((voteCounts[opt] / totalVoters) * 100)\n    : 0;\n}\n\n// Calculate narrative aggregates\nconst proNarrative = percentages.A + percentages.B;\nconst contraNarrative = percentages.D + percentages.E;\nconst neutral = percentages.C;\n\n// Determine result type for categorization\nlet resultType;\nif (proNarrative \u003e= 75) {\n  resultType = \u0027pro-narrative\u0027;\n} else if (contraNarrative \u003e= 75) {\n  resultType = \u0027contra-narrative\u0027;\n} else {\n  resultType = \u0027mixed\u0027;\n}\n\n// Extract sample quotes by sentiment\nconst sampleQuotes = {\n  pro: [],\n  contra: [],\n  neutral: []\n};\n\n// Categorize responses by voting pattern\nresponses.forEach(response =\u003e {\n  const quote = {\n    persona_id: response.persona_id,\n    option: response.option,\n    thinking: response.thinking,\n    sentiment: null\n  };\n  \n  // Categorize based on vote\n  if ([\u0027A\u0027, \u0027B\u0027].includes(response.option)) {\n    quote.sentiment = \u0027pro\u0027;\n    if (sampleQuotes.pro.length \u003c 5) sampleQuotes.pro.push(quote);\n  } else if ([\u0027D\u0027, \u0027E\u0027].includes(response.option)) {\n    quote.sentiment = \u0027contra\u0027;\n    if (sampleQuotes.contra.length \u003c 5) sampleQuotes.contra.push(quote);\n  } else if (response.option === \u0027C\u0027) {\n    quote.sentiment = \u0027neutral\u0027;\n    if (sampleQuotes.neutral.length \u003c 5) sampleQuotes.neutral.push(quote);\n  }\n});\n\n// Prepare data for storage\nconst processedResult = {\n  date: today,\n  session_id: item.session_id,\n  url: item.url,\n  title: item.title,\n  totalVoters,\n  voteCounts,\n  percentages,\n  proNarrative,\n  contraNarrative,\n  neutral,\n  resultType,\n  summary: item.summary,\n  sampleQuotes,\n  timestamp: new Date().toISOString(),\n  \n  // For analytics aggregation\n  analytics: {\n    totalVoters,\n    proVotes: voteCounts.A + voteCounts.B,\n    contraVotes: voteCounts.D + voteCounts.E,\n    neutralVotes: voteCounts.C\n  }\n};\n\nreturn processedResult; ",
        "mode": "runOnceForEachItem"
      },
      "position": [
        2020,
        1560
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "e4cb1642-9a4f-46f1-81dc-ae4f358c0891",
      "name": "Merge4",
      "parameters": {
        "combineBy": "combineByPosition",
        "mode": "combine",
        "options": {}
      },
      "position": [
        1640,
        1560
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "48e29a34-7c68-4218-abf5-4cb5d7d7ecf4",
      "name": "Merge5",
      "parameters": {
        "combineBy": "combineByPosition",
        "mode": "combine",
        "options": {}
      },
      "position": [
        2400,
        1560
      ],
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2
    },
    {
      "id": "ac844b24-425d-4bc0-a9f7-a9f964813a57",
      "name": "Sticky Note12",
      "parameters": {
        "color": 3,
        "content": "\u003c- Make sure to add your RSS links",
        "height": 80,
        "width": 220
      },
      "position": [
        200,
        1540
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    }
  ],
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [],
  "versionId": "434ea456-ad3b-4444-b2f4-3419b6376c79"
}

About the Author

Rhys Fisher

Rhys Fisher

Rhys Fisher is the COO & Co-Founder of Rally. He previously co-founded a boutique analytics agency called Unvanity, crossed the Pyrenees coast-to-coast via paraglider, and now watches virtual crowds respond to memes. Follow him on Twitter @virtual_rf