☰
Scrapy实战:东方财富股票评论Ajax接口抓取与反爬应对
2026/10/28 23:51:36 网站建设 项目流程

简介:这是一份基于Scrapy框架的东方财富股票评论数据爬虫源码,适合想学习金融数据采集或情感分析的Python爬虫开发者。项目围绕批量抓取东方财富网股票评论展开,覆盖Spider解析规则、Item定义、Pipeline清洗与情感分析、Downloader Middleware反爬配置等核心模块,能帮助读者快速搭建可运行的评论采集流程。压缩包共17个文件,以Python源码(含7个py与7个pyc)、2个txt说明文件和1个cfg配置文件为主,整体仅71KB,结构紧凑,适合对照源码理解Scrapy项目的工程组织方式。包内还涉及jieba分词、情感词典等中文评论处理思路,并提醒遵守robots协议与隐私合规,兼顾实用性与规范性。目前已有4073人学习下载,对于希望入门Scrapy或扩展金融舆情分析能力的开发者而言,是一份轻量且完整的参考实现。

1. 东方财富股票评论数据的抓取到底难在哪:不是 HTML,是那条 Ajax 接口链路

做股票评论情感分析的人,第一反应通常是先做一个可视化界面,把评论标题、点赞数、发布时间摆到页面上。真正把时间拖住的从来不是展示层,而是底层数据能不能稳定拿到。东方财富股票评论数据的页面看着是静态的,其实评论列表是异步加载的,HTML 源码里根本没有完整评论;这套基于 Python 爬虫 + Scrapy 框架的抓取资源,就是围绕「找到真实 Ajax 接口,把分页、反爬、落库串成一条可靠链路」这件事展开的。适合要做舆情因子、股吧情绪指标、评论情感分类训练集的人,也适合只想系统学一遍 Scrapy 工程化的爬虫新手。

2. 评论数据的真实来源:从 XHR 抓包到 JSON 字段拆解

2.1 页面源码里的评论是「假象」:先学会看 Network 面板

东方财富的个股评论页打开时,服务器返回的只有页面骨架和一堆 JS 脚本。评论列表是页面加载完成后由 JavaScript 发起 XHR 请求,拿到 JSON 数据后再渲染到 DOM 上。所以你在 Elements 面板里看到的评论,不代表它在页面源码里存在;用 requests 直接 GET 那个 URL,得到的 HTML 里根本搜不到「reply_id」「like_count」这类字段。

正确做法是用 Chrome DevTools 的 Network 面板,过滤类型选 XHR,然后刷新页面。你会看到几个返回 JSON 的连接,其中带code=600519这类请求参数的就是评论接口。关键不是去看 Response 里渲染好的 HTML,而是看这条请求的 Query String Parameters。

我一般会先把接口完整 URL 复制出来,把所有参数原样保留,再用一个独立的 requests 脚本去请求一次,确认能拿到数据。这个「确认」动作省掉了我后面很多反复调试的时间——很多人是直接开写 Scrapy,结果跑半天发现是接口地址抄错了。

2.2 评论接口的关键参数与响应结构

评论接口的请求参数和响应结构,决定了下游所有代码怎么组织。抓包后你会发现参数不多,但每个都直接影响数据和翻页逻辑:

参数示例值作用
code600519股票代码,决定抓哪只股票
sort1排序方式,常见 1 为按时间倒序,2 为按热度,具体以抓包实际值为准
p1页码,从 1 开始
ps50页大小,常见可接受 10 / 20 / 50,个别场景下设置过大反而会被截断

不同栏目(个股吧、大盘吧、热闹话题)的域名和后端字段命名不完全一致,这是拆解这类站点的常态。我在代码里一律用dict.get()做兜底,不假设每个字段一定存在。

响应是一个标准的 JSON 结构,核心数据在data.reply数组中。每条评论常见包含:

  • reply_id或id:评论唯一编号,是去重和增量更新的关键
  • content:评论正文,可能包含 HTML 实体
  • like_count或comment_like_count:点赞数
  • post_publish_time:发布时间,常见为 10 位或 13 位时间戳
  • user_id:用户标识,注意有的评论这条字段会缺失

2.3 单页 requests 验通:写 Scrapy 之前的五分钟确认

在动手写 Scrapy 工程之前,先用 requests 把单页数据验通。这一步能提前暴露接口地址、字段结构、编码三个层面的问题。

import requests url = "https://comments.pinglun.eastmoney.com/api/getComments" params = { "code": "600519", # 股票代码 "sort": "1", # 排序方式,按时间倒序 "p": "1", # 第一页 "ps": "10", # 每页 10 条,先验通逻辑 } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Referer": "https://guba.eastmoney.com/list,600519.html", "X-Requested-With": "XMLHttpRequest", } resp = requests.get(url, params=params, headers=headers, timeout=10) print(resp.status_code) print(resp.text[:500])

这里的关键是Referer必须指向股吧页面本身,很多评论接口对Referer做轻量校验。p和ps先用小值只是为了快速确认链路,真正跑全量时再按需调。如果这一步返回的不是 JSON 而是 HTML,先检查是不是被重定向到了验证页;返回正常后,再对data.reply字段做一次遍历打印,确认content和reply_id的准确名称。

2.4 为什么是 Scrapy 而不是 requests 循环

单页验证用 requests 足够,但一旦进入多只股票、几百页评论、每页 50 条的场景,requests 循环的短板就很明显:没有调度器,没有去重,没有失败重试,数据存储逻辑全得自己写在 for 循环里。Scrapy 把这些能力做成了框架内置件,你要做的只是实现 Spider 和 Pipeline。

能力requests 循环Scrapy 框架
并发控制自己写线程池CONCURRENT_REQUESTS 配置项
失败重试自己写 try/except + 递归RETRY_ENABLED / RETRY_TIMES
请求去重自己维护 URL 集合内置 DUPEFILTER_CLASS
数据出口手写文件写入Item Pipeline 统一处理
横向扩展难以做到可换 scrapy-redis 实现分布式

评论接口是无状态的,请求参数里只有股票代码和页码,不依赖登录态和 Cookie 链,这意味着它天然适合横向扩展。用 scrapy-redis 把调度器换成 Redis 队列后,多台机器可以同时消费同一批请求任务,这是 requests 脚本很难做到的。

3. Scrapy 工程落地:Item、Spider、Pipeline 三层怎么配合

3.1 先建骨架:项目结构与 Item 定义

创建工程后,目录结构是固定的。爬虫主体、数据契约、存储逻辑分别放在不同文件里,这是 Scrapy 工程化的核心习惯——不是把代码塞进一个文件跑通就结束。

scrapy startproject eastmoney_comments cd eastmoney_comments

目录里需要重点改写的只有四个文件:items.py定义数据字段、spiders/放爬虫主体、pipelines.py放清洗与存储、settings.py控制请求参数。下面先定义 Item:

import scrapy class EastmoneyCommentsItem(scrapy.Item): comment_id = scrapy.Field() # 评论唯一编号,去重依赖它 stock_code = scrapy.Field() # 股票代码,来源是请求参数 content = scrapy.Field() # 评论正文 like_count = scrapy.Field() # 点赞数 post_time = scrapy.Field() # 发布时间,统一成 ISO 格式字符串 user_id = scrapy.Field() # 用户标识,允许为空

Item 是数据契约,相当于在代码里把「这条数据长什么样」写清楚了。后续无论是接 MySQL、CSV 还是消息队列,Pipeline 都拿同一个 Item,不会因为存储目标变化而改动 Spider。这也是为什么不建议在 Spider 里直接写入库代码——一旦要加清洗、去重或切换落库目标,动的是管道而不是爬虫主体。

3.2 爬虫主体:start_requests 动态传参与翻页闭环

爬虫主体的核心是 start_requests 动态构造请求,以及翻页闭环。不要写死 URL 列表,因为你要把股票代码带进回调函数,方便每条评论记住自己属于哪只股票。

import json import time import scrapy from eastmoney_comments.items import EastmoneyCommentsItem class EastmoneyCommentSpider(scrapy.Spider): name = "eastmoney_comment" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.stock_codes = ["600519", "000001", "300750"] self.seen_comment_ids = set() # 整个爬虫生命周期去重 self.max_page = 500 # 防御性上限,防止失控翻页 def start_requests(self): base_url = "https://comments.pinglun.eastmoney.com/api/getComments" for code in self.stock_codes: yield scrapy.Request( url=base_url, method="GET", headers={ "Referer": f"https://guba.eastmoney.com/list,{code}.html", "X-Requested-With": "XMLHttpRequest", }, cb_kwargs={"stock_code": code, "page": 1}, dont_filter=True, ) def parse(self, response, stock_code, page): if page > self.max_page: return try: payload = json.loads(response.text) except json.JSONDecodeError: self.logger.warning("非 JSON 响应,可能触发风控: %s", response.url) return reply_list = (payload.get("data") or {}).get("reply") or [] if not reply_list: self.logger.info("第 %d 页无数据,停止翻页 stock=%s", page, stock_code) return for r in reply_list: comment_id = r.get("reply_id") or r.get("id") if not comment_id or comment_id in self.seen_comment_ids: continue self.seen_comment_ids.add(comment_id) content = (r.get("content") or "").strip() if not content: continue item = EastmoneyCommentsItem( comment_id=str(comment_id), stock_code=stock_code, content=content, like_count=r.get("like_count") or r.get("comment_like_count") or 0, post_time=r.get("post_publish_time"), user_id=r.get("user_id") or "", ) yield item time.sleep(0.6) yield scrapy.Request( url=response.url, method="GET", headers={ "Referer": f"https://guba.eastmoney.com/list,{stock_code}.html", "X-Requested-With": "XMLHttpRequest", }, cb_kwargs={"stock_code": stock_code, "page": page + 1}, dont_filter=True, )

这段代码有三个关键设计。第一,去重集合放在 Spider 实例上而不是每页新建,避免翻页过程中同一批数据被反复统计;第二,cb_kwargs是 Scrapy 2.x 推荐的传参方式,不用去改meta的 dict,回调函数签名里直接声明参数;第三,dont_filter=True是因为翻页请求的 URL 本身不变,只靠page参数区分,默认的去重器会把这些请求当成重复的而丢弃。

关于解析方式多说一句:评论接口返回的是 JSON,优先走 JSON 解析,字段稳定且不需要额外处理。但有些子栏目偶尔会返回 HTML 片段作为降级方案,那种场景退回 xpath 解析也能顶住:

# 备用方案:接口返回 HTML 片段时使用 divs = response.xpath('//div[contains(@class,"comment-item")]') for div in divs: content = "".join(div.xpath('.//span[contains(@class,"content")]//text()').getall()).strip()

xpath 里//text()的作用是取出匹配节点下的所有文本节点。比如一个评论内容被多个标签片段包裹,直接用text()只会取到第一层文本,后面的内容会丢;用.//text()配合.getall()才能拿到完整内容。这里踩过坑的同学应该都懂。

3.3 清洗与落库:Pipeline 是唯一出口

Pipeline 是数据离开 Spider 之后的统一处理层。清洗规则、去重补漏、存储方案都挂在这一层。用文件句柄写 CSV 是最简单的落地方式,也能直接看出数据结构是否符合预期:

import csv import html from datetime import datetime class EastmoneyCommentsPipeline: def open_spider(self, spider): self.file = open("eastmoney_comments.csv", "w", newline="", encoding="utf-8") self.writer = csv.DictWriter(self.file, fieldnames=[ "comment_id", "stock_code", "content", "like_count", "post_time", "user_id"]) self.writer.writeheader() def process_item(self, item, spider): content = item.get("content") or "" content = html.unescape(content).strip() # 解码 & 这类实体,去掉两端空白 post_time = item.get("post_time") if post_time: try: ts = int(post_time) if ts > 10_000_000_000: # 13 位毫秒时间戳 ts = ts / 1000 item["post_time"] = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") except (ValueError, OSError): item["post_time"] = None self.writer.writerow({ "comment_id": item.get("comment_id"), "stock_code": item.get("stock_code"), "content": content, "like_count": item.get("like_count"), "post_time": item.get("post_time"), "user_id": item.get("user_id"), }) return item def close_spider(self, spider): self.file.close()

处理post_time时注意时间戳位数:10 位是秒,13 位是毫秒。直接拿去格式化,前者会有 1000 倍的偏差,出来的日期会直接飞到 2001 年。content做html.unescape是为了把&、<这类实体还原成正常字符,不处理的话落库后全是乱码残渣。

字段清洗完成后再考虑存储。如果接 MySQL,主键尽量用comment_id,写入语句用INSERT IGNORE:

INSERT IGNORE INTO stock_comments (comment_id, stock_code, content, like_count, post_time, user_id) VALUES (%s, %s, %s, %s, %s, %s)

INSERT IGNORE的好处是重复任务重跑时,碰到已存在的comment_id直接跳过而不是报错中断。爬虫增量任务一定会重跑,这个写法能保证任务可控。最后记得在 settings.py 里打开对应的 Pipeline:

ITEM_PIPELINES = { "eastmoney_comments.pipelines.EastmoneyCommentsPipeline": 300, }

数字 300 表示执行顺序,越小越靠前。如果有多个 Pipeline 叠加,比如先清洗再入库,就按顺序配 100、200、300。

4. 把请求链路调稳:Headers、代理、限速与 Scrapy 扩展点

4.1 请求头与 Referer:裸 UA 跑不了几页

很多人写爬虫习惯只挂一个User-Agent,其余请求头全裸奔,这在做深度分页时就是白白送命。评论接口对请求头不算苛刻,但Referer是校验重点。请求来源如果不是东方财富的股吧页面,很容易被挡在网关层。

settings.py 里用DEFAULT_REQUEST_HEADERS统一配:

DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://guba.eastmoney.com/", "X-Requested-With": "XMLHttpRequest", }

需要注意DEFAULT_REQUEST_HEADERS会被 Spider 里每个请求继承,但如果你在scrapy.Request的headers参数里覆盖了某个字段,以单个请求为准。我在 start_requests 里重复写Referer是为了针对不同股票代码拼出精确的列表页地址,比统一设置更稳妥。

4.2 中间件和 Extensions 的区别:重试、代理、统计挂在哪

要理解 Scrapy 的两个扩展点:Downloader Middleware 作用在请求/响应这条链路上,适合做加代理、拦截 403、改重试次数;Extensions 作用在整个引擎生命周期,适合做统计数据、开关通知这类全局任务。把两者混在一起用是新手常见的问题——在 Middleware 里统计全量数据,结果输出维度不够,还得再改一版。

先写一个下载中间件,处理代理和风控状态码:

class ProxyRetryMiddleware: def process_request(self, request, spider): proxy = spider.proxy_pool.get() if hasattr(spider, "proxy_pool") else None if proxy: request.meta["proxy"] = proxy return None def process_response(self, request, response, spider): if response.status in (403, 429): request.meta["retry_times"] = request.meta.get("retry_times", 0) + 1 if request.meta["retry_times"] <= 3: return request return response

这里process_response遇到 403/429 时把请求原样返回,Scrapy 会把它重新放回调度队列。修改次数上限控制在 3 次,再多就很可能是 IP 被限频,单纯重试没有意义,要换代理或降速。

后台评论接口结构如果升级,改成 iframe 内嵌渲染且接口参数被前端加密,这类纯 Ajax 方案会失效。常见的兜底做法是用 playwright 渲染页面,等 iframe 内容加载完成后截获真实请求参数,再把参数注入 Scrapy 的请求。这套方案的代价是速度会慢很多,但作为「框架能力之外的最后一道保险」是值得保留的。

Extensions 的使用场景是另一面。比如你想看每只股票实际抓到多少条评论,可以在 Spider 里维护一个计数器,再用 Extension 监听spider_idle信号输出结果:

from scrapy import signals class CommentStatsExtension: def __init__(self, stats): self.stats = stats @classmethod def from_crawler(cls, crawler): ext = cls(crawler.stats) crawler.signals.connect(ext.spider_closed, signals.spider_closed) return ext def spider_closed(self, spider): count = self.stats.get_value("item_scraped_count", 0) spider.logger.info("总抓取条数: %d", count)

所以记住:Middleware 管单条请求的生死,Extension 管整个任务的结果。界线划清楚,代码后面维护起来不混乱。

4.3 限速与增量抓取的节奏控制

settings.py 里有几个配置直接决定你能不能稳定跑完一个全量任务:

CONCURRENT_REQUESTS = 8 CONCURRENT_REQUESTS_PER_DOMAIN = 4 DOWNLOAD_DELAY = 0.8 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 0.5 AUTOTHROTTLE_MAX_DELAY = 5.0 RETRY_ENABLED = True RETRY_TIMES = 3

DOWNLOAD_DELAY是每个域名下的全局延迟,AUTOTHROTTLE会根据响应速度动态调整延迟。两者同时开着,Scrapy 会自动取两者中更严格的那个,所以不用担心配置冲突。首次跑全量时建议把并发压到 8 以内,等日志稳定了再逐步上调。

5. 避坑记录:东方财富评论接口最容易翻车的五个位置

5.1 302/403 反复出现,日志里全是 0 条

现象:任务启动后大量请求返回 302,跟进重定向后是 403,日志里一条评论都没抓到。

原因:请求头不完整是主因,尤其是Referer。评论接口对来源页面做了轻量校验,请求头里只有裸 UA 或者Referer指向首页,都会被网关拦下。另一个原因是并发开得太猛,短时间请求频率超过阈值。

解决:在DEFAULT_REQUEST_HEADERS里补上完整的请求头,Referer指向对应股票代码的股吧列表页;同时把CONCURRENT_REQUESTS_PER_DOMAIN降到 4,DOWNLOAD_DELAY调到 0.8 以上。改完先单只股票跑 50 页,确认日志里没 302 了再放量。

5.2 返回的不是 JSON 而是 HTML 验证页

现象:response.text 开头是<!DOCTYPE html>,JSON 解析直接抛异常,代码里没有任何输出。

原因:IP 被临时限频,或请求触发了站点安全策略。注意这里说的不是登录墙,而是服务端根据请求频率和头部特征返回了验证页。

解决:先在浏览器手动打开评论接口 URL,确认自己是否还能正常访问。能访问就说明是请求特征被识别,优先降速;频繁出现则给下载中间件挂代理,让请求从不同出口 IP 出去。另外代码里对 JSON 解析要做异常保护,解析失败时写 WARNING 日志并return,而不是让整个任务崩溃。

5.3 评论内容乱码与 HTML 实体残留

现象:CSV 里中文是正常的,但评论里频繁出现&amp;、&lt;、&gt;这类字符串,还有一些\u开头的内容。

原因:接口返回的内容字段本身携带 HTML 实体,\u开头的是 JSON 里的 Unicode 转义。抓包时看 Response 里是正常字符,落库就变了,这是缺失清洗步骤的典型表现。

解决:Pipeline 里对content执行html.unescape(),对\u转义可以直接用json.loads(f'"{content}"')还原,或者统一用content.encode("utf-8").decode("unicode_escape")处理。文件输出侧记得固定encoding="utf-8",CSV 写入用newline=""避免空行问题。

5.4 深度分页触顶:页码到了某处就永远重复

现象:跑前 20 页正常,到第 50 页之后返回的评论内容和第 49 页完全一样,甚至空数组。

原因:评论接口对深度分页有限制,单靠p页码翻不了太多页。这类接口设计上更倾向游标分页,即每次传上次最后一条评论的时间或 ID,服务端从这个锚点往后取。

解决:观察接口是否支持传入时间游标参数。支持就把当前页最后一条评论的post_publish_time存下来,下一页请求带上这个值,替代页码递增。不支持就在代码里加一个停止条件:如果当前页所有评论的 ID 都已在去重集合里,立刻停止翻页,避免无效请求浪费配额。

5.5 user_id 字段时有时无,Item 直接抛异常

现象:跑了一段时间后 Pipeline 或 Scrapy 日志抛出 KeyError,任务中断,CPU 时间全浪费在已经抓到的数据上。

原因:评论数据里匿名用户的user_id字段可能缺失,后端对公开数据的字段裁剪不统一。代码里如果直接写r["user_id"],碰上这条评论没有该字段,Python 立刻抛 KeyError。

解决:统一用r.get("user_id") or ""这样的写法,所有可能缺失的字段都设默认值。同时content也要判空,有的评论被举报删除后只剩一条占位记录,只有 ID 没有正文,这类数据直接跳过就好。

6. 增量更新与数据校验:跑之前先看一眼时间锚点

6.1 用 max(post_time) 做增量锚点,而不是全靠页码

全量抓取跑通之后,第二个任务就是增量更新。新评论一直在产生,总不能每次把几百页全部重来一遍。常见做法是用 SQL 查出每只股票已有的最大评论时间,把它作为增量锚点,新任务只抓晚于这个时间的数据。

import requests anchor_time = None # 从库里读最近一条评论时间,localtime 转成接口可识别的时间戳 # 伪代码示意,实际取数逻辑要根据你用的数据库调整 anchor_time = get_max_post_time_from_db("600519") params = { "code": "600519", "sort": "1", "p": "1", "ps": "50", } if anchor_time: params["start_time"] = anchor_time # 参数名仅作示意,以抓包为准

注意增量抓取的停止条件要改成「当前页评论的最早时间已经小于锚点就停」,而不是等接口返回空数组。因为分页顺序通常按时间倒序,如果某一页的首条评论时间已经早于锚点,说明后面只会更旧,继续翻纯粹是浪费请求资源。

数据校验方面,我的习惯是启动任务前强制走一遍「唯一键约束 + 空值统计」。唯一键约束靠数据库的INSERT IGNORE兜底,空值统计则是检查落库后的content字段空值比例,如果超过 1%,先怀疑清洗逻辑而不是继续加量去抓。

有段时间我为了省事,把 600519 的全量数据重复跑了三遍,同一批评论在库里插了三份,后面统计评论情感分布时怎么算都不对,查了两小时才发现是重复数据污染。从那以后,我每次启动任务前强制走一遍「唯一键约束 + 时间锚点 + 空值统计」三件事,再也没翻过车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询