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,610 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Xiaohongshu (Little Red Book) research post search script.
4
+
5
+ Searches for research-related posts on Xiaohongshu using the
6
+ xiaohongshu-cli tool, which handles authentication via browser cookies
7
+ and anti-detection automatically.
8
+
9
+ Usage:
10
+ python search_xiaohongshu.py --config research_interests.yaml --output xhs_results.json
11
+ python search_xiaohongshu.py --keywords "LLM,大模型,transformer" --top-n 20
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import logging
21
+ from datetime import datetime
22
+ from typing import Dict, List, Optional
23
+ from pathlib import Path
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Import shared scoring utilities
29
+ # ---------------------------------------------------------------------------
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ from scoring_utils import (
33
+ SCORE_MAX,
34
+ calculate_relevance_score,
35
+ calculate_recency_score,
36
+ calculate_quality_score,
37
+ calculate_recommendation_score,
38
+ )
39
+
40
+ # Popularity scoring: total engagement (likes + collects + comments) at which
41
+ # a post receives the maximum popularity score.
42
+ POPULARITY_ENGAGEMENT_FULL_SCORE = 500
43
+
44
+ # Quality scoring thresholds based on content length (characters).
45
+ QUALITY_LENGTH_THRESHOLDS = [
46
+ (1000, 3.0), # >= 1000 chars: max quality
47
+ (500, 2.0), # >= 500 chars
48
+ (200, 1.0), # >= 200 chars
49
+ ]
50
+ QUALITY_LENGTH_DEFAULT = 0.5
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Config loading
55
+ # ---------------------------------------------------------------------------
56
+ def load_research_config(config_path: str) -> Dict:
57
+ """Load research interests from a YAML config file."""
58
+ import json
59
+
60
+ try:
61
+ with open(config_path, "r", encoding="utf-8-sig") as f:
62
+ if config_path.endswith(".json"):
63
+ config = json.load(f)
64
+ else:
65
+ try:
66
+ import yaml
67
+ config = yaml.safe_load(f)
68
+ except ImportError:
69
+ config = json.load(f)
70
+ return config
71
+ except Exception as e:
72
+ logger.error("Error loading config: %s", e)
73
+ return {
74
+ "research_domains": {},
75
+ "excluded_keywords": [],
76
+ }
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # xiaohongshu-cli helpers
81
+ # ---------------------------------------------------------------------------
82
+ def _check_xhs_cli() -> str:
83
+ """Return the path to the xhs CLI, or raise if not found."""
84
+ path = shutil.which("xhs")
85
+ if not path:
86
+ raise RuntimeError(
87
+ "xiaohongshu-cli is not installed. "
88
+ "Run 'npm install' or install manually: uv tool install xiaohongshu-cli"
89
+ )
90
+ return path
91
+
92
+
93
+ def _parse_count(value) -> int:
94
+ """Parse an engagement count that might be a string like '1.2w' or '123'."""
95
+ if value is None:
96
+ return 0
97
+ if isinstance(value, (int, float)):
98
+ return int(value)
99
+ s = str(value).strip().lower()
100
+ if not s:
101
+ return 0
102
+ # Handle Chinese '万' (10k) and 'w' suffix
103
+ for suffix, multiplier in [("万", 10000), ("w", 10000), ("k", 1000)]:
104
+ if s.endswith(suffix):
105
+ try:
106
+ return int(float(s[:-len(suffix)]) * multiplier)
107
+ except ValueError:
108
+ return 0
109
+ try:
110
+ return int(float(s))
111
+ except ValueError:
112
+ return 0
113
+
114
+
115
+ def _run_xhs_cli(args: List[str], timeout: int = 120) -> Optional[Dict]:
116
+ """Run an xhs CLI command and return parsed JSON envelope, or None on error."""
117
+ xhs_bin = shutil.which("xhs") or "xhs"
118
+ cmd = [xhs_bin] + args + ["--json"]
119
+ logger.info("[XHS] Running: %s", " ".join(cmd))
120
+
121
+ # The xhs CLI uses its own Python interpreter (via shebang). When this
122
+ # script is run by a *different* Python (e.g. 3.8), macOS sets
123
+ # __PYVENV_LAUNCHER__ to the parent interpreter, which causes the child
124
+ # Python to look for its stdlib in the wrong prefix and fail with
125
+ # "No module named 'encodings'". Stripping this variable fixes it.
126
+ env = os.environ.copy()
127
+ env.pop("__PYVENV_LAUNCHER__", None)
128
+
129
+ result = subprocess.run(cmd, capture_output=True, timeout=timeout, env=env)
130
+
131
+ if result.returncode != 0:
132
+ logger.error("[XHS] CLI exited %d: %s", result.returncode, result.stderr.decode(errors="replace"))
133
+ return None
134
+
135
+ raw = result.stdout
136
+ if not raw.strip():
137
+ logger.error("[XHS] CLI returned empty stdout")
138
+ return None
139
+
140
+ # Decode and strip ANSI escape sequences
141
+ stdout = raw.decode("utf-8", errors="replace")
142
+ stdout = re.sub(r"\x1b\[[0-9;]*m", "", stdout)
143
+
144
+ try:
145
+ envelope = json.loads(stdout)
146
+ except json.JSONDecodeError as e:
147
+ logger.error("[XHS] Failed to parse CLI output: %s", e)
148
+ logger.error("[XHS] cleaned stdout (first 200): %s", stdout[:200])
149
+ return None
150
+
151
+ if not envelope.get("ok"):
152
+ err = envelope.get("error", {})
153
+ msg = err.get("message", "") if isinstance(err, dict) else str(err)
154
+ logger.error("[XHS] CLI returned error: %s", msg)
155
+ return None
156
+
157
+ return envelope
158
+
159
+
160
+ def search_via_xhs_cli(keyword: str) -> List[Dict]:
161
+ """
162
+ Search Xiaohongshu for notes matching a keyword using xiaohongshu-cli.
163
+
164
+ Returns a list of raw note items from the CLI JSON output.
165
+ """
166
+ envelope = _run_xhs_cli(["search", keyword])
167
+ if not envelope:
168
+ return []
169
+
170
+ items = (envelope.get("data", {}) or {}).get("items", [])
171
+ logger.info("[XHS] keyword='%s' => %d items", keyword, len(items))
172
+ return items
173
+
174
+
175
+ def fetch_note_detail(note_id: str, xsec_token: str = "") -> Optional[str]:
176
+ """
177
+ Fetch the full body text of a note via `xhs read`.
178
+
179
+ Returns the desc (body text) string, or None on failure.
180
+ """
181
+ args = ["read", note_id]
182
+ if xsec_token:
183
+ args += ["--xsec-token", xsec_token]
184
+
185
+ envelope = _run_xhs_cli(args, timeout=30)
186
+ if not envelope:
187
+ return None
188
+
189
+ items = (envelope.get("data", {}) or {}).get("items", [])
190
+ if not items:
191
+ return None
192
+
193
+ nc = items[0].get("note_card", {})
194
+ return nc.get("desc", "")
195
+
196
+
197
+ def parse_note_item(item: Dict) -> Optional[Dict]:
198
+ """
199
+ Parse a raw API item into a normalized note dict.
200
+
201
+ Returns None if the item cannot be parsed.
202
+ """
203
+ note_card = item.get("note_card") or {}
204
+ note_id = item.get("id") or note_card.get("note_id") or ""
205
+ if not note_id:
206
+ return None
207
+
208
+ # Title: CLI uses "display_title", raw API uses "title"
209
+ title = (
210
+ note_card.get("display_title")
211
+ or note_card.get("title", "")
212
+ ).strip()
213
+ desc = note_card.get("desc", "").strip()
214
+ user_info = note_card.get("user") or {}
215
+ interact = note_card.get("interact_info") or {}
216
+
217
+ # Parse timestamp — raw API has epoch ms in "time",
218
+ # CLI has relative text like "2天前" in corner_tag_info
219
+ published_dt = None
220
+ published_str = ""
221
+ ts = note_card.get("time")
222
+ if ts:
223
+ try:
224
+ published_dt = datetime.fromtimestamp(int(ts) / 1000)
225
+ published_str = published_dt.isoformat()
226
+ except (ValueError, TypeError, OSError):
227
+ pass
228
+
229
+ if not published_str:
230
+ # Fall back to corner_tag_info relative time text
231
+ for tag in note_card.get("corner_tag_info") or []:
232
+ if tag.get("type") == "publish_time":
233
+ published_str = tag.get("text", "")
234
+ break
235
+
236
+ # Extract image URLs — CLI nests them under info_list[].url,
237
+ # raw API has them directly as image_list[].url
238
+ image_list = note_card.get("image_list") or []
239
+ media_urls = []
240
+ for img in image_list:
241
+ url = img.get("url") or img.get("url_default") or ""
242
+ if not url:
243
+ # CLI format: pick the first info_list entry
244
+ for info in img.get("info_list") or []:
245
+ url = info.get("url", "")
246
+ if url:
247
+ break
248
+ if url:
249
+ media_urls.append(url)
250
+
251
+ likes = _parse_count(interact.get("liked_count"))
252
+ collects = _parse_count(interact.get("collected_count"))
253
+ comments = _parse_count(interact.get("comment_count"))
254
+
255
+ # Add cover image if not already in media_urls
256
+ cover = note_card.get("cover") or {}
257
+ cover_url = cover.get("url_default", "")
258
+ if cover_url and cover_url not in media_urls:
259
+ media_urls.insert(0, cover_url)
260
+
261
+ # Build link with xsec_token for authentication
262
+ xsec_token = item.get("xsec_token", "")
263
+ if xsec_token:
264
+ link = (
265
+ f"https://www.xiaohongshu.com/explore/{note_id}"
266
+ f"?xsec_token={xsec_token}"
267
+ f"&xsec_source=pc_search&source=web_search_result_notes"
268
+ )
269
+ else:
270
+ link = f"https://www.xiaohongshu.com/explore/{note_id}"
271
+
272
+ return {
273
+ "id": note_id,
274
+ "title": title or "(Untitled)",
275
+ "desc": desc,
276
+ "username": user_info.get("nickname") or user_info.get("nick_name", ""),
277
+ "avatar_url": user_info.get("avatar", ""),
278
+ "likes": likes,
279
+ "collects": collects,
280
+ "comments": comments,
281
+ "published_dt": published_dt,
282
+ "published_str": published_str,
283
+ "media_urls": media_urls,
284
+ "link": link,
285
+ "xsec_token": xsec_token,
286
+ }
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Scoring
291
+ # ---------------------------------------------------------------------------
292
+ def calculate_popularity_score(likes: int, collects: int, comments: int) -> float:
293
+ """
294
+ Calculate popularity score based on engagement metrics.
295
+
296
+ Total engagement = likes + collects + comments.
297
+ Normalized so that POPULARITY_ENGAGEMENT_FULL_SCORE total = SCORE_MAX.
298
+
299
+ Returns:
300
+ Popularity score (0 - SCORE_MAX).
301
+ """
302
+ total = likes + collects + comments
303
+ score = (total / POPULARITY_ENGAGEMENT_FULL_SCORE) * SCORE_MAX
304
+ return min(score, SCORE_MAX)
305
+
306
+
307
+ def calculate_content_quality_score(text: str) -> float:
308
+ """
309
+ Calculate quality score based on content length and depth indicators.
310
+
311
+ Longer, more detailed posts score higher. Posts that contain research
312
+ methodology or data-related terms get a bonus.
313
+
314
+ Returns:
315
+ Quality score (0 - SCORE_MAX).
316
+ """
317
+ length = len(text)
318
+ score = QUALITY_LENGTH_DEFAULT
319
+ for threshold, s in QUALITY_LENGTH_THRESHOLDS:
320
+ if length >= threshold:
321
+ score = s
322
+ break
323
+
324
+ # Bonus for depth indicators
325
+ text_lower = text.lower()
326
+ depth_indicators = [
327
+ "experiment", "evaluation", "benchmark",
328
+ "dataset", "comparison", "analysis",
329
+ "methodology", "framework", "algorithm",
330
+ "results", "performance", "accuracy",
331
+ # Chinese depth indicators
332
+ "实验", "评估", "数据集", "对比", "分析",
333
+ "方法论", "框架", "算法", "结果", "性能",
334
+ "论文", "研究", "模型", "训练", "推理",
335
+ ]
336
+ depth_count = sum(1 for ind in depth_indicators if ind in text_lower)
337
+ if depth_count >= 5:
338
+ score += 0.5
339
+ elif depth_count >= 2:
340
+ score += 0.2
341
+
342
+ return min(score, SCORE_MAX)
343
+
344
+
345
+ def score_and_rank_notes(
346
+ notes: List[Dict],
347
+ config: Dict,
348
+ keyword: str,
349
+ ) -> List[Dict]:
350
+ """
351
+ Score and rank parsed notes using shared scoring utilities.
352
+
353
+ Args:
354
+ notes: List of parsed note dicts from parse_note_item.
355
+ config: Research interests config.
356
+ keyword: The search keyword that produced these notes.
357
+
358
+ Returns:
359
+ List of scored note dicts, sorted by final_score descending.
360
+ """
361
+ domains = config.get("research_domains", {})
362
+ excluded_keywords = config.get("excluded_keywords", [])
363
+
364
+ scored = []
365
+ for note in notes:
366
+ # Build a paper-like dict for calculate_relevance_score
367
+ paper_like = {
368
+ "title": note["title"],
369
+ "summary": note["desc"],
370
+ "categories": [],
371
+ }
372
+
373
+ relevance, matched_domain, matched_keywords = calculate_relevance_score(
374
+ paper_like, domains, excluded_keywords,
375
+ )
376
+
377
+ # If no domain matched but the post was found via our keyword search,
378
+ # give a baseline relevance score so it isn't dropped entirely.
379
+ if relevance == 0 and matched_domain is None:
380
+ # Check if the search keyword appears in title or desc
381
+ combined = (note["title"] + " " + note["desc"]).lower()
382
+ if keyword.lower() in combined:
383
+ relevance = 0.5
384
+ matched_domain = "keyword_match"
385
+ matched_keywords = [keyword]
386
+ else:
387
+ # Still include but with minimal relevance
388
+ relevance = 0.1
389
+ matched_domain = "keyword_search"
390
+ matched_keywords = [keyword]
391
+
392
+ recency = calculate_recency_score(note["published_dt"])
393
+ popularity = calculate_popularity_score(
394
+ note["likes"], note["collects"], note["comments"],
395
+ )
396
+ quality = calculate_content_quality_score(note["desc"])
397
+
398
+ final_score = calculate_recommendation_score(
399
+ relevance, recency, popularity, quality,
400
+ is_hot_paper=False,
401
+ )
402
+
403
+ scored.append({
404
+ "id": note["id"],
405
+ "title": note["title"],
406
+ "authors": note["username"],
407
+ "abstract": note["desc"],
408
+ "published": note["published_str"],
409
+ "categories": [],
410
+ "relevance_score": round(relevance, 2),
411
+ "recency_score": round(recency, 2),
412
+ "popularity_score": round(popularity, 2),
413
+ "quality_score": round(quality, 2),
414
+ "final_score": final_score,
415
+ "matched_domain": matched_domain,
416
+ "matched_keywords": matched_keywords,
417
+ "link": note["link"],
418
+ "source": "xiaohongshu",
419
+ "engagement": {
420
+ "likes": note["likes"],
421
+ "collects": note["collects"],
422
+ "comments": note["comments"],
423
+ },
424
+ "avatar_url": note["avatar_url"],
425
+ "media_urls": note["media_urls"],
426
+ })
427
+
428
+ scored.sort(key=lambda x: x["final_score"], reverse=True)
429
+ return scored
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Main
434
+ # ---------------------------------------------------------------------------
435
+ def main() -> int:
436
+ import argparse
437
+
438
+ default_config = os.environ.get("OBSIDIAN_VAULT_PATH", "")
439
+ if default_config:
440
+ default_config = os.path.join(
441
+ default_config, "99_System", "Config", "research_interests.yaml"
442
+ )
443
+
444
+ parser = argparse.ArgumentParser(
445
+ description="Search Xiaohongshu for research-related posts",
446
+ )
447
+ parser.add_argument(
448
+ "--config", type=str,
449
+ default=default_config or None,
450
+ help="Path to research interests YAML config file",
451
+ )
452
+ parser.add_argument(
453
+ "--output", type=str,
454
+ default="xhs_results.json",
455
+ help="Output JSON file path",
456
+ )
457
+ parser.add_argument(
458
+ "--top-n", type=int, default=10,
459
+ help="Number of top posts to return (default: 10)",
460
+ )
461
+ parser.add_argument(
462
+ "--keywords", type=str, default=None,
463
+ help="Comma-separated search keywords (overrides config)",
464
+ )
465
+
466
+ args = parser.parse_args()
467
+
468
+ logging.basicConfig(
469
+ level=logging.INFO,
470
+ format="%(asctime)s [%(levelname)s] %(message)s",
471
+ datefmt="%H:%M:%S",
472
+ stream=sys.stderr,
473
+ )
474
+
475
+ # ---- Check xiaohongshu-cli is installed ----
476
+ try:
477
+ _check_xhs_cli()
478
+ except RuntimeError as e:
479
+ logger.error(str(e))
480
+ return 1
481
+
482
+ # ---- Determine search keywords ----
483
+ search_keywords: List[str] = []
484
+
485
+ if args.keywords:
486
+ search_keywords = [kw.strip() for kw in args.keywords.split(",") if kw.strip()]
487
+ elif args.config:
488
+ logger.info("Loading config from: %s", args.config)
489
+ config = load_research_config(args.config)
490
+ domains = config.get("research_domains", {})
491
+ for domain_name, domain_config in domains.items():
492
+ kws = domain_config.get("keywords", [])
493
+ search_keywords.extend(kws)
494
+ else:
495
+ logger.error(
496
+ "No keywords specified. Use --keywords or --config to provide search terms."
497
+ )
498
+ return 1
499
+
500
+ if not search_keywords:
501
+ logger.error("No keywords found in config or arguments.")
502
+ return 1
503
+
504
+ # Load config for scoring (even if keywords came from CLI)
505
+ if args.config:
506
+ config = load_research_config(args.config)
507
+ else:
508
+ config = {"research_domains": {}, "excluded_keywords": []}
509
+
510
+ logger.info("Search keywords (%d): %s", len(search_keywords), search_keywords)
511
+
512
+ # ---- Search ----
513
+ all_notes: List[Dict] = []
514
+ seen_ids: set = set()
515
+
516
+ for keyword in search_keywords:
517
+ raw_items = search_via_xhs_cli(keyword)
518
+
519
+ for item in raw_items:
520
+ note = parse_note_item(item)
521
+ if note and note["id"] not in seen_ids:
522
+ seen_ids.add(note["id"])
523
+ note["_search_keyword"] = keyword
524
+ all_notes.append(note)
525
+
526
+ logger.info("Total unique notes fetched: %d", len(all_notes))
527
+
528
+ # ---- Enrich: fetch full body text for notes missing desc ----
529
+ enriched_count = 0
530
+ for note in all_notes:
531
+ if not note["desc"]:
532
+ logger.info("[XHS] Fetching detail for %s ...", note["id"])
533
+ desc = fetch_note_detail(note["id"], note.get("xsec_token", ""))
534
+ if desc:
535
+ note["desc"] = desc
536
+ enriched_count += 1
537
+ if enriched_count:
538
+ logger.info("[XHS] Enriched %d notes with full body text", enriched_count)
539
+
540
+ if not all_notes:
541
+ logger.warning("No posts found for any keyword.")
542
+ output = {
543
+ "top_papers": [],
544
+ "total_found": 0,
545
+ "total_filtered": 0,
546
+ "search_date": datetime.now().strftime("%Y-%m-%d"),
547
+ }
548
+ with open(args.output, "w", encoding="utf-8") as f:
549
+ json.dump(output, f, ensure_ascii=False, indent=2)
550
+ print(json.dumps(output, ensure_ascii=True, indent=2))
551
+ return 0
552
+
553
+ # ---- Score and rank ----
554
+ # Group notes by their search keyword for relevance scoring context
555
+ all_scored: List[Dict] = []
556
+ for note in all_notes:
557
+ kw = note.pop("_search_keyword", "")
558
+ scored_batch = score_and_rank_notes([note], config, kw)
559
+ all_scored.extend(scored_batch)
560
+
561
+ # Deduplicate (already done by seen_ids, but just in case)
562
+ final_scored: List[Dict] = []
563
+ final_ids: set = set()
564
+ for item in all_scored:
565
+ if item["id"] not in final_ids:
566
+ final_ids.add(item["id"])
567
+ final_scored.append(item)
568
+
569
+ # Filter out invalid/empty entries
570
+ final_scored = [
571
+ p for p in final_scored
572
+ if p.get("title") and p["title"] != "(Untitled)"
573
+ ]
574
+
575
+ # Sort by final score
576
+ final_scored.sort(key=lambda x: x["final_score"], reverse=True)
577
+
578
+ total_found = len(final_scored)
579
+ top_papers = final_scored[: args.top_n]
580
+
581
+ output = {
582
+ "top_papers": top_papers,
583
+ "total_found": total_found,
584
+ "total_filtered": len(top_papers),
585
+ "search_date": datetime.now().strftime("%Y-%m-%d"),
586
+ }
587
+
588
+ # ---- Save results ----
589
+ with open(args.output, "w", encoding="utf-8") as f:
590
+ json.dump(output, f, ensure_ascii=False, indent=2)
591
+
592
+ logger.info("Results saved to: %s", args.output)
593
+ logger.info("Top %d posts:", len(top_papers))
594
+ for i, p in enumerate(top_papers, 1):
595
+ logger.info(
596
+ " %d. %s (Score: %.2f, Likes: %d)",
597
+ i,
598
+ p["title"][:50],
599
+ p["final_score"],
600
+ p["engagement"]["likes"],
601
+ )
602
+
603
+ # Also print to stdout
604
+ print(json.dumps(output, ensure_ascii=True, indent=2))
605
+
606
+ return 0
607
+
608
+
609
+ if __name__ == "__main__":
610
+ sys.exit(main())