docs: 更新 README 和安装文档,新增 yt-studio-groupid-lookup 技能说明
This commit is contained in:
22
README.md
22
README.md
@@ -1,6 +1,6 @@
|
|||||||
# YouTube Studio 工具集
|
# YouTube Studio 工具集
|
||||||
|
|
||||||
本项目包含三个 Trae CN 技能(skills)及配套 Python 脚本,覆盖 YouTube Studio 内容管理器(Content Manager)分析的完整工作流:**环境准备 → 批量生成报告 URL → 脚本化下载分析数据**。
|
本项目包含四个 Trae CN 技能(skills)及配套 Python 脚本,覆盖 YouTube Studio 内容管理器(Content Manager)分析的完整工作流:**环境准备 → 批量生成报告 URL → 群组名解析实体 ID → 脚本化下载分析数据**。
|
||||||
|
|
||||||
仅支持 Windows。
|
仅支持 Windows。
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
| `uv-env-setup` | 用 uv 准备并维护 Python 运行环境(`pyproject.toml` → `uv sync` → `uv run`) | 「帮我准备环境」「装依赖」 |
|
| `uv-env-setup` | 用 uv 准备并维护 Python 运行环境(`pyproject.toml` → `uv sync` → `uv run`) | 「帮我准备环境」「装依赖」 |
|
||||||
| `yt-studio-url-builder` | 根据需求清单(CSV/Excel)批量拼接 explore 报告 URL | 「根据这份清单生成 Studio URL」 |
|
| `yt-studio-url-builder` | 根据需求清单(CSV/Excel)批量拼接 explore 报告 URL | 「根据这份清单生成 Studio URL」 |
|
||||||
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载分析 CSV(zip) | 「用我的 Chrome 会话下载这份 CSV」 |
|
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载分析 CSV(zip) | 「用我的 Chrome 会话下载这份 CSV」 |
|
||||||
|
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 | 「把这些群组名查成 Group ID」 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -23,10 +24,14 @@
|
|||||||
│ │ ├── SKILL.md
|
│ │ ├── SKILL.md
|
||||||
│ │ ├── references/ # input-guide.md(输入清单指南)、troubleshooting.md
|
│ │ ├── references/ # input-guide.md(输入清单指南)、troubleshooting.md
|
||||||
│ │ └── scripts/ # build_studio_urls.py、countries.json
|
│ │ └── scripts/ # build_studio_urls.py、countries.json
|
||||||
│ └── youtube-studio-csv-download/ # CSV 下载技能
|
│ ├── youtube-studio-csv-download/ # CSV 下载技能
|
||||||
|
│ │ ├── SKILL.md
|
||||||
|
│ │ ├── references/troubleshooting.md
|
||||||
|
│ │ └── scripts/youtube_export_download.py
|
||||||
|
│ └── yt-studio-groupid-lookup/ # 群组名->entity_id 解析技能
|
||||||
│ ├── SKILL.md
|
│ ├── SKILL.md
|
||||||
│ ├── references/troubleshooting.md
|
│ ├── references/ # input-guide.md(名单/套件指南)、troubleshooting.md
|
||||||
│ └── scripts/youtube_export_download.py
|
│ └── scripts/lookup_groups.py
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── install-skills.bat # 傻瓜安装入口(双击运行)
|
│ ├── install-skills.bat # 傻瓜安装入口(双击运行)
|
||||||
│ └── install-skills.ps1 # 安装逻辑
|
│ └── install-skills.ps1 # 安装逻辑
|
||||||
@@ -56,6 +61,15 @@ scripts\install-skills.bat
|
|||||||
|
|
||||||
看到 `全部安装成功` 即完成。脚本会把 `skills\` 下全部技能复制到 `%USERPROFILE%\.trae-cn\skills\`;同名旧版自动备份到 `~\.trae-cn\skills-backup\`,不会丢数据。详细说明见 [docs/install-skills.md](docs/install-skills.md)。
|
看到 `全部安装成功` 即完成。脚本会把 `skills\` 下全部技能复制到 `%USERPROFILE%\.trae-cn\skills\`;同名旧版自动备份到 `~\.trae-cn\skills-backup\`,不会丢数据。详细说明见 [docs/install-skills.md](docs/install-skills.md)。
|
||||||
|
|
||||||
|
**默认安装全部技能**。需要只装部分或先预览时,可在命令行加参数(bat 与 ps1 都支持):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 只安装指定的几个技能
|
||||||
|
scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download
|
||||||
|
# 只预览将安装/覆盖/备份的技能,不实际复制
|
||||||
|
scripts\install-skills.bat -DryRun
|
||||||
|
```
|
||||||
|
|
||||||
### 第 2 步:准备 Python 运行环境
|
### 第 2 步:准备 Python 运行环境
|
||||||
|
|
||||||
项目根目录执行:
|
项目根目录执行:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
| `uv-env-setup` | 用 uv 准备并维护本项目 Python 运行环境(`uv sync` / `uv run`) |
|
| `uv-env-setup` | 用 uv 准备并维护本项目 Python 运行环境(`uv sync` / `uv run`) |
|
||||||
| `yt-studio-url-builder` | 根据需求清单(CSV/Excel)批量生成 YouTube Studio 内容管理器 explore URL |
|
| `yt-studio-url-builder` | 根据需求清单(CSV/Excel)批量生成 YouTube Studio 内容管理器 explore URL |
|
||||||
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSV(zip) |
|
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSV(zip) |
|
||||||
|
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 |
|
||||||
|
|
||||||
## 安装(傻瓜式)
|
## 安装(傻瓜式)
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
scripts\install-skills.bat
|
scripts\install-skills.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。
|
看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。**默认安装全部技能**;同名旧版自动备份到 `~/.trae-cn/skills-backup/`。
|
||||||
|
|
||||||
命令行等价方式(PowerShell / cmd 均可):
|
命令行等价方式(PowerShell / cmd 均可):
|
||||||
|
|
||||||
@@ -26,12 +27,28 @@ scripts\install-skills.bat
|
|||||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-skills.ps1
|
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` 的子目录(即可安装技能)。
|
1. 扫描项目 `skills\` 下每个包含 `SKILL.md` 的子目录(即可安装技能;缺 `SKILL.md` 的目录会提示「跳过」)。
|
||||||
2. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`。
|
2. **默认安装全部技能**;若带 `-Skills` 则只装指定的,并提示未找到的技能名。
|
||||||
3. 目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版——不直接覆盖,旧版不丢。
|
3. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)。
|
||||||
4. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功时退出码为 0。
|
4. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`;目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖。
|
||||||
|
5. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功退出码 0,失败退出码 2。
|
||||||
|
6. 带 `-DryRun` 时只打印将安装/覆盖/备份的技能清单,不做任何修改。
|
||||||
|
|
||||||
安全性:脚本只处理本项目 `skills\` 中出现的技能,不会删除或改动 `.trae-cn\skills\` 下的其他技能;不修改任何 Trae CN 配置文件。
|
安全性:脚本只处理本项目 `skills\` 中出现的技能,不会删除或改动 `.trae-cn\skills\` 下的其他技能;不修改任何 Trae CN 配置文件。
|
||||||
|
|
||||||
|
|||||||
289
docs/yt-studio-groupid-lookup-principle.md
Normal file
289
docs/yt-studio-groupid-lookup-principle.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# 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`。*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "StudioLift"
|
name = "StudioLift"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。"
|
description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Trae CN skill installer launcher - double click to run
|
rem Trae CN skill installer launcher - double click to run
|
||||||
rem Actual logic lives in install-skills.ps1 (same folder)
|
rem Actual logic lives in install-skills.ps1 (same folder)
|
||||||
|
rem Usage: install-skills.bat (install ALL skills)
|
||||||
|
rem install-skills.bat -Skills a,b,c (install only those)
|
||||||
|
rem install-skills.bat -DryRun (preview only, no changes)
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
if not exist "install-skills.ps1" (
|
if not exist "install-skills.ps1" (
|
||||||
echo [ERROR] install-skills.ps1 not found in %~dp0
|
echo [ERROR] install-skills.ps1 not found in %~dp0
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "install-skills.ps1"
|
powershell -NoProfile -ExecutionPolicy Bypass -File "install-skills.ps1" %*
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -2,10 +2,20 @@
|
|||||||
# Trae CN 技能安装器(Windows)
|
# Trae CN 技能安装器(Windows)
|
||||||
# 作用:把本项目 skills\ 下全部技能安装到用户级技能目录
|
# 作用:把本项目 skills\ 下全部技能安装到用户级技能目录
|
||||||
# %USERPROFILE%\.trae-cn\skills\
|
# %USERPROFILE%\.trae-cn\skills\
|
||||||
|
# 默认安装全部技能;也可用 -Skills 只装一部分,或用 -DryRun 预览。
|
||||||
# 用法:双击同目录下的 install-skills.bat(推荐),或
|
# 用法:双击同目录下的 install-skills.bat(推荐),或
|
||||||
# powershell -NoProfile -ExecutionPolicy Bypass -File install-skills.ps1
|
# powershell -NoProfile -ExecutionPolicy Bypass -File install-skills.ps1
|
||||||
|
# powershell ... -Skills yt-studio-url-builder,youtube-studio-csv-download
|
||||||
|
# powershell ... -DryRun
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
# 只安装这些技能(逗号/分号/空格分隔,匹配技能文件夹名,不区分大小写)。缺省=全部。
|
||||||
|
[string[]]$Skills,
|
||||||
|
# 只预览将要安装/备份/跳过的技能,不实际复制。
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
# --- 1. 定位源目录与目标目录 ---
|
# --- 1. 定位源目录与目标目录 ---
|
||||||
@@ -19,7 +29,15 @@ $BackupRoot = Join-Path $env:USERPROFILE ".trae-cn\skills-backup"
|
|||||||
Write-Host "=== Trae CN 技能安装器 ===" -ForegroundColor Cyan
|
Write-Host "=== Trae CN 技能安装器 ===" -ForegroundColor Cyan
|
||||||
Write-Host "技能源目录: $SkillsSource"
|
Write-Host "技能源目录: $SkillsSource"
|
||||||
Write-Host "安装目标: $TraeSkills"
|
Write-Host "安装目标: $TraeSkills"
|
||||||
Write-Host ""
|
|
||||||
|
# 把 -Skills 参数拆成规范化名字列表(兼容逗号/分号/空格分隔)
|
||||||
|
$skillFilter = @()
|
||||||
|
if ($Skills) {
|
||||||
|
$skillFilter = @(
|
||||||
|
$Skills | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } |
|
||||||
|
Where-Object { $_ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
# --- 2. 校验源目录并收集技能(含 SKILL.md 的子目录才算) ---
|
# --- 2. 校验源目录并收集技能(含 SKILL.md 的子目录才算) ---
|
||||||
if (-not (Test-Path $SkillsSource)) {
|
if (-not (Test-Path $SkillsSource)) {
|
||||||
@@ -27,25 +45,102 @@ if (-not (Test-Path $SkillsSource)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$skills = @(Get-ChildItem -Path $SkillsSource -Directory | Where-Object {
|
$allSkills = @(Get-ChildItem -Path $SkillsSource -Directory | Where-Object {
|
||||||
Test-Path (Join-Path $_.FullName "SKILL.md")
|
Test-Path (Join-Path $_.FullName "SKILL.md")
|
||||||
})
|
})
|
||||||
|
|
||||||
if ($skills.Count -eq 0) {
|
if ($allSkills.Count -eq 0) {
|
||||||
Write-Host "[错误] 源目录下没有可用技能(每个技能文件夹需包含 SKILL.md)。" -ForegroundColor Red
|
Write-Host "[错误] 源目录下没有可用技能(每个技能文件夹需包含 SKILL.md)。" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ("发现 {0} 个技能: {1}" -f $skills.Count, ($skills.Name -join ", "))
|
# 可选:漏了 SKILL.md 的目录会被跳过;给个提示,避免以为是 bug
|
||||||
|
$skippedDirs = @(Get-ChildItem -Path $SkillsSource -Directory | Where-Object {
|
||||||
|
-not (Test-Path (Join-Path $_.FullName "SKILL.md"))
|
||||||
|
})
|
||||||
|
foreach ($d in $skippedDirs) {
|
||||||
|
Write-Host "[跳过] $($d.Name)(缺 SKILL.md,不作为技能)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 按 -Skills 过滤
|
||||||
|
$targets = if ($skillFilter.Count -gt 0) {
|
||||||
|
$wanted = @($skillFilter | ForEach-Object { $_.ToLowerInvariant() })
|
||||||
|
@($allSkills | Where-Object { $wanted -contains $_.Name.ToLowerInvariant() })
|
||||||
|
} else {
|
||||||
|
$allSkills
|
||||||
|
}
|
||||||
|
|
||||||
|
# 过滤后校验:用户指定的技能名是否存在
|
||||||
|
if ($skillFilter.Count -gt 0 -and $targets.Count -lt $skillFilter.Count) {
|
||||||
|
$found = @($targets | ForEach-Object { $_.Name.ToLowerInvariant() })
|
||||||
|
$missing = @($skillFilter | Where-Object { $found -notcontains $_.ToLowerInvariant() })
|
||||||
|
Write-Host ("[警告] 未找到这些技能: {0}" -f ($missing -join ", ")) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targets.Count -eq 0) {
|
||||||
|
Write-Host "[错误] 没有待安装的技能(-Skills 指定的技能不存在,或源目录无可用技能)。" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 3. 读取每个技能的 frontmatter name,并校验其与文件夹名一致 ---
|
||||||
|
# 预测性关键:Trae 以 frontmatter 的 name 作为技能名,若与文件夹名不一致会引发歧义/旧名残留。
|
||||||
|
$warnCount = 0
|
||||||
|
foreach ($skill in $targets) {
|
||||||
|
$skillFile = Join-Path $skill.FullName "SKILL.md"
|
||||||
|
$fmName = $null
|
||||||
|
try {
|
||||||
|
$lines = Get-Content -Path $skillFile -TotalCount 6 -Encoding UTF8
|
||||||
|
foreach ($l in $lines) {
|
||||||
|
if ($l -match '^\s*name\s*[:=]\s*["'']([^"'']+)["'']') {
|
||||||
|
$fmName = $Matches[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# 读取失败不阻塞安装
|
||||||
|
}
|
||||||
|
if ($fmName -and $fmName -ne $skill.Name) {
|
||||||
|
$warnCount++
|
||||||
|
Write-Host ("[警告] {0}: frontmatter name 为 {1},与文件夹名不一致。" -f $skill.Name, $fmName) -ForegroundColor Yellow
|
||||||
|
} elseif (-not $fmName) {
|
||||||
|
$warnCount++
|
||||||
|
Write-Host ("[警告] {0}: SKILL.md 顶部未解析到 name,安装后可能无法触发。" -f $skill.Name) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("发现 {0} 个技能: {1}" -f $targets.Count, ($targets.Name -join ", "))
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
# --- 3. 逐个安装:旧版先备份,再复制新版 ---
|
# --- 4. 计算操作(备份/安装)并可选预览 ---
|
||||||
|
function Test-SkillInstalled([string]$skillName) {
|
||||||
|
return Test-Path (Join-Path $TraeSkills $skillName)
|
||||||
|
}
|
||||||
|
|
||||||
|
$toBackup = @($targets | Where-Object { Test-SkillInstalled $_.Name })
|
||||||
|
$toFresh = @($targets | Where-Object { -not (Test-SkillInstalled $_.Name) })
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "== 预览(未执行)==" -ForegroundColor Cyan
|
||||||
|
if ($toFresh.Count -gt 0) {
|
||||||
|
Write-Host (" 新安装 {0}: {1}" -f $toFresh.Count, ($toFresh.Name -join ", "))
|
||||||
|
}
|
||||||
|
if ($toBackup.Count -gt 0) {
|
||||||
|
Write-Host (" 覆盖安装(旧版将备份){0}: {1}" -f $toBackup.Count, ($toBackup.Name -join ", "))
|
||||||
|
}
|
||||||
|
Write-Host " 总技能数: $($targets.Count)"
|
||||||
|
Write-Host "同步目录: $TraeSkills"
|
||||||
|
Write-Host "备份目录: $BackupRoot"
|
||||||
|
Write-Host "[DryRun] 完成,未做任何修改。" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 5. 执行安装 ---
|
||||||
New-Item -ItemType Directory -Force -Path $TraeSkills | Out-Null
|
New-Item -ItemType Directory -Force -Path $TraeSkills | Out-Null
|
||||||
|
|
||||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
$backupCount = 0
|
$backupCount = 0
|
||||||
|
|
||||||
foreach ($skill in $skills) {
|
foreach ($skill in $targets) {
|
||||||
$dest = Join-Path $TraeSkills $skill.Name
|
$dest = Join-Path $TraeSkills $skill.Name
|
||||||
|
|
||||||
if (Test-Path $dest) {
|
if (Test-Path $dest) {
|
||||||
@@ -60,11 +155,11 @@ foreach ($skill in $skills) {
|
|||||||
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
|
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 4. 校验安装结果 ---
|
# --- 6. 校验安装结果 ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "=== 校验结果 ==="
|
Write-Host "=== 校验结果 ==="
|
||||||
$failed = @()
|
$failed = @()
|
||||||
foreach ($skill in $skills) {
|
foreach ($skill in $targets) {
|
||||||
if (Test-Path (Join-Path $TraeSkills "$($skill.Name)\SKILL.md")) {
|
if (Test-Path (Join-Path $TraeSkills "$($skill.Name)\SKILL.md")) {
|
||||||
Write-Host " [OK] $($skill.Name)" -ForegroundColor Green
|
Write-Host " [OK] $($skill.Name)" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +168,7 @@ foreach ($skill in $skills) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 5. 汇总与后续提示 ---
|
# --- 7. 汇总与后续提示 ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($failed.Count -gt 0) {
|
if ($failed.Count -gt 0) {
|
||||||
Write-Host ("安装失败: {0}" -f ($failed -join ", ")) -ForegroundColor Red
|
Write-Host ("安装失败: {0}" -f ($failed -join ", ")) -ForegroundColor Red
|
||||||
@@ -81,12 +176,16 @@ if ($failed.Count -gt 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "全部安装成功。" -ForegroundColor Green
|
Write-Host "全部安装成功。" -ForegroundColor Green
|
||||||
|
Write-Host ("本次:新建 {0} 个,覆盖 {1} 个(备份到技能目录外的 backups)。" -f $toFresh.Count, $toBackup.Count) -ForegroundColor Green
|
||||||
|
if ($warnCount -gt 0) {
|
||||||
|
Write-Host ("注意:{0} 处 name/文件夹不一致或缺 name 的可疑技能(见上方警告)。" -f $warnCount) -ForegroundColor Yellow
|
||||||
|
}
|
||||||
if ($backupCount -gt 0) {
|
if ($backupCount -gt 0) {
|
||||||
Write-Host "旧版本备份于: $BackupRoot(确认新版可用后可手动删除)" -ForegroundColor Yellow
|
Write-Host "旧版本备份于: $BackupRoot(确认新版可用后可手动删除)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "下一步: 重启 Trae CN 或新建会话后,在对话中提及技能名或相关意图即可触发:"
|
Write-Host "下一步: 重启 Trae CN 或新建会话后,在对话中提及技能名或相关意图即可触发:"
|
||||||
foreach ($s in $skills) {
|
foreach ($s in $targets) {
|
||||||
Write-Host " - $($s.Name)"
|
Write-Host " - $($s.Name)"
|
||||||
}
|
}
|
||||||
|
|||||||
124
skills/yt-studio-groupid-lookup/SKILL.md
Normal file
124
skills/yt-studio-groupid-lookup/SKILL.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
name: "yt-studio-groupid-lookup"
|
||||||
|
description: "把一批群组名批量解析为 entity_id(即 groupId),并标注其归属的内容所有者。围绕 scripts/lookup_groups.py,用「套件回放」search_groups 接口:在线捕获鉴权套件后并发查询,或离线用已存套件回放。当用户给出群组名清单、要查找/映射/匹配群组的 groupId 或 entity_id、确认某群组属于哪个所有者/内容管理器、排查群组 ID 查不到或回放 401 时使用。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 群组名解析为 entity_id(脚本版)
|
||||||
|
|
||||||
|
把群组名 --> `groupId`(即 explore URL 里的 `entity_id`),并标注归属的内容所有者。底层执行 `scripts/lookup_groups.py`。核心是「套件回放」:**套件**(捕获页面的完整鉴权头 + 请求体模板),**回放**(重发 `search_groups`,只改 `query`),因手写请求必 401。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- **批量解析**:用户有一批群组名,要拿到每个名字对应的 groupId / entity_id。
|
||||||
|
- **归属确认**:同名群组可能多个所有者都有,需要确认某群组属于哪个内容管理器(ownerId + 显示名)。
|
||||||
|
- **建 URL 前置**:要生成 explore 报告 URL,但清单里只有群组名、没有 entity_id。
|
||||||
|
- **排查失败**:回放遇到 401 / 查不到 / 大小写差异,需要定位修复。
|
||||||
|
|
||||||
|
## 领域词汇
|
||||||
|
|
||||||
|
- **套件(Bundle)**:从页面捕获的一次 `search_groups` 请求 = 完整请求头(`Authorization: SAPISIDHASH...`、`Cookie`、`X-YouTube-Delegation-Context` 等)+ 请求体模板(含 `context.user.delegationContext`)。一个内容所有者一份。手写必 401,唯一可靠来源。
|
||||||
|
- **回放(Replay)**:用套件重发 `search_groups`,**只改 body 的 `query` 字段**,其余(尤其 delegation 语境)一律不动。
|
||||||
|
- **实体ID / groupId**:响应 `groupDatas[].groupId`,即 explore URL 的 `entity_id`(如 `NCy9C2QPQ1E`)。
|
||||||
|
- **精确命中**:响应当中存在 `displayName === 查询名` 的项,直接取它的 groupId;命中即停,不再往下查。
|
||||||
|
- **变体候选**:无精确命中时,接口仍可能返回前 5 个相近名,写入结果「备注」列供人工复核。
|
||||||
|
|
||||||
|
## 脚本与依赖
|
||||||
|
|
||||||
|
- 脚本:`scripts/lookup_groups.py`(本技能目录下;若缺失,用 SearchCodebase 按 `lookup_groups.py` 定位)。
|
||||||
|
- 依赖:playwright、requests、openpyxl 已统一在根 `pyproject.toml` 声明(`uv sync` 自动装),环境准备见 `uv-env-setup` 技能。
|
||||||
|
- 登录态必须已存在:脚本不登录,只复用已登录 YouTube Studio 会话,否则跳 Google 登录页。
|
||||||
|
|
||||||
|
## 选择运行分支
|
||||||
|
|
||||||
|
| 你的情况 | 分支 | 关键参数 |
|
||||||
|
|---|---|---|
|
||||||
|
| 要在线拿新套件并立即查询 | 分支 A:捕获+回放 | `--url` + `--names` |
|
||||||
|
| 套件已存盘(`--save-bundles` 产物) | 分支 B:离线回放 | `--bundles` + `--names` |
|
||||||
|
| 只想捕获/校验套件,先不查 | 分支 C:仅捕获 | `--url`(不带 `--names`) |
|
||||||
|
| 无浏览器/网络,验脚本逻辑 | 分支 D:自测 | `--selftest` |
|
||||||
|
|
||||||
|
多所有者的分三种做法(三者都对应分支 A):`--url` 重复传多次、或 `--url ... --save-bundles bundles.json` 先存盘、或下次直接用 `--bundles bundles.json`(分支 B)。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
### 步骤 0:环境与自测(分支 D)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts\lookup_groups.py --selftest
|
||||||
|
```
|
||||||
|
|
||||||
|
**完成判据**:打印 `selftest OK`,退出码 0(无需浏览器/网络)。
|
||||||
|
|
||||||
|
### 步骤 1:确认登录态来源
|
||||||
|
|
||||||
|
复用已登录 YouTube Studio 的浏览器会话,否则跳 Google 登录页。与 `youtube-studio-csv-download` 同一套约定:
|
||||||
|
|
||||||
|
- 方式 A(能关浏览器):`--user-data-dir <目录> --channel chrome|msedge`,二者必须同品牌。
|
||||||
|
- 方式 B(浏览器不能关):先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`,再加 `--connect http://localhost:9222`。
|
||||||
|
|
||||||
|
**完成判据**:得到任意一种可复用的登录态(用户数据目录路径,或调试端口号)。
|
||||||
|
|
||||||
|
### 步骤 2:拿到所有者 URL 与群组名单
|
||||||
|
|
||||||
|
- 所有者 URL:`studio.youtube.com/owner/<ownerId>/analytics?...`;脚本自动从 URL 路径 `owner/<id>` 或 `?o=<id>` 提取 ownerId,不需手工填。
|
||||||
|
- 群组名单:json / txt / csv / xlsx 均可,支持 `群组名称`、`group_name`、`实体名称`、`名称` 等列名,自动去重。(格式细节见 [references/input-guide.md](references/input-guide.md))
|
||||||
|
|
||||||
|
**完成判据**:`--names` 指向的文件能被脚本解析出非空名单(列名可识别)。
|
||||||
|
|
||||||
|
### 步骤 3:运行脚本
|
||||||
|
|
||||||
|
分支 A(在线捕获 + 回放):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 单所有者
|
||||||
|
uv run python scripts\lookup_groups.py --url "<owner URL>" --names 名单.xlsx --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data"
|
||||||
|
# 多所有者:--url 重复;浏览器不能关就改用 --connect
|
||||||
|
uv run python scripts\lookup_groups.py --url "<owner1 URL>" --url "<owner2 URL>" --names 名单.csv --connect http://localhost:9222 --save-bundles bundles.json
|
||||||
|
```
|
||||||
|
|
||||||
|
分支 B(离线回放,套件已存盘,无需浏览器):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python scripts\lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
分支 C(仅捕获/校验套件,暂不查询):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python scripts\lookup_groups.py --url "<owner URL>" --save-bundles bundles.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键行为**:
|
||||||
|
- 打开页面后先**自动触发一次群组搜索**(猜搜索框);猜不中会提示「在浏览器顶部搜索框输入任意词并回车」,只需触发一次 `search_groups` 请求即可。
|
||||||
|
- 捕到请求后,脚本校验 `delegationContext.externalOwnerId` 与 URL ownerId 是否一致、鉴权头是否齐全(缺 Authorization/Cookie/delegation 是 401 根因,会在捕获时直接打警告)。
|
||||||
|
- 回放用线程本地 Session,并发(默认 8 线程)逐个名字 × 逐个所有者,**首个精确命中即停**。
|
||||||
|
|
||||||
|
**完成判据**:终端打印 `[输出] 精确命中 N/M -> <路径>`,退出码 0,且生成结果文件。
|
||||||
|
|
||||||
|
### 步骤 4:校验产物与处理未命中
|
||||||
|
|
||||||
|
- 结果列:`group_name / ownerid / owner_display / groupid / 备注`;`ownerid + owner_display` 标注群组归属的所有者。
|
||||||
|
- 未命中的名字会列在末尾 `[复核] 未命中 N 个`,其候选/线索在「备注」列。
|
||||||
|
- 处理变体:若备注里只有大小写/空格差异的候选,直接改用候选 groupId;若跨所有者都命中,以**精确 displayName** 为准核对归属;若名字疑似错字,用更短关键词(如品牌名)重跑一次。
|
||||||
|
|
||||||
|
**完成判据**:无 `HTTPxxx` / `ERR` 残留;每个群组要么有确定 groupId,要么在备注列明确记录了候选或原因。
|
||||||
|
|
||||||
|
## 常用参数速查
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `--url` | 所有者分析页 URL,可重复(多所有者) |
|
||||||
|
| `--names` | 群组名单:json / txt / csv / xlsx |
|
||||||
|
| `--bundles` | 已有套件 json(离线回放;或与 `--url` 捕获结果合并) |
|
||||||
|
| `--save-bundles` | 把套件写盘,供下次离线回放 |
|
||||||
|
| `--out` | 结果文件,默认 `group_entity_id_result.xlsx`,缺 openpyxl 自动回退 csv |
|
||||||
|
| `--max-workers` | 并发线程数,默认 8 |
|
||||||
|
| `--connect` | CDP 附加到已打开浏览器 |
|
||||||
|
| `--user-data-dir` / `--channel` | 用已登录用户数据目录启动 |
|
||||||
|
| `--owner-display` | 所有者显示名(可选,单 `--url` 时用于结果标注) |
|
||||||
|
| `--wait-seconds` | 等待手动触发搜索的最长秒数,默认 180 |
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [名单与套件制作指南](references/input-guide.md):名单文件格式/列别名、套件 bundles.json 结构、多所有者、产物核对。
|
||||||
|
- [常见问题排查](references/troubleshooting.md):按 现象 → 原因 → 解决 逐条排查(401、未登录跳转、捕获不到请求、查不到、CDP 连不上、channel 不匹配等)。
|
||||||
87
skills/yt-studio-groupid-lookup/references/input-guide.md
Normal file
87
skills/yt-studio-groupid-lookup/references/input-guide.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 名单与套件制作指南
|
||||||
|
|
||||||
|
`lookup_groups.py` 的两个输入:群组**名单**(`--names`)和鉴权**套件**(`--bundles` / `--save-bundles`)。本指南说明怎么组织。
|
||||||
|
|
||||||
|
## 1. 群组名单(`--names`)
|
||||||
|
|
||||||
|
### 1.1 文件格式
|
||||||
|
|
||||||
|
- **json**:三种结构都支持。
|
||||||
|
- 纯数组:`["名字1", "名字2"]`
|
||||||
|
- 带表头的二维数组:`[["GROUP_NAME"], ["名字1"], ["名字2"]]`
|
||||||
|
- 对象:`{"names": ["名字1", "名字2"]}`(也认 `group_names` / `groups` / `group_name`)
|
||||||
|
- **txt**:每行一个名字,空行自动忽略。
|
||||||
|
- **csv / xlsx**:读首个工作表,自动识别名字列(去空白转小写比对)。
|
||||||
|
|
||||||
|
### 1.2 自动识别的列名(别名)
|
||||||
|
|
||||||
|
`群组名称` / `群组` / `group_name` / `groupname` / `名称` / `name` / `实体名称` / `group name`。
|
||||||
|
|
||||||
|
- 有表头:首行表头命别名列,取该列后续所有行;首行不是表头且**单列**时整列当名字。
|
||||||
|
- 多列且首行无别名列:脚本报错「无法识别名字列」,改用别名列名或改成单列文件。
|
||||||
|
- 空值 / `nan` / `none` 自动跳过,名字按顺序去重。
|
||||||
|
|
||||||
|
### 1.3 示例
|
||||||
|
|
||||||
|
```csv
|
||||||
|
群组名称,备注
|
||||||
|
X 漫剧-1,
|
||||||
|
X 漫剧-2,重点
|
||||||
|
靓舟桃,
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 核对清单
|
||||||
|
|
||||||
|
- [ ] 明确要查的群组名,含必要的同名区分(如品牌名 + 序号)。
|
||||||
|
- [ ] 名字与页面上的 displayName 完全一致,否则只能进变体候选。
|
||||||
|
- [ ] csv/xlsx 用了别名列名或为单列文件。
|
||||||
|
|
||||||
|
## 2. 鉴权套件(`--bundles` / `--save-bundles`)
|
||||||
|
|
||||||
|
一个内容所有者对应一个**套件**,`bundles.json` 是套件数组。在线捕获(`--url` + `--save-bundles`)自动生成;也可手工维护。
|
||||||
|
|
||||||
|
### 2.1 结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ownerId": "bqSUnNpU67xJ51TxH4PKpQ",
|
||||||
|
"ownerDisplay": "FUTURE TV Co,Ltd",
|
||||||
|
"url": "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "SAPISIDHASH ...",
|
||||||
|
"Cookie": "...",
|
||||||
|
"X-YouTube-Delegation-Context": "...",
|
||||||
|
"...": "..."
|
||||||
|
},
|
||||||
|
"bodyTemplate": { "...": "...", "query": "" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 关键字段
|
||||||
|
|
||||||
|
- `ownerId`:内容管理器的 id(约 18 字符 base62)。
|
||||||
|
- `headers`:约 12+ 个;**必须原样回放**,缺失任一(尤其 `Authorization`、`Cookie`、`X-YouTube-Delegation-Context`)都会 401。不要重造。
|
||||||
|
- 回放时脚本会剥离长度/传输类头(`content-length`、`host`、`connection`、`transfer-encoding`、`accept-encoding` 等),由 requests 自算。
|
||||||
|
- `bodyTemplate`:请求体模板,含 `context.user.delegationContext.externalOwnerId`(锁定查询在哪个所有者名下)与 `serializedDelegationContext`。**回放只改 `query`**,其余一律不动。
|
||||||
|
|
||||||
|
### 2.3 多所有者
|
||||||
|
|
||||||
|
- 逐个所有者页面各捕获一份套件,合并进同一个 `bundles.json`。
|
||||||
|
- 同名 ownerId 以最新捕获为准(`--save-bundles` 会与 `--bundles` 读入的合并后写盘)。
|
||||||
|
- 查询时按 `bundles.json` 中的顺序逐个所有者尝试,**首个精确命中即停**。
|
||||||
|
|
||||||
|
### 2.4 核对清单
|
||||||
|
|
||||||
|
- [ ] `headers` 含 Authorization / Cookie / X-YouTube-Delegation-Context。
|
||||||
|
- [ ] `bodyTemplate` 的 `externalOwnerId` 与 URL ownerId 一致(不一致以请求体为准,脚本会打警告)。
|
||||||
|
- [ ] 每个所有者都有独立套件,不混用。
|
||||||
|
- [ ] 套件有效期:鉴权头是页面逐请求计算的,过期需重新 `--url` 捕获。
|
||||||
|
|
||||||
|
## 3. 产物核对
|
||||||
|
|
||||||
|
- 结果列:`group_name / ownerid / owner_display / groupid / 备注`。
|
||||||
|
- `groupid` 非空 = 精确命中;其 `ownerid + owner_display` 即归属所有者。
|
||||||
|
- `groupid` 为空时看「备注」:候选(`名=ID`)或各所有者线索(HTTP/网络错/无结果)。
|
||||||
|
- 行数应与名单一致(含重复项已去重后的名字数)。
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 常见问题排查
|
||||||
|
|
||||||
|
先按输出判断类型:`[捕获]`/`[套件]` 前缀的警告是**提示**(不中断);`SystemExit` / 致命错误则中断。核心是先分清是「没登录 / 套件没捕到 / 回放 401 / 查不到」。
|
||||||
|
|
||||||
|
## 登录态与浏览器
|
||||||
|
|
||||||
|
### 跳转到 Google 登录页
|
||||||
|
- 现象:脚本报「当前会话未登录,已跳转到 Google 登录页」,或捕获时停在 accounts.google。
|
||||||
|
- 原因:没复用已登录 YouTube Studio 的会话,开了全新会话。
|
||||||
|
- 解决:改用 `--user-data-dir + --channel`(方式 A,需先关闭对应浏览器),或 `--connect http://localhost:9222`(方式 B,浏览器开在调试端口)。
|
||||||
|
|
||||||
|
### 启动/连接浏览器失败
|
||||||
|
- 现象:`[!] 启动/连接浏览器失败: ...`。
|
||||||
|
- 原因:方式 A 时浏览器未关闭(用户数据目录被占用);方式 B 时调试端口没起。
|
||||||
|
- 解决:方式 A 关闭 Chrome/Edge 后重试;方式 B 先 `chrome.exe --remote-debugging-port=9222`(或 msedge)再运行。
|
||||||
|
|
||||||
|
### channel 不匹配
|
||||||
|
- 现象:启动后用 --user-data-dir 报告浏览器品牌不符。
|
||||||
|
- 原因:`--channel` 与用户数据目录指向的浏览器不是同一品牌(chrome / msedge)。
|
||||||
|
- 解决:`--channel` 必须与 `--user-data-dir` 指向的浏览器一致。
|
||||||
|
|
||||||
|
### CDP 附加后页面是空白/不是目标所有者
|
||||||
|
- 现象:连上 9222 但页面停在别的标签或未登录。
|
||||||
|
- 原因:`connect_over_cdp` 取的是第一个 context 的新标签,可能与已有登录标签不同步。
|
||||||
|
- 解决:确保浏览器已登录目标所有者;必要时先用浏览器手动打开所有者 URL 确认登录态。
|
||||||
|
|
||||||
|
## 套件捕获(`_capture_bundle`)
|
||||||
|
|
||||||
|
### 捕获不到 search_groups 请求
|
||||||
|
- 现象:`[捕获] ... 未捕获到 search_groups 请求` / `TimeoutError`。
|
||||||
|
- 原因:没触发搜索,或页面不是高级模式分析页,或搜索框选择器没猜中。
|
||||||
|
- 解决:在浏览器里于该所有者分析页**顶部搜索/筛选框输入任意词并回车**(只需一次)。脚本会提示并等待(默认 180 秒,可 `--wait-seconds` 调大)。若选择器每次都猜不中,可先手动搜索确认页面是高级模式。
|
||||||
|
|
||||||
|
### 警告:externalOwnerId 与 URL ownerId 不一致
|
||||||
|
- 现象:`[捕获] 警告:请求体 externalOwnerId=... 与 URL ownerId=... 不一致,以请求体为准`。
|
||||||
|
- 原因:URL 指向的 owner 与当前页面 delegation 语境不同(切换所有者后会残留)。
|
||||||
|
- 解决:确认 URL 是目标所有者;页面确实停在目标所有者高级模式页再捕获。
|
||||||
|
|
||||||
|
### 警告:缺 Authorization/Cookie/delegation
|
||||||
|
- 现象:`[捕获] 警告:缺少 Authorization(SAPISIDHASH)头 / ...`。
|
||||||
|
- 原因:捕获到的请求头不全,或请求体缺 `context.user.delegationContext`。
|
||||||
|
- 解决:重新捕获一次,或确认页面是在已登录的目标所有者分析页发起的群组搜索。
|
||||||
|
|
||||||
|
## 回放查询
|
||||||
|
|
||||||
|
### 回放全量 401
|
||||||
|
- 现象:结果备注大量 `HTTP401(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)`。
|
||||||
|
- 原因:套件过期(鉴权头是逐请求计算的),或头不全。
|
||||||
|
- 解决:重新用 `--url` 在线捕获套件(更新到 `--save-bundles`),再回放。查 `references/input-guide.md` 第 2 节核对 headers。
|
||||||
|
|
||||||
|
### HTTP403 / 无权限
|
||||||
|
- 现象:`HTTP403(无权限或 delegation 语境不符)`。
|
||||||
|
- 原因:delegation 语境不属于当前登录账号,或该所有者无授权。
|
||||||
|
- 解决:确认登录账号对该内容管理器有权限,重新捕获正确所有者的套件。
|
||||||
|
|
||||||
|
### HTTP429 / 限流
|
||||||
|
- 现象:`HTTP429(限流:调低 --max-workers 或稍后重试)`。
|
||||||
|
- 原因:并发太高或请求过频繁。
|
||||||
|
- 解决:降低 `--max-workers`,稍后重试。
|
||||||
|
|
||||||
|
### 网络错(ERR)
|
||||||
|
- 现象:备注 `网络错: ...`。
|
||||||
|
- 原因:网络不通 / 域名被拦 / 代理导致请求失败。
|
||||||
|
- 解决:检查网络与代理,重试。
|
||||||
|
|
||||||
|
## 匹配结果
|
||||||
|
|
||||||
|
### 某名字显示「无结果」
|
||||||
|
- 现象:备注含 `无结果`。
|
||||||
|
- 原因:该所有者下没有匹配的群组,或名字与 displayName 不完全一致。
|
||||||
|
- 解决:换所有者再试,或用更短关键词重跑;若名字疑似错字,用品牌名做短词触发变体候选。
|
||||||
|
|
||||||
|
### 名字只在候选里(大小写/空格差异)
|
||||||
|
- 现象:备注含候选 `名=ID`,但没有精确命中。
|
||||||
|
- 原因:列表里的 displayName 与输入存在大小写/空格差异。
|
||||||
|
- 解决:直接采用候选 groupId,或把名单里的名字改成与 displayName 完全一致后重跑。
|
||||||
|
|
||||||
|
### 同名群组跨所有者都有
|
||||||
|
- 现象:多个所有者都返回候选,或都命中但归属不同。
|
||||||
|
- 原因:不同内容管理器可各有一个「X 漫剧-N」。
|
||||||
|
- 解决:以**精确 displayName** 命中为准核对实体归属;跨所有者同名时确认要的是哪个 ownerId 下的 groupId。
|
||||||
|
|
||||||
|
### 名单不识别 / 空名单
|
||||||
|
- 现象:`[!] 名单为空或无法解析:...`,或 `无法识别名字列`。
|
||||||
|
- 原因:列名不在别名表,或文件结构不对。
|
||||||
|
- 解决:按 `references/input-guide.md` 第 1 节整理:用别名列名(群组名称/group_name/实体名称/名称),或改为单列文件。
|
||||||
|
|
||||||
|
## 输出问题
|
||||||
|
|
||||||
|
### 输出行数为 0 / 找不到输出文件
|
||||||
|
- 现象:`[输出] 精确命中 0/N`,或默认路径没找到。
|
||||||
|
- 解决:确认 `--names` 能解析出名单;`--out` 显式指定路径。缺 openpyxl 时 `.xlsx` 自动回退为 `.csv`(脚本会提示)。
|
||||||
|
|
||||||
|
### 缺依赖报 ModuleNotFoundError
|
||||||
|
- 现象:`ModuleNotFoundError: No module named 'requests'` 或 `'playwright'` 或 `'openpyxl'`。
|
||||||
|
- 解决:项目根 `uv sync` 后统一用 `uv run` 前缀执行;或单独 `pip install requests playwright openpyxl`。
|
||||||
Reference in New Issue
Block a user