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,69 @@
|
|
|
1
|
+
"""What a source is handed when the engine calls it.
|
|
2
|
+
|
|
3
|
+
Its own module so `sources.*` can depend on it without importing the engine,
|
|
4
|
+
which imports the sources back through the ladder config.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .tiers import Ladder, Tier
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RetrievalContext:
|
|
15
|
+
"""Per-record retrieval state shared with every source."""
|
|
16
|
+
|
|
17
|
+
http: object # http.HttpClient
|
|
18
|
+
resolver: object # resolve.BatchResolver
|
|
19
|
+
ladder: Ladder
|
|
20
|
+
save_path: str # the .pdf path the caller asked for
|
|
21
|
+
target_task: str = "extraction"
|
|
22
|
+
verbose: bool = False
|
|
23
|
+
use_playwright: bool = False
|
|
24
|
+
email: Optional[str] = None
|
|
25
|
+
delay: float = 0.1
|
|
26
|
+
provenance: Optional[object] = None # provenance.ProvenanceRecord or None
|
|
27
|
+
|
|
28
|
+
#: --download-data-artifacts. Off, the linked-artifact providers route only
|
|
29
|
+
#: the paper's own material (CLASS_OWNED) and record everything else in the
|
|
30
|
+
#: sidecar without fetching it. On, they also route `related` deposits --
|
|
31
|
+
#: which is where the actual research data lives: in one 44-sidecar corpus
|
|
32
|
+
#: every `owned` link was a BioStudies mirror of supplementary files already
|
|
33
|
+
#: fetched, while the Zenodo and Dataverse replication packages were all
|
|
34
|
+
#: classified `related`.
|
|
35
|
+
download_data_artifacts: bool = False
|
|
36
|
+
|
|
37
|
+
#: Per-record ceiling for those artifacts, kept separate from the
|
|
38
|
+
#: supplementary budget so one large dataset cannot starve the paper's own
|
|
39
|
+
#: SI (or the reverse). None means the default in supplementary.py.
|
|
40
|
+
max_data_artifact_bytes: Optional[int] = None
|
|
41
|
+
|
|
42
|
+
#: --download-related-unverified. Off, a `related` deposit is skipped when
|
|
43
|
+
#: its own DataCite record declares it belongs to a DIFFERENT article. On,
|
|
44
|
+
#: every index assertion is taken at face value -- which is how "Tying
|
|
45
|
+
#: Odysseus to the Mast" ended up filed under a savings-reminder megastudy.
|
|
46
|
+
download_related_unverified: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: Set once a T1 artifact has been accepted for this record. NCBI efetch and
|
|
51
|
+
#: Europe PMC serve the same underlying PMC JATS, so storing both is not
|
|
52
|
+
#: corroboration -- it manufactures false agreement downstream.
|
|
53
|
+
pmc_xml_already_taken: bool = False
|
|
54
|
+
|
|
55
|
+
#: Sources may leave breadcrumbs for later sources in the same walk.
|
|
56
|
+
scratch: dict = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def log(self, message: str) -> None:
|
|
59
|
+
if self.verbose:
|
|
60
|
+
print(message)
|
|
61
|
+
|
|
62
|
+
def threshold(self, key: str, default: int) -> int:
|
|
63
|
+
return self.ladder.threshold(key, default)
|
|
64
|
+
|
|
65
|
+
def is_extraction(self) -> bool:
|
|
66
|
+
return self.target_task == "extraction"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["RetrievalContext", "Tier"]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Who to ask when the file cannot be fetched.
|
|
2
|
+
|
|
3
|
+
For a paywalled paper or a withheld supplement the corresponding author is
|
|
4
|
+
often the only remaining route -- no API has the file and no browser gets past
|
|
5
|
+
the publisher. Their address is not in any metadata API, but it is nearly
|
|
6
|
+
always in the artifacts already on disk.
|
|
7
|
+
|
|
8
|
+
Measured over 43 papers in a real corpus:
|
|
9
|
+
|
|
10
|
+
Crossref author[].email 0 of 3 probed -- not populated in practice
|
|
11
|
+
JATS <corresp><email> structural, exact, but only 7 records have XML
|
|
12
|
+
HTML several shapes highest precision when present (see below)
|
|
13
|
+
PDF first two pages 26 of 31 PDF-only records
|
|
14
|
+
|
|
15
|
+
Combined, ~79% of records lacking material yield an address. Everything here
|
|
16
|
+
reads files already downloaded, so discovery costs no network requests.
|
|
17
|
+
|
|
18
|
+
Precision, not recall, is the thing to protect: a wrong address emails a
|
|
19
|
+
stranger in the user's name. The ordering below is by how *explicitly* each
|
|
20
|
+
source states the corresponding role --
|
|
21
|
+
|
|
22
|
+
JATS @corresp="yes" the document says so in markup
|
|
23
|
+
HTML "Corresponding Author" + address the page says so in markup
|
|
24
|
+
PDF prose near a cue, scored we are inferring
|
|
25
|
+
|
|
26
|
+
-- and the scored PDF heuristic scores 97% (35/36 checkable picks) with 86%
|
|
27
|
+
coverage. The single error was a Nature *news* page reprinting a different
|
|
28
|
+
article's author block, which is why callers must skip records already
|
|
29
|
+
classified as news/editorial.
|
|
30
|
+
|
|
31
|
+
One HTML shape deserves its own note. Wiley renders the address as the literal
|
|
32
|
+
text "[email protected]" -- Cloudflare email protection -- so a plain regex
|
|
33
|
+
finds nothing on a page that does in fact carry the address. The real value is
|
|
34
|
+
in `data-cfemail`, hex, XOR-ed with its own first byte. Verified:
|
|
35
|
+
|
|
36
|
+
data-cfemail="244a49455e45566446510a414051" -> nmazar@bu.edu
|
|
37
|
+
|
|
38
|
+
which matches the same paper's PDF exactly.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import html as _html
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from typing import List, Optional
|
|
46
|
+
|
|
47
|
+
#: How the address was found, most trustworthy first. Recorded on the Contact
|
|
48
|
+
#: so a draft can show its provenance and a human can judge a stale address.
|
|
49
|
+
SOURCE_JATS = "jats"
|
|
50
|
+
SOURCE_HTML = "html"
|
|
51
|
+
SOURCE_PDF = "pdf"
|
|
52
|
+
|
|
53
|
+
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
54
|
+
|
|
55
|
+
#: Phrases that mark the following (or nearby) address as the corresponding
|
|
56
|
+
#: author's. Taken from the shapes actually observed across the corpus, not
|
|
57
|
+
#: guessed: "Corresponding author:", "E-mail addresses:", "Electronic mail may
|
|
58
|
+
#: be sent to", "Address correspondence to".
|
|
59
|
+
_CUE_RE = re.compile(
|
|
60
|
+
r"(correspond\w*|e-?mail address(es)?|electronic mail may be sent"
|
|
61
|
+
r"|address correspondence|to whom correspondence|e-?mail:)",
|
|
62
|
+
re.I,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
#: Addresses that are never a person we should write to.
|
|
66
|
+
_JUNK_RE = re.compile(
|
|
67
|
+
r"(^|[.@])(editor|permissions?|reprints?|support|info|help|noreply|no-reply"
|
|
68
|
+
r"|subscription|customerservice|webmaster|admin)([.@]|$)"
|
|
69
|
+
r"|@(wiley|elsevier|springernature|sciencedirect|tandf|sagepub)\.com$",
|
|
70
|
+
re.I,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: How far back to look for a cue. One sentence, roughly -- far enough to cross
|
|
74
|
+
#: "Corresponding author. E-mail address:" and its markup, short enough not to
|
|
75
|
+
#: reach the previous author's block.
|
|
76
|
+
_CUE_WINDOW = 160
|
|
77
|
+
|
|
78
|
+
#: Author blocks live at the top. Reading two pages bounds the cost and avoids
|
|
79
|
+
#: the reference list, where other people's addresses appear.
|
|
80
|
+
_PDF_PAGES = 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Contact:
|
|
85
|
+
"""One corresponding-author address, and where it came from."""
|
|
86
|
+
|
|
87
|
+
email: str
|
|
88
|
+
source: str # SOURCE_JATS | SOURCE_HTML | SOURCE_PDF
|
|
89
|
+
name: Optional[str] = None
|
|
90
|
+
cue: str = "" # the phrase that justified the pick
|
|
91
|
+
paper_doi: Optional[str] = None
|
|
92
|
+
paper_year: Optional[int] = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def confidence(self) -> str:
|
|
96
|
+
"""Coarse, and honest about what each source proves."""
|
|
97
|
+
if self.source == SOURCE_JATS:
|
|
98
|
+
return "high"
|
|
99
|
+
if self.source == SOURCE_HTML:
|
|
100
|
+
return "high" if self.cue else "medium"
|
|
101
|
+
return "medium" if self.cue else "low"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def provenance(self) -> str:
|
|
105
|
+
where = {SOURCE_JATS: "JATS <corresp>", SOURCE_HTML: "article HTML",
|
|
106
|
+
SOURCE_PDF: "PDF first page"}.get(self.source, self.source)
|
|
107
|
+
bits = [where]
|
|
108
|
+
if self.paper_doi:
|
|
109
|
+
bits.append(f"of {self.paper_doi}")
|
|
110
|
+
if self.paper_year:
|
|
111
|
+
bits.append(f"({self.paper_year})")
|
|
112
|
+
if self.cue:
|
|
113
|
+
bits.append(f'cue "{self.cue}"')
|
|
114
|
+
return " ".join(bits)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _clean(email: str) -> str:
|
|
118
|
+
return email.strip().rstrip(".,;:)>\"'").lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _acceptable(email: str) -> bool:
|
|
122
|
+
return bool(email) and not _JUNK_RE.search(email)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- Cloudflare email protection ---------------------------------------------
|
|
126
|
+
|
|
127
|
+
def decode_cfemail(hex_blob: str) -> Optional[str]:
|
|
128
|
+
"""Undo Cloudflare's data-cfemail obfuscation.
|
|
129
|
+
|
|
130
|
+
First byte is the XOR key; the rest is the address. Without this, every
|
|
131
|
+
Cloudflare-protected publisher page looks like it has no address at all --
|
|
132
|
+
the DOM shows only the literal string "[email protected]".
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
raw = bytes.fromhex(hex_blob.strip())
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
if len(raw) < 2:
|
|
139
|
+
return None
|
|
140
|
+
key = raw[0]
|
|
141
|
+
try:
|
|
142
|
+
decoded = "".join(chr(byte ^ key) for byte in raw[1:])
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
return decoded if _EMAIL_RE.fullmatch(decoded) else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# -- JATS ---------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
_JATS_CORRESP_RE = re.compile(
|
|
151
|
+
r"<corresp\b[^>]*>(.*?)</corresp>", re.S | re.I)
|
|
152
|
+
_JATS_EMAIL_RE = re.compile(r"<email[^>]*>([^<]+)</email>", re.I)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def from_jats(text: str) -> Optional[Contact]:
|
|
156
|
+
"""The address inside a <corresp> block -- the document's own statement."""
|
|
157
|
+
if not text:
|
|
158
|
+
return None
|
|
159
|
+
for block in _JATS_CORRESP_RE.findall(text):
|
|
160
|
+
found = _JATS_EMAIL_RE.search(block)
|
|
161
|
+
if found:
|
|
162
|
+
email = _clean(found.group(1))
|
|
163
|
+
if _acceptable(email):
|
|
164
|
+
return Contact(email=email, source=SOURCE_JATS,
|
|
165
|
+
name=_name_in_corresp(block), cue="corresp")
|
|
166
|
+
# Some publishers put the email outside <corresp> but mark the author
|
|
167
|
+
# @corresp="yes"; fall back to the first <email> in the front matter.
|
|
168
|
+
head = text[:_front_matter_end(text)]
|
|
169
|
+
found = _JATS_EMAIL_RE.search(head)
|
|
170
|
+
if found:
|
|
171
|
+
email = _clean(found.group(1))
|
|
172
|
+
if _acceptable(email):
|
|
173
|
+
return Contact(email=email, source=SOURCE_JATS)
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
#: "should be addressed to: Mara Mather, 3715 McClintock Ave., ..." -- the
|
|
178
|
+
#: name is the first comma-delimited fragment after the phrase. Only accepted
|
|
179
|
+
#: when it looks like a personal name (2-4 capitalised words), so an address
|
|
180
|
+
#: fragment or a department never becomes a salutation.
|
|
181
|
+
_CORRESP_NAME_RE = re.compile(
|
|
182
|
+
r"(?:addressed to|correspondence to|author)[:\s]+"
|
|
183
|
+
r"([A-Z][A-Za-z.'\u2019-]+(?:\s+[A-Z][A-Za-z.'\u2019-]+){1,3})(?=[,.]|\s+at\b)")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _name_in_corresp(block: str) -> Optional[str]:
|
|
187
|
+
"""The addressee named inside a <corresp> block, if it states one.
|
|
188
|
+
|
|
189
|
+
Better than "Dear Dr. [name]" in a draft the user has to edit, and safe to
|
|
190
|
+
skip when absent -- a wrong name is worse than a placeholder.
|
|
191
|
+
"""
|
|
192
|
+
plain = " ".join(_TAG_RE.sub(" ", block or "").split())
|
|
193
|
+
found = _CORRESP_NAME_RE.search(plain)
|
|
194
|
+
return found.group(1).strip() if found else None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _front_matter_end(text: str) -> int:
|
|
198
|
+
"""Where the article body starts, so we never read the reference list."""
|
|
199
|
+
for marker in ("</front>", "<body", "<ref-list"):
|
|
200
|
+
index = text.find(marker)
|
|
201
|
+
if index > 0:
|
|
202
|
+
return index
|
|
203
|
+
return min(len(text), 20000)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# -- HTML ---------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
209
|
+
_SCRIPT_RE = re.compile(r"<script.*?</script>|<style.*?</style>", re.S | re.I)
|
|
210
|
+
_CFEMAIL_RE = re.compile(r'data-cfemail="([a-fA-F0-9]+)"')
|
|
211
|
+
_MAILTO_RE = re.compile(r'href="mailto:([^"?]+)', re.I)
|
|
212
|
+
_META_EMAIL_RE = re.compile(
|
|
213
|
+
r'<meta[^>]+name="citation_author_email"[^>]+content="([^"]+)"', re.I)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def from_html(text: str) -> Optional[Contact]:
|
|
217
|
+
"""Address from an article page, in descending order of explicitness."""
|
|
218
|
+
if not text:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# 1. An explicit mailto is unambiguous.
|
|
222
|
+
for match in _MAILTO_RE.finditer(text):
|
|
223
|
+
email = _clean(match.group(1))
|
|
224
|
+
if _acceptable(email):
|
|
225
|
+
near = _TAG_RE.sub(" ", text[max(0, match.start() - 300):match.start()])
|
|
226
|
+
cue = _CUE_RE.search(near)
|
|
227
|
+
return Contact(email=email, source=SOURCE_HTML,
|
|
228
|
+
cue=cue.group(0) if cue else "")
|
|
229
|
+
|
|
230
|
+
# 2. Cloudflare-obfuscated. Prefer one whose surrounding text says
|
|
231
|
+
# "Corresponding Author" -- Wiley marks the role in the DOM even while
|
|
232
|
+
# hiding the address.
|
|
233
|
+
best = None
|
|
234
|
+
for match in _CFEMAIL_RE.finditer(text):
|
|
235
|
+
email = decode_cfemail(match.group(1))
|
|
236
|
+
if not email or not _acceptable(_clean(email)):
|
|
237
|
+
continue
|
|
238
|
+
near = _TAG_RE.sub(" ", text[max(0, match.start() - 400):match.start()])
|
|
239
|
+
cue = _CUE_RE.search(near)
|
|
240
|
+
candidate = Contact(email=_clean(email), source=SOURCE_HTML,
|
|
241
|
+
cue=cue.group(0) if cue else "")
|
|
242
|
+
if cue:
|
|
243
|
+
return candidate
|
|
244
|
+
best = best or candidate
|
|
245
|
+
if best:
|
|
246
|
+
return best
|
|
247
|
+
|
|
248
|
+
# 3. Publisher metadata, when emitted.
|
|
249
|
+
found = _META_EMAIL_RE.search(text)
|
|
250
|
+
if found:
|
|
251
|
+
email = _clean(found.group(1))
|
|
252
|
+
if _acceptable(email):
|
|
253
|
+
return Contact(email=email, source=SOURCE_HTML, cue="citation_author_email")
|
|
254
|
+
|
|
255
|
+
# 4. A bare address in the visible text, scored like the PDF case.
|
|
256
|
+
visible = _html.unescape(_TAG_RE.sub(" ", _SCRIPT_RE.sub(" ", text)))
|
|
257
|
+
return _scored_pick(visible, SOURCE_HTML)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# -- PDF ----------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def from_pdf(path: str) -> Optional[Contact]:
|
|
263
|
+
"""Address from the first pages of the PDF.
|
|
264
|
+
|
|
265
|
+
pypdf rather than a pdftotext subprocess: pure Python, so this works on
|
|
266
|
+
Windows and needs no external binary.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
from pypdf import PdfReader
|
|
270
|
+
except ImportError:
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
reader = PdfReader(path)
|
|
274
|
+
pages = reader.pages[:_PDF_PAGES]
|
|
275
|
+
text = "\n".join((page.extract_text() or "") for page in pages)
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
return _scored_pick(text, SOURCE_PDF)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _scored_pick(text: str, source: str) -> Optional[Contact]:
|
|
282
|
+
"""The most likely corresponding address in a block of prose.
|
|
283
|
+
|
|
284
|
+
14 of 43 corpus PDFs carry more than one address, so first-match is wrong.
|
|
285
|
+
Score: +2 for a corresponding-author cue just before the match, +1 for
|
|
286
|
+
sitting in the first half of the text (author blocks precede everything
|
|
287
|
+
else). Ties break toward the earlier match.
|
|
288
|
+
"""
|
|
289
|
+
if not text:
|
|
290
|
+
return None
|
|
291
|
+
best_score = -1
|
|
292
|
+
best: Optional[Contact] = None
|
|
293
|
+
midpoint = max(1, len(text) // 2)
|
|
294
|
+
for match in _EMAIL_RE.finditer(text):
|
|
295
|
+
email = _clean(match.group(0))
|
|
296
|
+
if not _acceptable(email):
|
|
297
|
+
continue
|
|
298
|
+
before = text[max(0, match.start() - _CUE_WINDOW):match.start()]
|
|
299
|
+
cue = _CUE_RE.search(before)
|
|
300
|
+
score = (2 if cue else 0) + (1 if match.start() < midpoint else 0)
|
|
301
|
+
if score > best_score:
|
|
302
|
+
best_score = score
|
|
303
|
+
best = Contact(email=email, source=source,
|
|
304
|
+
cue=cue.group(0) if cue else "")
|
|
305
|
+
return best
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# -- the record-level entry point ---------------------------------------------
|
|
309
|
+
|
|
310
|
+
#: Extensions searched, in the precision order argued for in the docstring.
|
|
311
|
+
_MARKUP = ((".xml", from_jats), (".nxml", from_jats),
|
|
312
|
+
(".fulltext.html", from_html), (".html", from_html),
|
|
313
|
+
(".htm", from_html))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def find_corresponding_author(stem: str, doi: Optional[str] = None,
|
|
317
|
+
year: Optional[int] = None) -> Optional[Contact]:
|
|
318
|
+
"""Best available address for one record, from artifacts already on disk.
|
|
319
|
+
|
|
320
|
+
`stem` is the output path stem, matching how the rest of the supplementary
|
|
321
|
+
subsystem addresses a record. Returns None when nothing usable is found --
|
|
322
|
+
the caller lists those separately rather than guessing.
|
|
323
|
+
"""
|
|
324
|
+
if not stem:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
for extension, extract in _MARKUP:
|
|
328
|
+
path = stem + extension
|
|
329
|
+
if not os.path.exists(path):
|
|
330
|
+
continue
|
|
331
|
+
try:
|
|
332
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
333
|
+
text = handle.read()
|
|
334
|
+
except OSError:
|
|
335
|
+
continue
|
|
336
|
+
contact = extract(text)
|
|
337
|
+
if contact:
|
|
338
|
+
contact.paper_doi = contact.paper_doi or doi
|
|
339
|
+
contact.paper_year = contact.paper_year or year
|
|
340
|
+
return contact
|
|
341
|
+
|
|
342
|
+
pdf_path = stem + ".pdf"
|
|
343
|
+
if os.path.exists(pdf_path):
|
|
344
|
+
contact = from_pdf(pdf_path)
|
|
345
|
+
if contact:
|
|
346
|
+
contact.paper_doi = contact.paper_doi or doi
|
|
347
|
+
contact.paper_year = contact.paper_year or year
|
|
348
|
+
return contact
|
|
349
|
+
return None
|