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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. 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)