简介:东方财富网股吧数据抓取是一套基于Scrapy框架的Python爬虫工程,面向希望系统学习财经社区数据采集的爬虫初学者与数据分析人员,可解决股吧帖子、评论等公开信息的结构化抓取与存储问题。完整工程包含十四个文件,以八个Python脚本为主,涵盖项目调度、数据管道、下载中间件、爬虫核心等模块,另附配置文件、脚本备份、说明文档及知识拓展压缩包,整体约两MB,结构紧凑便于研读。目前已有157人浏览学习,适合作为快速上手Scrapy爬虫的参考案例。从项目结构到具体实现,读者可学到设置加载、中间件处理、数据清洗、爬虫编写等完整流程,并依据说明文档快速复现运行环境;压缩包内的知识拓展资料也有助于梳理爬虫工程化与合规采集的要点。
1. 东方财富网股吧数据抓取:先看清数据形态再动手
股吧里的帖子本质上是一份持续产出的市场情绪样本:标题是事件标签,阅读量是热度,回复量是分歧度,回帖的先后顺序还会暴露多空切换的痕迹。把东方财富网股吧数据抓取下来做投研、做舆情监控,是很多财经数据团队的常规起点。但这件事没有看起来那么轻松——列表分页很浅,热门股帖子密度大,页面结构每隔一阵就会调整,一次性的脚本抓两天就断了。这篇文章会按“分析数据形态、做最小请求、处理反爬、设计增量”的顺序,把整个链路讲清楚,适合有 Python 基础、准备让抓取长期运转的工程或分析人员。文章只覆盖数据抓取部分,交易策略不在范围里。
2. 东方财富网股吧的数据形态:列表、详情与接口
拿到一个抓取任务,我不会急着写 requests.get,而是先把页面打开看三样东西:内容在哪个标签里、翻页后 URL 和参数怎么变、刷新时发了哪些请求。股吧的内容分布在两种页面里——列表页和详情页,它们承载的信息类型完全不同,抓取策略也因此不同,这个顺序不能颠倒。
2.1 列表页与详情页各取所需
列表页展示的是某一支股票下的全部讨论,通常按发布时间倒序排列,一页能拿到标题、作者、阅读量、回复数和相对时间。做热度监控只用这批字段就够,字段密度高、请求成本低。详情页包含帖子正文和完整回帖序列,适合做语义分析,但每篇详情要单独发一个请求,抓一千条列表数据再去拉详情,请求量会放大十倍以上。我的习惯是先抓列表,把子集筛出来,再决定要不要补详情。
| 数据位置 | 可获取字段 | 典型用途 | 请求成本 |
|---|---|---|---|
| 列表页 | 标题、作者、阅读量、回复数、发帖时间、帖子链接 | 热度排序、情绪计数、事件跟踪 | 低,一页一次 |
| 详情页 | 正文、逐条回帖、点赞数、楼层 | 观点挖掘、多空统计、KOL 分析 | 高,一帖一次 |
这样把任务拆开之后,后续选题就清晰了:长期监控跑列表页,定量研究挑部分帖子抓详情。很多抓取项目失败不是因为反爬,而是因为一开始就把列表和详情混在一起抓,请求总量失控。
2.2 用浏览器开发者工具还原一次真实请求
打开任意一个热门股的股吧页面,按 F12 进入开发者工具,切到 Network 面板,勾上 Preserve log 后刷新页面。把类型过滤条件固定为 Doc,就能看到当前页面在加载时发出的主文档请求。点击该请求,在 Response 标签页里直接看响应体:如果返回的 HTML 里已经包含帖子列表,属于服务端渲染;如果响应体里只有空壳和脚本引用,而页面上却有内容,说明内容来自异步接口,需要去 Fetch/XHR 分类里找返回 JSON 的请求。
习惯了用小工具抓包的同事也喜欢用 Charles 这类抓包软件,思路一样:把浏览器或客户端的 HTTPS 流量接到 Charles 上,按域名过滤请求就能看到数据从哪个接口回来。不过日常调试我一般直接用浏览器自带的工具,免去安装证书的步骤,定位更快。开发者工具里还能直接看到请求头和响应头,这对后面构造抓取请求非常有用。
2.3 静态渲染与异步接口的判断标准
判断方法很直接:在 Elements 面板里搜一个你在页面上肉眼可见的帖子标题,搜不到就说明是异步渲染,页面里的列表是脚本后来填进去的。另一个判断点是翻页时注意看 URL 里的页码参数,如果页码变了但页面内容没按预期变化,大概率内部走了异步请求。异步请求的返回体通常是 JSON,比解析 HTML 更规整,字段名稳定,我反而更倾向于抓接口。
真实地址以你本地抓到的请求为准,不同入口可能不完全一样。抓包得到的查询参数要原样保留,常见的包括股票代码、页码、排序方式等。把这些信息记下来之后,代码里要还原的就是三个要素:请求 URL、查询参数、必要的请求头。绝大多数反爬问题都发生在这三个要素和浏览器不一致时,这点在后面的章节还会展开。
3. 用 Python 实现股吧抓取最小闭环:请求、解析、入库
现在拿一支股票把流程走通。以平安银行(股票代码 000001)为例,列表页的 URL 形如https://guba.eastmoney.com/list,000001_1.html,下划线后面的数字是页码。不同时期入口可能有变,以你抓到的真实地址为准。先请求列表页,解析出帖子信息,再落到 SQLite,这是整个抓取项目的最小可运行版本。
3.1 先写请求函数:编码和超时是默认项
import time import random import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36", "Referer": "https://guba.eastmoney.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Accept-Encoding": "gzip, deflate", } def fetch_list_page(stock_code: str, page: int) -> str: url = f"https://guba.eastmoney.com/list,{stock_code}_{page}.html" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text这里做了几件容易被忽略的事。请求头里必须带完整的 User-Agent,不带的话很多站点直接返回 403;Referer 填站点根地址,让请求来源看起来是从股吧主页点进去的;Accept-Encoding 只保留 gzip 和 deflate,因为 requests 对 br 压缩的支持需要额外装 brotli,否则响应体解码会乱。timeout=10 表示连接和读取各给 10 秒,不给超时时间会让任务挂在异常网络上,日志也看不出问题。resp.encoding 手动指定为 utf-8,防止页面内容里出现中文乱码。
返回的字符串只代表请求成功,不表示内容一定可解析。后面解析之前还要做一道校验:看响应长度是否过短,或是否包含人机校验提示文案。第一次跑通时异常可以直接抛出来,便于看到问题,后续再补异常处理。
3.2 解析列表页:选择器以实测为准
解析我用 BeautifulSoup 加 lxml 引擎,选择器写起来直接。先建一个解析函数,把列表里的每一行读出来。
from bs4 import BeautifulSoup def parse_list_page(html: str): soup = BeautifulSoup(html, "lxml") posts = [] # 下面的选择器必须与你抓到的页面结构一致,先用开发者工具复制 for row in soup.select("div.article_list div.item"): title_node = row.select_one("a.title") if not title_node: continue title = title_node.get_text(strip=True) link = title_node.get("href") author_node = row.select_one("span.author a") author = author_node.get_text(strip=True) if author_node else "" read_node = row.select_one("span.read") reply_node = row.select_one("span.reply") read_val = read_node.get_text(strip=True) if read_node else "0" reply_val = reply_node.get_text(strip=True) if reply_node else "0" posts.append({ "title": title, "link": link, "author": author, "read": read_val, "reply": reply_val, }) return posts选择器部分一定以实际页面为准。现在的股吧前端经常调整 class 名,直接套用别人的 CSS 选择器很容易拿到空列表。我的做法是先在开发者工具里右键目标元素,选择 Copy selector,再贴进来运行,看到非空结果后再固化到代码里。这里也顺带处理了脏数据:某个字段不存在时用空字符串或 0 兜底,之后入库不会因为 None 报错。
阅读量在很多页面里显示成“1.2万”,入库前可以写个转换函数,但第一次跑通链路时先保留原始字符串更省事。清洗逻辑和数据抓取逻辑分开维护,后面改口径只动一个函数,不用重抓数据。
3.3 写入 SQLite:建表加唯一约束一次做对
存数据我会优先选 SQLite,单文件、零部署、适合抓取任务的规模。
import sqlite3 conn = sqlite3.connect("guba.db") conn.execute(""" CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, stock_code TEXT, title TEXT, link TEXT UNIQUE, author TEXT, read_num TEXT, reply_num TEXT, created_at TEXT, fetched_at TEXT DEFAULT (datetime('now', 'localtime')) ) """) def save_posts(conn, stock_code: str, posts: list): cur = conn.cursor() for p in posts: cur.execute( "INSERT OR IGNORE INTO posts " "(stock_code, title, link, author, read_num, reply_num, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (stock_code, p["title"], p["link"], p["author"], p["read"], p["reply"], time.strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit()表结构里设置了 link 字段的 UNIQUE 约束,配合 INSERT OR IGNORE 实现最简单的去重:同一个帖子链接出现第二次时,数据库直接忽略,不会报错。这样重复抓取同一页也不会让库里的记录翻倍。第一次跑通时不要追求把所有字段都预处理成干净类型,先把原始数据完整落下来,字段口径后面再统一清洗。创建表和保存数据分开两个函数,方便以后把 SQLite 换成 MySQL 时只改入库这一层。
到这里,抓一页列表并入库的闭环就通了。在主程序里按顺序调用 fetch_list_page、parse_list_page、save_posts,打印一下返回的帖子数量就能验证。下一步要解决的,是抓几页之后被拦下来的情况。
4. 东方股吧反爬识别与访问频率控制:请求头、限速与重试
股吧页面本身不会像电商平台那样上强度很大的验证码,但请求频率一高,会在响应里出现要求滑动验证或者直接 403。处理反爬的思路不是绕,而是让请求行为和普通阅读尽量一致,再把异常情况用代码兜住。这套思路对绝大多数资讯类站点的抓取都适用,不只是股吧。
4.1 请求头字段别乱填,关键是和浏览器一致
先看一份我常用的关键头配置:
| 请求头字段 | 推荐值 | 作用说明 |
|---|---|---|
| User-Agent | 完整浏览器 UA,含版本号 | 缺失或伪造过短会立刻被识别为脚本 |
| Referer | 股吧首页或当前列表页地址 | 拒绝来源为空的请求是常见网关策略 |
| Accept | text/html,...;q=0.9,/;q=0.8 | 告知服务端期望返回的格式 |
| Accept-Language | zh-CN,zh;q=0.9 | 与访问地区一致,避免被安全策略挑出来 |
| Accept-Encoding | gzip, deflate | 不写 br,避免响应体无法解压 |
请求头不是越多越安全,关键在于和真实浏览器发出的请求保持一致。有一个常见误操作是把 curl 里那一串过长的头原样复制进 requests,里面包含一些 requests 不会默认处理的字段,反而让行为变得更怪。我一般只保留上表五个字段加 Cookie,其他字段交给库去处理。Cookie 方面,第一次抓取不需要主动带,被验证码拦住后再考虑通过登录态获取。
提示:请求头不是越多越好,保持与真实浏览器一致即可。
4.2 限速与随机延时:最有性价比的参数调节
访问频率是触发反爬的第一因素。把抓取间隔写成固定值等于给自己留特征,用随机区间能让请求时间轴更贴近人工操作。下面这段代码把延时放在请求之前,每次都从区间里取一个随机秒数。
def fetch_with_pacing(stock_code, page, min_interval=1.5, max_interval=3.5): time.sleep(random.uniform(min_interval, max_interval)) url = f"https://guba.eastmoney.com/list,{stock_code}_{page}.html" try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: print(f"page {page} status {resp.status_code}") return None # 内容层面的风控提示往往也是 200 返回 if "访问过于频繁" in resp.text or "验证码" in resp.text[:2000]: print(f"page {page} blocked") return None resp.encoding = "utf-8" return resp.text except requests.RequestException as exc: print(f"page {page} error {exc}") return None参数可以按场景调整:只抓列表页时 1.5 到 3.5 秒的间隔足够温和;如果要顺带抓详情页,详情请求要放到 5 秒以上,因为拿一篇详情对服务器来说成本更高,触发阈值更低。代码里对返回码和内容做了双重重试条件:返回 200 不代表真的成功,页面里出现风控提示文案时同样是失败。这个判断放在解析之前,免得把错误页面也交给 BeautifulSoup 解析。
第一个参数组合不要一上来就抓几百页,先用两三页验证间隔是否合理。如果前两页正常、第三页才开始拦截,说明触发点在页面数或者时间窗口而不在瞬时频率,此时适当把最大间隔往上调。批量跑的时候不要开多线程并发,股吧这类页面对 IP 维度的频率统计非常直接,单线程顺序抓是性价比最高的跑法。
4.3 出现 403、429 和超时后的退避重试
重试必须有退避,快速重试只会加剧封锁。给请求函数加一个重试层:第一次失败等 1 到 2 秒,第二次等 3 到 5 秒,第三次等 7 到 9 秒。这个时间大约是 2 的 n 次方,每次都叠随机数,让重试节奏也不规律。
def fetch_with_retry(stock_code, page, max_retries=3): for attempt in range(max_retries): html = fetch_with_pacing(stock_code, page) if html is not None: return html wait = 2 ** attempt + random.random() * 0.5 print(f"page {page} fail, retry in {wait:.1f}s") time.sleep(wait) return None重试次数给 3 就够。有的页面会持续封一段时间,重试到第三次还没过不如先停一分钟再从当前页继续。任务运行中我还会把最近失败页号记到一个列表里,待全部抓完后再单独补跑。比起让主流程反复撞墙,这种“失败页集中重放”的方式效率高很多。反爬最终的目标不是对抗,而是把请求控制在对方允许的阈值内,重试只是在边界处兜底。
5. 增量更新与任务调度:让股吧抓取长期运转
5.1 按活跃窗口增量拉取
股吧列表页按时间倒序,热门帖子的生命周期基本只集中在最新几页,因此增量抓取不必从头扫历史页。我通常固定只抓前 3 到 5 页,每页间隔拉长到 3 秒以上,既能覆盖当天的新帖,也不会制造过多请求。这样跑出来的数据是滚动的,配合数据库主键去重,即可维持一份截至当天的帖子数据。列表页发布时间显示为“今天 08:12”“昨天 23:10”这类相对时间,入库时保留原文会更好,等需要按小时分析时再统一格式化。
5.2 用帖子链接指纹去重
第 3 章去重依赖 link 字段的唯一约束,这里把它固化成一个通用做法:取帖子链接的后半段作为稳定 ID,计算指纹后落库。
import hashlib def post_id_from_link(link: str) -> str: basename = link.rstrip("/").rsplit("/", 1)[-1] return hashlib.md5(basename.encode("utf-8")).hexdigest()指纹优先用 URL 里的稳定片段,不要只用标题加作者,因为股吧里标题重复的情况并不少,转载帖更是常见。URL 里的帖子 ID 是唯一的,即使页面改版导致某些字段位置变化,ID 一般也不会变。
5.3 cron 调度与日志
落地长期运行时,我一般用 cron 每天固定时间跑一次。
15 9 * * * cd /opt/guba && /usr/bin/python3 run.py >> /opt/guba/logs/run.log 2>&1cron 里必须写绝对路径,环境变量也要在脚本内重新声明,否则可能遇到找不到 Python 或第三方库的问题。日志里每次运行打印抓取页数、成功条数和失败页号,跑完看一眼行数变化就能确认数据在持续累积。日志里出现连续的 HTTP 200 且数据库行数递增,就说明任务在按预期工作。
本文还有配套的精品资源,点击获取