16 KiB
YouTube Studio 群组名 → entity_id(groupId)原理
技术原理说明 · 实测分析
从「一个群组名」到「它的 entity_id(即 groupId)」——拆解 YouTube Studio 内容管理器里群组搜索接口
search_groups的请求-响应机制,以及为什么必须走**「捕获页面自己的请求 → 原样回放,只改 query」**这条路,而不能手写请求。分析基础:
scripts/lookup_groups.py的实现回放与yt-studio-groupid-lookup技能的实测结论 · 方法:浏览器捕获 + 脚本复现
目录
- 概述与核心结论
- 为什么需要 groupId
- 网络请求机制
- search_groups 请求体结构
- 服务端响应结构
- 为什么手写请求必 401
- 套件(Bundle):捕获页面请求
- 回放(Replay):只改 query
- 匹配逻辑:精确命中与候选
- 多所有者与归属标注
- 端到端流程总览
- 失败模式速查
- 与其它工具的衔接
01 概述与核心结论
在 YouTube Studio 内容管理器(Content Manager)里,一个**群组(Group)**是内容所有者名下用于组织一批频道/资产的单位。面向高级分析报表,每个群组在 URL 里由一个 entity_id 标识(当 entity_type=GROUP 时,实体 ID 就是群组的 groupId)。问题往往在于:我们只有群组的名字,不知道它的 entity_id,也就无法拼接出可访问的 explore 报表 URL。
群组搜索是页面自身的一个内部接口:search_groups。给它一个搜索词(query),它返回一批群组的 displayName 与 groupId。于是「拿到 groupId」就变成了「调用 search_groups,把群组名作为 query 发给它」。原理上很简单,实践上却有一个关键障碍:这个接口受 YouTube 内部鉴权保护,手写请求几乎必然返回 401。
一句话结论
获取 groupId = 捕获页面自带的
search_groups请求作为「套件」(完整请求头 + 请求体模板)→ 回放时只改动query字段 → 从响应groupDatas里按 displayName 精确匹配取回groupId。因为鉴权头是前端逐请求计算的,唯一可靠路径是借用页面已经发出的请求本身。
02 为什么需要 groupId
完整的看数工作流通常从「一份需求清单」开始,其中可能只有群组名称,而缺少报表 URL 所需的 entity_id。此时需要先把名称解析成 ID。
- 生成报表 URL:
yt-studio-url-builder技能根据清单批量拼接 explore URL,群组行需要entity_id(entity_type=GROUP&entity_id=<groupId>)。清单若只有名称,就得先补 ID。 - 多所有者同名歧义:不同内容所有者名下可能有同名群组(比如都叫「X 漫剧-1」)。解析需要确认该群组到底归属哪个所有者(
ownerId)。 - 报表导出:相关报表/导出的 URL 同样以
entity_id定位实体(详见docs/yt-studio-export-principle.md)。
所以「群组名 → groupId」是连接「需求清单」与「可访问报表」之间的关键一环,目标是产出至少 群组名 / groupid / 归属所有者 这样的映射。
03 网络请求机制
群组搜索来自 YouTube 的 youtubei 风格 v1 接口,属于 Web 端分析命名空间:
POST https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json
yta_web:YouTube Analytics 的 Web 端命名空间。search_groups:在内容管理器内搜索群组的动作。?alt=json:要求 JSON 响应。
请求体是一个 JSON,核心是 query(搜索词)。它跟前面讲的导出接口 csv_export 同属一套 youtubei 内部接口,但鉴权机制更严格——这正是后文要展开的重点。
04 search_groups 请求体结构
请求体看起来是一坨带 context 的 JSON,关键字段如下:
{
"context": {
"user": {
"delegationContext": {
"externalOwnerId": "bqSUnNpU67xJ51TxH4PKpQ", // 锁定查询发生在哪个所有者名下
"serializedDelegationContext": "<protobuf base64>" // 序列化的委托上下文
},
"...": "..."
},
"client": { /* 客户端元信息 */ }
},
"query": "X 漫剧-1" // ★ 唯一要改的字段
}
query:搜索词(群组名)。这是回放时唯一需要改动的字段。context.user.delegationContext.externalOwnerId:把查询锁定在某个内容所有者名下。它决定了「这一搜是替哪个所有者搜的」。context.user.serializedDelegationContext:一段 base64 编码的委托上下文,同样参与鉴权与所有者锁定。
关键词义
delegationContext:委托上下文。同一个登录账号可能管理着多个内容所有者(Content Owner),search_groups必须携带这一上下文,才能知道「在哪个所有者维度下搜索」。缺失或写错,要么 401,要么搜到的其实是别人的群组。
05 服务端响应结构
响应体相对简洁,群组结果集中在 groupDatas 数组:
{
"groupDatas": [
{ "displayName": "X 漫剧-1", "groupId": "NCy9C2QPQ1E" },
{ "displayName": "X 漫剧-2", "groupId": "NCyYYY......." }
]
}
displayName:群组的显示名,与页码上展示的名称一致。匹配的依据是它,而不是搜索引擎式的模糊匹配。groupId:群组的实体 ID,也就是 explore URL 里的entity_id。点进某个群组后,URL 会变成entity_type=GROUP&entity_id=<groupId>。
一条响应通常返回与搜索词相关的一组结果。我们只关心其中 displayName 与目标名完全一致的那一项,其余可当作「变体候选」供人工复核。
06 为什么手写请求必 401
这是整个机制里最核心、也最反直觉的一点:用常规 HTTP 客户端重发一遍同样的 URL 和 JSON,会返回 401。原因在于该接口的鉴权依赖前端 JavaScript 在每次请求时动态计算、并伴随请求发送的一组请求头。
实测页面发出的「套件」约包含 12 个以上请求头,其中至关重要的是:
| 请求头 | 作用 | 能不能手写 |
|---|---|---|
Authorization: SAPISIDHASH <ts>_<hash> |
由会话 Cookie(如 SAPISID)与当前时间戳经哈希得到,时间敏感 |
原则上可算,但极易过期/算错 |
X-YouTube-Delegation-Context |
委托上下文,锁定「替哪个所有者查询」 | 通常只能取自真实请求 |
Cookie |
浏览器登录会话凭证 | 浏览器专属,需完整复刻 |
其它还有一批 X-Goog-*、Origin、Referer 等头共同参与。任何一个缺失或不一致,都可能触发 401 或错误的所有者语境。
一句话结论
鉴权头是前端逐请求计算的,而不是静态的。试图用脚本「拼」出一份能通过鉴权的请求,等于重复实现 YouTube 内部鉴权——既不现实也不稳定。正确姿势是捕获页面已经发出的请求,把它的头和请求体原样拿过来,回放时只改
query。
07 套件(Bundle):捕获页面请求
「套件」就是页面某一次真实 search_groups 请求的完整快照,一个内容所有者一份。它由两大部分组成:
{
"ownerId": "bqSUnNpU67xJ51TxH4PKpQ", // 该套件归属的内容所有者 id
"ownerDisplay": "FUTURE TV Co,Ltd", // 显示名,用于结果标注
"headers": { "Authorization": "SAPISIDHASH ...", "Cookie": "...",
"X-YouTube-Delegation-Context": "...", "...": "..." },
"bodyTemplate": { "context": { "user": { "delegationContext": { "externalOwnerId": "..." } } },
"query": "" },
"url": "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json"
}
捕获时机发生在用户已在浏览器中打开目标所有者的高级分析页之后。lookup_groups.py 的做法:
- 用 Playwright 打开所有者分析页(必须复用已登录 YouTube Studio 的会话,见文档底部登录态说明)。
- 用页面响应监听器捕获名字里含
search_groups的请求,取最近一次。 - 读取该请求的全部请求头(
request.all_headers(),含 Cookie)与请求体(request.post_data)。 - 校验套件完整性:
headers是否含Authorization/Cookie/X-YouTube-Delegation-Context,bodyTemplate是否含delegationContext。 - 校验
externalOwnerId与 URL 中的ownerId是否一致,不一致则以请求体为准并打警告。
关键词义
套件(Bundle):鉴权头 + 请求体模板 + 请求地址的集合,是回放所需的一切。它必须来自真实请求,不能臆造。
捕获时只改
query,其余字段一律不动——这是「查对所有者、不 401」的保障。
08 回放(Replay):只改 query
拿到套件后,回放就是把「捕获的请求」用任意 HTTP 客户端重发一遍,但只替换 query 字段:
body = json.loads(json.dumps(bundle["bodyTemplate"])) # 深拷贝,避免污染模板
body["query"] = name # 只改这一处
resp = session.post(bundle["url"], json=body, headers=bundle["headers"])
两个关键细节:
- 深拷贝模板:每次回放都基于原模板新建一份,绝不复用污染。否则上一次
query会残留在模板里。 - 剥离「逐跳/长度类」请求头:像
content-length、host、connection、transfer-encoding、accept-encoding、content-encoding这类头交由 HTTP 客户端自行计算/处理,避免与客户端冲突。其余鉴权相关头原样透传。
回放通常并发进行(脚本默认 8 线程),并用线程本地 Session 复用连接,避免为每个名字新建连接带来的开销。每个名字按套件顺序逐个所有者尝试,首个精确命中即停。
09 匹配逻辑:精确命中与候选
search_groups 返回的是「相关」结果,不是「精确」结果。所以匹配要做判断:
for g in group_datas:
if g["displayName"] == name: # 完全一致才算命中
return g["groupId"], []
return None, [f"{g['displayName']}={g['groupId']}" for g in group_datas[:5]] # 否则取前 5 候选
- 精确命中:存在
displayName与查询名完全一致的一项 → 直接取其groupId,搜寻结束。 - 变体候选:没有精确命中时,把最多前 5 个相近结果以
名=ID形式记下来,写入结果「备注」列供人工判断(如大小写/空格差异)。
注意
匹配以
displayName的完全相等为准,不是模糊/子串匹配。若名字与页面显示只差大小写或空格,会被归为「候选」而非「命中」——此时人工直接采用候选groupId即可。
10 多所有者与归属标注
一个登录账号可管理多个内容所有者,而群组是「某所有者名下」的概念。所以查询实际上有两层维度:名字 × 所有者。
- 套件按所有者分:
bundles.json是一个数组,每个元素是一个所有者的套件。 - 逐个尝试:对每个名字,按套件顺序依次在所有者下搜索,首个精确命中即停,从而确定「这个名字属于哪个所有者、groupId 是多少」。
- 合并与更新:同一所有者重复捕获时,新的套件按
ownerId覆盖旧的;多所有者可以边捕获边累积。
结果表每一行用 ownerid + owner_display 标注该群组归属的内容所有者,便于后续拼接 explore URL 时把正确的 o、/owner/<id>/ 带进去。
11 端到端流程总览
flowchart TD
A[1. 准备名单<br/>一堆群组名 json/txt/csv/xlsx] --> B
B[2. 捕获套件<br/>打开所有者分析页;监听 search_groups 请求;取完整头+请求体] --> C
C[3. 校验套件<br/>缺 Authorization/Cookie/delegation 则警告] --> D
D[4. 回放查询<br/>深拷贝模板,只改 query;逐所有者逐个名字] --> E
E[5. 匹配取 ID<br/>displayName 精确命中→groupId;否则记候选] --> F
F[6. 输出结果<br/>群组名/ownerid/owner_display/groupid/备注 -> Excel]
style A fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style B fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style C fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style D fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style E fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style F fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
图 1 · 从群组名到 groupId 的端到端流程(红色 = 数据分析/匹配,蓝色 = 浏览器捕获与请求)
关键结构映射
| 环节 | 关键内容 |
|---|---|
| 名单输入 | 群组名列表(json / txt / csv / xlsx) |
| 套件捕获 | headers(鉴权)+ bodyTemplate(含 delegationContext)+ url |
| 请求体 | context.user.delegationContext.externalOwnerId(锁定所有者) |
| 唯一改动字段 | query |
| 响应 | groupDatas[].displayName / groupId |
| 结果 | group_name / ownerid / owner_display / groupid / 备注 |
12 失败模式速查
| 现象 | 原因 | 处理 |
|---|---|---|
回放全量 HTTP401 |
套件缺 Authorization/Cookie,或已过期(鉴权头逐请求计算) |
重新在线捕获套件;核对 headers 完整性 |
HTTP403 |
无权限,或委托上下文不符 | 确认登录账号对该所有者有权限;重新捕获正确所有者的套件 |
HTTP429 |
并发太高/请求过频被限流 | 调低 --max-workers,稍后重试 |
名单大规模 无结果 |
名字与 displayName 不完全一致,或不在该所有者下 |
换所有者;或用更短关键词(如品牌名)触发变体候选 |
| 查到的是别人的群组 | externalOwnerId 与目标 ownerId 不符 |
换所有者需重新捕获套件,不要复用旧 template |
| 跳转到 Google 登录页 | 未复用已登录会话 | 用 --user-data-dir 或 --connect 复用已登录浏览器 |
捕获不到 search_groups |
没触发搜索,或选择器没猜中搜索框 | 在浏览器顶部搜索框输入任意词并回车(脚本会等,默认最多 180 秒) |
13 与其它工具的衔接
lookup_groups.py 不是孤立脚本,它补齐了「看数工作流」里缺的那一环:
flowchart LR
A[需求清单<br/>群组名 + 数据周期等] --> B[群组名解析<br/>lookup_groups.py]
B --> C[生成 explore 报表 URL<br/>build_studio_urls.py]
C --> D[脚本化下载分析 CSV<br/>youtube-studio-csv-download]
style A fill:#ff5c5c22,stroke:#ff5c5c,color:#e8ecf1
style B fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style C fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
style D fill:#58a6ff22,stroke:#58a6ff,color:#e8ecf1
yt-studio-url-builder:根据清单拼接 explore URL,群组行需要entity_id。本脚本补上「名称 → entity_id」这一步。youtube-studio-csv-download:复用已登录会话下载报表 ZIP,URL 里同样需要正确的entity_id。- 复用已登录浏览器会话:与上面两个 skill 使用同一套登录态约定——要么以已登录的用户数据目录启动浏览器(
--user-data-dir+--channel),要么通过调试端口附加(--connect http://localhost:9222)。
分析基于 scripts/lookup_groups.py 的实现回放与 yt-studio-groupid-lookup 技能的实测结论。配套脚本:scripts/lookup_groups.py;配套技能:yt-studio-groupid-lookup。