fetchpdf 0.1.3__tar.gz → 0.1.4__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 (122) hide show
  1. {fetchpdf-0.1.3/fetchpdf.egg-info → fetchpdf-0.1.4}/PKG-INFO +17 -11
  2. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/README.md +16 -10
  3. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/__init__.py +1 -1
  4. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/fetchpdf.py +30 -10
  5. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/get_cookies.py +6 -6
  6. fetchpdf-0.1.4/fetchpdf/retrieval/byline.py +257 -0
  7. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/figures.py +6 -0
  8. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/institutional.py +3 -3
  9. fetchpdf-0.1.4/fetchpdf/retrieval/references.py +280 -0
  10. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/to_html.py +163 -19
  11. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/to_markdown.py +10 -1
  12. {fetchpdf-0.1.3 → fetchpdf-0.1.4/fetchpdf.egg-info}/PKG-INFO +17 -11
  13. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf.egg-info/SOURCES.txt +2 -0
  14. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_cookie_offer.py +1 -1
  15. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_get_cookies.py +1 -1
  16. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_institutional.py +1 -1
  17. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_supplementary.py +24 -0
  18. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_to_html.py +140 -2
  19. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/LICENSE +0 -0
  20. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/MANIFEST.in +0 -0
  21. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/__main__.py +0 -0
  22. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/_env.py +0 -0
  23. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/_http.py +0 -0
  24. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/fetch_abstract_from_doi.py +0 -0
  25. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/fetch_metadata_from_doi.py +0 -0
  26. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/__init__.py +0 -0
  27. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/_util.py +0 -0
  28. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/artifact.py +0 -0
  29. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/__init__.py +0 -0
  30. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
  31. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/openrouter.py +0 -0
  32. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/blocked.py +0 -0
  33. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/cache.py +0 -0
  34. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/chunk.py +0 -0
  35. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/classify.py +0 -0
  36. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/context.py +0 -0
  37. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/corresponding.py +0 -0
  38. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/engine.py +0 -0
  39. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/extract_images.py +0 -0
  40. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/fulltext_scan.py +0 -0
  41. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/goals.py +0 -0
  42. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/http.py +0 -0
  43. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/identifiers.py +0 -0
  44. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/ladder.json +0 -0
  45. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/linked_artifacts.py +0 -0
  46. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
  47. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/llm_agent_retrieval.py +0 -0
  48. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/normalize.py +0 -0
  49. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/pdf_identity.py +0 -0
  50. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/pdf_text.py +0 -0
  51. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/provenance.py +0 -0
  52. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/pubpeer.py +0 -0
  53. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/ratelimit.py +0 -0
  54. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/repository_waf.py +0 -0
  55. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/request_drafts.py +0 -0
  56. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/resolve.py +0 -0
  57. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/__init__.py +0 -0
  58. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/arxiv.py +0 -0
  59. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/core_text.py +0 -0
  60. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
  61. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/epmc.py +0 -0
  62. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/epmc_repo.py +0 -0
  63. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/landing.py +0 -0
  64. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
  65. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/pmc.py +0 -0
  66. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/pmc_html.py +0 -0
  67. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/preprint.py +0 -0
  68. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
  69. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/supplements.py +0 -0
  70. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_atypon.py +0 -0
  71. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_dspace.py +0 -0
  72. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_graph.py +0 -0
  73. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_index.py +0 -0
  74. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_jci.py +0 -0
  75. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_pmc.py +0 -0
  76. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_publishers.py +0 -0
  77. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplementary.py +0 -0
  78. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/tiers.py +0 -0
  79. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/validate.py +0 -0
  80. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf/retrieval/verify.py +0 -0
  81. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf.egg-info/dependency_links.txt +0 -0
  82. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf.egg-info/entry_points.txt +0 -0
  83. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf.egg-info/requires.txt +0 -0
  84. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/fetchpdf.egg-info/top_level.txt +0 -0
  85. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/pyproject.toml +0 -0
  86. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/requirements.txt +0 -0
  87. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/setup.cfg +0 -0
  88. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_agent_backends.py +0 -0
  89. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_blocked.py +0 -0
  90. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_corresponding.py +0 -0
  91. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_datacite_supplement_identity.py +0 -0
  92. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_dspace.py +0 -0
  93. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_entrypoints.py +0 -0
  94. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_epmc_repo.py +0 -0
  95. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_extract_images.py +0 -0
  96. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_figures.py +0 -0
  97. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_folder_encoding.py +0 -0
  98. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_fulltext_scan.py +0 -0
  99. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_jci_supplements.py +0 -0
  100. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_json_output.py +0 -0
  101. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_landing_candidates.py +0 -0
  102. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_landing_page_detection.py +0 -0
  103. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_last_resort_reachability.py +0 -0
  104. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_linked_artifacts.py +0 -0
  105. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_llm_adjudicate.py +0 -0
  106. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_llm_agent_retrieval.py +0 -0
  107. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_normalize_cells.py +0 -0
  108. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pdf_identity.py +0 -0
  109. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pdf_trailer.py +0 -0
  110. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pmc_page.py +0 -0
  111. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_polite_ua.py +0 -0
  112. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pubmed_identity.py +0 -0
  113. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pubpeer.py +0 -0
  114. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pull_for_record_call_sites.py +0 -0
  115. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_pure.py +0 -0
  116. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_request_drafts.py +0 -0
  117. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_retrieval.py +0 -0
  118. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_scan_record.py +0 -0
  119. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_screening_conversion.py +0 -0
  120. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_serpapi_scholar.py +0 -0
  121. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_short_conference_xml.py +0 -0
  122. {fetchpdf-0.1.3 → fetchpdf-0.1.4}/tests/test_supplement_asset_identity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchpdf
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -56,7 +56,7 @@ A comprehensive Python package to download academic papers (PDFs) from DOIs (or
56
56
  - [Supplementary Material](#supplementary-material)
57
57
  - [`--pull-everything`: the second layer](#--pull-everything-the-second-layer)
58
58
  - [Figure Images](#figure-images)
59
- - [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions) — papers your library subscribes to (`fetchpdf cookies setup`)
59
+ - [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions) — papers your library subscribes to (`fetchpdf fetchcookies`)
60
60
  - [Calling fetchpdf from a script or an agent](#calling-fetchpdf-from-a-script-or-an-agent) — `--json`
61
61
  - [API Reference](#api-reference)
62
62
  - [Download Sources](#download-sources-in-order-of-priority)
@@ -85,7 +85,7 @@ A comprehensive Python package to download academic papers (PDFs) from DOIs (or
85
85
  - 🖨️ **[XML to HTML / PDF](#rendering-the-xml-as-html-or-pdf)**: `--generate-html-from-xml` / `--generate-pdf-from-xml` render a retrieved XML as one self-contained page, figures and tables embedded
86
86
  - 🔗 **[Linked Datasets & Code](#linked-datasets-and-code-stem_linked_artifactsjson)**: the same pass discovers each paper's external datasets/software via ScholeXplorer, Europe PMC and DataCite, downloads ownership-confirmed deposits from figshare/Zenodo/OSF/Dryad/Dataverse, and records every link in a sidecar
87
87
  - 🤖 **[LLM-assisted retrieval](#--pull-everything-the-second-layer)**: `--pull-everything` adds a second layer — a model reads the paper itself and goes after the SI and datasets the APIs missed
88
- - 🔐 **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** (opt-in): a one-time `fetchpdf cookies setup` lets fetchpdf download papers your library subscribes to
88
+ - 🔐 **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** (opt-in): a one-time `fetchpdf fetchcookies` lets fetchpdf download papers your library subscribes to
89
89
  - 🚀 **Batch Processing**: Process multiple DOIs/PMIDs from CSV files or lists
90
90
  - ⚡ **Parallel Execution**: Download multiple papers simultaneously with configurable workers
91
91
 
@@ -184,7 +184,7 @@ fetchpdf '10.1002/(SICI)1521-3773(19981116)37:21<2922::AID-ANIE2922>3.0.CO;2-1,
184
184
  # ...but single-quote any list holding ( ) < > ; -- the shell would read them as syntax
185
185
  ```
186
186
 
187
- **At a university?** Run `fetchpdf cookies setup` once and fetchpdf will also fetch
187
+ **At a university?** Run `fetchpdf fetchcookies` once and fetchpdf will also fetch
188
188
  papers your library subscribes to. See
189
189
  [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions).
190
190
 
@@ -1005,11 +1005,17 @@ fetchpdf-render ./out --html --pdf --fetch-figures # XML you already have
1005
1005
 
1006
1006
  **A rendering, not the publisher's PDF**, and never written as `{stem}.pdf`:
1007
1007
  that name means the typeset article to the skip check and the validators. The
1008
- page opens with a banner naming the source file, the DOI, and how many figures
1009
- were embedded. A figure whose image could not be had keeps a visible
1008
+ page opens with a banner naming the source file, the fetchpdf version and the
1009
+ DOI, plus a note for anything that could not be shown: figures without an image,
1010
+ tables published only as an image that is missing, or no reference list. A figure whose image could not be had keeps a visible
1010
1011
  placeholder, so an incomplete rendering looks incomplete. The prose and
1011
- tables come from the same walker as `--to-markdown`, so the two agree;
1012
- references are omitted and formulas stay as text.
1012
+ tables come from the same walker as `--to-markdown`, so the two agree, except
1013
+ that the page ends with the reference list (with DOI links), which the Markdown
1014
+ leaves out. Formulas stay as text. The XML never says how large a figure was
1015
+ typeset, so each is given about the same area (a square figure ~10.5 cm across),
1016
+ shaped by its aspect ratio and capped at the column width. Under the title come
1017
+ the authors, their affiliations and the journal citation (volume, issue, pages).
1018
+ Tables always fit the page width in the PDF; very wide ones print smaller.
1013
1019
 
1014
1020
  Only records whose XML was kept are rendered — add `--get-xml-or-html` to keep
1015
1021
  the XML beside the PDF.
@@ -1026,7 +1032,7 @@ library, for the papers that have no open-access copy.
1026
1032
  Browser cookie support is included in the standard fetchpdf installation.
1027
1033
 
1028
1034
  ```bash
1029
- fetchpdf cookies setup
1035
+ fetchpdf fetchcookies
1030
1036
  ```
1031
1037
 
1032
1038
  The wizard asks two questions, then opens some tabs:
@@ -1058,7 +1064,7 @@ session exists but the test article was refused: the session lapsed, or your
1058
1064
  library does not take that particular journal. Access is turned on only if at
1059
1065
  least one publisher works.
1060
1066
 
1061
- **Cookies often expire within hours**. You likely will have to rerun `fetchpdf cookies setup` periodically to restore access.
1067
+ **Cookies often expire within hours**. You likely will have to rerun `fetchpdf fetchcookies` periodically to restore access.
1062
1068
 
1063
1069
  ### Living with it
1064
1070
 
@@ -1092,7 +1098,7 @@ run. This works the same for a single DOI and for a batch:
1092
1098
  ```
1093
1099
 
1094
1100
  Runs that cannot be asked (pipes, cron, `--json`) print the command instead
1095
- (`fetchpdf cookies setup --publishers wiley,tandf`, or `refresh --publishers ...`).
1101
+ (`fetchpdf fetchcookies --publishers wiley,tandf`, or `refresh --publishers ...`).
1096
1102
  `--no-cookies` and `--cookies FILE` turn the offer off. Royal Society papers are
1097
1103
  not offered, because there is no test article to sign in on.
1098
1104
 
@@ -17,7 +17,7 @@ A comprehensive Python package to download academic papers (PDFs) from DOIs (or
17
17
  - [Supplementary Material](#supplementary-material)
18
18
  - [`--pull-everything`: the second layer](#--pull-everything-the-second-layer)
19
19
  - [Figure Images](#figure-images)
20
- - [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions) — papers your library subscribes to (`fetchpdf cookies setup`)
20
+ - [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions) — papers your library subscribes to (`fetchpdf fetchcookies`)
21
21
  - [Calling fetchpdf from a script or an agent](#calling-fetchpdf-from-a-script-or-an-agent) — `--json`
22
22
  - [API Reference](#api-reference)
23
23
  - [Download Sources](#download-sources-in-order-of-priority)
@@ -46,7 +46,7 @@ A comprehensive Python package to download academic papers (PDFs) from DOIs (or
46
46
  - 🖨️ **[XML to HTML / PDF](#rendering-the-xml-as-html-or-pdf)**: `--generate-html-from-xml` / `--generate-pdf-from-xml` render a retrieved XML as one self-contained page, figures and tables embedded
47
47
  - 🔗 **[Linked Datasets & Code](#linked-datasets-and-code-stem_linked_artifactsjson)**: the same pass discovers each paper's external datasets/software via ScholeXplorer, Europe PMC and DataCite, downloads ownership-confirmed deposits from figshare/Zenodo/OSF/Dryad/Dataverse, and records every link in a sidecar
48
48
  - 🤖 **[LLM-assisted retrieval](#--pull-everything-the-second-layer)**: `--pull-everything` adds a second layer — a model reads the paper itself and goes after the SI and datasets the APIs missed
49
- - 🔐 **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** (opt-in): a one-time `fetchpdf cookies setup` lets fetchpdf download papers your library subscribes to
49
+ - 🔐 **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** (opt-in): a one-time `fetchpdf fetchcookies` lets fetchpdf download papers your library subscribes to
50
50
  - 🚀 **Batch Processing**: Process multiple DOIs/PMIDs from CSV files or lists
51
51
  - ⚡ **Parallel Execution**: Download multiple papers simultaneously with configurable workers
52
52
 
@@ -145,7 +145,7 @@ fetchpdf '10.1002/(SICI)1521-3773(19981116)37:21<2922::AID-ANIE2922>3.0.CO;2-1,
145
145
  # ...but single-quote any list holding ( ) < > ; -- the shell would read them as syntax
146
146
  ```
147
147
 
148
- **At a university?** Run `fetchpdf cookies setup` once and fetchpdf will also fetch
148
+ **At a university?** Run `fetchpdf fetchcookies` once and fetchpdf will also fetch
149
149
  papers your library subscribes to. See
150
150
  [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions).
151
151
 
@@ -966,11 +966,17 @@ fetchpdf-render ./out --html --pdf --fetch-figures # XML you already have
966
966
 
967
967
  **A rendering, not the publisher's PDF**, and never written as `{stem}.pdf`:
968
968
  that name means the typeset article to the skip check and the validators. The
969
- page opens with a banner naming the source file, the DOI, and how many figures
970
- were embedded. A figure whose image could not be had keeps a visible
969
+ page opens with a banner naming the source file, the fetchpdf version and the
970
+ DOI, plus a note for anything that could not be shown: figures without an image,
971
+ tables published only as an image that is missing, or no reference list. A figure whose image could not be had keeps a visible
971
972
  placeholder, so an incomplete rendering looks incomplete. The prose and
972
- tables come from the same walker as `--to-markdown`, so the two agree;
973
- references are omitted and formulas stay as text.
973
+ tables come from the same walker as `--to-markdown`, so the two agree, except
974
+ that the page ends with the reference list (with DOI links), which the Markdown
975
+ leaves out. Formulas stay as text. The XML never says how large a figure was
976
+ typeset, so each is given about the same area (a square figure ~10.5 cm across),
977
+ shaped by its aspect ratio and capped at the column width. Under the title come
978
+ the authors, their affiliations and the journal citation (volume, issue, pages).
979
+ Tables always fit the page width in the PDF; very wide ones print smaller.
974
980
 
975
981
  Only records whose XML was kept are rendered — add `--get-xml-or-html` to keep
976
982
  the XML beside the PDF.
@@ -987,7 +993,7 @@ library, for the papers that have no open-access copy.
987
993
  Browser cookie support is included in the standard fetchpdf installation.
988
994
 
989
995
  ```bash
990
- fetchpdf cookies setup
996
+ fetchpdf fetchcookies
991
997
  ```
992
998
 
993
999
  The wizard asks two questions, then opens some tabs:
@@ -1019,7 +1025,7 @@ session exists but the test article was refused: the session lapsed, or your
1019
1025
  library does not take that particular journal. Access is turned on only if at
1020
1026
  least one publisher works.
1021
1027
 
1022
- **Cookies often expire within hours**. You likely will have to rerun `fetchpdf cookies setup` periodically to restore access.
1028
+ **Cookies often expire within hours**. You likely will have to rerun `fetchpdf fetchcookies` periodically to restore access.
1023
1029
 
1024
1030
  ### Living with it
1025
1031
 
@@ -1053,7 +1059,7 @@ run. This works the same for a single DOI and for a batch:
1053
1059
  ```
1054
1060
 
1055
1061
  Runs that cannot be asked (pipes, cron, `--json`) print the command instead
1056
- (`fetchpdf cookies setup --publishers wiley,tandf`, or `refresh --publishers ...`).
1062
+ (`fetchpdf fetchcookies --publishers wiley,tandf`, or `refresh --publishers ...`).
1057
1063
  `--no-cookies` and `--cookies FILE` turn the offer off. Royal Society papers are
1058
1064
  not offered, because there is no test article to sign in on.
1059
1065
 
@@ -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.3"
18
+ __version__ = "0.1.4"
19
19
  __all__ = [
20
20
  "fetch_pdf",
21
21
  "fetch_pdf_from_doi", # deprecated alias for fetch_pdf
@@ -4024,7 +4024,7 @@ def _try_institutional(save_path, resolved, cookies_file=None, verbose=False,
4024
4024
  _source_out=None):
4025
4025
  """The institutional-access route. Returns the saved path, or None.
4026
4026
 
4027
- Off unless the user opted in -- `fetchpdf cookies setup`, or --cookies FILE --
4027
+ Off unless the user opted in -- `fetchpdf fetchcookies`, or --cookies FILE --
4028
4028
  and silent for a publisher the route does not cover, so a run over APA or
4029
4029
  Elsevier DOIs carries no extra noise.
4030
4030
  """
@@ -5131,7 +5131,7 @@ def _fetch_pdf_chain(doi,
5131
5131
  # tail (Crossref landing pages, CORE, DOAJ, DataCite, PMID, Elsevier) that
5132
5132
  # rarely finds what a subscription serves in a second or two. Grey sources
5133
5133
  # (fetchpdf_grey) still run after the whole walk. A no-op unless the user
5134
- # ran `fetchpdf cookies setup` or passed --cookies, and for publishers it does
5134
+ # ran `fetchpdf fetchcookies` or passed --cookies, and for publishers it does
5135
5135
  # not cover. Once per record: not on related-DOI re-entries.
5136
5136
  if _top_level and not (xml_only or xml_html_only):
5137
5137
  institutional_path = _try_institutional(
@@ -5809,6 +5809,14 @@ def _prune_empty_record_dir(record_dir):
5809
5809
  """
5810
5810
  if not record_dir:
5811
5811
  return
5812
+ # The resolution cache is written beside the artifact, so a record that got
5813
+ # nothing still holds one; alone, it is not something the record wrote.
5814
+ from .retrieval.cache import CACHE_FILENAME
5815
+ try:
5816
+ if os.listdir(record_dir) == [CACHE_FILENAME]:
5817
+ os.unlink(os.path.join(record_dir, CACHE_FILENAME))
5818
+ except OSError:
5819
+ pass
5812
5820
  try:
5813
5821
  os.rmdir(record_dir)
5814
5822
  except OSError:
@@ -6423,7 +6431,11 @@ def _batch_fetch_pdfs_inner(dois, output_dir, email=None, verbose=False, delay=0
6423
6431
  # path, or a record with four supplementary PDFs would report "pdf 5" and
6424
6432
  # corrupt the format composition tally.
6425
6433
  _pull_si(display_id, raw_identifier, canonical_doi, save_path)
6426
- _pull_figures(display_id, raw_identifier, canonical_doi, save_path)
6434
+ # Unlike the supplements, figures belong to an article we have: with no
6435
+ # artifact there is nothing for them to illustrate, and the pass would
6436
+ # leave an empty `_figures/` and manifest in a record that failed.
6437
+ if success:
6438
+ _pull_figures(display_id, raw_identifier, canonical_doi, save_path)
6427
6439
  _render_xml(display_id, save_path)
6428
6440
  _pull_images(result or save_path, display_id)
6429
6441
 
@@ -7000,7 +7012,7 @@ def _ask_yes(question: str) -> bool:
7000
7012
 
7001
7013
 
7002
7014
  def _import_cookies(cfg, publishers) -> bool:
7003
- """Run `fetchpdf cookies setup` (no config yet) or `refresh` in-process,
7015
+ """Run `fetchpdf fetchcookies` (no config yet) or `refresh` in-process,
7004
7016
  for *publishers* only -- the ones the failed papers are at.
7005
7017
 
7006
7018
  True when the retry is worth making: setup succeeded, or refresh found a
@@ -7064,7 +7076,7 @@ def _offer_cookie_import(failed, retry):
7064
7076
  if cfg is None:
7065
7077
  print(f"🔐 {n} failed {noun} ({where}) may be available through your "
7066
7078
  f"library's subscriptions, by importing your browser's cookies.")
7067
- command = f"fetchpdf cookies setup --publishers {','.join(publishers)}"
7079
+ command = f"fetchpdf fetchcookies --publishers {','.join(publishers)}"
7068
7080
  else:
7069
7081
  print(f"🔐 {n} failed {noun} ({where}) are at publishers your library access "
7070
7082
  f"covers. If your library sign-in there has lapsed, signing in again may get them.")
@@ -7319,6 +7331,9 @@ def main(argv=None):
7319
7331
  if argv and argv[0] == "cookies":
7320
7332
  from .get_cookies import main as cookies_main
7321
7333
  return cookies_main(argv[1:])
7334
+ if argv and argv[0] == "fetchcookies":
7335
+ from .get_cookies import main as cookies_main
7336
+ return cookies_main(["setup"] + argv[1:])
7322
7337
 
7323
7338
  pre = argparse.ArgumentParser(add_help=False)
7324
7339
  pre.add_argument("--json", action="store_true")
@@ -7410,7 +7425,7 @@ def _main(argv=None, json_out=None):
7410
7425
  description="Download PDFs from DOIs using multiple fallback sources.",
7411
7426
  epilog=(
7412
7427
  "Institutional access:\n"
7413
- " fetchpdf cookies setup # sign in through your library\n"
7428
+ " fetchpdf fetchcookies # sign in through your library\n"
7414
7429
  " fetchpdf cookies --help # all cookie commands\n\n"
7415
7430
  "Examples:\n"
7416
7431
  " fetchpdf papers.csv -o ./pdfs # batch from CSV\n"
@@ -7824,7 +7839,7 @@ def _main(argv=None, json_out=None):
7824
7839
  metavar="FILE",
7825
7840
  default=None,
7826
7841
  help="Institutional access from this cookie file instead of the one "
7827
- "`fetchpdf cookies setup` configured. Without it, access is used only "
7842
+ "`fetchpdf fetchcookies` configured. Without it, access is used only "
7828
7843
  "if setup has been run. Covers publishers whose PDF URL follows "
7829
7844
  "from the DOI (Wiley, T&F, SAGE, Springer, Royal Society, Hogrefe, "
7830
7845
  "INFORMS); tried after the fast open-access lookups. Only cookies "
@@ -7890,7 +7905,7 @@ def _main(argv=None, json_out=None):
7890
7905
  if args.cookies_only:
7891
7906
  if args.no_cookies or not _institutional.active_cookies_file():
7892
7907
  print("❌ --cookies-only: institutional access is not set up. Run "
7893
- "`fetchpdf cookies setup` (or pass --cookies FILE).")
7908
+ "`fetchpdf fetchcookies` (or pass --cookies FILE).")
7894
7909
  return 2
7895
7910
  _institutional.set_cookies_only(True)
7896
7911
  # "Alone" includes the grey last resorts an installed fetchpdf_grey adds.
@@ -8295,7 +8310,9 @@ def _main(argv=None, json_out=None):
8295
8310
  except Exception as e:
8296
8311
  print(f"\n📎 supplementary pass failed: {str(e)[:200]}")
8297
8312
 
8298
- if args.pull_figures:
8313
+ # Only for a record that was retrieved: figures with no article to go
8314
+ # with them are an empty `_figures/` and manifest in a failed record.
8315
+ if args.pull_figures and result:
8299
8316
  # A separate call above fetch_pdf, for the same reason the
8300
8317
  # supplementary pass is one: the tiered engine re-enters the chain
8301
8318
  # at T5 with a temporary save_path, and a flag threaded through
@@ -8337,6 +8354,8 @@ def _main(argv=None, json_out=None):
8337
8354
  print(f"\n🖨️ render: {outcome['error']}")
8338
8355
  except Exception as e:
8339
8356
  print(f"\n🖨️ render failed: {str(e)[:200]}")
8357
+ elif not result:
8358
+ print("\n🖨️ no XML could be retrieved for this record, so nothing to render")
8340
8359
  else:
8341
8360
  print("\n🖨️ no .xml was kept for this record, so nothing to render "
8342
8361
  "(add --get-xml-or-html)")
@@ -8391,7 +8410,8 @@ def _main(argv=None, json_out=None):
8391
8410
  return 0
8392
8411
  else:
8393
8412
  _prune_empty_record_dir(made_record_dir)
8394
- print(f"\n❌ Failed to download PDF for {args.doi}")
8413
+ wanted = "XML" if args.xml_only else ("XML or HTML" if args.xml_html_only else "PDF")
8414
+ print(f"\n❌ Failed to download {wanted} for {args.doi}")
8395
8415
  _emit_json(json_out, args.doi, resolved_identifier, None,
8396
8416
  JSON_STATUS_FAILED, identity=identity,
8397
8417
  reasons=reasons or ["no source produced a usable artifact"],
@@ -1,12 +1,12 @@
1
1
  """fetchpdf cookies: set up institutional access for fetchpdf, once.
2
2
 
3
- fetchpdf cookies setup # one-time wizard
3
+ fetchpdf fetchcookies # one-time wizard
4
4
  fetchpdf cookies check # which publishers your sessions currently open
5
5
  fetchpdf cookies refresh # re-open sign-in tabs for the ones that lapsed
6
6
  fetchpdf cookies disable # stop using institutional access
7
7
 
8
8
  Institutional access lets fetchpdf download a paper your library subscribes to
9
- when no open-access copy exists. It is OFF until you run ``fetchpdf cookies setup``.
9
+ when no open-access copy exists. It is OFF until you run ``fetchpdf fetchcookies``.
10
10
 
11
11
  How it works. Each publisher keeps your library sign-in as a cookie on its own
12
12
  site (wiley.com, tandfonline.com, ...). The wizard opens one subscription
@@ -378,7 +378,7 @@ def cmd_setup(args) -> int:
378
378
  import browser_cookie3 # noqa: F401
379
379
  except ImportError:
380
380
  print(" A required dependency is missing: browser_cookie3.\n"
381
- " Run pip install 'browser_cookie3>=0.19', then run fetchpdf cookies setup again.")
381
+ " Run pip install 'browser_cookie3>=0.19', then run fetchpdf fetchcookies again.")
382
382
  return 2
383
383
 
384
384
  # 1. Library
@@ -443,7 +443,7 @@ def cmd_setup(args) -> int:
443
443
 
444
444
  if not any(st == "ok" for st in status.values()):
445
445
  print(" No publisher served a PDF, so institutional access is NOT turned on.\n"
446
- " Check you can open a PDF in your browser, then run fetchpdf cookies setup again.")
446
+ " Check you can open a PDF in your browser, then run fetchpdf fetchcookies again.")
447
447
  return 1
448
448
 
449
449
  cfg["publishers"] = status
@@ -463,7 +463,7 @@ def cmd_setup(args) -> int:
463
463
  def _require_config() -> Optional[dict]:
464
464
  cfg = inst.load_access_config()
465
465
  if not cfg:
466
- print("Institutional access is not set up. Run: fetchpdf cookies setup")
466
+ print("Institutional access is not set up. Run: fetchpdf fetchcookies")
467
467
  return cfg
468
468
 
469
469
 
@@ -516,7 +516,7 @@ def cmd_disable(args) -> int:
516
516
  return 0
517
517
  cfg["enabled"] = False
518
518
  _write_private_json(ACCESS_CONFIG, cfg)
519
- print(f"Institutional access is OFF. Turn it back on with: fetchpdf cookies setup")
519
+ print(f"Institutional access is OFF. Turn it back on with: fetchpdf fetchcookies")
520
520
  return 0
521
521
 
522
522
 
@@ -0,0 +1,257 @@
1
+ """Authors, affiliations and the journal citation, for under the rendered title.
2
+
3
+ Like references.py, this is for the page a person reads (to_html.py) and is
4
+ kept out of the Markdown walker, whose readers do not need a byline.
5
+
6
+ JATS carries them in `<article-meta>`: `<contrib-group>` (each author's
7
+ `<xref ref-type="aff">` points at an `<aff>` by id), and the volume, issue and
8
+ pages as its direct children, with the journal title in `<journal-meta>`.
9
+ Elsevier's full-text XML carries `<ce:author-group>` (`<ce:cross-ref refid>`
10
+ to `<ce:affiliation>`) in the article head and the citation in `prism:`
11
+ elements of its `coredata`; when the head is absent, `dc:creator` still names
12
+ the authors.
13
+ """
14
+
15
+ import html as html_lib
16
+ import xml.etree.ElementTree as ET
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Optional
19
+
20
+ from ._util import localname
21
+ from .references import _compose
22
+ from .to_markdown import _clean, _first_text
23
+
24
+
25
+ @dataclass
26
+ class Byline:
27
+ authors: List[tuple] = field(default_factory=list) # (name, [markers])
28
+ affiliations: List[tuple] = field(default_factory=list) # (label, text)
29
+ journal: str = "" # HTML
30
+
31
+ def html(self) -> str:
32
+ if not (self.authors or self.affiliations or self.journal):
33
+ return ""
34
+ out = ['<div class="byline">']
35
+ if self.authors:
36
+ names = [_esc(name) + (f"<sup>{_esc(','.join(marks))}</sup>" if marks else "")
37
+ for name, marks in self.authors]
38
+ out.append(f'<p class="authors">{", ".join(names)}</p>')
39
+ if self.affiliations:
40
+ out.append('<ul class="affiliations">' + "".join(
41
+ "<li>" + (f"<sup>{_esc(label)}</sup> " if label else "") + f"{_esc(text)}</li>"
42
+ for label, text in self.affiliations) + "</ul>")
43
+ if self.journal:
44
+ out.append(f'<p class="journal">{self.journal}</p>')
45
+ out.append("</div>")
46
+ return "\n".join(out) + "\n"
47
+
48
+
49
+ def byline_from_xml(content: bytes) -> Byline:
50
+ try:
51
+ root = ET.fromstring(content)
52
+ except ET.ParseError:
53
+ return Byline()
54
+ article = next((e for e in root.iter() if localname(e.tag) == "article"), None)
55
+ front = _child(article, "front")
56
+ meta = _child(front, "article-meta")
57
+ if meta is not None:
58
+ return _jats(front, meta)
59
+ return _elsevier(root)
60
+
61
+
62
+ # -- JATS ---------------------------------------------------------------------
63
+
64
+
65
+ def _jats(front, meta) -> Byline:
66
+ byline = Byline()
67
+ affs = [] # (id, label, text)
68
+ for aff in _jats_affs(meta):
69
+ label = _first_text(aff, "label", direct_only=True) or _first_text(aff, "sup", True)
70
+ text = _clean(" ".join(_text_without(aff, ("label", "sup", "institution-id"))))
71
+ if text:
72
+ affs.append((aff.get("id") or "", label, text.strip(" ,;")))
73
+
74
+ authors = [] # (name, [(rid, kind, text)])
75
+ for group in meta:
76
+ if localname(group.tag) != "contrib-group":
77
+ continue
78
+ for contrib in group:
79
+ if localname(contrib.tag) != "contrib":
80
+ continue
81
+ if (contrib.get("contrib-type") or "author").lower() != "author":
82
+ continue
83
+ name = _jats_name(contrib)
84
+ if name:
85
+ refs = [(x.get("rid") or "", (x.get("ref-type") or "").lower(),
86
+ _clean("".join(x.itertext())))
87
+ for x in contrib.iter() if localname(x.tag) == "xref"]
88
+ authors.append((name, refs))
89
+
90
+ # An editor's or reviewer's <aff> sits beside the authors'; when authors
91
+ # point at theirs, only those belong in the byline.
92
+ cited = {rid for _, refs in authors for rid, kind, _ in refs if kind == "aff"}
93
+ if cited & {aff_id for aff_id, _, _ in affs}:
94
+ affs = [a for a in affs if a[0] in cited]
95
+ if len(affs) > 1 and not any(label for _, label, _ in affs):
96
+ affs = [(aff_id, str(n), text) for n, (aff_id, _, text) in enumerate(affs, 1)]
97
+ labels = {aff_id: label for aff_id, label, _ in affs if aff_id}
98
+ byline.affiliations = [(label, text) for _, label, text in affs]
99
+
100
+ for name, refs in authors:
101
+ marks = []
102
+ for rid, kind, text in refs:
103
+ if kind not in ("aff", "corresp", "fn", "author-notes"):
104
+ continue
105
+ mark = labels.get(rid) if kind == "aff" and rid in labels else text
106
+ if mark and mark not in marks:
107
+ marks.append(mark)
108
+ byline.authors.append((name, marks))
109
+
110
+ journal_meta = _child(front, "journal-meta")
111
+ journal = ""
112
+ if journal_meta is not None:
113
+ journal = (_first_text(journal_meta, "journal-title")
114
+ or _first_text(journal_meta, "abbrev-journal-title"))
115
+ fpage, lpage = _direct(meta, "fpage"), _direct(meta, "lpage")
116
+ byline.journal = _citation(journal, _jats_year(meta), _direct(meta, "volume"),
117
+ _direct(meta, "issue"),
118
+ _pages(fpage, lpage) or _direct(meta, "elocation-id"))
119
+ return byline
120
+
121
+
122
+ def _jats_affs(meta):
123
+ """Each `<aff>` once: under the contrib-groups or the meta, first of any alternatives."""
124
+ found = []
125
+ def visit(element):
126
+ for child in element:
127
+ tag = localname(child.tag)
128
+ if tag == "aff":
129
+ found.append(child)
130
+ elif tag == "aff-alternatives":
131
+ first = _child(child, "aff")
132
+ if first is not None:
133
+ found.append(first)
134
+ elif tag == "contrib-group":
135
+ visit(child)
136
+ visit(meta)
137
+ return found
138
+
139
+
140
+ def _jats_name(contrib) -> str:
141
+ for el in contrib:
142
+ tag = localname(el.tag)
143
+ if tag == "name-alternatives":
144
+ el = next((n for n in el if localname(n.tag) in ("name", "string-name")), None)
145
+ if el is None:
146
+ continue
147
+ tag = localname(el.tag)
148
+ if tag == "name":
149
+ return " ".join(filter(None, (_first_text(el, "prefix"), _first_text(el, "given-names"),
150
+ _first_text(el, "surname"), _first_text(el, "suffix"))))
151
+ if tag in ("string-name", "collab"):
152
+ return _clean(" ".join(el.itertext()))
153
+ return ""
154
+
155
+
156
+ def _jats_year(meta) -> str:
157
+ dates = [d for d in meta if localname(d.tag) == "pub-date"]
158
+ def kind(d):
159
+ return (d.get("pub-type") or d.get("date-type") or "").lower()
160
+ for wanted in ("ppub", "collection", "pub", "epub"):
161
+ for date in dates:
162
+ if kind(date) == wanted and _first_text(date, "year"):
163
+ return _first_text(date, "year")
164
+ return next((_first_text(d, "year") for d in dates if _first_text(d, "year")), "")
165
+
166
+
167
+ # -- Elsevier -----------------------------------------------------------------
168
+
169
+
170
+ def _elsevier(root) -> Byline:
171
+ byline = Byline()
172
+ labels = {}
173
+ groups = [e for e in root.iter() if localname(e.tag) == "author-group"]
174
+ for group in groups:
175
+ for aff in group:
176
+ if localname(aff.tag) != "affiliation":
177
+ continue
178
+ label = _first_text(aff, "label", direct_only=True)
179
+ text = _first_text(aff, "textfn") or _clean(
180
+ " ".join(_text_without(aff, ("label", "affiliation"))))
181
+ if text:
182
+ byline.affiliations.append((label, text))
183
+ if aff.get("id"):
184
+ labels[aff.get("id")] = label
185
+ for group in groups:
186
+ for person in group:
187
+ tag = localname(person.tag)
188
+ if tag == "author":
189
+ name = " ".join(filter(None, (_first_text(person, "given-name"),
190
+ _first_text(person, "surname"))))
191
+ elif tag == "collaboration":
192
+ name = _first_text(person, "text") or _clean(" ".join(person.itertext()))
193
+ else:
194
+ continue
195
+ marks = []
196
+ for ref in person.iter():
197
+ if localname(ref.tag) == "cross-ref":
198
+ # The affiliation's own label wins: some XML prints "1"
199
+ # at the author and "a" at the affiliation it points to.
200
+ mark = labels.get(ref.get("refid") or "") or _clean("".join(ref.itertext()))
201
+ if mark and mark not in marks:
202
+ marks.append(mark)
203
+ if name:
204
+ byline.authors.append((name, marks))
205
+ if not byline.authors:
206
+ for el in root.iter():
207
+ if localname(el.tag) == "creator":
208
+ surname, _, given = _clean("".join(el.itertext())).partition(", ")
209
+ byline.authors.append((" ".join(filter(None, (given, surname))), []))
210
+
211
+ coredata = next((e for e in root.iter() if localname(e.tag) == "coredata"), root)
212
+ first, last = _first_text(coredata, "startingpage"), _first_text(coredata, "endingpage")
213
+ byline.journal = _citation(
214
+ _first_text(coredata, "publicationname"), _first_text(coredata, "coverdate")[:4],
215
+ _first_text(coredata, "volume"), _first_text(coredata, "issueidentifier"),
216
+ _pages(first, last) or _first_text(coredata, "pagerange"))
217
+ return byline
218
+
219
+
220
+ # -- shared -------------------------------------------------------------------
221
+
222
+
223
+ def _citation(journal: str, year: str, volume: str, issue: str, pages: str) -> str:
224
+ """`<i>Journal.</i> 2026;21(11):5256–5260.`, or "" when there is no journal."""
225
+ if not journal:
226
+ return ""
227
+ return _compose(authors=[], etal=False, title="", editors=[], source=journal,
228
+ publisher="", year=year, volume=volume, issue=issue, pages=pages,
229
+ doi="", url="", comment="")
230
+
231
+
232
+ def _pages(first: str, last: str) -> str:
233
+ return f"{first}–{last}" if first and last and first != last else first
234
+
235
+
236
+ def _text_without(element, skip) -> List[str]:
237
+ """Text of `element`, leaving out the subtrees of the named descendants (tails kept)."""
238
+ parts = [element.text or ""]
239
+ for child in element:
240
+ if localname(child.tag) not in skip:
241
+ parts.extend(_text_without(child, skip))
242
+ parts.append(child.tail or "")
243
+ return parts
244
+
245
+
246
+ def _direct(element, name: str) -> str:
247
+ return _first_text(element, name, direct_only=True)
248
+
249
+
250
+ def _child(element, name: str):
251
+ if element is None:
252
+ return None
253
+ return next((c for c in element if localname(c.tag) == name), None)
254
+
255
+
256
+ def _esc(text: Optional[str]) -> str:
257
+ return html_lib.escape(text or "", quote=True)
@@ -403,6 +403,12 @@ def _run(manifest, manifest_path, directory, stem, ids, ctx, http,
403
403
 
404
404
  def finish() -> FiguresSummary:
405
405
  manifest["completed_at"] = _iso(time.time())
406
+ # The directory is made before each download; when every one failed it
407
+ # is left empty, and an empty `_figures/` reads as "figures here".
408
+ try:
409
+ os.rmdir(directory)
410
+ except OSError:
411
+ pass # holds figures, or never made
406
412
  path = write_manifest(manifest_path, manifest, ctx.verbose)
407
413
  refusal = (manifest.get("refusal") or {}).get("kind", "")
408
414
  rows = manifest["figures"]
@@ -48,7 +48,7 @@ SOURCE = "institutional_cookies"
48
48
  # --------------------------------------------------------------------------
49
49
  # Off until the user opts in. Two ways to opt in:
50
50
  #
51
- # * `fetchpdf cookies setup` writes ACCESS_CONFIG (library + everyday browser). From
51
+ # * `fetchpdf fetchcookies` writes ACCESS_CONFIG (library + everyday browser). From
52
52
  # then on every fetchpdf run re-reads the publisher cookies straight from that
53
53
  # browser, once per process, so sessions the user keeps alive by ordinary
54
54
  # browsing are picked up without re-exporting anything.
@@ -69,7 +69,7 @@ _live_resolved = False
69
69
 
70
70
 
71
71
  def load_access_config() -> Optional[dict]:
72
- """The `fetchpdf cookies setup` configuration, or None if access is not set up."""
72
+ """The `fetchpdf fetchcookies` configuration, or None if access is not set up."""
73
73
  try:
74
74
  cfg = json.loads(ACCESS_CONFIG.read_text(encoding="utf-8"))
75
75
  except (OSError, ValueError):
@@ -132,7 +132,7 @@ def cookies_user_managed() -> bool:
132
132
  def active_cookies_file() -> Optional[str]:
133
133
  """The cookie file this process should use, or None when access is off.
134
134
 
135
- An explicit ``--cookies`` wins. Otherwise, if `fetchpdf cookies setup` has been
135
+ An explicit ``--cookies`` wins. Otherwise, if `fetchpdf fetchcookies` has been
136
136
  run, the configured browser's cookies are exported once per process to the
137
137
  config dir and that file is used; if the browser cannot be read (locked
138
138
  store, keyring prompt declined) the last export is used instead.