347 lines
12 KiB
Python
347 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""Export public Wolai pages listed in scraped_docs/网址.md to Markdown."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from collections import deque
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Dict, Iterable, List, Sequence
|
||
from urllib.parse import quote
|
||
|
||
import requests
|
||
|
||
|
||
URL_LIST_PATH = Path("scraped_docs/网址.md")
|
||
OUTPUT_DIR = Path("scraped_docs")
|
||
COOKIE_ENV = "WOLAI_COOKIE"
|
||
|
||
|
||
def load_page_mapping(path: Path) -> Dict[str, str]:
|
||
content = path.read_text(encoding="utf-8").strip()
|
||
entries = re.split(r"[;;\n]", content)
|
||
mapping: Dict[str, str] = {}
|
||
for entry in entries:
|
||
entry = entry.strip()
|
||
if not entry or ":" not in entry:
|
||
continue
|
||
name, url = entry.split(":", 1)
|
||
name = name.strip()
|
||
url = url.strip()
|
||
if name and url.startswith("https://"):
|
||
mapping[name] = url
|
||
return mapping
|
||
|
||
|
||
def sanitize_filename(name: str) -> str:
|
||
return re.sub(r"[\\/:\*\?\"<>\|]", "_", name)
|
||
|
||
|
||
def split_batches(items: Iterable[str], size: int) -> Iterable[List[str]]:
|
||
batch: List[str] = []
|
||
for item in items:
|
||
batch.append(item)
|
||
if len(batch) >= size:
|
||
yield batch
|
||
batch = []
|
||
if batch:
|
||
yield batch
|
||
|
||
|
||
class WolaiClient:
|
||
API_URL = "https://api.wolai.com/v1/pages/getData"
|
||
|
||
def __init__(self, cookie: str) -> None:
|
||
headers = {
|
||
"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, */*",
|
||
"Cookie": cookie,
|
||
}
|
||
self.session = requests.Session()
|
||
self.session.headers.update(headers)
|
||
|
||
def fetch_blocks(self, page_id: str) -> Dict[str, dict]:
|
||
blocks: Dict[str, dict] = {}
|
||
queue: deque[str] = deque([page_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(self.API_URL, json=payload, timeout=30)
|
||
resp.raise_for_status()
|
||
for entry in resp.json().get("data", []):
|
||
value = entry.get("value")
|
||
if not value:
|
||
continue
|
||
blocks[value["id"]] = value
|
||
for child in value.get("sub_nodes") or []:
|
||
if 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 rich_text(fragments: Sequence[Sequence]) -> str:
|
||
result: List[str] = []
|
||
for fragment in fragments:
|
||
if not fragment:
|
||
continue
|
||
text = fragment[0]
|
||
marks = fragment[1] if len(fragment) > 1 else None
|
||
result.append(apply_marks(text, marks))
|
||
return "".join(result).strip()
|
||
|
||
|
||
def apply_marks(text: str, marks: Sequence[Sequence] | None) -> str:
|
||
if not marks:
|
||
return text
|
||
decorated = text
|
||
link_target: str | None = None
|
||
for mark in marks:
|
||
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:
|
||
link_target = mark[1]
|
||
elif kind == "BiLink":
|
||
# 链接到空间内其他块,保留文本即可
|
||
continue
|
||
elif kind == "h":
|
||
continue
|
||
else:
|
||
continue
|
||
if link_target:
|
||
decorated = f"[{decorated}]({link_target})"
|
||
return decorated
|
||
|
||
|
||
def build_image_url(block: dict) -> str | None:
|
||
attrs = block.get("attributes") or {}
|
||
img = attrs.get("img") or []
|
||
if not img or not img[0]:
|
||
return None
|
||
path = img[0][0]
|
||
if path.startswith("http"):
|
||
return path
|
||
safe_path = quote(path, safe="/%")
|
||
return f"https://secure2.wostatic.cn/{safe_path}"
|
||
|
||
|
||
def indent_lines(lines: Sequence[str], prefix: str) -> List[str]:
|
||
indented: List[str] = []
|
||
for line in lines:
|
||
if line:
|
||
indented.append(f"{prefix}{line}")
|
||
else:
|
||
indented.append("")
|
||
return indented
|
||
|
||
|
||
@dataclass
|
||
class MarkdownRenderer:
|
||
blocks: Dict[str, dict]
|
||
|
||
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["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_type = self.blocks[ids[start]]["type"]
|
||
group = {
|
||
"enumList": {"enumList"},
|
||
"bullList": {"bullList"},
|
||
"todoList": {"todoList", "todoListPro"},
|
||
"todoListPro": {"todoList", "todoListPro"},
|
||
}[first_type]
|
||
counter = 1
|
||
while i < len(ids):
|
||
block = self.blocks.get(ids[i])
|
||
if not block or block["type"] not in group:
|
||
break
|
||
text = rich_text(get_title_fragments(block))
|
||
if block["type"] == "enumList":
|
||
prefix = f"{counter}. "
|
||
elif block["type"] in {"todoList", "todoListPro"}:
|
||
checked = block.get("attributes", {}).get("checked") == "yes"
|
||
prefix = f"- [{'x' if checked else ' '}] "
|
||
else:
|
||
prefix = "- "
|
||
lines.append(f"{prefix}{text}")
|
||
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["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}"]
|
||
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)
|
||
return [f"" if url else ""]
|
||
if btype == "code":
|
||
language = block.get("attributes", {}).get("language") or ""
|
||
body = text
|
||
fence = f"```{language.lower()}" if language else "```"
|
||
return [fence, body, "```"]
|
||
if btype in {"row", "column"}:
|
||
return self.render_children(block.get("sub_nodes") or [])
|
||
if btype == "enumList":
|
||
return [f"1. {text}"]
|
||
if btype == "bullList":
|
||
return [f"- {text}"]
|
||
if btype in {"todoList", "todoListPro"}:
|
||
checked = block.get("attributes", {}).get("checked") == "yes"
|
||
return [f"- [{'x' if checked else ' '}] {text}"]
|
||
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 + "(可折叠)"
|
||
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", {}).get("progress", 0)
|
||
return [f"进度条:{progress}%"]
|
||
if btype == "xiguaVideo":
|
||
source = block.get("attributes", {}).get("source") or ""
|
||
label = text or "西瓜视频"
|
||
return [f"[{label}]({source})" if source else label]
|
||
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", {}).get("title") or []))
|
||
rows.append(cols)
|
||
if not rows:
|
||
return []
|
||
widths = max(len(r) for r in rows)
|
||
for row in rows:
|
||
if len(row) < widths:
|
||
row.extend([""] * (widths - len(row)))
|
||
header = rows[0]
|
||
separator = ["---" for _ in header]
|
||
lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(separator) + " |"]
|
||
for row in rows[1:]:
|
||
lines.append("| " + " | ".join(row) + " |")
|
||
return lines
|
||
|
||
|
||
def main() -> None:
|
||
cookie = os.environ.get(COOKIE_ENV)
|
||
if not cookie:
|
||
raise SystemExit(
|
||
f"请先在环境变量 {COOKIE_ENV} 中配置 wolai Cookie,例如:\n"
|
||
f"$env:{COOKIE_ENV}='wolai_client_id=...; token=...'"
|
||
)
|
||
mapping = load_page_mapping(URL_LIST_PATH)
|
||
client = WolaiClient(cookie)
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
for name, url in mapping.items():
|
||
if name == "页面选项":
|
||
continue
|
||
page_id = url.rstrip("/").split("/")[-1]
|
||
print(f"正在导出 {name} ({page_id}) ...")
|
||
blocks = client.fetch_blocks(page_id)
|
||
renderer = MarkdownRenderer(blocks)
|
||
content = renderer.render_page(page_id)
|
||
out_path = OUTPUT_DIR / f"{sanitize_filename(name)}.md"
|
||
out_path.write_text(content, encoding="utf-8")
|
||
print("导出完成。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|