Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9020752eed
|
|||
|
b8bd749f43
|
|||
|
ba00651fa9
|
61
README.md
61
README.md
@@ -48,45 +48,44 @@
|
|||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- Windows
|
- Windows
|
||||||
- [Trae CN](https://www.trae.cn/) 已安装
|
- agent 之一:[Trae CN](https://www.trae.cn/) 或 [ZCode](https://github.com/ZhipuAI)(技能装到对应用户级目录)
|
||||||
- [uv](https://docs.astral.sh/uv/) 已安装(未装可 `winget install astral-sh.uv`,装完重开终端)
|
- [uv](https://docs.astral.sh/uv/) 已安装(未装可 `winget install astral-sh.uv`,装完重开终端;当前会话不识别时先跑 `python -m uv`,详见 uv-env-setup 技能)
|
||||||
|
|
||||||
### 第 1 步:安装技能到 Trae CN
|
### 第 1 步:安装技能
|
||||||
|
|
||||||
双击运行:
|
双击运行(默认装到 Trae CN):
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts\install-skills.bat
|
scripts\install-skills.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
看到 `全部安装成功` 即完成。脚本会把 `skills\` 下全部技能复制到 `%USERPROFILE%\.trae-cn\skills\`;同名旧版自动备份到 `~\.trae-cn\skills-backup\`,不会丢数据。详细说明见 [docs/install-skills.md](docs/install-skills.md)。
|
或用 `-Agent` 指定目标:`trae-cn`(默认)/ `zcode` / `all`(全部目标)。脚本会把 `skills\` 下全部技能复制到对应用户级目录(trae-cn: `%USERPROFILE%\.trae-cn\skills\`;zcode: `%USERPROFILE%\.zcode\skills\`);同名旧版自动备份到该目录旁的 `skills-backup\`,不会丢数据。详细说明见 [docs/install-skills.md](docs/install-skills.md)。
|
||||||
|
|
||||||
**默认安装全部技能**。需要只装部分或先预览时,可在命令行加参数(bat 与 ps1 都支持):
|
**默认安装全部技能**。需要只装部分、指定目标或先预览时,可在命令行加参数(bat 与 ps1 都支持):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 只安装指定的几个技能
|
# 装到 ZCode
|
||||||
scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download
|
scripts\install-skills.bat -Agent zcode
|
||||||
|
# 全部目标只安装指定的几个技能
|
||||||
|
scripts\install-skills.bat -Agent all -Skills yt-studio-url-builder,youtube-studio-csv-download
|
||||||
# 只预览将安装/覆盖/备份的技能,不实际复制
|
# 只预览将安装/覆盖/备份的技能,不实际复制
|
||||||
scripts\install-skills.bat -DryRun
|
scripts\install-skills.bat -DryRun
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第 2 步:准备 Python 运行环境
|
### 第 2 步:准备 Python 运行环境
|
||||||
|
|
||||||
项目根目录执行:
|
两种模式按场景二选一(细节与回退链见 `skills/uv-env-setup/SKILL.md`):
|
||||||
|
|
||||||
```powershell
|
- **项目环境**(在项目内跑整套流程):项目根执行 `uv sync`,之后统一 `uv run python <脚本>`。uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright、requests);本机无兼容 Python 时 uv 会自动下载托管 Python(要求 ≥3.10)。
|
||||||
uv sync
|
- **单脚本模式**(技能已装到用户级目录、脱离项目使用):什么都不用配——脚本头部带 PEP 723 内联依赖声明,直接 `uv run <脚本路径>` 即可。
|
||||||
```
|
|
||||||
|
|
||||||
uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright)。本机无兼容 Python 时 uv 会自动下载托管 Python(要求 ≥3.10)。
|
|
||||||
|
|
||||||
### 第 3 步:开始使用
|
### 第 3 步:开始使用
|
||||||
|
|
||||||
重启 Trae CN(或新建会话)后,在对话中直接说需求即可触发对应技能,例如:
|
重启对应 agent(或新建会话)后,在对话中直接说需求即可触发对应技能,例如:
|
||||||
|
|
||||||
> 根据这份清单批量生成 Studio URL
|
> 根据这份清单批量生成 Studio URL
|
||||||
|
|
||||||
把需求整理成 CSV/Excel(格式见 `skills/yt-studio-url-builder/references/input-guide.md`,示例见 `assets/需求输入示例.xlsx`),技能会引导生成 URL 清单。
|
把需求整理成 CSV/Excel(格式见 `skills/yt-studio-url-builder/references/input-guide.md`,示例见 `assets/需求输入示例.xlsx`),技能会引导生成 URL 清单。**清单里只有群组名时,先说「帮我把这些群组名解析成 entity_id」走 groupid-lookup 技能补 ID**,再生成 URL。
|
||||||
|
|
||||||
> 用我的 Chrome 会话下载这份分析 CSV
|
> 用我的 Chrome 会话下载这份分析 CSV
|
||||||
|
|
||||||
@@ -94,20 +93,40 @@ uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright
|
|||||||
|
|
||||||
> 帮我准备/重建环境
|
> 帮我准备/重建环境
|
||||||
|
|
||||||
技能会引导走 `uv sync` 与验证流程。
|
技能会引导选择运行分支(项目环境 / 单脚本模式)并完成验证。
|
||||||
|
|
||||||
所有脚本统一用 `uv run python <脚本>` 执行(自动使用项目环境,免激活、免手动装依赖)。
|
|
||||||
|
|
||||||
## 后续优化与修改
|
## 后续优化与修改
|
||||||
|
|
||||||
### 改 URL 固定参数
|
### 改 URL 固定参数
|
||||||
|
|
||||||
指标、粒度、维度、排序等固定参数集中在 `skills/yt-studio-url-builder/scripts/build_studio_urls.py` 顶部的 `CONFIG` 字典中,**单点维护**——改完重跑脚本即生效,需求清单无需变动。
|
粒度、表格列指标等固定参数集中在 `skills/yt-studio-url-builder/scripts/build_studio_urls.py` 顶部的 `CONFIG` 字典中,**单点维护**——改完重跑脚本即生效,需求清单无需变动。
|
||||||
|
|
||||||
|
主指标 `metric`、细分维度 `dimension` 可**按行选择**:在需求清单加一列 `指标`(映射见同目录 `metrics.json` 或填已知指标代码)、一列 `维度`(映射见同目录 `dimensions.json` 或填已知维度代码),留空则走 `CONFIG` 默认。
|
||||||
|
|
||||||
### 扩充国家映射
|
### 扩充国家映射
|
||||||
|
|
||||||
中文国家名 → ISO 代码的映射在 `skills/yt-studio-url-builder/scripts/countries.json`,直接追加条目即可;映射外的两位 ISO 代码(如 `US`)会原样透传。
|
中文国家名 → ISO 代码的映射在 `skills/yt-studio-url-builder/scripts/countries.json`,直接追加条目即可;映射外的两位 ISO 代码(如 `US`)会原样透传。
|
||||||
|
|
||||||
|
### 维护映射文件(指标 / 维度 / 国家)
|
||||||
|
|
||||||
|
`skills/yt-studio-url-builder/scripts/` 下有三个映射文件,把「中文/英文名」翻译成「URL 内部代码」,均可自行扩充:
|
||||||
|
|
||||||
|
| 文件 | 作用 | 新增示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `metrics.json` | 指标名 → 指标代码(如 `观看时长` → `EXTERNAL_WATCH_TIME`) | `"新指标名": "SOME_METRIC"` |
|
||||||
|
| `dimensions.json` | 细分维度名 → 维度代码(如 `地理位置` → `COUNTRY`) | `"新维度名": "SOME_DIMENSION"` |
|
||||||
|
| `countries.json` | 中文国家名 → ISO 两位代码(如 `美国` → `US`) | `"新国名": "XX"` |
|
||||||
|
|
||||||
|
方法和规则:
|
||||||
|
|
||||||
|
- 都是扁平 `key -> value` 的 JSON 对象,UTF-8 编码;键为人类可读名、值为大写代码。
|
||||||
|
- **同一代码可有多个别名**(多对一),例如 `metrics.json` 里 `税前收益` / `税前收入` / `预计收益` 都指向 `TOTAL_ESTIMATED_EARNINGS`。
|
||||||
|
- 新增一个指标/维度后,其代码会被脚本自动加入「可透传集合」,可直接在需求清单里填裸代码,无需改 `build_studio_urls.py`。
|
||||||
|
- **默认主指标/维度/粒度不在这些 JSON 里**,而在 `build_studio_urls.py` 顶部的 `CONFIG`(见上文「改 URL 固定参数」)。
|
||||||
|
- `dimensions.json`、`metrics.json` 的基准数据源是 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx`(「细分维度」「数据指标」两张表);`countries.json` 基于 ISO 3166-1 标准手工维护。
|
||||||
|
|
||||||
|
完整说明(加载方式、从 Excel 重新同步的脚本片段、更新后校验清单与常见坑)见 [skills/yt-studio-url-builder/references/maintenance-guide.md](skills/yt-studio-url-builder/references/maintenance-guide.md)。
|
||||||
|
|
||||||
### 新增/修改依赖
|
### 新增/修改依赖
|
||||||
|
|
||||||
只改根目录 `pyproject.toml` 的 `dependencies`,然后 `uv sync` 更新 `uv.lock`。不要 `pip install` 直装(绕过 lock,环境不可复现)。
|
只改根目录 `pyproject.toml` 的 `dependencies`,然后 `uv sync` 更新 `uv.lock`。不要 `pip install` 直装(绕过 lock,环境不可复现)。
|
||||||
@@ -138,4 +157,4 @@ uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
UPDATE: 2026-08-21
|
UPDATE: 2026-08-24
|
||||||
|
|||||||
152
assets/skill-问题复盘与优化建议.md
Normal file
152
assets/skill-问题复盘与优化建议.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# StudioSkillsSet 四技能使用问题复盘与优化建议
|
||||||
|
|
||||||
|
> 记录日期:2026-08-26
|
||||||
|
> 涉及技能:`uv-env-setup`、`yt-studio-groupid-lookup`、`yt-studio-url-builder`、`youtube-studio-csv-download`
|
||||||
|
> 数据链路:群组名 →(groupid-lookup)→ groupId →(url-builder)→ explore URL →(csv-download)→ CSV zip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
| 技能 | 是否遇到问题 | 问题是否已解决 | 是否需要优化 | 优化紧急度 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| uv-env-setup | 是(命令不可用) | 是 | 是(轻) | 低 |
|
||||||
|
| yt-studio-groupid-lookup | 是(端口/登录/401/伪头) | 是 | 是 | 中 |
|
||||||
|
| yt-studio-url-builder | 否(但存在隐性前置依赖) | 是 | 是(轻) | 低 |
|
||||||
|
| youtube-studio-csv-download | 是(登录/按钮定位/base64 解码) | 是 | **是** | **高** |
|
||||||
|
|
||||||
|
整体结论:4 个技能功能均跑通,最终产物(6 份 CSV zip)全部有效。其中 `youtube-studio-csv-download` 暴露了 **2 处真实脚本缺陷**(导出按钮定位、`zippedData` 解码),是本次最需要回填到技能脚本的修复点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、uv-env-setup(环境准备)
|
||||||
|
|
||||||
|
### 问题 1:`uv` 命令不被识别
|
||||||
|
|
||||||
|
- **现象**:执行 `uv --version`、`uv sync` 报 `The term 'uv' is not recognized`。
|
||||||
|
- **为什么会出现**:`uv` 刚安装,当前 PowerShell 会话的 PATH 未刷新;或者安装方式(如 `pip install uv`)没有把可执行文件加入 PATH。
|
||||||
|
- **解决方法**:改用 `python -m uv` 调用;或在后续会话中直接调用项目已有的 `.venv\Scripts\python.exe` 绕过 uv 代理。
|
||||||
|
- **如何避免**:安装后重开终端;或脚本执行统一改为"优先 `.venv\Scripts\python.exe` 直调,回退 `uv run`"。
|
||||||
|
|
||||||
|
### 问题 2:已有 `.venv` 但缺 `pyproject.toml`
|
||||||
|
|
||||||
|
- **现象**:项目已存在 Python 3.12.6 的 `.venv`(内含 pandas/openpyxl/playwright),但没有 `pyproject.toml`。
|
||||||
|
- **为什么会出现**:原环境是手动 `pip install` 搭出来的,不是 uv 管理。
|
||||||
|
- **解决方法**:新建 `pyproject.toml` 声明依赖作为唯一事实来源,`uv sync` 复用现有 Python 生成 `uv.lock`。
|
||||||
|
- **如何避免**:首次迁移到 uv 时先补 `pyproject.toml`,再 `uv sync`。
|
||||||
|
|
||||||
|
**是否已解决**:是。环境最终就绪,三条自检(`import pandas/openpyxl/playwright`、`--selftest`、`--help`)通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、yt-studio-groupid-lookup(群组名 → groupId)
|
||||||
|
|
||||||
|
### 问题 1:Chrome 调试端口 9222 连不上
|
||||||
|
|
||||||
|
- **现象**:`--connect http://localhost:9222` 连不上,报"无法连接到远程服务器"。
|
||||||
|
- **为什么会出现**:Chrome 未以 `--remote-debugging-port=9222` 启动。
|
||||||
|
- **解决方法**:用带调试端口的命令重启 Chrome。
|
||||||
|
- **如何避免**:运行前先 `Invoke-WebRequest http://localhost:9222/json/version` 探测端口是否在线。
|
||||||
|
|
||||||
|
### 问题 2:Chrome 进程未完全退出,调试端口参数被忽略
|
||||||
|
|
||||||
|
- **现象**:已带参数重启 Chrome,但 9222 仍不通。
|
||||||
|
- **为什么会出现**:后台仍有 Chrome 残留进程,新启动实例的 `--remote-debugging-port` 被已运行实例"吞掉"(Chrome 单实例复用机制)。
|
||||||
|
- **解决方法**:完全退出所有 Chrome 进程后再启动调试实例,或者改用独立 `--user-data-dir` 隔离启动。
|
||||||
|
- **如何避免**:启动前检测残留进程;或固定使用独立 profile 目录 + 调试端口。
|
||||||
|
|
||||||
|
### 问题 3:回放请求全量 401
|
||||||
|
|
||||||
|
- **现象**:`search_groups` 回放返回 `HTTP401`。
|
||||||
|
- **为什么会出现**:套件过期(鉴权头是逐请求计算的),或捕获时鉴权头不全。
|
||||||
|
- **解决方法**:重新在线捕获套件,更新 `bundles.json`。
|
||||||
|
- **如何避免**:套件一次性用完即弃;长期复用前先重新捕获校验。
|
||||||
|
|
||||||
|
### 问题 4:HTTP/2 伪头导致 `InvalidHeader` 报错
|
||||||
|
|
||||||
|
- **现象**:`requests` 抛 `InvalidHeader: Invalid leading whitespace...`。
|
||||||
|
- **为什么会出现**:捕获到的请求头里残留了 HTTP/2 伪头 `:authority`、`:method`、`:path`、`:scheme`,这些带冒号前缀的伪头不能被 `requests` 直接作为普通请求头发送。
|
||||||
|
- **解决方法**:从 `bundles.json` 中手动删除这 4 个伪头字段。
|
||||||
|
- **如何避免**:脚本捕获时自动过滤伪头,而不是留到下游报错后人工处理。
|
||||||
|
|
||||||
|
**是否已解决**:是。3 个群组名全部解析出 groupId 并标注归属所有者(产物 `群组ID结果-需求输入-测试.xlsx`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、yt-studio-url-builder(生成 explore URL)
|
||||||
|
|
||||||
|
- **现象**:本身运行无报错,从需求清单成功生成 6 条 URL。
|
||||||
|
- **隐性前置依赖(潜在问题)**:URL 中 `entity_id` 必须是群组的 groupId,清单里只有群组名、没有 ID 时会缺 `entity_id` 而失败。本次实际流程正是"先 groupid-lookup 填 ID,再 url-builder 生成 URL"。
|
||||||
|
- **如何避免**:在 SKILL.md 中显式注明"清单若只有群组名,需先经 groupid-lookup 补 entity_id"。
|
||||||
|
|
||||||
|
**是否已解决**:是。产物 `studio_urls_需求输入-测试.csv` 含 6 条有效 URL。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、youtube-studio-csv-download(下载 CSV)
|
||||||
|
|
||||||
|
这是问题最集中的技能,存在 2 处需要回填的脚本缺陷。
|
||||||
|
|
||||||
|
### 问题 1:登录态失效,跳转 Google 登录页
|
||||||
|
|
||||||
|
- **现象**:用已保存的 `cdp-profile`(Chrome)和 `edge-yts-debug-profile`(Edge)启动,页面都停在 `accounts.google.com` 登录页。
|
||||||
|
- **为什么会出现**:两个 profile 里保存的 YouTube Studio 登录会话已过期。注意:Edge 配置目录的 Cookies 文件里仍能看到 SAPISID/SID/__Secure-3PSID 等 cookie,但**cookie 存在不等于会话仍有效**,Google 仍要求重新登录。
|
||||||
|
- **解决方法**:由用户在浏览器中手动完成一次登录,之后复用该已登录会话。
|
||||||
|
- **如何避免**:下载前先探测页面是否跳登录页(脚本已有该判断,但只在跳转后提示);长期任务建议在开始前确认一遍登录态。
|
||||||
|
|
||||||
|
### 问题 2:导出按钮定位失败(脚本缺陷 1)
|
||||||
|
|
||||||
|
- **现象**:`page.get_by_text("导出当前视图")` 点击超时 30s,日志 `waiting for get_by_text("导出当前视图")`。
|
||||||
|
- **为什么会出现**:当前 YouTube Studio「高级模式」页的导出入口是右上角的**下载图标按钮**,它只有 `aria-label="导出当前视图"`,**没有可见文本**。脚本用 `get_by_text`(文本匹配)自然匹配不到。
|
||||||
|
- **解决方法**:改用 `page.get_by_label("导出当前视图")`(或 `get_by_role("button", name=...)`、`[aria-label=...]`),已验证三种方式都能命中 `count=1`。
|
||||||
|
- **如何避免**:对"图标类按钮"优先用 `aria-label` 定位,而非可见文本。
|
||||||
|
|
||||||
|
### 问题 3:`zippedData` 解码失败 / 生成的 zip 损坏(脚本缺陷 2,核心)
|
||||||
|
|
||||||
|
- **现象**:拦截到 `csv_export` 响应后,`base64.b64decode(zippedData)` 报 `Incorrect padding`;即使解码未抛错,写出的 zip 文件用 `zipfile` 打开报 `BadZipFile: Bad magic number for central directory`(文件尾部无 `PK\x05\x06` 结束记录)。
|
||||||
|
- **为什么会出现**:响应中 `zippedData` 字段是 **URL-safe base64**(用 `-`、`_` 替代 `+`、`/`)且**去掉了 `=` 填充**。脚本用标准 `base64.b64decode` 解码:遇到 `-`/`_` 或非 4 对齐长度时,要么抛 padding 错,要么静默解出损坏字节流,导致 zip 不完整。
|
||||||
|
- **排查过程**:诊断脚本发现响应 `status=200`、`content-encoding: br`(Brotli),但 Playwright 已自动解压、`resp.json()` 能正常解析,因此排除了"响应被压缩截断"的猜想,最终锁定是 base64 变体问题。
|
||||||
|
- **解决方法**:解码前补 `=` 填充(`z += "=" * (-len(z) % 4)`),并用 `base64.b64decode(z, altchars=b"-_")` 指定 URL-safe 字符表。
|
||||||
|
- **如何避免**:对来自前端接口的 base64 字段,先确认是否为 URL-safe + 无填充变体再解码;解码后增加 `zipfile.testzip()` 完整性校验。
|
||||||
|
|
||||||
|
**是否已解决**:是。修正后 6/6 条全部下载成功,每个 zip 含 `表格数据.csv` / `图表数据.csv` / `总计.csv`,抽查验证美国筛选、按频道明细、按日收益均正确。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、执行过程与原 skill 的差异对比
|
||||||
|
|
||||||
|
| 环节 | 原 skill 脚本行为 | 本次实际执行行为 | 差异原因 | 如何回归/解决 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| csv-download:导出按钮 | `get_by_text("导出当前视图")` | `get_by_label("导出当前视图")` | 前端已改为图标按钮,无可见文本,原定位失效 | 回填脚本:优先 `get_by_label`,可保留 `get_by_text` 兜底 |
|
||||||
|
| csv-download:zip 解码 | `base64.b64decode(zipped)` | 补 `=` 填充 + `altchars=b"-_"` | 接口返回 URL-safe、无填充 base64 | 回填脚本:改用 URL-safe 解码 + 完整性校验 |
|
||||||
|
| csv-download:下载目录 | 写死 `D:\Downloads` | 传 `--download-dir` 指向工作区 `YT导出` | 沙箱外 `D:\Downloads` 用户不可见 | 脚本已支持 `--download-dir`,无需改;仅执行时传参 |
|
||||||
|
| csv-download:批量下载 | `run()` 单 URL、每次新建会话 | 外层驱动循环复用一次浏览器连接逐条下载 + zip 校验 + 失败重试 | 清单有 6 条 URL,单次调用不够 | 可选:给脚本增加批量能力;或保持单次语义、由调用方写循环 |
|
||||||
|
| groupid-lookup:伪头 | 捕获后原样落 `bundles.json` | 人工删除 4 个 HTTP/2 伪头 | 脚本未在捕获阶段过滤伪头 | 建议:捕获时自动过滤伪头 |
|
||||||
|
| url-builder | 直接读清单生成 URL | 先经 groupid-lookup 补 entity_id 再生成 | 清单只有群组名,缺 groupId | 无需改脚本;在 SKILL.md 注明前置依赖即可 |
|
||||||
|
|
||||||
|
> 差异分为两类:**脚本缺陷**(按钮定位、base64 解码、伪头过滤)应回填到技能脚本;**环境/规模差异**(下载目录、批量循环、前置补 ID)属于执行时的合理适配,脚本本身逻辑无需破坏性改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、四技能优化清单(建议)
|
||||||
|
|
||||||
|
### youtube-studio-csv-download(高优先)
|
||||||
|
1. 导出按钮定位由 `get_by_text` 改为 `get_by_label("导出当前视图")`(保留文本定位兜底)。
|
||||||
|
2. `decode_zipped_data` 改为 URL-safe base64 解码(补 `=` + `altchars=b"-_"`)。
|
||||||
|
3. (可选增强)保存后加 `zipfile.testzip()` 校验,失败自动重试一次。
|
||||||
|
|
||||||
|
### yt-studio-groupid-lookup(中优先)
|
||||||
|
4. 捕获套件时自动过滤 HTTP/2 伪头(`:authority` / `:method` / `:path` / `:scheme`)。
|
||||||
|
5. (可选)运行前探测 Chrome 残留进程,给出更明确的"完全退出后重启"提示。
|
||||||
|
|
||||||
|
### uv-env-setup(低优先)
|
||||||
|
6. 补充"`uv` 命令不可用时改用 `python -m uv` 或直调 `.venv\Scripts\python.exe`"的排查提示。
|
||||||
|
|
||||||
|
### yt-studio-url-builder(低优先)
|
||||||
|
7. SKILL.md 显式说明"清单只有群组名时,需先经 groupid-lookup 补 entity_id"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、待确认事项
|
||||||
|
|
||||||
|
以上优化点(尤其第三节、第五节的 3 处脚本缺陷修复)尚未改动任何技能脚本,仅记录在本文档。是否执行优化、执行到哪些范围(仅修复缺陷 / 同时做增强),请确认后再实施。
|
||||||
Binary file not shown.
@@ -1,15 +1,23 @@
|
|||||||
# 技能安装说明(Windows)
|
# 技能安装说明(Windows,多 agent)
|
||||||
|
|
||||||
把本项目 `skills\` 下的技能一键安装到 Trae CN 的用户级技能目录 `%USERPROFILE%\.trae-cn\skills\`,安装后在任意项目的对话中都可触发。
|
把本项目 `skills\` 下的技能一键安装到各 agent 的用户级技能目录,安装后在任意项目的对话中都可触发。当前支持:
|
||||||
|
|
||||||
|
| `-Agent` 参数 | 目标目录 | 适用 agent |
|
||||||
|
|---|---|---|
|
||||||
|
| `trae-cn`(默认) | `%USERPROFILE%\.trae-cn\skills\` | Trae CN / Trae SOLO |
|
||||||
|
| `zcode` | `%USERPROFILE%\.zcode\skills\` | ZCode CLI |
|
||||||
|
| `all` | 上面全部目录 | 所有已登记 agent |
|
||||||
|
|
||||||
|
新增其他 agent 时在 `scripts\install-skills.ps1` 顶部 `$AgentTargets` 表里登记一行即可。
|
||||||
|
|
||||||
## 技能清单
|
## 技能清单
|
||||||
|
|
||||||
| 技能 | 用途 |
|
| 技能 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `uv-env-setup` | 用 uv 准备并维护本项目 Python 运行环境(`uv sync` / `uv run`) |
|
| `uv-env-setup` | 用 uv 准备并维护 Python 运行环境(项目环境 / 单脚本 PEP 723 模式) |
|
||||||
| `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) |
|
|
||||||
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 |
|
| `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 |
|
||||||
|
| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSV(zip) |
|
||||||
|
|
||||||
## 安装(傻瓜式)
|
## 安装(傻瓜式)
|
||||||
|
|
||||||
@@ -19,7 +27,7 @@
|
|||||||
scripts\install-skills.bat
|
scripts\install-skills.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。**默认安装全部技能**;同名旧版自动备份到 `~/.trae-cn/skills-backup/`。
|
看到 `全部安装成功` 及逐项 `[安装]` 即完成,按任意键关闭窗口。**默认安装全部技能到 trae-cn**;同名旧版自动备份到目标目录旁的 `skills-backup\`。
|
||||||
|
|
||||||
命令行等价方式(PowerShell / cmd 均可):
|
命令行等价方式(PowerShell / cmd 均可):
|
||||||
|
|
||||||
@@ -31,30 +39,44 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-skills.ps1
|
|||||||
|
|
||||||
| 参数 | 作用 |
|
| 参数 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `-Agent <名>` | 目标 agent:`trae-cn`(默认)/ `zcode` / `all` |
|
||||||
| `-Skills <名1,名2>` | 只安装指定的技能(按技能文件夹名,不区分大小写);缺省=全部 |
|
| `-Skills <名1,名2>` | 只安装指定的技能(按技能文件夹名,不区分大小写);缺省=全部 |
|
||||||
| `-DryRun` | 只预览将安装/覆盖/备份的技能,不实际复制 |
|
| `-DryRun` | 只预览将安装/覆盖/备份的技能,不实际复制 |
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download
|
# 装到 ZCode
|
||||||
scripts\install-skills.bat -DryRun
|
scripts\install-skills.bat -Agent zcode
|
||||||
|
|
||||||
|
# 全部目标只装这两个技能
|
||||||
|
scripts\install-skills.bat -Agent all -Skills yt-studio-url-builder,youtube-studio-csv-download
|
||||||
|
|
||||||
|
# 预览
|
||||||
|
scripts\install-skills.bat -Agent trae-cn -DryRun
|
||||||
```
|
```
|
||||||
|
|
||||||
## 脚本做了什么
|
## 脚本做了什么
|
||||||
|
|
||||||
1. 扫描项目 `skills\` 下每个包含 `SKILL.md` 的子目录(即可安装技能;缺 `SKILL.md` 的目录会提示「跳过」)。
|
1. 扫描项目 `skills\` 下每个包含 `SKILL.md` 的子目录(即可安装技能;缺 `SKILL.md` 的目录会提示「跳过」)。
|
||||||
2. **默认安装全部技能**;若带 `-Skills` 则只装指定的,并提示未找到的技能名。
|
2. 解析 `-Agent` 得到一个或多个目标目录;默认全部技能、-trae-cn。
|
||||||
3. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)。
|
3. 若带 `-Skills` 则只装指定的,并提示未找到的技能名。
|
||||||
4. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`;目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖。
|
4. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)。
|
||||||
5. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功退出码 0,失败退出码 2。
|
5. 逐个复制到各目标目录;目标已存在同名技能时,先把旧版移动到该目标旁的 `skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖。
|
||||||
6. 带 `-DryRun` 时只打印将安装/覆盖/备份的技能清单,不做任何修改。
|
6. 逐个校验 SKILL.md 落盘并打印 `[安装]` / `[FAIL]`;有失败退出码 2。
|
||||||
|
|
||||||
安全性:脚本只处理本项目 `skills\` 中出现的技能,不会删除或改动 `.trae-cn\skills\` 下的其他技能;不修改任何 Trae CN 配置文件。
|
安全性:脚本只处理本项目 `skills\` 中出现的技能,不会删除或改动目标技能目录下的其他技能;不修改任何 agent 配置文件。
|
||||||
|
|
||||||
|
## 各 agent 的差异与注意事项
|
||||||
|
|
||||||
|
- **Trae CN**:用户级技能目录即上表;重启 Trae CN 或新建会话后生效。
|
||||||
|
- **ZCode**:技能落在 `~\.zcode\skills\` 后由 ZCode 自动发现(本机会话中已验证);新建会话后生效。zcode 环境里的同名工程技能可能遮蔽用户级技能,排查时注意优先级。
|
||||||
|
- **终端方言**:两个 agent 都可能在 PowerShell 或 Git Bash 里执行命令。所有 SKILL.md 的示例统一用正斜杠路径 + 引号写法,两边通用;uv-env-setup 提供 PATH 刷新的两种方言写法。
|
||||||
|
- **通用兜底**:无论哪个 agent,跑脚本前都先按 `uv-env-setup` 技能确认 uv 分支(项目环境 / 单脚本模式 / python -m uv 回退)。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
1. 重启 Trae CN(或新建会话)。
|
1. 重启对应 agent(或新建会话)。
|
||||||
2. 对话中直接提及技能名或相关意图,例如:
|
2. 对话中直接提及技能名或相关意图,例如:
|
||||||
- 「帮我准备环境」→ `uv-env-setup`
|
- 「帮我准备环境」→ `uv-env-setup`
|
||||||
- 「根据这份清单批量生成 Studio URL」→ `yt-studio-url-builder`
|
- 「根据这份清单批量生成 Studio URL」→ `yt-studio-url-builder`
|
||||||
@@ -62,19 +84,19 @@ scripts\install-skills.bat -DryRun
|
|||||||
|
|
||||||
## 更新与卸载
|
## 更新与卸载
|
||||||
|
|
||||||
- **更新**:项目技能有改动后,重新双击 `install-skills.bat`(旧版自动备份)。
|
- **更新**:项目技能有改动后,重新运行安装脚本(可加 `-Agent all` 同步所有目标;旧版自动备份)。
|
||||||
- **回滚**:把 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 整个文件夹复制回 `%USERPROFILE%\.trae-cn\skills\<技能名>\`。
|
- **回滚**:把 `<目标目录旁>\skills-backup\<技能名>-<时间戳>\` 整个文件夹复制回对应技能目录。
|
||||||
- **卸载**:删除 `%USERPROFILE%\.trae-cn\skills\` 下对应技能文件夹。
|
- **卸载**:删除对应技能目录下的技能文件夹。
|
||||||
- **清理备份**:确认新版可用后,删除 `%USERPROFILE%\.trae-cn\skills-backup\` 整个文件夹。
|
- **清理备份**:确认新版可用后,删除 skills-backup 整个文件夹。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
- **双击 bat 闪退**:在 PowerShell 中手动运行 `scripts\install-skills.bat` 查看报错;最常见原因是 `install-skills.ps1` 没有和 bat 放在同一目录。
|
- **双击 bat 闪退**:在 PowerShell 中手动运行 `scripts\install-skills.bat` 查看报错;最常见原因是 `install-skills.ps1` 没有和 bat 放在同一目录。
|
||||||
- **PowerShell 执行策略受限**:bat 已带 `-ExecutionPolicy Bypass`;若组策略仍拦截,以管理员身份运行。
|
- **PowerShell 执行策略受限**:bat 已带 `-ExecutionPolicy Bypass`;若组策略仍拦截,以管理员身份运行。
|
||||||
- **技能装了但不触发**:需重启 Trae CN 或新建会话后生效;确认对话窗口正常加载。
|
- **技能装了但不触发**:需重启对应 agent 或新建会话后生效;先用 `-DryRun` 确认装到了你所用 agent 的目录。
|
||||||
- **中文输出乱码**:`install-skills.ps1` 必须保持 UTF-8 with BOM 编码(重新编辑保存时注意)。
|
- **中文输出乱码**:`install-skills.ps1` 必须保持 UTF-8 with BOM 编码(重新编辑保存时注意)。
|
||||||
|
|
||||||
## 注意
|
## 注意
|
||||||
|
|
||||||
- 运行时依赖不随技能安装:两个脚本型技能的 Python 依赖(pandas、openpyxl、playwright)由项目根 `pyproject.toml` 统一管理,首次使用按 `uv-env-setup` 技能执行 `uv sync` 即可。
|
- 运行时依赖不随技能安装:脚本型技能的 Python 依赖由两层机制承接——项目根 `pyproject.toml`(项目环境)或脚本头部 PEP 723 内联声明(单脚本模式),首次使用按 `uv-env-setup` 技能执行即可。
|
||||||
- 安装脚本仅支持 Windows(路径与命令均按 Windows 编写)。
|
- 安装脚本仅支持 Windows(路径与命令均按 Windows 编写)。
|
||||||
|
|||||||
35
docs/usage-guide.md
Normal file
35
docs/usage-guide.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# YouTube Studio 技能的使用指南
|
||||||
|
|
||||||
|
这一组技能,都是为了能更好的驱动Agent完成YouTube Studio数据的下载。一个常见的场景,就是,基于一个被授权的所有者数据访问权之后,从指定的群组中,提取指定的一组数据,保存到本地。
|
||||||
|
|
||||||
|
如果你需要完成上面这个常见的应用场景,那么,本技能组中的所有技能都是必须的,包括:
|
||||||
|
|
||||||
|
- `uv-env-setup`:用于设置必要的 Python 运行环境,以便下面的技能所带的脚本可以正确运行。
|
||||||
|
- `yt-studio-url-builder`:用于构建 YouTube Studio 分析页面 URL。
|
||||||
|
- `youtube-studio-csv-download`:用于下载 YouTube Studio 分析数据。
|
||||||
|
- `yt-studio-groupid-lookup`:用于从群组名称中提取实体 ID。
|
||||||
|
|
||||||
|
对于这样一个任务,对于只有一个群组数据需要下载的情况,这组技能并没有人工下载来的快,更适合批量下载众多群组的数据。也就是,你需要Agent帮你从YouTube Studio的分析页面中,下载众多群组的众多情况的数据时,这组技能可以更好的指导Agent完成工作,也能很好的加速下载。
|
||||||
|
|
||||||
|
### 流程说明
|
||||||
|
|
||||||
|
首先,你需要准备好以下几个东西:
|
||||||
|
|
||||||
|
1. 一个被授权的所有者数据访问权。
|
||||||
|
2. 一个包含群组名称的名单文件(json / txt / csv / xlsx 均可)和所需的数据需求。
|
||||||
|
|
||||||
|
这里,数据需求,就是你需要下载哪些指标的数据,以及你需要哪些时间段的数据,也包括需要筛选什么信息,同时,你需要的是什么细分的数据,例如:频道数据、内容数据还是其他的数据。
|
||||||
|
|
||||||
|
下一步,是获取群组的实体 ID(groupid)。你可以让Agent使用 `yt-studio-groupid-lookup` 技能,来从群组名称中提取实体 ID。这样,前期的准备就算基本完成了。
|
||||||
|
|
||||||
|
然后,指示Agent基于你的需求与获取的实体ID,创建符合需求的分析页面 URL。
|
||||||
|
|
||||||
|
拿到这些URL之后,就可以让Agent,基于这些URL,自动下载你需要的数据了。
|
||||||
|
|
||||||
|
这个流程,你可以分步指示Agent完成,也可以一次性指示Agent完成所有步骤,但需要注意的是,**每次指示Agent完成一个步骤后,都需要确认Agent是否正确完成了任务,以及是否有任何错误或异常情况发生**,尽可能提供你需要的检查模式,让Agent自己去检查它的结果,以保证下载的数据是正确的。
|
||||||
|
|
||||||
|
### 检查与确认
|
||||||
|
|
||||||
|
常用的检查就是,需要下载的文件数量需要对应于生成的URL数量。如果数量不对应,那么,就需要检查Agent是否正确生成了URL,或者是否有任何错误或异常情况发生。
|
||||||
|
|
||||||
|
另外,在URL生成过程中,也需要检查Agent是否正确解析了群组名称,以及是否有任何错误或异常情况发生。可以要求Agent抽样几个URL打开浏览器进行浏览确认。这个方式也可以确认Agent当前是否正确的读取了浏览器的登录状态,以避免因为登录状态过期而导致的下载失败。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "StudioLift"
|
name = "StudioLift"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。"
|
description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "pandas>=2.0",
|
||||||
|
# "openpyxl>=3.1",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
"""
|
"""
|
||||||
根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。
|
根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。
|
||||||
|
|
||||||
@@ -15,12 +22,18 @@
|
|||||||
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
|
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
|
||||||
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
|
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
|
||||||
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
|
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
|
||||||
|
指标 : 指标 / 主指标 / 数据指标 / metric (可留空,则用 CONFIG 默认;中文名或指标代码)
|
||||||
|
维度 : 维度 / 细分维度 / dimension (可留空,则用 CONFIG 默认;中文名或维度代码)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 数据周期起止日期均包含在数据范围内,time_period 结束值取结束日后一天的日界线。
|
- 数据周期起止日期均包含在数据范围内,time_period 结束值取结束日后一天的日界线。
|
||||||
- 国家为多个时,ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27)。
|
- 国家为多个时,ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27)。
|
||||||
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json(可自行扩充);已是两位代码的原样透传。
|
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json(可自行扩充);已是两位代码的原样透传。
|
||||||
- 固定参数(metric/granularity/dimension/t_metrics 等)在本文件 CONFIG 中统一配置。
|
- 指标每行可选(metric 参数,中文名 -> 代码的映射放在同目录 metrics.json,可自行扩充;
|
||||||
|
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
|
||||||
|
- 维度每行可选(dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json,可自行扩充;
|
||||||
|
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
|
||||||
|
- 其余固定参数(granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -55,6 +68,11 @@ CONFIG = {
|
|||||||
"comparison_type": "NONE",
|
"comparison_type": "NONE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 常用指标代码集合(供指标代码透传;metrics.json 提供中文/英文别名映射)
|
||||||
|
KNOWN_METRICS = {CONFIG["metric"], CONFIG["o_column"], *CONFIG["t_metrics"]}
|
||||||
|
# 常见维度代码(供维度代码透传;dimensions.json 提供中文别名映射)
|
||||||
|
KNOWN_DIMENSIONS = {CONFIG["dimension"]}
|
||||||
|
|
||||||
# 实体类型 中文/代码 -> URL 参数值(可扩充)
|
# 实体类型 中文/代码 -> URL 参数值(可扩充)
|
||||||
ENTITY_TYPE_MAP = {
|
ENTITY_TYPE_MAP = {
|
||||||
"群组": "GROUP", "GROUP": "GROUP",
|
"群组": "GROUP", "GROUP": "GROUP",
|
||||||
@@ -95,6 +113,12 @@ COLUMN_ALIASES = {
|
|||||||
# 国家
|
# 国家
|
||||||
"国家": "countries", "国家/地区": "countries", "国家地区": "countries",
|
"国家": "countries", "国家/地区": "countries", "国家地区": "countries",
|
||||||
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
|
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
|
||||||
|
# 指标(每行可选;缺省用 CONFIG)
|
||||||
|
"指标": "metric", "主指标": "metric", "主要指标": "metric", "数据指标": "metric",
|
||||||
|
"metric": "metric", "metric_name": "metric", "metricname": "metric", "指标名": "metric",
|
||||||
|
# 维度(每行可选;缺省用 CONFIG)
|
||||||
|
"维度": "dimension", "细分维度": "dimension", "dimension": "dimension",
|
||||||
|
"dim": "dimension", "细分": "dimension", "维度名": "dimension",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +184,66 @@ def parse_countries(text, country_map):
|
|||||||
return codes
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def load_metric_map(path):
|
||||||
|
"""加载 指标中文/英文名 -> 指标代码 映射。文件缺失则仅支持已知指标代码透传。"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print("[提示] 未找到指标映射文件 %s,仅支持直接填写指标代码" % path, file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
|
||||||
|
if str(k).strip() and str(v).strip()}
|
||||||
|
# 允许映射中出现的所有指标代码原样透传(大小写不敏感)
|
||||||
|
for code in set(mapped.values()) | KNOWN_METRICS:
|
||||||
|
mapped.setdefault(code.lower(), code)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def parse_metric(text, metric_map):
|
||||||
|
"""指标列 -> 指标代码。空则返回 None(走 CONFIG 默认);具体代码透传;未知则报错。"""
|
||||||
|
if text is None or (isinstance(text, float) and str(text) == "nan"):
|
||||||
|
return None
|
||||||
|
raw = str(text).strip().strip("'\"").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw.upper() in KNOWN_METRICS: # 已是已知指标代码
|
||||||
|
return raw.upper()
|
||||||
|
key = raw.lower()
|
||||||
|
if key in metric_map: # 中文/英文别名
|
||||||
|
return metric_map[key]
|
||||||
|
raise ValueError("未识别的指标: %r(不在映射文件中,也不是已知指标代码)" % raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_dimension_map(path):
|
||||||
|
"""加载 细分维度中文名 -> 代码 映射。文件缺失则仅支持已知维度代码透传。"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print("[提示] 未找到维度映射文件 %s,仅支持直接填写维度代码" % path, file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
|
||||||
|
if str(k).strip() and str(v).strip()}
|
||||||
|
# 允许映射中出现的所有维度代码原样透传(大小写不敏感)
|
||||||
|
for code in set(mapped.values()) | KNOWN_DIMENSIONS:
|
||||||
|
mapped.setdefault(code.lower(), code)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dimension(text, dimension_map):
|
||||||
|
"""维度列 -> 维度代码。空则返回 None(走 CONFIG 默认);具体代码透传;未知则报错。"""
|
||||||
|
if text is None or (isinstance(text, float) and str(text) == "nan"):
|
||||||
|
return None
|
||||||
|
raw = str(text).strip().strip("'\"").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw.upper() in KNOWN_DIMENSIONS: # 已是已知维度代码
|
||||||
|
return raw.upper()
|
||||||
|
key = raw.lower()
|
||||||
|
if key in dimension_map: # 中文别名
|
||||||
|
return dimension_map[key]
|
||||||
|
raise ValueError("未识别的维度: %r(不在映射文件中,也不是已知维度代码)" % raw)
|
||||||
|
|
||||||
|
|
||||||
def resolve_entity_type(text):
|
def resolve_entity_type(text):
|
||||||
"""实体类型 -> URL 参数值。空则用默认群组。"""
|
"""实体类型 -> URL 参数值。空则用默认群组。"""
|
||||||
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
|
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
|
||||||
@@ -170,8 +254,11 @@ def resolve_entity_type(text):
|
|||||||
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)" % key)
|
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)" % key)
|
||||||
|
|
||||||
|
|
||||||
def build_url(row, country_map):
|
def build_url(row, country_map, metric_map=None, dimension_map=None):
|
||||||
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。"""
|
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。
|
||||||
|
|
||||||
|
metric_map / dimension_map 为 名 -> 代码 映射;对应列缺省时回退到 CONFIG 默认。
|
||||||
|
"""
|
||||||
owner_id = row.get("owner_id", "").strip()
|
owner_id = row.get("owner_id", "").strip()
|
||||||
entity_type = resolve_entity_type(row.get("entity_type", ""))
|
entity_type = resolve_entity_type(row.get("entity_type", ""))
|
||||||
entity_id = row.get("entity_id", "").strip()
|
entity_id = row.get("entity_id", "").strip()
|
||||||
@@ -191,6 +278,15 @@ def build_url(row, country_map):
|
|||||||
|
|
||||||
codes = parse_countries(row.get("countries", ""), country_map)
|
codes = parse_countries(row.get("countries", ""), country_map)
|
||||||
|
|
||||||
|
# 指标:每行可选;空 -> 主指标/排序字段用 CONFIG 默认;非空 -> 二者都跟随所选指标
|
||||||
|
sel_metric = parse_metric(row.get("metric", ""), metric_map or {})
|
||||||
|
metric = sel_metric or CONFIG["metric"]
|
||||||
|
o_column = sel_metric or CONFIG["o_column"]
|
||||||
|
|
||||||
|
# 维度:每行可选;空 -> 用 CONFIG 默认细分维度
|
||||||
|
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map or {})
|
||||||
|
dimension = sel_dimension or CONFIG["dimension"]
|
||||||
|
|
||||||
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
|
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
|
||||||
params = []
|
params = []
|
||||||
params.append("o=%s" % owner_id)
|
params.append("o=%s" % owner_id)
|
||||||
@@ -204,12 +300,12 @@ def build_url(row, country_map):
|
|||||||
params.append("ur_exclusive_ends=")
|
params.append("ur_exclusive_ends=")
|
||||||
params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
|
params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
|
||||||
params.append("explore_type=%s" % CONFIG["explore_type"])
|
params.append("explore_type=%s" % CONFIG["explore_type"])
|
||||||
params.append("metric=%s" % CONFIG["metric"])
|
params.append("metric=%s" % metric)
|
||||||
params.append("granularity=%s" % CONFIG["granularity"])
|
params.append("granularity=%s" % CONFIG["granularity"])
|
||||||
for m in CONFIG["t_metrics"]:
|
for m in CONFIG["t_metrics"]:
|
||||||
params.append("t_metrics=%s" % m)
|
params.append("t_metrics=%s" % m)
|
||||||
params.append("dimension=%s" % CONFIG["dimension"])
|
params.append("dimension=%s" % dimension)
|
||||||
params.append("o_column=%s" % CONFIG["o_column"])
|
params.append("o_column=%s" % o_column)
|
||||||
params.append("o_direction=%s" % CONFIG["o_direction"])
|
params.append("o_direction=%s" % CONFIG["o_direction"])
|
||||||
params.append("comparison_type=%s" % CONFIG["comparison_type"])
|
params.append("comparison_type=%s" % CONFIG["comparison_type"])
|
||||||
|
|
||||||
@@ -222,6 +318,8 @@ def main():
|
|||||||
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls)")
|
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls)")
|
||||||
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv)")
|
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv)")
|
||||||
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json)")
|
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json)")
|
||||||
|
parser.add_argument("--metrics", default=None, help="指标映射 JSON 路径(默认:脚本同目录 metrics.json)")
|
||||||
|
parser.add_argument("--dimensions", default=None, help="维度映射 JSON 路径(默认:脚本同目录 dimensions.json)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not os.path.exists(args.input):
|
if not os.path.exists(args.input):
|
||||||
@@ -252,18 +350,26 @@ def main():
|
|||||||
|
|
||||||
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
|
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
|
||||||
country_map = load_country_map(countries_file)
|
country_map = load_country_map(countries_file)
|
||||||
|
metrics_file = args.metrics or os.path.join(os.path.dirname(os.path.abspath(__file__)), "metrics.json")
|
||||||
|
metric_map = load_metric_map(metrics_file)
|
||||||
|
dimensions_file = args.dimensions or os.path.join(os.path.dirname(os.path.abspath(__file__)), "dimensions.json")
|
||||||
|
dimension_map = load_dimension_map(dimensions_file)
|
||||||
|
|
||||||
records = []
|
records = []
|
||||||
errors = []
|
errors = []
|
||||||
for idx, raw in df.iterrows():
|
for idx, raw in df.iterrows():
|
||||||
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
|
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
|
||||||
try:
|
try:
|
||||||
url, status, msg = build_url(row, country_map)
|
url, status, msg = build_url(row, country_map, metric_map, dimension_map)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
url, status, msg = None, "error", str(e)
|
url, status, msg = None, "error", str(e)
|
||||||
if status == "ok":
|
if status == "ok":
|
||||||
start_ms, end_ms = parse_period(row.get("period", ""))
|
start_ms, end_ms = parse_period(row.get("period", ""))
|
||||||
codes = parse_countries(row.get("countries", ""), country_map)
|
codes = parse_countries(row.get("countries", ""), country_map)
|
||||||
|
sel_metric = parse_metric(row.get("metric", ""), metric_map)
|
||||||
|
metric_code = sel_metric or CONFIG["metric"]
|
||||||
|
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map)
|
||||||
|
dimension_code = sel_dimension or CONFIG["dimension"]
|
||||||
records.append({
|
records.append({
|
||||||
"所有者名称": row.get("owner_name", ""),
|
"所有者名称": row.get("owner_name", ""),
|
||||||
"所有者ID": row.get("owner_id", ""),
|
"所有者ID": row.get("owner_id", ""),
|
||||||
@@ -273,6 +379,10 @@ def main():
|
|||||||
"数据周期": row.get("period", ""),
|
"数据周期": row.get("period", ""),
|
||||||
"国家": row.get("countries", ""),
|
"国家": row.get("countries", ""),
|
||||||
"国家代码": ",".join(codes),
|
"国家代码": ",".join(codes),
|
||||||
|
"指标": row.get("metric", ""),
|
||||||
|
"指标代码": metric_code,
|
||||||
|
"维度": row.get("dimension", ""),
|
||||||
|
"维度代码": dimension_code,
|
||||||
"开始时间戳": start_ms,
|
"开始时间戳": start_ms,
|
||||||
"结束时间戳": end_ms,
|
"结束时间戳": end_ms,
|
||||||
"URL": url,
|
"URL": url,
|
||||||
|
|||||||
35
scripts/dimensions.json
Normal file
35
scripts/dimensions.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"内容": "VIDEO",
|
||||||
|
"流量来源": "TRAFFIC_SOURCE_TYPE",
|
||||||
|
"地理位置": "COUNTRY",
|
||||||
|
"频道": "USER",
|
||||||
|
"资产": "ASSET",
|
||||||
|
"频道所有权": "UPLOADER_TYPE",
|
||||||
|
"版权声明状态": "CLAIMED_STATUS",
|
||||||
|
"内容类型": "CREATOR_CONTENT_TYPE",
|
||||||
|
"播放列表": "PLAYLIST",
|
||||||
|
"观看者年龄": "VIEWER_AGE",
|
||||||
|
"观看者性别": "VIEWER_GENDER",
|
||||||
|
"新观看者和回访观看者": "LOYALTY_STATE",
|
||||||
|
"按观看行为细分的观众群": "AUDIENCE_LOYALTY_SEGMENT",
|
||||||
|
"订阅状态": "SUBSCRIBED_TO_UPLOADER_STATE",
|
||||||
|
"订阅来源": "SUBSCRIPTION_SOURCE_TYPE",
|
||||||
|
"YouTube 产品": "PLAYER_APP_TYPE",
|
||||||
|
"设备类型": "DEVICE_PLATFORM_TYPE",
|
||||||
|
"操作系统": "DEVICE_OS_TYPE",
|
||||||
|
"收入来源": "EARNINGS_SOURCE_ALL",
|
||||||
|
"广告类型": "ADTYPES",
|
||||||
|
"交易类型": "TRANSACTION_BUSINESS_MODEL",
|
||||||
|
"自然流量和付费流量": "AD_STATUS",
|
||||||
|
"日期": "DAY",
|
||||||
|
"字幕": "CAPTION_LANGUAGE",
|
||||||
|
"视频信息语言": "VIDEO_METADATA_LANGUAGE",
|
||||||
|
"是否使用翻译": "IS_CROSS_LANGUAGE",
|
||||||
|
"片尾画面元素": "ENDSCREEN_ELEMENT_ID",
|
||||||
|
"片尾画面元素类型": "ENDSCREEN_ELEMENT_TYPE",
|
||||||
|
"卡片": "INFO_CARD_ID",
|
||||||
|
"卡片类型": "INFO_CARD_TYPE",
|
||||||
|
"播放位置": "PLAYBACK_LOCATION_TYPE",
|
||||||
|
"播放器类型": "EMBEDDED_PLAYER_MODE",
|
||||||
|
"分享服务": "SHARING_SERVICE"
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
@echo off
|
@echo off
|
||||||
rem Trae CN skill installer launcher - double click to run
|
rem 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 Usage: install-skills.bat (install ALL skills to trae-cn)
|
||||||
rem install-skills.bat -Skills a,b,c (install only those)
|
rem install-skills.bat -Agent zcode (install to %USERPROFILE%\.zcode\skills)
|
||||||
|
rem install-skills.bat -Agent all (install to every known target)
|
||||||
|
rem install-skills.bat -Skills a,b -Agent all (subset)
|
||||||
rem install-skills.bat -DryRun (preview only, no changes)
|
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" (
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Trae CN 技能安装器(Windows)
|
# 技能安装器(Windows,多 agent 目标)
|
||||||
# 作用:把本项目 skills\ 下全部技能安装到用户级技能目录
|
# 作用:把本项目 skills\ 下全部技能安装到指定的用户级技能目录:
|
||||||
# %USERPROFILE%\.trae-cn\skills\
|
# trae-cn -> %USERPROFILE%\.trae-cn\skills\ (Trae CN / Trae SOLO)
|
||||||
# 默认安装全部技能;也可用 -Skills 只装一部分,或用 -DryRun 预览。
|
# zcode -> %USERPROFILE%\.zcode\skills\ (ZCode CLI)
|
||||||
|
# 默认安装全部技能到全部已知目录;也可用 -Agent、-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 ... -Agent zcode -Skills yt-studio-url-builder,youtube-studio-csv-download
|
||||||
# powershell ... -DryRun
|
# powershell ... -Agent all -DryRun
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
param(
|
param(
|
||||||
|
# 目标 agent:trae-cn(默认)/ zcode / all。all = 装到下面全部已知目标。
|
||||||
|
[string]$Agent = "trae-cn",
|
||||||
# 只安装这些技能(逗号/分号/空格分隔,匹配技能文件夹名,不区分大小写)。缺省=全部。
|
# 只安装这些技能(逗号/分号/空格分隔,匹配技能文件夹名,不区分大小写)。缺省=全部。
|
||||||
[string[]]$Skills,
|
[string[]]$Skills,
|
||||||
# 只预览将要安装/备份/跳过的技能,不实际复制。
|
# 只预览将要安装/备份/跳过的技能,不实际复制。
|
||||||
@@ -18,17 +22,30 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
# --- 1. 定位源目录与目标目录 ---
|
# --- 已知目标:agent 名 -> 用户级技能目录 -----------------------------------
|
||||||
|
# 新增 agent 支持时在此登记即可,其余逻辑不变。
|
||||||
|
$AgentTargets = @{
|
||||||
|
"trae-cn" = ".trae-cn\skills"
|
||||||
|
"zcode" = ".zcode\skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 1. 解析目标目录(可能多个) ---
|
||||||
|
if ($AgentTargets.ContainsKey($Agent)) {
|
||||||
|
$destRelList = @($AgentTargets[$Agent])
|
||||||
|
} elseif ($Agent -eq "all") {
|
||||||
|
$destRelList = @($AgentTargets.Values)
|
||||||
|
} else {
|
||||||
|
Write-Host "[错误] 未知 agent: $Agent(可选: $($AgentTargets.Keys -join ', ') | all)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
$ScriptDir = $PSScriptRoot
|
$ScriptDir = $PSScriptRoot
|
||||||
$ProjectRoot = Split-Path -Parent $ScriptDir
|
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||||
$SkillsSource = Join-Path $ProjectRoot "skills"
|
$SkillsSource = Join-Path $ProjectRoot "skills"
|
||||||
$TraeSkills = Join-Path $env:USERPROFILE ".trae-cn\skills"
|
|
||||||
# 备份放在技能目录外,避免被 Trae 当成技能重复扫描
|
|
||||||
$BackupRoot = Join-Path $env:USERPROFILE ".trae-cn\skills-backup"
|
|
||||||
|
|
||||||
Write-Host "=== Trae CN 技能安装器 ===" -ForegroundColor Cyan
|
Write-Host "=== 技能安装器 ===" -ForegroundColor Cyan
|
||||||
Write-Host "技能源目录: $SkillsSource"
|
Write-Host "技能源目录: $SkillsSource"
|
||||||
Write-Host "安装目标: $TraeSkills"
|
Write-Host ("安装目标: " + (($destRelList | ForEach-Object { "$env:USERPROFILE\$_" }) -join ", "))
|
||||||
|
|
||||||
# 把 -Skills 参数拆成规范化名字列表(兼容逗号/分号/空格分隔)
|
# 把 -Skills 参数拆成规范化名字列表(兼容逗号/分号/空格分隔)
|
||||||
$skillFilter = @()
|
$skillFilter = @()
|
||||||
@@ -83,7 +100,7 @@ if ($targets.Count -eq 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- 3. 读取每个技能的 frontmatter name,并校验其与文件夹名一致 ---
|
# --- 3. 读取每个技能的 frontmatter name,并校验其与文件夹名一致 ---
|
||||||
# 预测性关键:Trae 以 frontmatter 的 name 作为技能名,若与文件夹名不一致会引发歧义/旧名残留。
|
# 预测性关键:各 agent 以 frontmatter 的 name 作为技能名,若与文件夹名不一致会引发歧义/旧名残留。
|
||||||
$warnCount = 0
|
$warnCount = 0
|
||||||
foreach ($skill in $targets) {
|
foreach ($skill in $targets) {
|
||||||
$skillFile = Join-Path $skill.FullName "SKILL.md"
|
$skillFile = Join-Path $skill.FullName "SKILL.md"
|
||||||
@@ -111,81 +128,84 @@ foreach ($skill in $targets) {
|
|||||||
Write-Host ("发现 {0} 个技能: {1}" -f $targets.Count, ($targets.Name -join ", "))
|
Write-Host ("发现 {0} 个技能: {1}" -f $targets.Count, ($targets.Name -join ", "))
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
# --- 4. 计算操作(备份/安装)并可选预览 ---
|
# --- 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"
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
$backupCount = 0
|
$totalBackup = 0
|
||||||
|
$totalFresh = 0
|
||||||
|
$failedTargets = @()
|
||||||
|
|
||||||
foreach ($skill in $targets) {
|
foreach ($destRel in $destRelList) {
|
||||||
$dest = Join-Path $TraeSkills $skill.Name
|
$DestSkills = Join-Path $env:USERPROFILE $destRel
|
||||||
|
$BackupRoot = (Join-Path $env:USERPROFILE ($destRel -replace '\\skills$', '')) + "\skills-backup"
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "== 预览 [$env:USERPROFILE\$destRel](未执行)==" -ForegroundColor Cyan
|
||||||
|
$freshNames = @($targets | Where-Object { -not (Test-Path (Join-Path $DestSkills $_.Name)) })
|
||||||
|
$backupNames = @($targets | Where-Object { Test-Path (Join-Path $DestSkills $_.Name) })
|
||||||
|
if ($freshNames.Count -gt 0) {
|
||||||
|
Write-Host (" 新安装 {0}: {1}" -f $freshNames.Count, ($freshNames.Name -join ", "))
|
||||||
|
}
|
||||||
|
if ($backupNames.Count -gt 0) {
|
||||||
|
Write-Host (" 覆盖安装(旧版将备份){0}: {1}" -f $backupNames.Count, ($backupNames.Name -join ", "))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "--- 安装到 $DestSkills ---" -ForegroundColor Cyan
|
||||||
|
New-Item -ItemType Directory -Force -Path $DestSkills | Out-Null
|
||||||
|
|
||||||
|
foreach ($skill in $targets) {
|
||||||
|
$dest = Join-Path $DestSkills $skill.Name
|
||||||
|
|
||||||
|
try {
|
||||||
if (Test-Path $dest) {
|
if (Test-Path $dest) {
|
||||||
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
|
||||||
$backupPath = Join-Path $BackupRoot ("{0}-{1}" -f $skill.Name, $timestamp)
|
$backupPath = Join-Path $BackupRoot ("{0}-{1}" -f $skill.Name, $timestamp)
|
||||||
Move-Item -Path $dest -Destination $backupPath
|
Move-Item -Path $dest -Destination $backupPath
|
||||||
$backupCount++
|
$totalBackup++
|
||||||
Write-Host "[备份] $($skill.Name) 旧版 -> $backupPath" -ForegroundColor Yellow
|
Write-Host "[备份] $($skill.Name) 旧版 -> $backupPath" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
Copy-Item -Path $skill.FullName -Destination $dest -Recurse -Force
|
Copy-Item -Path $skill.FullName -Destination $dest -Recurse -Force
|
||||||
|
$totalFresh++
|
||||||
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
|
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
|
||||||
}
|
|
||||||
|
|
||||||
# --- 6. 校验安装结果 ---
|
# 校验该技能落盘完整
|
||||||
Write-Host ""
|
if (-not (Test-Path (Join-Path $DestSkills "$($skill.Name)\SKILL.md"))) {
|
||||||
Write-Host "=== 校验结果 ==="
|
throw "SKILL.md 未落盘"
|
||||||
$failed = @()
|
}
|
||||||
foreach ($skill in $targets) {
|
} catch {
|
||||||
if (Test-Path (Join-Path $TraeSkills "$($skill.Name)\SKILL.md")) {
|
Write-Host "[FAIL] $($skill.Name) -> $DestSkills : $_" -ForegroundColor Red
|
||||||
Write-Host " [OK] $($skill.Name)" -ForegroundColor Green
|
$failedTargets += "$destRel/$($skill.Name)"
|
||||||
} else {
|
}
|
||||||
Write-Host " [FAIL] $($skill.Name)" -ForegroundColor Red
|
|
||||||
$failed += $skill.Name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 7. 汇总与后续提示 ---
|
# --- 5. 预览模式收尾 ---
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[DryRun] 完成,未做任何修改。" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 6. 汇总与后续提示 ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($failed.Count -gt 0) {
|
if ($failedTargets.Count -gt 0) {
|
||||||
Write-Host ("安装失败: {0}" -f ($failed -join ", ")) -ForegroundColor Red
|
Write-Host ("安装失败: {0}" -f ($failedTargets -join ", ")) -ForegroundColor Red
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "全部安装成功。" -ForegroundColor Green
|
Write-Host "全部安装成功。" -ForegroundColor Green
|
||||||
Write-Host ("本次:新建 {0} 个,覆盖 {1} 个(备份到技能目录外的 backups)。" -f $toFresh.Count, $toBackup.Count) -ForegroundColor Green
|
Write-Host ("本次共写入 {0} 个技能实例(新装含覆盖),备份 {1} 个旧版。" -f ($totalFresh), $totalBackup) -ForegroundColor Green
|
||||||
if ($warnCount -gt 0) {
|
if ($warnCount -gt 0) {
|
||||||
Write-Host ("注意:{0} 处 name/文件夹不一致或缺 name 的可疑技能(见上方警告)。" -f $warnCount) -ForegroundColor Yellow
|
Write-Host ("注意:{0} 处 name/文件夹不一致或缺 name 的可疑技能(见上方警告)。" -f $warnCount) -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
if ($backupCount -gt 0) {
|
if ($totalBackup -gt 0) {
|
||||||
Write-Host "旧版本备份于: $BackupRoot(确认新版可用后可手动删除)" -ForegroundColor Yellow
|
Write-Host "旧版本备份于各目标目录旁的 skills-backup(确认新版可用后可手动删除)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "下一步: 重启 Trae CN 或新建会话后,在对话中提及技能名或相关意图即可触发:"
|
Write-Host "下一步: 重启对应 agent 或新建会话后,在对话中提及技能名或相关意图即可触发:"
|
||||||
foreach ($s in $targets) {
|
foreach ($s in $targets) {
|
||||||
Write-Host " - $($s.Name)"
|
Write-Host " - $($s.Name)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "playwright>=1.40",
|
||||||
|
# "requests>=2.31",
|
||||||
|
# "openpyxl>=3.1",
|
||||||
|
# "pandas>=2.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
r"""
|
r"""
|
||||||
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
||||||
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
||||||
@@ -88,6 +97,10 @@ HTTP_HINTS = {
|
|||||||
429: "(限流:调低 --max-workers 或稍后重试)",
|
429: "(限流:调低 --max-workers 或稍后重试)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# HTTP/2 伪头(:authority/:method/:path/:scheme):requests 作为普通头发送会抛
|
||||||
|
# InvalidHeader。捕获时即过滤,不让伪头流入 bundles.json。
|
||||||
|
PSEUDO_HEADER_RE = re.compile(r"^:")
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 30
|
REQUEST_TIMEOUT = 30
|
||||||
MANUAL_WAIT_SECONDS = 180
|
MANUAL_WAIT_SECONDS = 180
|
||||||
|
|
||||||
@@ -256,8 +269,10 @@ def search(bundle: dict, name: str, session=None) -> dict:
|
|||||||
|
|
||||||
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
||||||
"""
|
"""
|
||||||
|
# 双保险:捕获时已滤伪头,这里再滤一次兜住手工维护的旧 bundles.json
|
||||||
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
||||||
if str(k).lower() not in STRIP_HEADERS}
|
if str(k).lower() not in STRIP_HEADERS
|
||||||
|
and not PSEUDO_HEADER_RE.match(str(k))}
|
||||||
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
||||||
url = bundle.get("url") or ENDPOINT
|
url = bundle.get("url") or ENDPOINT
|
||||||
try:
|
try:
|
||||||
@@ -386,6 +401,26 @@ OWNER_DISPLAY_SELECTORS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_cdp_reachable(cdp_url):
|
||||||
|
"""CDP 附加前先探测调试端口,失败时给出可执行的启动指引(区别于端口通但被吞)。"""
|
||||||
|
base = (cdp_url or "").rstrip("/")
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
with urllib.request.urlopen(f"{base}/json/version", timeout=3):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise SystemExit(
|
||||||
|
f"[!] CDP 端口不可达: {base}\n"
|
||||||
|
" 先确认浏览器已带调试端口参数启动:\n"
|
||||||
|
' chrome.exe --remote-debugging-port=9222 '
|
||||||
|
'(msedge.exe 同理,或 chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/yts-cdp")\n'
|
||||||
|
" 若已带参数仍连不上:有 Chrome/Edge 残留进程占用了默认 profile,"
|
||||||
|
"新实例的调试端口参数会被吞掉。请先在任务管理器彻底退出所有 Chrome/Edge 进程,"
|
||||||
|
'再重启;或改用独立 user-data-dir 启动(如 --user-data-dir="C:/yts-cdp")。')
|
||||||
|
|
||||||
|
|
||||||
def _launch_page(p, user_data_dir, channel, cdp_url):
|
def _launch_page(p, user_data_dir, channel, cdp_url):
|
||||||
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
||||||
if cdp_url:
|
if cdp_url:
|
||||||
@@ -496,6 +531,12 @@ def _capture_bundle(page, url, wait_seconds, owner_display=""):
|
|||||||
headers = dict(req.all_headers())
|
headers = dict(req.all_headers())
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
headers = dict(req.headers)
|
headers = dict(req.headers)
|
||||||
|
# 过滤 HTTP/2 伪头,防止回放时 requests 抛 InvalidHeader
|
||||||
|
pseudo = sorted(k for k in headers if PSEUDO_HEADER_RE.match(k))
|
||||||
|
if pseudo:
|
||||||
|
print(f"[捕获] 已过滤 HTTP/2 伪头 {len(pseudo)} 个: {', '.join(pseudo)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
headers = {k: v for k, v in headers.items() if not PSEUDO_HEADER_RE.match(k)}
|
||||||
try:
|
try:
|
||||||
body = json.loads(req.post_data or "{}")
|
body = json.loads(req.post_data or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -535,6 +576,8 @@ def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
|
|
||||||
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
||||||
print(f"[捕获] 浏览器会话:{mode}")
|
print(f"[捕获] 浏览器会话:{mode}")
|
||||||
|
if cdp_url:
|
||||||
|
_assert_cdp_reachable(cdp_url)
|
||||||
|
|
||||||
bundles = []
|
bundles = []
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
@@ -545,7 +588,8 @@ def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"[!] 启动/连接浏览器失败: {e}\n"
|
f"[!] 启动/连接浏览器失败: {e}\n"
|
||||||
" 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n"
|
" 方式 A 需先关闭对应浏览器(有残留进程会报目录被占用);"
|
||||||
|
"方式 B 先以调试端口启动:\n"
|
||||||
" chrome.exe --remote-debugging-port=9222")
|
" chrome.exe --remote-debugging-port=9222")
|
||||||
try:
|
try:
|
||||||
for i, url in enumerate(urls, 1):
|
for i, url in enumerate(urls, 1):
|
||||||
|
|||||||
86
scripts/metrics.json
Normal file
86
scripts/metrics.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"观看次数": "EXTERNAL_VIEWS",
|
||||||
|
"感兴趣的观看次数": "ENGAGED_VIEWS",
|
||||||
|
"观看时长(小时)": "EXTERNAL_WATCH_TIME",
|
||||||
|
"订阅人数": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"平均观看时长": "AVERAGE_WATCH_TIME",
|
||||||
|
"平均观看百分比": "AVERAGE_WATCH_PERCENTAGE",
|
||||||
|
"添加的视频数": "VIDEO_COUNT_NEW",
|
||||||
|
"发布的视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"展示次数": "VIDEO_THUMBNAIL_IMPRESSIONS",
|
||||||
|
"展示点击率": "VIDEO_THUMBNAIL_IMPRESSIONS_VTR",
|
||||||
|
"继续观看": "SHORTS_FEED_IMPRESSIONS_VTR",
|
||||||
|
"唯一身份观看者人数": "ESTIMATED_UNIQUE_VIEWERS",
|
||||||
|
"人均观看次数": "AVERAGE_VIEWS_PER_VIEWER",
|
||||||
|
"获得的订阅人数": "SUBSCRIBERS_GAINED",
|
||||||
|
"流失的订阅人数": "SUBSCRIBERS_LOST",
|
||||||
|
"赞": "RATINGS_LIKES",
|
||||||
|
"不喜欢": "RATINGS_DISLIKES",
|
||||||
|
"赞和不喜欢的比率": "LIKES_PER_LIKES_PLUS_DISLIKES_PERCENT",
|
||||||
|
"分享数": "SHARINGS",
|
||||||
|
"添加的评论数": "COMMENTS",
|
||||||
|
"估算的合作伙伴收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"合作伙伴的交易收入": "TRANSACTION_EARNINGS_ALL",
|
||||||
|
"交易次数": "TRANSACTION_COUNT",
|
||||||
|
"合作伙伴的每笔交易收入": "AVERAGE_TRANSACTION_AMOUNT",
|
||||||
|
"YouTube Premium 合作伙伴收入": "SUBSCRIPTION_EARNINGS",
|
||||||
|
"估算的合作伙伴广告收入": "AD_EARNINGS",
|
||||||
|
"估算的合作伙伴 DoubleClick 收入": "YOUTUBE_EARNINGS",
|
||||||
|
"估算的合作伙伴 AdSense 收入": "AFV_EARNINGS",
|
||||||
|
"YouTube 广告收入": "AD_GROSS_REVENUE",
|
||||||
|
"广告展示次数": "IMPRESSIONS",
|
||||||
|
"基于播放的每千次展示费用": "CPM",
|
||||||
|
"每千次展示费用": "IMPRESSIONS_CPM",
|
||||||
|
"估算的获利播放次数": "PLAYBACKS",
|
||||||
|
"每千次展示收入 (RPM)": "EPM",
|
||||||
|
"YouTube Premium 观看次数": "EXTERNAL_YOUTUBE_RED_VIEWS",
|
||||||
|
"YouTube Premium 观看时长(小时)": "EXTERNAL_YOUTUBE_RED_WATCH_TIME",
|
||||||
|
"播放列表观看时长(小时)": "PLAYLIST_WATCH_TIME_HOURS",
|
||||||
|
"播放列表观看次数": "PLAYLIST_VIEWS",
|
||||||
|
"播放列表平均观看时长": "PLAYLIST_AVERAGE_WATCH_TIME",
|
||||||
|
"播放列表平均观看百分比": "PLAYLIST_AVERAGE_WATCH_PERCENTAGE",
|
||||||
|
"播放列表开始播放的次数": "PLAYLIST_STARTS",
|
||||||
|
"播放列表退出次数": "PLAYLIST_EXITS",
|
||||||
|
"播放列表退出率": "PLAYLIST_EXIT_RATE",
|
||||||
|
"播放列表的平均观看时长": "PLAYLIST_AVERAGE_START_DURATION",
|
||||||
|
"播放列表每次开始播放产生的观看次数": "PLAYLIST_AVERAGE_VIEWS_PER_START",
|
||||||
|
"播放列表保存次数": "PLAYLIST_SAVES_NET_CHANGE",
|
||||||
|
"社区剪辑片段观看次数": "CLIP_VIEWS",
|
||||||
|
"社区剪辑片段带来的观看时长(小时)": "CLIP_VIDEO_WATCHTIME",
|
||||||
|
"卡片点击次数": "INFO_CARD_CLICKS",
|
||||||
|
"卡片展示次数": "INFO_CARD_IMPRESSIONS",
|
||||||
|
"卡片每次展示所获得的点击次数": "INFO_CARD_CLICK_RATE",
|
||||||
|
"卡片宣传语点击次数": "INFO_CARD_TEASER_CLICKS",
|
||||||
|
"卡片宣传语展示次数": "INFO_CARD_TEASER_IMPRESSIONS",
|
||||||
|
"卡片宣传语每次展示所获得的点击次数": "INFO_CARD_TEASER_CLICK_RATE",
|
||||||
|
"片尾画面元素点击次数": "ENDSCREEN_ELEMENT_CLICKS",
|
||||||
|
"片尾画面元素展示次数": "ENDSCREEN_ELEMENT_IMPRESSIONS",
|
||||||
|
"片尾画面元素每次展示所获得的点击次数": "ENDSCREEN_ELEMENT_CLICK_RATE",
|
||||||
|
"税前收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"税前收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"预计收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"订阅净增长": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"订阅净变化": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"净订阅": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"订阅变化": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"视频发布数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"发布视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"互动观看": "ENGAGED_VIEWS",
|
||||||
|
"互动观看时长": "ENGAGED_VIEWS",
|
||||||
|
"参与观看": "ENGAGED_VIEWS",
|
||||||
|
"外部观看次数": "EXTERNAL_VIEWS",
|
||||||
|
"外部观看时长": "EXTERNAL_WATCH_TIME",
|
||||||
|
"观看时长": "EXTERNAL_WATCH_TIME",
|
||||||
|
"收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"estimated_earnings": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"earnings": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"subscribers_net_change": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"net_change": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"video_count_first_published": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"engaged_views": "ENGAGED_VIEWS",
|
||||||
|
"external_views": "EXTERNAL_VIEWS",
|
||||||
|
"external_watch_time": "EXTERNAL_WATCH_TIME",
|
||||||
|
"average_watch_time": "AVERAGE_WATCH_TIME",
|
||||||
|
"total_estimated_earnings": "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
}
|
||||||
@@ -1,60 +1,49 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "playwright>=1.40",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
r"""
|
r"""
|
||||||
YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载流程 —— 拦截/解码/自动保存/重名去重 参考实现。
|
YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载脚本
|
||||||
|
(拦截响应 + 解码 zip + 完整性校验 + 自动保存到下载目录 + 重名去重)。
|
||||||
|
|
||||||
## 已实证的下载生成机制(2026-08-21 抓包确认)
|
机制(已实证):前端点击导出后向后端
|
||||||
|
|
||||||
1. 前端点击「导出当前视图 → 逗号分隔值 (.csv)」后,向后端发起:
|
|
||||||
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
|
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
|
||||||
请求体 `exportQuery` 内含 joinRequest 各节点(表格数据/图表数据/总计),以及
|
后端把打好的 zip 以 base64 内联在响应 `zippedData` 字段里(开头 `UEsDBBQ` 即 ZIP 文件头 `PK`)。
|
||||||
日期范围 `dateIdRange.inclusiveStart` / `dateIdRange.exclusiveEnd`(都是 YYYYMMDD)。
|
注意两个字段细节(均为实证结论):
|
||||||
|
- `zippedData` 是 **URL-safe base64**(-/_ 代替 +//,且省略 = 填充);
|
||||||
|
- 当前界面导出入口是右上角**下载图标按钮**:只有 `aria-label="导出当前视图"`,没有可见文本。
|
||||||
|
本脚本拦截该响应,base64 解码并校验 zip 完整性后按 `<维度标签> <起始日>_<结束日> <账号名>.zip`
|
||||||
|
写盘,重名自动加 ` (n)` 后缀,n 从 1 起;未捕获到有效响应时自动重试一次触发导出。
|
||||||
|
|
||||||
2. 后端**不在服务器上生成一个可下载的 URL**,而是直接把打好的 zip 以
|
登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一:
|
||||||
**base64 字符串**内联在响应里:
|
- --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器)
|
||||||
{ "responseContext": {...}, "zippedData": "<base64>" }
|
- --connect http://localhost:9222 :附加到已在调试端口运行的浏览器
|
||||||
实测 `zippedData` 以 `UEsDBBQ...` 开头(即 `PK\\x03\\x04`,ZIP 魔数),
|
|
||||||
base64 解码后得到 zip,内含 `表格数据.csv`、`图表数据.csv`、`总计.csv`。
|
|
||||||
|
|
||||||
3. 前端把 `zippedData` base64 解码 → Blob → 触发浏览器下载。
|
用法:
|
||||||
由于没有出现指向下载文件的 GET/跳转,判定为「客户端 Blob 下载」而非服务端重定向。
|
python youtube_export_download.py --selftest
|
||||||
|
python youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:/Users/<you>/AppData/Local/Google/Chrome/User Data"
|
||||||
4. 浏览器自身已自动保存到本机默认下载目录(本机为 `D:\\Downloads`),无需"另存为"确认;
|
python youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
|
||||||
且 Chromium 对重名文件会自动追加 ` (1)`、` (2)` …后缀。
|
|
||||||
|
|
||||||
## 结论:可以跨越下载流程
|
|
||||||
- 在响应层拦截 `csv_export`,拿到 `zippedData`,自行 base64 解码并写盘,
|
|
||||||
即可完全掌控「保存目录 + 文件名 + 重名去重」,不依赖浏览器的下载管理器和弹窗。
|
|
||||||
- 或者只用 Chromium 的下载偏好(auto-download + 内置去重)让它自动落盘。
|
|
||||||
|
|
||||||
## 文件名约定(与实测 D:\\Downloads 中产物一致)
|
|
||||||
<维度标签> <inclusiveStart>_<exclusiveEnd> <账号名>.zip
|
|
||||||
例:内容 2026-07-23_2026-08-20 WL Media.zip
|
|
||||||
- 维度标签:VIDEO -> 内容;USER -> 频道(生产中建议从页面"维度"按钮文本读取)
|
|
||||||
- 日期格式 YYYY-MM-DD:inclusiveStart 与 exclusiveEnd 各取 YYYYMMDD 转 YYYY-MM-DD
|
|
||||||
- 账号名:右上角账号按钮文本
|
|
||||||
|
|
||||||
自测(无需 Playwright): python youtube_export_interceptor.py --selftest
|
|
||||||
|
|
||||||
实际运行(需 Playwright;必须复用已登录 YouTube Studio 的浏览器会话,否则跳登录页):
|
|
||||||
方式 A(用已登录的用户数据目录启动,需先关闭 Chrome/Edge):
|
|
||||||
python youtube_export_interceptor.py --url "<explore URL>" --channel chrome ^
|
|
||||||
--user-data-dir "%LOCALAPPDATA%\Google\Chrome\User Data"
|
|
||||||
方式 B(附加到已在调试端口运行的浏览器,无需关闭):
|
|
||||||
python youtube_export_interceptor.py --url "<explore URL>" --connect http://localhost:9222
|
|
||||||
# 先启动: chrome.exe --remote-debugging-port=9222 或 msedge.exe --remote-debugging-port=9222
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
|
||||||
# 本机 Windows 下载目录。可改为 os.path.expanduser("~") / "Downloads"。
|
DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖
|
||||||
DOWNLOAD_DIR = r"D:\Downloads"
|
|
||||||
|
|
||||||
# 维度类型 -> 文件名前缀标签(生产环境请从页面「维度」按钮文本读取,这里兜底映射)。
|
EXPORT_RESPONSE_TIMEOUT = 30 # 每次触发导出后等待 csv_export 响应的秒数
|
||||||
|
MAX_EXPORT_ATTEMPTS = 2 # 未捕获到有效响应时的最大触发次数(1 次重试)
|
||||||
|
|
||||||
|
# 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。
|
||||||
DIMENSION_LABEL = {
|
DIMENSION_LABEL = {
|
||||||
"VIDEO": "内容",
|
"VIDEO": "内容",
|
||||||
"USER": "频道",
|
"USER": "频道",
|
||||||
@@ -64,15 +53,8 @@ DIMENSION_LABEL = {
|
|||||||
CSV_EXPORT_PATH = "/youtubei/v1/yta_web/csv_export"
|
CSV_EXPORT_PATH = "/youtubei/v1/yta_web/csv_export"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# 重名去重:需求文件.zip -> 需求文件 (1).zip -> 需求文件 (2).zip ... #
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def dedup_path(directory, filename):
|
def dedup_path(directory, filename):
|
||||||
"""返回不冲突的落盘路径。重名时按 `名称 (n).后缀` 递增,n 从 1 开始。
|
"""返回不冲突的落盘路径;重名按 `名称 (n).后缀` 递增,n 从 1 起。"""
|
||||||
|
|
||||||
规则与用户要求一致:第二次同名保存 `xx (1).zip`,第三次 `xx (2).zip`,以此类推;
|
|
||||||
若 `xx (1).zip` 也已存在,则继续找 `xx (2).zip`(即取最小无冲突的 n)。
|
|
||||||
"""
|
|
||||||
directory = os.path.abspath(directory)
|
directory = os.path.abspath(directory)
|
||||||
base, ext = os.path.splitext(filename)
|
base, ext = os.path.splitext(filename)
|
||||||
candidate = os.path.join(directory, filename)
|
candidate = os.path.join(directory, filename)
|
||||||
@@ -84,20 +66,16 @@ def dedup_path(directory, filename):
|
|||||||
|
|
||||||
|
|
||||||
def build_export_filename(export_query, account_name, dimension_label=None):
|
def build_export_filename(export_query, account_name, dimension_label=None):
|
||||||
"""从 csv_export 请求体 `exportQuery` 反推导出文件名。
|
"""从 csv_export 请求体 `exportQuery` 反推文件名。"""
|
||||||
|
|
||||||
与实测产物命名一致:`<维度标签> <inclusiveStart>_<exclusiveEnd> <账号名>.zip`
|
|
||||||
"""
|
|
||||||
def fmt_dateid(yyyymmdd):
|
def fmt_dateid(yyyymmdd):
|
||||||
s = str(yyyymmdd)
|
s = str(yyyymmdd)
|
||||||
return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
|
return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
|
||||||
|
|
||||||
# 从任意一个 joinRequest 节点取 dateIdRange
|
|
||||||
date_range = None
|
date_range = None
|
||||||
dimension = None
|
dimension = None
|
||||||
nodes = (export_query.get("joinRequest", {}).get("nodes") or [])
|
nodes = export_query.get("joinRequest", {}).get("nodes") or []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
q = (node.get("value", {}).get("query") or {})
|
q = node.get("value", {}).get("query") or {}
|
||||||
if not date_range:
|
if not date_range:
|
||||||
tr = q.get("timeRange", {}).get("dateIdRange")
|
tr = q.get("timeRange", {}).get("dateIdRange")
|
||||||
if tr and tr.get("inclusiveStart"):
|
if tr and tr.get("inclusiveStart"):
|
||||||
@@ -117,17 +95,46 @@ def build_export_filename(export_query, account_name, dimension_label=None):
|
|||||||
|
|
||||||
|
|
||||||
def decode_zipped_data(payload):
|
def decode_zipped_data(payload):
|
||||||
"""把 csv_export 响应 payload 里的 zippedData 解码为 zip 字节流。"""
|
"""把 csv_export 响应 payload 里的 zippedData 解码为 zip 字节流。
|
||||||
|
|
||||||
|
该字段是 URL-safe base64(-/_ 代替 +//,且省略 = 填充):标准 b64decode 遇
|
||||||
|
-/_ 或非 4 对齐长度会抛 Incorrect padding,或静默解出损坏字节流。先补齐填充,
|
||||||
|
再用 altchars 兼容 URL-safe 与标准两种字符表;解码后校验 PK 文件头。
|
||||||
|
"""
|
||||||
zipped = payload.get("zippedData")
|
zipped = payload.get("zippedData")
|
||||||
if not zipped:
|
if not zipped:
|
||||||
raise ValueError("响应中缺少 zippedData 字段")
|
raise ValueError("响应中缺少 zippedData 字段")
|
||||||
return base64.b64decode(zipped)
|
z = str(zipped).strip()
|
||||||
|
data = base64.b64decode(z + "=" * (-len(z) % 4), altchars=b"-_")
|
||||||
|
if not data.startswith(b"PK"):
|
||||||
|
raise ValueError(
|
||||||
|
"zippedData 解码结果不是 zip 字节流(缺 PK 文件头),"
|
||||||
|
"响应可能被网关改写或导出接口已变动")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
def verify_zip(data):
|
||||||
# Playwright 主流程(拦截响应 -> 解码 -> 去重 -> 落盘) #
|
"""校验内存 zip 完整性:结构可读、成员 CRC 全过;否则抛 ValueError。"""
|
||||||
# --------------------------------------------------------------------------- #
|
try:
|
||||||
def intercept_and_save(page, account_name):
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
bad = zf.testzip()
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
raise ValueError(f"zip 结构损坏: {e}") from e
|
||||||
|
if bad is not None:
|
||||||
|
raise ValueError(f"zip 成员 CRC 校验失败: {bad}")
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_export(page):
|
||||||
|
"""点「导出当前视图」→「逗号分隔值 (.csv)」。
|
||||||
|
|
||||||
|
导出入口是右上角的下载图标按钮:只有 aria-label、无可见文本,
|
||||||
|
优先按 label 定位(get_by_text 兜底旧版有可见文本的界面)。
|
||||||
|
"""
|
||||||
|
page.get_by_label("导出当前视图").or_(page.get_by_text("导出当前视图")).first.click()
|
||||||
|
page.get_by_text("逗号分隔值 (.csv)").click()
|
||||||
|
|
||||||
|
|
||||||
|
def intercept_and_save(page, account_name, download_dir):
|
||||||
"""给 page 绑定 response 拦截器:命中 csv_export 就把 zip 保存到下载目录。"""
|
"""给 page 绑定 response 拦截器:命中 csv_export 就把 zip 保存到下载目录。"""
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
@@ -139,18 +146,17 @@ def intercept_and_save(page, account_name):
|
|||||||
try:
|
try:
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
data = decode_zipped_data(payload)
|
data = decode_zipped_data(payload)
|
||||||
|
verify_zip(data)
|
||||||
|
|
||||||
# 反推文件名:请求体在 response.request.post_data 里不总是可读,
|
|
||||||
# 这里从已捕获的 body 兜底;找不到就用时间戳命名,保证不误覆盖。
|
|
||||||
filename = None
|
filename = None
|
||||||
try:
|
try:
|
||||||
body = json.loads(response.request.post_data or "{}")
|
body = json.loads(response.request.post_data or "{}")
|
||||||
filename = build_export_filename(body.get("exportQuery", {}), account_name)
|
filename = build_export_filename(body.get("exportQuery", {}), account_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
filename = f"export-{response.request.headers.get('date', '')}.zip"
|
filename = "export.zip" # 反推失败兜底,避免丢内容
|
||||||
|
|
||||||
filename = re.sub(r"[\\/:*?\"<>|]", "_", filename) # Windows 非法字符
|
filename = re.sub(r"[\\/:*?\"<>|]", "_", filename) # Windows 非法字符
|
||||||
path = dedup_path(DOWNLOAD_DIR, filename)
|
path = dedup_path(download_dir, filename)
|
||||||
pathlib.Path(path).write_bytes(data)
|
pathlib.Path(path).write_bytes(data)
|
||||||
saved.append((filename, len(data), path))
|
saved.append((filename, len(data), path))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
@@ -168,29 +174,25 @@ def _default_user_data_dir(channel):
|
|||||||
return os.path.join(_local, "Google", "Chrome", "User Data")
|
return os.path.join(_local, "Google", "Chrome", "User Data")
|
||||||
|
|
||||||
|
|
||||||
def run(url, user_data_dir=None, channel=None, cdp_url=None):
|
def run(url, user_data_dir=None, channel=None, cdp_url=None,
|
||||||
"""启动/连接浏览器并触发导出。
|
download_dir=None, account_name=None):
|
||||||
|
"""启动/连接浏览器并触发导出。必须复用已登录会话,否则跳 Google 登录页。"""
|
||||||
关键:必须复用「已登录 YouTube Studio」的浏览器会话,否则会跳 Google 登录页。
|
|
||||||
- cdp_url: 附加到已在调试端口运行的浏览器(推荐,无需关闭浏览器)
|
|
||||||
- user_data_dir:用已登录的用户数据目录启动持久化上下文(需先关闭该浏览器)
|
|
||||||
- 两者都不传: 新建空白会话(大概率未登录,仅作占位/调试)
|
|
||||||
"""
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
download_dir = download_dir or DOWNLOAD_DIR
|
||||||
|
os.makedirs(download_dir, exist_ok=True)
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = None
|
browser = None
|
||||||
context = None
|
context = None
|
||||||
|
|
||||||
if cdp_url:
|
if cdp_url:
|
||||||
# 方式 B:附加到已打开、已登录的浏览器(先以调试端口启动浏览器)
|
|
||||||
browser = p.chromium.connect_over_cdp(cdp_url)
|
browser = p.chromium.connect_over_cdp(cdp_url)
|
||||||
context = browser.contexts[0] if browser.contexts else \
|
context = browser.contexts[0] if browser.contexts else \
|
||||||
browser.new_context(accept_downloads=True)
|
browser.new_context(accept_downloads=True)
|
||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
page.goto(url, wait_until="domcontentloaded")
|
page.goto(url, wait_until="domcontentloaded")
|
||||||
elif user_data_dir:
|
elif user_data_dir:
|
||||||
# 方式 A:用已登录的用户数据目录启动(cookies 复用;必须先关闭同名浏览器)
|
|
||||||
context = p.chromium.launch_persistent_context(
|
context = p.chromium.launch_persistent_context(
|
||||||
user_data_dir=user_data_dir or _default_user_data_dir(channel),
|
user_data_dir=user_data_dir or _default_user_data_dir(channel),
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -206,12 +208,12 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None):
|
|||||||
page = context.new_page()
|
page = context.new_page()
|
||||||
page.goto(url, wait_until="domcontentloaded")
|
page.goto(url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
if "studio.youtube.com" not in page.url and "accounts.google" in page.url:
|
if "accounts.google" in page.url:
|
||||||
print("[!] 当前会话未登录,已跳转到 Google 登录页。", file=sys.stderr)
|
print("[!] 当前会话未登录,已跳转到 Google 登录页。", file=sys.stderr)
|
||||||
print(" 请用 --user-data-dir 复用已登录浏览器,或先手动登录后再试。",
|
print(" 请用 --user-data-dir 或 --connect 复用已登录浏览器后重试。",
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
|
|
||||||
# 账号名:右上角账号按钮(示例选择器,按实际页面微调)
|
if not account_name:
|
||||||
account_name = "WL Media"
|
account_name = "WL Media"
|
||||||
try:
|
try:
|
||||||
account_name = page.locator(
|
account_name = page.locator(
|
||||||
@@ -220,13 +222,20 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
saved = intercept_and_save(page, account_name)
|
saved = intercept_and_save(page, account_name, download_dir)
|
||||||
|
|
||||||
# 触发导出:点「导出当前视图」→「逗号分隔值 (.csv)」
|
# 触发导出并等待响应;未捕获到有效 zip(未触发/响应无效)自动重试一次
|
||||||
page.get_by_text("导出当前视图").click()
|
for attempt in range(1, MAX_EXPORT_ATTEMPTS + 1):
|
||||||
page.get_by_text("逗号分隔值 (.csv)").click()
|
trigger_export(page)
|
||||||
|
deadline = time.time() + EXPORT_RESPONSE_TIMEOUT
|
||||||
|
while not saved and time.time() < deadline:
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
if saved:
|
||||||
|
break
|
||||||
|
if attempt < MAX_EXPORT_ATTEMPTS:
|
||||||
|
print(f"[重试] 第 {attempt} 次未捕获到有效导出响应,自动重试……",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
page.wait_for_timeout(3000)
|
|
||||||
if not saved:
|
if not saved:
|
||||||
print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。")
|
print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。")
|
||||||
else:
|
else:
|
||||||
@@ -239,15 +248,11 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None):
|
|||||||
context.close()
|
context.close()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# 自测 #
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def selftest():
|
def selftest():
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
# 1) 用户示例:需求文件.zip 第二次 -> 需求文件 (1).zip,第三次 -> (2).zip
|
|
||||||
p0 = Path(dedup_path(td, "需求文件.zip"))
|
p0 = Path(dedup_path(td, "需求文件.zip"))
|
||||||
assert p0.name == "需求文件.zip", p0.name
|
assert p0.name == "需求文件.zip", p0.name
|
||||||
p0.write_bytes(b"a")
|
p0.write_bytes(b"a")
|
||||||
@@ -260,14 +265,9 @@ def selftest():
|
|||||||
assert p2.name == "需求文件 (2).zip", p2.name
|
assert p2.name == "需求文件 (2).zip", p2.name
|
||||||
p2.write_bytes(b"c")
|
p2.write_bytes(b"c")
|
||||||
|
|
||||||
# 2) 若 (1) 已存在,也应跳到 (2)
|
|
||||||
assert Path(dedup_path(td, "需求文件.zip")).name == "需求文件 (3).zip"
|
assert Path(dedup_path(td, "需求文件.zip")).name == "需求文件 (3).zip"
|
||||||
|
assert Path(dedup_path(td, "其他.zip")).name == "其他.zip"
|
||||||
|
|
||||||
# 3) 无冲突时不加后缀
|
|
||||||
p3 = Path(dedup_path(td, "其他.zip"))
|
|
||||||
assert p3.name == "其他.zip", p3.name
|
|
||||||
|
|
||||||
# 4) 文件名反推(对应实测产物「内容 2026-07-23_2026-08-20 WL Media.zip」)
|
|
||||||
export_query = {
|
export_query = {
|
||||||
"joinRequest": {"nodes": [{"value": {"query": {
|
"joinRequest": {"nodes": [{"value": {"query": {
|
||||||
"dimensions": [{"type": "VIDEO"}],
|
"dimensions": [{"type": "VIDEO"}],
|
||||||
@@ -278,7 +278,25 @@ def selftest():
|
|||||||
name = build_export_filename(export_query, "WL Media")
|
name = build_export_filename(export_query, "WL Media")
|
||||||
assert name == "内容 2026-07-23_2026-08-20 WL Media.zip", name
|
assert name == "内容 2026-07-23_2026-08-20 WL Media.zip", name
|
||||||
|
|
||||||
print("selftest OK:去重与文件名反推逻辑全部通过")
|
# zip 解码:标准 base64 与 URL-safe 无填充变体都要解出同一字节流
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("a.csv", "x,y\n1,2")
|
||||||
|
raw = buf.getvalue()
|
||||||
|
std = base64.b64encode(raw).decode("ascii")
|
||||||
|
urlsafe_nopad = std.replace("+", "-").replace("/", "_").rstrip("=")
|
||||||
|
assert decode_zipped_data({"zippedData": std}) == raw
|
||||||
|
assert decode_zipped_data({"zippedData": urlsafe_nopad}) == raw
|
||||||
|
verify_zip(raw)
|
||||||
|
|
||||||
|
# 非 zip 字节流要报 PK 文件头错误,而不是静默落盘损坏文件
|
||||||
|
try:
|
||||||
|
decode_zipped_data({"zippedData": base64.b64encode(b"not a zip").decode()})
|
||||||
|
raise AssertionError("非 zip 数据应抛 ValueError")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "PK" in str(e), e
|
||||||
|
|
||||||
|
print("selftest OK:去重、文件名反推、zip 解码与完整性校验全部通过")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -287,20 +305,23 @@ if __name__ == "__main__":
|
|||||||
ap.add_argument("--url", help="explore URL")
|
ap.add_argument("--url", help="explore URL")
|
||||||
ap.add_argument("--user-data-dir", help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)")
|
ap.add_argument("--user-data-dir", help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)")
|
||||||
ap.add_argument("--channel", choices=["chrome", "msedge"],
|
ap.add_argument("--channel", choices=["chrome", "msedge"],
|
||||||
help="浏览器品牌:chrome / msedge(复用登录态时必填其一)")
|
help="浏览器品牌,复用登录态时必填其一")
|
||||||
ap.add_argument("--connect", help="通过 CDP 附加到已打开的浏览器,如 http://localhost:9222")
|
ap.add_argument("--connect", help="通过 CDP 附加到已打开浏览器,如 http://localhost:9222")
|
||||||
|
ap.add_argument("--download-dir", help=f"下载目录,默认 {DOWNLOAD_DIR}")
|
||||||
|
ap.add_argument("--account-name", help="账号名(用于文件名),默认从页面读取")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if args.selftest:
|
if args.selftest:
|
||||||
selftest()
|
selftest()
|
||||||
elif args.url:
|
elif args.url:
|
||||||
run(args.url, user_data_dir=args.user_data_dir,
|
run(args.url, user_data_dir=args.user_data_dir, channel=args.channel,
|
||||||
channel=args.channel, cdp_url=args.connect)
|
cdp_url=args.connect, download_dir=args.download_dir,
|
||||||
|
account_name=args.account_name)
|
||||||
else:
|
else:
|
||||||
selftest()
|
selftest()
|
||||||
print("\n实际运行请先: pip install playwright\n"
|
print("\n实际运行需先准备依赖环境(uv sync 或直接 uv run,见 uv-env-setup 技能),"
|
||||||
"复用登录态(必选其一,详见脚本顶部 docstring):\n"
|
"再复用登录态(二选一):\n"
|
||||||
" 方式 A: python youtube_export_interceptor.py --url \"<explore URL>\" "
|
" 方式 A: python youtube_export_download.py --url \"<explore URL>\" "
|
||||||
"--channel chrome --user-data-dir \"%LOCALAPPDATA%\\Google\\Chrome\\User Data\"\n"
|
"--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n"
|
||||||
" 方式 B: python youtube_export_interceptor.py --url \"<explore URL>\" "
|
" 方式 B: python youtube_export_download.py --url \"<explore URL>\" "
|
||||||
"--connect http://localhost:9222")
|
"--connect http://localhost:9222")
|
||||||
@@ -1,61 +1,95 @@
|
|||||||
---
|
---
|
||||||
name: "uv-env-setup"
|
name: "uv-env-setup"
|
||||||
description: "用 uv 准备并维护本项目 Python 运行环境(pyproject.toml → uv sync → uv run)。当用户提到环境准备/环境初始化/装依赖/重建 venv,脚本报 ModuleNotFoundError(pandas/openpyxl/playwright),.venv 缺失或损坏,或首次运行 scripts/、skills/*/scripts/ 下的 Python 脚本时使用。"
|
description: "用 uv 准备并维护本项目与各技能脚本的 Python 运行环境(项目环境 uv sync / 单脚本 PEP 723 模式)。当用户提到环境准备/装依赖/重建 venv、脚本报 ModuleNotFoundError(pandas/openpyxl/playwright),或 uv/.venv 不可用时使用。"
|
||||||
---
|
---
|
||||||
|
|
||||||
# uv 运行环境准备
|
# uv 运行环境准备
|
||||||
|
|
||||||
本项目所有 Python 脚本共用一套 uv 管理的环境:根目录 `pyproject.toml` 是依赖的**唯一事实来源**,`uv sync` 落地 `.venv\`,`uv run` 执行脚本(自动使用该环境,免激活、免手装依赖)。
|
三种运行形态共用一个入口 `uv run`:
|
||||||
|
|
||||||
## 步骤 1:确认 uv 可用
|
- **项目环境**:在含 `pyproject.toml` 的项目根运行 `scripts/` 或整套技能。根 `pyproject.toml` 是依赖唯一事实来源,`uv sync` 落地 `.venv\`,`uv run` 免激活执行。
|
||||||
|
- **单脚本模式**:脱离项目仓库单独运行某个技能脚本(如技能安装到用户级目录后跨项目使用)。三个脚本头部都带 PEP 723 内联依赖声明(`# /// script ... ///`),`uv run <脚本路径>` 会自动建缓存环境并装依赖,无需任何项目文件。
|
||||||
|
- **无 uv 回退链**:uv 完全不可用时按固定顺序降级,见步骤 1。
|
||||||
|
|
||||||
|
先按下表选分支,再执行对应步骤。
|
||||||
|
|
||||||
|
| 你的情况 | 分支 |
|
||||||
|
|---|---|
|
||||||
|
| 在本项目目录内跑任何脚本 | A:项目环境 |
|
||||||
|
| 技能已安装到用户级目录(如 `~\.trae-cn\skills\`、`~\.zcode\skills\`),当前目录没有 pyproject.toml | B:单脚本模式 |
|
||||||
|
| `uv` 命令无法识别 | 先走步骤 1 的回退链,再回到 A/B |
|
||||||
|
|
||||||
|
**完成判据(全局)**:最终用于跑业务脚本的那条命令成功打印结果,且不是靠裸 `python` 碰运气。
|
||||||
|
|
||||||
|
## 步骤 1:确认 uv 可用(含回退链)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv --version
|
uv --version
|
||||||
```
|
```
|
||||||
|
|
||||||
未安装时任选其一(Windows):
|
失败时**按顺序**尝试,命中即停:
|
||||||
|
|
||||||
- `winget install astral-sh.uv`
|
1. **会话内刷新 PATH 再试**(刚装完 uv、不想重开终端):
|
||||||
- `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
- PowerShell:`$env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")`
|
||||||
- `pip install uv`
|
- Git Bash / zsh:`export PATH="$PATH:$HOME/.local/bin"`
|
||||||
|
- cmd:重开终端(cmd 无法可靠刷新)
|
||||||
|
2. **`python -m uv --version`**:只要 uv 以模块存在就可用。此后把本文所有 `uv <子命令>` 都替换为 `python -m uv <子命令>`,其余不变。
|
||||||
|
3. **`pip install uv` 装上后回到第 2 条**(winget/官方脚本安装的 uv 对新会话才生效,pip 装的立即随当前 Python 可用)。
|
||||||
|
|
||||||
装完重开终端使 PATH 生效。
|
**完成判据**:`uv --version` 或 `python -m uv --version` 打印出版本号;若走了第 2 条,记下后续命令都要带 `-m uv`。
|
||||||
|
|
||||||
**完成判据**:`uv --version` 打印出版本号。
|
## 步骤 2A:项目环境同步
|
||||||
|
|
||||||
## 步骤 2:同步依赖
|
项目根(`pyproject.toml` 所在处)执行:
|
||||||
|
|
||||||
项目根目录(`pyproject.toml` 所在处)执行:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
- 首次运行自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright)。
|
- 首次自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright、requests)。
|
||||||
- 本机无兼容 Python 时,uv 自动下载托管 Python(`requires-python = ">=3.10"`)。
|
- 本机无兼容 Python 时,uv 自动下载托管 Python(`requires-python = ">=3.10"`)。
|
||||||
- `uv run` 本身也会隐式同步;显式 `uv sync` 是为了在跑脚本前把依赖错误一次性暴露出来。
|
- `uv run` 会隐式同步;显式 `uv sync` 是为了把依赖错误一次性暴露。
|
||||||
|
|
||||||
**完成判据**:命令退出码 0,且项目根出现 `.venv\` 目录。
|
**完成判据**:退出码 0,且项目根出现 `.venv\` 目录。
|
||||||
|
|
||||||
|
## 步骤 2B:单脚本模式(无项目文件)
|
||||||
|
|
||||||
|
对任意一个技能脚本直接:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run "C:/Users/<you>/.trae-cn/skills/yt-studio-url-builder/scripts/build_studio_urls.py" --help
|
||||||
|
```
|
||||||
|
|
||||||
|
- uv 读脚本头部的 PEP 723 声明,自动缓存专用环境(默认仅 pandas/openpyxl,或含 playwright)。
|
||||||
|
- 环境、路径写法用正斜杠 `/`,在 PowerShell、Git Bash、cmd 中都合法。
|
||||||
|
- 首次运行某脚本会下载其依赖,耗时略长属正常现象。
|
||||||
|
|
||||||
|
**完成判据**:脚本按预期输出(`--help` 打印用法 / `--selftest` 打印 selftest OK),无需 `.venv` 或 pyproject.toml。
|
||||||
|
|
||||||
## 步骤 3:验证
|
## 步骤 3:验证
|
||||||
|
|
||||||
|
项目环境下三条自检:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run python -c "import pandas, openpyxl, playwright; print('env OK')"
|
uv run python -c "import pandas, openpyxl, playwright, requests; print('env OK')"
|
||||||
uv run python skills\youtube-studio-csv-download\scripts\youtube_export_download.py --selftest
|
uv run python skills/youtube-studio-csv-download/scripts/youtube_export_download.py --selftest
|
||||||
uv run python skills\yt-studio-url-builder\scripts\build_studio_urls.py --help
|
uv run python skills/yt-studio-groupid-lookup/scripts/lookup_groups.py --selftest
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成判据**:三条命令均成功——打印 `env OK`、`selftest OK`、脚本的用法帮助。
|
**完成判据**:打印 `env OK` 与两条 `selftest OK`。
|
||||||
|
|
||||||
## 其他技能如何使用本环境
|
## 其他技能如何使用本环境
|
||||||
|
|
||||||
- 统一在项目根目录用 `uv run python <脚本> [参数]` 执行,uv 自动向上定位 `pyproject.toml` 并复用其环境。
|
- 四个业务技能(url-builder、groupid-lookup、csv-download)的 SKILL.md 默认**本技能已跑通**;它们给出的 `uv run python <脚本>` 命令按所选分支执行即可。
|
||||||
- 不用裸 `python`(依赖 PATH 碰运气),不手动激活 venv,不 `pip install` 到全局。
|
- 统一不裸调 `python`、不手动激活 venv、不向全局 `pip install` 依赖。
|
||||||
|
- 各 agent(trae-cn / zcode 等)的终端方言不同:示例中的调用统一写成对两者都安全的形态——正斜杠路径 + 引号包裹;确需 shell 特有能力时区分 PowerShell 与 bash 写法,不要混用。
|
||||||
|
|
||||||
## 新增依赖
|
## 新增依赖
|
||||||
|
|
||||||
改 `pyproject.toml` 的 `dependencies`(唯一入口),再 `uv sync` 更新 `uv.lock`。禁止 `pip install` / `uv pip install` 直装——绕过 lock,环境不可复现。
|
- 项目环境:改根 `pyproject.toml` 的 `dependencies`(唯一入口)后 `uv sync` 更新 `uv.lock`。
|
||||||
|
- 单脚本模式:改脚本头部 PEP 723 的 `dependencies`,下次 `uv run` 自动生效。
|
||||||
|
- 两个来源不要混着加同一依赖,防止版本漂移;禁止绕过声明直装。
|
||||||
|
|
||||||
## 排查
|
## 排查
|
||||||
|
|
||||||
网络慢/超时、uv 安装失败、`.venv` 损坏重建、多版本 Python 冲突等,查 [references/troubleshooting.md](references/troubleshooting.md)。
|
网络慢/超时、镜像配置、`.venv` 损坏重建、uv 安装方式差异、无 uv 手动兜底等,查 [references/troubleshooting.md](references/troubleshooting.md)。
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
# 环境排查
|
# 环境排查
|
||||||
|
|
||||||
## uv 安装与 PATH
|
## uv 不识别(按序排查)
|
||||||
|
|
||||||
- **`uv: command not found` / 无法识别**:装完未重开终端,PATH 未生效;重开终端或手动刷新 `$env:Path`。
|
1. **刚装完、当前会话不认识**:会话内刷新 PATH——
|
||||||
- **winget 安装失败**:改用官方脚本 `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`,或 `pip install uv`。
|
- PowerShell:`$env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")`
|
||||||
|
- Git Bash / zsh:`export PATH="$PATH:$HOME/.local/bin"`
|
||||||
|
- cmd:重开终端。
|
||||||
|
2. **试试模块入口**:`python -m uv --version`。可用则后续所有 `uv <子命令>` 都换成 `python -m uv <子命令>`。
|
||||||
|
3. **用 pip 兜底安装**:`pip install uv` 后回到第 2 条,立即生效(winget/官方脚本方式要等新终端)。
|
||||||
|
4. **彻底没有 Python/uv**:先装 Python 3.10+(或让 `uv` 官方脚本一并装好托管 Python),再走第 3 条。
|
||||||
|
|
||||||
|
## 无 pyproject.toml 的目录跑脚本
|
||||||
|
|
||||||
|
- 脚本头部带 PEP 723 声明(`# /// script ... ///`),直接 `uv run <脚本>` 即可,uv 自动建缓存环境。
|
||||||
|
- 报「无法解析依赖/找不到脚本」时:确认路径存在且为正斜杠写法;确认脚本头部声明未被删改。
|
||||||
|
- 需要重建缓存环境:删除 `%LOCALAPPDATA%\uv\cache\environments-v2` 下对应目录(或整体清 `uv cache clean`)后重试。
|
||||||
|
|
||||||
## 网络与镜像(国内环境)
|
## 网络与镜像(国内环境)
|
||||||
|
|
||||||
@@ -28,6 +39,14 @@ default = true
|
|||||||
- **lock 与 pyproject 不一致**:`uv.lock` 过期,`uv sync` 会自动更新;若报 lock 损坏,删除 `uv.lock` 后重新 `uv sync`。
|
- **lock 与 pyproject 不一致**:`uv.lock` 过期,`uv sync` 会自动更新;若报 lock 损坏,删除 `uv.lock` 后重新 `uv sync`。
|
||||||
- **`requires-python` 不满足**:本机 Python 全部低于 3.10。让 uv 自动下载即可:`uv sync -p 3.12`(或省略,uv 自选)。
|
- **`requires-python` 不满足**:本机 Python 全部低于 3.10。让 uv 自动下载即可:`uv sync -p 3.12`(或省略,uv 自选)。
|
||||||
|
|
||||||
|
## 已有 .venv 但缺 pyproject.toml
|
||||||
|
|
||||||
|
历史环境是手动 `pip install` 搭的,不是 uv 管理:
|
||||||
|
|
||||||
|
1. 在项目根新建 `pyproject.toml`,`dependencies` 写全实际用到的包(参考现有 .venv 里 `pip list`)。
|
||||||
|
2. `requires-python` 与现有解释器大版本一致(如 `">=3.12"`),避免 uv 另下新 Python。
|
||||||
|
3. `uv sync` 让 uv 接管该 `.venv` 并生成 `uv.lock`。
|
||||||
|
|
||||||
## .venv 损坏 / 重建
|
## .venv 损坏 / 重建
|
||||||
|
|
||||||
症状:`uv run` 报奇怪的导入错误、DLL 加载失败,或 `.venv` 被移动过。
|
症状:`uv run` 报奇怪的导入错误、DLL 加载失败,或 `.venv` 被移动过。
|
||||||
@@ -39,10 +58,20 @@ uv sync
|
|||||||
|
|
||||||
## playwright 相关
|
## playwright 相关
|
||||||
|
|
||||||
- **`ModuleNotFoundError: playwright`**:环境未同步。项目根执行 `uv sync`,或直接用 `uv run python <脚本>`(隐式同步)。
|
- **`ModuleNotFoundError: playwright`**:环境未同步。项目根执行 `uv sync`,或直接用 `uv run python <脚本>`;单脚本模式下直接 `uv run <脚本>`(PEP 723 自动装)。详见 SKILL.md 步骤 1 回退链。
|
||||||
- **复用系统 Chrome/Edge 无需 `playwright install`**:只有用 uv 环境内置 Chromium 时才需要 `uv run playwright install chromium`。
|
- **复用系统 Chrome/Edge 无需 `playwright install`**:只有用 uv 环境内置 Chromium 时才需要 `uv run playwright install chromium`。
|
||||||
- **`playwright install` 下载浏览器慢**:`$env:PLAYWRIGHT_DOWNLOAD_HOST = "https://npmmirror.com/mirrors/playwright"` 后重试。
|
- **`playwright install` 下载浏览器慢**:`$env:PLAYWRIGHT_DOWNLOAD_HOST = "https://npmmirror.com/mirrors/playwright"` 后重试。
|
||||||
|
|
||||||
|
## 无 uv 的最终兜底
|
||||||
|
|
||||||
|
uv 完全装不上时,用标准库 venv 手动搭(仅应急,失去 lock 可复现性):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install pandas openpyxl playwright requests
|
||||||
|
# 此后所有命令用 .venv\Scripts\python.exe 替代 uv run python
|
||||||
|
```
|
||||||
|
|
||||||
## 与全局环境隔离
|
## 与全局环境隔离
|
||||||
|
|
||||||
- 本项目所有命令都经 `uv run`,不会污染全局 site-packages。
|
- 本项目所有命令都经 `uv run`,不会污染全局 site-packages。
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ description: "Download YouTube Studio analytics CSV export (zip) via a Playwrigh
|
|||||||
|
|
||||||
## 脚本与依赖
|
## 脚本与依赖
|
||||||
|
|
||||||
- 脚本:`scripts/youtube_export_download.py`(本技能目录下;若缺失,用 SearchCodebase 按 `youtube_export_download.py` 定位)
|
- 脚本:`scripts/youtube_export_download.py`(本技能目录下;若缺失,用代码库全局搜索按 `youtube_export_download.py` 定位)
|
||||||
- 依赖:playwright 已在根 `pyproject.toml` 统一声明,环境准备见 `uv-env-setup` 技能(复用系统 Chrome/Edge 时无需 `playwright install chromium`)
|
- 依赖与环境:见 `uv-env-setup` 技能——项目环境走 `uv sync`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境(复用系统 Chrome/Edge 时无需 `playwright install chromium`)。
|
||||||
- 登录态必须已存在:脚本不登录,只复用已登录会话。
|
- 登录态必须已存在:脚本不登录,只复用已登录会话。
|
||||||
|
|
||||||
## 步骤 1:确认登录态来源(二选一)
|
## 步骤 1:确认登录态来源(二选一)
|
||||||
@@ -32,30 +32,43 @@ description: "Download YouTube Studio analytics CSV export (zip) via a Playwrigh
|
|||||||
|
|
||||||
## 步骤 2:运行脚本
|
## 步骤 2:运行脚本
|
||||||
|
|
||||||
|
路径写法用正斜杠 + 引号,PowerShell / Git Bash / cmd 通用(`C:/Users/<you>/...` 即 `$env:LOCALAPPDATA` 指向的位置)。
|
||||||
|
|
||||||
方式 A:
|
方式 A:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run python scripts\youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data"
|
uv run python scripts/youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:/Users/<you>/AppData/Local/Google/Chrome/User Data"
|
||||||
```
|
```
|
||||||
|
|
||||||
方式 B(先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222` 打开已登录浏览器):
|
方式 B(先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222` 打开已登录浏览器):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run python scripts\youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
|
uv run python scripts/youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
|
||||||
```
|
```
|
||||||
|
|
||||||
`--channel` 只允许 `chrome` / `msedge`,且必须与 `--user-data-dir` 指向的浏览器**同一品牌**。
|
- 脱离项目环境时省略 `python <脚本>` 前缀,直接 `uv run "<技能目录>/scripts/youtube_export_download.py" ...`(单脚本模式)。
|
||||||
|
- 默认下载到 `D:\Downloads`;运行在沙箱/受限环境的 agent(如部分 IDE 内置终端、云环境)看不到该盘符,**必须传 `--download-dir` 指向工作区内已存在的目录**。
|
||||||
|
- 脚本会自动重试一次:未捕获到有效导出响应时再点一遍导出按钮。
|
||||||
|
- `--channel` 只允许 `chrome` / `msedge`,且必须与 `--user-data-dir` 指向的浏览器**同一品牌**。
|
||||||
|
|
||||||
**完成判据**:终端打印 `已保存: <完整路径> (<字节数>)`,退出码 0。
|
**完成判据**:终端打印 `已保存: <完整路径> (<字节数>)`,退出码 0。
|
||||||
|
|
||||||
## 步骤 3:校验产物
|
## 步骤 3:校验产物
|
||||||
|
|
||||||
|
列出下载目录最新的 zip(按所用 shell 选一条):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-ChildItem -Path "D:\Downloads" -Filter "*.zip" | Sort-Object LastWriteTime -Descending | Select-Object -First 3 Name, Length, LastWriteTime
|
Get-ChildItem -Path "<download-dir>" -Filter "*.zip" | Sort-Object LastWriteTime -Descending | Select-Object -First 3 Name, Length, LastWriteTime
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成判据**:存在最新 `<维度标签> <起始日>_<结束日> <账号名>.zip`(如 `内容 2026-07-23_2026-08-20 WL Media.zip`);同名重复时后缀为 ` (1)`、` (2)`。
|
```bash
|
||||||
|
ls -lt "<download-dir>"/*.zip | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**完成判据**:存在最新 `<维度标签> <起始日>_<结束日> <账号名>.zip`(如 `内容 2026-07-23_2026-08-20 WL Media.zip`);同名重复时后缀为 ` (1)`、` (2)`。脚本落盘前已做 zip 完整性校验,能写盘即可解压。
|
||||||
|
|
||||||
## 遇到问题
|
## 遇到问题
|
||||||
|
|
||||||
查 `references/troubleshooting.md`(未登录跳转、目录被占用、CDP 连不上、channel 不匹配、未捕获响应、文件名回退等)。
|
查 `references/troubleshooting.md`(未登录跳转、目录被占用、CDP 连不上、channel 不匹配、未捕获响应、文件名回退等)。
|
||||||
|
|
||||||
|
> **批量场景**:多份 URL 循环下载时,由调用方外层逐条调用本脚本并复用同一登录态,脚本保持单 URL 语义;每条失败可独立重跑。
|
||||||
@@ -14,12 +14,16 @@
|
|||||||
|
|
||||||
## 运行过程
|
## 运行过程
|
||||||
|
|
||||||
- **找不到「导出当前视图」按钮 / 点击超时**:URL 不是 explore 页,或页面尚未加载完。务必用用户提供的 explore URL,别用 overview URL。
|
- **找不到「导出当前视图」按钮 / 点击超时**:
|
||||||
|
- URL 不是 explore 页,或页面尚未加载完。务必用用户提供的 explore URL,别用 overview URL。
|
||||||
|
- 当前界面该入口是右上角**下载图标按钮**(只有 `aria-label`、无可见文本),脚本已按 label 定位并以文本定位兜底;若仍超时,确认页面语言为中文或改在英文界面重试(label 为 "Export current view")。
|
||||||
|
- 脚本未捕获到有效响应时会自动重试一次触发;连续失败时手动确认页面能正常导出。
|
||||||
- **没打印「已保存」(未捕获 csv_export 响应)**:导出未被触发,或登录态已失效。重试步骤 2,必要时重新登录后重跑。
|
- **没打印「已保存」(未捕获 csv_export 响应)**:导出未被触发,或登录态已失效。重试步骤 2,必要时重新登录后重跑。
|
||||||
|
- **解出的 zip 报损坏 / `Incorrect padding`**:`zippedData` 是 URL-safe 无填充 base64,脚本已按此解码并做完整性校验后落盘;仍报错说明响应内容异常,检查是否被代理/网关改写。
|
||||||
- **文件名变成 `export.zip` 而非标准命名**:请求体解析失败触发兜底,内容仍完整;检查日期范围与账号名是否正常。
|
- **文件名变成 `export.zip` 而非标准命名**:请求体解析失败触发兜底,内容仍完整;检查日期范围与账号名是否正常。
|
||||||
- **中文/特殊字符文件名**:已把 Windows 非法字符 `\/:*?"<>|` 自动替换为 `_`,不会因文件名失败。
|
- **中文/特殊字符文件名**:已把 Windows 非法字符 `\/:*?"<>|` 自动替换为 `_`,不会因文件名失败。
|
||||||
|
|
||||||
## 产物与去重
|
## 产物与去重
|
||||||
|
|
||||||
- **下载目录不存在导致写盘失败**:脚本不自动建目录。先建 `D:\Downloads`,或用 `--download-dir` 指定已存在目录。
|
- **下载目录不存在导致写盘失败**:脚本启动时会自动创建下载目录(`os.makedirs(..., exist_ok=True)`)。沙箱/受限环境看不到 `D:\Downloads` 时,用 `--download-dir` 指向工作区内目录即可。
|
||||||
- **重名文件**:自动按 `名称 (1).zip`、`名称 (2).zip` 递增;这是脚本自己的 dedup_path 逻辑,不依赖浏览器。
|
- **重名文件**:自动按 `名称 (1).zip`、`名称 (2).zip` 递增;这是脚本自己的 dedup_path 逻辑,不依赖浏览器。
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "playwright>=1.40",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
r"""
|
r"""
|
||||||
YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载脚本
|
YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载脚本
|
||||||
(拦截响应 + 解码 zip + 自动保存到下载目录 + 重名去重)。
|
(拦截响应 + 解码 zip + 完整性校验 + 自动保存到下载目录 + 重名去重)。
|
||||||
|
|
||||||
机制(已实证):前端点击导出后向后端
|
机制(已实证):前端点击导出后向后端
|
||||||
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
|
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
|
||||||
后端把打好的 zip 以 base64 内联在响应 `zippedData` 字段里(开头 `UEsDBBQ` 即 ZIP 文件头 `PK`)。
|
后端把打好的 zip 以 base64 内联在响应 `zippedData` 字段里(开头 `UEsDBBQ` 即 ZIP 文件头 `PK`)。
|
||||||
本脚本拦截该响应,base64 解码后按 `<维度标签> <起始日>_<结束日> <账号名>.zip` 写盘,
|
注意两个字段细节(均为实证结论):
|
||||||
重名自动加 ` (n)` 后缀,n 从 1 起。
|
- `zippedData` 是 **URL-safe base64**(-/_ 代替 +//,且省略 = 填充);
|
||||||
|
- 当前界面导出入口是右上角**下载图标按钮**:只有 `aria-label="导出当前视图"`,没有可见文本。
|
||||||
|
本脚本拦截该响应,base64 解码并校验 zip 完整性后按 `<维度标签> <起始日>_<结束日> <账号名>.zip`
|
||||||
|
写盘,重名自动加 ` (n)` 后缀,n 从 1 起;未捕获到有效响应时自动重试一次触发导出。
|
||||||
|
|
||||||
登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一:
|
登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一:
|
||||||
- --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器)
|
- --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器)
|
||||||
@@ -15,19 +24,25 @@ YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)
|
|||||||
|
|
||||||
用法:
|
用法:
|
||||||
python youtube_export_download.py --selftest
|
python youtube_export_download.py --selftest
|
||||||
python youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:\Users\<you>\AppData\Local\Google\Chrome\User Data"
|
python youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:/Users/<you>/AppData/Local/Google/Chrome/User Data"
|
||||||
python youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
|
python youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
|
||||||
DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖
|
DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖
|
||||||
|
|
||||||
|
EXPORT_RESPONSE_TIMEOUT = 30 # 每次触发导出后等待 csv_export 响应的秒数
|
||||||
|
MAX_EXPORT_ATTEMPTS = 2 # 未捕获到有效响应时的最大触发次数(1 次重试)
|
||||||
|
|
||||||
# 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。
|
# 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。
|
||||||
DIMENSION_LABEL = {
|
DIMENSION_LABEL = {
|
||||||
"VIDEO": "内容",
|
"VIDEO": "内容",
|
||||||
@@ -80,11 +95,43 @@ def build_export_filename(export_query, account_name, dimension_label=None):
|
|||||||
|
|
||||||
|
|
||||||
def decode_zipped_data(payload):
|
def decode_zipped_data(payload):
|
||||||
"""把 csv_export 响应 payload 里的 zippedData 解码为 zip 字节流。"""
|
"""把 csv_export 响应 payload 里的 zippedData 解码为 zip 字节流。
|
||||||
|
|
||||||
|
该字段是 URL-safe base64(-/_ 代替 +//,且省略 = 填充):标准 b64decode 遇
|
||||||
|
-/_ 或非 4 对齐长度会抛 Incorrect padding,或静默解出损坏字节流。先补齐填充,
|
||||||
|
再用 altchars 兼容 URL-safe 与标准两种字符表;解码后校验 PK 文件头。
|
||||||
|
"""
|
||||||
zipped = payload.get("zippedData")
|
zipped = payload.get("zippedData")
|
||||||
if not zipped:
|
if not zipped:
|
||||||
raise ValueError("响应中缺少 zippedData 字段")
|
raise ValueError("响应中缺少 zippedData 字段")
|
||||||
return base64.b64decode(zipped)
|
z = str(zipped).strip()
|
||||||
|
data = base64.b64decode(z + "=" * (-len(z) % 4), altchars=b"-_")
|
||||||
|
if not data.startswith(b"PK"):
|
||||||
|
raise ValueError(
|
||||||
|
"zippedData 解码结果不是 zip 字节流(缺 PK 文件头),"
|
||||||
|
"响应可能被网关改写或导出接口已变动")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def verify_zip(data):
|
||||||
|
"""校验内存 zip 完整性:结构可读、成员 CRC 全过;否则抛 ValueError。"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||||
|
bad = zf.testzip()
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
raise ValueError(f"zip 结构损坏: {e}") from e
|
||||||
|
if bad is not None:
|
||||||
|
raise ValueError(f"zip 成员 CRC 校验失败: {bad}")
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_export(page):
|
||||||
|
"""点「导出当前视图」→「逗号分隔值 (.csv)」。
|
||||||
|
|
||||||
|
导出入口是右上角的下载图标按钮:只有 aria-label、无可见文本,
|
||||||
|
优先按 label 定位(get_by_text 兜底旧版有可见文本的界面)。
|
||||||
|
"""
|
||||||
|
page.get_by_label("导出当前视图").or_(page.get_by_text("导出当前视图")).first.click()
|
||||||
|
page.get_by_text("逗号分隔值 (.csv)").click()
|
||||||
|
|
||||||
|
|
||||||
def intercept_and_save(page, account_name, download_dir):
|
def intercept_and_save(page, account_name, download_dir):
|
||||||
@@ -99,6 +146,7 @@ def intercept_and_save(page, account_name, download_dir):
|
|||||||
try:
|
try:
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
data = decode_zipped_data(payload)
|
data = decode_zipped_data(payload)
|
||||||
|
verify_zip(data)
|
||||||
|
|
||||||
filename = None
|
filename = None
|
||||||
try:
|
try:
|
||||||
@@ -132,6 +180,7 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
download_dir = download_dir or DOWNLOAD_DIR
|
download_dir = download_dir or DOWNLOAD_DIR
|
||||||
|
os.makedirs(download_dir, exist_ok=True)
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = None
|
browser = None
|
||||||
@@ -175,10 +224,17 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
|
|
||||||
saved = intercept_and_save(page, account_name, download_dir)
|
saved = intercept_and_save(page, account_name, download_dir)
|
||||||
|
|
||||||
# 触发导出:点「导出当前视图」→「逗号分隔值 (.csv)」
|
# 触发导出并等待响应;未捕获到有效 zip(未触发/响应无效)自动重试一次
|
||||||
page.get_by_text("导出当前视图").click()
|
for attempt in range(1, MAX_EXPORT_ATTEMPTS + 1):
|
||||||
page.get_by_text("逗号分隔值 (.csv)").click()
|
trigger_export(page)
|
||||||
page.wait_for_timeout(3000)
|
deadline = time.time() + EXPORT_RESPONSE_TIMEOUT
|
||||||
|
while not saved and time.time() < deadline:
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
if saved:
|
||||||
|
break
|
||||||
|
if attempt < MAX_EXPORT_ATTEMPTS:
|
||||||
|
print(f"[重试] 第 {attempt} 次未捕获到有效导出响应,自动重试……",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
if not saved:
|
if not saved:
|
||||||
print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。")
|
print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。")
|
||||||
@@ -222,7 +278,25 @@ def selftest():
|
|||||||
name = build_export_filename(export_query, "WL Media")
|
name = build_export_filename(export_query, "WL Media")
|
||||||
assert name == "内容 2026-07-23_2026-08-20 WL Media.zip", name
|
assert name == "内容 2026-07-23_2026-08-20 WL Media.zip", name
|
||||||
|
|
||||||
print("selftest OK:去重与文件名反推逻辑全部通过")
|
# zip 解码:标准 base64 与 URL-safe 无填充变体都要解出同一字节流
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("a.csv", "x,y\n1,2")
|
||||||
|
raw = buf.getvalue()
|
||||||
|
std = base64.b64encode(raw).decode("ascii")
|
||||||
|
urlsafe_nopad = std.replace("+", "-").replace("/", "_").rstrip("=")
|
||||||
|
assert decode_zipped_data({"zippedData": std}) == raw
|
||||||
|
assert decode_zipped_data({"zippedData": urlsafe_nopad}) == raw
|
||||||
|
verify_zip(raw)
|
||||||
|
|
||||||
|
# 非 zip 字节流要报 PK 文件头错误,而不是静默落盘损坏文件
|
||||||
|
try:
|
||||||
|
decode_zipped_data({"zippedData": base64.b64encode(b"not a zip").decode()})
|
||||||
|
raise AssertionError("非 zip 数据应抛 ValueError")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "PK" in str(e), e
|
||||||
|
|
||||||
|
print("selftest OK:去重、文件名反推、zip 解码与完整性校验全部通过")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -245,7 +319,8 @@ if __name__ == "__main__":
|
|||||||
account_name=args.account_name)
|
account_name=args.account_name)
|
||||||
else:
|
else:
|
||||||
selftest()
|
selftest()
|
||||||
print("\n实际运行需先 pip install playwright,再复用登录态(二选一):\n"
|
print("\n实际运行需先准备依赖环境(uv sync 或直接 uv run,见 uv-env-setup 技能),"
|
||||||
|
"再复用登录态(二选一):\n"
|
||||||
" 方式 A: python youtube_export_download.py --url \"<explore URL>\" "
|
" 方式 A: python youtube_export_download.py --url \"<explore URL>\" "
|
||||||
"--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n"
|
"--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n"
|
||||||
" 方式 B: python youtube_export_download.py --url \"<explore URL>\" "
|
" 方式 B: python youtube_export_download.py --url \"<explore URL>\" "
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并
|
|||||||
- **建 URL 前置**:要生成 explore 报告 URL,但清单里只有群组名、没有 entity_id。
|
- **建 URL 前置**:要生成 explore 报告 URL,但清单里只有群组名、没有 entity_id。
|
||||||
- **排查失败**:回放遇到 401 / 查不到 / 大小写差异,需要定位修复。
|
- **排查失败**:回放遇到 401 / 查不到 / 大小写差异,需要定位修复。
|
||||||
|
|
||||||
|
> **流程位置**:本技能是 `yt-studio-url-builder` 的前置——url-builder 只认 `entity_id`(groupId);清单里只有群组名时必须先跑本技能把 ID 填进清单,再交给 url-builder。
|
||||||
|
|
||||||
## 领域词汇
|
## 领域词汇
|
||||||
|
|
||||||
- **套件(Bundle)**:从页面捕获的一次 `search_groups` 请求 = 完整请求头(`Authorization: SAPISIDHASH...`、`Cookie`、`X-YouTube-Delegation-Context` 等)+ 请求体模板(含 `context.user.delegationContext`)。一个内容所有者一份。手写必 401,唯一可靠来源。
|
- **套件(Bundle)**:从页面捕获的一次 `search_groups` 请求 = 完整请求头(`Authorization: SAPISIDHASH...`、`Cookie`、`X-YouTube-Delegation-Context` 等)+ 请求体模板(含 `context.user.delegationContext`)。一个内容所有者一份。手写必 401,唯一可靠来源。
|
||||||
@@ -24,8 +26,8 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并
|
|||||||
|
|
||||||
## 脚本与依赖
|
## 脚本与依赖
|
||||||
|
|
||||||
- 脚本:`scripts/lookup_groups.py`(本技能目录下;若缺失,用 SearchCodebase 按 `lookup_groups.py` 定位)。
|
- 脚本:`scripts/lookup_groups.py`(本技能目录下;若缺失,用代码库全局搜索按 `lookup_groups.py` 定位)。
|
||||||
- 依赖:playwright、requests、openpyxl 已统一在根 `pyproject.toml` 声明(`uv sync` 自动装),环境准备见 `uv-env-setup` 技能。
|
- 依赖与环境:见 `uv-env-setup` 技能——项目环境走 `uv sync`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境。
|
||||||
- 登录态必须已存在:脚本不登录,只复用已登录 YouTube Studio 会话,否则跳 Google 登录页。
|
- 登录态必须已存在:脚本不登录,只复用已登录 YouTube Studio 会话,否则跳 Google 登录页。
|
||||||
|
|
||||||
## 选择运行分支
|
## 选择运行分支
|
||||||
@@ -44,7 +46,7 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并
|
|||||||
### 步骤 0:环境与自测(分支 D)
|
### 步骤 0:环境与自测(分支 D)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run python scripts\lookup_groups.py --selftest
|
uv run python scripts/lookup_groups.py --selftest
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成判据**:打印 `selftest OK`,退出码 0(无需浏览器/网络)。
|
**完成判据**:打印 `selftest OK`,退出码 0(无需浏览器/网络)。
|
||||||
@@ -54,7 +56,8 @@ uv run python scripts\lookup_groups.py --selftest
|
|||||||
复用已登录 YouTube Studio 的浏览器会话,否则跳 Google 登录页。与 `youtube-studio-csv-download` 同一套约定:
|
复用已登录 YouTube Studio 的浏览器会话,否则跳 Google 登录页。与 `youtube-studio-csv-download` 同一套约定:
|
||||||
|
|
||||||
- 方式 A(能关浏览器):`--user-data-dir <目录> --channel chrome|msedge`,二者必须同品牌。
|
- 方式 A(能关浏览器):`--user-data-dir <目录> --channel chrome|msedge`,二者必须同品牌。
|
||||||
- 方式 B(浏览器不能关):先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`,再加 `--connect http://localhost:9222`。
|
- 方式 B(浏览器不能关):先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`,再加 `--connect http://localhost:9222`。脚本附加前会先探测端口;报 `CDP 端口不可达` 时按提示排查——最常见是**旧 Chrome/Edge 残留进程吞掉了调试端口参数**,需彻底退出后重启或改用独立 user-data-dir 启动。
|
||||||
|
- 注意:cookie 存在不等于会话有效。profile 目录里看得到 SAPISID/SID 等 cookie 也可能仍跳登录页,此时由用户在浏览器手动登录一次再重跑。
|
||||||
|
|
||||||
**完成判据**:得到任意一种可复用的登录态(用户数据目录路径,或调试端口号)。
|
**完成判据**:得到任意一种可复用的登录态(用户数据目录路径,或调试端口号)。
|
||||||
|
|
||||||
@@ -67,19 +70,19 @@ uv run python scripts\lookup_groups.py --selftest
|
|||||||
|
|
||||||
### 步骤 3:运行脚本
|
### 步骤 3:运行脚本
|
||||||
|
|
||||||
分支 A(在线捕获 + 回放):
|
分支 A(在线捕获 + 回放;路径写法统一正斜杠 + 引号,PowerShell / Git Bash / cmd 通用):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 单所有者
|
# 单所有者
|
||||||
uv run python scripts\lookup_groups.py --url "<owner URL>" --names 名单.xlsx --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data"
|
uv run python scripts/lookup_groups.py --url "<owner URL>" --names 名单.xlsx --channel chrome --user-data-dir "C:/Users/<you>/AppData/Local/Google/Chrome/User Data"
|
||||||
# 多所有者:--url 重复;浏览器不能关就改用 --connect
|
# 多所有者:--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
|
uv run python scripts/lookup_groups.py --url "<owner1 URL>" --url "<owner2 URL>" --names 名单.csv --connect http://localhost:9222 --save-bundles bundles.json
|
||||||
```
|
```
|
||||||
|
|
||||||
分支 B(离线回放,套件已存盘,无需浏览器):
|
分支 B(离线回放,套件已存盘,无需浏览器):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv run python scripts\lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx
|
uv run python scripts/lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
分支 C(仅捕获/校验套件,暂不查询):
|
分支 C(仅捕获/校验套件,暂不查询):
|
||||||
@@ -90,8 +93,9 @@ uv run python scripts\lookup_groups.py --url "<owner URL>" --save-bundles bundle
|
|||||||
|
|
||||||
**关键行为**:
|
**关键行为**:
|
||||||
- 打开页面后先**自动触发一次群组搜索**(猜搜索框);猜不中会提示「在浏览器顶部搜索框输入任意词并回车」,只需触发一次 `search_groups` 请求即可。
|
- 打开页面后先**自动触发一次群组搜索**(猜搜索框);猜不中会提示「在浏览器顶部搜索框输入任意词并回车」,只需触发一次 `search_groups` 请求即可。
|
||||||
- 捕到请求后,脚本校验 `delegationContext.externalOwnerId` 与 URL ownerId 是否一致、鉴权头是否齐全(缺 Authorization/Cookie/delegation 是 401 根因,会在捕获时直接打警告)。
|
- 捕到请求后,脚本校验 `delegationContext.externalOwnerId` 与 URL ownerId 是否一致、鉴权头是否齐全(缺 Authorization/Cookie/delegation 是 401 根因,会在捕获时直接打警告),并**自动过滤 HTTP/2 伪头**(`:authority` 等,流入 requests 会报 InvalidHeader)。
|
||||||
- 回放用线程本地 Session,并发(默认 8 线程)逐个名字 × 逐个所有者,**首个精确命中即停**。
|
- 回放用线程本地 Session,并发(默认 8 线程)逐个名字 × 逐个所有者,**首个精确命中即停**。
|
||||||
|
- 套件是一次性凭据:鉴权头逐请求计算,回放前如间隔较久或全量 401,直接重新在线捕获更新 `bundles.json`。
|
||||||
|
|
||||||
**完成判据**:终端打印 `[输出] 精确命中 N/M -> <路径>`,退出码 0,且生成结果文件。
|
**完成判据**:终端打印 `[输出] 精确命中 N/M -> <路径>`,退出码 0,且生成结果文件。
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
- 原因:方式 A 时浏览器未关闭(用户数据目录被占用);方式 B 时调试端口没起。
|
- 原因:方式 A 时浏览器未关闭(用户数据目录被占用);方式 B 时调试端口没起。
|
||||||
- 解决:方式 A 关闭 Chrome/Edge 后重试;方式 B 先 `chrome.exe --remote-debugging-port=9222`(或 msedge)再运行。
|
- 解决:方式 A 关闭 Chrome/Edge 后重试;方式 B 先 `chrome.exe --remote-debugging-port=9222`(或 msedge)再运行。
|
||||||
|
|
||||||
|
### CDP 端口不可达(脚本前置探测)
|
||||||
|
- 现象:`[!] CDP 端口不可达: http://localhost:9222`(Playwright 连接之前就报出)。
|
||||||
|
- 原因一:浏览器根本没带 `--remote-debugging-port=9222` 启动。按提示命令重启。
|
||||||
|
- 原因二:**带参数启动了但仍不通**——有 Chrome/Edge 残留进程占着默认 profile,新实例的调试端口参数被已运行实例吞掉(Chrome 单实例复用机制)。彻底退出所有该浏览器进程(任务管理器确认)后重启;或改用独立目录启动:`chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/yts-cdp"`。
|
||||||
|
- 脚本已在附加前先探测端口并区分这两种情况,遇到即按输出指引处理,无需先跑 Playwright 再排错。
|
||||||
|
|
||||||
### channel 不匹配
|
### channel 不匹配
|
||||||
- 现象:启动后用 --user-data-dir 报告浏览器品牌不符。
|
- 现象:启动后用 --user-data-dir 报告浏览器品牌不符。
|
||||||
- 原因:`--channel` 与用户数据目录指向的浏览器不是同一品牌(chrome / msedge)。
|
- 原因:`--channel` 与用户数据目录指向的浏览器不是同一品牌(chrome / msedge)。
|
||||||
@@ -43,6 +49,16 @@
|
|||||||
|
|
||||||
## 回放查询
|
## 回放查询
|
||||||
|
|
||||||
|
### 回放报 InvalidHeader(Invalid leading whitespace / 伪头)
|
||||||
|
- 现象:`requests` 抛 `InvalidHeader: Invalid leading whitespace, expected field value...`。
|
||||||
|
- 原因:套件 headers 里残留 HTTP/2 伪头(`:authority`、`:method`、`:path`、`:scheme`),不能当普通头发送。
|
||||||
|
- 解决:脚本已双保险——捕获时自动过滤(输出 `已过滤 HTTP/2 伪头 N 个`),回放时再滤一次兜住旧 bundles.json;新版本无需人工处理。
|
||||||
|
|
||||||
|
### 跳转到 Google 登录页但 profile 里看得到 cookie
|
||||||
|
- 现象:Cookies 文件里有 SAPISID/SID/__Secure-3PSID 等,页面仍停在 accounts.google。
|
||||||
|
- 原因:cookie 存在不等于会话有效,Google 已在服务端让该会话过期。
|
||||||
|
- 解决:由用户在该浏览器手动重新登录一次,之后继续复用该会话;不要试图靠改 cookie 文件修复。
|
||||||
|
|
||||||
### 回放全量 401
|
### 回放全量 401
|
||||||
- 现象:结果备注大量 `HTTP401(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)`。
|
- 现象:结果备注大量 `HTTP401(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)`。
|
||||||
- 原因:套件过期(鉴权头是逐请求计算的),或头不全。
|
- 原因:套件过期(鉴权头是逐请求计算的),或头不全。
|
||||||
@@ -93,4 +109,4 @@
|
|||||||
|
|
||||||
### 缺依赖报 ModuleNotFoundError
|
### 缺依赖报 ModuleNotFoundError
|
||||||
- 现象:`ModuleNotFoundError: No module named 'requests'` 或 `'playwright'` 或 `'openpyxl'`。
|
- 现象:`ModuleNotFoundError: No module named 'requests'` 或 `'playwright'` 或 `'openpyxl'`。
|
||||||
- 解决:项目根 `uv sync` 后统一用 `uv run` 前缀执行;或单独 `pip install requests playwright openpyxl`。
|
- 解决:项目根 `uv sync` 后统一用 `uv run` 前缀执行;脱离项目时直接 `uv run <脚本>`(脚本头部 PEP 723 声明会自动装依赖,见 `uv-env-setup` 技能);或单独 `pip install requests playwright openpyxl`。
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "playwright>=1.40",
|
||||||
|
# "requests>=2.31",
|
||||||
|
# "openpyxl>=3.1",
|
||||||
|
# "pandas>=2.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
r"""
|
r"""
|
||||||
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
||||||
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
||||||
@@ -88,6 +97,10 @@ HTTP_HINTS = {
|
|||||||
429: "(限流:调低 --max-workers 或稍后重试)",
|
429: "(限流:调低 --max-workers 或稍后重试)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# HTTP/2 伪头(:authority/:method/:path/:scheme):requests 作为普通头发送会抛
|
||||||
|
# InvalidHeader。捕获时即过滤,不让伪头流入 bundles.json。
|
||||||
|
PSEUDO_HEADER_RE = re.compile(r"^:")
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 30
|
REQUEST_TIMEOUT = 30
|
||||||
MANUAL_WAIT_SECONDS = 180
|
MANUAL_WAIT_SECONDS = 180
|
||||||
|
|
||||||
@@ -256,8 +269,10 @@ def search(bundle: dict, name: str, session=None) -> dict:
|
|||||||
|
|
||||||
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
||||||
"""
|
"""
|
||||||
|
# 双保险:捕获时已滤伪头,这里再滤一次兜住手工维护的旧 bundles.json
|
||||||
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
||||||
if str(k).lower() not in STRIP_HEADERS}
|
if str(k).lower() not in STRIP_HEADERS
|
||||||
|
and not PSEUDO_HEADER_RE.match(str(k))}
|
||||||
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
||||||
url = bundle.get("url") or ENDPOINT
|
url = bundle.get("url") or ENDPOINT
|
||||||
try:
|
try:
|
||||||
@@ -386,6 +401,26 @@ OWNER_DISPLAY_SELECTORS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_cdp_reachable(cdp_url):
|
||||||
|
"""CDP 附加前先探测调试端口,失败时给出可执行的启动指引(区别于端口通但被吞)。"""
|
||||||
|
base = (cdp_url or "").rstrip("/")
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
with urllib.request.urlopen(f"{base}/json/version", timeout=3):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise SystemExit(
|
||||||
|
f"[!] CDP 端口不可达: {base}\n"
|
||||||
|
" 先确认浏览器已带调试端口参数启动:\n"
|
||||||
|
' chrome.exe --remote-debugging-port=9222 '
|
||||||
|
'(msedge.exe 同理,或 chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/yts-cdp")\n'
|
||||||
|
" 若已带参数仍连不上:有 Chrome/Edge 残留进程占用了默认 profile,"
|
||||||
|
"新实例的调试端口参数会被吞掉。请先在任务管理器彻底退出所有 Chrome/Edge 进程,"
|
||||||
|
'再重启;或改用独立 user-data-dir 启动(如 --user-data-dir="C:/yts-cdp")。')
|
||||||
|
|
||||||
|
|
||||||
def _launch_page(p, user_data_dir, channel, cdp_url):
|
def _launch_page(p, user_data_dir, channel, cdp_url):
|
||||||
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
||||||
if cdp_url:
|
if cdp_url:
|
||||||
@@ -496,6 +531,12 @@ def _capture_bundle(page, url, wait_seconds, owner_display=""):
|
|||||||
headers = dict(req.all_headers())
|
headers = dict(req.all_headers())
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
headers = dict(req.headers)
|
headers = dict(req.headers)
|
||||||
|
# 过滤 HTTP/2 伪头,防止回放时 requests 抛 InvalidHeader
|
||||||
|
pseudo = sorted(k for k in headers if PSEUDO_HEADER_RE.match(k))
|
||||||
|
if pseudo:
|
||||||
|
print(f"[捕获] 已过滤 HTTP/2 伪头 {len(pseudo)} 个: {', '.join(pseudo)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
headers = {k: v for k, v in headers.items() if not PSEUDO_HEADER_RE.match(k)}
|
||||||
try:
|
try:
|
||||||
body = json.loads(req.post_data or "{}")
|
body = json.loads(req.post_data or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -535,6 +576,8 @@ def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
|
|
||||||
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
||||||
print(f"[捕获] 浏览器会话:{mode}")
|
print(f"[捕获] 浏览器会话:{mode}")
|
||||||
|
if cdp_url:
|
||||||
|
_assert_cdp_reachable(cdp_url)
|
||||||
|
|
||||||
bundles = []
|
bundles = []
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
@@ -545,7 +588,8 @@ def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"[!] 启动/连接浏览器失败: {e}\n"
|
f"[!] 启动/连接浏览器失败: {e}\n"
|
||||||
" 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n"
|
" 方式 A 需先关闭对应浏览器(有残留进程会报目录被占用);"
|
||||||
|
"方式 B 先以调试端口启动:\n"
|
||||||
" chrome.exe --remote-debugging-port=9222")
|
" chrome.exe --remote-debugging-port=9222")
|
||||||
try:
|
try:
|
||||||
for i, url in enumerate(urls, 1):
|
for i, url in enumerate(urls, 1):
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内
|
|||||||
- **制作输入清单**:用户有零散需求(所有者ID、实体、日期范围、国家),需先整理成脚本可读的输入文件。
|
- **制作输入清单**:用户有零散需求(所有者ID、实体、日期范围、国家),需先整理成脚本可读的输入文件。
|
||||||
- **排查问题**:运行后出现失败行、空输出、解析错误,需要定位并修复。
|
- **排查问题**:运行后出现失败行、空输出、解析错误,需要定位并修复。
|
||||||
|
|
||||||
|
> **前置依赖**:URL 的 `entity_id` 必须是现成 ID。清单里只有**群组名**时,先走 `yt-studio-groupid-lookup` 技能解析出 groupId 并回填清单,再执行本技能——群组名无法直接拼 URL,这是流程顺序要求而非脚本缺陷。
|
||||||
|
|
||||||
## 领域词汇
|
## 领域词汇
|
||||||
|
|
||||||
- **所有者(Content Owner)**:拥有内容管理器的账号实体,URL 中由 `o` 参数 + `/owner/<id>/` 路径标识。
|
- **所有者(Content Owner)**:拥有内容管理器的账号实体,URL 中由 `o` 参数 + `/owner/<id>/` 路径标识。
|
||||||
@@ -22,13 +24,15 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内
|
|||||||
- **数据周期(Period)**:`yyyy.mm.dd-yyyy.mm.dd` 或 `yyyy.m.d-yyyy.m.d` 的日期区间,起始日与结束日均包含。
|
- **数据周期(Period)**:`yyyy.mm.dd-yyyy.mm.dd` 或 `yyyy.m.d-yyyy.m.d` 的日期区间,起始日与结束日均包含。
|
||||||
- **日界线(Day Boundary)**:周期中日期对应的 Unix 毫秒,采用「锚点 2026-06-15 = 1781506800000 + 整日偏移」计算,不做时区换算。
|
- **日界线(Day Boundary)**:周期中日期对应的 Unix 毫秒,采用「锚点 2026-06-15 = 1781506800000 + 整日偏移」计算,不做时区换算。
|
||||||
- **国家筛选(Country Filter)**:`ur_dimensions=COUNTRY` + `ur_values`;多国以 `'`(`%27`)包裹、`|`(`%7C`)连接;国家用 ISO 3166-1 alpha-2 代码。
|
- **国家筛选(Country Filter)**:`ur_dimensions=COUNTRY` + `ur_values`;多国以 `'`(`%27`)包裹、`|`(`%7C`)连接;国家用 ISO 3166-1 alpha-2 代码。
|
||||||
|
- **指标(Metric)**:报表主指标,URL 中的 `metric` 参数;每行可选(`指标` 列),中文名/英文名写在同目录 `metrics.json` 里映射到内部代码,留空走 `CONFIG` 默认。选中后排序字段 `o_column` 一并跟随。
|
||||||
|
- **维度(Dimension)**:报表细分维度,URL 中的 `dimension` 参数;每行可选(`维度` 列),中文名写在同目录 `dimensions.json` 里映射到内部代码,留空走 `CONFIG` 默认。
|
||||||
|
|
||||||
## 工作流
|
## 工作流
|
||||||
|
|
||||||
### 1. 定位脚本与依赖
|
### 1. 定位脚本与依赖
|
||||||
|
|
||||||
- 脚本默认位于 `scripts/build_studio_urls.py`;若缺失,用 SearchCodebase 按 `build_studio_urls.py` 定位。
|
- 脚本默认位于 `scripts/build_studio_urls.py`;若缺失,用代码库全局搜索按 `build_studio_urls.py` 定位。
|
||||||
- 运行环境由 `uv-env-setup` 技能统一管理:依赖(pandas、openpyxl)在根 `pyproject.toml` 声明,`uv run` 自动同步。首次运行前先按该技能准备环境。
|
- 运行环境由 `uv-env-setup` 技能统一管理:项目环境走 `uv sync` + `uv run`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境。首次运行前先按该技能选好分支。
|
||||||
- 中文国家名 → ISO 代码的映射在 `scripts/countries.json`,可自行扩充。
|
- 中文国家名 → ISO 代码的映射在 `scripts/countries.json`,可自行扩充。
|
||||||
|
|
||||||
完成标准:脚本路径已确定,且第 4 步的运行命令可直接执行。
|
完成标准:脚本路径已确定,且第 4 步的运行命令可直接执行。
|
||||||
@@ -44,6 +48,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内
|
|||||||
| 实体ID | 视类型 | 群组/频道/节目必填;所有者场景可留空(回退用所有者ID) |
|
| 实体ID | 视类型 | 群组/频道/节目必填;所有者场景可留空(回退用所有者ID) |
|
||||||
| 数据周期 | 是 | 见领域词汇「数据周期」 |
|
| 数据周期 | 是 | 见领域词汇「数据周期」 |
|
||||||
| 国家 | 可空 | 一个或多个,中文名或两位 ISO 代码 |
|
| 国家 | 可空 | 一个或多个,中文名或两位 ISO 代码 |
|
||||||
|
| 指标 | 可默认 | 主指标(`metric` 参数);中文名/英文名或已知指标代码;留空走 CONFIG 默认 |
|
||||||
|
| 维度 | 可默认 | 细分维度(`dimension` 参数);中文名或已知维度代码;留空走 CONFIG 默认 |
|
||||||
|
|
||||||
完成标准:对每一条需求,上表要素已确定,或明确「留空走默认」。
|
完成标准:对每一条需求,上表要素已确定,或明确「留空走默认」。
|
||||||
|
|
||||||
@@ -56,8 +62,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内
|
|||||||
### 4. 运行脚本
|
### 4. 运行脚本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 项目根目录下
|
# 项目根目录下;路径写法用正斜杠,PowerShell / Git Bash / cmd 通用
|
||||||
uv run python scripts\build_studio_urls.py -i <输入文件> [-o <输出csv>]
|
uv run python scripts/build_studio_urls.py -i <输入文件> [-o <输出csv>]
|
||||||
```
|
```
|
||||||
|
|
||||||
- 输入支持 `.csv`(UTF-8 或 GBK,自动尝试)与 `.xlsx` / `.xls`。
|
- 输入支持 `.csv`(UTF-8 或 GBK,自动尝试)与 `.xlsx` / `.xls`。
|
||||||
@@ -68,17 +74,22 @@ uv run python scripts\build_studio_urls.py -i <输入文件> [-o <输出csv>]
|
|||||||
|
|
||||||
### 5. 校验输出并处理失败行
|
### 5. 校验输出并处理失败行
|
||||||
|
|
||||||
- 检查输出列:所有者名称、所有者ID、实体类型、实体名称、实体ID、数据周期、国家、国家代码、开始时间戳、结束时间戳、URL。
|
- 检查输出列:所有者名称、所有者ID、实体类型、实体名称、实体ID、数据周期、国家、国家代码、指标、指标代码、维度、维度代码、开始时间戳、结束时间戳、URL。
|
||||||
- 抽查 URL 是否包含正确的 `entity_type` / `entity_id` / `time_period` / `ur_values`(国家筛选)。
|
- 抽查 URL 是否包含正确的 `entity_type` / `entity_id` / `time_period` / `ur_values`(国家筛选)、`metric`(指标)、`dimension`(维度)。
|
||||||
- 有失败行时脚本以退出码 2 结束,并在 stderr 逐行打印「第N行 …:原因」;逐条按 [references/troubleshooting.md](references/troubleshooting.md) 修复后重跑。
|
- 有失败行时脚本以退出码 2 结束,并在 stderr 逐行打印「第N行 …:原因」;逐条按 [references/troubleshooting.md](references/troubleshooting.md) 修复后重跑。
|
||||||
|
|
||||||
完成标准:所有需求行均生成 URL,或失败行已定位原因并修复。
|
完成标准:所有需求行均生成 URL,或失败行已定位原因并修复。
|
||||||
|
|
||||||
## 固定参数(CONFIG)
|
## 固定参数(CONFIG)
|
||||||
|
|
||||||
metric / granularity / dimension / t_metrics / o_column / o_direction / explore_type / comparison_type 等固定参数集中在脚本顶部 `CONFIG` 一处维护。**修改固定参数只需改 `CONFIG`(单点维护)**,不要逐行携带到需求清单中。
|
granularity / dimension / t_metrics / o_direction / explore_type / comparison_type 等固定参数集中在脚本顶部 `CONFIG` 一处维护。**修改这些固定参数只需改 `CONFIG`(单点维护)**,不要逐行携带到需求清单中。
|
||||||
|
|
||||||
|
`metric`(主指标)**可按行选择**:在需求清单加一列 `指标`(别名 `主指标` / `数据指标` / `metric`),填中文名/英文名(`metrics.json` 映射)或已知指标代码均可;留空则回退 `CONFIG["metric"]`,选中时 `o_column`(排序字段)一并跟随。
|
||||||
|
|
||||||
|
`dimension`(细分维度)**同样可按行选择**:在需求清单加一列 `维度`(别名 `细分维度` / `dimension`),填中文名(`dimensions.json` 映射)或已知维度代码均可;留空则回退 `CONFIG["dimension"]`。
|
||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [需求输入制作指南](references/input-guide.md):列名别名、日期/国家/实体类型写法、完整示例、制作核对清单。
|
- [需求输入制作指南](references/input-guide.md):列名别名、日期/国家/实体类型写法、完整示例、制作核对清单。
|
||||||
- [常见问题排查](references/troubleshooting.md):按 现象 → 原因 → 解决 逐条排查。
|
- [常见问题排查](references/troubleshooting.md):按 现象 → 原因 → 解决 逐条排查。
|
||||||
|
- [映射文件维护指南](references/maintenance-guide.md):`countries.json` / `metrics.json` / `dimensions.json` 三个映射文件的来源、结构与新增/更新条目方法。
|
||||||
|
|||||||
@@ -21,10 +21,12 @@
|
|||||||
| entity_type 实体类型 | 否 | 实体类型 / 类型 / entity_type / type | 群组 |
|
| entity_type 实体类型 | 否 | 实体类型 / 类型 / entity_type / type | 群组 |
|
||||||
| period 数据周期 | **是** | 数据周期 / 周期 / period / time_period / 日期范围 | 2026.08.01-2026.08.31 |
|
| period 数据周期 | **是** | 数据周期 / 周期 / period / time_period / 日期范围 | 2026.08.01-2026.08.31 |
|
||||||
| countries 国家 | 否 | 国家 / 国家/地区 / countries / country / 筛选国家 / 地区 | 美国,日本 |
|
| countries 国家 | 否 | 国家 / 国家/地区 / countries / country / 筛选国家 / 地区 | 美国,日本 |
|
||||||
|
| metric 指标 | 否 | 指标 / 主指标 / 数据指标 / metric / 指标名 | 观看时长 |
|
||||||
|
| dimension 维度 | 否 | 维度 / 细分维度 / dimension / 细分 | 地理位置 |
|
||||||
|
|
||||||
> 注意:
|
> 注意:
|
||||||
> - 「所有者名称 / 实体名称」只进输出回显,不参与 URL 拼装,缺列不影响生成。
|
> - 「所有者名称 / 实体名称」只进输出回显,不参与 URL 拼装,缺列不影响生成。
|
||||||
> - **必要列只有两列:所有者ID、数据周期**。
|
> - **必要列只有两列:所有者ID、数据周期**。指标列留空则用脚本 `CONFIG` 默认主指标。
|
||||||
|
|
||||||
## 3. 数据周期格式
|
## 3. 数据周期格式
|
||||||
|
|
||||||
@@ -56,16 +58,16 @@
|
|||||||
|
|
||||||
## 6. 完整示例(对应 assets/需求输入示例.xlsx)
|
## 6. 完整示例(对应 assets/需求输入示例.xlsx)
|
||||||
|
|
||||||
| 所有者名称 | 所有者ID | 实体类型 | 实体名称 | 实体ID | 数据周期 | 国家 |
|
| 所有者名称 | 所有者ID | 实体类型 | 实体名称 | 实体ID | 数据周期 | 国家 | 指标 | 维度 |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-1 | NCy9C2QPQ1E | 2026.08.01-2026.08.31 | 美国 |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-1 | NCy9C2QPQ1E | 2026.08.01-2026.08.31 | 美国 | 观看时长 | (留空,走默认) |
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-2 | NCyxxxxxxxxx | 2026.8.1-2026.8.31 | 美国,日本 |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-2 | NCyxxxxxxxxx | 2026.8.1-2026.8.31 | 美国,日本 | (留空,走默认) | 地理位置 |
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-3 | NCyYYYYYYYYY | 2026.07.01-2026.07.31 | GB,DE,FR |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-3 | NCyYYYYYYYYY | 2026.07.01-2026.07.31 | GB,DE,FR | 预计收益 | (留空,走默认) |
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 所有者 | 账号整体 | (留空) | 2026.06.01-2026.06.30 | US |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 所有者 | 账号整体 | (留空) | 2026.06.01-2026.06.30 | US | (留空,走默认) | 内容 |
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 频道 | 示例频道 | UCxxxxxUCxxxxx | 2026.08.01-2026.08.15 | 日本 |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 频道 | 示例频道 | UCxxxxxUCxxxxx | 2026.08.01-2026.08.15 | 日本 | 外部展示 | (留空,走默认) |
|
||||||
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 节目 | 示例节目 | video123456 | 2026.08.01-2026.08.31 | 韩国,日本 |
|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 节目 | 示例节目 | video123456 | 2026.08.01-2026.08.31 | 韩国,日本 | (留空,走默认) | (留空,走默认) |
|
||||||
|
|
||||||
覆盖场景:单/多国家、中文名与 ISO 代码混用、四种实体类型、带/不带前导零的日期、所有者场景留空实体ID。
|
覆盖场景:单/多国家、中文名与 ISO 代码混用、四种实体类型、带/不带前导零的日期、所有者场景留空实体ID、指标可选(中文名或留空走默认)、维度可选(中文名或留空走默认)。
|
||||||
|
|
||||||
## 7. 制作核对清单
|
## 7. 制作核对清单
|
||||||
|
|
||||||
@@ -74,6 +76,8 @@
|
|||||||
- [ ] 群组/频道/节目行已填实体ID;所有者行可留空实体ID。
|
- [ ] 群组/频道/节目行已填实体ID;所有者行可留空实体ID。
|
||||||
- [ ] 中文国家名已在 countries.json 中,否则改用两位 ISO 代码。
|
- [ ] 中文国家名已在 countries.json 中,否则改用两位 ISO 代码。
|
||||||
- [ ] 多国分隔符正确。
|
- [ ] 多国分隔符正确。
|
||||||
|
- [ ] 指标列:中文名/英文名已在 metrics.json 中,或用已知指标代码;留空表示走默认。
|
||||||
|
- [ ] 维度列:中文名已在 dimensions.json 中,或用已知维度代码;留空表示走默认。
|
||||||
- [ ] 试跑无失败行、无「缺必要列」错误。
|
- [ ] 试跑无失败行、无「缺必要列」错误。
|
||||||
|
|
||||||
试跑命令(项目根目录):
|
试跑命令(项目根目录):
|
||||||
@@ -81,3 +85,36 @@
|
|||||||
```bash
|
```bash
|
||||||
uv run python scripts\build_studio_urls.py -i <输入文件> -o <输出csv>
|
uv run python scripts\build_studio_urls.py -i <输入文件> -o <输出csv>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 8. 指标格式
|
||||||
|
|
||||||
|
指标列(`指标` / `主指标` / `数据指标` / `metric`)**每行可选**,决定 URL 的主指标 `metric` 参数与排序字段 `o_column`。
|
||||||
|
|
||||||
|
- **留空**:用脚本 `CONFIG["metric"]` 默认主指标(`SUBSCRIBERS_NET_CHANGE`),排序字段用 `CONFIG["o_column"]`。
|
||||||
|
- **中文/英文名**:须在 `scripts/metrics.json` 映射内(可自行扩充),如 `观看时长` → `EXTERNAL_WATCH_TIME`、`预计收益` → `TOTAL_ESTIMATED_EARNINGS`。
|
||||||
|
- **指标代码**:已知代码原样透传,大小写不敏感,如 `external_views` ↔ `EXTERNAL_VIEWS`。
|
||||||
|
|
||||||
|
`metrics.json` 内已覆盖完整清单(与 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 的「数据指标」表一致,57 条),并额外收编了常用别名。下面仅列一部分;完整可用映射见该文件,也可自行扩充:
|
||||||
|
|
||||||
|
| 中文名 | 指标代码 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 订阅人数 / 订阅净增长 | SUBSCRIBERS_NET_CHANGE | 主指标/排序默认 |
|
||||||
|
| 观看次数 | EXTERNAL_VIEWS | 表格列指标 |
|
||||||
|
| 观看时长(小时) | EXTERNAL_WATCH_TIME | 表格列指标 |
|
||||||
|
| 估算的合作伙伴收入 / 预计收益 / 税前收益 / 税前收入 | TOTAL_ESTIMATED_EARNINGS | 表格列指标 |
|
||||||
|
| 平均观看时长 | AVERAGE_WATCH_TIME | 表格列指标 |
|
||||||
|
| 发布的视频数 | VIDEO_COUNT_FIRST_PUBLISHED | 表格列指标 |
|
||||||
|
|
||||||
|
> 指标只影响 `metric` 与 `o_column`;`t_metrics`(表格列指标集合)、粒度等仍是 `CONFIG` 固定值。
|
||||||
|
|
||||||
|
## 9. 细分维度
|
||||||
|
|
||||||
|
维度列(`维度` / `细分维度` / `dimension`)**每行可选**,决定 URL 的 `dimension` 参数。
|
||||||
|
|
||||||
|
- **留空**:用脚本 `CONFIG["dimension"]` 默认细分维度(`USER`,即「频道」)。
|
||||||
|
- **中文名**:须在 `scripts/dimensions.json` 映射内(可自行扩充),如 `内容` → `VIDEO`、`地理位置` → `COUNTRY`、`频道` → `USER`。
|
||||||
|
- **维度代码**:已知代码原样透传,大小写不敏感,如 `video` ↔ `VIDEO`。
|
||||||
|
|
||||||
|
`dimensions.json` 内已覆盖完整清单(与「细分维度」表一致,33 条),例如:内容 `VIDEO`、流量来源 `TRAFFIC_SOURCE_TYPE`、地理位置 `COUNTRY`、频道 `USER`、资产 `ASSET`、设备类型 `DEVICE_PLATFORM_TYPE`、操作系统 `DEVICE_OS_TYPE`、收入来源 `EARNINGS_SOURCE_ALL`、日期 `DAY` 等。
|
||||||
|
|
||||||
|
> 维度只影响 `dimension` 参数;`metric`、`t_metrics`、粒度等不受影响。
|
||||||
|
|||||||
135
skills/yt-studio-url-builder/references/maintenance-guide.md
Normal file
135
skills/yt-studio-url-builder/references/maintenance-guide.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 映射文件维护指南
|
||||||
|
|
||||||
|
`scripts/` 下有三个映射文件,把「人类可读的中文/英文名」翻译成「URL 内部代码」。本指南说明它们各自的作用、数据来源、结构规则,以及如何新增/更新条目。
|
||||||
|
|
||||||
|
- `countries.json`:中文国家名 → ISO 3166-1 alpha-2 代码(两位大写)。
|
||||||
|
- `metrics.json`:指标中文/英文名 → 指标代码(大写、下划线风格)。
|
||||||
|
- `dimensions.json`:细分维度中文名 → 维度代码(大写)。
|
||||||
|
|
||||||
|
三者都是**扁平 `key -> value` 的 JSON 对象**,UTF-8 编码。脚本按 UTF-8-SIG 读取,带 BOM 无影响。
|
||||||
|
|
||||||
|
## 1. 脚本如何加载它们
|
||||||
|
|
||||||
|
| 文件 | 加载函数 | 参数 | 缺失/文件不存在时的行为 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| countries.json | `load_country_map` | `--countries` | 仅支持两位 ISO 代码透传,中文名会报「未识别的国家」 |
|
||||||
|
| metrics.json | `load_metric_map` | `--metrics` | 仅支持 `CONFIG` 里的已知指标代码透传,中文/英文名会报「未识别的指标」 |
|
||||||
|
| dimensions.json | `load_dimension_map` | `--dimensions` | 仅支持 `CONFIG["dimension"]` 已知维度代码透传,中文名会报「未识别的维度」 |
|
||||||
|
|
||||||
|
加载时统一做了`strip` + 值 `upper()`,因此**文件里大小写、首尾空格不影响结果**——但建议源文件就保持规范,便于 diff 和阅读。
|
||||||
|
|
||||||
|
## 2. 数据来源(Source of Truth)
|
||||||
|
|
||||||
|
内部代码的真实来源是 YouTube Studio 后台 explore 报告 URL 里的 `metric=` / `dimension=` / `t_metrics=` 参数(或捕获的 `search_groups`/analytics 请求)。仓库内 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 已把已知清单整理好,是当前映射的**基准数据源**:
|
||||||
|
|
||||||
|
| 映射文件 | 对应 Excel 表 | 取「同名」列 → 值列 |
|
||||||
|
|---|---|---|
|
||||||
|
| dimensions.json | `细分维度` 表 | `中文名称(显示名)` → `URL 参数值(dimension=)` |
|
||||||
|
| metrics.json | `数据指标` 表 | `中文名称(显示名)` → `URL 参数值(t_metrics=/metric=)` |
|
||||||
|
| countries.json | 无 Excel 源 | 基于 ISO 3166-1 alpha-2 标准手工维护 |
|
||||||
|
|
||||||
|
`时间颗粒度` 表对应脚本 `CONFIG["granularity"]`,**不落入 JSON 文件**;`URL参数总览` 表是对 URL 各参数的说明,供核对。
|
||||||
|
|
||||||
|
## 3. 三个文件的结构与规则
|
||||||
|
|
||||||
|
### 3.1 dimensions.json(细分维度)
|
||||||
|
|
||||||
|
- 键:维度中文显示名,如 `内容`、`地理位置`、`收入来源`。
|
||||||
|
- 值:维度代码(大写),如 `VIDEO`、`COUNTRY`、`EARNINGS_SOURCE_ALL`。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 一个中文名对应一个代码;多个不同中文名可指向**同一**代码(如需,但避免冗余)。
|
||||||
|
- 新增一个维度后,其代码会被 `load_dimension_map` 自动加入「可透传集合」——即该代码立刻可作为裸代码在 `维度` 列直接填写,**无需改 `build_studio_urls.py`**。
|
||||||
|
- 维度代码只影响 URL 的 `dimension` 参数。
|
||||||
|
|
||||||
|
### 3.2 metrics.json(数据指标)
|
||||||
|
|
||||||
|
标准条目(来自 Excel「数据指标」表,57 条):
|
||||||
|
- 键:指标中文名,如 `观看次数`、`估算的合作伙伴收入`。
|
||||||
|
- 值:指标代码(大写),如 `EXTERNAL_VIEWS`、`TOTAL_ESTIMATED_EARNINGS`。
|
||||||
|
|
||||||
|
别名条目(手工追加,用于同义词/英文名,**不会出现在 Excel 中**):
|
||||||
|
- 多个中文同义词 → 同一代码。如 `税前收益`、`税前收入`、`预计收益`、`收益`、`收入` 都指向 `TOTAL_ESTIMATED_EARNINGS`。
|
||||||
|
- 英文 snake_case → 同一代码。如 `subscribers_net_change` → `SUBSCRIBERS_NET_CHANGE`。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 一个键对应唯一一个代码(JSON 键唯一,重复键后者覆盖前者,应避免手写重复)。
|
||||||
|
- 一个代码可有多个键(同义词),方向只能多对一。
|
||||||
|
- 新增一个指标后,其代码会被 `load_metric_map` 自动加入「可透传集合」,可直接作为裸代码填写。
|
||||||
|
- 指标只影响 URL 的 `metric` 与 `o_column`(排序字段)两个参数;`t_metrics`(表格列指标集合)仍是 `CONFIG` 固定值,**不在 JSON 里维护**。
|
||||||
|
|
||||||
|
### 3.3 countries.json(国家)
|
||||||
|
|
||||||
|
- 键:中文国家名(可含别名,如 `台湾`、`中国台湾` 都指向 `TW`)。
|
||||||
|
- 值:ISO 3166-1 alpha-2 两位大写代码,如 `US`、`JP`、`CN`。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 值必须为**两位大写字母**。多字/小写虽能被脚本 upper 后处理,但透传逻辑依赖 `[A-Z]{2}` 正则,非两位会走中文名映射路径。
|
||||||
|
- 两位 ISO 代码本身**无需在此文件登记**即可透传(`parse_countries` 正则直接识别),所以国家列填 `US` 永远可行;只有在需要「中文名 → 代码」时才在这加一行。
|
||||||
|
|
||||||
|
## 4. 当改什么 / 怎么改
|
||||||
|
|
||||||
|
| 场景 | 改动位置 | 例子 |
|
||||||
|
|---|---|---|
|
||||||
|
| 新增一个细分维度 | dimensions.json 加 `"中文名": "代码"` | `"播放来源": "PLAYBACK_SOURCE_TYPE"` |
|
||||||
|
| 新增一个指标 | metrics.json 加 `"中文名": "代码"` | `"新增指标": "SOME_NEW_METRIC"` |
|
||||||
|
| 给指标加中文同义词/英文别名 | metrics.json 加 `"别名": "同一代码"` | `"税前收益": "TOTAL_ESTIMATED_EARNINGS"` |
|
||||||
|
| 给维度加别名 | dimensions.json 加 `"别名": "同一代码"` | `"地区": "COUNTRY"` |
|
||||||
|
| 新增中文国家名 | countries.json 加 `"中文名": "XX"` | `"瑞典": "SE"` |
|
||||||
|
| 调整默认主指标/维度/粒度 | 改 `build_studio_urls.py` 顶部 `CONFIG`(**不是 JSON 文件**) | `CONFIG["metric"] = "..."` |
|
||||||
|
|
||||||
|
## 5. 从 Excel 重新同步(可选脚本)
|
||||||
|
|
||||||
|
当 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 更新后,可用以下片段把「数据指标」「细分维度」两张表重新导出为 JSON,再合并回文件(别名条目需要手工保留):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import openpyxl, json
|
||||||
|
|
||||||
|
xlsx = r"assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx"
|
||||||
|
wb = openpyxl.load_workbook(xlsx, data_only=True)
|
||||||
|
|
||||||
|
def dump(sheet_name, name_col, code_col):
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
header = rows[0]
|
||||||
|
ni, ci = header.index(name_col), header.index(code_col)
|
||||||
|
return {str(r[ni]).strip(): str(r[ci]).strip().upper()
|
||||||
|
for r in rows[1:] if r[ni] and r[ci]}
|
||||||
|
|
||||||
|
metrics = dump("数据指标", "中文名称(显示名)", "URL 参数值(t_metrics=/metric=)")
|
||||||
|
dims = dump("细分维度", "中文名称(显示名)", "URL 参数值(dimension=)")
|
||||||
|
|
||||||
|
json.dump(metrics, open("skills/yt-studio-url-builder/scripts/metrics.json", "w", encoding="utf-8"),
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
json.dump(dims, open("skills/yt-studio-url-builder/scripts/dimensions.json", "w", encoding="utf-8"),
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 该片段生成的是「标准条目全量覆盖」,会**丢失**你在 metrics.json 里追加的别名;建议先备份,再把别名手工合并回去。
|
||||||
|
|
||||||
|
## 6. 更新后的校验
|
||||||
|
|
||||||
|
改完任一 JSON,按下面顺序自检:
|
||||||
|
|
||||||
|
1. **JSON 语法有效**:
|
||||||
|
```bash
|
||||||
|
uv run python -m json.tool scripts\metrics.json > $null
|
||||||
|
uv run python -m json.tool scripts\dimensions.json > $null
|
||||||
|
uv run python -m json.tool scripts\countries.json > $null
|
||||||
|
```
|
||||||
|
2. **键非空、值非空**:加载函数已过滤空键值,但建议源文件里就不要出现空串。
|
||||||
|
3. **值大写**:脚本会 `upper()`,但提交前统一大写更清晰;国家值必须是两位字母。
|
||||||
|
4. **无重复键**:JSON 标准允许重复键但后者覆盖,属于隐性错误;手改时避免。
|
||||||
|
5. **跑一条真实需求验证**:造一行含新中文名/新代码的输入,确认输出 CSV 的「指标代码/维度代码/国家代码」解析正确、URL 里 `metric`/`dimension`/`ur_values` 正确。
|
||||||
|
6. **回归测试**:
|
||||||
|
```bash
|
||||||
|
uv run python -m pytest tests/test_build_studio_urls.py tests/test_build_studio_urls_cli.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 常见坑
|
||||||
|
|
||||||
|
- **文件缺失**:三个文件单个缺失只会降级(stderr 提示),名称/代码透传受限;务必恢复同目录下的文件,不要依赖降级路径长期运行。
|
||||||
|
- **以为改 JSON 能改默认值**:默认主指标/维度/粒度在 `build_studio_urls.py` 的 `CONFIG`,不在 JSON。JSON 只提供「可按行选择」的名称→代码映射。
|
||||||
|
- **`t_metrics` 不在 metrics.json 里**:表格列指标集合是 `CONFIG["t_metrics"]` 固定数组,加指标时若想让其进入表格列,需同时在 `CONFIG["t_metrics"]` 追加(否则只影响 `metric`/`o_column`)。
|
||||||
|
- **国家值写三位或带空格**:透传按 `[A-Z]{2}` 匹配,非两位值不会命中透传分支,中文名若也查不到就会报「未识别的国家」。
|
||||||
|
- **别名覆盖了标准名**:手动追加别名时不要与现有键重复,否则后者覆盖,标准名会失效。
|
||||||
@@ -46,6 +46,14 @@
|
|||||||
- 原因:`entity_type` 不在映射表。
|
- 原因:`entity_type` 不在映射表。
|
||||||
- 解决:使用 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO。
|
- 解决:使用 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO。
|
||||||
|
|
||||||
|
### 未识别的指标
|
||||||
|
- 原因:`指标` 列的值不在 `metrics.json` 映射里,也不是已知指标代码。
|
||||||
|
- 解决:改用 `metrics.json` 中的中文/英文名或已知指标代码(如 `SUBSCRIBERS_NET_CHANGE`),或向 `scripts/metrics.json` 追加映射;留空该列则走 `CONFIG` 默认。
|
||||||
|
|
||||||
|
### 未识别的维度
|
||||||
|
- 原因:`维度` 列的值不在 `dimensions.json` 映射里,也不是已知维度代码。
|
||||||
|
- 解决:改用 `dimensions.json` 中的中文名或已知维度代码(如 `VIDEO`),或向 `scripts/dimensions.json` 追加映射;留空该列则走 `CONFIG` 默认。
|
||||||
|
|
||||||
## 输出问题
|
## 输出问题
|
||||||
|
|
||||||
### 输出行数 < 输入行数
|
### 输出行数 < 输入行数
|
||||||
@@ -67,5 +75,5 @@
|
|||||||
|
|
||||||
## 改了固定参数不生效
|
## 改了固定参数不生效
|
||||||
|
|
||||||
- 原因:固定参数集中在脚本顶部 `CONFIG`,改后需重跑脚本才会生效。
|
- 原因:粒度、维度、`t_metrics` 等固定参数集中在脚本顶部 `CONFIG`,改后需重跑脚本才会生效。
|
||||||
- 提示:改 `CONFIG` 只影响 URL 参数,不需要改需求清单。
|
- 提示:改 `CONFIG` 只影响 URL 参数,不需要改需求清单;若想按行选主指标,改用需求清单的 `指标` 列(无需改 `CONFIG`)。
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "pandas>=2.0",
|
||||||
|
# "openpyxl>=3.1",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
"""
|
"""
|
||||||
根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。
|
根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。
|
||||||
|
|
||||||
@@ -15,12 +22,18 @@
|
|||||||
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
|
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
|
||||||
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
|
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
|
||||||
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
|
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
|
||||||
|
指标 : 指标 / 主指标 / 数据指标 / metric (可留空,则用 CONFIG 默认;中文名或指标代码)
|
||||||
|
维度 : 维度 / 细分维度 / dimension (可留空,则用 CONFIG 默认;中文名或维度代码)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 数据周期起止日期均包含在数据范围内,time_period 结束值取结束日后一天的日界线。
|
- 数据周期起止日期均包含在数据范围内,time_period 结束值取结束日后一天的日界线。
|
||||||
- 国家为多个时,ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27)。
|
- 国家为多个时,ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27)。
|
||||||
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json(可自行扩充);已是两位代码的原样透传。
|
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json(可自行扩充);已是两位代码的原样透传。
|
||||||
- 固定参数(metric/granularity/dimension/t_metrics 等)在本文件 CONFIG 中统一配置。
|
- 指标每行可选(metric 参数,中文名 -> 代码的映射放在同目录 metrics.json,可自行扩充;
|
||||||
|
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
|
||||||
|
- 维度每行可选(dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json,可自行扩充;
|
||||||
|
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
|
||||||
|
- 其余固定参数(granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -55,6 +68,11 @@ CONFIG = {
|
|||||||
"comparison_type": "NONE",
|
"comparison_type": "NONE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 常用指标代码集合(供指标代码透传;metrics.json 提供中文/英文别名映射)
|
||||||
|
KNOWN_METRICS = {CONFIG["metric"], CONFIG["o_column"], *CONFIG["t_metrics"]}
|
||||||
|
# 常见维度代码(供维度代码透传;dimensions.json 提供中文别名映射)
|
||||||
|
KNOWN_DIMENSIONS = {CONFIG["dimension"]}
|
||||||
|
|
||||||
# 实体类型 中文/代码 -> URL 参数值(可扩充)
|
# 实体类型 中文/代码 -> URL 参数值(可扩充)
|
||||||
ENTITY_TYPE_MAP = {
|
ENTITY_TYPE_MAP = {
|
||||||
"群组": "GROUP", "GROUP": "GROUP",
|
"群组": "GROUP", "GROUP": "GROUP",
|
||||||
@@ -95,6 +113,12 @@ COLUMN_ALIASES = {
|
|||||||
# 国家
|
# 国家
|
||||||
"国家": "countries", "国家/地区": "countries", "国家地区": "countries",
|
"国家": "countries", "国家/地区": "countries", "国家地区": "countries",
|
||||||
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
|
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
|
||||||
|
# 指标(每行可选;缺省用 CONFIG)
|
||||||
|
"指标": "metric", "主指标": "metric", "主要指标": "metric", "数据指标": "metric",
|
||||||
|
"metric": "metric", "metric_name": "metric", "metricname": "metric", "指标名": "metric",
|
||||||
|
# 维度(每行可选;缺省用 CONFIG)
|
||||||
|
"维度": "dimension", "细分维度": "dimension", "dimension": "dimension",
|
||||||
|
"dim": "dimension", "细分": "dimension", "维度名": "dimension",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -160,6 +184,66 @@ def parse_countries(text, country_map):
|
|||||||
return codes
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def load_metric_map(path):
|
||||||
|
"""加载 指标中文/英文名 -> 指标代码 映射。文件缺失则仅支持已知指标代码透传。"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print("[提示] 未找到指标映射文件 %s,仅支持直接填写指标代码" % path, file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
|
||||||
|
if str(k).strip() and str(v).strip()}
|
||||||
|
# 允许映射中出现的所有指标代码原样透传(大小写不敏感)
|
||||||
|
for code in set(mapped.values()) | KNOWN_METRICS:
|
||||||
|
mapped.setdefault(code.lower(), code)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def parse_metric(text, metric_map):
|
||||||
|
"""指标列 -> 指标代码。空则返回 None(走 CONFIG 默认);具体代码透传;未知则报错。"""
|
||||||
|
if text is None or (isinstance(text, float) and str(text) == "nan"):
|
||||||
|
return None
|
||||||
|
raw = str(text).strip().strip("'\"").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw.upper() in KNOWN_METRICS: # 已是已知指标代码
|
||||||
|
return raw.upper()
|
||||||
|
key = raw.lower()
|
||||||
|
if key in metric_map: # 中文/英文别名
|
||||||
|
return metric_map[key]
|
||||||
|
raise ValueError("未识别的指标: %r(不在映射文件中,也不是已知指标代码)" % raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_dimension_map(path):
|
||||||
|
"""加载 细分维度中文名 -> 代码 映射。文件缺失则仅支持已知维度代码透传。"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print("[提示] 未找到维度映射文件 %s,仅支持直接填写维度代码" % path, file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
|
||||||
|
if str(k).strip() and str(v).strip()}
|
||||||
|
# 允许映射中出现的所有维度代码原样透传(大小写不敏感)
|
||||||
|
for code in set(mapped.values()) | KNOWN_DIMENSIONS:
|
||||||
|
mapped.setdefault(code.lower(), code)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dimension(text, dimension_map):
|
||||||
|
"""维度列 -> 维度代码。空则返回 None(走 CONFIG 默认);具体代码透传;未知则报错。"""
|
||||||
|
if text is None or (isinstance(text, float) and str(text) == "nan"):
|
||||||
|
return None
|
||||||
|
raw = str(text).strip().strip("'\"").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw.upper() in KNOWN_DIMENSIONS: # 已是已知维度代码
|
||||||
|
return raw.upper()
|
||||||
|
key = raw.lower()
|
||||||
|
if key in dimension_map: # 中文别名
|
||||||
|
return dimension_map[key]
|
||||||
|
raise ValueError("未识别的维度: %r(不在映射文件中,也不是已知维度代码)" % raw)
|
||||||
|
|
||||||
|
|
||||||
def resolve_entity_type(text):
|
def resolve_entity_type(text):
|
||||||
"""实体类型 -> URL 参数值。空则用默认群组。"""
|
"""实体类型 -> URL 参数值。空则用默认群组。"""
|
||||||
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
|
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
|
||||||
@@ -170,8 +254,11 @@ def resolve_entity_type(text):
|
|||||||
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)" % key)
|
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)" % key)
|
||||||
|
|
||||||
|
|
||||||
def build_url(row, country_map):
|
def build_url(row, country_map, metric_map=None, dimension_map=None):
|
||||||
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。"""
|
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。
|
||||||
|
|
||||||
|
metric_map / dimension_map 为 名 -> 代码 映射;对应列缺省时回退到 CONFIG 默认。
|
||||||
|
"""
|
||||||
owner_id = row.get("owner_id", "").strip()
|
owner_id = row.get("owner_id", "").strip()
|
||||||
entity_type = resolve_entity_type(row.get("entity_type", ""))
|
entity_type = resolve_entity_type(row.get("entity_type", ""))
|
||||||
entity_id = row.get("entity_id", "").strip()
|
entity_id = row.get("entity_id", "").strip()
|
||||||
@@ -191,6 +278,15 @@ def build_url(row, country_map):
|
|||||||
|
|
||||||
codes = parse_countries(row.get("countries", ""), country_map)
|
codes = parse_countries(row.get("countries", ""), country_map)
|
||||||
|
|
||||||
|
# 指标:每行可选;空 -> 主指标/排序字段用 CONFIG 默认;非空 -> 二者都跟随所选指标
|
||||||
|
sel_metric = parse_metric(row.get("metric", ""), metric_map or {})
|
||||||
|
metric = sel_metric or CONFIG["metric"]
|
||||||
|
o_column = sel_metric or CONFIG["o_column"]
|
||||||
|
|
||||||
|
# 维度:每行可选;空 -> 用 CONFIG 默认细分维度
|
||||||
|
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map or {})
|
||||||
|
dimension = sel_dimension or CONFIG["dimension"]
|
||||||
|
|
||||||
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
|
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
|
||||||
params = []
|
params = []
|
||||||
params.append("o=%s" % owner_id)
|
params.append("o=%s" % owner_id)
|
||||||
@@ -204,12 +300,12 @@ def build_url(row, country_map):
|
|||||||
params.append("ur_exclusive_ends=")
|
params.append("ur_exclusive_ends=")
|
||||||
params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
|
params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
|
||||||
params.append("explore_type=%s" % CONFIG["explore_type"])
|
params.append("explore_type=%s" % CONFIG["explore_type"])
|
||||||
params.append("metric=%s" % CONFIG["metric"])
|
params.append("metric=%s" % metric)
|
||||||
params.append("granularity=%s" % CONFIG["granularity"])
|
params.append("granularity=%s" % CONFIG["granularity"])
|
||||||
for m in CONFIG["t_metrics"]:
|
for m in CONFIG["t_metrics"]:
|
||||||
params.append("t_metrics=%s" % m)
|
params.append("t_metrics=%s" % m)
|
||||||
params.append("dimension=%s" % CONFIG["dimension"])
|
params.append("dimension=%s" % dimension)
|
||||||
params.append("o_column=%s" % CONFIG["o_column"])
|
params.append("o_column=%s" % o_column)
|
||||||
params.append("o_direction=%s" % CONFIG["o_direction"])
|
params.append("o_direction=%s" % CONFIG["o_direction"])
|
||||||
params.append("comparison_type=%s" % CONFIG["comparison_type"])
|
params.append("comparison_type=%s" % CONFIG["comparison_type"])
|
||||||
|
|
||||||
@@ -222,6 +318,8 @@ def main():
|
|||||||
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls)")
|
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls)")
|
||||||
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv)")
|
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv)")
|
||||||
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json)")
|
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json)")
|
||||||
|
parser.add_argument("--metrics", default=None, help="指标映射 JSON 路径(默认:脚本同目录 metrics.json)")
|
||||||
|
parser.add_argument("--dimensions", default=None, help="维度映射 JSON 路径(默认:脚本同目录 dimensions.json)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not os.path.exists(args.input):
|
if not os.path.exists(args.input):
|
||||||
@@ -252,18 +350,26 @@ def main():
|
|||||||
|
|
||||||
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
|
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
|
||||||
country_map = load_country_map(countries_file)
|
country_map = load_country_map(countries_file)
|
||||||
|
metrics_file = args.metrics or os.path.join(os.path.dirname(os.path.abspath(__file__)), "metrics.json")
|
||||||
|
metric_map = load_metric_map(metrics_file)
|
||||||
|
dimensions_file = args.dimensions or os.path.join(os.path.dirname(os.path.abspath(__file__)), "dimensions.json")
|
||||||
|
dimension_map = load_dimension_map(dimensions_file)
|
||||||
|
|
||||||
records = []
|
records = []
|
||||||
errors = []
|
errors = []
|
||||||
for idx, raw in df.iterrows():
|
for idx, raw in df.iterrows():
|
||||||
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
|
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
|
||||||
try:
|
try:
|
||||||
url, status, msg = build_url(row, country_map)
|
url, status, msg = build_url(row, country_map, metric_map, dimension_map)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
url, status, msg = None, "error", str(e)
|
url, status, msg = None, "error", str(e)
|
||||||
if status == "ok":
|
if status == "ok":
|
||||||
start_ms, end_ms = parse_period(row.get("period", ""))
|
start_ms, end_ms = parse_period(row.get("period", ""))
|
||||||
codes = parse_countries(row.get("countries", ""), country_map)
|
codes = parse_countries(row.get("countries", ""), country_map)
|
||||||
|
sel_metric = parse_metric(row.get("metric", ""), metric_map)
|
||||||
|
metric_code = sel_metric or CONFIG["metric"]
|
||||||
|
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map)
|
||||||
|
dimension_code = sel_dimension or CONFIG["dimension"]
|
||||||
records.append({
|
records.append({
|
||||||
"所有者名称": row.get("owner_name", ""),
|
"所有者名称": row.get("owner_name", ""),
|
||||||
"所有者ID": row.get("owner_id", ""),
|
"所有者ID": row.get("owner_id", ""),
|
||||||
@@ -273,6 +379,10 @@ def main():
|
|||||||
"数据周期": row.get("period", ""),
|
"数据周期": row.get("period", ""),
|
||||||
"国家": row.get("countries", ""),
|
"国家": row.get("countries", ""),
|
||||||
"国家代码": ",".join(codes),
|
"国家代码": ",".join(codes),
|
||||||
|
"指标": row.get("metric", ""),
|
||||||
|
"指标代码": metric_code,
|
||||||
|
"维度": row.get("dimension", ""),
|
||||||
|
"维度代码": dimension_code,
|
||||||
"开始时间戳": start_ms,
|
"开始时间戳": start_ms,
|
||||||
"结束时间戳": end_ms,
|
"结束时间戳": end_ms,
|
||||||
"URL": url,
|
"URL": url,
|
||||||
|
|||||||
35
skills/yt-studio-url-builder/scripts/dimensions.json
Normal file
35
skills/yt-studio-url-builder/scripts/dimensions.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"内容": "VIDEO",
|
||||||
|
"流量来源": "TRAFFIC_SOURCE_TYPE",
|
||||||
|
"地理位置": "COUNTRY",
|
||||||
|
"频道": "USER",
|
||||||
|
"资产": "ASSET",
|
||||||
|
"频道所有权": "UPLOADER_TYPE",
|
||||||
|
"版权声明状态": "CLAIMED_STATUS",
|
||||||
|
"内容类型": "CREATOR_CONTENT_TYPE",
|
||||||
|
"播放列表": "PLAYLIST",
|
||||||
|
"观看者年龄": "VIEWER_AGE",
|
||||||
|
"观看者性别": "VIEWER_GENDER",
|
||||||
|
"新观看者和回访观看者": "LOYALTY_STATE",
|
||||||
|
"按观看行为细分的观众群": "AUDIENCE_LOYALTY_SEGMENT",
|
||||||
|
"订阅状态": "SUBSCRIBED_TO_UPLOADER_STATE",
|
||||||
|
"订阅来源": "SUBSCRIPTION_SOURCE_TYPE",
|
||||||
|
"YouTube 产品": "PLAYER_APP_TYPE",
|
||||||
|
"设备类型": "DEVICE_PLATFORM_TYPE",
|
||||||
|
"操作系统": "DEVICE_OS_TYPE",
|
||||||
|
"收入来源": "EARNINGS_SOURCE_ALL",
|
||||||
|
"广告类型": "ADTYPES",
|
||||||
|
"交易类型": "TRANSACTION_BUSINESS_MODEL",
|
||||||
|
"自然流量和付费流量": "AD_STATUS",
|
||||||
|
"日期": "DAY",
|
||||||
|
"字幕": "CAPTION_LANGUAGE",
|
||||||
|
"视频信息语言": "VIDEO_METADATA_LANGUAGE",
|
||||||
|
"是否使用翻译": "IS_CROSS_LANGUAGE",
|
||||||
|
"片尾画面元素": "ENDSCREEN_ELEMENT_ID",
|
||||||
|
"片尾画面元素类型": "ENDSCREEN_ELEMENT_TYPE",
|
||||||
|
"卡片": "INFO_CARD_ID",
|
||||||
|
"卡片类型": "INFO_CARD_TYPE",
|
||||||
|
"播放位置": "PLAYBACK_LOCATION_TYPE",
|
||||||
|
"播放器类型": "EMBEDDED_PLAYER_MODE",
|
||||||
|
"分享服务": "SHARING_SERVICE"
|
||||||
|
}
|
||||||
86
skills/yt-studio-url-builder/scripts/metrics.json
Normal file
86
skills/yt-studio-url-builder/scripts/metrics.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"观看次数": "EXTERNAL_VIEWS",
|
||||||
|
"感兴趣的观看次数": "ENGAGED_VIEWS",
|
||||||
|
"观看时长(小时)": "EXTERNAL_WATCH_TIME",
|
||||||
|
"订阅人数": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"平均观看时长": "AVERAGE_WATCH_TIME",
|
||||||
|
"平均观看百分比": "AVERAGE_WATCH_PERCENTAGE",
|
||||||
|
"添加的视频数": "VIDEO_COUNT_NEW",
|
||||||
|
"发布的视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"展示次数": "VIDEO_THUMBNAIL_IMPRESSIONS",
|
||||||
|
"展示点击率": "VIDEO_THUMBNAIL_IMPRESSIONS_VTR",
|
||||||
|
"继续观看": "SHORTS_FEED_IMPRESSIONS_VTR",
|
||||||
|
"唯一身份观看者人数": "ESTIMATED_UNIQUE_VIEWERS",
|
||||||
|
"人均观看次数": "AVERAGE_VIEWS_PER_VIEWER",
|
||||||
|
"获得的订阅人数": "SUBSCRIBERS_GAINED",
|
||||||
|
"流失的订阅人数": "SUBSCRIBERS_LOST",
|
||||||
|
"赞": "RATINGS_LIKES",
|
||||||
|
"不喜欢": "RATINGS_DISLIKES",
|
||||||
|
"赞和不喜欢的比率": "LIKES_PER_LIKES_PLUS_DISLIKES_PERCENT",
|
||||||
|
"分享数": "SHARINGS",
|
||||||
|
"添加的评论数": "COMMENTS",
|
||||||
|
"估算的合作伙伴收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"合作伙伴的交易收入": "TRANSACTION_EARNINGS_ALL",
|
||||||
|
"交易次数": "TRANSACTION_COUNT",
|
||||||
|
"合作伙伴的每笔交易收入": "AVERAGE_TRANSACTION_AMOUNT",
|
||||||
|
"YouTube Premium 合作伙伴收入": "SUBSCRIPTION_EARNINGS",
|
||||||
|
"估算的合作伙伴广告收入": "AD_EARNINGS",
|
||||||
|
"估算的合作伙伴 DoubleClick 收入": "YOUTUBE_EARNINGS",
|
||||||
|
"估算的合作伙伴 AdSense 收入": "AFV_EARNINGS",
|
||||||
|
"YouTube 广告收入": "AD_GROSS_REVENUE",
|
||||||
|
"广告展示次数": "IMPRESSIONS",
|
||||||
|
"基于播放的每千次展示费用": "CPM",
|
||||||
|
"每千次展示费用": "IMPRESSIONS_CPM",
|
||||||
|
"估算的获利播放次数": "PLAYBACKS",
|
||||||
|
"每千次展示收入 (RPM)": "EPM",
|
||||||
|
"YouTube Premium 观看次数": "EXTERNAL_YOUTUBE_RED_VIEWS",
|
||||||
|
"YouTube Premium 观看时长(小时)": "EXTERNAL_YOUTUBE_RED_WATCH_TIME",
|
||||||
|
"播放列表观看时长(小时)": "PLAYLIST_WATCH_TIME_HOURS",
|
||||||
|
"播放列表观看次数": "PLAYLIST_VIEWS",
|
||||||
|
"播放列表平均观看时长": "PLAYLIST_AVERAGE_WATCH_TIME",
|
||||||
|
"播放列表平均观看百分比": "PLAYLIST_AVERAGE_WATCH_PERCENTAGE",
|
||||||
|
"播放列表开始播放的次数": "PLAYLIST_STARTS",
|
||||||
|
"播放列表退出次数": "PLAYLIST_EXITS",
|
||||||
|
"播放列表退出率": "PLAYLIST_EXIT_RATE",
|
||||||
|
"播放列表的平均观看时长": "PLAYLIST_AVERAGE_START_DURATION",
|
||||||
|
"播放列表每次开始播放产生的观看次数": "PLAYLIST_AVERAGE_VIEWS_PER_START",
|
||||||
|
"播放列表保存次数": "PLAYLIST_SAVES_NET_CHANGE",
|
||||||
|
"社区剪辑片段观看次数": "CLIP_VIEWS",
|
||||||
|
"社区剪辑片段带来的观看时长(小时)": "CLIP_VIDEO_WATCHTIME",
|
||||||
|
"卡片点击次数": "INFO_CARD_CLICKS",
|
||||||
|
"卡片展示次数": "INFO_CARD_IMPRESSIONS",
|
||||||
|
"卡片每次展示所获得的点击次数": "INFO_CARD_CLICK_RATE",
|
||||||
|
"卡片宣传语点击次数": "INFO_CARD_TEASER_CLICKS",
|
||||||
|
"卡片宣传语展示次数": "INFO_CARD_TEASER_IMPRESSIONS",
|
||||||
|
"卡片宣传语每次展示所获得的点击次数": "INFO_CARD_TEASER_CLICK_RATE",
|
||||||
|
"片尾画面元素点击次数": "ENDSCREEN_ELEMENT_CLICKS",
|
||||||
|
"片尾画面元素展示次数": "ENDSCREEN_ELEMENT_IMPRESSIONS",
|
||||||
|
"片尾画面元素每次展示所获得的点击次数": "ENDSCREEN_ELEMENT_CLICK_RATE",
|
||||||
|
"税前收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"税前收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"预计收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"订阅净增长": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"订阅净变化": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"净订阅": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"订阅变化": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"视频发布数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"发布视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"互动观看": "ENGAGED_VIEWS",
|
||||||
|
"互动观看时长": "ENGAGED_VIEWS",
|
||||||
|
"参与观看": "ENGAGED_VIEWS",
|
||||||
|
"外部观看次数": "EXTERNAL_VIEWS",
|
||||||
|
"外部观看时长": "EXTERNAL_WATCH_TIME",
|
||||||
|
"观看时长": "EXTERNAL_WATCH_TIME",
|
||||||
|
"收益": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"收入": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"estimated_earnings": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"earnings": "TOTAL_ESTIMATED_EARNINGS",
|
||||||
|
"subscribers_net_change": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"net_change": "SUBSCRIBERS_NET_CHANGE",
|
||||||
|
"video_count_first_published": "VIDEO_COUNT_FIRST_PUBLISHED",
|
||||||
|
"engaged_views": "ENGAGED_VIEWS",
|
||||||
|
"external_views": "EXTERNAL_VIEWS",
|
||||||
|
"external_watch_time": "EXTERNAL_WATCH_TIME",
|
||||||
|
"average_watch_time": "AVERAGE_WATCH_TIME",
|
||||||
|
"total_estimated_earnings": "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
|
|
||||||
URL_BUILDER_SCRIPT = ROOT / "skills" / "yt-studio-url-builder" / "scripts" / "build_studio_urls.py"
|
URL_BUILDER_SCRIPT = ROOT / "skills" / "yt-studio-url-builder" / "scripts" / "build_studio_urls.py"
|
||||||
COUNTRIES_JSON = URL_BUILDER_SCRIPT.parent / "countries.json"
|
COUNTRIES_JSON = URL_BUILDER_SCRIPT.parent / "countries.json"
|
||||||
|
METRICS_JSON = URL_BUILDER_SCRIPT.parent / "metrics.json"
|
||||||
|
DIMENSIONS_JSON = URL_BUILDER_SCRIPT.parent / "dimensions.json"
|
||||||
DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
|
DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
|
||||||
LOOKUP_SCRIPT = ROOT / "scripts" / "lookup_groups.py"
|
LOOKUP_SCRIPT = ROOT / "scripts" / "lookup_groups.py"
|
||||||
|
|
||||||
@@ -57,6 +59,18 @@ def real_countries(url_builder):
|
|||||||
return url_builder.load_country_map(str(COUNTRIES_JSON))
|
return url_builder.load_country_map(str(COUNTRIES_JSON))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def real_metrics(url_builder):
|
||||||
|
"""脚本自带 metrics.json 加载出的指标映射(含指标代码透传)。"""
|
||||||
|
return url_builder.load_metric_map(str(METRICS_JSON))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def real_dimensions(url_builder):
|
||||||
|
"""脚本自带 dimensions.json 加载出的维度映射(含维度代码透传)。"""
|
||||||
|
return url_builder.load_dimension_map(str(DIMENSIONS_JSON))
|
||||||
|
|
||||||
|
|
||||||
def run_script(script, args, cwd=None):
|
def run_script(script, args, cwd=None):
|
||||||
"""以子进程运行被测脚本(uv run pytest 下 sys.executable 即 venv python)。
|
"""以子进程运行被测脚本(uv run pytest 下 sys.executable 即 venv python)。
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import math
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from conftest import COUNTRIES_JSON
|
from conftest import COUNTRIES_JSON, DIMENSIONS_JSON, METRICS_JSON
|
||||||
|
|
||||||
# 文档化锚点:2026-06-15 = 1781506800000(见 docs/adr/0001,脚本内 ANCHOR_MS)
|
# 文档化锚点:2026-06-15 = 1781506800000(见 docs/adr/0001,脚本内 ANCHOR_MS)
|
||||||
ANCHOR_MS = 1781506800000
|
ANCHOR_MS = 1781506800000
|
||||||
@@ -183,8 +183,121 @@ class TestLoadCountryMap:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# parse_countries:国家列解析
|
# load_metric_map / parse_metric:指标映射与解析
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestLoadMetricMap:
|
||||||
|
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||||||
|
path = tmp_path / "not_exists.json"
|
||||||
|
assert url_builder.load_metric_map(str(path)) == {}
|
||||||
|
assert "未找到指标映射文件" in capsys.readouterr().err
|
||||||
|
|
||||||
|
def test_bom_file_loads(self, url_builder, tmp_path):
|
||||||
|
p = tmp_path / "m.json"
|
||||||
|
p.write_bytes('{"订阅净增长": "subscribers_net_change"}'.encode("utf-8-sig"))
|
||||||
|
assert url_builder.load_metric_map(str(p))["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
|
||||||
|
def test_known_code_passthrough_added(self, url_builder, tmp_path):
|
||||||
|
p = tmp_path / "m.json"
|
||||||
|
p.write_text("{}", encoding="utf-8")
|
||||||
|
m = url_builder.load_metric_map(str(p))
|
||||||
|
assert m["subscribers_net_change"] == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
assert m["total_estimated_earnings"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
|
||||||
|
def test_real_metrics_json(self, real_metrics):
|
||||||
|
assert real_metrics["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
assert real_metrics["观看时长"] == "EXTERNAL_WATCH_TIME"
|
||||||
|
assert real_metrics["预计收益"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
# 全量:估算的合作伙伴收入 + 新增「税前收益/税前收入」别名
|
||||||
|
assert real_metrics["估算的合作伙伴收入"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
assert real_metrics["税前收益"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
assert real_metrics["税前收入"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
# 覆盖足够多的指标(>50 条)
|
||||||
|
assert len(real_metrics) > 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseMetric:
|
||||||
|
@pytest.mark.parametrize("empty", [None, "", float("nan")])
|
||||||
|
def test_blank_returns_none(self, url_builder, empty):
|
||||||
|
assert url_builder.parse_metric(empty, {}) is None
|
||||||
|
|
||||||
|
def test_known_code_passthrough_case_insensitive(self, url_builder):
|
||||||
|
assert url_builder.parse_metric("SUBSCRIBERS_NET_CHANGE", {}) == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
assert url_builder.parse_metric("subscribers_net_change", {}) == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
|
||||||
|
def test_chinese_name_mapped(self, url_builder):
|
||||||
|
assert url_builder.parse_metric("订阅净增长", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
|
||||||
|
== "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
|
||||||
|
def test_english_synonym_mapped(self, url_builder):
|
||||||
|
assert url_builder.parse_metric("watch_time", {"watch_time": "EXTERNAL_WATCH_TIME"}) \
|
||||||
|
== "EXTERNAL_WATCH_TIME"
|
||||||
|
|
||||||
|
def test_quotes_stripped(self, url_builder):
|
||||||
|
assert url_builder.parse_metric("'订阅净增长'", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
|
||||||
|
== "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
|
||||||
|
def test_unknown_raises(self, url_builder):
|
||||||
|
with pytest.raises(ValueError, match="未识别的指标"):
|
||||||
|
url_builder.parse_metric("神秘指标", {})
|
||||||
|
|
||||||
|
def test_real_map_common(self, url_builder, real_metrics):
|
||||||
|
assert url_builder.parse_metric("平均观看时长", real_metrics) == "AVERAGE_WATCH_TIME"
|
||||||
|
assert url_builder.parse_metric("收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
|
||||||
|
def test_estimated_partner_revenue_aliases(self, url_builder, real_metrics):
|
||||||
|
assert url_builder.parse_metric("估算的合作伙伴收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
assert url_builder.parse_metric("税前收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
assert url_builder.parse_metric("税前收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_dimension_map / parse_dimension:维度映射与解析
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestLoadDimensionMap:
|
||||||
|
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||||||
|
path = tmp_path / "not_exists.json"
|
||||||
|
assert url_builder.load_dimension_map(str(path)) == {}
|
||||||
|
assert "未找到维度映射文件" in capsys.readouterr().err
|
||||||
|
|
||||||
|
def test_known_code_passthrough_added(self, url_builder, tmp_path):
|
||||||
|
p = tmp_path / "d.json"
|
||||||
|
p.write_text("{}", encoding="utf-8")
|
||||||
|
d = url_builder.load_dimension_map(str(p))
|
||||||
|
assert d["user"] == "USER"
|
||||||
|
|
||||||
|
def test_real_dimensions_json(self, real_dimensions):
|
||||||
|
assert real_dimensions["内容"] == "VIDEO"
|
||||||
|
assert real_dimensions["地理位置"] == "COUNTRY"
|
||||||
|
assert real_dimensions["频道"] == "USER"
|
||||||
|
assert len(real_dimensions) >= 30
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDimension:
|
||||||
|
@pytest.mark.parametrize("empty", [None, "", float("nan")])
|
||||||
|
def test_blank_returns_none(self, url_builder, empty):
|
||||||
|
assert url_builder.parse_dimension(empty, {}) is None
|
||||||
|
|
||||||
|
def test_known_code_passthrough_case_insensitive(self, url_builder):
|
||||||
|
# 仅 CONFIG 默认维度(USER)在无映射时也能透传
|
||||||
|
assert url_builder.parse_dimension("USER", {}) == "USER"
|
||||||
|
assert url_builder.parse_dimension("user", {}) == "USER"
|
||||||
|
|
||||||
|
def test_chinese_name_mapped(self, url_builder):
|
||||||
|
assert url_builder.parse_dimension("地理位置", {"地理位置": "COUNTRY"}) == "COUNTRY"
|
||||||
|
|
||||||
|
def test_quotes_stripped(self, url_builder):
|
||||||
|
assert url_builder.parse_dimension("'内容'", {"内容": "VIDEO"}) == "VIDEO"
|
||||||
|
|
||||||
|
def test_unknown_raises(self, url_builder):
|
||||||
|
with pytest.raises(ValueError, match="未识别的维度"):
|
||||||
|
url_builder.parse_dimension("神秘维度", {})
|
||||||
|
|
||||||
|
def test_real_map_common(self, url_builder, real_dimensions):
|
||||||
|
assert url_builder.parse_dimension("内容", real_dimensions) == "VIDEO"
|
||||||
|
assert url_builder.parse_dimension("设备类型", real_dimensions) == "DEVICE_PLATFORM_TYPE"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseCountries:
|
class TestParseCountries:
|
||||||
def test_none_returns_empty(self, url_builder):
|
def test_none_returns_empty(self, url_builder):
|
||||||
assert url_builder.parse_countries(None, {}) == []
|
assert url_builder.parse_countries(None, {}) == []
|
||||||
@@ -319,6 +432,62 @@ class TestBuildUrl:
|
|||||||
for m in url_builder.CONFIG["t_metrics"]:
|
for m in url_builder.CONFIG["t_metrics"]:
|
||||||
assert "t_metrics=%s" % m in url
|
assert "t_metrics=%s" % m in url
|
||||||
|
|
||||||
|
# ---- 指标(每行可选)----
|
||||||
|
def test_metric_blank_uses_config_default(self, url_builder):
|
||||||
|
"""指标列留空:主指标与排序字段回退 CONFIG 默认(向后兼容)。"""
|
||||||
|
url, status, _ = url_builder.build_url(make_row(), {})
|
||||||
|
assert status == "ok"
|
||||||
|
assert "metric=%s" % url_builder.CONFIG["metric"] in url
|
||||||
|
assert "o_column=%s" % url_builder.CONFIG["o_column"] in url
|
||||||
|
|
||||||
|
def test_metric_chinese_sets_metric_and_o_column(self, url_builder, real_metrics):
|
||||||
|
"""指标列选中"预计收益":metric 与 o_column 都跟随所选指标。"""
|
||||||
|
url, status, _ = url_builder.build_url(
|
||||||
|
make_row(metric="预计收益"), {}, real_metrics)
|
||||||
|
assert status == "ok"
|
||||||
|
assert "metric=TOTAL_ESTIMATED_EARNINGS" in url
|
||||||
|
assert "o_column=TOTAL_ESTIMATED_EARNINGS" in url
|
||||||
|
|
||||||
|
def test_metric_code_passthrough(self, url_builder):
|
||||||
|
"""指标列填已知代码:原样透传。"""
|
||||||
|
url, status, _ = url_builder.build_url(
|
||||||
|
make_row(metric="EXTERNAL_VIEWS"), {})
|
||||||
|
assert status == "ok"
|
||||||
|
assert "metric=EXTERNAL_VIEWS" in url
|
||||||
|
assert "o_column=EXTERNAL_VIEWS" in url
|
||||||
|
|
||||||
|
def test_metric_unknown_raises(self, url_builder):
|
||||||
|
with pytest.raises(ValueError, match="未识别的指标"):
|
||||||
|
url_builder.build_url(make_row(metric="神秘指标"), {}, {})
|
||||||
|
|
||||||
|
def test_metric_case_insensitive_code(self, url_builder):
|
||||||
|
url, status, _ = url_builder.build_url(make_row(metric="external_views"), {})
|
||||||
|
assert status == "ok"
|
||||||
|
assert "metric=EXTERNAL_VIEWS" in url
|
||||||
|
|
||||||
|
# ---- 维度(每行可选)----
|
||||||
|
def test_dimension_blank_uses_config_default(self, url_builder):
|
||||||
|
"""维度列留空:回退 CONFIG 默认(向后兼容)。"""
|
||||||
|
url, status, _ = url_builder.build_url(make_row(), {})
|
||||||
|
assert status == "ok"
|
||||||
|
assert "dimension=%s" % url_builder.CONFIG["dimension"] in url
|
||||||
|
|
||||||
|
def test_dimension_chinese_sets_dimension(self, url_builder, real_dimensions):
|
||||||
|
url, status, _ = url_builder.build_url(
|
||||||
|
make_row(dimension="地理位置"), {}, dimension_map=real_dimensions)
|
||||||
|
assert status == "ok"
|
||||||
|
assert "dimension=COUNTRY" in url
|
||||||
|
|
||||||
|
def test_dimension_code_passthrough(self, url_builder, real_dimensions):
|
||||||
|
url, status, _ = url_builder.build_url(
|
||||||
|
make_row(dimension="video"), {}, dimension_map=real_dimensions)
|
||||||
|
assert status == "ok"
|
||||||
|
assert "dimension=VIDEO" in url
|
||||||
|
|
||||||
|
def test_dimension_unknown_raises(self, url_builder):
|
||||||
|
with pytest.raises(ValueError, match="未识别的维度"):
|
||||||
|
url_builder.build_url(make_row(dimension="神秘维度"), {}, {})
|
||||||
|
|
||||||
def test_no_country_omits_country_params(self, url_builder):
|
def test_no_country_omits_country_params(self, url_builder):
|
||||||
url, _, _ = url_builder.build_url(make_row(), {})
|
url, _, _ = url_builder.build_url(make_row(), {})
|
||||||
assert "ur_dimensions" not in url
|
assert "ur_dimensions" not in url
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ ASSET_XLSX = ROOT / "assets" / "需求输入示例.xlsx"
|
|||||||
HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"]
|
HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"]
|
||||||
|
|
||||||
OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID",
|
OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID",
|
||||||
"数据周期", "国家", "国家代码", "开始时间戳", "结束时间戳", "URL"]
|
"数据周期", "国家", "国家代码", "指标", "指标代码", "维度", "维度代码",
|
||||||
|
"开始时间戳", "结束时间戳", "URL"]
|
||||||
|
|
||||||
|
|
||||||
def run_builder(args):
|
def run_builder(args):
|
||||||
@@ -183,6 +184,123 @@ class TestHappyPath:
|
|||||||
assert len(read_output(out)) == 1
|
assert len(read_output(out)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 指标(每行可选)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestMetricColumn:
|
||||||
|
def test_metric_selected_and_default(self, tmp_path):
|
||||||
|
"""指标列:一行选"观看时长",一行留空走 CONFIG 默认。"""
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
pd.DataFrame([
|
||||||
|
{"所有者ID": "MC123", "实体ID": "G001", "数据周期": "2026.07.01-2026.08.01",
|
||||||
|
"指标": "观看时长"},
|
||||||
|
{"所有者ID": "MC123", "实体ID": "G002", "数据周期": "2026.07.01-2026.08.01",
|
||||||
|
"指标": ""},
|
||||||
|
]).to_csv(src, index=False, encoding="utf-8-sig")
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
df = read_output(out)
|
||||||
|
assert list(df.columns) == OUTPUT_COLUMNS
|
||||||
|
assert df.loc[0, "指标"] == "观看时长"
|
||||||
|
assert df.loc[0, "指标代码"] == "EXTERNAL_WATCH_TIME"
|
||||||
|
assert "metric=EXTERNAL_WATCH_TIME" in df.loc[0, "URL"]
|
||||||
|
assert "o_column=EXTERNAL_WATCH_TIME" in df.loc[0, "URL"]
|
||||||
|
# 空白行:回退 CONFIG 默认
|
||||||
|
assert df.loc[1, "指标代码"] == "SUBSCRIBERS_NET_CHANGE"
|
||||||
|
assert "metric=SUBSCRIBERS_NET_CHANGE" in df.loc[1, "URL"]
|
||||||
|
|
||||||
|
def test_unknown_metric_exits_2(self, tmp_path):
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
src.write_text(
|
||||||
|
"所有者ID,实体ID,数据周期,指标\nMC123,G001,2026.07.01-2026.08.01,神秘指标\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 2
|
||||||
|
assert "未识别的指标" in r.stderr
|
||||||
|
assert "第2行" in r.stderr
|
||||||
|
|
||||||
|
def test_metric_code_passthrough(self, tmp_path):
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
src.write_text(
|
||||||
|
"所有者ID,实体ID,数据周期,指标\nMC123,G001,2026.07.01-2026.08.01,external_views\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
df = read_output(out)
|
||||||
|
assert df.loc[0, "指标代码"] == "EXTERNAL_VIEWS"
|
||||||
|
assert "metric=EXTERNAL_VIEWS" in df.loc[0, "URL"]
|
||||||
|
|
||||||
|
def test_metric_alias_tax_revenue(self, tmp_path):
|
||||||
|
"""「税前收益/税前收入」别名 -> TOTAL_ESTIMATED_EARNINGS。"""
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
src.write_text(
|
||||||
|
"所有者ID,实体ID,数据周期,指标\nMC123,G001,2026.07.01-2026.08.01,税前收益\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
df = read_output(out)
|
||||||
|
assert df.loc[0, "指标代码"] == "TOTAL_ESTIMATED_EARNINGS"
|
||||||
|
assert "metric=TOTAL_ESTIMATED_EARNINGS" in df.loc[0, "URL"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 维度(每行可选)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestDimensionColumn:
|
||||||
|
def test_dimension_selected_and_default(self, tmp_path):
|
||||||
|
"""维度列:一行选"地理位置",一行留空走 CONFIG 默认。"""
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
pd.DataFrame([
|
||||||
|
{"所有者ID": "MC123", "实体ID": "G001", "数据周期": "2026.07.01-2026.08.01",
|
||||||
|
"维度": "地理位置"},
|
||||||
|
{"所有者ID": "MC123", "实体ID": "G002", "数据周期": "2026.07.01-2026.08.01",
|
||||||
|
"维度": ""},
|
||||||
|
]).to_csv(src, index=False, encoding="utf-8-sig")
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
df = read_output(out)
|
||||||
|
assert list(df.columns) == OUTPUT_COLUMNS
|
||||||
|
assert df.loc[0, "维度"] == "地理位置"
|
||||||
|
assert df.loc[0, "维度代码"] == "COUNTRY"
|
||||||
|
assert "dimension=COUNTRY" in df.loc[0, "URL"]
|
||||||
|
# 空白行:回退 CONFIG 默认
|
||||||
|
assert df.loc[1, "维度代码"] == "USER"
|
||||||
|
assert "dimension=USER" in df.loc[1, "URL"]
|
||||||
|
|
||||||
|
def test_unknown_dimension_exits_2(self, tmp_path):
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
src.write_text(
|
||||||
|
"所有者ID,实体ID,数据周期,维度\nMC123,G001,2026.07.01-2026.08.01,神秘维度\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 2
|
||||||
|
assert "未识别的维度" in r.stderr
|
||||||
|
assert "第2行" in r.stderr
|
||||||
|
|
||||||
|
def test_dimension_code_passthrough(self, tmp_path):
|
||||||
|
src = tmp_path / "需求.csv"
|
||||||
|
src.write_text(
|
||||||
|
"所有者ID,实体ID,数据周期,维度\nMC123,G001,2026.07.01-2026.08.01,video\n",
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
out = tmp_path / "out.csv"
|
||||||
|
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
df = read_output(out)
|
||||||
|
assert df.loc[0, "维度代码"] == "VIDEO"
|
||||||
|
assert "dimension=VIDEO" in df.loc[0, "URL"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 失败行与退出码
|
# 失败行与退出码
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -53,6 +53,63 @@ class FakeSession:
|
|||||||
return FakeResp(200, {"groupDatas": []})
|
return FakeResp(200, {"groupDatas": []})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP/2 伪头过滤
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestPseudoHeaderFilter:
|
||||||
|
def test_regex_matches_pseudo_headers(self, lookup_groups):
|
||||||
|
assert lookup_groups.PSEUDO_HEADER_RE.match(":authority")
|
||||||
|
assert lookup_groups.PSEUDO_HEADER_RE.match(":method")
|
||||||
|
assert not lookup_groups.PSEUDO_HEADER_RE.match("Authorization")
|
||||||
|
assert not lookup_groups.PSEUDO_HEADER_RE.match("X-YouTube-Delegation-Context")
|
||||||
|
|
||||||
|
def test_search_strips_pseudo_headers(self, lookup_groups):
|
||||||
|
"""套件里残留伪头时,回放请求不得携带(requests 会抛 InvalidHeader)。"""
|
||||||
|
bundle = {
|
||||||
|
"ownerId": "O1", "ownerDisplay": "一号", "url": "https://example/post",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
|
||||||
|
":authority": "studio.youtube.com", ":method": "POST",
|
||||||
|
":path": "/youtubei/v1/yta_web/search_groups", ":scheme": "https",
|
||||||
|
},
|
||||||
|
"bodyTemplate": {"query": ""},
|
||||||
|
}
|
||||||
|
sess = FakeSession({})
|
||||||
|
sess.routing["::x"] = FakeResp(200, {"groupDatas": [{"displayName": "x", "groupId": "G1"}]})
|
||||||
|
r = lookup_groups.search(bundle, "x", sess)
|
||||||
|
assert r["status"] == "OK"
|
||||||
|
sent = sess.calls[-1][2]
|
||||||
|
assert all(not k.startswith(":") for k in sent)
|
||||||
|
assert sent["Authorization"] == "SAPISIDHASH x"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CDP 端口预检
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestCdpPrecheck:
|
||||||
|
def test_unreachable_port_exits_with_guidance(self, lookup_groups):
|
||||||
|
"""端口不通时 SystemExit,输出含可执行的启动指引(含残留进程提示)。"""
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
lookup_groups._assert_cdp_reachable("http://localhost:59999")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "remote-debugging-port" in msg
|
||||||
|
assert "残留" in msg
|
||||||
|
|
||||||
|
def test_reachable_port_passes(self, lookup_groups, monkeypatch):
|
||||||
|
class FakeUrlopen:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"urllib.request.urlopen",
|
||||||
|
lambda url, timeout=3: FakeUrlopen(),
|
||||||
|
)
|
||||||
|
lookup_groups._assert_cdp_reachable("http://localhost:9222") # 不抛即通过
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def bundle():
|
def bundle():
|
||||||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||||||
|
|||||||
@@ -185,6 +185,29 @@ class TestDecodeZippedData:
|
|||||||
with zipfile.ZipFile(io.BytesIO(out)) as zf:
|
with zipfile.ZipFile(io.BytesIO(out)) as zf:
|
||||||
assert zf.read("x.csv") == b"1,2"
|
assert zf.read("x.csv") == b"1,2"
|
||||||
|
|
||||||
|
def test_urlsafe_nopad_variant(self, downloader):
|
||||||
|
"""真实接口返回 URL-safe(-/_)且无 = 填充的 base64,必须与标准表等价解码。"""
|
||||||
|
data = make_zip_bytes({"表格数据.csv": "a,b\n1,2"})
|
||||||
|
std = base64.b64encode(data).decode("ascii")
|
||||||
|
urlsafe_nopad = std.replace("+", "-").replace("/", "_").rstrip("=")
|
||||||
|
assert downloader.decode_zipped_data({"zippedData": urlsafe_nopad}) == data
|
||||||
|
|
||||||
|
def test_std_and_urlsafe_agree(self, downloader):
|
||||||
|
"""同一字节流的标准/URL-safe 两种编码解出相同结果。"""
|
||||||
|
data = make_zip_bytes({"x.csv": "1,2"})
|
||||||
|
std = base64.b64encode(data).decode("ascii")
|
||||||
|
urlsafe = base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||||
|
a = downloader.decode_zipped_data({"zippedData": std})
|
||||||
|
b = downloader.decode_zipped_data({"zippedData": urlsafe})
|
||||||
|
assert a == b == data
|
||||||
|
|
||||||
|
def test_non_zip_payload_raises_pk_error(self, downloader):
|
||||||
|
"""解码结果不是 zip(缺 PK 头)时报错,而非静默落盘损坏文件。"""
|
||||||
|
import base64 as b64
|
||||||
|
bogus = b64.b64encode(b"not a zip at all------").decode("ascii")
|
||||||
|
with pytest.raises(ValueError, match="PK"):
|
||||||
|
downloader.decode_zipped_data({"zippedData": bogus})
|
||||||
|
|
||||||
def test_missing_field_raises(self, downloader):
|
def test_missing_field_raises(self, downloader):
|
||||||
with pytest.raises(ValueError, match="zippedData"):
|
with pytest.raises(ValueError, match="zippedData"):
|
||||||
downloader.decode_zipped_data({"foo": "bar"})
|
downloader.decode_zipped_data({"foo": "bar"})
|
||||||
@@ -195,6 +218,24 @@ class TestDecodeZippedData:
|
|||||||
downloader.decode_zipped_data({"zippedData": empty})
|
downloader.decode_zipped_data({"zippedData": empty})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# verify_zip:zip 完整性校验
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestVerifyZip:
|
||||||
|
def test_valid_zip_passes(self, downloader):
|
||||||
|
downloader.verify_zip(make_zip_bytes({"a.csv": "1,2", "b.csv": "3,4"}))
|
||||||
|
|
||||||
|
def test_truncated_zip_raises_value_error(self, downloader):
|
||||||
|
good = make_zip_bytes({"a.csv": "1,2"})
|
||||||
|
bad = good[: len(good) // 2] # 截断中央目录
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
downloader.verify_zip(bad)
|
||||||
|
|
||||||
|
def test_not_a_zip_raises(self, downloader):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
downloader.verify_zip(b"plain text")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# intercept_and_save:拦截器(FakePage 模拟)
|
# intercept_and_save:拦截器(FakePage 模拟)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user