新增 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>
169 lines
5.5 KiB
Python
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")
|