ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

东方财富股吧数据抓取实战:从请求到增量更新

东方财富股吧数据抓取实战:从请求到增量更新 简介东方财富网股吧数据抓取是一套基于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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, Accept-Encoding: gzip, deflate, } def fetch_list_page(stock_code: str, page: int) - str: url fhttps://guba.eastmoney.com/list,{stock_code}_{page}.html resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text这里做了几件容易被忽略的事。请求头里必须带完整的 User-Agent不带的话很多站点直接返回 403Referer 填站点根地址让请求来源看起来是从股吧主页点进去的Accept-Encoding 只保留 gzip 和 deflate因为 requests 对 br 压缩的支持需要额外装 brotli否则响应体解码会乱。timeout10 表示连接和读取各给 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(stripTrue) link title_node.get(href) author_node row.select_one(span.author a) author author_node.get_text(stripTrue) if author_node else read_node row.select_one(span.read) reply_node row.select_one(span.reply) read_val read_node.get_text(stripTrue) if read_node else 0 reply_val reply_node.get_text(stripTrue) 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股吧首页或当前列表页地址拒绝来源为空的请求是常见网关策略Accepttext/html,...;q0.9,/;q0.8告知服务端期望返回的格式Accept-Languagezh-CN,zh;q0.9与访问地区一致避免被安全策略挑出来Accept-Encodinggzip, deflate不写 br避免响应体无法解压请求头不是越多越安全关键在于和真实浏览器发出的请求保持一致。有一个常见误操作是把 curl 里那一串过长的头原样复制进 requests里面包含一些 requests 不会默认处理的字段反而让行为变得更怪。我一般只保留上表五个字段加 Cookie其他字段交给库去处理。Cookie 方面第一次抓取不需要主动带被验证码拦住后再考虑通过登录态获取。提示请求头不是越多越好保持与真实浏览器一致即可。4.2 限速与随机延时最有性价比的参数调节访问频率是触发反爬的第一因素。把抓取间隔写成固定值等于给自己留特征用随机区间能让请求时间轴更贴近人工操作。下面这段代码把延时放在请求之前每次都从区间里取一个随机秒数。def fetch_with_pacing(stock_code, page, min_interval1.5, max_interval3.5): time.sleep(random.uniform(min_interval, max_interval)) url fhttps://guba.eastmoney.com/list,{stock_code}_{page}.html try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code ! 200: print(fpage {page} status {resp.status_code}) return None # 内容层面的风控提示往往也是 200 返回 if 访问过于频繁 in resp.text or 验证码 in resp.text[:2000]: print(fpage {page} blocked) return None resp.encoding utf-8 return resp.text except requests.RequestException as exc: print(fpage {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_retries3): 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(fpage {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 21cron 里必须写绝对路径环境变量也要在脚本内重新声明否则可能遇到找不到 Python 或第三方库的问题。日志里每次运行打印抓取页数、成功条数和失败页号跑完看一眼行数变化就能确认数据在持续累积。日志里出现连续的 HTTP 200 且数据库行数递增就说明任务在按预期工作。本文还有配套的精品资源点击获取
返回列表