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,490 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
X (Twitter) research tweet search script.
|
|
4
|
+
|
|
5
|
+
Searches recent tweets matching research-related queries using the
|
|
6
|
+
twitter-cli tool (https://github.com/trevorhobenshield/twitter-cli),
|
|
7
|
+
which handles authentication via browser cookies automatically.
|
|
8
|
+
|
|
9
|
+
Scores results using the shared scoring_utils module, producing output
|
|
10
|
+
compatible with the other search scripts (search_arxiv.py, etc.).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
# scoring_utils lives in the same directory
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
from scoring_utils import (
|
|
29
|
+
SCORE_MAX,
|
|
30
|
+
calculate_relevance_score,
|
|
31
|
+
calculate_recency_score,
|
|
32
|
+
calculate_quality_score,
|
|
33
|
+
calculate_recommendation_score,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Popularity normalisation: 1000 total engagements == SCORE_MAX
|
|
39
|
+
POPULARITY_ENGAGEMENT_FULL_SCORE = 1000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Config loading
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def load_research_config(config_path: str) -> Dict:
|
|
47
|
+
"""Load research interest configuration from a YAML file."""
|
|
48
|
+
import json
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
with open(config_path, "r", encoding="utf-8-sig") as f:
|
|
52
|
+
if config_path.endswith(".json"):
|
|
53
|
+
config = json.load(f)
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
import yaml
|
|
57
|
+
config = yaml.safe_load(f)
|
|
58
|
+
except ImportError:
|
|
59
|
+
config = json.load(f)
|
|
60
|
+
return config
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error("Error loading config: %s", e)
|
|
63
|
+
return {
|
|
64
|
+
"research_domains": {
|
|
65
|
+
"LLM": {
|
|
66
|
+
"keywords": ["large language model", "LLM"],
|
|
67
|
+
"priority": 5,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"excluded_keywords": [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# twitter-cli helpers
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _check_twitter_cli() -> str:
|
|
79
|
+
"""Return the path to the twitter CLI, or raise if not found."""
|
|
80
|
+
path = shutil.which("twitter")
|
|
81
|
+
if not path:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
"twitter-cli is not installed. "
|
|
84
|
+
"Run 'npm install' or install manually: uv tool install twitter-cli"
|
|
85
|
+
)
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def search_via_twitter_cli(query: str) -> List[Dict]:
|
|
90
|
+
"""
|
|
91
|
+
Search tweets using twitter-cli.
|
|
92
|
+
|
|
93
|
+
Returns a list of raw tweet dicts from the CLI JSON output.
|
|
94
|
+
"""
|
|
95
|
+
twitter_bin = shutil.which("twitter") or "twitter"
|
|
96
|
+
cmd = [twitter_bin, "search", query, "--json"]
|
|
97
|
+
logger.info("[X] Running: %s", " ".join(cmd))
|
|
98
|
+
|
|
99
|
+
# Strip __PYVENV_LAUNCHER__ so uv-installed CLIs find the right stdlib
|
|
100
|
+
env = os.environ.copy()
|
|
101
|
+
env.pop("__PYVENV_LAUNCHER__", None)
|
|
102
|
+
|
|
103
|
+
result = subprocess.run(cmd, capture_output=True, timeout=120, env=env)
|
|
104
|
+
|
|
105
|
+
if result.returncode != 0:
|
|
106
|
+
logger.error("[X] CLI exited %d: %s", result.returncode, result.stderr.decode(errors="replace"))
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
raw = result.stdout
|
|
110
|
+
if not raw.strip():
|
|
111
|
+
logger.error("[X] CLI returned empty stdout")
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
# Decode and strip ANSI escape sequences (the CLI colorizes JSON output)
|
|
115
|
+
stdout = raw.decode("utf-8", errors="replace")
|
|
116
|
+
stdout = re.sub(r"\x1b\[[0-9;]*m", "", stdout)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
envelope = json.loads(stdout)
|
|
120
|
+
except json.JSONDecodeError as e:
|
|
121
|
+
logger.error("[X] Failed to parse CLI output: %s", e)
|
|
122
|
+
logger.error("[X] cleaned stdout (first 200): %s", stdout[:200])
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
if not envelope.get("ok"):
|
|
126
|
+
err = envelope.get("error", {})
|
|
127
|
+
msg = err.get("message", "") if isinstance(err, dict) else str(err)
|
|
128
|
+
logger.error("[X] CLI returned error: %s", msg)
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
tweets = envelope.get("data", [])
|
|
132
|
+
logger.info("[X] Query '%s': got %d tweets", query[:40], len(tweets))
|
|
133
|
+
return tweets
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Scoring helpers
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _tweet_engagement(tweet: Dict) -> Dict[str, int]:
|
|
141
|
+
"""Extract engagement metrics from a tweet.
|
|
142
|
+
|
|
143
|
+
Supports both twitter-cli format (metrics.*) and X API v2 format
|
|
144
|
+
(public_metrics.*_count) for backward compatibility.
|
|
145
|
+
"""
|
|
146
|
+
metrics = tweet.get("metrics", {})
|
|
147
|
+
pm = tweet.get("public_metrics", {})
|
|
148
|
+
return {
|
|
149
|
+
"likes": metrics.get("likes", pm.get("like_count", 0)),
|
|
150
|
+
"retweets": metrics.get("retweets", pm.get("retweet_count", 0)),
|
|
151
|
+
"replies": metrics.get("replies", pm.get("reply_count", 0)),
|
|
152
|
+
"impressions": metrics.get("views", pm.get("impression_count", 0)),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _calculate_popularity_score(engagement: Dict[str, int]) -> float:
|
|
157
|
+
"""
|
|
158
|
+
Popularity score based on total engagement.
|
|
159
|
+
|
|
160
|
+
1000 engagements = SCORE_MAX (3.0).
|
|
161
|
+
"""
|
|
162
|
+
total = engagement["likes"] + engagement["retweets"] + engagement["replies"]
|
|
163
|
+
return min(total / (POPULARITY_ENGAGEMENT_FULL_SCORE / SCORE_MAX), SCORE_MAX)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _calculate_tweet_quality_score(tweet: Dict) -> float:
|
|
167
|
+
"""
|
|
168
|
+
Quality score for a tweet.
|
|
169
|
+
|
|
170
|
+
Bonus for:
|
|
171
|
+
- containing URLs (especially arxiv / paper links)
|
|
172
|
+
- thread indicators (e.g. "thread", "1/")
|
|
173
|
+
Also delegates to the generic calculate_quality_score for text analysis.
|
|
174
|
+
"""
|
|
175
|
+
text = tweet.get("text", "")
|
|
176
|
+
text_lower = text.lower()
|
|
177
|
+
|
|
178
|
+
# Start with the generic text-quality score from scoring_utils
|
|
179
|
+
score = calculate_quality_score(text)
|
|
180
|
+
|
|
181
|
+
# Bonus for URLs — twitter-cli puts urls as a top-level list of strings,
|
|
182
|
+
# X API v2 nests them under entities.urls as objects with expanded_url.
|
|
183
|
+
urls = tweet.get("urls", [])
|
|
184
|
+
entities = tweet.get("entities", {})
|
|
185
|
+
entity_urls = entities.get("urls", [])
|
|
186
|
+
all_urls = urls + [u.get("expanded_url", "") for u in entity_urls]
|
|
187
|
+
if all_urls:
|
|
188
|
+
score += 0.3
|
|
189
|
+
# Extra bonus for academic / paper links
|
|
190
|
+
for url_str in all_urls:
|
|
191
|
+
expanded = (url_str if isinstance(url_str, str) else "").lower()
|
|
192
|
+
if any(domain in expanded for domain in [
|
|
193
|
+
"arxiv.org", "openreview.net", "semanticscholar.org",
|
|
194
|
+
"aclanthology.org", "papers.nips.cc", "proceedings.mlr.press",
|
|
195
|
+
"huggingface.co/papers",
|
|
196
|
+
]):
|
|
197
|
+
score += 0.5
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
# Bonus for thread indicators
|
|
201
|
+
if any(ind in text_lower for ind in ["thread", "🧵", "1/"]):
|
|
202
|
+
score += 0.2
|
|
203
|
+
|
|
204
|
+
return min(score, SCORE_MAX)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_tweet_datetime(created_at: str) -> Optional[datetime]:
|
|
208
|
+
"""Parse the ISO-8601 created_at string from X API."""
|
|
209
|
+
if not created_at:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
# X returns e.g. "2024-01-15T12:34:56.000Z"
|
|
213
|
+
return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
214
|
+
except (ValueError, TypeError):
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Core pipeline
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def build_queries(
|
|
223
|
+
config: Dict,
|
|
224
|
+
extra_queries: Optional[List[str]] = None,
|
|
225
|
+
accounts: Optional[List[str]] = None,
|
|
226
|
+
) -> List[str]:
|
|
227
|
+
"""
|
|
228
|
+
Build a list of X search queries from config domains, extra queries, and
|
|
229
|
+
tracked accounts.
|
|
230
|
+
"""
|
|
231
|
+
queries: List[str] = []
|
|
232
|
+
|
|
233
|
+
# From research_domains config
|
|
234
|
+
domains = config.get("research_domains", {})
|
|
235
|
+
for domain_name, domain_config in domains.items():
|
|
236
|
+
keywords = domain_config.get("keywords", [])
|
|
237
|
+
for kw in keywords:
|
|
238
|
+
# Wrap multi-word keywords in quotes for exact matching
|
|
239
|
+
if " " in kw:
|
|
240
|
+
queries.append(f'"{kw}"')
|
|
241
|
+
else:
|
|
242
|
+
queries.append(kw)
|
|
243
|
+
|
|
244
|
+
# From --queries CLI arg
|
|
245
|
+
if extra_queries:
|
|
246
|
+
for q in extra_queries:
|
|
247
|
+
q = q.strip()
|
|
248
|
+
if q:
|
|
249
|
+
queries.append(q)
|
|
250
|
+
|
|
251
|
+
# From --accounts CLI arg: "from:handle" searches
|
|
252
|
+
if accounts:
|
|
253
|
+
for handle in accounts:
|
|
254
|
+
handle = handle.strip().lstrip("@")
|
|
255
|
+
if handle:
|
|
256
|
+
queries.append(f"from:{handle}")
|
|
257
|
+
|
|
258
|
+
# Deduplicate while preserving order
|
|
259
|
+
seen = set()
|
|
260
|
+
unique: List[str] = []
|
|
261
|
+
for q in queries:
|
|
262
|
+
q_lower = q.lower()
|
|
263
|
+
if q_lower not in seen:
|
|
264
|
+
seen.add(q_lower)
|
|
265
|
+
unique.append(q)
|
|
266
|
+
return unique
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def score_tweets(
|
|
270
|
+
tweets: List[Dict],
|
|
271
|
+
config: Dict,
|
|
272
|
+
) -> List[Dict]:
|
|
273
|
+
"""
|
|
274
|
+
Score and normalise a list of raw tweets from twitter-cli.
|
|
275
|
+
|
|
276
|
+
Returns a list of scored item dicts ready for output.
|
|
277
|
+
"""
|
|
278
|
+
domains = config.get("research_domains", {})
|
|
279
|
+
excluded_keywords = config.get("excluded_keywords", [])
|
|
280
|
+
scored: List[Dict] = []
|
|
281
|
+
|
|
282
|
+
for tweet in tweets:
|
|
283
|
+
tweet_id = tweet.get("id", "")
|
|
284
|
+
text = tweet.get("text", "")
|
|
285
|
+
created_at = tweet.get("createdAt", tweet.get("created_at", ""))
|
|
286
|
+
|
|
287
|
+
# twitter-cli nests author info under "author"
|
|
288
|
+
author = tweet.get("author", {})
|
|
289
|
+
username = author.get("screenName", author.get("username", "unknown"))
|
|
290
|
+
display_name = author.get("name", username)
|
|
291
|
+
avatar_url = author.get("profileImageUrl", author.get("profile_image_url", ""))
|
|
292
|
+
|
|
293
|
+
# Build a paper-like dict so calculate_relevance_score works
|
|
294
|
+
paper_proxy = {
|
|
295
|
+
"title": text[:100],
|
|
296
|
+
"summary": text,
|
|
297
|
+
"categories": [],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# -- Relevance --
|
|
301
|
+
relevance, matched_domain, matched_keywords = calculate_relevance_score(
|
|
302
|
+
paper_proxy, domains, excluded_keywords
|
|
303
|
+
)
|
|
304
|
+
# Even if relevance == 0 we still keep the tweet (it matched the
|
|
305
|
+
# search query itself), but give it a floor relevance of 0.5
|
|
306
|
+
if relevance == 0:
|
|
307
|
+
relevance = 0.5
|
|
308
|
+
matched_domain = matched_domain or "general"
|
|
309
|
+
matched_keywords = matched_keywords or []
|
|
310
|
+
|
|
311
|
+
# -- Recency --
|
|
312
|
+
published_dt = _parse_tweet_datetime(created_at)
|
|
313
|
+
recency = calculate_recency_score(published_dt)
|
|
314
|
+
|
|
315
|
+
# -- Popularity --
|
|
316
|
+
engagement = _tweet_engagement(tweet)
|
|
317
|
+
popularity = _calculate_popularity_score(engagement)
|
|
318
|
+
|
|
319
|
+
# -- Quality --
|
|
320
|
+
quality = _calculate_tweet_quality_score(tweet)
|
|
321
|
+
|
|
322
|
+
# -- Final score --
|
|
323
|
+
final_score = calculate_recommendation_score(
|
|
324
|
+
relevance, recency, popularity, quality, is_hot_paper=False
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Extract media URLs (photos only — video URLs return 403)
|
|
328
|
+
media_urls = [
|
|
329
|
+
m.get("url", "")
|
|
330
|
+
for m in tweet.get("media", [])
|
|
331
|
+
if m.get("url") and m.get("type") == "photo"
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
item = {
|
|
335
|
+
"id": tweet_id,
|
|
336
|
+
"title": text[:100],
|
|
337
|
+
"authors": f"@{username} ({display_name})",
|
|
338
|
+
"abstract": text,
|
|
339
|
+
"published": created_at,
|
|
340
|
+
"categories": [],
|
|
341
|
+
"relevance_score": round(relevance, 2),
|
|
342
|
+
"recency_score": round(recency, 2),
|
|
343
|
+
"popularity_score": round(popularity, 2),
|
|
344
|
+
"quality_score": round(quality, 2),
|
|
345
|
+
"final_score": final_score,
|
|
346
|
+
"matched_domain": matched_domain,
|
|
347
|
+
"matched_keywords": matched_keywords,
|
|
348
|
+
"link": f"https://x.com/{username}/status/{tweet_id}",
|
|
349
|
+
"source": "x",
|
|
350
|
+
"engagement": engagement,
|
|
351
|
+
"avatar_url": avatar_url,
|
|
352
|
+
"media_urls": media_urls,
|
|
353
|
+
}
|
|
354
|
+
scored.append(item)
|
|
355
|
+
|
|
356
|
+
return scored
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Main
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def main() -> int:
|
|
364
|
+
parser = argparse.ArgumentParser(
|
|
365
|
+
description="Fetch and score research-related tweets from X (Twitter) API v2"
|
|
366
|
+
)
|
|
367
|
+
parser.add_argument(
|
|
368
|
+
"--config", type=str, default=None,
|
|
369
|
+
help="Path to research interests YAML config file",
|
|
370
|
+
)
|
|
371
|
+
parser.add_argument(
|
|
372
|
+
"--output", type=str, default="x_filtered.json",
|
|
373
|
+
help="Output JSON file path",
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--top-n", type=int, default=10,
|
|
377
|
+
help="Number of top tweets to return",
|
|
378
|
+
)
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--queries", type=str, default=None,
|
|
381
|
+
help="Comma-separated search queries (e.g. 'LLM,GPT,AI agents')",
|
|
382
|
+
)
|
|
383
|
+
parser.add_argument(
|
|
384
|
+
"--accounts", type=str, default=None,
|
|
385
|
+
help="Comma-separated X handles to track (e.g. '_akhaliq,ylaboratory')",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
args = parser.parse_args()
|
|
389
|
+
|
|
390
|
+
logging.basicConfig(
|
|
391
|
+
level=logging.INFO,
|
|
392
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
393
|
+
datefmt="%H:%M:%S",
|
|
394
|
+
stream=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# --- Check twitter-cli is installed ---
|
|
398
|
+
try:
|
|
399
|
+
_check_twitter_cli()
|
|
400
|
+
except RuntimeError as e:
|
|
401
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
402
|
+
return 1
|
|
403
|
+
|
|
404
|
+
# --- Load config ---
|
|
405
|
+
if args.config:
|
|
406
|
+
config = load_research_config(args.config)
|
|
407
|
+
else:
|
|
408
|
+
config = {
|
|
409
|
+
"research_domains": {},
|
|
410
|
+
"excluded_keywords": [],
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# --- Build queries ---
|
|
414
|
+
extra_queries = (
|
|
415
|
+
[q.strip() for q in args.queries.split(",") if q.strip()]
|
|
416
|
+
if args.queries
|
|
417
|
+
else None
|
|
418
|
+
)
|
|
419
|
+
accounts = (
|
|
420
|
+
[a.strip() for a in args.accounts.split(",") if a.strip()]
|
|
421
|
+
if args.accounts
|
|
422
|
+
else None
|
|
423
|
+
)
|
|
424
|
+
queries = build_queries(config, extra_queries, accounts)
|
|
425
|
+
|
|
426
|
+
if not queries:
|
|
427
|
+
logger.error(
|
|
428
|
+
"No search queries. Provide --config, --queries, or --accounts."
|
|
429
|
+
)
|
|
430
|
+
return 1
|
|
431
|
+
|
|
432
|
+
logger.info("Will search %d queries: %s", len(queries), queries)
|
|
433
|
+
|
|
434
|
+
# --- Fetch tweets ---
|
|
435
|
+
all_tweets: List[Dict] = []
|
|
436
|
+
seen_tweet_ids: set = set()
|
|
437
|
+
|
|
438
|
+
for query in queries:
|
|
439
|
+
tweets = search_via_twitter_cli(query)
|
|
440
|
+
for t in tweets:
|
|
441
|
+
tid = t.get("id")
|
|
442
|
+
if tid and tid not in seen_tweet_ids:
|
|
443
|
+
seen_tweet_ids.add(tid)
|
|
444
|
+
all_tweets.append(t)
|
|
445
|
+
|
|
446
|
+
total_found = len(all_tweets)
|
|
447
|
+
logger.info("Total unique tweets fetched: %d", total_found)
|
|
448
|
+
|
|
449
|
+
if total_found == 0:
|
|
450
|
+
logger.warning("No tweets found for any query.")
|
|
451
|
+
|
|
452
|
+
# --- Score ---
|
|
453
|
+
scored_items = score_tweets(all_tweets, config)
|
|
454
|
+
|
|
455
|
+
# Sort by final_score descending
|
|
456
|
+
scored_items.sort(key=lambda x: x["final_score"], reverse=True)
|
|
457
|
+
|
|
458
|
+
top_items = scored_items[: args.top_n]
|
|
459
|
+
|
|
460
|
+
# --- Output ---
|
|
461
|
+
output = {
|
|
462
|
+
"top_papers": top_items,
|
|
463
|
+
"total_found": total_found,
|
|
464
|
+
"total_filtered": len(scored_items),
|
|
465
|
+
"search_date": datetime.now().strftime("%Y-%m-%d"),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# Write to file
|
|
469
|
+
output_path = Path(args.output)
|
|
470
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
471
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
472
|
+
json.dump(output, f, ensure_ascii=False, indent=2)
|
|
473
|
+
|
|
474
|
+
logger.info("Results saved to: %s", args.output)
|
|
475
|
+
for i, item in enumerate(top_items, 1):
|
|
476
|
+
logger.info(
|
|
477
|
+
" %d. @%s — %s... (score: %.2f)",
|
|
478
|
+
i,
|
|
479
|
+
item["authors"].split(" ")[0],
|
|
480
|
+
item["title"][:50],
|
|
481
|
+
item["final_score"],
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Also write to stdout for piping
|
|
485
|
+
print(json.dumps(output, ensure_ascii=True, indent=2))
|
|
486
|
+
return 0
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if __name__ == "__main__":
|
|
490
|
+
sys.exit(main())
|