简介:这份东方财富股吧爬虫项目以Selenium模拟用户操作,抓取指定股票的帖子与评论信息,并写入MongoDB,支持多线程同时抓取多支股票。实现上分为入口、抓取、解析、存储等模块,另附readme.pdf与README.md详细操作说明、界面截图及报错排查示意,适合非科班新手入门或小规模采集。资源共14个文件,以Python脚本为主体,辅以文档、图片、JS脚本及许可证文件,压缩包约4.02MB,整体轻量易读。数据上,帖子保存标题、浏览量、评论数、链接、发帖时间等字段,评论区分一级/二级并记录点赞数与时间,通过post_id与帖子_id可建立关联。目前已有744人浏览学习,对想上手Selenium、MongoDB及爬虫入库流程的初学者,是一份可运行的参考实现。
1. 东方财富股吧爬虫落地:从帖子到评论再到 MongoDB
做股票数据分析的同行应该都有同感:真正有价值的信息不在行情快照里,而在股吧的帖子、回帖、点赞数、浏览数这些“非结构化”数据中。我拆过不少财经类爬虫项目,东方财富股吧算是比较典型的一个——它页面是异步渲染的,直接请求 HTML 拿不到帖子列表,抓包走 JSON 接口反而更干净,数据量适中、频率限制比微博宽松,特别适合做“帖子+评论+入库”的完整链路练习。这份资源的价值也正在于此:它不是只教你怎么解析一个页面,而是把「列表页抓取 → 详情页评论采集 → MongoDB 存储 → 断点续爬」整条流水线串起来,附带的详细操作说明基本能把从零到落地的每个环节交代清楚。适合刚啃完 requests 和 BeautifulSoup、想拿真实项目练手的人,也适合需要定期拉取股吧舆情数据做分析的一线开发。我按自己的习惯把整个项目重拆了一遍,下面把关键技术点和踩过的坑一起说清楚。
2. 摸清接口规律:先看懂股吧的数据链路
2.1 股吧的异步加载机制与传统爬虫的差异
打开东方财富股吧的任何一个股票讨论区,你会发现帖子列表和用户头像、点赞数都是分块出现的,页面滚动到底部才会加载下一页。这种表现说明数据不是服务端渲染的 HTML,而是前端通过 XHR 请求 JSON 接口动态填充的。用 curl 直接抓页面源码,只能拿到一个空壳和一堆 JS 引用,根本看不到帖子标题。
我一般会先打开浏览器的开发者工具,切到 Network 面板,刷新页面后过滤 XHR 请求,就能看到股吧的数据接口。这种接口通常返回的是标准的 JSON 结构,字段设计得相当规整——帖子 ID、标题、阅读数、评论数、作者、发布时间全部落在固定字段里。相比解析 HTML 那套正则加 XPath 的路子,直接消费 JSON 要省事得多,解析稳定性也高不少,因为页面改版往往只动模板,接口字段很少轻易变更。
资源里已经把这条接口规律整理成了文档,但你在自己写爬虫时也该养成这个习惯:任何异步加载的网站,第一步永远是找接口,而不是硬刚 HTML。找到接口之后,用 Postman 或者 requests 模拟一次请求,确认响应结构,后面的解析工作就顺手了。股吧的接口参数并不复杂,核心就是股票代码、页码、页面大小这几个字段。
提示:浏览器开发者工具里看到的所有请求头、Cookie、查询参数,都要在代码里一比一复刻,包括 User-Agent 和 Referer。缺少 Referer 时接口大概率返回 403。
2.2 关键参数拆解与分页策略
股吧的帖子列表接口用的是 GET 请求,查询参数集中在code、p、pageSize这几个字段上。code是股票代码,格式带交易所前缀,比如上海市场是sh600519,深圳市场是sz000001;p是页码,从 1 开始递增;pageSize是每页条数,常见做法是填 20 到 50 不等。接口返回的 JSON 里,data字段下的trends就是帖子数组,replies字段则代表这条帖子的回复总数。
分页策略上,我建议不要用固定循环次数,而是循环到接口返回空数组或trends长度为零时停止。有些帖子列表接口还会在返回数据里带一个has_next或类似字段,有的话直接拿它判断更省事。股吧的列表接口返回结构里通常能拿到当前页帖子数量和总的回复数,拿这个做分页条件也靠谱。
import requests def fetch_post_list(stock_code, page, page_size=50): url = "https://gbapi.eastmoney.com/stock/getOpinionList" params = { "code": stock_code, "p": page, "pageSize": page_size, "sort": "1", # 1按时间排序, 2按热门排序 "type": "1", "token": "44c94afc58d4e7d3f7e4d3d4e7d3f7e4" } headers = { "User-Agent": "Mozilla/5.0", "Referer": f"https://guba.eastmoney.com/list,{stock_code}.html" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json() return data["data"]["trends"]这个代码块里有几个参数值得展开说。sort字段决定了帖子列表的排列口径,1 是发布时间倒序,2 是热度优先,具体业务需要按热门抓还是按时间抓,在这里改即可。token是股吧接口的固定鉴权参数,不同接口的值不太一样,以你抓包里实际拿到的为准。pageSize我习惯填 50,因为一次拉 50 条对服务端的压力适中,出现频率限制的概率比填 100 小得多。
请求头上必须带Referer,否则东方财富的网关会把请求判定为跨域调用直接拒绝。timeout参数建议显式声明,网络抖动时不会让线程无限期挂起。另外注意,股吧接口有时候会在几次请求后要求携带Cookie字段,第一次开发时如果遇到 403,先别急着换代理,检查一下请求头里有没有带上浏览器访问时生成的 Cookie。
2.3 帖子 ID 与评论接口的映射关系
列表页返回的每条帖子数据里都有一个唯一的post_id字段,这个 ID 是后续拼接评论接口 URL 的关键。东方财富的评论接口路径里直接包含帖子 ID,形如https://gbapi.eastmoney.com/stock/getCommentList,查询参数里需要同时传post_id和分页参数。拿到帖子 ID 到请求评论之间,建议写一个解析函数把 ID 批量提取出来,再交给后续线程池去消费。
评论接口返回的 JSON 结构比列表更复杂一些,除了评论正文、评论作者、发布时间外,还有楼层数、点赞数、被回复数等字段。字段名容易混淆的是reply_id和post_id——前者标识一条评论本身,后者标识评论所属的帖子。入库时这两个字段都要保留,一个是业务主键,一个是外键关联。
从工程角度讲,把「列表接口」和「评论接口」拆成两个独立的采集函数是一个值得保持的习惯。列表采集关注的是广度,评论采集关注的是深度,两者频率、超时策略、失败重试机制都不同,混在一起只会让代码的异常处理越写越乱。资源里的代码也是这么组织的,函数粒度拆得比较细,单测和排错都方便。
3. 动手抓数据:requests 搭配 threading 的并发采集
3.1 生产级爬虫的代码骨架
单线程爬股吧的速度其实够用,因为接口返回快,但如果要抓几千只股票的全量帖子,速度差异就会很明显。这里要用到concurrent.futures里的ThreadPoolExecutor做线程池并发。线程数控制在 8 到 16 之间比较稳——股吧接口的限速策略不算激进,但 32 个线程同时打过去还是会触发反爬。
from concurrent.futures import ThreadPoolExecutor, as_completed import time def crawl_posts(stock_codes, max_workers=8): all_posts = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(fetch_post_list, code, 1, 50): code for code in stock_codes } for future in as_completed(future_map): code = future_map[future] try: posts = future.result() all_posts.extend(posts) print(f"股票 {code} 抓取成功, 获取 {len(posts)} 条帖子") except Exception as exc: print(f"股票 {code} 抓取失败: {exc}") time.sleep(0.5) return all_posts这段代码有几个设计点值得说明。as_completed是按完成顺序消费结果的,不会因为某一只股票接口响应慢而阻塞其他请求。future_map把 future 对象和股票代码做了映射,回调异常时能准确知道是哪只股票出了问题,排查日志时不用去猜。每个请求完成后强制sleep(0.5)是刻意的——给接口一个喘息窗口,避免线程池空闲后立刻发起下一波密集请求。
线程数不要盲目调大。我实测过,8 线程和 16 线程的吞吐量差别不大,但 32 线程触发频率限制的概率直线上升。如果你的代理池足够大,可以试 16;只有一个 IP 的情况下,规规矩矩用 8。
3.2 解析帖子列表与评论内容
JSON 解析这块,直接用内置的json模块就能搞定。data["data"]["trends"]里每个元素是一篇帖子,需要提取的字段包括post_id、post_title、post_abstract、read_count、comment_count、post_publish_time、user_nickname。这些字段名在不同接口版本里可能略有出入,以实际抓包返回的 key 为准。
评论解析稍微复杂一点,因为评论是分页的——一个帖子可能有几千条评论,每条帖子都需要单独请求多次评论接口。评论接口的返回体里有一个replies数组,每个元素保存一条评论的完整信息,包括reply_id、reply_content、reply_time、user_name、floor_number。
import json def parse_post(post_item): return { "post_id": post_item.get("post_id", ""), "title": post_item.get("post_title", ""), "abstract": post_item.get("post_abstract", ""), "read_count": post_item.get("read_count", 0), "comment_count": post_item.get("comment_count", 0), "publish_time": post_item.get("post_publish_time", ""), "author": post_item.get("user_nickname", "") } def parse_comment(comment_item, post_id): return { "post_id": post_id, "reply_id": comment_item.get("reply_id", ""), "content": comment_item.get("reply_content", ""), "publish_time": comment_item.get("reply_time", ""), "user_name": comment_item.get("user_name", ""), "floor": comment_item.get("floor_number", 0) }两个解析函数都用了dict.get()加默认值的方式,这是处理半结构化数据的常规手段。股吧接口偶尔会漏掉某个字段,如果直接item["post_title"]访问,KeyError 会让整个线程中断,而get方式最多丢一个字段的值,不影响整条数据入库。时间字段建议统一转成字符串存进 MongoDB,节省空间的同时也避免时区换算的麻烦。
注意:
parse_post返回的字典会直接传给 MongoDB 插入,字段名不建议用中文。MongoDB 的字段名支持 UTF-8,但查询语句、聚合管道写起来很别扭,统一用英文下划线命名是更工程化的选择。
3.3 请求频率控制与超时重试机制
写爬虫的人都懂:让脚本稳定跑一个小时,比第一次成功跑通难得多。股吧的反爬不凶,但不代表没有。在没有任何频率控制的情况下连续请求上百次,接口会不定时返回 403 或验证码页面。我的处理方式是设计一个简单的重试装饰器,遇到网络异常、超时、非 200 状态码时自动重试,最多重试 3 次,每次间隔递增。
import time from functools import wraps def retry(max_retries=3, delay=1): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as exc: if attempt == max_retries - 1: raise wait = delay * (2 ** attempt) print(f"请求失败, {wait} 秒后重试 ({attempt + 1}/{max_retries})") time.sleep(wait) return None return wrapper return decorator @retry(max_retries=3, delay=2) def fetch_with_retry(url, params, headers): resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() return resp.json()重试间隔用了指数退避算法:第一次失败后等 2 秒,第二次等 4 秒,第三次等 8 秒。这个策略比固定间隔重试更友好,既能在短暂网络抖动时快速恢复,又能避免服务端还在限流时死磕。delay和max_retries都做成了参数,不同接口可以调整——评论接口的失败率高,可以放宽到 5 次;列表接口稳定,3 次足够。
4. 入库 MongoDB:库表设计与写入优化
4.1 Boll 集合设计与索引规划
MongoDB 在这个项目里承担的是文档存储的角色。股吧的数据天然是嵌套结构,一条帖子下面挂着多条评论,用关系型数据库建模要拆两张表再加外键,MongoDB 则可以有两种选择:帖子集合和评论集合分开,用post_id关联。我更推荐后者,因为股吧帖子动辄几十上百条评论,全部嵌进一个文档会把文档撑得过大,MongoDB 单个文档 16MB 的限制虽然不容易触及,但查询评论列表时每次都要把整个帖子文档读出来,性能不划算。
// 帖子集合 db.posts.createIndex({ "post_id": 1 }, { unique: true }) db.posts.createIndex({ "publish_time": -1 }) db.posts.createIndex({ "read_count": -1 }) // 评论集合 db.comments.createIndex({ "post_id": 1, "reply_id": 1 }, { unique: true }) db.comments.createIndex({ "publish_time": -1 })索引设计上,帖子集合的post_id建唯一索引,防止重复插入;publish_time做倒序索引,方便按时间范围查询最新帖子。评论集合的核心查询模式是「按帖子找评论」,所以post_id和reply_id的复合唯一索引是必须的。如果你的分析场景经常按点赞数排序拉取热门评论,可以考虑给like_count也加一个索引,不过前期数据量不大时,这个索引的收益不明显,可以后续按需补充。
4.2 批量插入与去重更新策略
爬虫第二次运行的时候,上一轮的帖子可能还留在库里。直接无条件insert_many会产生大量重复数据,后面做分析还得先清洗一轮。这里我用的是 MongoDB 的replace_one加upsert参数,以post_id为过滤条件实现「有则更新,无则插入」。
from pymongo import MongoClient client = MongoClient("mongodb://localhost:27017/") db = client["guba_data"] posts_col = db["posts"] comments_col = db["comments"] def save_post(post_doc): posts_col.replace_one( {"post_id": post_doc["post_id"]}, post_doc, upsert=True ) def save_comments(comment_docs): if not comment_docs: return operations = [ {"replaceOne": { "filter": {"reply_id": c["reply_id"]}, "replacement": c, "upsert": True }} for c in comment_docs ] comments_col.bulk_write(operations, ordered=False)bulk_write的ordered=False参数值得注意。它允许 MongoDB 并行处理一批写入操作,不会因为某一条评论的reply_id冲突中断整个批次。实测下来,一万条评论的批量写入时间在一秒以内,速度远超逐条插入。另外,replaceOne的语义是整体替换文档,所以comment_docs里的字典一定要包含全部需要持久化的字段,不要只放增量字段——否则旧数据会被覆盖掉。
提示:生产环境建议开启 MongoDB 的写关注(write concern)为
w: 1,不要用默认的w: 0。爬虫挂了可以重跑,但数据丢了没有后悔药。批量写入时如果出现 duplicate key 报错,检查是不是post_id或reply_id在源数据里本身有重复。
4.3 连接池与会话管理
PyMongo 自带的连接池机制让客户端对象可以被多个线程安全共享,不需要为每个线程单独创建连接。常见的错误写法是在每个线程函数里MongoClient()一次——这会导致连接数爆炸,MongoDB 服务端默认最大连接数是 65536,但大量空闲连接会占用文件描述符,拖垮整个进程。
from pymongo import MongoClient # 全局唯一客户端实例 client = MongoClient( "mongodb://localhost:27017/", maxPoolSize=20, minPoolSize=5, serverSelectionTimeoutMS=5000 )线程池里 8 个采集线程共享这个全局客户端,连接池最多扩到 20 个连接,是够用的。serverSelectionTimeoutMS设成 5 秒,MongoDB 服务端暂时不可用时,写入操作会在 5 秒内快速失败,而不是让线程无限期阻塞。如果你把 MongoDB 部署到了远程服务器,这个参数尤其重要,云数据库偶尔的网络抖动不该拖住整个爬虫。
5. 高频踩坑:请求被拦、字段漂移与存储膨胀
5.1 踩坑记录一:请求被 403 拦截
现象:爬虫运行 5 到 10 分钟后,所有请求开始返回 403,浏览器访问正常,但脚本的请求完全被拒。
原因:请求频率太高触发了服务端限流。403 响应体里通常能看到一个验证码页面的 HTML 结构,说明已经进入风控流程。另一个常见原因是请求头缺失或爬虫特征明显,比如User-Agent是默认的python-requests,服务端直接识别为非浏览器请求。
解决:给每个请求设置完整请求头,至少包含User-Agent和Referer;把线程池从 16 降到 8;每轮请求后加time.sleep(0.5)的固定间隔。如果使用的是公网 IP,有条件的话换一个家庭宽带出口或备用 IP。触发 403 后不要立刻重试,等 10 分钟再启动,否则会加重风控标记。
5.2 踩坑记录二:评论字段值全部为空
现象:帖子列表抓取正常,评论也成功入库,但评论集合里user_name和content字段全是空字符串。
原因:评论接口返回的字段名和预期不一致。股吧评论接口有两个版本,旧版的字段名是user_name,新版的字段名改成了nickname。解析代码里用的get("user_name")取不到值,又因为写了默认值,所以没有报错而是静默返回空字符串——这正是get默认值的双刃剑。
解决:先手动打印一条原始 JSON 响应,把实际字段名拉出来核对。解析函数里做兼容处理,连续尝试多个可能的字段名:
def safe_get(item, *keys, default=""): for key in keys: if item.get(key): return item.get(key) return default # 用法 user_name = safe_get(comment_item, "user_name", "nickname", "author_name")5.3 踩坑记录三:MongoDB 磁盘空间暴涨
现象:爬虫跑了一周,MongoDB 数据目录占用从 2GB 涨到 30GB,远超预期。
原因:replaceOne的 upsert 语义有一个隐含陷阱——每次更新都会写入一个新文档版本,旧版本由 MongoDB 的存储引擎在后台异步回收。如果写入频繁且文档较大,回收速度跟不上写入速度,磁盘占用就会虚高。另一个原因是评论数据里包含了大量无用的展示字段,比如用户头像 URL、浏览器类型等,这些字段对分析毫无价值,却占了不少空间。
解决:入库前做字段过滤,只保留分析需要的核心字段,把parse_comment的返回字典精简到 8 个字段以内。定期执行db.comments.cleanup()或者用compact命令回收存储空间。如果数据量确实大,考虑开启 MongoDB 的压缩选项(wiredTiger引擎默认开启 snappy 压缩),并定时导出冷数据到归档集合。
5.4 踩坑记录四:爬虫中途崩溃导致数据断层
现象:线程池里某个线程抛出未捕获异常,整个进程退出,已经抓取的 3 万条帖子全部写入失败。
原因:ThreadPoolExecutor默认的行为是吞掉线程异常,但as_completed循环里如果对future.result()的异常处理不完整,主线程会被raise中断。另外,MongoDB 写入失败(比如网络断开)也会抛出ServerSelectionTimeoutError,没有捕获就一路向上传递。
解决:在采集主循环里加try-except兜底,确保单个股票或单条帖子失败不影响整体流程;给 MongoDB 写入单独加异常捕获,失败时把待写入数据转存到本地 JSON 文件作为备份;更稳妥的做法是引入tenacity这样的重试库,对整体流程做两层保护:
with ThreadPoolExecutor(max_workers=8) as executor: futures = [executor.submit(crawl_one_stock, code) for code in codes] for future in as_completed(futures): try: future.result() except Exception as exc: log_error(f"任务失败: {exc}") # 记录失败股票代码到本地文件 with open("failed_codes.txt", "a") as f: f.write(future_map[future] + "\n")6. 再进一步:增量更新与断点续爬的实现
6.1 用时间戳做增量采集
全量爬虫跑第一次之后就够用了,后续每天只需要抓新增的数据即可。增量更新的核心是在定时任务里记录上次抓取的时间点,只请求发布时间晚于该时间点的帖子。股吧列表接口本身支持按时间排序,但分页翻到后面会遇到大量旧数据,效率不高。
我一般会在本地维护一个last_crawl_time的标记文件,每天定时任务启动时读取这个值,抓完一轮后再更新。判断数据是否新增的依据是post_id是否已存在于 MongoDB——如果存在且publish_time早于时间戳,跳过;如果post_id不存在,说明是增量帖子,插入并记录。这种做法的优点是不依赖接口的时间过滤参数,对接口版本变化的容忍度高。
6.2 断点续爬:不重复劳动的关键设计
爬虫跑了一天半夜,突然服务器重启,之前抓了一半的数据全部作废重来,这种血泪教训我经历过太多次。断点续爬的常见实现思路是在每个股票代码抓取完成后,把已完成的代码写入一个done_codes.txt文件;下次启动时,先读取这个文件,过滤掉已完成的任务,只处理剩余部分。这个方案简单直接,进程崩溃后最多丢一个股票的数据,不会全量重来。
import os def load_done_codes(): if not os.path.exists("done_codes.txt"): return set() with open("done_codes.txt", "r") as f: return set(line.strip() for line in f if line.strip()) def mark_done(code): with open("done_codes.txt", "a") as f: f.write(code + "\n") # 主流程 all_codes = get_stock_list() done_codes = load_done_codes() pending_codes = [c for c in all_codes if c not in done_codes]这里要注意一个并发问题:多线程同时写done_codes.txt会导致文件内容互相覆盖。我的做法是只在主线程里写,子线程完成一个股票后把代码放进一个queue.Queue,主线程统一消费并写入文件。另外,文件路径要放到和代码同级的data目录下,别随手放临时目录,服务器重启后/tmp目录可能被清空,断点记录就没了。
6.3 数据校验:入库后不放心就抽查
爬虫写完不是终点,入库数据的质量直接决定了后续分析的可靠性。我最后会跑一遍校验脚本,用 SQL 风格的聚合查询检查数据分布是否合理——比如帖子总数是否为 0、publish_time的时间范围是否覆盖预期区间、评论平均获取量是否过低(正常情况每条帖子至少有 1 到 5 条评论,如果全是 0 说明评论接口很可能挂了)。
// 检查评论获取率 db.posts.aggregate([ { $lookup: { from: "comments", localField: "post_id", foreignField: "post_id", as: "comments" }}, { $project: { post_id: 1, "comment_count_in_db": { $size: "$comments" }, "comment_count_source": "$comment_count" }}, { $match: { $expr: { $ne: ["$comment_count_in_db", "$comment_count_source"] } }}, { $limit: 10 } ])这个聚合查询用$lookup做帖子集合和评论集合的关联,然后比较数据库里实际抓到的评论数和帖子字段声明的评论数。如果差异巨大,说明评论抓取逻辑有问题或接口反爬升级了。从那以后,我每次部署完爬虫都会先把这个校验脚本跑一遍,确认数据对得上才开始安排定时任务,跑完数据再分析,基本就告别了“数据抓到一半才发现问题”的被动场面。希望帮到你。
本文还有配套的精品资源,点击获取