简介这份东方财富股吧爬虫项目以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深圳市场是sz000001p是页码从 1 开始递增pageSize是每页条数常见做法是填 20 到 50 不等。接口返回的 JSON 里data字段下的trends就是帖子数组replies字段则代表这条帖子的回复总数。分页策略上我建议不要用固定循环次数而是循环到接口返回空数组或trends长度为零时停止。有些帖子列表接口还会在返回数据里带一个has_next或类似字段有的话直接拿它判断更省事。股吧的列表接口返回结构里通常能拿到当前页帖子数量和总的回复数拿这个做分页条件也靠谱。import requests def fetch_post_list(stock_code, page, page_size50): 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: fhttps://guba.eastmoney.com/list,{stock_code}.html } resp requests.get(url, paramsparams, headersheaders, timeout10) 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_workers8): all_posts [] with ThreadPoolExecutor(max_workersmax_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_retries3, delay1): 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_retries3, delay2) def fetch_with_retry(url, params, headers): resp requests.get(url, paramsparams, headersheaders, timeout10) 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, upsertTrue ) 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, orderedFalse)bulk_write的orderedFalse参数值得注意。它允许 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/, maxPoolSize20, minPoolSize5, serverSelectionTimeoutMS5000 )线程池里 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_workers8) 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做帖子集合和评论集合的关联然后比较数据库里实际抓到的评论数和帖子字段声明的评论数。如果差异巨大说明评论抓取逻辑有问题或接口反爬升级了。从那以后我每次部署完爬虫都会先把这个校验脚本跑一遍确认数据对得上才开始安排定时任务跑完数据再分析基本就告别了“数据抓到一半才发现问题”的被动场面。希望帮到你。本文还有配套的精品资源点击获取