""" Parsea el FAQ oficial de reglas de Riftbound (Unleashed) desde la URL oficial. El contenido está en __NEXT_DATA__ JSON (Next.js SSR), en blades de tipo articleRichText. No hace falta Selenium — el HTML viene en el payload inicial. Chunking: - Blade "Revised and Clarified Rulings": 1 chunk por H2 (+ intro como chunk 0) - Blade "Frequently Asked Questions": 1 chunk por Q&A (bold-question paragraph) Salida: data/processed/rules_faq.md """ import json import re import sys from pathlib import Path import requests from bs4 import BeautifulSoup, NavigableString, Tag _SOURCE_URL = ( "https://riftbound.leagueoflegends.com/en-us/news/rules-and-releases" "/unleashed-rules-faq-and-clarifications/" ) _HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; RiftboundJudgeBot/1.0)"} # --------------------------------------------------------------------------- # Fetch # --------------------------------------------------------------------------- def _fetch_blades(source: str | Path) -> list[dict]: """Return articleRichText blades from the page.""" path = Path(str(source)) if path.exists(): html = path.read_text(encoding="utf-8") else: resp = requests.get(str(source), timeout=30, headers=_HEADERS) resp.raise_for_status() html = resp.text m = re.search(r'', html, re.DOTALL) if not m: raise ValueError("__NEXT_DATA__ not found — page structure may have changed") data = json.loads(m.group(1)) blades = data["props"]["pageProps"]["page"]["blades"] return [b for b in blades if b.get("type") == "articleRichText"] # --------------------------------------------------------------------------- # HTML → Markdown conversion # --------------------------------------------------------------------------- def _inline(tag: Tag) -> str: """Render inline content preserving bold/italic marks.""" parts = [] for child in tag.children: if isinstance(child, NavigableString): parts.append(str(child)) elif isinstance(child, Tag): name = child.name text = _inline(child) if name in ("strong", "b"): parts.append(f"**{text}**") elif name in ("em", "i"): parts.append(f"*{text}*") elif name == "a": parts.append(text) else: parts.append(text) # Collapse non-breaking spaces and normalize return re.sub(r"[\xa0 ]+", " ", "".join(parts)).strip() def _table_to_md(table: Tag) -> str: """Render a to Markdown. Two patterns: 1. Before/After comparison: 2 columns, first row is header row 2. Rule citation: 2 columns, left = rule number, right = rule text """ rows = table.find_all("tr") if not rows: return "" cells_per_row = [r.find_all(["td", "th"]) for r in rows] # Pattern: rule citation (left col is short rule number) if len(cells_per_row[0]) == 2: first_left = cells_per_row[0][0].get_text(strip=True) if re.match(r"^\d{3,}", first_left): lines = [] for row_cells in cells_per_row: if len(row_cells) == 2: num = row_cells[0].get_text(strip=True) text = row_cells[1].get_text(separator=" ", strip=True) lines.append(f"> **{num}** {text}") return "\n".join(lines) # Pattern: Before/After or generic table → Markdown table md_rows = [] for i, row_cells in enumerate(cells_per_row): cols = [_inline(cell) if isinstance(cell, Tag) else cell.get_text(strip=True) for cell in row_cells] md_rows.append("| " + " | ".join(cols) + " |") if i == 0: md_rows.append("| " + " | ".join("---" for _ in row_cells) + " |") return "\n".join(md_rows) def _el_to_lines(el: Tag) -> list[str]: """Convert one block element to markdown lines.""" name = el.name if not name or name in ("meta", "script", "style"): return [] if name in ("h1", "h2", "h3", "h4"): level = int(name[1]) return [f"{'#' * level} {el.get_text(strip=True)}", ""] if name == "p": # Skip empty / meta-only paragraphs text = _inline(el) if not text: return [] return [text, ""] if name in ("ul", "ol"): lines = [] for li in el.find_all("li", recursive=False): bullet = "- " if name == "ul" else "1. " lines.append(bullet + _inline(li)) lines.append("") return lines if name == "blockquote": inner = el.get_text(separator="\n", strip=True) return ["\n".join(f"> {l}" for l in inner.splitlines()), ""] if name == "div": # Likely a rule-citation div wrapping a
table = el.find("table") if table: return [_table_to_md(table), ""] # Fall through to inner text text = el.get_text(separator=" ", strip=True) return [text, ""] if text else [] if name == "figure": table = el.find("table") if table: return [_table_to_md(table), ""] return [] return [] def _elements_to_md(elements: list[Tag]) -> str: lines: list[str] = [] for el in elements: lines.extend(_el_to_lines(el)) md = "\n".join(lines) md = re.sub(r"\n{3,}", "\n\n", md) return md.strip() # --------------------------------------------------------------------------- # Chunk splitting # --------------------------------------------------------------------------- def _is_standalone_bold(p: Tag) -> bool: """True if

is ONLY a single / child (= FAQ question header).""" if p.name != "p": return False children = [c for c in p.children if not (isinstance(c, NavigableString) and not str(c).strip())] return ( len(children) == 1 and isinstance(children[0], Tag) and children[0].name in ("strong", "b") ) def _split_by_h2(elements: list[Tag], section_title: str) -> list[str]: """Split a list of elements at H2 boundaries. Returns markdown chunks.""" chunks: list[str] = [] current: list[Tag] = [] def flush(acc: list[Tag]) -> None: md = _elements_to_md(acc) if md: chunks.append(md) for el in elements: if el.name == "h2" and current: flush(current) current = [] current.append(el) flush(current) return chunks def _split_by_question(elements: list[Tag]) -> list[str]: """Split FAQ elements at bold-question boundaries. Returns markdown chunks.""" chunks: list[str] = [] current: list[Tag] = [] def flush(acc: list[Tag]) -> None: md = _elements_to_md(acc) if md: chunks.append(md) for el in elements: if _is_standalone_bold(el) and current: flush(current) current = [] current.append(el) flush(current) return chunks # --------------------------------------------------------------------------- # Main parser # --------------------------------------------------------------------------- def parse_rules_faq(source: str | Path = _SOURCE_URL) -> str: rich_blades = _fetch_blades(source) all_chunks: list[str] = [ "# Unleashed Rules FAQ and Clarifications\n\n" "*Source: Official Riftbound rules site — Unleashed release*" ] for i, blade in enumerate(rich_blades): body_html = blade.get("richText", {}).get("body", "") if not body_html: continue soup = BeautifulSoup(body_html, "html.parser") elements = [el for el in soup.find_all(True, recursive=False) if isinstance(el, Tag) and el.name not in ("meta",)] # Heuristic: blade with H2 headers → clarification section (split by H2) # Blade without H2s → FAQ section (split by bold question) has_h2 = any(el.name == "h2" for el in elements) if has_h2: chunks = _split_by_h2(elements, "Revised and Clarified Rulings") else: chunks = _split_by_question(elements) all_chunks.extend(chunks) return "\n\n---\n\n".join(all_chunks) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": source = sys.argv[1] if len(sys.argv) > 1 else _SOURCE_URL out_path = Path("data/processed/rules_faq.md") out_path.parent.mkdir(parents=True, exist_ok=True) print(f"Fetching/parsing: {source}") result = parse_rules_faq(source) out_path.write_text(result, encoding="utf-8") chunk_count = result.count("---") + 1 print(f"Generado: {out_path} ({len(result):,} chars, ~{chunk_count} chunks)")