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

@@ -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` | 复用已登录浏览器会话,脚本化下载分析 CSVzip | 「用我的 Chrome 会话下载这份 CSV」 | | `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载分析 CSVzip | 「用我的 Chrome 会话下载这份 CSV」 |
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_idgroupId并标注归属的内容所有者 | 「把这些群组名查成 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 运行环境
项目根目录执行: 项目根目录执行:

View File

@@ -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 分析 CSVzip | | `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSVzip |
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_idgroupId并标注归属的内容所有者 |
## 安装(傻瓜式) ## 安装(傻瓜式)
@@ -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 配置文件。

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`。*

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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)"
} }

View 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 不匹配等)。

View 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/网络错/无结果)。
- 行数应与名单一致(含重复项已去重后的名字数)。

View File

@@ -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
- 现象:`[捕获] 警告:缺少 AuthorizationSAPISIDHASH头 / ...`
- 原因:捕获到的请求头不全,或请求体缺 `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`