mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
"""A book to rows a database can take, without a subprocess in sight.
|
|||
|
|
|
||
|
|
This is the shape a pipeline stage ends up as once the reading is a library
|
||
|
|
call: convert, then loop, and what is left is the part that is actually about
|
||
|
|
your schema.
|
||
|
|
|
||
|
|
python examples/python/book_to_citations.py path/to/book.epub
|
||
|
|
|
||
|
|
With no argument it fetches Gibbon's first volume from Project Gutenberg,
|
||
|
|
which is public domain and has a few hundred pages of footnotes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pathlib
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import urllib.request
|
||
|
|
|
||
|
|
import catena
|
||
|
|
|
||
|
|
GUTENBERG = "https://www.gutenberg.org/cache/epub/731/pg731.epub"
|
||
|
|
|
||
|
|
|
||
|
|
def fetch(into: pathlib.Path) -> pathlib.Path:
|
||
|
|
"""The sample book, downloaded once."""
|
||
|
|
book = into / "gibbon.epub"
|
||
|
|
if not book.exists():
|
||
|
|
into.mkdir(parents=True, exist_ok=True)
|
||
|
|
print(f"fetching {GUTENBERG}")
|
||
|
|
with urllib.request.urlopen(GUTENBERG, timeout=120) as reply: # noqa: S310
|
||
|
|
book.write_bytes(reply.read())
|
||
|
|
return book
|
||
|
|
|
||
|
|
|
||
|
|
def to_markdown(book: pathlib.Path) -> str:
|
||
|
|
"""Pandoc is the one part of this that is somebody else's program.
|
||
|
|
|
||
|
|
`commonmark` rather than `commonmark_x`: the extended flavour writes
|
||
|
|
anchors as bracketed spans, and they end up inside the passages.
|
||
|
|
"""
|
||
|
|
done = subprocess.run(
|
||
|
|
["pandoc", "-f", "epub", "-t", "commonmark", "--wrap=none", str(book)],
|
||
|
|
capture_output=True,
|
||
|
|
text=True,
|
||
|
|
check=True,
|
||
|
|
)
|
||
|
|
return done.stdout
|
||
|
|
|
||
|
|
|
||
|
|
def rows(markdown: str) -> list[dict]:
|
||
|
|
"""Passages in, citation rows out.
|
||
|
|
|
||
|
|
Two library calls do the work. `chunk_markdown` cuts the book into
|
||
|
|
passages and flags the ones carrying citation material — which is the
|
||
|
|
whole reason to ask it rather than looking for a heading, since a page of
|
||
|
|
notes at the foot of a chapter has no heading over it. `find_citations`
|
||
|
|
then reads each of those.
|
||
|
|
"""
|
||
|
|
out = []
|
||
|
|
for chunk in catena.chunk_markdown(catena.scrub(markdown)):
|
||
|
|
if "footnote_citations" not in chunk["flags"] and chunk["role"] != "bibliography":
|
||
|
|
continue
|
||
|
|
for found in catena.find_citations(chunk["text"]):
|
||
|
|
# An `ibid.` or a short cite points at an entry above rather than
|
||
|
|
# naming a work of its own, so there is nothing to store.
|
||
|
|
if found["continuation"]:
|
||
|
|
continue
|
||
|
|
if not (found["title"] or found["authors"] or found["ids"]):
|
||
|
|
continue
|
||
|
|
out.append(
|
||
|
|
{
|
||
|
|
"page": chunk["page"],
|
||
|
|
"chapter": chunk["chapter"],
|
||
|
|
"citation_text": found["text"],
|
||
|
|
"referenced_work": found["title"],
|
||
|
|
"referenced_author": "; ".join(found["authors"]) or None,
|
||
|
|
"referenced_year": found["year"],
|
||
|
|
"referenced_container": found["container"],
|
||
|
|
"kind": found["kind"],
|
||
|
|
"unsettled": found["unsettled"],
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
where = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else fetch(pathlib.Path("work"))
|
||
|
|
|
||
|
|
print(f"== {where.name}")
|
||
|
|
identified = catena.identify(where.name, where.read_bytes())
|
||
|
|
print(f" a {identified['kind']}, {identified['bytes']} bytes")
|
||
|
|
|
||
|
|
markdown = to_markdown(where)
|
||
|
|
passages = catena.chunk_markdown(catena.scrub(markdown))
|
||
|
|
carrying = [c for c in passages if "footnote_citations" in c["flags"]]
|
||
|
|
print(f" {len(passages)} passages, {len(carrying)} carrying citation material")
|
||
|
|
|
||
|
|
found = rows(markdown)
|
||
|
|
named = [r for r in found if r["referenced_work"]]
|
||
|
|
print(f"\n== {len(found)} rows, {len(named)} naming a work")
|
||
|
|
|
||
|
|
# What a stage like this would do next is its own business: one executemany
|
||
|
|
# against your own table. The point is that everything above it is two
|
||
|
|
# function calls rather than a parser you have to keep.
|
||
|
|
print("\n INSERT INTO citations (citation_text, referenced_work, referenced_author, …)")
|
||
|
|
whole = [r for r in named if r["referenced_author"]] or named
|
||
|
|
for row in whole[:5]:
|
||
|
|
work = (row["referenced_work"] or "")[:40]
|
||
|
|
who = (row["referenced_author"] or "—")[:22]
|
||
|
|
print(f" (…, {work!r}, {who!r}, {row['referenced_year']})")
|
||
|
|
|
||
|
|
# Nothing is invented. Where a field could not be read the record says so
|
||
|
|
# rather than guessing, and a caller that wants a model to fill the gaps
|
||
|
|
# has `citation_prompt` and `merge_citation_reply` for exactly that.
|
||
|
|
# Gibbon cites in the classical short form — `Tacit. Annal. xiii. 5.` —
|
||
|
|
# so most of his notes name a passage rather than a book. Nothing is
|
||
|
|
# invented to cover that: the record says which fields it could not read,
|
||
|
|
# and `citation_prompt` and `merge_citation_reply` are there for a caller
|
||
|
|
# that wants a model to finish them.
|
||
|
|
unsettled = [r for r in found if r["unsettled"]]
|
||
|
|
print(f"\n {len(unsettled)} rows name something they could not finish reading:")
|
||
|
|
for row in unsettled[:3]:
|
||
|
|
print(f" {row['citation_text'][:56]} → {', '.join(row['unsettled'])}")
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|