{
  "id": "bHmHHEt75JaovVaB",
  "meta": {
    "instanceId": "7921b3cd29c1121b3ec4f2177acf06fe1f1325838297f593db7db4e9563eb98d",
    "templateCredsSetupCompleted": true
  },
  "name": "narrative monitoring",
  "tags": [
    {
      "id": "G8kDAqCFw5rVCOgs",
      "name": "News Pipeline Testing",
      "createdAt": "2025-06-22T22:10:20.475Z",
      "updatedAt": "2025-06-22T22:10:20.475Z"
    }
  ],
  "nodes": [
    {
      "id": "52dc2d74-fe6a-422c-9be9-d14b68250afc",
      "name": "Code",
      "type": "n8n-nodes-base.code",
      "position": [
        600,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Extract content and prepare Rally API payload\nlet rssData = {};\nlet httpData = {};\n\ntry {\n  // Get RSS data from the dedup + URL extraction step\n  rssData = $('Dedup + url extraction').item.json || {};\n} catch (e) {\n  console.log('No RSS data accessible:', e.message);\n}\n\ntry {\n  // Get HTTP data from the content fetching step\n  httpData = $('Get Content').item.json || {};\n} catch (e) {\n  console.log('No HTTP data accessible:', e.message);\n}\n\n// Extract HTML content\nconst htmlContent = httpData.data || httpData.body || '';\n\n// Extract RSS metadata\nconst title = rssData.title || '';\nconst snippet = rssData.contentSnippet || rssData.content || rssData.summary || '';\nconst articleUrl = rssData.cleanUrl || rssData.link || '';\n\n// Extract article content from HTML\nlet extractedContent = '';\nif (htmlContent) {\n  extractedContent = htmlContent\n    .replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n    .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n    .replace(/<[^>]*>/g, ' ')\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&amp;/g, '&')\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/&quot;/g, '\"')\n    .replace(/\\s+/g, ' ')\n    .trim();\n\n  // Limit length\n  if (extractedContent.length > 3000) {\n    extractedContent = extractedContent.substring(0, 3000) + '...';\n  }\n}\n\n// Use extracted content if substantial, otherwise RSS snippet\nconst useExtracted = extractedContent.length > 200;\nconst fullContent = useExtracted ? extractedContent : snippet;\nconst memoryContent = [`You've 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    hasTitle: !!title,\n    hasUrl: !!articleUrl,\n    htmlLength: htmlContent.length,\n    rssDataFound: Object.keys(rssData).length > 0,\n    httpDataFound: Object.keys(httpData).length > 0\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "8f134637-f907-4be4-bcf0-0e497b7a711e",
      "name": "HTTP Request",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        840,
        0
      ],
      "parameters": {
        "url": "ENTER RALLY API END POINT HERE",
        "method": "POST",
        "options": {},
        "jsonBody": "={{$json.rallyPayload}}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "credential-id",
          "name": "httpBearerAuth Credential"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "eb63694e-b291-4d24-a1ee-c87a5a4304ce",
      "name": "Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1840,
        0
      ],
      "webhookId": "d47ea3ca-9545-4638-a6f5-b25374cf0760",
      "parameters": {
        "sendTo": "ENTER YOUR EMAIL HERE",
        "message": "=<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\">\n\n<!-- Header Section -->\n<div style=\"background-color: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px;\">\n  <h2 style=\"margin: 0; color: #333;\">📰 AskRally Alert</h2>\n</div>\n\n<!-- Article Information -->\n<div style=\"background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px;\">\n  <h3 style=\"color: #495057; margin-top: 0; border-bottom: 2px solid #dee2e6; padding-bottom: 10px;\">\n    📄 Article Details\n  </h3>\n  \n  <p><strong>Title:</strong><br/>\n  {{ $('Code').item.json.title }}</p>\n  \n  <p><strong>URL:</strong><br/>\n  <a href=\"{{$node[\"Code\"].json[\"url\"]}}\" style=\"color: #007bff; text-decoration: none;\">\n    {{$node[\"Code\"].json[\"url\"]}}\n  </a></p>\n  \n  <p><strong>Content Snippet:</strong><br/>\n  <em style=\"color: #6c757d;\">{{ $('Dedup + url extraction').item.json.contentSnippet }}</em></p>\n</div>\n\n<!-- Full Content Section -->\n<div style=\"background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px;\">\n  <h3 style=\"color: #495057; margin-top: 0; border-bottom: 2px solid #dee2e6; padding-bottom: 10px;\">\n    📖 Full Content\n  </h3>\n  <div style=\"background-color: #ffffff; padding: 15px; border-radius: 4px; border-left: 4px solid #007bff; font-size: 14px; line-height: 1.6;\">\n    {{$node[\"Code\"].json[\"fullContent\"]}}\n  </div>\n</div>\n\n<!-- Results Section -->\n<div style=\"background-color: #ffffff; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px;\">\n  <h3 style=\"color: #495057; margin-top: 0; border-bottom: 2px solid #dee2e6; padding-bottom: 10px;\">\n    📊 Rally Results\n  </h3>\n  \n  <div style=\"background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 15px;\">\n    <p><strong>{{ $json.message }}</strong></p>\n    <p><strong>Total Voters:</strong> {{ $json.totalVoters }}</p>\n    <p><strong>Pro-Narrative:</strong> {{ $json.proNarrative }}% | \n       <strong>Contra-Narrative:</strong> {{ $json.contraNarrative }}% | \n       <strong>Neutral:</strong> {{ $json.neutral }}%</p>\n  </div>\n\n  <!-- Sample Responses -->\n  <div style=\"background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 15px;\">\n    <h4 style=\"margin-top: 0; color: #495057;\">💬 Sample Responses</h4>\n    \n    {{\n      (() => {\n        const sampleResponses = $json.sampleResponses || [];\n        const notificationType = $json.notificationType;\n        \n        if (sampleResponses.length === 0) {\n          return '<p style=\"color: #6c757d; font-style: italic;\">No sample responses available.</p>';\n        }\n        \n        let sectionTitle = \"\";\n        let sectionColor = \"\";\n        \n        if (notificationType === 'pro-narrative') {\n          sectionTitle = \"🟢 Pro-Narrative Voices (A & B voters):\";\n          sectionColor = \"#d4edda\";\n        } else if (notificationType === 'contra-narrative') {\n          sectionTitle = \"🔴 Contra-Narrative Voices (D & E voters):\";\n          sectionColor = \"#f8d7da\";\n        } else {\n          sectionTitle = \"⚪ Mixed Responses:\";\n          sectionColor = \"#fff3cd\";\n        }\n        \n        let html = `<div style=\"background-color: ${sectionColor}; padding: 10px; border-radius: 4px; margin-bottom: 10px;\">\n          <strong>${sectionTitle}</strong>\n        </div>`;\n        \n        sampleResponses.forEach((response, index) => {\n          const optionLabel = {\n            'A': 'Much more interested',\n            'B': 'Somewhat more interested', \n            'C': 'No change',\n            'D': 'Somewhat less interested',\n            'E': 'Much less interested'\n          }[response.option] || response.option;\n          \n          html += `\n          <div style=\"border-left: 3px solid #007bff; padding: 10px; margin: 10px 0; background-color: #f8f9fa;\">\n            <p style=\"margin: 0 0 5px 0;\"><strong>Voter ${index + 1} (${response.option} - ${optionLabel}):</strong></p>\n            <p style=\"margin: 0; font-size: 14px; line-height: 1.4; color: #495057;\">\"${response.thinking}\"</p>\n          </div>`;\n        });\n        \n        return html;\n      })()\n    }}\n  </div>\n</div>\n\n<!-- Footer -->\n<div style=\"background-color: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; font-size: 12px; color: #6c757d;\">\n  <p style=\"margin: 0;\">\n    Powered by <a href=\"https://askrally.com/?utm_source=api&utm_medium=email&utm_campaign=narrative-alerts-n8n\" style=\"color: #007bff; text-decoration: none;\">AskRally.com</a>\n  </p>\n</div>\n\n</div>",
        "options": {},
        "subject": "={{ $json.message }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "credential-id",
          "name": "gmailOAuth2 Credential"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "390bb7a2-3e59-4824-af8b-be188c49284d",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -60,
        -120
      ],
      "parameters": {
        "width": 800,
        "height": 300,
        "content": "## Dedup + Extract content from RSS alert"
      },
      "typeVersion": 1
    },
    {
      "id": "3a1c9b9b-192c-499a-be4a-0edaf4b6bf47",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        760,
        -120
      ],
      "parameters": {
        "color": 5,
        "width": 720,
        "height": 300,
        "content": "## Ask Rally"
      },
      "typeVersion": 1
    },
    {
      "id": "c3d20aa4-83f8-4398-8537-9580450f8736",
      "name": "analyze simulation results",
      "type": "n8n-nodes-base.code",
      "position": [
        1160,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "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  // The Rally API response is in 'responses' array\n  if (item.responses && Array.isArray(item.responses)) {\n    totalVoters = item.responses.length;\n    \n    for (const personaResponse of item.responses) {\n      try {\n        // Parse each persona's response\n        const data = JSON.parse(personaResponse.response);\n        const option = data.option;\n        \n        if (option && voteCounts.hasOwnProperty(option)) {\n          voteCounts[option]++;\n        }\n        \n        responses.push({\n          persona_id: personaResponse.persona_id,\n          option: option,\n          thinking: data.thinking || '',\n          thoughts: personaResponse.thoughts\n        });\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('Error processing Rally response:', err.message);\n}\n\n// Calculate percentages for this individual simulation\nconst percentages = {};\nfor (const opt of Object.keys(voteCounts)) {\n  percentages[opt] = totalVoters > 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// Determine narrative type and select sample responses\nlet notificationType = '';\nlet sampleResponses = [];\n\nif (proNarrative >= 75) {\n  notificationType = 'pro-narrative';\n  // Get 5 random responses from A & B voters\n  const proResponses = responses.filter(r => ['A', 'B'].includes(r.option));\n  sampleResponses = proResponses.sort(() => 0.5 - Math.random()).slice(0, 5);\n} else if (contraNarrative >= 75) {\n  notificationType = 'contra-narrative';\n  // Get 5 random responses from D & E voters\n  const contraResponses = responses.filter(r => ['D', 'E'].includes(r.option));\n  sampleResponses = contraResponses.sort(() => 0.5 - Math.random()).slice(0, 5);\n} else {\n  notificationType = 'mixed';\n  // Get 5 random responses from all voters\n  sampleResponses = responses.sort(() => 0.5 - Math.random()).slice(0, 5);\n}\n\n// Return individual result for this RSS item\nreturn {\n  // Rally simulation metadata\n  session_id: item.session_id || '',\n  title: item.title || 'Interest in Synthetic Research Spending',\n  \n  // Rally simulation results\n  totalVoters,\n  voteCounts,\n  percentages,\n  proNarrative,\n  contraNarrative,\n  neutral,\n  responses,\n  summary: item.summary || '',\n  \n  // Pre-selected sample responses for email\n  sampleResponses,\n  notificationType,\n  \n  // Metadata\n  simulationId: item.session_id || `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    sampleResponsesCount: sampleResponses.length\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "a0620b7b-b18c-4568-aa67-6b6a180134a1",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1500,
        -120
      ],
      "parameters": {
        "color": 6,
        "width": 540,
        "height": 300,
        "content": "## Send an Alert"
      },
      "typeVersion": 1
    },
    {
      "id": "889d4d3d-3f57-44d8-82c9-87da3fcbd94e",
      "name": "alert trigger",
      "type": "n8n-nodes-base.code",
      "position": [
        1580,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Process each Rally API response individually\nconst item = $input.item.json;\n\n// The data is already processed - just extract what we need\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 > 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 notification type\nlet notificationType, message;\nif (proNarrative >= 75) {\n  notificationType = 'pro-narrative';\n  message = `🟢 Pro-Narrative Detected: ${proNarrative}% voted A+B ` +\n            `(${percentages.A}% A, ${percentages.B}% B)`;\n} else if (contraNarrative >= 75) {\n  notificationType = 'contra-narrative';\n  message = `🔴 Contra-Narrative Detected: ${contraNarrative}% voted D+E ` +\n            `(${percentages.D}% D, ${percentages.E}% E)`;\n} else {\n  notificationType = 'mixed';\n  message = `⚪ Mixed Response: Pro=${proNarrative}%, ` +\n            `Contra=${contraNarrative}%, Neutral=${neutral}%`;\n}\n\n// Select sample responses based on narrative type\nlet sampleResponses = [];\nif (responses.length > 0) {\n  let targetOptions = [];\n  \n  if (notificationType === 'pro-narrative') {\n    targetOptions = ['A', 'B'];\n  } else if (notificationType === 'contra-narrative') {\n    targetOptions = ['D', 'E'];\n  } else {\n    // For mixed, get a variety\n    targetOptions = ['A', 'B', 'C', 'D', 'E'];\n  }\n  \n  // Filter responses by target options and take up to 5\n  sampleResponses = responses\n    .filter(r => targetOptions.includes(r.option))\n    .slice(0, 5);\n}\n\n// Return the processed data\nreturn {\n  session_id: item.session_id,\n  title: item.title,\n  totalVoters,\n  voteCounts,\n  percentages,\n  proNarrative,\n  contraNarrative,\n  neutral,\n  responses,\n  summary: item.summary,\n  sampleResponses,\n  notificationType,\n  message,\n  simulationId: item.session_id,\n  shouldNotify: proNarrative >= 75 || contraNarrative >= 75,\n  debug: {\n    foundResponses: responses.length > 0,\n    responseCount: responses.length,\n    hasSessionId: !!item.session_id,\n    sampleResponsesCount: sampleResponses.length\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "8874bf82-4182-4846-ba83-77299a357323",
      "name": "RSS Read",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        0,
        0
      ],
      "parameters": {
        "url": "https://www.google.com/alerts/feeds/12254772602100657129/16808085471630190739",
        "options": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "63dcaba0-650d-42fb-b2c5-9ef9447e7f3a",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -260,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 2
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a15c2ecb-a6bf-4546-b989-4edf480227f9",
      "name": "Dedup + url extraction",
      "type": "n8n-nodes-base.code",
      "position": [
        200,
        0
      ],
      "parameters": {
        "jsCode": "// Multi-item deduplication + URL extraction\nconst items = $input.all();\nconst hoursThreshold = 3; // Adjust as needed (24 hours = 1 day)\nconst now = new Date();\n\nconsole.log(`Processing ${items.length} RSS items for deduplication and URL extraction`);\n\nconst processedItems = items.filter((item, index) => {\n  try {\n    // First: Check age for deduplication\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}\" - Age: ${ageInHours.toFixed(1)} hours`);\n    \n    if (ageInHours <= hoursThreshold) {\n      console.log(`✅ Item ${index} passed filter (within ${hoursThreshold} hours)`);\n      return true;\n    } else {\n      console.log(`❌ Item ${index} filtered out (older than ${hoursThreshold} hours)`);\n      return false;\n    }\n  } catch (error) {\n    console.log(`⚠️ Error processing item ${index}:`, error.message);\n    return false;\n  }\n}).map(item => {\n  // Second: Extract clean URL for items that passed the filter\n  const originalUrl = item.json.link;\n  let cleanUrl = originalUrl;\n  \n  // Handle Google redirect URLs (from Google Alerts)\n  if (originalUrl && originalUrl.includes('google.com/url')) {\n    const match = originalUrl.match(/[&?]url=([^&]+)/);\n    if (match) {\n      try {\n        cleanUrl = decodeURIComponent(match[1]);\n        console.log(`🔗 URL extracted: ${originalUrl} → ${cleanUrl}`);\n      } catch (error) {\n        console.log('URL decode error:', error);\n        cleanUrl = originalUrl;\n      }\n    }\n  }\n  \n  // Return item with clean URL added\n  return {\n    json: {\n      ...item.json,\n      cleanUrl: cleanUrl\n    }\n  };\n});\n\nconsole.log(`Final result: ${processedItems.length} items processed with clean URLs`);\n\nreturn processedItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "bb7083f6-b6bd-45cb-ba85-9c45457d3af2",
      "name": "Get Content",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        400,
        0
      ],
      "parameters": {
        "url": "={{ \n  (() => {\n    const url = $json.link;\n    if (url.includes('google.com/url')) {\n      const match = url.match(/[&?]url=([^&]+)/);\n      return match ? decodeURIComponent(match[1]) : url;\n    }\n    return url;\n  })() \n}}",
        "options": {}
      },
      "typeVersion": 4.2
    }
  ],
  "active": true,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "78058eb2-dfaa-44bd-8a20-0906b547d699",
  "connections": {
    "Code": {
      "main": [
        [
          {
            "node": "HTTP Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Read": {
      "main": [
        [
          {
            "node": "Dedup + url extraction",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Content": {
      "main": [
        [
          {
            "node": "Code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Request": {
      "main": [
        [
          {
            "node": "analyze simulation results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "alert trigger": {
      "main": [
        [
          {
            "node": "Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "RSS Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dedup + url extraction": {
      "main": [
        [
          {
            "node": "Get Content",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "analyze simulation results": {
      "main": [
        [
          {
            "node": "alert trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}