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.
- package/LICENSE +1380 -0
- package/NOTICE +17 -0
- package/README.md +875 -0
- package/README.zh-CN.md +857 -0
- package/dist/api-docs.html +878 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
- package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
- package/dist/assets/arc-DOMBmC_A.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
- package/dist/assets/channel-BFOE7OyC.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
- package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
- package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
- package/dist/assets/clone-BulQSblH.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
- package/dist/assets/graph-Blm-emkR.js +1 -0
- package/dist/assets/index-B8Yutsnm.css +1 -0
- package/dist/assets/index-Cf6gyhc3.js +654 -0
- package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
- package/dist/assets/layout-Ct2kFIId.js +1 -0
- package/dist/assets/linear-CKG9EwIJ.js +1 -0
- package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
- package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
- package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
- package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/dr-claw.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/file.svg +16997 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/news/arxiv.svg +1 -0
- package/dist/icons/news/huggingface.svg +8 -0
- package/dist/icons/news/x-black.png +0 -0
- package/dist/icons/news/x-white.png +0 -0
- package/dist/icons/news/xiaohongshu.png +0 -0
- package/dist/index.html +51 -0
- package/dist/leveraged-cognition.png +0 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/chat.png +0 -0
- package/dist/screenshots/new_claw.png +0 -0
- package/dist/screenshots/news_dashboard.png +0 -0
- package/dist/screenshots/project_dashboard.png +0 -0
- package/dist/screenshots/research_lab.png +0 -0
- package/dist/screenshots/skill_library.png +0 -0
- package/dist/screenshots/task_list.png +0 -0
- package/dist/screenshots/terminal_example.png +0 -0
- package/dist/screenshots/terminal_example1.png +0 -0
- package/dist/sw.js +58 -0
- package/dist/wechat-group-qr.jpg +0 -0
- package/package.json +133 -0
- package/public/api-docs.html +878 -0
- package/public/clear-cache.html +85 -0
- package/public/convert-icons.md +53 -0
- package/public/dr-claw.png +0 -0
- package/public/favicon.png +0 -0
- package/public/generate-icons.js +49 -0
- package/public/icons/claude-ai-icon.svg +1 -0
- package/public/icons/codex-white.svg +3 -0
- package/public/icons/codex.svg +3 -0
- package/public/icons/cursor-white.svg +12 -0
- package/public/icons/cursor.svg +1 -0
- package/public/icons/file.svg +16997 -0
- package/public/icons/generate-icons.md +19 -0
- package/public/icons/icon-128x128.png +0 -0
- package/public/icons/icon-144x144.png +0 -0
- package/public/icons/icon-152x152.png +0 -0
- package/public/icons/icon-192x192.png +0 -0
- package/public/icons/icon-384x384.png +0 -0
- package/public/icons/icon-512x512.png +0 -0
- package/public/icons/icon-72x72.png +0 -0
- package/public/icons/icon-96x96.png +0 -0
- package/public/icons/news/arxiv.svg +1 -0
- package/public/icons/news/huggingface.svg +8 -0
- package/public/icons/news/x-black.png +0 -0
- package/public/icons/news/x-white.png +0 -0
- package/public/icons/news/xiaohongshu.png +0 -0
- package/public/leveraged-cognition.png +0 -0
- package/public/manifest.json +61 -0
- package/public/screenshots/chat.png +0 -0
- package/public/screenshots/new_claw.png +0 -0
- package/public/screenshots/news_dashboard.png +0 -0
- package/public/screenshots/project_dashboard.png +0 -0
- package/public/screenshots/research_lab.png +0 -0
- package/public/screenshots/skill_library.png +0 -0
- package/public/screenshots/task_list.png +0 -0
- package/public/screenshots/terminal_example.png +0 -0
- package/public/screenshots/terminal_example1.png +0 -0
- package/public/sw.js +58 -0
- package/public/wechat-group-qr.jpg +0 -0
- package/scripts/export-skills-catalog-v2.mjs +746 -0
- package/scripts/fix-node-pty.js +45 -0
- package/scripts/gemini_image_core.py +470 -0
- package/scripts/install-python-tools.js +64 -0
- package/server/AGENTS.md +73 -0
- package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
- package/server/__tests__/runtimePorts.test.mjs +115 -0
- package/server/__tests__/session-delete.test.mjs +166 -0
- package/server/bin/compute-node +70 -0
- package/server/claude-sdk.js +799 -0
- package/server/cli-chat.js +488 -0
- package/server/cli.js +354 -0
- package/server/compute-node.js +439 -0
- package/server/constants/config.js +5 -0
- package/server/cursor-cli.js +340 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +1852 -0
- package/server/database/init.sql +194 -0
- package/server/gemini-cli.js +1352 -0
- package/server/index.js +3154 -0
- package/server/load-env.js +71 -0
- package/server/local-gpu.js +860 -0
- package/server/middleware/auth.js +100 -0
- package/server/openai-codex.js +724 -0
- package/server/openrouter.js +890 -0
- package/server/project-token-usage.js +407 -0
- package/server/projects.js +3973 -0
- package/server/routes/agent.js +1272 -0
- package/server/routes/auth.js +128 -0
- package/server/routes/auto-research.js +558 -0
- package/server/routes/cli-auth.js +958 -0
- package/server/routes/codex.js +344 -0
- package/server/routes/commands.js +601 -0
- package/server/routes/compute.js +358 -0
- package/server/routes/cursor.js +795 -0
- package/server/routes/git.js +1348 -0
- package/server/routes/mcp-utils.js +48 -0
- package/server/routes/mcp.js +618 -0
- package/server/routes/news.js +711 -0
- package/server/routes/projects.js +633 -0
- package/server/routes/references.js +338 -0
- package/server/routes/settings.js +285 -0
- package/server/routes/skills.js +914 -0
- package/server/routes/taskmaster.js +2566 -0
- package/server/routes/telemetry.js +89 -0
- package/server/routes/user.js +155 -0
- package/server/scripts/fix_session_names.js +57 -0
- package/server/scripts/research-news/common_words.py +19 -0
- package/server/scripts/research-news/link_keywords.py +298 -0
- package/server/scripts/research-news/scan_existing_notes.py +266 -0
- package/server/scripts/research-news/scoring_utils.py +227 -0
- package/server/scripts/research-news/search_arxiv.py +842 -0
- package/server/scripts/research-news/search_huggingface.py +438 -0
- package/server/scripts/research-news/search_x.py +490 -0
- package/server/scripts/research-news/search_xiaohongshu.py +610 -0
- package/server/taskmaster-templates/README.md +150 -0
- package/server/taskmaster-templates/README.zh-CN.md +150 -0
- package/server/taskmaster-templates/ai-research-dataset.json +230 -0
- package/server/taskmaster-templates/ai-research-method-model.json +230 -0
- package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
- package/server/telemetry.js +121 -0
- package/server/templates/AGENTS.md +103 -0
- package/server/templates/CLAUDE.md +104 -0
- package/server/templates/GEMINI.md +103 -0
- package/server/templates/cursor-project.md +108 -0
- package/server/templates/index.js +37 -0
- package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
- package/server/utils/auto-research-mailer.js +57 -0
- package/server/utils/cliResolution.js +170 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/cursorCommand.js +61 -0
- package/server/utils/geminiApiKey.js +28 -0
- package/server/utils/gitConfig.js +24 -0
- package/server/utils/imageAttachmentFiles.js +35 -0
- package/server/utils/mcp-detector.js +198 -0
- package/server/utils/parsers/bibtex-parser.js +207 -0
- package/server/utils/permissions.js +100 -0
- package/server/utils/runtimePorts.js +212 -0
- package/server/utils/sessionFormatting.js +68 -0
- package/server/utils/sessionIndex.js +100 -0
- package/server/utils/sessionMode.js +95 -0
- package/server/utils/sessionTokenUsage.js +105 -0
- package/server/utils/taskmaster-websocket.js +129 -0
- package/server/utils/zotero-client.js +136 -0
- package/shared/errorClassifier.js +102 -0
- package/shared/geminiThinkingSupport.d.ts +22 -0
- package/shared/geminiThinkingSupport.js +96 -0
- package/shared/geminiThoughtParser.d.ts +8 -0
- package/shared/geminiThoughtParser.js +30 -0
- 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())
|