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
fetchpdf/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ fetchpdf: A comprehensive tool to download PDFs from DOIs using multiple sources.
3
+
4
+ This package provides functions to download academic papers (PDFs) from DOIs using
5
+ multiple fallback sources including OpenAlex, Unpaywall, PubMed Central, Crossref,
6
+ Europe PMC, Semantic Scholar
7
+ """
8
+
9
+ from .fetchpdf import fetch_pdf, batch_fetch_pdfs
10
+ from .fetch_metadata_from_doi import fetch_metadata_from_doi
11
+
12
+ # Deprecated alias. `fetch_pdf_from_doi` was the old name for both this function
13
+ # and the module it lives in; both are retired in favour of `fetch_pdf` /
14
+ # `fetchpdf.fetchpdf`. Kept so existing callers keep working -- there is no
15
+ # behavioural difference, it is the same object.
16
+ fetch_pdf_from_doi = fetch_pdf
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = [
20
+ "fetch_pdf",
21
+ "fetch_pdf_from_doi", # deprecated alias for fetch_pdf
22
+ "batch_fetch_pdfs",
23
+ "fetch_metadata_from_doi",
24
+ "pull_supplementary_for",
25
+ ]
26
+
27
+
28
+ def pull_supplementary_for(*args, **kwargs):
29
+ """Fetch every supplementary file for one record, beside its main artifact.
30
+
31
+ A separate call rather than a keyword on fetch_pdf_from_doi, deliberately: the
32
+ tiered engine re-enters that function at T5 with a temporary save_path, so a
33
+ flag threaded through it would fire on the re-entry and write supplementary
34
+ siblings next to a file that is about to be deleted. The two-call shape makes
35
+ that structurally impossible.
36
+
37
+ from fetchpdf import fetch_pdf_from_doi, pull_supplementary_for
38
+
39
+ path = fetch_pdf_from_doi("10.1371/journal.pone.0000308", "out/paper.pdf")
40
+ summary = pull_supplementary_for("10.1371/journal.pone.0000308",
41
+ doi="10.1371/journal.pone.0000308",
42
+ save_path="out/paper.pdf")
43
+
44
+ Imported lazily so the retrieval package is untouched unless it is used. See
45
+ fetchpdf.retrieval.supplementary.pull_for_record for the full signature.
46
+ """
47
+ from .retrieval.supplementary import pull_for_record
48
+
49
+ return pull_for_record(*args, **kwargs)
fetchpdf/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m fetchpdf` as an alias for the `fetchpdf` console script."""
2
+
3
+ from fetchpdf.fetchpdf import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
fetchpdf/_env.py ADDED
@@ -0,0 +1,63 @@
1
+ """Single source of truth for .env.local loading and API credentials.
2
+
3
+ Imported for its side effects at package import time, not lazily: callers such
4
+ as birds_eye_review_code/5_download_pdfs.py load their own .env before
5
+ importing fetchpdf precisely because these are read at import. Moving them to
6
+ call time would silently degrade those callers to no-email API calls.
7
+ """
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv, find_dotenv
13
+
14
+
15
+ def _load_env_file():
16
+ """Locate and load .env.local, returning its path (or None).
17
+
18
+ Repo root is tried first so an editable install keeps resolving exactly as
19
+ it always has. The CWD search is the fallback that makes a real (non-editable)
20
+ install work at all: there, __file__ points into site-packages/ and the
21
+ repo-root path simply does not exist, so every key silently came back None.
22
+ """
23
+ repo_root = Path(__file__).resolve().parent.parent / ".env.local"
24
+ if repo_root.exists():
25
+ load_dotenv(repo_root)
26
+ return str(repo_root)
27
+
28
+ found = find_dotenv(".env.local", usecwd=True)
29
+ if found:
30
+ load_dotenv(found)
31
+ return found
32
+
33
+ return None
34
+
35
+
36
+ ENV_FILE = _load_env_file()
37
+
38
+ EMAIL = os.getenv("EMAIL")
39
+
40
+ # Rate-limit lifts these keys buy, where the provider documents one:
41
+ S2_API_KEY = os.getenv("SEMANTIC_SCHOLAR_API_KEY") # Semantic Scholar: 1 -> 100 req/s
42
+ ENTREZ_API_KEY = os.getenv("ENTREZ_EUTILS_API_KEY") # NCBI E-utils: 3 -> 10 req/s
43
+ OPENALEX_API_KEY = os.getenv("OPENALEXAPIKEY")
44
+ CORE_API_KEY = os.getenv("COREAPIKEY")
45
+ SCOPUS_API_KEY = os.getenv("SCOPUS_API_KEY")
46
+
47
+ # The retrieval agent's OpenRouter backend. Optional: without it that backend
48
+ # reports itself unavailable and the run falls back to the Claude Code CLI, or
49
+ # to the deterministic rules alone. A missing key is never a failed record.
50
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
51
+
52
+ # Elsevier full-text (TDM) retrieval.
53
+ ELSEVIER_TDM_API_KEY = os.getenv("ELSEVIER_TDM_API_KEY")
54
+
55
+
56
+ if not EMAIL:
57
+ print("\033[93m⚠️ Warning: EMAIL not set in .env.local")
58
+ print(" Please create .env.local with: EMAIL=your@email.com")
59
+ print(" This affects:")
60
+ print(" - Crossref API rate limits (10 req/s with email, 5 req/s without)")
61
+ print(" - Unpaywall API access (required)")
62
+ print(" - Europe PMC contact info (optional)")
63
+ print(" Continuing without email...\033[0m")
fetchpdf/_http.py ADDED
@@ -0,0 +1,40 @@
1
+ """Shared HTTP constants and response checks.
2
+
3
+ Its own module because fetchpdf imports download_from_aa, so the
4
+ latter cannot import back from the former at module scope without a cycle.
5
+ """
6
+
7
+ # Keep the Chrome version current: some hosts (Zenodo among them) 403 stale
8
+ # Chrome UAs as a bot signal, which silently breaks every source on that host.
9
+ #
10
+ # Defined once and referenced everywhere -- previously four copies drifted apart,
11
+ # leaving three Playwright/requests paths on Chrome/120 while the main header
12
+ # said 131, so the bot-signal problem this string exists to avoid still applied
13
+ # to exactly the browser-driven sources most likely to be fingerprinted.
14
+ USER_AGENT = (
15
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
16
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
17
+ "Chrome/131.0.0.0 Safari/537.36"
18
+ )
19
+
20
+
21
+ def elsevier_first_page_only(headers) -> bool:
22
+ """True when Elsevier's X-ELS-Status header marks a first-page-only PDF.
23
+
24
+ A key entitled to a record's XML but not its PDF does not get an error
25
+ from `Accept: application/pdf` -- it gets a genuine typeset FIRST PAGE:
26
+ HTTP 200, %PDF magic, megabyte-scale, real text layer. Nothing in the body
27
+ distinguishes it from the full article; the only in-band signal is
28
+
29
+ X-ELS-Status: WARNING - Response limited to first page because
30
+ requestor not entitled to resource
31
+
32
+ (observed 2026-08-16 on PII S0022510X13030578, where the "success" was
33
+ 1 of 8 pages). Matched case-insensitively on key and value: the live API
34
+ serves the header lowercased over HTTP/2, while requests' own header dict
35
+ is case-insensitive -- callers pass either.
36
+ """
37
+ for key, value in (headers or {}).items():
38
+ if key.lower() == "x-els-status":
39
+ return "limited to first page" in str(value).lower()
40
+ return False