ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Scrapy新闻爬虫工程化实战:多站点解析与反爬策略

Scrapy新闻爬虫工程化实战:多站点解析与反爬策略 简介一份基于Scrapy框架的Python新闻爬虫项目面向有一定Python基础的爬虫学习者和需要批量采集新闻数据的开发者解决从网易、搜狐、凤凰、澎湃等新闻网站批量抓取标题、正文、评论及发布时间并本地化存储的问题。项目设计了完整的Scrapy工程架构包含18个Python脚本、3个XML配置、2个pyc编译文件及Scrapy配置和Markdown说明文档压缩包共26个文件、大小约26KB结构紧凑便于快速上手。spiders目录为核心爬虫代码针对四个目标站点分别实现抓取逻辑另有pipelines、middlewares、items模块处理数据管线和中间件逻辑debug_xxx.py作为独立运行入口支持分站调试。项目需求明确提出文章与评论页面不少于10万页且可每日更新因此代码中兼顾了定时调度与去重存储适合作为学习多站点分布式爬虫、新闻数据采集或Scrapy框架实战的参考范例。目前已有494人学习下载对希望从零搭建新闻爬虫的开发者具有不错的借鉴价值。1. 四个新闻站的页面长成四个样但你要的是同一张表做新闻聚合或舆情分析时最先卡住的往往不是算法而是数据从哪儿来。网易的标题在首页的栏目块里正文区有固定的post_body搜狐的新闻正文分散在多个text节点里凤凰的图片新闻和文字新闻混在一个列表中澎湃的移动端页面默认走 JS 渲染直接抓 HTML 会拿到一整片空的div。更要命的是评论数据几乎都不在正文页里而是各自独立的异步接口。想靠手写 requests 循环把这四个站的标题、内容、评论、时间整理成一份结构化数据不是不行只是你会发现时间都花在了重复处理重定向、重复请求、编码混乱和反爬封禁上。Scrapy 的价值恰恰在这里它把请求调度、去重、并发、失败重试、中间件和 Item Pipeline 都做成了框架级能力。你只需要聚焦两件事——页面里哪个 XPath 取值以及数据落盘怎么清洗。这篇文章会从工程搭建讲起覆盖多站点路由、评论接口拼接、动态 iframe 解决方案和 Pipeline 落库最后给出可验证的命令行操作。适合已经写过单页爬虫、但还没正经做过工程化 Scrapy 项目的人。2. 用 Scrapy 搭新闻爬虫工程从项目骨架到第一个请求2.1 Scrapy 在新闻爬虫里解决的四个核心问题单机爬虫脚本与 Scrapy 项目之间最本质的差距在于“失败恢复”和“资源调度”。新闻站的列表页有几十个栏目每个栏目下面几十条链接详情页里还有图片、分页、跳转链接。如果用 requests 写需要自己维护队列、自己写去重、自己处理超时重试、自己限制并发一旦中间进程挂掉就得从头再来。Scrapy 的调度器默认对请求 URL 做指纹去重DOWNLOAD_DELAY 控制请求间隔CONCURRENT_REQUESTS 控制并发JOBDIR 参数可以做到断点续爬。这些特性对新闻爬虫这种“目标站点多、页面结构不统一、单站数据量大”的场景非常合适。更重要的一点是Scrapy 的 Item Pipeline 天然支持多级数据流转你可以在 Spider 里只负责提取原始字段把清洗、去重、存储全部放到 Pipeline 里做四个站的解析结果可以共用同一个清洗逻辑。2.2 三分钟把工程跑起来的 shell 命令先创建项目。这里以 Python 3.10 Scrapy 2.11 为例命令如下pip install scrapy scrapy startproject news_spider cd news_spider scrapy genspider netease news.163.com执行完会生成如下目录结构news_spider/ scrapy.cfg news_spider/ __init__.py items.py middlewares.py pipelines.py settings.py spiders/ __init__.py netease.pynews_spider/spiders/netease.py是刚生成的蜘蛛模板代码只包含一个parse方法。现在把最小可运行的版本替换进去import scrapy class NeteaseSpider(scrapy.Spider): name netease allowed_domains [news.163.com] start_urls [https://news.163.com/] def parse(self, response): self.logger.info(HTTP 状态: %s, response.status) item {url: response.url, title: response.xpath(//title/text()).get()} yield item这个示例中allowed_domains限定爬虫只能在网易域名内爬取防止正则生成的 URL 跳到外站。parse方法的返回值必须是一个可迭代对象这里直接yield字典是最简单的写法。运行命令scrapy crawl netease启动后Scrapy 会打印 Request 日志和 Item 输出终端里能看到一个包含url和title的字典。这个最小案例跑通说明环境和工程骨架都没有问题。2.3 settings.py 里三个必须改的参数新建项目后settings.py里的默认值偏向保守。对于新闻爬虫我一般会改这三个值参数默认值推荐配置理由ROBOTSTXT_OBEYTrueFalse部分新闻站的 robots.txt 会屏蔽详情页目录生产环境下通常手动遵守还是关闭取决于你的合规要求DOWNLOAD_DELAY01.5四个站对请求频率都敏感1.5 秒是保底值CONCURRENT_REQUESTS168降低并发能显著减少 IP 被临时封禁的概率四个站点并发 8 已经足够具体操作是用编辑器打开settings.py定位到这三项把注释去掉并改成上面的值。其中DOWNLOAD_DELAY是每两个请求之间的最小间隔Scrapy 内部会用随机因子微调避免出现规律的请求间隔而被识别。实际跑批时如果某站仍然出现 403 或 418就把该站的custom_settings里的DOWNLOAD_DELAY单独调大。class NeteaseSpider(scrapy.Spider): name netease custom_settings { DOWNLOAD_DELAY: 2.5, CONCURRENT_REQUESTS: 4, }3. 四站新闻解析Item 模型、路由策略与 XPath 差异3.1 定义跨站点统一字段 NewsItem四个站的字段需求是一致的标题、内容、评论数、发布时间。但原始页面里这几个字段所在的标签完全不同甚至同一个站不同栏目都有差异。因此需要在items.py里定义统一的数据结构import scrapy class NewsItem(scrapy.Item): source scrapy.Field() url scrapy.Field() title scrapy.Field() content scrapy.Field() comment_count scrapy.Field() publish_time scrapy.Field()source字段用来标记这条新闻来自哪个站url是详情页链接content是清洗后的正文字符串不是列表comment_count用整数表示评论数。这样四个站的 Spider 解析逻辑虽然有差异但 yield 出来的都是同一个 Item 结构后续 Pipeline 做入库和去重时不需要区分来源。3.2 列表页抓取策略从栏目页提取详情链接新闻网站的列表页通常会把最新内容放在首页或频道页。比如网易新闻的首页中链接结构是https://news.163.com/25/0102/xxxx.html这样的带日期路径通过判断 URL 里是否包含/20和.html就可以过滤出合法详情页。下面是一个多栏目抓取策略的示例import scrapy from news_spider.items import NewsItem class MultiSiteSpider(scrapy.Spider): name news_all start_urls [ https://news.163.com/, https://news.sohu.com/, https://news.ifeng.com/, https://www.thepaper.cn/, ] allowed_domains [ news.163.com, news.sohu.com, news.ifeng.com, thepaper.cn, ] def parse(self, response): if thepaper.cn in response.url: links response.xpath(//a[contains(href, detail_)]/href).extract() else: links response.xpath( //a[re:test(href, https?://.*/20\\d{2}/\\d{4}/.*\\.html)]/href ).extract() for link in links: if link.startswith(//): link https: link yield scrapy.Request(link, callbackself.parse_detail) def parse_detail(self, response): item NewsItem() item[source] netease if 163.com in response.url else other item[url] response.url item[title] self.extract_title(response) yield item这个示例的关键在于第 19 行的正则表达式re:test。它匹配的是 URL 中带日期路径的新闻详情页直接过滤掉了首页的导航链接和图片专题。对于澎湃它的详情页 URL 格式是https://www.thepaper.cn/newsDetail_forward_xxxxx所以单独用contains(href, detail_)做匹配。需要注意re:test是 Scrapy 自带的选择器扩展需要在 Spider 顶部不额外 import 就可以使用。如果你的 XPath 写成了href返回的是相对路径比如/news/20250102.html需要手动拼接域名因为scrapy.Request不会自动补全相对 URL。3.3 正文、时间和评论字段的异构解析对照四个站点的详情页结构差异集中在正文容器、时间标签和评论入口上。我在实际爬取时总结了下面这一套回退策略先尝试精确选择器失败后按通用容器提取再失败就在 Pipeline 里做清洗。站点标题选择器正文容器时间字段评论入口网易//h1[idh1title]/text()//div[contains(class,post_body)]//p/text()//div[classpost_date]/text()页面底部>def parse_detail(self, response): item NewsItem() item[source] netease item[url] response.url item[title] response.xpath(//h1[idh1title]/text()).get() item[publish_time] response.xpath(//div[classpost_date]/text()).get() content_list response.xpath(//div[contains(class,post_body)]//p/text()).extract() item[content] .join(p.strip() for p in content_list if p.strip()) comment_id response.xpath(//meta[namecommentid]/content).get() if comment_id: comment_api fhttps://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c18f3c4c28e/threads/{comment_id}/comments/newList?offset0limit30 yield scrapy.Request(comment_api, callbackself.parse_comment, meta{item: item}) else: yield item代码中meta{item: item}是把已经解析好的字段传给下一个回调函数避免评论接口返回后再重新解析页面。comment_count没有在页面里直接赋值而是在parse_comment中获取import json def parse_comment(self, response): item response.meta[item] try: data json.loads(response.text) item[comment_count] data.get(size, 0) except json.JSONDecodeError: item[comment_count] 0 yield item网易评论接口返回的是 JSON 数据data.get(size, 0)获取的就是该新闻的总评论数。搜狐和澎湃的评论接口原理相同只是 URL 和参数签名不同实际使用时可以在 Network 面板搜索comment关键字拿到接口地址然后用同样的方式拼接。4. 动态 iframe、异步评论与反爬把爬虫从“能跑”升级为“抗造”4.1 评论和正文不在同一个响应里抓取策略要分开新闻网站的评论模块通常不是服务端渲染的而是页面加载后用 XHR 请求评论接口。凤凰网更特殊评论框被包在一个 iframe 里需要先找到 iframe 的src属性再去请求内层页面。如果直接在详情页的 HTML 里搜动态加载数据的代码会发现响应里根本没有评论数因为评论数是在 iframe 加载完成后才从另一个接口写入的。针对这种情况我一般会分成两条线来处理。第一条线直接在 Spider 中找出评论接口的 URL像上文网易的例子那样直接请求接口第二条线如果接口很难模拟比如带签名加密则启用无头浏览器渲染。对于新闻爬虫来说能不走浏览器就不要走浏览器因为它会把爬取速度拖慢一个数量级。4.2 用 scrapy-playwright 处理 iframe 和 JS 渲染澎湃新闻的移动版页面默认加载的是 JS 渲染的空壳用 Scrapy 默认的 HtmlResponse 拿到的 content 字段是空字符串。凤凰网的部分专题页面也有类似情况标题在 HTML 中可以取到但正文内容需要通过点击“展开全文”按钮才加载。这种情况下框架级方案是引入scrapy-playwright让 Scrapy 在抓取时直接驱动 Chromium 渲染后再解析 HTML。安装命令如下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启用后在 Spider 中对指定 URL 开启渲染import scrapy from scrapy_playwright.page import PageMethod class PengpaiSpider(scrapy.Spider): name pengpai allowed_domains [thepaper.cn] def start_requests(self): yield scrapy.Request( urlhttps://www.thepaper.cn/, meta{ playwright: True, playwright_page_methods: [ PageMethod(wait_for_selector, div.news_txt), PageMethod(evaluate, window.scrollTo(0, document.body.scrollHeight)), ], }, ) def parse(self, response): self.logger.info(渲染后正文长度: %d, len(response.xpath(//div[contains(class,news_txt)]//p/text()).extract()))这段代码里PageMethod(wait_for_selector, div.news_txt)是暂停到页面中出现该选择器对应的节点才继续执行避免 Scrapy 拿到未渲染完成的 HTML。第二行evaluate是让浏览器滚动到底部触发滚动加载的请求。注意playwright会显著降低爬取速度建议只对检测到正文缺失的 URL 启用而不是全站开启。4.3 请求头中间件和限速参数防止 403 封禁四家新闻站都有反爬策略表现最常见的两种未带浏览器特征的请求返回 403短时间高频请求被临时封禁 IP。第二个问题靠DOWNLOAD_DELAY和CONCURRENT_REQUESTS解决第一个问题用 Scrapy 的 Downloader Middleware 来解决。在middlewares.py中添加一个自定义中间件class RandomHeadersMiddleware: def process_request(self, request, spider): request.headers.setdefault( 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 ) request.headers.setdefault(Accept-Language, zh-CN,zh;q0.9) request.headers.setdefault(Referer, request.url)然后在settings.py里激活它DOWNLOADER_MIDDLEWARES { news_spider.middlewares.RandomHeadersMiddleware: 543, }543 这个数值表示在默认中间件序列中的执行顺序数字越小越靠近下载器。设置固定的浏览器 UA 之后还要注意请求之间的间隔不能太规律可以用DOWNLOAD_DELAY的随机因子来打散参数配置值说明DOWNLOAD_DELAY1.5基础间隔RANDOMIZE_DOWNLOAD_DELAYTrue在 0.5 到 1.5 倍之间随机取间隔AUTOTHROTTLE_ENABLEDTrue根据响应延迟动态调整速度AUTOTHROTTLE_ENABLED开启后Scrapy 会观察服务器响应耗时响应变慢就自动拉大请求间隔能有效降低被封的概率。需要注意的是它与手动设置的DOWNLOAD_DELAY同时存在时后者会作为最低限制。4.4 断点续爬与去重规则中断一次不用从头再来长任务的痛点在于中断。新闻爬虫通常要跑几小时甚至几天中途网络抖动或进程被杀是常态。Scrapy 支持通过JOBDIR参数保持爬取状态。启动命令带上这个参数就会在本地目录写入去重指纹和调度队列scrapy crawl news_all -s JOBDIRjobs/news_all再次执行同一条命令时Scrapy 会从上一次停止的位置继续已经爬过的 URL 不会再请求。搭配上位置持久化还能在爬取过程中随时按CtrlC停止scrapy crawl会有礼貌地处理完当前请求并保存状态。需要注意JOBDIR只对同一目录下的同一爬虫生效且不会自动清理跑完一周的批任务后手动删除 jobs 目录释放空间。5. Pipeline 清洗落库与数据验证方法5.1 文本清洗与 SQLite 落库解析出来的正文中往往夹杂着大量换行、空格、版权声明和“责任编辑”字样。在 Pipeline 中做统一清洗是最合理的位置import sqlite3 import re class CleanPipeline: def process_item(self, item, spider): if item.get(content): item[content] re.sub(r\s, , item[content]).strip() if item.get(publish_time): item[publish_time] item[publish_time].replace(来源:, ).strip() return item清洗逻辑里第一个正则把正文中冗余的换行和空格替换为单个空格解决字符串拼接后出现的字间距异常问题。第二个替换是去掉网易时间字符串中的“来源:”前缀。清洗完成后将数据写入 SQLite。在 Pipeline 里保持一个全局连接避免每条记录都新建连接class SQLitePipeline: def open_spider(self, spider): self.conn sqlite3.connect(news.db) self.conn.execute( CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT, url TEXT UNIQUE, title TEXT, content TEXT, comment_count INTEGER, publish_time TEXT ) ) def process_item(self, item, spider): self.conn.execute( INSERT OR IGNORE INTO news (source, url, title, content, comment_count, publish_time) VALUES (?, ?, ?, ?, ?, ?), (item.get(source), item.get(url), item.get(title), item.get(content), item.get(comment_count), item.get(publish_time)) ) self.conn.commit() return item def close_spider(self, spider): self.conn.close()open_spider在爬虫启动时建表和连接process_item对每一条数据执行插入。INSERT OR IGNORE依赖url的唯一约束来去重如果同一篇文章被两个站的列表页重复收录第二次到达时会被自动忽略。5.2 用命令行参数切换数据输出格式开发调试阶段写入 SQLite 太麻烦一种常见做法是用命令行参数控制 Pipeline 的启用状态直接用 Scrapy 自带的 JSON Feed 导出器。运行时不加-s参数会默认走全量 Pipeline加上导出参数就只输出结构化文件scrapy crawl news_all -o news.json -s FEED_EXPORT_ENCODINGutf-8-o news.json是 Scrapy 内置的 Feed Export 功能支持 JSON、CSV、JSONL 格式不经过 Pipeline 直接输出到文件。加上FEED_EXPORT_ENCODINGutf-8是为了避免 Windows 下中文乱码。这个命令只适合小批量验证大任务仍建议走 SQLite因为 JSON 文件无法增量写入任务中断会导致文件损坏。5.3 用 scrapy parse 和日志统计验证采集链路最后一环是验证。调试详情页时不需要启动完整爬虫用下面的命令定向解析单个页面scrapy parse https://news.163.com/25/0102/xxxx.html -c parse_detail-c参数指定调用的回调函数命令执行后会在终端打印 Item 的内容方便直接确认 XPath 有没有写错。如果某个站点的字段提取为空可以先用这个命令验证是不是选择器的问题再检查页面结构是否改版。完整跑完一批后查看日志中的统计输出重点看item_scraped_count和downloader/response_status_count/200两个值。当item_scraped_count远小于详情页请求数量时说明有相当一部分页面解析失败常见原因是正文区域改版导致 XPath 失效。这时用scrapy parse访问该页并打印响应比对实际页面结构修改选择器后再跑一遍即可。本文还有配套的精品资源点击获取
返回列表