Files
Video-Tree-TRM5/.claude/tools/semantic_scholar_fetch.py
T
iomgaa 6bdb802f01 chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
2026-07-06 20:59:03 -04:00

225 lines
6.9 KiB
Python

"""Semantic Scholar 搜索命令行工具。"""
from __future__ import annotations
import argparse
import json
import sys
import time
from typing import Any
try:
import requests
except ImportError:
print("警告: 未安装 requests,已跳过 Semantic Scholar CLI。", file=sys.stderr)
sys.exit(0)
API_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
REQUEST_FIELDS = "title,authors,year,venue,citationCount,externalIds,tldr"
REQUEST_INTERVAL_SECONDS = 1.0
REQUEST_TIMEOUT_SECONDS = 30.0
USER_AGENT = "semantic_scholar_fetch.py/1.0"
_LAST_REQUEST_AT: float | None = None
def _normalize_filter_values(values: list[str] | None) -> str | None:
"""将过滤参数整理为 API 需要的逗号分隔字符串。"""
if not values:
return None
items: list[str] = []
for value in values:
for item in value.split(","):
cleaned = item.strip()
if cleaned:
items.append(cleaned)
if not items:
return None
return ",".join(items)
def _respect_rate_limit() -> None:
"""确保连续请求之间至少间隔一秒。"""
global _LAST_REQUEST_AT
now = time.monotonic()
if _LAST_REQUEST_AT is not None:
elapsed = now - _LAST_REQUEST_AT
if elapsed < REQUEST_INTERVAL_SECONDS:
time.sleep(REQUEST_INTERVAL_SECONDS - elapsed)
_LAST_REQUEST_AT = time.monotonic()
def _clean_text(value: Any) -> str:
"""压缩并清理任意文本值。"""
return " ".join(str(value).split())
def _normalize_authors(authors: Any) -> list[str]:
"""将作者字段统一为作者姓名列表。"""
normalized_authors: list[str] = []
if not isinstance(authors, list):
return normalized_authors
for author in authors:
if isinstance(author, dict):
name = author.get("name")
if name:
normalized_authors.append(_clean_text(name))
elif author:
normalized_authors.append(_clean_text(author))
return normalized_authors
def _normalize_external_ids(external_ids: Any) -> dict[str, str]:
"""将 externalIds 统一整理为字符串字典,并保留 arXiv 标识。"""
normalized: dict[str, str] = {}
if isinstance(external_ids, dict):
for key, value in external_ids.items():
if value is None:
continue
cleaned_value = _clean_text(value)
if cleaned_value:
normalized[str(key)] = cleaned_value
arxiv_id = (
normalized.get("ArXiv") or normalized.get("arXiv") or normalized.get("arxiv")
)
if arxiv_id and "ArXiv" not in normalized:
normalized["ArXiv"] = arxiv_id
return normalized
def _normalize_tldr(tldr: Any) -> Any:
"""保留 TLDR 字段原始结构,但去除明显的空字符串。"""
if isinstance(tldr, dict):
normalized_tldr: dict[str, Any] = {}
for key, value in tldr.items():
if isinstance(value, str):
cleaned = _clean_text(value)
if cleaned:
normalized_tldr[key] = cleaned
elif value is not None:
normalized_tldr[key] = value
return normalized_tldr or None
return tldr
def _normalize_paper(paper: dict[str, Any]) -> dict[str, Any]:
"""将 API 返回的论文记录压缩成稳定的输出结构。"""
return {
"title": _clean_text(paper.get("title", "")),
"authors": _normalize_authors(paper.get("authors")),
"year": paper.get("year"),
"venue": _clean_text(paper.get("venue", "")),
"citationCount": paper.get("citationCount"),
"externalIds": _normalize_external_ids(paper.get("externalIds")),
"tldr": _normalize_tldr(paper.get("tldr")),
}
def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
"""发起受限频率控制的 GET 请求并返回 JSON 对象。"""
_respect_rate_limit()
response = requests.get(
url,
params=params,
headers={"User-Agent": USER_AGENT},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Semantic Scholar 返回了非对象类型的 JSON")
return payload
def search_papers(
query: str,
max_results: int,
fields_of_study: list[str] | None = None,
publication_types: list[str] | None = None,
) -> list[dict[str, Any]]:
"""搜索 Semantic Scholar 论文并返回标准化结果。"""
params: dict[str, Any] = {
"query": query,
"limit": max_results,
"fields": REQUEST_FIELDS,
}
normalized_fields_of_study = _normalize_filter_values(fields_of_study)
if normalized_fields_of_study is not None:
params["fieldsOfStudy"] = normalized_fields_of_study
normalized_publication_types = _normalize_filter_values(publication_types)
if normalized_publication_types is not None:
params["publicationTypes"] = normalized_publication_types
payload = _request_json(API_URL, params)
papers = payload.get("data", [])
if not isinstance(papers, list):
raise ValueError("Semantic Scholar 响应缺少 data 列表")
normalized_papers: list[dict[str, Any]] = []
for paper in papers:
if isinstance(paper, dict):
normalized_papers.append(_normalize_paper(paper))
return normalized_papers
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="Semantic Scholar 搜索工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索论文")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
search_parser.add_argument(
"--fields-of-study",
nargs="+",
default=None,
help="按学科领域过滤",
)
search_parser.add_argument(
"--publication-types",
nargs="+",
default=None,
help="按出版类型过滤",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "search":
results = search_papers(
query=args.query,
max_results=args.max_results,
fields_of_study=args.fields_of_study,
publication_types=args.publication_types,
)
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
except (requests.RequestException, ValueError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
parser.error(f"未知命令: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())