Scrapy多站点新闻爬虫实战:网易搜狐凤凰澎湃抓取与增量更新
简介:这是一份基于Scrapy框架的Python新闻爬虫项目源码,面向具备一定Python基础、希望学习分布式爬虫与数据采集的开发者。项目针对网易、搜狐、凤凰和澎湃四家新闻站点,实现文章标题、正文、评论及发布时间的抓取与本地保存,并支持定时运行以完成每日更新,适合用作爬虫课程设计或数据采集练手案例。压缩包共26个文件,以18个py脚本为核心,涵盖spiders爬虫逻辑、pipelines数据管道、middlewares中间件与items字段定义,另含3个xml配置、1个cfg及1个md说明文档,整体约26KB,结构清晰便于按模块阅读。资源已有495人学习下载。通过阅读源码,可理解Scrapy从Scheduler调度、Downloader下载到Spider解析、Item Pipeline存储的完整流程,并掌握多站点适配、评论抓取与定时更新等实用思路。
1. 从四个门户站抓新闻:这套 Scrapy 爬虫到底能扛什么活
做舆情监控或者新闻聚合的朋友大概率都遇到过这个场景:老板丢过来一句“把网易、搜狐、凤凰、澎湃的新闻每天扒下来,标题正文评论时间都要”,然后你打开四个网站一看,结构完全不一样,有的评论是异步加载,有的正文藏在 iframe 里,有的列表页翻着翻着就给你来个验证码。这时候如果还想着用 requests + BeautifulSoup 一把梭,基本就是给自己挖坑。这套基于 Scrapy 框架的 Python 新闻爬虫,就是冲着这个场景来的——它把四个门户站的抓取逻辑拆成独立的 spider,用 Scrapy 的调度器统一管理请求队列,抓下来的标题、正文、评论、发布时间通过 Item Pipeline 落盘保存。适合谁用?一是需要批量采集新闻语料做 NLP 训练或舆情分析的人,二是想拿一个真实多站点爬虫项目练手 Scrapy 的开发者。它不解决“绕过所有反爬”这种玄学问题,但能把多站点异构页面的抓取流程给你搭明白。
2. Scrapy 的调度链路:为什么这套架构能撑住 10 万页
2.1 从 Scheduler 到 Pipeline 的数据流拆解
很多人用 Scrapy 就是写个 spider 就完事,但其实这套框架真正值钱的地方在于它把“请求调度”和“数据落地”做了解耦。整个链路是这样的:初始 URL 进 Scheduler,Scheduler 按优先级和去重规则把请求交给 Downloader,Downloader 下载完页面丢给 Spider 解析。Spider 解析出来的结果分两路走——需要继续抓的链接通过 Middleware 传回 Scheduler 排队,需要保存的数据封装成 Item 送进 Item Pipeline 做清洗和存储。
这个设计对新闻爬虫来说特别关键。因为新闻站点有个特点:列表页要翻很多页,详情页要抓正文和评论,评论还经常分页。如果不用 Scrapy 的调度机制,你得自己维护一个待抓队列,还得处理去重、重试、并发控制,写到最后就是一个残缺版的 Scrapy。这套项目直接用了框架自带的调度器,你只需要在 spider 里定义好start_urls和解析规则,剩下的排队、去重、失败重试它帮你管了。
项目结构里scrapyspider是框架主目录,spiders放核心抓取逻辑,debug_163.py、debug_sohu.py、debug_ifeng.py、debug_pengpai.py是四个站点的运行入口。这种拆法有个好处:调试单个站点的时候不用动其他站的代码,哪个站改版了就改对应的 spider 和 debug 文件,互不影响。
2.2 四个站点的 spider 拆分策略
为什么不是一个 spider 打天下?因为网易、搜狐、凤凰、澎湃的页面结构差异太大了。网易新闻的正文通常在div.post_body里,评论走的是异步接口;搜狐的正文有时候嵌在 iframe 里;凤凰网的列表页和详情页 URL 规则不一样;澎湃的页面相对规整但评论加载方式又不同。如果硬塞进一个 spider,解析函数里全是 if-else 判断域名,维护起来就是灾难。
拆成四个 spider 之后,每个 spider 只管自己的站点,parse方法里写该站的 XPath 或 CSS 选择器,parse_detail处理正文和评论。常见做法是在items.py里定义好统一的字段结构,比如title、content、comments、publish_time、url、source,这样不管哪个站抓下来的数据,进 Pipeline 之后格式是一致的,后面存 CSV 或者入库都不用再改。
# items.py 定义统一的数据结构 import scrapy class NewsspiderItem(scrapy.Item): title = scrapy.Field() # 新闻标题 content = scrapy.Field() # 正文内容 comments = scrapy.Field() # 评论列表 publish_time = scrapy.Field() # 发布时间 url = scrapy.Field() # 原文链接 source = scrapy.Field() # 来源站点标识字段定义看着简单,但这是多站点爬虫能统一落地的前提。source字段尤其重要,后面做数据筛选或者分站点统计的时候全靠它。comments用 Field 存列表,Pipeline 里再决定是拼成字符串还是拆成多条记录。
2.3 评论抓取的两种常见路径
新闻评论是这套项目里最容易翻车的部分。四个站点里,有的评论直接渲染在 HTML 里,有的走 XHR 异步接口,有的需要滚动加载。Scrapy 本身不执行 JavaScript,所以遇到异步加载的评论,常见做法有两种:一是找到评论的 API 接口,直接用 Scrapy 发请求拿 JSON;二是配合 Playwright 或 Selenium 做动态渲染。
这个项目里debug_qq.py的存在说明评论抓取可能涉及额外的请求逻辑。我一般会先打开浏览器开发者工具,切到 Network 面板,看评论加载时有没有独立的 XHR 请求。如果有,就把那个接口的 URL、请求头、参数抄下来,在 spider 里用scrapy.Request单独发一次。注意接口通常需要带上Referer和User-Agent,否则返回空数据。
# 在 spider 中追加评论请求的典型写法 def parse_detail(self, response): item = NewsspiderItem() item['title'] = response.xpath('//h1/text()').get() item['content'] = ''.join(response.xpath('//div[@class="post_body"]//text()').getall()) item['publish_time'] = response.xpath('//span[@class="time"]/text()').get() item['url'] = response.url item['source'] = 'netease' # 构造评论接口请求 comment_api = f"https://example.com/comment/api?id={news_id}&page=1" yield scrapy.Request( comment_api, callback=self.parse_comments, meta={'item': item}, headers={'Referer': response.url} # 评论接口通常校验 Referer ) def parse_comments(self, response): item = response.meta['item'] data = response.json() item['comments'] = [c['content'] for c in data.get('comments', [])] yield item这里用meta把 item 从详情页传到评论解析函数,是 Scrapy 里跨请求传递数据的标准做法。headers里补Referer是因为很多站点的评论接口会校验来源页,不带就直接返回 403。评论分页的话,在parse_comments里判断有没有下一页,有就继续 yield 新的 Request,直到抓完。
3. 跑起来才算数:环境配置与四站调试实操
3.1 环境依赖与项目初始化
拿到NewsSpider-master.zip之后,先别急着跑。Scrapy 项目对环境有要求,Python 版本建议 3.7 以上,pip 装好之后先确认 Scrapy 能正常导入。项目里带了settings.pyc,说明原始环境可能编译过,但.pyc文件在不同 Python 版本下不一定兼容,建议以settings.py为准。
# 创建虚拟环境并安装依赖 python -m venv newsenv source newsenv/bin/activate # Windows 用 newsenv\Scripts\activate pip install scrapy pip install requests # 部分 debug 脚本可能用到 # 验证 Scrapy 安装 scrapy version装完之后进项目根目录,看到scrapy.cfg就说明位置对了。scrapy.cfg里定义了 settings 模块的路径,Scrapy 靠它找到项目配置。如果这个文件缺失或者路径写错,运行时会报ModuleNotFoundError。
# scrapy.cfg 典型内容 [settings] default = scrapyspider.settings [deploy] project = scrapyspiderdefault指向scrapyspider.settings,意味着 Scrapy 会去scrapyspider/settings.py里读配置。如果你改了项目目录名,这里必须同步改,否则跑不起来。
3.2 settings.py 里必须调的五个参数
settings.py是 Scrapy 的行为控制中心,默认配置能跑但效率不高,做新闻批量抓取至少要调这几个:
| 参数 | 默认值 | 建议值 | 作用 |
|---|---|---|---|
DOWNLOAD_DELAY | 0 | 1~3 | 请求间隔,防止被封 |
CONCURRENT_REQUESTS | 16 | 8~16 | 全局并发数 |
CONCURRENT_REQUESTS_PER_DOMAIN | 8 | 4~8 | 单域名并发 |
RETRY_TIMES | 2 | 3 | 失败重试次数 |
DOWNLOAD_TIMEOUT | 180 | 30 | 超时秒数 |
DOWNLOAD_DELAY设成 0 的时候跑得飞快,但四个门户站都有频率检测,跑不了几百页就给你返回 403 或者验证码页。我一般设 1.5 秒起步,观察一段时间没有异常再往下调。CONCURRENT_REQUESTS_PER_DOMAIN比全局并发更关键,因为你是对同一批站点持续抓取,单域名并发太高等于告诉对方“我在爬你”。
# settings.py 关键配置片段 DOWNLOAD_DELAY = 1.5 CONCURRENT_REQUESTS = 16 CONCURRENT_REQUESTS_PER_DOMAIN = 4 RETRY_TIMES = 3 DOWNLOAD_TIMEOUT = 30 # 开启 AutoThrottle 让 Scrapy 自动调节速率 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 10 AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0AUTOTHROTTLE是 Scrapy 自带的自动限速扩展,它会根据服务器的响应时间动态调整请求间隔。开了这个之后,DOWNLOAD_DELAY相当于一个下限,实际间隔由 AutoThrottle 根据延迟反馈来定。对新闻站点这种响应时间波动大的目标,比固定延迟更稳。
3.3 四个 debug 脚本的运行方式
项目里的debug_163.py、debug_sohu.py、debug_ifeng.py、debug_pengpai.py是四个站点的独立运行入口。这种设计的好处是你可以单独调试某个站,不用把四个站都跑一遍。常见做法是在每个 debug 脚本里用CrawlerProcess启动对应的 spider:
# debug_163.py 典型结构 from scrapy.crawler import CrawlerProcess from scrapy.utils.project import get_project_settings from scrapyspider.spiders.netease import NeteaseSpider process = CrawlerProcess(get_project_settings()) process.crawl(NeteaseSpider) process.start()get_project_settings()会自动读取scrapy.cfg里指定的 settings 模块,所以不用手动指定配置文件。跑的时候直接python debug_163.py就行。注意 spider 的类名要和spiders目录下的实际类名对上,改过代码之后如果报ImportError,先检查导入路径。
四个站建议逐个跑通再一起上。先跑网易,看能不能正常抓到列表页和详情页;再跑搜狐,重点看 iframe 里的正文有没有抓到;凤凰和澎湃类似。每个站跑通之后,观察日志里的item_scraped_count,确认数据确实进了 Pipeline。
3.4 Pipeline 落盘与 csv_process.py 的角色
抓下来的数据最终要落盘。Scrapy 默认支持Feed Exporter,可以在 settings 里配置输出格式和路径,但项目里单独写了pipelines.py和csv_process.py,说明对数据做了额外处理。
# pipelines.py 典型的数据清洗逻辑 import csv from datetime import datetime class NewsspiderPipeline: def open_spider(self, spider): self.file = open(f'news_{spider.name}_{datetime.now().strftime("%Y%m%d")}.csv', 'w', newline='', encoding='utf-8') self.writer = csv.writer(self.file) self.writer.writerow(['title', 'content', 'comments', 'publish_time', 'url', 'source']) def process_item(self, item, spider): # 清洗正文中的多余空白 item['content'] = ' '.join(item['content'].split()) if item.get('content') else '' # 评论列表拼成字符串 item['comments'] = ' | '.join(item['comments']) if item.get('comments') else '' self.writer.writerow([item.get(k, '') for k in ['title', 'content', 'comments', 'publish_time', 'url', 'source']]) return item def close_spider(self, spider): self.file.close()open_spider在爬虫启动时打开文件并写表头,process_item每收到一个 item 就写一行,close_spider在结束时关闭文件。按 spider 名字和日期分文件,避免多个站的数据混在一起。csv_process.py可能是对生成的 CSV 做二次处理,比如合并多天数据、去重或者格式转换,具体逻辑要看文件内容,但思路一般是读入原始 CSV,按 URL 去重,再输出清洗后的版本。
注意:CSV 写入时
newline=''不能省,否则 Windows 下会出现空行。编码统一用utf-8,不然中文会乱码。
4. 避坑排查:多站点爬虫最容易翻车的五个地方
4.1 现象:网易新闻正文抓下来是空的
原因:网易的正文容器 class 名改过版本,旧代码里的 XPath 匹配不到。另外部分网易新闻的正文在div.post_body里,但页面结构在不同频道下不一致。
解决:先用浏览器打开目标页面,右键检查正文元素,确认当前的 class 或 id。XPath 尽量用相对路径加多个备选,比如//div[contains(@class,"post_body")]//text(),contains比精确匹配更抗改版。如果还是空,检查是不是被重定向到了移动版页面,移动版的 DOM 结构完全不同。
4.2 现象:搜狐的正文抓不到,返回的 HTML 里没有内容
原因:搜狐部分新闻页的正文嵌在 iframe 里,Scrapy 默认只下载主文档,不处理 iframe 内容。
解决:在开发者工具里找到 iframe 的src属性,那是一个独立 URL,用scrapy.Request单独请求它,再在回调里解析正文。如果 iframe 的 src 是 JavaScript 动态生成的,就需要上 Playwright 做渲染,Scrapy 配合scrapy-playwright可以处理这种场景。
4.3 现象:跑了几百页之后开始返回 403 或验证码页
原因:请求频率过高触发了站点的反爬机制。即使设了DOWNLOAD_DELAY,如果并发数太高,单位时间内的请求量仍然超标。
解决:先把CONCURRENT_REQUESTS_PER_DOMAIN降到 2,DOWNLOAD_DELAY提到 3 秒,观察是否恢复。同时检查User-Agent是否每次请求都一样,Scrapy 默认的 UA 字符串很容易被识别。在middlewares.py里加一个随机 UA 中间件,每次请求从 UA 池里随机选一个。
# middlewares.py 随机 UA 中间件 import random class RandomUserAgentMiddleware: def __init__(self): self.agents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', ] def process_request(self, request, spider): request.headers['User-Agent'] = random.choice(self.agents)写完中间件之后要在settings.py里启用:DOWNLOADER_MIDDLEWARES = {'scrapyspider.middlewares.RandomUserAgentMiddleware': 543}。数字是优先级,543 是常见位置,比默认的 UserAgentMiddleware 靠后执行。
4.4 现象:评论数据抓下来是空的或者只有第一条
原因:评论接口需要分页请求,只抓了第一页;或者接口返回的 JSON 结构和预期不一致,字段名对不上。
解决:先在浏览器里手动翻评论,观察每次翻页时 Network 面板里新增的请求,把 URL 模板和参数规律记下来。然后在parse_comments里根据返回数据里的total或has_more字段判断是否继续翻页。字段名对不上的话,把接口返回的 JSON 打印出来看一眼,别靠猜。
4.5 现象:定时任务跑完发现数据没更新
原因:Scrapy 的JOBDIR去重机制会记住已经抓过的 URL,第二次跑的时候如果 URL 没变,会被直接过滤掉,导致新数据抓不到。
解决:如果要做每日增量更新,不能依赖 Scrapy 的默认去重。常见做法是在 Pipeline 里根据publish_time做过滤,只保留当天或最近一天的数据;或者在启动前清空JOBDIR目录。另外检查DUPEFILTER_CLASS的配置,默认的RFPDupeFilter是基于 URL 的,如果新闻 URL 带时间戳参数,去重可能失效。
5. 增量更新与数据验证:让爬虫每天自己跑起来
5.1 用 cron 或计划任务实现每日抓取
项目需求里写了“每个新闻网页及其评论能在 1 天内更新”,这意味着爬虫需要定时运行。Linux 下用 cron,Windows 下用任务计划程序。核心思路是每天固定时间启动一次爬虫,跑完之后把数据追加到当天的文件里。
# Linux crontab 示例:每天凌晨 2 点跑四个站 0 2 * * * cd /path/to/NewsSpider-master && /path/to/newsenv/bin/python debug_163.py >> logs/163.log 2>&1 30 2 * * * cd /path/to/NewsSpider-master && /path/to/newsenv/bin/python debug_sohu.py >> logs/sohu.log 2>&1 0 3 * * * cd /path/to/NewsSpider-master && /path/to/newsenv/bin/python debug_ifeng.py >> logs/ifeng.log 2>&1 30 3 * * * cd /path/to/NewsSpider-master && /path/to/newsenv/bin/python debug_pengpai.py >> logs/pengpai.log 2>&1四个站错开时间跑,避免同时占用带宽和触发风控。日志重定向到文件方便排查,2>&1把错误输出也写进去。跑之前确认虚拟环境的 Python 路径写对了,cron 的环境变量和登录 shell 不一样,用绝对路径最稳。
5.2 数据完整性校验的三种手段
爬虫跑完不等于数据没问题。我一般会做三层校验:第一层看日志里的item_scraped_count,和预期数量对比,差太多说明有页面没抓到;第二层抽查 CSV 文件,看标题、正文、时间字段有没有大面积空值;第三层用脚本统计每个站点的数据量和字段完整率。
# 数据校验脚本:统计各站点数据量和空值率 import csv from collections import defaultdict stats = defaultdict(lambda: {'total': 0, 'empty_content': 0, 'empty_time': 0}) with open('news_netease_20250101.csv', 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: source = row['source'] stats[source]['total'] += 1 if not row['content'].strip(): stats[source]['empty_content'] += 1 if not row['publish_time'].strip(): stats[source]['empty_time'] += 1 for source, s in stats.items(): print(f"{source}: 总数={s['total']}, 正文空值={s['empty_content']}, 时间空值={s['empty_time']}")这个脚本按source字段分组统计,能快速看出哪个站的数据质量有问题。如果某个站的正文空值率超过 10%,基本可以确定是 XPath 失效或者页面结构变了,需要回去改 spider。
5.3 一个我踩过的坑:时区问题导致时间字段错乱
有次跑完发现所有新闻的publish_time都比实际时间早了 8 小时。排查了半天以为是 XPath 抓错了,后来发现是服务器时区设成了 UTC,而新闻页面上显示的是北京时间。Scrapy 本身不处理时区转换,抓下来是什么就是什么。如果页面上的时间是“2025-01-01 10:30”这种不带时区的字符串,存进 CSV 之后做时间分析就会出错。
从那以后我每次处理时间字段都强制走一遍时区确认:先看服务器date命令输出,再看目标页面时间的标注方式,如果页面用的是北京时间而服务器是 UTC,就在 Pipeline 里统一加 8 小时再存。这个习惯帮我省了不少后悔药。希望帮到你。
本文还有配套的精品资源,点击获取