Consensus 的官方 API 可以按研究问题检索论文,并返回研究类型、样本量、是否预印本等字段。 这篇做
evidence_table.py:把检索结果整理成按证据类型排序的阅读清单(Markdown + CSV),自动去重,并标出预印本、小样本、动物研究和缺少 DOI 的条目。
先看清成品
- 用无效 key 真实调用 Consensus API,得到 401,脚本给出可读提示。
- 本地替身上跑通:自动翻页、在 is_end 处停止;7 篇去重为 6 篇;荟萃分析排第一,RCT 按年份排序。
- 发现并修正:布尔参数被 requests 发成 "True",改为 "true"。pytest 5 项通过。
合规提醒:Consensus 的回答和要点摘录是 AI 生成的,可能遗漏或误读论文;引用时必须引用原论文,并阅读原文核对。医疗、健康、法律问题请咨询专业人士,不要把检索结果当成诊疗建议。本文只使用官方功能:Consensus 网页版、官方 API(x-api-key),以及 Crossref 公开 REST API。不抓取网页,不共享账号。调用次数和套餐以官方页面为准(本文不列价格)。
运行说明:box 上没有 Consensus 账号和 API key。我用无效 key 和不带 key 各真实调用了一次
GET https://api.consensus.app/v1/search,分别得到 HTTP 401{"detail":"Invalid authentication credentials."}和{"detail":"Not authenticated"}(2026-10-21 20:06,UTC+8)。A 篇其余响应来自我写的本地替身(字段名照官方 OpenAPI,论文是虚构的,DOI 用 10.5555 测试前缀)。B 篇真实调用了 Crossref 公开 API。C 篇是纯本地规则。所有脚本和测试都在 box 上真实运行过(Python 3.13)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
evidence_table.py |
检索并生成证据表 |
fake_consensus.py |
本地替身(字段照官方 OpenAPI,数据虚构) |
demo_local.py |
用替身演示 |
test_evidence.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install requests pytest |
| Consensus | A 篇需要 API key(在 Consensus 账户中创建,免费套餐也有少量调用次数,以官方说明为准);B、C 篇不需要 |
| 网络 | B 篇需要访问 api.crossref.org |
关键事实
| 事实 | 来源 |
|---|---|
接口 GET https://api.consensus.app/v1/search,请求头 x-api-key;旧接口 /v1/quick_search 已弃用,将于 2027-02-07 移除 |
官方 Search 接口参考 |
筛选参数:year_min、study_types、human、exclude_preprints、sample_size_min、sjr_max 等 |
同上 |
分页:page 从 0 开始,(page + 1) × page_size 不能超过 1000;响应中有 is_end、next_page |
同上 |
| 计费:每返回 100 篇论文算 1 次调用(向上取整,至少 1 次) | 同上,OpenAPI 描述 |
结果字段包括 study_type、sample_size、is_preprint、population_type、takeaway、citation_count |
同上 |
本文按“系统综述/荟萃分析 → RCT → 观察性研究 → 综述 → 个案 → 动物/体外”排序,只是阅读顺序,不代表单篇论文质量;一项设计差的荟萃分析不如一项好的 RCT。
文件结构
71-consensus-A-src/
├── evidence_table.py
├── fake_consensus.py
├── demo_local.py
└── test_evidence.py
第 1 步:创建 API key(手动)
登录 Consensus,在账户设置中创建 API key,放进环境变量 CONSENSUS_API_KEY。(本文没有账号,没有实际操作这一步。)
第 2 步:主脚本
"""用 Consensus 官方 API(GET /v1/search)把一个研究问题的检索结果整理成证据表(Markdown + CSV)。
- 认证:请求头 x-api-key(官方文档);key 放在环境变量 CONSENSUS_API_KEY
- 分页:page 从 0 开始,看 is_end / next_page;(page+1)×page_size 不能超过 1000
- 每 100 篇结果算 1 次调用(官方说明),所以默认只取 1 页、20 篇
证据表只是阅读清单,不是结论:每篇都要打开原文核对。"""
import argparse, csv, os, sys, requests
API = os.environ.get("CONSENSUS_API", "https://api.consensus.app")
# 证据等级排序(数值越小越靠前);只用于排序阅读顺序,不代表单篇论文质量
RANK = {"systematic review": 0, "meta-analysis": 0, "rct": 1, "non-rct experimental": 2, "non-randomized experimental study": 2,
"cohort study": 3, "case-control study": 3, "longitudinal / panel data study": 3, "cross-sectional study": 4,
"non-rct observational study": 4, "literature review": 5, "case report": 6, "case study": 6, "animal": 7,
"non-rct in vitro": 7, "bench experiment": 7, "commentary or perspective": 8}
class ConsensusError(Exception): pass
def search(query, key=None, pages=1, **filters):
key = key or os.environ.get("CONSENSUS_API_KEY")
if not key: raise ConsensusError("没有设置 CONSENSUS_API_KEY")
h = {"x-api-key": key, "User-Agent": "binbho-tutorial/1.0"}
out, page = [], 0
# 布尔值按 true/false 发送(requests 默认会把 True 变成字符串 "True")
params = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in filters.items() if v is not None}
for _ in range(pages):
r = requests.get(f"{API}/v1/search", params={"query": query, "page": page, **params}, headers=h, timeout=60)
if r.status_code == 401: raise ConsensusError(f"401:{r.json().get('detail')}(检查 API key)")
if r.status_code == 422: raise ConsensusError(f"422 参数错误:{r.json().get('detail')}")
r.raise_for_status(); j = r.json(); out += j["results"]
if j.get("is_end") or j.get("next_page") is None: break
page = j["next_page"]
return out
def flags(p, min_n=100):
f = []
if p.get("is_preprint"): f.append("预印本")
if p.get("sample_size") is not None and p["sample_size"] < min_n and (p.get("study_type") or "") not in ("meta-analysis", "systematic review"): f.append(f"样本 {p['sample_size']}")
if p.get("population_type") and p["population_type"] != "human": f.append(p["population_type"])
if not p.get("doi"): f.append("无 DOI")
return ";".join(f)
def table(papers):
rows = []
for p in papers:
st = (p.get("study_type") or "").lower()
rows.append({"rank": RANK.get(st, 9), "year": p.get("publish_year"), "study_type": st or "未知", "n": p.get("sample_size") or p.get("study_count") or "",
"title": p["title"], "journal": p.get("journal_name", ""), "doi": p.get("doi", ""), "citations": p.get("citation_count", 0),
"takeaway": p.get("takeaway") or "", "flags": flags(p), "url": p.get("url", "")})
rows.sort(key=lambda r: (r["rank"], -(r["year"] or 0), -r["citations"]))
seen, uniq = set(), []
for r in rows: # 同一篇论文可能以不同版本出现,按 DOI(没有时按标题)去重
k = (r["doi"] or r["title"]).lower().strip()
if k not in seen: seen.add(k); uniq.append(r)
return uniq
def to_md(q, rows):
L = [f"# 证据表:{q}", "", "| 类型 | 年份 | 样本/研究数 | 标题 | 期刊 | 被引 | 要点(Consensus 摘录,需核对) | 提醒 |", "|---|---|---|---|---|---|---|---|"]
for r in rows:
t = f"[{r['title']}](https://doi.org/{r['doi']})" if r["doi"] else r["title"]
L.append(f"| {r['study_type']} | {r['year']} | {r['n']} | {t} | {r['journal']} | {r['citations']} | {r['takeaway']} | {r['flags']} |")
return "\n".join(L) + "\n"
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("query"); ap.add_argument("--year-min", type=int); ap.add_argument("--human", action="store_true")
ap.add_argument("--exclude-preprints", action="store_true"); ap.add_argument("--pages", type=int, default=1); ap.add_argument("--out", default="evidence")
a = ap.parse_args(argv)
try:
papers = search(a.query, pages=a.pages, year_min=a.year_min, human=True if a.human else None, exclude_preprints=True if a.exclude_preprints else None)
except ConsensusError as e:
print("Consensus API 错误:", e); return 1
rows = table(papers)
open(a.out + ".md", "w", encoding="utf-8").write(to_md(a.query, rows))
with open(a.out + ".csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["title"]); w.writeheader(); w.writerows(rows)
print(f"{len(papers)} 篇 → 去重后 {len(rows)} 篇,写入 {a.out}.md / {a.out}.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:本地替身
"""本地替身(我写的,不是 Consensus 官方的)。字段名照官方 OpenAPI 中的 QueryResult;论文标题、DOI、数据都是虚构的(DOI 用 10.5555 测试前缀)。
401 的响应体是对真实接口的实测结果。"""
import json, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
def P(i, st, year, n, cites, pre=False, pop="human", doi=True, title=None):
return {"title": title or f"虚构研究 {i}", "abstract": "…", "authors": ["A. Author"], "doi": f"10.5555/demo.{i}" if doi else "",
"journal_name": "Demo Journal", "pages": "1-10", "volume": "1", "publish_year": year, "url": f"https://consensus.app/papers/demo-{i}",
"citation_count": cites, "study_type": st, "takeaway": f"研究 {i} 的要点", "sample_size": n, "study_count": None,
"population_type": pop, "is_preprint": pre}
PAPERS = [P(1, "rct", 2021, 48, 30), P(2, "meta-analysis", 2023, None, 120), P(3, "cohort study", 2019, 5200, 210),
P(4, "animal", 2022, 24, 15, pop="animal"), P(5, "rct", 2024, 300, 5, pre=True), P(6, None, 2018, None, 2, doi=False),
P(2, "meta-analysis", 2023, None, 120)] # 第 7 条与第 2 条重复(同一 DOI)
def make(page_size=4):
calls = []
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def send(self, code, obj):
b = json.dumps(obj).encode(); self.send_response(code); self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
def do_GET(self):
u = urlparse(self.path); q = {k: v[0] for k, v in parse_qs(u.query).items()}; calls.append(q)
if "x-api-key" not in {k.lower() for k in self.headers.keys()}: return self.send(401, {"detail": "Not authenticated"})
if self.headers.get("x-api-key") != "good": return self.send(401, {"detail": "Invalid authentication credentials."})
if u.path != "/v1/search": return self.send(404, {"detail": "Not Found"})
page = int(q.get("page", 0)); res = PAPERS[page * page_size:(page + 1) * page_size]
end = (page + 1) * page_size >= len(PAPERS)
self.send(200, {"results": res, "page": page, "page_size": page_size, "is_end": end, "next_page": None if end else page + 1})
srv = ThreadingHTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv, f"http://127.0.0.1:{srv.server_address[1]}", calls
第 4 步:演示脚本
"""用本地替身演示(不连接 Consensus,不消耗调用次数):python demo_local.py "问题" [参数]"""
import os, sys, fake_consensus as F
srv, url, calls = F.make(); os.environ["CONSENSUS_API"] = url; os.environ.setdefault("CONSENSUS_API_KEY", "good")
import evidence_table as E
try: code = E.main(sys.argv[1:])
finally: srv.shutdown()
print("请求参数:", calls); sys.exit(code)
第 5 步:测试
import pytest, fake_consensus as F
@pytest.fixture
def api(monkeypatch):
srv, url, calls = F.make(); monkeypatch.setattr("evidence_table.API", url); yield calls; srv.shutdown()
def test_pagination_and_dedupe(api):
import evidence_table as E
papers = E.search("q", key="good", pages=5)
assert len(papers) == 7 and [c["page"] for c in api] == ["0", "1"] # is_end 后停止
rows = E.table(papers)
assert len(rows) == 6 and rows[0]["study_type"] == "meta-analysis"
def test_order_and_flags(api):
import evidence_table as E
rows = E.table(E.search("q", key="good", pages=2))
assert [r["study_type"] for r in rows] == ["meta-analysis", "rct", "rct", "cohort study", "animal", "未知"]
assert rows[1]["flags"] == "预印本" # 2024 年的 RCT 排在 2021 年之前,且是预印本
assert rows[2]["flags"] == "样本 48"
assert "animal" in rows[4]["flags"] and rows[5]["flags"] == "无 DOI"
def test_filters_passed(api):
import evidence_table as E
E.search("q", key="good", year_min=2020, human=True, exclude_preprints=None)
assert api[0]["year_min"] == "2020" and api[0]["human"] == "true" and "exclude_preprints" not in api[0]
def test_bad_key(api):
import evidence_table as E
with pytest.raises(E.ConsensusError, match="Invalid authentication credentials"):
E.search("q", key="bad")
def test_cli(api, tmp_path, monkeypatch):
import evidence_table as E
monkeypatch.setenv("CONSENSUS_API_KEY", "good")
assert E.main(["间歇性禁食与胰岛素敏感性", "--pages", "2", "--out", str(tmp_path / "ev")]) == 0
md = (tmp_path / "ev.md").read_text(encoding="utf-8")
assert "[虚构研究 2](https://doi.org/10.5555/demo.2)" in md and md.count("虚构研究 2") == 1
第 6 步:运行
$ python demo_local.py "间歇性禁食是否改善成年人的胰岛素敏感性" --pages 3 --human --year-min 2018
7 篇 → 去重后 6 篇,写入 evidence.md / evidence.csv
请求参数: [{'query': '间歇性禁食是否改善成年人的胰岛素敏感性', 'page': '0', 'year_min': '2018', 'human': 'true'}, {'query': '间歇性禁食是否改善成年人的胰岛素敏感性', 'page': '1', 'year_min': '2018', 'human': 'true'}]
$ cat evidence.md
# 证据表:间歇性禁食是否改善成年人的胰岛素敏感性
| 类型 | 年份 | 样本/研究数 | 标题 | 期刊 | 被引 | 要点(Consensus 摘录,需核对) | 提醒 |
|---|---|---|---|---|---|---|---|
| meta-analysis | 2023 | | [虚构研究 2](https://doi.org/10.5555/demo.2) | Demo Journal | 120 | 研究 2 的要点 | |
| rct | 2024 | 300 | [虚构研究 5](https://doi.org/10.5555/demo.5) | Demo Journal | 5 | 研究 5 的要点 | 预印本 |
| rct | 2021 | 48 | [虚构研究 1](https://doi.org/10.5555/demo.1) | Demo Journal | 30 | 研究 1 的要点 | 样本 48 |
| cohort study | 2019 | 5200 | [虚构研究 3](https://doi.org/10.5555/demo.3) | Demo Journal | 210 | 研究 3 的要点 | |
| animal | 2022 | 24 | [虚构研究 4](https://doi.org/10.5555/demo.4) | Demo Journal | 15 | 研究 4 的要点 | 样本 24;animal |
| 未知 | 2018 | | 虚构研究 6 | Demo Journal | 2 | 研究 6 的要点 | 无 DOI |
$ CONSENSUS_API_KEY=bad python demo_local.py x
Consensus API 错误: 401:Invalid authentication credentials.(检查 API key)
$ curl -s -H 'x-api-key: invalid' 'https://api.consensus.app/v1/search?query=intermittent%20fasting' # 真实接口
{"detail":"Invalid authentication credentials."}
$ python -m pytest -q
5 passed
怎么检查结果?
- 第二页的
is_end为 true 后不再请求(替身中只有 2 页)。 - 同一 DOI 只出现一次。
- 每一行的“提醒”列都要看:预印本没有经过同行评审,动物研究不能直接推到人。
- 本地替身不执行筛选,
year_min等参数只检查是否正确发送。
调试示例
1. 布尔参数的格式。 第一版直接把 human=True 交给 requests,测试中替身收到的是字符串 "True"。Consensus API 是否接受首字母大写的写法,我没有 key,无法验证;为了不依赖服务端的宽松解析,改为发送小写 true/false。
2. 两种 401。 真实接口对“没带 key”和“key 无效”给出不同提示(Not authenticated 与 Invalid authentication credentials.)。脚本直接显示服务端的 detail,看到第一种说明环境变量没生效,看到第二种说明 key 本身不对。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 401 Not authenticated | 没有设置 CONSENSUS_API_KEY | 检查环境变量 |
| 401 Invalid authentication credentials | key 错误或已删除 | 重新创建 key |
| 422 | 参数取值错误,例如 month_min 没有配合 year_min | 按接口参考修改 |
| 调用次数用得太快 | page_size 很大或页数很多 | 每 100 篇算 1 次;先取 1 页 |
模板规则
- 证据表是阅读清单,不是结论。
- 每篇论文都要打开原文核对要点。
- 预印本、小样本、动物研究要单独看待。
- key 只放在环境变量里。
项目资源
| 用途 | 地址 |
|---|---|
| Consensus 官网 | https://consensus.app/ |
| Consensus API 入门(官方) | https://docs.consensus.app/api-get-started |
| Consensus Search 接口参考(官方) | https://docs.consensus.app/api-reference/search |
让 API 帮你找论文、排顺序,结论要自己读原文得出。