fetchpdf 0.1.7__tar.gz → 0.1.8__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.
- {fetchpdf-0.1.7/fetchpdf.egg-info → fetchpdf-0.1.8}/PKG-INFO +13 -6
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/README.md +12 -5
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/__init__.py +1 -1
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetchpdf.py +6 -1
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/get_cookies.py +3 -1
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/institutional.py +66 -5
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_atypon.py +12 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_index.py +38 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_pmc.py +18 -9
- fetchpdf-0.1.8/fetchpdf/retrieval/supplement_wayback.py +178 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplementary.py +21 -3
- {fetchpdf-0.1.7 → fetchpdf-0.1.8/fetchpdf.egg-info}/PKG-INFO +13 -6
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/SOURCES.txt +1 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_institutional.py +37 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_supplementary.py +223 -2
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/LICENSE +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/MANIFEST.in +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/__main__.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/_env.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/_http.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetch_abstract_from_doi.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetch_metadata_from_doi.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/__init__.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/_util.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/artifact.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/__init__.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/openrouter.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/blocked.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/byline.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/cache.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/chunk.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/classify.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/context.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/corresponding.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/engine.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/extract_images.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/figures.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/fulltext_scan.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/goals.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/http.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/identifiers.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/ladder.json +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/linked_artifacts.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_agent_retrieval.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/normalize.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_identity.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_text.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/provenance.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pubpeer.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/ratelimit.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/references.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/repository_waf.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/request_drafts.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/resolve.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/__init__.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/arxiv.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/core_text.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc_repo.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/landing.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc_html.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/preprint.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/supplements.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_dspace.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_graph.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_jci.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_publishers.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/tiers.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_html.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_markdown.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/validate.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/verify.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/dependency_links.txt +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/entry_points.txt +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/requires.txt +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/top_level.txt +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/pyproject.toml +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/requirements.txt +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/setup.cfg +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_agent_backends.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_blocked.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_cookie_offer.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_corresponding.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_datacite_supplement_identity.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_dspace.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_elsevier_routing.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_entrypoints.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_epmc_repo.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_extract_images.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_figures.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_folder_encoding.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_fulltext_scan.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_get_cookies.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_jci_supplements.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_json_output.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_landing_candidates.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_landing_page_detection.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_last_resort_reachability.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_linked_artifacts.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_llm_adjudicate.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_llm_agent_retrieval.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_normalize_cells.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pdf_identity.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pdf_trailer.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pmc_page.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_polite_ua.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pubmed_identity.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pubpeer.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pull_for_record_call_sites.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pure.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_request_drafts.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_retrieval.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_review_fixes.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_scan_record.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_screening_conversion.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_serpapi_scholar.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_short_conference_xml.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_supplement_asset_identity.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_to_html.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_xml_fallback_extras.py +0 -0
- {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_xml_last.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fetchpdf
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: A comprehensive tool to download PDFs from DOIs using multiple sources
|
|
5
5
|
Author-email: Dan Elton <delton17@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -578,7 +578,8 @@ suppressed when the answer is already known: any flag that turns the
|
|
|
578
578
|
supplementary pass on (`--pull-supplementary`, `--refresh-supplementary`,
|
|
579
579
|
`--download-data-artifacts`, `--fetch-everything`, `--llm-agent-retrieval`), an
|
|
580
580
|
explicit `--on-existing skip` or `supplement`, `--abstract-only`, or the
|
|
581
|
-
goal-aware flags (`--get-xml-or-html-and-pdf`, `--
|
|
581
|
+
goal-aware flags (`--get-xml-or-html-and-pdf`, `--get-xml-and-pdf`, `--get-xml-and-html-and-pdf`,
|
|
582
|
+
`--upgrade-existing` on the tiered path),
|
|
582
583
|
which do not skip records at all. Single-identifier runs never ask.
|
|
583
584
|
|
|
584
585
|
What is *not* remembered between runs: failures. `failed_dois.csv` is a report,
|
|
@@ -678,11 +679,12 @@ Twenty routes, tried in a fixed order so the numbering is reproducible:
|
|
|
678
679
|
| Figshare / Zenodo / OSF | repository DOIs, and datasets reached through link services |
|
|
679
680
|
| DataCite reverse relations | any DOI — finds deposits whose own DataCite record points at the article through a non-citation relation (e.g. `IsSupplementTo`); deposits that merely cite it are dropped |
|
|
680
681
|
| Crossref component DOIs | publishers that register components |
|
|
681
|
-
| JATS-declared URLs | absolute URLs the JATS itself declares (e.g. LWW permalinks) — recovery of last resort |
|
|
682
|
+
| JATS-declared URLs | absolute URLs the JATS itself declares (e.g. LWW permalinks, publisher Source Data links) — recovery of last resort. Read from the PMC copy or, with no PMCID, from a publisher `{stem}.xml` saved beside the record (e.g. by `--get-xml-and-pdf`); TEI and non-JATS XML are ignored |
|
|
682
683
|
| OpenAIRE ScholeXplorer | any DOI — Scholix publication→dataset/software links |
|
|
683
684
|
| Europe PMC datalinks | any PMID — text-mined accessions and data citations |
|
|
684
685
|
| Full-text scan | deposits the paper names in its own prose — only under `--download-data-artifacts` |
|
|
685
686
|
| Atypon publisher SI | PNAS, Science, Annual Reviews, SAGE, T&F — only when Europe PMC withholds the SI or the record has no PMCID; drives a headed browser, so it needs Playwright, Chromium and a display (or Xvfb) |
|
|
687
|
+
| Internet Archive (Wayback Machine) | Last-ditch fallback for publisher-hosted SI (Atypon hosts incl. SAGE, INFORMS, AOM, PNAS; Elsevier `ars.els-cdn.com` mmc files; supp.apa.org): asks the Archive's open CDX index for captures under the article's supplement URLs and downloads the archived copy. Runs only when no publisher-supplement provider listed anything; an Archive outage is reported as `blocked`, not as "none found" |
|
|
686
688
|
| LLM retrieval agent | only under [`--fetch-everything`](#--fetch-everything-the-second-layer) or `--llm-agent-retrieval` |
|
|
687
689
|
| JCI | `10.1172/jci…` |
|
|
688
690
|
|
|
@@ -1134,6 +1136,11 @@ the DOI link, and the PDF the landing page declares is fetched: **AACR**
|
|
|
1134
1136
|
(`10.1182`), **Company of Biologists** (`10.1242`), **ERS** (`10.1183`),
|
|
1135
1137
|
**BMJ** (`10.1136`), **Bioscientifica** (`10.1530`) and **LWW** (`10.1097`).
|
|
1136
1138
|
|
|
1139
|
+
**IEEE Xplore** (`10.1109`) keys articles by a number the DOI does not
|
|
1140
|
+
contain, so the route reads it from the DOI link's redirect and fetches
|
|
1141
|
+
`stampPDF/getPDF.jsp` with your cookies. Sign in once (or just open any IEEE
|
|
1142
|
+
PDF from a network your library covers) and the session cookies carry over.
|
|
1143
|
+
|
|
1137
1144
|
Several of these are open access but sit behind a Cloudflare bot check that a
|
|
1138
1145
|
plain download cannot pass. Opening any article on the site once in your
|
|
1139
1146
|
everyday browser earns the clearance cookie, and the next run reuses it.
|
|
@@ -1229,9 +1236,9 @@ refusal, not a bare failure:
|
|
|
1229
1236
|
| `path` | str \| null | Absolute path to the artifact, or null. |
|
|
1230
1237
|
| `format` | str | From the artifact actually written: `pdf`, `xml`, `html`, `latex`, `landing`, `suppl`, `abstract`, `text`, `other`. **A successful record is not necessarily a PDF** — with the default XML fallback a record can succeed as `xml`. |
|
|
1231
1238
|
| `source` | str \| null | The route that produced it (`unpaywall`, `core`, `osf`, …), `existing` for a skip, `structured_fallback` when no PDF was available and structured full text was saved instead. |
|
|
1232
|
-
| `identity` | str \| null | The verdict on the PDF **fetched in this run and reported in `path`**: `verified`, or a refusal — `wrong_article`, `truncated`, `unreadable`, `no_reference`, `no_engine`. Null when nothing was judged: an XML artifact, a file already on disk, or any run under the tiered flags (`--prioritize-xml`, `--get-xml-or-html-and-pdf`, `--xml-only`, `--xml-html-only`), where the engine validates per tier instead. A success with a non-`verified` identity is a PDF that was kept rather than deleted because the verdict could not be trusted — see [the identity check](#format-prioritized-retrieval) — not one that passed. |
|
|
1239
|
+
| `identity` | str \| null | The verdict on the PDF **fetched in this run and reported in `path`**: `verified`, or a refusal — `wrong_article`, `truncated`, `unreadable`, `no_reference`, `no_engine`. Null when nothing was judged: an XML artifact, a file already on disk, or any run under the tiered flags (`--prioritize-xml`, `--get-xml-or-html-and-pdf`, `--get-xml-and-pdf`, `--get-xml-and-html-and-pdf`, `--xml-only`, `--xml-html-only`), where the engine validates per tier instead. A success with a non-`verified` identity is a PDF that was kept rather than deleted because the verdict could not be trusted — see [the identity check](#format-prioritized-retrieval) — not one that passed. |
|
|
1233
1240
|
| `reasons` | list[str] | Why a record failed, or why a kept PDF could not be verified. Empty on a verified success. |
|
|
1234
|
-
| `paths` | list[str] | The other artifacts a tiered run wrote (`--get-xml-or-html-and-pdf`), empty otherwise. `path` is not repeated here. |
|
|
1241
|
+
| `paths` | list[str] | The other artifacts a tiered run wrote (`--get-xml-or-html-and-pdf` and its `--get-xml-and-pdf` / `--get-xml-and-html-and-pdf` variants), empty otherwise. `path` is not repeated here. |
|
|
1235
1242
|
| `supplementary` | object | Only with `--pull-supplementary` (or a flag that implies it): `status` (`ok`, `none_found`, `partial`, `incomplete`, `error`, `skipped`), `written`, `skipped`, `manifest` (absolute path or null), `missing_declared` (files the article declares that were not obtained) and `blocked_urls` (refused to this client; open them in a browser). |
|
|
1236
1243
|
| `figures` | object | Only with `--pull-figures`: `status`, `written`, `failed`, `refusal` and `manifest`. |
|
|
1237
1244
|
| `images` | object | Only with `--extract-images`: `status`, `written` and `manifest`. |
|
|
@@ -1469,7 +1476,7 @@ These run first when the DOI matches (by prefix, or by the name appearing anywhe
|
|
|
1469
1476
|
records whose key is entitled to the XML but not the PDF, and covers
|
|
1470
1477
|
ScienceDirect DOIs outside `10.1016/*`. It does not repeat a PDF request the
|
|
1471
1478
|
early step already made. The XML half is skipped with `--no-xml-fallback`.
|
|
1472
|
-
- With `--prioritize-xml` / `--xml-only` / `--get-xml
|
|
1479
|
+
- With `--prioritize-xml` / `--xml-only` / any `--get-xml-*-and-pdf` flag the tiered
|
|
1473
1480
|
ladder asks the same endpoint itself (`elsevier_tdm` at T1, `elsevier_pdf`
|
|
1474
1481
|
at T5, ahead of the legacy chain).
|
|
1475
1482
|
- **Off campus?** Entitlement is decided by your IP unless you also set
|
|
@@ -538,7 +538,8 @@ suppressed when the answer is already known: any flag that turns the
|
|
|
538
538
|
supplementary pass on (`--pull-supplementary`, `--refresh-supplementary`,
|
|
539
539
|
`--download-data-artifacts`, `--fetch-everything`, `--llm-agent-retrieval`), an
|
|
540
540
|
explicit `--on-existing skip` or `supplement`, `--abstract-only`, or the
|
|
541
|
-
goal-aware flags (`--get-xml-or-html-and-pdf`, `--
|
|
541
|
+
goal-aware flags (`--get-xml-or-html-and-pdf`, `--get-xml-and-pdf`, `--get-xml-and-html-and-pdf`,
|
|
542
|
+
`--upgrade-existing` on the tiered path),
|
|
542
543
|
which do not skip records at all. Single-identifier runs never ask.
|
|
543
544
|
|
|
544
545
|
What is *not* remembered between runs: failures. `failed_dois.csv` is a report,
|
|
@@ -638,11 +639,12 @@ Twenty routes, tried in a fixed order so the numbering is reproducible:
|
|
|
638
639
|
| Figshare / Zenodo / OSF | repository DOIs, and datasets reached through link services |
|
|
639
640
|
| DataCite reverse relations | any DOI — finds deposits whose own DataCite record points at the article through a non-citation relation (e.g. `IsSupplementTo`); deposits that merely cite it are dropped |
|
|
640
641
|
| Crossref component DOIs | publishers that register components |
|
|
641
|
-
| JATS-declared URLs | absolute URLs the JATS itself declares (e.g. LWW permalinks) — recovery of last resort |
|
|
642
|
+
| JATS-declared URLs | absolute URLs the JATS itself declares (e.g. LWW permalinks, publisher Source Data links) — recovery of last resort. Read from the PMC copy or, with no PMCID, from a publisher `{stem}.xml` saved beside the record (e.g. by `--get-xml-and-pdf`); TEI and non-JATS XML are ignored |
|
|
642
643
|
| OpenAIRE ScholeXplorer | any DOI — Scholix publication→dataset/software links |
|
|
643
644
|
| Europe PMC datalinks | any PMID — text-mined accessions and data citations |
|
|
644
645
|
| Full-text scan | deposits the paper names in its own prose — only under `--download-data-artifacts` |
|
|
645
646
|
| Atypon publisher SI | PNAS, Science, Annual Reviews, SAGE, T&F — only when Europe PMC withholds the SI or the record has no PMCID; drives a headed browser, so it needs Playwright, Chromium and a display (or Xvfb) |
|
|
647
|
+
| Internet Archive (Wayback Machine) | Last-ditch fallback for publisher-hosted SI (Atypon hosts incl. SAGE, INFORMS, AOM, PNAS; Elsevier `ars.els-cdn.com` mmc files; supp.apa.org): asks the Archive's open CDX index for captures under the article's supplement URLs and downloads the archived copy. Runs only when no publisher-supplement provider listed anything; an Archive outage is reported as `blocked`, not as "none found" |
|
|
646
648
|
| LLM retrieval agent | only under [`--fetch-everything`](#--fetch-everything-the-second-layer) or `--llm-agent-retrieval` |
|
|
647
649
|
| JCI | `10.1172/jci…` |
|
|
648
650
|
|
|
@@ -1094,6 +1096,11 @@ the DOI link, and the PDF the landing page declares is fetched: **AACR**
|
|
|
1094
1096
|
(`10.1182`), **Company of Biologists** (`10.1242`), **ERS** (`10.1183`),
|
|
1095
1097
|
**BMJ** (`10.1136`), **Bioscientifica** (`10.1530`) and **LWW** (`10.1097`).
|
|
1096
1098
|
|
|
1099
|
+
**IEEE Xplore** (`10.1109`) keys articles by a number the DOI does not
|
|
1100
|
+
contain, so the route reads it from the DOI link's redirect and fetches
|
|
1101
|
+
`stampPDF/getPDF.jsp` with your cookies. Sign in once (or just open any IEEE
|
|
1102
|
+
PDF from a network your library covers) and the session cookies carry over.
|
|
1103
|
+
|
|
1097
1104
|
Several of these are open access but sit behind a Cloudflare bot check that a
|
|
1098
1105
|
plain download cannot pass. Opening any article on the site once in your
|
|
1099
1106
|
everyday browser earns the clearance cookie, and the next run reuses it.
|
|
@@ -1189,9 +1196,9 @@ refusal, not a bare failure:
|
|
|
1189
1196
|
| `path` | str \| null | Absolute path to the artifact, or null. |
|
|
1190
1197
|
| `format` | str | From the artifact actually written: `pdf`, `xml`, `html`, `latex`, `landing`, `suppl`, `abstract`, `text`, `other`. **A successful record is not necessarily a PDF** — with the default XML fallback a record can succeed as `xml`. |
|
|
1191
1198
|
| `source` | str \| null | The route that produced it (`unpaywall`, `core`, `osf`, …), `existing` for a skip, `structured_fallback` when no PDF was available and structured full text was saved instead. |
|
|
1192
|
-
| `identity` | str \| null | The verdict on the PDF **fetched in this run and reported in `path`**: `verified`, or a refusal — `wrong_article`, `truncated`, `unreadable`, `no_reference`, `no_engine`. Null when nothing was judged: an XML artifact, a file already on disk, or any run under the tiered flags (`--prioritize-xml`, `--get-xml-or-html-and-pdf`, `--xml-only`, `--xml-html-only`), where the engine validates per tier instead. A success with a non-`verified` identity is a PDF that was kept rather than deleted because the verdict could not be trusted — see [the identity check](#format-prioritized-retrieval) — not one that passed. |
|
|
1199
|
+
| `identity` | str \| null | The verdict on the PDF **fetched in this run and reported in `path`**: `verified`, or a refusal — `wrong_article`, `truncated`, `unreadable`, `no_reference`, `no_engine`. Null when nothing was judged: an XML artifact, a file already on disk, or any run under the tiered flags (`--prioritize-xml`, `--get-xml-or-html-and-pdf`, `--get-xml-and-pdf`, `--get-xml-and-html-and-pdf`, `--xml-only`, `--xml-html-only`), where the engine validates per tier instead. A success with a non-`verified` identity is a PDF that was kept rather than deleted because the verdict could not be trusted — see [the identity check](#format-prioritized-retrieval) — not one that passed. |
|
|
1193
1200
|
| `reasons` | list[str] | Why a record failed, or why a kept PDF could not be verified. Empty on a verified success. |
|
|
1194
|
-
| `paths` | list[str] | The other artifacts a tiered run wrote (`--get-xml-or-html-and-pdf`), empty otherwise. `path` is not repeated here. |
|
|
1201
|
+
| `paths` | list[str] | The other artifacts a tiered run wrote (`--get-xml-or-html-and-pdf` and its `--get-xml-and-pdf` / `--get-xml-and-html-and-pdf` variants), empty otherwise. `path` is not repeated here. |
|
|
1195
1202
|
| `supplementary` | object | Only with `--pull-supplementary` (or a flag that implies it): `status` (`ok`, `none_found`, `partial`, `incomplete`, `error`, `skipped`), `written`, `skipped`, `manifest` (absolute path or null), `missing_declared` (files the article declares that were not obtained) and `blocked_urls` (refused to this client; open them in a browser). |
|
|
1196
1203
|
| `figures` | object | Only with `--pull-figures`: `status`, `written`, `failed`, `refusal` and `manifest`. |
|
|
1197
1204
|
| `images` | object | Only with `--extract-images`: `status`, `written` and `manifest`. |
|
|
@@ -1429,7 +1436,7 @@ These run first when the DOI matches (by prefix, or by the name appearing anywhe
|
|
|
1429
1436
|
records whose key is entitled to the XML but not the PDF, and covers
|
|
1430
1437
|
ScienceDirect DOIs outside `10.1016/*`. It does not repeat a PDF request the
|
|
1431
1438
|
early step already made. The XML half is skipped with `--no-xml-fallback`.
|
|
1432
|
-
- With `--prioritize-xml` / `--xml-only` / `--get-xml
|
|
1439
|
+
- With `--prioritize-xml` / `--xml-only` / any `--get-xml-*-and-pdf` flag the tiered
|
|
1433
1440
|
ladder asks the same endpoint itself (`elsevier_tdm` at T1, `elsevier_pdf`
|
|
1434
1441
|
at T5, ahead of the legacy chain).
|
|
1435
1442
|
- **Off campus?** Entitlement is decided by your IP unless you also set
|
|
@@ -15,7 +15,7 @@ from .fetch_metadata_from_doi import fetch_metadata_from_doi
|
|
|
15
15
|
# behavioural difference, it is the same object.
|
|
16
16
|
fetch_pdf_from_doi = fetch_pdf
|
|
17
17
|
|
|
18
|
-
__version__ = "0.1.
|
|
18
|
+
__version__ = "0.1.8"
|
|
19
19
|
__all__ = [
|
|
20
20
|
"fetch_pdf",
|
|
21
21
|
"fetch_pdf_from_doi", # deprecated alias for fetch_pdf
|
|
@@ -5756,7 +5756,7 @@ def _append_missing_si_to_report(output_dir, si_statuses, run_timestamp, lock,
|
|
|
5756
5756
|
for display_id, summary in (si_statuses or {}).items():
|
|
5757
5757
|
status = getattr(summary, "status", "")
|
|
5758
5758
|
blocked_urls = list(getattr(summary, "blocked_urls", None) or [])
|
|
5759
|
-
if status not in ("partial", "incomplete"):
|
|
5759
|
+
if status not in ("partial", "incomplete", "blocked"):
|
|
5760
5760
|
continue
|
|
5761
5761
|
missing = ", ".join(getattr(summary, "missing_declared", None) or []) or "—"
|
|
5762
5762
|
# A withheld-but-existing supplement is the click-through candidate:
|
|
@@ -6426,6 +6426,11 @@ def _batch_fetch_pdfs_inner(dois, output_dir, email=None, verbose=False, delay=0
|
|
|
6426
6426
|
# point of the gate is that this cannot pass silently.
|
|
6427
6427
|
names = ", ".join(summary.missing_declared) or "unknown"
|
|
6428
6428
|
print(f" ⚠️ {display_id}: SI INCOMPLETE — missing: {names}")
|
|
6429
|
+
elif summary.status == "blocked":
|
|
6430
|
+
# Not "none found": a source refused or was down, so the paper
|
|
6431
|
+
# may well have supplements. Printed regardless of verbosity.
|
|
6432
|
+
print(f" ⚠️ {display_id}: SI BLOCKED — a supplement source refused "
|
|
6433
|
+
f"or was unavailable ({summary.detail or 'no detail'})")
|
|
6429
6434
|
elif summary.written and summary.status != "skipped":
|
|
6430
6435
|
extra = f", {summary.skipped} skipped" if summary.skipped else ""
|
|
6431
6436
|
print(f" 📎 {display_id}: {summary.written} supplementary file(s){extra}")
|
|
@@ -70,11 +70,13 @@ PROBES: Dict[str, str] = {
|
|
|
70
70
|
"informs": "10.1287/mnsc.2023.4866",
|
|
71
71
|
"science": "10.1126/science.abn1725",
|
|
72
72
|
"aha": "10.1161/circulationaha.121.058901",
|
|
73
|
+
"ieee": "10.1109/embc.2014.6943774",
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
PUBLISHER_NAMES = {"wiley": "Wiley", "tandf": "Taylor & Francis", "sage": "SAGE",
|
|
76
77
|
"springer": "Springer", "hogrefe": "Hogrefe", "informs": "INFORMS",
|
|
77
|
-
"science": "Science", "aha": "AHA Journals"
|
|
78
|
+
"science": "Science", "aha": "AHA Journals",
|
|
79
|
+
"ieee": "IEEE Xplore"}
|
|
78
80
|
|
|
79
81
|
CONFIG_DIR = inst.CONFIG_DIR
|
|
80
82
|
ACCESS_CONFIG = inst.ACCESS_CONFIG
|
|
@@ -448,15 +448,58 @@ def _prefix(doi: str) -> str:
|
|
|
448
448
|
return (doi or "").split("/")[0]
|
|
449
449
|
|
|
450
450
|
|
|
451
|
+
# --------------------------------------------------------------------------
|
|
452
|
+
# IEEE Xplore
|
|
453
|
+
# --------------------------------------------------------------------------
|
|
454
|
+
# IEEE keys every article by an "arnumber" that is not derivable from the DOI
|
|
455
|
+
# (10.1109/aina.2010.102 is document 5474692). Its landing pages sit behind
|
|
456
|
+
# AWS WAF -- a plain client gets HTTP 202 with an empty body -- so the arnumber
|
|
457
|
+
# cannot be scraped from them. doi.org's own redirect names it, though, and
|
|
458
|
+
# costs one request that never reaches IEEE. The PDF endpoint,
|
|
459
|
+
# stampPDF/getPDF.jsp, is not behind the WAF: without a session it redirects
|
|
460
|
+
# to Xplore/login.jsp, with one it serves the PDF. Measured 2026-11-08.
|
|
461
|
+
IEEE_PREFIX = "10.1109"
|
|
462
|
+
IEEE_SITE = "ieee.org"
|
|
463
|
+
_IEEE_ARNUMBER_RE = re.compile(r"ieeexplore\.ieee\.org/(?:abstract/)?document/(\d+)")
|
|
464
|
+
_ieee_lock = threading.Lock()
|
|
465
|
+
#: DOI -> arnumber. Only successful lookups are kept. Seeded with the
|
|
466
|
+
#: `fetchpdf cookies` test article so the wizard never needs the network to
|
|
467
|
+
#: know where IEEE lives.
|
|
468
|
+
_ieee_arnumbers = {"10.1109/embc.2014.6943774": "6943774"}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def ieee_arnumber(doi: str, timeout: float = 30) -> Optional[str]:
|
|
472
|
+
"""IEEE Xplore's article number for *doi*, from doi.org's redirect; None if unknown."""
|
|
473
|
+
key = (doi or "").strip().lower()
|
|
474
|
+
with _ieee_lock:
|
|
475
|
+
if key in _ieee_arnumbers:
|
|
476
|
+
return _ieee_arnumbers[key]
|
|
477
|
+
url = f"https://doi.org/{key}"
|
|
478
|
+
try:
|
|
479
|
+
r = requests.get(url, allow_redirects=False, timeout=timeout,
|
|
480
|
+
headers=headers_for(url, {}))
|
|
481
|
+
m = _IEEE_ARNUMBER_RE.search(r.headers.get("location") or "")
|
|
482
|
+
except requests.RequestException:
|
|
483
|
+
return None
|
|
484
|
+
if not m:
|
|
485
|
+
return None
|
|
486
|
+
with _ieee_lock:
|
|
487
|
+
_ieee_arnumbers[key] = m.group(1)
|
|
488
|
+
return m.group(1)
|
|
489
|
+
|
|
490
|
+
|
|
451
491
|
def covers(doi: str) -> bool:
|
|
452
492
|
"""True if this route knows where the publisher keeps the DOI's PDF."""
|
|
453
493
|
prefix = _prefix(doi)
|
|
454
|
-
return prefix in PUBLISHER_PDF_TEMPLATES or prefix in PUBLISHER_LANDING_SITES
|
|
494
|
+
return (prefix in PUBLISHER_PDF_TEMPLATES or prefix in PUBLISHER_LANDING_SITES
|
|
495
|
+
or prefix == IEEE_PREFIX)
|
|
455
496
|
|
|
456
497
|
|
|
457
498
|
def publisher_sites(doi: str) -> List[str]:
|
|
458
499
|
"""The publisher sites whose cookies this DOI's fetch may use, main one first."""
|
|
459
500
|
prefix = _prefix(doi)
|
|
501
|
+
if prefix == IEEE_PREFIX:
|
|
502
|
+
return [IEEE_SITE]
|
|
460
503
|
if prefix in PUBLISHER_LANDING_SITES:
|
|
461
504
|
return list(PUBLISHER_LANDING_SITES[prefix])
|
|
462
505
|
article = publisher_article_url(doi)
|
|
@@ -469,6 +512,7 @@ def cookie_sites() -> List[str]:
|
|
|
469
512
|
for t in PUBLISHER_ARTICLE_TEMPLATES.values()}
|
|
470
513
|
for more in PUBLISHER_LANDING_SITES.values():
|
|
471
514
|
sites.update(more)
|
|
515
|
+
sites.add(IEEE_SITE)
|
|
472
516
|
return sorted(sites)
|
|
473
517
|
|
|
474
518
|
|
|
@@ -476,8 +520,12 @@ def publisher_article_url(doi: str) -> Optional[str]:
|
|
|
476
520
|
"""The article landing page on the publisher's own host, or None.
|
|
477
521
|
|
|
478
522
|
For a publisher in PUBLISHER_LANDING_SITES that is the DOI link, which
|
|
479
|
-
redirects there: its article URLs are not derivable from the DOI.
|
|
523
|
+
redirects there: its article URLs are not derivable from the DOI. For IEEE
|
|
524
|
+
it is the Xplore document page, which costs a doi.org lookup the first time.
|
|
480
525
|
"""
|
|
526
|
+
if _prefix(doi) == IEEE_PREFIX:
|
|
527
|
+
n = ieee_arnumber(doi)
|
|
528
|
+
return f"https://ieeexplore.ieee.org/document/{n}" if n else None
|
|
481
529
|
template = PUBLISHER_ARTICLE_TEMPLATES.get(_prefix(doi))
|
|
482
530
|
if template:
|
|
483
531
|
return template.format(doi=doi)
|
|
@@ -522,6 +570,9 @@ def _count_download() -> None:
|
|
|
522
570
|
|
|
523
571
|
def publisher_pdf_urls(doi: str) -> List[str]:
|
|
524
572
|
"""Canonical publisher PDF URLs for a DOI, or ``[]`` if none is derivable."""
|
|
573
|
+
if _prefix(doi) == IEEE_PREFIX:
|
|
574
|
+
n = ieee_arnumber(doi)
|
|
575
|
+
return [f"https://ieeexplore.ieee.org/stampPDF/getPDF.jsp?tp=&arnumber={n}"] if n else []
|
|
525
576
|
return [t.format(doi=doi) for t in PUBLISHER_PDF_TEMPLATES.get(_prefix(doi), [])]
|
|
526
577
|
|
|
527
578
|
|
|
@@ -606,6 +657,9 @@ _ELSEVIER_REDIRECT_RE = re.compile(r'name=["\']redirectURL["\'][^>]*value=["\'](
|
|
|
606
657
|
|
|
607
658
|
|
|
608
659
|
def _is_challenge(response) -> bool:
|
|
660
|
+
"""A Cloudflare challenge, or AWS WAF's (IEEE: 202 + x-amzn-waf-action)."""
|
|
661
|
+
if response.status_code == 202:
|
|
662
|
+
return bool(response.headers.get("x-amzn-waf-action"))
|
|
609
663
|
return (response.status_code in (403, 429, 503)
|
|
610
664
|
and "cloudflare" in (response.headers.get("server") or "").lower())
|
|
611
665
|
|
|
@@ -795,14 +849,21 @@ _FETCH_JS = """async ([u, cap]) => {
|
|
|
795
849
|
|
|
796
850
|
|
|
797
851
|
def wait_past_challenge(page, timeout_s: float = 30.0) -> bool:
|
|
798
|
-
"""Wait until the page is no longer a bot-challenge interstitial.
|
|
852
|
+
"""Wait until the page is no longer a bot-challenge interstitial.
|
|
853
|
+
|
|
854
|
+
Cloudflare names itself in the title; AWS WAF (IEEE Xplore) does not, but
|
|
855
|
+
its page carries the challenge script's globals.
|
|
856
|
+
"""
|
|
857
|
+
from .blocked import WAF_CHALLENGE_MARKERS
|
|
799
858
|
deadline = time.time() + timeout_s
|
|
800
859
|
while time.time() < deadline:
|
|
801
860
|
try:
|
|
802
861
|
title = (page.title() or "").lower()
|
|
862
|
+
body = page.content()[:20000] if "ieee.org" in (page.url or "") else ""
|
|
803
863
|
except Exception: # noqa: BLE001 - mid-navigation
|
|
804
|
-
title = "just a moment"
|
|
805
|
-
if not any(t in title for t in _CHALLENGE_TITLES)
|
|
864
|
+
title, body = "just a moment", ""
|
|
865
|
+
if not any(t in title for t in _CHALLENGE_TITLES) and \
|
|
866
|
+
not any(m in body for m in WAF_CHALLENGE_MARKERS):
|
|
806
867
|
return True
|
|
807
868
|
page.wait_for_timeout(1000)
|
|
808
869
|
return False
|
|
@@ -74,6 +74,17 @@ def atypon_host_for(doi: str) -> Optional[str]:
|
|
|
74
74
|
return _ATYPON_HOSTS.get(prefix)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def note_blocked(ctx, provider: str, url: str, detail: str) -> None:
|
|
78
|
+
"""Record that a provider was refused, so the manifest can say "blocked".
|
|
79
|
+
|
|
80
|
+
An empty list alone reads as "nothing listed", which claims the article has
|
|
81
|
+
no supplements. A refused listing page proves nothing of the sort.
|
|
82
|
+
"""
|
|
83
|
+
scratch = getattr(ctx, "scratch", None)
|
|
84
|
+
if isinstance(scratch, dict):
|
|
85
|
+
scratch.setdefault("_provider_blocked", {})[provider] = {"url": url, "detail": detail}
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
def headed_browser_available() -> bool:
|
|
78
89
|
"""Whether a headed Chromium can actually be launched here.
|
|
79
90
|
|
|
@@ -190,6 +201,7 @@ def fetch_atypon_supplements(ids, ctx) -> List[SupplementFile]:
|
|
|
190
201
|
status = response.status if response else None
|
|
191
202
|
if status != 200:
|
|
192
203
|
ctx.log(f" atypon SI: listing page HTTP {status}")
|
|
204
|
+
note_blocked(ctx, "atypon_suppl", listing, f"listing page HTTP {status}")
|
|
193
205
|
return []
|
|
194
206
|
page.wait_for_timeout(_SETTLE_MS)
|
|
195
207
|
|
|
@@ -716,6 +716,9 @@ def _providers() -> Tuple[Tuple[str, Any], ...]:
|
|
|
716
716
|
# records Europe PMC has already said it is withholding, which on a
|
|
717
717
|
# 46-record corpus was 1, not 46.
|
|
718
718
|
("atypon_suppl", _atypon_if_withheld), # E19
|
|
719
|
+
# E22 after every publisher route: archived copies, only when none of
|
|
720
|
+
# them listed a publisher supplement.
|
|
721
|
+
("wayback", _wayback_if_nothing_found), # E22
|
|
719
722
|
# E20 truly last: the only provider that costs money, and the only one
|
|
720
723
|
# that benefits from knowing what all the others already found.
|
|
721
724
|
("llm_agent", _llm_agent), # E20
|
|
@@ -723,6 +726,29 @@ def _providers() -> Tuple[Tuple[str, Any], ...]:
|
|
|
723
726
|
)
|
|
724
727
|
|
|
725
728
|
|
|
729
|
+
#: Providers that list the publisher's own supplement files. When one of them
|
|
730
|
+
#: found something, the Internet Archive has nothing to add.
|
|
731
|
+
_PUBLISHER_SI_PROVIDERS = frozenset({
|
|
732
|
+
"europepmc_supplements", "pmc_s3", "elsevier_objects", "springer_esm", "plos",
|
|
733
|
+
"preprint_supplements", "apa_supplemental", "jats_declared", "atypon_suppl", "jci",
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _wayback_if_nothing_found(ids, ctx) -> List[SupplementFile]:
|
|
738
|
+
"""E22: the Internet Archive's copies, as a last-ditch fallback.
|
|
739
|
+
|
|
740
|
+
Skipped as soon as any publisher-supplement provider has listed a file, so a
|
|
741
|
+
record that is already served costs the Archive no request. Repository files
|
|
742
|
+
(OSF, Zenodo, figshare) do not count: they are data deposits, and a paper
|
|
743
|
+
can have those while its publisher-hosted supplement is still missing.
|
|
744
|
+
"""
|
|
745
|
+
so_far = ctx.scratch.get("enumerated_so_far") or []
|
|
746
|
+
if any(str(f.provider or "").split(":", 1)[0] in _PUBLISHER_SI_PROVIDERS for f in so_far):
|
|
747
|
+
return []
|
|
748
|
+
from .supplement_wayback import enumerate_wayback
|
|
749
|
+
return enumerate_wayback(ids, ctx)
|
|
750
|
+
|
|
751
|
+
|
|
726
752
|
def _llm_agent(ids, ctx):
|
|
727
753
|
"""Imported at call time: a run without the flag never loads a backend."""
|
|
728
754
|
from .llm_agent_retrieval import enumerate_llm_agent
|
|
@@ -750,6 +776,7 @@ PROVIDER_NAMES: Tuple[str, ...] = (
|
|
|
750
776
|
"epmc_datalinks",
|
|
751
777
|
"fulltext_scan",
|
|
752
778
|
"atypon_suppl",
|
|
779
|
+
"wayback",
|
|
753
780
|
"llm_agent",
|
|
754
781
|
"jci",
|
|
755
782
|
)
|
|
@@ -799,6 +826,17 @@ def enumerate_all(ids, ctx, providers=None) -> Tuple[List[SupplementFile], List[
|
|
|
799
826
|
for entry in files:
|
|
800
827
|
entry.provider = entry.provider or name
|
|
801
828
|
found.extend(files)
|
|
829
|
+
blocked = (ctx.scratch.get("_provider_blocked") or {}).pop(name, None)
|
|
830
|
+
if blocked and not files:
|
|
831
|
+
# Refused, not empty: the supplements may well exist.
|
|
832
|
+
reports.append({
|
|
833
|
+
"name": name,
|
|
834
|
+
"status": "blocked",
|
|
835
|
+
"listed": 0,
|
|
836
|
+
"url": blocked.get("url"),
|
|
837
|
+
"reason": blocked.get("detail"),
|
|
838
|
+
})
|
|
839
|
+
continue
|
|
802
840
|
reports.append({
|
|
803
841
|
"name": name,
|
|
804
842
|
"status": "ok" if files else "nothing_listed",
|
|
@@ -60,9 +60,9 @@ def enumerate_jats_manifest(ids, ctx) -> List[SupplementFile]:
|
|
|
60
60
|
Returns no files by design -- see the module docstring. Runs first so every
|
|
61
61
|
later provider can classify against ground truth instead of a filename guess.
|
|
62
62
|
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
# No PMCID is fine when a T1 pass already saved the publisher's XML beside
|
|
64
|
+
# the record: _jats_for reads that copy first and only needs the PMCID for
|
|
65
|
+
# the EPMC fallback.
|
|
66
66
|
content = _jats_for(ids, ctx)
|
|
67
67
|
if not content:
|
|
68
68
|
return []
|
|
@@ -71,11 +71,12 @@ def enumerate_jats_manifest(ids, ctx) -> List[SupplementFile]:
|
|
|
71
71
|
except ElementTree.ParseError:
|
|
72
72
|
return []
|
|
73
73
|
|
|
74
|
-
# A zero count is only trustworthy from a document that actually has a
|
|
75
|
-
# An abstract stub or an EPMC error document parses cleanly and has no
|
|
76
|
-
# <supplementary-material> either, and
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
# A zero count is only trustworthy from a JATS document that actually has a
|
|
75
|
+
# body. An abstract stub or an EPMC error document parses cleanly and has no
|
|
76
|
+
# <supplementary-material> either, and neither does a TEI or Elsevier XML
|
|
77
|
+
# saved as {stem}.xml -- those declare supplements some other way, if at all.
|
|
78
|
+
if not _is_full_jats(root):
|
|
79
|
+
ctx.log(" JATS manifest: not a full-text JATS document, so a zero supplement count proves nothing")
|
|
79
80
|
return []
|
|
80
81
|
|
|
81
82
|
supplements, labels, raw = _supplementary_hrefs(root)
|
|
@@ -128,6 +129,8 @@ def _jats_for(ids, ctx) -> Optional[bytes]:
|
|
|
128
129
|
except OSError:
|
|
129
130
|
pass
|
|
130
131
|
|
|
132
|
+
if not ids.pmcid:
|
|
133
|
+
return None
|
|
131
134
|
response = ctx.http.get(EPMC_FULLTEXT.format(pmcid=ids.pmcid), timeout=60)
|
|
132
135
|
if response.ok and response.content:
|
|
133
136
|
return response.content
|
|
@@ -145,12 +148,18 @@ def declared_from_xml(content: bytes) -> Optional[Dict[str, str]]:
|
|
|
145
148
|
root = ElementTree.fromstring(content)
|
|
146
149
|
except ElementTree.ParseError:
|
|
147
150
|
return None
|
|
148
|
-
if
|
|
151
|
+
if not _is_full_jats(root):
|
|
149
152
|
return None
|
|
150
153
|
_, _, raw = _supplementary_hrefs(root)
|
|
151
154
|
return raw
|
|
152
155
|
|
|
153
156
|
|
|
157
|
+
def _is_full_jats(root) -> bool:
|
|
158
|
+
"""JATS (<article-meta>) with a <body>: the only kind whose count means anything."""
|
|
159
|
+
has = lambda name: any(_tag_is(element, name) for element in root.iter())
|
|
160
|
+
return has("article-meta") and has("body")
|
|
161
|
+
|
|
162
|
+
|
|
154
163
|
def _supplementary_hrefs(root) -> Tuple[Set[str], Dict[str, str], Dict[str, str]]:
|
|
155
164
|
"""Every href under a <supplementary-material>: basenames, labels, raw hrefs."""
|
|
156
165
|
hrefs: Set[str] = set()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""E22: publisher supplements as archived by the Internet Archive.
|
|
2
|
+
|
|
3
|
+
A last-ditch fallback. Several publishers refuse every automated request for
|
|
4
|
+
their supplement pages (SAGE, INFORMS, AOM and PNAS answer 403 from a bot check;
|
|
5
|
+
Elsevier's file host and supp.apa.org are often unreachable too), yet the
|
|
6
|
+
Wayback Machine has crawled some of those same files. Its CDX index is an open
|
|
7
|
+
API, so this asks it one question per record: "which captures do you hold under
|
|
8
|
+
this article's supplement URL prefix?"
|
|
9
|
+
|
|
10
|
+
Measured on a 26-paper SAGE set: 5 papers had at least one archived supplement
|
|
11
|
+
file, 8 had only an archived listing page, 13 had nothing. So this recovers a
|
|
12
|
+
minority -- which is why it only runs when no publisher-supplement provider
|
|
13
|
+
listed anything (see supplement_index._wayback_if_nothing_found).
|
|
14
|
+
|
|
15
|
+
What comes back is a capture, not the live file: the manifest keeps both the
|
|
16
|
+
original publisher URL and the capture timestamp so a reader can tell.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
from urllib.parse import unquote, urljoin
|
|
23
|
+
|
|
24
|
+
from .supplement_index import ROLE_SUPPLEMENT, SupplementFile
|
|
25
|
+
|
|
26
|
+
CDX = "https://web.archive.org/cdx/search/cdx"
|
|
27
|
+
#: `id_` asks for the capture's original bytes, without the Wayback toolbar or
|
|
28
|
+
#: rewritten links.
|
|
29
|
+
RAW = "https://web.archive.org/web/{timestamp}id_/{url}"
|
|
30
|
+
|
|
31
|
+
#: Atypon-platform hosts, by DOI prefix. Wider than supplement_atypon's table on
|
|
32
|
+
#: purpose: that one lists hosts a headed browser gets through, this one lists
|
|
33
|
+
#: hosts whose /doi/suppl/ URLs the Archive may hold.
|
|
34
|
+
_ATYPON_HOSTS = {
|
|
35
|
+
"10.1073": "www.pnas.org",
|
|
36
|
+
"10.1126": "www.science.org",
|
|
37
|
+
"10.1146": "www.annualreviews.org",
|
|
38
|
+
"10.1177": "journals.sagepub.com",
|
|
39
|
+
"10.1080": "www.tandfonline.com",
|
|
40
|
+
"10.1287": "pubsonline.informs.org",
|
|
41
|
+
"10.5465": "journals.aom.org",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_MAX_FILES = 20
|
|
45
|
+
#: Listing pages worth opening to find file links the prefix query missed.
|
|
46
|
+
_MAX_LISTINGS = 2
|
|
47
|
+
_SUPPL_HREF_RE = re.compile(r'href="([^"]*suppl_file/[^"]+)"', re.IGNORECASE)
|
|
48
|
+
_WAYBACK_PREFIX_RE = re.compile(r"^(?:https?:)?//web\.archive\.org/web/\d+[a-z_]*/", re.IGNORECASE)
|
|
49
|
+
_PAGE_SUFFIXES = (".html", ".htm", ".css", ".js", ".php", ".asp", ".aspx")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def url_prefixes(ids) -> List[str]:
|
|
53
|
+
"""The URL prefixes under which this article's supplements are published."""
|
|
54
|
+
doi = (getattr(ids, "doi", None) or "").strip()
|
|
55
|
+
if not doi:
|
|
56
|
+
return []
|
|
57
|
+
prefix = doi.split("/", 1)[0]
|
|
58
|
+
out = []
|
|
59
|
+
host = _ATYPON_HOSTS.get(prefix)
|
|
60
|
+
if host:
|
|
61
|
+
out.append(f"{host}/doi/suppl/{doi}")
|
|
62
|
+
pii = getattr(ids, "elsevier_pii", None)
|
|
63
|
+
if pii:
|
|
64
|
+
out.append(f"ars.els-cdn.com/content/image/1-s2.0-{pii}-mmc")
|
|
65
|
+
if prefix == "10.1037":
|
|
66
|
+
code = doi.rsplit("/", 1)[-1].lower()
|
|
67
|
+
if code:
|
|
68
|
+
out.append(f"supp.apa.org/psycarticles/supplemental/{code}/")
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _captures(ctx, url: str, prefix: bool) -> List[dict]:
|
|
73
|
+
"""Successful captures at (or, with prefix, under) a URL; one per distinct URL."""
|
|
74
|
+
params = {
|
|
75
|
+
"url": url + "*" if prefix else url,
|
|
76
|
+
"output": "json",
|
|
77
|
+
"fl": "timestamp,original,mimetype,length",
|
|
78
|
+
"filter": "statuscode:200",
|
|
79
|
+
"collapse": "urlkey",
|
|
80
|
+
"limit": "100" if prefix else "-1",
|
|
81
|
+
}
|
|
82
|
+
# polite=False: the Archive needs no contact address, so none is sent.
|
|
83
|
+
response = ctx.http.get(CDX, params=params, timeout=60, polite=False)
|
|
84
|
+
rows = None
|
|
85
|
+
if response.ok:
|
|
86
|
+
try:
|
|
87
|
+
rows = response.json() if response.content.strip() else []
|
|
88
|
+
except ValueError:
|
|
89
|
+
rows = None # the Archive's "temporarily offline" page, served as HTML
|
|
90
|
+
if not isinstance(rows, list):
|
|
91
|
+
# An outage is not an answer. Without this the manifest would say
|
|
92
|
+
# "nothing listed" for a record the Archive was never actually asked about.
|
|
93
|
+
from .supplement_atypon import note_blocked
|
|
94
|
+
note_blocked(ctx, "wayback", f"https://web.archive.org/web/*/{url}*",
|
|
95
|
+
f"Internet Archive index unavailable (HTTP {response.status})")
|
|
96
|
+
return []
|
|
97
|
+
out = []
|
|
98
|
+
for row in rows[1:]:
|
|
99
|
+
if isinstance(row, list) and len(row) >= 4:
|
|
100
|
+
out.append({"timestamp": row[0], "original": row[1],
|
|
101
|
+
"mimetype": row[2] or "", "length": row[3]})
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_page(capture: dict) -> bool:
|
|
106
|
+
name = capture["original"].split("?")[0].lower()
|
|
107
|
+
return "html" in capture["mimetype"].lower() or name.endswith(_PAGE_SUFFIXES)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _listed_file_urls(ctx, capture: dict) -> List[str]:
|
|
111
|
+
"""Supplement-file links on an archived Atypon listing page."""
|
|
112
|
+
response = ctx.http.get(
|
|
113
|
+
RAW.format(timestamp=capture["timestamp"], url=capture["original"]),
|
|
114
|
+
timeout=60, polite=False,
|
|
115
|
+
)
|
|
116
|
+
if not response.ok:
|
|
117
|
+
return []
|
|
118
|
+
urls = []
|
|
119
|
+
for href in _SUPPL_HREF_RE.findall(response.text or ""):
|
|
120
|
+
url = urljoin(capture["original"], href.replace("&", "&"))
|
|
121
|
+
url = _WAYBACK_PREFIX_RE.sub("", url)
|
|
122
|
+
if url not in urls:
|
|
123
|
+
urls.append(url)
|
|
124
|
+
return urls
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _name(url: str) -> str:
|
|
128
|
+
return unquote(url.split("?")[0].rstrip("/").rsplit("/", 1)[-1])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _as_int(value) -> Optional[int]:
|
|
132
|
+
try:
|
|
133
|
+
return int(value)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def enumerate_wayback(ids, ctx) -> List[SupplementFile]:
|
|
139
|
+
"""Archived copies of the article's publisher-hosted supplement files."""
|
|
140
|
+
files: List[SupplementFile] = []
|
|
141
|
+
seen = set()
|
|
142
|
+
|
|
143
|
+
def add(capture: dict) -> None:
|
|
144
|
+
key = capture["original"].split("://", 1)[-1].lower()
|
|
145
|
+
name = _name(capture["original"])
|
|
146
|
+
if key in seen or not name or len(files) >= _MAX_FILES:
|
|
147
|
+
return
|
|
148
|
+
seen.add(key)
|
|
149
|
+
files.append(SupplementFile(
|
|
150
|
+
name=name,
|
|
151
|
+
url=RAW.format(timestamp=capture["timestamp"], url=capture["original"]),
|
|
152
|
+
provider="wayback",
|
|
153
|
+
listing_index=len(files),
|
|
154
|
+
mimetype=capture["mimetype"] or None,
|
|
155
|
+
# CDX `length` is the compressed record size, not the file's: leave
|
|
156
|
+
# size_bytes unset rather than declare a number that will not match.
|
|
157
|
+
role=ROLE_SUPPLEMENT,
|
|
158
|
+
origin_doi=getattr(ids, "doi", None),
|
|
159
|
+
polite=False,
|
|
160
|
+
extra={"original_url": capture["original"],
|
|
161
|
+
"wayback_timestamp": capture["timestamp"]},
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
for prefix in url_prefixes(ids):
|
|
165
|
+
captures = _captures(ctx, prefix, prefix=True)
|
|
166
|
+
for capture in captures:
|
|
167
|
+
if not _is_page(capture):
|
|
168
|
+
add(capture)
|
|
169
|
+
# A listing page may name files the prefix query did not return (they
|
|
170
|
+
# were captured under another scheme or letter case). Look each one up.
|
|
171
|
+
for listing in [c for c in captures if _is_page(c)][:_MAX_LISTINGS]:
|
|
172
|
+
for url in _listed_file_urls(ctx, listing):
|
|
173
|
+
if url.split("://", 1)[-1].lower() in seen:
|
|
174
|
+
continue
|
|
175
|
+
for capture in _captures(ctx, url, prefix=False)[-1:]:
|
|
176
|
+
if not _is_page(capture):
|
|
177
|
+
add(capture)
|
|
178
|
+
return files
|