648 lines
23 KiB
Python
648 lines
23 KiB
Python
#!/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""]
|
||
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())
|