东方财富股吧爬虫实战:从接口请求到Excel数据落地
做了这么久的社区舆情数据整理东方财富股吧是绕不开的数据源。股吧的话题列表、回复数、阅读数、点赞数都是现成的市场情绪指标尤其做个股热度分析的时候这些数据比空看 K 线来得更直接。但问题也摆在眼前网页版的话题散落在几十上百个分页里手动翻页复制根本不可能一次想要几千条话题明细靠鼠标点会直接劝退。我这次做了一个从接口请求到 Excel 数据落地的完整爬虫把股吧话题抓下来、清洗干净、写成标准表格。整个过程没有用 Selenium 那一套重浏览器方案核心就是 requests 请求接口、pandas 做数据处理、openpyxl 做 Excel 格式化三个库串起来就够用了。这篇文章会把接口分析、请求头伪装、分页逻辑、字段清洗、Excel 样式优化每一步都拆开讲适合有一定 Python 基础、想接触金融数据爬虫的读者参考。1. 项目背景与整体设计思路1.1 这个爬虫到底解决了什么问题很多做股票量化或者社区情绪分析的朋友都会遇到同样的尴尬行情数据有现成的 Tushare、AkShare 这类库但股吧这种社区舆论数据几乎没有人帮你整理好。股吧的话题里藏着大量散户情绪信号——某只股票被反复讨论、回复数畸高、阅读量暴涨往往伴随着市场关注度的快速变化。手动收集的话一个人一个小时顶多翻十几页而且复制到 Excel 里格式全乱。我做这个爬虫的目标很明确输入一个股票代码自动把该标的在股吧的话题列表抓下来包括标题、作者、发布时间、回复数、阅读数、点赞数这些核心字段最终落成一个干干净净的 Excel 表格。一次跑完等于以前半天的活而且后续改个股票代码就能复用。这个项目我定位成“轻量级工具”不是搞分布式采集那种重工程。所以设计上一开始就定了两个原则一是能直接调接口就不解析 HTML数据精度和速度都不在一个量级二是所有依赖只控制在 requests、pandas、openpyxl 这几个库里任何人拿过去装完环境就能跑。1.2 为什么选择 requests pandas 而不是 Scrapy 或 Playwright有人可能会问既然要做爬虫为什么不直接上 Scrapy我的理由是这个场景的复杂程度根本用不上框架。Scrapy 的优势在于大规模采集、管道化处理、并发调度适合需要每天增量跑几十万条数据的场景。但这里只是抓一个股票代码下的若干页话题数据量撑死几千条。为了这点活上 Scrapy等于开一辆重卡去取快递配置环境、写 spider、调 pipeline半天时间搭进去了不值当。再说 Playwright。很多新手一上来就喜欢用 Playwright 模拟浏览器因为它能看到页面渲染过程出了问题好排查。但代价是内存占用高、运行速度慢而且股吧这种老牌页面真实数据都在 XHR 接口里根本不需要渲染。直接用 requests 请求接口整个爬取过程可以控制在几秒钟比浏览器方案快出一个数量级。所以我的选型结论是单机、小批量、快速验证需求requests 是最优解需要大规模分布式采集再考虑 Scrapy目标网站有强混淆、数据全在 JS 渲染里才轮到 Playwright 出手。这次项目的场景requests 完全够用而且代码量少调试起来也直观。2. 核心前置工作接口分析与请求封装2.1 用开发者工具锁定数据接口爬虫的第一步从来不是写代码而是搞清楚数据存在哪。打开 Chrome 无痕窗口访问东方财富股吧某个股票的话题页按 F12 进入开发者工具切到 Network网络面板刷新页面然后筛选 XHR/Fetch 请求。这时候能看到页面在加载过程中发出去的一堆请求很多是埋点、统计类的不用理会。我们要找的是返回内容里包含话题标题、回复数这些信息的那个接口。我的经验是直接在 Fetch/XHR 的返回列表里用关键词搜索比如搜某条帖子的标题文字或者搜 “replyCount”“readCount” 这类英文关键词。我这次项目中命中的接口路径形如https://gbapi.eastmoney.com/Reply/List参数里带了code股票代码、pageNo页码、pageSize每页条数等字段。不同的页面版本接口路径可能会有差异你们实际操作时以抓包看到的真实请求为准思路完全一样——凡是网页上能直接看到的数据背后一定有一个接口在支撑找到了接口爬虫就成功了一半。2.2 请求参数与 Headers 的伪装细节找到接口后先别急着写代码先把请求参数和请求头完整复制出来逐个确认含义。常用参数我整理过基本长这样参数含义示例code股票代码或板块代码300059pageNo当前页码1pageSize每页返回条数50sort排序方式reply、pubTime、hit其中 sort 字段比较关键reply 是按回复数排序pubTime 是按发布时间排序具体取值需要多试几个。参数确认好之后最关键的是 Headers 伪装。import requests headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://guba.eastmoney.com/, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, } params { code: 300059, pageNo: 1, pageSize: 50, sort: reply, } url https://gbapi.eastmoney.com/Reply/List resp requests.get(url, headersheaders, paramsparams, timeout10) resp.raise_for_status() data resp.json() print(data[data][list][0])这里有两个容易踩坑的地方。第一个是Referer必须带上很多接口会校验来源页面Referer 缺失或错误会直接返回 403。第二个是.json()解析前最好确认返回内容确实是 JSON有几次接口临时返回了 HTML 错误页直接调.json()会抛异常我一般会先打印resp.text[:200]看一眼。2.3 分页策略与请求间隔控制接口一次最多返回 50 条要抓完整话题列表就得处理分页。我这里的思路是写一个循环从第 1 页开始翻直到当前页返回的条数小于 pageSize说明已经是最后一页翻到底了。不过分页循环一定要控制请求频率这是整个爬虫能不能稳定跑完的关键。我见过太多人把循环写在高并发下sleep 都不加结果请求发出去没多久就收到 403 或者 IP 被临时限制。def fetch_all_pages(code, max_pages50): all_items [] page 1 while page max_pages: params { code: code, pageNo: page, pageSize: 50, sort: reply, } resp requests.get(url, headersheaders, paramsparams, timeout10) resp.raise_for_status() data resp.json() items data.get(data, {}).get(list, []) all_items.extend(items) if len(items) 50: break page 1 time.sleep(random.uniform(1.5, 3)) return all_itemstime.sleep(random.uniform(1.5, 3))这句是我长期积累出来的经验。固定 sleep 1 秒太容易被识别加一点随机抖动模拟人工浏览的节奏实测下来稳定很多。当然也别为了追求速度把间隔压得太低这次抓完下一次可能就轮到你头疼了。3. 数据清洗与字段加工3.1 接口返回结构逐层拆解接口返回的 JSON 结构通常不是平铺的我这次抓到的数据里最外层是data里面套着一个list数组数组里每个元素才是一条话题记录。新手最容易在这里翻车写data[list]不知道外面还套了一层排错半天。拿到 JSON 后我习惯先把第一条完整打出来逐字段确认{ post_id: 123456789, post_title: 新能源板块今天的走势有点意思, post_content: 开盘冲高回落量能没有跟上..., user_nickname: 散户老张, user_id: 10086, reply_count: 38, read_count: 15230, like_count: 16, post_publish_time: 1737446400 }看到这个结构之后心里就有数了。post_publish_time是 Unix 时间戳不是可读的日期字符串这里后面必须做转换。字段有的是英文命名有的可能是中文命名不同接口不太一样总之按实际返回结构提取就行。3.2 时间戳与文本清洗处理数据清洗是我这次特别想强调的一环因为接口拿到的原始字段基本没法直接用。时间戳还是其次真正麻烦的是文本里的乱七八糟字符。时间戳转换很简单用datetime.fromtimestamp就行。但要注意时区问题如果服务器返回的是 UTC 时间戳要先把时区修正成北京时间再格式化。我代码里的实际写法是from datetime import datetime def format_time(ts): if not ts: return return datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S)文本清洗需要做两件事。第一是去除标题和摘要里的换行符、空格以及一些不可见的控制字符不然之后写进 Excel 单元格时会出现奇怪的换行表格看起来特别乱。第二是截断超长文本摘要字段有时候几百上千字全放进 Excel 里行高会撑得很夸张我一般只保留前 200 字对分析来说信息量已经够了。3.3 去重、排序与数据质量校验清洗完字段之后必须做一次性去重。理由很简单分页抓取时如果某一页网络超时你可能会重试请求而重试的那一页和之前抓到的一页很可能重复。更常见的情况是排序方式变更导致有些话题在翻页过程中被重复返回。去重我是用 pandas 来做的直接指定主键字段保留第一条df df.drop_duplicates(subset帖子ID, keepfirst)去掉重复数据之后我对几个关键字段做了一层校验。阅读数、回复数、点赞数这些数字字段检查是否都大于等于零有没有异常为空的情况。发布时间字段检查是否有极端值比如 1970 年的时间戳或者未来时间这两种情况都说明解析有问题需要回过去检查字段名。做完这些校验数据才算达到可以落地的质量标准。很多人写爬虫到抓取完就结束了其实后半程的数据清洗才是真正拉开差距的地方脏数据写进 Excel 跟没做没太大区别。4. Excel 数据落地实现4.1 pandas 快速落表数据清洗完成之后落地环节我先想到的是 pandas 的to_excel因为确实快几行代码就能生成一份标准表格。import pandas as pd records [] for item in all_items: records.append({ 帖子ID: item.get(post_id), 标题: item.get(post_title), 摘要: item.get(post_content, )[:200], 作者: item.get(user_nickname), 作者ID: item.get(user_id), 回复数: item.get(reply_count, 0), 阅读数: item.get(read_count, 0), 点赞数: item.get(like_count, 0), 发布时间: format_time(item.get(post_publish_time)), }) df pd.DataFrame(records) df.drop_duplicates(subset帖子ID, keepfirst, inplaceTrue) df.to_excel(股吧话题数据.xlsx, indexFalse)这一步生成的文件能打开能看但距离“好的表格”还有距离。直接用 pandas 默认格式导出表头是默认样式、列不会自适应宽度、没有筛选功能真要对着一两千条数据分析体验确实差一些。4.2 openpyxl 样式优化与冻结窗格为了让 Excel 打开之后直接能用我会再用 openpyxl 做一轮收尾加工。openpyxl 是处理 Excel 文件最灵活的库能调样式、设定列宽、冻结窗格、加筛选按钮。from openpyxl import load_workbook from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter wb load_workbook(股吧话题数据.xlsx) ws wb.active header_fill PatternFill(start_color4472C4, end_color4472C4, fill_typesolid) header_font Font(colorFFFFFF, boldTrue, size11) for cell in ws[1]: cell.fill header_fill cell.font header_font cell.alignment Alignment(horizontalcenter, verticalcenter) width_map {A: 14, B: 50, C: 60, D: 12, E: 14, F: 10, G: 12, H: 10, I: 20} for col, width in width_map.items(): ws.column_dimensions[col].width width ws.freeze_panes A2 ws.auto_filter.ref ws.dimensions wb.save(股吧话题数据.xlsx)这里面几个细节我觉得值得拿出来说一下。先说表头样式。深蓝背景加白色粗体是我比较常用的一套配色看着专业打印出来也不容易脏。列宽的设置我参考了实际内容长度标题和摘要这两个字段最长给了 50 和 60窄的字段像回复数、点赞数给 10 就够了太宽反而浪费空间。冻结窗格freeze_panes A2这个属于必备操作。数据一多往下滚动时表头滚没了要回头确认这一列是什么内容就特别费劲。冻结之后表头始终固定在第一行滚动查看长列表的时候友好得多。自动筛选auto_filter.ref ws.dimensions也是我每次必加的。加了之后表头的每一列都会出现一个下拉箭头后期想按回复数排序、按作者筛选或者只看某个时间段的帖子点两下鼠标就完成等于是把最基础的筛选功能直接做进了 Excel。4.3 带时间戳的文件名与自动归档还有一个小细节很多人没注意就是导出文件的命名。如果每天都跑一遍爬虫文件名都叫“股吧话题数据.xlsx”第二天就把第一天覆盖了回看历史数据就无从谈起。我改进的办法是文件名里拼上日期和运行时刻from datetime import datetime now_str datetime.now().strftime(%Y%m%d_%H%M%S) file_name f股吧_{code}_{now_str}.xlsx这样每次运行生成的文件都不会互相覆盖。跑一周下来文件夹里就是一份完整的时间序列数据后面想分析热度趋势直接把这些文件合并起来就行。这个习惯看着不起眼但在长期跑数据的场景里帮了我大忙。5. 常见问题排查与经验沉淀5.1 高频问题速查表这个项目做完我陆陆续续遇到过不少问题整理了一个高频问题速查表对刚接触接口爬虫的人来说应该能少走很多弯路。现象可能原因解决办法请求返回 403Headers 不全或请求频率过高补全 User-Agent 和 Referer拉长请求间隔返回空列表code 参数格式不对或接口路径变化打开 F12 重新确认实际请求参照最新格式时间字段全是 1970时间戳单位不是秒而是毫秒判断数值位数超过 10 位说明是毫秒除以 1000字段大量为空接口返回结构与预期不一致用print(resp.json())逐层打印先看清真实字段名Excel 打开乱码或格式错文本中有特殊控制字符正则清洗[\x00-\x1f]这类不可见字符跑到中途被限制访问请求间隔固定且过短改成随机间隔并增加重试、退避逻辑这里最值得说的是 403 这个现象。我最早写爬虫时遇到 403 第一反应是“要用代理了”后来才发现多数情况下根本不是代理的问题单纯的请求头信息不完整就会触发。把 Referer 和完整的 User-Agent 带上问题往往立刻消失。代理只是最后的选项优先级应该排在所有常规手段之后。5.2 几个值得注意的细节与爬虫规范最后聊几个我做这个项目时沉淀下来的经验算是给后来者的一些提醒。第一任何爬虫项目都要注意请求频率这不是口号而是现实约束。你可以在本地拿东财股吧练手但不管抓取哪个站点把单次请求间隔压到 1 秒以内都已经是危险操作了。成年人写爬虫要对自己的 IP 负责控制频次、限定范围、只做学习用途这是基本功。第二接口返回的字段一定要做容错处理。用.get()带上默认值是最基本的操作否则某一条数据缺字段整个循环直接崩掉前面抓的数据全白费。稳健的代码应该把“跳过异常数据”当成默认行为。第三Excel 落地时建议做好备份分区。爬虫的数据是一次性的重新抓成本很高我习惯在本地按日期建文件夹每次运行结果独立归档。一个月之后再回看这些数据你会庆幸自己当时没有偷懒。这个项目做完以后我最大的体会是爬虫的价值不在于能爬到多少数据而在于能把数据变成自己随手能用、随时能查的资产。从接口分析到 Excel 落地每一步都不难但每一步都有值得打磨的细节。套到这同样的流程换个数据源、加个定时任务就能变成一套持续运转的舆情监控小工具。