docs: 更新 README 和安装文档,新增 yt-studio-groupid-lookup 技能说明

This commit is contained in:
2026-08-24 14:25:59 +08:00
parent bcfe0f4a29
commit 5bc318e0cb
9 changed files with 750 additions and 21 deletions

View File

@@ -9,6 +9,7 @@
| `uv-env-setup` | 用 uv 准备并维护本项目 Python 运行环境(`uv sync` / `uv run` |
| `yt-studio-url-builder` | 根据需求清单CSV/Excel批量生成 YouTube Studio 内容管理器 explore URL |
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSVzip |
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_idgroupId并标注归属的内容所有者 |
## 安装(傻瓜式)
@@ -18,7 +19,7 @@
scripts\install-skills.bat
```
看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。
看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。**默认安装全部技能**;同名旧版自动备份到 `~/.trae-cn/skills-backup/`
命令行等价方式PowerShell / cmd 均可):
@@ -26,12 +27,28 @@ scripts\install-skills.bat
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-skills.ps1
```
### 可选参数bat 与 ps1 均支持)
| 参数 | 作用 |
|---|---|
| `-Skills <名1,名2>` | 只安装指定的技能(按技能文件夹名,不区分大小写);缺省=全部 |
| `-DryRun` | 只预览将安装/覆盖/备份的技能,不实际复制 |
示例:
```powershell
scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download
scripts\install-skills.bat -DryRun
```
## 脚本做了什么
1. 扫描项目 `skills\` 下每个包含 `SKILL.md` 的子目录(即可安装技能)。
2. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`
3. 目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版——不直接覆盖,旧版不丢
4. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功时退出码为 0
1. 扫描项目 `skills\` 下每个包含 `SKILL.md` 的子目录(即可安装技能;缺 `SKILL.md` 的目录会提示「跳过」)。
2. **默认安装全部技能**;若带 `-Skills` 则只装指定的,并提示未找到的技能名
3. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)
4. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`;目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖
5. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功退出码 0失败退出码 2。
6.`-DryRun` 时只打印将安装/覆盖/备份的技能清单,不做任何修改。
安全性:脚本只处理本项目 `skills\` 中出现的技能,不会删除或改动 `.trae-cn\skills\` 下的其他技能;不修改任何 Trae CN 配置文件。

View File

@@ -0,0 +1,289 @@
# YouTube Studio 群组名 → entity_idgroupId原理
> 技术原理说明 · 实测分析
>
> 从「一个群组名」到「它的 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`**:复用已登录会话下载报表 ZIPURL 里同样需要正确的 `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`。*