news 2026/10/1 20:42:58

python快手批量采集(二):用 pcursor 接口分页拉取与断点续采实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
python快手批量采集(二):用 pcursor 接口分页拉取与断点续采实战

1. 从一次翻页翻车说起:pcursor 分页游标到底是什么

做快手批量采集,第一页往往最顺利:请求发出去,二十条视频数据整整齐齐躺在visionProfilePhotoList里。真正让人抓头的是第二页——你把同样的参数再发一次,返回的还是那二十条。问题不在请求头,也不在 Cookie,而在一个叫pcursor的字段上。

pcursor是快手接口里的分页游标(cursor),你可以把它理解成图书馆管理员手里那枚书签:第一次借书时书签是空的,管理员从第一排开始给你拿;你拿走二十本后,他会在第二十本的位置夹一枚书签,下次你再来,他直接翻到书签处继续。这个「书签」不是页码,而是一串看起来像科学计数法的长数字,比如1.660360144345E12。它由上一组响应体返回,再原样塞进下一组请求的表单里,如此循环,直到响应体里出现no_more才代表到底了。

这篇要解决的就是这条链路上的工程化问题:怎么解析返回结构、怎么推进游标、什么时候停、断了之后怎么接着采。适合已经能发出第一页请求、但卡在「第二页拿不到数据」或者「采到一半程序崩了要重头再来」的 Python 开发者。核心检索词就三个:python 快手批量采集、pcursor 分页、断点续采。下面所有代码都可以直接复制改参数运行,我会把每一步的中间结果也打出来,方便你对照自己的返回体。

先说清楚一个前提:采集行为要遵守目标平台的服务条款和 robots 协议,控制请求频率,只采公开数据,别给服务器添堵。本文聚焦的是分页游标这套机制本身,把它吃透,你在别的分页接口上也能复用同样的思路。

2. 前置准备:TaoToken 接入与请求环境搭建

在写分页逻辑之前,得先把「能稳定发请求」这件事解决掉。很多同学卡在第一步不是代码问题,而是请求链路本身不稳:要么直连超时,要么返回一堆风控页面。我的做法是把模型调用和采集脚本的调试分开——采集脚本负责抓数据,遇到返回结构看不懂、报错信息读不明白的时候,用 TaoToken 的模型对话能力帮我快速解析 JSON 结构和定位字段路径,效率比对着几百行响应体肉眼找高得多。

TaoToken 是一个聚合多家大模型能力的 API 平台,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。它的价值在于:你写采集脚本时经常需要「让模型帮我看看这段返回体里 pcursor 到底在哪一层」,或者「这段正则为什么匹配不到」,直接调模型比开浏览器搜半天快。对于长期做数据采集和 Agent 的同学,Coding Plan 这类套餐能把调用成本压下来,适合把模型能力嵌进日常脚本调试流程。

接入本身不复杂,关键是三件套要配全:Base URL、API Key、Model ID。少任何一个都会报 401 或者 model not found。我建议你先把 Key 拿到手,后面验证分页逻辑时如果 JSON 解析报错,可以直接把响应体丢给模型问字段路径。

拿 Key 的路径是:登录后进控制台,在 API Keys 页面创建。地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建完复制那串sk-开头的字符串,只显示一次,记得存好。

如果你用的是 Claude Code 这类编码工具,它需要单独配置 Anthropic 兼容的接入方式,文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有 Base URL 和鉴权头的完整写法。配置类工具最怕的就是 Base URL 少写一段路径,或者 Key 前面多了空格,这两个坑我后面排障章节会专门讲。

环境层面,Python 侧只需要requests和json,标准库够用。建议建一个独立虚拟环境,避免和你机器上其他项目的依赖打架:

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install requests

请求头这块,快手接口对User-Agent、Referer、Cookie比较敏感。我的经验是把浏览器里真实请求的这几个头完整复制过来,尤其是Cookie,它决定了你能不能拿到数据。别用默认的 python-requests UA,那基本第一页就给你返回空。

3. 可复制的分页请求模板与游标持久化配置

这一节是全文的核心,我把分页请求拆成「请求参数模板」和「游标状态文件」两块。先看请求参数模板。快手这个接口的表单里,pcursor初始值是空字符串,其余参数(比如userId、count)保持固定。下面是一个可以直接跑的模板:

import requests import json import time import os BASE_URL = "https://www.kuaishou.com/graphql" # 以实际抓包到的接口为准 HEADERS = { "User-Agent": "你的浏览器UA", "Referer": "https://www.kuaishou.com/", "Cookie": "你的Cookie", "Content-Type": "application/json", } def build_payload(user_id, pcursor=""): return { "operationName": "visionProfilePhotoList", "variables": { "userId": user_id, "pcursor": pcursor, "count": 20, }, "query": "你的GraphQL查询语句", }

注意pcursor默认给空字符串,这就是第一页的起点。请求发出去后,从响应体里取下一枚游标:

def fetch_page(user_id, pcursor=""): payload = build_payload(user_id, pcursor) resp = requests.post(BASE_URL, headers=HEADERS, json=payload, timeout=15) resp.raise_for_status() data = resp.json() photo_list = data["data"]["visionProfilePhotoList"] next_cursor = photo_list.get("pcursor", "") videos = photo_list.get("feeds", []) return videos, next_cursor

这里有个细节:pcursor在响应体里的位置是data.visionProfilePhotoList.pcursor,不是顶层。字段路径写错是新手最常见的翻页失败原因,返回的next_cursor会是空,循环直接退出,你还以为是采完了。

接下来是断点续采的关键——游标持久化。思路很简单:每采完一页,把当前游标和已采数量写进本地 JSON 文件;程序重启时先读这个文件,从上次的游标继续。配置文件长这样:

{ "user_id": "3x1234567890", "last_pcursor": "1.660360144345E12", "fetched_count": 40, "seen_ids": ["video_id_1", "video_id_2"], "updated_at": "2025-01-01T12:00:00" }

对应的读写函数:

STATE_FILE = "cursor_state.json" def load_state(): if os.path.exists(STATE_FILE): with open(STATE_FILE, "r", encoding="utf-8") as f: return json.load(f) return {"user_id": "", "last_pcursor": "", "fetched_count": 0, "seen_ids": []} def save_state(state): state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)

seen_ids这个字段是去重用的。快手在翻页边界偶尔会返回重复视频,尤其是你请求间隔太短的时候。把已采的视频 id 存下来,写入前先判断,能避免数据里出现重复行。这个列表会越滚越大,采几千条之后建议换成 SQLite 或者布隆过滤器,但小规模采集用 JSON 完全够。

把分页和状态串起来的主循环:

def crawl(user_id, max_pages=50): state = load_state() pcursor = state["last_pcursor"] if state["user_id"] == user_id else "" seen = set(state["seen_ids"]) page = 0 while page < max_pages: videos, next_cursor = fetch_page(user_id, pcursor) if not videos: print("本页无数据,可能已到底或触发风控") break new_count = 0 for v in videos: vid = v.get("id") if vid and vid not in seen: seen.add(vid) new_count += 1 # 这里写你的入库逻辑 state.update({ "user_id": user_id, "last_pcursor": next_cursor, "fetched_count": state["fetched_count"] + new_count, "seen_ids": list(seen), }) save_state(state) print(f"第 {page+1} 页,新增 {new_count} 条,游标 {next_cursor}") if next_cursor == "no_more" or not next_cursor: print("已到最后一页") break pcursor = next_cursor page += 1 time.sleep(2) # 控制频率,别把服务器打爆 return state

这段代码里有两个终止条件:next_cursor == "no_more"和not next_cursor。前者是接口明确告诉你到底了,后者是防御性判断,防止字段缺失导致死循环。time.sleep(2)是必须的,我试过把间隔调到 0.5 秒,第三页就开始返回空数据,等几分钟再跑又正常,典型的频率限制。

4. 验证请求:用最小样本检查翻页完整性与去重效果

代码写完不能直接上大批量,先用一个粉丝量小的账号跑三页,验证三件事:游标是否真的在推进、翻页有没有漏、去重有没有生效。

第一步,打印每页的游标变化。正常情况应该是:第一页请求pcursor="",返回一个长数字;第二页请求这个长数字,返回另一个不同的长数字;直到某页返回no_more。如果你发现第二页返回的游标和第一页一样,说明请求参数没生效,大概率是pcursor没塞进variables里,或者塞错了层级。

v1, c1 = fetch_page(user_id, "") print("第一页游标:", c1) v2, c2 = fetch_page(user_id, c1) print("第二页游标:", c2) print("游标是否推进:", c1 != c2)

第二步,检查翻页完整性。把三页的视频 id 收集起来,看总数是不是接近 60(每页 20 条)。如果第二页只有 5 条,可能是账号本身视频就少,也可能是被截断。这时候把响应体完整打印出来,看feeds数组长度和pcursor字段:

resp = requests.post(BASE_URL, headers=HEADERS, json=build_payload(user_id, c1)) body = resp.json() feeds = body["data"]["visionProfilePhotoList"]["feeds"] print("本页条数:", len(feeds)) print("本页游标:", body["data"]["visionProfilePhotoList"]["pcursor"])

第三步,验证去重。故意把同一页请求两次,看seen_ids有没有拦住重复:

state = crawl(user_id, max_pages=2) print("累计采集:", state["fetched_count"]) print("去重集合大小:", len(state["seen_ids"]))

如果fetched_count小于len(seen_ids),说明有重复被拦下了,去重逻辑生效。正常情况下两者应该相等。

第四步,模拟断点续采。跑到第二页时按 Ctrl+C 中断,然后重新运行crawl,观察它是不是从last_pcursor继续,而不是从第一页重来。这一步能验证状态文件读写是否正确。我踩过的坑是:状态文件写在了相对路径,换了个工作目录运行就读不到了,结果又从第一页开始。建议用绝对路径,或者把状态文件和脚本放同一目录并用os.path.dirname(__file__)拼路径。

验证通过后,你会看到类似这样的输出:

第 1 页,新增 20 条,游标 1.660360144345E12 第 2 页,新增 20 条,游标 1.660360144346E12 第 3 页,新增 18 条,游标 no_more 已到最后一页 累计采集: 58 去重集合大小: 58

58 而不是 60,是因为账号本身只有 58 条视频,这是正常的。如果每页都满 20 但总数对不上,才需要怀疑漏采。

5. 常见报错排查:401、游标不推进与 JSON 解析失败

采集过程中最常撞见的几个报错,我按出现频率排一下,每个都给定位方法。

401 Unauthorized / 鉴权失败。这个在采集脚本里通常不是 Key 的问题,而是 Cookie 过期。快手接口靠 Cookie 里的登录态鉴权,Cookie 一般几小时到几天就失效。表现是请求返回 401 或者返回一个空的feeds。解决办法是重新从浏览器复制 Cookie。如果你同时用 TaoToken 调模型辅助调试,注意区分两套鉴权:TaoToken 用Authorization: Bearer sk-xxx,快手用 Cookie,别混。TaoToken 侧如果报 401,检查 Key 有没有多余空格、Base URL 是不是写成了https://taotoken.net/api(注意结尾不要多加斜杠导致路径拼接错误)。

local proxy failed / 连接超时。这个报错说明请求根本没发出去,卡在本地网络层。常见原因是系统代理设置和脚本里的代理配置冲突。检查requests有没有走系统代理,可以显式设置proxies={"http": None, "https": None}排除干扰。另外超时时间别设太短,快手接口偶尔响应慢,timeout=15比较稳妥。

reading 'choices' of undefined / JSON 解析失败。这个报错在采集脚本里对应的是resp.json()抛异常,说明返回的不是 JSON,可能是 HTML 风控页。先打印resp.text[:500]看内容。如果是 HTML,说明触发了风控,降低频率、换 Cookie、加请求间隔。如果你是在用模型辅助解析响应体时看到reading 'choices',那是模型 API 返回结构和你预期的不一致,检查请求体里model字段填的 Model ID 是否正确,以及messages数组格式对不对。

游标不推进,第二页和第一页数据一样。这是分页逻辑最典型的 bug。排查顺序:先确认pcursor有没有从响应体正确取出,打印body["data"]["visionProfilePhotoList"]["pcursor"];再确认取出的值有没有传进下一次请求的variables.pcursor;最后确认请求体是json=而不是data=,用data=发 GraphQL 会导致参数不被识别。这三步走完基本能定位。

OAuth 相关报错。如果你用 Claude Code 或类似工具接入 TaoToken,报 OAuth 错误通常是鉴权头格式不对。Anthropic 兼容接口需要x-api-key头而不是Authorization,具体看文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。配置类工具的三件套(Base URL、Key、Model ID)缺一不可,Model ID 填错会报 model not found,Base URL 填错会报 404 或连接失败。

no_more 判断失效导致死循环。有些账号的最后一页返回的pcursor是空字符串而不是no_more,如果你的终止条件只判断no_more,就会一直请求空游标,拿到重复数据。所以终止条件要写成if next_cursor in ("no_more", "", None)。这个坑我在两个不同账号上遇到过,返回格式不一致,防御性写法能省很多事。

6. 把分页能力沉淀成可复用的采集骨架

走到这里,你已经有了一个能翻页、能续采、能去重的采集脚本。我想再补一个实用技巧:把fetch_page和状态管理抽成一个类,这样换账号、换接口时只改参数不改逻辑。

class CursorCrawler: def __init__(self, fetch_fn, state_file): self.fetch_fn = fetch_fn self.state_file = state_file def run(self, key, max_pages=50): state = load_state() pcursor = state["last_pcursor"] if state["user_id"] == key else "" # ... 同上逻辑

这样你采完视频列表,想接着采评论或者别的分页接口,只要换一个fetch_fn就行,游标持久化和去重逻辑完全复用。

另外提醒一句:seen_ids用列表存,采到几万条之后in判断会变慢,因为列表查找是 O(n)。换成set或者写进 SQLite 加唯一索引,性能会好很多。我采一个五万粉的账号时,列表方案跑到两万条明显卡顿,换 set 之后流畅了。

最后,采集频率这件事再怎么强调都不为过。time.sleep(2)是底线,账号视频多的话建议加到 3 到 5 秒,并且每采几百条停一会儿。数据是采不完的,账号被封了就什么都没了。把游标状态存好,今天采一半明天接着采,比一口气冲到底稳妥得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 20:42:14

MyBatis缓存机制完全解析:一级缓存、二级缓存原理与实战排坑

最近收到不少读者关于MyBatis缓存的私信&#xff0c;问的内容几乎能拼出一套完整的高频面试题&#xff1a;一级缓存和二级缓存到底什么区别&#xff1f;为什么SpringBoot项目里连续调用两次查询&#xff0c;SQL却照样打印两次&#xff1f;为什么开了二级缓存反而读到脏数据&…

作者头像 李华
网站建设 2026/10/1 20:39:14

Spring Boot文件上传cleanup失败原因与解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 20:36:36

RTC实时时钟驱动开发实战:从初始化到低功耗唤醒与校准

简介&#xff1a;面向嵌入式驱动开发者的RTC&#xff08;实时时钟&#xff09;驱动开发参考包&#xff0c;围绕实时时钟芯片的驱动实现展开&#xff0c;覆盖初始化、时间读取与设置、中断处理、电源管理、闰年与月份天数更新等关键环节&#xff0c;适合需要基于嵌入式平台实现或…

作者头像 李华