
1. 快手 photoId 抓取到底卡在哪快手 photoId 是视频在平台内的唯一编号拿到它才能继续请求评论列表、播放量、作者信息这些二级数据。很多 Python 爬虫开发者第一次写快手采集时会以为 photoId 藏在页面 HTML 里正则一搜就能出来结果发现页面源码里根本没有明文的视频 ID只有一堆压缩过的 JS 和加密参数。真正能拿到 photoId 的入口是https://www.kuaishou.com/graphql这个接口通过visionProfilePhotoList这个 operation 返回的 feeds 数组里每个photo.id就是你要的 photoId。问题在于这个 graphql 接口对请求头、Cookie、content-type 都有要求少一个字段就可能返回空数据或者 403。更麻烦的是当你在本地反复调试、频繁请求时很容易触发风控表现为返回result: 2或者直接连接超时。这时候很多人会想到挂代理但代理配置写错、环境变量没生效、requests 不读系统代理又是一连串新报错。我试过在同一个脚本里同时处理签名头、Cookie 刷新和出口 IP 切换如果没有一个统一的配置层代码会变得非常难维护。这篇内容面向的是已经会写基础 requests 请求、但卡在快手 photoId 抓取和请求配置上的 Python 爬虫开发者。我会先给出一份可复制的config.toml骨架把请求头、超时、重试、出口配置都收拢到一处再讲怎么用 TaoToken 的统一 Key 接入方式管理调用凭证最后用一个 photoId 接口验证动作确认整条链路通了。整个过程不需要你改业务代码结构只需要把配置层替换掉。2. TaoToken 前置统一 Key 与 config.toml 骨架在写快手采集脚本之前先把请求配置和凭证管理独立出来。TaoToken 提供的是统一 API 接入层官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。它的作用是让你用一个 Key 管理多个模型的调用同时把请求出口和鉴权逻辑收敛到统一配置里避免每个脚本都散落着硬编码的 token。你需要先在控制台创建一个 API Key。打开 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 登录后进入 API Keys 页面点新建复制生成的 Key。这个 Key 后面会写进config.toml不要直接贴在 Python 代码里。如果你还没决定用哪个模型做辅助分析可以先到模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 看看可用列表快手采集场景下通常用轻量模型做字段提取和异常归类就够了。下面是我实际在用的config.toml骨架你可以直接复制到项目根目录把api_key换成你自己的# config.toml [app] name kuaishou-photoid-crawler timeout 15 retry 3 retry_backoff 1.5 [taotoken] base_url https://taotoken.net/api api_key sk-替换成你在控制台创建的Key model gpt-4o-mini max_tokens 1024 [request] user_agent Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 content_type application/json accept_language zh-CN,zh;q0.9 cookie 替换成你浏览器里复制的Cookie [graphql] endpoint https://www.kuaishou.com/graphql operation_profile visionProfilePhotoList operation_comment commentListQuery读取配置用 Python 3.11 自带的tomllib就行不需要额外装包import tomllib from pathlib import Path def load_config(path: str config.toml) - dict: with Path(path).open(rb) as f: return tomllib.load(f) CFG load_config() print(CFG[graphql][endpoint])这样做的直接好处是Cookie 过期时只改config.toml一行换 Key 时也只改一行业务代码完全不用动。如果你后面要跑长期编码任务或者 Agent 调度可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 把配置和额度管理放到统一面板里省得每个项目单独维护。3. 可复制配置请求头、重试与 photoId 请求体快手 graphql 接口对请求头比较敏感content-type必须是application/jsonaccept建议写*/*Cookie必须带上且不能过期。下面这段代码把配置读取、请求头组装、重试逻辑都封装好了你可以直接复制import json import time import requests import tomllib from pathlib import Path def load_config(path: str config.toml) - dict: with Path(path).open(rb) as f: return tomllib.load(f) CFG load_config() def build_headers() - dict: r CFG[request] return { User-Agent: r[user_agent], Cookie: r[cookie], accept: */*, Accept-Language: r[accept_language], content-type: r[content_type], } def post_graphql(payload: dict, retry: int None) - dict: retry retry or CFG[app][retry] url CFG[graphql][endpoint] headers build_headers() last_err None for i in range(retry): try: resp requests.post( url, headersheaders, jsonpayload, timeoutCFG[app][timeout], ) if resp.status_code 200: return resp.json() last_err fHTTP {resp.status_code} except requests.RequestException as e: last_err str(e) time.sleep(CFG[app][retry_backoff] ** i) raise RuntimeError(fgraphql 请求失败: {last_err})photoId 的获取请求体里operationName固定为visionProfilePhotoListvariables里传userId和pcursor。第一次请求pcursor传None返回结果里的pcursor就是下一页的游标。下面这个函数只做一件事从博主主页拉第一页视频把每个视频的id、caption、photoUrl提取出来PROFILE_QUERY query visionProfilePhotoList($pcursor: String, $userId: String, $page: String, $webPageArea: String) { visionProfilePhotoList(pcursor: $pcursor, userId: $userId, page: $page, webPageArea: $webPageArea) { result pcursor feeds { author { id name } photo { id caption photoUrl viewCount commentCount } } } } def fetch_photo_ids(user_id: str, pcursor: str None) - tuple: payload { operationName: CFG[graphql][operation_profile], variables: { page: profile, pcursor: pcursor, userId: user_id, }, query: PROFILE_QUERY, } data post_graphql(payload) node data.get(data, {}).get(visionProfilePhotoList) or {} feeds node.get(feeds) or [] next_cursor node.get(pcursor) items [] for feed in feeds: photo feed.get(photo) or {} items.append({ photo_id: photo.get(id), caption: photo.get(caption), photo_url: photo.get(photoUrl), view_count: photo.get(viewCount), comment_count: photo.get(commentCount), }) return items, next_cursor注意query字段我做了精简只保留 photoId 和基础统计需要的字段。原版 query 里有一大堆 fragment字段越多返回体越大风控也更容易盯上。实测下来精简 query 后返回速度更快photoId 一样能拿到。4. 验证请求photoId 接口成功结果长什么样配置写完后先别急着跑全量采集用一个已知的 userId 做单次验证。userId 可以从博主主页 URL 后缀拿到比如https://www.kuaishou.com/profile/3xr2ergp6i9jhgq里的3xr2ergp6i9jhgq就是 userId。运行下面这段if __name__ __main__: user_id 3xr2ergp6i9jhgq items, cursor fetch_photo_ids(user_id) print(f本页拿到 {len(items)} 个 photoId下一页游标: {cursor}) for it in items[:3]: print(it[photo_id], it[caption], it[view_count])成功时你会看到类似这样的输出本页拿到 20 个 photoId下一页游标: 1.7xxxxxxxx 3xabc123def 某条视频标题 12345 3xdef456ghi 另一条视频标题 6789 3xjkl789mno 第三条视频标题 4321如果items为空但cursor有值说明请求通了但该页没有视频换一个 userId 再试。如果返回result: 2或者data为None大概率是 Cookie 失效或请求头被识别先检查config.toml里的cookie字段是否和浏览器当前登录态一致。拿到 photoId 后你可以把它传给评论接口做二次验证COMMENT_QUERY query commentListQuery($photoId: String, $pcursor: String) { visionCommentList(photoId: $photoId, pcursor: $pcursor) { commentCount pcursor rootComments { commentId authorName content likedCount } } } def fetch_comments(photo_id: str, pcursor: str None) - tuple: payload { operationName: CFG[graphql][operation_comment], variables: {pcursor: pcursor, photoId: photo_id}, query: COMMENT_QUERY, } data post_graphql(payload) node data.get(data, {}).get(visionCommentList) or {} comments node.get(rootComments) or [] return comments, node.get(pcursor)调用fetch_comments(items[0][photo_id])如果返回评论列表且commentCount大于 0说明 photoId 有效、Cookie 有效、请求头有效整条链路就通了。这一步是整个采集流程里最关键的验证动作别跳过。5. 本篇常见错排查5.1 返回 403 或 result 为 2最常见的原因是 Cookie 过期。快手 Cookie 里的did和kuaishou.web.cp.api_ph这两个字段有效期较短浏览器关掉再打开可能就变了。解决办法是从浏览器开发者工具的 Network 面板里找任意一个 graphql 请求右键 Copy as cURL把Cookie头完整复制到config.toml。注意不要只复制一部分漏掉did就会返回空数据。5.2 requests 报 ProxyError 或连接超时如果你在环境变量里配了HTTP_PROXYrequests 默认会读取但格式写错就会报ProxyError。检查config.toml里没有代理相关字段同时确认终端里echo $HTTP_PROXY为空。如果确实需要走统一出口用 TaoToken 的 API 入口 https://taotoken.net/api 做请求转发而不是在本地硬编码代理地址。另外timeout设成 15 秒比较稳设太短在弱网下会频繁触发重试。5.3 photoId 字段为 Nonefeeds数组里每个元素的photo.id就是 photoId但如果 query 里没写photo { id }返回体里就不会有这个字段。检查你的PROFILE_QUERY是否包含photo { id caption photoUrl }。另外有些视频是图文类型photoUrl可能为空但id一定有值不要用photoUrl是否存在来判断 photoId 是否有效。5.4 pcursor 翻页死循环第一次请求pcursor传None返回的pcursor传给下一次请求。如果下一次返回的pcursor和上一次相同说明已经到底了要加一个判断if next_cursor current_cursor: break。不加这个判断脚本会一直请求同一页既浪费请求次数又容易触发风控。5.5 config.toml 读取报 KeyErrortomllib读取后是嵌套字典访问CFG[request][cookie]时如果config.toml里没有[request]段就会报 KeyError。确保你的配置文件里每个段名和代码里访问的键名完全一致大小写敏感。建议在load_config里加一层默认值兜底def load_config(path: str config.toml) - dict: with Path(path).open(rb) as f: cfg tomllib.load(f) cfg.setdefault(app, {}) cfg.setdefault(request, {}) cfg.setdefault(graphql, {}) return cfg6. 接入文档与后续动作整条链路跑通后photoId 只是起点。你可以基于它继续拉评论、播放量、作者作品列表但每次新增接口都要回到config.toml里确认请求头和 Cookie 是否还适用。如果后面要接模型做评论情感分析或标题关键词提取统一走 TaoToken 的 API Key 就行接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言的调用示例。需要管理多个 Key 或查看额度直接进 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。长期跑采集任务的话把重试间隔设成 2 秒以上单次会话别超过 200 个请求比任何代理配置都管用。