zhoujie ea69788d0c feat(literature-search-verify): 新增 OpenAlex 检索源、限流重试与单元测试
新增 scripts/http_utils.py 统一封装 HTTP 请求的限流重试(429/5xx 指数退避,
优先遵守服务端 Retry-After),search_arxiv/crossref/semantic_scholar.py 和
verify_citation.py 都已接入,不再各自裸调 urllib。

新增 scripts/search_openalex.py 作为第四个检索源:免费、无需 API key,覆盖面
比单独的 Crossref 更广,还能拿到开放获取PDF直链;已接入 literature_search.py
的主检索流程。verify_citation.py 的跨源标题核查同步改为同时查 Semantic
Scholar 和 OpenAlex 两个独立源、任一命中相似度达标即通过,不再单点依赖 S2——
这是针对"S2 被限流导致整批候选退化成 unverified"这个实际发生过的问题的直接
修复,已用真实网络请求验证:复测中 S2 确实当场返回了 429,靠 OpenAlex 兜底
最终判定仍然是 verified。

顺带修了 archive_references.py 的 slugify(),之前中文主题名会被正则全部
过滤掉、退化成通用的 "references",导致不同中文主题的归档目录互相冲突。

scripts/tests/ 下补了 37 个 unittest(全部 mock 网络请求,不发真实请求),
覆盖 verify_citation 的三档判定和双源核查合并逻辑、archive_references 的
bib 解析边界情况(嵌套花括号、中文主题名)、literature_search 的候选去重
合并与 BibTeX 生成、http_utils 的重试逻辑。用标准库 unittest 而不是 pytest,
和这些脚本本身不引入第三方依赖的原则保持一致。

CLAUDE.md 的下一步计划里,这一项已标记为完成。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:53:27 -10:00

169 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""
End-to-end literature search: query arXiv + Semantic Scholar + Crossref +
OpenAlex, merge/dedupe candidates, independently verify each one, and emit
both a human-readable report and BibTeX for the entries that passed
verification.
This is the one script Claude should actually call for a normal literature
search -- the individual search_*.py / verify_citation.py scripts exist
mainly as building blocks it can reuse for one-off / follow-up lookups.
CLI usage:
python3 literature_search.py "UAV magnetic compensation Tolles-Lawson" \\
--max-per-source 8 --bib-out refs.bib
Output: prints a JSON report to stdout (one entry per merged candidate,
with its verdict), and if --bib-out is given, writes BibTeX for every
"verified" entry to that file (never for "suspect" or "unverified" ones).
"""
import sys
import os
import json
import argparse
import difflib
import re
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from search_arxiv import search_arxiv
from search_semantic_scholar import search_s2
from search_crossref import search_crossref
from search_openalex import search_openalex
from verify_citation import verify
def _similar(a, b, threshold=0.88):
if not a or not b:
return False
return difflib.SequenceMatcher(None, a.lower().strip(), b.lower().strip()).ratio() >= threshold
def merge_candidates(all_results):
"""Dedupe candidates that are the same paper found via multiple sources,
merging their metadata (preferring whichever source has an identifier)."""
merged = []
for item in all_results:
placed = False
for m in merged:
if _similar(item.get("title"), m.get("title")):
# merge: fill in any missing fields, keep track of all sources
for key in ("doi", "arxiv_id", "abstract", "venue", "year", "citation_count", "pdf_url"):
if not m.get(key) and item.get(key):
m[key] = item[key]
m["sources"] = sorted(set(m.get("sources", [m.get("source")]) + [item.get("source")]))
placed = True
break
if not placed:
item = dict(item)
item["sources"] = [item.get("source")]
merged.append(item)
return merged
def make_bibtex_key(candidate, used_keys):
authors = candidate.get("authors") or []
surname = "unknown"
if authors:
first_author = authors[0]
surname = first_author.strip().split()[-1].lower()
surname = re.sub(r"[^a-z]", "", surname) or "unknown"
year = str(candidate.get("year") or "nd")
base = f"{surname}{year}"
key = base
suffix = ord("a")
while key in used_keys:
key = f"{base}{chr(suffix)}"
suffix += 1
used_keys.add(key)
return key
def to_bibtex(candidate, key):
authors = candidate.get("authors") or []
author_str = " and ".join(authors) if authors else "Unknown"
title = candidate.get("title") or ""
year = candidate.get("year") or ""
venue = candidate.get("venue") or ""
doi = candidate.get("doi") or ""
arxiv_id = candidate.get("arxiv_id") or ""
if arxiv_id and not venue:
entry_type = "misc"
fields = [
("author", author_str),
("title", title),
("year", str(year)),
("eprint", arxiv_id),
("archivePrefix", "arXiv"),
]
else:
entry_type = "article"
fields = [
("author", author_str),
("title", title),
("journal", venue),
("year", str(year)),
]
if doi:
fields.append(("doi", doi))
lines = [f"@{entry_type}{{{key},"]
for k, v in fields:
if v:
lines.append(f" {k} = {{{v}}},")
lines.append("}")
return "\n".join(lines)
def run(query, max_per_source=8):
all_results = []
errors = {}
sources = (
("arxiv", search_arxiv),
("semantic_scholar", search_s2),
("crossref", search_crossref),
("openalex", search_openalex),
)
for name, fn in sources:
try:
all_results.extend(fn(query, max_per_source))
except Exception as e:
errors[name] = str(e)
merged = merge_candidates(all_results)
used_keys = set()
for cand in merged:
result = verify(title=cand.get("title"), arxiv_id=cand.get("arxiv_id"), doi=cand.get("doi"))
cand["verdict"] = result["verdict"]
cand["verification_checks"] = result["checks"]
if result["verdict"] == "verified":
cand["bibtex_key"] = make_bibtex_key(cand, used_keys)
return {"query": query, "search_errors": errors, "candidates": merged}
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("query")
ap.add_argument("--max-per-source", type=int, default=8)
ap.add_argument("--bib-out", default=None, help="path to write BibTeX for verified entries")
args = ap.parse_args()
try:
report = run(args.query, args.max_per_source)
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
sys.exit(1)
print(json.dumps(report, ensure_ascii=False, indent=2))
if args.bib_out:
verified = [c for c in report["candidates"] if c["verdict"] == "verified"]
with open(args.bib_out, "w", encoding="utf-8") as f:
for cand in verified:
f.write(to_bibtex(cand, cand["bibtex_key"]))
f.write("\n\n")
sys.stderr.write(f"Wrote {len(verified)} verified BibTeX entries to {args.bib_out}\n")