Files
daily-news-digest/n8n/workflows/02-fetch-news-to-web.json
T

109 lines
8.4 KiB
JSON

{
"id": "DNDfetchRss0001",
"name": "Daily News Digest - Fetch RSS to Web",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "手动执行",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [240, 260]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 30 7 * * *"
}
]
}
},
"id": "schedule-trigger",
"name": "每天 07:30",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 420]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id AS source_id, name AS source_name, feed_url, region, category, priority\nFROM news_sources\nWHERE enabled = TRUE\nORDER BY priority DESC, id;",
"options": {}
},
"id": "load-sources",
"name": "加载启用资讯源",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [500, 340]
},
{
"parameters": {
"url": "={{ $json.feed_url }}",
"options": {}
},
"id": "read-rss",
"name": "读取 RSS",
"type": "n8n-nodes-base.rssFeedRead",
"typeVersion": 1.2,
"position": [760, 340],
"continueOnFail": true
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const feed = $json;\nconst source = $('加载启用资讯源').item.json;\n\nconst skipped = () => ({\n json: {\n skip: true,\n source_id: source.source_id,\n title: '',\n summary: '',\n url: '',\n author: '',\n language_code: 'en',\n region: source.region || 'international',\n category: source.category || 'general',\n topics: [],\n keywords: [],\n importance_score: Number(source.priority || 50),\n published_at: null,\n raw_payload: feed\n }\n});\n\nif (feed.error) return skipped();\n\nconst stripHtml = (value) => String(value || '')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<[^>]+>/g, ' ')\n .replace(/&nbsp;/gi, ' ')\n .replace(/&amp;/gi, '&')\n .replace(/&lt;/gi, '<')\n .replace(/&gt;/gi, '>')\n .replace(/&quot;/gi, String.fromCharCode(34))\n .replace(/&#39;/gi, String.fromCharCode(39))\n .replace(/\\s+/g, ' ')\n .trim();\n\nconst title = stripHtml(feed.title);\nconst summary = stripHtml(feed.contentSnippet || feed.content || feed.description).slice(0, 1800);\nconst url = String(feed.link || feed.guid || '').trim();\nif (!title || !url || !/^https?:\\/\\//i.test(url)) return skipped();\n\nconst publishedRaw = feed.isoDate || feed.pubDate || feed.published || feed.updated || null;\nconst publishedDate = publishedRaw ? new Date(publishedRaw) : null;\nconst publishedAt = publishedDate && !Number.isNaN(publishedDate.getTime())\n ? publishedDate.toISOString()\n : null;\nconst maxAgeMs = 14 * 24 * 60 * 60 * 1000;\nif (publishedDate && Date.now() - publishedDate.getTime() > maxAgeMs) return skipped();\n\nconst text = `${title} ${summary}`.toLowerCase();\nconst keywordGroups = {\n ai_agent: [\n 'ai', 'artificial intelligence', 'machine learning', 'deep learning',\n 'large language model', 'llm', 'agent', 'agentic', 'generative ai',\n '人工智能', '大模型', '智能体', '机器学习', '深度学习', '生成式', '具身智能'\n ],\n agri_hardware: [\n 'agricultural machinery', 'farm machinery', 'sensor', 'drone', 'robot',\n 'tractor', 'harvester', 'iot', 'satellite', 'precision equipment',\n '传感器', '无人机', '机器人', '农机', '拖拉机', '收割机', '物联网', '卫星', '智能装备'\n ],\n agri_solutions: [\n 'smart farm', 'smart farming', 'precision agriculture', 'digital agriculture',\n 'greenhouse', 'irrigation', 'traceability', 'farm management',\n '智慧农业', '智慧农场', '精准农业', '数字农业', '温室', '灌溉', '水肥一体化', '追溯',\n '解决方案', '项目落地', '示范项目'\n ],\n agri_services: [\n 'agricultural service', 'farm service', 'agronomy', 'extension service',\n 'farm saas', 'crop insurance', 'agricultural finance', 'supply chain',\n '农业服务', '农技服务', '社会化服务', '托管服务', '农业 saas', '农业保险',\n '农业金融', '供应链', '运维服务'\n ],\n policy_market: [\n 'agricultural policy', 'farm policy', 'subsidy', 'regulation', 'investment',\n 'funding', 'tender', 'procurement', 'market outlook',\n '农业政策', '补贴', '监管', '投资', '融资', '招标', '采购', '市场行情'\n ]\n};\n\nconst escapeRegex = (value) => value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\nconst containsKeyword = (keyword) => {\n if (/^[a-z0-9+#.-]+$/i.test(keyword) && keyword.length <= 3) {\n return new RegExp(`\\\\b${escapeRegex(keyword)}\\\\b`, 'i').test(text);\n }\n return text.includes(keyword.toLowerCase());\n};\n\nconst matchedByCategory = {};\nfor (const [category, words] of Object.entries(keywordGroups)) {\n matchedByCategory[category] = words.filter(containsKeyword);\n}\n\nconst sourceCategory = source.category || 'general';\nconst scores = Object.fromEntries(\n Object.entries(matchedByCategory).map(([category, words]) => [\n category,\n words.length + (sourceCategory === category ? 2 : 0)\n ])\n);\nlet category = sourceCategory;\nconst best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];\nif (best && best[1] > 0) category = best[0];\n\nconst matchedKeywords = [...new Set(Object.values(matchedByCategory).flat())];\nconst dedicatedSource = sourceCategory !== 'general';\nif (!dedicatedSource && matchedKeywords.length === 0) return skipped();\n\nconst matchedTopics = Object.entries(matchedByCategory)\n .filter(([, words]) => words.length > 0)\n .map(([topic]) => topic);\nconst topics = [...new Set([category, source.region, ...matchedTopics])];\nconst languageCode = /[\\u3400-\\u9fff]/.test(`${title}${summary}`) ? 'zh' : 'en';\nconst importanceScore = Math.max(0, Math.min(100,\n Number(source.priority || 50) + Math.min(12, matchedKeywords.length * 2)\n));\n\nreturn {\n json: {\n skip: false,\n source_id: source.source_id,\n source_name: source.source_name,\n title,\n summary,\n url,\n author: stripHtml(feed.creator || feed.author || ''),\n language_code: languageCode,\n region: source.region,\n category,\n topics,\n keywords: matchedKeywords,\n importance_score: importanceScore,\n published_at: publishedAt,\n raw_payload: feed\n }\n};"
},
"id": "normalize-classify",
"name": "清洗与规则分类",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1020, 340]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT *\nFROM public.ingest_news_article(\n $1::bigint, $2::text, $3::text, $4::text, $5::text,\n $6::varchar, $7::varchar, $8::varchar, $9::jsonb, $10::jsonb,\n $11::integer, $12::timestamptz, $13::jsonb, $14::boolean\n);",
"options": {
"queryReplacement": "={{ [\n $json.source_id,\n $json.title,\n $json.summary,\n $json.url,\n $json.author,\n $json.language_code,\n $json.region,\n $json.category,\n JSON.stringify($json.topics || []),\n JSON.stringify($json.keywords || []),\n $json.importance_score,\n $json.published_at,\n JSON.stringify($json.raw_payload || {}),\n $json.skip === true\n] }}"
}
},
"id": "save-article",
"name": "保存文章并发布日报",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [1280, 340]
}
],
"connections": {
"手动执行": {
"main": [[{"node": "加载启用资讯源", "type": "main", "index": 0}]]
},
"每天 07:30": {
"main": [[{"node": "加载启用资讯源", "type": "main", "index": 0}]]
},
"加载启用资讯源": {
"main": [[{"node": "读取 RSS", "type": "main", "index": 0}]]
},
"读取 RSS": {
"main": [[{"node": "清洗与规则分类", "type": "main", "index": 0}]]
},
"清洗与规则分类": {
"main": [[{"node": "保存文章并发布日报", "type": "main", "index": 0}]]
}
},
"active": false,
"settings": {
"timezone": "Asia/Shanghai",
"executionOrder": "v1"
},
"versionId": "daily-news-digest-fetch-rss-v1",
"meta": {
"templateCredsSetupCompleted": false
},
"pinData": {},
"tags": []
}