简介:这是一份基于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_row | div.post_body | div.post_info内文本 | 独立接口comment/api |
| 搜狐 | 服务端渲染,li.news | article.article | span.time | 页面内嵌 JSON |
| 凤凰 | 部分栏目 iframe 嵌套 | iframe 内div.main_content | span.ss01 | 独立接口 |
| 澎湃 | 服务端渲染 + JS 补全 | div.news_txt | div.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[@class='data_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[@class='post_body']//p/text()").getall() item["content"] = "\n".join(p.strip() for p in paras if p.strip()) item["publish_time"] = response.xpath( "//div[@class='post_info']/text()").get(default="") # 从 URL 抠 docid,如 .../A1B2C3D4.html docid = response.url.rsplit("/", 1)[-1].replace(".html", "") comment_api = f"https://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/{docid}/comments/newList?offset=0&limit=20" yield scrapy.Request(comment_api, callback=self.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 透传到评论回调,避免二次请求详情页。参数上limit=20是接口默认上限,要更多得翻offset;Referer必须带,否则接口返回空。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[@class='time']/text()").get(default="") # 从页面内嵌 JSON 抠评论 m = re.search(r"window\.__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[@class='ss01']/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 = f"https://api.thepaper.cn/cont/comment?contId={cont_id}&pageSize=20" yield scrapy.Request(api, callback=self.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 尾部切,比从页面正则抠稳。参数上pageSize=20按接口实际支持改,超了会被截断。评论接口返回结构可能变,所以整段包 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( host="127.0.0.1", user="root", password="yourpass", database="news", charset="utf8mb4") 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 直接跳过,不用先查再插。参数上charset="utf8mb4"必须写,否则评论里的 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降到 2,DOWNLOAD_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,连接串也带charset="utf8mb4"。这个坑一次踩过就忘不了,建表脚本里直接写死。
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_ITEMCOUNT=20只跑 20 条,确认字段都对再放全量。这个习惯帮我省过好几次"跑了一夜发现字段全错"的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取