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.
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/PKG-INFO +33 -3
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/README.md +32 -2
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/README_zh.md +35 -2
- pypaperflow-0.6.2/src/pyPaperFlow/__init__.py +1 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/cli.py +18 -8
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/arxiv_fetcher.py +7 -32
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/biorxiv_fetcher.py +25 -6
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/chemrxiv_fetcher.py +9 -5
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/europepmc_fetcher.py +34 -9
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_utils.py +61 -1
- pypaperflow-0.6.1/.claude/settings.local.json +0 -8
- pypaperflow-0.6.1/src/pyPaperFlow/__init__.py +0 -1
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/.github/workflows/docs.yml +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/.gitignore +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/LICENSE +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/mkdoc_site/index.md +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/mkdocs.yml +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/pyproject.toml +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/requirements-docs.txt +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/scripts/sync_docs.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/cloak_fallback.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/cloak_pdf.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/github_export.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/mineru_parser.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/pdf_fetch.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/undetected_fallback.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/integrations/undetected_pdf.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_merge.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/preprint/source_models.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/__init__.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/pubmed_fetcher.py +0 -0
- {pypaperflow-0.6.1 → pypaperflow-0.6.2}/src/pyPaperFlow/pubmed/pubmed_merger.py +0 -0
- {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.
|
|
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)`
|
|
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)`
|
|
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)` /
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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"[
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|