早几年做网页数据抓取,我属于“先动手复制、后写脚本”的那类人:打开浏览器开发者面板,把商品标题、价格、规格一条条从HTML里挑出来,再手工粘到表格里。页面少的时候还能忍,一旦需要持续采集几十上百个列表页,这种手工操作显然扛不住。后来听一个做数据的老手说,别在选型上纠结,把BeautifulSoup和Scrapy放在一起用,一个负责拆HTML,一个负责调度和下载,各管各的,反而省心。这个思路我在实际项目里一直沿用到现在,今天把整个融合方案完整拆开讲一遍。
这套方案解决的核心问题很直接:Scrapy是有完整架构的爬虫框架,调度、下载、去重、管道、中间件全都有;BeautifulSoup则是纯粹的HTML解析库,对不规范的页面容错能力很强。两者融合,本质上是“用Scrapy的工程能力,把BeautifulSoup变成项目里的解析层”。它不是让人放弃XPath或CSS选择器,而是让你在复杂的页面结构面前多一把好用的工具。适合已经跑通Scrapy基本流程、但总觉得提取逻辑写起来别扭的读者,也适合想了解动态页面、iframe、扩展机制这些进阶话题的人。
1. 把Scrapy当作调度中心,让BeautifulSoup专注于HTML拆解
1.1 两者的定位刚好接在抓取链路的两端
很多刚接触爬虫的朋友会有个误解:用了Scrapy,就不要再碰BeautifulSoup了;或者用了BeautifulSoup,就老老实实搭配Requests一个页面一个页面地抓。实际上这两种工具体量完全不同,硬要二选一,等于把两个擅长不同事情的零件放在同一条线上比较。
Scrapy真正强大的地方,是它的“编排”能力。它把一次完整抓取拆成了好几个环节,调度器负责决定下一个请求该发谁,下载器负责真正拿到响应,爬虫负责解析页面,管道负责清洗和落库,中间件负责在请求和响应之间做钩子。你完全可以不写一行解析逻辑,但依然能稳定地把几百个页面按顺序抓下来,这就是框架层面的价值。
BeautifulSoup则是一个只做解析的库。它不关心网络请求怎么发,不关心并发,不关心重试,也不关心数据要存到哪里。它拿到一段HTML文本,就能在上面做查找、定位、遍历、文本清洗。由于定位足够单一,它在“拆HTML”这件事上做得异常顺手,特别是遇到残缺标签、乱嵌套、属性值被截断这些真实页面里的脏结构时,容错表现往往比严格依赖XPath的写法更稳定。
所以把两者融合起来,本质上是一条清晰的职责划分:Scrapy负责“把页面拿回来并管理整个抓取生命周期”,BeautifulSoup负责“把页面里的有效信息解析出来”。其余的数据校验、去重、导出,还是交给Scrapy的管道和组件。
1.2 “非规范HTML”场景里,BeautifulSoup比XPath更不容易翻车
很多时候XPath和Scrapy内置选择器本身完全够用,尤其页面结构规律、class命名稳定的时候,用response.xpath('//span[@class="price"]/text()')一行就能拿到结果,不需要额外引入BeautifulSoup。我之所以会在部分项目里专门把BeautifulSoup加进解析层,是因为真实页面并不总是像文档示例那样规矩。
举一个常见例子。某个商品价格标签的HTML可能是这样:
<div class="product-price"> <span>¥ 299</span><small>.00</small> </div>价格文字被拆成了两个标签,中间既有空格又有换行。如果只用XPath的text(),你得分别取两个节点的文本再自己拼接;如果学了//div[@class="product-price"]//text()去取全部文本,拿回来的是一串带着空白符的碎片,还要再做一遍清理。BeautifulSoup处理这种结构就轻松不少:
from bs4 import BeautifulSoup html = '<div class="product-price"><span>¥ 299</span><small>.00</small></div>' soup = BeautifulSoup(html, "lxml") price = soup.select_one("div.product-price").get_text(" ", strip=True) print(price) # 结果: ¥ 299 .00当然价格本身还存在“29.90”和“299.00”这类显示规则的差异,后面管道里仍要二次清洗,但至少第一步提取时,解析写法清晰很多。get_text的separator参数和strip=True组合,是很多人在用XPath的text()函数时容易忽略的处理方式,在BeautifulSoup这边则是默认级别的便利。
还有一类场景:页面里出现大量未闭合的div,或者属性值里带引号,XPath解析时会因为节点树不完整而拿不到想要的节点。BeautifulSoup底层的解析器(例如lxml或html.parser)会先尝试把残缺HTML修复成一颗相对完整的树,再供查询使用,这种容错对整站采集的价值非常高。
1.3 融合不是无脑双写,而是按场景选择解析工具
需要先说清楚一点:融合绝不是让项目里所有页面都写两套解析,也不是在Scrapy的Selector拿到结果之后再丢给BeautifulSoup做二次提取,那是重复劳动。我实际使用的判断标准有三个。
第一,页面结构非常规整、肉眼扫一遍就能写出稳定XPath的,直接用Scrapy自带选择器,性能更好,代码也更短。
第二,页面里嵌套层级深、标签结构混乱、需要“找到某个容器后再逐层下钻”的,用BeautifulSoup的find、find_all、select_one组合起来写,维护成本远远低于一长串XPath。
第三,某些字段拿回来的是HTML片段,比如商品详情里的富文本描述,需要在后续管道中转成纯文本,这时候管道里再用BeautifulSoup做一次轻量解析最顺手。
这样分工之后,Scrapy依然是整个爬虫的统一调度中心,BeautifulSoup则变成一个服务于不同解析阶段的工具库,而不是脱离框架的“外包代工”。用着用着你会发现自己写出来的爬虫,请求调度和解析逻辑分得很清楚,改解析规则时也不用担心动到抓取流程。
2. 一套能跑通复用的融合项目骨架
2.1 环境准备与项目生成
先说明一下,下面这套骨架我在本地和服务器上都跑过,依赖很简单:Python 3.8以上版本,pip install scrapy beautifulsoup4 lxml。其中的lxml既作为Scrapy底层解析器,也让BeautifulSoup在解析HTML时有更好的容错和速度。
初始化项目用Scrapy自带命令:
scrapy startproject fusion_demo cd fusion_demo生成后的目录里最重要的是items.py、pipelines.py、settings.py和spiders/文件夹。下面的示例围绕“商品列表采集”这个通用场景设计,站点用demo-mall.com代替,实际使用时替换成你自己的目标站,并注意遵守目标站的访问规则和robots协议。
2.2 定义数据条目
在items.py里定义这次抓取的字段。字段不要定义得过于细碎,够用就好,但最好为一个字段预留“原始值”和“清洗值”两个空间,方便管道处理:
import scrapy class ProductItem(scrapy.Item): name = scrapy.Field() price = scrapy.Field() price_value = scrapy.Field() # 清洗后的纯数字价格 product_url = scrapy.Field() description_html = scrapy.Field() # 原始HTML,留给管道清理 description_text = scrapy.Field() # 清洗后的纯文本 updated_at = scrapy.Field()这样设计的原因是,很多抓取问题并不是在第一步提取时爆发的,而是在数据入库时发现“价格列带着单位”“描述里全是HTML标签”。把原始值保留到管道里做最终清洗,可以降低爬虫里写复杂解析的冲动,也让数据链路更清晰。
2.3 在爬虫里把BeautifulSoup挂进解析流程
创建一个普通爬虫文件后,核心解析逻辑直接使用BeautifulSoup:
import scrapy from bs4 import BeautifulSoup from fusion_demo.items import ProductItem class CatalogSpider(scrapy.Spider): name = "catalog" allowed_domains = ["demo-mall.com"] start_urls = ["https://demo-mall.com/catalog/phones"] def parse(self, response): # 用BeautifulSoup把整个响应文本解析成一棵DOM树 soup = BeautifulSoup(response.text, "lxml") # 定位列表里的每一个商品卡片 product_nodes = soup.select("div.product-card") for node in product_nodes: item = ProductItem() title_tag = node.select_one("h2.product-title a") price_tag = node.select_one("span.product-price") desc_tag = node.select_one("div.product-desc") item["name"] = title_tag.get_text(strip=True) if title_tag else "" # 先保留原始价格字符串,如 "¥ 299.00" item["price"] = price_tag.get_text(strip=True) if price_tag else "" item["product_url"] = response.urljoin( title_tag.get("href") ) if title_tag and title_tag.get("href") else "" # 描述字段保留HTML,纯文本清理放到管道 item["description_html"] = str(desc_tag) if desc_tag else "" item["updated_at"] = response.headers.get("Date", "").decode("utf-8", "ignore").strip() yield item # 翻页继续抓取 next_tag = soup.select_one("a.next-page") if next_tag and next_tag.get("href"): next_url = response.urljoin(next_tag.get("href")) yield scrapy.Request(next_url, callback=self.parse)这里有个容易被忽略的细节:BeautifulSoup解析用的response.text,是Scrapy下载器根据响应编码处理后的文本,已经做过字符集判断,不需要你手动再解一遍字节。如果你用response.body,那就得自己处理编码,反而容易埋坑。
用select_one和select的好处是,选择器写法和前端CSS选择器几乎一致,团队成员接手时基本不需要额外学习。而response.xpath写法虽然也强,但遇到多层嵌套时,那串轴的复杂度会让维护者皱眉。
2.4 用Pipeline把脏数据处理干净
爬虫里做初步提取,管道里做二次清洗,这个分工我建议严格执行。比如商品描述字段存的是HTML片段,可以直接在管道里交给BeautifulSoup:
from bs4 import BeautifulSoup import re class HtmlCleanPipeline: """把HTML字段转成纯文本,并清洗价格字段""" def process_item(self, item, spider): # 描述字段:如果存在HTML,就解析成纯文本 if item.get("description_html"): soup = BeautifulSoup(item["description_html"], "lxml") item["description_text"] = soup.get_text(" ", strip=True) # 如果纯文本过短,说明描述主要靠图片,保留空串也无妨 if len(item["description_text"]) < 5: item["description_text"] = "" else: item["description_text"] = "" # 价格字段:去掉货币符号、空格和千分位逗号 raw_price = item.get("price", "") match = re.search(r"(\d+[.,]?\d*)", raw_price.replace(",", "")) item["price_value"] = float(match.group(1)) if match else 0.0 return item价格清洗是爬虫里最常见的需求,但这里要注意一点:不同站点对小数点的写法不一样,有的用1.299,00,有的用1,299.00。我给出的写法只是常见欧洲格式的简化版,在你的实际项目里,必须针对目标站的格式设计正则,最好多拿几个真实页面验证,不要在清洗逻辑上想当然。
管道启用方式是修改settings.py:
ITEM_PIPELINES = { "fusion_demo.pipelines.HtmlCleanPipeline": 300, }数字300是管道的优先级,数字越小越先执行。如果你的项目里有多个管道,比如先清洗再去重再入库,建议按照这个顺序合理分配数值,避免互相依赖时顺序错乱。
2.5 启动和导出
跑这条爬虫很简单:
scrapy crawl catalog -O products.json追加方式只需要把-O改成-o,但几乎没人会在真实场景里这样做,因为重复抓同一批页面很容易造出重复数据。正式项目建议直接接数据库,或者用-O每次全量覆盖一个中间文件,再由后续任务做增量合并。
3. 动态页面与iframe:从静态HTML升级到渲染后的DOM
3.1 动态页面为什么让“下载到HTML”这一层失效
传统抓取思路是下载HTML、解析DOM、提取数据。但前后端分离的站点越来越多,很多页面的HTML初始状态只是一个外壳,真正的商品列表、用户评论、价格信息,都要等浏览器执行完JavaScript之后才渲染出来。直接用requests或Scrapy的普通下载器拿到手的,往往是一堆<script>标签和空节点。
iframe又是另一类麻烦。页面里嵌套了第三方模块,比如供应链报价、直播间状态、实时库存,这些内容位于一个独立的iframe里,父页面的HTML最多提供一个<iframe src="...">,真正的内容是在另一个文档里。BeautifulSoup再擅长解析,也拿不到“还没有被加载的文档”。
处理这类场景,思路不是“想尽办法逼BeautifulSoup去解析子框架”,而是先让Scrapy把内容真正拿到手,再交给BeautifulSoup解析。
3.2 优先找接口,其次再考虑浏览器渲染
动态页面里有一个老手都懂的“捷径”:很多异步数据其实是页面通过AJAX请求JSON接口拿到的。你打开浏览器开发者面板里的Network标签,刷新页面,找那几个返回JSON的XHR请求,往往就能直接拼出完整接口地址。如果能拿到接口,直接用Scrapy请求它,再用标准库的json解析,会比折腾任何渲染工具都快。
只有当接口地址经过加密、签名、参数混淆,或者数据必须靠浏览器执行环境才能生成时,才考虑引入无头浏览器。处理动态内容常见的选择是scrapy-playwright,它把Playwright封装成Scrapy下载器的一部分,让单个请求在下载阶段自动跑完浏览器渲染逻辑,随后再进入你的爬虫解析方法。
3.3 配置scrapy-playwright环境
安装依赖:
pip install scrapy-playwright playwright install chromium在settings.py里做基础配置:
DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"第一个配置项让Scrapy在处理请求时使用Playwright的下载器,第二个配置项是异步事件循环的配套设置。不配置这个reactor,启动时多半会报错。
3.4 实战写法:等待渲染完成再交给BeautifulSoup
下面这个爬虫会在请求阶段开启Playwright,并等待页面里出现一个指定元素,确认内容渲染完成后再进入解析:
import scrapy from bs4 import BeautifulSoup class DynamicCatalogSpider(scrapy.Spider): name = "dynamic_catalog" def start_requests(self): yield scrapy.Request( url="https://demo-mall.com/collection/latest", meta={ "playwright": True, "playwright_page_methods": [ { "method": "wait_for_selector", "args": ["div.product-card"] } ], }, callback=self.parse, ) def parse(self, response): soup = BeautifulSoup(response.text, "lxml") for node in soup.select("div.product-card"): # 此时节点已经是渲染后的真实内容 title = node.select_one("h2.product-title") if title: yield {"name": title.get_text(strip=True)}wait_for_selector的等待时间不能太短,页面在网络环境慢时可能还没渲染完就超时了;也不建议都习惯性地等待三五秒,那会把整个采集速度拖垮。我的做法是先抓一两个页面观察平均渲染时间,把等待时间设置在合理范围,同时给请求配置超时和重试,避免个别慢页面拖住整个队列。
如果页面里需要处理iframe,我的建议是:先用BeautifulSoup从外层HTML里取出iframe的src,然后把那个地址当成一个独立的请求再次抓取,而不是试图在浏览器页面对象里跨框架取数据。代码大致是这样:
def parse(self, response): soup = BeautifulSoup(response.text, "lxml") iframe_node = soup.select_one("iframe.inventory-frame") if iframe_node and iframe_node.get("src"): iframe_url = response.urljoin(iframe_node.get("src")) yield scrapy.Request(iframe_url, callback=self.parse_iframe) def parse_iframe(self, response): soup = BeautifulSoup(response.text, "lxml") stock_text = soup.select_one("div.stock-num") if stock_text: yield {"stock": stock_text.get_text(strip=True)}这种方式的好处是逻辑清晰、容易调试。如果iframe内部又依赖JavaScript二次渲染,那就在这个子请求的meta里同样开启Playwright等待渲染。本质上,BeautifulSoup始终只负责“解析已经到手的HTML”,至于HTML是不是由浏览器渲染出来的,那是Scrapy和Playwright的事。
4. 工程化才是高级爬虫的分水岭:扩展、去重、增量与分布式
4.1 先搞懂Scrapy的extensions扩展到底是什么
Scrapy的“扩展”是挂在引擎生命周期上的模块,可以理解为全局钩子。它会随爬虫进程启动,注册一系列信号回调,在请求调度、爬虫打开、关闭、管道出错等关键节点收到通知,适合做统计、监控、日志告警这类跨页面、跨请求的事情。
扩展和中间件的区别,我换个方式解释:中间件插在请求/响应的链路里,是“路上”的关卡,每个请求都要过一遍;扩展则站在路边,听全局广播,自己决定哪些事件要响应。写自定义扩展很简单,只要定义一个类,并实现from_crawler类方法:
from scrapy import signals class ScheduleMetricExtension: def __init__(self, stats): self.stats = stats @classmethod def from_crawler(cls, crawler): ext = cls(crawler.stats) crawler.signals.connect(ext.request_scheduled, signal=signals.request_scheduled) return ext def request_scheduled(self, request, spider): self.stats.inc_value("custom/request_scheduled_count")在settings.py里启用它:
EXTENSIONS = { "fusion_demo.extensions.ScheduleMetricExtension": 500, }数字表示扩展优先级,数字越小越早启动、越晚关闭。这个机制很适合做“采集进度看板”:每请求一个链接,统计计数加一;页面解析完成再减一,配合Scrapy的Stats Collector,就能在日志里看到当前还有多少待处理请求,方便判断爬虫是正常推进还是卡住了。
4.2 内置去重与断点续爬
Scrapy自带去重机制,核心是“请求指纹”。每发出一个请求,Scrapy会根据URL、请求方法、请求体等内容生成指纹,指纹重复的请求会被调度器忽略。默认的RFPDupeFilter靠这个机制避免同一页面被反复下载。
但要注意,爬虫重启时默认是“不保留上次去重状态”的:你手动终止爬虫,下次再跑,之前请求过的页面会重新进入队列。真实项目里更稳的写法是配合断点续爬,通过JOBDIR参数把调度队列和去重状态保存下来:
scrapy crawl catalog -s JOBDIR=jobs/catalog-001这样在爬虫运行过程中,会定期把队列状态、去重集合写入到指定目录;如果中途断网或手动终止,下次使用同样的JOBDIR继续启动,它会跳过已经抓过的请求,从断点接着跑。对长周期、大规模的数据采集来说,这个参数比什么花哨技巧都实在。
增量抓取常用做法是依赖一条时间线索:每次抓完一批页面,记录最新的更新时间戳,下次只抓取此时间之后的新页面。具体到代码,可以在爬虫里维护一个last_ts,在Item管道里对比updated_at字段,发现小于阈值的直接丢弃。这样可以避免全量重抓,也减少对目标站的压力。
4.3 合理限速和中间件:抓取不是越快越好
高级爬虫不等于“高并发”。服务端更关注的是单IP请求频率、请求头是否异常、访问路径是否规律。我自己的项目里,除非是明确允许高访问的API,否则都会在settings.py里做限速:
ROBOTSTXT_OBEY = True CONCURRENT_REQUESTS = 8 DOWNLOAD_DELAY = 1.0 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1.0 AUTOTHROTTLE_MAX_DELAY = 10.0AUTOTHROTTLE_ENABLED是Scrapy自带的自动限速机制,它会根据目标站响应速度动态调整延迟,请求耗时越长,间隔越大。这个机制对服务器友好,也能降低被限制的风险。我一般会把它和DOWNLOAD_DELAY配合使用,起点设为1秒,既不会太慢,也不至于对站点造成压力。
如果目标站点需要辨别你的爬虫身份,更体面的方式是自定义一个User-Agent中间件,把自己的项目名和联系方式写在UA里。这样站点管理员在日志里看到你的访问,可以知道你来自哪里、大概在做什么,比伪装成普通浏览器要好得多。爬虫技术的正确用法,始终是采集公开的、允许被访问的数据,并且以不给对方服务器造成负担为前提。
4.4 分布式扩展的基本思路
当单机并发再高也扛不住千万级页面时,自然就会想到分布式。Scrapy生态里最常用的方案是scrapy-redis,核心思路是把调度队列从本地内存搬到Redis上,多个爬虫节点共享同一个请求队列和去重集合。这样每个节点只负责下载和解析,而“下一个请求该给谁”完全由Redis队列决定。
启用方式通常是修改settings.py里的调度器和去重类:
SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"配置好之后,启动多个scrapy crawl catalog进程,每个进程都会从同一个Redis队列里取请求,不会出现两个节点重复抓同一页面的问题。但分布式绝不只是换两个配置项那么轻松,你还要考虑结果落库的并发冲突、Redis单点故障、节点间网络延迟。我的经验是,单机把抓取链路、清洗逻辑和扩展监控都跑稳了,再上分布式;如果单机都乱成一团,分布式只会把雪花变成雪崩。
回到BeautifulSoup与Scrapy的配合上:无论单机还是分布式,解析层的工作方式都不变。调度中心负责把HTML带回来,BeautifulSoup负责把有价值的信息从HTML里剥离出来。真正把一版爬虫从“能跑”提升到“能稳定跑”,靠的是对抓取节奏的把控、对管道清洗的边界划分、以及对扩展和去重机制的理解。这些工程化细节,才是高级爬虫与入门脚本之间最清晰的界线。