dr-claw 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. package/shared/modelConstants.js +139 -0
@@ -0,0 +1,711 @@
1
+ import express from 'express';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { spawn } from 'child_process';
6
+ import { credentialsDb } from '../database/db.js';
7
+ import { checkCommandAvailable } from '../utils/cliResolution.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ const router = express.Router();
13
+
14
+ // Data directory for news config & results
15
+ const DATA_DIR = path.join(__dirname, '..', 'data');
16
+ const SCRIPTS_DIR = path.join(__dirname, '..', 'scripts');
17
+ const PYTHON_RUNTIME_CACHE_TTL_MS = 30_000;
18
+ const PYTHON_RUNTIME_INSPECTION_CODE = [
19
+ 'import importlib.util, json, os, ssl, sys',
20
+ 'paths = ssl.get_default_verify_paths()',
21
+ 'data = {',
22
+ ' "executable": sys.executable,',
23
+ ' "version": sys.version.split()[0],',
24
+ ' "has_certifi": importlib.util.find_spec("certifi") is not None,',
25
+ ' "cafile": paths.cafile,',
26
+ ' "cafile_exists": bool(paths.cafile and os.path.exists(paths.cafile)),',
27
+ ' "openssl_cafile": paths.openssl_cafile,',
28
+ ' "openssl_cafile_exists": bool(paths.openssl_cafile and os.path.exists(paths.openssl_cafile))',
29
+ '}',
30
+ 'print(json.dumps(data))',
31
+ ].join('\n');
32
+
33
+ let cachedPythonRuntime = null;
34
+ let pythonRuntimeCacheExpiry = 0;
35
+
36
+ function getPythonRuntimeCandidates() {
37
+ if (process.platform === 'win32') {
38
+ return [
39
+ { command: 'python', args: [], label: 'python' },
40
+ { command: 'py', args: ['-3'], label: 'py -3' },
41
+ { command: 'python3', args: [], label: 'python3' },
42
+ ];
43
+ }
44
+
45
+ return [
46
+ { command: 'python3', args: [], label: 'python3' },
47
+ { command: 'python', args: [], label: 'python' },
48
+ ];
49
+ }
50
+
51
+ function inspectPythonRuntime(candidate) {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(candidate.command, [...candidate.args, '-c', PYTHON_RUNTIME_INSPECTION_CODE], {
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ });
56
+
57
+ let stdout = '';
58
+ let stderr = '';
59
+ let settled = false;
60
+
61
+ const finish = (value) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ resolve(value);
65
+ };
66
+
67
+ const timeout = setTimeout(() => {
68
+ try { child.kill(); } catch {}
69
+ finish(null);
70
+ }, 3000);
71
+
72
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
73
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
74
+ child.on('error', () => {
75
+ clearTimeout(timeout);
76
+ finish(null);
77
+ });
78
+ child.on('close', (code) => {
79
+ clearTimeout(timeout);
80
+ if (code !== 0) {
81
+ finish(null);
82
+ return;
83
+ }
84
+ try {
85
+ const inspected = JSON.parse(stdout.trim());
86
+ finish({
87
+ ...candidate,
88
+ ...inspected,
89
+ hasUsableCertificates: Boolean(
90
+ inspected.has_certifi ||
91
+ inspected.cafile_exists ||
92
+ inspected.openssl_cafile_exists
93
+ ),
94
+ stderr: stderr.trim(),
95
+ });
96
+ } catch {
97
+ finish(null);
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ async function resolvePythonRuntime(options = {}) {
104
+ const { refresh = false } = options;
105
+
106
+ if (!refresh && cachedPythonRuntime && Date.now() < pythonRuntimeCacheExpiry) {
107
+ return cachedPythonRuntime;
108
+ }
109
+
110
+ const inspectedCandidates = [];
111
+ for (const candidate of getPythonRuntimeCandidates()) {
112
+ const probeArgs = [...candidate.args, '--version'];
113
+ if (await checkCommandAvailable(candidate.command, probeArgs, { platform: process.platform })) {
114
+ const inspected = await inspectPythonRuntime(candidate);
115
+ inspectedCandidates.push(inspected || {
116
+ ...candidate,
117
+ executable: null,
118
+ version: null,
119
+ has_certifi: false,
120
+ cafile: null,
121
+ cafile_exists: false,
122
+ openssl_cafile: null,
123
+ openssl_cafile_exists: false,
124
+ hasUsableCertificates: false,
125
+ });
126
+ }
127
+ }
128
+
129
+ const selectedRuntime = inspectedCandidates.find((candidate) => candidate?.hasUsableCertificates)
130
+ || inspectedCandidates[0]
131
+ || null;
132
+
133
+ if (selectedRuntime) {
134
+ cachedPythonRuntime = selectedRuntime;
135
+ pythonRuntimeCacheExpiry = Date.now() + PYTHON_RUNTIME_CACHE_TTL_MS;
136
+ }
137
+
138
+ return selectedRuntime;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Source Registry
143
+ // ---------------------------------------------------------------------------
144
+ const SOURCE_REGISTRY = {
145
+ arxiv: {
146
+ label: 'arXiv',
147
+ script: 'research-news/search_arxiv.py',
148
+ configFile: 'news-config-arxiv.json',
149
+ resultsFile: 'news-results-arxiv.json',
150
+ defaultConfig: {
151
+ research_domains: {
152
+ 'Large Language Models': {
153
+ keywords: ['large language model', 'LLM', 'transformer', 'foundation model'],
154
+ arxiv_categories: ['cs.AI', 'cs.LG', 'cs.CL'],
155
+ priority: 5,
156
+ },
157
+ 'Multimodal': {
158
+ keywords: ['vision-language', 'multimodal', 'image-text', 'visual'],
159
+ arxiv_categories: ['cs.CV', 'cs.MM', 'cs.CL'],
160
+ priority: 4,
161
+ },
162
+ 'AI Agents': {
163
+ keywords: ['agent', 'multi-agent', 'orchestration', 'autonomous', 'planning'],
164
+ arxiv_categories: ['cs.AI', 'cs.MA', 'cs.RO'],
165
+ priority: 4,
166
+ },
167
+ },
168
+ top_n: 10,
169
+ max_results: 200,
170
+ categories: 'cs.AI,cs.LG,cs.CL,cs.CV,cs.MM,cs.MA,cs.RO',
171
+ },
172
+ requiresCredentials: false,
173
+ },
174
+ huggingface: {
175
+ label: 'HuggingFace Daily Papers',
176
+ script: 'research-news/search_huggingface.py',
177
+ configFile: 'news-config-huggingface.json',
178
+ resultsFile: 'news-results-huggingface.json',
179
+ defaultConfig: {
180
+ research_domains: {},
181
+ top_n: 30,
182
+ },
183
+ requiresCredentials: false,
184
+ },
185
+ x: {
186
+ label: 'X (Twitter)',
187
+ script: 'research-news/search_x.py',
188
+ configFile: 'news-config-x.json',
189
+ resultsFile: 'news-results-x.json',
190
+ defaultConfig: {
191
+ research_domains: {
192
+ 'Large Language Models': {
193
+ keywords: ['large language model', 'LLM', 'transformer', 'foundation model'],
194
+ arxiv_categories: [],
195
+ priority: 5,
196
+ },
197
+ },
198
+ top_n: 10,
199
+ queries: 'LLM,AI agents,foundation model',
200
+ accounts: '',
201
+ },
202
+ requiresCredentials: false,
203
+ },
204
+ xiaohongshu: {
205
+ label: 'Xiaohongshu',
206
+ script: 'research-news/search_xiaohongshu.py',
207
+ configFile: 'news-config-xiaohongshu.json',
208
+ resultsFile: 'news-results-xiaohongshu.json',
209
+ defaultConfig: {
210
+ research_domains: {
211
+ 'Large Language Models': {
212
+ keywords: ['大模型', 'LLM', 'AI', '人工智能'],
213
+ arxiv_categories: [],
214
+ priority: 5,
215
+ },
216
+ },
217
+ top_n: 10,
218
+ keywords: '大模型,AI论文,人工智能',
219
+ },
220
+ requiresCredentials: false,
221
+ },
222
+ };
223
+
224
+ async function ensureDataDir() {
225
+ await fs.mkdir(DATA_DIR, { recursive: true });
226
+ }
227
+
228
+ function getSourceEntry(source) {
229
+ return SOURCE_REGISTRY[source] || null;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // GET /api/news/sources — list all sources with status
234
+ // ---------------------------------------------------------------------------
235
+ router.get('/sources', async (req, res) => {
236
+ try {
237
+ await ensureDataDir();
238
+ const sources = [];
239
+ for (const [key, entry] of Object.entries(SOURCE_REGISTRY)) {
240
+ // Check if results file exists
241
+ let hasResults = false;
242
+ let lastSearchDate = null;
243
+ try {
244
+ const resultsPath = path.join(DATA_DIR, entry.resultsFile);
245
+ const data = JSON.parse(await fs.readFile(resultsPath, 'utf8'));
246
+ hasResults = (data.top_papers?.length ?? 0) > 0;
247
+ lastSearchDate = data.search_date || null;
248
+ } catch { /* no results yet */ }
249
+
250
+ // Check credentials status for sources that need them
251
+ let credentialStatus = 'not_required';
252
+ if (entry.requiresCredentials) {
253
+ try {
254
+ const cred = credentialsDb.getActiveCredential(req.user.id, entry.credentialType);
255
+ credentialStatus = cred ? 'configured' : 'missing';
256
+ } catch {
257
+ credentialStatus = 'missing';
258
+ }
259
+ }
260
+
261
+ sources.push({
262
+ key,
263
+ label: entry.label,
264
+ hasResults,
265
+ lastSearchDate,
266
+ requiresCredentials: entry.requiresCredentials,
267
+ credentialType: entry.credentialType || null,
268
+ credentialStatus,
269
+ });
270
+ }
271
+ res.json({ sources });
272
+ } catch (err) {
273
+ res.status(500).json({ error: 'Failed to list sources', details: err.message });
274
+ }
275
+ });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // GET /api/news/config/:source — per-source config
279
+ // ---------------------------------------------------------------------------
280
+ router.get('/config/:source', async (req, res) => {
281
+ try {
282
+ const entry = getSourceEntry(req.params.source);
283
+ if (!entry) return res.status(404).json({ error: `Unknown source: ${req.params.source}` });
284
+
285
+ await ensureDataDir();
286
+ const configPath = path.join(DATA_DIR, entry.configFile);
287
+ const data = await fs.readFile(configPath, 'utf8');
288
+ res.json(JSON.parse(data));
289
+ } catch (err) {
290
+ if (err.code === 'ENOENT') {
291
+ const entry = getSourceEntry(req.params.source);
292
+ res.json(entry.defaultConfig);
293
+ } else {
294
+ res.status(500).json({ error: 'Failed to read config', details: err.message });
295
+ }
296
+ }
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // PUT /api/news/config/:source — save per-source config
301
+ // ---------------------------------------------------------------------------
302
+ router.put('/config/:source', async (req, res) => {
303
+ try {
304
+ const entry = getSourceEntry(req.params.source);
305
+ if (!entry) return res.status(404).json({ error: `Unknown source: ${req.params.source}` });
306
+
307
+ await ensureDataDir();
308
+ const configPath = path.join(DATA_DIR, entry.configFile);
309
+ await fs.writeFile(configPath, JSON.stringify(req.body, null, 2), 'utf8');
310
+ res.json({ success: true });
311
+ } catch (err) {
312
+ res.status(500).json({ error: 'Failed to save config', details: err.message });
313
+ }
314
+ });
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Search handler — streams progress via Server-Sent Events (SSE)
318
+ // ---------------------------------------------------------------------------
319
+ async function handleSearch(sourceName, req, res) {
320
+ try {
321
+ const entry = getSourceEntry(sourceName);
322
+ if (!entry) return res.status(404).json({ error: `Unknown source: ${sourceName}` });
323
+
324
+ await ensureDataDir();
325
+
326
+ // Read current config
327
+ const configPath = path.join(DATA_DIR, entry.configFile);
328
+ let config;
329
+ try {
330
+ config = JSON.parse(await fs.readFile(configPath, 'utf8'));
331
+ } catch {
332
+ config = entry.defaultConfig;
333
+ }
334
+
335
+ // Write JSON config for the Python script
336
+ const tmpConfigPath = path.join(DATA_DIR, `research_interests_${sourceName}.json`);
337
+ await fs.writeFile(tmpConfigPath, JSON.stringify(config, null, 2), 'utf8');
338
+
339
+ const scriptPath = path.join(SCRIPTS_DIR, entry.script);
340
+
341
+ // Check if script exists
342
+ try {
343
+ await fs.access(scriptPath);
344
+ } catch {
345
+ return res.status(404).json({ error: `Search script not found for source: ${sourceName}` });
346
+ }
347
+
348
+ const resultsPath = path.join(DATA_DIR, entry.resultsFile);
349
+ const topN = config.top_n || 10;
350
+
351
+ // Build args based on source
352
+ // HuggingFace: config is optional (fetches all daily papers without filtering)
353
+ const args = [scriptPath];
354
+ const hasDomains = config.research_domains && Object.keys(config.research_domains).length > 0;
355
+ if (sourceName !== 'huggingface' || hasDomains) {
356
+ args.push('--config', tmpConfigPath);
357
+ }
358
+ args.push('--output', resultsPath, '--top-n', String(topN));
359
+
360
+ if (sourceName === 'arxiv') {
361
+ const maxResults = config.max_results || 200;
362
+ const categories = config.categories || 'cs.AI,cs.LG,cs.CL,cs.CV,cs.MM,cs.MA,cs.RO';
363
+ args.push('--max-results', String(maxResults), '--categories', categories);
364
+ }
365
+
366
+ if (sourceName === 'x' && config.queries) {
367
+ args.push('--queries', config.queries);
368
+ }
369
+ if (sourceName === 'x' && config.accounts) {
370
+ args.push('--accounts', config.accounts);
371
+ }
372
+ if (sourceName === 'xiaohongshu' && config.keywords) {
373
+ args.push('--keywords', config.keywords);
374
+ }
375
+
376
+ // Build env — pass credentials if required.
377
+ // Strip __PYVENV_LAUNCHER__ so uv-installed Python CLIs invoked by the
378
+ // search scripts find the correct stdlib (macOS Python framework sets this
379
+ // variable and it confuses child interpreters with a different version).
380
+ const env = { ...process.env };
381
+ delete env.__PYVENV_LAUNCHER__;
382
+ if (entry.requiresCredentials) {
383
+ try {
384
+ const credValue = credentialsDb.getActiveCredential(req.user.id, entry.credentialType);
385
+ if (!credValue) {
386
+ return res.status(400).json({
387
+ error: `No active credential found for ${entry.label}. Please add your ${entry.credentialType} in settings.`,
388
+ });
389
+ }
390
+ // Map credential types to environment variables
391
+ const credEnvMap = {
392
+ // Add future credential mappings here
393
+ };
394
+ const envVar = credEnvMap[entry.credentialType];
395
+ if (envVar) {
396
+ env[envVar] = credValue;
397
+ }
398
+ } catch (credErr) {
399
+ return res.status(400).json({ error: 'Failed to retrieve credentials', details: credErr.message });
400
+ }
401
+ }
402
+
403
+ // Write search logs to a file so they can be polled by the frontend
404
+ const logPath = path.join(DATA_DIR, `news-log-${sourceName}.json`);
405
+ await fs.writeFile(logPath, JSON.stringify([]), 'utf8');
406
+ const logs = [];
407
+
408
+ const pythonRuntime = await resolvePythonRuntime();
409
+ if (!pythonRuntime) {
410
+ const details = process.platform === 'win32'
411
+ ? 'No usable Python runtime found. Tried: python, py -3, python3.'
412
+ : 'No usable Python runtime found. Tried: python3, python.';
413
+ logs.push(details);
414
+ try { await fs.writeFile(logPath, JSON.stringify(logs), 'utf8'); } catch {}
415
+ console.error(`[news][${sourceName}] ${details}`);
416
+ return res.status(503).json({
417
+ error: `Search failed for ${entry.label}`,
418
+ details,
419
+ logs,
420
+ });
421
+ }
422
+
423
+ const runtimeDetails = [pythonRuntime.label];
424
+ if (pythonRuntime.version) runtimeDetails.push(`Python ${pythonRuntime.version}`);
425
+ if (pythonRuntime.executable) runtimeDetails.push(pythonRuntime.executable);
426
+ logs.push(`[runtime] Using ${runtimeDetails.join(' | ')}`);
427
+ if (!pythonRuntime.hasUsableCertificates) {
428
+ logs.push('[runtime] Warning: selected Python runtime has no detectable CA bundle; HTTPS requests may fail.');
429
+ }
430
+ try { await fs.writeFile(logPath, JSON.stringify(logs), 'utf8'); } catch {}
431
+
432
+ const child = spawn(pythonRuntime.command, [...pythonRuntime.args, ...args], {
433
+ cwd: path.join(SCRIPTS_DIR, 'research-news'),
434
+ env,
435
+ });
436
+
437
+ let stdout = '';
438
+ let stderrBuf = '';
439
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
440
+ child.stderr.on('data', async (data) => {
441
+ const chunk = data.toString();
442
+ stderrBuf += chunk;
443
+ const lines = stderrBuf.split('\n');
444
+ stderrBuf = lines.pop();
445
+ for (const line of lines) {
446
+ const trimmed = line.trim();
447
+ if (trimmed) logs.push(trimmed);
448
+ }
449
+ // Update log file for polling
450
+ try { await fs.writeFile(logPath, JSON.stringify(logs), 'utf8'); } catch {}
451
+ });
452
+
453
+ child.on('close', async (code) => {
454
+ if (stderrBuf.trim()) logs.push(stderrBuf.trim());
455
+ try { await fs.writeFile(logPath, JSON.stringify(logs), 'utf8'); } catch {}
456
+
457
+ if (code !== 0) {
458
+ console.error(`[news][${sourceName}] script failed (exit ${code})`);
459
+ return res.status(500).json({
460
+ error: `Search failed for ${entry.label}`,
461
+ details: logs.join('\n'),
462
+ logs,
463
+ exitCode: code,
464
+ });
465
+ }
466
+
467
+ try {
468
+ const results = JSON.parse(await fs.readFile(resultsPath, 'utf8'));
469
+ results.logs = logs;
470
+ res.json(results);
471
+ } catch (readErr) {
472
+ res.status(500).json({ error: 'Failed to read search results', details: readErr.message });
473
+ }
474
+ });
475
+
476
+ child.on('error', (err) => {
477
+ console.error(`[news][${sourceName}] Failed to spawn script:`, err);
478
+ res.status(500).json({ error: 'Failed to execute search script', details: err.message });
479
+ });
480
+ } catch (err) {
481
+ res.status(500).json({ error: 'Search failed', details: err.message });
482
+ }
483
+ }
484
+
485
+ // POST /api/news/search/:source — trigger search for one source
486
+ router.post('/search/:source', (req, res) => handleSearch(req.params.source, req, res));
487
+
488
+ // GET /api/news/logs/:source — poll search progress logs
489
+ router.get('/logs/:source', async (req, res) => {
490
+ try {
491
+ const logPath = path.join(DATA_DIR, `news-log-${req.params.source}.json`);
492
+ const data = await fs.readFile(logPath, 'utf8');
493
+ res.json({ logs: JSON.parse(data) });
494
+ } catch {
495
+ res.json({ logs: [] });
496
+ }
497
+ });
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // POST /api/news/xhs-login — trigger xiaohongshu-cli login
501
+ // ---------------------------------------------------------------------------
502
+ router.post('/xhs-login', (req, res) => {
503
+ const requestedMethod = req.body?.method === 'qrcode' ? 'qrcode' : 'browser';
504
+ const requestedCookieSource = typeof req.body?.cookieSource === 'string'
505
+ ? req.body.cookieSource.trim().toLowerCase()
506
+ : 'auto';
507
+ const allowedCookieSources = new Set([
508
+ 'auto', 'arc', 'brave', 'chrome', 'chromium', 'edge', 'firefox', 'librewolf', 'opera', 'opera_gx', 'safari', 'vivaldi',
509
+ ]);
510
+ const cookieSource = allowedCookieSources.has(requestedCookieSource) ? requestedCookieSource : 'auto';
511
+ const commandArgs = ['login'];
512
+ if (requestedMethod === 'qrcode') {
513
+ commandArgs.push('--qrcode');
514
+ } else if (cookieSource !== 'auto') {
515
+ commandArgs.push('--cookie-source', cookieSource);
516
+ }
517
+ commandArgs.push('--json');
518
+
519
+ const xhsEnv = { ...process.env };
520
+ delete xhsEnv.__PYVENV_LAUNCHER__;
521
+ const child = spawn('xhs', commandArgs, {
522
+ env: xhsEnv,
523
+ });
524
+
525
+ let stdoutBuf = '';
526
+ let stderrBuf = '';
527
+ const logs = [];
528
+ let responded = false;
529
+
530
+ const sendOnce = (status, payload) => {
531
+ if (responded || res.headersSent) return;
532
+ responded = true;
533
+ res.status(status).json(payload);
534
+ };
535
+
536
+ child.stdout.on('data', (data) => { stdoutBuf += data.toString(); });
537
+ child.stderr.on('data', (data) => {
538
+ stderrBuf += data.toString();
539
+ const lines = stderrBuf.split('\n');
540
+ stderrBuf = lines.pop() || '';
541
+ for (const line of lines) {
542
+ const trimmed = line.trim();
543
+ if (trimmed) logs.push(trimmed);
544
+ }
545
+ });
546
+
547
+ child.on('close', (code) => {
548
+ if (stderrBuf.trim()) logs.push(stderrBuf.trim());
549
+
550
+ let authenticated = false;
551
+ let nickname = '';
552
+ let error = '';
553
+ let contextHint = '';
554
+ try {
555
+ const result = JSON.parse(stdoutBuf);
556
+ authenticated = !!(result?.ok && result?.data?.authenticated);
557
+ nickname = result?.data?.user?.nickname || '';
558
+ if (!authenticated) {
559
+ error = result?.error?.message || result?.message || '';
560
+ }
561
+ } catch {
562
+ authenticated = code === 0;
563
+ if (!authenticated) {
564
+ error = stdoutBuf.trim();
565
+ }
566
+ }
567
+
568
+ if (!authenticated && !error) {
569
+ error = requestedMethod === 'qrcode'
570
+ ? 'QR login failed or timed out.'
571
+ : 'Browser cookie extraction failed.';
572
+ }
573
+
574
+ if (!authenticated) {
575
+ contextHint = requestedMethod === 'qrcode'
576
+ ? 'QR login is recommended for remote deployments and Linux browser-cookie issues.'
577
+ : 'Browser cookie extraction runs on the machine hosting the dr-claw service, not on the device where this page is open.';
578
+ }
579
+
580
+ sendOnce(200, {
581
+ success: authenticated,
582
+ nickname,
583
+ logs,
584
+ exitCode: code,
585
+ method: requestedMethod,
586
+ cookieSource,
587
+ error,
588
+ contextHint: contextHint || undefined,
589
+ });
590
+ });
591
+
592
+ child.on('error', (err) => {
593
+ const contextHint = requestedMethod === 'qrcode'
594
+ ? 'QR login is recommended for remote deployments and Linux browser-cookie issues.'
595
+ : 'Browser cookie extraction runs on the machine hosting the dr-claw service, not on the device where this page is open.';
596
+
597
+ sendOnce(500, {
598
+ success: false,
599
+ error: `Failed to run xhs login: ${err.message}`,
600
+ logs,
601
+ method: requestedMethod,
602
+ cookieSource,
603
+ contextHint,
604
+ });
605
+ });
606
+ });
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // GET /api/news/results/:source — cached results for one source
610
+ // ---------------------------------------------------------------------------
611
+ router.get('/results/:source', async (req, res) => {
612
+ try {
613
+ const entry = getSourceEntry(req.params.source);
614
+ if (!entry) return res.status(404).json({ error: `Unknown source: ${req.params.source}` });
615
+
616
+ const resultsPath = path.join(DATA_DIR, entry.resultsFile);
617
+ const data = await fs.readFile(resultsPath, 'utf8');
618
+ res.json(JSON.parse(data));
619
+ } catch (err) {
620
+ if (err.code === 'ENOENT') {
621
+ res.json({ top_papers: [], total_found: 0, total_filtered: 0 });
622
+ } else {
623
+ res.status(500).json({ error: 'Failed to read results', details: err.message });
624
+ }
625
+ }
626
+ });
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Backward-compatible aliases (old routes → arxiv source)
630
+ // ---------------------------------------------------------------------------
631
+ router.get('/config', async (req, res) => {
632
+ try {
633
+ await ensureDataDir();
634
+ const entry = SOURCE_REGISTRY.arxiv;
635
+ const configPath = path.join(DATA_DIR, entry.configFile);
636
+ const data = await fs.readFile(configPath, 'utf8');
637
+ res.json(JSON.parse(data));
638
+ } catch (err) {
639
+ if (err.code === 'ENOENT') {
640
+ res.json(SOURCE_REGISTRY.arxiv.defaultConfig);
641
+ } else {
642
+ res.status(500).json({ error: 'Failed to read config', details: err.message });
643
+ }
644
+ }
645
+ });
646
+
647
+ router.put('/config', async (req, res) => {
648
+ try {
649
+ const entry = SOURCE_REGISTRY.arxiv;
650
+ await ensureDataDir();
651
+ const configPath = path.join(DATA_DIR, entry.configFile);
652
+ await fs.writeFile(configPath, JSON.stringify(req.body, null, 2), 'utf8');
653
+ res.json({ success: true });
654
+ } catch (err) {
655
+ res.status(500).json({ error: 'Failed to save config', details: err.message });
656
+ }
657
+ });
658
+
659
+ router.post('/search', (req, res) => handleSearch('arxiv', req, res));
660
+
661
+ router.get('/results', async (req, res) => {
662
+ try {
663
+ const entry = SOURCE_REGISTRY.arxiv;
664
+ const resultsPath = path.join(DATA_DIR, entry.resultsFile);
665
+ const data = await fs.readFile(resultsPath, 'utf8');
666
+ res.json(JSON.parse(data));
667
+ } catch (err) {
668
+ if (err.code === 'ENOENT') {
669
+ // Also try the legacy path for backward compat
670
+ try {
671
+ const legacyPath = path.join(DATA_DIR, 'news-results.json');
672
+ const data = await fs.readFile(legacyPath, 'utf8');
673
+ res.json(JSON.parse(data));
674
+ } catch {
675
+ res.json({ top_papers: [], total_found: 0, total_filtered: 0 });
676
+ }
677
+ } else {
678
+ res.status(500).json({ error: 'Failed to read results', details: err.message });
679
+ }
680
+ }
681
+ });
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // Helpers
685
+ // ---------------------------------------------------------------------------
686
+ function buildYamlConfig(config) {
687
+ let yaml = '# Auto-generated from Dr. Claw News Dashboard config\n\n';
688
+ yaml += 'research_domains:\n';
689
+
690
+ const domains = config.research_domains || {};
691
+ for (const [name, domain] of Object.entries(domains)) {
692
+ yaml += ` "${name}":\n`;
693
+ yaml += ` keywords:\n`;
694
+ for (const kw of domain.keywords || []) {
695
+ yaml += ` - "${kw}"\n`;
696
+ }
697
+ if (domain.arxiv_categories?.length) {
698
+ yaml += ` arxiv_categories:\n`;
699
+ for (const cat of domain.arxiv_categories) {
700
+ yaml += ` - "${cat}"\n`;
701
+ }
702
+ }
703
+ if (domain.priority) {
704
+ yaml += ` priority: ${domain.priority}\n`;
705
+ }
706
+ }
707
+
708
+ return yaml;
709
+ }
710
+
711
+ export default router;