feat: letters added

This commit is contained in:
gosangam
2026-05-04 00:08:31 +05:30
parent 35d1d14f37
commit 401766928a
20 changed files with 4272 additions and 26 deletions
+717
View File
@@ -0,0 +1,717 @@
#!/usr/bin/env python3
"""Extract Back to Godhead magazine (1966-1973) from btg-1978.html.
Each magazine issue becomes its own JSON file.
Issues are grouped by volume/era in the intro.json metadata.
Advertisement sections are excluded.
Structure:
- p2 = Issue bookmark (bold, e.g., "Back to Godhead Magazine. Vol. 1, No. 01, October 23, 1966")
- p8 = Article bookmark within an issue
- p9 = Article display title (bold)
- p7, p5, p12, p4, p3, etc. = Body text
- Articles titled "Advertisement" are skipped entirely
Output:
lib/jsons/magazines/btg-1978/intro.json
lib/jsons/magazines/btg-1978/issues/no-01.json
lib/jsons/magazines/btg-1978/issues/no-02.json
...
lib/jsons/magazines/btg-1978/issues/no-54.json
Usage:
python3 scripts/btg_1978_extract.py
"""
import json
import os
import re
import sys
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="s2">text</span> → <i>text</i> (italic marker in btg-1978)
html = re.sub(
r'<span[^>]*class="s2"[^>]*>(.*?)</span>', r"<i>\1</i>", html, flags=re.DOTALL
)
# Remove all other span tags but keep inner content
html = re.sub(r"<span[^>]*>(.*?)</span>", r"\1", html, flags=re.DOTALL)
# Remove Apple-tab-span and tab characters
html = html.replace("\t", " ")
# Collapse multiple spaces (but not around <br>)
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: dict, 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")
# ---------------------------------------------------------------------------
# Parse issue info from p2 text
# ---------------------------------------------------------------------------
def parse_issue_info(text: str):
"""Parse issue info from the p2 bookmark text.
Handles various formats:
"Back to Godhead Magazine. Vol. 1, No. 01, October 23, 1966"
"Back to Godhead Magazine. Vol. 1, No. 06, January 20, 1967"
"Back to Godhead Magazine. [VOL II No 1.] No. 16, 1968"
"Back to Godhead Magazine VOL II. No 2. [#17], 1968"
"Back to Godhead Magazine no. 30, 1969"
"Back to Godhead Magazine No. 36, 1970"
Returns (issue_number, volume, display_title, date_str, slug) or None.
"""
text = text.strip()
# Remove "Back to Godhead Magazine" prefix variations
text_clean = re.sub(
r"^Back to Godhead Magazine\.?\s*", "", text, flags=re.IGNORECASE
).strip()
issue_num = None
volume = None
date_str = ""
# Try to extract issue number from various patterns
# Pattern 1: "Vol. X, No. YY, date" or "Vol. X No. YY, date"
m = re.search(
r"Vol\.?\s*(\d+)\.?\s*,?\s*No\.?\s*(\d+)\s*,?\s*(.*)", text_clean, re.IGNORECASE
)
if m:
volume = int(m.group(1))
issue_num = int(m.group(2))
date_str = m.group(3).strip().rstrip(",. ")
# Pattern 2: "[VOL II No 1.] No. 16, 1968" — use the absolute number
if issue_num is None:
m = re.search(r"No\.?\s*(\d+)\s*,?\s*(\d{4})", text_clean, re.IGNORECASE)
if m:
issue_num = int(m.group(1))
date_str = m.group(2).strip()
# Pattern 3: "VOL II. No 2. [#17], 1968"
if issue_num is None:
m = re.search(r"#(\d+)", text_clean)
if m:
issue_num = int(m.group(1))
# Pattern 4: "no. 30, 1969" or "no 31, 1969"
if issue_num is None:
m = re.search(r"no\.?\s*(\d+)\s*,?\s*(.*)", text_clean, re.IGNORECASE)
if m:
issue_num = int(m.group(1))
date_str = m.group(2).strip().rstrip(",. ")
# Try to extract volume from text if not found yet
if volume is None:
m = re.search(r"VOL\.?\s*(?:II|2)", text_clean, re.IGNORECASE)
if m:
volume = 2
else:
m = re.search(r"VOL\.?\s*(?:I|1)", text_clean, re.IGNORECASE)
if m:
volume = 1
if issue_num is None:
print(f" ⚠ Could not parse issue number from: {text}")
return None
# Extract year from date_str if present
year_match = re.search(r"(\d{4})", date_str)
year = int(year_match.group(1)) if year_match else None
# Build display title
if volume and issue_num <= 17:
display_title = f"Vol. {volume}, No. {issue_num:02d}"
if date_str:
display_title += f", {date_str}"
else:
display_title = f"No. {issue_num}"
if date_str:
display_title += f", {date_str}"
slug = f"no-{issue_num:02d}"
return issue_num, volume, display_title, date_str, year, 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", "btg-1978.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "magazines", "btg-1978")
issues_dir = os.path.join(output_dir, "issues")
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 issue boundaries
# ---------------------------------------------------------------------------
issue_boundaries = [] # List of (tag_index, issue_num, volume, display_title, date_str, year, slug)
for i, tag in enumerate(all_tags):
cls = get_css_class(tag)
if cls == "p2":
text = tag.get_text().strip()
if "Back to Godhead" in text:
parsed = parse_issue_info(text)
if parsed:
issue_num, volume, display_title, date_str, year, slug = parsed
issue_boundaries.append(
(i, issue_num, volume, display_title, date_str, year, slug)
)
print(f"\n Found {len(issue_boundaries)} issues")
for ib in issue_boundaries:
tag_idx, inum, vol, title, ds, yr, slug = ib
print(f" tag[{tag_idx}]: #{inum} (Vol {vol}) {title} → {slug}")
# ---------------------------------------------------------------------------
# Phase 2: Extract articles and paragraphs for each issue
# ---------------------------------------------------------------------------
# Known skip classes (bookmarks, empty lines)
SKIP_CLASSES = {"p2", "p11", "p28", "p24"}
issues_data = []
skipped_ads = 0
# Maximum paragraphs per issue — prevents mega-issues from absorbing
# unmarked content that belongs to missing intermediate issues.
MAX_PARAGRAPHS_PER_ISSUE = 500
for idx, (
start_tag_idx,
issue_num,
vol,
display_title,
date_str,
year,
slug,
) in enumerate(issue_boundaries):
# Determine end boundary
if idx + 1 < len(issue_boundaries):
end_tag_idx = issue_boundaries[idx + 1][0]
else:
end_tag_idx = len(all_tags)
issue_tags = all_tags[start_tag_idx:end_tag_idx]
paragraphs = []
articles = []
in_advertisement = False
for tag in issue_tags:
cls = get_css_class(tag)
text = tag.get_text().strip()
# Skip bookmark tags and empty lines
if cls in SKIP_CLASSES:
continue
if not text:
continue
# p8 = Article bookmark
if cls == "p8":
article_title = decode_scagoudy(text).strip()
# Check if this is an advertisement
if re.match(r"^Advertisements?", article_title, re.IGNORECASE):
in_advertisement = True
skipped_ads += 1
continue
else:
in_advertisement = False
articles.append({"title": article_title})
continue
# p122 and other classes can also be advertisement markers
if re.match(r"^Advertisements?\s*$", text, re.IGNORECASE):
in_advertisement = True
skipped_ads += 1
continue
# p9 = Article display title
if cls == "p9":
if in_advertisement:
continue
# Also check the p9 display title itself for advertisement
plain_title = decode_scagoudy(text).strip()
if re.match(r"^Advertisements?\s*$", plain_title, re.IGNORECASE):
in_advertisement = True
skipped_ads += 1
continue
html = inner_html(tag)
if html:
# Make it a bold article heading if not already
if not html.startswith("<b>"):
html = f"<b>{html}</b>"
paragraphs.append(html)
continue
# Skip content within advertisement sections
if in_advertisement:
# Check if next p8 will reset it — for now just skip
continue
# p15 = Verse block — center align
if cls == "p15":
html = inner_html(tag)
if html:
paragraphs.append(f'<div style="text-align:center">{html}</div>')
continue
# All other classes — extract as body text
html = inner_html(tag)
if html and html != "<br>":
# Check if this looks like a verse (entirely italic with <br> breaks)
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)
# Post-process: remove any ad-like paragraphs that slipped through.
# Two-pass approach:
# 1. Detect ad section start markers and skip until next real article
# 2. Filter individual ad-like paragraphs by content patterns
# --- Pass 1: Section-level ad detection ---
ad_section_patterns = [
r"^Advertisements?\s*$",
r"^CIRCULATION AND SUBSCRIPTIONS",
r"^Order Form",
r"^SPECIAL OFFER",
r"^Subscriptions?:",
r"^WORLDWIDE IMPORTS",
r"^THE\s+ORIENT\s+SHOP",
r"^PROTECT YOUR CHECKS",
]
ad_section_re = re.compile("|".join(ad_section_patterns), re.IGNORECASE)
cleaned_pass1 = []
skip_until_next_bold = False
for p in paragraphs:
plain = re.sub(r"<[^>]+>", "", p).strip()
# Detect ad section start
if ad_section_re.search(plain):
skip_until_next_bold = True
continue
if skip_until_next_bold:
# A bold article heading that's NOT an ad resets the skip
if p.startswith("<b>") and len(plain) < 200:
if ad_section_re.search(plain):
continue
else:
skip_until_next_bold = False
cleaned_pass1.append(p)
continue
# If it looks like real content (long paragraph), stop skipping
if len(plain) > 150:
skip_until_next_bold = False
cleaned_pass1.append(p)
continue
# Short paragraphs in ad zone — skip
continue
cleaned_pass1.append(p)
# --- Pass 2: Individual paragraph ad detection ---
# These patterns catch standalone ad fragments that weren't part of
# a marked advertisement section.
ad_para_keywords = [
"subscription",
"per copy",
"enclosed is",
"please send me",
"order form",
"postage paid",
"money order",
"check or money order",
"zip code",
"state_____",
"address_____",
"city_____",
"available from iskcon",
"waterpipes",
"djellabas",
"panchos",
"orient shop",
"worldwide imports",
"sullivan st",
"bleecker",
"check protector",
"safeguards your checks",
"satisfaction guaranteed",
"import-export",
"davis & manswell",
"davis &amp; manswell",
"bedford ave",
"brooklyn, n.y.",
"immediate delivery",
"free gift guide",
"write for free",
"free catalog",
"free catalogue",
"protect your checks",
"name of purchaser",
"such dresses at $",
"and gowns yet $",
"price per copy",
"one-year subscription",
"year subscription costs",
"back issues available",
"a subscription to",
"send check or money",
"your name & address",
"your name &amp; address",
"674-9511",
]
# Also detect short paragraphs that look like addresses or phone numbers
address_re = re.compile(
r"^\d+\s+\w+\s+(st|street|ave|avenue|blvd|road|rd|lane|ln|dr|drive)\b",
re.IGNORECASE,
)
phone_re = re.compile(r"^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\s*$")
zipcode_re = re.compile(
r"(N\.?Y\.?\s+\d{5}|CA\s+\d{5}|Brooklyn|Manhattan)",
re.IGNORECASE,
)
cleaned = []
for p in cleaned_pass1:
plain = re.sub(r"<[^>]+>", "", p).strip()
plain_lower = plain.lower()
# Skip if matches any ad keyword
if any(kw in plain_lower for kw in ad_para_keywords):
continue
# Skip short paragraphs that look like addresses
if len(plain) < 80 and address_re.search(plain):
continue
# Skip standalone phone numbers
if phone_re.match(plain):
continue
# Skip short paragraphs with zip codes (likely address fragments)
if len(plain) < 60 and zipcode_re.search(plain):
continue
# Skip price-only paragraphs like "$1 plus 25c"
if re.match(
r"^\$?\d+[\.\d]*\s*(plus|postage|each|per)", plain, re.IGNORECASE
):
continue
cleaned.append(p)
paragraphs = cleaned
# Cap paragraphs to prevent mega-issues from absorbing unmarked
# intermediate issues (e.g., issues 18-29 have no p2 markers and
# would otherwise be absorbed into issue 17).
if len(paragraphs) > MAX_PARAGRAPHS_PER_ISSUE:
print(
f" ⚠ {slug}: Capped from {len(paragraphs)} to {MAX_PARAGRAPHS_PER_ISSUE} paragraphs "
f"(likely absorbed unmarked intermediate issues)"
)
paragraphs = paragraphs[:MAX_PARAGRAPHS_PER_ISSUE]
issue_data = {
"book": "btg-1978",
"volume": vol,
"part": issue_num,
"title": display_title,
"slug": slug,
"year": year,
"articles": articles,
"paragraphs": paragraphs,
}
issues_data.append(issue_data)
print(
f" {slug}: {display_title} — "
f"{len(articles)} articles, {len(paragraphs)} paragraphs"
)
print(f"\n Skipped {skipped_ads} advertisement sections")
# ---------------------------------------------------------------------------
# Phase 3: Build intro.json with hierarchical groups
# ---------------------------------------------------------------------------
# Group issues by era/volume
# Early issues (1-17) have Vol 1/Vol 2 designations
# Later issues (30+) just have issue numbers
group_early = [] # Issues 1-17 (1966-1968)
group_later = [] # Issues 30+ (1969-1973)
for issue in issues_data:
if issue["part"] <= 17:
group_early.append(issue)
else:
group_later.append(issue)
chapters = []
if group_early:
sub = []
for issue in group_early:
sub.append({"slug": issue["slug"], "title": issue["title"]})
chapters.append(
{
"title": "Issues #1–17 (1966–1968)",
"subChapters": sub,
}
)
if group_later:
sub = []
for issue in group_later:
sub.append({"slug": issue["slug"], "title": issue["title"]})
chapters.append(
{
"title": "Issues #30–54 (1969–1973)",
"subChapters": sub,
}
)
total_issues = len(issues_data)
total_articles = sum(len(i["articles"]) for i in issues_data)
# Determine year range
years = [i["year"] for i in issues_data if i["year"]]
min_year = min(years) if years else None
max_year = max(years) if years else None
intro_json = {
"book": "btg-1978",
"title": "Back to Godhead (1966–1973)",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": min_year,
"description": (
f"Back to Godhead magazine issues from the Western era ({min_year}–{max_year}). "
f"{total_issues} issues containing {total_articles} articles — lectures, essays, "
f"poems, and philosophical discussions on Kṛṣṇa consciousness."
),
"totalIssues": total_issues,
"totalArticles": total_articles,
"flag": "Pre 1978",
"chapters": chapters,
}
# ---------------------------------------------------------------------------
# Phase 4: Write JSON files
# ---------------------------------------------------------------------------
# Clean output directory
if os.path.exists(issues_dir):
for f in os.listdir(issues_dir):
os.remove(os.path.join(issues_dir, f))
# Write intro.json
write_json(intro_json, os.path.join(output_dir, "intro.json"))
print(f"\n Written: intro.json ({total_issues} issues, {total_articles} articles)")
# Write issue files
order = 100
for issue in issues_data:
issue_json = {
"book": "btg-1978",
"volume": issue["volume"],
"part": issue["part"],
"title": issue["title"],
"slug": issue["slug"],
"order": order,
"articles": issue["articles"],
"paragraphs": issue["paragraphs"],
}
filepath = os.path.join(issues_dir, f"{issue['slug']}.json")
write_json(issue_json, filepath)
order += 1
print(f" Written: {len(issues_data)} issue files")
# ---------------------------------------------------------------------------
# Phase 5: Quality checks
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" QUALITY CHECKS")
print(f"{'=' * 60}")
# Check for ScaGoudy remnants
sca_chars = set(SCAGOUDY_TO_IAST.keys())
sca_remnants = 0
for issue in issues_data:
for p in issue["paragraphs"]:
if any(c in p for c in ("å", "ë", "ö")):
sca_remnants += 1
break
if sca_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {sca_remnants} issues may have ScaGoudy remnants")
# Check for empty issues
empty_issues = [i for i in issues_data if len(i["paragraphs"]) == 0]
if empty_issues:
print(
f" ⚠ {len(empty_issues)} empty issues: {[i['slug'] for i in empty_issues]}"
)
else:
print(f" ✅ No empty issues")
# Check centered verses
verse_count = 0
for issue in issues_data:
for p in issue["paragraphs"]:
if '<div style="text-align:center">' in p:
verse_count += 1
print(f" ℹ️ {verse_count} center-aligned verse paragraphs")
# Check for remaining advertisement content
ad_check = 0
ad_keywords = ["advertisement", "subscribe", "subscription form"]
for issue in issues_data:
for p in issue["paragraphs"]:
lower_p = p.lower()
if any(kw in lower_p for kw in ad_keywords):
# Only flag if the paragraph is primarily an ad
if "advertisement" in lower_p and len(p) < 200:
ad_check += 1
if ad_check == 0:
print(f" ✅ No advertisement content detected in output")
else:
print(f" ⚠ {ad_check} paragraphs may contain advertisement content")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" EXTRACTION SUMMARY")
print(f"{'=' * 60}")
if group_early:
early_arts = sum(len(i["articles"]) for i in group_early)
early_paras = sum(len(i["paragraphs"]) for i in group_early)
print(
f" Issues #1–17 (1966–1968): {len(group_early)} issues, "
f"{early_arts} articles, {early_paras} paragraphs"
)
for issue in group_early:
print(
f" {issue['slug']}: {issue['title']} "
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
)
if group_later:
later_arts = sum(len(i["articles"]) for i in group_later)
later_paras = sum(len(i["paragraphs"]) for i in group_later)
print(
f" Issues #30–54 (1969–1973): {len(group_later)} issues, "
f"{later_arts} articles, {later_paras} paragraphs"
)
for issue in group_later:
print(
f" {issue['slug']}: {issue['title']} "
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
)
print(f"\n Total: {total_issues} issues, {total_articles} articles")
print(f" Skipped: {skipped_ads} advertisement sections")
print(f"{'=' * 60}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""Extract Back to Godhead magazine from btg-1960.html.
Each magazine issue becomes its own JSON file.
Issues are grouped by volume in the intro.json metadata.
Structure:
- p2 = Volume/Part bookmark (e.g., "BTGPY1: Volume 1, Part 1, 1944")
- p3 = Volume/Part display title
- p8 = Article bookmark within an issue (e.g., "BTGPY1a: Message of His Divine Grace")
- p9 = Article display title (bold)
- p12, p35 = Body paragraphs
- p14, p17, p88 = Continuation notes / misc text
- p15 = Verse blocks (italic, with ScaGoudy encoding)
- p33, p40, p47, p54 = Magazine mastheads/headers
Output:
lib/jsons/magazines/btg/intro.json
lib/jsons/magazines/btg/issues/v1-p1.json
lib/jsons/magazines/btg/issues/v1-p2.json
...
lib/jsons/magazines/btg/issues/v4-p1.json
Usage:
python3 scripts/btg_extract.py
"""
import json
import os
import re
import sys
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 (s1 is a ref style)
html = re.sub(
r'<span[^>]*class="s1"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s3">text</span> → keep inner text (superscript digit)
html = re.sub(
r'<span[^>]*class="s3"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s4">text</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s4"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s5">text</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s5"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s7">text</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s7"[^>]*>(.*?)</span>', r"\1", html, flags=re.DOTALL
)
# <span class="s8">text</span> → keep inner text
html = re.sub(
r'<span[^>]*class="s8"[^>]*>(.*?)</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 Apple-tab-span
html = html.replace("\t", " ")
# Collapse multiple spaces (but not around <br>)
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 is_verse_paragraph(tag: Tag) -> bool:
"""Check if a paragraph is a verse block (italic with line breaks)."""
cls = get_css_class(tag)
# p15 is commonly used for verse blocks
if cls == "p15":
return True
# Also check for paragraphs that are entirely italic with <br> breaks
# (some verses use p14 or other classes but are entirely in <i> tags)
return False
def format_verse(tag: Tag) -> str:
"""Format a verse paragraph as a center-aligned div."""
html = inner_html(tag)
if not html:
return ""
# Wrap in center-aligned div
return f'<div style="text-align:center">{html}</div>'
def write_json(data: dict, 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")
# ---------------------------------------------------------------------------
# Parse volume/part info from p2 bookmark text
# ---------------------------------------------------------------------------
def parse_volume_part(text: str):
"""Parse 'BTGPY1: Volume 1, Part 1, 1944' → (volume, part, date_str, slug).
Returns (volume_num, part_num, date_string, slug) or None.
"""
# Remove the BTGPY prefix
text = text.strip()
# Match "Volume X, Part Y, ..."
m = re.search(r"Volume\s+(\d+),?\s*Part\s+(\d+),?\s*(.*)", text, re.IGNORECASE)
if m:
vol = int(m.group(1))
part = int(m.group(2))
date_str = m.group(3).strip().rstrip(" ")
slug = f"v{vol}-p{part}"
return vol, part, date_str, slug
return None
# ---------------------------------------------------------------------------
# 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", "btg-1960.html")
output_dir = os.path.join(base_dir, "lib", "jsons", "magazines", "btg")
issues_dir = os.path.join(output_dir, "issues")
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 issue boundaries
# ---------------------------------------------------------------------------
# Each issue starts with a p2 bookmark tag
issue_boundaries = [] # List of (tag_index, volume, part, date_str, slug)
for i, tag in enumerate(all_tags):
cls = get_css_class(tag)
if cls == "p2":
text = tag.get_text().strip()
parsed = parse_volume_part(text)
if parsed:
vol, part, date_str, slug = parsed
issue_boundaries.append((i, vol, part, date_str, slug))
print(f"\n Found {len(issue_boundaries)} issues")
for ib in issue_boundaries:
print(f" tag[{ib[0]}]: Vol {ib[1]}, Part {ib[2]}, {ib[3]} → {ib[4]}")
# ---------------------------------------------------------------------------
# Phase 2: Extract articles and paragraphs for each issue
# ---------------------------------------------------------------------------
# Classes to skip entirely
SKIP_CLASSES = {"p2", "p11", "p24", "p28"}
# Classes that are header/masthead material (include as paragraphs)
MASTHEAD_CLASSES = {"p3", "p4", "p5", "p6", "p7", "p32", "p33", "p34", "p40", "p47"}
# Classes that are body text
BODY_CLASSES = {
"p10",
"p12",
"p13",
"p14",
"p15",
"p16",
"p17",
"p35",
"p36",
"p37",
"p38",
"p39",
"p41",
"p42",
"p43",
"p44",
"p45",
"p46",
"p48",
"p49",
"p50",
"p51",
"p52",
"p53",
"p54",
"p55",
"p56",
"p57",
"p58",
"p59",
"p60",
"p61",
"p62",
"p63",
"p64",
"p65",
"p66",
"p67",
"p68",
"p69",
"p70",
"p71",
"p72",
"p73",
"p74",
"p75",
"p76",
"p77",
"p78",
"p79",
"p80",
"p81",
"p82",
"p83",
"p84",
"p85",
"p86",
"p87",
"p88",
"p89",
"p90",
"p91",
"p92",
"p93",
"p94",
"p95",
"p96",
"p97",
"p98",
"p99",
}
issues_data = []
for idx, (start_tag_idx, vol, part, date_str, slug) in enumerate(issue_boundaries):
# Determine end boundary
if idx + 1 < len(issue_boundaries):
end_tag_idx = issue_boundaries[idx + 1][0]
else:
end_tag_idx = len(all_tags)
issue_tags = all_tags[start_tag_idx:end_tag_idx]
paragraphs = []
articles = [] # List of { title, slug }
for tag in issue_tags:
cls = get_css_class(tag)
text = tag.get_text().strip()
# Skip bookmark tags and empty lines
if cls in SKIP_CLASSES:
continue
if not text and cls not in ("p15",):
continue
# p8 = Article bookmark — extract title for metadata
if cls == "p8":
# Extract article title from bookmark text (remove BTGPYxx: prefix)
article_text = text
m = re.match(r"BTGPY\d+[a-z]?:\s*(.*)", article_text)
if m:
article_title = m.group(1).strip()
else:
article_title = article_text
article_title = decode_scagoudy(article_title)
articles.append({"title": article_title})
continue
# p9 = Article display title — render as bold heading
if cls == "p9":
html = inner_html(tag)
if html:
# Make it a bold article heading
if not html.startswith("<b>"):
html = f"<b>{html}</b>"
paragraphs.append(html)
continue
# p15 = Verse block — center align
if cls == "p15":
html = format_verse(tag)
if html:
paragraphs.append(html)
continue
# p3 = Volume title — skip (it's the display title of the issue header)
if cls == "p3":
continue
# Masthead classes — include but as regular paragraphs
if cls in MASTHEAD_CLASSES:
html = inner_html(tag)
if html and html != "<br>":
paragraphs.append(html)
continue
# Body text classes
html = inner_html(tag)
if html and html != "<br>":
# Check if this looks like a verse (entirely italic with <br> breaks)
stripped = html.strip()
if (
stripped.startswith("<i>")
and "<br" in stripped
and not '<div style="text-align:center">' in stripped
):
# Check if it's predominantly a verse (italic block with line breaks)
# by seeing if removing the <i> tags leaves mostly text with <br>
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)
# Build display title
if date_str:
display_title = f"Volume {vol}, Part {part}, {date_str}"
else:
display_title = f"Volume {vol}, Part {part}"
issue_data = {
"book": "btg",
"volume": vol,
"part": part,
"title": display_title,
"slug": slug,
"articles": articles,
"paragraphs": paragraphs,
}
issues_data.append(issue_data)
print(
f" {slug}: {display_title} — "
f"{len(articles)} articles, {len(paragraphs)} paragraphs"
)
# ---------------------------------------------------------------------------
# Phase 3: Build intro.json with hierarchical volume groups
# ---------------------------------------------------------------------------
# Group issues by volume
volume_groups = {}
for issue in issues_data:
vol = issue["volume"]
if vol not in volume_groups:
volume_groups[vol] = []
volume_groups[vol].append(issue)
# Build chapters array for intro.json
chapters = []
for vol_num in sorted(volume_groups.keys()):
vol_issues = volume_groups[vol_num]
sub_chapters = []
for issue in vol_issues:
sub_chapters.append(
{
"slug": issue["slug"],
"title": issue["title"],
}
)
chapters.append(
{
"title": f"Volume {vol_num}",
"subChapters": sub_chapters,
}
)
total_issues = len(issues_data)
total_articles = sum(len(i["articles"]) for i in issues_data)
intro_json = {
"book": "btg",
"title": "Back to Godhead",
"author": "His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
"year": 1944,
"description": f"The original Back to Godhead magazine founded in 1944. {total_issues} issues across {len(volume_groups)} volumes, containing {total_articles} articles on Kṛṣṇa consciousness, Vedic philosophy, and spiritual life.",
"totalIssues": total_issues,
"totalArticles": total_articles,
"chapters": chapters,
}
# ---------------------------------------------------------------------------
# Phase 4: Write JSON files
# ---------------------------------------------------------------------------
# Clean output directory
if os.path.exists(issues_dir):
for f in os.listdir(issues_dir):
os.remove(os.path.join(issues_dir, f))
# Write intro.json
write_json(intro_json, os.path.join(output_dir, "intro.json"))
print(f"\n Written: intro.json ({total_issues} issues, {total_articles} articles)")
# Write issue files
order = 100
for issue in issues_data:
issue_json = {
"book": "btg",
"volume": issue["volume"],
"part": issue["part"],
"title": issue["title"],
"slug": issue["slug"],
"order": order,
"articles": issue["articles"],
"paragraphs": issue["paragraphs"],
}
filepath = os.path.join(issues_dir, f"{issue['slug']}.json")
write_json(issue_json, filepath)
order += 1
print(f" Written: {len(issues_data)} issue files")
# ---------------------------------------------------------------------------
# Phase 5: Quality checks
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print(f" QUALITY CHECKS")
print(f"{'=' * 60}")
# Check for ScaGoudy remnants
sca_chars = set(SCAGOUDY_TO_IAST.keys())
sca_remnants = 0
for issue in issues_data:
for p in issue["paragraphs"]:
if any(c in p for c in sca_chars if c not in ("é", "ü", "à")):
# Skip common French/German chars that might be legitimate
# Focus on clearly ScaGoudy-only chars
if "å" in p or "ë" in p or "ö" in p and "Ö" not in p:
sca_remnants += 1
break
if sca_remnants == 0:
print(f" ✅ No ScaGoudy remnants detected")
else:
print(f" ⚠ {sca_remnants} issues may have ScaGoudy remnants")
# Check for empty issues
empty_issues = [i for i in issues_data if len(i["paragraphs"]) == 0]
if empty_issues:
print(
f" ⚠ {len(empty_issues)} empty issues: {[i['slug'] for i in empty_issues]}"
)
else:
print(f" ✅ No empty issues")
# Check centered verses
verse_count = 0
for issue in issues_data:
for p in issue["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}")
for vol_num in sorted(volume_groups.keys()):
vol_issues = volume_groups[vol_num]
vol_articles = sum(len(i["articles"]) for i in vol_issues)
vol_paras = sum(len(i["paragraphs"]) for i in vol_issues)
print(
f" Volume {vol_num}: {len(vol_issues)} issues, {vol_articles} articles, {vol_paras} paragraphs"
)
for issue in vol_issues:
print(
f" {issue['slug']}: {issue['title']} "
f"({len(issue['articles'])} articles, {len(issue['paragraphs'])} paras)"
)
print(f"\n Total: {total_issues} issues, {total_articles} articles")
print(f"{'=' * 60}")
print(f"\nDone! ✨")
if __name__ == "__main__":
main()
+689
View File
@@ -0,0 +1,689 @@
#!/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()
+158
View File
@@ -0,0 +1,158 @@
/**
* seed-letters.ts — Push letters into MongoDB.
*
* Collection written:
* - letters → 6,866 individual letter documents
*
* JSON sources:
* - lib/jsons/letters/1947.json through 1977.json
* - lib/jsons/letters/undated.json
*
* Usage:
* npx tsx scripts/seed-letters.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env" });
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("❌ DATABASE_URL is not set");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const LETTERS_DIR = path.join(BASE_DIR, "lib", "jsons", "letters");
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
function sanitizeString(s: string): string {
s = s.normalize("NFC");
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
s = s.replace(/[\x7f-\x9f]/g, "");
s = s.replace(/[\uD800-\uDFFF]/g, "");
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
s = s.replace(/[\uFDD0-\uFDEF\uFFFE\uFFFF]/g, "");
s = Buffer.from(s, "utf-8").toString("utf-8");
s = s.split("\0").join("");
return s;
}
function sanitizeDoc(obj: unknown): unknown {
if (typeof obj === "string") return sanitizeString(obj);
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>))
result[key] = sanitizeDoc(value);
return result;
}
return obj;
}
type LetterJson = {
slug: string;
recipients: string[];
recipientDisplay: string;
date: string | null;
dateRef: string | null;
year: number | null;
month: number | null;
day: number | null;
location: string | null;
salutation: string | null;
paragraphs: string[];
};
async function main() {
console.log("🌱 Seeding Letters...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const col = db.collection("letters");
const start = Date.now();
// Delete all existing letters
const deleted = await col.deleteMany({});
console.log(` 🗑️ Deleted ${deleted.deletedCount} existing letters`);
// Ensure indexes
await col.createIndex({ slug: 1 }, { unique: true });
await col.createIndex({ year: 1 });
await col.createIndex({ recipient: 1 });
await col.createIndex({ location: 1 });
await col.createIndex({ year: 1, month: 1 });
// Read all year files
const files = fs
.readdirSync(LETTERS_DIR)
.filter((f) => f.endsWith(".json") && f !== "index.json")
.sort();
console.log(`\n 📂 Found ${files.length} letter files`);
let inserted = 0;
let errors = 0;
for (const file of files) {
const letters = readJson<LetterJson[]>(path.join(LETTERS_DIR, file));
console.log(`\n 📄 ${file}: ${letters.length} letters`);
for (const letter of letters) {
const doc = sanitizeDoc(letter) as Record<string, unknown>;
try {
await col.insertOne(doc);
inserted++;
if (inserted % 500 === 0) {
console.log(` ... ${inserted} letters inserted`);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("duplicate key")) {
// Skip duplicates silently
continue;
}
console.error(
` ❌ Error inserting ${letter.slug}: ${msg.substring(0, 100)}`,
);
errors++;
if (errors > 10) {
console.error(" Too many errors, stopping.");
break;
}
}
}
if (errors > 10) break;
}
// Verify
const totalDocs = await col.countDocuments();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log("\n" + "─".repeat(50));
console.log(`📊 Summary:`);
console.log(` Inserted: ${inserted} letters`);
console.log(` Errors: ${errors}`);
console.log(` Total in collection: ${totalDocs}`);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});
+189
View File
@@ -0,0 +1,189 @@
/**
* seed-magazines.ts — Push Magazines into MongoDB.
*
* Collections written:
* - magazine_meta → one doc per magazine
* - magazine_issues → N issue docs per magazine
*
* Usage:
* npx tsx scripts/seed-magazines.ts
*/
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { MongoClient } from "mongodb";
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env" });
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("❌ DATABASE_URL is not set");
process.exit(1);
}
const BASE_DIR = path.resolve(__dirname, "..");
const MAGAZINES_DIR = path.join(BASE_DIR, "lib", "jsons", "magazines");
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
function sanitizeString(s: string): string {
// Normalize to NFC
s = s.normalize("NFC");
// Remove null bytes and C0 control chars (keep \t \n \r)
s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
// Remove C1 control chars
s = s.replace(/[\x7f-\x9f]/g, "");
// Remove lone surrogates (U+D800–U+DFFF) that are invalid in BSON
s = s.replace(/[\uD800-\uDFFF]/g, "");
// Remove zero-width characters that can confuse BSON length calculations
s = s.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, "");
// Remove non-characters (U+FDD0–U+FDEF, U+FFFE, U+FFFF)
s = s.replace(/[\uFDD0-\uFDEF\uFFFE\uFFFF]/g, "");
// Remove any remaining non-BMP characters that could cause issues
// (keep common supplementary planes like emoji, but remove PUA etc.)
s = s.replace(/[\uDB80-\uDBFF][\uDC00-\uDFFF]/g, ""); // Supplementary PUA
// Round-trip through Buffer to guarantee valid UTF-8 byte sequence
s = Buffer.from(s, "utf-8").toString("utf-8");
// Final safety: ensure the string is null-terminated compatible
// by removing any embedded nulls that survived
s = s.split("\0").join("");
return s;
}
function sanitizeDoc(obj: unknown): unknown {
if (typeof obj === "string") {
return sanitizeString(obj);
}
if (Array.isArray(obj)) return obj.map(sanitizeDoc);
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>))
result[key] = sanitizeDoc(value);
return result;
}
return obj;
}
async function seedOneMagazine(
db: ReturnType<MongoClient["db"]>,
magDir: string,
): Promise<{ bookCode: string; issues: number }> {
const introPath = path.join(magDir, "intro.json");
if (!fs.existsSync(introPath)) throw new Error(`No intro.json in ${magDir}`);
const intro = sanitizeDoc(
readJson<Record<string, unknown>>(introPath),
) as Record<string, unknown>;
const bookCode = intro.book as string;
const metaCol = db.collection("magazine_meta");
await metaCol.replaceOne(
{ book: bookCode },
{ book: bookCode, data: intro },
{ upsert: true },
);
console.log(` 📖 Upserted magazine_meta for "${intro.title}" (${bookCode})`);
const issuesCol = db.collection("magazine_issues");
// Delete old issues for this magazine
await issuesCol.deleteMany({ book: bookCode });
const issuesDir = path.join(magDir, "issues");
let inserted = 0;
if (fs.existsSync(issuesDir)) {
const files = fs
.readdirSync(issuesDir)
.filter((f) => f.endsWith(".json"))
.sort();
for (const file of files) {
const issue = sanitizeDoc(
readJson<Record<string, unknown>>(path.join(issuesDir, file)),
) as Record<string, unknown>;
// Deep-sanitize each paragraph string
const rawParas = (issue.paragraphs as string[]) || [];
const cleanParas = rawParas.map((p: string) => sanitizeString(String(p)));
// Store paragraphs as a single JSON string to avoid MongoDB BSON
// wire protocol issues with large string arrays.
// The data layer will JSON.parse() this back into an array on read.
const paragraphsJson = JSON.stringify(cleanParas);
const doc = {
book: bookCode,
volume: issue.volume,
part: issue.part,
title: issue.title,
slug: issue.slug,
order: issue.order,
paragraphsJson,
paragraphs: [] as string[], // Keep empty array for schema compat
};
await issuesCol.replaceOne({ book: bookCode, slug: issue.slug }, doc, {
upsert: true,
});
inserted++;
}
console.log(` 📄 Issues: ${inserted}`);
}
const total = await issuesCol.countDocuments({ book: bookCode });
console.log(` ✅ ${bookCode}: ${total} issue entries`);
return { bookCode, issues: inserted };
}
async function main() {
console.log("🌱 Seeding Magazines...\n");
const client = new MongoClient(DATABASE_URL!);
await client.connect();
console.log("📡 Connected to MongoDB\n");
const db = client.db();
const start = Date.now();
if (!fs.existsSync(MAGAZINES_DIR)) {
console.error(`❌ ${MAGAZINES_DIR} not found`);
process.exit(1);
}
const subdirs = fs
.readdirSync(MAGAZINES_DIR)
.filter((e) => {
const p = path.join(MAGAZINES_DIR, e);
return (
fs.statSync(p).isDirectory() &&
fs.existsSync(path.join(p, "intro.json"))
);
})
.sort();
if (subdirs.length === 0) {
console.error("❌ No magazine directories found");
process.exit(1);
}
console.log(`📂 Found ${subdirs.length} magazine(s)\n`);
let totalIssues = 0;
const bookCodes: string[] = [];
for (const subdir of subdirs) {
console.log(`\n📝 Processing: ${subdir}`);
const result = await seedOneMagazine(db, path.join(MAGAZINES_DIR, subdir));
bookCodes.push(result.bookCode);
totalIssues += result.issues;
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log("\n" + "─".repeat(50));
console.log(`📊 Summary: ${subdirs.length} magazines, ${totalIssues} issues`);
console.log(` Book codes: ${bookCodes.join(", ")}`);
console.log(`\n🎉 Done in ${elapsed}s`);
await client.close();
}
main().catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});