8
n8n 中文网amn8n.com

基于 AI 的供应商政策与 RSS 源分析及风险评分

高级

这是一个AI领域的自动化工作流,包含 29 个节点。主要使用 If, Code, Sort, Gmail, Merge 等节点,结合人工智能技术实现智能自动化。 集成风险评分的 AI 供应商政策和 RSS 源分析系统

前置要求
  • Google 账号和 Gmail API 凭证
  • 可能需要目标 API 的认证凭证
  • Google Gemini API Key
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
  "id": "DHLbKyEoYyzkhEBA",
  "meta": {
    "instanceId": "818946a823ca34034ea73f38168c80e4b7ae5021e8280b7645e14b95574ba259"
  },
  "name": "AI-Powered Vendor Policy & Feed Analysis with Integrated Risk Scoring",
  "tags": [],
  "nodes": [
    {
      "id": "477ac26f-b2f9-48a9-ba04-8acff89225ff",
      "name": "HTTP 请求",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "🌐 Requests vendor policy pages for content inspection.",
      "position": [
        -700,
        260
      ],
      "parameters": {
        "url": "={{ $json.feedUrl }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "notesInFlow": true,
      "typeVersion": 4.2,
      "alwaysOutputData": false
    },
    {
      "id": "209ac43c-361b-4cdf-b8f1-5cfadf3bace0",
      "name": "AI 代理",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "notes": "🤖 Analyzes grouped RSS feeds and categorizes risks using structured JSON.",
      "position": [
        200,
        -420
      ],
      "parameters": {
        "text": "={{ $json.entries }}",
        "options": {
          "systemMessage": "=Vendor Risk & Compliance Digest Prompt\n\nAI Agent Role: You are a Senior Compliance & Risk Intelligence Analyst embedded in an automation workflow. Your job is to analyze compliance-related content from vendor feeds and produce structured, actionable summaries for use by compliance, security, and executive teams. Dont Halucinate, Dont add any new news, only act on the provided data\n\n🧾 Input Format\n\nYou will receive a single JSON object containing an array of items. Each item is structured as:\n\n{\n  \"Title\": \"...\",\n  \"Vendor Name\": \"...\",\n  \"link\": \"https://…\",\n  \"content\": \"Snippet or article content here\"\n}\n\n🧠 Instructions\n\nDeduplicate entries by the link field.\n\nFor each unique item:\n\nAssign a Risk Rating:\n\nHigh\n\nMedium\n\nLow\n\nInformational\n\nWrite a 2-sentence summary from a compliance, risk, or security standpoint.\n\nGroup the output by the risk level in a single JSON object.\n\nOmit any empty groups (i.e., risk levels with no entries).\n\nDo not include extra prose, HTML, Markdown, or escape characters.\n\n✅ Output Format\n\nRespond with only this structure:\n\n{\n  \"High\": [\n    { \"summary\": \"...\", \"link\": \"https://...\", \"Title\": \"...\",\"Vendor Name\": \"...\" }\n  ],\n  \"Medium\": [ ... ],\n  \"Low\": [ ... ],\n  \"Informational\": [ ... ]\n}\n\nThis output will be used by downstream code to generate an HTML email digest.",
          "passthroughBinaryImages": false
        },
        "promptType": "define"
      },
      "notesInFlow": true,
      "typeVersion": 2,
      "alwaysOutputData": false
    },
    {
      "id": "371b32e3-46de-41a7-9358-34761fb1a796",
      "name": "RSS Feed List",
      "type": "n8n-nodes-base.code",
      "notes": "📰 Provide a list of RSS feeds (Security, Privacy, Compliance). Customize URLs as needed.",
      "position": [
        -1120,
        -320
      ],
      "parameters": {
        "jsCode": "const feeds = [\n  { name: 'TechCrunch',    feedUrl: 'https://techcrunch.com/feed/' },\n  { name: 'The Verge',     feedUrl: 'https://www.theverge.com/rss/index.xml' },\n  { name: 'SmashingMag',    feedUrl: 'https://www.smashingmagazine.com/feed/' },\n];\n\n// 2) Transform into n8n-compatible output\nreturn feeds.map(feed => ({\n  json: {\n    name:    feed.name,\n    feedUrl: feed.feedUrl,\n  }\n}));"
      },
      "executeOnce": true,
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "2f441c7f-6acf-49a8-bfdc-7daca7d444d1",
      "name": "Splitting the Feeds",
      "type": "n8n-nodes-base.splitOut",
      "notes": "🔀 Split RSS list so each feed can be fetched individually.",
      "position": [
        -900,
        -320
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "feedUrl"
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "745a6e0b-418a-4f1f-ace6-2b91ac5e0a8a",
      "name": "Vendor RSS Feed Read",
      "type": "n8n-nodes-base.rssFeedRead",
      "notes": "📥 Reads RSS feed content per URL.",
      "position": [
        -680,
        -320
      ],
      "parameters": {
        "url": "={{ $json.feedUrl }}",
        "options": {}
      },
      "notesInFlow": true,
      "typeVersion": 1.1
    },
    {
      "id": "ec6e38d4-dcc4-4b16-8894-d4161a244291",
      "name": "Vendor URLs",
      "type": "n8n-nodes-base.code",
      "notes": "🌐 Provide a list of direct vendor policy webpage URLs to monitor updates.",
      "position": [
        -1140,
        260
      ],
      "parameters": {
        "jsCode": "const feeds = [\n    \n  {\n  name: 'test', feedUrl:'https://www.ted.com/about/our-organization/our-policies-terms/privacy-policy'},\n  {name: 'test2', feedUrl: 'https://www.teamviewer.com/en-in/global/support/knowledge-base/teamviewer-remote/devices/policies/'},\n];\n\n// 2) Transform into n8n-compatible output\nreturn feeds.map(feed => ({\n  json: {\n    name:    feed.name,\n    feedUrl: feed.feedUrl,\n  }\n}));"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "7c705e85-bff5-4d71-9a82-5985b1f230c1",
      "name": "AI Agent1",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "notes": "🧠 Summarizes individual webpage content into short risk summaries.",
      "position": [
        560,
        260
      ],
      "parameters": {
        "text": "={{ $json.content }} & {{ $json.url }}",
        "options": {
          "systemMessage": "You are a Senior Risk and Compliance Analyst.\n\nYour job is to:\n- Read the content and generate a short 2-line summary\n- Categorize the risk as one of: High, Medium, Low, Informational\n\nReturn a clean JSON like:\n{\n  \"summary\": \"...\",\n  \"risk\": \"Medium\",\n  \"title\": \"...\",\n  \"Vendor Name\": \"....\",\n  \"url\": \"...\"\n}\n\nInput:\nTitle: {{$json[\"title\"]}}\nLink: {{$json[\"link\"]}}\nContent: {{$json[\"content\"]}}\n",
          "passthroughBinaryImages": false
        },
        "promptType": "define"
      },
      "notesInFlow": true,
      "typeVersion": 2,
      "alwaysOutputData": false
    },
    {
      "id": "225e4108-f4e1-428c-a62a-870fdf2e0eb4",
      "name": "Google Gemini 聊天模型1",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        580,
        480
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-2.0-flash-001"
      },
      "credentials": {
        "googlePalmApi": {
          "id": "3Ekb1D8LUyg2zvwJ",
          "name": "Google Gemini(PaLM) Api account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "92968717-e76c-4fd4-acad-7cae37e6ca07",
      "name": "Gmail",
      "type": "n8n-nodes-base.gmail",
      "notes": "📨 Sends summary email for webpage policy updates.",
      "position": [
        1160,
        260
      ],
      "webhookId": "d33f7afd-47c3-4616-9a37-6319d93c0e92",
      "parameters": {
        "sendTo": "Email id here",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "=Vendor Webpage Change  {{$now}}"
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "DCglvd9VagxSYbJx",
          "name": "Gmail account"
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.1
    },
    {
      "id": "d7e6e8bd-6f10-4107-b31f-c65650b40ce4",
      "name": "Gmail1",
      "type": "n8n-nodes-base.gmail",
      "notes": "📧 Sends HTML newsletter summary email for RSS feeds.",
      "position": [
        960,
        -320
      ],
      "webhookId": "66d44179-c4aa-4acd-b61e-d91a1f888d9d",
      "parameters": {
        "sendTo": "Email id here",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "=Vendor RSS Feed Monitoring as on {{ $now }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "DCglvd9VagxSYbJx",
          "name": "Gmail account"
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.1
    },
    {
      "id": "dbe4724d-1914-40f3-b79b-48927a7dd62b",
      "name": "Google Gemini 聊天模型",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        200,
        -200
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-2.0-flash-001"
      },
      "credentials": {
        "googlePalmApi": {
          "id": "3Ekb1D8LUyg2zvwJ",
          "name": "Google Gemini(PaLM) Api account"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "df8b19b8-a079-4776-9165-4272f65d1833",
      "name": "Filtering last 24hrs feed",
      "type": "n8n-nodes-base.filter",
      "notes": "⏳ Filters RSS articles published in the last 24 hours.",
      "position": [
        -460,
        -320
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "09fe1774-6a5a-4cf5-97c8-0afb7d44431e",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ new Date($json.isoDate).getTime() }}",
              "rightValue": "={{Date.now() - 24 * 60 * 60 * 1000 }}"
            },
            {
              "id": "93e97b32-40d7-4514-bc70-a7f51729b681",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ new Date($json.pubDate).getTime()}}",
              "rightValue": "={{ Date.now() - 24 * 60 * 60 * 1000 }}"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.2
    },
    {
      "id": "2a4925f3-69cd-4162-9e65-dfd69f47e8c8",
      "name": "Formatting the Content for Mail",
      "type": "n8n-nodes-base.code",
      "notes": "💌 Formats AI response as styled HTML grouped by risk category.",
      "position": [
        660,
        -320
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\nlet combined = [];\n\nfor (const item of allItems) {\n  let rawOutput = item.json.output || '';\n\n  let cleaned = rawOutput\n    .replace(/^```json\\s*/, '')\n    .replace(/```$/, '')\n    .trim()\n    .replace(/,\\s*(\\]|\\})/g, '$1');\n\n  try {\n    const parsed = JSON.parse(cleaned);\n    for (const [riskLevel, entries] of Object.entries(parsed)) {\n      if (Array.isArray(entries)) {\n        entries.forEach(entry => {\n          combined.push({\n            risk: riskLevel,\n            summary: entry.summary,\n            link: entry.link,\n            title: entry.title || '',\n            vendor: entry['Vendor Name'] || ''\n          });\n        });\n      }\n    }\n  } catch (err) {\n    throw new Error(\"Invalid JSON in input item: \" + err.message);\n  }\n}\n\nconst riskData = { High: [], Medium: [], Low: [], Informational: [] };\ncombined.forEach(entry => {\n  const risk = entry.risk || 'Informational';\n  riskData[risk]?.push(entry);\n});\n\nconst categoryStyles = {\n  High: { color: '#f28b82', emoji: '🔴' },\n  Medium: { color: '#fbbc04', emoji: '🟠' },\n  Low: { color: '#81c995', emoji: '🟢' },\n  Informational: { color: '#a7c7e7', emoji: '🔵' }\n};\n\nlet html = `\n<!DOCTYPE html>\n<html>\n<head>\n  <style>\n    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');\n    body {\n      font-family: 'Inter', sans-serif;\n      background: #fff;\n      padding: 32px;\n      margin: 0;\n      color: #333;\n    }\n    .risk-heading {\n      font-size: 22px;\n      font-weight: 700;\n      margin: 30px 0 20px;\n      padding: 16px 24px;\n      border-radius: 16px;\n      color: #fff;\n      display: inline-block;\n      box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n    }\n    .card {\n      background: #fff;\n      border-left: 6px solid;\n      border-radius: 12px;\n      padding: 24px;\n      margin: 20px 0;\n      box-shadow: 0 1px 6px rgba(0,0,0,0.06);\n      max-width: 800px;\n    }\n    .vendor {\n      font-size: 18px;\n      font-weight: 700;\n      color: #111;\n      margin-bottom: 6px;\n    }\n    .title {\n      font-size: 15px;\n      font-weight: 600;\n      color: #444;\n      margin-bottom: 14px;\n    }\n    .summary {\n      font-size: 15px;\n      line-height: 1.6;\n      margin-bottom: 12px;\n    }\n    .read-more a {\n      font-size: 14px;\n      color: #1a73e8;\n      text-decoration: none;\n      font-weight: 500;\n    }\n    .read-more a:hover {\n      text-decoration: underline;\n    }\n  </style>\n</head>\n<body>\n`;\n\nfor (const [category, entries] of Object.entries(riskData)) {\n  if (entries.length === 0) continue;\n  const { color, emoji } = categoryStyles[category];\n  html += `<div class=\"risk-heading\" style=\"background-color: ${color};\">${emoji} ${category} Risk</div>`;\n  entries.forEach(item => {\n    html += `\n      <div class=\"card\" style=\"border-left-color: ${color};\">\n        <div class=\"vendor\">${item.vendor || '—'}</div>\n        <div class=\"title\">${item.title || '—'}</div>\n        <div class=\"summary\">${item.summary}</div>\n        <div class=\"read-more\"><a href=\"${item.link}\" target=\"_blank\" rel=\"noopener noreferrer\">Read more</a></div>\n      </div>\n    `;\n  });\n}\n\nhtml += `</body></html>`;\n\nreturn [{ json: { html } }];\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "1e2a44d6-bc8b-42e8-abe9-c08e9dfde4a5",
      "name": "Sort the feeds",
      "type": "n8n-nodes-base.sort",
      "notes": "🔃 Sort the RSS entries by pubDate for consistent order.",
      "position": [
        -240,
        -320
      ],
      "parameters": {
        "options": {},
        "sortFieldsUi": {
          "sortField": [
            {
              "fieldName": "pubDate"
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "4a7bb247-bc86-4228-b4a4-9a94e5bfcfb4",
      "name": "Merge the content for AI",
      "type": "n8n-nodes-base.code",
      "notes": "🧩 Merges cleaned RSS entries into one payload for the AI agent.",
      "position": [
        -20,
        -320
      ],
      "parameters": {
        "jsCode": "// Build an array of { link, content } pairs\nconst entries = items.map(item => ({\n  link:    item.json.link,\n  content: item.json.contentSnippet || item.json.content || \"\"\n}));\n\n// Return one item with a single field “entries”\nreturn [\n  {\n    json: {\n      entries\n    }\n  }\n];\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "1a4ca2a0-1379-4140-b9a0-f0a7efd5e774",
      "name": "合并",
      "type": "n8n-nodes-base.merge",
      "notes": "🔀 Merge both page meta and body-based content analysis.",
      "position": [
        -40,
        260
      ],
      "parameters": {},
      "notesInFlow": true,
      "typeVersion": 3.2
    },
    {
      "id": "d8a76c14-7aad-47f8-9b62-8254bb1e2d01",
      "name": "Format the Summary for Email",
      "type": "n8n-nodes-base.code",
      "notes": "🧾 Formats webpage summary output into email-ready HTML.",
      "position": [
        940,
        260
      ],
      "parameters": {
        "jsCode": "const allItems = $input.all();\nlet combined = [];\n\nfor (const item of allItems) {\n  let rawOutput = item.json.output || '';\n\n  let cleaned = rawOutput\n    .replace(/^```json\\s*/, '')\n    .replace(/```$/, '')\n    .trim()\n    .replace(/,\\s*(\\]|\\})/g, '$1');\n\n  try {\n    const parsed = JSON.parse(cleaned);\n    if (Array.isArray(parsed)) {\n      combined.push(...parsed);\n    } else if (parsed && parsed.risk && parsed.summary) {\n      combined.push(parsed);\n    }\n  } catch (err) {\n    throw new Error(\"Invalid JSON in input item: \" + err.message);\n  }\n}\n\n// Group by risk\nlet riskData = {\n  High: [],\n  Medium: [],\n  Low: [],\n  Informational: []\n};\n\ncombined.forEach(entry => {\n  const risk = entry.risk || 'Informational';\n  riskData[risk]?.push(entry);\n});\n\nconst categoryStyles = {\n  High: { color: '#f28b82', emoji: '🔴' },\n  Medium: { color: '#fbbc04', emoji: '🟠' },\n  Low: { color: '#81c995', emoji: '🟢' },\n  Informational: { color: '#a7c7e7', emoji: '🔵' },\n};\n\nlet html = `\n<!DOCTYPE html>\n<html>\n<head>\n  <style>\n    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');\n    body {\n      font-family: 'Inter', sans-serif;\n      background: #fff;\n      padding: 32px;\n      margin: 0;\n      color: #333;\n    }\n    .risk-heading {\n      font-size: 22px;\n      font-weight: 700;\n      margin: 30px 0 20px;\n      padding: 16px 24px;\n      border-radius: 16px;\n      color: #fff;\n      display: inline-block;\n      box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n    }\n    .card {\n      background: #fff;\n      border-left: 6px solid;\n      border-radius: 12px;\n      padding: 24px;\n      margin: 20px 0;\n      box-shadow: 0 1px 6px rgba(0,0,0,0.06);\n      max-width: 800px;\n    }\n    .vendor {\n      font-size: 18px;\n      font-weight: 700;\n      color: #111;\n      margin-bottom: 6px;\n    }\n    .title {\n      font-size: 15px;\n      font-weight: 600;\n      color: #444;\n      margin-bottom: 14px;\n    }\n    .summary {\n      font-size: 15px;\n      line-height: 1.6;\n      margin-bottom: 12px;\n    }\n    .read-more a {\n      font-size: 14px;\n      color: #1a73e8;\n      text-decoration: none;\n      font-weight: 500;\n    }\n    .read-more a:hover {\n      text-decoration: underline;\n    }\n  </style>\n</head>\n<body>\n`;\n\nfor (const [category, entries] of Object.entries(riskData)) {\n  if (entries.length === 0) continue;\n  const { color, emoji } = categoryStyles[category];\n  html += `<div class=\"risk-heading\" style=\"background-color: ${color};\">${emoji} ${category} Risk</div>`;\n  entries.forEach(item => {\n    html += `\n      <div class=\"card\" style=\"border-left-color: ${color};\">\n        <div class=\"vendor\">${item['Vendor Name'] || 'Vendor Unknown'}</div>\n        <div class=\"title\">${item.title || 'Untitled Update'}</div>\n        <div class=\"summary\">${item.summary}</div>\n        <div class=\"read-more\"><a href=\"${item.url || item.link}\" target=\"_blank\" rel=\"noopener noreferrer\">Read more</a></div>\n      </div>\n    `;\n  });\n}\n\nhtml += `</body></html>`;\n\nreturn [{ json: { html } }];\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "eb66887a-7de8-4151-8368-9e394084bdce",
      "name": "Scrapping inside the Webpage content",
      "type": "n8n-nodes-base.code",
      "notes": "🔍 Inspects webpage HTML body to find 'update/modified' dates.",
      "position": [
        -240,
        380
      ],
      "parameters": {
        "jsCode": "const now = new Date();\nconst results = [];\n\nconst datePatterns = [\n  /\\b(\\d{4}-\\d{2}-\\d{2}(?:[ T]\\d{2}:\\d{2}:\\d{2}(?:Z|[+\\-]\\d{2}:\\d{2})?)?)\\b/g,  // ISO + timezone\n  /\\b(\\d{4}\\/\\d{2}\\/\\d{2}(?:[ T]\\d{2}:\\d{2}:\\d{2})?)\\b/g,\n  /\\b(\\d{2}\\/\\d{2}\\/\\d{4}(?: \\d{2}:\\d{2}:\\d{2})?)\\b/g,\n  /\\b(\\d{2}\\.\\d{2}\\.\\d{4})\\b/g,\n  /\\b([A-Z][a-z]{2,8} \\d{1,2},? \\d{4})\\b/g,\n  /\\b(\\d{1,2} [A-Z][a-z]{2,8} \\d{4})\\b/g,\n  /\\b([A-Z][a-z]{2},? \\d{1,2} [A-Z][a-z]{2} \\d{4})\\b/g  // e.g., Fri, 20 Jun 2025\n];\n\nconst keywordRegex = /(updated|modified|published)/i;\nconst relativeRegex = /(updated|modified|published)[^\\d]{0,30}(today|yesterday)/i;\nconst keywordDateRegex = new RegExp(\n  `(updated|modified|published)[^\\\\dA-Z]{0,30}[:\\\\-\\\\s]*?(\\\\d{4}-\\\\d{2}-\\\\d{2}(?:[ T]\\\\d{2}:\\\\d{2}:\\\\d{2})?|\\\\d{2}\\\\/\\\\d{2}\\\\/\\\\d{4}|\\\\d{2}\\\\.\\\\d{2}\\\\.\\\\d{4}|[A-Z][a-z]{2,8} \\\\d{1,2},? \\\\d{4})`,\n  'i'\n);\n\nfor (const item of items) {\n  const content = item.json.data || \"\";\n  let found = false;\n  let matchedDate = null;\n  let matchedType = null;\n  let matchedKeyword = null;\n  let note = \"\";\n  let published = null;\n\n  // --- 1. Direct keyword-date detection (e.g. Updated: 2025-06-20)\n  const directMatch = content.match(keywordDateRegex);\n  if (directMatch) {\n    matchedKeyword = directMatch[1];\n    const dateStr = directMatch[2];\n    const parsedDate = new Date(dateStr);\n\n    if (!isNaN(parsedDate)) {\n      const isRecent = (() => {\n        const diffMs = now - parsedDate;\n        if (/\\d{2}:\\d{2}:\\d{2}/.test(dateStr)) {\n          return diffMs >= 0 && diffMs <= 24 * 60 * 60 * 1000;\n        } else {\n          const test = new Date(parsedDate); test.setHours(0,0,0,0);\n          const nowTrimmed = new Date(now); nowTrimmed.setHours(0,0,0,0);\n          const diffDays = (nowTrimmed - test) / (24 * 60 * 60 * 1000);\n          return diffDays === 0 || diffDays === 1;\n        }\n      })();\n\n      if (isRecent) {\n        found = true;\n        matchedDate = dateStr;\n        matchedType = /\\d{2}:\\d{2}:\\d{2}/.test(dateStr) ? \"datetime\" : \"date-only\";\n        note = `Found '${matchedKeyword}' with ${matchedType} \"${dateStr}\" (recent).`;\n      }\n    }\n  }\n\n  // --- 2. Relative phrases like \"Updated yesterday\"\n  if (!found) {\n    const relMatch = content.match(relativeRegex);\n    if (relMatch) {\n      matchedKeyword = relMatch[1];\n      const rel = relMatch[2].toLowerCase();\n      if (rel === \"today\" || rel === \"yesterday\") {\n        const refDate = new Date(now);\n        if (rel === \"yesterday\") refDate.setDate(refDate.getDate() - 1);\n        const isoDate = refDate.toISOString().split(\"T\")[0];\n        found = true;\n        matchedDate = isoDate;\n        matchedType = \"relative\";\n        note = `Found '${matchedKeyword}' with relative date \"${rel}\".`;\n      }\n    }\n  }\n\n  // --- 3. Fallback: Any keyword in proximity of any valid date\n  if (!found && keywordRegex.test(content)) {\n    for (const pat of datePatterns) {\n      pat.lastIndex = 0;\n      let dateMatch;\n      while ((dateMatch = pat.exec(content)) !== null) {\n        const dateStr = dateMatch[1];\n        const parsedDate = new Date(dateStr);\n        if (!isNaN(parsedDate)) {\n          const isTime = /\\d{2}:\\d{2}:\\d{2}/.test(dateStr);\n          const diff = isTime ? now - parsedDate : (() => {\n            const pd = new Date(parsedDate); pd.setHours(0,0,0,0);\n            const nd = new Date(now); nd.setHours(0,0,0,0);\n            return (nd - pd);\n          })();\n          const withinLimit = isTime ? diff >= 0 && diff <= 86400000 : (diff === 0 || diff === 86400000);\n          if (withinLimit) {\n            found = true;\n            matchedKeyword = \"generic-fallback\";\n            matchedDate = dateStr;\n            matchedType = isTime ? \"datetime\" : \"date-only\";\n            note = `Fallback: keyword present and found nearby date \"${dateStr}\" (recent).`;\n            break;\n          }\n        }\n      }\n      if (found) break;\n    }\n  }\n\n  // --- Extract published (non-filtering fallback)\n  if (!published) {\n    for (const pat of datePatterns) {\n      const match = pat.exec(content);\n      if (match && Date.parse(match[1])) {\n        published = match[1];\n        break;\n      }\n    }\n  }\n\n  // --- Return only matched results\n  if (!found) continue;\n\n  results.push({\n    json: {\n      keywordDetected: true,\n      matchedKeyword,\n      matchedDate,\n      matchedType,\n      published: published || \"Not available\",\n      note,\n      data: content\n    }\n  });\n}\n\nreturn results;\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "dd0073e0-dbe8-4a46-953f-cc3759160817",
      "name": "Cleaning up the code",
      "type": "n8n-nodes-base.code",
      "notes": "🧹 Cleans and strips raw HTML into readable content for AI processing.",
      "position": [
        260,
        260
      ],
      "parameters": {
        "jsCode": "const items = [];\n\nfor (const item of $input.all()) {\n  const rawHtml = item.json.body || item.json.data || \"\";\n  let urlFromRequest = item.json.url || item.json.requestUrl || item.json.headers?.['x-final-url'] || '';\n\n  let html = rawHtml.toString();\n\n  // Try to extract canonical URL or og:url from HTML head\n  let extractedUrl = '';\n\n  const canonicalMatch = html.match(/<link[^>]+rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)[\"']/i);\n  if (canonicalMatch) {\n    extractedUrl = canonicalMatch[1];\n  } else {\n    const ogUrlMatch = html.match(/<meta[^>]+property=[\"']og:url[\"'][^>]*content=[\"']([^\"']+)[\"']/i);\n    if (ogUrlMatch) {\n      extractedUrl = ogUrlMatch[1];\n    }\n  }\n\n  const finalUrl = extractedUrl || urlFromRequest;\n\n  // Strip scripts/styles/noscript/iframe/svg/etc.\n  html = html.replace(/<script[\\s\\S]*?>[\\s\\S]*?<\\/script>/gi, '');\n  html = html.replace(/<style[\\s\\S]*?>[\\s\\S]*?<\\/style>/gi, '');\n  html = html.replace(/<noscript[\\s\\S]*?>[\\s\\S]*?<\\/noscript>/gi, '');\n  html = html.replace(/<iframe[\\s\\S]*?>[\\s\\S]*?<\\/iframe>/gi, '');\n  html = html.replace(/<svg[\\s\\S]*?>[\\s\\S]*?<\\/svg>/gi, '');\n  html = html.replace(/<!--[\\s\\S]*?-->/g, '');\n  html = html.replace(/<\\/?[^>]+(>|$)/g, '');\n\n  // Decode HTML entities\n  html = html\n    .replace(/&nbsp;/g, ' ')\n    .replace(/&amp;/g, '&')\n    .replace(/&quot;/g, '\"')\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>');\n\n  // Clean whitespace\n  let content = html.replace(/\\s{2,}/g, ' ').trim();\n\n  // Limit size (safe for AI)\n  const maxLength = 30000;\n  if (content.length > maxLength) content = content.slice(0, maxLength) + '... [truncated]';\n\n  items.push({\n    json: {\n      url: finalUrl,\n      content\n    }\n  });\n}\n\nreturn items;\n"
      },
      "notesInFlow": true,
      "typeVersion": 2
    },
    {
      "id": "c37f2b54-bdd5-4177-b70d-b48226692b7b",
      "name": "每日触发器",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "⏰ Triggers the workflow every day at 3 AM to initiate all newsletter jobs.",
      "position": [
        -1740,
        20
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 3
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 1.2
    },
    {
      "id": "7062eea3-a57a-4eb3-89f1-60209f9ed518",
      "name": "便签1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1200,
        -460
      ],
      "parameters": {
        "color": 6,
        "width": 2540,
        "height": 460,
        "content": "## ✳️  RSS Feed-Based Monitoring\n"
      },
      "typeVersion": 1
    },
    {
      "id": "03543938-705f-4ac9-9324-3316e1fa75f1",
      "name": "便签",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1200,
        160
      ],
      "parameters": {
        "color": 4,
        "width": 2560,
        "height": 460,
        "content": "## 🔎 Direct Webpage Monitoring"
      },
      "typeVersion": 1
    },
    {
      "id": "13f31425-21fd-4485-b40b-1bccbb18db6b",
      "name": "便签2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1140,
        440
      ],
      "parameters": {
        "width": 170,
        "height": 140,
        "content": "## ⬆️  Provide your vendor URLs here"
      },
      "typeVersion": 1
    },
    {
      "id": "1f0d93fa-8ba9-4389-abca-5ea4f75ecbec",
      "name": "便签3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1140,
        -160
      ],
      "parameters": {
        "width": 170,
        "height": 140,
        "content": "## ⬆️  Provide your vendor URLs here"
      },
      "typeVersion": 1
    },
    {
      "id": "e210deb2-b084-4bd9-8355-6c1b1bb85fd9",
      "name": "便签4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        980,
        -160
      ],
      "parameters": {
        "width": 150,
        "height": 80,
        "content": "## 📧 Add your email"
      },
      "typeVersion": 1
    },
    {
      "id": "3ce9e429-f52e-49e1-aaa4-d9c4e59a4469",
      "name": "便签5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1160,
        440
      ],
      "parameters": {
        "width": 150,
        "height": 80,
        "content": "## 📧 Add your email"
      },
      "typeVersion": 1
    },
    {
      "id": "e6c24c42-8c67-4d2d-93e2-e7f7a7bfce4f",
      "name": "Splitting the Urls",
      "type": "n8n-nodes-base.splitOut",
      "notes": "🔀 Split array of vendor URLs to process them individually.",
      "position": [
        -920,
        260
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "feedUrl"
      },
      "notesInFlow": true,
      "typeVersion": 1
    },
    {
      "id": "0d873aa3-baa9-4632-b489-bf439ed7d897",
      "name": "Check the headers",
      "type": "n8n-nodes-base.if",
      "notes": "🕵️‍♂️ Checks vendor page headers for signs of recent updates (last modified).",
      "position": [
        -480,
        260
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "3cc0b4e8-e015-44fc-8118-ec7ec79dc2de",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{    Object.entries($json.headers).some(([key, val]) =>     /update|modified/i.test(key) &&     !isNaN(Date.parse(val)) &&     Date.parse(val) >= Date.now() - 24*60*60*1000   ) }}",
              "rightValue": ""
            }
          ]
        }
      },
      "notesInFlow": true,
      "typeVersion": 2.2
    },
    {
      "id": "059afda6-dc5f-457f-bdec-f6acd7feb161",
      "name": "便签6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2700,
        -880
      ],
      "parameters": {
        "width": 800,
        "height": 1840,
        "content": "## 🛡️ AI-Powered Vendor Policy & RSS Feed Analysis with Integrated Risk Scoring\n\nThis workflow automates monitoring, analyzing, and scoring vendor-related updates using RSS feeds and direct web scraping. It leverages AI agents to produce actionable, risk-categorized summaries delivered via email in a clean, styled HTML format.\n\n### 🚀 Features\n- 📥 **Pulls updates** from RSS feeds and vendor policy pages  \n- 🤖 **Uses AI agents** to analyze and assign risk ratings (High, Medium, Low, Informational)  \n- 🧹 **Extracts and cleans** web content for LLM processing  \n- 🕵️ **Filters changes** within last 24 hours using headers, body text, and AI-based date detection  \n- 💌 **Sends separate HTML email digests** for RSS and webpage-based insights  \n\n### 🧱 Workflow Modules\n\n#### 📡 RSS Feed Monitoring\n- 🔗 Reads vendor feed URLs  \n- ⏳ Filters posts from the last 24 hours  \n- 🧠 Sends entries to AI agent for risk assessment and summarization  \n\n#### 🌐 Webpage Monitoring\n- 🔍 Scrapes vendor policy URLs  \n- 📅 Extracts update dates using:\n  - 🧾 *Last-Modified headers*\n  - 🧠 *HTML body scan* for keywords like “updated” or “modified”\n- ✍️ Uses AI to extract, summarize, and categorize risk content  \n\n#### 🧾 Output\n- 🗂️ Formats summaries by risk level  \n- 📧 Sends HTML digest emails to designated recipients  \n\n---\n\n### 🧪 Use Case Scenarios\n\n#### ✅ Use Case 1: RSS Feed-Based Risk Categorization\n\n**Scenario:**  \nYour organization subscribes to cybersecurity and compliance blogs like TechCrunch or Smashing Magazine. You want to identify which new updates might indicate High or Medium vendor risk.\n\n**Workflow Behavior:**\n- 🗞️ Filters feed articles from the last 24 hours  \n- 🧠 AI assigns risk level and summarizes content  \n- 📬 Email shows grouped insights (🔴 High, 🟠 Medium, etc.)\n\n**Example Output:**\n\n🔴 **High Risk**  \n**Vendor:** Vendor name  \n**Title:** “Zero-Day Vulnerability in XYZ”  \n**Summary:** A critical zero-day was reported affecting vendor systems. Immediate patching is required.  \n👉 *Read more*\n\n---\n\n#### ✅ Use Case 2: Vendor Policy Page Monitoring\n\n**Scenario:**  \nYou need to track privacy or terms-of-service pages for subtle but important changes.\n\n**Workflow Behavior:**\n- 🌐 Scrapes specified vendor URLs (e.g., Privacy Policy)  \n- 📅 Extracts “Last Modified” date or infers updates via HTML content  \n- 🤖 AI summarizes the change and assesses risk  \n\n**Example Output:**\n\n🟠 **Medium Risk**  \n**Vendor:** Vendor name  \n**Title:** “Privacy Policy Updated”  \n**Summary:** The vendor added new clauses around third-party data sharing. Legal review recommended.  \n👉 *Read more*\n"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "d815af19-e2bb-4f3a-b954-c10b47d98347",
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "Cleaning up the code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Formatting the Content for Mail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent1": {
      "main": [
        [
          {
            "node": "Format the Summary for Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vendor URLs": {
      "main": [
        [
          {
            "node": "Splitting the Urls",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Request": {
      "main": [
        [
          {
            "node": "Check the headers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Trigger": {
      "main": [
        [
          {
            "node": "Vendor URLs",
            "type": "main",
            "index": 0
          },
          {
            "node": "RSS Feed List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RSS Feed List": {
      "main": [
        [
          {
            "node": "Splitting the Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sort the feeds": {
      "main": [
        [
          {
            "node": "Merge the content for AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the headers": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Scrapping inside the Webpage content",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Splitting the Urls": {
      "main": [
        [
          {
            "node": "HTTP Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Splitting the Feeds": {
      "main": [
        [
          {
            "node": "Vendor RSS Feed Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cleaning up the code": {
      "main": [
        [
          {
            "node": "AI Agent1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vendor RSS Feed Read": {
      "main": [
        [
          {
            "node": "Filtering last 24hrs feed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Merge the content for AI": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filtering last 24hrs feed": {
      "main": [
        [
          {
            "node": "Sort the feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini Chat Model1": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent1",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Format the Summary for Email": {
      "main": [
        [
          {
            "node": "Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Formatting the Content for Mail": {
      "main": [
        [
          {
            "node": "Gmail1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrapping inside the Webpage content": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  }
}
常见问题

如何使用这个工作流?

复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。

这个工作流适合什么场景?

高级 - 人工智能

需要付费吗?

本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。

工作流信息
难度等级
高级
节点数量29
分类1
节点类型13
难度说明

适合高级用户,包含 16+ 个节点的复杂工作流

外部链接
在 n8n.io 查看

分享此工作流