ghostwriter-api / scripts /clean_corpora.py
Corina Kaiser
Deploy to HF Space
be1ce5d
Raw
History Blame Contribute Delete
13.5 kB
#!/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/<persona>.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()