Files
backend/backend/app/services/pubmed_api.py
T
34047007@qq.com 73f9468384
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
fix: 第8轮搜索深度审计修复 — 缓存失效、Redis重试、中文标签、翻页稳定性等12项
CRITICAL:
- invalidate_search_cache 清理 atm:* 缓存(MeSH ATM扩展不再用过期结果)
- Pro 方案 api_quota_per_day 1000→10000(修复低于 Free 的数据错误)
- CacheService/RateLimitMiddleware Redis 连接失败60秒自动重试(原永久降级)
- 普通搜索中文输入自动匹配 GlobalTag.name_zh(如"肺癌"通过MeSH标签关联文献)
- AdvancedPubSearchView resolveQuery 添加 seen Set 检测交替 #N 循环引用

HIGH:
- 限速器 _burst_windows 每500请求清理过期条目(防止内存泄漏)
- cron daily_ftp_update 末尾调用 invalidate_search_cache()(自动管道不再用过期缓存)

MEDIUM:
- _apply_order_by ASC 排序加 id tiebreaker(title/journal/first_author翻页跳行/重复)
- _keyset_condition 所有 is_(None) 加 id tiebreaker + __NULL__ 哨兵值
- _field_condition("all") 默认tsvector路径加 journal/journal_iso ILIKE 兜底
- SearchView restoreFromQuery date_preset/year_from/year_to 优先顺序修复

docs: 更新 12/13 搜索文档,移除 CLAUDE.md 陈旧 SQLite 提及
2026-07-28 11:02:29 +08:00

1317 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PubMed E-utilities API 管道:搜索 + 获取 + 解析 + 打标 + 入库"""
import asyncio
import hashlib
import logging
import uuid
import xml.etree.ElementTree as ET
from datetime import date, datetime, timezone
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.compat import UTC
from app.config import settings
# 预印本检测常量
_PREPRINT_DOI_PREFIXES = ("10.1101/", "10.21203/", "10.22541/", "10.20944/", "10.26434/", "10.31219/", "10.2139/", "10.48550/")
_PREPRINT_JOURNALS = frozenset({"medrxiv", "biorxiv", "arxiv", "research square", "preprints.org", "ssrn", "chemrxiv", "authorea"})
# 动态计算搜索年范围(覆盖最近两年,非硬编码) — 每次调用时实时计算
def _year_filter() -> str:
now = datetime.now()
return f"{now.year - 2}:{now.year}[dp]"
from app.db import async_session
from app.models.literature import GlobalLiterature
from app.services.journal_utils import ensure_journal_async
from app.services.feed_engine import generate_feeds_for_literature
from app.services.tag_service import tag_article
from app.services.pmc_oa import fetch_pmc_xml
from app.services.cos_client import download_json, make_full_text_key, upload_json
from app.config import settings
from app.services.jats_parser import extract_baseline_data, extract_license_from_xml, parse_full_text
from app.constants.study_design import classify_study_design
from app.services.rct_detector import detect_rct
logger = logging.getLogger(__name__)
# NCBI E-utilities 配置
ESEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
EFETCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
ELINK_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi"
# Europe PMC RESTful API(支持 cursor 分页,无 API Key 限制)
EUROPE_PMC_SEARCH_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
API_KEY = settings.PUBMED_API_KEY or None # 空字符串视为 None
TOOL_NAME = "scilit_oncology"
TOOL_EMAIL = "dev@scilit-oncology.com"
# 重试配置
MAX_RETRIES = 3
RETRYABLE_CODES = {429, 500, 502, 503, 504}
async def _fetch_with_retry(client: httpx.AsyncClient, url: str, params: dict) -> httpx.Response:
"""带指数退避重试的 HTTP GET"""
last_exc = None
for attempt in range(1, MAX_RETRIES + 1):
try:
r = await client.get(url, params=params)
if r.status_code in RETRYABLE_CODES:
raise httpx.HTTPStatusError(f"Retryable status {r.status_code}", request=r.request, response=r)
r.raise_for_status()
return r
except (httpx.TimeoutException, httpx.NetworkError) as e:
last_exc = e
logger.warning("NCBI %s attempt %d/%d failed: %s", url, attempt, MAX_RETRIES, e)
if attempt == MAX_RETRIES:
raise
await asyncio.sleep(2 ** attempt)
except httpx.HTTPStatusError as e:
last_exc = e
if e.response.status_code in RETRYABLE_CODES and attempt < MAX_RETRIES:
logger.warning("NCBI %s attempt %d/%d got %d, retrying", url, attempt, MAX_RETRIES, e.response.status_code)
await asyncio.sleep(2 ** attempt)
else:
raise
raise last_exc or RuntimeError("Unexpected retry exit")
# 肿瘤学搜索策略(两组)
# 1. 高精度:MeSH Major Topic,仅 indexed 记录
# 精度模式:majr(默认,Major Topic 优先)| mesh(普通 MeSH,更高召回)
_PRECISION_MODE: str = "majr"
def _build_oncology_queries(precision_mode: str = "majr") -> list[str]:
"""根据精度模式生成肿瘤学 MeSH 查询列表。"""
tag = "MAJR" if precision_mode == "majr" else "MeSH"
year_f = _year_filter()
queries = [
f'("Lung Neoplasms"[{tag}]) AND {year_f}',
f'("Breast Neoplasms"[{tag}]) AND {year_f}',
f'("Colorectal Neoplasms"[{tag}]) AND {year_f}',
f'("Stomach Neoplasms"[{tag}]) AND {year_f}',
f'("Liver Neoplasms"[{tag}]) AND {year_f}',
f'("Prostatic Neoplasms"[{tag}]) AND {year_f}',
f'("Leukemia"[{tag}]) AND {year_f}',
f'("Lymphoma"[{tag}]) AND {year_f}',
f'("Melanoma"[{tag}]) AND {year_f}',
f'("Pancreatic Neoplasms"[{tag}]) AND {year_f}',
f'("Ovarian Neoplasms"[{tag}]) AND {year_f}',
f'("Esophageal Neoplasms"[{tag}]) AND {year_f}',
f'("Glioma"[{tag}]) AND {year_f}',
f'("Head and Neck Neoplasms"[{tag}]) AND {year_f}',
f'("Sarcoma"[{tag}]) AND {year_f}',
f'("Immunotherapy"[{tag}]) AND ("Neoplasms"[{tag}]) AND {year_f}',
f'("Molecular Targeted Therapy"[{tag}]) AND ("Neoplasms"[{tag}]) AND {year_f}',
# 兜底:任何 Neoplasm 相关(覆盖甲状腺/肾/膀胱/宫颈/骨髓瘤等非白名单癌种)
f'("Neoplasms"[{tag}]) AND {year_f}',
]
return queries
# 保持 ONCOLOGY_SEARCH_QUERIES 向后兼容(不破坏已有 import)
ONCOLOGY_SEARCH_QUERIES = _build_oncology_queries("majr")
# 2. 高召回:Title/Abstract 关键词,覆盖 in-process + publisher(尚未 MeSH-indexed
def _build_broad_queries() -> list[str]:
year_f = _year_filter()
return [
f'("lung cancer"[Title/Abstract] OR "lung carcinoma"[Title/Abstract] OR "lung adenocarcinoma"[Title/Abstract]) AND {year_f}',
f'("breast cancer"[Title/Abstract] OR "breast carcinoma"[Title/Abstract]) AND {year_f}',
f'("colorectal cancer"[Title/Abstract] OR "colon cancer"[Title/Abstract] OR "rectal cancer"[Title/Abstract]) AND {year_f}',
f'("gastric cancer"[Title/Abstract] OR "stomach cancer"[Title/Abstract]) AND {year_f}',
f'("hepatocellular"[Title/Abstract] OR "liver cancer"[Title/Abstract] OR "hepatic"[Title/Abstract]) AND {year_f}',
f'("prostate cancer"[Title/Abstract] OR "prostatic cancer"[Title/Abstract]) AND {year_f}',
f'("leukemia"[Title/Abstract] OR "leukaemia"[Title/Abstract]) AND {year_f}',
f'("lymphoma"[Title/Abstract] OR "Hodgkin"[Title/Abstract] OR "non-Hodgkin"[Title/Abstract]) AND {year_f}',
f'("melanoma"[Title/Abstract]) AND {year_f}',
f'("pancreatic cancer"[Title/Abstract]) AND {year_f}',
f'("ovarian cancer"[Title/Abstract] OR "ovarian carcinoma"[Title/Abstract]) AND {year_f}',
f'("esophageal cancer"[Title/Abstract] OR "oesophageal"[Title/Abstract]) AND {year_f}',
f'("glioma"[Title/Abstract] OR "glioblastoma"[Title/Abstract]) AND {year_f}',
f'("head and neck cancer"[Title/Abstract] OR "oral cancer"[Title/Abstract] OR "squamous cell carcinoma"[Title/Abstract]) AND {year_f}',
f'("sarcoma"[Title/Abstract] OR "osteosarcoma"[Title/Abstract]) AND {year_f}',
# 兜底:非白名单癌种(甲状腺/肾/膀胱/宫颈/骨髓瘤等)
f'("cancer"[Title/Abstract] OR "tumor"[Title/Abstract] OR "tumour"[Title/Abstract] OR "neoplasm"[Title/Abstract] OR "malignan*"[Title/Abstract]) AND {year_f}',
]
def _uid() -> uuid.UUID:
return uuid.uuid4()
async def search_pmids(query: str, max_results: int = 50) -> list[int]:
"""搜索 PubMed,返回 PMID 列表"""
params = {
"db": "pubmed",
"term": query,
"retmax": max_results,
"retmode": "json",
"sort": "date",
"tool": TOOL_NAME,
"email": TOOL_EMAIL,
}
if API_KEY:
params["api_key"] = API_KEY
async with httpx.AsyncClient(timeout=30) as client:
r = await _fetch_with_retry(client, ESEARCH_URL, params)
data = r.json()
return [int(x) for x in data.get("esearchresult", {}).get("idlist", [])]
# ═══════════════════════════════════════════════
# Europe PMC API — cursor-based pagination
# 文档: https://europepmc.org/RestfulWebService
# ═══════════════════════════════════════════════
_EUROPE_PMC_SEM = asyncio.Semaphore(5) # 并发限制
def _parse_europe_pmc_article(art: dict) -> dict | None:
"""将 Europe PMC JSON 文献转换为标准 dict 格式(与 `_parse_pubmed_xml()` 一致)"""
pmid_str = art.get("id") or art.get("pmid")
if not pmid_str:
return None
try:
pmid = int(pmid_str)
except (ValueError, TypeError):
return None
# Authors
authors = []
author_list = art.get("authorList", {})
for au in (author_list.get("author") or []):
authors.append({
"family": au.get("lastName", ""),
"given": au.get("firstName", ""),
"affiliation": ((au.get("affiliation") or [None])[0]
if isinstance(au.get("affiliation"), list)
else (au.get("affiliation") or "")),
})
if not authors:
author_str = art.get("authorString", "")
if author_str:
for name in author_str.split(", "):
parts = name.strip().split(" ", 1)
authors.append({
"family": parts[0],
"given": parts[-1] if len(parts) > 1 else "",
"affiliation": "",
})
# DOI
doi = art.get("doi") or art.get("DOI")
# pub_date
pub_date = None
pub_year = None
fpd = art.get("firstPublicationDate")
if fpd:
try:
parts = fpd.split("-")
pub_year = int(parts[0])
if len(parts) >= 3:
pub_date = date(int(parts[0]), int(parts[1]), int(parts[2]))
elif len(parts) == 2:
pub_date = date(int(parts[0]), int(parts[1]), 1)
# 仅年份精度不生成伪日期,pub_year 已足够
except (ValueError, TypeError):
pass
elif art.get("pubYear"):
try:
pub_year = int(art["pubYear"])
# 仅年份精度,不生成误导性的 YYYY-01-01
except (ValueError, TypeError):
pass
# MeSH Headings
mesh_headings = []
for mh in (art.get("meshHeadingList", {}).get("meshHeading") or []):
desc = mh.get("descriptorName", "")
ui = mh.get("descriptorUI", "")
major = mh.get("majorTopic_YN", "") == "Y"
qualifiers = [q.get("qualifierName", "") for q in
(mh.get("qualifierList", {}).get("qualifier") or []) if q.get("qualifierName")]
if desc:
mesh_headings.append({
"descriptor": desc, "ui": ui, "major": major, "qualifiers": qualifiers,
})
# KeywordsEurope PMC 可能返回单字符串而非数组)
_kw_val = art.get("keywordList", {}).get("keyword")
if isinstance(_kw_val, str):
_kw_val = [_kw_val]
keywords = [kw for kw in (_kw_val or []) if kw]
# Publication types
pub_types = []
pt = art.get("publicationType", "")
if pt:
pub_types.append(pt)
for ptype in (art.get("pubTypeList", {}).get("publicationType") or []):
if ptype and ptype not in pub_types:
pub_types.append(ptype)
# Cache citedByCount for later use
cbc = art.get("citedByCount", 0)
return {
"pmid": pmid,
"title": (art.get("title") or "").strip(),
"abstract": (art.get("abstractText") or "").strip() or None,
"authors": authors,
"doi": doi,
"pmc_id": (art.get("pmcid") or "").removeprefix("PMC"),
"is_oa": bool(art.get("pmcid")),
"pubmed_revised": None,
"citation_status": None,
"date_completed": None,
"meshed_date": None,
"grants": [],
"journal": art.get("journalTitle", ""),
"journal_issn": art.get("journalIssn", "") or art.get("journalISSN", ""),
"journal_iso": art.get("journalAbbreviation") or "",
"volume": art.get("journalVolume") or art.get("volume", ""),
"issue": art.get("journalIssue") or art.get("issue", ""),
"pages": art.get("pageInfo"),
"pub_date": pub_date,
"pub_year": pub_year,
"pub_types": pub_types,
"mesh_headings": mesh_headings,
"language": art.get("language", "en"),
"trial_reg": None,
"retracted": art.get("isRetracted", False),
"retraction_details": None,
"chemical_list": [{"name": c.get("name", ""), "registry_number": c.get("registryNumber", ""), "mesh_ui": c.get("ui", "")}
for c in (art.get("chemicalList") or []) if isinstance(c, dict) and c.get("name")],
"gene_symbols": [g for g in (art.get("geneSymbolList") or []) if g],
"keywords": keywords,
"num_refs": art.get("numReferences"),
"publication_status": art.get("publicationStatus"),
"article_date": None,
"print_date": None,
"create_date": None,
"entrez_date": None,
"databank_list": [{"databank_name": d.get("dataBankName", ""),
"accession_numbers": d.get("accessionNumberList", []) or []}
for d in (art.get("dataBankList") or []) if isinstance(d, dict) and d.get("dataBankName")],
"suppl_mesh_list": [{"descriptor": s.get("name", s.get("supplMeshName", "")),
"ui": s.get("ui", s.get("supplMeshUI", "")),
"type": s.get("type", s.get("supplMeshType", ""))}
for s in (art.get("supplMeshList") or []) if isinstance(s, dict) and (s.get("name") or s.get("supplMeshName"))],
"pharmacological_actions": [],
"cois_statement": None,
"vernacular_title": None,
"is_preprint": (
(doi or "").lower().startswith(_PREPRINT_DOI_PREFIXES)
or (art.get("journalTitle") or "").lower() in _PREPRINT_JOURNALS
),
"source": "europe_pmc",
}
async def search_europe_pmc_articles(
query: str,
max_results: int = 1000,
page_size: int = 1000,
) -> list[dict]:
"""搜索 Europe PMC,返回完整文献 dict 列表(与 `fetch_articles()` 输出格式一致)。
- 使用 cursor-based 分页,适合大数据量场景(每日增量 >5000 篇时)
- 无 API Key 限制,10+ req/s
- 单步返回完整元数据(无需分两步 esearch + efetch
- 返回结果的 citedByCount 自动缓存供后续引用更新
"""
if not query.strip():
return []
results = []
cursor = "*"
ps = min(page_size, 1000)
actual_limit = min(max_results, 100_000)
async with httpx.AsyncClient(timeout=30) as client:
while len(results) < actual_limit:
params = {
"query": query,
"resultType": "core",
"pageSize": ps,
"cursor": cursor,
"format": "json",
}
try:
async with _EUROPE_PMC_SEM:
r = await _fetch_with_retry(client, EUROPE_PMC_SEARCH_URL, params)
data = r.json()
except Exception:
logger.warning("europe_pmc_search_failed query=%s cursor=%s", query[:60], cursor, exc_info=True)
break
hit_count = data.get("hitCount", 0)
if hit_count == 0:
break
articles = data.get("resultList", {}).get("result", [])
if not articles:
break
for art in articles:
if len(results) >= actual_limit:
break
parsed = _parse_europe_pmc_article(art)
if parsed:
results.append(parsed)
next_cursor = data.get("nextCursorMark")
if not next_cursor or next_cursor == cursor:
break
cursor = next_cursor
# 避免无限循环——Europe PMC 的 nextCursorMark 不会变空
if len(results) >= hit_count:
break
logger.info("europe_pmc_search: query=%s results=%d", query[:60], len(results))
return results
async def fetch_articles(pmids: list[int]) -> list[dict]:
"""批量获取文献详情(XML → 结构化 dict"""
if not pmids:
return []
params = {
"db": "pubmed",
"id": ",".join(str(p) for p in pmids),
"retmode": "xml",
"tool": TOOL_NAME,
"email": TOOL_EMAIL,
}
if API_KEY:
params["api_key"] = API_KEY
async with httpx.AsyncClient(timeout=30) as client:
r = await _fetch_with_retry(client, EFETCH_URL, params)
return _parse_pubmed_xml(r.text)
def _parse_pubmed_xml(xml_text: str) -> list[dict]:
"""解析 PubMed XML,返回文献 dict 列表"""
root = ET.fromstring(xml_text)
articles = []
for article_elem in root.findall(".//PubmedArticle"):
try:
medline = article_elem.find(".//MedlineCitation")
if medline is None:
continue
article = medline.find(".//Article")
if article is None:
continue
# PMID
pmid_elem = medline.find(".//PMID")
pmid = int(pmid_elem.text) if pmid_elem is not None and pmid_elem.text else None
if pmid is None:
continue
# Title
title_elem = article.find(".//ArticleTitle")
title = "".join(title_elem.itertext()).strip() if title_elem is not None else ""
# Abstract
abstract_parts = []
for at in article.findall(".//Abstract/AbstractText"):
label = at.get("Label", "")
text = "".join(at.itertext()).strip()
if label:
abstract_parts.append(f"{label}: {text}")
else:
abstract_parts.append(text)
abstract = "\n".join(abstract_parts) if abstract_parts else None
# Authors
authors = []
for au in article.findall(".//Author"):
last = au.findtext("LastName", "")
fore = au.findtext("ForeName", "")
aff_elem = au.find(".//AffiliationInfo/Affiliation")
aff = aff_elem.text if aff_elem is not None else ""
# Author Identifiers (ORCID etc.)
identifiers = []
for id_elem in au.findall("Identifier"):
id_source = id_elem.get("Source", "")
id_val = id_elem.text or ""
if id_source and id_val:
identifiers.append({"type": id_source, "value": id_val})
entry = {"family": last, "given": fore, "affiliation": aff}
if identifiers:
entry["identifiers"] = identifiers
# Type="editor" in AuthorList
if au.get("Type") == "editor":
entry["type"] = "editor"
authors.append(entry)
# InvestigatorList(多中心试验研究者)
investigators = []
for inv in article.findall(".//InvestigatorList/Investigator"):
last = inv.findtext("LastName", "")
fore = inv.findtext("ForeName", "")
aff_elem = inv.find(".//AffiliationInfo/Affiliation")
aff = aff_elem.text if aff_elem is not None else ""
identifiers = []
for id_elem in inv.findall("Identifier"):
id_source = id_elem.get("Source", "")
id_val = id_elem.text or ""
if id_source and id_val:
identifiers.append({"type": id_source, "value": id_val})
entry = {"family": last, "given": fore, "affiliation": aff}
if identifiers:
entry["identifiers"] = identifiers
investigators.append(entry)
# DOI
doi = None
for eid in article.findall(".//ELocationID"):
if eid.get("EIdType") == "doi":
doi = eid.text
break
# PMCID
pmc_id = None
for aid in article_elem.findall(".//PubmedData/ArticleIdList/ArticleId"):
if aid.get("IdType") == "pmc":
pmc_id = (aid.text or "").removeprefix("PMC")
break
# 临床试验注册号
trial_reg = {}
for aid in article_elem.findall(".//PubmedData/ArticleIdList/ArticleId"):
id_type = aid.get("IdType")
if id_type == "ClinicalTrials.gov":
trial_reg["nct"] = aid.text
elif id_type == "EU-CTR":
trial_reg["eudract"] = aid.text
elif id_type == "ChiCTR":
trial_reg["chictr"] = aid.text
# DateRevisedPubMed 修订日期)
pubmed_revised = None
dr_elem = medline.find(".//DateRevised")
if dr_elem is not None:
try:
yr = int(dr_elem.findtext("Year", "0"))
mo = int(dr_elem.findtext("Month", "1"))
dy = int(dr_elem.findtext("Day", "1"))
pubmed_revised = date(yr, mo, dy)
except (ValueError, TypeError):
pass
# CitationStatus(文献状态:publisher / in-data-review / in-process / medline / ...
citation_status = medline.get("Status")
# DateCompletedMeSH 标引完成日,即"成为成品"的日期)
date_completed = None
dc_elem = medline.find(".//DateCompleted")
if dc_elem is not None:
try:
yr = int(dc_elem.findtext("Year", "0"))
mo = int(dc_elem.findtext("Month", "1"))
dy = int(dc_elem.findtext("Day", "1"))
date_completed = date(yr, mo, dy)
except (ValueError, TypeError):
pass
# ─── CommentsCorrections(撤稿 / 修正 / 关注)───
retracted = False
retraction_details = None
for cc in medline.findall(".//CommentsCorrections"):
ref_type = cc.findtext("RefType")
ref_pmid_elem = cc.find("PMID")
ref_pmid = int(ref_pmid_elem.text) if ref_pmid_elem is not None and ref_pmid_elem.text else None
if ref_type in ("RetractedBy", "RetractionOf"):
retraction_details = {
"ref_type": ref_type,
"ref_pmid": ref_pmid,
"ref_source": cc.findtext("RefSource"),
}
if ref_type == "RetractedBy":
retracted = True
# Keywords(作者关键词)
keywords = []
for kw in article.findall(".//KeywordList/Keyword"):
if kw.text:
keywords.append(kw.text)
# Grants(基金资助)
grants = []
for gr in article.findall(".//GrantList/Grant"):
gr_abbrev = gr.findtext("Acronym") or ""
gr_agency = gr.findtext("Agency") or ""
gr_country = gr.get("Country", "")
gr_number = gr.findtext("GrantID") or ""
if gr_abbrev or gr_agency or gr_number:
grants.append({
"abbreviation": gr_abbrev,
"agency": gr_agency,
"country": gr_country,
"grant_id": gr_number,
})
# Journal
journal_elem = article.find(".//Journal")
journal_title = None
issn = None
journal_iso = None
volume = None
issue = None
pub_date = None
print_date = None
pub_year = None
pages = ""
if journal_elem is not None:
journal_title = journal_elem.findtext("Title")
journal_iso = journal_elem.findtext("ISOAbbreviation")
issn_elem = journal_elem.find(".//ISSN")
issn = issn_elem.text if issn_elem is not None else None
ji = journal_elem.find(".//JournalIssue")
if ji is not None:
volume = ji.findtext("Volume")
issue = ji.findtext("Issue")
pd_elem = ji.find(".//PubDate")
if pd_elem is not None:
y = pd_elem.findtext("Year")
if y:
pub_year = int(y)
m = pd_elem.findtext("Month", "Jan")
d = pd_elem.findtext("Day", "01")
try:
month_map = {"jan":1,"feb":2,"mar":3,"apr":4,"may":5,"jun":6,
"jul":7,"aug":8,"sep":9,"oct":10,"nov":11,"dec":12}
mi = month_map.get(m.lower()[:3], 1)
di = min(int(d) if d.isdigit() else 1, 31)
pub_date = date(int(y), mi, di)
print_date = pub_date # 原始 PubDate 作为 PPDAT
except (ValueError, TypeError):
pub_date = date(int(y), 1, 1)
print_date = pub_date
else:
md = pd_elem.findtext("MedlineDate")
if md:
import re
ym = re.search(r'(\d{4})', md)
if ym:
pub_year = int(ym.group(1))
pagination = article.findtext(".//Pagination/MedlinePgn")
if pagination:
pages = pagination
# Publication types
pub_types = []
for pt in article.findall(".//PublicationTypeList/PublicationType"):
if pt.text:
pub_types.append(pt.text)
# MeSH Headings(核心:用于后续打标)
mesh_headings = []
for mh in medline.findall(".//MeshHeadingList/MeshHeading"):
desc = mh.findtext("DescriptorName")
dn = mh.find("DescriptorName")
ui = dn.get("UI", "") if dn is not None else ""
major = dn.get("MajorTopicYN", "N") == "Y" if dn is not None else False
qualifiers = [q.text for q in mh.findall("QualifierName") if q.text]
mesh_headings.append({
"descriptor": desc,
"ui": ui,
"major": major,
"qualifiers": qualifiers,
})
# Language
lang_elem = article.find(".//Language")
language = lang_elem.text if lang_elem is not None else "en"
# ─── ChemicalList(化合物列表)───
chemical_list = []
for ch in medline.findall(".//ChemicalList/Chemical"):
rn = ch.findtext("RegistryNumber", "")
ns = ch.find("NameOfSubstance")
name = ns.text if ns is not None else ""
ui = ns.get("UI", "") if ns is not None else ""
chemical_list.append({
"name": name,
"registry_number": rn,
"mesh_ui": ui,
})
# ─── PharmacologicalAction(药理作用)───
pharmacological_actions = []
for pa in medline.findall(".//PharmacologicalAction/NameOfSubstance"):
name = pa.text or ""
ui = pa.get("UI", "")
if name:
pharmacological_actions.append({"name": name, "ui": ui})
# ─── GeneSymbolList(基因符号)───
gene_symbols = [gs.text for gs in article.findall(".//GeneSymbolList/GeneSymbol") if gs.text]
# ─── NumberOfReferences(参考文献数)───
num_refs = None
nr_elem = medline.find(".//NumberOfReferences")
if nr_elem is not None and nr_elem.text:
try:
num_refs = int(nr_elem.text)
except (ValueError, TypeError):
pass
# ─── PublicationStatus(出版阶段)───
publication_status = None
ps_elem = article_elem.find(".//PubmedData/PublicationStatus")
if ps_elem is not None:
publication_status = ps_elem.text
# ─── ArticleDate(电子出版日期)───
article_date = None
ad_elem = article.find(".//ArticleDate")
if ad_elem is not None:
try:
ayr = int(ad_elem.findtext("Year", "0"))
amo = int(ad_elem.findtext("Month", "1"))
ady = int(ad_elem.findtext("Day", "1"))
article_date = date(ayr, amo, ady)
except (ValueError, TypeError):
pass
# ─── 修复:无 ArticleDate 时,PubDate 的 Day 可能是 NCBI 默认(当月最后一天)───
if article_date is None and pub_date is not None:
import calendar
last = calendar.monthrange(pub_date.year, pub_date.month)[1]
if pub_date.day == last:
pub_date = date(pub_date.year, pub_date.month, 1)
# ─── PubmedData/History → create_date [CRDT] + entrez_date [EDAT] ───
create_date = None
entrez_date = None
for hist in article_elem.findall(".//PubmedData/History/PubMedPubDate"):
status = hist.get("PubStatus", "")
h_year = hist.findtext("Year")
if h_year and h_year.isdigit():
h_mo = int(hist.findtext("Month", "1"))
h_dy = int(hist.findtext("Day", "1"))
h_dt = date(int(h_year), h_mo, h_dy)
if status == "pubmed":
create_date = h_dt
elif status == "entrez":
entrez_date = h_dt
# ─── DataBankList(数据库引用)───
databank_list = []
for db in medline.findall(".//DataBankList/DataBank"):
db_name = db.findtext("DataBankName", "")
accessions = [an.text for an in db.findall(".//AccessionNumberList/AccessionNumber") if an.text]
if db_name:
databank_list.append({
"databank_name": db_name,
"accession_numbers": accessions,
})
# ─── SupplMeshList(补充 MeSH)───
suppl_mesh_list = []
for sm in medline.findall(".//SupplMeshList/SupplMeshName"):
name = sm.text or ""
ui = sm.get("UI", "")
stype = sm.get("Type", "")
suppl_mesh_list.append({
"descriptor": name,
"ui": ui,
"type": stype,
})
# ─── CoiStatement(利益冲突声明)───
cois_elem = article.find(".//CoiStatement")
cois_statement = cois_elem.text.strip() if cois_elem is not None and cois_elem.text else None
# ─── VernacularTitle(非英文标题的拉丁转写)───
vt_elem = article.find(".//VernacularTitle")
vernacular_title = vt_elem.text.strip() if vt_elem is not None and vt_elem.text else None
# ─── PersonalNameSubjectList(作为主题的人名)───
personal_name_subjects = []
for pns in medline.findall(".//PersonalNameSubjectList/PersonalNameSubject"):
last = pns.findtext("LastName", "")
fore = pns.findtext("ForeName", "")
if last or fore:
personal_name_subjects.append({"family": last, "given": fore})
# ─── PubmedData/PublicationNote(出版注释)───
publication_notes = []
for pn in article_elem.findall(".//PubmedData/PublicationNote"):
if pn.text and pn.text.strip():
publication_notes.append(pn.text.strip())
articles.append({
"pmid": pmid,
"title": title.strip(),
"abstract": abstract.strip() if abstract else None,
"authors": authors,
"doi": doi,
"pmc_id": pmc_id,
"is_oa": pmc_id is not None,
"pubmed_revised": pubmed_revised,
"citation_status": citation_status,
"meshed_date": date_completed, # 首次导入=date_completed,年更后刷新
"keywords": keywords,
"grants": grants,
"journal": journal_title,
"journal_issn": issn,
"journal_iso": journal_iso,
"volume": volume,
"issue": issue,
"pages": pages or None,
"pub_date": pub_date,
"pub_year": pub_year,
"pub_types": pub_types,
"mesh_headings": mesh_headings,
"language": language,
"trial_reg": trial_reg or None,
"retracted": retracted,
"retraction_details": retraction_details,
"chemical_list": chemical_list,
"gene_symbols": gene_symbols,
"num_refs": num_refs,
"publication_status": publication_status,
"article_date": article_date,
"print_date": print_date,
"create_date": create_date,
"entrez_date": entrez_date,
"databank_list": databank_list,
"suppl_mesh_list": suppl_mesh_list,
"pharmacological_actions": pharmacological_actions,
"cois_statement": cois_statement,
"vernacular_title": vernacular_title,
"is_preprint": (
(doi or "").lower().startswith(_PREPRINT_DOI_PREFIXES)
or (journal_title or "").lower() in _PREPRINT_JOURNALS
),
"investigators": investigators,
"personal_name_subjects": personal_name_subjects,
"publication_notes": publication_notes,
"source": "pubmed_api",
})
except Exception:
logger.warning("parse_article_failed", exc_info=True)
continue
return articles
# ═══════════════════════════════════════════════
# 引用计数 — PubMed elink (免费, 3/10 req/s)
# 文档: https://www.ncbi.nlm.nih.gov/books/NBK25499/#chapter4.ElLink
# ═══════════════════════════════════════════════
ELINK_RATE_LIMIT = 0.34 # ≈ 3 req/s (有 API key 则 0.1 = 10 req/s)
async def fetch_citedby_counts(pmids: list[int]) -> dict[int, int]:
"""批量查询引用次数,返回 {pmid: count}"""
if not pmids:
return {}
# 去重并保留顺序
unique = list(dict.fromkeys(pmids))
result: dict[int, int] = {}
client_kw = {"timeout": 30}
async with httpx.AsyncClient(**client_kw) as client:
for i in range(0, len(unique), 200):
batch = unique[i:i + 200]
params = {
"dbfrom": "pubmed",
"db": "pubmed",
"linkname": "pubmed_pubmed_citedin",
"id": ",".join(str(p) for p in batch),
"retmode": "xml",
"tool": TOOL_NAME,
"email": TOOL_EMAIL,
}
if API_KEY:
params["api_key"] = API_KEY
try:
r = await _fetch_with_retry(client, ELINK_URL, params)
# 解析 XMLelink 返回 <LinkSetSet> 包含多个 <LinkSet>
root = ET.fromstring(r.text)
for linkset in root.iter("LinkSet"):
# 取源 PMIDIdUrlList/IdUrl/Id(旧格式)或 IdList/Id(新格式,2025年起 NCBI 简化了 XML
src_el = linkset.findtext("IdUrlList/IdUrl/Id") or linkset.findtext("IdList/Id")
if not src_el:
continue
source_pmid = int(src_el.strip())
# 统计 cited-by 数:LinkSetDb[LinkName=pubmed_pubmed_citedin] -> Link -> Id
count = 0
for lsdb in linkset.findall("LinkSetDb"):
ln = lsdb.findtext("LinkName", "")
if ln == "pubmed_pubmed_citedin":
count = len([e for e in lsdb.findall("Link/Id") if e.text])
break
result[source_pmid] = max(result.get(source_pmid, 0), count)
except Exception:
logger.warning("elink_failed batch size=%d offset=%d", len(batch), i, exc_info=True)
# 部分失败不中断,已获取的结果保留
# 限速
delay = ELINK_RATE_LIMIT if not API_KEY else 0.11
await asyncio.sleep(delay)
return result
async def update_citation_counts(
db: AsyncSession,
pmids: list[int] | None = None,
*,
limit: int = 0,
) -> dict:
"""更新被引次数。pmids=None 时更新全库 (limit=0=全部)。返回 {updated, total, errors}"""
if pmids:
targets = pmids
else:
q = select(GlobalLiterature.pmid).order_by(GlobalLiterature.created_at.desc())
if limit > 0:
q = q.limit(limit)
result = await db.execute(q)
targets = [r for (r,) in result]
if not targets:
return {"updated": 0, "total": 0, "errors": 0}
counts = await fetch_citedby_counts(targets)
updated = 0
errors = 0
now = datetime.now(UTC)
for pmid, count in counts.items():
try:
await db.execute(
GlobalLiterature.__table__.update()
.where(GlobalLiterature.pmid == pmid)
.values(cited_by_count=count, updated_at=now)
)
updated += 1
except Exception:
errors += 1
logger.warning("citation_update_failed pmid=%d", pmid, exc_info=True)
if updated:
await db.commit()
return {"updated": updated, "total": len(targets), "errors": errors}
def _update_lit_from_article(lit: GlobalLiterature, article: dict):
"""用 PubMed 返回的最新数据更新已存在的文献记录"""
fields = [
"title", "abstract", "authors", "doi", "journal", "journal_issn", "journal_iso",
"volume", "issue", "pages", "pub_date", "pub_year", "pub_types",
"mesh_headings", "language", "pmc_id", "keywords", "grants",
"pubmed_revised", "trial_reg", "citation_status", "date_completed", "meshed_date",
"retracted", "retraction_details",
"chemical_list", "gene_symbols", "num_refs", "publication_status",
"article_date", "print_date", "create_date", "entrez_date",
"databank_list", "suppl_mesh_list",
"pharmacological_actions",
"cois_statement", "vernacular_title",
"investigators", "personal_name_subjects", "publication_notes",
"is_preprint",
]
for f in fields:
val = article.get(f)
if val is not None and val != "" and val != [] and val != {}:
setattr(lit, f, val)
lit.is_oa = article.get("pmc_id") is not None
# 同步更新研究设计分类
if article.get("pub_types"):
lit.study_design = classify_study_design(article["pub_types"])
# RCT 检测(两档可信度)
lit.rct_detection = detect_rct(
article.get("pub_types"), article.get("title"),
article.get("abstract"), article.get("pub_year"),
)
# 阴性结果检测
if article.get("abstract") or article.get("title"):
from app.services.negative_detector import detect_negative_result
# 每次重新抓取后重置 is_negative_result,避免旧标记永不清零
lit.is_negative_result = False
lit.negative_result_details = None
neg = detect_negative_result(article.get("abstract"), article.get("title", ""))
if neg.get("is_negative"):
lit.is_negative_result = True
lit.negative_result_details = neg
async def _run_pipeline(
db: AsyncSession,
max_per_query: int,
use_majr: bool,
use_broad: bool,
precision_mode: str = "majr",
update_citations: bool = True,
use_europe_pmc: bool = False,
) -> dict:
"""管道核心:执行查询列表 + 入库/更新 + 打标 + Feed + 被引。返回统计 dict
当 use_europe_pmc=True 时使用 Europe PMC APIcursor 分页,适合大数据量);
False 时使用 NCBI E-utilities(默认,向后兼容)。
"""
# 记录管线开始时间,供 PipelineRun duration 计算
_started_at = datetime.now(UTC)
stats = {"searched": 0, "fetched": 0, "new": 0, "updated": 0, "tagged": 0, "feeds": 0, "citations": 0, "oa_fetched": 0, "started_at": _started_at}
all_new_pmids: list[int] = []
oa_pending: dict[int, str] = {} # pmid -> pmc_id(待抓全文的新 OA 文献)
seen_pmids: set[int] = set()
async def _process_article(article: dict, existing_set: set, pmid_to_lit: dict) -> None:
"""处理单条文献:更新已存在的或创建新记录
DOI 跨源去重:当 PMID 不存在但 DOI 已在数据库中时,
更新已有记录(补充 PMID/PMCID 等),避免同文献多源重复。
"""
nonlocal stats
if article["pmid"] in existing_set:
lit = pmid_to_lit[article["pmid"]]
_update_lit_from_article(lit, article)
await tag_article(db, lit.id, article.get("mesh_headings", []))
stats["updated"] += 1
else:
# 二次检查:防止并发/间发的 duplicate INSERT
dup = await db.execute(
select(GlobalLiterature).where(GlobalLiterature.pmid == article["pmid"])
)
dup_lit = dup.scalar()
if dup_lit:
_update_lit_from_article(dup_lit, article)
await tag_article(db, dup_lit.id, article.get("mesh_headings", []))
stats["updated"] += 1
return
# DOI 跨源去重:检查数据库中是否存在同 DOI 但 PMID 不同的记录
doi_dedup_lit = None
doi = article.get("doi")
if doi:
doi_result = await db.execute(
select(GlobalLiterature).where(
GlobalLiterature.doi == doi,
GlobalLiterature.pmid != article["pmid"],
)
)
doi_dedup_lit = doi_result.scalar()
if doi_dedup_lit:
# DOI 相同但 PMID 不同——视为同篇文献,保留已有记录并补充新数据
_update_lit_from_article(doi_dedup_lit, article)
await tag_article(db, doi_dedup_lit.id, article.get("mesh_headings", []))
existing_set.add(article["pmid"])
pmid_to_lit[article["pmid"]] = doi_dedup_lit
stats["updated"] += 1
return
lit = GlobalLiterature(
id=_uid(), pmid=article["pmid"], title=article["title"],
abstract=article.get("abstract"), authors=article.get("authors", []),
doi=article.get("doi"), journal=article.get("journal"),
journal_issn=article.get("journal_issn"),
journal_iso=article.get("journal_iso"),
volume=article.get("volume"), issue=article.get("issue"),
pages=article.get("pages"), pub_date=article.get("pub_date"),
pub_year=article.get("pub_year"),
pub_types=article.get("pub_types", []),
mesh_headings=article.get("mesh_headings", []),
language=article.get("language", "en"),
pmc_id=article.get("pmc_id"), is_oa=article.get("is_oa", False),
keywords=article.get("keywords", []),
pubmed_revised=article.get("pubmed_revised"),
citation_status=article.get("citation_status"),
date_completed=article.get("date_completed"),
meshed_date=article.get("meshed_date"),
grants=article.get("grants", []),
trial_reg=article.get("trial_reg"),
study_design=classify_study_design(article.get("pub_types", [])),
rct_detection=detect_rct(
article.get("pub_types"), article.get("title"),
article.get("abstract"), article.get("pub_year"),
),
chemical_list=article.get("chemical_list", []),
gene_symbols=article.get("gene_symbols", []),
num_refs=article.get("num_refs"),
publication_status=article.get("publication_status"),
article_date=article.get("article_date"),
print_date=article.get("print_date"),
create_date=article.get("create_date"),
entrez_date=article.get("entrez_date"),
databank_list=article.get("databank_list", []),
suppl_mesh_list=article.get("suppl_mesh_list", []),
pharmacological_actions=article.get("pharmacological_actions", []),
cois_statement=article.get("cois_statement"),
vernacular_title=article.get("vernacular_title"),
is_preprint=article.get("is_preprint", False),
investigators=article.get("investigators", []),
personal_name_subjects=article.get("personal_name_subjects", []),
publication_notes=article.get("publication_notes", []),
source=article.get("source", "pubmed_api"),
raw_xml_hash=hashlib.sha256(article["title"].encode()).hexdigest()[:16],
)
db.add(lit)
await db.flush()
tagged = await tag_article(db, lit.id, article.get("mesh_headings", []))
stats["tagged"] += tagged
stats["new"] += 1
all_new_pmids.append(article["pmid"])
pmc_id = article.get("pmc_id")
if pmc_id:
oa_pending[article["pmid"]] = pmc_id
feeds = await generate_feeds_for_literature(db, lit.id)
stats["feeds"] += feeds
if feeds > 0:
try:
from app.core.websocket import ws_manager
await ws_manager.send_new_literature_alert(
article["title"], article["pmid"], article.get("journal", ""))
except Exception:
logger.warning("ws_push_failed pmid=%s", article.get("pmid"), exc_info=True)
# 确保期刊在 global_journals 中存在
try:
await ensure_journal_async(
db, article.get("journal_issn"),
article.get("journal"),
)
except Exception:
logger.warning("journal_ensure_failed pmid=%s", article.get("pmid"), exc_info=True)
async def _process_queries(query_list: list[str]):
nonlocal stats
for query in query_list:
try:
if use_europe_pmc:
articles = await search_europe_pmc_articles(query, max_results=max_per_query)
pmids = [a["pmid"] for a in articles if a.get("pmid")]
else:
pmids = await search_pmids(query, max_results=max_per_query)
fresh = [p for p in pmids if p not in seen_pmids]
seen_pmids.update(pmids)
if not fresh:
continue
stats["searched"] += len(fresh)
existing_set = set()
pmid_to_lit: dict[int, GlobalLiterature] = {}
result = await db.execute(
select(GlobalLiterature).where(GlobalLiterature.pmid.in_(fresh))
)
for lit in result.scalars().all():
existing_set.add(lit.pmid)
pmid_to_lit[lit.pmid] = lit
new_pmids = [p for p in fresh if p not in existing_set]
old_pmids = [p for p in fresh if p in existing_set]
fetch_targets = new_pmids + old_pmids
if use_europe_pmc:
# search_europe_pmc_articles 已返回完整数据,按 pmid 索引即可
art_by_pmid = {a["pmid"]: a for a in articles if a.get("pmid")}
# 只处理 fetch_targets 中的文献
for batch_start in range(0, len(fetch_targets), 50):
batch = fetch_targets[batch_start:batch_start+50]
batch_articles = [art_by_pmid[p] for p in batch if p in art_by_pmid]
stats["fetched"] += len(batch_articles)
for article in batch_articles:
await _process_article(article, existing_set, pmid_to_lit)
await db.commit()
await asyncio.sleep(0.4)
else:
for batch_start in range(0, len(fetch_targets), 50):
batch = fetch_targets[batch_start:batch_start+50]
articles = await fetch_articles(batch)
stats["fetched"] += len(articles)
for article in articles:
await _process_article(article, existing_set, pmid_to_lit)
await db.commit()
await asyncio.sleep(0.4)
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as e:
logger.warning("Query error (%s): %s", query[:60], e)
continue
if use_majr:
await _process_queries(_build_oncology_queries(precision_mode))
if use_broad:
await _process_queries(_build_broad_queries())
# OA 全文抓取(新创建的 OA 文献)
if oa_pending:
oa_ok = await _fetch_oa_fulltexts(db, oa_pending)
stats["oa_fetched"] = oa_ok
if all_new_pmids and update_citations:
try:
cit = await update_citation_counts(db, all_new_pmids)
stats["citations"] = cit["updated"]
except Exception:
logger.warning("citation_update_skipped", exc_info=True)
return stats
async def _fetch_oa_fulltexts(db: AsyncSession, pending: dict[int, str]) -> int:
"""批量抓取 PMC OA 全文 XML → 解析 → 写入 COS(或 PG 降级)"""
use_cos = bool(settings.COS_SECRET_ID)
ok = 0
for pmid, pmc_id in pending.items():
xml = await fetch_pmc_xml(pmc_id)
if not xml:
continue
lit = (await db.execute(
select(GlobalLiterature).where(GlobalLiterature.pmid == pmid)
)).scalar()
if not lit:
continue
try:
parsed = parse_full_text(xml)
if not parsed:
lit.pre_extracted_data = {
"_extract_failed": True,
"_extract_error": "XML 解析为空(可能为非标准 DTD)",
"_note": "自动识别失败,请人工核对原始全文",
}
continue
if use_cos:
# 写入 COSPG 只存路径引用
key = make_full_text_key(pmid)
if await upload_json(key, parsed):
lit.full_text_path = key
else:
# COS 上传失败,降级到 PG
lit.full_text_sections = parsed
else:
# 开发环境无 COS,继续存 PG
lit.full_text_sections = parsed
# 提取许可信息
lic = parsed.get("license")
if lic:
lit.license_info = lic
# 预抽取基线特征(Table 1 → pre_extracted_data
baseline = extract_baseline_data(parsed)
if baseline:
lit.pre_extracted_data = baseline
else:
lit.pre_extracted_data = {
"_extract_failed": False,
"_note": "未识别到基线特征表(可能无 Table 1 或格式非常规),请人工核对",
}
ok += 1
except Exception:
logger.warning("oa_save_failed pmid=%s", pmid, exc_info=True)
lit.pre_extracted_data = {
"_extract_failed": True,
"_extract_error": "基线抽取异常崩溃",
"_note": "自动识别失败,请人工核对原始全文",
}
if ok:
await db.commit()
logger.info("oa_fulltext_fetched: %d/%d (cos=%s)", ok, len(pending), use_cos)
return ok
async def pull_oncology_literature(db: AsyncSession, max_per_query: int = 30, precision_mode: str = "majr") -> dict:
"""手动全量管道:MeSH 精搜 + Title/Abstract 宽搜,完整覆盖。返回统计 dict"""
stats = await _run_pipeline(db, max_per_query, use_majr=True, use_broad=True, precision_mode=precision_mode)
return stats
async def pull_daily_oncology(precision_mode: str = "majr"):
"""每日精搜(供 ARQ 定时任务),仅 MeSH 高精度查询"""
async with async_session() as db:
stats = await _run_pipeline(db, max_per_query=20, use_majr=True, use_broad=False, precision_mode=precision_mode)
from app.models.operations import PipelineRun
run = PipelineRun(
id=_uid(), run_type="daily_update", status="success",
articles_total=stats["fetched"], articles_new=stats["new"],
articles_filtered=stats["tagged"], feeds_generated=stats["feeds"],
started_at=stats["started_at"],
completed_at=datetime.now(UTC),
)
db.add(run)
await db.commit()
# pipeline 结束后刷新 stats 缓存
try:
from app.api.v1.public import refresh_stats_cache
await refresh_stats_cache()
except Exception:
logger.warning("refresh_stats_cache 失败", exc_info=True)
return stats
async def pull_broad_oncology():
"""宽搜补充(供 ARQ 定时任务),仅 Title/Abstract 覆盖 in-process + publisher"""
async with async_session() as db:
stats = await _run_pipeline(db, max_per_query=50, use_majr=False, use_broad=True)
from app.models.operations import PipelineRun
run = PipelineRun(
id=_uid(), run_type="broad_update", status="success",
articles_total=stats["fetched"], articles_new=stats["new"],
articles_filtered=stats["tagged"], feeds_generated=stats["feeds"],
started_at=stats["started_at"],
completed_at=datetime.now(UTC),
)
db.add(run)
await db.commit()
try:
from app.api.v1.public import refresh_stats_cache
await refresh_stats_cache()
except Exception:
logger.warning("refresh_stats_cache 失败", exc_info=True)
return stats
async def pull_europe_pmc_oncology(max_results_per_query: int = 5000):
"""Europe PMC 全量管道(cursor 分页,适合初始回填或大数据量)。
供管理后台手动触发。使用 Europe PMC API
- cursor-based 分页,无 10K 上限
- 含 MAJR 精搜 + Title/Abstract 宽搜
- 无 API Key 依赖,10+ req/s
"""
async with async_session() as db:
stats = await _run_pipeline(
db, max_per_query=max_results_per_query,
use_majr=True, use_broad=True,
use_europe_pmc=True,
)
from app.models.operations import PipelineRun
run = PipelineRun(
id=_uid(), run_type="europe_pmc_full", status="success",
articles_total=stats["fetched"], articles_new=stats["new"],
articles_filtered=stats["tagged"], feeds_generated=stats["feeds"],
started_at=stats["started_at"],
completed_at=datetime.now(UTC),
)
db.add(run)
await db.commit()
return stats