从 AI 回答里复制出来的参考文献,可能 DOI 不存在、标题对不上,或者论文已经被撤稿。 这篇做
ref_check.py:用 Crossref 公开 REST API 逐条核对 DOI、标题、年份和撤稿状态。
先看清成品
- 真实调用 Crossref:5 条参考文献中 2 条通过,3 条被标出。
- 发现 2013 年 PREDIMED 论文(10.1056/NEJMoa1200303)已撤稿(2018-06-13,数据来源 Retraction Watch),2018 年重新发表的版本是另一个 DOI。
- 我凭记忆写的一个 DOI 在 Crossref 中不存在(404),正好演示了 AI 编造 DOI 的情况。pytest 5 项通过(离线,使用真实响应的缓存)。
合规提醒:Consensus 的回答和要点摘录是 AI 生成的,可能遗漏或误读论文;引用时必须引用原论文,并阅读原文核对。医疗、健康、法律问题请咨询专业人士,不要把检索结果当成诊疗建议。本文只使用官方功能:Consensus 网页版、官方 API(x-api-key),以及 Crossref 公开 REST API。不抓取网页,不共享账号。调用次数和套餐以官方页面为准(本文不列价格)。
运行说明:本篇真实调用了 Crossref 公开 API(2026-10-21,UTC+8),不需要账号。测试使用当时取回并缓存的字段,可以离线运行。参考文献清单是我写的示例,其中的 DOI 都是真实存在或经 Crossref 确认不存在的。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
ref_check.py |
核对参考文献 |
references.csv |
5 条示例参考文献 |
crossref_cache.json |
Crossref 真实响应(只保留用到的字段) |
test_refs.py |
5 项离线测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install requests pytest |
| 网络 | 访问 api.crossref.org;不需要 key |
| 可选 | 设置 CROSSREF_MAILTO=你的邮箱,按 Crossref 说明进入 polite 池 |
关键事实
| 事实 | 来源 |
|---|---|
Crossref REST API 公开免费,GET https://api.crossref.org/works/{DOI} 返回元数据;DOI 不存在时返回 404 |
Crossref REST API 文档;本文实测 |
Crossref 已整合 Retraction Watch 数据库,撤稿信息出现在 updated-by 中,type 为 retraction,source 为 retraction-watch |
Crossref Retraction Watch 说明;本文实测 |
| 撤稿论文的标题前会加 “RETRACTED:” | 本文实测(10.1056/NEJMoa1200303) |
文件结构
71-consensus-B-src/
├── ref_check.py
├── references.csv
├── crossref_cache.json
└── test_refs.py
第 1 步:示例清单
doi,title,year
10.1038/nature14539,Deep learning,2015
https://doi.org/10.1126/science.aaa8415,Machine learning: Trends perspectives and prospects,2015
10.1056/NEJMoa1200303,Primary Prevention of Cardiovascular Disease with a Mediterranean Diet,2013
10.1056/NEJMoa1800389,Primary Prevention of Cardiovascular Disease with a Mediterranean Diet,2013
10.1056/NEJMoa1307687,Mediterranean diet and cardiovascular outcomes,2014
第 2 步:核对脚本
"""核对一份参考文献清单(例如从 Consensus 回答里复制出来的 DOI、标题、年份),数据来自 Crossref 公开 REST API(无需 key)。
检查:DOI 是否存在、标题是否一致、年份是否一致(允许 ±1:网络首发和纸质出版常差一年)、是否被撤稿/有关注声明。
Crossref 礼仪:串行请求、有缓存;设置环境变量 CROSSREF_MAILTO 可进入官方说明的 polite 池。"""
import csv, difflib, json, os, re, sys, time, requests
API = os.environ.get("CROSSREF_API", "https://api.crossref.org")
MAILTO = os.environ.get("CROSSREF_MAILTO")
UA = "binbho-tutorial/1.0" + (f" (mailto:{MAILTO})" if MAILTO else "")
FLAG_TYPES = {"retraction": "已撤稿", "withdrawal": "已撤回", "expression_of_concern": "有关注声明", "removal": "已移除"}
def norm(t):
t = re.sub(r"^(retracted|withdrawn)\s*:\s*", "", t.strip(), flags=re.I) # Crossref 会在撤稿论文标题前加 “RETRACTED:”
t = re.sub(r"<[^>]+>", "", t) # 有些标题带 <i> 等标签
return re.sub(r"[^\w]+", " ", t.lower()).strip()
def clean_doi(d):
return re.sub(r"^(https?://(dx\.)?doi\.org/|doi:\s*)", "", d.strip(), flags=re.I).lower()
def fetch(doi, cache):
if doi in cache: return cache[doi]
r = requests.get(f"{API}/works/{doi}", headers={"User-Agent": UA}, timeout=30); time.sleep(0.2)
if r.status_code == 404: cache[doi] = None
else:
r.raise_for_status(); m = r.json()["message"]
cache[doi] = {k: m.get(k) for k in ("title", "issued", "container-title", "updated-by")} # 只缓存用到的字段
return cache[doi]
def check(ref, cache):
doi = clean_doi(ref["doi"]); m = fetch(doi, cache); issues = []
if m is None: return {"doi": doi, "status": "DOI 不存在", "issues": ["Crossref 返回 404:DOI 可能是编出来的或抄错了"]}
real_title = (m.get("title") or [""])[0]
sim = difflib.SequenceMatcher(None, norm(ref.get("title", "")), norm(real_title)).ratio()
if ref.get("title") and sim < 0.85: issues.append(f"标题不符(相似度 {sim:.2f}):实际是《{real_title}》")
year = (m.get("issued", {}).get("date-parts") or [[None]])[0][0]
if ref.get("year") and year and abs(int(ref["year"]) - year) > 1: issues.append(f"年份不符:写的是 {ref['year']},实际 {year}")
for u in m.get("updated-by") or []: # 注意字段名是 type,不是 update-type
if u.get("type") in FLAG_TYPES:
d = u.get("updated", {}).get("date-parts", [[None]])[0]
issues.append(f"{FLAG_TYPES[u['type']]}({'-'.join(map(str, d))},通知 DOI {u.get('DOI')},来源 {u.get('source', '?')})")
status = "需核对" if issues else "通过"
return {"doi": doi, "status": status, "issues": issues, "title": real_title, "year": year, "journal": (m.get("container-title") or [""])[0]}
def main(argv=None):
argv = argv or sys.argv[1:]; src = argv[0]; cache_path = argv[1] if len(argv) > 1 else "crossref_cache.json"
cache = json.load(open(cache_path)) if os.path.exists(cache_path) else {}
bad = 0
for ref in csv.DictReader(open(src, encoding="utf-8")):
r = check(ref, cache); bad += r["status"] != "通过"
print(f"[{r['status']}] {r['doi']}"); [print(" -", i) for i in r["issues"]]
json.dump(cache, open(cache_path, "w"), ensure_ascii=False)
print(f"共 {bad} 条需要处理"); return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:测试
"""离线测试:crossref_cache.json 是 2026-10-21 从 Crossref 真实取回的字段(运行 ref_check.py 时生成)。"""
import csv, json, ref_check as R
CACHE = json.load(open("crossref_cache.json"))
REFS = {R.clean_doi(r["doi"]): r for r in csv.DictReader(open("references.csv", encoding="utf-8"))}
def run(doi): return R.check(REFS[doi], dict(CACHE))
def test_ok_and_url_form():
assert run("10.1038/nature14539")["status"] == "通过"
assert run("10.1126/science.aaa8415")["status"] == "通过" # 输入是 https://doi.org/… 形式,标题少了逗号
def test_retracted():
r = run("10.1056/nejmoa1200303")
assert r["status"] == "需核对" and r["issues"][0].startswith("已撤稿(2018-6-13")
assert r["title"].startswith("RETRACTED:") # 标题前缀不算“标题不符”
assert not any("标题不符" in i for i in r["issues"])
def test_republished_version_mismatch():
r = run("10.1056/nejmoa1800389")
assert any("标题不符" in i for i in r["issues"]) and any("实际 2018" in i for i in r["issues"])
def test_made_up_doi():
assert run("10.1056/nejmoa1307687")["status"] == "DOI 不存在"
def test_no_network_when_cached(monkeypatch):
monkeypatch.setattr(R.requests, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError("不应联网")))
for d in REFS: run(d)
第 4 步:运行
$ python ref_check.py references.csv
[通过] 10.1038/nature14539
[通过] 10.1126/science.aaa8415
[需核对] 10.1056/nejmoa1200303
- 已撤稿(2018-6-13,通知 DOI 10.1056/nejmc1806491,来源 retraction-watch)
[需核对] 10.1056/nejmoa1800389
- 标题不符(相似度 0.74):实际是《Primary Prevention of Cardiovascular Disease with a Mediterranean Diet Supplemented with Extra-Virgin Olive Oil or Nuts》
- 年份不符:写的是 2013,实际 2018
[DOI 不存在] 10.1056/nejmoa1307687
- Crossref 返回 404:DOI 可能是编出来的或抄错了
共 3 条需要处理
$ python -m pytest -q
5 passed
怎么检查结果?
- “需核对”的每一条都去出版社页面看一眼。
- 已撤稿的论文不能作为证据引用;如果确实要提,要同时说明撤稿。
- 年份允许相差 1 年(网络首发和纸质出版常跨年)。
- 通过只代表元数据一致,不代表论文支持你的结论。
调试示例
1. 我凭记忆写的 DOI 不存在。 准备示例时,我从记忆中写了一个 PREDIMED 相关的 DOI 10.1056/NEJMoa1307687,Crossref 返回 404。这正是 AI 回答中常见的问题:格式完全正确的 DOI,实际不存在。我把它保留在清单里作为反例。
2. 撤稿字段读错了。 第一次查看 10.1056/NEJMoa1200303 时,我读的是 updated-by[].update-type,结果为 None,看起来像没有撤稿。打印完整字段后发现正确的字段名是 type:
{"DOI": "10.1056/nejmc1806491", "type": "retraction", "label": "Retraction", "source": "retraction-watch", "updated": {"date-parts": [[2018, 6, 13]]}}
3. 同名的重新发表版本。 10.1056/NEJMoa1800389 是 2018 年重新发表的版本,标题多了 “Supplemented with Extra-Virgin Olive Oil or Nuts”。清单中按 2013 年的标题和年份写,被标为标题不符(相似度 0.74)和年份不符。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 全部 404 | DOI 带了多余字符 | 脚本会去掉 https://doi.org/ 和 doi: 前缀;检查空格 |
| 标题不符但其实是同一篇 | 标题带副标题或特殊符号 | 人工核对;必要时调整 0.85 的阈值 |
| 请求很慢 | Crossref 负载高 | 设置 CROSSREF_MAILTO;结果有缓存,重跑很快 |
| 没有撤稿但有争议 | 关注声明等也会列出 | 看 issues 中的“有关注声明” |
模板规则
- 引用前核对每个 DOI。
- 撤稿论文不作为证据。
- 保留缓存文件,方便复查。
- 不要相信 AI 给出的 DOI,直到 Crossref 确认。
项目资源
| 用途 | 地址 |
|---|---|
| Consensus 官网 | https://consensus.app/ |
| Consensus API 入门(官方) | https://docs.consensus.app/api-get-started |
| Consensus Search 接口参考(官方) | https://docs.consensus.app/api-reference/search |
| Crossref REST API 文档 | https://www.crossref.org/documentation/retrieve-metadata/rest-api/ |
| Crossref 中的 Retraction Watch 数据 | https://www.crossref.org/documentation/retrieve-metadata/retraction-watch/ |
格式正确的 DOI 不等于真实存在的论文,核对一遍只需要几秒钟。