Files
spsays-training/scripts/letters_extract.py
T
2026-05-04 00:08:31 +05:30

690 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Extract letters from letters.html.
Each letter starts with a p7 tag containing "Letter to: <recipient>".
The header contains recipient, location, and date information.
Structure:
- p7 = Letter header (recipient, dash, location, date)
- p8 = Reference code (YY-MM-DD) and also postscript/closing lines
- p9 = Recipient address block
- p10 = Salutation ("Dear...")
- p11 = Body paragraphs
- p12 = Centered text (verses, centered quotes)
- p13 = Closing/signature block (color: #408080)
- p14 = Indented/numbered items
- p16-p22, p28-p37 = Various body text styles
Output:
lib/jsons/letters/letters.json (array of all letters with metadata)
Usage:
python3 scripts/letters_extract.py
"""
import json
import os
import re
import sys
from collections import Counter
from bs4 import BeautifulSoup, Tag
# ---------------------------------------------------------------------------
# ScaGoudy → IAST Unicode mapping
# ---------------------------------------------------------------------------
SCAGOUDY_TO_IAST = {
"ä": "ā",
"Ä": "Ā",
"å": "ṛ",
"Å": "Ṛ",
"ñ": "ṣ",
"Ñ": "Ṣ",
"ö": "ṭ",
"Ö": "Ṭ",
"ë": "ṇ",
"Ë": "Ṇ",
"ò": "ḍ",
"Ò": "Ḍ",
"ç": "ś",
"Ç": "Ś",
"ì": "ṅ",
"Ì": "Ṅ",
"ï": "ñ",
"Ï": "Ñ",
"ü": "ū",
"Ü": "Ū",
"é": "ī",
"É": "Ī",
"à": "ṁ",
"ù": "ḥ",
"è": "ṝ",
"ÿ": "ḷ",
}
def decode_scagoudy(text: str) -> str:
"""Replace ScaGoudy-encoded characters with proper IAST Unicode."""
for src, dst in SCAGOUDY_TO_IAST.items():
text = text.replace(src, dst)
return text
def get_css_class(tag: Tag) -> str:
classes = tag.get("class", [])
return classes[0] if classes else ""
def inner_html(tag: Tag) -> str:
"""Get inner HTML of a tag, decode ScaGoudy, clean up spans."""
html = "".join(str(child) for child in tag.children)
html = decode_scagoudy(html)
# <span class="s1">text</span> → keep inner text (reference style)
html = re.sub(
r'<span[^>]*class="s\d+"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# Remove any remaining spans but keep content
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
# Remove tab characters
html = html.replace("\t", " ")
# Collapse multiple spaces
html = re.sub(r"[ ]+", " ", html)
html = html.strip()
# Remove leading <br> tags
html = re.sub(r"^(<br\s*/?>[\s]*)+", "", html)
# Remove trailing <br> tags
html = re.sub(r"(<br\s*/?>[\s]*)+$", "", html)
return html
def write_json(data, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
# ---------------------------------------------------------------------------
# Month name → number mapping
# ---------------------------------------------------------------------------
MONTH_MAP = {
"january": 1,
"february": 2,
"march": 3,
"april": 4,
"may": 5,
"june": 6,
"july": 7,
"august": 8,
"september": 9,
"october": 10,
"november": 11,
"december": 12,
"jan": 1,
"feb": 2,
"mar": 3,
"apr": 4,
"jun": 6,
"jul": 7,
"aug": 8,
"sep": 9,
"sept": 9,
"oct": 10,
"nov": 11,
"dec": 12,
}
def parse_date(date_str: str):
"""Parse a date string like '12 July, 1947' or 'May 1949'.
Returns (year, month, day) where month and day can be None.
"""
if not date_str:
return None, None, None
date_str = date_str.strip().rstrip(".")
# Try "DD Month, YYYY" or "DD Month YYYY"
m = re.match(r"(\d{1,2})\s+([A-Za-z]+),?\s*(\d{4})", date_str)
if m:
day = int(m.group(1))
month_name = m.group(2).lower()
year = int(m.group(3))
month = MONTH_MAP.get(month_name)
if year >= 1900 and year <= 2000:
return year, month, day
# Try "Month DD, YYYY" or "Month DD YYYY"
m = re.match(r"([A-Za-z]+)\s+(\d{1,2}),?\s*(\d{4})", date_str)
if m:
month_name = m.group(1).lower()
day = int(m.group(2))
year = int(m.group(3))
month = MONTH_MAP.get(month_name)
if year >= 1900 and year <= 2000:
return year, month, day
# Try "Month, YYYY" or "Month YYYY"
m = re.match(r"([A-Za-z]+),?\s*(\d{4})", date_str)
if m:
month_name = m.group(1).lower()
year = int(m.group(2))
month = MONTH_MAP.get(month_name)
if month and year >= 1900 and year <= 2000:
return year, month, None
# Try just "YYYY"
m = re.match(r"(\d{4})$", date_str.strip())
if m:
year = int(m.group(1))
if year >= 1900 and year <= 2000:
return year, None, None
return None, None, None
def parse_header(tag: Tag):
"""Parse a p7 letter header tag.
Expected format:
Letter to: <recipient>
—
<location>
<date>
Or sometimes:
Letter to: <recipient>
<date>
Returns (recipient, location, date_str).
"""
text = tag.get_text().strip()
lines = [l.strip() for l in text.split("\n") if l.strip()]
recipient = None
location = None
date_str = None
if not lines:
return recipient, location, date_str
# First line: "Letter to: <recipient>"
first = lines[0]
m = re.match(r"Letter to:\s*(.*)", first, re.IGNORECASE)
if m:
recipient = m.group(1).strip().rstrip(",.")
recipient = decode_scagoudy(recipient)
# Remove "—" lines
content_lines = [l for l in lines[1:] if l != "—" and l != "-"]
if len(content_lines) >= 2:
# Last line is usually the date
potential_date = content_lines[-1]
year, month, day = parse_date(potential_date)
if year:
date_str = potential_date
# Everything before the date is location
loc_lines = content_lines[:-1]
if loc_lines:
location = loc_lines[0].strip().rstrip(",.")
location = decode_scagoudy(location)
else:
# Maybe the second-to-last is location and last is something else
# Try to find a date anywhere
for cl in reversed(content_lines):
yr, mo, dy = parse_date(cl)
if yr:
date_str = cl
idx = content_lines.index(cl)
if idx > 0:
location = content_lines[0].strip().rstrip(",.")
location = decode_scagoudy(location)
break
elif len(content_lines) == 1:
# Could be just a date, or just a location
potential_date = content_lines[0]
year, month, day = parse_date(potential_date)
if year:
date_str = potential_date
else:
location = potential_date.strip().rstrip(",.")
location = decode_scagoudy(location)
return recipient, location, date_str
def make_slug(recipient: str, date_ref: str, year, month, day, index: int) -> str:
"""Create a unique slug for a letter."""
parts = []
# Date part
if date_ref and re.match(r"\d{2}-\d{2}", date_ref):
parts.append(date_ref)
elif year:
yr = str(year)[2:] # Last 2 digits
if month:
if day:
parts.append(f"{yr}-{month:02d}-{day:02d}")
else:
parts.append(f"{yr}-{month:02d}-00")
else:
parts.append(f"{yr}-00-00")
else:
parts.append(f"unknown-{index}")
# Recipient part
if recipient:
# Slugify recipient name
name = recipient.lower()
name = re.sub(r"[^a-z0-9]+", "-", name)
name = name.strip("-")[:40]
parts.append(name)
slug = "-".join(parts)
return slug
# ---------------------------------------------------------------------------
# Main extraction
# ---------------------------------------------------------------------------
def main():
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
html_path = os.path.join(base_dir, "public", "html", "letters.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "letters")
if not os.path.exists(html_path):
print(f"ERROR: {html_path} not found!")
sys.exit(1)
print(f"Reading {html_path}...")
with open(html_path, "r", encoding="utf-8") as f:
content = f.read()
soup = BeautifulSoup(content, "html.parser")
all_tags = soup.find_all("p")
print(f" Parsed {len(all_tags)} <p> tags total")
# ---------------------------------------------------------------------------
# Phase 1: Find all letter boundaries
# ---------------------------------------------------------------------------
letter_starts = []
for i, tag in enumerate(all_tags):
cls = get_css_class(tag)
text = tag.get_text().strip()
if cls == "p7" and text.startswith("Letter to:"):
letter_starts.append(i)
print(f"\n Found {len(letter_starts)} letters")
# ---------------------------------------------------------------------------
# Phase 2: Extract each letter
# ---------------------------------------------------------------------------
# Classes that are just empty spacers
SKIP_CLASSES = {"p2", "p15", "p23", "p24", "p26"}
# p12 = centered (verse-like)
CENTERED_CLASSES = {"p12", "p18", "p19", "p20", "p21", "p27"}
# p13, p34, p38 = signature/closing blocks (colored)
SIGNATURE_CLASSES = {"p13", "p34", "p38"}
letters = []
slug_counts = Counter()
for li, start_idx in enumerate(letter_starts):
# Determine end boundary
end_idx = (
letter_starts[li + 1] if li + 1 < len(letter_starts) else len(all_tags)
)
# Parse header
header_tag = all_tags[start_idx]
recipient, location, date_str = parse_header(header_tag)
# Parse date into components
year, month, day = parse_date(date_str) if date_str else (None, None, None)
# Find date reference code (first p8 after header that matches YY-MM-DD)
date_ref = None
salutation = None
paragraphs = []
first_p8_seen = False
for i in range(start_idx + 1, end_idx):
tag = all_tags[i]
cls = get_css_class(tag)
text = tag.get_text().strip()
if cls in SKIP_CLASSES:
continue
if not text:
continue
# p8 = reference code or body text
if cls == "p8":
if not first_p8_seen:
# First p8 after header — check if it's a date reference
if re.match(r"\d{2}-\d{2}", text):
date_ref = text.strip()
first_p8_seen = True
# Try to extract year/month/day from ref if not already parsed
if not year and date_ref:
ref_parts = date_ref.split("-")
if len(ref_parts) >= 3:
try:
yr = int(ref_parts[0])
mo = int(ref_parts[1])
dy = int(ref_parts[2])
year = 1900 + yr if yr < 100 else yr
month = mo if 1 <= mo <= 12 else None
day = dy if 1 <= dy <= 31 else None
except ValueError:
pass
continue
else:
first_p8_seen = True
# Not a date ref — treat as body text
html = inner_html(tag)
if html:
paragraphs.append(html)
continue
else:
# Subsequent p8 tags — body/closing text
html = inner_html(tag)
if html:
paragraphs.append(html)
continue
# p9 = recipient address block
if cls == "p9":
html = inner_html(tag)
if html:
paragraphs.append(html)
continue
# p10 = salutation
if cls == "p10":
salutation = decode_scagoudy(text)
html = inner_html(tag)
if html:
paragraphs.append(html)
continue
# Centered classes (verse blocks)
if cls in CENTERED_CLASSES:
html = inner_html(tag)
if html:
# Check if it has line breaks (verse-like)
if "<br" in html:
html = f'<div style="text-align:center">{html}</div>'
paragraphs.append(html)
continue
# Signature/closing blocks
if cls in SIGNATURE_CLASSES:
html = inner_html(tag)
if html:
paragraphs.append(html)
continue
# All other body text classes (p11, p14, p16, p22, p28, p29, etc.)
html = inner_html(tag)
if html and html != "<br>":
# Check for verse-like content
stripped = html.strip()
if (
stripped.startswith("<i>")
and "<br" in stripped
and '<div style="text-align:center">' not in stripped
):
plain = re.sub(r"</?[bi]>", "", stripped)
lines = [
l.strip() for l in re.split(r"<br\s*/?>", plain) if l.strip()
]
if len(lines) >= 2:
html = f'<div style="text-align:center">{stripped}</div>'
paragraphs.append(html)
# Clean recipient name — strip trailing punctuation
if recipient:
recipient = recipient.rstrip(":,.; ").strip()
# Also clean " :" (space before colon)
recipient = re.sub(r"\s+:$", "", recipient).strip()
# Split multi-recipient strings into individual names
# Patterns: "Mukunda, Janaki" or "Mukunda and Janaki"
recipient_display = recipient or "Unknown"
if recipient:
# Split by comma, semicolon, " and ", " & "
parts = re.split(r"\s*[,;]\s*|\s+and\s+|\s+&\s+", recipient)
recipients_list = []
for p in parts:
name = p.strip().rstrip(":,.; ").strip()
# Clean " :" pattern
name = re.sub(r"\s+:$", "", name).strip()
if name:
recipients_list.append(name)
if not recipients_list:
recipients_list = ["Unknown"]
else:
recipients_list = ["Unknown"]
# Build slug
slug = make_slug(recipient_display, date_ref, year, month, day, li)
# Ensure unique slug
if slug in slug_counts:
slug_counts[slug] += 1
slug = f"{slug}-{slug_counts[slug]}"
else:
slug_counts[slug] = 1
# Normalize location
if location:
# Clean up common variants
location = location.rstrip(",.")
# Normalize "Vrndavana" variants
if location.lower() in ("vrndavana", "vrndävana"):
location = "Vrindaban"
letter = {
"slug": slug,
"recipients": recipients_list,
"recipientDisplay": recipient_display,
"date": date_str,
"dateRef": date_ref,
"year": year,
"month": month,
"day": day,
"location": location,
"salutation": salutation,
"paragraphs": paragraphs,
}
letters.append(letter)
print(f"\n Extracted {len(letters)} letters")
# ---------------------------------------------------------------------------
# Phase 3: Statistics
# ---------------------------------------------------------------------------
# Year distribution
year_counts = Counter()
for letter in letters:
if letter["year"]:
year_counts[letter["year"]] += 1
print(f"\n Year distribution:")
for yr in sorted(year_counts.keys()):
if 1940 <= yr <= 1980:
print(f" {yr}: {year_counts[yr]} letters")
# Location distribution (top 20)
loc_counts = Counter()
for letter in letters:
if letter["location"]:
loc_counts[letter["location"]] += 1
print(f"\n Top 20 locations:")
for loc, count in loc_counts.most_common(20):
print(f" {loc}: {count}")
# Recipient distribution (by individual name, not group)
recip_counts = Counter()
for letter in letters:
for r in letter["recipients"]:
recip_counts[r] += 1
print(f"\n Top 20 recipients (individual):")
for recip, count in recip_counts.most_common(20):
print(f" {recip}: {count}")
# Letters with missing data
no_year = sum(1 for l in letters if not l["year"])
no_location = sum(1 for l in letters if not l["location"])
no_date = sum(1 for l in letters if not l["date"])
no_recipient = sum(1 for l in letters if l["recipients"] == ["Unknown"])
print(f"\n Missing data:")
print(f" No year: {no_year}")
print(f" No location: {no_location}")
print(f" No date: {no_date}")
print(f" No recipient (Unknown): {no_recipient}")
# Unique counts
all_individual_recipients = set()
for l in letters:
for r in l["recipients"]:
all_individual_recipients.add(r)
unique_recipients = len(all_individual_recipients)
unique_locations = len(set(l["location"] for l in letters if l["location"]))
unique_years = len(set(l["year"] for l in letters if l["year"]))
print(f"\n Unique counts:")
print(f" Recipients: {unique_recipients}")
print(f" Locations: {unique_locations}")
print(f" Years: {unique_years}")
# ---------------------------------------------------------------------------
# Phase 4: Write output
# ---------------------------------------------------------------------------
# Write individual letter files grouped by year for manageable sizes
years_with_letters = sorted(
set(l["year"] for l in letters if l["year"] and 1940 <= l["year"] <= 1980)
)
# Write a summary/index file
index_data = {
"totalLetters": len(letters),
"uniqueRecipients": unique_recipients,
"uniqueLocations": unique_locations,
"years": [],
"topRecipients": [
{"name": r, "count": c} for r, c in recip_counts.most_common(50)
],
"topLocations": [
{"name": l, "count": c} for l, c in loc_counts.most_common(50)
],
}
for yr in years_with_letters:
yr_letters = [l for l in letters if l["year"] == yr]
index_data["years"].append(
{
"year": yr,
"count": len(yr_letters),
}
)
# Also count letters without a valid year
undated = [
l for l in letters if not l["year"] or l["year"] < 1940 or l["year"] > 1980
]
if undated:
index_data["years"].append(
{
"year": 0,
"count": len(undated),
}
)
write_json(index_data, os.path.join(output_dir, "index.json"))
print(f"\n Written: index.json")
# Write letters grouped by year (one JSON file per year)
for yr in years_with_letters:
yr_letters = [l for l in letters if l["year"] == yr]
yr_letters.sort(key=lambda l: (l["month"] or 0, l["day"] or 0))
write_json(yr_letters, os.path.join(output_dir, f"{yr}.json"))
# Write undated letters
if undated:
write_json(undated, os.path.join(output_dir, "undated.json"))
print(f" Written: {len(years_with_letters)} year files + undated.json")
# ---------------------------------------------------------------------------
# Phase 5: Quality checks
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" QUALITY CHECKS")
print(f"{'=' * 60}")
# Check for ScaGoudy remnants
sca_chars = {"å", "ë", "ö"}
sca_remnants = 0
for letter in letters[:100]: # Check first 100
for p in letter["paragraphs"]:
if any(c in p for c in sca_chars):
sca_remnants += 1
break
if sca_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected (checked first 100)")
else:
print(f" ⚠ {sca_remnants} letters may have ScaGoudy remnants")
# Check empty letters
empty = [l for l in letters if len(l["paragraphs"]) == 0]
if empty:
print(f" ⚠ {len(empty)} empty letters (no paragraphs)")
else:
print(f" ✅ No empty letters")
# Check duplicate slugs
slug_list = [l["slug"] for l in letters]
dupes = len(slug_list) - len(set(slug_list))
if dupes:
print(f" ⚠ {dupes} duplicate slugs")
else:
print(f" ✅ No duplicate slugs")
# Verse count
verse_count = 0
for letter in letters:
for p in letter["paragraphs"]:
if '<div style="text-align:center">' in p:
verse_count += 1
print(f" ℹ️ {verse_count} center-aligned verse paragraphs")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
print(f" Total letters: {len(letters)}")
print(f" Unique recipients: {unique_recipients}")
print(f" Unique locations: {unique_locations}")
print(f" Year range: {min(years_with_letters)}–{max(years_with_letters)}")
print(f" Total paragraphs: {sum(len(l['paragraphs']) for l in letters)}")
print(f"{'=' * 60}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()