
简介这份资源面向具备一定 Python 基础、希望批量获取雪球平台文章内容的学习者与开发者核心解决自动登录雪球并抓取收藏文章、进而整理成电子书的需求。压缩包共 31 个文件以 27 个 py 脚本为主体辅以 2 个 cfg 配置文件、1 个 gitignore 与 1 个 md 说明文档整体约 32KB体积轻量便于本地运行与二次修改。脚本覆盖登录鉴权、文章抓取、数据处理、PDF 生成等环节其中 snowball.py 负责自动登录并输出 txt 文件其余模块涉及策略、热门股票、历史组合、文件拆分与工具函数等目录结构清晰便于按功能定位代码。目前已有 1339 人学习下载读者可借此掌握模拟登录、请求封装、内容解析与电子书生成的基本思路并参考配置文件的组织方式快速跑通流程适合作为爬虫入门到进阶的实践素材。1. 从登录态到全量文章雪球爬虫到底难在哪很多人第一次写雪球爬虫代码不到三十行就卡住了requests.get拿回来的 HTML 里文章列表是空的。不是选择器写错是雪球把内容渲染交给了前端服务端返回的只是壳子。更麻烦的是未登录状态下接口会直接返回400或要求验证你连第一页都翻不过去。这个资源要解决的就是这两件事一是把登录态拿到手二是用这个登录态把某个用户主页下的文章一篇篇抓全。它适合已经会写基础requests、但一碰需要登录的站点就翻车的 Python 从业者也适合想拿雪球做文本分析、却卡在数据采集这一步的人。核心不是「爬」是「带着合法身份稳定地爬」。2. 登录态获取Cookie、Session 与请求头三件套雪球这类站点的登录校验本质是看你请求里有没有有效的身份凭证。凭证通常落在 Cookie 里而 Cookie 又和 Session 绑定。所以第一步不是写爬虫是搞清楚「登录之后浏览器到底存了什么」。2.1 为什么直接 requests 登录大概率失败雪球的登录接口不是简单的表单 POST。密码在传输前会经过前端处理请求体里还带着时间戳、设备标识等字段。你如果照着抓包结果硬拼一个 POST常见结果是返回「用户名或密码错误」哪怕密码完全正确——因为缺了前端生成的校验参数。常见做法是绕开模拟登录直接复用浏览器已经登录好的 Cookie。这不是偷懒是工程上更稳的选择登录逻辑由官方前端负责你只负责把凭证搬过来用。代价是 Cookie 会过期需要定期更新。具体操作在浏览器里正常登录雪球打开开发者工具切到 Network 面板刷新页面找到任意一个xueqiu.com域下的请求复制它的Cookie请求头整行。注意要复制完整包括xq_a_token、u这些关键字段少一个都可能导致接口返回未登录。import requests # 从浏览器开发者工具 Network 面板复制的完整 Cookie 字符串 COOKIE xq_a_tokenxxxxxx; uxxxxxx; device_idxxxxxx; ... 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, Cookie: COOKIE, Accept: application/json, text/plain, */*, Referer: https://xueqiu.com/, } session requests.Session() session.headers.update(HEADERS)这段代码做了三件事把 Cookie 塞进请求头、伪装成正常浏览器、用Session保持连接复用。User-Agent不能省雪球对空 UA 的请求会直接拒绝。Referer设成主站避免部分接口校验来源时返回异常。Session的好处是后续所有请求自动带上这套头不用每次重复写。参数说明COOKIE是唯一需要你手动更新的字段其余可以固定。Accept设成 JSON 是因为雪球的数据接口返回的是 JSON不是 HTML。2.2 用 Session 验证登录态是否生效拿到 Cookie 后别急着爬文章先打一个轻量接口确认身份有效。雪球有一个返回当前登录用户信息的接口用它做探针最合适。# 用当前登录用户信息接口验证 Cookie 是否有效 check_url https://xueqiu.com/v4/statuses/user_timeline.json params {user_id: 你的用户ID, page: 1} resp session.get(check_url, paramsparams, timeout10) if resp.status_code 200 and resp.json().get(statuses) is not None: print(登录态有效可以开始采集) else: print(登录态失效状态码:, resp.status_code)逻辑很直白如果 Cookie 有效接口返回 200 且 JSON 里有statuses字段如果失效通常返回 400 或者 JSON 里带错误码。这一步的价值在于把「登录问题」和「解析问题」分开——很多人爬不到数据其实是 Cookie 早就过期了却一直在调选择器。参数说明user_id换成你自己的雪球用户 ID在个人主页 URL 里能找到。page从 1 开始。timeout建议设 10 秒雪球偶尔响应慢不设超时容易卡死。提示Cookie 里的xq_a_token是核心凭证它过期后整个登录态失效。一般有效期在几天到两周之间取决于你是否勾选「记住登录」。建议把 Cookie 存在环境变量或独立配置文件里别硬编码进脚本。3. 文章列表接口分页参数与数据提取登录态通了之后真正的采集才开始。雪球用户主页的文章不是靠翻 HTML 拿的是走一个时间线接口按页返回 JSON。你要做的是搞懂分页怎么翻、每页返回什么、什么时候停。3.1 时间线接口的分页机制雪球用户文章列表走的是user_timeline.json这类接口核心参数是user_id和page。每页默认返回一定数量的条目翻页就是递增page。但这里有个坑不是所有条目都是「文章」时间线里混着转发、短贴、问答你需要按字段过滤。import time def fetch_article_list(session, user_id, max_page50): 按页拉取用户时间线过滤出原创文章 articles [] url https://xueqiu.com/v4/statuses/user_timeline.json for page in range(1, max_page 1): params {user_id: user_id, page: page} resp session.get(url, paramsparams, timeout10) if resp.status_code ! 200: print(f第 {page} 页请求失败状态码 {resp.status_code}) break data resp.json() statuses data.get(statuses, []) if not statuses: print(f第 {page} 页无数据采集结束) break for item in statuses: # 过滤只保留有正文的文章排除纯转发和短贴 if item.get(type) article or item.get(description): articles.append({ id: item.get(id), title: item.get(title, ).strip(), description: item.get(description, ), created_at: item.get(created_at), url: fhttps://xueqiu.com{item.get(target, )}, }) print(f第 {page} 页完成累计 {len(articles)} 篇) time.sleep(1.5) # 控制频率避免触发风控 return articles这段代码的关键点在三个地方。第一max_page是保护上限防止接口异常时无限翻页。第二过滤条件用type和description双重判断因为雪球不同时期返回的字段结构略有差异单靠一个字段容易漏或误收。第三time.sleep(1.5)是必须的雪球对高频请求有风控连续快速翻页会返回空数据甚至临时封禁。参数说明user_id是目标用户的 ID不是昵称。page从 1 递增。max_page根据目标用户文章量估算一般设 50 到 100 足够覆盖大部分活跃用户。time.sleep的间隔建议不低于 1 秒1.5 到 2 秒更稳。3.2 从列表到正文二次请求拿全文时间线接口返回的description通常只是摘要不是全文。要拿完整正文需要再请求一次文章详情接口。这一步容易被忽略很多人以为列表里的description就是全部内容结果拿到的数据缺斤少两。def fetch_article_detail(session, article_id): 根据文章 ID 拉取完整正文 url fhttps://xueqiu.com/statuses/show.json params {id: article_id} resp session.get(url, paramsparams, timeout10) if resp.status_code ! 200: return None data resp.json() return { id: article_id, title: data.get(title, ), text: data.get(text, ), # 完整正文含 HTML 标签 created_at: data.get(created_at), view_count: data.get(view_count, 0), }逻辑说明详情接口用文章 ID 作为参数返回的text字段是完整正文但带 HTML 标签。如果你要做文本分析需要额外用BeautifulSoup或正则清洗。view_count这类字段可以用来做后续的排序或筛选。参数说明article_id来自上一步列表里的id。详情接口同样受频率限制建议每篇之间也加time.sleep(1)左右。如果文章量大可以把列表和详情分两阶段跑中间落盘避免中途失败全部重来。注意雪球的正文接口返回的text里可能包含图片、链接、话题标签。如果只想要纯文本清洗时要把img、a这些标签去掉但保留文字内容。别直接strip_tags一刀切容易把段落结构也切没。4. 避坑与排查那些让我重跑三次的坑这一章是我自己踩过的、也见过别人反复踩的坑。每一条都按「现象 → 原因 → 解决」写你对着排查能省不少时间。4.1 现象接口返回 200但 JSON 里 statuses 为空原因Cookie 过期了但雪球没有返回 401而是返回了一个空列表。这是最阴的坑因为状态码是 200你会以为请求成功实际登录态早就没了。解决不要只看状态码要检查返回 JSON 里关键字段是否存在。在采集循环里加一个判断如果连续两页statuses为空就停下来重新检查 Cookie而不是继续翻页。4.2 现象翻到第几页之后突然全部返回空原因触发了频率风控。雪球对短时间内的连续请求有阈值超过之后会静默返回空数据不报错。解决把time.sleep间隔调大建议 2 秒以上。如果文章量大分批次跑比如每次跑 20 页就停一段时间。另外Session复用比每次新建连接更不容易被风控因为连接特征更稳定。4.3 现象正文里全是 HTML 标签没法直接做分析原因详情接口返回的text字段是富文本带完整 HTML 结构。解决用BeautifulSoup做定向清洗保留p段落结构去掉img、script、style。不要用正则硬删标签容易误伤正文里的符号。from bs4 import BeautifulSoup def clean_html(raw_html): 清洗正文 HTML保留段落结构 soup BeautifulSoup(raw_html, html.parser) # 去掉脚本和样式 for tag in soup([script, style, img]): tag.decompose() # 用换行连接段落 paragraphs [p.get_text(stripTrue) for p in soup.find_all(p)] return \n.join(paragraphs) if paragraphs else soup.get_text(stripTrue)4.4 现象文章 ID 重复同一篇被存了多次原因时间线接口在翻页时如果期间有新文章发布会导致分页错位同一篇出现在两页里。解决在入库前用文章 ID 做去重。用一个set记录已采集的 ID或者写入数据库时用INSERT OR IGNORE。别依赖页码去重页码不可靠。4.5 现象跑了几百篇之后脚本突然卡死原因没有设timeout某个请求挂起后整个脚本阻塞。或者内存里累积了太多数据没落盘。解决每个请求都加timeout10。采集过程中定期把数据写入本地文件或数据库不要全部攒在内存里最后一次性写。我一般每采集 50 篇就落一次盘。5. 进阶把采集结果落成可分析的数据集爬到数据只是第一步真正让这份资源有价值的是把它变成能直接喂给分析工具的结构化数据。这里说两个我常用的收尾技巧。5.1 用 SQLite 做本地存储与去重SQLite 不需要额外服务单文件就能存适合个人采集场景。建表时把文章 ID 设为主键天然去重。import sqlite3 def init_db(pathxueqiu_articles.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS articles ( id TEXT PRIMARY KEY, title TEXT, text TEXT, created_at INTEGER, view_count INTEGER ) ) conn.commit() return conn def save_article(conn, article): 插入文章ID 冲突时忽略 conn.execute( INSERT OR IGNORE INTO articles (id, title, text, created_at, view_count) VALUES (?, ?, ?, ?, ?), (article[id], article[title], article[text], article[created_at], article[view_count]) ) conn.commit()逻辑说明INSERT OR IGNORE配合主键重复文章自动跳过不用在 Python 层做去重判断。created_at存原始时间戳后续要按时间排序或筛选都方便。view_count可以用来做热度分析。参数说明数据库文件路径自己定建议放在脚本同目录。如果文章量超过几万篇可以给created_at加索引查询会快很多。5.2 验证采集完整性的三个检查点跑完之后别直接关掉做三个快速验证确认数据是完整的。第一对比总数。打开目标用户主页看页面显示的文章总数和你数据库里的行数对比。如果差距超过 10%大概率是分页漏了或者过滤条件太严。第二抽查正文长度。随机抽 5 篇看text字段的字符数是否合理。如果某篇只有几十个字可能是详情接口没拿到全文只存了摘要。第三检查时间跨度。按created_at排序看最早和最晚的文章时间是否覆盖了你预期的范围。如果最早的文章停在某个时间点说明翻页提前终止了。# 快速验证采集结果 conn sqlite3.connect(xueqiu_articles.db) cursor conn.cursor() # 总数 cursor.execute(SELECT COUNT(*) FROM articles) print(总篇数:, cursor.fetchone()[0]) # 正文长度分布 cursor.execute(SELECT id, LENGTH(text) FROM articles ORDER BY LENGTH(text) ASC LIMIT 5) print(最短的 5 篇:, cursor.fetchall()) # 时间范围 cursor.execute(SELECT MIN(created_at), MAX(created_at) FROM articles) print(时间范围:, cursor.fetchone())这三个查询跑完数据质量心里就有数了。如果最短的几篇正文长度明显异常回去检查详情接口的清洗逻辑。如果时间范围不对回去检查翻页终止条件。从那以后我每次跑完采集都强制走一遍这三个检查不看完不关脚本。希望帮到你。本文还有配套的精品资源点击获取