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,213 @@
|
|
|
1
|
+
"""The format tier ladder, as declarative config rather than scattered branches.
|
|
2
|
+
|
|
3
|
+
Tier ordering is the whole point of this package, so it is a first-class
|
|
4
|
+
IntEnum rather than a lookup table that can drift out of sync with the ladder.
|
|
5
|
+
|
|
6
|
+
Note that the enum's *numeric* order is the extraction preference, not a
|
|
7
|
+
universal one: the screening ladder ranks T6 above T5, so "better than what is
|
|
8
|
+
on disk" (which --upgrade-existing needs) is a position on the ACTIVE ladder,
|
|
9
|
+
not an int comparison. See engine._rank_of.
|
|
10
|
+
|
|
11
|
+
The ladder itself, the tier->sources map and the per-host rate limits live in
|
|
12
|
+
ladder.json so they can be reordered without editing logic. Set FETCHPDF_LADDER
|
|
13
|
+
to a path to override the shipped file wholesale.
|
|
14
|
+
|
|
15
|
+
JSON, not TOML: tomllib landed in 3.11 and pyproject declares
|
|
16
|
+
requires-python = ">=3.8". A tomli dependency to read our own config would be a
|
|
17
|
+
poor trade.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from enum import IntEnum
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Tier(IntEnum):
|
|
27
|
+
"""Format tiers, ordered by how much table structure survives to the model.
|
|
28
|
+
|
|
29
|
+
Lower is better. The numbering is load-bearing -- see module docstring.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
T1_XML = 1 # JATS/TEI. Cells, spans, headers, footnotes are markup.
|
|
33
|
+
T2_HTML = 2 # Publisher HTML. Real <table>; usually generated from the same JATS.
|
|
34
|
+
T3_SOURCE = 3 # LaTeX/e-print. Lossless in principle, macro-hostile in practice.
|
|
35
|
+
T4_SUPPLEMENT = 4 # .xlsx/.csv/.tsv/.docx. Often the underlying data; partial coverage.
|
|
36
|
+
T5_PDF = 5 # Structure must be reconstructed. Undetectable numeric corruption.
|
|
37
|
+
T6_PLAINTEXT = 6 # Flattened. Destroys row/column association SILENTLY.
|
|
38
|
+
T7_LANDING = 7 # Landing page / abstract-only / search fallback.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#: Default on-disk suffix per tier. Sources may override via Artifact.extension
|
|
42
|
+
#: (a supplement may legitimately be .xlsx rather than the .suppl.zip default).
|
|
43
|
+
TIER_EXTENSIONS = {
|
|
44
|
+
Tier.T1_XML: ".xml",
|
|
45
|
+
Tier.T2_HTML: ".fulltext.html",
|
|
46
|
+
Tier.T3_SOURCE: ".source.tar.gz",
|
|
47
|
+
Tier.T4_SUPPLEMENT: ".suppl.zip",
|
|
48
|
+
Tier.T5_PDF: ".pdf",
|
|
49
|
+
Tier.T6_PLAINTEXT: ".txt",
|
|
50
|
+
Tier.T7_LANDING: ".landing.html",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Every suffix the retrieval layer may write, best tier first: the
|
|
54
|
+
#: skip-if-exists check takes the first hit.
|
|
55
|
+
ARTIFACT_EXTENSIONS = [TIER_EXTENSIONS[t] for t in sorted(TIER_EXTENSIONS)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_LADDER_FILENAME = "ladder.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LadderConfigError(ValueError):
|
|
62
|
+
"""Raised at load time for a malformed ladder config.
|
|
63
|
+
|
|
64
|
+
Deliberately fatal: a typo that silently drops a tier would not surface
|
|
65
|
+
until some record thousands of rows into a batch quietly came back as a PDF.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SourceSpec:
|
|
70
|
+
"""One configured source: what it can serve, and what it needs to run."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, name, spec):
|
|
73
|
+
self.name = name
|
|
74
|
+
self.callable_ref = spec["callable"] # "module:function"
|
|
75
|
+
self.requires = tuple(spec.get("requires", ())) # IdentifierSet fields
|
|
76
|
+
self.enabled = bool(spec.get("enabled", True))
|
|
77
|
+
self.notes = spec.get("notes", "")
|
|
78
|
+
self._fn = None
|
|
79
|
+
|
|
80
|
+
def applicable(self, ids) -> bool:
|
|
81
|
+
"""True when every identifier this source needs has been resolved."""
|
|
82
|
+
if not self.enabled:
|
|
83
|
+
return False
|
|
84
|
+
return all(getattr(ids, field, None) for field in self.requires)
|
|
85
|
+
|
|
86
|
+
def resolve(self):
|
|
87
|
+
"""Import and cache the source callable. Raises LadderConfigError if absent."""
|
|
88
|
+
if self._fn is not None:
|
|
89
|
+
return self._fn
|
|
90
|
+
module_name, _, func_name = self.callable_ref.partition(":")
|
|
91
|
+
if not module_name or not func_name:
|
|
92
|
+
raise LadderConfigError(
|
|
93
|
+
f"source {self.name!r}: callable must be 'module:function', "
|
|
94
|
+
f"got {self.callable_ref!r}"
|
|
95
|
+
)
|
|
96
|
+
import importlib
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
module = importlib.import_module(
|
|
100
|
+
f"{__package__}.sources.{module_name}"
|
|
101
|
+
)
|
|
102
|
+
except ImportError as e:
|
|
103
|
+
raise LadderConfigError(
|
|
104
|
+
f"source {self.name!r}: cannot import sources.{module_name} ({e})"
|
|
105
|
+
)
|
|
106
|
+
fn = getattr(module, func_name, None)
|
|
107
|
+
if fn is None or not callable(fn):
|
|
108
|
+
raise LadderConfigError(
|
|
109
|
+
f"source {self.name!r}: sources.{module_name} has no callable {func_name!r}"
|
|
110
|
+
)
|
|
111
|
+
self._fn = fn
|
|
112
|
+
return fn
|
|
113
|
+
|
|
114
|
+
def __repr__(self):
|
|
115
|
+
return f"<SourceSpec {self.name} -> {self.callable_ref}>"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Ladder:
|
|
119
|
+
"""A loaded, validated ladder config."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, raw: dict, path: Optional[str] = None):
|
|
122
|
+
self.path = path
|
|
123
|
+
self.raw = raw
|
|
124
|
+
self.sources: Dict[str, SourceSpec] = {
|
|
125
|
+
name: SourceSpec(name, spec) for name, spec in raw.get("sources", {}).items()
|
|
126
|
+
}
|
|
127
|
+
self.tier_sources: Dict[Tier, List[str]] = {}
|
|
128
|
+
for tier_name, source_names in raw.get("tier_sources", {}).items():
|
|
129
|
+
self.tier_sources[_tier_by_name(tier_name)] = list(source_names)
|
|
130
|
+
self.ladders: Dict[str, List[Tier]] = {}
|
|
131
|
+
for task, tier_names in raw.get("ladders", {}).items():
|
|
132
|
+
self.ladders[task] = [_tier_by_name(n) for n in tier_names]
|
|
133
|
+
self.rate_limits: Dict[str, dict] = raw.get("rate_limits", {})
|
|
134
|
+
self.thresholds: Dict[str, int] = raw.get("thresholds", {})
|
|
135
|
+
|
|
136
|
+
def for_task(self, target_task: str) -> List[Tier]:
|
|
137
|
+
"""The ordered tier walk for a target task."""
|
|
138
|
+
try:
|
|
139
|
+
return self.ladders[target_task]
|
|
140
|
+
except KeyError:
|
|
141
|
+
raise LadderConfigError(
|
|
142
|
+
f"unknown --target-task {target_task!r}; "
|
|
143
|
+
f"configured: {sorted(self.ladders)}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def sources_for(self, tier: Tier) -> List[SourceSpec]:
|
|
147
|
+
"""Configured, enabled sources for a tier, in declared order."""
|
|
148
|
+
return [
|
|
149
|
+
self.sources[name]
|
|
150
|
+
for name in self.tier_sources.get(tier, [])
|
|
151
|
+
if self.sources[name].enabled
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def threshold(self, key: str, default: int) -> int:
|
|
155
|
+
return int(self.thresholds.get(key, default))
|
|
156
|
+
|
|
157
|
+
def validate(self, resolve_callables: bool = True) -> None:
|
|
158
|
+
"""Fail loudly on a config that would silently misbehave at runtime."""
|
|
159
|
+
if not self.ladders:
|
|
160
|
+
raise LadderConfigError("no ladders configured")
|
|
161
|
+
for task, tiers in self.ladders.items():
|
|
162
|
+
if not tiers:
|
|
163
|
+
raise LadderConfigError(f"ladder {task!r} is empty")
|
|
164
|
+
if len(set(tiers)) != len(tiers):
|
|
165
|
+
raise LadderConfigError(f"ladder {task!r} lists a tier twice")
|
|
166
|
+
for tier, names in self.tier_sources.items():
|
|
167
|
+
for name in names:
|
|
168
|
+
if name not in self.sources:
|
|
169
|
+
raise LadderConfigError(
|
|
170
|
+
f"tier {tier.name} references undefined source {name!r}"
|
|
171
|
+
)
|
|
172
|
+
# A source defined but wired to no tier is dead config -- almost always a
|
|
173
|
+
# rename that only got applied in one place.
|
|
174
|
+
wired = {n for names in self.tier_sources.values() for n in names}
|
|
175
|
+
orphans = sorted(set(self.sources) - wired)
|
|
176
|
+
if orphans:
|
|
177
|
+
raise LadderConfigError(f"sources defined but not wired to any tier: {orphans}")
|
|
178
|
+
if resolve_callables:
|
|
179
|
+
for spec in self.sources.values():
|
|
180
|
+
if spec.enabled:
|
|
181
|
+
spec.resolve()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tier_by_name(name: str) -> Tier:
|
|
185
|
+
try:
|
|
186
|
+
return Tier[name]
|
|
187
|
+
except KeyError:
|
|
188
|
+
raise LadderConfigError(
|
|
189
|
+
f"unknown tier {name!r}; valid: {[t.name for t in Tier]}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def default_ladder_path() -> str:
|
|
194
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), _LADDER_FILENAME)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def load_ladder(path: Optional[str] = None, validate: bool = True) -> Ladder:
|
|
198
|
+
"""Load the ladder config.
|
|
199
|
+
|
|
200
|
+
Precedence: explicit path > $FETCHPDF_LADDER > the shipped ladder.json.
|
|
201
|
+
"""
|
|
202
|
+
path = path or os.getenv("FETCHPDF_LADDER") or default_ladder_path()
|
|
203
|
+
try:
|
|
204
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
205
|
+
raw = json.load(f)
|
|
206
|
+
except OSError as e:
|
|
207
|
+
raise LadderConfigError(f"cannot read ladder config {path}: {e}")
|
|
208
|
+
except json.JSONDecodeError as e:
|
|
209
|
+
raise LadderConfigError(f"ladder config {path} is not valid JSON: {e}")
|
|
210
|
+
ladder = Ladder(raw, path=path)
|
|
211
|
+
if validate:
|
|
212
|
+
ladder.validate()
|
|
213
|
+
return ladder
|