290 lines
16 KiB
Markdown
290 lines
16 KiB
Markdown
# YouTube Studio 群组名 → entity_id(groupId)原理
|
||
|
||
> 技术原理说明 · 实测分析
|
||
>
|
||
> 从「一个群组名」到「它的 entity_id(即 groupId)」——拆解 YouTube Studio 内容管理器里群组搜索接口 `search_groups` 的请求-响应机制,以及为什么必须走**「捕获页面自己的请求 → 原样回放,只改 query」**这条路,而不能手写请求。
|
||
>
|
||
> 分析基础:`scripts/lookup_groups.py` 的实现回放与 `yt-studio-groupid-lookup` 技能的实测结论 · 方法:浏览器捕获 + 脚本复现
|
||
|
||
## 目录
|
||
|
||
1. [概述与核心结论](#01-概述与核心结论)
|
||
2. [为什么需要 groupId](#02-为什么需要-groupid)
|
||
3. [网络请求机制](#03-网络请求机制)
|
||
4. [search_groups 请求体结构](#04-search_groups-请求体结构)
|
||
5. [服务端响应结构](#05-服务端响应结构)
|
||
6. [为什么手写请求必 401](#06-为什么手写请求必-401)
|
||
7. [套件(Bundle):捕获页面请求](#07-套件bundle捕获页面请求)
|
||
8. [回放(Replay):只改 query](#08-回放replay只改-query)
|
||
9. [匹配逻辑:精确命中与候选](#09-匹配逻辑精确命中与候选)
|
||
10. [多所有者与归属标注](#10-多所有者与归属标注)
|
||
11. [端到端流程总览](#11-端到端流程总览)
|
||
12. [失败模式速查](#12-失败模式速查)
|
||
13. [与其它工具的衔接](#13-与其它工具的衔接)
|
||
|
||
---
|
||
|
||
## 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,关键字段如下:
|
||
|
||
```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` 数组:
|
||
|
||
```json
|
||
{
|
||
"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` 请求的完整快照,一个内容所有者一份。它由两大部分组成:
|
||
|
||
```json
|
||
{
|
||
"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` 的做法:
|
||
|
||
1. 用 Playwright 打开所有者分析页(必须复用已登录 YouTube Studio 的会话,见文档底部登录态说明)。
|
||
2. 用页面响应监听器捕获名字里含 `search_groups` 的请求,取最近一次。
|
||
3. 读取该请求的**全部请求头**(`request.all_headers()`,含 Cookie)与**请求体**(`request.post_data`)。
|
||
4. 校验套件完整性:`headers` 是否含 `Authorization` / `Cookie` / `X-YouTube-Delegation-Context`,`bodyTemplate` 是否含 `delegationContext`。
|
||
5. 校验 `externalOwnerId` 与 URL 中的 `ownerId` 是否一致,不一致则以请求体为准并打警告。
|
||
|
||
> **关键词义**
|
||
>
|
||
> **套件(Bundle)**:鉴权头 + 请求体模板 + 请求地址的集合,是回放所需的一切。它必须来自真实请求,不能臆造。
|
||
>
|
||
> 捕获时**只改 `query`**,其余字段一律不动——这是「查对所有者、不 401」的保障。
|
||
|
||
---
|
||
|
||
## 08 回放(Replay):只改 query
|
||
|
||
拿到套件后,回放就是把「捕获的请求」用任意 HTTP 客户端重发一遍,但**只替换 `query` 字段**:
|
||
|
||
```python
|
||
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` 返回的是「相关」结果,不是「精确」结果。所以匹配要做判断:
|
||
|
||
```python
|
||
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 端到端流程总览
|
||
|
||
```mermaid
|
||
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` 不是孤立脚本,它补齐了「看数工作流」里缺的那一环:
|
||
|
||
```mermaid
|
||
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`。*
|