☰
pycurl+pyspider+phantomjs组合:动态渲染与静态抓取的爬虫实战
2026/10/14 18:03:12 网站建设 项目流程

简介:这套工具包面向需要处理复杂网页数据的 Python 爬虫开发者,围绕 pycurl、pyspider 与 PhantomJS 三款组件,重点解决静态页面抓取、动态内容渲染以及爬虫任务调度协同等常见问题。压缩包共 52 个文件,整体约 28.71MB,包含 2 个可直接安装的 whl 包、无头浏览器可执行程序与 45 个 JavaScript 示例脚本,并附有说明文档、更新日志和第三方许可文件;从安装、验证到二次开发,文件结构清晰,便于快速上手。已有 328 人学习下载,适合具备一定 Python 基础、希望掌握爬虫框架与无头浏览器配合作战的开发者。通过其中的示例脚本,可以练习协议级请求配置、可视化任务编排,以及依赖 JavaScript 渲染页面的抓取处理;三个组件在项目中相互配合,为复杂网页数据采集提供了可直接运行的环境与参考样例。

1. pycurl+pyspider+phantomjs 这一组合,到底在解决什么

pycurl、pyspider、phantomjs 这组组合,适合页面里一半静态、一半靠 JS 渲染的抓取场景。我在某公司维护过一个资讯聚合爬虫,列表页的数据要等 JS 跑完才出现,详情页却是普通静态 HTML,还有几十 MB 的附件需要下载。如果只上 requests,列表就抓空;只上 pyspider,大文件会吃爆内存;只上 phantomjs,调度和断点又得自己写。标题里的三个工具正好拆成三层:pycurl 管可控 HTTP 下载,pyspider 管任务调度、去重和失败重试,phantomjs(标题里 phabtomjs 是它的手误写法)管渲染。下面是这“下载 + 调度 + 渲染”三层怎么接到一起、参数怎么调、坑在哪的完整拆解,按可复现的方式讲,适合正在做抓取链路、想少踩坑的从业者。

2. 先划清三个工具的职责边界:下载、调度、渲染各自管什么

2.1 pycurl 的角色:不是爬虫框架,是“可控 HTTP 下载器”

很多读者会把 pycurl 和 requests 摆在一起选型,这是个误区。pycurl 的本质是 libcurl 的 Python 绑定,它提供的不是get()这种封装好的方法,而是一堆setopt让你直接控制 curl 的底层行为。这意味着它在“爬虫”这个事情上其实没有框架概念,真正的价值在于网络会话的可控性。

我实际使用中,pycurl 主要在四个场景里不可替代。第一是大文件下载:WRITEDATA可以直接指向一个文件对象,边下载边落盘,内存占用恒定,而 requests 默认把整个响应读进内存,几十 MB 的文件一来就容易把 worker 打垮。第二是断点续传:setopt(pycurl.RESUME_FROM, offset)一行就能从指定字节继续下载,requests 要自己拼 Range 头。第三是限速:MAX_RECV_SPEED可以直接限制下载速度,做多任务并发时防止把出口带宽打满。第四是 HTTPS 握手的细节控制:指定 CA 包、关闭或开启证书校验、自定义 DNS、绑定出口 IP 等。

诉求pycurl 的做法用 requests 的体验
大文件写盘WRITEDATA 指向 file 对象,边下边写读入内存再落盘,内存压力大
断点续传RESUME_FROM 指定偏移量手动拼 Range 头,还得处理状态码
限速MAX_RECV_SPEED 单行开启需要自定义 stream 循环,代码量大
HTTPS 细节setopt 直接控制 SSL_VERIFYPEER、CAINFOsession 配置散落,行为不直观

所以我的结论是:pycurl 是“下载层”的工具,不是“抓取逻辑层”的工具。在本文的实战场景里,它专门负责详情页大附件的下载,不参与列表页和详情页的 HTML 抓取,那些交给 pyspider 默认机制更合适。

2.2 pyspider 解决的不是“发请求”,而是“任务生命周期”

pyspider 的设计核心是“每一个 URL 就是一条任务”,任务有自己的状态机:active(等待抓取)、success(抓取回调正常结束)、failed(回调异常或网络失败)。调度器负责决定任务何时被 worker 取走,同一个 URL 会不会重复调度,失败后是否重试,以及多久之后允许重新抓同一地址。这些特性是我选它的直接原因。

我之前自己写过调度循环,用 dict 存 URL 状态,进程一重启全部归零,断点续爬完全靠运气。pyspider 把任务状态持久化到 taskdb 里,重启以后任务还在,失败任务也可以从 WebUI 手动重新执行,这份能力不便宜。另一个值得说的是去重:self.crawl(url)同一个 url 只会生成一条任务,后续重复调用会复用旧任务,避免同一页面反复被抓。

和一个常见的误解区分开:pyspider 不是什么“性能怪兽”,它单机吞吐并不夸张,但它把“任务去重、过期重抓、断点恢复、结果回收”这一整套索引机制替你写好了。爬虫最烧时间的从来不是抓取本身,而是任务管理和恢复,pyspider 解决的是这部分。

2.3 phantomjs 的作用域:只在“JS 渲染”这一环出手

phantomjs 是一个基于 QtWebKit 的无头浏览器,能像一个真实浏览器那样加载页面、执行 JS、等待异步请求返回,再把渲染后的 DOM 交给你。在 pyspider 里,它通过一个常驻的 phantomjs_fetcher 进程对外提供渲染服务,调度器遇到fetch_type='js'的任务时,会把 URL 发给这个渲染进程,而不是发给普通的下载器。

什么时候需要 phantomjs?判断标准只有一个:页面数据在静态 HTML 里是否存在。我用一个笨办法验证:直接 curl 拉 URL 后用 grep 找关键字段,找不到就说明数据靠 JS 生成的,这时候再考虑渲染。常见的需要渲染的场景有三种:列表数据由 AJAX 接口在浏览器端填进 DOM、页面存在懒加载导致滚动后才加载更多内容、分页按钮依赖 JS 事件绑定而不是普通 href。

需要提醒的是,phantomjs 是重型工具,每个渲染任务会占一个独立的浏览器上下文,并发越高内存涨得越快。所以它只适合处理“必须渲染”的那部分页面,纯静态的 URL 千万不要往渲染通道里塞。

2.4 三者如何接在一起:架构一句话说清

整体架构在实战项目里是这样串的:pyspider 作为主框架接收全部抓取请求,其中需要 JS 渲染的任务通过fetch_type='js'派发给 phantomjs_fetcher;渲染完成后的 HTML 和脚本返回值原样回流到 pyspider 的 worker;worker 解析出的附件下载链接则交给 pycurl 直接处理,避免大文件进入任务队列。

这样分工有一个额外好处:三层可以独立替换。phantomjs 挂了只需重启渲染进程,pyspider 的任务状态不受影响;pycurl 下载失败也只是下载层失败,列表和详情抓取逻辑无需改动。抓取链路拆得越独立,排障边界就越清晰,这是我把这套组合用于生产环境的主要原因。

3. 本地跑通三件套:环境安装、最小脚本、phantomjs 配置

3.1 环境安装:pycurl 的编译依赖和 phantomjs 的二进制

先把环境装好,这一步最容易翻车。pyspider 对运行环境比较挑,建议直接用独立的 Python 3.7 虚拟环境,能省掉大量不兼容问题。Python 3.8 以上的版本跑 pyspider 会遇到语法兼容问题,虽然能 patch,但没必要在生产环境冒险。

# 建议用 python3.7 或 python3.6 python3.7 -m venv ~/venvs/spider source ~/venvs/spider/bin/activate pip install --upgrade pip # 先装 pycurl,libcurl 开发头不足时会编译失败 pip install pycurl # 再装 pyspider pip install pyspider

pycurl 在 Linux 上需要系统的 libcurl 开发头,常见发行版一般用包管理器安装,比如libcurl4-openssl-dev。装完以后可以用一段极简代码验证是否可用,注意pycurl.curl_info()能打印 libcurl 版本,如果提示找不到 libcurl.so 之类的错误,说明编译时没找到动态库,需要重新安装或设置环境变量指定路径。

phantomjs 单独下载二进制即可,不需要 pip。去 phantomjs 官方页面拿到对应操作系统的压缩包,解压后确保phantomjs --version能跑出版本号。pyspider 的 phantomjs_fetcher 本质就是用这个二进制去执行一个 JS 文件,所以 phantomjs 本身必须能独立运行。

3.2 最小脚本:先让 pyspider 跑通一个静态页

装完环境先不要碰 JS,把 pyspider 最小任务脚本跑起来。下面这个脚本的逻辑很简单:入口on_start发起对某站首页的请求,回调index_page从页面里提取所有外链,再交给detail_page解析标题。

from pyspider.libs.base_handler import * class Handler(BaseHandler): def on_start(self): # 入口方法,启动时会被调度器调用一次 self.crawl( 'https://example.com/', callback=self.index_page ) def index_page(self, response): # response.doc 是 pyquery 对象,选择器和 jquery 风格一致 for link in response.doc('a[href^="http"]').items(): self.crawl( link.attr.href, callback=self.detail_page ) def detail_page(self, response): # 返回值会进入 on_result,这一步存的就是你要的结构化数据 return { 'url': response.url, 'title': response.doc('title').text(), }

self.crawl是 pyspider 最核心的调用,第一个参数是目标 URL,callback指定处理函数。response.doc返回 pyquery 对象,用类似 jQuery 的语法做选择器。detail_page返回的字典会被 pyspider 自动收集,经on_result方法写入 resultdb。

然后启动项目。开发阶段直接跑一体式命令:

pyspider all

pyspider all会把 scheduler、worker、webui 全部拉在同一个进程里启动,端口默认 5000。打开http://127.0.0.1:5000/就能看到任务面板,点运行按钮触发on_start,然后观察任务从 active 变成 success,failed 则表示回调或网络层出错。

3.3 挂上 phantomjs:配置渲染服务和第一个 js 任务

跑通静态页之后,把渲染服务挂上。pyspider 需要一份配置文件告诉 scheduler 怎么联系 phantomjs 渲染进程。我一般单独建一个config.json:

{ "phantomjs_fetcher": "http://127.0.0.1:25544", "phantomjs_fetcher_port": 25544, "phantomjs_fetcher_timeout": 15000 }

然后手动启动渲染进程,用哪个 phantomjs 二进制、加载哪些 cookie、是否加载图片,都在这一步控制:

phantomjs --load-images=no --cookies-file=/tmp/pjs_cookies.txt \ /path/to/pyspider/phantomjs_fetcher.js

--load-images=no表示不加载图片,渲染速度提升非常明显,也能避免部分图片资源阻塞页面加载。phantomjs_fetcher.js 在 pyspider 安装目录的 phantomjs 文件夹里,自己找到绝对路径填进去即可。启动完访问http://127.0.0.1:25544/能看到服务响应说明成功。

再写一个带渲染的抓取任务,关键参数是fetch_type='js'和js_script:

from pyspider.libs.base_handler import * class Handler(BaseHandler): def on_start(self): self.crawl( 'https://example.com/dynamic', fetch_type='js', js_script=""" function() { var node = document.querySelector('.main-content'); return node ? node.innerText : ''; } """, callback=self.dynamic_page ) def dynamic_page(self, response): return { 'url': response.url, 'content': response.js_script_result, }

fetch_type='js'是切换渲染通道的开关,一旦设置,pyspider 会把这个 URL 交给 phantomjs_fetcher,而不是普通下载器。js_script里的函数会在页面加载完成后于浏览器上下文执行,返回值经过 JSON 序列化传回,最终能从response.js_script_result取到。这里建议脚本里做好空值判断,渲染失败或节点加载慢时返回空字符串而不是抛异常。

4. 实战组合场景:JS 渲染列表页 + 静态详情页 + 大文件下载

4.1 先画页面关系图,再动手写代码

这个实战场景我做过很多次:某个资料站点的列表页由 JS 动态生成条目,详情页是服务端渲染的普通 HTML,详情页底部还有一个几十 MB 的附件下载链接。如果只靠 pyspider 默认下载器,列表页抓回来是空的,附件会撑爆内存。用三件套的分工方式,页面关系梳理成四条链:

  • 列表页:URL 结构为/list?page=1,内容由 JS 填充,需要 phantomjs 渲染。
  • 详情页:URL 为/detail/123.html,静态 HTML,pyspider 直接抓。
  • 附件:详情页解析出的下载链接,交给 pycurl 流式落盘。
  • 结果:每条详情页解析出的字段统一回收到 MySQL。

画清楚页面关系后,handler 的职责边界就非常明确了。不要在一个回调函数里既做渲染又做下载,那样排障时你分不清到底是页面变了、脚本挂了,还是下载失败。每层独立,日志里才能快速定位。

4.2 主 handler 代码:列表页渲染、详情页解析、附件下载

完整代码按职责拆成四个方法:on_start入口、list_page处理渲染结果、detail_page解析详情并触发下载、以及一个静态方法做 pycurl 附件下载。

import os import pycurl from pyspider.libs.base_handler import * class Handler(BaseHandler): crawl_config = { 'headers': {'User-Agent': 'Mozilla/5.0'}, 'timeout': 20, 'retries': 3, } def on_start(self): # 列表页必须走 phantomjs 渲染,js_script 提取条目 URL 和标题 self.crawl( 'https://example.com/list?page=1', fetch_type='js', js_script=""" function() { var arr = []; var nodes = document.querySelectorAll('.product-item'); for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; var a = node.querySelector('a.item-link'); if (!a) continue; arr.push({ url: a.href, title: node.querySelector('.title').innerText }); } return arr; } """, callback=self.list_page ) def list_page(self, response): # phantomjs 渲染后返回的结构化结果直接可用 items = response.js_script_result or [] for item in items: self.crawl( item['url'], callback=self.detail_page, save={'title': item['title']} ) def detail_page(self, response): # 静态详情页可以直接用 response.doc 解析 price = response.doc('.price').text() file_node = response.doc('.download-btn a') result = { 'url': response.url, 'title': response.save.get('title'), 'price': price, 'local_path': None, } if file_node: file_url = file_node.attr.href local_path = self.download_file(file_url) result['local_path'] = local_path return result @staticmethod def download_file(file_url): # 大文件用 pycurl 流式写盘,不进内存,避免 worker 被撑爆 dest_dir = '/data/files' os.makedirs(dest_dir, exist_ok=True) file_name = file_url.split('/')[-1] dest = os.path.join(dest_dir, file_name) with open(dest, 'wb') as f: curl = pycurl.Curl() try: curl.setopt(pycurl.URL, file_url) curl.setopt(pycurl.WRITEDATA, f) curl.setopt(pycurl.FOLLOWLOCATION, 1) curl.setopt(pycurl.CONNECTTIMEOUT, 15) curl.setopt(pycurl.TIMEOUT, 300) curl.setopt(pycurl.NOSIGNAL, 1) curl.setopt(pycurl.USERAGENT, 'Mozilla/5.0') curl.perform() except pycurl.error as exc: # 这里只记录日志,不抛异常,避免影响详情页任务状态 import logging logging.error('download failed: %s, url=%s', exc, file_url) return None finally: curl.close() return dest

local_path为 None 时,就说明附件下载失败,业务上可以后续重放,而不是让整个详情页任务失败。crawl_config里的超时和重试是全局默认值,个别 URL 需要覆盖时可以在self.crawl里再传一遍timeout、retries。

4.3 结果落库:on_result 与基于 URL 的幂等写入

detail_page 返回的结构化数据会被 pyspider 统一交给on_result方法。这里我建议落 MySQL,用 URL 做主键天然去重。

CREATE TABLE IF NOT EXISTS items ( url VARCHAR(512) PRIMARY KEY, title VARCHAR(255), price VARCHAR(50), local_path VARCHAR(512), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

on_result 里的写入逻辑:

def on_result(self, result): if not result: return sql = """ INSERT INTO items (url, title, price, local_path) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE title = VALUES(title), price = VALUES(price), local_path = VALUES(local_path) """ params = ( result['url'], result['title'], result.get('price', ''), result.get('local_path'), ) self.mysql.execute(sql, params)

用 URL 做主键的价值在于:pyspider 的任务去重是针对 URL 的,但数据库写入是另一套逻辑,两者不一定对齐。赶上任务重放,pyspider 会再次回调 detail_page,再次进入 on_result;URL 主键加上ON DUPLICATE KEY UPDATE可以保证重复抓取时更新而不是插入新行,这也是我踩过一次重复数据后养成的习惯。

5. 避坑与排查:phantomjs 白屏、pycurl 段错误、任务卡死

5.1 phantomjs 渲染结果白屏或字段为空

现象:任务显示 success,但js_script_result是空字符串或空数组,点开任务详情查看抓回的 HTML,页面里面空空荡荡,只有<html><head></head><body></body></html>。

原因多半有两个:一是页面本身有懒加载,数据是滚动或点击后通过 AJAX 追加的,phantomjs 加载完立即执行脚本时,数据还没到位;二是--load-images=no在某些站点上把判断脚本执行时机的机制改变了,渲染进程返回得过早。

解决方法是把 js_script 改成轮询等待模式,而不是一次性提取。常见做法是在脚本里用定时器反复检查目标节点是否存在,最多等一个窗口期,超时就返回当前状态。这个窗口期一般给 10 到 15 秒,太久会拖慢整个队列。

function() { var deadline = Date.now() + 10000; function getData() { var nodes = document.querySelectorAll('.product-item'); if (nodes.length > 0 || Date.now() > deadline) { return Array.prototype.map.call(nodes, function(node) { return node.innerText; }); } setTimeout(getData, 500); } return getData(); }

注意这里的返回值不能立刻拿到,因为setTimeout是异步的;实际项目中更稳妥的做法是在 pyspider 侧把phantomjs_fetcher_timeout调大,配合js_run_at='document-end'让脚本在文档加载完成后执行。先加超时,再调脚本,不要一上来就调大并发。

5.2 pycurl 在抓取过程中直接段错误

现象:任务跑起来的前几分钟正常,过一会儿 worker 进程直接崩溃,看日志发现是 Segmentation fault,没有 Python traceback。

原因:pycurl 的 Curl 句柄本质上是 libcurl 的 easy handle,这个句柄不是线程安全的。pyspider 的 worker 是多线程模型,如果为了提高下载效率在代码里复用了同一个 Curl 对象,或者把一个 Header List 反复绑定到多个句柄上,多个线程同时perform时就可能触发 libcurl 底层的竞争条件。

解决:pycurl.Curl() 实例每次都新建,用完立刻 close,绝不做全局复用的“优化”。另外一定要打开NOSIGNAL选项,它会让 libcurl 不用信号处理超时,避免在多线程环境下因为信号机制打断系统调用导致崩溃。

curl = pycurl.Curl() curl.setopt(pycurl.NOSIGNAL, 1) # ... 其他 setopt curl.perform() curl.close()

如果你发现崩溃出现在perform之后而不是之前,检查是否在多个 Curl 实例之间共享了HTTPHEADER列表。每个 Curl 实例都应该独立构造 header 列表,这是容易忽略的隐蔽点。

5.3 任务永远停在 active,不 success 也不 failed

现象:WebUI 里某个任务始终是 active 状态,调度器、worker 都重启了也没变化,任务既没有进入 success,也没有变成 failed。

原因:这个通常不是回调异常,而是请求在 phantomjs_fetcher 那边一直没有返回。渲染进程被某个超长加载页面卡住后,任务就悬挂在 active。pyspider 的 active 状态代表“调度器已经派出,但结果还没回来”,如果 phantomjs 进程本身僵死,这个状态会一直维持。

解决:先看 phantomjs_fetcher 的进程 CPU 和内存,如果内存持续上涨,直接重启渲染进程;再把phantomjs_fetcher_timeout调小一些,让超时的任务快速失败而不是无限等待。生产环境里我给渲染进程配了守护脚本,每两小时检测一次内存占用,超过阈值就自动重启。phantomjs 本身有不可避免的内存增长,不要指望一个进程能长期稳定。

5.4 同一个 URL 重新抓取后,数据库出现重复记录

现象:跑了一段时间发现库里同一 URL 对应多条记录,数据出现重复,排查后发现不是任务重复调度,而是任务重放时生成了新的写入。

原因:pyspider 对 URL 的去重是任务级别的,同一 URL 只会有一个任务,但任务可以被手动重放或者因为 age 过期重新抓取。重新抓取后回调会再次执行,on_result再次触发,如果数据库侧没有唯一约束,一条记录就变成了两条。

解决:数据库表加 URL 唯一主键,写入用ON DUPLICATE KEY UPDATE,也就是 4.3 里写的模式。更进一步的写法是用 URL + 参数指纹做联合唯一,这样即使同一地址对应不同查询参数也不会漏掉。这个坑在抓取量小的时候根本感觉不到,等跑两周之后看数据质量才会发现,越早加约束越好。

6. 进阶技巧:把吞吐量和稳定性一起拉上去

三件套跑通只是开始,抓到后期,真正的瓶颈通常在 phantomjs 渲染速度和 pyspider 单 worker 的并发能力。我的习惯是做一个“最小渲染面”原则:列表页尽量让 js_script 在 phantomjs 内部完成数据提取,只把结构化结果返回,而不是把整个渲染后 HTML 都传回 pyspider。这样既减少网络传输,也减少了 worker 的解析时间。

吞吐量上,一个有效手段是给 phantomjs 做实例池。pyspider 允许配置多个 phantomjs_fetcher 地址,把 URL 按任务 hash 分发到不同渲染进程。实操时我先起两个渲染进程,分别占 25544 和 25545 端口,观察一段时间后按单进程每秒能处理的渲染任务数决定是否需要扩容。phantomjs 实例池最关键的是每个实例的 cookie 目录要分开,共用 cookie 文件会导致 session 串号,这是我在某次登录态混乱时踩出来的教训。

pycurl 也可以配合带宽控制做并发下载。给每批下载任务设置MAX_RECV_SPEED,比如限制在 2MB/s,这样 10 个附件并发下载时总带宽会被压到 20MB/s,不至于把业务站点的网络出口占满。这个参数在 pycurl 里就是一行 setopt,但价值很大,尤其在服务器带宽不富裕、还要同时跑列表页任务的场景。

还有一个容易被忽略的稳定性技巧:回调函数里不要抛异常。detail_page 如果因为解析字段报错,任务会直接 failed,但 failed 任务太多会影响你对整体运行状况的判断。更稳妥的是在关键解析处做空值兜底,字段缺失时返回 None 或空字符串,把异常记录到日志里,而不是让任务失败。这样日志里能看到真实情况,WebUI 上一片 success 也不会掩盖数据质量问题。

我现在做一个抓取项目,第一件事不是写 handler,而是先确定哪些页面必须走 phantomjs、哪些可以直接静态抓、哪些文件要 pycurl 落盘。抓取任务不是写功能,是写容错。每批代码发布前我都会用几百条样本跑一轮,观察 success 和 failed 的比例、附件下载成功率,再决定放量。这套“先分层、再落盘、后调参”的顺序,帮我省下的调试时间远比写代码的时间多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询