Back to n8n Workflows

Narrative Threat / Opportunity Detection

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.

Narrative Threat / Opportunity Detection n8n workflow diagram

Click to expand

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.
  • Auto-Triggered Playbooks – Map score thresholds to specific GTM actions—e.g., +75 kicks off a paid-social boost around the positive narrative, -75 schedules a thought-leadership sales battle card post, -42 routes extracts key keywords and spins up search volume report and deployes monitering.

  • 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. RSS Triggers - RSS trigger checks for news every [enter time] 
  2. Extract content- using the RSS link, run a HTTP request.
  3. Structure Output - Parse out article content and format simulation query 
  4. Rally Simulation Testing - AI personas get content as memory, and are asked (in voting mode) to answer how it impacts interest in spending money on [synthetic research] (swap for your category)
  5. Extract Individual Votes - Splits Rally's response array to process each persona's individual voting decision for detailed analysis
  6. Calculate Responses - Custom code processes all votes, counts selections for each variation, calculates percentages
  7. Alert trigger- Depending on count thresholds, triggers emails.

📋 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": true,
  "connections": {
    "Code": {
      "main": [
        [
          {
            "index": 0,
            "node": "HTTP Request",
            "type": "main"
          }
        ]
      ]
    },
    "HTTP Request": {
      "main": [
        [
          {
            "index": 0,
            "node": "Split Out",
            "type": "main"
          }
        ]
      ]
    },
    "HTTP Request1": {
      "main": [
        [
          {
            "index": 0,
            "node": "Code",
            "type": "main"
          }
        ]
      ]
    },
    "RSS Feed Trigger": {
      "main": [
        [
          {
            "index": 0,
            "node": "HTTP Request1",
            "type": "main"
          }
        ]
      ]
    },
    "Split Out": {
      "main": [
        [
          {
            "index": 0,
            "node": "analyze simulation results",
            "type": "main"
          }
        ]
      ]
    },
    "alert trigger": {
      "main": [
        [
          {
            "index": 0,
            "node": "Gmail",
            "type": "main"
          }
        ]
      ]
    },
    "analyze simulation results": {
      "main": [
        [
          {
            "index": 0,
            "node": "alert trigger",
            "type": "main"
          }
        ]
      ]
    }
  },
  "id": "pzY3a5Vjwy6NgbOj",
  "meta": {
    "instanceId": "e7edbb8f3aa3d5d1631a21cb8cab0e2bb8ae88e9936fc176642c186bd51b82c7",
    "templateCredsSetupCompleted": true
  },
  "name": "Detect Pro/Contra Narratives",
  "nodes": [
    {
      "id": "82efdbe1-d638-49b3-8e03-46017cbaccbd",
      "name": "RSS Feed Trigger",
      "parameters": {
        "feedUrl": "https://www.google.com/alerts/feeds/12254772602100657129/16808085471630190739",
        "pollTimes": {
          "item": [
            {
              "mode": "everyHour"
            }
          ]
        }
      },
      "position": [
        0,
        0
      ],
      "type": "n8n-nodes-base.rssFeedReadTrigger",
      "typeVersion": 1
    },
    {
      "id": "f95c364d-733a-4512-a754-933478d29339",
      "name": "Code",
      "parameters": {
        "jsCode": "// Try to access RSS data directly from the RSS step\nlet rssData = {};\nlet httpData = {};\n\ntry {\n  // Replace \u0027RSS Read\u0027 with the actual name of your RSS node\n  rssData = $(\u0027RSS Feed Trigger\u0027).item.json || {};\n  console.log(\u0027RSS data found:\u0027, JSON.stringify(rssData, null, 2));\n} catch (e) {\n  console.log(\u0027Could not access RSS step directly:\u0027, e.message);\n  try {\n    // Try alternative names for RSS node\n    rssData = $(\u0027RSS\u0027).item.json || {};\n  } catch (e2) {\n    console.log(\u0027No RSS data accessible\u0027);\n  }\n}\n\ntry {\n  httpData = $(\u0027HTTP Request1\u0027).item.json || {};\n} catch (e) {\n  console.log(\u0027No HTTP data accessible\u0027);\n}\n\n// Extract HTML content\nconst htmlContent = httpData.data || \u0027\u0027;\n\n// Extract RSS metadata\nconst title = rssData.title || \u0027\u0027;\nconst snippet = rssData.contentSnippet || rssData.content || rssData.summary || \u0027\u0027;\nconst articleUrl = rssData.link || \u0027\u0027;\n\n// Extract article content from HTML (simplified version)\nlet extractedContent = \u0027\u0027;\nif (htmlContent) {\n  // Remove scripts, styles, and get text content\n  extractedContent = htmlContent\n    .replace(/\u003cscript[^\u003e]*\u003e[\\s\\S]*?\u003c\\/script\u003e/gi, \u0027\u0027)\n    .replace(/\u003cstyle[^\u003e]*\u003e[\\s\\S]*?\u003c\\/style\u003e/gi, \u0027\u0027)\n    .replace(/\u003c[^\u003e]*\u003e/g, \u0027 \u0027)\n    .replace(/\u0026nbsp;/g, \u0027 \u0027)\n    .replace(/\u0026amp;/g, \u0027\u0026\u0027)\n    .replace(/\u0026lt;/g, \u0027\u003c\u0027)\n    .replace(/\u0026gt;/g, \u0027\u003e\u0027)\n    .replace(/\u0026quot;/g, \u0027\"\u0027)\n    .replace(/\\s+/g, \u0027 \u0027)\n    .trim();\n\n  // Limit length\n  if (extractedContent.length \u003e 3000) {\n    extractedContent = extractedContent.substring(0, 3000) + \u0027...\u0027;\n  }\n}\n\n// Use extracted content if substantial, otherwise RSS snippet\nconst useExtracted = extractedContent.length \u003e 200;\nconst fullContent = useExtracted ? extractedContent : snippet;\nconst memoryContent = [`You\u0027ve just read this content: ${fullContent}`];\n\n// Rally API payload\nconst rallyPayload = {\n  smart: false,\n  provider: \"openai\",\n  query: \"After reading that content, how has your interest in spending money on synthetic research changed? A) Much more interested, B) Somewhat more interested, C) No change, D) Somewhat less interested, E) Much less interested\",\n  audience_id: \"r8eb276513d8241\",\n  \"voting_mode\": true,\n  mode: \"fast\",\n  manual_memories: memoryContent,\n};\n\nreturn {\n  title: title,\n  content: snippet,\n  url: articleUrl,\n  fullContent: fullContent,\n  rallyPayload: rallyPayload,\n  extractedLength: extractedContent.length,\n  usedExtracted: useExtracted,\n  debug: {\n    rssDataAvailable: Object.keys(rssData).length \u003e 0,\n    rssKeys: Object.keys(rssData),\n    hasTitle: !!title,\n    hasUrl: !!articleUrl,\n    htmlLength: htmlContent.length\n  }\n};"
      },
      "position": [
        480,
        0
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "credentials": {
        "httpBearerAuth": {
          "id": "WaZtZVWIgLjZZwTa",
          "name": "Bearer Auth account"
        }
      },
      "id": "6f9a4aca-411d-414d-bee2-fcbf1186a537",
      "name": "HTTP Request",
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "jsonBody": "={{$json.rallyPayload}}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "url": "REPLACE WITH RALLY API END POINT"
      },
      "position": [
        740,
        0
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "credentials": {
        "gmailOAuth2": {
          "id": "whOIhaWRN1PVV0wR",
          "name": "Gmail account"
        }
      },
      "id": "aca248fd-5d4d-4fc8-abec-cf0f94bc8fbc",
      "name": "Gmail",
      "parameters": {
        "message": "=Title:   {{$node[\"RSS Feed Trigger\"].json[\"title\"]}}\nURL:     {{$node[\"Code\"].json[\"url\"]}}\nContent: {{$node[\"Code\"].json[\"fullContent\"]}}\n",
        "options": {},
        "sendTo": "REPLACE WITH YOUR EMAIL",
        "subject": "={{ $json.analysis.message }}"
      },
      "position": [
        1740,
        0
      ],
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "webhookId": "d47ea3ca-9545-4638-a6f5-b25374cf0760"
    },
    {
      "id": "74919e81-560e-427b-98df-572bc8695cde",
      "name": "HTTP Request1",
      "parameters": {
        "options": {},
        "url": "={{ \n  (() =\u003e {\n    const url = $json.link;\n    if (url.includes(\u0027google.com/url\u0027)) {\n      const match = url.match(/[\u0026?]url=([^\u0026]+)/);\n      return match ? decodeURIComponent(match[1]) : url;\n    }\n    return url;\n  })() \n}}"
      },
      "position": [
        220,
        0
      ],
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2
    },
    {
      "id": "90c8aaf9-f88c-4854-baca-d4508dfae18b",
      "name": "Sticky Note",
      "parameters": {
        "content": "## Extract content from RSS alert",
        "height": 300,
        "width": 480
      },
      "position": [
        160,
        -120
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "1e4062d4-5d02-4c06-b071-c9098e7e5d09",
      "name": "Sticky Note4",
      "parameters": {
        "color": 5,
        "content": "## Ask Rally",
        "height": 300,
        "width": 720
      },
      "position": [
        660,
        -120
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "2784a6dd-dbcc-44f3-866b-ddcaafd17c69",
      "name": "Split Out",
      "parameters": {
        "fieldToSplitOut": "responses",
        "options": {}
      },
      "position": [
        960,
        0
      ],
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1
    },
    {
      "id": "0655dd89-71da-406a-9d23-321924f32fdf",
      "name": "analyze simulation results",
      "parameters": {
        "jsCode": "// Steps 1\u20133: Gather all incoming items, init counters \u0026 tally votes\nconst items       = $input.all();\nconst totalVoters = items.length;\n\nconst voteCounts = {\n  A: 0,\n  B: 0,\n  C: 0,\n  D: 0,\n  E: 0,\n};\n\nfor (const item of items) {\n  try {\n    // each item.json.response is a JSON string like \u0027{\"option\":\"B\",...}\u0027\n    const data   = JSON.parse(item.json.response);\n    const option = data.option;\n    if (voteCounts.hasOwnProperty(option)) {\n      voteCounts[option]++;\n    }\n  } catch (err) {\n    console.log(`Error parsing response for persona ${item.json.persona_id}:`, err.message);\n  }\n}\n\n// Steps 4\u20135: Compute percentages \u0026 narrative aggregates\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// Return a single summary object wrapped in an array\nreturn [\n  {\n    json: {\n      totalVoters,\n      voteCounts,\n      percentages,\n      proNarrative,\n      contraNarrative,\n      neutral,\n    }\n  }\n];\n"
      },
      "position": [
        1180,
        0
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    },
    {
      "id": "f89ff0ef-b7ed-4f6a-ba5a-c9fcd7189c1a",
      "name": "Sticky Note1",
      "parameters": {
        "color": 6,
        "content": "## Send an Alert",
        "height": 300,
        "width": 540
      },
      "position": [
        1400,
        -120
      ],
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1
    },
    {
      "id": "44558231-38bd-4f69-aa79-dba2e7024a1d",
      "name": "alert trigger",
      "parameters": {
        "jsCode": "// Read the pre-computed summary from the previous node\nconst {\n  totalVoters,\n  voteCounts,\n  percentages,\n  proNarrative,\n  contraNarrative,\n  neutral\n} = $input.item.json;\n\n// Decide notification type \u0026 message\nlet notificationType, message;\n\nif (proNarrative \u003e= 75) {\n  notificationType = \u0027pro-narrative\u0027;\n  message = `\ud83d\udfe2 Pro-Narrative Detected: ${proNarrative}% voted A+B ` +\n            `(${percentages.A}% A, ${percentages.B}% B)`;\n} else if (contraNarrative \u003e= 75) {\n  notificationType = \u0027contra-narrative\u0027;\n  message = `\ud83d\udd34 Contra-Narrative Detected: ${contraNarrative}% voted D+E ` +\n            `(${percentages.D}% D, ${percentages.E}% E)`;\n} else {\n  notificationType = \u0027mixed\u0027;\n  message = `\u26aa Mixed Response: Pro=${proNarrative}%, ` +\n            `Contra=${contraNarrative}%, Neutral=${neutral}%`;\n}\n\n// Return only the analysis\nreturn [\n  {\n    json: {\n      analysis: {\n        totalVoters,\n        voteCounts,\n        percentages,\n        proNarrative,\n        contraNarrative,\n        neutral,\n        notificationType,\n        message,\n        shouldNotify: proNarrative \u003e= 75 || contraNarrative \u003e= 75\n      }\n    }\n  }\n];\n"
      },
      "position": [
        1480,
        0
      ],
      "type": "n8n-nodes-base.code",
      "typeVersion": 2
    }
  ],
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [
    {
      "createdAt": "2025-06-22T20:47:30.466Z",
      "id": "5AmSz2lDXwZFmZ6B",
      "name": "News Pipeline Testing",
      "updatedAt": "2025-06-22T20:47:30.466Z"
    }
  ],
  "versionId": "928fb10e-ff38-45b3-b0ef-2f9c40c00b17"
}

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