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
@@ -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())