文章

实战:用Playwright整本抓取阿里云ADB文档

#246 · 2026-05-13 · 21ZHAO Blog

这次要整理成博客的,其实不是“一个爬虫项目”这么抽象的东西,而是三份非常具体的材料:

  1. aliyun_full.py
  2. Untitled.ipynb
  3. aliyun_adb_docs_final/ 里已经抓下来的 27 份 AnalyticDB for MySQL Markdown

所以这篇文章我不打算空讲“爬虫思路”,而是直接按这三份材料复盘:这个脚本到底怎么抓、为什么必须这么抓、抓下来的文档到底值不值得保留成一个本地知识库。

为什么我要把云厂商产品文档整本抓成 Markdown

云厂商文档最大的问题不是“信息少”,而是信息太散。

你如果只是临时查一个参数,网页当然够用;但如果你想把一个产品体系真正吃透,比如 AnalyticDB for MySQL 到底支持什么数据源、怎么导入、怎么集成 OpenAPI、有哪些客户端和调度生态,那网页阅读体验就很差了:

  • 页面深,来回跳转多
  • 左侧导航、右侧目录、顶部页头占了大量注意力
  • 适合浏览,不适合沉淀
  • 不适合本地全文检索,更不适合后续接 RAG

所以我的目标不是“爬几个页面看看”,而是把 https://help.aliyun.com/zh/analyticdb/analyticdb-for-mysql/ 这个产品域下的文档,尽量整批抓成一个可本地消费的 Markdown 文档库。

这组三份材料分别代表什么

aliyun_full.py:真正的主角

这个文件不是一个试玩脚本,而是完整的抓取主流程。它把入口、域名限制、输出目录、并发进程数、队列等待、页面超时全部参数化了:

DEFAULT_START_URL = (
    "https://help.aliyun.com/zh/analyticdb/analyticdb-for-mysql/"
    "product-overview/what-is-analyticdb-for-mysql"
)
DEFAULT_DOMAIN_PREFIX = "https://help.aliyun.com/zh/analyticdb/analyticdb-for-mysql/"
DEFAULT_OUTPUT_DIR = "aliyun_adb_docs_final"
DEFAULT_PROCESS_COUNT = 8
DEFAULT_QUEUE_TIMEOUT = 2
DEFAULT_IDLE_LIMIT = 30

这几行其实已经把整个意图写明白了:

  • 入口页不是站点首页,而是 AnalyticDB for MySQL 的产品概览页
  • 抓取范围不是整个 help.aliyun.com,而是限定在产品文档子树
  • 输出目标不是数据库或 JSON,而是本地 Markdown 目录
  • 并发策略是多进程,不是单线程慢慢爬

Untitled.ipynb:一个临时工作台,不是正式入口

Notebook 这份材料反而很有意思,因为它暴露了这套东西最真实的开发状态。

里面最醒目的一段不是成功结果,而是一个直接报错的单元:

python aliyun_full.py

它在 Jupyter 里直接触发了:

SyntaxError: invalid syntax

这说明当时的工作方式非常典型:脚本先成型,Notebook 只是拿来临时启动、临时看结果的壳。后来才改成了:

!python aliyun_full.py --help

以及:

output_dir = Path("aliyun_adb_docs_final")
files = sorted(p.name for p in output_dir.glob("*.md"))
print("markdown_count =", len(files))
print("sample_files =", files[:10])

所以这份 Notebook 的价值,不在于它多优雅,而在于它准确记录了这套方案的使用方式:真正的逻辑在脚本里,Notebook 只是临时观测窗口。

aliyun_adb_docs_final/:不是“抓了很多文件”,而是已经形成了可用语料

目前目录里已经有 27 份 Markdown,文件名像这样:

zh_analyticdb_analyticdb-for-mysql_developer-reference_cli-integration-example.md
zh_analyticdb_analyticdb-for-mysql_developer-reference_integration-overview.md
zh_analyticdb_analyticdb-for-mysql_product-overview_what-is-analyticdb-for-mysql.md
zh_analyticdb_analyticdb-for-mysql_user-guide_data-import.md

这类命名其实很实用,因为它把 URL path 直接摊平进文件名了。你一眼就能知道它属于哪个产品、哪个栏目、哪个主题,而不是得到一堆 page_001.md 这种后期无法维护的垃圾文件名。

这份脚本为什么必须用 Playwright,而不是静态抓取

如果只是爬一个简单博客,requests + BeautifulSoup 往往够用。

但帮助文档站和普通博客不一样,它的问题不只是“HTML 复杂”,而是:

  • 页面结构重
  • 导航层级深
  • 目录、反馈、页头页脚噪音很多
  • 很多页面内容要等浏览器环境稳定后再提取更保险

所以脚本直接选了:

import html2text
from playwright.sync_api import sync_playwright

这里的分工非常清楚:

  • Playwright 负责在真实浏览器上下文里打开页面
  • html2text 负责把清洗后的正文转成 Markdown

而且它不是拿默认浏览器直接跑,还专门给了桌面浏览器常见 UA 和 viewport:

context = browser.new_context(
    user_agent=(
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    ),
    viewport={"width": 1920, "height": 1080},
)

这说明它的目标不是“把 HTML 抓回来”,而是尽量按正常桌面访问场景把页面稳定渲染出来。

脚本里最关键的一步:先提链接,再清洗页面

我觉得这份脚本最聪明的地方,不是多进程,而是处理页面的顺序

很多人写这类脚本时,会先把各种 sidebar、header、footer 全删掉,然后再提内容。但这里如果你太早删,就会把左侧文档树里的链接也一起删没。

所以脚本的顺序是:

  1. 先打开页面
  2. .aliyun-docs-sidebar, #se-knowledge 这类关键结构出现
  3. 先把页面里所有 a[href] 提出来
  4. 再删除噪音节点
  5. 最后只抽正文容器

对应代码非常直接:

def discover_links(page, domain_prefix, visited_dict, task_queue):
    links = page.evaluate(
        """
        () => Array.from(document.querySelectorAll('a[href]')).map(a => a.href)
        """
    )
    new_count = 0
    for link in links:
        clean = normalize_url(link)
        if clean.startswith(domain_prefix) and clean not in visited_dict:
            task_queue.put(clean)
            new_count += 1
    return new_count

然后再清洗:

def strip_noise(page):
    page.evaluate(
        """
        () => {
            const trash = [
                '.aliyun-docs-sidebar',
                '.aliyun-common-layout-header',
                '.aliyun-common-layout-footer',
                '.right-side-bar',
                '.toc-container',
                '.doc-feedback',
                '.feedback-wrapper',
                '.pre-next-page',
                '.icms-guide-anchor',
                'script', 'style', 'iframe', 'noscript'
            ];
            trash.forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));
        }
        """
    )

最后才取正文:

def extract_content_html(page):
    return page.evaluate(
        """
        () => {
            const el = document.querySelector('#se-knowledge') ||
                       document.querySelector('.markdown-body') ||
                       document.querySelector('article');
            return el ? el.innerHTML : null;
        }
        """
    )

这三个函数连起来看,你就会明白这不是“随便抓抓页面”,而是在认真处理文档站:

  • 先吃下文档树链接,保证不漏章节
  • 再去掉站点噪音,保证 Markdown 干净
  • 最后容错式提取正文,兼容不同页面模板

抓取范围为什么控制得这么死

这个脚本有一个很正确的克制:它完全没有想爬整站

入口是:

DEFAULT_START_URL = (
    "https://help.aliyun.com/zh/analyticdb/analyticdb-for-mysql/"
    "product-overview/what-is-analyticdb-for-mysql"
)

域名限制是:

DEFAULT_DOMAIN_PREFIX = "https://help.aliyun.com/zh/analyticdb/analyticdb-for-mysql/"

而且在入队时又做了一次前缀校验:

if clean.startswith(domain_prefix) and clean not in visited_dict:
    task_queue.put(clean)

这个限定非常重要。因为如果你不锁死在产品文档子树里,帮助中心这种站点会非常容易“爬偏”:

  • 跳到别的产品
  • 跳到通用 OpenAPI 文档
  • 跳到账号、权限、CLI 总站
  • 跳到一些你根本不想收的全局帮助页

而当前这套做法的好处是,抓出来的库天然是一个主题明确的垂直知识集:只围绕 AnalyticDB for MySQL

多进程设计不是为了炫技,而是为了把等待时间吃掉

脚本默认:

DEFAULT_PROCESS_COUNT = 8

注释里还明确提到 M4 Max 建议 8-10。这个取值很现实,因为这里的主要耗时并不在 CPU,而在浏览器打开页面、等待渲染、提链接、生成 Markdown。

真正让我觉得写得靠谱的,不是“用了 multiprocessing”,而是它的退出策略并不天真。

很多脚本会写成“队列空了就结束”,但这里不行。因为多进程抓取里,一个 worker 可能刚拿到一个页面,等它把侧边栏链接解析出来,又会继续往队列里塞新任务。此时你如果仅凭某一瞬间的 queue empty 判定退出,就会提前收工。

所以它用了空闲计数器:

empty_strikes = 0

while True:
    try:
        url = task_queue.get(timeout=config["queue_timeout"])
        empty_strikes = 0
    except Empty:
        empty_strikes += 1
        if empty_strikes >= config["idle_limit"]:
            print(f"💤 [Worker-{worker_id}] 长时间无任务,下班。")
            break
        time.sleep(config["queue_timeout"])
        continue

这其实是在给“页面还没把新链接吐出来”留缓冲时间。比起简单粗暴的空队列退出,这种设计明显更适合文档树型站点。

另外,入口处还显式设了:

multiprocessing.set_start_method("spawn", force=True)

这对 macOS 上跑 Playwright 多进程也更稳,不容易踩到 fork 继承浏览器状态之类的坑。

文件名策略很朴素,但很对

文件名转换逻辑是:

def sanitize_filename(url):
    path = urlparse(url).path
    name = path.strip("/").replace("/", "_")
    return f"{name if name else 'index'}.md"

也就是说,URL path 会直接变成:

zh_analyticdb_analyticdb-for-mysql_user-guide_data-import.md

这有三个好处:

  1. 不丢原始层级信息
  2. 不需要额外维护一张 URL 到文件名的映射表
  3. 后期做 grep、分组、向量入库都很好用

写文件时还把原始 URL 也带上了:

f.write(f"# {title}\n\n> URL: {url}\n\n---\n\n{markdown}")

这一步很小,但特别值钱,因为 Markdown 一旦脱离原网页,被二次搬运、切片、入库后,很容易失去来源。现在每一篇文件开头都保留了源地址,后续追溯会轻松很多。

抓下来的三类文档,为什么真的有价值

这里我最不想写成“抓了很多页面,可用于知识库”。这种说法太空了。更准确的说法是:抓下来的内容本身就已经能说明 AnalyticDB for MySQL 这套产品的结构。

1. 产品概览:你能迅速看出这个产品到底想解决什么问题

what-is-analyticdb-for-mysql 这篇里,信息密度其实很高。

开头先给出产品定位:

  • 全托管 PB 级实时数仓
  • 支持毫秒级数据更新、亚秒级查询响应
  • 高度兼容 MySQL 协议
  • 基于湖仓一体架构

再往下不是抽象口号,而是直接把使用场景摊开:

  • 多源融合
  • 实时分析
  • 离线 ETL
  • Spark 数据分析

然后又列了生态兼容性:

  • 数据源:RDS、PolarDB、MongoDB、MaxCompute、OSS、Kafka、SLS 等
  • 客户端:DBeaver、Navicat、MySQL 命令行
  • 驱动:JDBC、MySQLdb、PDO、Go 驱动、Node.js
  • BI:Power BI、Tableau、Quick BI、Superset、Metabase、Redash
  • 调度:DMS、DataWorks、Airflow、DolphinScheduler、Azkaban

所以这类页面的价值不只是“介绍产品”,而是能让你迅速建立一张心智图:这个产品接在什么生态上、服务什么角色、覆盖哪些数据链路。

2. 集成概览:你能看到自动化接入的边界条件

integration-overview 这一篇对后续自动化最有价值,因为它不是泛讲 SDK,而是把接入现实说得比较完整:

  • API 分版本号,2019-03-152021-12-01 分别对应不同集群体系
  • 调试入口是分集群类型的,不是一个入口通吃
  • 接入点区分公网和 VPC
  • 身份上建议用 RAM 用户或 RAM 角色,而不是直接拿阿里云账号乱调
  • 集成方式明确支持 SDK、CLI、Terraform、ROS、自定义封装

这意味着你后面如果想做:

  • 自动化运维
  • OpenAPI 封装
  • Terraform 编排
  • CLI 快速调度

这篇文档已经能给出边界:你该用什么身份、走什么接入方式、不同版本 API 怎么区分。

3. 数据导入:你能直接看见一张“入仓/入湖”能力矩阵

data-import 这篇尤其适合保留成本地资料,因为它本质上就是一张很长的能力清单。

它先把“数据入仓”和“数据入湖”分清楚:

  • 入仓:走玄武分析型存储引擎,偏高性能查询分析
  • 入湖:导成 Iceberg、Paimon 这类开放表格式,偏开放共享和湖上计算

然后再展开来源和导入方式矩阵,例如:

  • RDS MySQL:外表、DTS、DataWorks、Zero-ETL
  • PolarDB MySQL:联邦分析、DTS、Zero-ETL、一键建仓
  • MongoDB:外表、Zero-ETL
  • OSS:外表、DataWorks
  • MaxCompute:外表、DataWorks
  • Kafka:DataWorks,入湖侧还有数据同步推荐方案
  • SLS:数据同步
  • 本地数据:SQLAlchemy、LOAD DATA、导入工具、Kettle

这种页面如果只在浏览器里看,你每次都得翻。抓成 Markdown 后,后续不管是全文搜索还是给模型做检索,都特别顺手。

Notebook 暴露出一个很真实的问题:这套东西还处在“脚本驱动”阶段

我挺喜欢保留 Untitled.ipynb 里那条 SyntaxError,因为它特别真实。

它说明当前这个项目的重心并不是“把交互体验打磨得多好”,而是先把真正可用的抓取逻辑做出来。Notebook 没有包装成一个漂亮的分析面板,也没有复杂的后处理流程,它就是:

  • 启动脚本
  • 看帮助
  • 点一下输出目录
  • 抽样看看抓了多少文件

从工程角度说,这反而是正常顺序:

  1. 先把抓取逻辑写结实
  2. 再考虑是否需要 Notebook、CLI 包装、增量更新、索引构建

这套方案后面还能怎么继续长

我觉得这批材料继续往下做,至少有四个方向:

1. 给产物补 manifest

脚本现在已经会写 _crawl_manifest.json,记录:

  • start_url
  • domain_prefix
  • total_pages
  • visited_urls

这已经比“只留一堆 md 文件”强很多了。下一步可以继续补抓取时间、页面标题、hash、上次更新时间,用于增量同步。

2. 给 Markdown 建一个本地检索入口

27 份文档已经不算少了,再往后规模更大时,最自然的方向就是:

  • 本地全文检索
  • SQLite/FTS 建索引
  • 简单 Web 检索页

3. 接 RAG

这批文档天生适合做垂直知识库,因为主题非常集中,全是 AnalyticDB for MySQL,而且每份文档开头都保留了 URL 来源。

做法也很直接:

  1. Markdown 切块
  2. 保留标题和源 URL
  3. 向量化入库
  4. 检索时把产品概览、集成、导入矩阵这几类文档一起召回

4. 做增量更新而不是全量重爬

如果这批资料后面要长期维护,那就不应该每次从头跑一遍,而应该基于 manifest 和页面指纹只更新变动页面。

总结

如果只看名字,aliyun_full.py + Untitled.ipynb + aliyun_adb_docs_final/ 很像一个普通的小爬虫实验;但把材料摊开看,其实它已经有一套相当清楚的路线:

  • 从 AnalyticDB for MySQL 产品概览页起步
  • 严格锁定产品文档子树,避免爬偏
  • 用 Playwright 处理真实页面,用 html2text 生成 Markdown
  • 先提链接再清洗页面,兼顾覆盖率和正文纯度
  • 用多进程和空闲计数器把等待型抓取跑稳
  • 最终沉淀出 27 份真实可用的产品文档 Markdown

这类工作最值钱的地方,不是“我会写爬虫”,而是你最后得到了一套可以继续被检索、被索引、被向量化、被长期维护的产品知识底稿。对后面的知识库和 RAG 来说,这一步其实已经把最难啃的原始资料整理工作做掉了。

参考来源

  • aliyun_full.py
  • Untitled.ipynb
  • aliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_product-overview_what-is-analyticdb-for-mysql.md
  • aliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_developer-reference_integration-overview.md
  • aliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_user-guide_data-import.md