☰
用pycurl+pyspider+PhantomJS搭建高并发动态页面采集流水线
2026/10/14 1:35:19 网站建设 项目流程

简介:这是一份面向爬虫开发者的资源合集,聚焦 pycurl、pyspider 与 PhantomJS 三种工具的集成使用,解决动态加载页面与传统静态抓取难以兼顾的问题。资源共 52 个文件,压缩包约 28.71MB,内含 pycurl 和 pyspider 的 wheel 安装包、PhantomJS Windows 可执行文件、45 个 JavaScript 示例脚本、使用说明与开源许可文件,既有可直接部署的运行环境组件,也有便于上手学习的脚本素材。pycurl 负责多协议与异步请求处理,pyspider 提供可视化任务调度框架,PhantomJS 则作为无头浏览器完成 JavaScript 渲染,三者组合可覆盖静态页面、动态内容、代理交互等复杂场景。示例脚本涉及页面渲染、截图、代理访问、扩展框架接入等典型功能,适合中高级爬虫开发与框架研究者参考;已有 328 人学习,按需提取组件即可加速爬虫项目落地。

1. 把 pycurl + pyspider + PhantomJS 串成一条采集流水线:它到底解决了什么

做 Web 采集的开发者迟早会遇到一类场景:目标页面是异步渲染的,真实数据藏在 JavaScript 生成的 DOM 里,用 requests 直接抓只能拿到空壳;就算抓到明文接口,对方又给请求层加了 TLS 指纹校验和 Cookie 绑定,换台机器跑就翻车。这套「pycurl+pyspider+phabtomjs」组合,就是把「高并发请求」「任务调度」「浏览器级渲染」三件事拆开解决:pycurl 负责底层 HTTP 和代理池,pyspider 负责去重、重试、频率控制,PhantomJS 负责补上动态渲染的缺口。适合准备做中型采集项目、被反爬折磨得想换语言的开发者,也适合拿来研究动静态页面混合抓取的完整链路。这套资源的核心价值不是某个单一工具,而是三者的分工与交接协议。

2. pycurl 实战:把请求响应从 8 秒压到 1 秒的请求层基础

2.1 为什么不用 requests 而选 pycurl

先说选型理由。requests 在普通场景下足够好用,但它的问题在于连接复用和 DNS 缓存策略不够激进,遇到高并发采集时 socket 重建开销明显。pycurl 底层是 libcurl,支持连接池、HTTP/2、多协议、细粒度超时控制,还能直接绑定本地 IP 或网卡,这在配置了多出口 IP 的服务器上非常实用。

这套资源里 pycurl 承担的角色是「请求执行器」,不是调度器。实践中通常让 pyspider 把任务分配给 pycurl 执行器,执行器负责真实发出 HTTP 请求,并把响应体、响应头、状态码原样返回给调度层。这样拆分的好处是,未来就算要换底层传输层(比如换 Go 或换 http.client),调度层可以完全不动。

2.2 初始化 Curl 会话:连接复用与超时参数

pycurl 的用法比 requests 繁琐,但性能提升也来自这种显式操作。下面是一段封装的请求函数,也是这套资源里给 pycurl 模块定下的基础模板:

import pycurl from io import BytesIO def fetch_with_curl(url, proxy=None, timeout=10): buffer = BytesIO() c = pycurl.Curl() c.setopt(pycurl.URL, url) c.setopt(pycurl.WRITEDATA, buffer) c.setopt(pycurl.TIMEOUT, timeout) c.setopt(pycurl.CONNECTTIMEOUT, timeout - 2) c.setopt(pycurl.FOLLOWLOCATION, 1) c.setopt(pycurl.MAXREDIRS, 5) c.setopt(pycurl.HTTPHEADER, [ 'User-Agent: Mozilla/5.0', 'Accept-Language: zh-CN,zh;q=0.9', ]) if proxy: c.setopt(pycurl.PROXY, proxy) c.perform() status = c.getinfo(pycurl.RESPONSE_CODE) c.close() return status, buffer.getvalue()

这段代码的核心逻辑是三步:写入 BytesIO 避免落盘、设置超时和重定向上限、通过 getinfo 拿到最终状态码。TIMEOUT 控制整个请求的最终截止时间,CONNECTTIMEOUT 单独控制 TCP 握手阶段,这两个值要联动调整,不能只设一个。FOLLOWLOCATION 开启后会自动跟随重定向,但必须配合 MAXREDIS 防止无限跳转。

参数说明:WRITEDATA指向 BytesIO 对象,响应体会直接写入该对象;perform()是阻塞调用,超时和重试逻辑要在调用前准备好;close()之后 Curl 对象不可复用,高频请求场景建议用CurlMulti做并发,但那样代码复杂度会成倍提升,这套资源里的做法是线程池 + 单 Curl 对象。

2.3 代理池切换与失败重试

采集目标一旦上反爬,代理池就是刚需。pycurl 换代理很简单,只需在每次请求前重新设置PROXY选项,但这里有个坑:如果你复用一个 Curl 对象,连接会被 libcurl 复用,代理切换不生效。所以每换一个代理,要么销毁重建对象,要么显式调用c.setopt(pycurl.FRESH_CONNECT, 1)。

import random, time PROXY_LIST = [('http://127.0.0.1', 8080), ('http://127.0.0.1', 8081)] def fetch_with_proxy_retry(url, max_retries=3): for attempt in range(max_retries): proxy_ip, proxy_port = random.choice(PROXY_LIST) try: status, body = fetch_with_curl( url, proxy=f'http://{proxy_ip}:{proxy_port}', timeout=8 ) if status == 200 and len(body) > 500: return body except pycurl.error as e: print(f'attempt {attempt+1} failed: {e}') time.sleep(0.5 * (attempt + 1)) return None

这里的重试策略是指数退避,第一次失败等 0.5 秒,第二次等 1 秒,第三次放弃。pycurl.error是 libcurl 抛出的异常,常见的原因是CURLE_OPERATION_TIMEDOUT(超时)和CURLE_COULDNT_CONNECT(连接失败)。注意这里的判断条件:状态码 200 且响应体长度大于 500 才视为成功,目的是过滤掉反爬返回的空壳页面。

血泪经验:代理池的 IP 失效速度比想象中快,尤其是在爬取频率较高的场景。建议在代理池模块中维护一个黑名单队列,连续失败两次的 IP 直接拉黑 5 分钟,而不是靠重试逻辑硬扛。

3. PhantomJS 渲染与 token 提取:让动态页面变成静态 HTML

3.1 PhantomJS 在动静态混合抓取中的定位

PhantomJS 是一个无头浏览器,核心价值是执行页面里的 JavaScript,把异步加载完成后的 DOM 状态暴露给外部调用。这套资源里的典型用法是:先用 pycurl 快速请求页面,发现关键数据没有渲染出来,就把同一个 URL 交给 PhantomJS 渲染,拿到渲染后的完整 HTML 再交给解析层。

选择 PhantomJS 而不是 Headless Chrome,有几个考量:第一,PhantomJS 的内存占用远低于 Chrome;第二,它的启动速度更快,适合高频短任务的采集场景;第三,旧版 PhantomJS(2.1.1)对老式页面的兼容性反而更好,某些网站的 JS 代码在 Chrome 下能执行,在 PhantomJS 下可能报错,反之亦然。当然,如果你采集的目标是重度 SPA(单页应用),更推荐把 PhantomJS 换成 CDP 协议驱动无头浏览器,但那就脱离这个资源包的范畴了。

3.2 用 PhantomJS 渲染并输出完整 HTML

写一个 PhantomJS 脚本,接收 URL 参数,渲染完成后输出 HTML。脚本文件命名为render.js:

var page = require('webpage').create(); var system = require('system'); var url = system.args[1]; var timeout = parseInt(system.args[2] || '5000'); page.settings.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; page.settings.loadImages = true; page.settings.resourceTimeout = timeout + 2000; page.open(url, function(status) { if (status !== 'success') { console.log('LOAD_FAIL'); phantom.exit(2); } else { window.setTimeout(function() { var html = page.content; var match = html.match(/<title>([^<]+)<\/title>/); if (match) { console.log('TITLE=' + match[1]); } console.log('HTML_LEN=' + html.length); console.log(html); phantom.exit(0); }, timeout); } });

system.args[1]是传入的 URL,system.args[2]是渲染等待时间,也就是给 JavaScript 执行预留的缓冲。这里用window.setTimeout而不是page.onLoadFinished,原因是很多框架的异步渲染在 load 事件触发后才开始,等待一个固定时间比监听事件更稳定。page.content拿到的是当前 DOM 序列化后的 HTML 字符串,page.settings.resourceTimeout防止某个资源加载卡死导致整个渲染任务挂住。

注意 PhantomJS 脚本输出到 stdout 的内容会和页面 HTML 混在一起,所以我用TITLE=和HTML_LEN=这类前缀标记来区分元信息和正文内容。外部程序解析时先按行读,遇到HTML_LEN=后再读后面的内容,避免被 HTML 里的标签干扰。

3.3 把 Cookie 从 pycurl 导入 PhantomJS

很多站点要求先访问首页获取 Cookie,再带着 Cookie 访问目标页面才能拿到数据。pycurl 执行完首页请求后,可以通过c.getinfo(pycurl.INFO_COOKIELIST)拿到当前会话的所有 Cookie,然后把它转成 Netscape 格式,传给 PhantomJS 使用。

cookie_file = '/tmp/cookiejar.txt' with open(cookie_file, 'w') as f: for c in c.getinfo(pycurl.INFO_COOKIELIST): f.write(c + '\n')
page.addCookie({ 'name': 'sessionid', 'value': 'xxxxxx', 'domain': '.example.com', 'path': '/' });

这里的坑在于:pycurl 返回的 COOKIELIST 格式是\t分隔的 Netscape 格式,包含 domain、flag、path、secure、expiry、name、value 七个字段,而 PhantomJS 的 cookiejar 只接受 JSON 化的 Cookie 对象。我通常会写一个解析函数,把 COOKIELIST 一行拆成字典再传给page.addCookie,而不是手动硬编码。

常见做法是维护一个独立的cookie_bridge.py模块,负责把 pycurl 的 Cookie 转成 PhantomJS 能直接加载的 JSON 文件。这个模块不参与具体业务逻辑,纯粹做格式转换,方便后续替换成其他浏览器内核。

4. pyspider 调度 + pycurl 抓取:把请求管道变成数据管道

4.1 pyspider 的 on_start / index / detail 抓取模型

pyspider 的抓取模型基于状态机:on_start是入口,on_message接收消息,index和detail是自定义回调。这套资源里的分工是:调度层全部用 pyspider,请求层全部走 pycurl 封装,渲染层走 PhantomJS 子进程。

from pyspider.libs.base_handler import * from utils.curl_fetcher import fetch_with_proxy_retry class Handler(BaseHandler): crawl_config = { 'itag': 'v202', 'retries': 3, 'taskid': 'auto', 'proxy': None } @every(minutes=30) def on_start(self): for url in self.get_seed_urls(): self.send_message(self.project_name, { 'url': url, 'stage': 'list' }) def get_seed_urls(self): return ['https://example.com/list/1', 'https://example.com/list/2']

crawl_config里的taskid字段决定了去重粒度。auto模式会基于 URL 生成唯一 taskid,如果你想针对「同一 URL 不同参数」做更细粒度管控,可以自定义get_taskid方法。retries控制调度层的重试次数,它和 pycurl 层的重试是两层逻辑,不要混为一谈。

4.2 把 pycurl 封装成 pyspider 的 fetch 接口

pyspider 默认用自带的self.fetch方法发请求,但我们可以直接改写on_index逻辑,让它调用我们自定义的 pycurl 访问器,这样能同时拿到 PhantomJS 渲染后的内容:

def on_index(self, msg): url = msg['url'] html = fetch_with_proxy_retry(url) if html is None or len(html) < 500: rendered_html = run_phantomjs_render(url) if rendered_html: html = rendered_html if html: items = self.extract_list(html) for item in items: self.send_message(self.project_name, { 'url': item['url'], 'stage': 'detail' })

逻辑说明:先用 pycurl 做快速请求,如果响应体异常(比如被反爬重定向到验证页),再调用 PhantomJS 渲染。send_message是把任务继续往下游推进的关键接口,它会把消息交给on_detail处理。注意send_message的第二个参数stage字段,用来区分当前任务处于列表页还是详情页,避免下游处理函数逻辑混乱。

参数说明:self.project_name是 pyspider 当前项目名,send_message的第一个参数必须填它,否则消息会丢失。run_phantomjs_render是自定义的 Python 函数,内部通过subprocess.Popen启动phantomjs render.js <url>并读取 stdout,这里有一个隐藏的坑——子进程最怕僵尸化,所有启动 PhantomJS 的地方都必须设置timeout和popen.kill()兜底逻辑。

4.3 taskid 去重策略与 rate 控制

pyspider 的默认行为是按 URL 去重,但如果目标网站的详情页 URL 是不变的路由(页面内容靠 JS 切换),同一个 URL 会漏掉所有后续数据。这时候可以自定义 taskid,把内容摘要加入去重因子:

def get_taskid(self, task): if task.get('stage') == 'list': return md5(f"LIST_{task['url']}") elif task.get('stage') == 'detail': title_hash = md5(task['title']) return md5(f"DETAIL_{task['url']}_{title_hash}")

这种做法的代价是去重空间变大,但能避免「URL 相同、内容不同」的页面被错误跳过。crawl_config里的rate参数控制每分钟最大抓取数,默认是1.0(每分钟 1 个请求),做采集时要主动调大,比如'rate': 20,否则整套流水线跑起来会慢得让人怀疑人生。

翻车提醒:pyspider 的 rate 控制和代理池的响应速度是联动的。如果某个代理 IP 响应特别慢,rate 再高也没意义,因为调度器在等请求完成。所以你的代理池里最好加一个「最大延迟」筛选,超过 5 秒的代理自动剔除,不然调度器的令牌桶会被慢速请求耗尽。

5. 避坑与常见问题:五个我踩过的坑

5.1 渲染结果不完整:等了 10 秒拿到还是空壳

现象:PhantomJS 渲染出来的 HTML 只有几百字节,核心列表数据没有出现。原因是等待时间设成了固定值,但目标页面的某个 JS 文件加载慢,异步请求在设定的等待窗口后才返回。解决方法是双重保障:先把wait_time从 5 秒调到 10 秒,同时在脚本里增加「轮询检测」逻辑,用page.evaluate判断页面上是否出现了某个关键节点,比如列表容器的innerHTML.length是否超过阈值,超过后再输出 HTML。

这段要放在render.js里:

function check_ready() { return page.evaluate(function() { var el = document.querySelector('#js-list'); return el && el.children.length > 0; }); }

判断逻辑在外部调用处:每 500ms 检查一次,直到check_ready()返回 true,或者超过最大等待时间才退出。

5.2 pycurl 偶发请求失败:同一 URL 有时 200 有时 302

现象:同一个 URL 连续请求,状态码在 200 和 302 之间摇摆。原因是服务端对请求指纹做了概率性校验,或者代理 IP 的出口质量不稳定。解决方法是把 302 和 200 都视为「需要解析」的响应,但增加一次「验证页探测」——解析 HTML 中是否包含「验证」「captcha」等关键词,一旦命中就把当前代理拉黑并重试。

if b'captcha' in body.lower() or b'verify' in body.lower(): blacklist_proxy(proxy) return fetch_with_proxy_retry(url, fresh_proxy=True)

5.3 pyspider 偶发重复抓取:日志里看到完全相同的 URL 被请求了两次

现象:同一个 URL 在短时间内被 pyspider 调度执行多次。原因是消息风暴:当一个send_message在回调里被连续调用多次,且taskid生成方式没有把参数加进去,调度器就会把它们视为不同的任务。解决方法是调整get_taskid,让它基于 URL + stage + 必要的业务字段生成,并在crawl_config中开启'force_update': False,避免新消息覆盖旧任务。

5.4 PhantomJS 进程残留:服务器上堆了几百个僵尸进程

现象:跑了两小时后,ps aux看到的 phantomjs 进程数超过 200,CPU 被打满。原因是 Python 侧在调用subprocess.Popen后,只处理了stdout和stderr,没有调用popen.wait()和popen.kill(),导致子进程退出后父进程未回收。解决方法是写一个统一的调用包装器,在finally块中强制回收:

def run_phantomjs_render(url): proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: stdout, stderr = proc.communicate(timeout=15) return stdout.decode('utf-8', errors='ignore') except subprocess.TimeoutExpired: proc.kill() return None

注意communicate(timeout=15)是对「超时回收」的双重保险:它既等待子进程输出,也会在超时后抛异常,让外部有机会执行kill(),从根本上杜绝僵尸进程堆积。

5.5 pycurl 和 PhantomJS 的 Cookie 对不上:渲染页面提示未登录

现象:同一个 URL,用 pycurl 拿到的 Cookie 是正常的,但 PhantomJS 渲染页面却返回未登录。原因是 PhantomJS 的 Cookie 作用域判断和 pycurl 不完全一致,page.addCookie时domain字段没写对,或者漏掉了secure标志。解决方法是把 Cookie 的 domain 统一转成目标站点根域名,并且不设置 Secure 属性,除非页面明确是用 HTTPS 且启用了 Secure Cookie。另一个技巧是先从 PhantomJS 访问首页拿到它自己的 Cookie,再访问目标页,而不是强制从 pycurl 导入 Cookie——虽然慢一点,但兼容性更好。

6. 让 PhantomJS 渲染结果自动修复:从脏 HTML 到结构化数据

这套资源的最后一个实战技巧,是把 PhantomJS 渲染出来的完整 HTML 直接丢给一个「自动修复管道」,让它变成可以直接入库的结构化数据。常见的坑是:渲染结果里既有页面头部、侧边栏、页脚的大段噪音,也有真正需要的列表和详情内容。正则只适合小型样本,样本一多就崩。

我的做法是写一个基于节点路径的修复脚本,用 PhantomJS 自带的page.evaluate做预处理,在输出 HTML 之前就把 DOM 裁剪到目标容器内:

var container = page.evaluate(function () { var el = document.querySelector('#content-main'); return el ? el.innerHTML : null; }); if (container) { console.log('CONTAINER=' + container.length); console.log(container); } else { console.log('NO_CONTAINER'); }

这样做的好处是,HTML 体积减少一半以上,下游解析模块不用再费劲清洗导航和标注。但要注意:并不是所有页面都有稳定的#content-main选择器,某些站点在前端框架下每次渲染的容器 ID 都不一样。我的习惯是维护一个「候选选择器列表」:先尝试#content-main,再尝试#main .list,最后尝试.article-content,谁命中取谁。

这个配置要放在单独的 JSON 文件里,方便演进:

{ "selectors": ["#content-main", ".article-body", "div.main-content"], "strip_nodes": ["script", "style", ".ad-banner", ".recommend"], "min_content_len": 300 }

验证方法很简单:跑完整个流水线后,抽查 50 条数据,人工核对结构化字段是否完整。如果字段缺失率超过 5%,说明选择器或者清洗规则有问题,优先怀疑strip_nodes误伤了正文节点。

从那以后,每次上线采集任务前,我都强制走一遍「验证三步」:先跑 20 条样本,检查 HTML 长度和关键字段索引;再跑 200 条检查去重率和增量更新;最后跑完整天数据后核对峰值内存和代理池消耗。如果这三步没问题,这套流水线基本就能长期稳定运行了。希望帮到你。

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

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

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

立即咨询