fetchpdf 0.1.6__tar.gz → 0.1.7__tar.gz

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 (126) hide show
  1. {fetchpdf-0.1.6/fetchpdf.egg-info → fetchpdf-0.1.7}/PKG-INFO +1 -1
  2. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/__init__.py +1 -1
  3. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/pdf_identity.py +92 -2
  4. {fetchpdf-0.1.6 → fetchpdf-0.1.7/fetchpdf.egg-info}/PKG-INFO +1 -1
  5. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pdf_identity.py +104 -0
  6. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_serpapi_scholar.py +3 -1
  7. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/LICENSE +0 -0
  8. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/MANIFEST.in +0 -0
  9. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/README.md +0 -0
  10. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/__main__.py +0 -0
  11. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/_env.py +0 -0
  12. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/_http.py +0 -0
  13. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/fetch_abstract_from_doi.py +0 -0
  14. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/fetch_metadata_from_doi.py +0 -0
  15. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/fetchpdf.py +0 -0
  16. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/get_cookies.py +0 -0
  17. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/__init__.py +0 -0
  18. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/_util.py +0 -0
  19. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/artifact.py +0 -0
  20. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/backends/__init__.py +0 -0
  21. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
  22. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/backends/openrouter.py +0 -0
  23. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/blocked.py +0 -0
  24. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/byline.py +0 -0
  25. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/cache.py +0 -0
  26. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/chunk.py +0 -0
  27. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/classify.py +0 -0
  28. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/context.py +0 -0
  29. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/corresponding.py +0 -0
  30. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/engine.py +0 -0
  31. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/extract_images.py +0 -0
  32. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/figures.py +0 -0
  33. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/fulltext_scan.py +0 -0
  34. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/goals.py +0 -0
  35. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/http.py +0 -0
  36. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/identifiers.py +0 -0
  37. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/institutional.py +0 -0
  38. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/ladder.json +0 -0
  39. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/linked_artifacts.py +0 -0
  40. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
  41. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/llm_agent_retrieval.py +0 -0
  42. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/normalize.py +0 -0
  43. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/pdf_text.py +0 -0
  44. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/provenance.py +0 -0
  45. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/pubpeer.py +0 -0
  46. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/ratelimit.py +0 -0
  47. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/references.py +0 -0
  48. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/repository_waf.py +0 -0
  49. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/request_drafts.py +0 -0
  50. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/resolve.py +0 -0
  51. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/__init__.py +0 -0
  52. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/arxiv.py +0 -0
  53. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/core_text.py +0 -0
  54. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
  55. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/epmc.py +0 -0
  56. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/epmc_repo.py +0 -0
  57. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/landing.py +0 -0
  58. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
  59. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/pmc.py +0 -0
  60. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/pmc_html.py +0 -0
  61. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/preprint.py +0 -0
  62. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
  63. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/sources/supplements.py +0 -0
  64. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_atypon.py +0 -0
  65. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_dspace.py +0 -0
  66. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_graph.py +0 -0
  67. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_index.py +0 -0
  68. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_jci.py +0 -0
  69. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_pmc.py +0 -0
  70. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplement_publishers.py +0 -0
  71. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/supplementary.py +0 -0
  72. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/tiers.py +0 -0
  73. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/to_html.py +0 -0
  74. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/to_markdown.py +0 -0
  75. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/validate.py +0 -0
  76. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf/retrieval/verify.py +0 -0
  77. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf.egg-info/SOURCES.txt +0 -0
  78. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf.egg-info/dependency_links.txt +0 -0
  79. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf.egg-info/entry_points.txt +0 -0
  80. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf.egg-info/requires.txt +0 -0
  81. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/fetchpdf.egg-info/top_level.txt +0 -0
  82. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/pyproject.toml +0 -0
  83. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/requirements.txt +0 -0
  84. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/setup.cfg +0 -0
  85. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_agent_backends.py +0 -0
  86. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_blocked.py +0 -0
  87. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_cookie_offer.py +0 -0
  88. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_corresponding.py +0 -0
  89. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_datacite_supplement_identity.py +0 -0
  90. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_dspace.py +0 -0
  91. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_elsevier_routing.py +0 -0
  92. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_entrypoints.py +0 -0
  93. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_epmc_repo.py +0 -0
  94. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_extract_images.py +0 -0
  95. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_figures.py +0 -0
  96. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_folder_encoding.py +0 -0
  97. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_fulltext_scan.py +0 -0
  98. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_get_cookies.py +0 -0
  99. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_institutional.py +0 -0
  100. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_jci_supplements.py +0 -0
  101. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_json_output.py +0 -0
  102. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_landing_candidates.py +0 -0
  103. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_landing_page_detection.py +0 -0
  104. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_last_resort_reachability.py +0 -0
  105. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_linked_artifacts.py +0 -0
  106. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_llm_adjudicate.py +0 -0
  107. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_llm_agent_retrieval.py +0 -0
  108. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_normalize_cells.py +0 -0
  109. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pdf_trailer.py +0 -0
  110. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pmc_page.py +0 -0
  111. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_polite_ua.py +0 -0
  112. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pubmed_identity.py +0 -0
  113. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pubpeer.py +0 -0
  114. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pull_for_record_call_sites.py +0 -0
  115. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_pure.py +0 -0
  116. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_request_drafts.py +0 -0
  117. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_retrieval.py +0 -0
  118. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_review_fixes.py +0 -0
  119. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_scan_record.py +0 -0
  120. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_screening_conversion.py +0 -0
  121. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_short_conference_xml.py +0 -0
  122. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_supplement_asset_identity.py +0 -0
  123. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_supplementary.py +0 -0
  124. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_to_html.py +0 -0
  125. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_xml_fallback_extras.py +0 -0
  126. {fetchpdf-0.1.6 → fetchpdf-0.1.7}/tests/test_xml_last.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchpdf
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: A comprehensive tool to download PDFs from DOIs using multiple sources
5
5
  Author-email: Dan Elton <delton17@gmail.com>
6
6
  License-Expression: MIT
@@ -15,7 +15,7 @@ from .fetch_metadata_from_doi import fetch_metadata_from_doi
15
15
  # behavioural difference, it is the same object.
16
16
  fetch_pdf_from_doi = fetch_pdf
17
17
 
18
- __version__ = "0.1.6"
18
+ __version__ = "0.1.7"
19
19
  __all__ = [
20
20
  "fetch_pdf",
21
21
  "fetch_pdf_from_doi", # deprecated alias for fetch_pdf
@@ -160,6 +160,44 @@ PAGE_COUNT_MIN_SPAN = 2
160
160
 
161
161
  _PAGE_RANGE_RE = re.compile(r"^\s*(?:[A-Za-z]*)(\d+)\s*(?:[-–—]\s*(?:[A-Za-z]*)(\d+))?\s*$")
162
162
 
163
+ #: S1 on its own is not enough when the front pages are legible and do not carry
164
+ #: the title (issue #5). The requested DOI is printed by the paper itself, but
165
+ #: also by a paper that CITES it and by the article's own supplementary file:
166
+ #: fetch_pdf("10.1037/0022-3514.74.6.1464") wrote 10.5964/meth.7155, whose
167
+ #: abstract reads "(IAT; Greenwald et al., 1998. DOI: 10.1037/0022-3514.74.6.1464)",
168
+ #: and the Supplementary Information PDF for 10.1038/nature08833 prints
169
+ #: "doi: 10.1038/nature08833" on page 1. Measured over 121 cached PDFs whose DOI
170
+ #: was found in the text, scoring the Crossref title against the front two pages:
171
+ #: the citing paper 0.52, the supplement 0.64, a line-numbered medRxiv preprint
172
+ #: (correct) 0.76, the other 118 correct papers >= 0.97. 0.70 sits in the gap; it
173
+ #: is narrow, so a page too short to judge (LEGIBLE_FRONT_CHARS) keeps the DOI
174
+ #: verdict rather than risk refusing a correct scan.
175
+ DOI_TITLE_PAGES = 2
176
+ DOI_TITLE_MIN = 0.70
177
+
178
+ #: The same DOI printed inside an author-year citation -- "Greenwald et al.,
179
+ #: 1998. DOI: 10.1037/...", "(Smith & Jones, 2020, https://doi.org/...)" -- is a
180
+ #: reference to the paper, not the paper's own masthead. Matched on the text
181
+ #: just before the DOI, so it also works when no title is known to compare.
182
+ _CITED_BEFORE_DOI_RE = re.compile(
183
+ r"(?:et\s+al\.?|&\s*[a-z][a-z'\-]+|\band\s+[a-z][a-z'\-]+)" # et al. / & Name / and Name
184
+ r"[\s,]*\(?\s*(?:1[89]|20)\d{2}[a-z]?\s*\)?" # , 1998 / (1998) / 2020a
185
+ r"\s*[.,;:]?\s*"
186
+ r"(?:doi\s*[:.]?\s*|(?:https?\s*:\s*//\s*)?(?:dx\s*\.\s*)?doi\s*\.\s*org\s*/\s*)?$",
187
+ re.IGNORECASE,
188
+ )
189
+ #: How far before the DOI the author-year pattern may end, and how far back a
190
+ #: "cite this article" line may start. A paper's own front matter often prints
191
+ #: a suggested citation in exactly the cited shape ("Cite this article as: Smith
192
+ #: et al., 2021. https://doi.org/..."), and that is the paper's own DOI.
193
+ CITED_CONTEXT_CHARS = 80
194
+ OWN_CITATION_LINE_CHARS = 400
195
+ _OWN_CITATION_RE = re.compile(
196
+ r"cite\s+(?:this|the)\s+(?:article|paper|chapter|work|preprint)|please\s+cite"
197
+ r"|how\s+to\s+cite|citation\s*:|recommended\s+citation|cite\s+as",
198
+ re.IGNORECASE,
199
+ )
200
+
163
201
  #: Embedded titles that carry no identity: a converter's leftovers, a filename,
164
202
  #: a template default. Present in a large minority of real PDFs, and treating
165
203
  #: them as "declares a different article" would reject correct files.
@@ -279,11 +317,24 @@ def verify_pdf_identity(source, doi: Optional[str] = None,
279
317
 
280
318
  # S1: the requested DOI, printed on the paper. True of essentially every
281
319
  # version of record, and squashing means a line break inside it is harmless.
320
+ # Not on its own, though, when the DOI is only cited, or when the front pages
321
+ # are legible and do not carry the title (DOI_TITLE_MIN): then the later
322
+ # signals decide, and a refusal says the DOI was seen and why it did not count.
323
+ doi_discounted = ""
282
324
  if doi:
283
325
  wanted = squash(normalise_doi(doi))
284
326
  if wanted and wanted in squashed_page:
285
- return truncated or IdentityVerdict(
286
- VERIFIED, "requested DOI found in the PDF text", ["doi-in-text"])
327
+ if _doi_only_cited(text, doi):
328
+ doi_discounted = "only inside a citation"
329
+ elif title:
330
+ two_pages = squash(reader.text(DOI_TITLE_PAGES) or text)
331
+ fraction = matched_fraction(squash(title), two_pages)
332
+ if len(two_pages) >= LEGIBLE_FRONT_CHARS and fraction < DOI_TITLE_MIN:
333
+ doi_discounted = ("but its front pages carry only {:.0%} of the "
334
+ "title".format(fraction))
335
+ if not doi_discounted:
336
+ return truncated or IdentityVerdict(
337
+ VERIFIED, "requested DOI found in the PDF text", ["doi-in-text"])
287
338
 
288
339
  # S1b: the arXiv id, stamped down the margin of every PDF arXiv serves. It
289
340
  # is S1 for a class of record that S1 cannot reach and neither can S2-S4:
@@ -410,10 +461,23 @@ def verify_pdf_identity(source, doi: Optional[str] = None,
410
461
  "so it cannot be checked against {}".format(
411
462
  len(squashed_page), DOI_PAGES, doi or "the requested record"),
412
463
  )
464
+ if doi_discounted:
465
+ return IdentityVerdict(
466
+ WRONG,
467
+ 'PDF prints the requested DOI {}, so it is not taken as "{}" '
468
+ "(likely a paper citing it, or its supplement)".format(
469
+ doi_discounted, _clip(title)),
470
+ )
413
471
  return IdentityVerdict(
414
472
  WRONG,
415
473
  'PDF does not contain the requested DOI or the title "{}"'.format(_clip(title)),
416
474
  )
475
+ if doi_discounted:
476
+ return IdentityVerdict(
477
+ NO_REFERENCE,
478
+ "PDF prints {} {}, and no title is known to check it against".format(
479
+ doi or "the requested DOI", doi_discounted),
480
+ )
417
481
  return IdentityVerdict(
418
482
  NO_REFERENCE,
419
483
  "no title known for {} and its DOI is not printed in the PDF, so there "
@@ -630,6 +694,32 @@ def _is_generic_title(embedded: str) -> bool:
630
694
  return bool(_GENERIC_TITLE_RE.match(stripped) or _FILENAME_TITLE_RE.match(stripped))
631
695
 
632
696
 
697
+ def _doi_only_cited(text: str, doi: str) -> bool:
698
+ """Whether every place `doi` is printed in `text` is inside a citation.
699
+
700
+ True only when the DOI appears at least once and each appearance follows an
701
+ author-year citation ("et al., 1998. DOI:") with no "cite this article" line
702
+ before it. A DOI printed once in a masthead and once in a citation is the
703
+ paper's own, so one non-citation appearance is enough to say False.
704
+ """
705
+ wanted = normalise_doi(doi)
706
+ if not wanted:
707
+ return False
708
+ # Line wraps land anywhere inside a DOI, so allow whitespace between its characters.
709
+ pattern = re.compile(r"\s*".join(re.escape(c) for c in wanted), re.IGNORECASE)
710
+ hits = list(pattern.finditer(text))
711
+ if not hits:
712
+ return False
713
+ for hit in hits:
714
+ before = text[max(0, hit.start() - CITED_CONTEXT_CHARS):hit.start()]
715
+ if not _CITED_BEFORE_DOI_RE.search(before):
716
+ return False
717
+ line_window = text[max(0, hit.start() - OWN_CITATION_LINE_CHARS):hit.start()]
718
+ if _OWN_CITATION_RE.search(line_window):
719
+ return False
720
+ return True
721
+
722
+
633
723
  def _clip(text: str, limit: int = 70) -> str:
634
724
  text = " ".join((text or "").split())
635
725
  return text if len(text) <= limit else text[:limit - 1] + "…"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchpdf
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: A comprehensive tool to download PDFs from DOIs using multiple sources
5
5
  Author-email: Dan Elton <delton17@gmail.com>
6
6
  License-Expression: MIT
@@ -14,6 +14,7 @@ from conftest import text_pdf
14
14
  from fetchpdf.retrieval import pdf_identity
15
15
  from fetchpdf.retrieval.pdf_identity import (
16
16
  NO_ENGINE,
17
+ NO_REFERENCE,
17
18
  TRUNCATED,
18
19
  UNREADABLE,
19
20
  VERIFIED,
@@ -583,3 +584,106 @@ def test_a_wall_is_discarded_even_when_metadata_is_unreachable(tmp_path, monkeyp
583
584
  monkeypatch.setattr(fpd, "_title_for", lambda doi, verbose=False: None) # Crossref down
584
585
  assert fpd._accept_downloaded_pdf(str(path), DOI) is False
585
586
  assert not os.path.exists(path)
587
+
588
+
589
+ # --------------------------------------------------------------------------
590
+ # The requested DOI is printed, but not as this paper's own (issue #5)
591
+ # --------------------------------------------------------------------------
592
+
593
+ IAT_DOI = "10.1037/0022-3514.74.6.1464"
594
+ IAT_TITLE = ("Measuring individual differences in implicit cognition: "
595
+ "The implicit association test")
596
+ CITING_TITLE = ("Filling the gap between implicit associations and behavior: "
597
+ "A linear mixed-effects Rasch analysis of the Implicit Association Test")
598
+ CITING_PROSE = ["Participants completed the task in a single session and responses "
599
+ "were modelled with crossed random effects for items and persons."] * 8
600
+
601
+
602
+ def test_paper_citing_the_doi_is_not_accepted_as_it(tmp_path):
603
+ """10.5964/meth.7155, fetched for the 1998 IAT paper (issue #5).
604
+
605
+ Its abstract prints the requested DOI inside a citation. Before the fix the
606
+ DOI alone accepted it.
607
+ """
608
+ path = _pdf(tmp_path, "citing.pdf", [
609
+ CITING_TITLE,
610
+ "The measure obtained from the Implicit Association Test (IAT; Greenwald",
611
+ f"et al., 1998. DOI: {IAT_DOI}) is often used to predict behaviour.",
612
+ ] + CITING_PROSE)
613
+ verdict = verify_pdf_identity(path, IAT_DOI, IAT_TITLE)
614
+ assert verdict.state == WRONG
615
+ assert "only inside a citation" in verdict.reason
616
+
617
+
618
+ def test_paper_citing_the_doi_is_not_accepted_when_no_title_is_known(tmp_path):
619
+ """The citation shape alone is enough to refuse: there is no title to check."""
620
+ path = _pdf(tmp_path, "citing-untitled.pdf", [
621
+ CITING_TITLE,
622
+ f"(Greenwald, McGhee, & Schwartz, 1998, https://doi.org/{IAT_DOI}).",
623
+ ] + CITING_PROSE)
624
+ verdict = verify_pdf_identity(path, IAT_DOI, None)
625
+ assert verdict.state == NO_REFERENCE
626
+ assert "only inside a citation" in verdict.reason
627
+
628
+
629
+ def test_supplement_printing_the_doi_without_the_title_is_not_accepted(tmp_path):
630
+ """The Supplementary Information PDF for 10.1038/nature08833 prints the
631
+ article DOI on page 1 but not its title (0.64 of it, measured)."""
632
+ path = _pdf(tmp_path, "si.pdf", [
633
+ "SUPPLEMENTARY INFORMATION",
634
+ "doi: 10.1038/nature08833",
635
+ "Supplementary Methods, Supplementary Figures S1-S12 and Supplementary Tables S1-S4",
636
+ ] + ["Supplementary Figure legends and additional methods for the cohort "
637
+ "analyses, including sequencing depth and quality filters."] * 8)
638
+ verdict = verify_pdf_identity(path, "10.1038/nature08833", TITLE)
639
+ assert verdict.state == WRONG
640
+ assert "carry only" in verdict.reason
641
+
642
+
643
+ def test_own_suggested_citation_still_counts_as_the_papers_doi(tmp_path):
644
+ """The false-reject guard for the citation rule. Front matter often prints the
645
+ paper's own suggested citation in exactly the cited shape."""
646
+ path = _pdf(tmp_path, "own-cite.pdf", [
647
+ "Cite this article as: Smith et al., 2021.",
648
+ f"https://doi.org/{DOI}",
649
+ ] + PROSE)
650
+ verdict = verify_pdf_identity(path, DOI, None)
651
+ assert verdict.state == VERIFIED
652
+ assert verdict.signals == ["doi-in-text"]
653
+
654
+
655
+ def test_doi_in_masthead_and_in_a_citation_is_the_papers_own(tmp_path):
656
+ """One appearance outside a citation is enough to keep S1."""
657
+ path = _pdf(tmp_path, "masthead.pdf", [
658
+ f"https://doi.org/{DOI}",
659
+ f"As reported earlier (Smith et al., 2021. doi: {DOI}), the index held.",
660
+ ] + PROSE)
661
+ verdict = verify_pdf_identity(path, DOI, None)
662
+ assert verdict.state == VERIFIED
663
+ assert verdict.signals == ["doi-in-text"]
664
+
665
+
666
+ def test_line_numbered_preprint_with_a_partly_legible_title_keeps_the_doi(tmp_path):
667
+ """A line-numbered medRxiv preprint (correct paper) scored 0.76: above the bar."""
668
+ words = TITLE.split()
669
+ half = len(words) // 2
670
+ numbered = [f"1 {' '.join(words[:half])}", "2 3 4 5 6", f"7 {' '.join(words[half:])}"]
671
+ path = _pdf(tmp_path, "medrxiv.pdf", numbered + [f"doi: {DOI}"] + PROSE)
672
+ from fetchpdf.retrieval._util import matched_fraction, squash
673
+ front = squash("\n".join(numbered + [f"doi: {DOI}"] + PROSE))
674
+ assert matched_fraction(squash(TITLE), front) >= pdf_identity.DOI_TITLE_MIN
675
+ verdict = verify_pdf_identity(path, DOI, TITLE)
676
+ assert verdict.state == VERIFIED
677
+
678
+
679
+ def test_garbled_front_page_keeps_the_doi_verdict(tmp_path):
680
+ """Too little text to judge the title: the DOI is still trusted."""
681
+ # Between MIN_FRONT_CHARS (read at all) and LEGIBLE_FRONT_CHARS (judged).
682
+ stamp = ["Downloaded from https://academic.example.org by guest on June 5, 2016"] * 4
683
+ path = _pdf(tmp_path, "scan.pdf", [f"doi:{DOI}"] + stamp)
684
+ from fetchpdf.retrieval._util import squash
685
+ chars = len(squash(chr(10).join([f"doi:{DOI}"] + stamp)))
686
+ assert pdf_identity.MIN_FRONT_CHARS <= chars < pdf_identity.LEGIBLE_FRONT_CHARS, chars
687
+ verdict = verify_pdf_identity(path, DOI, TITLE)
688
+ assert verdict.state == VERIFIED
689
+ assert verdict.signals == ["doi-in-text"]
@@ -193,7 +193,9 @@ def test_wrong_pdf_is_rejected_and_next_copy_is_tried(monkeypatch, tmp_path):
193
193
  "doi:10.9999/wrong"] + ["Sedimentary rocks form in layers."] * 20))
194
194
  else:
195
195
  assert not path.exists(), "Rejected PDF must be removed"
196
- path.write_bytes(text_pdf(doi=DOI))
196
+ # The correct copy prints its title, as a real article does: a
197
+ # DOI alone on legible pages is what a citing paper also prints.
198
+ path.write_bytes(text_pdf([TITLE, f"doi:{DOI}"] + ["Full text of the trial report."] * 250))
197
199
  return True
198
200
 
199
201
  monkeypatch.setattr(fpd, "try_download", download)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes