pyPaperFlow 0.6.1__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/PKG-INFO +33 -3
  2. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/README.md +32 -2
  3. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/README_zh.md +35 -2
  4. pypaperflow-0.6.2/src/pyPaperFlow/__init__.py +1 -0
  5. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/cli.py +18 -8
  6. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/arxiv_fetcher.py +7 -32
  7. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/biorxiv_fetcher.py +25 -6
  8. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/chemrxiv_fetcher.py +9 -5
  9. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/europepmc_fetcher.py +34 -9
  10. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_utils.py +61 -1
  11. pypaperflow-0.6.1/.claude/settings.local.json +0 -8
  12. pypaperflow-0.6.1/src/pyPaperFlow/__init__.py +0 -1
  13. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/.github/workflows/docs.yml +0 -0
  14. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/.gitignore +0 -0
  15. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/LICENSE +0 -0
  16. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/mkdoc_site/index.md +0 -0
  17. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/mkdocs.yml +0 -0
  18. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/pyproject.toml +0 -0
  19. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/requirements-docs.txt +0 -0
  20. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/scripts/sync_docs.py +0 -0
  21. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/cloak_fallback.py +0 -0
  22. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/cloak_pdf.py +0 -0
  23. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/github_export.py +0 -0
  24. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/mineru_parser.py +0 -0
  25. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/pdf_fetch.py +0 -0
  26. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/undetected_fallback.py +0 -0
  27. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/undetected_pdf.py +0 -0
  28. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_merge.py +0 -0
  29. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_models.py +0 -0
  30. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/__init__.py +0 -0
  31. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/pubmed_fetcher.py +0 -0
  32. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/pubmed_merger.py +0 -0
  33. {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyPaperFlow
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Automated paper fetching and analysis platform.
5
5
  Project-URL: Homepage, https://github.com/MaybeBio/pyPaperFlow
6
6
  Project-URL: Issues, https://github.com/MaybeBio/pyPaperFlow/issues
@@ -112,7 +112,7 @@ This tool is designed to `complement rather than replace` reference management s
112
112
 
113
113
  - **Automated Retrieval from Multiple Sources**: Automatically search and retrieve paper metadata and full-text records from `PubMed/Medline, arXiv, medRxiv, chemRxiv and bioRxiv`. The repository focuses primarily on biomedical research and computational interdisciplinary fields (`Biomedicine + Computational Biology`).
114
114
  - **Full-Text Access**: Enable automatic downloading of open-access full texts in XML/Text format from `PMC`. For preprints and other publications without accessible PMC full texts, alternative acquisition modules are integrated to fetch `original PDFs`, with `Sci-Hub` set as the fallback provider.
115
- - **Preprint Full-Text Fetch (no PDF parsing)**: For preprints without an OA PDF, dedicated methods return clean section-headed text directly — `ArxivFetcher.fetch_full_text(arxiv_id)` reads ar5iv-rendered HTML (arXiv LaTeX → HTML), and `BioRxivFetcher.fetch_full_text(doi)` / `EuropePMCFullText.full_text_xml(doi)` read JATS full-text XML from Europe PMC (bioRxiv / medRxiv and other DOI-indexed preprints).
115
+ - **Preprint Full-Text Fetch (no PDF parsing)**: For preprints without an OA PDF, dedicated methods return clean section-headed text directly — `ArxivFetcher.fetch_full_text(arxiv_id)` reads ar5iv-rendered HTML (arXiv LaTeX → HTML), and `BioRxivFetcher.fetch_full_text(doi)` reads the native bioRxiv/medRxiv full-text HTML first (browser User-Agent + rate-limit handling), then falls back to Europe PMC fullTextXML for preprints already published into PMC.
116
116
  - **Structured Storage**:
117
117
  - **Metadata**: Preserved in well-structured detailed JSON files.
118
118
  - **Full Text**: Stored in multiple formats including parsed JSON and Markdown for versatile downstream usage — JSON for programmatic data analysis, and Markdown optimized for LLM comprehension and processing.
@@ -361,6 +361,8 @@ Our literature database primarily covers biomedical research and computational i
361
361
  >
362
362
  > **Difference vs. exhaustive full-corpus enumeration:** a relevance search is a heuristic over the deposited metadata. The "no-omission-by-construction" alternative is to list the *whole* platform corpus (`filter=prefix…` with no `query`, cursor-paging every record — ≈ 55k ChemRxiv / 436k openRxiv) and run the boolean AND locally, with no relevance engine in the loop; recall is then exactly "all records whose metadata fully matches the query" (a `--start/--end-date` window shrinks the pull). The cost is downloading the full corpus per search, and it still inherits the source-level boundaries above (deposit lag, metadata-only, version duplication). This tool's `search()` path is relevance-based today; the exhaustive mode is not currently exposed as a flag.
363
363
 
364
+ **Retries & backoff (all preprint commands).** Every preprint fetcher retries failed HTTP requests with exponential backoff — the delay doubles per attempt (`1.5s → 3s → 6s → 12s → …`, capped at 30s) and honors the server's `Retry-After` header when present. The default budget is **3 retries (≈4.5s of backoff)**, tuned to fail fast for interactive use so you get a quick answer rather than a ~22s silent stall. Override it per command with the `--max-retries` flag (e.g. `biorxiv-search ... --max-retries 5`); unattended jobs (e.g. `monitor.py`) pass a larger value explicitly. For **bioRxiv / medRxiv**, when the Europe PMC full-text leg is unreachable (e.g. a transient upstream outage) the search degrades to Crossref-only metadata matching and prints a `Warning: ... degraded ...` notice to stderr — this is a fallback, not a failure, but it drops body-only-term matches, so watch for the warning on unattended runs.
365
+
364
366
  We recommend that you proactively learn and master the search syntax of these databases, as our built‑in search module functions similarly to the search bar on official web portals.
365
367
 
366
368
  For instance, here is a typical complex query example tailored for PubMed:
@@ -1464,7 +1466,7 @@ from pyPaperFlow.preprint.europepmc_fetcher import EuropePMCFullText
1464
1466
  arxiv = ArxivFetcher(root_dir="./papers")
1465
1467
  text = arxiv.fetch_full_text("1706.03762") # "" on failure
1466
1468
 
1467
- # bioRxiv / medRxiv → Europe PMC fullTextXML
1469
+ # bioRxiv / medRxiv → native full-text HTML first, then Europe PMC fullTextXML
1468
1470
  biorxiv = BioRxivFetcher(root_dir="./papers", platform="biorxiv")
1469
1471
  text = biorxiv.fetch_full_text("10.1101/2023.06.22.546069")
1470
1472
 
@@ -1476,6 +1478,25 @@ epmc.close()
1476
1478
 
1477
1479
  All three return an empty string `""` on failure, so callers can gracefully fall back to the abstract. The returned text is section-headed (`## Section`) plain text ready for LLM input.
1478
1480
 
1481
+ **bioRxiv / medRxiv full-text route & rate-limit handling**
1482
+
1483
+ `BioRxivFetcher.fetch_full_text(doi)` resolves full text in this order:
1484
+
1485
+ 1. **Native full-text HTML** (`{landing_base}/{doi}.full-text`) — the preprint's own rendered page, parsed into `## Section` text (stops at References, skips figure/table captions). Requires a browser `User-Agent`.
1486
+ 2. **Europe PMC `fullTextXML`** — only present once the preprint has been published into PMC; preprints still at the PPR (preprint) stage return 404 here.
1487
+ 3. Otherwise `""` (caller falls back to the abstract).
1488
+
1489
+ Because bioRxiv/medRxiv sit behind a Cloudflare wall that returns `429` on rapid successive requests, the HTML route applies several safeguards so a batch fetch does not get silently rate-limited into `""`:
1490
+
1491
+ - **Browser User-Agent** — non-browser UAs are always 429 on `.full-text`.
1492
+ - **Connection reuse + `trust_env=False`** — fewer handshakes, avoids local-proxy timeouts.
1493
+ - **Inter-request throttle** (2 s, shared across instances) — requests are never fired back-to-back.
1494
+ - **Status-code branching** — `404` returns immediately (genuinely no full text); only `429/403/5xx` retry.
1495
+ - **Exponential backoff** (3/6/12 s, capped at 20 s, jittered) — retried up to `max_retries` times.
1496
+ - **Global cooldown** (30 s) — after any `429/403`, the whole batch pauses before the next request instead of hammering the wall.
1497
+
1498
+ Typical cost: ~2–4 s per successful paper, ~30–90 s when a rate limit is hit — a small fraction of any downstream LLM step, so it is not the pipeline bottleneck.
1499
+
1479
1500
  ### 6. Critical Reading and Knowledge Graph Analysis: Downstream End‑Use
1480
1501
 
1481
1502
  Upon completing literature retrieval, parsing, and structured processing as outlined above, users obtain chapter‑organised Markdown files and structured JSON files, which serve as the fundamental inputs for subsequent critical reading and knowledge graph analysis.
@@ -1509,6 +1530,8 @@ You may directly run the test scripts to verify the correctness and completeness
1509
1530
 
1510
1531
  > - [ ] Supplement query syntax for various literature databases and implement skill‑based support. Currently only partial MeSH‑aware syntax priors for PubMed are integrated.
1511
1532
  > - [ ] Maintain and update the BioPython library (E‑utilities API) for PubMed parsing from this stage onward. Current version: BioPython 1.87; see [biopython Repository](https://github.com/biopython/biopython) for details.
1533
+ > - [x] Europe PMC can return errors inside an HTTP 200 body (e.g. `{"errCode":404,...}` or a bare `{"version":"6.9"}` with no `resultList`); `EuropePMCSearch` now raises on `errCode` / a missing `resultList` so `last_search_degraded` is set instead of silently returning an empty result set.
1534
+ > - [x] `SourcePaper.version` is populated for arXiv (from the `vN` suffix) and was hardcoded to `""` for bioRxiv/medRxiv/chemRxiv. Now derived from the DOI version suffix (e.g. `.../v2`) via `extract_version_from_doi`, so version dedup (# ③ above) works uniformly across sources.
1512
1535
 
1513
1536
  </details>
1514
1537
 
@@ -1550,5 +1573,12 @@ You may directly run the test scripts to verify the correctness and completeness
1550
1573
 
1551
1574
  </details>
1552
1575
 
1576
+ <details>
1577
+ <summary><b>7. Testing & CI</b></summary>
1578
+
1579
+ > - [ ] Add a pytest suite for pyPaperFlow itself. Regression coverage for the retry/backoff + degradation changes currently lives only in the monitor repo's `tests/test_backfill.py`; `test/` here holds sample output data, and CI (`docs.yml`) builds docs only.
1580
+
1581
+ </details>
1582
+
1553
1583
 
1554
1584
 
@@ -83,7 +83,7 @@ This tool is designed to `complement rather than replace` reference management s
83
83
 
84
84
  - **Automated Retrieval from Multiple Sources**: Automatically search and retrieve paper metadata and full-text records from `PubMed/Medline, arXiv, medRxiv, chemRxiv and bioRxiv`. The repository focuses primarily on biomedical research and computational interdisciplinary fields (`Biomedicine + Computational Biology`).
85
85
  - **Full-Text Access**: Enable automatic downloading of open-access full texts in XML/Text format from `PMC`. For preprints and other publications without accessible PMC full texts, alternative acquisition modules are integrated to fetch `original PDFs`, with `Sci-Hub` set as the fallback provider.
86
- - **Preprint Full-Text Fetch (no PDF parsing)**: For preprints without an OA PDF, dedicated methods return clean section-headed text directly — `ArxivFetcher.fetch_full_text(arxiv_id)` reads ar5iv-rendered HTML (arXiv LaTeX → HTML), and `BioRxivFetcher.fetch_full_text(doi)` / `EuropePMCFullText.full_text_xml(doi)` read JATS full-text XML from Europe PMC (bioRxiv / medRxiv and other DOI-indexed preprints).
86
+ - **Preprint Full-Text Fetch (no PDF parsing)**: For preprints without an OA PDF, dedicated methods return clean section-headed text directly — `ArxivFetcher.fetch_full_text(arxiv_id)` reads ar5iv-rendered HTML (arXiv LaTeX → HTML), and `BioRxivFetcher.fetch_full_text(doi)` reads the native bioRxiv/medRxiv full-text HTML first (browser User-Agent + rate-limit handling), then falls back to Europe PMC fullTextXML for preprints already published into PMC.
87
87
  - **Structured Storage**:
88
88
  - **Metadata**: Preserved in well-structured detailed JSON files.
89
89
  - **Full Text**: Stored in multiple formats including parsed JSON and Markdown for versatile downstream usage — JSON for programmatic data analysis, and Markdown optimized for LLM comprehension and processing.
@@ -332,6 +332,8 @@ Our literature database primarily covers biomedical research and computational i
332
332
  >
333
333
  > **Difference vs. exhaustive full-corpus enumeration:** a relevance search is a heuristic over the deposited metadata. The "no-omission-by-construction" alternative is to list the *whole* platform corpus (`filter=prefix…` with no `query`, cursor-paging every record — ≈ 55k ChemRxiv / 436k openRxiv) and run the boolean AND locally, with no relevance engine in the loop; recall is then exactly "all records whose metadata fully matches the query" (a `--start/--end-date` window shrinks the pull). The cost is downloading the full corpus per search, and it still inherits the source-level boundaries above (deposit lag, metadata-only, version duplication). This tool's `search()` path is relevance-based today; the exhaustive mode is not currently exposed as a flag.
334
334
 
335
+ **Retries & backoff (all preprint commands).** Every preprint fetcher retries failed HTTP requests with exponential backoff — the delay doubles per attempt (`1.5s → 3s → 6s → 12s → …`, capped at 30s) and honors the server's `Retry-After` header when present. The default budget is **3 retries (≈4.5s of backoff)**, tuned to fail fast for interactive use so you get a quick answer rather than a ~22s silent stall. Override it per command with the `--max-retries` flag (e.g. `biorxiv-search ... --max-retries 5`); unattended jobs (e.g. `monitor.py`) pass a larger value explicitly. For **bioRxiv / medRxiv**, when the Europe PMC full-text leg is unreachable (e.g. a transient upstream outage) the search degrades to Crossref-only metadata matching and prints a `Warning: ... degraded ...` notice to stderr — this is a fallback, not a failure, but it drops body-only-term matches, so watch for the warning on unattended runs.
336
+
335
337
  We recommend that you proactively learn and master the search syntax of these databases, as our built‑in search module functions similarly to the search bar on official web portals.
336
338
 
337
339
  For instance, here is a typical complex query example tailored for PubMed:
@@ -1435,7 +1437,7 @@ from pyPaperFlow.preprint.europepmc_fetcher import EuropePMCFullText
1435
1437
  arxiv = ArxivFetcher(root_dir="./papers")
1436
1438
  text = arxiv.fetch_full_text("1706.03762") # "" on failure
1437
1439
 
1438
- # bioRxiv / medRxiv → Europe PMC fullTextXML
1440
+ # bioRxiv / medRxiv → native full-text HTML first, then Europe PMC fullTextXML
1439
1441
  biorxiv = BioRxivFetcher(root_dir="./papers", platform="biorxiv")
1440
1442
  text = biorxiv.fetch_full_text("10.1101/2023.06.22.546069")
1441
1443
 
@@ -1447,6 +1449,25 @@ epmc.close()
1447
1449
 
1448
1450
  All three return an empty string `""` on failure, so callers can gracefully fall back to the abstract. The returned text is section-headed (`## Section`) plain text ready for LLM input.
1449
1451
 
1452
+ **bioRxiv / medRxiv full-text route & rate-limit handling**
1453
+
1454
+ `BioRxivFetcher.fetch_full_text(doi)` resolves full text in this order:
1455
+
1456
+ 1. **Native full-text HTML** (`{landing_base}/{doi}.full-text`) — the preprint's own rendered page, parsed into `## Section` text (stops at References, skips figure/table captions). Requires a browser `User-Agent`.
1457
+ 2. **Europe PMC `fullTextXML`** — only present once the preprint has been published into PMC; preprints still at the PPR (preprint) stage return 404 here.
1458
+ 3. Otherwise `""` (caller falls back to the abstract).
1459
+
1460
+ Because bioRxiv/medRxiv sit behind a Cloudflare wall that returns `429` on rapid successive requests, the HTML route applies several safeguards so a batch fetch does not get silently rate-limited into `""`:
1461
+
1462
+ - **Browser User-Agent** — non-browser UAs are always 429 on `.full-text`.
1463
+ - **Connection reuse + `trust_env=False`** — fewer handshakes, avoids local-proxy timeouts.
1464
+ - **Inter-request throttle** (2 s, shared across instances) — requests are never fired back-to-back.
1465
+ - **Status-code branching** — `404` returns immediately (genuinely no full text); only `429/403/5xx` retry.
1466
+ - **Exponential backoff** (3/6/12 s, capped at 20 s, jittered) — retried up to `max_retries` times.
1467
+ - **Global cooldown** (30 s) — after any `429/403`, the whole batch pauses before the next request instead of hammering the wall.
1468
+
1469
+ Typical cost: ~2–4 s per successful paper, ~30–90 s when a rate limit is hit — a small fraction of any downstream LLM step, so it is not the pipeline bottleneck.
1470
+
1450
1471
  ### 6. Critical Reading and Knowledge Graph Analysis: Downstream End‑Use
1451
1472
 
1452
1473
  Upon completing literature retrieval, parsing, and structured processing as outlined above, users obtain chapter‑organised Markdown files and structured JSON files, which serve as the fundamental inputs for subsequent critical reading and knowledge graph analysis.
@@ -1480,6 +1501,8 @@ You may directly run the test scripts to verify the correctness and completeness
1480
1501
 
1481
1502
  > - [ ] Supplement query syntax for various literature databases and implement skill‑based support. Currently only partial MeSH‑aware syntax priors for PubMed are integrated.
1482
1503
  > - [ ] Maintain and update the BioPython library (E‑utilities API) for PubMed parsing from this stage onward. Current version: BioPython 1.87; see [biopython Repository](https://github.com/biopython/biopython) for details.
1504
+ > - [x] Europe PMC can return errors inside an HTTP 200 body (e.g. `{"errCode":404,...}` or a bare `{"version":"6.9"}` with no `resultList`); `EuropePMCSearch` now raises on `errCode` / a missing `resultList` so `last_search_degraded` is set instead of silently returning an empty result set.
1505
+ > - [x] `SourcePaper.version` is populated for arXiv (from the `vN` suffix) and was hardcoded to `""` for bioRxiv/medRxiv/chemRxiv. Now derived from the DOI version suffix (e.g. `.../v2`) via `extract_version_from_doi`, so version dedup (# ③ above) works uniformly across sources.
1483
1506
 
1484
1507
  </details>
1485
1508
 
@@ -1521,5 +1544,12 @@ You may directly run the test scripts to verify the correctness and completeness
1521
1544
 
1522
1545
  </details>
1523
1546
 
1547
+ <details>
1548
+ <summary><b>7. Testing & CI</b></summary>
1549
+
1550
+ > - [ ] Add a pytest suite for pyPaperFlow itself. Regression coverage for the retry/backoff + degradation changes currently lives only in the monitor repo's `tests/test_backfill.py`; `test/` here holds sample output data, and CI (`docs.yml`) builds docs only.
1551
+
1552
+ </details>
1553
+
1524
1554
 
1525
1555
 
@@ -82,7 +82,7 @@
82
82
 
83
83
  - **多来源自动检索**:自动从 `PubMed/Medline`、`arXiv`、`medRxiv`、`chemRxiv` 和 `bioRxiv` 搜索并获取论文元数据与全文记录。项目主要聚焦于生物医学与计算交叉领域(`Biomedicine + Computational Biology`)。
84
84
  - **全文获取**:支持自动从 `PMC` 下载开放获取的 XML/Text 全文。对于预印本及其他没有 PMC 全文的文献,集成了额外的获取模块以下载 `原始 PDF`,并将 `Sci-Hub` 作为兜底来源。
85
- - **预印本全文获取(免 PDF 解析)**:对于没有开放获取 PDF 的预印本,提供专用方法直接返回带章节标题的纯文本——`ArxivFetcher.fetch_full_text(arxiv_id)` 读取 ar5iv 渲染 HTML(arXiv LaTeX→HTML),`BioRxivFetcher.fetch_full_text(doi)` / `EuropePMCFullText.full_text_xml(doi)` 从 Europe PMC 读取 JATS 全文 XML(bioRxiv / medRxiv 及其它 DOI 收录预印本)。
85
+ - **预印本全文获取(免 PDF 解析)**:对于没有开放获取 PDF 的预印本,提供专用方法直接返回带章节标题的纯文本——`ArxivFetcher.fetch_full_text(arxiv_id)` 读取 ar5iv 渲染 HTML(arXiv LaTeX→HTML),`BioRxivFetcher.fetch_full_text(doi)` 优先读取 bioRxiv / medRxiv 原生全文 HTML(浏览器 User-Agent + 限流处理),再回退到 Europe PMC fullTextXML(已正式收录进 PMC 的预印本);`EuropePMCFullText.full_text_xml(doi)` 从 Europe PMC 读取 JATS 全文 XML。
86
86
  - **结构化存储**:
87
87
  - **元数据**:保存为结构清晰的详细 JSON 文件。
88
88
  - **全文**:保存为多种格式,包括解析后的 JSON 和 Markdown,方便下游使用。其中 JSON 适合程序化分析,Markdown 更适合 LLM 理解与处理。
@@ -336,6 +336,8 @@ ChemRxiv 相关模块:
336
336
  >
337
337
  > 全库拉取对于轻量级的文献调研并不适用,除非你有明确的理由需要获取某一特定数据库的全部文献,而且每年每月更新的文献本身就具有一定的冗余性,所以从效率+数量上考虑,单纯相关性检索应该能够满足绝大多数科研工作者的文献调研需求(`因为真正重要的内容一定会反复出现,往往不需要担心全量遗漏`)。当然,对于全量拉取,可以参考其他开源工具如 [paperscraper](https://github.com/jannisborn/paperscraper) 等的实现。
338
338
 
339
+ **重试与退避(所有预印本命令)。** 每个预印本 fetcher 在 HTTP 请求失败时都会按指数退避重试——延迟逐次翻倍(`1.5s → 3s → 6s → 12s → …`,上限 30s),并在服务器返回 `Retry-After` 头时优先遵循该头。默认预算是 **3 次重试(约 4.5s 退避)**,为交互式使用而调成"快速失败",让你尽快得到结果,而不是静默卡住约 22s。可用每条命令的 `--max-retries` 参数覆盖(例如 `biorxiv-search ... --max-retries 5`);无人值守任务(如 `monitor.py`)会显式传入更大的值。对 **bioRxiv / medRxiv** 而言,当 Europe PMC 全文支路不可达时(如上游临时故障),检索会降级为 Crossref 纯元数据匹配,并向 stderr 打印 `Warning: ... degraded ...` 提示——这是降级而非失败,但会丢失仅出现在正文中的词项命中,因此无人值守运行时务必留意该警告。
340
+
339
341
  建议用户提前学习并熟练掌握上述数据库的检索语法,本工具内置搜索模块的运行逻辑与数据库网页端搜索框基本一致。
340
342
 
341
343
  > ✨ 这里我们为你提供了几个特定文献数据库构建搜索query的skill,[paper query skill](./docs/Skills.md)
@@ -1454,7 +1456,7 @@ from pyPaperFlow.preprint.europepmc_fetcher import EuropePMCFullText
1454
1456
  arxiv = ArxivFetcher(root_dir="./papers")
1455
1457
  text = arxiv.fetch_full_text("1706.03762") # 失败时返回 ""
1456
1458
 
1457
- # bioRxiv / medRxiv → Europe PMC fullTextXML
1459
+ # bioRxiv / medRxiv → 原生全文 HTML 优先,Europe PMC fullTextXML 兜底
1458
1460
  biorxiv = BioRxivFetcher(root_dir="./papers", platform="biorxiv")
1459
1461
  text = biorxiv.fetch_full_text("10.1101/2023.06.22.546069")
1460
1462
 
@@ -1466,6 +1468,25 @@ epmc.close()
1466
1468
 
1467
1469
  三者失败时均返回空字符串 `""`,调用方可优雅回退到摘要。返回文本为带章节标题(`## Section`)的纯文本,可直接作为 LLM 输入。
1468
1470
 
1471
+ **bioRxiv / medRxiv 全文获取路径与限流处理**
1472
+
1473
+ `BioRxivFetcher.fetch_full_text(doi)` 按以下顺序解析全文:
1474
+
1475
+ 1. **原生全文 HTML**(`{landing_base}/{doi}.full-text`)——预印本自身的渲染页面,解析为 `## Section` 纯文本(遇 References 停止,跳过图/表标题)。需要浏览器 `User-Agent`。
1476
+ 2. **Europe PMC `fullTextXML`**——仅在预印本已正式收录进 PMC 后才存在;仍处于 PPR(预印本)阶段的记录在此返回 404。
1477
+ 3. 否则返回 `""`(调用方回退到摘要)。
1478
+
1479
+ 由于 bioRxiv/medRxiv 位于 Cloudflare 防火墙之后,连续快速请求会触发 `429`,HTML 路径应用了多重保护,避免批量抓取被静默限流成 `""`:
1480
+
1481
+ - **浏览器 User-Agent**——非浏览器 UA 在 `.full-text` 上恒为 429。
1482
+ - **连接复用 + `trust_env=False`**——减少握手、避免本地代理超时。
1483
+ - **请求间隔节流**(2 s,跨实例共享)——请求绝不背靠背连发。
1484
+ - **状态码分支**——`404` 立即返回(确实无全文);仅 `429/403/5xx` 才重试。
1485
+ - **指数退避**(3/6/12 s,封顶 20 s,加抖动)——最多重试 `max_retries` 次。
1486
+ - **全局冷却**(30 s)——任一次 `429/403` 后,整批在下一次请求前暂停,而非继续撞击防火墙。
1487
+
1488
+ 典型耗时:成功约 2–4 s/篇,触发限流约 30–90 s——相对任何下游 LLM 步骤都只是零头,不构成流水线瓶颈。
1489
+
1469
1490
 
1470
1491
  #### 1. 命令速查 (TL;DR)
1471
1492
 
@@ -1570,6 +1591,8 @@ Europe PMC 走的是预印本全文,能补上 Crossref 只看标题摘要而漏
1570
1591
  5. **ChemRxiv 检索走 Crossref,不用官方 API**:ChemRxiv 的公开 API(`chemrxiv.org/engage/chemrxiv/public-api/v1`)对非浏览器客户端(httpx/curl)返回 Cloudflare 403,而 Crossref 侧(prefix `10.26434`)是稳定、最全的元数据通道,故 `chemrxiv-*` 只查 Crossref(也不并入 Europe PMC)。⚠️ 代价见下(版本重复 / 新贴有入库延迟 / 只看标题摘要)。完整讨论见 README「注意点:为什么预印本检索走 Crossref 元数据」。
1571
1592
  6. **ChemRxiv PDF 直连可下**:PDF 端点固定为 `https://chemrxiv.org/doi/pdf/{doi}`,本网络实测经 httpx 直连即返回 `%PDF` 字节,**不需要** CloakBrowser / undetected_chromedriver 回退(与 bioRxiv/medRxiv 的 Cloudflare 403 相反)。万一某篇直连失败,`--download-pdf` 仍会自动走浏览器回退链。
1572
1593
  7. **版本重复(去重要手动)**:Crossref 把 ChemRxiv 每次改版都单独注册成一个 DOI work——`10.26434/chemrxiv-2025-tj4pr-v2` 与 `chemrxiv-2025-tj4pr`、`10.26434/chemrxiv.15007500/v2` 与 `/v1` 都会作为独立结果同时命中(见下方实测,3 条 DOI 实为 2 篇论文)。`chemrxiv-*` 不去重,用 `--file` 清单抓取前可自行剔除旧版 DOI。
1594
+ 8. **重试与退避**:所有预印本命令(`arxiv-*` / `biorxiv-*` / `medrxiv-*` / `chemrxiv-*`)的 HTTP 请求失败都会按指数退避重试(延迟逐次翻倍 `1.5s→3s→6s→12s→…`,封顶 30s,并优先遵循 `Retry-After` 头)。默认 **3 次重试(约 4.5s)**,面向交互式使用快速失败;可用 `--max-retries` 覆盖(如 `biorxiv-search ... --max-retries 5`),无人值守任务(如 `monitor.py`)会显式传更大值。
1595
+ 9. **bioRxiv/medRxiv 的 Europe PMC 降级**:当 Europe PMC 全文支路不可达(如上游临时 503)时,`biorxiv-*` / `medrxiv-*` 的搜索会自动降级为纯 Crossref 元数据匹配,并向 stderr 打印 `Warning: ... degraded ...`——这是降级而非失败,但会丢失仅出现在正文中的词项命中(如基因缩写),无人值守运行时务必留意该警告。
1573
1596
 
1574
1597
  #### 2. 搜索并获取 arXiv 论文
1575
1598
  如果你只想先拿到 ID,可以先搜索;如果想同时获取元数据和 PDF,可以直接 fetch。
@@ -2328,6 +2351,8 @@ paperflow pubmed-export-md -i IDR_all_20260520_2026-05-20_18-33-54.json -o ./IDR
2328
2351
 
2329
2352
  > - [ ] 各文献数据库Query搜索语法的补充,尝试skill化,目前仅实现pubmed mesh部分语法先验结合
2330
2353
  > - [ ] 从这一步开始,关于pubmed数据库解析部分,考虑BioPython库的更新与维护(E-utility的接口)。目前biopython version 1.87,详情参考[biopython仓库](https://github.com/biopython/biopython)
2354
+ > - [x] Europe PMC 可能在 HTTP 200 响应体内返回错误(如 `{"errCode":404,...}` 或缺少 `resultList` 的裸 `{"version":"6.9"}`);`EuropePMCSearch` 现对 `errCode` / 缺失 `resultList` 抛异常,从而设置 `last_search_degraded`,不再静默返回空结果集。
2355
+ > - [x] `SourcePaper.version` 对 arXiv 有值(来自 `vN` 后缀),但此前对 bioRxiv/medRxiv/chemRxiv 硬编码为 `""`。现通过 `extract_version_from_doi` 从 DOI 版本后缀(如 `.../v2`)推导,使版本去重(上文 ③)在各源间一致生效。
2331
2356
 
2332
2357
  </details>
2333
2358
 
@@ -2376,6 +2401,14 @@ paperflow pubmed-export-md -i IDR_all_20260520_2026-05-20_18-33-54.json -o ./IDR
2376
2401
  </details>
2377
2402
 
2378
2403
 
2404
+ <details markdown="1">
2405
+ <summary><b>7. 测试与 CI</b></summary>
2406
+
2407
+ > - [ ] 为 pyPaperFlow 本身补充 pytest 测试套件。目前退避/降级改动的回归覆盖只存在于 monitor 仓库的 `tests/test_backfill.py`;本仓库 `test/` 只有样例输出数据,CI(`docs.yml`)仅构建文档。
2408
+
2409
+ </details>
2410
+
2411
+
2379
2412
 
2380
2413
 
2381
2414
 
@@ -0,0 +1 @@
1
+ __version__ = "0.6.2"
@@ -8,6 +8,7 @@ from .pubmed.pubmed_fetcher import PubmedFetcher
8
8
  from .preprint.arxiv_fetcher import ArxivFetcher
9
9
  from .preprint.biorxiv_fetcher import BioRxivFetcher
10
10
  from .preprint.chemrxiv_fetcher import ChemRxivFetcher
11
+ from .preprint.source_utils import DEFAULT_MAX_RETRIES
11
12
  from .pubmed.pubmed_merger import PubmedMerger
12
13
  from .integrations import pdf_fetch
13
14
  from .integrations.github_export import run_github_export
@@ -20,6 +21,7 @@ opt_storage = typer.Option("./Papers", "--storage-dir", "-s", help="Directory in
20
21
  opt_email = typer.Option(..., "--email", help="Entrez Email.")
21
22
  opt_api_key = typer.Option(None, "--api-key", help="NCBI API Key (recommended).")
22
23
  opt_max_retries = typer.Option(3, "--max-retries", help="Maximum number of retries for Entrez API calls.")
24
+ opt_preprint_max_retries = typer.Option(DEFAULT_MAX_RETRIES, "--max-retries", help="Maximum number of retries per request. Backoff is exponential, capped at 30s.")
23
25
  opt_batch_size = typer.Option(50, "--batch-size", "-b", help="Batch size for fetching.")
24
26
  opt_arxiv_backend = typer.Option("native", "--backend", help="arXiv backend: 'native' or 'paperscraper'.")
25
27
 
@@ -338,9 +340,10 @@ def arxiv_search_cmd(
338
340
  start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
339
341
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
340
342
  backend: str = opt_arxiv_backend,
343
+ max_retries: int = opt_preprint_max_retries,
341
344
  ):
342
345
  """Search arXiv and write matching IDs to a text file."""
343
- fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend)
346
+ fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend, max_retries=max_retries)
344
347
  records = fetcher.search(query=query, max_results=max_results, start_date=start_date, end_date=end_date)
345
348
  typer.echo(f"Found {len(records)} arXiv papers.")
346
349
  if not records:
@@ -374,12 +377,13 @@ def arxiv_fetch_cmd(
374
377
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD (query mode only)."),
375
378
  download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
376
379
  backend: str = opt_arxiv_backend,
380
+ max_retries: int = opt_preprint_max_retries,
377
381
  ):
378
382
  """Fetch arXiv metadata and attempt to download PDFs.
379
383
 
380
384
  Provide one of: a positional query, --file, or one or more --id values.
381
385
  """
382
- fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend)
386
+ fetcher = ArxivFetcher(root_dir=storage_dir, backend=backend, max_retries=max_retries)
383
387
  output = output_dir if output_dir else storage_dir
384
388
 
385
389
  provided = [name for name, value in (("query", query), ("--file", file), ("--id", arxiv_id)) if value]
@@ -426,6 +430,7 @@ def biorxiv_search_cmd(
426
430
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
427
431
  window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
428
432
  use_europepmc: bool = typer.Option(True, "--europepmc/--no-europepmc", help="Also search Europe PMC full text (boolean AND) and union with Crossref results. Default: enabled."),
433
+ max_retries: int = opt_preprint_max_retries,
429
434
  ):
430
435
  """Search bioRxiv and write matching IDs to a text file.
431
436
 
@@ -438,7 +443,7 @@ def biorxiv_search_cmd(
438
443
  fg=typer.colors.YELLOW,
439
444
  )
440
445
 
441
- fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days)
446
+ fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days, max_retries=max_retries)
442
447
  records = fetcher.search(query=query, start_date=start_date, end_date=end_date, max_results=max_results, use_europepmc=use_europepmc)
443
448
  typer.echo(f"Found {len(records)} bioRxiv papers.")
444
449
  for record in records:
@@ -462,6 +467,7 @@ def biorxiv_fetch_cmd(
462
467
  window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
463
468
  download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
464
469
  use_europepmc: bool = typer.Option(True, "--europepmc/--no-europepmc", help="In query mode, also search Europe PMC full text and union with Crossref results. Default: enabled."),
470
+ max_retries: int = opt_preprint_max_retries,
465
471
  ):
466
472
  """Fetch bioRxiv metadata and attempt to download PDFs.
467
473
 
@@ -475,7 +481,7 @@ def biorxiv_fetch_cmd(
475
481
  fg=typer.colors.YELLOW,
476
482
  )
477
483
 
478
- fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days)
484
+ fetcher = BioRxivFetcher(root_dir=storage_dir, window_days=window_days, max_retries=max_retries)
479
485
  output = output_dir if output_dir else storage_dir
480
486
 
481
487
  provided = [name for name, value in (("query", query), ("--file", file), ("--doi", doi)) if value]
@@ -523,6 +529,7 @@ def medrxiv_search_cmd(
523
529
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
524
530
  window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
525
531
  use_europepmc: bool = typer.Option(True, "--europepmc/--no-europepmc", help="Also search Europe PMC full text (boolean AND) and union with Crossref results. Default: enabled."),
532
+ max_retries: int = opt_preprint_max_retries,
526
533
  ):
527
534
  """Search medRxiv and write matching IDs to a text file.
528
535
 
@@ -535,7 +542,7 @@ def medrxiv_search_cmd(
535
542
  fg=typer.colors.YELLOW,
536
543
  )
537
544
 
538
- fetcher = BioRxivFetcher(root_dir=storage_dir, platform="medrxiv", window_days=window_days)
545
+ fetcher = BioRxivFetcher(root_dir=storage_dir, platform="medrxiv", window_days=window_days, max_retries=max_retries)
539
546
  records = fetcher.search(query=query, start_date=start_date, end_date=end_date, max_results=max_results, use_europepmc=use_europepmc)
540
547
  typer.echo(f"Found {len(records)} medRxiv papers.")
541
548
  for record in records:
@@ -559,6 +566,7 @@ def medrxiv_fetch_cmd(
559
566
  window_days: int = typer.Option(365, "--window-days", help="Compatibility-only option. Retained for older scripts; not used by current Crossref-backed direct query path."),
560
567
  download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
561
568
  use_europepmc: bool = typer.Option(True, "--europepmc/--no-europepmc", help="In query mode, also search Europe PMC full text and union with Crossref results. Default: enabled."),
569
+ max_retries: int = opt_preprint_max_retries,
562
570
  ):
563
571
  """Fetch medRxiv metadata and attempt to download PDFs.
564
572
 
@@ -572,7 +580,7 @@ def medrxiv_fetch_cmd(
572
580
  fg=typer.colors.YELLOW,
573
581
  )
574
582
 
575
- fetcher = BioRxivFetcher(root_dir=storage_dir, platform="medrxiv", window_days=window_days)
583
+ fetcher = BioRxivFetcher(root_dir=storage_dir, platform="medrxiv", window_days=window_days, max_retries=max_retries)
576
584
  output = output_dir if output_dir else storage_dir
577
585
 
578
586
  provided = [name for name, value in (("query", query), ("--file", file), ("--doi", doi)) if value]
@@ -618,13 +626,14 @@ def chemrxiv_search_cmd(
618
626
  output_dir: Optional[str] = typer.Option(None, "--output-dir", "-o", help="Directory to save searched ChemRxiv IDs."),
619
627
  start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD."),
620
628
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD."),
629
+ max_retries: int = opt_preprint_max_retries,
621
630
  ):
622
631
  """Search ChemRxiv and write matching DOIs to a text file.
623
632
 
624
633
  Metadata is retrieved from Crossref over ChemRxiv records (prefix
625
634
  10.26434, publisher American Chemical Society (ACS)).
626
635
  """
627
- fetcher = ChemRxivFetcher(root_dir=storage_dir)
636
+ fetcher = ChemRxivFetcher(root_dir=storage_dir, max_retries=max_retries)
628
637
  records = fetcher.search(query=query, start_date=start_date, end_date=end_date, max_results=max_results)
629
638
  typer.echo(f"Found {len(records)} ChemRxiv papers.")
630
639
  for record in records:
@@ -646,13 +655,14 @@ def chemrxiv_fetch_cmd(
646
655
  start_date: Optional[str] = typer.Option(None, "--start-date", help="Optional start date in YYYY-MM-DD (query mode only)."),
647
656
  end_date: Optional[str] = typer.Option(None, "--end-date", help="Optional end date in YYYY-MM-DD (query mode only)."),
648
657
  download_pdf: bool = typer.Option(True, "--download-pdf/--no-download-pdf", help="Download PDFs when available."),
658
+ max_retries: int = opt_preprint_max_retries,
649
659
  ):
650
660
  """Fetch ChemRxiv metadata and attempt to download PDFs.
651
661
 
652
662
  Metadata retrieval uses Crossref over ChemRxiv records (prefix 10.26434).
653
663
  Provide one of: a positional query, --file, or one or more --doi values.
654
664
  """
655
- fetcher = ChemRxivFetcher(root_dir=storage_dir)
665
+ fetcher = ChemRxivFetcher(root_dir=storage_dir, max_retries=max_retries)
656
666
  output = output_dir if output_dir else storage_dir
657
667
 
658
668
  provided = [name for name, value in (("query", query), ("--file", file), ("--doi", doi)) if value]
@@ -5,7 +5,6 @@ import importlib
5
5
  import time
6
6
  import xml.etree.ElementTree as ET
7
7
  from datetime import date, datetime, timezone
8
- from email.utils import parsedate_to_datetime
9
8
  from pathlib import Path
10
9
  from typing import Any, Dict, Iterable, List, Optional
11
10
 
@@ -14,12 +13,14 @@ from bs4 import BeautifulSoup
14
13
 
15
14
  from .source_models import SourcePaper
16
15
  from .source_utils import (
16
+ DEFAULT_MAX_RETRIES,
17
17
  build_source_record_dir,
18
18
  download_binary,
19
19
  extract_year,
20
20
  normalize_text,
21
21
  safe_filename,
22
22
  save_json,
23
+ sleep_before_retry,
23
24
  )
24
25
 
25
26
 
@@ -73,7 +74,7 @@ class ArxivFetcher:
73
74
  root_dir: str,
74
75
  backend: str = "native",
75
76
  batch_size: int = 100,
76
- max_retries: int = 3,
77
+ max_retries: int = DEFAULT_MAX_RETRIES,
77
78
  request_timeout: float = 60.0,
78
79
  ):
79
80
  self.root_dir = root_dir
@@ -382,7 +383,7 @@ class ArxivFetcher:
382
383
  if response.status_code == 429:
383
384
  last_error = RuntimeError(f"arXiv API rate limited request for {description}")
384
385
  if attempt + 1 < self.max_retries:
385
- self._sleep_before_retry(response, attempt)
386
+ sleep_before_retry(response, attempt)
386
387
  continue
387
388
  break
388
389
  response.raise_for_status()
@@ -391,19 +392,19 @@ class ArxivFetcher:
391
392
  except ET.ParseError as exc:
392
393
  last_error = exc
393
394
  if attempt + 1 < self.max_retries:
394
- self._sleep_before_retry(response, attempt)
395
+ sleep_before_retry(response, attempt)
395
396
  continue
396
397
  break
397
398
  except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.TransportError) as exc:
398
399
  last_error = exc
399
400
  if attempt + 1 < self.max_retries:
400
- self._sleep_before_retry(response, attempt)
401
+ sleep_before_retry(response, attempt)
401
402
  continue
402
403
  break
403
404
  except Exception as exc:
404
405
  last_error = exc
405
406
  if attempt + 1 < self.max_retries:
406
- self._sleep_before_retry(response, attempt)
407
+ sleep_before_retry(response, attempt)
407
408
  continue
408
409
  break
409
410
 
@@ -426,32 +427,6 @@ class ArxivFetcher:
426
427
  self._http_client = httpx.Client(http2=False, **client_kwargs)
427
428
  return self._http_client
428
429
 
429
- def _sleep_before_retry(self, response: Optional[httpx.Response], attempt: int) -> None:
430
- retry_after = self._retry_after_seconds(response)
431
- delay = retry_after if retry_after is not None else min(30.0, 1.5 * (2**attempt))
432
- time.sleep(max(0.0, delay))
433
-
434
- def _retry_after_seconds(self, response: Optional[httpx.Response]) -> Optional[float]:
435
- if response is None:
436
- return None
437
-
438
- raw_retry_after = normalize_text(response.headers.get("Retry-After", ""))
439
- if not raw_retry_after:
440
- return None
441
-
442
- if raw_retry_after.isdigit():
443
- return float(raw_retry_after)
444
-
445
- try:
446
- retry_after_dt = parsedate_to_datetime(raw_retry_after)
447
- except (TypeError, ValueError, IndexError):
448
- return None
449
-
450
- if retry_after_dt.tzinfo is None:
451
- retry_after_dt = retry_after_dt.replace(tzinfo=timezone.utc)
452
- now = datetime.now(retry_after_dt.tzinfo)
453
- return max(0.0, (retry_after_dt - now).total_seconds())
454
-
455
430
  def _normalize_date_bounds(
456
431
  self,
457
432
  start_date: Optional[str],
@@ -18,15 +18,18 @@ from ..integrations.undetected_fallback import is_undetected_enabled, undetected
18
18
 
19
19
  from .source_models import SourcePaper
20
20
  from .source_utils import (
21
+ DEFAULT_MAX_RETRIES,
21
22
  basic_boolean_text_match,
22
23
  build_source_record_dir,
23
24
  detect_platform_from_doi,
24
25
  download_binary,
25
26
  ensure_directory,
27
+ extract_version_from_doi,
26
28
  extract_year,
27
29
  normalize_text,
28
30
  safe_filename,
29
31
  save_json,
32
+ sleep_before_retry,
30
33
  )
31
34
 
32
35
 
@@ -123,7 +126,7 @@ class BioRxivFetcher:
123
126
  root_dir: str,
124
127
  platform: str = "biorxiv",
125
128
  window_days: int = 365,
126
- max_retries: int = 3,
129
+ max_retries: int = DEFAULT_MAX_RETRIES,
127
130
  request_timeout: float = 60.0,
128
131
  ):
129
132
  if platform not in PLATFORM_CONFIG:
@@ -136,6 +139,10 @@ class BioRxivFetcher:
136
139
  self.window_days = max(1, int(window_days))
137
140
  self.max_retries = max(1, int(max_retries))
138
141
  self.request_timeout = float(request_timeout)
142
+ # Set by search() when Europe PMC was unreachable, so the results came from
143
+ # Crossref alone. Returning [] there is indistinguishable from a genuine
144
+ # empty result, so callers need this to tell a lossy week from a quiet one.
145
+ self.last_search_degraded: Optional[str] = None
139
146
  self.headers = {
140
147
  "User-Agent": "pyPaperFlow/0.1.0 (+https://github.com/MaybeBio/pyPaperFlow)",
141
148
  "Accept": "application/json,text/html;q=0.9,*/*;q=0.8",
@@ -178,6 +185,8 @@ class BioRxivFetcher:
178
185
  if not query_text:
179
186
  raise ValueError("query must be non-empty")
180
187
 
188
+ self.last_search_degraded = None
189
+
181
190
  if DOI_RE.match(query_text):
182
191
  return self._search_by_doi(query_text)
183
192
 
@@ -262,6 +271,7 @@ class BioRxivFetcher:
262
271
  try:
263
272
  from .europepmc_fetcher import EuropePMCSearch
264
273
  except Exception:
274
+ self.last_search_degraded = "Europe PMC module unavailable"
265
275
  return []
266
276
 
267
277
  start_dt, end_dt = self._normalize_date_range(start_date, end_date)
@@ -280,8 +290,15 @@ class BioRxivFetcher:
280
290
  max_results=max_results,
281
291
  )
282
292
  except Exception as exc:
293
+ status = getattr(getattr(exc, "response", None), "status_code", None)
294
+ if status:
295
+ reason = f"HTTP {status}"
296
+ else:
297
+ message = str(exc).strip()
298
+ reason = message or type(exc).__name__
299
+ self.last_search_degraded = f"Europe PMC unavailable ({reason})"
283
300
  print(
284
- f"[biorxiv] Europe PMC search failed ({exc}); returning Crossref-only results.",
301
+ f"[{self.platform}] Europe PMC search failed ({exc}); returning Crossref-only results.",
285
302
  file=sys.stderr,
286
303
  )
287
304
  return []
@@ -341,7 +358,7 @@ class BioRxivFetcher:
341
358
  landing_url=landing_url,
342
359
  pdf_url=pdf_url,
343
360
  query=query,
344
- version="",
361
+ version=extract_version_from_doi(doi),
345
362
  keywords=[],
346
363
  extra={
347
364
  "provider": "europepmc",
@@ -398,6 +415,7 @@ class BioRxivFetcher:
398
415
  url = f"{BIO_RXIV_CROSSREF_API}/{quote(doi, safe='')}"
399
416
  last_error: Optional[Exception] = None
400
417
  for attempt in range(self.max_retries):
418
+ response: Optional[httpx.Response] = None
401
419
  try:
402
420
  response = self._get_http_client().get(url)
403
421
  if response.status_code == 404:
@@ -407,7 +425,7 @@ class BioRxivFetcher:
407
425
  except Exception as exc:
408
426
  last_error = exc
409
427
  if attempt + 1 < self.max_retries:
410
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
428
+ sleep_before_retry(response, attempt)
411
429
  if last_error is not None:
412
430
  raise last_error
413
431
  raise RuntimeError(f"Failed to fetch Crossref work for DOI {doi}")
@@ -591,6 +609,7 @@ class BioRxivFetcher:
591
609
  last_error: Optional[Exception] = None
592
610
 
593
611
  for attempt in range(self.max_retries):
612
+ response: Optional[httpx.Response] = None
594
613
  try:
595
614
  response = self._get_http_client().get(BIO_RXIV_CROSSREF_API, params=params)
596
615
  response.raise_for_status()
@@ -598,7 +617,7 @@ class BioRxivFetcher:
598
617
  except Exception as exc:
599
618
  last_error = exc
600
619
  if attempt + 1 < self.max_retries:
601
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
620
+ sleep_before_retry(response, attempt)
602
621
 
603
622
  if last_error is not None:
604
623
  raise last_error
@@ -665,7 +684,7 @@ class BioRxivFetcher:
665
684
  landing_url=landing_url,
666
685
  pdf_url=pdf_url,
667
686
  query=query,
668
- version="",
687
+ version=extract_version_from_doi(doi),
669
688
  keywords=keywords,
670
689
  extra={
671
690
  "publisher": record.get("publisher", ""),
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- import time
5
4
  from datetime import datetime
6
5
  from pathlib import Path
7
6
  from typing import Any, Dict, Iterable, List, Optional
@@ -15,13 +14,16 @@ from ..integrations.undetected_fallback import is_undetected_enabled, undetected
15
14
 
16
15
  from .source_models import SourcePaper
17
16
  from .source_utils import (
17
+ DEFAULT_MAX_RETRIES,
18
18
  basic_boolean_text_match,
19
19
  build_source_record_dir,
20
20
  download_binary,
21
+ extract_version_from_doi,
21
22
  extract_year,
22
23
  normalize_text,
23
24
  safe_filename,
24
25
  save_json,
26
+ sleep_before_retry,
25
27
  )
26
28
 
27
29
 
@@ -48,7 +50,7 @@ class ChemRxivFetcher:
48
50
  def __init__(
49
51
  self,
50
52
  root_dir: str,
51
- max_retries: int = 3,
53
+ max_retries: int = DEFAULT_MAX_RETRIES,
52
54
  request_timeout: float = 60.0,
53
55
  ):
54
56
  self.root_dir = root_dir
@@ -157,6 +159,7 @@ class ChemRxivFetcher:
157
159
  url = f"{CHEM_RXIV_CROSSREF_API}/{quote(doi, safe='')}"
158
160
  last_error: Optional[Exception] = None
159
161
  for attempt in range(self.max_retries):
162
+ response: Optional[httpx.Response] = None
160
163
  try:
161
164
  response = self._get_http_client().get(url)
162
165
  if response.status_code == 404:
@@ -166,7 +169,7 @@ class ChemRxivFetcher:
166
169
  except Exception as exc:
167
170
  last_error = exc
168
171
  if attempt + 1 < self.max_retries:
169
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
172
+ sleep_before_retry(response, attempt)
170
173
  if last_error is not None:
171
174
  raise last_error
172
175
  raise RuntimeError(f"Failed to fetch Crossref work for DOI {doi}")
@@ -254,6 +257,7 @@ class ChemRxivFetcher:
254
257
 
255
258
  last_error: Optional[Exception] = None
256
259
  for attempt in range(self.max_retries):
260
+ response: Optional[httpx.Response] = None
257
261
  try:
258
262
  response = self._get_http_client().get(CHEM_RXIV_CROSSREF_API, params=params)
259
263
  response.raise_for_status()
@@ -261,7 +265,7 @@ class ChemRxivFetcher:
261
265
  except Exception as exc:
262
266
  last_error = exc
263
267
  if attempt + 1 < self.max_retries:
264
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
268
+ sleep_before_retry(response, attempt)
265
269
 
266
270
  if last_error is not None:
267
271
  raise last_error
@@ -321,7 +325,7 @@ class ChemRxivFetcher:
321
325
  landing_url=landing_url,
322
326
  pdf_url=pdf_url,
323
327
  query=query,
324
- version="",
328
+ version=extract_version_from_doi(doi),
325
329
  keywords=keywords,
326
330
  extra={
327
331
  "publisher": record.get("publisher", ""),
@@ -1,12 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- import time
5
4
  from typing import Any, Dict, List, Optional
6
5
 
7
6
  import httpx
8
7
 
9
- from .source_utils import normalize_text
8
+ from .source_utils import DEFAULT_MAX_RETRIES, normalize_text, sleep_before_retry
10
9
 
11
10
  EUROPE_PMC_SEARCH_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
12
11
  EUROPE_PMC_FULLTEXT_URL = "https://www.ebi.ac.uk/europepmc/webservices/rest/{source}/{id}/fullTextXML"
@@ -24,13 +23,16 @@ class EuropePMCSearch:
24
23
  Crossref's metadata-only relevance search cannot.
25
24
 
26
25
  The client bypasses the local HTTP proxy via ``trust_env=False`` because
27
- the proxy commonly times out (HTTP 504) on ``ebi.ac.uk``.
26
+ the proxy commonly times out (HTTP 504) on ``ebi.ac.uk``. Note that the
27
+ 503s occasionally seen against this endpoint are an upstream EBI outage,
28
+ not the proxy: direct and proxied requests fail (and recover) together, and
29
+ ``trust_env=False`` only sidesteps the proxy's own 504 flakiness.
28
30
  """
29
31
 
30
32
  def __init__(
31
33
  self,
32
34
  request_timeout: float = 60.0,
33
- max_retries: int = 3,
35
+ max_retries: int = DEFAULT_MAX_RETRIES,
34
36
  page_size: int = 100,
35
37
  ):
36
38
  self.request_timeout = float(request_timeout)
@@ -129,18 +131,39 @@ class EuropePMCSearch:
129
131
  }
130
132
  last_error: Optional[Exception] = None
131
133
  for attempt in range(self.max_retries):
134
+ response: Optional[httpx.Response] = None
132
135
  try:
133
136
  response = self._client.get(EUROPE_PMC_SEARCH_URL, params=params)
134
137
  response.raise_for_status()
135
- return response.json()
138
+ payload = response.json()
139
+ self._validate_payload(payload)
140
+ return payload
136
141
  except Exception as exc:
137
142
  last_error = exc
138
143
  if attempt + 1 < self.max_retries:
139
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
144
+ sleep_before_retry(response, attempt)
140
145
  if last_error is not None:
141
146
  raise last_error
142
147
  raise RuntimeError("Failed to query Europe PMC")
143
148
 
149
+ @staticmethod
150
+ def _validate_payload(payload: Any) -> None:
151
+ """Raise when Europe PMC returns an error inside an HTTP 200 body.
152
+
153
+ The REST API can answer with a bare ``{"version":"6.9"}`` (no
154
+ ``resultList``) or an ``errCode``/``errMessage`` envelope on a 200,
155
+ which ``raise_for_status`` cannot see. Treat those as failures so
156
+ callers degrade instead of silently returning an empty result set.
157
+ """
158
+ if not isinstance(payload, dict):
159
+ raise RuntimeError(f"Europe PMC returned a non-object payload: {type(payload).__name__}")
160
+ if "errCode" in payload:
161
+ raise RuntimeError(
162
+ f"Europe PMC error: errCode={payload.get('errCode')} errMessage={payload.get('errMessage', '')}"
163
+ )
164
+ if "resultList" not in payload:
165
+ raise RuntimeError("Europe PMC returned an unexpected payload without resultList")
166
+
144
167
 
145
168
  class EuropePMCFullText:
146
169
  """Fetch full-text JATS XML for a DOI from Europe PMC REST.
@@ -149,7 +172,7 @@ class EuropePMCFullText:
149
172
  times out on ebi.ac.uk.
150
173
  """
151
174
 
152
- def __init__(self, request_timeout: float = 60.0, max_retries: int = 3):
175
+ def __init__(self, request_timeout: float = 60.0, max_retries: int = DEFAULT_MAX_RETRIES):
153
176
  self.request_timeout = float(request_timeout)
154
177
  self.max_retries = max(1, int(max_retries))
155
178
  self.headers = {
@@ -177,19 +200,21 @@ class EuropePMCFullText:
177
200
  source = "PPR" if source_id.startswith("PPR") else "PMC"
178
201
  url = EUROPE_PMC_FULLTEXT_URL.format(source=source, id=source_id)
179
202
  for attempt in range(self.max_retries):
203
+ response: Optional[httpx.Response] = None
180
204
  try:
181
205
  response = self._client.get(url)
182
206
  response.raise_for_status()
183
207
  return response.text
184
208
  except Exception:
185
209
  if attempt + 1 < self.max_retries:
186
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
210
+ sleep_before_retry(response, attempt)
187
211
  return ""
188
212
 
189
213
  def _resolve_id(self, doi: str) -> str:
190
214
  """Map a DOI to a Europe PMC id (prefer pmcid, then the PPR/PMC id)."""
191
215
  params = {"query": f'DOI:"{doi}"', "format": "json", "pageSize": 1, "resultType": "core"}
192
216
  for attempt in range(self.max_retries):
217
+ response: Optional[httpx.Response] = None
193
218
  try:
194
219
  response = self._client.get(EUROPE_PMC_SEARCH_URL, params=params)
195
220
  response.raise_for_status()
@@ -200,5 +225,5 @@ class EuropePMCFullText:
200
225
  return normalize_text(record.get("pmcid") or record.get("id") or "")
201
226
  except Exception:
202
227
  if attempt + 1 < self.max_retries:
203
- time.sleep(min(2.0, 0.5 * (attempt + 1)))
228
+ sleep_before_retry(response, attempt)
204
229
  return ""
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
- from datetime import datetime, timedelta
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+ from email.utils import parsedate_to_datetime
6
8
  from pathlib import Path
7
9
  from typing import Any, Dict, Iterable, List, Optional
8
10
 
@@ -12,6 +14,7 @@ import httpx
12
14
  BOOLEAN_OR_SPLIT_RE = re.compile(r"\s+OR\s+", re.IGNORECASE)
13
15
  BOOLEAN_AND_SPLIT_RE = re.compile(r"\s+AND\s+", re.IGNORECASE)
14
16
  TOKEN_RE = re.compile(r'"([^"]+)"|\'([^\']+)\'|(\S+)')
17
+ VERSION_SUFFIX_RE = re.compile(r"v(\d+)$", re.IGNORECASE)
15
18
 
16
19
 
17
20
  def normalize_text(value: Any) -> str:
@@ -54,6 +57,21 @@ def detect_platform_from_doi(doi: Any) -> str:
54
57
  return ""
55
58
 
56
59
 
60
+ def extract_version_from_doi(doi: Any) -> str:
61
+ """Return the ``N`` of a trailing ``vN`` DOI version suffix, else "".
62
+
63
+ bioRxiv/medRxiv and chemRxiv register each revision as its own DOI work
64
+ (e.g. ``10.1101/2023.06.22.546069v2`` or ``10.26434/chemrxiv-2025-tj4pr-v2``),
65
+ so the version is recoverable from the DOI itself. A DOI without a suffix
66
+ (or an explicit ``v1``) is the first version and yields "".
67
+ """
68
+ text = normalize_text(doi)
69
+ if not text:
70
+ return ""
71
+ match = VERSION_SUFFIX_RE.search(text)
72
+ return match.group(1) if match else ""
73
+
74
+
57
75
  def extract_year(date_text: Any) -> str:
58
76
  text = normalize_text(date_text)
59
77
  if not text:
@@ -106,6 +124,48 @@ def download_binary(url: str, output_path: Path | str, headers: Optional[Dict[st
106
124
  return False
107
125
 
108
126
 
127
+ def retry_after_seconds(response: Optional[httpx.Response]) -> Optional[float]:
128
+ """Seconds to wait per the Retry-After header, or None when unusable."""
129
+ if response is None:
130
+ return None
131
+
132
+ raw_retry_after = normalize_text(response.headers.get("Retry-After", ""))
133
+ if not raw_retry_after:
134
+ return None
135
+
136
+ if raw_retry_after.isdigit():
137
+ return float(raw_retry_after)
138
+
139
+ try:
140
+ retry_after_dt = parsedate_to_datetime(raw_retry_after)
141
+ except (TypeError, ValueError, IndexError):
142
+ return None
143
+
144
+ if retry_after_dt.tzinfo is None:
145
+ retry_after_dt = retry_after_dt.replace(tzinfo=timezone.utc)
146
+ now = datetime.now(retry_after_dt.tzinfo)
147
+ return max(0.0, (retry_after_dt - now).total_seconds())
148
+
149
+
150
+ # Default retry budget for every preprint fetcher. This is the interactive/tool
151
+ # default: fail fast (~4.5s of backoff) so a human gets a quick answer plus the
152
+ # degradation notice, instead of a ~22s silent stall. Unattended callers (e.g.
153
+ # monitor.py) pass a larger max_retries explicitly.
154
+ DEFAULT_MAX_RETRIES = 3
155
+
156
+
157
+ def sleep_before_retry(response: Optional[httpx.Response], attempt: int) -> None:
158
+ """Back off between attempts, capped at 30s.
159
+
160
+ A 503 usually asks for a pause via Retry-After; otherwise back off
161
+ exponentially. A short linear cap cannot outlast a transient outage, which
162
+ pushes callers into whatever lossy fallback they have.
163
+ """
164
+ retry_after = retry_after_seconds(response)
165
+ delay = retry_after if retry_after is not None else min(30.0, 1.5 * (2**attempt))
166
+ time.sleep(max(0.0, delay))
167
+
168
+
109
169
  def parse_boolean_query(query: str) -> List[List[str]]:
110
170
  text = normalize_text(query)
111
171
  if not text:
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(curl -s -m 40 --noproxy '*' \"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=SRC:PPR%20AND%20zinc%20AND%20finger%20AND%20263%20AND%20FIRST_PDATE:%5B2026-08-01%20TO%202026-12-31%5D&format=json&pageSize=25&cursorMark=*&resultType=core\" -o /tmp/epmc_date.json -w \"HTTP=%{http_code}\\\\n\")",
5
- "Bash(python3 -c ' *)"
6
- ]
7
- }
8
- }
@@ -1 +0,0 @@
1
- __version__ = "0.6.1"
File without changes
File without changes
File without changes
File without changes