#!/usr/bin/env python3
"""Build UI-ready Lenormand pairs asset from the scraped corpus.
Writes:
assets/data/lenormand_pairs.json
oracle_enricher/data/lenormand_pairs.json (same blob)
Also supplements traditions_canon.json lore from Magya short meanings.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CORPUS = ROOT / "oracle_enricher" / "data" / "sources" / "lenormand_corpus.json"
CANON = ROOT / "oracle_enricher" / "data" / "traditions_canon.json"
UNIFIED = ROOT / "oracle_enricher" / "out" / "lenormand_unify" / "unified.json"
OUT_ASSET = ROOT / "assets" / "data" / "lenormand_pairs.json"
OUT_DATA = ROOT / "oracle_enricher" / "data" / "lenormand_pairs.json"
HTML_CACHE = ROOT / "oracle_enricher" / "out" / "lenormand_scrape" / "html"
ASSET_CANON = ROOT / "assets" / "data" / "traditions_canon.json"
HTML_VIEWER = ROOT / "lexicon_viewer.html"
KARUSEL_MEAN_SLUG = {
1: "karty-lenorman-vsadnik",
2: "karty-lenorman-klever",
3: "karty-lenorman-korabl",
4: "karty-lenorman-dom",
5: "karty-lenorman-derevo",
6: "karty-lenorman-tuchi",
7: "karty-lenorman-zmeya",
8: "karty-lenorman-grob",
9: "karty-lenorman-buket",
10: "karty-lenorman-kosa",
11: "karty-lenorman-metla",
12: "karty-lenorman-sovy",
13: "karty-lenorman-rebenok",
14: "karty-lenorman-lisa",
15: "karty-lenorman-medved",
16: "karty-lenorman-zvezdy",
17: "karty-lenorman-aist",
18: "karty-lenorman-sobaka",
19: "karty-lenorman-bashnya",
20: "karty-lenorman-sad",
21: "karty-lenorman-gora",
22: "karty-lenorman-razvilka",
23: "karty-lenorman-krysy",
24: "karta-lenorman-serdce",
25: "karty-lenorman-kolco",
26: "karty-lenorman-kniga",
27: "karty-lenorman-pismo",
28: "karty-lenorman-muzhchina",
29: "karty-lenorman-zhenshhina",
30: "karty-lenorman-lilii",
31: "karty-lenorman-solnce",
32: "karty-lenorman-luna",
33: "karty-lenorman-klyuch",
34: "karty-lenorman-ryby",
35: "karty-lenorman-yakor",
36: "karty-lenorman-krest",
}
SOURCE_LABELS = {
"karusel_table": "Карусель · таблица",
"karusel_short": "Карусель · кратко",
"ezoplaneta": "Эзопланета",
"magya_meaning": "Magya · кратко",
"magya_pair": "Magya · эссе",
"karusel": "Карусель",
"magya": "Magya",
}
SOURCE_ORDER = [
"magya_pair",
"karusel_table",
"magya_meaning",
"ezoplaneta",
"karusel_short",
]
def sort_readings(readings: list[dict]) -> list[dict]:
rank = {s: i for i, s in enumerate(SOURCE_ORDER)}
return sorted(readings, key=lambda r: (rank.get(r.get("source"), 99), r.get("left", 0)))
def strip_tags(html: str) -> str:
html = re.sub(r"(?is)", " ", html)
html = re.sub(r"(?is)", " ", html)
html = re.sub(r"(?i)
", "\n", html)
html = re.sub(r"(?i)
", "\n\n", html)
html = re.sub(r"(?i)", "\n", html)
html = re.sub(r"(?i)", "\n", html)
html = re.sub(r"<[^>]+>", " ", html)
html = html.replace("\xa0", " ")
html = re.sub(r"[ \t]+", " ", html)
html = re.sub(r"\n[ \t]+", "\n", html)
html = re.sub(r"\n{3,}", "\n\n", html)
return html.strip()
def clean_magya_meaning(text: str) -> str:
m = re.search(r"Краткое значение:", text)
if m:
text = text[m.start() :]
text = re.split(r"(?i)Сочетание карты .+ с другими картами", text)[0]
text = re.sub(r"(?is)Гадания\s*>[\s\S]{0,200}?Всадник\s*", "", text)
text = re.sub(r"^\s*>\s*", "", text, flags=re.M)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def clean_ezo_meaning(text: str) -> str:
text = re.sub(r"(?im)^.*\.(jpg|png|jpeg).*$", "", text)
text = re.sub(r"(?im)^.*\d[\d\s.,]*\s*КБ.*просмотр.*$", "", text)
text = re.split(r"(?i)\nСочетани", text)[0]
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
def clean_karusel_from_cache(n: int, fallback: str) -> str:
slug = KARUSEL_MEAN_SLUG.get(n)
if not slug:
return fallback
path = HTML_CACHE / f"karusel-magii.ru_{slug}_.html"
if not path.exists():
path = HTML_CACHE / f"karusel-magii.ru_{slug}.html"
if not path.exists():
return fallback
html = path.read_text(encoding="utf-8", errors="replace")
m = re.search(r'(?is)(.*)', html)
if not m:
return fallback
body = m.group(1)
for marker in ('id="sidebar', 'id="footer', "sharedaddy", "yarpp"):
i = body.find(marker)
if i > 200:
body = body[:i]
break
text = strip_tags(body)
text = re.split(r"(?i)Смотрите также", text)[0]
text = re.split(r"(?i)Рекомендуем по этой теме", text)[0]
text = re.sub(r"(?im)^Карты Ленорман .*$", "", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text if len(text) > 80 else fallback
def preview_of(readings: list[dict]) -> str:
for src in SOURCE_ORDER:
for r in readings:
if r.get("source") == src and r.get("text"):
t = r["text"].replace("\n", " ").strip()
return t if len(t) <= 160 else t[:157].rstrip() + "…"
return ""
def main() -> None:
corpus = json.loads(CORPUS.read_text(encoding="utf-8"))
canon = json.loads(CANON.read_text(encoding="utf-8"))
unified = {}
if UNIFIED.exists():
unified = json.loads(UNIFIED.read_text(encoding="utf-8"))
stations = canon["lenormand"]["stations"]
by_n: dict[int, dict] = {}
for st in stations:
sid = st.get("id", "")
if sid.startswith("card_"):
n = int(sid.split("_")[1])
by_n[n] = st
cards_out = []
for n in range(1, 37):
cc = corpus["cards"][str(n)]
st = by_n.get(n, {})
meanings = []
magya_short = ""
for m in cc.get("meanings", []):
src = m.get("source", "")
text = (m.get("text") or "").strip()
if src == "magya":
text = clean_magya_meaning(text)
mm = re.search(r"Краткое значение:\s*(.+)", text)
if mm:
magya_short = mm.group(1).split("\n")[0].strip(" .")
elif src == "ezoplaneta":
text = clean_ezo_meaning(text)
elif src == "karusel":
text = clean_karusel_from_cache(n, text)
if len(text) < 40:
continue
meanings.append(
{
"source": src,
"label": SOURCE_LABELS.get(src, src),
"text": text,
}
)
pairs = []
for other in range(1, 37):
if other == n:
continue
key = f"{min(n, other):02d}-{max(n, other):02d}"
bucket = corpus["pairs"].get(key)
if not bucket:
continue
readings = []
for r in sort_readings(bucket.get("readings", [])):
readings.append(
{
"source": r.get("source"),
"label": SOURCE_LABELS.get(r.get("source", ""), r.get("source", "")),
"left": r.get("left"),
"right": r.get("right"),
"text": (r.get("text") or "").strip(),
}
)
if not readings:
continue
ost = by_n.get(other, {})
up = (unified.get("pairs") or {}).get(key) or {}
utext = (up.get("text") or "").strip()
pairs.append(
{
"n": other,
"id": f"card_{other}",
"name": corpus["cards"][str(other)]["name"],
"symbol": ost.get("symbol", ""),
"preview": (utext[:157] + "…") if len(utext) > 160 else (utext or preview_of(readings)),
"text": utext,
"readings": [] if utext else readings,
}
)
ucard = (unified.get("cards") or {}).get(str(n)) or {}
umeaning = (ucard.get("meaning") or "").strip()
ucore = (ucard.get("core") or "").strip()
cards_out.append(
{
"n": n,
"id": f"card_{n}",
"name": cc["name"],
"symbol": st.get("symbol", ""),
"lore": ucore or magya_short or st.get("lore", ""),
"group": st.get("group", f"Карта {n}"),
"meaning": umeaning,
"shadow": (ucard.get("shadow") or "").strip(),
"meanings": [] if umeaning else meanings,
"pairs": pairs,
}
)
names = {c["n"]: c["name"] for c in cards_out}
triads = []
uni_triads = unified.get("triads") or []
if uni_triads:
for t in uni_triads:
nums = [int(x) for x in t.get("cards", [])]
if len(nums) != 3:
continue
triads.append(
{
"cards": nums,
"names": [names.get(x, str(x)) for x in nums],
"text": (t.get("text") or "").strip(),
}
)
else:
for t in corpus.get("triads", []):
nums = [int(x) for x in t.get("cards", []) if str(x).isdigit()]
if len(nums) != 3:
continue
triads.append(
{
"cards": nums,
"names": [names.get(x, str(x)) for x in nums],
"text": (t.get("text") or "").strip(),
}
)
extras = []
for e in corpus.get("extras", []):
title = (e.get("title") or "").strip()
text = (e.get("text") or "").strip()
if len(text) < 80:
continue
extras.append(
{
"source": e.get("source", ""),
"title": title,
"text": text,
}
)
blob = {
"system": "lenormand",
"source_labels": SOURCE_LABELS,
"card_count": 36,
"pair_count": sum(len(c["pairs"]) for c in cards_out) // 2,
"cards": cards_out,
"triads": triads,
"extras": extras,
}
text = json.dumps(blob, ensure_ascii=False, indent=2) + "\n"
OUT_ASSET.parent.mkdir(parents=True, exist_ok=True)
OUT_ASSET.write_text(text, encoding="utf-8")
OUT_DATA.write_text(text, encoding="utf-8")
# Supplement station lore in traditions canon (short line on the card).
lore_by_n = {c["n"]: c["lore"] for c in cards_out if c.get("lore")}
for canon_path in (CANON, ASSET_CANON):
data = json.loads(canon_path.read_text(encoding="utf-8"))
ln = data["lenormand"]
ln["description"] = (
"36 карт французского оракула мадемуазель Ленорман: значения с трёх источников "
"и 630 парных сочетаний (карусель, эзопланета, magya) — источники не схлопываются."
)
for st in ln["stations"]:
sid = st.get("id", "")
if not sid.startswith("card_"):
continue
n = int(sid.split("_")[1])
if lore_by_n.get(n):
st["lore"] = lore_by_n[n]
canon_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
if HTML_VIEWER.exists():
html = HTML_VIEWER.read_text(encoding="utf-8")
start = html.find(" const TRADITIONS_CANON = ")
end = html.find(" const WORDS_DATA = ")
if start > 0 and end > start:
data = json.loads(CANON.read_text(encoding="utf-8"))
blob_js = (
" const TRADITIONS_CANON = "
+ json.dumps(data, ensure_ascii=False, indent=2)
+ ";\n\n"
)
HTML_VIEWER.write_text(html[:start] + blob_js + html[end:], encoding="utf-8")
print("patched", HTML_VIEWER.relative_to(ROOT))
print(
f"cards={len(cards_out)} pairs_per_card={len(cards_out[0]['pairs'])} "
f"triads={len(triads)} extras={len(extras)} bytes={len(text)}"
)
print("wrote", OUT_ASSET.relative_to(ROOT))
if __name__ == "__main__":
main()