fetchpdf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fetchpdf/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""T2: publisher and repository HTML full text.
|
|
2
|
+
|
|
3
|
+
HTML sits second, not last. At most OA publishers the article HTML is generated
|
|
4
|
+
from the same JATS the XML endpoint serves, so it carries real <table> with
|
|
5
|
+
<th>, colspan and rowspan -- fidelity close to T1. Ranking it below PDF, as the
|
|
6
|
+
original ladder did, threw away the second-best structured source in favour of
|
|
7
|
+
the one that has to be reconstructed.
|
|
8
|
+
|
|
9
|
+
Two routes here:
|
|
10
|
+
|
|
11
|
+
* Unpaywall oa_locations[]. The `url` field is not always a PDF -- it is
|
|
12
|
+
frequently the publisher's HTML full text, and sometimes XML. Inspecting
|
|
13
|
+
what comes back rather than assuming from the field name is what makes this
|
|
14
|
+
a T2 source at all. publishedVersion is preferred over acceptedVersion, and
|
|
15
|
+
publisher hosts over repositories, because repository copies are more often
|
|
16
|
+
author manuscripts whose tables have been reflowed.
|
|
17
|
+
|
|
18
|
+
* Plain DOI resolution to whatever the publisher serves.
|
|
19
|
+
|
|
20
|
+
A headless-browser retry is available but deliberately narrow: it runs only when
|
|
21
|
+
the static fetch returned a page that has <table> elements with no populated
|
|
22
|
+
cells, which is the signature of tables loaded by JS or hosted in a separate
|
|
23
|
+
table viewer. It reuses the existing --add-playwright flag rather than adding a
|
|
24
|
+
dependency or a new switch.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
|
|
29
|
+
from ..artifact import Artifact
|
|
30
|
+
from ..tiers import Tier
|
|
31
|
+
from ..validate import has_empty_table_containers
|
|
32
|
+
|
|
33
|
+
UNPAYWALL = "https://api.unpaywall.org/v2/{doi}"
|
|
34
|
+
DOI_RESOLVER = "https://doi.org/{doi}"
|
|
35
|
+
|
|
36
|
+
_VERSION_RANK = {"publishedVersion": 0, "acceptedVersion": 1, "submittedVersion": 2}
|
|
37
|
+
_HOST_RANK = {"publisher": 0, "repository": 1}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch_unpaywall_fulltext(ids, ctx) -> Optional[Artifact]:
|
|
41
|
+
"""Walk oa_locations looking for HTML or XML full text, best version first."""
|
|
42
|
+
if not ids.doi:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Shared with the resolver, which reads the same oa_locations[] to harvest an
|
|
46
|
+
# arXiv id. Whichever runs first pays for the request; the other gets it free.
|
|
47
|
+
data = ctx.resolver.unpaywall(ids)
|
|
48
|
+
if not data:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
locations = data.get("oa_locations") or []
|
|
52
|
+
best = data.get("best_oa_location")
|
|
53
|
+
if best and best not in locations:
|
|
54
|
+
locations = [best] + list(locations)
|
|
55
|
+
if not locations:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
for location in sorted(locations, key=_location_rank):
|
|
59
|
+
# url_for_pdf is explicitly a PDF; this source is looking for the
|
|
60
|
+
# landing/full-text URL, which is where the HTML lives.
|
|
61
|
+
url = location.get("url_for_landing_page") or location.get("url")
|
|
62
|
+
if not url or url == location.get("url_for_pdf"):
|
|
63
|
+
continue
|
|
64
|
+
artifact = _fetch_and_wrap(
|
|
65
|
+
ctx, url, ids, source="unpaywall_html",
|
|
66
|
+
license_=location.get("license"),
|
|
67
|
+
extra={
|
|
68
|
+
"version": location.get("version"),
|
|
69
|
+
"host_type": location.get("host_type"),
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
if artifact is not None:
|
|
73
|
+
return artifact
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fetch_publisher_html(ids, ctx) -> Optional[Artifact]:
|
|
78
|
+
"""Whatever the DOI resolves to, if it turns out to be full text with tables."""
|
|
79
|
+
if not ids.doi:
|
|
80
|
+
return None
|
|
81
|
+
return _fetch_and_wrap(
|
|
82
|
+
ctx, DOI_RESOLVER.format(doi=ids.doi), ids,
|
|
83
|
+
source="publisher_html", license_=ids.license,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _location_rank(location: dict):
|
|
88
|
+
return (
|
|
89
|
+
_VERSION_RANK.get(location.get("version"), 3),
|
|
90
|
+
_HOST_RANK.get(location.get("host_type"), 2),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fetch_and_wrap(ctx, url, ids, source, license_=None, extra=None) -> Optional[Artifact]:
|
|
95
|
+
response = ctx.http.get(url, timeout=45, polite=False)
|
|
96
|
+
if not response.ok or not response.content:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
content = response.content
|
|
100
|
+
notes: List[str] = []
|
|
101
|
+
|
|
102
|
+
if has_empty_table_containers(content):
|
|
103
|
+
rendered = _render_with_playwright(ctx, url)
|
|
104
|
+
if rendered:
|
|
105
|
+
content = rendered
|
|
106
|
+
notes.append("re-fetched with headless browser: tables were JS-loaded")
|
|
107
|
+
else:
|
|
108
|
+
notes.append("tables present but unpopulated; no browser retry available")
|
|
109
|
+
|
|
110
|
+
return Artifact(
|
|
111
|
+
content=content,
|
|
112
|
+
tier=Tier.T2_HTML,
|
|
113
|
+
source=source,
|
|
114
|
+
url=response.url,
|
|
115
|
+
http_status=response.status,
|
|
116
|
+
served_content_type=response.content_type,
|
|
117
|
+
identifier_used=ids.doi,
|
|
118
|
+
license=license_ or ids.license,
|
|
119
|
+
extra=dict(extra or {}, notes=notes),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _render_with_playwright(ctx, url: str) -> Optional[bytes]:
|
|
124
|
+
"""Render the page in the browser this package already ships with.
|
|
125
|
+
|
|
126
|
+
Gated on --add-playwright, and only reached when a static fetch produced
|
|
127
|
+
table containers with no cells. Anything broader would put a browser launch
|
|
128
|
+
in the hot path of every record.
|
|
129
|
+
"""
|
|
130
|
+
if not ctx.use_playwright:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
from playwright.sync_api import sync_playwright
|
|
134
|
+
except ImportError:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
from ..._http import USER_AGENT
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
with sync_playwright() as p:
|
|
141
|
+
browser = p.chromium.launch(
|
|
142
|
+
headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"]
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
page = browser.new_context(user_agent=USER_AGENT).new_page()
|
|
146
|
+
page.set_default_timeout(30000)
|
|
147
|
+
page.goto(url, wait_until="networkidle", timeout=45000)
|
|
148
|
+
html = page.content()
|
|
149
|
+
finally:
|
|
150
|
+
browser.close()
|
|
151
|
+
return html.encode("utf-8") if html else None
|
|
152
|
+
except Exception as e:
|
|
153
|
+
ctx.log(f" browser render failed: {str(e)[:100]}")
|
|
154
|
+
return None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""T4: structured supplementary data.
|
|
2
|
+
|
|
3
|
+
Frequently the actual underlying data rather than a rendering of it -- a
|
|
4
|
+
spreadsheet of per-arm outcomes beats any reconstruction of the typeset table.
|
|
5
|
+
Coverage is partial and mapping supplements back to the paper's tables is
|
|
6
|
+
manual, which is why this sits below LaTeX rather than above it.
|
|
7
|
+
|
|
8
|
+
The repository routes here are nearly free: the existing Figshare and Zenodo
|
|
9
|
+
handlers already fetch the complete file listing and then discard everything
|
|
10
|
+
that is not a PDF. Filtering that same listing on mimetype and extension costs
|
|
11
|
+
one predicate.
|
|
12
|
+
|
|
13
|
+
Europe PMC's supplementaryFiles endpoint is the best of the four because it is
|
|
14
|
+
keyed on the PMCID phase 1 already resolved: GET /{PMCID}/supplementaryFiles
|
|
15
|
+
returns 200 application/zip (verified, 132 KB for PMC1817752).
|
|
16
|
+
|
|
17
|
+
The listings themselves now live in ../supplement_index.py, shared with
|
|
18
|
+
--pull-supplementary. The two callers want different subsets and the difference
|
|
19
|
+
is exactly one predicate, applied here and nowhere else: _is_structured. This
|
|
20
|
+
tier takes only what survives to an extraction pass with its rows and columns
|
|
21
|
+
intact, because here a supplement stands *in place of* the paper's full text --
|
|
22
|
+
a supplementary PDF is no better than the PDF it replaced. --pull-supplementary
|
|
23
|
+
takes everything, because there a supplement stands alongside the paper.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import io
|
|
27
|
+
import zipfile
|
|
28
|
+
from typing import List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from ..artifact import Artifact
|
|
31
|
+
from ..supplement_index import (
|
|
32
|
+
EPMC_SUPPL,
|
|
33
|
+
enumerate_figshare,
|
|
34
|
+
enumerate_osf,
|
|
35
|
+
enumerate_zenodo,
|
|
36
|
+
match_id,
|
|
37
|
+
)
|
|
38
|
+
from ..tiers import Tier
|
|
39
|
+
|
|
40
|
+
#: Extensions whose row/column structure survives to the extraction pass.
|
|
41
|
+
STRUCTURED_EXTENSIONS = (".xlsx", ".xls", ".csv", ".tsv", ".docx", ".ods", ".txt")
|
|
42
|
+
STRUCTURED_MIMETYPES = (
|
|
43
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
44
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
45
|
+
"application/vnd.ms-excel",
|
|
46
|
+
"text/csv",
|
|
47
|
+
"text/tab-separated-values",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_MAX_FILES = 25
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_epmc_supplements(ids, ctx) -> Optional[Artifact]:
|
|
54
|
+
"""The EPMC bundle, stored verbatim as one .suppl.zip.
|
|
55
|
+
|
|
56
|
+
Deliberately still its own request rather than going through
|
|
57
|
+
supplement_index: this tier stores the archive exactly as served, so it wants
|
|
58
|
+
the bytes, not a listing, and it wants them under the endpoint's default
|
|
59
|
+
parameters. --pull-supplementary asks the same endpoint with
|
|
60
|
+
includeInlineImage=no, which returns a different (better, for its purpose)
|
|
61
|
+
archive -- 4 members instead of 14 on PMC1817752. Sharing one call between
|
|
62
|
+
the two would mean silently changing what this tier writes.
|
|
63
|
+
"""
|
|
64
|
+
if not ids.pmcid:
|
|
65
|
+
return None
|
|
66
|
+
url = EPMC_SUPPL.format(pmcid=ids.pmcid)
|
|
67
|
+
response = ctx.http.get(url, timeout=60)
|
|
68
|
+
if not response.ok or not response.content:
|
|
69
|
+
ctx.log(f" EPMC supplements: HTTP {response.status}")
|
|
70
|
+
return None
|
|
71
|
+
if not _looks_structured_zip(response.content):
|
|
72
|
+
ctx.log(" EPMC supplements: bundle holds nothing structured")
|
|
73
|
+
return None
|
|
74
|
+
return Artifact(
|
|
75
|
+
content=response.content,
|
|
76
|
+
tier=Tier.T4_SUPPLEMENT,
|
|
77
|
+
source="europepmc_supplements",
|
|
78
|
+
url=response.url,
|
|
79
|
+
http_status=response.status,
|
|
80
|
+
served_content_type=response.content_type,
|
|
81
|
+
identifier_used=ids.pmcid,
|
|
82
|
+
license=ids.license,
|
|
83
|
+
extension=".suppl.zip",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch_figshare_files(ids, ctx) -> Optional[Artifact]:
|
|
88
|
+
article_id = match_id(ids.doi, r"figshare\.(\d+)")
|
|
89
|
+
if not article_id:
|
|
90
|
+
return None
|
|
91
|
+
files = enumerate_figshare(ids, ctx, with_item_type=False)
|
|
92
|
+
return _bundle(ctx, _structured_only(files), ids, "figshare_files", article_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fetch_zenodo_files(ids, ctx) -> Optional[Artifact]:
|
|
96
|
+
record_id = match_id(ids.doi, r"zenodo\.(\d+)")
|
|
97
|
+
if not record_id:
|
|
98
|
+
return None
|
|
99
|
+
return _bundle(ctx, _structured_only(enumerate_zenodo(ids, ctx)), ids,
|
|
100
|
+
"zenodo_files", record_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fetch_osf_files(ids, ctx) -> Optional[Artifact]:
|
|
104
|
+
osf_id = match_id(ids.doi, r"osf\.io/([a-z0-9]+)") or match_id(ids.doi, r"/([a-z0-9]{5})$")
|
|
105
|
+
if not osf_id:
|
|
106
|
+
return None
|
|
107
|
+
return _bundle(ctx, _structured_only(enumerate_osf(ids, ctx)), ids,
|
|
108
|
+
"osf_files", osf_id)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -- shared -----------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _structured_only(files) -> List[Tuple[str, str]]:
|
|
115
|
+
"""The one predicate that separates this tier from --pull-supplementary."""
|
|
116
|
+
return [(f.name, f.url) for f in files if _is_structured(f.name, f.mimetype)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_structured(name: Optional[str], mimetype: Optional[str]) -> bool:
|
|
120
|
+
"""Filter on mimetype/extension rather than assuming PDF."""
|
|
121
|
+
if mimetype and str(mimetype).lower() in STRUCTURED_MIMETYPES:
|
|
122
|
+
return True
|
|
123
|
+
lowered = (name or "").lower()
|
|
124
|
+
return any(lowered.endswith(ext) for ext in STRUCTURED_EXTENSIONS)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _bundle(ctx, candidates, ids, source, identifier) -> Optional[Artifact]:
|
|
128
|
+
"""Download the structured files and return them as one artifact.
|
|
129
|
+
|
|
130
|
+
A single file keeps its own extension so the downstream reader does not have
|
|
131
|
+
to unwrap a one-entry zip; several are bundled so a record's supplements
|
|
132
|
+
stay together and the artifact stays a single addressable thing.
|
|
133
|
+
"""
|
|
134
|
+
candidates = [(n, u) for n, u in candidates if u][:_MAX_FILES]
|
|
135
|
+
if not candidates:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
downloaded = []
|
|
139
|
+
for name, url in candidates:
|
|
140
|
+
response = ctx.http.get(url, timeout=60, polite=False)
|
|
141
|
+
if response.ok and response.content:
|
|
142
|
+
downloaded.append((name or "supplement", response.content))
|
|
143
|
+
if not downloaded:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if len(downloaded) == 1:
|
|
147
|
+
name, content = downloaded[0]
|
|
148
|
+
extension = "." + name.rsplit(".", 1)[-1].lower() if "." in name else ".suppl"
|
|
149
|
+
return Artifact(
|
|
150
|
+
content=content,
|
|
151
|
+
tier=Tier.T4_SUPPLEMENT,
|
|
152
|
+
source=source,
|
|
153
|
+
url=candidates[0][1],
|
|
154
|
+
http_status=200,
|
|
155
|
+
identifier_used=str(identifier),
|
|
156
|
+
license=ids.license,
|
|
157
|
+
extension=extension,
|
|
158
|
+
extra={"files": [name]},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
buffer = io.BytesIO()
|
|
162
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
163
|
+
for name, content in downloaded:
|
|
164
|
+
archive.writestr(name, content)
|
|
165
|
+
return Artifact(
|
|
166
|
+
content=buffer.getvalue(),
|
|
167
|
+
tier=Tier.T4_SUPPLEMENT,
|
|
168
|
+
source=source,
|
|
169
|
+
url=candidates[0][1],
|
|
170
|
+
http_status=200,
|
|
171
|
+
identifier_used=str(identifier),
|
|
172
|
+
license=ids.license,
|
|
173
|
+
extension=".suppl.zip",
|
|
174
|
+
extra={"files": [n for n, _ in downloaded]},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _looks_structured_zip(content: bytes) -> bool:
|
|
179
|
+
try:
|
|
180
|
+
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
|
181
|
+
return any(_is_structured(name, None) for name in archive.namelist())
|
|
182
|
+
except (zipfile.BadZipFile, OSError):
|
|
183
|
+
return False
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Supplementary files from Atypon-hosted journals, via a real browser tab.
|
|
2
|
+
|
|
3
|
+
Europe PMC holds supplements for many non-open-access articles and is not
|
|
4
|
+
permitted to serve them: `supplementaryFiles` answers HTTP 200 wrapping
|
|
5
|
+
`<errorBean>...Article with id PMC... is not open access one</errorBean>`.
|
|
6
|
+
Those files still exist on the publisher's site, and for PNAS and its Atypon
|
|
7
|
+
siblings they are reachable -- but only from something that behaves like a
|
|
8
|
+
person with a browser open.
|
|
9
|
+
|
|
10
|
+
What was measured against 10.1073/pnas.1209746109, all on the same machine and
|
|
11
|
+
network:
|
|
12
|
+
|
|
13
|
+
direct GET of the SI url (curl, guessed name) 403
|
|
14
|
+
Playwright headless, GET the real url 403
|
|
15
|
+
Playwright headless, in-page fetch() with 6 cookies 403
|
|
16
|
+
Playwright headless, persistent profile, click download canceled
|
|
17
|
+
Playwright HEADED (xvfb), click the link 261 KB %PDF <-- works
|
|
18
|
+
same, fresh profile, on an open-access control 341 KB %PDF <-- works
|
|
19
|
+
|
|
20
|
+
Two things are load-bearing and neither is obvious:
|
|
21
|
+
|
|
22
|
+
* **Headed, not headless.** This is the discriminating factor. Cookies, a
|
|
23
|
+
persistent profile, a referer and a browser user-agent were all tried
|
|
24
|
+
headless and all refused. On a headless server that means xvfb.
|
|
25
|
+
* **Click, do not navigate.** A direct navigation to the same URL still 403s
|
|
26
|
+
even when headed. The download has to originate from a user gesture on the
|
|
27
|
+
listing page.
|
|
28
|
+
|
|
29
|
+
The redirect chain explains why: `302 -> ?cookieSet=1 -> 302 ->
|
|
30
|
+
/action/cookieAbsent` is Atypon's cookie handshake behind Cloudflare, and it
|
|
31
|
+
only completes for a client that looks entirely ordinary.
|
|
32
|
+
|
|
33
|
+
This is expensive -- one browser per record -- so it is deliberately the last
|
|
34
|
+
resort, and the caller only reaches it for the handful of records EPMC has
|
|
35
|
+
already said it is withholding.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import tempfile
|
|
42
|
+
from typing import List, Optional
|
|
43
|
+
|
|
44
|
+
from .supplement_index import ROLE_SUPPLEMENT, SupplementFile
|
|
45
|
+
|
|
46
|
+
#: Publishers on the Atypon platform, whose SI lives at /doi/suppl/<doi> and
|
|
47
|
+
#: whose files hang off a[href*="suppl_file"]. PNAS is the case that prompted
|
|
48
|
+
#: this; the others share the platform and the same URL shape.
|
|
49
|
+
_ATYPON_HOSTS = {
|
|
50
|
+
"10.1073": "https://www.pnas.org", # PNAS
|
|
51
|
+
"10.1126": "https://www.science.org", # Science / AAAS
|
|
52
|
+
"10.1146": "https://www.annualreviews.org", # Annual Reviews
|
|
53
|
+
"10.1177": "https://journals.sagepub.com", # SAGE
|
|
54
|
+
"10.1080": "https://www.tandfonline.com", # Taylor & Francis
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_SUPPL_PATH = "{host}/doi/suppl/{doi}"
|
|
58
|
+
|
|
59
|
+
#: Long enough for Cloudflare's handshake plus a real page render. The listing
|
|
60
|
+
#: page took ~5 s to settle in testing; the download itself is fast.
|
|
61
|
+
_PAGE_TIMEOUT_MS = 90000
|
|
62
|
+
_SETTLE_MS = 5000
|
|
63
|
+
_DOWNLOAD_TIMEOUT_MS = 45000
|
|
64
|
+
|
|
65
|
+
#: One browser per record is the whole cost of this provider, so cap how many
|
|
66
|
+
#: files a single record may pull through it.
|
|
67
|
+
_MAX_FILES = 12
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def atypon_host_for(doi: str) -> Optional[str]:
|
|
71
|
+
"""The publisher base URL for a DOI, or None if not an Atypon journal."""
|
|
72
|
+
if not doi:
|
|
73
|
+
return None
|
|
74
|
+
prefix = str(doi).split("/", 1)[0].strip()
|
|
75
|
+
return _ATYPON_HOSTS.get(prefix)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def headed_browser_available() -> bool:
|
|
79
|
+
"""Whether a headed Chromium can actually be launched here.
|
|
80
|
+
|
|
81
|
+
On Linux that needs a display: either a real one ($DISPLAY) or xvfb. On
|
|
82
|
+
Windows and macOS headed mode works directly. Returning False makes the
|
|
83
|
+
caller degrade to its warning rather than raising.
|
|
84
|
+
"""
|
|
85
|
+
if os.name == "nt" or os.sys.platform == "darwin":
|
|
86
|
+
return True
|
|
87
|
+
if os.environ.get("DISPLAY"):
|
|
88
|
+
return True
|
|
89
|
+
return shutil.which("xvfb-run") is not None or shutil.which("Xvfb") is not None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _start_xvfb(ctx):
|
|
93
|
+
"""A virtual X display for headed Chromium, or None.
|
|
94
|
+
|
|
95
|
+
Returns the Popen so the caller can stop it. Uses a high display number to
|
|
96
|
+
avoid colliding with a real session, and verifies the server actually came
|
|
97
|
+
up rather than assuming.
|
|
98
|
+
"""
|
|
99
|
+
import subprocess
|
|
100
|
+
import time as _time
|
|
101
|
+
xvfb = shutil.which("Xvfb")
|
|
102
|
+
if not xvfb:
|
|
103
|
+
return None
|
|
104
|
+
for display in range(99, 110):
|
|
105
|
+
lock = f"/tmp/.X{display}-lock"
|
|
106
|
+
if os.path.exists(lock):
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
proc = subprocess.Popen(
|
|
110
|
+
[xvfb, f":{display}", "-screen", "0", "1400x900x24", "-nolisten", "tcp"],
|
|
111
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
_time.sleep(1.0)
|
|
115
|
+
if proc.poll() is None:
|
|
116
|
+
os.environ["DISPLAY"] = f":{display}"
|
|
117
|
+
return proc
|
|
118
|
+
return None
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _stop_xvfb(proc) -> None:
|
|
123
|
+
if proc is None:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
proc.terminate()
|
|
127
|
+
proc.wait(timeout=5)
|
|
128
|
+
except Exception:
|
|
129
|
+
try:
|
|
130
|
+
proc.kill()
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
os.environ.pop("DISPLAY", None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def fetch_atypon_supplements(ids, ctx) -> List[SupplementFile]:
|
|
137
|
+
"""Download SI from an Atypon publisher into a temp dir, as local files.
|
|
138
|
+
|
|
139
|
+
Unlike every other enumerator this cannot return remote URLs for the
|
|
140
|
+
engine to fetch: the URLs only work from inside the browser session that
|
|
141
|
+
clicked them. So the files are downloaded here and handed back as
|
|
142
|
+
`file://` entries the normal pipeline can take.
|
|
143
|
+
"""
|
|
144
|
+
doi = getattr(ids, "doi", None)
|
|
145
|
+
host = atypon_host_for(doi)
|
|
146
|
+
if not host:
|
|
147
|
+
return []
|
|
148
|
+
if not headed_browser_available():
|
|
149
|
+
ctx.log(" atypon SI: no display and no xvfb; skipping headed fetch")
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
from playwright.sync_api import sync_playwright
|
|
154
|
+
except ImportError:
|
|
155
|
+
ctx.log(" atypon SI: playwright not installed")
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
# Headed Chromium needs a display. On a headless Linux box there is none,
|
|
159
|
+
# so start a virtual one for the duration -- Playwright reads $DISPLAY at
|
|
160
|
+
# launch. Windows/macOS need nothing.
|
|
161
|
+
virtual_display = None
|
|
162
|
+
if os.name != "nt" and os.sys.platform != "darwin" \
|
|
163
|
+
and not os.environ.get("DISPLAY"):
|
|
164
|
+
virtual_display = _start_xvfb(ctx)
|
|
165
|
+
if virtual_display is None:
|
|
166
|
+
ctx.log(" atypon SI: could not start a virtual display")
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
listing = _SUPPL_PATH.format(host=host, doi=doi)
|
|
170
|
+
scratch = tempfile.mkdtemp(prefix=".fetchpdf-atypon-")
|
|
171
|
+
files: List[SupplementFile] = []
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
with sync_playwright() as p:
|
|
175
|
+
context = p.chromium.launch_persistent_context(
|
|
176
|
+
os.path.join(scratch, "profile"),
|
|
177
|
+
headless=False, # load-bearing; see module docstring
|
|
178
|
+
accept_downloads=True,
|
|
179
|
+
args=["--no-sandbox", "--disable-setuid-sandbox",
|
|
180
|
+
"--disable-blink-features=AutomationControlled"],
|
|
181
|
+
viewport={"width": 1400, "height": 900},
|
|
182
|
+
user_agent=("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
183
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"),
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
187
|
+
page.add_init_script(
|
|
188
|
+
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined});")
|
|
189
|
+
response = page.goto(listing, wait_until="domcontentloaded",
|
|
190
|
+
timeout=_PAGE_TIMEOUT_MS)
|
|
191
|
+
status = response.status if response else None
|
|
192
|
+
if status != 200:
|
|
193
|
+
ctx.log(f" atypon SI: listing page HTTP {status}")
|
|
194
|
+
return []
|
|
195
|
+
page.wait_for_timeout(_SETTLE_MS)
|
|
196
|
+
|
|
197
|
+
links = page.eval_on_selector_all(
|
|
198
|
+
'a[href*="suppl_file"]', "els => els.map(e => e.href)")
|
|
199
|
+
if not links:
|
|
200
|
+
ctx.log(" atypon SI: no suppl_file links on the listing page")
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
for index, href in enumerate(dict.fromkeys(links)):
|
|
204
|
+
if index >= _MAX_FILES:
|
|
205
|
+
ctx.log(f" atypon SI: stopping at {_MAX_FILES} files")
|
|
206
|
+
break
|
|
207
|
+
name = href.rsplit("/", 1)[-1].split("?")[0] or f"si_{index+1}"
|
|
208
|
+
try:
|
|
209
|
+
# A click, not a navigation -- see the module docstring.
|
|
210
|
+
# Selected by nth match rather than by full href: the
|
|
211
|
+
# DOM href is often relative while eval returns it
|
|
212
|
+
# absolute, so an [href="<absolute>"] selector matches
|
|
213
|
+
# nothing and times out.
|
|
214
|
+
with page.expect_download(
|
|
215
|
+
timeout=_DOWNLOAD_TIMEOUT_MS) as download:
|
|
216
|
+
page.locator('a[href*="suppl_file"]').nth(index).click(
|
|
217
|
+
timeout=20000)
|
|
218
|
+
saved = os.path.join(scratch, name)
|
|
219
|
+
download.value.save_as(saved)
|
|
220
|
+
except Exception as error:
|
|
221
|
+
ctx.log(f" atypon SI: {name}: "
|
|
222
|
+
f"{str(error).splitlines()[0][:80]}")
|
|
223
|
+
continue
|
|
224
|
+
if not os.path.exists(saved) or os.path.getsize(saved) == 0:
|
|
225
|
+
continue
|
|
226
|
+
files.append(SupplementFile(
|
|
227
|
+
name=name,
|
|
228
|
+
url="file://" + saved,
|
|
229
|
+
provider="atypon_suppl",
|
|
230
|
+
listing_index=index,
|
|
231
|
+
size_bytes=os.path.getsize(saved),
|
|
232
|
+
role=ROLE_SUPPLEMENT,
|
|
233
|
+
origin_doi=doi,
|
|
234
|
+
extra={"listing_url": listing, "local": saved,
|
|
235
|
+
"scratch_dir": scratch},
|
|
236
|
+
))
|
|
237
|
+
if files:
|
|
238
|
+
ctx.log(f" atypon SI: {len(files)} file(s) via headed browser")
|
|
239
|
+
finally:
|
|
240
|
+
try:
|
|
241
|
+
context.close()
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
except Exception as error:
|
|
245
|
+
ctx.log(f" atypon SI: {str(error).splitlines()[0][:100]}")
|
|
246
|
+
finally:
|
|
247
|
+
_stop_xvfb(virtual_display)
|
|
248
|
+
|
|
249
|
+
if not files:
|
|
250
|
+
# Nothing to keep the scratch dir for.
|
|
251
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
252
|
+
else:
|
|
253
|
+
# The browser profile is large and is dead weight once the files are
|
|
254
|
+
# downloaded; the files themselves must survive until the pipeline has
|
|
255
|
+
# copied them out, so only the profile goes now.
|
|
256
|
+
shutil.rmtree(os.path.join(scratch, "profile"), ignore_errors=True)
|
|
257
|
+
ctx.scratch.setdefault("_atypon_scratch_dirs", []).append(scratch)
|
|
258
|
+
return files
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cleanup_scratch(ctx) -> None:
|
|
262
|
+
"""Remove the temp dirs this provider downloaded into.
|
|
263
|
+
|
|
264
|
+
Called once the pipeline has copied the files to their real homes. Kept
|
|
265
|
+
separate from fetch because the `file://` entries must stay readable until
|
|
266
|
+
then -- deleting on the way out would race the download.
|
|
267
|
+
"""
|
|
268
|
+
for path in ctx.scratch.pop("_atypon_scratch_dirs", []) or []:
|
|
269
|
+
shutil.rmtree(path, ignore_errors=True)
|