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,661 @@
|
|
|
1
|
+
"""Turn a retrieved XML or HTML artifact into Markdown for an LLM.
|
|
2
|
+
|
|
3
|
+
Prose becomes Markdown. **Tables do not.** Every table is emitted as canonical
|
|
4
|
+
minimal HTML, inline at its position in the document, with its caption and
|
|
5
|
+
footnotes attached.
|
|
6
|
+
|
|
7
|
+
That is the one non-obvious decision here, so the reasoning, plainly:
|
|
8
|
+
|
|
9
|
+
Markdown has no span mechanism. A header cell spanning two treatment arms
|
|
10
|
+
(`<th colspan="2">Cerebrolysin</th>`) cannot be expressed, so the header row
|
|
11
|
+
collapses and every value to its right shifts one column. The result is still
|
|
12
|
+
a valid Markdown table, and a model reads it confidently. Converting XML to
|
|
13
|
+
Markdown tables would reintroduce exactly the corruption the tier ladder was
|
|
14
|
+
built to avoid, one step after paying to avoid it.
|
|
15
|
+
|
|
16
|
+
Inline HTML is part of the Markdown spec (CommonMark defines HTML blocks), so
|
|
17
|
+
this is ordinary Markdown, not a hybrid format. Models see this shape
|
|
18
|
+
constantly in READMEs and docs.
|
|
19
|
+
|
|
20
|
+
Uniformity is the real requirement: EVERY table is HTML, including simple ones
|
|
21
|
+
with no spans where pipes would do. Mixing pipes and HTML would make a
|
|
22
|
+
three-column header ambiguous -- no spans, or spans lost in conversion? -- and
|
|
23
|
+
that ambiguity is worse than either format alone.
|
|
24
|
+
|
|
25
|
+
Figures are recorded, never inlined. JATS references images by filename and
|
|
26
|
+
never contains them, so a `<fig>` becomes a visible placeholder naming what is
|
|
27
|
+
missing. A record whose key outcome lives in a forest plot should look
|
|
28
|
+
incomplete rather than complete.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import xml.etree.ElementTree as ET
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import List, Optional
|
|
36
|
+
|
|
37
|
+
from ._util import iso, localname
|
|
38
|
+
from .normalize import html_table_to_canonical, jats_table_to_canonical, token_count
|
|
39
|
+
|
|
40
|
+
_WS_RE = re.compile(r"\s+")
|
|
41
|
+
|
|
42
|
+
#: Emitted above every table so a reader (human or model) knows the format is
|
|
43
|
+
#: deliberate and that the span attributes carry meaning.
|
|
44
|
+
TABLE_FORMAT_NOTE = (
|
|
45
|
+
"Tables below are HTML, not Markdown: `colspan` and `rowspan` are "
|
|
46
|
+
"authoritative and Markdown cannot express them."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Conversion:
|
|
52
|
+
"""The Markdown, plus what a reader needs to trust or distrust it."""
|
|
53
|
+
|
|
54
|
+
markdown: str
|
|
55
|
+
n_tables: int = 0
|
|
56
|
+
n_tables_empty: int = 0 # table-wrap with no convertible <table>
|
|
57
|
+
n_figures: int = 0 # referenced, never included
|
|
58
|
+
prose_tokens: int = 0
|
|
59
|
+
table_tokens: int = 0
|
|
60
|
+
failures: List[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ok(self) -> bool:
|
|
64
|
+
return bool(self.markdown.strip())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def convert_file(path: str) -> Optional[Conversion]:
|
|
68
|
+
"""Convert one artifact by extension. Returns None for formats with no route."""
|
|
69
|
+
with open(path, "rb") as f:
|
|
70
|
+
content = f.read()
|
|
71
|
+
stem = os.path.basename(path)
|
|
72
|
+
if path.endswith(".xml"):
|
|
73
|
+
return jats_to_markdown(content, name=stem)
|
|
74
|
+
if path.endswith((".fulltext.html", ".html")):
|
|
75
|
+
return html_to_markdown(content, name=stem)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -- JATS -------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def jats_to_markdown(content: bytes, name: str = "") -> Conversion:
|
|
83
|
+
"""JATS/TEI XML -> Markdown, walking the body in document order."""
|
|
84
|
+
result = Conversion(markdown="")
|
|
85
|
+
try:
|
|
86
|
+
root = ET.fromstring(content)
|
|
87
|
+
except ET.ParseError as e:
|
|
88
|
+
result.failures.append(f"XML unparseable: {str(e)[:100]}")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
parts: List[str] = []
|
|
92
|
+
title = _first_text(root, "article-title")
|
|
93
|
+
if title:
|
|
94
|
+
parts.append(f"# {title}\n")
|
|
95
|
+
|
|
96
|
+
# `or` would call bool() on the Element, which ElementTree deprecates (an
|
|
97
|
+
# element with no children is falsey, so `or` would also silently fall
|
|
98
|
+
# through on an empty-but-present <body>).
|
|
99
|
+
body = _find_one(root, "body")
|
|
100
|
+
if body is None:
|
|
101
|
+
body = _find_one(root, "text")
|
|
102
|
+
if body is None:
|
|
103
|
+
result.failures.append("no <body> or TEI <text>; nothing to convert")
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
_walk_jats(body, parts, result, depth=2)
|
|
107
|
+
return _finish(parts, result)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _walk_jats(element, parts: List[str], result: Conversion, depth: int) -> None:
|
|
111
|
+
"""Emit prose and tables in the order they appear.
|
|
112
|
+
|
|
113
|
+
Order matters: a table extracted out of position loses the sentence that
|
|
114
|
+
introduces it, which is often where the units and the sample live.
|
|
115
|
+
"""
|
|
116
|
+
for child in element:
|
|
117
|
+
tag = localname(child.tag)
|
|
118
|
+
|
|
119
|
+
if tag == "sec":
|
|
120
|
+
heading = _first_text(child, "title", direct_only=True)
|
|
121
|
+
if heading:
|
|
122
|
+
parts.append("\n{} {}\n".format("#" * min(depth, 6), heading))
|
|
123
|
+
_walk_jats(child, parts, result, depth + 1)
|
|
124
|
+
|
|
125
|
+
elif tag == "title":
|
|
126
|
+
continue # already consumed by the enclosing <sec>
|
|
127
|
+
|
|
128
|
+
elif tag == "p":
|
|
129
|
+
text = _inline_text(child)
|
|
130
|
+
if text:
|
|
131
|
+
parts.append(text + "\n")
|
|
132
|
+
|
|
133
|
+
elif tag == "table-wrap":
|
|
134
|
+
parts.append(_jats_table_block(child, result))
|
|
135
|
+
|
|
136
|
+
elif tag == "fig":
|
|
137
|
+
label = _first_text(child, "label") or "Figure"
|
|
138
|
+
caption = _first_text(child, "caption")
|
|
139
|
+
graphic = _graphic_href(child)
|
|
140
|
+
result.n_figures += 1
|
|
141
|
+
# Named, never inlined -- JATS references images and never holds them.
|
|
142
|
+
parts.append(
|
|
143
|
+
f"\n> **[{label} ā image not included]** {caption}"
|
|
144
|
+
f"{f' (source file: `{graphic}`)' if graphic else ''}\n"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
elif tag in ("list",):
|
|
148
|
+
for item in child:
|
|
149
|
+
if localname(item.tag) == "list-item":
|
|
150
|
+
text = _inline_text(item)
|
|
151
|
+
if text:
|
|
152
|
+
parts.append(f"- {text}")
|
|
153
|
+
parts.append("")
|
|
154
|
+
|
|
155
|
+
elif tag in ("disp-quote", "boxed-text"):
|
|
156
|
+
text = _inline_text(child)
|
|
157
|
+
if text:
|
|
158
|
+
parts.append(f"> {text}\n")
|
|
159
|
+
|
|
160
|
+
elif tag in ("disp-formula", "inline-formula"):
|
|
161
|
+
formula = _formula_text(child)
|
|
162
|
+
if formula:
|
|
163
|
+
parts.append(f"\n$$ {formula} $$\n")
|
|
164
|
+
|
|
165
|
+
else:
|
|
166
|
+
# Unknown wrapper: descend rather than drop. Losing a whole section
|
|
167
|
+
# because of an unrecognised container is the failure mode to avoid.
|
|
168
|
+
if len(child):
|
|
169
|
+
_walk_jats(child, parts, result, depth)
|
|
170
|
+
else:
|
|
171
|
+
text = _inline_text(child)
|
|
172
|
+
if text:
|
|
173
|
+
parts.append(text + "\n")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _jats_table_block(wrap, result: Conversion) -> str:
|
|
177
|
+
"""One table, as canonical HTML, with caption and footnotes attached.
|
|
178
|
+
|
|
179
|
+
Kept together deliberately: JATS <table-wrap-foot> carries the semantics of
|
|
180
|
+
the values above it ("mean +/- SD unless stated", per-column denominators).
|
|
181
|
+
Separate them and every number in the table becomes misinterpretable.
|
|
182
|
+
"""
|
|
183
|
+
failures: List[str] = []
|
|
184
|
+
table_el = _find_one(wrap, "table")
|
|
185
|
+
html = jats_table_to_canonical(table_el, failures)
|
|
186
|
+
result.failures.extend(failures)
|
|
187
|
+
|
|
188
|
+
label = _first_text(wrap, "label") or "Table"
|
|
189
|
+
caption = _first_text(wrap, "caption")
|
|
190
|
+
lines = ["", f"**{label}.** {caption}".rstrip(".").rstrip() + ""]
|
|
191
|
+
|
|
192
|
+
if html:
|
|
193
|
+
result.n_tables += 1
|
|
194
|
+
result.table_tokens += token_count(html)
|
|
195
|
+
lines.append("")
|
|
196
|
+
lines.append(html)
|
|
197
|
+
else:
|
|
198
|
+
result.n_tables_empty += 1
|
|
199
|
+
graphic = _graphic_href(wrap)
|
|
200
|
+
# A <table-wrap> holding only a <graphic> is a table shipped as an image:
|
|
201
|
+
# zero cells, nothing extractable. Say so rather than emit an empty block.
|
|
202
|
+
lines.append("")
|
|
203
|
+
lines.append(
|
|
204
|
+
f"> **[table not machine-readable ā published as an image]**"
|
|
205
|
+
f"{f' (source file: `{graphic}`)' if graphic else ''}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
footnotes = _jats_footnotes(wrap)
|
|
209
|
+
if footnotes:
|
|
210
|
+
lines.append("")
|
|
211
|
+
lines.append("*Table footnotes:*")
|
|
212
|
+
lines.extend(f"- {note}" for note in footnotes)
|
|
213
|
+
lines.append("")
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _jats_footnotes(wrap) -> List[str]:
|
|
218
|
+
notes, seen = [], set()
|
|
219
|
+
for el in wrap.iter():
|
|
220
|
+
if localname(el.tag) in ("table-wrap-foot", "fn"):
|
|
221
|
+
text = _clean(" ".join(el.itertext()))
|
|
222
|
+
if text and text not in seen:
|
|
223
|
+
seen.add(text)
|
|
224
|
+
notes.append(text)
|
|
225
|
+
return notes
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _graphic_href(element) -> str:
|
|
229
|
+
for el in element.iter():
|
|
230
|
+
if localname(el.tag) in ("graphic", "inline-graphic"):
|
|
231
|
+
for key, value in el.attrib.items():
|
|
232
|
+
if key.endswith("href"):
|
|
233
|
+
return value
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _formula_text(element) -> str:
|
|
238
|
+
for el in element.iter():
|
|
239
|
+
for key, value in el.attrib.items():
|
|
240
|
+
# LaTeX preserved in MathML alttext -- arXiv's LaTeXML HTML always
|
|
241
|
+
# carries it, and it is strictly better than the rendered glyphs.
|
|
242
|
+
if key.endswith("alttext") and value.strip():
|
|
243
|
+
return value.strip()
|
|
244
|
+
return _clean(" ".join(element.itertext()))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# -- publisher HTML ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def html_to_markdown(content: bytes, name: str = "") -> Conversion:
|
|
251
|
+
"""Publisher HTML -> Markdown, after stripping page chrome."""
|
|
252
|
+
result = Conversion(markdown="")
|
|
253
|
+
try:
|
|
254
|
+
import lxml.html
|
|
255
|
+
except ImportError:
|
|
256
|
+
result.failures.append("lxml not installed; install fetchpdf[html]")
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
text = content.decode("utf-8", errors="replace")
|
|
260
|
+
try:
|
|
261
|
+
tree = lxml.html.fromstring(text)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
result.failures.append(f"HTML unparseable: {str(e)[:100]}")
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
before = token_count(text)
|
|
267
|
+
_strip_chrome(tree)
|
|
268
|
+
after = token_count(" ".join(tree.itertext()))
|
|
269
|
+
if before and after / before > 0.95:
|
|
270
|
+
# A stripper that removed nothing means nav menus and cookie banners are
|
|
271
|
+
# about to be fed to the model as if they were the paper.
|
|
272
|
+
result.failures.append(
|
|
273
|
+
f"boilerplate stripper removed almost nothing (ratio {after / before:.2f})"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
parts: List[str] = []
|
|
277
|
+
root = _main_content(tree)
|
|
278
|
+
title = tree.findtext(".//title") or ""
|
|
279
|
+
if title.strip():
|
|
280
|
+
parts.append(f"# {_clean(title)}\n")
|
|
281
|
+
|
|
282
|
+
_walk_html(root, parts, result)
|
|
283
|
+
return _finish(parts, result)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _strip_chrome(tree) -> None:
|
|
287
|
+
from .normalize import _BOILERPLATE_HINTS, _BOILERPLATE_TAGS
|
|
288
|
+
|
|
289
|
+
for el in tree.xpath("//*"):
|
|
290
|
+
name = localname(el.tag)
|
|
291
|
+
parent = el.getparent()
|
|
292
|
+
if parent is None:
|
|
293
|
+
continue
|
|
294
|
+
if name in _BOILERPLATE_TAGS:
|
|
295
|
+
parent.remove(el)
|
|
296
|
+
continue
|
|
297
|
+
marker = " ".join(
|
|
298
|
+
filter(None, [el.get("class") or "", el.get("id") or "", el.get("role") or ""])
|
|
299
|
+
).lower()
|
|
300
|
+
if marker and any(hint in marker for hint in _BOILERPLATE_HINTS):
|
|
301
|
+
parent.remove(el)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _main_content(tree):
|
|
305
|
+
"""The article subtree if the page marks one, else the whole body."""
|
|
306
|
+
for xpath in ('//article', '//*[@role="main"]', '//main', '//body'):
|
|
307
|
+
found = tree.xpath(xpath)
|
|
308
|
+
if found:
|
|
309
|
+
return found[0]
|
|
310
|
+
return tree
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _walk_html(element, parts: List[str], result: Conversion) -> None:
|
|
314
|
+
for child in element:
|
|
315
|
+
tag = localname(child.tag)
|
|
316
|
+
|
|
317
|
+
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
318
|
+
text = _clean(" ".join(child.itertext()))
|
|
319
|
+
if text:
|
|
320
|
+
parts.append("\n{} {}\n".format("#" * int(tag[1]), text))
|
|
321
|
+
|
|
322
|
+
elif tag == "p":
|
|
323
|
+
text = _clean(" ".join(child.itertext()))
|
|
324
|
+
if text:
|
|
325
|
+
parts.append(text + "\n")
|
|
326
|
+
|
|
327
|
+
elif tag == "table":
|
|
328
|
+
parts.append(_html_table_block(child, result))
|
|
329
|
+
|
|
330
|
+
elif tag in ("ul", "ol"):
|
|
331
|
+
for item in child:
|
|
332
|
+
if localname(item.tag) == "li":
|
|
333
|
+
text = _clean(" ".join(item.itertext()))
|
|
334
|
+
if text:
|
|
335
|
+
parts.append(f"- {text}")
|
|
336
|
+
parts.append("")
|
|
337
|
+
|
|
338
|
+
elif tag in ("figure", "img"):
|
|
339
|
+
alt = child.get("alt") or ""
|
|
340
|
+
src = child.get("src") or _first_img_src(child)
|
|
341
|
+
result.n_figures += 1
|
|
342
|
+
parts.append(
|
|
343
|
+
f"\n> **[figure ā image not included]** {_clean(alt)}"
|
|
344
|
+
f"{f' (source: `{src}`)' if src else ''}\n"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
elif tag in ("script", "style"):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
else:
|
|
351
|
+
if len(child):
|
|
352
|
+
_walk_html(child, parts, result)
|
|
353
|
+
else:
|
|
354
|
+
text = _clean(child.text or "")
|
|
355
|
+
if text:
|
|
356
|
+
parts.append(text + "\n")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _html_table_block(table, result: Conversion) -> str:
|
|
360
|
+
failures: List[str] = []
|
|
361
|
+
html = html_table_to_canonical(table, failures)
|
|
362
|
+
result.failures.extend(failures)
|
|
363
|
+
if not html:
|
|
364
|
+
result.n_tables_empty += 1
|
|
365
|
+
return "\n> **[table had no readable cells]**\n"
|
|
366
|
+
|
|
367
|
+
result.n_tables += 1
|
|
368
|
+
result.table_tokens += token_count(html)
|
|
369
|
+
|
|
370
|
+
caption = ""
|
|
371
|
+
for el in table.iter():
|
|
372
|
+
if localname(el.tag) == "caption":
|
|
373
|
+
caption = _clean(" ".join(el.itertext()))
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
lines = [""]
|
|
377
|
+
lines.append(f"**Table.** {caption}" if caption else "**Table.**")
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append(html)
|
|
380
|
+
|
|
381
|
+
footer = [
|
|
382
|
+
_clean(" ".join(el.itertext()))
|
|
383
|
+
for el in table.iter()
|
|
384
|
+
if localname(el.tag) == "tfoot"
|
|
385
|
+
]
|
|
386
|
+
if any(footer):
|
|
387
|
+
lines.append("")
|
|
388
|
+
lines.append("*Table footnotes:*")
|
|
389
|
+
lines.extend(f"- {note}" for note in footer if note)
|
|
390
|
+
lines.append("")
|
|
391
|
+
return "\n".join(lines)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _first_img_src(element) -> str:
|
|
395
|
+
for el in element.iter():
|
|
396
|
+
if localname(el.tag) == "img" and el.get("src"):
|
|
397
|
+
return el.get("src")
|
|
398
|
+
return ""
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# -- shared -----------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _finish(parts: List[str], result: Conversion) -> Conversion:
|
|
405
|
+
body = "\n".join(p for p in parts if p is not None)
|
|
406
|
+
body = re.sub(r"\n{3,}", "\n\n", body).strip() + "\n"
|
|
407
|
+
result.markdown = body
|
|
408
|
+
result.prose_tokens = max(token_count(body) - result.table_tokens, 0)
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _find_one(root, name: str):
|
|
413
|
+
for el in root.iter():
|
|
414
|
+
if localname(el.tag) == name:
|
|
415
|
+
return el
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _first_text(element, name: str, direct_only: bool = False) -> str:
|
|
420
|
+
source = list(element) if direct_only else element.iter()
|
|
421
|
+
for el in source:
|
|
422
|
+
if localname(el.tag) == name:
|
|
423
|
+
return _clean(" ".join(el.itertext()))
|
|
424
|
+
return ""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _inline_text(element) -> str:
|
|
428
|
+
"""Paragraph text with superscript markers kept as literal characters.
|
|
429
|
+
|
|
430
|
+
A superscript 'a' is the link between a value and the footnote defining it,
|
|
431
|
+
so it is bracketed to survive as visible text. <xref> is NOT bracketed: JATS
|
|
432
|
+
prose almost always supplies its own delimiters around a citation, and adding
|
|
433
|
+
ours turned every reference into "[[1]]".
|
|
434
|
+
"""
|
|
435
|
+
parts = []
|
|
436
|
+
for node in element.iter():
|
|
437
|
+
tag = localname(node.tag)
|
|
438
|
+
if node is not element and tag == "sup":
|
|
439
|
+
if node.text:
|
|
440
|
+
parts.append(f"[{node.text.strip()}]")
|
|
441
|
+
if node.tail:
|
|
442
|
+
parts.append(node.tail)
|
|
443
|
+
continue
|
|
444
|
+
if node is not element and tag == "xref":
|
|
445
|
+
if node.text:
|
|
446
|
+
parts.append(node.text.strip())
|
|
447
|
+
if node.tail:
|
|
448
|
+
parts.append(node.tail)
|
|
449
|
+
continue
|
|
450
|
+
if node is element:
|
|
451
|
+
if node.text:
|
|
452
|
+
parts.append(node.text)
|
|
453
|
+
else:
|
|
454
|
+
if node.text:
|
|
455
|
+
parts.append(node.text)
|
|
456
|
+
if node.tail:
|
|
457
|
+
parts.append(node.tail)
|
|
458
|
+
return _clean("".join(parts))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _clean(text: str) -> str:
|
|
462
|
+
return _WS_RE.sub(" ", text or "").strip()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def front_matter(path: str, conversion: Conversion, extra: Optional[dict] = None) -> str:
|
|
466
|
+
"""YAML header recording what this file is and how far to trust it."""
|
|
467
|
+
import time
|
|
468
|
+
|
|
469
|
+
fields = {
|
|
470
|
+
"source_artifact": os.path.basename(path),
|
|
471
|
+
"converted_at": iso(time.time()),
|
|
472
|
+
"table_format": "canonical-html",
|
|
473
|
+
"tables": conversion.n_tables,
|
|
474
|
+
"tables_not_machine_readable": conversion.n_tables_empty,
|
|
475
|
+
"figures_referenced_not_included": conversion.n_figures,
|
|
476
|
+
"prose_tokens": conversion.prose_tokens,
|
|
477
|
+
"table_tokens": conversion.table_tokens,
|
|
478
|
+
}
|
|
479
|
+
fields.update(extra or {})
|
|
480
|
+
lines = ["---"]
|
|
481
|
+
for key, value in fields.items():
|
|
482
|
+
lines.append(f"{key}: {value}")
|
|
483
|
+
if conversion.failures:
|
|
484
|
+
lines.append("conversion_warnings:")
|
|
485
|
+
lines.extend(f" - {f}" for f in dict.fromkeys(conversion.failures))
|
|
486
|
+
lines.append("---")
|
|
487
|
+
lines.append("")
|
|
488
|
+
lines.append(f"<!-- {TABLE_FORMAT_NOTE} -->")
|
|
489
|
+
lines.append("")
|
|
490
|
+
lines.append("")
|
|
491
|
+
return "\n".join(lines)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# -- writing them out -------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
#: Artifact suffixes this module can convert, mapped to the markdown suffix each
|
|
497
|
+
#: produces. The source is in the FILENAME, not just the front matter, because a
|
|
498
|
+
#: consumer that globs a directory reads nothing else -- and JATS from the
|
|
499
|
+
#: publisher and scraped publisher HTML do not deserve equal trust. One is the
|
|
500
|
+
#: markup the publisher deposited; the other is what their web page happened to
|
|
501
|
+
#: render, after a boilerplate stripper had a go at it.
|
|
502
|
+
CONVERTIBLE_SUFFIXES = {
|
|
503
|
+
".xml": "_from_xml.md",
|
|
504
|
+
".fulltext.html": "_from_html.md",
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#: Best source first, for callers choosing between two artifacts of one record.
|
|
508
|
+
CONVERTIBLE = tuple(CONVERTIBLE_SUFFIXES)
|
|
509
|
+
|
|
510
|
+
MARKDOWN_SUFFIX = "_from_xml.md" # the default when the source is unknown
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def markdown_path_for(artifact_path: str) -> str:
|
|
514
|
+
"""`{stem}_from_xml.md` / `{stem}_from_html.md`, beside its source artifact."""
|
|
515
|
+
for suffix, markdown_suffix in CONVERTIBLE_SUFFIXES.items():
|
|
516
|
+
if artifact_path.endswith(suffix):
|
|
517
|
+
return artifact_path[: -len(suffix)] + markdown_suffix
|
|
518
|
+
return os.path.splitext(artifact_path)[0] + MARKDOWN_SUFFIX
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def record_stem_for(artifact_path: str) -> str:
|
|
522
|
+
"""The record's stem, with any artifact suffix removed."""
|
|
523
|
+
for suffix in CONVERTIBLE_SUFFIXES:
|
|
524
|
+
if artifact_path.endswith(suffix):
|
|
525
|
+
return artifact_path[: -len(suffix)]
|
|
526
|
+
return os.path.splitext(artifact_path)[0]
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def write_markdown(artifact_path: str, overwrite: bool = False,
|
|
530
|
+
verbose: bool = False) -> Optional[str]:
|
|
531
|
+
"""Convert one artifact and write `{stem}.md`. Returns the path, or None.
|
|
532
|
+
|
|
533
|
+
Never raises into a batch: a document that will not convert is one bad
|
|
534
|
+
record, not a dead run.
|
|
535
|
+
"""
|
|
536
|
+
out_path = markdown_path_for(artifact_path)
|
|
537
|
+
if os.path.exists(out_path) and not overwrite:
|
|
538
|
+
return out_path
|
|
539
|
+
try:
|
|
540
|
+
conversion = convert_file(artifact_path)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
if verbose:
|
|
543
|
+
print(f" markdown: {os.path.basename(artifact_path)} raised {type(e).__name__}: {str(e)[:90]}")
|
|
544
|
+
return None
|
|
545
|
+
if conversion is None or not conversion.ok:
|
|
546
|
+
if verbose:
|
|
547
|
+
reason = "; ".join(conversion.failures) if conversion else "unsupported format"
|
|
548
|
+
print(f" markdown: {os.path.basename(artifact_path)} -> nothing ({reason})")
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
text = front_matter(artifact_path, conversion) + conversion.markdown
|
|
552
|
+
tmp = out_path + ".tmp"
|
|
553
|
+
try:
|
|
554
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
555
|
+
f.write(text)
|
|
556
|
+
os.replace(tmp, out_path)
|
|
557
|
+
except OSError as e:
|
|
558
|
+
if verbose:
|
|
559
|
+
print(f" markdown: could not write {out_path}: {e}")
|
|
560
|
+
return None
|
|
561
|
+
if verbose:
|
|
562
|
+
print(
|
|
563
|
+
f" markdown: {os.path.basename(out_path)} "
|
|
564
|
+
f"({conversion.n_tables} tables, {conversion.n_figures} figures referenced)"
|
|
565
|
+
)
|
|
566
|
+
return out_path
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def convert_directory(directory: str, overwrite: bool = False, verbose: bool = False) -> dict:
|
|
570
|
+
"""Convert every convertible artifact in a directory. Returns a summary."""
|
|
571
|
+
summary = {"converted": 0, "skipped": 0, "failed": 0,
|
|
572
|
+
"tables": 0, "image_tables": 0, "figures": 0, "no_structured": 0}
|
|
573
|
+
|
|
574
|
+
# Group by record first, then pick the best source for each. Iterating
|
|
575
|
+
# os.listdir() order instead would let ".fulltext.html" win on a record that
|
|
576
|
+
# has both, purely because it sorts before ".xml" -- silently preferring
|
|
577
|
+
# scraped publisher HTML over the publisher's own JATS.
|
|
578
|
+
best_for_record = {}
|
|
579
|
+
for name in sorted(os.listdir(directory)):
|
|
580
|
+
path = os.path.join(directory, name)
|
|
581
|
+
if not name.endswith(CONVERTIBLE) or not os.path.isfile(path):
|
|
582
|
+
continue
|
|
583
|
+
record = record_stem_for(path)
|
|
584
|
+
rank = next(i for i, suffix in enumerate(CONVERTIBLE) if name.endswith(suffix))
|
|
585
|
+
existing = best_for_record.get(record)
|
|
586
|
+
if existing is None or rank < existing[0]:
|
|
587
|
+
if existing is not None:
|
|
588
|
+
summary["skipped"] += 1
|
|
589
|
+
best_for_record[record] = (rank, path)
|
|
590
|
+
else:
|
|
591
|
+
summary["skipped"] += 1
|
|
592
|
+
|
|
593
|
+
for record, (_rank, path) in sorted(best_for_record.items()):
|
|
594
|
+
out_path = markdown_path_for(path)
|
|
595
|
+
existed = os.path.exists(out_path)
|
|
596
|
+
conversion = None
|
|
597
|
+
try:
|
|
598
|
+
conversion = convert_file(path)
|
|
599
|
+
except Exception:
|
|
600
|
+
pass
|
|
601
|
+
written = write_markdown(path, overwrite=overwrite, verbose=verbose)
|
|
602
|
+
if written is None:
|
|
603
|
+
summary["failed"] += 1
|
|
604
|
+
elif existed and not overwrite:
|
|
605
|
+
summary["skipped"] += 1
|
|
606
|
+
else:
|
|
607
|
+
summary["converted"] += 1
|
|
608
|
+
if conversion:
|
|
609
|
+
summary["tables"] += conversion.n_tables
|
|
610
|
+
summary["image_tables"] += conversion.n_tables_empty
|
|
611
|
+
summary["figures"] += conversion.n_figures
|
|
612
|
+
|
|
613
|
+
# A PDF with no structured sibling has no markdown route here at all.
|
|
614
|
+
pdfs = {n[:-4] for n in os.listdir(directory) if n.endswith(".pdf")}
|
|
615
|
+
summary["no_structured"] = len(pdfs - {os.path.basename(r) for r in best_for_record})
|
|
616
|
+
return summary
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def main(argv=None):
|
|
620
|
+
"""Convert a directory of retrieved artifacts to Markdown."""
|
|
621
|
+
import argparse
|
|
622
|
+
|
|
623
|
+
parser = argparse.ArgumentParser(
|
|
624
|
+
prog="fetchpdf-md",
|
|
625
|
+
description=(
|
|
626
|
+
"Convert retrieved .xml / .fulltext.html artifacts into Markdown. "
|
|
627
|
+
"Prose becomes Markdown; tables stay canonical HTML so colspan and "
|
|
628
|
+
"rowspan survive, because Markdown cannot express them."
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
parser.add_argument("directory", help="Directory of artifacts (the -o dir from a run)")
|
|
632
|
+
parser.add_argument("--overwrite", action="store_true",
|
|
633
|
+
help="Rewrite .md files that already exist")
|
|
634
|
+
parser.add_argument("--verbose", "-v", action="store_true")
|
|
635
|
+
args = parser.parse_args(argv)
|
|
636
|
+
|
|
637
|
+
if not os.path.isdir(args.directory):
|
|
638
|
+
print(f"ā Not a directory: {args.directory}")
|
|
639
|
+
return 1
|
|
640
|
+
|
|
641
|
+
summary = convert_directory(args.directory, overwrite=args.overwrite, verbose=args.verbose)
|
|
642
|
+
print(f"\nš Markdown conversion ā {args.directory}")
|
|
643
|
+
print(f" converted {summary['converted']}")
|
|
644
|
+
print(f" already present {summary['skipped']}")
|
|
645
|
+
print(f" failed {summary['failed']}")
|
|
646
|
+
print(f" tables {summary['tables']}")
|
|
647
|
+
if summary["image_tables"]:
|
|
648
|
+
print(f" tables as images {summary['image_tables']} (not machine-readable)")
|
|
649
|
+
print(f" figures noted {summary['figures']} (referenced, never embedded)")
|
|
650
|
+
if summary["no_structured"]:
|
|
651
|
+
print(
|
|
652
|
+
f" PDF-only records {summary['no_structured']} "
|
|
653
|
+
f"(no XML/HTML to convert ā these still need the PDF pipeline)"
|
|
654
|
+
)
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
if __name__ == "__main__":
|
|
659
|
+
import sys
|
|
660
|
+
|
|
661
|
+
sys.exit(main())
|