ARTICLE DETAIL

资讯详情

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

小红书爬虫实战:x-s签名与Web接口采集全解析

小红书爬虫实战:x-s签名与Web接口采集全解析 简介面向需要完成爬虫相关毕业设计或大作业的学生开发者这份小红书爬虫项目包聚焦笔记、主页与搜索三类数据的抓取实现已有874人学习说明其具备实际参考价值。代码以TypeScript/JavaScript为主781个js与68个ts文件承载URL管理、请求发送、HTML解析、数据存储等核心逻辑63个json用于接口参数配置与抓取结果样例54个md文档提供项目说明和使用指引10个Python脚本辅助后续数据清洗与统计分析。整个压缩包共1079个文件大小仅2.7MB目录结构按功能模块划分便于快速定位爬虫入口、解析规则和存储模块。包内提供环境变量示例、依赖清单和说明文档能帮助读者理解小红书页面结构、接口参数以及常见反爬应对思路如robots约束、请求频率控制、User-Agent伪装和验证码处理既可作为毕业设计/大作业的完整项目参考也可在此基础上扩展搜索去重、数据可视化或情感分析等功能适合有一定编程基础、希望系统掌握爬虫开发全流程的中级学习者。1. 小红书爬虫的难点不在“爬”而在签名与数据链路很多人拿到“小红书爬虫”这个需求时第一反应是拿 requests 直接请求主页然后解析 HTML结果收到一堆无内容的空壳页面或者 461 状态码。小红书页面是典型的前端渲染架构笔记、主页、搜索三类数据都要通过 Web 端接口获取而每个接口的请求头里都必须带一组动态签名参数——这也是小红书爬虫与普通静态站爬虫最本质的区别。本篇文章面向有一定 Python 基础、想系统落地小红书笔记、主页与搜索三类采集任务的工程师核心目标是讲清楚接口选型、登录态维护、x-s 签名机制以及三种场景的完整代码实现。同时会穿插我在实际工程中的参数调优思路和限速策略让这套方案能稳定跑得久而不是跑完一次就失效。2. 地基接口选型、登录态与小红书 x-s 签名机制2.1 三种数据链路选型Web 接口、移动端接口、RPA在动手写代码前先明确数据从哪来。目前做小红书数据采集主流路径有三条各有取舍。数据链路数据完整性反爬复杂度维护成本适用场景Web 端接口较高返回 JSON需要 x-s 签名中接口偶尔变字段笔记、主页、搜索批量采集移动端接口高包含更多推荐字段需要 app 签名及设备指纹高抓包与签名难度大高并发、深度数据采集浏览器 RPA完整渲染无签名问题低模拟人工操作高速度慢且耗内存少量账号、交互类操作我一般优先走 Web 端接口原因是小红书 Web 端的接口结构比移动端直观字段命名接近业务语义且通过登录态拿到的数据已经包含大部分业务字段。签名方面Web 端也相对容易定位和封装。RPA 适合做“登录后操作”这类交互型任务比如批量关注、私信触达但吞吐量低不适合大规模数据采集。注意选型不是一次性决策。接口字段变化、风控策略升级都会影响稳定性所以工程上要把“接口调用层”和“业务解析层”拆开方便某一层变化时快速适配。2.2 用 requests 维护登录态Cookie、Headers 与页面数据预热Web 端接口的多数数据请求需要登录态直接用无 Cookie 的请求访问首页接口大概率拿到的是降级数据或直接 461。正确做法是先手动登录一次拿到 Cookie 后放进 requests.Session并在请求前先用浏览器打开过对应页面让服务端生成“页面浏览”上下文字段。import requests session requests.Session() # 初始化 headers后续所有请求基于这份配置 session.headers.update({ 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 ), Accept: application/json, text/plain, */*, Origin: https://www.xiaohongshu.com, Referer: https://www.xiaohongshu.com/, }) # 登录后从浏览器复制完整 Cookie 字符串 cookie_str webIdxxx; gidxxx; xsecappidxxx; ... for item in cookie_str.split(; ): key, value item.split(, 1) session.cookies.set(key, value) # 预热先访问一次首页再请求具体接口 session.get(https://www.xiaohongshu.com/explore, timeout10)这段代码先构建了一个带完整浏览器头信息的 Session再把登录后的 Cookie 逐个写入。Origin和Referer必须保留部分接口会校验来源页面。预热请求的作用是让服务端生成与当前会话绑定的页面浏览标识直接跨过“无浏览行为直接调接口”的异常请求检测。关于 Cookie 的时效性我遇到过两种情况一种是服务端返回 461 状态码表示 Cookie 已失效另一种是接口返回 200 但业务数据为空比如笔记列表为空数组这时要优先检查 Cookie 是否过期而不是数据结构解析问题。2.3 小红书 x-s 签名机制、定位与工程化封装x-s 签名是 Web 端接口调用绕不开的环节。它的核心机制是前端把当前时间戳、请求路径、请求体参数和若干固定字符串拼接后经过特定算法生成一段签名放在请求头x-s中同时通过x-t传递时间戳。服务端在收到请求后会验签签名缺失或者时间戳偏差超过容限期直接拒绝。定位签名生成逻辑的标准做法是用开发者工具的“搜索”功能进入 Sources 面板后按快捷键 CommandShiftFWindows 为 CtrlShiftF在所有文件中搜索请求路径字符串比如fe_api/batchdoc在调用堆栈中逐层向上找生成x-s的函数。找到后把该函数及其依赖的代码片段单独抽取出来放进 Node.js 环境执行。// sign.js - 从浏览器中抽取后的签名函数封装 // 实际使用时需要将浏览器中提取的完整函数体复制到此处 function getXS(timestamp, path, body) { // 伪代码示意真实算法由请求路径、时间戳、body 与固定 salt 共同决定 let raw [timestamp, path, JSON.stringify(body)].join(); // 执行抽取到的核心算法逻辑 return generateSignature(raw); } // 导出供 Python 端调用 module.exports { getXS };上述代码只是工程结构的示意。落地时你会在浏览器调试工具里找到生成签名的具体算法并将其完整复制到这个文件中。然后通过 Node.js 子进程或 HTTP 本地服务的方式对外提供签名接口Python 侧在构造请求前先调用它。import json import subprocess import time def get_x_s(path: str, body: dict) - str: ts str(int(time.time() * 1000)) env_body json.dumps(body, separators(,, :), ensure_asciiFalse) result subprocess.run( [node, sign.js, ts, path, env_body], capture_outputTrue, textTrue, checkTrue ) return result.stdout.strip()这里用subprocess调 Node.js 而不是用 Python 重写算法核心原因有两点一是签名算法可能依赖多个全局变量和加密库在 Python 中重写工程量太大二是抽取的 JavaScript 代码与浏览器环境行为完全一致降低因实现差异导致的验签失败概率。subprocess方式每次调用都会启动一个 Node 进程高频请求时性能瓶颈明显。优化做法是启动一个常驻 Node 服务进程Python 通过标准输入输出或本地 Socket 与它通信签名延迟可以从几十毫秒降到个位数毫秒。后续 3.3 节的高频搜索场景我会优先采用常驻服务模式。3. 小红书笔记、主页、搜索三种爬取场景落代码3.1 笔记详情从 note_id 到 JSON笔记详情是最基础的采集单元后续主页列表和搜索结果最终都要落到笔记详情上。请求的接口路径为/api/sns/web/v1/feed通过note_id参数指定目标笔记。import requests import json def fetch_note_detail(session: requests.Session, note_id: str): path /api/sns/web/v1/feed params { source: web_explore_feed, note_id: note_id, } # 构造 x-s 签名注意签名基于完整请求路径加查询参数 body {note_id: note_id} sign_path path ?sourceweb_explore_feed x_s get_x_s(sign_path, body) headers { x-s: x_s, x-t: str(int(time.time() * 1000)), } resp session.get( https://www.xiaohongshu.com path, paramsparams, headersheaders, timeout10, ) data resp.json() note data[data][items][0][note_card] return { note_id: note[note_id], title: note.get(display_title, ), desc: note.get(desc, ), image_count: len(note.get(image_list, [])), image_urls: [img[url_default].split(!)[0] for img in note.get(image_list, [])], like_count: note[interact_info][liked_count], collect_count: note[interact_info][collected_count], comment_count: note[interact_info][comment_count], share_count: note[interact_info][shared_count], }这段代码的核心在image_urls的解析。小红书图片链接后面通常带!开头的后缀比如!webp、!300w这些是服务端的图片处理指令。用split(!)[0]可以拿到原始图片地址方便做小红书图片下载或原图归档。interact_info里的四个计数分别对应点赞、收藏、评论、分享是做竞品分析和爆款判断的关键数据。x-s签名的拼接规则要特别注意小红书 Web 端签名生成的路径参数需要按实际请求的完整路径来计算。如果你在代码里对params增加了额外字段比如加一个aid或page参数签名路径也要同步更新否则服务端验签不通过。3.2 主页爬取从 user_id 拿到全部笔记列表主页信息是账号维度的核心数据源接口路径为/api/sns/web/v1/user_posted。该接口通过user_id定位用户返回该用户公开的全部笔记列表支持游标分页。def fetch_user_notes(session: requests.Session, user_id: str, max_pages: int 10): path /api/sns/web/v1/user_posted cursor result [] for page in range(max_pages): params { source: web_user_posted, num_per_page: 30, user_id: user_id, } if cursor: params[cursor] cursor sign_path path ? .join(f{k}{v} for k, v in params.items()) x_s get_x_s(sign_path, {}) headers { x-s: x_s, x-t: str(int(time.time() * 1000)), } resp session.get( https://www.xiaohongshu.com path, paramsparams, headersheaders, timeout10, ) data resp.json() items data[data][notes] for item in items: result.append({ note_id: item[note_id], title: item[display_title], cover: item[cover][url_default].split(!)[0], type: item[type], }) cursor data[data].get(cursor) has_more data[data].get(has_more, False) if not has_more or not cursor: break return result主页列表接口的cursor是继续翻页的凭证等价于传统意义上的页码但它的好处是天然支持增量同步——记录上一次的游标值下次从该位置继续。num_per_page官方上限为 30设置超过 30 也不会返回更多只会浪费请求次数。这个接口的限流比笔记详情严格得多。实践中单账号连续请求超过 5 页后响应时间会明显变长甚至直接返回空notes字段但 HTTP 状态码仍为 200。遇到这种情况不要立刻重试停 30 到 60 秒再继续比盲目调整签名更有效。3.3 搜索爬取关键词、排序、类型过滤与翻页搜索接口的路径是/api/sns/web/v1/search/notes按关键词搜索笔记列表。相比前两类接口搜索场景多了一个维度需要管理关键词集合和目标页数。def search_notes(session: requests.Session, keyword: str, max_pages: int 5, sort: str general, note_type: int 0, page_size: int 20): path /api/sns/web/v1/search/notes page 1 all_notes [] while page max_pages: params { source: web_search, keyword: keyword, page: page, page_size: page_size, search_id: web_search, sort: sort, note_type: note_type, } sign_path path ? .join(f{k}{v} for k, v in params.items()) x_s get_x_s(sign_path, {}) headers { x-s: x_s, x-t: str(int(time.time() * 1000)), } resp session.get( https://www.xiaohongshu.com path, paramsparams, headersheaders, timeout10, ) data resp.json() notes data[data][items] if not notes: break for item in notes: note item.get(note_card, {}) if not note: continue all_notes.append({ note_id: note[note_id], title: note.get(display_title, ), author_id: note.get(user, {}).get(user_id, ), author_name: note.get(user, {}).get(nickname, ), liked_count: note[interact_info].get(liked_count, 0), }) page 1 time.sleep(2) # 搜索接口限流最严格必须加间隔 return all_notes搜索接口的参数中sort支持general综合排序、time_descending最新发布、popularity_descending热门排序note_type可以过滤视频或图文其中1表示视频、0表示综合。与主页列表不同搜索结果中同一个note_id可能在不同页重复出现比如推荐算法把同一条笔记做了加权展示。实际工程中要用set做去重seen set() unique_notes [] for note in all_notes: if note[note_id] not in seen: seen.add(note[note_id]) unique_notes.append(note)搜索接口是三类接口中触发风控最快的。连续翻页超过 3 页大概率触发滑块验证或返回 461。克制是关键单关键词最多翻 5 页然后换下一个关键词再回来翻剩下的。把搜索任务设计成“小步快跑”模式远比一次性深翻要稳定。4. 限速、断点续爬与抓取完整性校验4.1 滑动窗口限速比随机 sleep 更稳常见的限速写法是time.sleep(random.uniform(1, 3))这种随机睡法的问题在于它无法精确控制请求密度。换成滑动窗口限速后能保证任意时间窗口内的请求数不超过阈值行为可预期也更接近人工浏览的请求节奏。import time from collections import deque class SlidingWindowLimiter: def __init__(self, max_requests: int, window_seconds: int): self.max_requests max_requests self.window_seconds window_seconds self.requests deque() def wait(self): now time.time() while self.requests and now - self.requests[0] self.window_seconds: self.requests.popleft() if len(self.requests) self.max_requests: sleep_time self.requests[0] self.window_seconds - now time.sleep(sleep_time 0.5) self.requests.append(time.time())这个限速器维护一个请求时间戳队列每当有新请求进来先清理窗口外的旧时间戳再判断当前窗口内请求数是否已达上限。达到上限时计算最早的请求时间戳距离窗口过期还有多久让程序睡到窗口滑动后再放行。调用方式很简单在每个接口请求前加一行limiter SlidingWindowLimiter(max_requests10, window_seconds60) def safe_get(url, **kwargs): limiter.wait() return session.get(url, **kwargs)需要说明的是max_requests10, window_seconds60是我在常规账号下的经验值即每分钟最多 10 次接口调用。高频场景可以把窗口缩小到 20 秒配合多账号轮换。即使后续把任务升级成分布式爬虫每个节点也要维持同样的限速策略而不是靠全局集中限速否则单节点的高频请求依然会暴露。4.2 断点续爬与图片批量下载配合长时间采集任务不可避免会遇到进程崩溃、网络中断或 IP 被临时限制。每次从头开始跑不仅浪费配额还会显著增加风控风险。断点续爬的落地方案很简单把已抓取的note_id持久化到本地文件每次启动任务时加载进集合跳过已完成的记录。import json import os class NoteStorage: def __init__(self, path: str): self.path path self.done_ids self._load() def _load(self): if not os.path.exists(self.path): return set() with open(self.path, r) as f: return set(json.load(f)) def save(self, note_id: str): self.done_ids.add(note_id) with open(self.path, w) as f: json.dump(list(self.done_ids), f)每次爬取前检查note_id是否已存在于存储中存在则直接跳过。保存时机建议在笔记详情下载完成后立刻调用不要攒到批量结束再写盘否则进程崩溃会丢失一大半进度。图片批量下载与断点续爬配合得紧密。搜索返回的笔记列表中只有封面图要拿全部图片需要进入详情页。下载时建议按note_id/序号.jpg的目录结构存储def download_images(images: list, note_id: str, base_dir: str images): note_dir os.path.join(base_dir, note_id) os.makedirs(note_dir, exist_okTrue) for idx, url in enumerate(images): file_path os.path.join(note_dir, f{idx:02d}.jpg) if os.path.exists(file_path) and os.path.getsize(file_path) 0: continue resp requests.get(url, timeout20) if resp.status_code 200: with open(file_path, wb) as f: f.write(resp.content)这里的“文件存在且大小不为 0”判定了断点续传能力图片下载一半失败时下次运行会自动补下。下载图片的请求与接口请求使用完全独立的会话和限速窗口因为小红书对图片 CDN 的限流策略与 Web 接口不同图片域名通常宽松得多。4.3 完整性校验清单一个成熟的爬虫任务不能只看最终文件行数对不对要做字段级校验。下表是我在每次任务收尾时必跑的检查项。校验项校验方式异常处理笔记字段完整性检查title、desc、note_id是否为空空字段回填重新请求详情接口图片数量一致性详情页image_count与本地图片文件数比对数量不足时单独重爬该笔记图片去重率总数 / 唯一note_id数去重率低于 95% 时排查翻页逻辑数据量级预估搜索关键词覆盖量 vs 实际抓取量比例低于 50% 时检查是否有 hidden 过滤检查方式可以直接在命令行跑一段统计脚本用 Python 的内置模块即可完成不需要引入额外的数据仓库组件。重点在于把校验做成流程的一部分而不是等任务结束再去翻日志。每抓完一批就执行一次校验并输出统计摘要日志里顺带记录每次请求的响应状态码分布这样被封号前就能看到状态码异常的趋势提前降速或者轮换账号。本文还有配套的精品资源点击获取
返回列表