interactive-chat / scripts /beatcard_convert.py
Junhoee's picture
Upload 140 files
6e20b9b verified
Raw
History Blame Contribute Delete
18.7 kB
"""๋น„ํŠธ์นด๋“œ MD โ†’ content/ YAML ๋ณ€ํ™˜๊ธฐ (๊ฐœ๋ฐœํŒ€ ์†Œ์œ ).
์‚ฌ์šฉ๋ฒ•:
.venv/bin/python scripts/beatcard_convert.py <๋น„ํŠธ์นด๋“œ_ํด๋”> -o content/beatcards
- ์ž…๋ ฅ: ์นด๋ฅด๋ฐ€๋ผ/0701_์นด๋ฅด๋ฐ€๋ผ/๋น„ํŠธ์นด๋“œ/ (๊ถŒ์œ„, ๋Œ€ํ‘œ๋‹˜ ์†Œ์œ  โ€” ๋‚ด์šฉ ๋ณ€ํ˜• ๊ธˆ์ง€)
- ์ถœ๋ ฅ: content/beatcards/E##.yaml + R-*.yaml
- ๊ตฌ์กฐ ๋ณ€ํ™˜๋งŒ ํ•œ๋‹ค. ๋งค์นญ์ด ๋ถˆํ™•์‹คํ•œ ์„ ํƒ์ง€ ๊ฒฐ๊ณผ๋Š” ๋ฆฌํฌํŠธ๋กœ ํ‘œ์‹œํ•˜๊ณ  axis=None์œผ๋กœ ๋‚จ๊ธด๋‹ค.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
# engine.schemas๋กœ ์ฆ‰์‹œ ๊ฒ€์ฆํ•˜๊ธฐ ์œ„ํ•ด ์ €์žฅ์†Œ ๋ฃจํŠธ๋ฅผ ๊ฒฝ๋กœ์— ์ถ”๊ฐ€
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine.schemas.beatcard import EpisodeFile, RCard # noqa: E402
AXIS_MAP = {"์‹ ๋ขฐ+": "trust", "์˜์‹ฌ+": "doubt", "์ค‘๋ฆฝ": "neutral",
"ํ˜๋ฆผ": "neutral", "์ˆ˜๋™": "neutral"}
GATE_MARKS = {"ํ‘œ๋ฉด": "all", "์‹ฌ์ธต": "trust_high"}
RE_CARD_HEADER = re.compile(r"^##\s+(?:โ˜…\s*)?([A-Z]\d{2}-\d{2}[A-Z]?)\s*[ยทยท]\s*(?:โ˜…\s*)?([^(\n]+?)(?:\s*\(([^)]*)\))?\s*$", re.M)
RE_TAG = re.compile(r"`(#[^`]+)`")
RE_NEXT = re.compile(r"ยท\s*โ†’\s*([\w๊ฐ€-ํžฃ/\-]+)")
RE_CHOICE_HEADER = re.compile(r"^\*\*โ–ท\s*(.+?)\*\*", re.M)
RE_CHOICE_LABEL = re.compile(r"\[([^\]]+)\]")
RE_CHOICE_MOD = re.compile(r"ใ€”([^ใ€•]+)ใ€•")
RE_CENTER = re.compile(r"โ\s*(.+?)\s*โž", re.S)
RE_CHAR_DIALOGUE = re.compile(r"^>\s*([๊ฐ€-ํžฃA-Za-zยท\s]{1,20}?):\s*[ใ€Œ\"](.+?)[ใ€\"]\s*$")
RE_OLD_RESULT = re.compile(r"^-\s*\*([^:*]+):\*\s*(.+)$")
RE_NEW_RESULT = re.compile(r"^ใ€ˆ([^ยทใ€‰]+)\s*ยท\s*([^ใ€‰]*)ใ€‰\s*(.*)$")
RE_RESULT_NEXT = re.compile(r"โ†’\s*\*{0,2}([A-Z]\d{2}-\d{2}[A-Z]?)\*{0,2}")
RE_BRANCH_MAP = re.compile(r"([๊ฐ€-ํžฃ]+)\s*โ†’\s*\*\*([A-Z]\d{2}-\d{2}[A-Z]?)\*\*")
RE_RCARD_REF = re.compile(r"R-[๊ฐ€-ํžฃ\w]+")
RE_SECTION_STOP = re.compile(r"^(?:โ–ธ\s*๋…ธํŠธ:|๐ŸŽฌ|๐Ÿ–ผ)")
# ์ €์ž‘ ๋ฉ”ํƒ€ ๋ผ์ธ โ€” ์ง€๋ฌธ์œผ๋กœ ์ƒˆ๋ฉด ์•ˆ ๋˜๋Š” ๋ถ„๊ธฐ ๋งคํ•‘ ๋ฉ”๋ชจ (์˜ˆ: "์ˆจ๊น€ โ†’ **E06-10C** (โ€ฆ)").
# ๋ณผ๋“œ ์นด๋“œID ์ฐธ์กฐ๊ฐ€ ๋ณธ๋ฌธ ๋Œ€์‚ฌ/์ง€๋ฌธ์— ๋“ฑ์žฅํ•  ์ผ์€ ์—†๋‹ค
RE_META_LINE = re.compile(r"\*\*E\d{2}-")
RE_STAGE = re.compile(r"^###\s*(\d)๋‹จ๊ณ„\s*[โ€”โ€“-]\s*(.+)$", re.M)
def _bigrams(s: str) -> set[str]:
s = re.sub(r"[^\w๊ฐ€-ํžฃ]", "", s)
return {s[i:i + 2] for i in range(len(s) - 1)} | set(s)
def _match_score(label: str, hint: str, text: str) -> float:
"""์„ ํƒ์ง€ ๋ผ๋ฒจ โ†” ๊ฒฐ๊ณผ ๋ธ”๋ก ์œ ์‚ฌ๋„ (๋ผ๋ฒจํžŒํŠธ ๊ฐ€์ค‘ + ๋ณธ๋ฌธ ์•ž๋ถ€๋ถ„)."""
lb = _bigrams(label)
return 3 * len(lb & _bigrams(hint)) + len(lb & _bigrams(text[:60]))
class EpisodeParser:
def parse(self, text: str, source: str) -> tuple[dict, list[str]]:
warnings: list[str] = []
m = re.search(r"^#\s+(E\d{2})\s+๋น„ํŠธ\s*์นด๋“œ\s*[โ€”โ€“-]\s*(.+?)\s*(?:\(v.*)?$", text, re.M)
ep_id, ep_title = (m.group(1), m.group(2).strip()) if m else ("UNKNOWN", "")
cards = []
headers = list(RE_CARD_HEADER.finditer(text))
for i, h in enumerate(headers):
body = text[h.end(): headers[i + 1].start() if i + 1 < len(headers) else len(text)]
card, ws = self._parse_card(h.group(1), h.group(2).strip(), (h.group(3) or "").strip(), body)
cards.append(card)
warnings += [f"{card['id']}: {w}" for w in ws]
self._resolve_branches(cards, warnings)
self._demote_reach_skips(cards, warnings)
return {"episode": {"id": ep_id, "title": ep_title, "source": source}, "cards": cards}, warnings
# โ”€โ”€ ์นด๋“œ ํ•œ ์žฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _parse_card(self, cid: str, title: str, subtitle: str, body: str) -> tuple[dict, list[str]]:
ws: list[str] = []
lines = body.split("\n")
# ํƒœ๊ทธ ๋ผ์ธ (์ฒซ '>' ๋ผ์ธ)
tag_line = next((l for l in lines if l.strip().startswith(">") and "`#" in l), "")
tags = RE_TAG.findall(tag_line)
nxt, next_options = None, []
nm = RE_NEXT.search(tag_line)
if nm:
raw = nm.group(1)
if "/" in raw: # E06-10B/C โ†’ [E06-10B, E06-10C]
head, *rest = raw.split("/")
prefix = head[: head.rfind(head.lstrip("A-Z0-9-")) ] if False else head
base = re.match(r"([A-Z]\d{2}-\d{2})", head)
next_options = [head] + [(base.group(1) + r) if base and len(r) <= 2 else r for r in rest]
else:
nxt = raw
# ์ถ•/์‡ ์•ฝ ํƒœ๊ทธ
axis = None
axis_tags = [t for t in tags if t.startswith("#์ถ•/")]
if axis_tags:
v = axis_tags[0][len("#์ถ•/"):]
axis = AXIS_MAP.get(v) # "์‹ ๋ขฐ+ยท์˜์‹ฌ+" ๊ฐ™์€ ๋ณตํ•ฉ๊ฐ’ โ†’ None (์„ ํƒ์ง€๊ฐ€ ๊ฒฐ์ •)
frailty = any(t.startswith("#๋ฉ”์ปค๋‹ˆ์ฆ˜/์‡ ์•ฝ๋„") for t in tags)
canon = any(t.startswith("#์บ๋…ผ/") for t in tags)
status = next((t.split("/", 1)[1] for t in tags if t.startswith("#์ƒํƒœ/")), "์ดˆ์•ˆ")
# ๋ณธ๋ฌธ ์ ˆ๋‹จ (๋…ธํŠธ/์—ฐ์ถœ/์ด๋ฏธ์ง€ ๋ถ„๋ฆฌ)
note = self._grab(body, r"โ–ธ\s*๋…ธํŠธ:\s*(.+?)(?=\n๐ŸŽฌ|\n๐Ÿ–ผ|\n---|\Z)")
direction = self._grab(body, r"๐ŸŽฌ\s*\*\*์—ฐ์ถœ ํฌ์ธํŠธ:\*\*\s*(.+?)(?=\n๐Ÿ–ผ|\n---|\Z)")
image = self._grab(body, r"๐Ÿ–ผ\s*\*\*์žฅ๋ฉด ์ด๋ฏธ์ง€:\*\*\s*(.+?)(?=\n---|\Z)")
# ์„ ํƒ์ง€ ๋ผ๋ฒจ
choices: list[dict] = []
ch = RE_CHOICE_HEADER.search(body)
if ch:
seg = ch.group(1)
mods = RE_CHOICE_MOD.findall(seg)
for lab in RE_CHOICE_LABEL.findall(seg):
choices.append({"label": lab.strip(), "modifiers": mods, "axis": None,
"result": "", "next": None})
# ๋‚ด๋ ˆ์ด์…˜ + ๊ฒฐ๊ณผ ๋ธ”๋ก
narration, results = self._parse_blocks(lines, tag_line, ws)
# ๊ฒฐ๊ณผ ๋ธ”๋ก โ†” ์„ ํƒ์ง€ ๋งค์นญ
if choices and results:
self._match_results(choices, results, ws)
elif results and not choices:
ws.append(f"๊ฒฐ๊ณผ ๋ธ”๋ก {len(results)}๊ฐœ๊ฐ€ ์„ ํƒ์ง€ ์—†์ด ์กด์žฌ")
# ๋ถ„๊ธฐ ๋งคํ•‘ ๋ผ์ธ (์ˆจ๊น€ โ†’ **E06-10C**)์€ ์ „์ฒด ์นด๋“œ ํŒŒ์‹ฑ ํ›„ ํ›„์ฒ˜๋ฆฌ (ํƒ€๊นƒ ์นด๋“œ ๋ณธ๋ฌธ ํ•„์š”)
branch_hints = RE_BRANCH_MAP.findall(body)
r_refs = sorted(set(RE_RCARD_REF.findall(body)))
card = {
"id": cid, "title": title, "subtitle": subtitle, "tags": tags,
"status": status, "canon": canon, "next": nxt, "next_options": next_options,
"axis": axis, "frailty": frailty, "narration": narration, "choices": choices,
"r_card_refs": r_refs, "note": note, "direction": direction, "image": image,
}
if branch_hints:
card["_branch_hints"] = branch_hints # ํ›„์ฒ˜๋ฆฌ์šฉ ์ž„์‹œ ํ‚ค (์ €์žฅ ์ „ ์ œ๊ฑฐ)
return card, ws
def _demote_reach_skips(self, cards: list[dict], warnings: list[str]):
"""canon ๋„๋‹ฌ์  ์šฐํšŒ ๋ฐฉ์ง€ โ€” ์„ ํƒ์ง€ next๊ฐ€ ํ•˜๋ฅ˜ ๋ถ„๊ธฐ์นด๋“œ์˜ ์˜ต์…˜์ด๋ฉด
preset_branch๋กœ ๊ฐ•๋“ฑํ•˜๊ณ  ์ •์ƒ ๊ฒฝ๋กœ(card.next)๋ฅผ ๋”ฐ๋ฅด๊ฒŒ ํ•œ๋‹ค."""
by_id = {c["id"]: c for c in cards}
for card in cards:
for ch in card["choices"]:
target = ch.get("next")
if not target:
continue
cur, hops = card, 0
while cur and cur.get("next") in by_id and hops < 6:
cur = by_id[cur["next"]]
hops += 1
if target in cur.get("next_options", []) and cur["id"] != target:
ch["next"] = None
ch["preset_branch"] = target
warnings.append(
f"{card['id']}: '{ch['label'][:14]}โ€ฆ' next={target}๋Š” "
f"๋„๋‹ฌ์  {cur['id']} ์šฐํšŒ โ†’ preset_branch๋กœ ๊ฐ•๋“ฑ")
break
def _resolve_branches(self, cards: list[dict], warnings: list[str]):
"""๋ถ„๊ธฐ ๋งคํ•‘ ํ›„์ฒ˜๋ฆฌ โ€” ํƒ€๊นƒ ์นด๋“œ์˜ ์ฒซ ์ง€๋ฌธ๊ณผ ์„ ํƒ์ง€ ๋ผ๋ฒจ์„ ๋Œ€์กฐํ•ด 1:1 ๋ฐฐ์ •."""
by_id = {c["id"]: c for c in cards}
for card in cards:
hints = card.pop("_branch_hints", None)
if not hints:
continue
used = set()
for hint, target in hints:
tgt = by_id.get(target)
tgt_text = tgt["narration"][0]["text"] if tgt and tgt["narration"] else ""
cands = [c for i, c in enumerate(card["choices"]) if i not in used]
if not cands:
warnings.append(f"{card['id']}: ๋ถ„๊ธฐ '{hint}โ†’{target}' ๋ฐฐ์ •ํ•  ์„ ํƒ์ง€ ์—†์Œ")
continue
best = max(cands, key=lambda c: _match_score(c["label"], hint, "")
+ _match_score(c["label"], "", tgt_text))
score = (_match_score(best["label"], hint, "")
+ _match_score(best["label"], "", tgt_text))
if score <= 0:
warnings.append(f"{card['id']}: ๋ถ„๊ธฐ '{hint}โ†’{target}' ๋งค์นญ ์‹คํŒจ โ€” ์ˆ˜๋™ ํ™•์ธ ํ•„์š”")
continue
best["next"] = target
used.add(card["choices"].index(best))
# โ”€โ”€ ๋ณธ๋ฌธ ๋ธ”๋ก ํŒŒ์‹ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _parse_blocks(self, lines: list[str], tag_line: str, ws: list[str]):
narration, results = [], []
buf: list[str] = []
gate = "all"
in_results = False # โ–ท ํ—ค๋” ์ดํ›„๋Š” ๊ฒฐ๊ณผ ์˜์—ญ
cur_result = None
def flush_prose():
t = "\n".join(buf).strip()
buf.clear()
if t:
narration.append({"type": "prose", "text": t, "gate": gate})
def close_result():
nonlocal cur_result
if cur_result:
# ์—ฐ์† ์ค„์— ์„ž์—ฌ ๋“  ๋ผ์šฐํŒ… ๋ฉ”๋ชจ(โ†’ E##-โ€ฆ)๋„ ์ ˆ๋‹จ
m = RE_RESULT_NEXT.search(cur_result["text"])
if m:
cur_result["next"] = cur_result["next"] or m.group(1)
cur_result["text"] = cur_result["text"][:m.start()]
cur_result["text"] = cur_result["text"].strip()
results.append(cur_result)
cur_result = None
for raw in lines:
line = raw.rstrip()
s = line.strip()
if not s or s == tag_line.strip() or s.startswith("---"):
continue
if RE_SECTION_STOP.match(s):
break
if RE_META_LINE.search(s): # ๋ถ„๊ธฐ ๋งคํ•‘ ๋ฉ”๋ชจ โ€” ์ง€๋ฌธ ๋ˆ„์ˆ˜ ์ฐจ๋‹จ
continue
if RE_CHOICE_HEADER.match(s):
flush_prose()
close_result()
in_results = True
continue
nr = RE_NEW_RESULT.match(s)
if nr:
mark, hint, rest = nr.group(1).strip(), nr.group(2).strip(), nr.group(3)
if mark in GATE_MARKS: # ใ€ˆํ‘œ๋ฉดใ€‰ใ€ˆ์‹ฌ์ธตใ€‰ = ์ง€๋ฌธ ๊ฒŒ์ดํŠธ
flush_prose()
gate = GATE_MARKS[mark]
if rest.strip():
buf.append(rest)
continue
flush_prose()
close_result()
nm = RE_RESULT_NEXT.search(rest)
# ํ™”์‚ดํ‘œ๋ถ€ํ„ฐ๋Š” ๋ผ์šฐํŒ… ๋ฉ”๋ชจ("โ†’ E06-10(๋‘ ์  ๋ฐœ๊ฒฌ) ์ดํ›„ โ€ฆ") โ€” ๋ณธ๋ฌธ์—์„œ ์ ˆ๋‹จ
cur_result = {"axis": AXIS_MAP.get(mark), "hint": hint,
"text": (rest[:nm.start()] if nm else rest).strip(),
"next": nm.group(1) if nm else None}
continue
orr = RE_OLD_RESULT.match(s)
if orr and in_results:
flush_prose()
close_result()
cur_result = {"axis": None, "hint": orr.group(1).strip(),
"text": orr.group(2).strip(), "next": None}
continue
if cur_result is not None: # ๊ฒฐ๊ณผ ๋ธ”๋ก ์—ฐ์† ์ค„
cur_result["text"] += "\n" + s
continue
cm = RE_CENTER.search(s)
if cm and s.startswith(">"):
flush_prose()
narration.append({"type": "dialogue_center", "text": cm.group(1).strip(), "gate": gate})
gate = "all" if gate == "trust_high" else gate # ์‹ฌ์ธต ๊ฒŒ์ดํŠธ๋Š” ๋Œ€ํ‘œ๋Œ€์‚ฌ๊นŒ์ง€
continue
dm = RE_CHAR_DIALOGUE.match(s)
if dm:
flush_prose()
narration.append({"type": "character_dialogue", "speaker": dm.group(1).strip(),
"text": dm.group(2).strip(), "anchor": True, "gate": gate})
continue
if re.match(r"^\*\(.+\)\*$", s):
flush_prose()
narration.append({"type": "stage_direction",
"text": s.strip("*()").strip(), "gate": gate})
continue
if s.startswith(">"):
s = s.lstrip("> ").strip()
buf.append(s)
flush_prose()
close_result()
# ํ™”๋ฉด ๋ฐฐ์น˜ ์—ญํ•  ์œ ๋„ โ€” ๊ทœ์น™ ๊ถŒ์œ„๋Š” engine.schemas.beatcard.is_lead_in (์ ์žฌ ์‹œ์—๋„ ๋™์ผ ์ ์šฉ)
from engine.schemas.beatcard import is_lead_in
for i, b in enumerate(narration[:-1]):
if "role" not in b and is_lead_in(b["type"], b["text"], narration[i + 1]["type"]):
b["role"] = "lead_in"
# ์Šคํ‚ค๋งˆ ๊ธฐ๋ณธ๊ฐ’๊ณผ ๊ฐ™์€ ํ‚ค ์ œ๊ฑฐ ์—†์ด ๊ทธ๋Œ€๋กœ ๋‘๋˜, speaker/anchor ์—†๋Š” ๋ธ”๋ก์€ ๊ธฐ๋ณธ๊ฐ’ ์ƒ๋žต
narration = [
{k: v for k, v in b.items() if not (k == "speaker" and not v)
and not (k == "anchor" and not v) and not (k == "gate" and v == "all")}
for b in narration
]
return narration, results
def _match_results(self, choices: list[dict], results: list[dict], ws: list[str]):
"""๊ฒฐ๊ณผ ๋ธ”๋ก์„ ์„ ํƒ์ง€์— ์œ ์‚ฌ๋„ ์ˆœ์œผ๋กœ ๋ฐฐ์ • (ํƒ์š•, 1:1)."""
pairs = sorted(
((r_i, c_i, _match_score(c["label"], r["hint"], r["text"]))
for r_i, r in enumerate(results) for c_i, c in enumerate(choices)),
key=lambda t: -t[2],
)
used_r, used_c = set(), set()
for r_i, c_i, score in pairs:
if r_i in used_r or c_i in used_c:
continue
r, c = results[r_i], choices[c_i]
if score <= 0:
ws.append(f"๊ฒฐ๊ณผ 'ใ€ˆ{r['hint']}ใ€‰' โ†” ์„ ํƒ์ง€ ๋งค์นญ ์‹คํŒจ (score 0)")
used_r.add(r_i)
continue
c["axis"], c["result"] = r["axis"], r["text"]
if r["next"]:
c["next"] = r["next"]
if score < 3:
ws.append(f"๋งค์นญ ์•ฝํ•จ(score {score}): '{c['label']}' โ† ใ€ˆ{r['hint']}ใ€‰")
used_r.add(r_i)
used_c.add(c_i)
@staticmethod
def _grab(body: str, pattern: str) -> str:
m = re.search(pattern, body, re.S)
return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
class RCardParser:
def parse(self, text: str) -> dict:
m = re.search(r"^#\s+(R-[๊ฐ€-ํžฃ\w]+)", text, re.M)
rid = m.group(1) if m else "R-UNKNOWN"
tm = re.search(r"^##\s+" + re.escape(rid) + r"\s*[ยทยท]\s*(.+)$", text, re.M)
title = tm.group(1).strip() if tm else ""
tag_line = next((l for l in text.split("\n") if l.strip().startswith(">") and "`#" in l), "")
tags = RE_TAG.findall(tag_line)
rules = []
rm = re.search(r"###\s*๐Ÿ”’[^\n]*\n(.*?)(?=\n###)", text, re.S)
if rm:
rules = [re.sub(r"\*\*", "", l.strip("- ").strip())
for l in rm.group(1).split("\n") if l.strip().startswith("-")]
stages = []
stage_iter = list(RE_STAGE.finditer(text))
for i, sm in enumerate(stage_iter):
seg = text[sm.end(): stage_iter[i + 1].start() if i + 1 < len(stage_iter) else len(text)]
anchor_m = RE_CENTER.search(seg)
variants = re.findall(r"-\s*(?:๋ณ€ํ˜•|๋ฌผ๋ฆฌ ์ฆ๊ฑฐ[^:]*):\s*(.+)", seg)
prose = "\n".join(
l.strip() for l in seg.split("\n")
if l.strip() and not l.strip().startswith(("-", ">", "โ–ธ", "#"))
).strip()
stages.append({"stage": int(sm.group(1)), "title": sm.group(2).strip(),
"anchor": anchor_m.group(1).strip() if anchor_m else "",
"prose": prose, "variants": variants})
return {"id": rid, "title": title, "tags": tags, "rules": rules,
"stages": stages, "raw": "" if stages else text}
def save(data: dict, path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.dump(data, allow_unicode=True, sort_keys=False, width=100),
encoding="utf-8")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input", help="๋น„ํŠธ์นด๋“œ ํด๋” (E01~ ํ•˜์œ„ ํด๋” + R-*.md)")
ap.add_argument("-o", "--output", default="content/beatcards")
ap.add_argument("--episodes", default="E01,E02,E03,E04,E05,E06")
args = ap.parse_args()
src, out = Path(args.input), Path(args.output)
episodes = args.episodes.split(",")
total_cards, all_warnings = 0, []
ep_parser = EpisodeParser()
for ep in episodes:
md = src / ep / f"{ep}_๋น„ํŠธ์นด๋“œ.md"
if not md.exists():
print(f"โš  ์›๋ณธ ์—†์Œ: {md}")
continue
data, ws = ep_parser.parse(md.read_text(encoding="utf-8"), str(md))
EpisodeFile.model_validate(data) # ์ €์žฅ ์ „ ์Šคํ‚ค๋งˆ ๊ฒ€์ฆ
save(data, out / f"{ep}.yaml")
total_cards += len(data["cards"])
all_warnings += ws
print(f"โœ“ {ep}: ์นด๋“œ {len(data['cards'])}๊ฐœ โ†’ {out / (ep + '.yaml')}")
r_parser = RCardParser()
for rmd in sorted(src.glob("R-*.md")):
data = r_parser.parse(rmd.read_text(encoding="utf-8"))
RCard.model_validate(data)
save(data, out / f"{data['id']}.yaml")
print(f"โœ“ {data['id']}: ๋‹จ๊ณ„ {len(data['stages'])}๊ฐœ")
print(f"\n์ด {total_cards}์นด๋“œ ๋ณ€ํ™˜ ์™„๋ฃŒ")
if all_warnings:
print(f"\n์ ๊ฒ€ ํ•ญ๋ชฉ {len(all_warnings)}๊ฑด:")
for w in all_warnings:
print(f" โš  {w}")
if __name__ == "__main__":
main()