实战:用Playwright整本抓取阿里云ADB文档
这次要整理成博客的,其实不是“一个爬虫项目”这么抽象的东西,而是三份非常具体的材料:
aliyun_full.pyUntitled.ipynbaliyun_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 全删掉,然后再提内容。但这里如果你太早删,就会把左侧文档树里的链接也一起删没。
所以脚本的顺序是:
- 先打开页面
- 等
.aliyun-docs-sidebar, #se-knowledge这类关键结构出现 - 先把页面里所有
a[href]提出来 - 再删除噪音节点
- 最后只抽正文容器
对应代码非常直接:
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
这有三个好处:
- 不丢原始层级信息
- 不需要额外维护一张 URL 到文件名的映射表
- 后期做 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-15和2021-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 没有包装成一个漂亮的分析面板,也没有复杂的后处理流程,它就是:
- 启动脚本
- 看帮助
- 点一下输出目录
- 抽样看看抓了多少文件
从工程角度说,这反而是正常顺序:
- 先把抓取逻辑写结实
- 再考虑是否需要 Notebook、CLI 包装、增量更新、索引构建
这套方案后面还能怎么继续长
我觉得这批材料继续往下做,至少有四个方向:
1. 给产物补 manifest
脚本现在已经会写 _crawl_manifest.json,记录:
start_urldomain_prefixtotal_pagesvisited_urls
这已经比“只留一堆 md 文件”强很多了。下一步可以继续补抓取时间、页面标题、hash、上次更新时间,用于增量同步。
2. 给 Markdown 建一个本地检索入口
27 份文档已经不算少了,再往后规模更大时,最自然的方向就是:
- 本地全文检索
- SQLite/FTS 建索引
- 简单 Web 检索页
3. 接 RAG
这批文档天生适合做垂直知识库,因为主题非常集中,全是 AnalyticDB for MySQL,而且每份文档开头都保留了 URL 来源。
做法也很直接:
- Markdown 切块
- 保留标题和源 URL
- 向量化入库
- 检索时把产品概览、集成、导入矩阵这几类文档一起召回
4. 做增量更新而不是全量重爬
如果这批资料后面要长期维护,那就不应该每次从头跑一遍,而应该基于 manifest 和页面指纹只更新变动页面。
总结
如果只看名字,aliyun_full.py + Untitled.ipynb + aliyun_adb_docs_final/ 很像一个普通的小爬虫实验;但把材料摊开看,其实它已经有一套相当清楚的路线:
- 从 AnalyticDB for MySQL 产品概览页起步
- 严格锁定产品文档子树,避免爬偏
- 用 Playwright 处理真实页面,用 html2text 生成 Markdown
- 先提链接再清洗页面,兼顾覆盖率和正文纯度
- 用多进程和空闲计数器把等待型抓取跑稳
- 最终沉淀出 27 份真实可用的产品文档 Markdown
这类工作最值钱的地方,不是“我会写爬虫”,而是你最后得到了一套可以继续被检索、被索引、被向量化、被长期维护的产品知识底稿。对后面的知识库和 RAG 来说,这一步其实已经把最难啃的原始资料整理工作做掉了。
参考来源
aliyun_full.pyUntitled.ipynbaliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_product-overview_what-is-analyticdb-for-mysql.mdaliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_developer-reference_integration-overview.mdaliyun_adb_docs_final/zh_analyticdb_analyticdb-for-mysql_user-guide_data-import.md