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,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] + "…"