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,533 @@
|
|
|
1
|
+
"""Is this PDF the paper we asked for?
|
|
2
|
+
|
|
3
|
+
T2 has had an identity check since the beginning: `validate._citation_doi_mismatch`
|
|
4
|
+
refuses a page that declares a different article. T5 had `%PDF` and a 1 KiB
|
|
5
|
+
floor, which is not an identity check -- it is a file-type check wearing one.
|
|
6
|
+
|
|
7
|
+
10.1111/all.14949 is what that cost. Unpaywall's only OA copy for that record
|
|
8
|
+
is a landing page, so the chain scraped it; the article's own PDF was behind a
|
|
9
|
+
bot wall, the next candidate 404ed, and the third was a genuine
|
|
10
|
+
`application/pdf` -- the USDA's 164-page *Dietary Guidelines for Americans*,
|
|
11
|
+
which the paper CITES. It was written out as an 11-page Wiley allergy paper,
|
|
12
|
+
alongside the correct XML for that same record, fetched in the same walk,
|
|
13
|
+
without either being compared to the other.
|
|
14
|
+
|
|
15
|
+
The rule this module enforces is one the codebase already states twice, and had
|
|
16
|
+
never applied to a PDF:
|
|
17
|
+
|
|
18
|
+
ONLY this paper's own material. A dataset the paper CITES belongs to
|
|
19
|
+
somebody else and must not be collected.
|
|
20
|
+
-- llm_agent_retrieval, on datasets
|
|
21
|
+
|
|
22
|
+
a later, less trustworthy source cannot quietly redirect retrieval to a
|
|
23
|
+
different paper.
|
|
24
|
+
-- IdentifierSet.learn, on identifiers
|
|
25
|
+
|
|
26
|
+
Only VERIFIED accepts. The other states all reject, but they are kept distinct
|
|
27
|
+
because the *reason* has to be honest: "this is a different paper" is a bug
|
|
28
|
+
signal, "this document has no text layer" is a corpus-quality signal, and "no
|
|
29
|
+
PDF reader is installed" is a broken install. Collapsing them into one refusal
|
|
30
|
+
would hide all three behind whichever is most common.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from typing import List, Optional
|
|
35
|
+
|
|
36
|
+
from ._util import matched_fraction, normalise_doi, squash
|
|
37
|
+
from .pdf_text import (
|
|
38
|
+
engine_status,
|
|
39
|
+
pdf_metadata,
|
|
40
|
+
pdf_page_count,
|
|
41
|
+
pdf_text,
|
|
42
|
+
pdf_text_from_bytes,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
#: Verdict states. Only the first accepts.
|
|
46
|
+
VERIFIED = "verified" # positive evidence this is the requested paper
|
|
47
|
+
WRONG = "wrong_article" # positive evidence it is a DIFFERENT paper
|
|
48
|
+
UNREADABLE = "unreadable" # engine present, no usable text layer
|
|
49
|
+
NO_REFERENCE = "no_reference" # nothing to compare against -- no DOI hit, no title
|
|
50
|
+
NO_ENGINE = "no_engine" # no PDF reader; the CLI refuses to start in this state
|
|
51
|
+
TRUNCATED = "truncated" # this article, but only a fragment of it
|
|
52
|
+
|
|
53
|
+
#: Pages read from the front of the document. Capped in both directions: enough
|
|
54
|
+
#: to clear a cover sheet, few enough that a 164-page report's own bibliography
|
|
55
|
+
#: cannot supply the requested DOI and fake a match. (The Dietary Guidelines PDF
|
|
56
|
+
#: does not cite this paper, but the general shape -- a long document that
|
|
57
|
+
#: happens to contain the DOI somewhere -- is exactly what an uncapped read
|
|
58
|
+
#: would fall for.)
|
|
59
|
+
DOI_PAGES = 3
|
|
60
|
+
TITLE_PAGES = 2
|
|
61
|
+
|
|
62
|
+
#: How much of the title must survive on the page, summed across matching runs
|
|
63
|
+
#: of at least a few characters -- deliberately gap-tolerant, so one substituted
|
|
64
|
+
#: character (a Greek letter typeset in a Latin face) cannot halve the score.
|
|
65
|
+
#: Measured on the record that motivated this module: the real title, broken
|
|
66
|
+
#: across three lines and hyphenated at the break, scores 1.00 against page 1;
|
|
67
|
+
#: the USDA report's title scores 0.11. Nothing lands near 0.85 by accident.
|
|
68
|
+
TITLE_BLOCK_MIN = 0.85
|
|
69
|
+
|
|
70
|
+
#: A PDF shorter than this fraction of the pages the record spans is a preview
|
|
71
|
+
#: or a supplement, not the article -- even though it carries the article's own
|
|
72
|
+
#: DOI and title, which is exactly why the identity signals pass it.
|
|
73
|
+
#:
|
|
74
|
+
#: Measured over 456 verified-correct inbox PDFs with a usable page range: the
|
|
75
|
+
#: ratios run 0.22, 0.31, 0.33, 0.33, 0.42 and then jump to 0.73, so 0.5 sits in
|
|
76
|
+
#: open space rather than on a slope. All five below it were genuinely not the
|
|
77
|
+
#: article -- two publisher first-page previews cut off mid-sentence, two figure
|
|
78
|
+
#: supplements, and an open-practices disclosure form ending in "Signature:".
|
|
79
|
+
TRUNCATION_RATIO = 0.5
|
|
80
|
+
|
|
81
|
+
#: A PDF holding less than this share of the text the SAME record's structured
|
|
82
|
+
#: full text holds is a fragment of it. Independent of page metadata entirely,
|
|
83
|
+
#: which is what makes it the better of the two truncation signals when both
|
|
84
|
+
#: apply. Measured on records where a PDF and a structured copy were both
|
|
85
|
+
#: retrieved: complete PDFs scored 0.98, 1.02, 1.04, and a two-page Brill
|
|
86
|
+
#: preview of a thirty-two-page article scored 0.07.
|
|
87
|
+
FRAGMENT_TEXT_RATIO = 0.5
|
|
88
|
+
|
|
89
|
+
#: Below this many expected pages the ratio is too noisy to act on: a 3-page
|
|
90
|
+
#: record delivered as 1 page may be an abstract-only DOI, a letter, or correct.
|
|
91
|
+
TRUNCATION_MIN_PAGES = 4
|
|
92
|
+
|
|
93
|
+
#: A title match too weak to stand alone, but not nothing. Combined with an
|
|
94
|
+
#: independent structural signal -- the page count -- it is enough. Both halves
|
|
95
|
+
#: are required precisely because either alone is unsafe: measured across 47
|
|
96
|
+
#: hand-confirmed wrong files from two corpora, the highest partial title match
|
|
97
|
+
#: was 0.62 and the highest that ALSO matched on page count was 0.47.
|
|
98
|
+
TITLE_BLOCK_PARTIAL = 0.55
|
|
99
|
+
|
|
100
|
+
#: Below this, the document's *embedded* title is not merely unmatched -- it is
|
|
101
|
+
#: affirmatively about something else, which is what lets us say WRONG rather
|
|
102
|
+
#: than "could not tell".
|
|
103
|
+
TITLE_BLOCK_FOREIGN = 0.30
|
|
104
|
+
|
|
105
|
+
#: A front-matter read is short by nature, so the whole-document floor in
|
|
106
|
+
#: pdf_text would misreport a legitimately brief title page as "no text layer".
|
|
107
|
+
MIN_FRONT_CHARS = 200
|
|
108
|
+
|
|
109
|
+
#: Below this much squashed text across the front pages, the document has not
|
|
110
|
+
#: said enough for "it does not match" to mean "it is a different paper".
|
|
111
|
+
LEGIBLE_FRONT_CHARS = 400
|
|
112
|
+
|
|
113
|
+
#: A cover sheet, a licence page or a trailing blank can legitimately pad a
|
|
114
|
+
#: scan beyond its printed page range. Fewer pages than the range, or many
|
|
115
|
+
#: more, is a different document.
|
|
116
|
+
PAGE_COUNT_SLACK = 2
|
|
117
|
+
|
|
118
|
+
#: Corroboration needs a page range long enough to be a fingerprint. See
|
|
119
|
+
#: `_page_count_corroborates`.
|
|
120
|
+
PAGE_COUNT_MIN_SPAN = 2
|
|
121
|
+
|
|
122
|
+
_PAGE_RANGE_RE = re.compile(r"^\s*(?:[A-Za-z]*)(\d+)\s*(?:[-–—]\s*(?:[A-Za-z]*)(\d+))?\s*$")
|
|
123
|
+
|
|
124
|
+
_DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:a-z0-9]+", re.IGNORECASE)
|
|
125
|
+
|
|
126
|
+
#: Embedded titles that carry no identity: a converter's leftovers, a filename,
|
|
127
|
+
#: a template default. Present in a large minority of real PDFs, and treating
|
|
128
|
+
#: them as "declares a different article" would reject correct files.
|
|
129
|
+
_GENERIC_TITLE_RE = re.compile(
|
|
130
|
+
r"^(untitled|microsoft word|microsoft powerpoint|document|manuscript"
|
|
131
|
+
r"|paper|article|print|pdf|slide|draft|final|revised|proof|template"
|
|
132
|
+
r"|no title|title)?[\s\-_.]*(document|file|manuscript|copy|version|\d+)?$",
|
|
133
|
+
re.IGNORECASE,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
#: A scanner or layout tool leaving its output filename in /Title. Says nothing
|
|
137
|
+
#: about identity, and reading it as "declares a different article" rejected a
|
|
138
|
+
#: correct 1995 scan whose embedded title was "1556.tif".
|
|
139
|
+
_FILENAME_TITLE_RE = re.compile(
|
|
140
|
+
r"^[\w \-.]{1,60}\.(tif|tiff|pdf|doc|docx|rtf|qxd|indd|eps|ps|xps|pmd|cdr)$",
|
|
141
|
+
re.IGNORECASE,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class IdentityVerdict:
|
|
146
|
+
"""A state, a sentence explaining it, and the signals that fired."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, state: str, reason: str, signals: Optional[List[str]] = None):
|
|
149
|
+
self.state = state
|
|
150
|
+
self.reason = reason
|
|
151
|
+
self.signals = signals or []
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def ok(self) -> bool:
|
|
155
|
+
return self.state == VERIFIED
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
return "IdentityVerdict({!r}, {!r})".format(self.state, self.reason)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def article_title(ids) -> str:
|
|
162
|
+
"""The requested article's title from what resolution already fetched.
|
|
163
|
+
|
|
164
|
+
Deliberately not a network call. `BatchResolver.crossref` memoizes the whole
|
|
165
|
+
Crossref message and every DOI record passes through a T1 source that asks
|
|
166
|
+
for it, so by the time a T5 artifact needs judging the title is already in
|
|
167
|
+
hand. Unpaywall's payload carries a second free copy.
|
|
168
|
+
"""
|
|
169
|
+
memo = getattr(ids, "memo", None) or {}
|
|
170
|
+
titles = (memo.get("crossref") or {}).get("title") or []
|
|
171
|
+
if titles:
|
|
172
|
+
return str(titles[0])
|
|
173
|
+
return str((memo.get("unpaywall") or {}).get("title") or "")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def article_pages(ids) -> str:
|
|
177
|
+
"""The record's printed page range, from the memoised Crossref payload."""
|
|
178
|
+
memo = getattr(ids, "memo", None) or {}
|
|
179
|
+
return str((memo.get("crossref") or {}).get("page") or "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def article_arxiv_id(ids) -> str:
|
|
183
|
+
"""The record's arXiv id, which resolution derives from the DOI for free."""
|
|
184
|
+
from .resolve import arxiv_id_from_doi
|
|
185
|
+
|
|
186
|
+
return str(
|
|
187
|
+
getattr(ids, "arxiv_id", None)
|
|
188
|
+
or arxiv_id_from_doi(getattr(ids, "doi", None))
|
|
189
|
+
or ""
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def verify_pdf_identity(source, doi: Optional[str] = None,
|
|
194
|
+
title: Optional[str] = None,
|
|
195
|
+
pages: Optional[str] = None,
|
|
196
|
+
reference_chars: Optional[int] = None,
|
|
197
|
+
arxiv_id: Optional[str] = None) -> IdentityVerdict:
|
|
198
|
+
"""Whether the PDF at `source` (a path, or bytes) is this DOI's paper.
|
|
199
|
+
|
|
200
|
+
Signals are checked cheapest-first and the first hit wins. Any one of them
|
|
201
|
+
is enough -- they are alternatives, not a checklist, because the documents
|
|
202
|
+
that legitimately fail one are common: an accepted manuscript carries no
|
|
203
|
+
publisher DOI, and a scanned-then-OCRed page may carry no clean title.
|
|
204
|
+
"""
|
|
205
|
+
if engine_status()[0] is None:
|
|
206
|
+
return IdentityVerdict(
|
|
207
|
+
NO_ENGINE,
|
|
208
|
+
"no PDF text engine, so this PDF cannot be checked against the "
|
|
209
|
+
"record it was fetched for",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
reader = _Reader(source)
|
|
213
|
+
truncated = _truncation_reason(reader, pages, reference_chars)
|
|
214
|
+
|
|
215
|
+
text = reader.text(DOI_PAGES)
|
|
216
|
+
if not text:
|
|
217
|
+
corroborated = _page_count_corroborates(reader, pages)
|
|
218
|
+
if corroborated:
|
|
219
|
+
return truncated or IdentityVerdict(
|
|
220
|
+
VERIFIED, corroborated, ["page-count-corroborated"])
|
|
221
|
+
return IdentityVerdict(
|
|
222
|
+
UNREADABLE,
|
|
223
|
+
"PDF has no readable text layer, so it cannot be checked against "
|
|
224
|
+
"{}".format(doi or "the requested record"),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
squashed_page = squash(text)
|
|
228
|
+
|
|
229
|
+
# S1: the requested DOI, printed on the paper. True of essentially every
|
|
230
|
+
# version of record, and squashing means a line break inside it is harmless.
|
|
231
|
+
if doi:
|
|
232
|
+
wanted = squash(normalise_doi(doi))
|
|
233
|
+
if wanted and wanted in squashed_page:
|
|
234
|
+
return truncated or IdentityVerdict(
|
|
235
|
+
VERIFIED, "requested DOI found in the PDF text", ["doi-in-text"])
|
|
236
|
+
|
|
237
|
+
# S1b: the arXiv id, stamped down the margin of every PDF arXiv serves. It
|
|
238
|
+
# is S1 for a class of record that S1 cannot reach and neither can S2-S4:
|
|
239
|
+
# arXiv mints 10.48550/arXiv.* DOIs at DataCite and prints the id, never the
|
|
240
|
+
# DOI, while Crossref -- the only place `article_title` and `article_pages`
|
|
241
|
+
# read from -- has no record of a DataCite DOI at all, so no title and no
|
|
242
|
+
# page range are available either. Every arXiv PDF therefore reached the end
|
|
243
|
+
# of this function with nothing checked and came back NO_REFERENCE, which the
|
|
244
|
+
# engine treats as a refusal: `--get-xml-or-html` on 10.48550/arXiv.2605.04265
|
|
245
|
+
# fetched the PDF, threw it away, and kept only the HTML, contradicting the
|
|
246
|
+
# flag it was asked for.
|
|
247
|
+
#
|
|
248
|
+
# The "arxiv" prefix is required, not decoration. Squashing strips the dot,
|
|
249
|
+
# so the id alone is nine digits ("260504265") and would match a phone
|
|
250
|
+
# number, a grant number or an accession in the front matter; "arXiv:" in
|
|
251
|
+
# front of it is what makes the match mean something. The version suffix is
|
|
252
|
+
# not compared -- v1 and v3 of a paper are the same paper.
|
|
253
|
+
if arxiv_id:
|
|
254
|
+
unversioned = re.sub(r"v\d+$", "", str(arxiv_id).strip())
|
|
255
|
+
stamped = "arxiv" + squash(unversioned)
|
|
256
|
+
if stamped != "arxiv" and stamped in squashed_page:
|
|
257
|
+
return truncated or IdentityVerdict(
|
|
258
|
+
VERIFIED, "arXiv id {} stamped on the PDF".format(unversioned),
|
|
259
|
+
["arxiv-id-in-text"])
|
|
260
|
+
|
|
261
|
+
# S2/S3: the title, on the page. This is what carries accepted manuscripts
|
|
262
|
+
# (nihms-*.pdf), which print the title and not the publisher's DOI.
|
|
263
|
+
if title:
|
|
264
|
+
front = reader.text(TITLE_PAGES) or text
|
|
265
|
+
squashed_front = squash(front)
|
|
266
|
+
wanted_title = squash(title)
|
|
267
|
+
if wanted_title and wanted_title in squashed_front:
|
|
268
|
+
return truncated or IdentityVerdict(
|
|
269
|
+
VERIFIED, "article title found on the front pages", ["title-on-page"])
|
|
270
|
+
fraction = matched_fraction(wanted_title, squashed_front)
|
|
271
|
+
if fraction >= TITLE_BLOCK_MIN:
|
|
272
|
+
return truncated or IdentityVerdict(
|
|
273
|
+
VERIFIED,
|
|
274
|
+
"article title matches the front pages ({:.0%} of it)".format(fraction),
|
|
275
|
+
["title-near-match"],
|
|
276
|
+
)
|
|
277
|
+
# Two weak signals, independent of each other. A badly OCRed scan
|
|
278
|
+
# shreds its own title -- a 1993 Journal of Affective Disorders page
|
|
279
|
+
# extracts as "eatures associat e attempts in rtial replication",
|
|
280
|
+
# scoring 0.62 against a title that is plainly there -- but it cannot
|
|
281
|
+
# also fake being exactly as long as the record it claims to be. Wrong
|
|
282
|
+
# documents fail one or both: a publisher's permissions page is one
|
|
283
|
+
# page like the 1968 abstract it replaced, but scores 0.45, and a
|
|
284
|
+
# registry entry scores 0.62 across six pages where the article spans
|
|
285
|
+
# twenty-six.
|
|
286
|
+
if fraction >= TITLE_BLOCK_PARTIAL and _page_count_corroborates(reader, pages):
|
|
287
|
+
return truncated or IdentityVerdict(
|
|
288
|
+
VERIFIED,
|
|
289
|
+
"article title partly legible ({:.0%}) and the PDF is exactly as "
|
|
290
|
+
"long as this record ({})".format(fraction, pages),
|
|
291
|
+
["title-partial-with-page-count"],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# S4: the title the document claims for itself, when its text layer did not
|
|
295
|
+
# carry one legibly.
|
|
296
|
+
embedded = reader.embedded_title()
|
|
297
|
+
if title and embedded and not _is_generic_title(embedded):
|
|
298
|
+
wanted_title = squash(title)
|
|
299
|
+
squashed_embedded = squash(embedded)
|
|
300
|
+
embedded_fraction = matched_fraction(wanted_title, squashed_embedded)
|
|
301
|
+
if embedded_fraction >= TITLE_BLOCK_MIN:
|
|
302
|
+
return truncated or IdentityVerdict(
|
|
303
|
+
VERIFIED, "embedded PDF title matches the article title",
|
|
304
|
+
["title-in-metadata"])
|
|
305
|
+
# A SHORT embedded title cannot disagree with a long one, it can only
|
|
306
|
+
# fail to contain it. `matched_fraction` divides by the length of the
|
|
307
|
+
# article title, so the best score a 17-character `/Title` can reach
|
|
308
|
+
# against an 80-character title is 0.21 -- below the threshold no matter
|
|
309
|
+
# what it says. Convicting on that made "Allergy - Wiley Online Library"
|
|
310
|
+
# and "untitled document", two of the commonest `/Title` values in the
|
|
311
|
+
# wild, into evidence of a different paper. So a title that could never
|
|
312
|
+
# have cleared the bar is treated as saying nothing, and only a title
|
|
313
|
+
# long enough to have matched is allowed to contradict.
|
|
314
|
+
could_have_matched = (
|
|
315
|
+
squashed_embedded
|
|
316
|
+
and len(squashed_embedded) >= len(wanted_title) * TITLE_BLOCK_MIN
|
|
317
|
+
)
|
|
318
|
+
if (could_have_matched and embedded_fraction < TITLE_BLOCK_FOREIGN
|
|
319
|
+
and len(squashed_page) >= LEGIBLE_FRONT_CHARS):
|
|
320
|
+
return IdentityVerdict(
|
|
321
|
+
WRONG,
|
|
322
|
+
'PDF is a different document: it calls itself "{}", not "{}"'.format(
|
|
323
|
+
_clip(embedded), _clip(title)),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Nothing matched. There used to be a branch here convicting a PDF that
|
|
327
|
+
# printed some OTHER DOI in its front matter. It is gone, and the
|
|
328
|
+
# measurements are why: across 36 known-bad files it convicted ZERO, and
|
|
329
|
+
# across 706 known-good files it convicted THREE -- every one a published
|
|
330
|
+
# paper printing its own preprint's DOI ("previously posted at medRxiv"),
|
|
331
|
+
# which is the same work. It also convicted every document when `doi` was
|
|
332
|
+
# None, because the guards were written `if wanted and ...` and an empty
|
|
333
|
+
# `wanted` makes every DOI on the page foreign.
|
|
334
|
+
#
|
|
335
|
+
# Nothing was lost by removing it. A legible wrong PDF with a known title
|
|
336
|
+
# still reaches WRONG through the branch below; with no title the honest
|
|
337
|
+
# answer is NO_REFERENCE, because nothing was checked. Removing it also lets
|
|
338
|
+
# a barely-legible scan reach the page-count rescue further down, which the
|
|
339
|
+
# early return used to jump over.
|
|
340
|
+
if title:
|
|
341
|
+
# A page carrying almost no machine-readable text cannot be convicted of
|
|
342
|
+
# being a different article -- it has not said anything. Scanned papers
|
|
343
|
+
# routinely extract to nothing but a library's download stamp ("by guest
|
|
344
|
+
# on June 5, 2016 ... Downloaded from"), which is text, but not text that
|
|
345
|
+
# could ever have carried a title. Measured on this corpus: two JNEN
|
|
346
|
+
# scans from 1992 and 1995, both the correct article, both reported as
|
|
347
|
+
# somebody else's until this branch existed. The file is refused either
|
|
348
|
+
# way; what changes is whether the run reports a retrieval bug or a
|
|
349
|
+
# document that was never machine-readable, and those send whoever reads
|
|
350
|
+
# the report to two very different places.
|
|
351
|
+
if len(squashed_page) < LEGIBLE_FRONT_CHARS:
|
|
352
|
+
corroborated = _page_count_corroborates(reader, pages)
|
|
353
|
+
if corroborated:
|
|
354
|
+
return truncated or IdentityVerdict(
|
|
355
|
+
VERIFIED, corroborated, ["page-count-corroborated"])
|
|
356
|
+
return IdentityVerdict(
|
|
357
|
+
UNREADABLE,
|
|
358
|
+
"PDF has almost no readable text ({} characters over {} pages), "
|
|
359
|
+
"so it cannot be checked against {}".format(
|
|
360
|
+
len(squashed_page), DOI_PAGES, doi or "the requested record"),
|
|
361
|
+
)
|
|
362
|
+
return IdentityVerdict(
|
|
363
|
+
WRONG,
|
|
364
|
+
'PDF does not contain the requested DOI or the title "{}"'.format(_clip(title)),
|
|
365
|
+
)
|
|
366
|
+
return IdentityVerdict(
|
|
367
|
+
NO_REFERENCE,
|
|
368
|
+
"no title known for {} and its DOI is not printed in the PDF, so there "
|
|
369
|
+
"is nothing to check the file against".format(doi or "this record"),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def expected_page_count(page_range: Optional[str]) -> Optional[int]:
|
|
374
|
+
"""How many printed pages "107-122" implies. None when it implies nothing.
|
|
375
|
+
|
|
376
|
+
Crossref's `page` is free text: "107-122", "314", "e12345", "S1-S8", and
|
|
377
|
+
occasionally a comma-separated mess. Only the shapes that clearly denote a
|
|
378
|
+
single page or a simple range are used; anything else declines to guess,
|
|
379
|
+
because a wrong expectation here refuses a correct file.
|
|
380
|
+
"""
|
|
381
|
+
if not page_range:
|
|
382
|
+
return None
|
|
383
|
+
matched = _PAGE_RANGE_RE.match(str(page_range))
|
|
384
|
+
if not matched:
|
|
385
|
+
return None
|
|
386
|
+
first = int(matched.group(1))
|
|
387
|
+
if matched.group(2) is None:
|
|
388
|
+
return 1
|
|
389
|
+
last = int(matched.group(2))
|
|
390
|
+
# "1049-58" is 1049-1058, not a negative range.
|
|
391
|
+
if last < first:
|
|
392
|
+
digits = len(str(last))
|
|
393
|
+
last = int(str(first)[:-digits] + str(last)) if digits < len(str(first)) else last
|
|
394
|
+
span = last - first + 1
|
|
395
|
+
return span if 1 <= span <= 400 else None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _truncation_reason(reader, page_range: Optional[str],
|
|
399
|
+
reference_chars: Optional[int] = None) -> Optional[IdentityVerdict]:
|
|
400
|
+
"""A verdict when the file is only a fragment of the record, else None.
|
|
401
|
+
|
|
402
|
+
A different failure from a wrong document, and invisible to every identity
|
|
403
|
+
signal: a publisher's first-page preview and an article's own supplementary
|
|
404
|
+
figures both carry the article's real DOI and real title, so they pass. What
|
|
405
|
+
gives them away is length. Two of thirteen replacement PDFs fetched during
|
|
406
|
+
the corpus repair were two-page previews of a seventeen- and a thirty-two-
|
|
407
|
+
page article, each cut off mid-sentence, and both would have been stored as
|
|
408
|
+
the paper.
|
|
409
|
+
|
|
410
|
+
Only a SHORTFALL counts. An accepted manuscript is routinely longer than its
|
|
411
|
+
typeset page range -- one here runs 57 double-spaced pages against a printed
|
|
412
|
+
36 -- and that is a complete article, not a defect.
|
|
413
|
+
"""
|
|
414
|
+
# Signal 1: the same record's structured full text, when the walk already
|
|
415
|
+
# has it. The strongest of the two, because it compares the document with
|
|
416
|
+
# ITSELF in another format rather than with metadata about it.
|
|
417
|
+
if reference_chars and reference_chars > 2000:
|
|
418
|
+
body = reader.text(None)
|
|
419
|
+
if body is not None and len(body) < reference_chars * FRAGMENT_TEXT_RATIO:
|
|
420
|
+
return IdentityVerdict(
|
|
421
|
+
TRUNCATED,
|
|
422
|
+
"PDF holds {} characters against {} in this record's structured "
|
|
423
|
+
"full text -- a fragment of the article, not the article".format(
|
|
424
|
+
len(body), reference_chars),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Signal 2: the printed page range. Available far more often, since most
|
|
428
|
+
# records never get a structured copy at all.
|
|
429
|
+
expected = expected_page_count(page_range)
|
|
430
|
+
if not expected or expected < TRUNCATION_MIN_PAGES:
|
|
431
|
+
return None
|
|
432
|
+
actual = reader.page_count()
|
|
433
|
+
if not actual:
|
|
434
|
+
return None
|
|
435
|
+
if actual >= expected * TRUNCATION_RATIO:
|
|
436
|
+
return None
|
|
437
|
+
return IdentityVerdict(
|
|
438
|
+
TRUNCATED,
|
|
439
|
+
"PDF is only {} of the {} pages this record spans ({}) -- a preview or "
|
|
440
|
+
"a supplement, not the article".format(actual, expected, page_range),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _page_count_corroborates(reader, page_range: Optional[str]) -> Optional[str]:
|
|
445
|
+
"""A reason string when the PDF's page count matches the record's, else None.
|
|
446
|
+
|
|
447
|
+
The only identity evidence an image-only scan can offer, and it is real
|
|
448
|
+
evidence: it is a property of the FILE, not of our metadata about the DOI
|
|
449
|
+
we asked for. That distinction is why this is not "old papers are probably
|
|
450
|
+
fine" -- a wrong file fetched for a 1995 record is still wrong, and this
|
|
451
|
+
still catches it. Measured on the corpus: four scanned papers from 1992-95
|
|
452
|
+
match their printed page ranges exactly, while the publisher advertisement
|
|
453
|
+
standing in for a six-page 2023 article is one page long and is still
|
|
454
|
+
refused.
|
|
455
|
+
"""
|
|
456
|
+
expected = expected_page_count(page_range)
|
|
457
|
+
# A single expected page is not evidence. Crossref reports an e-locator
|
|
458
|
+
# ("e12345", "1342") as the page field for most modern OA journals, and
|
|
459
|
+
# `expected_page_count` reads that as one page -- so with PAGE_COUNT_SLACK
|
|
460
|
+
# any 1-to-3-page file would corroborate, and a one-page publisher
|
|
461
|
+
# advertisement would be accepted as a twenty-page article. Corroboration
|
|
462
|
+
# only means something when the record spans a length worth matching.
|
|
463
|
+
if not expected or expected < PAGE_COUNT_MIN_SPAN:
|
|
464
|
+
return None
|
|
465
|
+
actual = reader.page_count()
|
|
466
|
+
if not actual:
|
|
467
|
+
return None
|
|
468
|
+
if expected <= actual <= expected + PAGE_COUNT_SLACK:
|
|
469
|
+
return ("PDF has no readable text, but its {} pages match the {} pages "
|
|
470
|
+
"this record spans ({})".format(actual, expected, page_range))
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class _Reader:
|
|
475
|
+
"""One document, read once per question asked of it.
|
|
476
|
+
|
|
477
|
+
A single `verify_pdf_identity` used to re-open and re-parse the same file up
|
|
478
|
+
to seven times -- a 3-page read, a 2-page read, a whole-document read, the
|
|
479
|
+
metadata, and the page count from as many as three call sites. On the pypdf
|
|
480
|
+
path each of those slurps the whole file into a fresh BytesIO and re-parses
|
|
481
|
+
the xref, so the cost is real and lands hardest on exactly the documents
|
|
482
|
+
this module exists to catch: the oversized ones.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(self, source):
|
|
486
|
+
self.source = source
|
|
487
|
+
self._text = {}
|
|
488
|
+
self._pages = _UNSET
|
|
489
|
+
self._metadata = None
|
|
490
|
+
|
|
491
|
+
def text(self, pages: Optional[int]) -> Optional[str]:
|
|
492
|
+
if pages not in self._text:
|
|
493
|
+
self._text[pages] = _read_text(self.source, pages)
|
|
494
|
+
return self._text[pages]
|
|
495
|
+
|
|
496
|
+
def page_count(self) -> Optional[int]:
|
|
497
|
+
if self._pages is _UNSET:
|
|
498
|
+
self._pages = pdf_page_count(self.source)
|
|
499
|
+
return self._pages
|
|
500
|
+
|
|
501
|
+
def metadata(self) -> dict:
|
|
502
|
+
if self._metadata is None:
|
|
503
|
+
self._metadata = pdf_metadata(self.source) or {}
|
|
504
|
+
return self._metadata
|
|
505
|
+
|
|
506
|
+
def embedded_title(self) -> str:
|
|
507
|
+
metadata = self.metadata()
|
|
508
|
+
for key in ("title", "Title", "/Title", "dc:title"):
|
|
509
|
+
value = metadata.get(key)
|
|
510
|
+
if value:
|
|
511
|
+
return str(value)
|
|
512
|
+
return ""
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
_UNSET = object()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _read_text(source, pages: Optional[int]) -> Optional[str]:
|
|
519
|
+
"""Text from the front `pages` (None = the whole document)."""
|
|
520
|
+
min_chars = 1 if pages is None else MIN_FRONT_CHARS
|
|
521
|
+
if isinstance(source, (bytes, bytearray)):
|
|
522
|
+
return pdf_text_from_bytes(bytes(source), max_pages=pages, min_chars=min_chars)
|
|
523
|
+
return pdf_text(source, max_pages=pages, min_chars=min_chars)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _is_generic_title(embedded: str) -> bool:
|
|
527
|
+
stripped = (embedded or "").strip()
|
|
528
|
+
return bool(_GENERIC_TITLE_RE.match(stripped) or _FILENAME_TITLE_RE.match(stripped))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _clip(text: str, limit: int = 70) -> str:
|
|
532
|
+
text = " ".join((text or "").split())
|
|
533
|
+
return text if len(text) <= limit else text[:limit - 1] + "…"
|