Scrapy网络爬虫:构建一个处理JavaScript页面的爬虫
Lead Scraping Automation Engineer
TL;DR:
- Scrapy是一个爬虫框架,而不是一个HTTP客户端。 它提供了调度器、重复过滤器、异步下载器和项目管道,因此即使在爬取一百个页面时,蜘蛛的代码也只需约二十行。
- 一个蜘蛛是一个包含三个必要部分的类: 一个
name、起始URL和一个返回字典的parse方法。其他配置都是可选的。 - Scrapy永远不执行JavaScript。 同一个蜘蛛从服务器渲染的页面返回10个项目,而从客户端渲染的页面返回0个项目,因为接收到的HTML包含一个空容器和一个脚本标签。
- 下载中间件解决了这个问题,而不需要修改蜘蛛。 在上游渲染页面并将普通的
HtmlResponse返回给Scrapy后,所有10个项目得以恢复,而parse方法保持字节相同。 - 版本锁定在这里比平常更重要。 Scrapy的TLS层基于Twisted和pyOpenSSL,两种特定的组合会导致每次HTTPS下载时出现错误,这些错误是关于证书而不是依赖的。
- 免费开始。 本文中使用的通用抓取API有免费层,因此您可以在没有付费计划的情况下运行整个比较。
Scrapy将您关心的抓取部分与您不关心的部分分离。您编写选择器。Scrapy处理请求队列、并发、去重、编码检测和序列化。
然后您指向一个由前端框架构建的页面,却得到了一个空文件。
本指南构建一个可工作的蜘蛛,故意让它在与JavaScript渲染的页面发生错误,然后通过下载中间件修复它——Scrapy中允许您更改页面抓取方式而不更改解析方式的部分。
Scrapy为您提供的,与请求循环不同之处
Scrapy是一个具有结构观点的爬虫引擎。一个手动循环遍历URL列表在您需要Scrapy所拥有的东西时会失效:
- 带有重复过滤器的调度器。 请求被排队、通过指纹去重,并以受限的并发量调度。
- 无需异步语法的异步下载。 Scrapy运行在Twisted反应堆上,因此在您的
parse方法如下普通同步代码时,许多请求正在进行。 - 内置选择器。
response.css()和response.xpath()来自Parsel,这也是CSS和XPath选择器指南中涉及的同一选择器库。 - 输出导出。
-O results.json可以将结果写入JSON、JSON Lines、CSV或XML,而无需序列化代码。 - 礼貌设置。
ROBOTSTXT_OBEY、DOWNLOAD_DELAY和AUTOTHROTTLE_ENABLED是设置,而不是您需要实现的东西。第一个设置读取在机器人排除协议标准中描述的文件。
代价是Scrapy有一个您需要学习的结构。回报通常在抓取的第三个页面到来。
安装Scrapy
在一个新的虚拟环境中安装,并显式锁定TLS栈:
bash
python3 -m venv .venv
source .venv/bin/activate
pip install "scrapy==2.17.0" "twisted==26.4.0" "pyopenssl==25.3.0"
这三个版本锁定是故意的。Scrapy的HTTPS支持基于Twisted,Twisted又调用pyOpenSSL,本文末尾记录的两种失败模式都来自于这个栈,而不是Scrapy本身。
在编写任何蜘蛛代码之前确认版本:
bash
python3 -c "import importlib.metadata as m; print(m.version('scrapy'), m.version('twisted'), m.version('pyopenssl'))"
编写您的第一个蜘蛛
一个Scrapy蜘蛛是一个包含名称、起始URL列表和接收响应并产生项目的parse方法的类。将以下内容保存为quotes_spider.py:
python
import scrapy
class QuotesSpider(scrapy.Spider):
name = "quotes"
start_urls = ["https://quotes.toscrape.com/"]
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}
next_page = response.css("li.next a::attr(href)").get()
if next_page:
yield response.follow(next_page, callback=self.parse)
response.css("div.quote")返回一个选择器列表,因此循环迭代的是元素而不是字符串。::text是Scrapy的伪元素,用于文本节点,.get()返回第一个匹配项,而.getall()返回所有匹配项——这就是为什么tags是列表而author不是的原因。response.follow直接接受相对的href,相对于当前URL进行解析,因此不需要调用urljoin。
最后一部分是分页。将来自 parse 的请求交回调度器,并将其回调指向 parse 会遍历整个列表。这种模式涵盖的一般形态在 网页抓取中的分页指南 中列出。
在没有项目的情况下运行它
scrapy startproject 生成一个包含设置、管道和蜘蛛目录的包。您暂时还不需要这些。runspider 执行单个文件,而 -s 从命令行覆盖任何设置:
bash
scrapy runspider quotes_spider.py -O quotes.json \
-s LOG_LEVEL=ERROR \
-s CLOSESPIDER_PAGECOUNT=3
-O 会截断输出文件,而 -o 会附加到输出文件 — 提早理解这个区分是值得的。CLOSESPIDER_PAGECOUNT=3 将爬取限制在三页,这使得教程运行礼貌且可重复。
该命令写入了30个项目:三页每页十个引用,第一条记录为 Albert Einstein,标签为 ['change', 'deep-thoughts', 'thinking', 'world']。
二十行的蜘蛛代码,三页已爬取,已遵循分页,JSON 在磁盘上。这是 Scrapy 真正擅长的部分。
Scrapy 停止的地方:JavaScript 渲染的页面
通过更改一行代码,将相同的蜘蛛指向该网站相同页面的客户端渲染版本:
python
start_urls = ["https://quotes.toscrape.com/js/"]
然后再次运行它:
bash
scrapy runspider quotes_spider.py -O js.json -s LOG_LEVEL=ERROR -s CLOSESPIDER_PAGECOUNT=1
结果是一个空数组。零个项目,没有错误,退出代码 0。
选择器没有问题。页面返回了 HTTP 200,并且 Scrapy 正确地解析了它 — 它接收到的标记中根本没有 div.quote 元素。引用是在加载后由脚本写入 DOM 的,而脚本执行是由 HTML 标准的脚本模型 定义的浏览器行为。Scrapy 是一个附带 HTML 解析器的 HTTP 客户端。它获取字节;它不运行 JavaScript 引擎,而 Scrapy 关于动态加载内容的指导 直接说明了这一点。
要注意那个静默的零。JavaScript 页的爬取在输出和退出代码上看起来与无内容页面的爬取完全相同。
通常的解决方案是附加一个浏览器:scrapy-playwright 每次请求驱动 Chromium,而 Splash 在爬取的同时运行渲染服务。两种方法都有效,并且都意味着每个请求现在都承载了浏览器的内存和启动成本,以及一个额外的运行时。
将渲染完全移出机器保留了 Scrapy 的请求模型不变。
使用下载器中间件进行上游渲染
下载器中间件位于 Scrapy 的引擎和下载器之间,它正好有这个问题所需的钩子。行为已被指定:当 process_request() 返回一个 Request 对象时,下载器中间件参考 表示 "Scrapy 将停止调用 process_request() 方法并重新调度返回的请求。" 其对应的 process_response() 随后将 Response 返回链上。
因此,该中间件可以将每个传出的请求替换为向渲染端点的 POST 请求,然后将回复解包为一个普通的 HtmlResponse,携带原始 URL。蜘蛛永远不会知道发生了什么。
将其保存为 scrapeless_middleware.py:
python
import json
import os
from scrapy.http import HtmlResponse
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
class ScrapelessMiddleware:
"""在上游渲染每个请求,然后将普通 HtmlResponse 交给 Scrapy。"""
def __init__(self, token, country):
self.token = token
self.country = country
@classmethod
def from_crawler(cls, crawler):
return cls(
os.environ["SCRAPELESS_API_KEY"],
crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
)
def process_request(self, request, spider):
if request.meta.get("scrapeless"):
return None
payload = {
"actor": "unlocker.webunlocker",
"input": {
"url": request.url,
"proxy_country": self.country,
"js_render": True,
},
}
return request.replace(
url=UNLOCKER,
method="POST",
body=json.dumps(payload),
headers={"Content-Type": "application/json", "x-api-token": self.token},
meta={**request.meta, "scrapeless": True, "origin_url": request.url},
dont_filter=True,
)
python
def process_response(self, request, response, spider):
if not request.meta.get("scrapeless"):
return response
rendered = json.loads(response.text)["data"]
return HtmlResponse(
url=request.meta["origin_url"],
body=rendered,
encoding="utf-8",
request=request,
)
request.meta 中的 scrapeless 标志防止了无限递归。如果没有它,重新调度的 POST 将重新进入 process_request 并再次被包装。由于现在每个渲染请求都针对相同的端点 URL,因此需要 dont_filter=True,否则重复过滤器会丢弃除了第一个之外的所有请求。
origin_url 使得交换变得不可见。HtmlResponse 是用页面的真实地址构造的,而不是 API 的,因此 response.url 是正确的,response.follow 继续相对于正确的基本 URL 解析相对链接。到达网络的请求是一个 POST,根据 HTTP 语义规范,而蜘蛛看到的响应是一个普通的 HTML 文档。
最后,API 密钥是在 from_crawler 内部从 SCRAPELESS_API_KEY 环境变量中读取的,因此没有凭据写入设置文件。完整的参数文档在 Universal Scraping API 参考 中,有关 js_render 背后的渲染行为在 页面渲染指南 中涵盖。
连接并重新运行相同的蜘蛛
导出密钥,然后通过设置启用中间件。 PYTHONPATH=. 让 runspider 从工作目录导入模块:
bash
export SCRAPELESS_API_KEY="your_api_key"
PYTHONPATH=. scrapy runspider quotes_spider.py -O js_unlocked.json \
-s LOG_LEVEL=ERROR \
-s CLOSESPIDER_PAGECOUNT=1 \
-s 'DOWNLOADER_MIDDLEWARES={"scrapeless_middleware.ScrapelessMiddleware": 543}'
该运行产生了 10 项,第一个记录再次读取 Albert Einstein,标签为 ['change', 'deep-thoughts', 'thinking', 'world'] — 这些与服务器渲染的页面提供的记录完全相同。
在那次运行和失败的运行之间, quotes_spider.py 变动为零行。选择器、分页、项形状和馈送导出都经历了在如何获取页面方面的完全变化,这是将渲染放置在中间件中而不是蜘蛛中的论据。
入门不需要卡片 — 免费计划 覆盖了这样规模的运行。
在一个脚本中证明所有三个案例
三个单独的命令很容易不一致地运行。这个脚本在一个进程中运行所有三个爬虫并打印比较,因此上述声明可以一次性检查:
python
import json
import os
import scrapy
from scrapy import signals
from scrapy.crawler import CrawlerProcess
from scrapy.http import HtmlResponse
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
class ScrapelessMiddleware:
"""将每个请求在上游渲染,然后交给 Scrapy 一个普通的 HtmlResponse。"""
def __init__(self, token, country):
self.token = token
self.country = country
@classmethod
def from_crawler(cls, crawler):
return cls(
os.environ["SCRAPELESS_API_KEY"],
crawler.settings.get("SCRAPELESS_PROXY_COUNTRY", "US"),
)
def process_request(self, request, spider):
if request.meta.get("scrapeless"):
return None
payload = {
"actor": "unlocker.webunlocker",
"input": {"url": request.url, "proxy_country": self.country, "js_render": True},
}
return request.replace(
url=UNLOCKER,
method="POST",
body=json.dumps(payload),
headers={"Content-Type": "application/json", "x-api-token": self.token},
meta={**request.meta, "scrapeless": True, "origin_url": request.url},
dont_filter=True,
)
def process_response(self, request, response, spider):
if not request.meta.get("scrapeless"):
return response
rendered = json.loads(response.text)["data"]
return HtmlResponse(
url=request.meta["origin_url"],
body=rendered,
encoding="utf-8",
request=request,
)
class QuotesSpider(scrapy.Spider):
name = "quotes"
def __init__(self, url, **kwargs):
super().__init__(**kwargs)
python
self.start_urls = [url]
def parse(self, response):
for quote in response.css("div.quote"):
yield {
"text": quote.css("span.text::text").get(),
"author": quote.css("small.author::text").get(),
"tags": quote.css("div.tags a.tag::text").getall(),
}
class ScrapelessQuotesSpider(QuotesSpider):
name = "quotes-scrapeless"
custom_settings = {"DOWNLOADER_MIDDLEWARES": {ScrapelessMiddleware: 543}}
def main():
import importlib.metadata as md
print(
"scrapy", md.version("scrapy"),
"| twisted", md.version("twisted"),
"| pyopenssl", md.version("pyopenssl"),
)
jobs = [
("静态页面,普通Scrapy", QuotesSpider, "https://quotes.toscrape.com/"),
("JavaScript页面,普通Scrapy", QuotesSpider, "https://quotes.toscrape.com/js/"),
("JavaScript页面,Scrapeless中间件", ScrapelessQuotesSpider, "https://quotes.toscrape.com/js/"),
]
collected = {label: [] for label, _, _ in jobs}
# Scrapy以弱引用的方式持有信号处理程序,因此需要保留对每个处理程序的强引用。
handlers = []
def collector(label):
def on_item(item, response, spider):
collected[label].append(item)
handlers.append(on_item)
return on_item
process = CrawlerProcess({
"LOG_LEVEL": "ERROR",
"SCRAPELESS_PROXY_COUNTRY": "US",
})
for label, spider_cls, url in jobs:
crawler = process.create_crawler(spider_cls)
crawler.signals.connect(collector(label), signal=signals.item_scraped)
process.crawl(crawler, url=url)
process.start()
for label, _, _ in jobs:
items = collected[label]
print(f"{label}: {len(items)} 个项目")
if items:
print(f" 第一个作者: {items[0]['author']}")
print(f" 第一个标签: {items[0]['tags']}")
print("两个爬虫共享的解析方法:", ScrapelessQuotesSpider.parse is QuotesSpider.parse)
if __name__ == "__main__":
main()
运行时输出:
text
scrapy 2.17.0 | twisted 26.4.0 | pyopenssl 25.3.0
静态页面,普通Scrapy: 10 个项目
第一个作者: 阿尔伯特·爱因斯坦
第一个标签: ['改变', '深思', '思考', '世界']
JavaScript页面,普通Scrapy: 0 个项目
JavaScript页面,Scrapeless中间件: 10 个项目
第一个作者: 阿尔伯特·爱因斯坦
第一个标签: ['改变', '深思', '思考', '世界']
两个爬虫共享的解析方法: True
ScrapelessQuotesSpider 继承自 QuotesSpider,并且除了 custom_settings 外没有添加其他内容,这就是最后一行显示为 True 的原因:两个爬虫都调用了相同的 parse 函数对象。中间的0是JavaScript问题,而下面的10是修复后的结果,针对相同的解析器进行衡量。
在爬虫类中的 custom_settings 将设置范围限定于该爬虫,这使得一个进程可以在有无中间件的情况下运行爬取。Scrapy还通过弱引用持有信号接收者,因此在爬取结束之前创建的内联收集器闭包会被垃圾回收,安静地什么也不记录——handlers 列表用于保持它们的存在。
故障排除
每个HTTPS请求都失败,显示'X509'对象没有'get_extension'属性。 Twisted的版本太老,与安装的pyOpenSSL不兼容。Twisted 24.3.0,许多Linux发行版仍然打包的版本,调用了当前的pyOpenSSL版本不再提供的方法。安装 twisted==26.4.0 可以解决该问题。通常在将Scrapy安装到系统Python而非虚拟环境时最常见。
每个HTTPS请求都失败,显示'证书验证失败'。 Scrapy 2.17.0与Twisted 26.4.0和pyOpenSSL 26.3.0的组合在普通公共网站上无法通过证书验证。固定 pyopenssl==25.3.0 可以解决该问题,这也是安装步骤中命名这三个版本的原因。
中间件从未运行。 runspider不会将工作目录添加到导入路径,因此在DOWNLOADER_MIDDLEWARES中指定的类无法被导入。可以在命令前添加 PYTHONPATH=. ,或者将爬虫移动到通过 scrapy startproject 创建的项目中,这样模块解析会为您处理。
第一个请求之后的所有请求都被丢弃。 由于重复过滤器正在指纹识别渲染端点,而每一页的端点都是相同的。在替换请求上使用 dont_filter=True 可以防止这种情况发生。
爬虫生成的项目但 response.follow 构建了错误的URL。 HtmlResponse 是用API端点作为其URL构造的,而不是原始页面地址。相对链接是相对于 response.url 解析的,因此必须将 origin_url 通过 request.meta 带上。
结论
Scrapy的分工使其值得学习曲线。爬虫拥有数据的意义;下载器负责字节如何到达。保持这一点的分离是页面即使返回为空也能够返回十条记录的原因,而不需要修改任何选择器。
首先针对服务器发送的内容构建蜘蛛。当选择器返回为空时,检查标记在达到浏览器之前是否曾经包含过它们。如果没有,通过中间件进行上游渲染可以保持爬虫异步,保持部署在一个Python进程中,并使您已经测试过的解析代码保持不变。
准备好针对您自己的目标运行这个吗?创建一个免费的Scrapeless帐户,导出您的密钥,并将中间件放入现有的蜘蛛中。有关渲染表面的详情,请参见通用抓取API产品页面,以及定价页面了解计划限制。
常见问题解答
问:Scrapy可以独立抓取JavaScript渲染的网站吗?
不能。Scrapy通过HTTP获取HTML并进行解析,管道中没有JavaScript引擎。一个在客户端构建内容的页面到达时是一个空容器加上一个脚本标签,选择器匹配不上任何东西。渲染必须在别处发生——在附加到爬虫的浏览器中,或者在上游的渲染API中,其输出通过下载器中间件反馈回来。
问:下载器中间件与蜘蛛中间件有什么区别?
下载器中间件位于引擎和下载器之间,因此它在发送每个请求之前和解析每个响应之前都会看到每个请求——适合用于代理、头部和渲染。蜘蛛中间件位于引擎和蜘蛛之间,处理您的回调生成的项目和请求。更改页面的获取方式应在下载器中间件中进行。
问:我需要scrapy startproject,还是一个文件就够了?
使用scrapy runspider运行的单个文件对于一个蜘蛛来说已经足够,而本指南中的每个命令都使用它。一旦需要共享设置、项目管道、多个蜘蛛或部署,就创建一个项目——项目布局为您提供一个设置模块和不依赖PYTHONPATH的导入路径。
问:为什么我的蜘蛛返回零个项目而没有错误信息?
因为空选择器匹配在Scrapy中不是错误。最常见的原因是JavaScript在加载后注入的内容,选择器是针对浏览器检查器显示但原始响应不包含的标记编写的,或者响应返回了一个带有HTTP 200的插页页。打印len(response.text)并在正文中搜索您期望的字符串,然后再假设选择器是错误的。
问:中间件会减慢抓取速度吗?
每个请求变成了一个渲染的抓取,而不是原始的抓取,因此每个请求的延迟增加。不过,Scrapy的并发模型没有改变:请求仍然通过相同的调度器和相同的CONCURRENT_REQUESTS限制运行,每个请求没有浏览器进程。只对需要它的域启用中间件,其余的保持在普通下载器上。
问:我如何将我的API密钥从代码库中分离出来?
在from_crawler中从环境读取,就像这里的中间件通过os.environ["SCRAPELESS_API_KEY"]所做的那样。密钥从未出现在设置文件中,因此没有敏感信息被提交,并且相同的代码在开发和生产中使用不同的凭证运行。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



