简介:基于Scrapy框架的Python新闻爬虫项目,面向Python爬虫学习者及需要批量采集新闻数据的开发者,能够抓取网易、搜狐、凤凰和澎湃四个主流新闻网站的文章标题、正文、评论及发布时间,并整理保存到本地,满足每日更新、十万级页面抓取的工程需求。资源共26个文件,以18个Python源码文件为主,包含Spider核心爬虫、Pipeline数据处理、中间件、配置及调试脚本,另有XML配置、Scrapy配置、说明文档等,整个压缩包仅26KB,结构精简清晰。已有494人学习下载。通过该项目不仅可以掌握Scrapy框架从URL调度、Downloader下载、Spider解析到Item Pipeline存储的完整流程,还能学习到针对多站点新闻爬取的调度逻辑、评论采集策略以及定时更新机制,同时项目目录划分明确,核心代码与调试运行脚本分离,便于二次开发和功能扩展,是一份适合进阶练习的爬虫实战范例。
1. 新闻站采集为什么不能直接 requests 一把梭
接手这个爬虫需求的时候,团队里最开始的想法很简单:网易、搜狐、凤凰、澎湃,四个门户的新闻列表页都是服务端渲染,直接用 requests 加 BeautifulSoup 循环翻页就行。结果跑了不到两个小时,问题就全出来了。网易的反爬在 30 分钟内开始拦截高频 IP,搜狐的新闻详情页 URL 看起来规整但部分链接失效,凤凰网的评论数据是单独的接口且带签名参数,澎湃的页面则干脆在部分版块里混入了 iframe 动态加载的内容。这时候才意识到,做新闻站采集,真正麻烦的不是解析 HTML,而是要同时处理多站点规则差异、链接时效性、动态接口和反爬策略。
换用 Scrapy 之后,整个项目的结构清晰了不少。Scrapy 自带的调度器、去重队列、下载中间件和 Item Pipeline 本身就是为了解决这类多页面、多规则、需要容错的采集场景设计的。配合 scrapy-playwright 处理动态内容、用 Crawlera 或自建代理池应对 IP 封锁,才把四个站点的数据稳定地跑下来。这篇文章就从选型开始,把整个项目的 Scrapy 工程结构、四站规则适配、动态评论抓取、数据入库这几个关键环节拆开讲,最后附上调试和验证的实用技巧。
2. Scrapy 工程结构设计与启动方式
2.1 为什么用 Scrapy 而不用 requests 方案
四个新闻站点的采集任务,如果用 requests 写,你需要自己维护请求队列、去重集合、重试机制、并发控制、日志系统和数据持久化。这些模块在 Scrapy 里都是内置的,而且经过了大量生产环境的验证。Scrapy 的异步架构基于 Twisted,默认并发数就能达到 16 个请求同时处理,比 requests 配合 threading 的写法稳定得多。
更重要的是 Scrapy 的去重机制。默认的 RFPDupeFilter 会对每个请求的 URL 做指纹计算,已经抓取过的链接不会重复请求。这个特性在新闻爬虫里特别重要,因为新闻站点的列表页经常会出现重复推荐、置顶或者轮播图链接,如果没有去重,同样的新闻会被重复入库,造成数据冗余。
scrapy startproject news_spider cd news_spider scrapy genspider netease news.163.com scrapy genspider sohu www.sohu.com scrapy genspider ifeng www.ifeng.com scrapy genspider thepaper www.thepaper.cn这段命令创建了项目的骨架和四个站点的爬虫文件。genspider 生成的爬虫文件默认继承 scrapy.Spider,对这四个站点的采集来说基本够用。但如果你打算走得更深一点,比如对接 scrapy-playwright 渲染动态页面,建议改用 scrapy.Spider 的 start_requests 方式手动构造请求,因为这样可以直接给每个请求带上 meta 参数,比如playwright: True来标记哪些页面需要浏览器渲染。
2.2 settings.py 里的关键配置项
Scrapy 的 settings.py 是决定爬虫稳定性的核心文件。对于新闻站采集,下面几个配置项需要格外注意。
# settings.py BOT_NAME = "news_spider" ROBOTSTXT_OBEY = False DOWNLOAD_DELAY = 1.5 CONCURRENT_REQUESTS = 8 CONCURRENT_REQUESTS_PER_DOMAIN = 4 COOKIES_ENABLED = False DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5", } DOWNLOADER_MIDDLEWARES = { "news_spider.middlewares.NewsSpiderDownloaderMiddleware": 543, "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": None, } ITEM_PIPELINES = { "news_spider.pipelines.NewsPipeline": 300, }ROBOTSTXT_OBEY 设为 False 是因为部分新闻站点用 robots.txt 屏蔽了搜索引擎之外的大多数爬虫,而这个项目是定向采集,所以需要绕过 robots 限制。DOWNLOAD_DELAY 设置为 1.5 秒,故意放慢请求频率来降低被反爬系统识别的概率。CONCURRENT_REQUESTS_PER_DOMAIN 设为 4,避免对单个域名打压力度过大。COOKIES_ENABLED 关闭是因为我们不依赖登录态采集,而且关闭 Cookies 能减少会话管理的开销。
这里有个常见的误区:很多人认为 DOWNLOAD_DELAY 越大越安全,实际上对于新闻站,1 到 2 秒的延迟配合随机 User-Agent 已经能规避绝大多数基础反爬。再大的延迟会导致采集耗时成倍增加,一个站点的几万条新闻可能要跑十几个小时,得不偿失。
2.3 中间件里实现随机 User-Agent 和代理切换
下载中间件是整个爬虫的咽喉,所有经过 Scrapy 的请求和响应都会在这里被拦截。新闻站点的反爬最基础的检测就是 User-Agent 和来源 IP。如果所有请求都使用同一个 UA,很容易被识别为脚本。常见的做法是准备一个 User-Agent 池,每次请求随机取一个。
# middlewares.py import random class NewsSpiderDownloaderMiddleware: UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Version/16.0 Mobile/15E148 Safari/604.1", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36", ] def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.UA_POOL) request.headers["Referer"] = request.url return NoneUA_POOL 里同时包含了桌面端和移动端的 User-Agent,因为部分新闻站对移动端的反爬策略较宽松。process_request 方法在请求发出前被调用,随机选择 UA 并动态设置。Referer 设置为当前 URL,模拟正常浏览行为,因为部分站点的安全策略会校验 Referer 是否来自站内。
如果单个 IP 仍然被限制,可以在 process_request 里继续封装代理逻辑。比如从代理池接口获取一个代理 IP,将其拼接到请求的 meta 中,同时配合scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware使用。自建代理池的话,可以用 Redis 维护一个可用 IP 队列,定时检测有效性。对于新闻站点,普通的高匿代理已经够用,不需要购买独享代理。
3. 四站新闻列表页与详情页规则解析
3.1 网易与搜狐的列表页结构对比
网易新闻的列表页结构相对规整,分类地址有明显规律。比如要爬取国内新闻,列表页 URL 是https://news.163.com/domestic/,翻页通过 URL 参数?index=2控制。详情页的 URL 是https://www.163.com/dy/article/{id}.html这种格式,文章 ID 是上传者文章的唯一标识。解析时直接用 XPath 提取标题和链接即可。
# spiders/netease.py import scrapy from news_spider.items import NewsItem class NeteaseSpider(scrapy.Spider): name = "netease" allowed_domains = ["163.com"] start_urls = ["https://news.163.com/domestic/"] def parse(self, response): links = response.xpath("//div[@class='ndi_main']//a/@href").getall() for link in links: if link and "dy/article" in link: yield scrapy.Request( url=response.urljoin(link), callback=self.parse_detail, meta={"source": "netease"} )网易的ndi_main区域是新闻列表的核心容器。这类页面的布局经常改版,所以实际开发时不能把选择器写得太死。我一般会先手动打开页面,用开发者工具确认当前页面的 DOM 结构,再用scrapy shell url验证 XPath 表达式。对于搜狐的列表页,结构类似,只是容器类名不同,判定新闻链接时要注意域名是否合法。
3.2 凤凰网与澎湃的翻页与去重处理
凤凰网的新闻列表翻页不依赖 URL 参数,而是通过点击“加载更多”按钮。这种场景下,使用LinkExtractor配合CrawlSpider可以自动化提取有效链接。CrawlSpider的规则里可以写多个规则,指定哪些链接需要跟进、哪些链接进入解析函数,这让列表页的深层次链接处理变得非常方便。
# spiders/ifeng.py from scrapy.spiders import CrawlSpider, Rule from scrapy.linkextractors import LinkExtractor class IfengSpider(CrawlSpider): name = "ifeng" allowed_domains = ["ifeng.com"] start_urls = ["https://news.ifeng.com/"] rules = ( Rule(LinkExtractor(allow=r"https://news\.ifeng\.com/c/", unique=True), callback="parse_detail"), ) def parse_detail(self, response): item = NewsItem() item["url"] = response.url item["title"] = response.xpath("//h1/text()").get() item["content"] = "".join(response.xpath("//div[@id='main_content']//p/text()").getall()) yield itemLinkExtractor的allow参数使用正则匹配链接特征,只有满足https://news.ifeng.com/c/开头的链接才会被跟进。unique=True让去重器生效,确保同一篇文章不会被重复爬取。澎湃新闻的列表页实际上是异步加载的 JSON 接口,这一点在后文会专门展开。所有站点进入详情页解析后,先做 URL 去重,再入库,这个流程在生产上是必须的。
3.3 详情页正文提取与 XPath 容错
新闻详情页的正文提取,最怕所谓“脏数据”:页面里混入了推荐阅读、广告、相关新闻等区块。如果直接把整个<body>下的文本都提取出来,入库的数据里会有一堆无关内容。常见做法是定位到正文的容器节点,再提取该节点下的文本。
# items.py import scrapy class NewsItem(scrapy.Item): title = scrapy.Field() url = scrapy.Field() source = scrapy.Field() publish_time = scrapy.Field() content = scrapy.Field() comments = scrapy.Field()这里定义了一个简单的 NewsItem,包含标题、URL、来源、发布时间、正文和评论六个字段。详情页解析时,核心代码逻辑如下:
def parse_detail(self, response): item = NewsItem() item["url"] = response.url item["source"] = response.meta.get("source") item["title"] = response.xpath("//h1/text()").get(default="").strip() item["publish_time"] = response.xpath("//span[@class='time']/text()").get(default="") item["content"] = "".join(response.xpath("//div[@class='post_body']//p/text()").getall()).strip() yield item网易的正文容器是div[class='post_body'],凤凰是div[class='main_content'],搜狐是article标签。每个站点的选择器不同,但逻辑一致:先提取容器,再从容器内提取所有段落文本并合并。这里有一个细节:有些新闻页面的正文不是直接渲染在 HTML 里的,而是由 JavaScript 动态生成的 JSON 数据拼接而成。对于这种情况,XPath 永远提取不到内容。解决办法有两个,一是用 scrapy-playwright 渲染后再提取,二是从页面源码中的 JSON 变量里直接取值。后者更快,但需要具体分析页面的数据格式。
4. 评论数据抓取与动态页面处理
4.1 评论系统的接口分析与签名参数处理
评论数据是新闻爬虫里比较麻烦的一环。网易新闻的评论是异步加载的,有一个公开的评论接口,返回 JSON 格式的数据。搜狐、凤凰的评论系统则带有多重参数,包括文章 ID、类型、页码、签名等。签名通常是将文章 ID 和时间戳拼接后进行 MD5 加密得到的,如果不知道签名算法,直接用 requests 构造接口请求会被拒。
以网易为例,它的评论接口 URL 格式大致为:
https://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/{post_id}/comments/newList?offset=0&limit=30参数post_id对应文章 ID,offset是分页偏移量,limit是每页条数。接口会返回一段 JSON,其中包含评论列表、总评论数、点赞数等信息。对于这类公开的 JSON 接口,直接用 Scrapy 的 Request 请求接口地址即可,不需要额外处理签名。
import json import scrapy class NeteaseCommentsSpider(scrapy.Spider): name = "netease_comments" API_TEMPLATE = ( "https://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/" "threads/{post_id}/comments/newList?offset={offset}&limit=30" ) def start_requests(self): post_ids = ["DJGPT8VS000181KT", "DK0T6Q8J0001899N"] for pid in post_ids: url = self.API_TEMPLATE.format(post_id=pid, offset=0) yield scrapy.Request(url=url, callback=self.parse_comments) def parse_comments(self, response): data = json.loads(response.text) comments = data.get("comments", []) for c in comments: yield {"post_id": c.get("postId"), "content": c.get("content")}post_ids列表是从详情页解析出来的文章 ID,这里演示时手动填了两个。实际项目中,应该是详情页爬虫解析完文章内容后,将文章 ID 传回给评论爬虫。可以通过 Scrapy 的 Request meta 机制传递,也可以用 Redis 队列做解耦。需要注意接口返回的comments字段是列表,这个字段可能会被服务端改为commentList或者嵌套结构,解析时最好先打印一下原始 JSON 的 key 结构再编写代码。
4.2 scrapy-playwright 处理动态 iframe 内容
凤凰网的某些包含视频或者互动元素的新闻页面,正文内容会嵌在 iframe 里。iframe 中的内容使用 XPath 无法直接命中,因为它是独立于主文档的另一个文档。这个时候需要借助 scrapy-playwright 集成,触发浏览器渲染后再解析。
# settings.py 中启用 playwright DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"配置完成后,在发送请求时给 meta 传入playwright: True参数,Scrapy Playwright 会自动用 Chromium 渲染页面。渲染完成后,response 对象还是同一个接口,但此时 HTML 里已经包含了 iframe 注入后的内容。解析逻辑不需要变化,XPath 仍然有效。
def start_requests(self): yield scrapy.Request( url="https://news.ifeng.com/c/xxxxx", meta={"playwright": True} ) def parse_detail(self, response): content = response.xpath("//div[@id='main_content']//p/text()").getall() yield {"content": "".join(content)}使用 playwright 的一个明显问题是资源开销。每个请求都会启动一个无头浏览器实例,内存占用非常高。我的经验是,只对包含 iframe 的链接启用 playwright,普通新闻页仍然走默认的下载器。可以在 real url 判断阶段先检测页面是否包含 iframe 标签,如果检测到再把请求重新提交给 playwright 渲染。这个策略能把资源消耗控制在可接受范围内。
4.3 评论与正文的关联入库
评论数据和正文数据是分开抓取的,但入库时必须通过文章 ID 关联。在 NewsItem 中设置comment字段为列表类型,等评论爬虫返回数据后,通过文章 ID 找到对应的 MongoDB 文档,将评论列表追加到正文文档中。这种设计比分别建两张表再联查要简单得多,适合体量不大的新闻库。数据量过百万级的话,建议还是拆表,用文章 ID 做外键关联。
5. 多站点数据清洗与 MongoDB 入库实践
5.1 正文清洗策略
从四个站点抓回来的正文文本,格式差异很大。网易的文章段落之间有大量空格和换行,凤凰的有些段落是图片说明文字,搜狐的详情页里混入了热点新闻推荐的列表,这些内容在持久化之前必须清洗。清洗规则不能写得太过激进,否则可能把正常内容删掉。
我采用的是这样一个流程:先用正则把连续的空白字符统一为单个换行,再删除掉包含特定推荐语法的段落,比如以“相关推荐”、“热点新闻”、“责任编辑”开头的段落。然后检查正文长度,低于 200 字的新闻通常可能是短讯、快讯,或者正文提取失败,这类内容单独标记状态,方便人工抽查。
import re def clean_content(raw_text): text = re.sub(r"\s+", "\n", raw_text) lines = [line.strip() for line in text.splitlines()] ignored_prefixes = ("相关推荐", "责任编辑", "热点新闻", "延伸阅读", "广告") cleaned = [line for line in lines if line and not line.startswith(ignored_prefixes)] return "\n".join(cleaned)清洗后的正文直接覆盖原字段,同时保留一份清洗前的原始 HTML 到独立字段中,方便后续排查解析问题。正则替换\s+会把所有连续空格和换行缩成一个换行,这样每段文字在一个逻辑行里,后续做文本分析和索引时更规整。
5.2 Item Pipeline 中的实时去重与入库
Scrapy 的 Pipeline 组件适合做数据入库前的最后一次加工。常见的需求是 URL 去重和发布时间格式统一。URL 去重可以通过 Redis 的 set 结构实现,比用关系数据库查询效率高得多。
# pipelines.py import redis import pymongo from datetime import datetime class NewsPipeline: def open_spider(self, spider): self.r = redis.Redis(host="localhost", port=6379, db=0) self.client = pymongo.MongoClient("mongodb://localhost:27017/") self.collection = self.client["news_db"]["articles"] def process_item(self, item, spider): url = item.get("url") if self.r.sadd("news:visited", url): item["crawl_time"] = datetime.now().isoformat() self.collection.update_one( {"url": url}, {"$set": dict(item)}, upsert=True ) return item raise DropItem(f"Duplicate URL: {url}")sadd方法只有当时地址不存在于集合中时才返回 1,利用这个特性做去重逻辑非常方便。返回 0 时说明已经抓过,直接抛出 DropItem 丢弃。MongoDB 的update_one配合upsert=True,保证同一 URL 只对应一个文档,重复抓取时自动覆盖。这里选择 MongoDB 而不是 MySQL,是因为评论、正文这类嵌套结构直接存成 BSON 文档更自然,不需要维护表关系。
5.3 四种时间格式的统一转换
四个网站的发布时间格式各不相同。网易的格式是2025-01-15 10:30:00,搜狐的是2025年01月15日10:30,凤凰的则带有时区,格式类似2025-01-15T10:30:00+08:00,澎湃的是相对时间,比如“10分钟前”。入库时必须统一为 ISO 格式,方便前后端展示和后续做时间范围查询。
from datetime import datetime, timedelta, timezone def normalize_time(raw): raw = raw.strip() try: dt = datetime.fromisoformat(raw) except ValueError: try: dt = datetime.strptime(raw, "%Y年%m月%d日%H:%M") except ValueError: if "分钟前" in raw: minutes = int(re.search(r"\d+", raw).group()) dt = datetime.now(timezone.utc) - timedelta(minutes=minutes) else: return datetime.now().isoformat() return dt.isoformat()这个函数按照多种格式依次尝试解析,兜底行为是返回当前时间。注意澎湃的“10分钟前”这类相对时间,入库后如果超过一小时再被查询,相对时间仍然有效,但会失去绝对语义,所以解析时就用当前 UTC 时间减去对应分钟数。采集任务如果是周期运行,相对时间必须落在爬取时刻这个坐标系里计算,否则误差会被时间差放大。
6. 新闻爬虫调试技巧与验证脚本
6.1 scrapy shell 快速验证 XPath 规则
在爬虫运行前,先用scrapy shell验证选择器是否准确命中,能省掉大量调试时间。用法是在命令行进入scrapy shell环境后发送一个请求,用response.xpath()测试表达式。这个方法适合所有站点规则的手工验证,尤其是刚改过版本的页面。
scrapy shell "https://news.163.com/domestic/"进入交互环境后,运行:
response.xpath("//div[@class='ndi_main']//a/@href").getall()观察返回的链接数量是否和页面上看到的数量一致。如果有结果但数量偏少,可能是部分链接被懒加载了;如果没有结果,说明页面结构改了或反爬拦截了请求。这时先检查响应内容:
response.status response.text[:500]状态码如果是 200 但内容里没有新闻链接,大概率是看到了反爬的验证页。此时需要在中间件中调整 UA 或者增加代理。这个验证流程我每次改完选择器之后都会跑一遍,比直接跑完整爬虫再翻日志要快很多。
6.2 增量采集与断点续爬的调度方案
新闻爬虫的采集任务通常需要定时启动。对于这种一天多次的增量采集,配合 Crontab 是简单直接的方案。增量最关键的是避免重复抓取旧数据。Scrapy 的请求去重已经有一定保障,但重启之后去重队列会清空。所以增量判断必须在 Data Pipeline 及存储层做兜底。
# crontab 每天 8 点和 20 点各跑一次 0 8,20 * * * cd /home/dev/news_spider && /usr/bin/scrapy crawl netease定时任务的输出建议重定向到日志文件。Scrapy 支持-s LOG_FILE参数指定日志文件。
scrapy crawl netease -s LOG_FILE=logs/netease_$(date +\%Y\%m\%d).log重启后重复 URL 会走 MongoDB 的update_one的 upsert 逻辑,直接覆盖同一 URL 的文档,不会产生重复记录。crawl_time字段会更新为本次爬取时间,后面要做“最近七日新闻”之类的查询时,直接按这个字段过滤即可。
如果想做更完整的断点续爬,比如爬到一半进程挂了,恢复后从断点继续,那就需要把待爬队列持久化。常见方案是把下一步要爬的 URL 放进 Redis 列表,爬虫进程启动时先从 Redis 中读取待爬列表,而不是从 start_urls 重新开始。
6.3 评论接口的频率控制与异常回退
评论接口属于站点的动态接口,虽然有公开的测试口,但频率控制往往比页面逻辑更严格。20 次左右的并发评论请求就可能触发临时封禁。评论接口的请求必须独立设置 DOWNLOAD_DELAY,且不要和正文页使用同一个下载中间件配置。更稳妥的做法是把评论接口的请求 delay 提升到 3 秒。
抓取过程中如果发现接口返回的 JSON 中包含"code": -1或者"message": "操作太频繁"这类提示,说明触发了频率限制。此时应该做指数退避:第一次等待 10 秒,第二次 30 秒,第三次 60 秒,还是被封就切换代理 IP 再继续。不要暴力重试,否则接口会越封越狠。
def parse_comments(self, response): if response.status != 200: retry_times = response.meta.get("retry_times", 0) if retry_times < 3: yield scrapy.Request( url=response.url, dont_filter=True, meta={"retry_times": retry_times + 1}, errback=self.errback_comment )设置dont_filter=True是为了让被跳过的请求重新进入下载队列。retry_times通过 meta 传递,每次重试加一,超过 3 次则放弃,转入错误回调记录日志并通知值班人员。Comment 数据的完整性检查,我一般会每天跑一个小脚本,统计每个 URL 文档中comments数组的长度,如果发现当前时间有超过 10% 的文章评论数为空,就会自动推送告警,提示可能是评论接口的规则又变了。
这套完整的流程从工程搭建、四站规则适配、动态接口处理到入库排错,基本覆盖了新闻站采集的常规技术题。按这个结构搭出来的爬虫,不仅稳定,而且后续换站、加站也很快。
本文还有配套的精品资源,点击获取