https://github.com/anders94/gutenberg-reader
Convert any Project Gutenberg book into structured JSON ready for text-to-speech (TTS) audiobook generation.
https://github.com/anders94/gutenberg-reader
Last synced: 30 days ago
JSON representation
Convert any Project Gutenberg book into structured JSON ready for text-to-speech (TTS) audiobook generation.
- Host: GitHub
- URL: https://github.com/anders94/gutenberg-reader
- Owner: anders94
- License: mit
- Created: 2026-05-09T02:21:21.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-05-22T15:24:36.000Z (2 months ago)
- Last Synced: 2026-05-22T20:22:30.269Z (2 months ago)
- Language: Python
- Size: 116 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# gutenberg-reader
Convert any [Project Gutenberg](https://www.gutenberg.org/) book into structured JSON ready for text-to-speech (TTS) audiobook generation with [tts-audiobook](https://github.com/anders94/tts-audiobook). Each sentence is labelled as **narration** or **dialogue**, with speaker attribution and pronunciation hints, so TTS engines can apply per-character voices automatically.
Processing runs entirely locally using [Ollama](https://ollama.com/) — no cloud API keys required.
---
## Features
- Downloads and processes any Gutenberg book by numeric ID
- Splits text into narration and dialogue segments with speaker attribution
- Identifies characters and their aliases across the full book
- Deterministic anchor-propagation pass corrects misattributed back-and-forth dialogue before the LLM review
- Resumable pipeline: interrupted runs pick up where they left off
- Atomic cache writes — safe to kill at any point
---
## Requirements
- Python 3.11+
- [Ollama](https://ollama.com/) running locally (default: `http://localhost:11434`)
- A capable instruction-following model — see [Model recommendations](#model-recommendations)
---
## Installation
```bash
pip install gutenberg-reader
```
Or install from source:
```bash
git clone https://github.com/your-username/gutenberg-reader
cd gutenberg-reader
pip install -e .
```
---
## Quick start
```bash
# Process Pride and Prejudice (book 1342)
gutenberg-reader 1342
# Process Dr. Jekyll and Mr. Hyde with verbose output
gutenberg-reader 43 --verbose
# Use a specific model
gutenberg-reader 1342 --model gemma3:27b --verbose
```
Output is written to `cache//07-final/.json`.
---
## CLI reference
```
gutenberg-reader BOOK_ID [OPTIONS]
```
`BOOK_ID` is the numeric Project Gutenberg book identifier. Find it in the book's URL:
`https://www.gutenberg.org/ebooks/1342` → ID is `1342`.
### Options
| Option | Default | Description |
|--------|---------|-------------|
| `--model MODEL` | `qwen2.5:14b` | Ollama model for segmentation and character discovery |
| `--validator MODEL` | *(same as --model)* | Separate model for the Stage 06 critic pass |
| `--ollama-url URL` | `http://localhost:11434` | Ollama API base URL |
| `--cache-dir DIR` | `./cache` | Directory for all cached stage outputs |
| `--output FILE` | *(auto)* | Override output JSON path |
| `--chunk-size N` | `400` | Words per processing chunk (lower = more API calls, less context pressure) |
| `--overlap N` | `150` | Overlap words between chunks |
| `--no-critic` | off | Skip Stage 06 LLM critic pass (faster; deterministic anchor pass still runs) |
| `--force-stage N` | — | Re-run from stage N (1–7) forward, discarding cached results from that stage on |
| `--chapters N[,N,…]` | — | Process only specific chapter numbers (e.g. `1,2,5`) |
| `--max-retries N` | `3` | Max LLM retries per chunk on integrity failure |
| `-v / --verbose` | off | Show per-stage progress and correction details |
### Examples
```bash
# Fast run: skip LLM critic, use deterministic anchor pass only
gutenberg-reader 1342 --no-critic --verbose
# Use a larger model for segmentation, lighter model for critic
gutenberg-reader 1342 --model gemma3:27b --validator gemma3:12b
# Re-run only the assembly stage (Stage 07) to regenerate final JSON
gutenberg-reader 1342 --force-stage 7
# Re-run segmentation + everything after, for chapters 1–3 only
gutenberg-reader 1342 --force-stage 5 --chapters 1,2,3
# Point at a remote Ollama instance
gutenberg-reader 1342 --ollama-url http://192.168.1.10:11434 --model llama3.3:70b
# Write output to a custom path
gutenberg-reader 1342 --output ~/audiobooks/pride_and_prejudice.json
```
---
## Model recommendations
Any Ollama model that follows structured JSON instructions will work. Tested configurations:
| Model | Quality | Speed | Notes |
|-------|---------|-------|-------|
| `gemma3:27b` | ★★★★★ | Slow | Best attribution accuracy |
| `qwen2.5:14b` | ★★★★☆ | Medium | Good default choice |
| `gemma3:12b` | ★★★★☆ | Fast | Good balance |
| `gemma4:latest` | ★★★★☆ | Fast | Tested reference model |
| `llama3.3:70b` | ★★★★★ | Very slow | For maximum quality |
Pull a model before first use:
```bash
ollama pull qwen2.5:14b
```
Smaller models (7B and below) tend to produce more JSON formatting errors and speaker misattributions. The pipeline has automatic repair and retry logic, but larger models need fewer corrections.
---
## Output format
The final JSON has this structure:
```json
{
"metadata": {
"title": "The Strange Case of Dr. Jekyll and Mr. Hyde",
"author": "Robert Louis Stevenson",
"language": "English",
"gutenberg_id": "43",
"release_date": "June 27, 2008",
"credits": "David Widger"
},
"chapters": [
{
"chapter": {
"number": 1,
"title": "STORY OF THE DOOR",
"start_line": 42,
"end_line": 310,
"word_count": 2150
},
"processed": {
"chapter_number": 1,
"chapter_title": "STORY OF THE DOOR",
"segments": [
{
"type": "narration",
"text": "Mr. Utterson the lawyer was a man of a rugged countenance that was never lighted by a smile;",
"speaker": null,
"pronunciation_hints": [],
"notes": null
},
{
"type": "dialogue",
"text": "\u201cI incline to Cain\u2019s heresy,\u201d",
"speaker": "Mr. Utterson",
"pronunciation_hints": [],
"notes": null
},
{
"type": "narration",
"text": "he used to say quaintly:",
"speaker": null,
"pronunciation_hints": [],
"notes": null
},
{
"type": "dialogue",
"text": "\u201cI let my brother go to the devil in his own way.\u201d",
"speaker": "Mr. Utterson",
"pronunciation_hints": [],
"notes": null
}
],
"word_count": 2150
},
"validation": null
}
],
"characters": [
{
"name": "Mr. Utterson",
"aliases": [],
"pronunciation_hints": [],
"first_appearance_chapter": 1
},
{
"name": "Dr. Jekyll",
"aliases": ["Henry Jekyll"],
"pronunciation_hints": [],
"first_appearance_chapter": 1
}
],
"statistics": {
"total_chapters": 10,
"total_words": 25615,
"total_segments": 1315,
"total_characters": 8,
"processing_time_seconds": 777.3,
"validation_performed": true,
"pipeline_version": "1.0.0"
}
}
```
### Segment types
| `type` | `speaker` | Description |
|--------|-----------|-------------|
| `"narration"` | `null` | All non-spoken text: description, action, attribution tags |
| `"dialogue"` | `"Character Name"` | Text inside quotation marks, spoken aloud by a character |
Attribution phrases like *"said Mr. Bennet"* or *"replied his wife"* are always `"narration"` segments, never merged into the surrounding dialogue. This lets TTS engines handle attribution in a natural voice separate from the speaking characters.
When the speaker cannot be determined, `speaker` is `"Unknown"`.
---
## Pipeline stages
The pipeline runs in 7 stages. Each stage writes to `cache//0N-/` and is skipped on subsequent runs if its output already exists.
| Stage | Name | Description |
|-------|------|-------------|
| 01 | Download | Fetches `pg.txt` from Gutenberg with retry/backoff |
| 02 | Discovery | Strips boilerplate, detects chapter boundaries, extracts metadata |
| 03 | Chapter split | Extracts each chapter into a plain-text file |
| 04 | Characters | LLM identifies all named characters and aliases |
| 05 | Segmentation | LLM splits each chapter into narration/dialogue segments with speaker attribution |
| 06 | Critic | Deterministic anchor-propagation pass + optional LLM quality review |
| 07 | Assembly | Merges all chapters into the final JSON |
### Resume and force-rerun
Every stage checks for its cached output before running. If you interrupt the pipeline (Ctrl+C), restarting the same command resumes from the interrupted chapter automatically.
To reprocess from a specific stage:
```bash
# Regenerate final JSON only
gutenberg-reader 1342 --force-stage 7
# Re-run critic pass and reassemble
gutenberg-reader 1342 --force-stage 6
# Start completely from scratch
gutenberg-reader 1342 --force-stage 1
```
### Cache layout
```
cache/
└── 1342/
├── 01-raw/ book.txt
├── 02-discovery/ discovery.json
├── 03-chapters/ chapter-01.txt … chapter-61.txt
├── 04-characters/ characters.json
├── 05-segments/ chapter-01.json … chapter-61.json
├── 06-critic/ chapter-01.json … chapter-61.json
└── 07-final/ 1342.json
```
---
## How attribution works
Back-and-forth dialogue that lacks explicit attribution tags (e.g., *"said Mr. Bennet"*) is handled in two passes:
1. **Anchor propagation (Stage 06, deterministic):** The pipeline locates every narration segment containing a speech verb adjacent to dialogue. It groups consecutive dialogue into *conversation chains*, then uses confirmed speakers as anchors and propagates via strict alternation for 2-character scenes. Characters present in a scene are detected from vocative name use inside dialogue (e.g., *"My dear Mr. Bennet,"* confirms Mr. Bennet is present).
2. **LLM critic (Stage 06, optional):** A second LLM pass reviews the anchor-corrected segments for remaining attribution issues, name inconsistencies, and coverage gaps. Disable with `--no-critic` for faster processing.
---
## Limitations
- Requires a locally running Ollama instance
- Processing a full novel takes 15–90 minutes depending on model and hardware
- Very long chapters (>5,000 words) are chunked; chunk boundaries can occasionally split a dialogue exchange across two LLM calls
- Books in languages other than English work if the model supports the language, but the speech-verb detection regex is English-only
- Some books use non-standard chapter structures (Roman numerals, titled chapters, etc.) — the pipeline falls back to LLM-based chapter discovery when regex detection finds fewer than 2 chapters
---
## License
MIT — see [LICENSE](LICENSE).