Download Pipeline¶
A technical reference for how a raw Project Gutenberg text becomes a
structured Markdown file with full author provenance under corpus/.
This document covers download only — the ingest pipeline (DocKG build +
KGRAG registration) is described in README.md.
1. End-to-End Flow¶
┌─────────────────────────────────┐
│ Project Gutenberg (network) │
│ ───────────────────────────── │
│ OPDS feed .opds │
│ RDF catalog pg<id>.rdf │
│ Plain text pg<id>.txt │
└────────────────┬────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ fetch_metadata(id) ──┐ │
│ ├─ OPDS → title, author, subjects, summary, language, rights │
│ └─ RDF → author_birth, author_death, author_url, │
│ author_agent_id │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ download_book(id) │
│ 1. idempotence check (skip if <slug>.md already exists) │
│ 2. fetch plain text │
│ 3. strip_boilerplate() — remove PG header/footer markers │
│ 4. text_to_markdown() — detect structure, emit headings │
│ 5. write_reference() — metadata sidecar │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ corpus/<genre>/<Title>/ │
│ ├── <slug>.md — full text with Markdown heading tree │
│ └── reference.md — author provenance + Gutenberg metadata │
└─────────────────────────────────────────────────────────────────┘
│
(periodically, after a batch of downloads)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ gutenkg authors (gutenberg_kg.authors.build) │
│ ├─ parse every corpus/*/*/reference.md │
│ ├─ [--refresh] re-fetch RDF for every book and rewrite the │
│ │ Author section of its reference.md │
│ ├─ write corpus/authors/<slug>/author.md (one per author) │
│ └─ write corpus/authors/index.md (master table) │
└─────────────────────────────────────────────────────────────────┘
2. Entry Points¶
| Command | Does |
|---|---|
gutenkg download book <id> [--genre G] |
Single book |
gutenkg download catalog <file> [--genre G] |
Batch from a catalog file |
gutenkg download search "<query>" [--author A] [--title T] [--subject S] [--language L] |
Gutenberg catalog search |
gutenkg download fetch-genre <G> |
Search + confirm + download, whole genre |
gutenkg download survey [--genre G] |
Show what's downloaded/indexed |
gutenkg authors [--refresh] [--dry-run] |
Rebuild author pages |
The gutenkg CLI is the authoritative interface. Scripts are thin wrappers that delegate to it:
src/gutenberg_kg/gutenberg.py— download library (download_book,run_search,run_catalog,run_fetch_genre,run_survey)src/gutenberg_kg/cli/cmd_download.py— Click subcommands (gutenkg download *)src/gutenberg_kg/authors.py— author-corpus logic (used bygutenkg authors)
3. Data Sources¶
Every book pulls from up to three Gutenberg endpoints:
| Endpoint | URL pattern | Used for |
|---|---|---|
| OPDS feed | https://www.gutenberg.org/ebooks/{id}.opds |
Title, author name, subjects, summary, language, rights, published date |
| RDF catalog | https://www.gutenberg.org/cache/epub/{id}/pg{id}.rdf |
Author birth year, death year, Wikipedia URL, Gutenberg agent ID |
| Plain text | https://www.gutenberg.org/cache/epub/{id}/pg{id}.txt |
The book itself (UTF-8 with BOM) |
| Search | https://www.gutenberg.org/ebooks/search.opds/?query=… |
Used only by download search / fetch-genre |
All four live in module-level constants at the top of
src/gutenberg_kg/gutenberg.py.
4. Downloading One Book¶
4.1 Metadata fetch — fetch_metadata(ebook_id)¶
Fetches the OPDS atom entry and extracts:
title—<atom:title>authors-- every<atom:author><atom:name>, as a fallback only: the feed lists co-authors in the reverse of the catalog's order, so the RDF creators (4.2) replace them whenever the RDF has anypublished—<atom:published>(date Gutenberg added the ebook, not original publication)rights—<atom:rights>subjects— all<atom:category term="...">language—<dcterms:language>summary,note— parsed from the XHTML block inside<atom:content>gutenberg_url— synthesized (/ebooks/{id})
Immediately after the OPDS parse, fetch_metadata calls _fetch_rdf_author(id)
and merges the result into the same meta dict (silent failure -- a missing
RDF doesn't break the download). It then sets author, the byline, from
authors with authors.credit: "A", "A and B", or "A, B, and C". A book
whose feed and RDF both name no author, such as the King James Bible, is
credited to authors.NO_AUTHOR, "Various", rather than left blank.
4.2 RDF enrichment — _fetch_rdf_author(ebook_id)¶
The RDF catalog exposes the data the OPDS feed omits. The parsing is in
rdf_authors(xml_text). The authors are the <dcterms:creator> agents, in
the record's order, which puts the primary author first (Dumas before
Maquet). Translators and editors sit under relator terms such as
marcrel:trl, not dcterms:creator, so they are never authors. Each name
goes through authors.display_name, which turns Gutenberg's
"Surname, Forenames, Title" headings into reading order: "Tolstoy, Leo,
graf" becomes "Leo Tolstoy", and "Wells, H. G. (Herbert George)" becomes
"H. G. Wells". Its docstring lists every rule.
The provenance fields describe the first creator:
| Field | Source |
|---|---|
authors |
Every <dcterms:creator> agent's <pgterms:name>, as display names |
author_birth |
<pgterms:birthdate> (year as string, may be negative for BCE) |
author_death |
<pgterms:deathdate> |
author_url |
The English Wikipedia <pgterms:webpage>, else any Wikipedia one |
author_agent_id |
Integer extracted from rdf:about="2009/agents/N" |
In reference.md the first creator is the **Name** line, followed by that
person's Born/Died/Wikipedia/Agent ID lines, and every further creator gets a
**Co-author** line. authors.parse_reference rebuilds authors and the
author byline from those lines. An Internet Archive reference has no Name
line; it records the item's creator as **Author** under ## Publication,
and parse_reference falls back to that.
BCE years are stored as negative integers (Homer: -750 / -650).
4.3 Idempotence check¶
Before downloading text, download_book() checks whether
corpus/<genre>/<title>/<slug>.md already exists. If yes, it prints [=] already
downloaded and returns early. Use --force to override.
Metadata fetch happens before the idempotence check because it provides the title needed to compute the output path.
4.4 Text download¶
Hits pg{id}.txt with retries + exponential backoff (fetch_url():
3 attempts, 2×/4× backoff). Always decodes as utf-8-sig to transparently
strip the Gutenberg BOM.
4.5 Boilerplate stripping — strip_boilerplate()¶
Removes everything before *** START OF THE PROJECT GUTENBERG EBOOK … *** and
everything after *** END OF THE PROJECT GUTENBERG EBOOK … ***. If markers
aren't found the full text is kept (better to have noise than to lose content).
4.6 Structure detection — text_to_markdown()¶
Two pre-passes before heading detection:
- Front-matter skip — drops "Produced by…", "Transcribed by…", "E-text prepared by…" credit blocks if they appear at the top.
- Table-of-contents skip — detects a
CONTENTS/TABLE OF CONTENTSblock in the first ~200 lines and excludes it from the output (redundant once headings are emitted structurally).
Heading detection (_is_heading()) walks the HEADING_PATTERNS list in
priority order. Supported patterns:
| Pattern | Example | Level |
|---|---|---|
THE FIRST/SECOND/… BOOK |
THE FIRST BOOK (Meditations) |
## |
VOLUME/BOOK/PART + numeral |
BOOK THE FIRST, VOLUME II, PART III |
## |
Book the First — Recalled to Life |
Dickens style | ## |
ACT + numeral |
ACT III |
## |
CHAPTER + numeral |
CHAPTER XIV., Chapter 3 |
## |
SCENE + numeral |
SCENE II |
### |
LETTER + numeral |
Letter 4 (epistolary novels) |
## |
| Bible books | The First Book of Moses: Called Genesis |
## |
| Old/New Testament divider | The Old Testament |
## |
STAVE + numeral |
STAVE I (A Christmas Carol) |
## |
I. A SCANDAL IN BOHEMIA |
Roman + ALL-CAPS titled | ## |
| Bare roman numeral | IV. |
### |
| Standalone ALL-CAPS line | THE END (with filters to reject sentences) |
### |
Multi-line titles are merged: e.g. CHAPTER I. followed by HOW MANY KINDS
OF ANIMALS… becomes one ## with the two lines joined. Short Title-Case
lines following a heading become italic subtitles.
Illustration markers ([Illustration …]) are stripped throughout.
4.7 Reference generation — write_reference()¶
Writes corpus/<genre>/<title>/reference.md with sections:
- Source — Project Gutenberg ID, URL, rights
- Author — Name, Born, Died, Wikipedia, Gutenberg Agent ID
- Gutenberg Published — ebook submission date
- Language
- Subjects — bulleted list
- Summary — if OPDS provided one
- Notes — e.g. "Wikipedia page about this book: …"
All Author fields except Name are emitted conditionally — if the RDF didn't
yield a birth year for that agent, no **Born** line appears.
5. Batch Download¶
Catalog files live in scripts/catalogs/ and use a simple
tab-separated format:
# Ancient & Classical
1727 The Odyssey
6130 The Iliad
228 The Aeneid
…
- Column 1: Gutenberg ebook ID (required)
- Column 2: Title override (optional)
- Column 3: Genre override (optional; otherwise taken from
--genre) - Lines starting with
#are comments
fetch-genre is the interactive variant: it searches Gutenberg for the genre
keyword, presents the results, prompts per book, downloads the confirmed set,
and writes a Markdown report to reports/fetch_genre_<genre>_<timestamp>.md.
6. Output Layout¶
corpus/
├── <genre>/ # ancient-classical, shakespeare, …
│ └── <Book Title>/
│ ├── <slug>.md # full text with Markdown heading tree
│ ├── reference.md # author provenance + Gutenberg metadata
│ └── .dockg/ # created later by ingest (gitignored)
│ ├── graph.sqlite # Graph database
│ └── vectors.sqlite # Vector index (sqlite-vec)
└── authors/ # built by `gutenkg authors`
├── index.md # master alphabetical table
├── jane_austen/
│ └── author.md
├── homer/
│ └── author.md
└── …
Slug rules — slugify() lowercases, strips non-alphanumerics, collapses
runs of whitespace/hyphens into a single _. For both book titles and author
names. (Book titles themselves remain cased and spaced — only the filename
inside the dir is slugified.)
7. Author Index Build¶
gutenkg authors (implemented in
src/gutenberg_kg/authors.py) is a
post-processor. It does not fetch anything on its own unless
--refresh is set.
Steps (in order):
- Scan — glob
corpus/*/*/reference.md - Parse --
parse_reference(path)extracts title, ebook_id, genre, authors, the author byline, author_birth, author_death, author_url and author_agent_id by regex - Refresh (only with
--refresh) -- for every book with a Gutenberg ID, callgutenberg._fetch_rdf_author(ebook_id)andrewrite_author_section()to replace the reference.md author lines with the RDF's. A failed fetch, or an RDF with no creator, leaves the file untouched - Group -- each person in
authorsgets the book, so a co-written book is listed under every author. Born/Died/Wikipedia describe the first author only, so a co-author's entry carries none of them - Write per-author pages --
corpus/authors/<slug>/author.mdwith# Name, era line (born - died), Wikipedia, agent ID, and a "Works in Corpus" table - Remove stale pages -- a page directory whose author is no longer in
the corpus, and which holds nothing but a generated
author.md, is removed - Write index --
corpus/authors/index.mdwith a master table
Rate-limiting: --refresh sleeps 0.3 s between RDF fetches to be polite.
8. Refresh Workflow¶
When you add a book manually, the new download_book() call already fetches
the RDF, so new books land with full provenance in reference.md.
For any book whose reference.md predates the RDF fetch, or after a change
to how authors are parsed, run:
gutenkg authors --refresh --dry-run # list what would change
gutenkg authors --refresh
This:
1. Re-fetches the RDF for every book with a Gutenberg ID
2. Rewrites the author lines of each reference.md whose authors or
provenance differ, and leaves every other line of the file as it was
3. Regenerates corpus/authors/ and removes pages for authors no longer in
the corpus
Safe to run repeatedly: a second run rewrites nothing. The book text files are never touched, since the index's character offsets depend on them.
A bundle carries authors in its catalog.json, which gutenkg build-corpus
writes at the end of a full build. To refresh only the catalog, for the same
books, without rebuilding the index:
poetry run python -c '
import json; from pathlib import Path
from gutenberg_kg.build_corpus import build_catalog
out = Path("bundles/gutenberg-all/.dockg")
keys = frozenset(json.loads((out / "catalog.json").read_text()))
build_catalog(sorted({k.split("/")[0] for k in keys}), out, catalog_keys=keys)'
make export-swift
export-swift reads authors from that catalog, so the app's packs pick up the
new names without re-embedding anything.
9. Failure Modes¶
| Failure | Behavior |
|---|---|
| OPDS 404 / network error | fetch_metadata returns {"ebook_id": id} — title falls back to Book_<id> |
| RDF 404 / network error | _fetch_rdf_author returns {} — book still downloads, just without Born/Died/Wikipedia |
| Plain-text 404 | fetch_url raises after 3 retries; the download CLI catches and reports an error per book |
| START/END markers missing | Boilerplate not stripped — full text (including Gutenberg header) is kept |
| TOC not detected | No skip — TOC ends up in the body. Cheap to ignore; the heading detector still works downstream |
| Author field missing from RDF | Book downloads normally; author_birth/author_death/author_url simply aren't in the reference. Happens for e.g. The Bible (ID 10) |
| Idempotence collision | [=] already downloaded — skipped unless --force |
| Bad title characters for filesystem | slugify() strips them; the book dir name itself uses the raw title |
10. Adding a Single New Book¶
# 1. Find it
gutenkg download search --author "Herman Melville"
# 2. Download into the right genre
gutenkg download book 2701 --genre american-literature
# 3. (optional) refresh the author index if you care about the author page
gutenkg authors
The book dir corpus/american-literature/Moby Dick/ appears immediately with
a fully-populated reference.md — no separate refresh needed for new
downloads.
11. Genre Registry¶
Genres live in corpus/genres.json — the single source of
truth. The file is split into "gutenberg" and "ia" source lists.
src/gutenberg_kg/genres.py loads it at import
time and exports GUTENBERG_GENRES, IA_GENRES, and ALL_GENRES.
To add a genre without touching any Python:
gutenkg genres init # one-time seed (creates genres.json)
gutenkg genres add medieval-literature --source gutenberg
gutenkg genres list # verify
genres add creates genres.json automatically if it doesn't exist.
12. Related Files¶
corpus/genres.json— genre registry (single source of truth)src/gutenberg_kg/genres.py— loads registry; exportsALL_GENRESetc.src/gutenberg_kg/cli/cmd_genres.py—gutenkg genres init/list/addsrc/gutenberg_kg/cli/cmd_download.py—gutenkg download *Click subcommandssrc/gutenberg_kg/authors.py— author-corpus builder (exposed asgutenkg authors)src/gutenberg_kg/cli/cmd_download.py—gutenkg download *Click subcommandssrc/gutenberg_kg/cli/cmd_authors.py—gutenkg authorsClick subcommandsrc/gutenberg_kg/cli/options.py—REPO_ROOT,CORPUS_ROOT,ALL_GENRESscripts/catalogs/— batch download catalogsREADME.md— corpus contents + ingest pipelineCHEATSHEET.md— command quick-reference