diff --git a/README.md b/README.md index a69fdc5..729cda6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # YouTube Studio 工具集 -本项目包含三个 Trae CN 技能(skills)及配套 Python 脚本,覆盖 YouTube Studio 内容管理器(Content Manager)分析的完整工作流:**环境准备 → 批量生成报告 URL → 脚本化下载分析数据**。 +本项目包含四个 Trae CN 技能(skills)及配套 Python 脚本,覆盖 YouTube Studio 内容管理器(Content Manager)分析的完整工作流:**环境准备 → 批量生成报告 URL → 群组名解析实体 ID → 脚本化下载分析数据**。 仅支持 Windows。 @@ -11,6 +11,7 @@ | `uv-env-setup` | 用 uv 准备并维护 Python 运行环境(`pyproject.toml` → `uv sync` → `uv run`) | 「帮我准备环境」「装依赖」 | | `yt-studio-url-builder` | 根据需求清单(CSV/Excel)批量拼接 explore 报告 URL | 「根据这份清单生成 Studio URL」 | | `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载分析 CSV(zip) | 「用我的 Chrome 会话下载这份 CSV」 | +| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 | 「把这些群组名查成 Group ID」 | ## 项目结构 @@ -23,10 +24,14 @@ │ │ ├── SKILL.md │ │ ├── references/ # input-guide.md(输入清单指南)、troubleshooting.md │ │ └── 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 -│ ├── references/troubleshooting.md -│ └── scripts/youtube_export_download.py +│ ├── references/ # input-guide.md(名单/套件指南)、troubleshooting.md +│ └── scripts/lookup_groups.py ├── scripts/ │ ├── install-skills.bat # 傻瓜安装入口(双击运行) │ └── 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)。 +**默认安装全部技能**。需要只装部分或先预览时,可在命令行加参数(bat 与 ps1 都支持): + +```powershell +# 只安装指定的几个技能 +scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download +# 只预览将安装/覆盖/备份的技能,不实际复制 +scripts\install-skills.bat -DryRun +``` + ### 第 2 步:准备 Python 运行环境 项目根目录执行: diff --git a/docs/install-skills.md b/docs/install-skills.md index 06481ed..c49d014 100644 --- a/docs/install-skills.md +++ b/docs/install-skills.md @@ -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 分析 CSV(zip) | +| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 | ## 安装(傻瓜式) @@ -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 配置文件。 diff --git a/docs/yt-studio-groupid-lookup-principle.md b/docs/yt-studio-groupid-lookup-principle.md new file mode 100644 index 0000000..dd60b2e --- /dev/null +++ b/docs/yt-studio-groupid-lookup-principle.md @@ -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=`)。清单若只有名称,就得先补 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`。* diff --git a/pyproject.toml b/pyproject.toml index 9853128..5a24c82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StudioLift" -version = "0.1.0" +version = "0.3.0" description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。" requires-python = ">=3.10" dependencies = [ diff --git a/scripts/install-skills.bat b/scripts/install-skills.bat index 40c3700..930615e 100644 --- a/scripts/install-skills.bat +++ b/scripts/install-skills.bat @@ -1,12 +1,15 @@ @echo off rem Trae CN skill installer launcher - double click to run 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" if not exist "install-skills.ps1" ( echo [ERROR] install-skills.ps1 not found in %~dp0 pause exit /b 1 ) -powershell -NoProfile -ExecutionPolicy Bypass -File "install-skills.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "install-skills.ps1" %* echo. pause diff --git a/scripts/install-skills.ps1 b/scripts/install-skills.ps1 index 1ff26ca..528dc42 100644 --- a/scripts/install-skills.ps1 +++ b/scripts/install-skills.ps1 @@ -2,10 +2,20 @@ # Trae CN 技能安装器(Windows) # 作用:把本项目 skills\ 下全部技能安装到用户级技能目录 # %USERPROFILE%\.trae-cn\skills\ +# 默认安装全部技能;也可用 -Skills 只装一部分,或用 -DryRun 预览。 # 用法:双击同目录下的 install-skills.bat(推荐),或 # 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" # --- 1. 定位源目录与目标目录 --- @@ -19,7 +29,15 @@ $BackupRoot = Join-Path $env:USERPROFILE ".trae-cn\skills-backup" Write-Host "=== Trae CN 技能安装器 ===" -ForegroundColor Cyan Write-Host "技能源目录: $SkillsSource" Write-Host "安装目标: $TraeSkills" -Write-Host "" + +# 把 -Skills 参数拆成规范化名字列表(兼容逗号/分号/空格分隔) +$skillFilter = @() +if ($Skills) { + $skillFilter = @( + $Skills | ForEach-Object { $_ -split '[,;]' } | ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) +} # --- 2. 校验源目录并收集技能(含 SKILL.md 的子目录才算) --- if (-not (Test-Path $SkillsSource)) { @@ -27,25 +45,102 @@ if (-not (Test-Path $SkillsSource)) { 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") }) -if ($skills.Count -eq 0) { +if ($allSkills.Count -eq 0) { Write-Host "[错误] 源目录下没有可用技能(每个技能文件夹需包含 SKILL.md)。" -ForegroundColor Red 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 "" -# --- 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 $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $backupCount = 0 -foreach ($skill in $skills) { +foreach ($skill in $targets) { $dest = Join-Path $TraeSkills $skill.Name if (Test-Path $dest) { @@ -60,11 +155,11 @@ foreach ($skill in $skills) { Write-Host "[安装] $($skill.Name)" -ForegroundColor Green } -# --- 4. 校验安装结果 --- +# --- 6. 校验安装结果 --- Write-Host "" Write-Host "=== 校验结果 ===" $failed = @() -foreach ($skill in $skills) { +foreach ($skill in $targets) { if (Test-Path (Join-Path $TraeSkills "$($skill.Name)\SKILL.md")) { Write-Host " [OK] $($skill.Name)" -ForegroundColor Green } else { @@ -73,7 +168,7 @@ foreach ($skill in $skills) { } } -# --- 5. 汇总与后续提示 --- +# --- 7. 汇总与后续提示 --- Write-Host "" if ($failed.Count -gt 0) { Write-Host ("安装失败: {0}" -f ($failed -join ", ")) -ForegroundColor Red @@ -81,12 +176,16 @@ if ($failed.Count -gt 0) { } 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) { Write-Host "旧版本备份于: $BackupRoot(确认新版可用后可手动删除)" -ForegroundColor Yellow } Write-Host "" Write-Host "下一步: 重启 Trae CN 或新建会话后,在对话中提及技能名或相关意图即可触发:" -foreach ($s in $skills) { +foreach ($s in $targets) { Write-Host " - $($s.Name)" } diff --git a/skills/yt-studio-groupid-lookup/SKILL.md b/skills/yt-studio-groupid-lookup/SKILL.md new file mode 100644 index 0000000..28cd297 --- /dev/null +++ b/skills/yt-studio-groupid-lookup/SKILL.md @@ -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//analytics?...`;脚本自动从 URL 路径 `owner/` 或 `?o=` 提取 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 "" --names 名单.xlsx --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data" +# 多所有者:--url 重复;浏览器不能关就改用 --connect +uv run python scripts\lookup_groups.py --url "" --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 "" --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 不匹配等)。 diff --git a/skills/yt-studio-groupid-lookup/references/input-guide.md b/skills/yt-studio-groupid-lookup/references/input-guide.md new file mode 100644 index 0000000..fede0ad --- /dev/null +++ b/skills/yt-studio-groupid-lookup/references/input-guide.md @@ -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/网络错/无结果)。 +- 行数应与名单一致(含重复项已去重后的名字数)。 diff --git a/skills/yt-studio-groupid-lookup/references/troubleshooting.md b/skills/yt-studio-groupid-lookup/references/troubleshooting.md new file mode 100644 index 0000000..1354918 --- /dev/null +++ b/skills/yt-studio-groupid-lookup/references/troubleshooting.md @@ -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`。