#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
抓取 Wolai 帮助中心（公开页面）并落盘为可检索语料：
- 通过 Wolai API 拉取 blocks（比 Playwright 更稳定、噪声更少）
- 递归抓取子块，并从内容里的链接继续爬取其他页面
- 下载图片到本地，并在 Markdown 中引用本地路径

用法示例（PowerShell）：
  python scripts/wolai_help_center/export_wolai_help_center.py `
    --seed-file scripts/wolai_help_center/seeds.txt `
    --out artifacts/wolai-help-center `
    --max-pages 200 `
    --download-images
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import time
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
from urllib.parse import quote, urlparse

import requests


API_URL = "https://api.wolai.com/v1/pages/getData"

# Wolai 页面 URL： https://www.wolai.com/wolai/<pageId>
WOLAI_PAGE_ID_RE = re.compile(r"(?:https?://www\.wolai\.com)?/wolai/([A-Za-z0-9]+)")


def _read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def _write_text(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def _write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")


def sanitize_filename(name: str) -> str:
    return re.sub(r"[\\/:\*\?\"<>\|]", "_", name).strip() or "untitled"


def parse_page_id(url_or_id: str) -> Optional[str]:
    url_or_id = url_or_id.strip()
    if not url_or_id:
        return None
    if "/" not in url_or_id:
        return url_or_id
    m = WOLAI_PAGE_ID_RE.search(url_or_id)
    if not m:
        return None
    return m.group(1)


def build_image_url(block: dict) -> Optional[str]:
    attrs = block.get("attributes") or {}
    # 新版/部分图片块使用 source 字段（例如动态图标、截图等）
    source = attrs.get("source")
    if isinstance(source, list) and source and isinstance(source[0], str) and source[0].strip():
        return source[0].strip()

    img = attrs.get("img") or []
    if not img or not img[0]:
        return None
    path = img[0][0]
    if isinstance(path, str) and path.startswith("http"):
        return path
    if not isinstance(path, str):
        return None
    safe_path = quote(path, safe="/%")
    return f"https://secure2.wostatic.cn/{safe_path}"


def guess_extension(content_type: str, url: str) -> str:
    content_type = (content_type or "").split(";")[0].strip().lower()
    if content_type in {"image/png"}:
        return ".png"
    if content_type in {"image/jpeg", "image/jpg"}:
        return ".jpg"
    if content_type in {"image/webp"}:
        return ".webp"
    if content_type in {"image/gif"}:
        return ".gif"
    if content_type in {"image/svg+xml"}:
        return ".svg"
    # 兜底：从 URL 后缀猜
    path = urlparse(url).path.lower()
    for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]:
        if path.endswith(ext):
            return ".jpg" if ext == ".jpeg" else ext
    return ".bin"


def sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def iter_strings(obj: Any) -> Iterable[str]:
    """深度遍历任意 JSON 结构，返回其中所有字符串。"""
    if isinstance(obj, str):
        yield obj
    elif isinstance(obj, list):
        for item in obj:
            yield from iter_strings(item)
    elif isinstance(obj, dict):
        for v in obj.values():
            yield from iter_strings(v)


def extract_mark_links_from_title_fragments(fragments: Sequence[Sequence]) -> Tuple[List[str], List[str]]:
    """从富文本片段中提取 Link(url) 与 BiLink(id)。"""
    link_urls: List[str] = []
    bilink_ids: List[str] = []
    for fragment in fragments:
        if not fragment:
            continue
        marks = fragment[1] if len(fragment) > 1 else None
        if not marks:
            continue
        for mark in marks:
            if not mark:
                continue
            kind = mark[0]
            if kind == "Link" and len(mark) > 1 and isinstance(mark[1], str):
                link_urls.append(mark[1])
            elif kind == "BiLink":
                # Wolai 的 BiLink 结构常见为：
                #   ["BiLink", "<blockId>", "<pageId>", ...]
                # 这里优先取第三段（pageId），避免把 blockId 当成页面导致爆炸式爬取。
                if len(mark) > 2 and isinstance(mark[2], str):
                    bilink_ids.append(mark[2])
                elif len(mark) > 1 and isinstance(mark[1], str):
                    # 兜底：少数情况下可能只有一个 id
                    bilink_ids.append(mark[1])
    return link_urls, bilink_ids


def extract_page_ids_from_blocks(blocks: Dict[str, dict]) -> Set[str]:
    """从 blocks 中提取可能的 Wolai 页面 ID（用于爬取下一页）。"""
    ids: Set[str] = set()
    for block in blocks.values():
        attrs = block.get("attributes") or {}
        title = attrs.get("title") or []
        if isinstance(title, str):
            title = [[title]]
        if isinstance(title, list):
            link_urls, bilink_ids = extract_mark_links_from_title_fragments(title)
            for url in link_urls:
                pid = parse_page_id(url)
                if pid:
                    ids.add(pid)
            for bid in bilink_ids:
                # 只接受“看起来像 Wolai id”的 BiLink 目标，避免误把其它资源标识加入队列
                if re.fullmatch(r"[A-Za-z0-9]{16,32}", bid):
                    ids.add(bid)
        # 更激进的兜底：扫整个 block 里的字符串，找 /wolai/<id>
        for s in iter_strings(block):
            for m in WOLAI_PAGE_ID_RE.finditer(s):
                ids.add(m.group(1))
    return ids


class WolaiApiClient:
    def __init__(self) -> None:
        self.session = requests.Session()
        self.session.headers.update(
            {
                "wolai-client-platform": "web",
                "wolai-app-version": "1.2.3-15",
                "wolai-os-platform": "win",
                "Origin": "https://www.wolai.com",
                "Referer": "https://www.wolai.com/",
                "User-Agent": (
                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                    "AppleWebKit/537.36 (KHTML, like Gecko) "
                    "Chrome/120.0.0.0 Safari/537.36"
                ),
                "Accept": "application/json, text/plain, */*",
            }
        )

    def fetch_blocks_recursive(self, root_id: str) -> Dict[str, dict]:
        """从 root_id 开始，批量拉取并递归展开 sub_nodes。"""
        blocks: Dict[str, dict] = {}
        queue: deque[str] = deque([root_id])
        seen: Set[str] = set()

        while queue:
            chunk: List[str] = []
            while queue and len(chunk) < 50:
                bid = queue.popleft()
                if bid in seen:
                    continue
                seen.add(bid)
                chunk.append(bid)

            if not chunk:
                continue

            payload = {"requests": [{"table": "wolai.block", "id": bid} for bid in chunk]}
            resp = self.session.post(API_URL, json=payload, timeout=60)
            resp.raise_for_status()
            data = resp.json().get("data") or []

            for entry in data:
                value = entry.get("value")
                if not value or not isinstance(value, dict):
                    continue
                bid = value.get("id")
                if not isinstance(bid, str):
                    continue
                blocks[bid] = value
                for child in value.get("sub_nodes") or []:
                    if isinstance(child, str) and child not in seen:
                        queue.append(child)

        return blocks


def get_title_fragments(block: dict) -> List[List]:
    attrs = block.get("attributes") or {}
    title = attrs.get("title")
    if isinstance(title, str):
        return [[title]]
    return title or []


def apply_marks(text: str, marks: Sequence[Sequence] | None) -> str:
    if not marks:
        return text
    decorated = text
    link_target: Optional[str] = None
    for mark in marks:
        if not mark:
            continue
        kind = mark[0]
        if kind == "B":
            decorated = f"**{decorated}**"
        elif kind == "I":
            decorated = f"*{decorated}*"
        elif kind == "S":
            decorated = f"~~{decorated}~~"
        elif kind == "<>":
            decorated = f"`{decorated}`"
        elif kind == "Link" and len(mark) > 1 and isinstance(mark[1], str):
            link_target = mark[1]
        elif kind == "BiLink":
            # 链接到空间内其他块/页面：这里只影响显示，不强行转成链接，避免生成无效 URL
            continue
        else:
            continue
    if link_target:
        decorated = f"[{decorated}]({link_target})"
    return decorated


def rich_text(fragments: Sequence[Sequence]) -> str:
    result: List[str] = []
    for fragment in fragments:
        if not fragment:
            continue
        text = fragment[0]
        if not isinstance(text, str):
            continue
        marks = fragment[1] if len(fragment) > 1 else None
        result.append(apply_marks(text, marks))
    return "".join(result).strip()


def indent_lines(lines: Sequence[str], prefix: str) -> List[str]:
    out: List[str] = []
    for line in lines:
        out.append(f"{prefix}{line}" if line else "")
    return out


@dataclass
class MarkdownRenderer:
    blocks: Dict[str, dict]
    image_url_to_local: Dict[str, str]

    def render_page(self, page_id: str) -> str:
        root = self.blocks.get(page_id)
        if not root:
            return ""
        lines = self.render_children(root.get("sub_nodes") or [])
        while lines and not lines[-1].strip():
            lines.pop()
        return "\n".join(lines) + "\n"

    def render_children(self, child_ids: Sequence[str]) -> List[str]:
        lines: List[str] = []
        i = 0
        while i < len(child_ids):
            block = self.blocks.get(child_ids[i])
            i += 1
            if not block:
                continue
            btype = block.get("type")
            if btype in {"enumList", "bullList", "todoList", "todoListPro"}:
                seq_lines, new_index = self.render_list_sequence(child_ids, i - 1)
                lines.extend(seq_lines)
                i = new_index
                continue
            block_lines = self.render_block(block)
            if not block_lines:
                continue
            lines.extend(block_lines)
            if block_lines[-1].strip():
                lines.append("")
        return lines

    def render_list_sequence(self, ids: Sequence[str], start: int) -> Tuple[List[str], int]:
        lines: List[str] = []
        i = start
        first = self.blocks.get(ids[start]) or {}
        first_type = first.get("type")
        group = {
            "enumList": {"enumList"},
            "bullList": {"bullList"},
            "todoList": {"todoList", "todoListPro"},
            "todoListPro": {"todoList", "todoListPro"},
        }.get(first_type, {first_type})
        counter = 1
        while i < len(ids):
            block = self.blocks.get(ids[i])
            if not block or block.get("type") not in group:
                break
            text = rich_text(get_title_fragments(block))
            btype = block.get("type")
            if btype == "enumList":
                prefix = f"{counter}. "
            elif btype in {"todoList", "todoListPro"}:
                checked = (block.get("attributes") or {}).get("checked") == "yes"
                prefix = f"- [{'x' if checked else ' '}] "
            else:
                prefix = "- "
            lines.append(f"{prefix}{text}".rstrip())
            child_lines = self.render_children(block.get("sub_nodes") or [])
            if child_lines:
                lines.extend(indent_lines(child_lines, "    "))
            counter += 1
            i += 1
        lines.append("")
        return lines, i

    def render_block(self, block: dict) -> List[str]:
        btype = block.get("type")
        text = rich_text(get_title_fragments(block))
        if btype == "text":
            return [text] if text else []
        if btype in {"midHeader", "subHeader", "tinyHeader"}:
            level = {"midHeader": "##", "subHeader": "###", "tinyHeader": "####"}[btype]
            return [f"{level} {text}".rstrip()]
        if btype == "quote":
            quote_lines = [f"> {line}" if line else ">" for line in (text.splitlines() or [""])]
            child_lines = self.render_children(block.get("sub_nodes") or [])
            if child_lines:
                quote_lines.extend(f"> {line}" if line else ">" for line in child_lines)
            return quote_lines
        if btype == "divider":
            return ["---"]
        if btype == "image":
            url = build_image_url(block)
            if not url:
                return ["![](图片资源)"]
            local = self.image_url_to_local.get(url)
            return [f"![]({local or url})"]
        if btype == "code":
            language = (block.get("attributes") or {}).get("language") or ""
            body = text
            fence = f"```{str(language).lower()}" if language else "```"
            return [fence, body, "```"]
        if btype in {"row", "column"}:
            return self.render_children(block.get("sub_nodes") or [])
        if btype == "toggleList":
            title = text or "折叠列表"
            child_lines = indent_lines(self.render_children(block.get("sub_nodes") or []), "  ")
            return [f"- **{title}**", *child_lines]
        if btype in {"toggleSubHeader", "toggleTinyHeader"}:
            level = "####" if btype == "toggleSubHeader" else "#####"
            title = (text + "（可折叠）").strip()
            child_lines = self.render_children(block.get("sub_nodes") or [])
            return [f"{level} {title}", *child_lines]
        if btype == "simpleTable":
            return self.render_table(block)
        if btype == "progressBar":
            progress = (block.get("attributes") or {}).get("progress", 0)
            return [f"进度条：{progress}%"]
        # 兜底
        return [text] if text else []

    def render_table(self, block: dict) -> List[str]:
        attrs = block.get("attributes") or {}
        raw = attrs.get("cells")
        if not raw:
            return []
        try:
            cells = json.loads(raw)
        except json.JSONDecodeError:
            return []
        rows: List[List[str]] = []
        for row in cells:
            cols = []
            for cell in row.get("column", []):
                cols.append(rich_text((cell.get("attributes") or {}).get("title") or []))
            rows.append(cols)
        if not rows:
            return []
        width = max(len(r) for r in rows)
        for r in rows:
            r.extend([""] * (width - len(r)))
        header = rows[0]
        sep = ["---"] * len(header)
        lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(sep) + " |"]
        for r in rows[1:]:
            lines.append("| " + " | ".join(r) + " |")
        return lines


def load_seed_ids(seed_urls: List[str], seed_file: Optional[Path]) -> List[str]:
    ids: List[str] = []
    for u in seed_urls:
        pid = parse_page_id(u)
        if pid:
            ids.append(pid)
    if seed_file and seed_file.exists():
        for line in _read_text(seed_file).splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            pid = parse_page_id(line)
            if pid:
                ids.append(pid)
    # 去重但保持顺序
    seen: Set[str] = set()
    out: List[str] = []
    for pid in ids:
        if pid in seen:
            continue
        seen.add(pid)
        out.append(pid)
    return out


def download_images(
    session: requests.Session,
    image_urls: List[str],
    out_images_dir: Path,
) -> Dict[str, str]:
    """下载图片并返回 url -> 相对路径 的映射。"""
    mapping: Dict[str, str] = {}
    out_images_dir.mkdir(parents=True, exist_ok=True)

    for url in image_urls:
        if url in mapping:
            continue
        digest = sha256_hex(url)[:24]
        # 若已下载过（不同页面复用），直接复用
        existing = list(out_images_dir.glob(f"{digest}.*"))
        if existing:
            mapping[url] = str(Path("images") / existing[0].name).replace("\\", "/")
            continue

        try:
            resp = session.get(url, timeout=60)
            resp.raise_for_status()
            ext = guess_extension(resp.headers.get("content-type", ""), url)
            filename = f"{digest}{ext}"
            out_path = out_images_dir / filename
            out_path.write_bytes(resp.content)
            mapping[url] = str(Path("images") / filename).replace("\\", "/")
        except Exception:
            # 下载失败也要留痕，避免重复尝试拖慢整体
            mapping[url] = url
    return mapping


def main(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--seed-url", action="append", default=[], help="种子页面 URL 或 ID（可重复指定）")
    parser.add_argument("--seed-file", type=str, default="", help="种子列表文件（每行一个 URL 或 ID）")
    parser.add_argument("--out", type=str, default="artifacts/wolai-help-center", help="输出目录")
    parser.add_argument("--max-pages", type=int, default=200, help="最大抓取页面数（防止无限扩张）")
    parser.add_argument("--download-images", action="store_true", help="下载图片到本地并在 Markdown 中引用")
    parser.add_argument("--sleep-ms", type=int, default=0, help="每页抓取后的延迟（毫秒）")
    parser.add_argument("--resume", action="store_true", help="从 state.json 恢复")
    args = parser.parse_args(argv)

    out_dir = Path(args.out)
    pages_dir = out_dir / "pages"
    images_dir = out_dir / "images"
    state_path = out_dir / "state.json"
    index_path = out_dir / "index.json"

    seed_file = Path(args.seed_file) if args.seed_file else None
    seeds = load_seed_ids(args.seed_url, seed_file)
    if not seeds:
        print("未提供 seed（--seed-url 或 --seed-file）。", file=sys.stderr)
        return 2

    api = WolaiApiClient()

    allowed_page_id: Optional[str] = None
    queue: deque[str] = deque(seeds)
    seen: Set[str] = set()
    results: List[dict] = []
    failures: List[dict] = []

    if args.resume and state_path.exists():
        try:
            state = json.loads(_read_text(state_path))
            allowed_page_id = state.get("allowed_page_id") or None
            queue = deque(state.get("queue") or [])
            seen = set(state.get("seen") or [])
            results = state.get("results") or []
            failures = state.get("failures") or []
        except Exception:
            pass

    exported = 0
    while queue and exported < args.max_pages:
        page_id = queue.popleft()
        if page_id in seen:
            continue
        seen.add(page_id)

        try:
            blocks = api.fetch_blocks_recursive(page_id)
            root = blocks.get(page_id)
            if not root:
                raise RuntimeError("root block 缺失")

            if root.get("type") != "page":
                # 队列里可能混入 blockId；仅导出“页面”类型，避免爆炸式爬取
                continue

            if allowed_page_id is None:
                allowed_page_id = root.get("page_id") if isinstance(root.get("page_id"), str) else None

            if allowed_page_id and root.get("page_id") != allowed_page_id:
                # 只抓同一个帮助中心空间，避免爬到用户分享页/其它空间
                continue

            title = rich_text(get_title_fragments(root)) or page_id
            safe_title = sanitize_filename(title)
            md_name = f"{page_id}__{safe_title}.md"
            json_name = f"{page_id}__{safe_title}.json"

            page_json_path = pages_dir / json_name
            page_md_path = pages_dir / md_name

            image_urls: List[str] = []
            if args.download_images:
                for b in blocks.values():
                    if b.get("type") == "image":
                        url = build_image_url(b)
                        if url:
                            image_urls.append(url)

            image_map: Dict[str, str] = {}
            if args.download_images and image_urls:
                image_map = download_images(api.session, image_urls, images_dir)

            renderer = MarkdownRenderer(blocks=blocks, image_url_to_local=image_map)
            body = renderer.render_page(page_id)
            header = {
                "source": f"https://www.wolai.com/wolai/{page_id}",
                "title": title,
                "pageId": page_id,
                "exportedAt": int(time.time() * 1000),
                "imageCount": len(image_urls),
            }
            md = "---\n" + json.dumps(header, ensure_ascii=False, indent=2) + "\n---\n\n" + body
            _write_text(page_md_path, md)

            # blocks 数据落盘（便于后续结构化分析）
            _write_json(
                page_json_path,
                {
                    "meta": header,
                    "blocks": blocks,
                    "images": [{"url": u, "local": image_map.get(u, u)} for u in image_urls],
                },
            )

            results.append(
                {
                    "pageId": page_id,
                    "title": title,
                    "source": header["source"],
                    "md": str(page_md_path.relative_to(out_dir)).replace("\\", "/"),
                    "json": str(page_json_path.relative_to(out_dir)).replace("\\", "/"),
                    "imageCount": len(image_urls),
                }
            )

            # 继续爬取下一层链接
            next_ids = extract_page_ids_from_blocks(blocks)
            for nid in sorted(next_ids):
                if nid not in seen:
                    queue.append(nid)

            exported += 1

            if args.sleep_ms > 0:
                time.sleep(args.sleep_ms / 1000)
        except Exception as e:
            failures.append({"pageId": page_id, "error": str(e)})

        # 持久化状态，避免中断重来
        _write_json(
            state_path,
            {
                "allowed_page_id": allowed_page_id,
                "queue": list(queue),
                "seen": sorted(seen),
                "results": results,
                "failures": failures,
            },
        )

    _write_json(index_path, {"results": results, "failures": failures, "allowed_page_id": allowed_page_id})
    print(f"已导出页面：{exported}，输出目录：{out_dir}")
    print(f"索引：{index_path}")
    if failures:
        print(f"失败：{len(failures)}（见 {state_path} / {index_path}）")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
