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', errors='ignore')这种容错处理,效果会好很多。
第二个习惯:提取数据用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(strip=True)。而用find_all()就要写成soup.find('div', class_='content').find('ul', class_='list').find_all('li')再遍历,代码量至少多了一倍。
第三个习惯:get_text(strip=True)永远是首选提取文本方式。网页源码里的换行和空格非常多,直接用.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 doesn't 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启用后面要写的 pipeline
3.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(strip=True) 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(url=url, callback=self.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(strip=True) if title_node else '' price_node = card.select_one("span.price") item['price'] = price_node.get_text(strip=True) 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( url=response.urljoin(link.get('href')), callback=self.parse_detail )第二步,请求详情页。这一步要带上playwright=True的 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( url=response.urljoin(iframe_src), callback=self.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再做去重 pipeline:
from 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 doesn't 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 里跑全量。这个习惯帮我省了非常多的调试时间,你也可以用起来。
最后说一句真心的建议:不要盲目追求多先进的框架,先把最基本的"请求 → 渲染 → 解析 → 清洗"这条链路理解透。所有高级功能——代理池、分布式爬取、断点续爬——都是在这条链路上加花活。链路稳了,加什么花活都有意义;链路不稳,花活越多越容易炸。