{
  "name": "BriefFeed \u2014 \u4e13\u4e1a\u60c5\u62a5\u7b80\u62a5",
  "nodes": [
    {
      "id": "aa000001-0000-0000-0000-000000000001",
      "name": "\u624b\u52a8\u89e6\u53d1",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        200,
        400
      ],
      "parameters": {}
    },
    {
      "id": "aa000007-0000-0000-0000-000000000007",
      "name": "\u8bfb\u53d6\u914d\u7f6e",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================\n// BriefFeed \u914d\u7f6e\u4e2d\u5fc3 \u2014 \u4fee\u6539\u6b64\u8282\u70b9\u6765\u8c03\u6574\u6240\u6709\u6293\u53d6\u4e0e\u8bc4\u5206\u53c2\u6570\n// rss_sources \u5bf9\u5e94 config/rss_sources.json\uff08\u4e0d\u542b template\uff09\n// \u5176\u4f59\u8bbe\u7f6e\u5bf9\u5e94 config/settings.json\uff08\u4e0d\u542b template\uff09\n// ============================================================\nconst CONFIG = {\n\n  // ---------- arXiv ----------\n  arxiv_rss_categories: ['cs.AI', 'cs.HC', 'cs.LG', 'cs.RO'],\n  paper_keywords: [\n    '(\"human computer interaction\" OR HCI) AND (\"large language model\" OR LLM OR agent)',\n    '(\"computer aided design\" OR CAD) AND (\"artificial intelligence\" OR \"machine learning\")',\n    '(\"multimodal\" OR \"multi-modal\") AND (\"3D\" OR \"design\" OR \"engineering\")',\n    '\"design automation\" AND (\"AI\" OR \"agent\" OR \"intelligent\")',\n    '\"engineering assistant\" OR \"CAD copilot\" OR \"design intelligence\"',\n  ],\n\n  // ---------- GitHub ----------\n  github_keywords: [\n    'CAD AI assistant',\n    'engineering design agent LLM',\n    'CAD copilot',\n    'HCI design tool AI',\n    'design automation agent',\n  ],\n  github_quality: {\n    stars_min:          10,   // star \u6570\u6700\u4f4e\u95e8\u69db\uff08\u964d\u4f4e\u4ee5\u907f\u514d\u6f0f\u6389\u65b0\u5174\u9879\u76ee\uff09\n    pushed_within_days: 365,  // \u6700\u8fd1\u6d3b\u8dc3\u5929\u6570\uff080 = \u4e0d\u8fc7\u6ee4\uff09\n    keep_top:           8,    // \u6bcf\u4e2a\u5173\u952e\u8bcd\u6700\u591a\u4fdd\u7559\u51e0\u4e2a\uff08\u6309 star \u964d\u5e8f\uff09\n  },\n\n  // ---------- RSS \u6e90\uff08\u6765\u81ea config/rss_sources.json\uff09----------\n  rss_sources: [\n    { name: 'OpenAI Blog',              url: 'https://openai.com/news/rss.xml',                                                                                    category: 'AI\u516c\u53f8\u535a\u5ba2',           enabled: true  },\n    { name: 'Anthropic News',           url: 'https://www.anthropic.com/news/rss.xml',                                                                             category: 'AI\u516c\u53f8\u535a\u5ba2',           enabled: true  },\n    { name: 'Google DeepMind Blog',     url: 'https://deepmind.com/blog/feed/basic/',                                                                              category: 'AI\u516c\u53f8\u535a\u5ba2',           enabled: true  },\n    { name: 'Hugging Face Blog',        url: 'https://huggingface.co/blog/feed.xml',                                                                               category: 'AI\u5de5\u5177',               enabled: true  },\n    { name: 'LangChain Blog',           url: 'https://blog.langchain.dev/rss/',                                                                                    category: 'AI\u5de5\u5177',               enabled: true  },\n    { name: 'LlamaIndex Blog',          url: 'https://www.llamaindex.ai/blog/rss.xml',                                                                             category: 'AI\u5de5\u5177',               enabled: true  },\n    { name: 'Jina AI',                  url: 'https://jina.ai/news/rss.xml',                                                                                       category: 'AI\u5de5\u5177',               enabled: true  },\n    { name: 'Microsoft Research Blog',  url: 'https://www.microsoft.com/en-us/research/feed/',                                                                     category: '\u7814\u7a76\u673a\u6784',             enabled: true  },\n    { name: 'deeplearning.ai',          url: 'https://rsshub.bestblogs.dev/deeplearning/the-batch',                                                                category: '\u7814\u7a76\u673a\u6784',             enabled: true  },\n    { name: 'The GitHub Blog',          url: 'https://github.blog/feed/',                                                                                          category: '\u5f00\u6e90\u793e\u533a',             enabled: true  },\n    { name: 'Vercel News',              url: 'https://vercel.com/atom',                                                                                            category: '\u5de5\u7a0b/AI\u4ea7\u54c1',          enabled: true  },\n    { name: \"Simon Willison's Weblog\",  url: 'https://simonwillison.net/atom/everything/',                                                                         category: 'AI\u5de5\u7a0b/\u72ec\u7acb\u6280\u672f\u535a\u5ba2',  enabled: true  },\n    { name: 'Latent Space',             url: 'https://www.latent.space/feed',                                                                                      category: 'AI\u5de5\u7a0b/Agent',         enabled: true  },\n    { name: 'UX Magazine',              url: 'https://uxmag.com/feed/',                                                                                            category: 'HCI/UX',               enabled: true  },\n    { name: 'Last Week in AI',          url: 'https://lastweekin.ai/feed/',                                                                                        category: 'AI\u5a92\u4f53',               enabled: false },\n  ],\n\n  // ---------- \u8bc4\u5206 & \u7b5b\u9009\uff08\u6765\u81ea config/settings.json\uff09----------\n  scoring_weights:       { relevance: 0.5, novelty: 0.3, transferability: 0.2 },\n  max_items_per_section: 10,\n  min_score_threshold:   4,    // \u4e09\u7ef4\u6574\u6570\u5206\u4e4b\u548c\u9608\u503c\uff08\u6ee1\u5206 15\uff0c\u5efa\u8bae 4~6\uff09\n\n  // ---------- LLM ----------\n  llm_model:             'deepseek-ai/DeepSeek-V3',\n  llm_batch_size:        20,\n  llm_max_concurrency:   3,    // \u5efa\u8bae 2~5\uff0c\u89c6 API \u9650\u6d41\u800c\u5b9a\n\n  // ---------- \u6293\u53d6\u91cf ----------\n  fresh_days:            7,    // \u53ea\u4fdd\u7559\u6700\u8fd1 N \u5929\u5185\u5bb9\uff080 = \u4e0d\u8fc7\u6ee4\uff09\n  rss_items_per_source:  10,\n  arxiv_results_per_kw:  8,\n  github_results_per_kw: 20,   // \u6293\u53d6\u5019\u9009\u6570\uff08\u8fc7\u6ee4\u524d\uff09\uff0c\u5e94\u5927\u4e8e keep_top\n\n  // ---------- \u8f93\u51fa ----------\n  output_dir: '/briefeed-output',\n\n  // ---------- LLM \u7cfb\u7edf\u63d0\u793a\u8bcd\uff08\u6765\u81ea config/researcher_profile.md\uff09----------\n  system_prompt: 'You are filtering daily professional updates for a researcher. All your output must be in Chinese.\\n\\nThe researcher is mainly interested in:\\n1. AI for CAD and engineering design\\n2. Human-AI interaction, especially CHI/UIST-style research\\n3. Intelligent engineering assistants, CAD copilots, and design automation systems\\n4. Agentic or multi-agent workflows when they are relevant to engineering tasks\\n5. Methods, frameworks, open-source tools, and product ideas that can be transferred into real systems\\n\\nThe researcher especially values:\\n- Content that is directly useful for research or project development\\n- Methodological novelty, system design insight, and engineering applicability\\n- Work related to interactive design tools, engineering intelligence, and AI-assisted workflows\\n- Ideas that can inform the development of CAD copilot / engineering assistant / design intelligence systems\\n\\nThe researcher is less interested in:\\n- Generic AI news or hype without technical substance\\n- Marketing-heavy announcements\\n- Product updates without technical depth\\n- Content with weak relevance to design, engineering, HCI, or agent systems\\n\\nReturn ONLY a valid JSON array. For each item provide these fields:\\n- id: original index (integer)\\n- relevance: integer 0-5. How closely the topic matches the researcher interests above.\\n  0=unrelated, 1=tangential, 2=somewhat relevant, 3=relevant, 4=highly relevant, 5=core topic\\n- novelty: integer 0-5. How new, surprising, or non-obvious the contribution is.\\n  0=rehash, 1=incremental, 2=slight advance, 3=notable, 4=significant, 5=breakthrough\\n- transferability: integer 0-5. How directly the ideas/code/methods can be applied to the researcher\\'s own work.\\n  0=not applicable, 1=inspirational only, 2=partial idea, 3=useful method, 4=directly adoptable, 5=ready to use\\n- category: \"\u8bba\u6587\" | \"\u535a\u5ba2\" | \"GitHub\"\\n- direction: \"AI+CAD\" | \"HCI\" | \"\u8bbe\u8ba1\u5de5\u5177\" | \"\u591a\u6a21\u6001\" | \"Agent\u7cfb\u7edf\" | \"\u5176\u4ed6\"\\n- summary_zh: 2-3 sentence Chinese summary of what this item is\\n- value_zh: 1-2 sentence Chinese explanation of why it matters to this researcher\\n- skip: true ONLY if relevance <= 1 (clearly off-topic), otherwise false\\n\\nCRITICAL:\\n1. Output ONLY a valid JSON array. No markdown. No extra text.\\n2. ALL scores must be integers 0-5. Never use decimals.\\n3. Do NOT modify any URLs. Use URLs exactly as provided.',\n};\n\nconsole.log('\u914d\u7f6e\u52a0\u8f7d\u5b8c\u6210\uff1aRSS\u6e90 ' + CONFIG.rss_sources.filter(function(s){return s.enabled;}).length + ' \u4e2a\u5df2\u542f\u7528\uff0carxiv\u5206\u7c7b ' + CONFIG.arxiv_rss_categories.length + ' \u4e2a\uff0cgithub\u5173\u952e\u8bcd ' + CONFIG.github_keywords.length + ' \u4e2a');\nreturn [{ json: { config: CONFIG } }];"
      }
    },
    {
      "id": "aa000002-0000-0000-0000-000000000002",
      "name": "\u5168\u91cf\u6570\u636e\u6293\u53d6",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// \u4ece\u4e0a\u6e38\u8282\u70b9\u63a5\u6536\u914d\u7f6e\nconst CONFIG = $input.first().json.config;\n\n// ============================================================\n// \u5de5\u5177\u51fd\u6570\n// ============================================================\nfunction extractTag(xml, tag) {\n  const m = xml.match(new RegExp('<' + tag + '[^>]*>([^]*?)<[/]' + tag + '>', 'i'));\n  return m ? m[1].trim() : '';\n}\nfunction extractAttrHref(xml) {\n  const m = xml.match(/<link[^>]+href=['\"]([^'\"]+)['\"]/i);\n  return m ? m[1] : '';\n}\nfunction stripHtml(s) {\n  return s\n    .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/gi, '$1')\n    .replace(/<[^>]+>/g, '')\n    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#\\d+;/g, '')\n    .trim();\n}\nfunction parseRSS(xml, sourceName, category, maxItems) {\n  const items = [];\n  const rx = /<(?:item|entry)[^]*?<[/](?:item|entry)>/gi;\n  for (const chunk of (xml.match(rx) || []).slice(0, maxItems || 10)) {\n    const title = stripHtml(extractTag(chunk, 'title'));\n    const link  = (extractTag(chunk, 'link') || extractAttrHref(chunk)).trim();\n    const desc  = stripHtml(extractTag(chunk, 'summary') || extractTag(chunk, 'description') || extractTag(chunk, 'content') || '');\n    const pub   = extractTag(chunk, 'pubDate') || extractTag(chunk, 'published') || extractTag(chunk, 'updated') || '';\n    if (!title || !link || !link.startsWith('http')) continue;\n    items.push({ title, url: link, abstract: desc.slice(0, 400), date: pub ? new Date(pub).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], source: sourceName, source_type: 'blog', category });\n  }\n  return items;\n}\nfunction parseArxiv(xml, label) {\n  const items = [];\n  for (const chunk of (xml.match(/<entry[^]*?<[/]entry>/gi) || [])) {\n    const title   = stripHtml(extractTag(chunk, 'title')).replace(/\\s+/g, ' ').trim();\n    const id      = extractTag(chunk, 'id').trim();\n    const summary = stripHtml(extractTag(chunk, 'summary'));\n    const pub     = extractTag(chunk, 'published') || '';\n    if (!title || !id) continue;\n    items.push({ title, url: id.replace('http://arxiv.org/abs/', 'https://arxiv.org/abs/'), abstract: summary.slice(0, 500), date: pub ? pub.split('T')[0] : new Date().toISOString().split('T')[0], source: 'arXiv (' + label + ')', source_type: 'paper', category: label });\n  }\n  return items;\n}\nfunction isFresh(item, cutoff) {\n  if (!cutoff || !item.date) return true;\n  try { return new Date(item.date).getTime() >= cutoff; } catch(e) { return true; }\n}\n\n// ============================================================\n// HTTP \u5de5\u5177\u51fd\u6570\n// ============================================================\nconst https = require('https');\nconst http = require('http');\nconst nodeUrl = require('url');\nfunction httpGet(urlStr, headers, timeoutMs) {\n  return new Promise(function(resolve, reject) {\n    const u = nodeUrl.parse(urlStr);\n    const isHttps = u.protocol === 'https:';\n    const lib = isHttps ? https : http;\n    const opts = { hostname: u.hostname, port: u.port ? parseInt(u.port) : (isHttps ? 443 : 80), path: (u.pathname || '/') + (u.search || ''), method: 'GET', headers: Object.assign({ 'User-Agent': 'BriefFeed/1.0' }, headers || {}), timeout: timeoutMs || 18000 };\n    const req = lib.request(opts, function(res) {\n      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {\n        return resolve(httpGet(res.headers.location, headers, timeoutMs));\n      }\n      const chunks = [];\n      res.on('data', function(c) { chunks.push(c); });\n      res.on('end', function() { resolve(Buffer.concat(chunks).toString('utf-8')); });\n    });\n    req.on('timeout', function() { req.destroy(); reject(new Error('Request timed out')); });\n    req.on('error', reject);\n    req.end();\n  });\n}\nfunction httpGetJson(urlStr, headers, timeoutMs) {\n  return httpGet(urlStr, headers, timeoutMs).then(function(text) { return JSON.parse(text); });\n}\n\n// ============================================================\n// \u5e76\u53d1\u6293\u53d6\n// ============================================================\nconst errors = [];\nconst T = 18000;\nconst cutoff = CONFIG.fresh_days > 0 ? Date.now() - CONFIG.fresh_days * 86400000 : 0;\nconst maxRSS = CONFIG.rss_items_per_source || 10;\nconst maxArxiv = CONFIG.arxiv_results_per_kw || 8;\nconst maxGH = CONFIG.github_results_per_kw || 20;\n\n// --- 1. arXiv RSS ---\nconst arxivRssResults = await Promise.allSettled(\n  CONFIG.arxiv_rss_categories.map(function(cat) {\n    return httpGet('https://rss.arxiv.org/rss/' + cat, {}, T)\n      .then(function(xml) {\n        return parseRSS(xml, 'arXiv ' + cat, cat, maxRSS).map(function(i) { return Object.assign({}, i, { source_type: 'paper' }); });\n      });\n  })\n);\nconst arxivRssItems = [];\narxivRssResults.forEach(function(r, i) {\n  if (r.status === 'fulfilled') { arxivRssItems.push(...r.value); }\n  else { errors.push('arXiv RSS ' + CONFIG.arxiv_rss_categories[i] + ': ' + r.reason.message); }\n});\n\n// --- 2. arXiv API \u5173\u952e\u8bcd\u641c\u7d22 ---\nconst arxivApiResults = await Promise.allSettled(\n  CONFIG.paper_keywords.map(function(kw) {\n    const url = 'https://export.arxiv.org/api/query?search_query=' + encodeURIComponent(kw) + '&start=0&max_results=' + maxArxiv + '&sortBy=submittedDate&sortOrder=descending';\n    return httpGet(url, {}, T).then(function(xml) { return parseArxiv(xml, kw.slice(0, 40)); });\n  })\n);\nconst arxivApiItems = [];\narxivApiResults.forEach(function(r, i) {\n  if (r.status === 'fulfilled') { arxivApiItems.push(...r.value); }\n  else { errors.push('arXiv API \"' + CONFIG.paper_keywords[i].slice(0, 30) + '\": ' + r.reason.message); }\n});\n\n// --- 3. RSS \u6e90 ---\nconst enabledSources = CONFIG.rss_sources.filter(function(s) { return s.enabled; });\nconst rssResults = await Promise.allSettled(\n  enabledSources.map(function(src) {\n    return httpGet(src.url, { 'User-Agent': 'BriefFeed/1.0' }, T)\n      .then(function(xml) { return parseRSS(xml, src.name, src.category, maxRSS); });\n  })\n);\nconst rssItems = [];\nrssResults.forEach(function(r, i) {\n  if (r.status === 'fulfilled') { rssItems.push(...r.value); }\n  else { errors.push('RSS ' + enabledSources[i].name + ': ' + r.reason.message); }\n});\n\n// --- 4. GitHub ---\nconst ghHeaders = { 'Accept': 'application/vnd.github+json', 'User-Agent': 'BriefFeed/1.0' };\nconst ghToken = $env.GITHUB_TOKEN || '';\nif (ghToken) { ghHeaders['Authorization'] = 'token ' + ghToken; }\nconst ghQuality = CONFIG.github_quality || {};\nconst ghStarsMin = ghQuality.stars_min != null ? ghQuality.stars_min : 10;\nconst ghPushedDays = ghQuality.pushed_within_days != null ? ghQuality.pushed_within_days : 365;\nconst ghKeepTop = ghQuality.keep_top || 8;\nconst ghPushedCutoff = ghPushedDays > 0 ? Date.now() - ghPushedDays * 86400000 : 0;\nconst githubResults = await Promise.allSettled(\n  CONFIG.github_keywords.map(function(kw) {\n    const url = 'https://api.github.com/search/repositories?q=' + encodeURIComponent(kw) + '&sort=stars&order=desc&per_page=' + maxGH;\n    return httpGetJson(url, ghHeaders, T).then(function(d) {\n      if (!d || !d.items) return [];\n      return d.items\n        .filter(function(repo) {\n          if (repo.stargazers_count < ghStarsMin) return false;\n          if (ghPushedCutoff && repo.pushed_at) {\n            if (new Date(repo.pushed_at).getTime() < ghPushedCutoff) return false;\n          }\n          return true;\n        })\n        .slice(0, ghKeepTop)\n        .map(function(repo) {\n          const pushedDate = (repo.pushed_at || '').split('T')[0];\n          const createdDate = (repo.created_at || '').split('T')[0];\n          return {\n            title: repo.full_name,\n            url: repo.html_url,\n            abstract: (repo.description || '\u65e0\u63cf\u8ff0').slice(0, 300),\n            date: pushedDate || new Date().toISOString().split('T')[0],\n            source: 'GitHub (' + kw + ')',\n            source_type: 'github',\n            category: 'GitHub',\n            stars: repo.stargazers_count,\n            extra: '\u2b50 ' + repo.stargazers_count + ' | \ud83c\udf74 ' + repo.forks_count + ' | ' + (repo.language || '?') + ' | \u6700\u8fd1\u63a8\u9001 ' + pushedDate + ' | \u521b\u5efa ' + createdDate,\n          };\n        });\n    });\n  })\n);\nconst githubItems = [];\ngithubResults.forEach(function(r, i) {\n  if (r.status === 'fulfilled') { githubItems.push(...r.value); }\n  else { errors.push('GitHub \"' + CONFIG.github_keywords[i] + '\": ' + r.reason.message); }\n});\n\n// --- \u5408\u5e76 & \u65b0\u9c9c\u5ea6\u8fc7\u6ee4 ---\nconst allItems = [...arxivRssItems, ...arxivApiItems, ...rssItems, ...githubItems]\n  .filter(function(item) { return isFresh(item, cutoff); });\n\nconsole.log('\u5e76\u53d1\u6293\u53d6\u5b8c\u6210\uff1a\u8bba\u6587RSS ' + arxivRssItems.length + ' | \u8bba\u6587API ' + arxivApiItems.length + ' | RSS ' + rssItems.length + ' | GitHub ' + githubItems.length + ' | \u65b0\u9c9c\u8fc7\u6ee4\u540e ' + allItems.length + ' \u6761\uff08' + CONFIG.fresh_days + ' \u5929\u5185\uff09');\n\nreturn [{ json: { items: allItems, config: CONFIG, errors: errors, fetchedAt: new Date().toISOString() } }];"
      }
    },
    {
      "id": "aa000003-0000-0000-0000-000000000003",
      "name": "\u6807\u51c6\u5316\u53bb\u91cd",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const data = $input.first().json;\nconst items = data.items;\nconst seen = new Set();\nconst deduped = items.filter(function(item) {\n  if (!item.title || !item.url || !item.url.startsWith('http')) return false;\n  const key = item.url.replace(/v\\d+$/, '').toLowerCase().replace(/\\/$/, '');\n  if (seen.has(key)) return false;\n  seen.add(key);\n  return true;\n});\nconsole.log('\u6293\u53d6\u5b8c\u6210\uff1a\u539f\u59cb ' + items.length + ' \u6761 \u2192 \u53bb\u91cd\u540e ' + deduped.length + ' \u6761');\nif (data.errors && data.errors.length) console.log('\u8b66\u544a\uff1a' + data.errors.join(' | '));\nreturn [{ json: { items: deduped, config: data.config, errors: data.errors, fetchedAt: data.fetchedAt } }];"
      }
    },
    {
      "id": "aa000004-0000-0000-0000-000000000004",
      "name": "LLM\u6279\u6b21\u8bc4\u5206",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const data = $input.first().json;\nconst items = data.items;\nconst config = data.config;\n\nconst API_KEY = $env.SILICONFLOW_API_KEY;\nif (!API_KEY) throw new Error('\u8bf7\u914d\u7f6e\u73af\u5883\u53d8\u91cf SILICONFLOW_API_KEY');\n\nconst SYSTEM_PROMPT = config.system_prompt;\nif (!SYSTEM_PROMPT) throw new Error('\u914d\u7f6e\u4e2d\u7f3a\u5c11 system_prompt\uff0c\u8bf7\u68c0\u67e5\u8bfb\u53d6\u914d\u7f6e\u8282\u70b9');\n\nconst batchSize   = config.llm_batch_size      || 20;\nconst maxConc     = config.llm_max_concurrency  || 3;\n// weights \u7528\u4e8e\u5c06\u4e09\u7ef4\u6574\u6570\u5206\uff080-5\uff09\u805a\u5408\u4e3a\u5355\u4e00\u6392\u5e8f\u5206\uff080-15\uff09\uff0c\u4e0d\u518d\u7528 0-1 \u5c0f\u6570\nconst weights     = config.scoring_weights      || { relevance: 0.5, novelty: 0.3, transferability: 0.2 };\nconst minTotal    = config.min_score_threshold  || 4;   // \u4e09\u7ef4\u4e4b\u548c >= 4 \u624d\u4fdd\u7559\uff0815 \u5206\u5236\uff09\nconst model       = config.llm_model            || 'deepseek-ai/DeepSeek-V3';\nconst https_llm   = require('https');\n\nfunction callLLM(batch, batchOffset) {\n  const payload = batch.map(function(item, idx) {\n    return { id: batchOffset + idx, title: item.title, url: item.url, source: item.source, type: item.source_type, date: item.date, abstract: (item.abstract || '').slice(0, 400) };\n  });\n  const userMsg = 'Analyze the following ' + batch.length + ' items and return a JSON array:\\n\\n' + JSON.stringify(payload, null, 2) + '\\n\\nReturn ONLY the JSON array, no markdown.';\n  const bodyStr = JSON.stringify({ model: model, messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: userMsg }], max_tokens: 4096, temperature: 0.1, stream: false });\n  return new Promise(function(resolve, reject) {\n    const opts = { hostname: 'api.siliconflow.cn', port: 443, path: '/v1/chat/completions', method: 'POST', headers: { 'Authorization': 'Bearer ' + API_KEY, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }, timeout: 180000 };\n    const req = https_llm.request(opts, function(res) {\n      const chunks = [];\n      res.on('data', function(c) { chunks.push(c); });\n      res.on('end', function() {\n        try {\n          const result = JSON.parse(Buffer.concat(chunks).toString('utf-8'));\n          const content = (result.choices && result.choices[0] && result.choices[0].message) ? result.choices[0].message.content : '';\n          let jsonStr = content;\n          const mdMatch = content.match(/```(?:json)?\\n?([^]*?)\\n?```/);\n          if (mdMatch) jsonStr = mdMatch[1];\n          let evals = [];\n          try { evals = JSON.parse(jsonStr.trim()); } catch(e2) {\n            const arrMatch = jsonStr.match(/\\[[^]*\\]/);\n            if (arrMatch) evals = JSON.parse(arrMatch[0]);\n          }\n          resolve(evals);\n        } catch(e) { reject(e); }\n      });\n    });\n    req.on('timeout', function() { req.destroy(); reject(new Error('LLM request timed out')); });\n    req.on('error', reject);\n    req.write(bodyStr);\n    req.end();\n  });\n}\n\nconst batches = [];\nfor (let i = 0; i < items.length; i += batchSize) {\n  batches.push({ batch: items.slice(i, i + batchSize), offset: i });\n}\n\nconst scored = [];\nfor (let gi = 0; gi < batches.length; gi += maxConc) {\n  const group = batches.slice(gi, gi + maxConc);\n  console.log('\u5e76\u53d1\u63d0\u4ea4\u6279\u6b21 ' + (gi+1) + '~' + Math.min(gi+maxConc, batches.length) + '/' + batches.length);\n  const results = await Promise.allSettled(\n    group.map(function(b) { return callLLM(b.batch, b.offset); })\n  );\n  results.forEach(function(r, ri) {\n    const b = group[ri];\n    let evals = [];\n    if (r.status === 'fulfilled') {\n      evals = r.value;\n    } else {\n      console.log('\u6279\u6b21 ' + (gi+ri+1) + ' LLM \u5931\u8d25: ' + r.reason.message);\n      evals = b.batch.map(function(_, idx) { return { id: b.offset+idx, relevance: 2, novelty: 2, transferability: 2, category: '\u535a\u5ba2', direction: '\u672a\u5206\u7c7b', summary_zh: '', value_zh: '\uff08LLM\u5931\u8d25\uff09', skip: false }; });\n    }\n    for (let ei = 0; ei < evals.length; ei++) {\n      const ev = evals[ei];\n      if (ev.skip) continue;\n      const localIdx = ev.id - b.offset;\n      if (localIdx < 0 || localIdx >= b.batch.length) continue;\n      const orig = b.batch[localIdx];\n      // \u6574\u6570\u5316\uff08\u9632\u6b62 LLM \u8fd4\u56de\u5c0f\u6570\uff09\n      const rel  = Math.round(Math.min(5, Math.max(0, ev.relevance      || 0)));\n      const nov  = Math.round(Math.min(5, Math.max(0, ev.novelty        || 0)));\n      const tra  = Math.round(Math.min(5, Math.max(0, ev.transferability || ev.actionability || 0)));\n      const total = rel + nov + tra;  // 0~15\n      if (total < minTotal) continue;\n      // \u52a0\u6743\u5206\uff08\u7528\u4e8e\u6392\u5e8f\uff0c\u4fdd\u6301\u5411\u540e\u517c\u5bb9 score \u5b57\u6bb5\uff09\n      const wScore = rel * (weights.relevance || 0.5) + nov * (weights.novelty || 0.3) + tra * (weights.transferability || weights.actionability || 0.2);\n      const merged = {};\n      for (const k in orig) merged[k] = orig[k];\n      merged.score            = Math.round(wScore * 100) / 100;  // \u52a0\u6743\u5206\uff0c\u7528\u4e8e\u6392\u5e8f\n      merged.total_score      = total;                            // \u4e09\u7ef4\u603b\u5206 /15\n      merged.relevance        = rel;\n      merged.novelty          = nov;\n      merged.transferability  = tra;\n      merged.category_zh      = ev.category || (orig.source_type === 'paper' ? '\u8bba\u6587' : orig.source_type === 'github' ? 'GitHub' : '\u535a\u5ba2');\n      merged.direction        = ev.direction || '\u5176\u4ed6';\n      merged.summary_zh       = ev.summary_zh || orig.abstract || '';\n      merged.value_zh         = ev.value_zh || '';\n      scored.push(merged);\n    }\n  });\n}\n\nconsole.log('LLM\u5b8c\u6210\uff1a' + scored.length + ' \u6761\u901a\u8fc7\u9608\u503c\uff0c\u5171 ' + batches.length + ' \u6279\u6b21\uff08\u6700\u5927\u5e76\u53d1 ' + maxConc + '\uff09');\nreturn [{ json: { items: scored, config: config, errors: data.errors, fetchedAt: data.fetchedAt } }];"
      }
    },
    {
      "id": "aa000005-0000-0000-0000-000000000005",
      "name": "\u751f\u6210Markdown\u62a5\u544a",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1160,
        400
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const data = $input.first().json;\nconst items = data.items;\nconst config = data.config;\nconst maxN = config.max_items_per_section || 10;\n\nconst papers  = items.filter(function(i){ return i.source_type === 'paper';  }).sort(function(a,b){ return b.score-a.score; }).slice(0, maxN);\nconst blogs   = items.filter(function(i){ return i.source_type === 'blog';   }).sort(function(a,b){ return b.score-a.score; }).slice(0, maxN);\nconst githubs = items.filter(function(i){ return i.source_type === 'github'; }).sort(function(a,b){ return b.score-a.score; }).slice(0, maxN);\n\nconst now = new Date();\nconst pad = function(n){ return String(n).padStart(2,'0'); };\nconst dateStr = now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate());\nconst timeStr = pad(now.getHours()) + ':' + pad(now.getMinutes());\nconst filename = 'briefeed_' + dateStr.replace(/-/g,'') + '_' + pad(now.getHours()) + pad(now.getMinutes()) + '.md';\n\nfunction fmt(item) {\n  const scoreStr = item.total_score != null ? ' | \u8bc4\u5206 ' + item.total_score + '/15\uff08\u76f8\u5173' + (item.relevance||0) + ' \u65b0\u9896' + (item.novelty||0) + ' \u8fc1\u79fb' + (item.transferability||0) + '\uff09' : '';\n  const extra = item.extra ? '\\n- **\u9644\u52a0\u4fe1\u606f**\uff1a' + item.extra : '';\n  return '### ' + item.title + '\\n- **\u6765\u6e90**\uff1a' + item.source + '\\n- **\u65e5\u671f**\uff1a' + item.date + ' | **\u65b9\u5411**\uff1a' + (item.direction||'-') + scoreStr + '\\n- **\u6982\u8ff0**\uff1a' + (item.summary_zh||item.abstract||'\uff08\u65e0\u6458\u8981\uff09') + '\\n- **\u4e3a\u4ec0\u4e48\u503c\u5f97\u5173\u6ce8**\uff1a' + (item.value_zh||'-') + extra + '\\n- **\u539f\u6587**\uff1a' + item.url + '\\n\\n';\n}\n\nconst errSec = data.errors && data.errors.length ? '\\n---\\n\\n> \u26a0\ufe0f **\u6293\u53d6\u8b66\u544a**\uff08' + data.errors.length + ' \u4e2a\u6e90\u5f02\u5e38\uff09\\n' + data.errors.map(function(e){ return '> - ' + e; }).join('\\n') + '\\n' : '';\n\nconst report = '# BriefFeed \u60c5\u62a5\u7b80\u62a5 \u2014 ' + dateStr + ' ' + timeStr + '\\n\\n> \u751f\u6210\u65f6\u95f4\uff1a' + data.fetchedAt + ' | \u901a\u8fc7\u7b5b\u9009\uff1a' + items.length + ' \u6761\uff08\u8bba\u6587 ' + papers.length + '\u3001\u535a\u5ba2 ' + blogs.length + '\u3001GitHub ' + githubs.length + '\uff09\\n\\n---\\n\\n## \u4e00\u3001\u4eca\u65e5\u91cd\u70b9\u8bba\u6587\\n\\n' + (papers.length ? papers.map(fmt).join('') : '_\u4eca\u65e5\u6682\u65e0\u9ad8\u76f8\u5173\u5ea6\u8bba\u6587\u3002_\\n') + '\\n---\\n\\n## \u4e8c\u3001\u4eca\u65e5\u6280\u672f / \u535a\u5ba2\u52a8\u6001\\n\\n' + (blogs.length ? blogs.map(fmt).join('') : '_\u4eca\u65e5\u6682\u65e0\u9ad8\u76f8\u5173\u5ea6\u535a\u5ba2\u52a8\u6001\u3002_\\n') + '\\n---\\n\\n## \u4e09\u3001\u4eca\u65e5 GitHub / \u5f00\u6e90\u9879\u76ee\\n\\n' + (githubs.length ? githubs.map(fmt).join('') : '_\u4eca\u65e5\u6682\u65e0\u9ad8\u76f8\u5173\u5ea6 GitHub \u9879\u76ee\u3002_\\n') + errSec;\n\nconst base64 = Buffer.from(report, 'utf-8').toString('base64');\nreturn [{ json: { filename: filename, outputDir: config.output_dir || '/briefeed-output', reportLength: report.length }, binary: { data: { data: base64, mimeType: 'text/markdown; charset=utf-8', fileName: filename, fileExtension: 'md' } } }];"
      }
    },
    {
      "id": "aa000006-0000-0000-0000-000000000006",
      "name": "\u4fdd\u5b58\u62a5\u544a",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        1400,
        400
      ],
      "parameters": {
        "operation": "write",
        "fileName": "={{ $json.outputDir + '/' + $json.filename }}",
        "dataPropertyName": "data",
        "options": {}
      }
    }
  ],
  "connections": {
    "\u624b\u52a8\u89e6\u53d1": {
      "main": [
        [
          {
            "node": "\u8bfb\u53d6\u914d\u7f6e",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u8bfb\u53d6\u914d\u7f6e": {
      "main": [
        [
          {
            "node": "\u5168\u91cf\u6570\u636e\u6293\u53d6",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u5168\u91cf\u6570\u636e\u6293\u53d6": {
      "main": [
        [
          {
            "node": "\u6807\u51c6\u5316\u53bb\u91cd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u6807\u51c6\u5316\u53bb\u91cd": {
      "main": [
        [
          {
            "node": "LLM\u6279\u6b21\u8bc4\u5206",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM\u6279\u6b21\u8bc4\u5206": {
      "main": [
        [
          {
            "node": "\u751f\u6210Markdown\u62a5\u544a",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u751f\u6210Markdown\u62a5\u544a": {
      "main": [
        [
          {
            "node": "\u4fdd\u5b58\u62a5\u544a",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "tags": [],
  "versionId": "briefeed-v1-1",
  "meta": {
    "templateCredsSetupCompleted": true
  }
}