fetchpdf 0.1.6__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.6/fetchpdf.egg-info → fetchpdf-0.1.8}/PKG-INFO +13 -6
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/README.md +12 -5
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/__init__.py +1 -1
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/fetchpdf.py +6 -1
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/get_cookies.py +3 -1
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/institutional.py +66 -5
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_identity.py +92 -2
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_atypon.py +12 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_index.py +38 -0
- {fetchpdf-0.1.6 → 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.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplementary.py +21 -3
- {fetchpdf-0.1.6 → fetchpdf-0.1.8/fetchpdf.egg-info}/PKG-INFO +13 -6
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf.egg-info/SOURCES.txt +1 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_institutional.py +37 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pdf_identity.py +104 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_serpapi_scholar.py +3 -1
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_supplementary.py +223 -2
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/LICENSE +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/MANIFEST.in +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/__main__.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/_env.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/_http.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/fetch_abstract_from_doi.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/fetch_metadata_from_doi.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/__init__.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/_util.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/artifact.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/__init__.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/openrouter.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/blocked.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/byline.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/cache.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/chunk.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/classify.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/context.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/corresponding.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/engine.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/extract_images.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/figures.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/fulltext_scan.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/goals.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/http.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/identifiers.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/ladder.json +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/linked_artifacts.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_agent_retrieval.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/normalize.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_text.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/provenance.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/pubpeer.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/ratelimit.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/references.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/repository_waf.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/request_drafts.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/resolve.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/__init__.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/arxiv.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/core_text.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc_repo.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/landing.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc_html.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/preprint.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/supplements.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_dspace.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_graph.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_jci.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_publishers.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/tiers.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_html.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_markdown.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/validate.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf/retrieval/verify.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf.egg-info/dependency_links.txt +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf.egg-info/entry_points.txt +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf.egg-info/requires.txt +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/fetchpdf.egg-info/top_level.txt +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/pyproject.toml +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/requirements.txt +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/setup.cfg +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_agent_backends.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_blocked.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_cookie_offer.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_corresponding.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_datacite_supplement_identity.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_dspace.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_elsevier_routing.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_entrypoints.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_epmc_repo.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_extract_images.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_figures.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_folder_encoding.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_fulltext_scan.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_get_cookies.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_jci_supplements.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_json_output.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_landing_candidates.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_landing_page_detection.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_last_resort_reachability.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_linked_artifacts.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_llm_adjudicate.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_llm_agent_retrieval.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_normalize_cells.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pdf_trailer.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pmc_page.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_polite_ua.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pubmed_identity.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pubpeer.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pull_for_record_call_sites.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_pure.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_request_drafts.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_retrieval.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_review_fixes.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_scan_record.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_screening_conversion.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_short_conference_xml.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_supplement_asset_identity.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_to_html.py +0 -0
- {fetchpdf-0.1.6 → fetchpdf-0.1.8}/tests/test_xml_fallback_extras.py +0 -0
- {fetchpdf-0.1.6 → 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
|
|
@@ -160,6 +160,44 @@ PAGE_COUNT_MIN_SPAN = 2
|
|
|
160
160
|
|
|
161
161
|
_PAGE_RANGE_RE = re.compile(r"^\s*(?:[A-Za-z]*)(\d+)\s*(?:[-–—]\s*(?:[A-Za-z]*)(\d+))?\s*$")
|
|
162
162
|
|
|
163
|
+
#: S1 on its own is not enough when the front pages are legible and do not carry
|
|
164
|
+
#: the title (issue #5). The requested DOI is printed by the paper itself, but
|
|
165
|
+
#: also by a paper that CITES it and by the article's own supplementary file:
|
|
166
|
+
#: fetch_pdf("10.1037/0022-3514.74.6.1464") wrote 10.5964/meth.7155, whose
|
|
167
|
+
#: abstract reads "(IAT; Greenwald et al., 1998. DOI: 10.1037/0022-3514.74.6.1464)",
|
|
168
|
+
#: and the Supplementary Information PDF for 10.1038/nature08833 prints
|
|
169
|
+
#: "doi: 10.1038/nature08833" on page 1. Measured over 121 cached PDFs whose DOI
|
|
170
|
+
#: was found in the text, scoring the Crossref title against the front two pages:
|
|
171
|
+
#: the citing paper 0.52, the supplement 0.64, a line-numbered medRxiv preprint
|
|
172
|
+
#: (correct) 0.76, the other 118 correct papers >= 0.97. 0.70 sits in the gap; it
|
|
173
|
+
#: is narrow, so a page too short to judge (LEGIBLE_FRONT_CHARS) keeps the DOI
|
|
174
|
+
#: verdict rather than risk refusing a correct scan.
|
|
175
|
+
DOI_TITLE_PAGES = 2
|
|
176
|
+
DOI_TITLE_MIN = 0.70
|
|
177
|
+
|
|
178
|
+
#: The same DOI printed inside an author-year citation -- "Greenwald et al.,
|
|
179
|
+
#: 1998. DOI: 10.1037/...", "(Smith & Jones, 2020, https://doi.org/...)" -- is a
|
|
180
|
+
#: reference to the paper, not the paper's own masthead. Matched on the text
|
|
181
|
+
#: just before the DOI, so it also works when no title is known to compare.
|
|
182
|
+
_CITED_BEFORE_DOI_RE = re.compile(
|
|
183
|
+
r"(?:et\s+al\.?|&\s*[a-z][a-z'\-]+|\band\s+[a-z][a-z'\-]+)" # et al. / & Name / and Name
|
|
184
|
+
r"[\s,]*\(?\s*(?:1[89]|20)\d{2}[a-z]?\s*\)?" # , 1998 / (1998) / 2020a
|
|
185
|
+
r"\s*[.,;:]?\s*"
|
|
186
|
+
r"(?:doi\s*[:.]?\s*|(?:https?\s*:\s*//\s*)?(?:dx\s*\.\s*)?doi\s*\.\s*org\s*/\s*)?$",
|
|
187
|
+
re.IGNORECASE,
|
|
188
|
+
)
|
|
189
|
+
#: How far before the DOI the author-year pattern may end, and how far back a
|
|
190
|
+
#: "cite this article" line may start. A paper's own front matter often prints
|
|
191
|
+
#: a suggested citation in exactly the cited shape ("Cite this article as: Smith
|
|
192
|
+
#: et al., 2021. https://doi.org/..."), and that is the paper's own DOI.
|
|
193
|
+
CITED_CONTEXT_CHARS = 80
|
|
194
|
+
OWN_CITATION_LINE_CHARS = 400
|
|
195
|
+
_OWN_CITATION_RE = re.compile(
|
|
196
|
+
r"cite\s+(?:this|the)\s+(?:article|paper|chapter|work|preprint)|please\s+cite"
|
|
197
|
+
r"|how\s+to\s+cite|citation\s*:|recommended\s+citation|cite\s+as",
|
|
198
|
+
re.IGNORECASE,
|
|
199
|
+
)
|
|
200
|
+
|
|
163
201
|
#: Embedded titles that carry no identity: a converter's leftovers, a filename,
|
|
164
202
|
#: a template default. Present in a large minority of real PDFs, and treating
|
|
165
203
|
#: them as "declares a different article" would reject correct files.
|
|
@@ -279,11 +317,24 @@ def verify_pdf_identity(source, doi: Optional[str] = None,
|
|
|
279
317
|
|
|
280
318
|
# S1: the requested DOI, printed on the paper. True of essentially every
|
|
281
319
|
# version of record, and squashing means a line break inside it is harmless.
|
|
320
|
+
# Not on its own, though, when the DOI is only cited, or when the front pages
|
|
321
|
+
# are legible and do not carry the title (DOI_TITLE_MIN): then the later
|
|
322
|
+
# signals decide, and a refusal says the DOI was seen and why it did not count.
|
|
323
|
+
doi_discounted = ""
|
|
282
324
|
if doi:
|
|
283
325
|
wanted = squash(normalise_doi(doi))
|
|
284
326
|
if wanted and wanted in squashed_page:
|
|
285
|
-
|
|
286
|
-
|
|
327
|
+
if _doi_only_cited(text, doi):
|
|
328
|
+
doi_discounted = "only inside a citation"
|
|
329
|
+
elif title:
|
|
330
|
+
two_pages = squash(reader.text(DOI_TITLE_PAGES) or text)
|
|
331
|
+
fraction = matched_fraction(squash(title), two_pages)
|
|
332
|
+
if len(two_pages) >= LEGIBLE_FRONT_CHARS and fraction < DOI_TITLE_MIN:
|
|
333
|
+
doi_discounted = ("but its front pages carry only {:.0%} of the "
|
|
334
|
+
"title".format(fraction))
|
|
335
|
+
if not doi_discounted:
|
|
336
|
+
return truncated or IdentityVerdict(
|
|
337
|
+
VERIFIED, "requested DOI found in the PDF text", ["doi-in-text"])
|
|
287
338
|
|
|
288
339
|
# S1b: the arXiv id, stamped down the margin of every PDF arXiv serves. It
|
|
289
340
|
# is S1 for a class of record that S1 cannot reach and neither can S2-S4:
|
|
@@ -410,10 +461,23 @@ def verify_pdf_identity(source, doi: Optional[str] = None,
|
|
|
410
461
|
"so it cannot be checked against {}".format(
|
|
411
462
|
len(squashed_page), DOI_PAGES, doi or "the requested record"),
|
|
412
463
|
)
|
|
464
|
+
if doi_discounted:
|
|
465
|
+
return IdentityVerdict(
|
|
466
|
+
WRONG,
|
|
467
|
+
'PDF prints the requested DOI {}, so it is not taken as "{}" '
|
|
468
|
+
"(likely a paper citing it, or its supplement)".format(
|
|
469
|
+
doi_discounted, _clip(title)),
|
|
470
|
+
)
|
|
413
471
|
return IdentityVerdict(
|
|
414
472
|
WRONG,
|
|
415
473
|
'PDF does not contain the requested DOI or the title "{}"'.format(_clip(title)),
|
|
416
474
|
)
|
|
475
|
+
if doi_discounted:
|
|
476
|
+
return IdentityVerdict(
|
|
477
|
+
NO_REFERENCE,
|
|
478
|
+
"PDF prints {} {}, and no title is known to check it against".format(
|
|
479
|
+
doi or "the requested DOI", doi_discounted),
|
|
480
|
+
)
|
|
417
481
|
return IdentityVerdict(
|
|
418
482
|
NO_REFERENCE,
|
|
419
483
|
"no title known for {} and its DOI is not printed in the PDF, so there "
|
|
@@ -630,6 +694,32 @@ def _is_generic_title(embedded: str) -> bool:
|
|
|
630
694
|
return bool(_GENERIC_TITLE_RE.match(stripped) or _FILENAME_TITLE_RE.match(stripped))
|
|
631
695
|
|
|
632
696
|
|
|
697
|
+
def _doi_only_cited(text: str, doi: str) -> bool:
|
|
698
|
+
"""Whether every place `doi` is printed in `text` is inside a citation.
|
|
699
|
+
|
|
700
|
+
True only when the DOI appears at least once and each appearance follows an
|
|
701
|
+
author-year citation ("et al., 1998. DOI:") with no "cite this article" line
|
|
702
|
+
before it. A DOI printed once in a masthead and once in a citation is the
|
|
703
|
+
paper's own, so one non-citation appearance is enough to say False.
|
|
704
|
+
"""
|
|
705
|
+
wanted = normalise_doi(doi)
|
|
706
|
+
if not wanted:
|
|
707
|
+
return False
|
|
708
|
+
# Line wraps land anywhere inside a DOI, so allow whitespace between its characters.
|
|
709
|
+
pattern = re.compile(r"\s*".join(re.escape(c) for c in wanted), re.IGNORECASE)
|
|
710
|
+
hits = list(pattern.finditer(text))
|
|
711
|
+
if not hits:
|
|
712
|
+
return False
|
|
713
|
+
for hit in hits:
|
|
714
|
+
before = text[max(0, hit.start() - CITED_CONTEXT_CHARS):hit.start()]
|
|
715
|
+
if not _CITED_BEFORE_DOI_RE.search(before):
|
|
716
|
+
return False
|
|
717
|
+
line_window = text[max(0, hit.start() - OWN_CITATION_LINE_CHARS):hit.start()]
|
|
718
|
+
if _OWN_CITATION_RE.search(line_window):
|
|
719
|
+
return False
|
|
720
|
+
return True
|
|
721
|
+
|
|
722
|
+
|
|
633
723
|
def _clip(text: str, limit: int = 70) -> str:
|
|
634
724
|
text = " ".join((text or "").split())
|
|
635
725
|
return text if len(text) <= limit else text[:limit - 1] + "…"
|
|
@@ -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()
|