#!/usr/bin/env python3 """ clean_corpora.py — Parse and normalize the four ghost corpora into entry-level JSONL. Sources (all US public domain): - Samuel Pepys, Diary (1660-1669). PG #4200, Wheatley ed. Footnotes stripped. - Vincent van Gogh, Letters of a Post-Impressionist (Ludovici trans., 1912/1913). PG #40393. - Katherine Mansfield, Journal (Knopf 1927/1928 printing). Archive.org OCR; editorial brackets by J.M. Murry stripped (both for voice purity and conservatism). - Mary MacLane, The Story of Mary MacLane (1902) PG #43696, and I, Mary MacLane (1917) PG #43556. Output: data/clean/.jsonl with one entry per line: {"persona": str, "date": str|None, "synthetic_date": bool, "title": str|None, "text": str} Usage: python scripts/clean_corpora.py [--raw-dir data/raw] [--out-dir data/clean] """ import argparse import json import random import re from pathlib import Path MONTHS = ("January February March April May June July August September " "October November December").split() MONTH_RE = "|".join(MONTHS) # ---------------------------------------------------------------- helpers def gutenberg_body(text: str) -> str: """Slice out the Gutenberg header/footer.""" start = re.search(r"\*\*\* ?START OF (THIS|THE) PROJECT GUTENBERG.*?\*\*\*", text) end = re.search(r"\*\*\* ?END OF (THIS|THE) PROJECT GUTENBERG", text) s = start.end() if start else 0 e = end.start() if end else len(text) return text[s:e] def unwrap(paragraph: str) -> str: """Join hard-wrapped lines into a single flowing paragraph.""" return re.sub(r"\s*\n\s*", " ", paragraph).strip() def paragraphs(text: str) -> list[str]: return [p for p in re.split(r"\n\s*\n", text) if p.strip()] def clean_ws(s: str) -> str: s = re.sub(r"[ \t]{2,}", " ", s) return s.strip() def entry_ok(text: str, min_chars: int = 120) -> bool: """Reject fragments too short to carry voice.""" return len(text) >= min_chars # ---------------------------------------------------------------- Pepys def clean_pepys(raw_dir: Path) -> list[dict]: text = (raw_dir / "pepys_complete.txt").read_text(encoding="utf-8", errors="replace") text = gutenberg_body(text).replace("\r\n", "\n") # Strip indented footnote blocks (Wheatley's notes: lines indented >= 4 spaces). lines = [ln for ln in text.split("\n") if not re.match(r"^\s{4,}\S", ln)] text = "\n".join(lines) # Strip any leftover inline editorial brackets that survived (conservative). text = re.sub(r"\[[^\[\]]{0,400}?\]", "", text, flags=re.S) # Drop "ETEXT EDITOR'S BOOKMARKS" blocks if present. text = re.sub(r"ETEXT EDITOR'S BOOKMARKS.*?(?=\n[A-Z]+ \d{4}|\Z)", "", text, flags=re.S) month_hdr = re.compile(rf"^({MONTH_RE.upper()})\s+(\d{{4}})(?:-(\d{{2,4}}))?\s*$") day_start = re.compile(r"^(\d{1,2})(st|nd|rd|th)[.\s]") entries, cur_month, cur_year, cur = [], None, None, None for para in paragraphs(text): first = para.lstrip().split("\n", 1)[0] mh = month_hdr.match(first.strip()) if mh: cur_month = mh.group(1).capitalize() # "1659-1660" style: the latter year is the modern reckoning. cur_year = mh.group(3) or mh.group(2) if len(cur_year) == 2: cur_year = mh.group(2)[:2] + cur_year continue ds = day_start.match(para.lstrip()) if ds and cur_month: if cur: entries.append(cur) day = f"{ds.group(1)}{ds.group(2)}" body = day_start.sub("", para.lstrip(), count=1) cur = { "persona": "pepys", "date": f"{day} {cur_month} {cur_year}", "synthetic_date": False, "title": None, "text": unwrap(body), } elif cur: cur["text"] += "\n\n" + unwrap(para) if cur: entries.append(cur) out = [] for e in entries: e["text"] = clean_ws(e["text"]) if entry_ok(e["text"]): out.append(e) return out # ---------------------------------------------------------------- Van Gogh def clean_vangogh(raw_dir: Path) -> list[dict]: text = (raw_dir / "vangogh_letters.txt").read_text(encoding="utf-8", errors="replace") text = gutenberg_body(text).replace("\r\n", "\n") # Drop Ludovici's introductory essay: keep from first letters section onward. m = re.search(r"^LETTERS TO HIS BROTHER\s*$", text, flags=re.M) if m: text = text[m.end():] # Remove section headings and illustrations. text = re.sub(r"^LETTERS TO E\. BERNARD\s*$", "", text, flags=re.M) text = re.sub(r"\[Illustration[^\]]*\]", "", text) # Letters are separated by asterisk rules. letters = re.split(r"\n\s*\*(?:\s+\*)+\s*\n", text) entries = [] for letter in letters: letter = letter.strip() if not letter: continue # Strip salutation and closing flourishes for diary-ification, but keep body. letter = re.sub(r"^(MY )?DEAR [A-Z]+,?\s*\n", "", letter) body = "\n\n".join(unwrap(p) for p in paragraphs(letter)) body = clean_ws(body) if entry_ok(body, min_chars=200): entries.append({ "persona": "vangogh", "date": None, # synthetic dates assigned below "synthetic_date": True, "title": None, "text": body, }) # The Ludovici letters run roughly 1881 (The Hague) -> 1890 (Auvers), in order. # Assign evenly spaced synthetic dates across that span so the ghost keeps # Vincent's chronology of mood (early earnestness -> Arles fever -> late calm). rng = random.Random(1890) n = len(entries) for i, e in enumerate(entries): year = 1881 + round(i * 9 / max(n - 1, 1)) month = MONTHS[rng.randrange(12)] day = rng.randrange(1, 29) e["date"] = f"{day} {month} {year}" return entries # ---------------------------------------------------------------- Mansfield OCR_DIGIT = str.maketrans({"I": "1", "i": "1", "l": "1", "O": "0", "o": "0", "Q": "9", "q": "9", "p": "9", "g": "9", "S": "5"}) def _ocr_year(line: str): """Detect OCR-mangled standalone year headers like 'IQI4' or 'igio' -> 1914/1910.""" s = line.strip() if not (3 <= len(s) <= 6): return None t = re.sub(r"\s", "", s).translate(OCR_DIGIT) if re.fullmatch(r"19[0-2][0-9]", t): return int(t) return None def clean_mansfield(raw_dir: Path) -> list[dict]: text = (raw_dir / "mansfield_journal_1927.txt").read_text(encoding="utf-8", errors="replace") # Trim front matter: start at the first year header (1904) region. lines = text.split("\n") body_lines, year, started = [], None, False page_hdr = re.compile(r"^\s*(Journal\s*(of)?\s*$|Katherine\s*Man.*$|Journal\s+\S{1,8}\s*$)") for ln in lines: y = _ocr_year(ln) if y: started = True body_lines.append(f"\n@@YEAR {y}@@\n") continue if not started: continue if page_hdr.match(ln) and len(ln.strip()) < 30: continue body_lines.append(ln) text = "\n".join(body_lines) # Remove Murry's editorial bracket blocks (may span lines). text = re.sub(r"\[[^\[\]]{0,2000}?\]", "", text, flags=re.S) # Strip OCR page-number artifact lines like " - 102 = " or "102". text = re.sub(r"^\s*[-=~*•']*\s*\d{1,3}\s*[-=~*•']*\s*$", "", text, flags=re.M) # OCR de-hyphenation across former line breaks: "to- day" -> "to-day" style joins. text = re.sub(r"(\w)-\s+(?=\w)", r"\1", text) # Collapse runs of spaces (OCR double-spacing). text = re.sub(r"[ \t]{2,}", " ", text) date_start = re.compile(rf"^({MONTH_RE})\s*(\d{{1,2}})?\s*\.") entries, cur, cur_year = [], None, 1904 fragment_buf = [] rng = random.Random(1923) def flush_fragments(): nonlocal fragment_buf buf, chunk = [], [] size = 0 for p in fragment_buf: chunk.append(p) size += len(p) if size > 700: buf.append("\n\n".join(chunk)); chunk, size = [], 0 if chunk: buf.append("\n\n".join(chunk)) for b in buf: if entry_ok(b, 250): entries.append({ "persona": "mansfield", "date": f"{rng.randrange(1,29)} {MONTHS[rng.randrange(12)]} {cur_year}", "synthetic_date": True, "title": None, "text": clean_ws(b), }) fragment_buf = [] for para in paragraphs(text): ym = re.match(r"@@YEAR (\d{4})@@", para.strip()) if ym: if cur: entries.append(cur); cur = None flush_fragments() cur_year = int(ym.group(1)) continue p = unwrap(para) dm = date_start.match(p) if dm: if cur: entries.append(cur) flush_fragments() month, day = dm.group(1), dm.group(2) date = f"{day} {month} {cur_year}" if day else f"{month} {cur_year}" cur = {"persona": "mansfield", "date": date, "synthetic_date": False, "title": None, "text": clean_ws(p[dm.end():])} elif cur and len(cur["text"]) < 1200: cur["text"] += "\n\n" + clean_ws(p) else: if cur: entries.append(cur); cur = None fragment_buf.append(clean_ws(p)) if cur: entries.append(cur) flush_fragments() return [e for e in entries if entry_ok(e["text"])] # ---------------------------------------------------------------- MacLane def clean_maclane(raw_dir: Path) -> list[dict]: entries = [] # --- The Story of Mary MacLane (1902): right-aligned date headers. text = (raw_dir / "maclane_story.txt").read_text(encoding="utf-8", errors="replace") text = gutenberg_body(text).replace("\r\n", "\n") text = re.sub(r"\[(Photograph|Illustration)[^\]]*\]", "", text) text = text.replace("_", "").replace("=", "") date_line = re.compile(rf"^\s{{20,}}({MONTH_RE})\s+(\d{{1,2}})(,\s*(\d{{4}}))?\.?\s*$", flags=re.M) cur_year = 1901 marks = list(date_line.finditer(text)) for i, m in enumerate(marks): if m.group(4): cur_year = int(m.group(4)) date = f"{m.group(2)} {m.group(1)} {cur_year}" seg = text[m.end(): marks[i + 1].start() if i + 1 < len(marks) else len(text)] body = "\n\n".join(unwrap(p) for p in paragraphs(seg)) body = clean_ws(body) if entry_ok(body): entries.append({"persona": "maclane", "date": date, "synthetic_date": False, "title": None, "text": body}) # --- I, Mary MacLane (1917): entries headed by right-aligned day-words # ("To-day", "To-morrow"), preceded by an italic title line. text = (raw_dir / "maclane_i.txt").read_text(encoding="utf-8", errors="replace") text = gutenberg_body(text).replace("\r\n", "\n") text = re.sub(r"\[(Photograph|Illustration)[^\]]*\]", "", text) head = re.compile(r"^\s{30,}(To-day|To-morrow|[A-Z][a-z]+day)\s*$", flags=re.M) title_re = re.compile(r"_([^_\n]{3,80})_\s*$") marks = list(head.finditer(text)) rng = random.Random(1917) for i, m in enumerate(marks): # look back a few lines for the italic title back = text[max(0, m.start() - 300): m.start()] tmatch = title_re.search(back.strip()) title = tmatch.group(1).strip() if tmatch else None seg = text[m.end(): marks[i + 1].start() if i + 1 < len(marks) else len(text)] seg = seg.replace("_", "") # cut the next entry's title off the tail seg = re.sub(r"\n[^\n]{3,80}\n\s*$", "\n", seg) body = "\n\n".join(unwrap(p) for p in paragraphs(seg)) body = clean_ws(body) if entry_ok(body): # The book was written Jan-Mar 1917 in Butte; scatter synthetic dates there. date = f"{rng.randrange(1,29)} {MONTHS[rng.randrange(0,3)]} 1917" entries.append({"persona": "maclane", "date": date, "synthetic_date": True, "title": title, "text": body}) return entries # ---------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser() ap.add_argument("--raw-dir", default="data/raw", type=Path) ap.add_argument("--out-dir", default="data/clean", type=Path) args = ap.parse_args() args.out_dir.mkdir(parents=True, exist_ok=True) cleaners = {"pepys": clean_pepys, "vangogh": clean_vangogh, "mansfield": clean_mansfield, "maclane": clean_maclane} stats = {} for name, fn in cleaners.items(): entries = fn(args.raw_dir) out = args.out_dir / f"{name}.jsonl" with out.open("w") as f: for e in entries: f.write(json.dumps(e, ensure_ascii=False) + "\n") chars = sum(len(e["text"]) for e in entries) stats[name] = (len(entries), chars) print(f"[clean] {name:10s} entries={len(entries):5d} chars={chars:9,d} " f"avg={chars // max(len(entries),1):5d}") total = sum(c for _, c in stats.values()) print(f"[clean] TOTAL chars={total:,} (~{total // 4:,} tokens)") if __name__ == "__main__": main()