# 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=`)。清单若只有名称,就得先补 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": "" // 序列化的委托上下文 }, "...": "..." }, "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=`。 一条响应通常返回与搜索词相关的一组结果。我们只关心其中 `displayName` 与目标名**完全一致**的那一项,其余可当作「变体候选」供人工复核。 --- ## 06 为什么手写请求必 401 这是整个机制里最核心、也最反直觉的一点:**用常规 HTTP 客户端重发一遍同样的 URL 和 JSON,会返回 401**。原因在于该接口的鉴权依赖前端 JavaScript 在每次请求时动态计算、并伴随请求发送的一组请求头。 实测页面发出的「套件」约包含 12 个以上请求头,其中至关重要的是: | 请求头 | 作用 | 能不能手写 | | --- | --- | --- | | `Authorization: SAPISIDHASH _` | 由会话 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//` 带进去。 --- ## 11 端到端流程总览 ```mermaid flowchart TD A[1. 准备名单
一堆群组名 json/txt/csv/xlsx] --> B B[2. 捕获套件
打开所有者分析页;监听 search_groups 请求;取完整头+请求体] --> C C[3. 校验套件
缺 Authorization/Cookie/delegation 则警告] --> D D[4. 回放查询
深拷贝模板,只改 query;逐所有者逐个名字] --> E E[5. 匹配取 ID
displayName 精确命中→groupId;否则记候选] --> F F[6. 输出结果
群组名/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[需求清单
群组名 + 数据周期等] --> B[群组名解析
lookup_groups.py] B --> C[生成 explore 报表 URL
build_studio_urls.py] C --> D[脚本化下载分析 CSV
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`。*