每天15分钟玩转Scrapy - 动态页面
开场
前面那篇讲的是怎么让服务器相信你是本人。这一篇讲另一件事,服务器给回来的东西不全。
你可能已经撞过这个场面。目标页在浏览器里打开挺正常,用 scrapy shell 一看,你要的那个节点一个都没有。请求状态码还是 200,页面也不空,就是内容对不上。
这事儿在今天的站点上很常见。前端框架把渲染搬到浏览器里做,服务端只发一个壳子出来,数据靠 JS 再要一次。你拿到的是那个壳子。
这一篇就处理这种情况。先说判断顺序,能不用浏览器就别用,因为这个选择的代价差着好几倍。然后是把浏览器接进来之后,怎么让它干更多的事,滚动、截图、把你想要的返回值带回来。
里面的数字都是我在自己的机器上跑出来的,同一台机器同一段时间,横向比较才有意义。
动态内容:先找数据源
页面上有内容,源代码里没有。这件事有三种不同的成因,对应的解法也完全不同。
- 页面骨架来自服务端,数据由 JS 另外发请求拿回来再塞进去
- 页面本来就只有骨架,内容全靠前端渲染
- 页面检测到你在爬它,故意给你一份空壳
前两种是技术问题,第三种是攻防问题。判断顺序应该是这样。

动手之前,你想想看数据是从哪儿来的。页面既然能显示出来,说明数据一定到过这台机器上。先花两分钟看一眼页面怎么运作的,我拿演示站的 /js 页面做实测,同一个页面用三条路线各跑一遍。
1. 路线一,普通请求
先用最朴素的方式抓。
| |
| |
零条。请求是成功的,状态码 200,页面也不是空的,只是你要的那个节点根本不存在。
2. 路线二,把数据源掏出来
现在换个思路。页面既然要把数据渲染出来,那数据一定在某个地方。去源代码里找找。
| |
| |
十条,跟浏览器里看到的数量一样。整个过程中没有启动浏览器,就是一次普通的 HTTP 请求加一段字符串处理。
这个页面里的写法是 var data = [...],很直白。真实站点的形式会更多样,可能是一个 XHR 接口返回 JSON,也可能是 window.__INITIAL_STATE__ 这种注入变量。判断标准是同一个,数据一定存在于某个你能用普通请求拿到的地方。
从 script 里抠 JSON 有几个小坑,我实测对比了三种写法。
| |
第一种失败是因为 parsel 的 .re() 不带 DOTALL 标志,.* 不跨行。JSON 一换行就匹配不上。
第二种失败是因为贪婪匹配吃掉了 JSON 之后的所有脚本内容。
第三种在这个页面上碰巧成功了,因为 JSON 恰好以 ]; 结尾。但这是巧合,JSON 里面出现 ]; 就会被提前切断。
稳妥的写法是让解析器自己找结尾,而不是用正则猜。
| |
raw_decode 从给定的位置开始解析一个 JSON 值,返回数据和解结束的位置。实测里它精确定位到了第 4061 个字符,而整段 script 长 4458 字符。
3. 三条路线的账
三条路线都跑完,把结果摆在一起看。
| 路线 | 拿到条数 | 耗时 | 要不要浏览器 |
|---|---|---|---|
| 一 普通请求 | 0 | 4.04 秒 | 不用 |
| 二 内嵌数据 | 10 | 2.32 秒 | 不用 |
| 三 浏览器渲染 | 10 | 4.92 秒 | 要 |
路线二比路线三快一倍还多,而且少了一个浏览器进程。这就是为什么判断顺序里,找数据源要排在渲染前面。
三条路线里,只有路线一和路线二值得优先考虑。渲染是兜底方案,不是首选。
交给浏览器渲染
数据源确实找不到的时候,才轮到浏览器出场。
Scrapy 官方没有把浏览器塞进核心,而是通过 scrapy-playwright 这个扩展接的。装它分两步,装包和装浏览器。
| |
然后改两处配置。
| |
下载器换掉之后,普通请求的写法一个字都不用改,还是 scrapy.Request。想让某个请求走浏览器,在 meta 里加一个键就行。
| |
playwright_page_methods 里那一行是必须的。页面加载完成不等于渲染完成,浏览器把 HTML 骨架交出来的时候,JS 可能还没跑完。wait_for_selector 让它等到名言节点真的出现再交给 parse。少了这一行,你拿到的就是一个空的 Selector,而且不报错。
跑起来。
| |
十条数据,跟路线二一样。
注意 playwright/request_count 是 7,而 Scrapy 层的 downloader/request_count 只有 2。一个是浏览器内部真实发出的请求数,一个是 Scrapy 看到的请求数。浏览器要额外去取 JS、CSS、图片这些资源,多出来的开销就在这里。
说到底,这是渲染方案的代价。多花的时间、多发的请求,都是为了让 JS 跑一遍。
拿浏览器当证据看一眼,页面渲染出来是这样的。

关于 Splash
早几年做渲染主要靠 Splash,一个独立跑着的渲染服务,Scrapy 这边用 scrapy-splash 接。它的思路是把浏览器能力做成一个 HTTP 服务,你发请求给它,它返回渲染后的 HTML。
这套方案现在还能用,但我不建议新项目再上了。理由有三个。它需要一个独立的 Docker 服务跟着跑,部署多一层。它的渲染内核版本偏老,现代前端框架在上面容易出问题。它的维护节奏也慢下来了。
scrapy-playwright 现在装起来只要两条命令,浏览器直接跑在本机,配置也少。新项目直接上 Playwright。 遇到老项目里已经在用 Splash,知道它是什么东西就行。
让浏览器多干一点
浏览器接进来之后,很多人就只用来渲染页面,拿到 HTML 就完事。这样亏了。PageMethod 能让它在渲染的同时把一些值顺手带回来,这个能力在处理滚动和交互类页面时特别省事。
1. PageMethod 的返回值能读回来
先说机制。PageMethod 对象建出来之后,你不要扔进列表就不管了,自己留一个引用。scrapy-playwright 在浏览器里跑完这个方法,会把返回值写回这个对象的 .result。
| |
跑出来是这样。
| |
三样东西都拿到了,而且它们是三种不同的类型。
| PageMethod | 参数作用 | 返回值类型 | 实测结果 |
|---|---|---|---|
title() | 不用参数 | 字符串 | Quotes to Scrape |
evaluate(js) | 一段 JS 表达式 | JS 值的 Python 映射 | 数字 10 |
screenshot() | path 落盘,full_page 整页 | 字节串 | 长度 100833 |
screenshot 那一项有个细节值得说。传了 path 它会同时落盘也返回字节串,实测那个 PNG 文件是 100833 字节,跟返回值的长度一样。这行代码可以让你在 CI 里给失败页面留一张图,比翻日志直观。
⚠️ 拿 .result 有个前提,你得在 parse 里能访问到那个 PageMethod 对象。上面用 cb_kwargs 传进来是一种办法。直接把对象挂在 self 上也能用,但多个请求并行跑的时候会互相覆盖,cb_kwargs 这种跟着请求走的写法更稳。
2. 无限滚动要数到不再增长
演示站有个 /scroll 页面,典型的无限滚动。初始给 20 条名言,往下滚一屏,JS 再去要下一批。
这类页面用 wait_for_selector 是没用的。第一批名言一开始就存在,选择器立刻能匹配上,它判断不出后面还有没有。必须自己滚。
PageMethod 的第一个参数可以是一个协程函数,scrapy-playwright 会把 page 当第一个参数传进去。所以滚动逻辑可以直接写成这样。
| |
这个函数有两条出口。一条是滚到某一轮数量不再增长,主动收工。另一条是把 rounds 循环用完,函数正常返回。
我拿两个不同的轮次上限各跑一遍,差别很大。
| 轮次上限 | 每一轮数到的条数 | 最终 |
|---|---|---|
| 6 | 20 / 30 / 40 / 50 / 60 / 70 | 70 |
| 12 | 20 / 30 / 40 / 50 / 60 / 70 / 80 / 90 / 100 / 100 | 100 |
这个页面真实的条目总量是 100。轮次上限设成 6 的那一次,item_scraped_count 是 70,跑完了,退出原因正常,日志里一句提醒都没有。

⚠️ 这两条出口的语义完全不同,但外部看不出来。 第一条是「我确认到底了」,第二条是「我次数用完了」。scroll_to_bottom 的返回值在两种情况下都是一个整数,调用方分不出哪个是哪个。所以轮次上限只能当成一道保险,不能当成结束条件,取值要留足。不然你会安安静静地少拿三成数据。
想区分也行,让函数返回一个元组,把「是否确认到底」这个布尔值一起带出来。
3. 把图片和字体拦掉
浏览器渲染的代价之一是它会去取页面引用的所有资源。图片、字体、样式表,一样不落。你要的只是渲染后的 DOM,这些资源大部分是白花的。
scrapy-playwright 留了一个钩子,让你在请求发出之前决定放不放行。
| |
挂上去的方式是在蜘蛛的 custom_settings 里写那个函数的位置。
| |
我拿一个书城列表页做对照,20 本书,每本一张封面图。两个蜘蛛跑同一个页面,一个全放行,一个掐掉图片和字体,各跑三轮。
| 轮次 | 全放行 | 掐掉图片字体 |
|---|---|---|
| 1 | 2.663 秒 | 2.475 秒 |
| 2 | 2.850 秒 | 2.448 秒 |
| 3 | 2.723 秒 | 2.397 秒 |
| 平均 | 2.745 秒 | 2.440 秒 |
省下来大约 0.3 秒,一成左右。说实话这个幅度不算大,它取决于页面引用了多少图片,图多的站效果更明显,我这只是一个 20 张图的列表页。你看这个页面的资源构成就明白了,20 张封面图加 2 个字体文件,能省的就是这些。
两侧的数据是完全一样的,item_scraped_count 都是 20,downloader/request_count 都是 1。缩短的只有浏览器内部的取资源时间,跟解析结果无关。
拦住的数量在统计里叫 playwright/request_count/aborted,三轮都是 22。这个数字对应 20 张图加 2 个字体。
⚠️ 这里有个容易看错的地方。同一个统计里的 playwright/request_count/resource_type/image 在两侧都是 20,看起来像没拦住。原因是资源类型的计数发生在请求发出之前,而 aborted 是另一套账。想知道自己到底拦掉了多少,看 aborted 那个键,别看 resource_type 的分解。
拦截本身要克制一点。有些站的图片地址里藏着业务数据,有些字体文件缺失会让 SPA 的字体加载超时卡住渲染。稳妥的做法是先全放行跑一遍拿到基准,再加拦截,对比两侧的 item 数量有没有变化。
容易栽的跟头
坑 1:以为用了浏览器就没有身份问题了。 这是个错觉,而且是双重的。我在同一个请求上把两个 UA 都读出来对比。
| |
第一行那个 UA 里明晃晃写着 HeadlessChrome。第二行 navigator.webdriver 是 True,这是自动化框架留下的标记,有些站会直接读这一个值。第三行更值得注意,Scrapy 那一层的请求头还是自己的默认 UA,跟浏览器里跑的是两份身份。
想让浏览器看起来正常一点,得在 PLAYWRIGHT_CONTEXTS 里设 user_agent,把 HeadlessChrome 换掉,再把 PLAYWRIGHT_LAUNCH_OPTIONS 里的 headless 关掉换虚拟显示。不过这一层做起来是没有尽头的,具体怎么权衡在反爬那一篇再展开。
坑 2:wait_for_selector 只等第一个匹配节点。 它等的是「页面上有这么一个元素」,不是「页面上该有的元素都齐了」。列表类页面尤其容易中招,第一批 20 条渲染出来它就放行,后面 80 条还在路上。你拿到的是个半成品。
几个替代思路。等 wait_for_load_state("networkidle"),让网络安静下来;或者自己写个循环,等 document.querySelectorAll(...).length 不再变化。前者简单,但页面有轮询接口的时候永远等不到 idle;后者更准,代价是每次要固定多等一会儿。
坑 3:用 playwright_include_page 忘了关页面。 把 page 对象拿到自己手里之后,回收这件事就归你了。parse 里必须有一句 await page.close(),而且要放在 finally 里。
| |
漏了不会立刻报错,是慢慢卡住的。页面的并发上限由 PLAYWRIGHT_MAX_PAGES_PER_CONTEXT 控制,我的配置里是 4。前 4 个请求拿走页面不还,第 5 个请求就开始排队,整个爬虫看起来像卡死。还要注意 errback 那条路径也得关页面,请求失败的时候 meta 里那个 page 同样在手上。
坑 4:把 PLAYWRIGHT_ABORT_REQUEST 的返回值理解反了。 那个函数的返回值是「要不要掐掉」的意思,返回 True 表示不发这个请求。很容易照着白名单的思路写,在函数里列出想保留的资源类型然后返回 True,那就正好反了,把该留的全拦掉。文档和参数名都指向「abort」,但写的时候手会顺。
它的入参是 request,上面带 resource_type、url、method 这些字段。稳妥的写法是维护一个黑名单集合,in 判断,这样默认行为是放行。想核对拦截有没有按预期生效,看统计里的 playwright/request_count/aborted。
坑 5:DOWNLOAD_HANDLERS 只映射了一个协议。 这个配置要写两条键,http 和 https 各一条。其实吧,这个坑有点冤,因为很多示例代码只写了 https 那一行,看着挺完整。
| |
只写 https 的话,浏览器渲染对 https 的页面正常工作,某个 http 的接口请求却走了老的下载器,表现是这个请求拿不到渲染后的结果。更难受的是它不报错,你只会发现某几个页面数据对不上。检查办法是看 playwright/request_count,如果它比预想的少,先回来核对这两行。
坑 6:退出时那一屏 asyncio 报错不是你的错。 用 Python 3.14 跑 scrapy-playwright,爬虫正常跑完、退出码是 0,最后还是会打出这么一段。
| |
这是 scrapy-playwright 的 _ThreadedLoopAdapter 在解释器关停阶段被回收导致的,跟你的蜘蛛代码无关,也不影响已经爬到的数据。判断方法很简单,看退出码。0 就说明业务跑完了,这段噪音可以忽略。想让它安静下来,把日志级别调到 WARNING 以上也压不住,因为它是 asyncio 直接打到 stderr 的。
小结
这一篇其实是在讲一个取舍。
判断顺序那部分,核心就一句话,先找数据源,渲染是兜底。三条路线摆在同一个页面上,普通请求拿 0 条,从 script 里抠出内嵌 JSON 拿 10 条且不用浏览器,渲染也是 10 条但要起一个 Chromium。路线二比路线三快一倍多,选择顺序里它排前面不是没道理的。
渲染那部分,scrapy-playwright 接进来的成本很低,两条安装命令加两处配置。真正的门槛在于你要意识到浏览器能做的事比「渲染一下」多。PageMethod 的返回值可以带回来,滚动逻辑可以写成协程塞进去,资源可以按类型掐掉。
那几个数字里我觉得最值得记的不是快了多少,是那个 70 和 100。轮次上限写 6 拿到 70 条,页面真实总量是 100,中间丢的三成,日志里一个字都没有。这类静默的截断比报错麻烦得多,报错会拦住你,静默不会。
我自己的感受是,动态页面这块的排查成本主要花在判断上,不是花在写代码上。一条 scrapy shell 命令加两分钟看一眼页面源码,能省下后面一堆白折腾。真正需要上浏览器的情况,比看起来要少。
这篇里要是有哪里讲得不对,欢迎拍砖。