BeautifulSoup + Scrapy + Playwright:构建高效爬虫流水线
1. 内容整体设计与思路拆解1.1 为什么要融合 BeautifulSoup 和 Scrapy先说一个很多人容易走偏的点一提到写爬虫新手的第一反应就是选一个框架干到底。有人死磕 Scrapy 的 Selector有人把 BeautifulSoup 当万能药只要碰到解析就soup.find_all()一把梭。实际上真正效率高的爬虫项目往往是各取所长——Scrapy 管调度、并发、去重、下载BeautifulSoup 管页面解析里的脏活累活两者结合起来才是干活的样子。BeautifulSoup 的优势在于它的 DOM 树解析方式非常直观尤其适合处理那些结构不标准、嵌套混乱、甚至标签不闭合的 HTML。而 Scrapy 的 Selector 基于 lxml性能强但语法上更像是 XPath 和 CSS 的混合体遇到复杂的嵌套结构时写起来很绕。我在实际项目里最常用的组合是Scrapy 负责下载和调度拿到响应后把response.text交给 BeautifulSoup 解析既享受了 Scrapy 的高并发又保住了 BeautifulSoup 的灵活解析能力两者并不冲突。再加一层原因现代网站越来越爱用动态渲染很多数据藏在 JavaScript 生成的 DOM 里直接抓 HTML 是空的。所以现在的高级爬虫技术里Scrapy 要往上接 Playwright往下接 BeautifulSoup 做终级解析三者是一条链路上的三个环节。另外Scrapy 的中间件机制和 Pipeline 机制特别适合做数据的后处理——清洗、去重、入库而这些恰恰是 BeautifulSoup 解析完之后的下一步动作。把解析器和框架拆开看它们各管一段合在一起才是一条完整的流水线抓取 → 渲染 → 解析 → 清洗 → 存储。1.2 整体架构一只爬虫的三层分工我习惯把爬虫项目拆成三层来看这样不管是用 Scrapy 还是别的什么框架思路都是通的。第一层是采集层负责把网页内容拿回来。这一层主要看并发、反爬、代理、Cookie 管理Scrapy 自带的异步引擎在这里发挥巨大作用。第二层是渲染层针对那些数据靠 JS 动态加载的页面用 Playwright 或 Selenium 把浏览器跑起来等页面真实渲染完成后再取 DOM。第三层是解析层把拿到的 HTML 字符串变成结构化数据这一层就是我常说的BeautifulSoup 的天下。有人会问能不能直接用 Playwright 从头写到尾连 Scrapy 都省了能但没必要。Playwright 的并发能力远不如 Scrapy 的异步引擎如果 1000 个 URL 要抓Playwright 开 10 个浏览器上下文就已经很吃力了而 Scrapy 加 Playwright 中间件可以做到页面动态渲染 链接异步调度两不误。再说为什么解析层不用 Scrapy 自带 Selector 而是接 BeautifulSoup。原因很实在Scrapy 的 XPath 表达式在遇到 HTML 实体乱码、属性值带引号、标签嵌套异常时经常直接报错或者提取为空。BeautifulSoup 对这类烂页面的容忍度高得多解析器够傻傻到无论你怎么写错它都能帮你猜出个大概意思。简单搭一个框架图Requests/Scrapy 异步下载 → 判断是否需要 Playwright 渲染 → 渲染完成取 HTML → BeautifulSoup 解析 → 数据清洗 → 落库。这套链路我跑了三年多稳定性非常高。2. 核心细节解析与实操要点2.1 BeautifulSoup 解析的三个关键习惯先说解析。很多教程一上来就讲find_all()的各种参数但实际工作中拼的是处理脏数据的能力。总结三个习惯第一个习惯指定解析器时不要用默认的html.parser。默认解析器在处理不规范的表格标签时会出现层级错乱我统一用lxml。注意lxml需要单独安装但性能比默认解析器快好几倍尤其在处理几千个节点的页面时差距很明显。有些页面上有中文编码问题用lxml解析后再配合soup.encode(gbk, errorsignore)这种容错处理效果会好很多。第二个习惯提取数据用select_one()优先而不是find_all()。select_one()的 CSS 选择器写起来比嵌套的find_all()链条简洁太多。比如从div.content ul.list li.items里取标题直接写soup.select_one(div.content ul.list li.item h3).get_text(stripTrue)。而用find_all()就要写成soup.find(div, class_content).find(ul, class_list).find_all(li)再遍历代码量至少多了一倍。第三个习惯get_text(stripTrue)永远是首选提取文本方式。网页源码里的换行和空格非常多直接用.text属性会带出一堆没用的空白字符后续清洗数据时烦死你。另外提一个容易踩的坑不要直接修改 BeautifulSoup 对象的属性来删除某些节点。我在早期做过一件事用tag.extract()移除广告节点后继续解析结果发现 extract 之后页面里的父节点引用会丢失导致后面的遍历直接报错。正确的做法是先用copy()复制一份文档树在副本上操作原树留着备用。2.2 Scrapy 项目结构里的隐藏管道Scrapy 的项目结构看起来很简单——spiders/、items.py、pipelines.py、middlewares.py但很多人的项目写得像一坨在 spiders 里全干完的脚本。我这里强调一个容易被忽略的点Pipeline 不只是存数据库的出口它还是数据清洗和校验的关卡。举个实际例子我需要抓一个电商网站的价格信息页面上的价格文本是¥1,299.00这样的字符串。如果在 spider 里直接转成 float需要到处处理¥和,。我通常的做法是在 pipeline 里统一做标准化。process_item()里先判断item[price]的类型如果它是字符串就先replace(¥, ).replace(,, )再float()如果已经是数字就直接通过。这样做的好处是spider 只负责把页面上的东西抠出来pipeline 负责把它变成能用的样子各干各的活。Pipeline 还常被用来做去重。Scrapy 自带的DupeFilter只能去重 URL但业务上去重通常需要去掉标题和内容完全一样的记录。我在 pipeline 里维护一个 Redis 集合每来一条 item 就把标题内容摘要的哈希值放进去如果放不进去说明重复了直接raise DropItem丢弃。还有Pipeline 是有优先级和顺序的。比如有的 pipeline 负责清洗有的负责存库那清洗的优先级必须高于存库的。在settings.py里配置ITEM_PIPELINES时用数字控制顺序数值越小越先执行。我通常会把清洗类 pipeline 放在 100 的档位去重类放在 200存库类放在 300。这样就算中途丢弃也不会执行到存库那一步。2.3 动态页面与 iframe 的处理思路这个点是最新爬虫技术的核心难点。现在很多网站的数据不是直接写在 HTML 里的而是通过 JavaScript 异步加载后再渲染出来的。你直接用requests.get()拿到的 HTML 里只有一堆 script 标签和空壳 div。这时候思路要换一下。我处理动态页面的顺序是这样的先用普通请求拿一次页面看看数据在不在 HTML 源码里或者接口里如果不在再考虑启用 Playwright 做浏览器渲染。不要一上来就开浏览器开销太大。很多时候数据藏在XHR响应里你只需要找到那个.json接口直接请求它比渲染页面快十倍。如果确实必须渲染Scrapy 可以接入scrapy-playwright中间件。response scrapy.Request(url, meta{playwright: True})这种写法可以在 Scrapy 的下载器里直接驱动 Chromium 渲染页面。渲染完成后response.text就是执行完 JavaScript 之后的完整 DOM这时候再喂给 BeautifulSoup一切照旧。iframe 是另一个更隐蔽的坑。很多第三方登录框、数据表格、甚至整个内容区都在 iframe 里父页面的选择器根本够不着它。用 BeautifulSoup 解析父页面时只能看到一个iframe标签没有内容。处理方案是先用soup.find(iframe)拿到src属性再把src当作一个新 URL 去请求。比如某些嵌套极深的页面一层 iframe 套一层 iframe需要递归处理。我的习惯是写一个小函数def extract_iframe_src(soup): iframe_tag soup.find(iframe) if iframe_tag and iframe_tag.get(src): return urljoin(base_url, iframe_tag[src]) return None拿到新的 src 之后判断它是不是同域再决定是继续走 Scrapy 还是再走一遍 Playwright。3. 实操过程与核心环节实现3.1 环境准备与项目初始化动手前先把环境搭好。我用的是一个虚拟环境Python 3.10装这些库pip install scrapy beautifulsoup4 lxml scrapy-playwright playwright playwright install chromium注意playwright install chromium这步不能省不然启动浏览器会报错。我之前在一台新服务器上跑项目忘了装浏览器内核结果playwright一直报Executable doesnt exist排查了半天。然后初始化 Scrapy 项目scrapy startproject my_crawler cd my_crawler scrapy genspider example example.com项目结构生成后我会先把settings.py里几个关键配置改掉ROBOTSTXT_OBEY设成False个人学习场景下可以关闭如果是正式项目还是要遵守协议DOWNLOAD_DELAY根据目标网站的承受能力来设一般0.5到1.5秒之间CONCURRENT_REQUESTS设成8到16之间太高容易被封 IPITEM_PIPELINES启用后面要写的 pipeline3.2 在 Scrapy 中优雅接入 BeautifulSoup先说怎么在 Scrapy 里用 BeautifulSoup。很多人会写import bs4 soup bs4.BeautifulSoup(response.text, lxml)这样能用但不够优雅。我顶一个自定义的工具函数把所有解析逻辑封装起来from bs4 import BeautifulSoup def parse_html(html_text): return BeautifulSoup(html_text, lxml) def extract_text(element, selector): if element is None: return target element.select_one(selector) return target.get_text(stripTrue) if target else 然后在spider的parse()方法里调用import scrapy from bs4 import BeautifulSoup class ExampleSpider(scrapy.Spider): name example_spider def start_requests(self): urls [https://example.com/list/page/1] for url in urls: yield scrapy.Request(urlurl, callbackself.parse) def parse(self, response): soup BeautifulSoup(response.text, lxml) product_cards soup.select(div.product-item) for card in product_cards: item { } title_node card.select_one(h2.product-title a) item[title] title_node.get_text(stripTrue) if title_node else price_node card.select_one(span.price) item[price] price_node.get_text(stripTrue) if price_node else image_node card.select_one(img.product-img) item[image] image_node.get(src) if image_node else yield item这种方式的好处是每个字段的空值兜底处理都在一行里完成写起来顺手跑起来稳定。3.3 处理动态页面与嵌套 iframe 的完整示例我拿一个实际场景说某个数据平台列表页是纯静态的但是点进去的详情页是一个 iframe 嵌着的动态页面而且 iframe 里的数据又依赖另一个接口异步加载。完整处理流程如下Spider 里第一步先请求列表页def parse(self, response): soup BeautifulSoup(response.text, lxml) detail_links soup.select(a.detail-link) for link in detail_links: yield scrapy.Request( urlresponse.urljoin(link.get(href)), callbackself.parse_detail )第二步请求详情页。这一步要带上playwrightTrue的 meta表示这个页需要浏览器渲染def parse_detail(self, response): # 这个页面的 HTML 里只有一个空 iframe soup BeautifulSoup(response.text, lxml) iframe_src self.extract_iframe_src(soup) if iframe_src: yield scrapy.Request( urlresponse.urljoin(iframe_src), callbackself.parse_iframe_content, meta{playwright: True, playwright_include_page: True} ) else: self.logger.warning(No iframe found, skip {}.format(response.url))第三步在 iframe 对应的响应里用response.text拿到渲染后的完整 HTML再交给 BeautifulSoup 提取def parse_iframe_content(self, response): soup BeautifulSoup(response.text, lxml) # 现在这个 soup 里才有真正的内容 title extract_text(soup, div.report-title) report_body extract_text(soup, div.report-body) yield { title: title, body: report_body, source_url: response.url }我跑了几个真实页面这种静态列表 动态 iframe 详情组合模式在新闻门户、数据平台、企查查类网站上非常常见用这套流程基本能通吃。3.4 Pipeline 里的数据清洗与入库数据从 spider 里 yield 出来之后Pipeline 开始工作。我写一个清洗类 pipeline把原始文本变成结构化的行数据。import re class CleanItemPipeline: def process_item(self, item, spider): # 清洗标题中的空白字符 if title in item: item[title] re.sub(r\s, , item[title]).strip() # 把价格字符串 ¥1,299.00 转为 float if price in item and isinstance(item[price], str): price_clean item[price].replace(¥, ).replace(,, ) item[price] float(price_clean) # 把空字段统一成 None for key in list(item.keys()): if item[key] : item[key] None return item再做去重 pipelinefrom scrapy.exceptions import DropItem import hashlib class DuplicatePipeline: def __init__(self): self.seen set() def process_item(self, item, spider): # 基于标题 内容摘要做去重 sig {}:{}.format(item.get(title), item.get(body, )) md5 hashlib.md5(sig.encode(utf-8)).hexdigest() if md5 in self.seen: raise DropItem(Duplicated item: {}.format(item.get(title))) self.seen.add(md5) return itemPipeline 要记得在settings.py里启用顺序上清洗在前、去重在后ITEM_PIPELINES { my_crawler.pipelines.CleanItemPipeline: 100, my_crawler.pipelines.DuplicatePipeline: 200, }这套 Pipeline 的经验是不要在一处把所有事情都做完。清洗归清洗去重归去重存库归存库每个 pipeline 只做一件事方便调试和复用。4. 常见问题与排查技巧实录4.1 BeautifulSoup 解析结果为空的排查症状页面明明有数据但soup.select_one()返回None。原因优先级排列页面是动态加载的请求到的 HTML 里根本没有目标节点。这类问题占比最高建议先打印response.text[:500]看看到底拿到了什么。目标内容在 iframe 或 frame 里解析的层级不对。选择器写错比如 class 名里有空格或者动态变化的随机值。有些网站每次刷新 class 都变常见的反爬手段之一。网站返回的是 gzip 压缩内容但 Scrapy 没有正确解压这种情况较少见但response.text会出现乱码。排查方法在parse()里临时打印响应内容比对目标文本是否存在。不存在就是动态加载或者 iframe存在就是选择器问题。按这个思路走一遍能省下至少一半的调试时间。4.2 Scrapy 接 Playwright 时常见的崩溃问题问题1缺少浏览器内核。报错信息通常长这样Error: browserType.launch: Executable doesnt exist at ...。解决方式就是重新执行playwright install chromium。问题2内存占用过高。如果同时开几十个 Playwright 上下文服务器的内存很容易被吃满。我的做法是给scrapy-playwright设置最大并发页面数PLAYWRIGHT_MAX_PAGES_PER_CONTEXT 3 PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT 30000问题3页面渲染超时。一些重型页面要加载几十个资源30 秒内完不成。将超时时间调高到 60 秒同时给渲染环节加一个重试机制重试两次还失败就跳过这个 URL 记录日志。4.3 反爬与请求头处理实战中总会撞上反爬。最高频的三种缺少浏览器 UA 或 UA 不一致被识别出来。解决中间件里随机换 UA列表里放 20 个常见的浏览器 UA 字符串。请求频率过高IP 被临时限制。解决合理控制并发和延时加入代理池。响应内容被替换成验证码或一个访问异常的 HTML 页面。解决写一个检查函数检测页面中是否出现特征关键词比如验证、异常、captcha出现了就直接停止对该域名的请求。我自用的一个小技巧是response.url、response.status、len(response.text)这三样东西在解析前先打印一遍一旦遇到数据为空的异常排查时能快速定位是没抓到还是没解析出来。4.4 一个完整的百条 URL 实战数据量参考为了验证这套组合方案的稳定性我跑过一个中型列表网站共 120 条详情页 URL其中 46 条是动态 iframe 页面。配置并发请求数8下载延迟0.5 秒每条动态页面启用 Playwright 渲染超时 30 秒最终耗时约 4 分钟成功抓取 118 条2 条因为目标页面在抓取时接口异常导致内容为空被跳过。这就足够日常使用。如果追求更高的成功率可以在 Pipeline 里加失败重试或者用 Redis 做断点续爬但说句实在话大部分业务场景这个成功率已经够用了。5. 后续扩展与个人体会聊到这儿我想把这套方案的扩展方向也说一下。你如果已经掌握了Scrapy 管抓取、Playwright 管渲染、BeautifulSoup 管解析这条链路那么接下来可以顺手把itemloaders学起来它可以把解析和清洗的动作做成模板化配置。另外一个很值得尝试的方向是数据落库存到 ClickHouse 或者 StarRocks 这类列式数据库里配合爬虫的批量写入查询分析体验完全不一样。我个人实际使用这套组合以来的最大感受是爬虫项目的稳定性不是靠某一个框架有多强而是靠每层工具在各自的环节上把守住边界。BeautifulSoup 负责找数据Scrapy 负责运数据Playwright 负责把浏览器里的数据逼出来三者各司其职组合起来几乎无敌。还有一个小技巧没提调试的时候不要每次都重新发起真实请求把页面 HTML 保存到本地文件直接BeautifulSoup(open(page.html), lxml)来开发解析逻辑。这样不但速度快还不至于频繁请求目标网站导致被封。等解析代码稳了再挂回到 Scrapy 里跑全量。这个习惯帮我省了非常多的调试时间你也可以用起来。最后说一句真心的建议不要盲目追求多先进的框架先把最基本的请求 → 渲染 → 解析 → 清洗这条链路理解透。所有高级功能——代理池、分布式爬取、断点续爬——都是在这条链路上加花活。链路稳了加什么花活都有意义链路不稳花活越多越容易炸。