资讯详情 Scrapy多源新闻爬虫实战:网易搜狐凤凰澎湃四站统一采集与反爬去重
📅 2026/10/25 12:07:39
简介这是一份基于Scrapy框架的Python新闻爬虫项目源码面向具备一定Python基础、希望学习分布式爬虫与数据采集的开发者。项目针对网易、搜狐、凤凰和澎湃四家新闻站点实现文章标题、正文、评论及发布时间的抓取与本地存储并支持定时运行以完成每日数据更新适合用作爬虫课程设计或数据采集练手案例。压缩包共26个文件以18个py脚本为核心涵盖spiders爬虫逻辑、pipelines数据管道、middlewares中间件与settings配置另含3个xml与iml等IDE工程文件、1个cfg部署配置及1个md说明文档整体约26KB结构清晰、便于按模块阅读。资源已有495人学习下载读者可从中掌握Scrapy的Scheduler、Downloader、Spider与Item Pipeline完整数据流理解多站点适配与增量更新思路并参考debug系列入口脚本快速调试运行。1. 四个门户、一套管道新闻爬虫到底在爬什么网易、搜狐、凤凰、澎湃这四家站点的新闻页面看起来都是标题 正文 时间 评论但真拿 Scrapy 去跑你会发现它们根本不是一个物种。网易和搜狐大量频道是服务端渲染response.text里直接能 XPath 到正文凤凰部分栏目把正文塞在iframe里或者用 JS 二次注入澎湃的评论走独立接口页面 HTML 里只有个空壳容器。所以这个标题真正要解决的问题不是怎么写一个 Spider而是怎么用一套 Scrapy 管道把四种结构差异极大的新闻源统一成标题、正文、时间、评论四个字段落库。适合谁看已经会写基础 Spider、但一上真实站点就被动态加载和反爬打回来的 Python 爬虫方向从业者想拿一个多源新闻采集项目练手、又不想只爬单一站点的进阶新手。下面按先立结构、再逐个站点拆、最后讲坑的顺序推代码可以直接抄参数按你实际环境改。2. 先定数据契约Item 字段与四站结构差异2.1 为什么先写 Item 而不是先写 Spider多源爬虫翻车最常见的原因是四个 Spider 各写各的字段名最后存进数据库时publish_time、pub_time、time三个名字指同一个东西。血泪经验是动手写第一个 Spider 之前先把 Item 定死后面所有解析都往这个契约上靠。# items.py import scrapy class NewsItem(scrapy.Item): # 来源站点标识netease / sohu / ifeng / thepaper source scrapy.Field() # 新闻标题统一去空白 title scrapy.Field() # 正文纯文本段落用 \n 拼接 content scrapy.Field() # 发布时间统一成 YYYY-MM-DD HH:MM:SS 字符串 publish_time scrapy.Field() # 原文链接作为去重主键 url scrapy.Field() # 评论列表元素为 dict: {user:..., text:..., time:...} comments scrapy.Field()逻辑说明url单独留一个字段并加唯一索引是因为四站都有同一新闻被多个频道转载的情况靠标题去重会误杀。comments用列表而不是拼成字符串是为了后面做 NLP 新闻处理时能按条切分。参数上publish_time我一般不在 Item 里做 datetime 转换统一在 Pipeline 里转Spider 只负责把原始字符串抠出来这样某个站点格式变了只改一处。2.2 四站结构对照先看清再动手站点列表页结构正文位置时间位置评论获取方式网易服务端渲染div.data_rowdiv.post_bodydiv.post_info内文本独立接口comment/api搜狐服务端渲染li.newsarticle.articlespan.time页面内嵌 JSON凤凰部分栏目 iframe 嵌套iframe 内div.main_contentspan.ss01独立接口澎湃服务端渲染 JS 补全div.news_txtdiv.news_about独立接口cont/comment这张表是选型依据网易、搜狐、澎湃的列表页可以直接用scrapy.Request拿凤凰那几个 iframe 栏目要么换移动端接口要么上scrapy-playwright。我一般优先找移动端或 JSON 接口因为 Playwright 渲染一个页面 2~3 秒量一大就顶不住。2.3 统一时间格式的 Pipeline# pipelines.py import re from datetime import datetime from itemadapter import ItemAdapter class TimeNormalizePipeline: # 各站点时间格式不同集中在这里转 PATTERNS [ (%Y-%m-%d %H:%M:%S, r\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}), (%Y年%m月%d日 %H:%M, r\d{4}年\d{2}月\d{2}日 \d{2}:\d{2}), (%Y-%m-%d %H:%M, r\d{4}-\d{2}-\d{2} \d{2}:\d{2}), ] def process_item(self, item, spider): adapter ItemAdapter(item) raw (adapter.get(publish_time) or ).strip() for fmt, pat in self.PATTERNS: m re.search(pat, raw) if m: dt datetime.strptime(m.group(), fmt) adapter[publish_time] dt.strftime(%Y-%m-%d %H:%M:%S) break else: # 匹配不上就留空别塞脏数据 adapter[publish_time] return item逻辑说明用正则先定位再strptime比直接strptime容错高因为原始文本里常混着来源xxx 2024-01-01 10:00这种前缀。参数上PATTERNS按最具体到最宽松排序避免%Y-%m-%d %H:%M先匹配掉带秒的字符串。匹配失败置空而不是抛异常是为了让单条脏数据不阻断整批入库。3. 四个 Spider 的解析写法与请求参数3.1 网易XPath 抓正文 评论接口网易新闻正文在div.post_body评论不在页面里得单独请求。常见做法是先拿docid再拼评论接口。# spiders/netease.py import scrapy from mynews.items import NewsItem class NeteaseSpider(scrapy.Spider): name netease allowed_domains [163.com] start_urls [https://news.163.com/special/0001386F/rank_whole.html] def parse(self, response): # 列表页取详情链接 for href in response.xpath(//div[classdata_row]//a/href).getall(): yield response.follow(href, self.parse_detail) def parse_detail(self, response): item NewsItem() item[source] netease item[url] response.url item[title] response.xpath(//h1/text()).get(default).strip() # 正文段落逐段取用 \n 拼接 paras response.xpath(//div[classpost_body]//p/text()).getall() item[content] \n.join(p.strip() for p in paras if p.strip()) item[publish_time] response.xpath( //div[classpost_info]/text()).get(default) # 从 URL 抠 docid如 .../A1B2C3D4.html docid response.url.rsplit(/, 1)[-1].replace(.html, ) comment_api fhttps://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/{docid}/comments/newList?offset0limit20 yield scrapy.Request(comment_api, callbackself.parse_comment, cb_kwargs{item: item}, headers{Referer: response.url}) def parse_comment(self, response, item): data response.json() comments [] for c in data.get(comments, {}).values(): comments.append({ user: c.get(user, {}).get(nickname, ), text: c.get(content, ), time: c.get(createTime, ), }) item[comments] comments yield item逻辑说明cb_kwargs把已解析的 item 透传到评论回调避免二次请求详情页。参数上limit20是接口默认上限要更多得翻offsetReferer必须带否则接口返回空。docid从 URL 抠是最稳的因为页面里docid有时藏在 JS 变量里正则容易漏。3.2 搜狐正文在 article 标签评论走内嵌 JSON搜狐正文在article.article评论常以window.__INITIAL_STATE__形式内嵌在页面里用正则抠出来比再发请求快。# spiders/sohu.py import json import re import scrapy from mynews.items import NewsItem class SohuSpider(scrapy.Spider): name sohu allowed_domains [sohu.com] start_urls [https://news.sohu.com/] def parse(self, response): for href in response.xpath(//li[contains(class,news)]//a/href).getall(): if href.startswith(//): href https: href yield scrapy.Request(href, self.parse_detail) def parse_detail(self, response): item NewsItem() item[source] sohu item[url] response.url item[title] response.xpath(//h1/text()).get(default).strip() paras response.xpath(//article[contains(class,article)]//p/text()).getall() item[content] \n.join(p.strip() for p in paras if p.strip()) item[publish_time] response.xpath(//span[classtime]/text()).get(default) # 从页面内嵌 JSON 抠评论 m re.search(rwindow\.__INITIAL_STATE__\s*\s*(\{.*?\});, response.text, re.S) comments [] if m: try: state json.loads(m.group(1)) for c in state.get(comments, {}).get(list, []): comments.append({ user: c.get(nickName, ), text: c.get(content, ), time: c.get(createTime, ), }) except json.JSONDecodeError: pass item[comments] comments yield item逻辑说明re.S让.匹配换行否则跨行的 JSON 抠不出来。json.loads包在 try 里是因为内嵌 JSON 偶尔带尾逗号直接解析会炸。参数上article用contains(class,...)而不是精确匹配因为搜狐不同频道 class 后缀不一样。3.3 凤凰iframe 与动态渲染的处理凤凰部分栏目正文在 iframe 里Scrapy 默认拿不到 iframe 内部 DOM。两条路找 iframe 的src直接请求或者上scrapy-playwright。我一般先试前者。# spiders/ifeng.py import scrapy from mynews.items import NewsItem class IfengSpider(scrapy.Spider): name ifeng allowed_domains [ifeng.com] start_urls [https://news.ifeng.com/] def parse(self, response): for href in response.xpath(//a[contains(href,/c/)]/href).getall(): yield response.follow(href, self.parse_detail) def parse_detail(self, response): # 如果正文容器为空说明内容在 iframe 里 if not response.xpath(//div[contains(class,main_content)]//p): iframe_src response.xpath(//iframe/src).get() if iframe_src: yield response.follow(iframe_src, self.parse_detail) return item NewsItem() item[source] ifeng item[url] response.url item[title] response.xpath(//h1/text()).get(default).strip() paras response.xpath(//div[contains(class,main_content)]//p/text()).getall() item[content] \n.join(p.strip() for p in paras if p.strip()) item[publish_time] response.xpath(//span[classss01]/text()).get(default) item[comments] [] yield item逻辑说明先判断正文容器有没有p没有就说明是 iframe 壳取src再请求一次。参数上allowed_domains要包含 iframe 的域名否则被 OffsiteMiddleware 拦掉。如果 iframe 内容是 JS 注入的response.follow也拿不到这时才需要scrapy-playwright配置在DOWNLOAD_HANDLERS里挂PlaywrightHandler并在 Spider 上加meta{playwright: True}。3.4 澎湃正文与评论接口分离澎湃正文在div.news_txt评论走cont/comment接口需要先拿contId。# spiders/thepaper.py import scrapy from mynews.items import NewsItem class ThepaperSpider(scrapy.Spider): name thepaper allowed_domains [thepaper.cn] start_urls [https://www.thepaper.cn/] def parse(self, response): for href in response.xpath(//a[contains(href,newsDetail_forward)]/href).getall(): yield response.follow(href, self.parse_detail) def parse_detail(self, response): item NewsItem() item[source] thepaper item[url] response.url item[title] response.xpath(//h1/text()).get(default).strip() paras response.xpath(//div[contains(class,news_txt)]//p/text()).getall() item[content] \n.join(p.strip() for p in paras if p.strip()) item[publish_time] response.xpath( //div[contains(class,news_about)]//text()).get(default) # contId 在 URL 里如 newsDetail_forward_12345678 cont_id response.url.split(_)[-1] api fhttps://api.thepaper.cn/cont/comment?contId{cont_id}pageSize20 yield scrapy.Request(api, callbackself.parse_comment, cb_kwargs{item: item}) def parse_comment(self, response, item): comments [] try: data response.json() for c in data.get(data, {}).get(list, []): comments.append({ user: c.get(nickName, ), text: c.get(content, ), time: c.get(createTime, ), }) except Exception: pass item[comments] comments yield item逻辑说明cont_id从 URL 尾部切比从页面正则抠稳。参数上pageSize20按接口实际支持改超了会被截断。评论接口返回结构可能变所以整段包 try失败就存空列表不阻断正文入库。4. 反爬、去重与入库三个必调参数4.1 请求头与下载延迟四站都会看User-Agent和请求频率。默认 Scrapy UA 是Scrapy/x.x直接暴露。在settings.py里改# settings.py USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 DOWNLOAD_DELAY 1.5 RANDOMIZE_DOWNLOAD_DELAY True CONCURRENT_REQUESTS_PER_DOMAIN 4 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1 AUTOTHROTTLE_MAX_DELAY 10逻辑说明DOWNLOAD_DELAY配合RANDOMIZE_DOWNLOAD_DELAY让间隔在 0.75~1.5 倍之间浮动比固定间隔更难被识别。CONCURRENT_REQUESTS_PER_DOMAIN压到 4是因为四站对单 IP 并发都敏感跑太快直接 403。AUTOTHROTTLE是后悔药被限速时自动降速不用手动调。4.2 去重URL 指纹 数据库唯一索引Scrapy 自带的dupefilter只在单次运行内有效重启就失效。跨运行去重得靠数据库唯一索引。# pipelines.py import hashlib import pymysql class MySQLPipeline: def open_spider(self, spider): self.conn pymysql.connect( host127.0.0.1, userroot, passwordyourpass, databasenews, charsetutf8mb4) self.cur self.conn.cursor() def process_item(self, item, spider): url_hash hashlib.md5(item[url].encode()).hexdigest() sql INSERT IGNORE INTO news (url_hash, source, title, content, publish_time, url, comments) VALUES (%s,%s,%s,%s,%s,%s,%s) self.cur.execute(sql, ( url_hash, item[source], item[title], item[content], item[publish_time], item[url], str(item[comments]))) self.conn.commit() return item def close_spider(self, spider): self.cur.close() self.conn.close()逻辑说明INSERT IGNORE配合url_hash唯一索引重复 URL 直接跳过不用先查再插。参数上charsetutf8mb4必须写否则评论里的 emoji 会报错。comments存成字符串是简化处理要查询方便可以另开一张评论表。4.3 评论接口的 Referer 与 Cookie网易、澎湃的评论接口都校验Referer部分还看 Cookie。最省事的做法是在settings.py开COOKIES_ENABLED True让 Scrapy 自动带上详情页拿到的 Cookie再在请求里手动补Referer。如果接口返回 403 且 body 是空 JSON八成是这两个头没带对先查这里再怀疑 IP。5. 避坑与排查五个真实翻车记录5.1 现象正文抓下来是空的XPath 明明对原因页面是 JS 渲染response.text里根本没有正文节点XPath 自然匹配不到。解决先用scrapy shell打开目标 URL执行view(response)看渲染后的 DOM如果正文不在里面就换移动端接口或上scrapy-playwright。别硬调 XPath方向错了调不出来。5.2 现象评论接口返回 200 但数据为空原因Referer缺失或docid/contId抠错。解决把请求 URL 复制到浏览器直接访问能出数据说明是请求头问题加Referer浏览器也空说明 ID 错了回详情页重新定位 ID 来源。我一般会在 Spider 里打一行spider.logger.info(comment_api)确认拼出来的 URL 长什么样。5.3 现象跑几百条后被封返回 403原因并发太高或没开自动限速。解决CONCURRENT_REQUESTS_PER_DOMAIN降到 2DOWNLOAD_DELAY提到 2 秒开AUTOTHROTTLE。如果还封检查是不是同一 IP 短时间请求了太多不同站点考虑按站点分批次跑别四个 Spider 同时开。5.4 现象时间字段入库后全是空原因站点时间格式和PATTERNS对不上比如凤凰用2024年01月01日 10:00但正则写的是两位月。解决在TimeNormalizePipeline里加日志把匹配失败的原始字符串打出来照着实际格式补正则。别猜格式打出来看。5.5 现象评论里的 emoji 入库报错原因数据库或表字符集不是utf8mb4。解决建库建表时显式指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci连接串也带charsetutf8mb4。这个坑一次踩过就忘不了建表脚本里直接写死。6. 让采集结果能直接喂给 NLP清洗与增量技巧爬到数据只是第一步真正让这个项目有价值的是采完能直接用。我一般会在 Pipeline 链末尾再加一个清洗环节把正文里的责任编辑xxx扫描二维码这类噪声去掉再按句号切分方便后面做 NLP 新闻处理。# pipelines.py import re class CleanPipeline: NOISE [ r责任编辑[:].*, r扫描二维码.*, r点击进入专题.*, r原标题[:].*, ] def process_item(self, item, spider): text item.get(content, ) for pat in self.NOISE: text re.sub(pat, , text) # 合并多余空行 text re.sub(r\n{2,}, \n, text).strip() item[content] text return item逻辑说明NOISE列表按实际抓到的噪声往里加别一次写太多容易误删正文。参数上re.sub默认贪婪匹配到行尾正好适合责任编辑xxx这种整行噪声。清洗放在入库前保证库里存的就是干净文本。增量采集是另一个实用技巧。四站列表页都有时间排序我一般记录上次跑到的最大publish_time下次只抓比它新的。实现上在 Spider 的start_requests里读数据库最大值列表页解析时遇到比它旧的就return不再往下翻。这样每天定时跑一次只增量拉新比全量重爬省太多。验证采集质量有个笨办法但很管用随机抽 20 条人工核对标题、正文首段、时间、评论条数四项错一项就回查对应 Spider。我自己的习惯是每次改完解析逻辑先scrapy crawl netease -s CLOSESPIDER_ITEMCOUNT20只跑 20 条确认字段都对再放全量。这个习惯帮我省过好几次跑了一夜发现字段全错的后悔药。希望帮到你。本文还有配套的精品资源点击获取