Files
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

191 lines
6.1 KiB
Python

"""OpenAlex 搜索命令行工具。"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any
try:
import requests
except ImportError:
print("警告: 未安装 requests,已跳过 OpenAlex CLI。", file=sys.stderr)
sys.exit(0)
API_URL = "https://api.openalex.org/works"
REQUEST_TIMEOUT_SECONDS = 30.0
USER_AGENT = "openalex_fetch.py/1.0"
def _clean_text(value: Any) -> str:
"""压缩空白并清理文本值。"""
return " ".join(str(value).split())
def _normalize_year_filter(raw_year: str) -> str:
"""将命令行年份参数转换为 OpenAlex filter 语法。"""
value = raw_year.strip()
if not value:
raise ValueError("年份参数不能为空")
if value.isdigit() and len(value) == 4:
return f"publication_year:{value}"
if value.endswith("-") and value[:-1].strip().isdigit():
start_year = value[:-1].strip()
return f"from_publication_date:{start_year}-01-01"
if value.startswith("-") and value[1:].strip().isdigit():
end_year = value[1:].strip()
return f"to_publication_date:{end_year}-12-31"
if "-" in value:
start_year, end_year = [part.strip() for part in value.split("-", 1)]
if start_year.isdigit() and end_year.isdigit():
return f"publication_year:{start_year}-{end_year}"
raise ValueError(f"无法识别的年份参数: {raw_year}")
def _normalize_sort_value(sort_value: str) -> str:
"""将用户输入的排序参数转换为 OpenAlex sort 语法。"""
value = sort_value.strip()
if not value:
raise ValueError("排序参数不能为空")
if value in {"relevance", "relevance_score"}:
return "relevance_score:desc"
return value
def _normalize_work(work: dict[str, Any]) -> dict[str, Any]:
"""将 OpenAlex work 记录压缩成稳定输出结构。"""
authorships = work.get("authorships")
authors: list[str] = []
institutions: list[str] = []
if isinstance(authorships, list):
for authorship in authorships:
if not isinstance(authorship, dict):
continue
author = authorship.get("author")
if isinstance(author, dict):
display_name = author.get("display_name")
if display_name:
cleaned_name = _clean_text(display_name)
if cleaned_name not in authors:
authors.append(cleaned_name)
author_institutions = authorship.get("institutions")
if isinstance(author_institutions, list):
for institution in author_institutions:
if not isinstance(institution, dict):
continue
display_name = institution.get("display_name")
if display_name:
cleaned_name = _clean_text(display_name)
if cleaned_name not in institutions:
institutions.append(cleaned_name)
return {
"title": _clean_text(work.get("display_name", "")),
"authors": authors,
"year": work.get("publication_year"),
"cited_by_count": work.get("cited_by_count"),
"institutions": institutions,
"doi": work.get("doi"),
}
def search_openalex(
query: str,
max_results: int,
year: str | None,
work_type: str | None,
sort: str,
) -> list[dict[str, Any]]:
"""搜索 OpenAlex 并返回标准化论文结果。"""
params: dict[str, Any] = {
"search": query,
"per-page": max_results,
"sort": _normalize_sort_value(sort),
}
filters: list[str] = []
if year:
filters.append(_normalize_year_filter(year))
if work_type:
filters.append(f"type:{work_type.strip()}")
if filters:
params["filter"] = ",".join(filters)
response = requests.get(
API_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("OpenAlex 返回了非对象类型的 JSON")
works = payload.get("results", [])
if not isinstance(works, list):
raise ValueError("OpenAlex 响应缺少 results 列表")
normalized_results: list[dict[str, Any]] = []
for work in works:
if isinstance(work, dict):
normalized_results.append(_normalize_work(work))
return normalized_results
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="OpenAlex 搜索工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索 OpenAlex works")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
search_parser.add_argument("--year", default=None, help="年份或年份范围")
search_parser.add_argument(
"--type", dest="work_type", default=None, help="作品类型"
)
search_parser.add_argument(
"--sort",
default="relevance",
help="排序字段,relevance 会映射为 relevance_score:desc",
)
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_openalex(
query=args.query,
max_results=args.max_results,
year=args.year,
work_type=args.work_type,
sort=args.sort,
)
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())