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
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
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
|