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.
Files changed (127) hide show
  1. {fetchpdf-0.1.7/fetchpdf.egg-info → fetchpdf-0.1.8}/PKG-INFO +13 -6
  2. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/README.md +12 -5
  3. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/__init__.py +1 -1
  4. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetchpdf.py +6 -1
  5. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/get_cookies.py +3 -1
  6. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/institutional.py +66 -5
  7. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_atypon.py +12 -0
  8. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_index.py +38 -0
  9. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_pmc.py +18 -9
  10. fetchpdf-0.1.8/fetchpdf/retrieval/supplement_wayback.py +178 -0
  11. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplementary.py +21 -3
  12. {fetchpdf-0.1.7 → fetchpdf-0.1.8/fetchpdf.egg-info}/PKG-INFO +13 -6
  13. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/SOURCES.txt +1 -0
  14. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_institutional.py +37 -0
  15. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_supplementary.py +223 -2
  16. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/LICENSE +0 -0
  17. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/MANIFEST.in +0 -0
  18. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/__main__.py +0 -0
  19. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/_env.py +0 -0
  20. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/_http.py +0 -0
  21. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetch_abstract_from_doi.py +0 -0
  22. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/fetch_metadata_from_doi.py +0 -0
  23. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/__init__.py +0 -0
  24. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/_util.py +0 -0
  25. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/artifact.py +0 -0
  26. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/__init__.py +0 -0
  27. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
  28. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/backends/openrouter.py +0 -0
  29. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/blocked.py +0 -0
  30. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/byline.py +0 -0
  31. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/cache.py +0 -0
  32. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/chunk.py +0 -0
  33. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/classify.py +0 -0
  34. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/context.py +0 -0
  35. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/corresponding.py +0 -0
  36. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/engine.py +0 -0
  37. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/extract_images.py +0 -0
  38. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/figures.py +0 -0
  39. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/fulltext_scan.py +0 -0
  40. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/goals.py +0 -0
  41. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/http.py +0 -0
  42. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/identifiers.py +0 -0
  43. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/ladder.json +0 -0
  44. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/linked_artifacts.py +0 -0
  45. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
  46. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/llm_agent_retrieval.py +0 -0
  47. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/normalize.py +0 -0
  48. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_identity.py +0 -0
  49. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pdf_text.py +0 -0
  50. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/provenance.py +0 -0
  51. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/pubpeer.py +0 -0
  52. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/ratelimit.py +0 -0
  53. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/references.py +0 -0
  54. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/repository_waf.py +0 -0
  55. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/request_drafts.py +0 -0
  56. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/resolve.py +0 -0
  57. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/__init__.py +0 -0
  58. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/arxiv.py +0 -0
  59. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/core_text.py +0 -0
  60. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
  61. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc.py +0 -0
  62. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/epmc_repo.py +0 -0
  63. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/landing.py +0 -0
  64. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
  65. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc.py +0 -0
  66. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/pmc_html.py +0 -0
  67. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/preprint.py +0 -0
  68. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
  69. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/sources/supplements.py +0 -0
  70. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_dspace.py +0 -0
  71. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_graph.py +0 -0
  72. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_jci.py +0 -0
  73. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/supplement_publishers.py +0 -0
  74. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/tiers.py +0 -0
  75. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_html.py +0 -0
  76. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/to_markdown.py +0 -0
  77. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/validate.py +0 -0
  78. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf/retrieval/verify.py +0 -0
  79. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/dependency_links.txt +0 -0
  80. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/entry_points.txt +0 -0
  81. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/requires.txt +0 -0
  82. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/fetchpdf.egg-info/top_level.txt +0 -0
  83. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/pyproject.toml +0 -0
  84. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/requirements.txt +0 -0
  85. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/setup.cfg +0 -0
  86. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_agent_backends.py +0 -0
  87. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_blocked.py +0 -0
  88. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_cookie_offer.py +0 -0
  89. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_corresponding.py +0 -0
  90. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_datacite_supplement_identity.py +0 -0
  91. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_dspace.py +0 -0
  92. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_elsevier_routing.py +0 -0
  93. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_entrypoints.py +0 -0
  94. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_epmc_repo.py +0 -0
  95. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_extract_images.py +0 -0
  96. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_figures.py +0 -0
  97. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_folder_encoding.py +0 -0
  98. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_fulltext_scan.py +0 -0
  99. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_get_cookies.py +0 -0
  100. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_jci_supplements.py +0 -0
  101. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_json_output.py +0 -0
  102. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_landing_candidates.py +0 -0
  103. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_landing_page_detection.py +0 -0
  104. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_last_resort_reachability.py +0 -0
  105. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_linked_artifacts.py +0 -0
  106. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_llm_adjudicate.py +0 -0
  107. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_llm_agent_retrieval.py +0 -0
  108. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_normalize_cells.py +0 -0
  109. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pdf_identity.py +0 -0
  110. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pdf_trailer.py +0 -0
  111. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pmc_page.py +0 -0
  112. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_polite_ua.py +0 -0
  113. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pubmed_identity.py +0 -0
  114. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pubpeer.py +0 -0
  115. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pull_for_record_call_sites.py +0 -0
  116. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_pure.py +0 -0
  117. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_request_drafts.py +0 -0
  118. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_retrieval.py +0 -0
  119. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_review_fixes.py +0 -0
  120. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_scan_record.py +0 -0
  121. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_screening_conversion.py +0 -0
  122. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_serpapi_scholar.py +0 -0
  123. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_short_conference_xml.py +0 -0
  124. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_supplement_asset_identity.py +0 -0
  125. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_to_html.py +0 -0
  126. {fetchpdf-0.1.7 → fetchpdf-0.1.8}/tests/test_xml_fallback_extras.py +0 -0
  127. {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.7
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`, `--upgrade-existing` on the tiered path),
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-or-html-and-pdf` the tiered
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`, `--upgrade-existing` on the tiered path),
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-or-html-and-pdf` the tiered
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.7"
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
- if not ids.pmcid:
64
- return []
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 body.
75
- # An abstract stub or an EPMC error document parses cleanly and has no
76
- # <supplementary-material> either, and the two are otherwise identical.
77
- if root.find(".//{*}body") is None and root.find(".//body") is None:
78
- ctx.log(" JATS manifest: no <body>, so a zero supplement count proves nothing")
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 root.find(".//{*}body") is None and root.find(".//body") is None:
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("&amp;", "&"))
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