feat(scripts): 添加 PEP 723 脚本元数据并修复 HTTP/2 伪头过滤问题

- 为 build_studio_urls.py 和 lookup_groups.py 添加 PEP 723 依赖声明
- 修复 HTTP/2 伪头导致 requests 抛 InvalidHeader 的问题
- 添加 CDP 端口连通性预检查及启动指引
This commit is contained in:
2026-08-27 13:33:34 +08:00
parent b8bd749f43
commit 9020752eed
23 changed files with 885 additions and 294 deletions

View File

@@ -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,9 +93,7 @@ uv 自动创建 `.venv\` 并安装全部依赖pandas、openpyxl、playwright
> 帮我准备/重建环境 > 帮我准备/重建环境
技能会引导`uv sync` 与验证流程 技能会引导选择运行分支(项目环境 / 单脚本模式)并完成验证
所有脚本统一用 `uv run python <脚本>` 执行(自动使用项目环境,免激活、免手动装依赖)。
## 后续优化与修改 ## 后续优化与修改

View 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
### 问题 1Chrome 调试端口 9222 连不上
- **现象**`--connect http://localhost:9222` 连不上,报"无法连接到远程服务器"。
- **为什么会出现**Chrome 未以 `--remote-debugging-port=9222` 启动。
- **解决方法**:用带调试端口的命令重启 Chrome。
- **如何避免**:运行前先 `Invoke-WebRequest http://localhost:9222/json/version` 探测端口是否在线。
### 问题 2Chrome 进程未完全退出,调试端口参数被忽略
- **现象**:已带参数重启 Chrome但 9222 仍不通。
- **为什么会出现**:后台仍有 Chrome 残留进程,新启动实例的 `--remote-debugging-port` 被已运行实例"吞掉"Chrome 单实例复用机制)。
- **解决方法**:完全退出所有 Chrome 进程后再启动调试实例,或者改用独立 `--user-data-dir` 隔离启动。
- **如何避免**:启动前检测残留进程;或固定使用独立 profile 目录 + 调试端口。
### 问题 3回放请求全量 401
- **现象**`search_groups` 回放返回 `HTTP401`
- **为什么会出现**:套件过期(鉴权头是逐请求计算的),或捕获时鉴权头不全。
- **解决方法**:重新在线捕获套件,更新 `bundles.json`
- **如何避免**:套件一次性用完即弃;长期复用前先重新捕获校验。
### 问题 4HTTP/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-downloadzip 解码 | `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.

View File

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

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "StudioLift" name = "StudioLift"
version = "0.3.4" 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 = [

View File

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

View File

@@ -1,9 +1,11 @@
@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 -DryRun (preview only, no changes) 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)
cd /d "%~dp0" cd /d "%~dp0"
if not exist "install-skills.ps1" ( if not exist "install-skills.ps1" (
echo [ERROR] install-skills.ps1 not found in %~dp0 echo [ERROR] install-skills.ps1 not found in %~dp0

View File

@@ -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(
# 目标 agenttrae-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) { $timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
return Test-Path (Join-Path $TraeSkills $skillName) $totalBackup = 0
$totalFresh = 0
$failedTargets = @()
foreach ($destRel in $destRelList) {
$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) {
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
$backupPath = Join-Path $BackupRoot ("{0}-{1}" -f $skill.Name, $timestamp)
Move-Item -Path $dest -Destination $backupPath
$totalBackup++
Write-Host "[备份] $($skill.Name) 旧版 -> $backupPath" -ForegroundColor Yellow
}
Copy-Item -Path $skill.FullName -Destination $dest -Recurse -Force
$totalFresh++
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
# 校验该技能落盘完整
if (-not (Test-Path (Join-Path $DestSkills "$($skill.Name)\SKILL.md"))) {
throw "SKILL.md 未落盘"
}
} catch {
Write-Host "[FAIL] $($skill.Name) -> $DestSkills : $_" -ForegroundColor Red
$failedTargets += "$destRel/$($skill.Name)"
}
}
} }
$toBackup = @($targets | Where-Object { Test-SkillInstalled $_.Name }) # --- 5. 预览模式收尾 ---
$toFresh = @($targets | Where-Object { -not (Test-SkillInstalled $_.Name) })
if ($DryRun) { if ($DryRun) {
Write-Host "== 预览(未执行)==" -ForegroundColor Cyan Write-Host ""
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 Write-Host "[DryRun] 完成,未做任何修改。" -ForegroundColor Green
exit 0 exit 0
} }
# --- 5. 执行安装 --- # --- 6. 汇总与后续提示 ---
New-Item -ItemType Directory -Force -Path $TraeSkills | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backupCount = 0
foreach ($skill in $targets) {
$dest = Join-Path $TraeSkills $skill.Name
if (Test-Path $dest) {
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
$backupPath = Join-Path $BackupRoot ("{0}-{1}" -f $skill.Name, $timestamp)
Move-Item -Path $dest -Destination $backupPath
$backupCount++
Write-Host "[备份] $($skill.Name) 旧版 -> $backupPath" -ForegroundColor Yellow
}
Copy-Item -Path $skill.FullName -Destination $dest -Recurse -Force
Write-Host "[安装] $($skill.Name)" -ForegroundColor Green
}
# --- 6. 校验安装结果 ---
Write-Host "" Write-Host ""
Write-Host "=== 校验结果 ===" if ($failedTargets.Count -gt 0) {
$failed = @() Write-Host ("安装失败: {0}" -f ($failedTargets -join ", ")) -ForegroundColor Red
foreach ($skill in $targets) {
if (Test-Path (Join-Path $TraeSkills "$($skill.Name)\SKILL.md")) {
Write-Host " [OK] $($skill.Name)" -ForegroundColor Green
} else {
Write-Host " [FAIL] $($skill.Name)" -ForegroundColor Red
$failed += $skill.Name
}
}
# --- 7. 汇总与后续提示 ---
Write-Host ""
if ($failed.Count -gt 0) {
Write-Host ("安装失败: {0}" -f ($failed -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)"
} }

View File

@@ -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_idgroupId」批量解析脚本 YouTube Studio 内容管理器「群组名 -> entity_idgroupId」批量解析脚本
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/:schemerequests 作为普通头发送会抛
# 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):

View File

@@ -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 抓包确认) 机制(已实证):前端点击导出后向后端
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
后端把打好的 zip 以 base64 内联在响应 `zippedData` 字段里(开头 `UEsDBBQ` 即 ZIP 文件头 `PK`)。
注意两个字段细节(均为实证结论):
- `zippedData` 是 **URL-safe base64**-/_ 代替 +//,且省略 = 填充);
- 当前界面导出入口是右上角**下载图标按钮**:只有 `aria-label="导出当前视图"`,没有可见文本。
本脚本拦截该响应base64 解码并校验 zip 完整性后按 `<维度标签> <起始日>_<结束日> <账号名>.zip`
写盘,重名自动加 ` (n)` 后缀n 从 1 起;未捕获到有效响应时自动重试一次触发导出。
1. 前端点击「导出当前视图 → 逗号分隔值 (.csv)」后,向后端发起 登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json - --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器)
请求体 `exportQuery` 内含 joinRequest 各节点(表格数据/图表数据/总计),以及 - --connect http://localhost:9222 :附加到已在调试端口运行的浏览器
日期范围 `dateIdRange.inclusiveStart` / `dateIdRange.exclusiveEnd`(都是 YYYYMMDD
2. 后端**不在服务器上生成一个可下载的 URL**,而是直接把打好的 zip 以 用法:
**base64 字符串**内联在响应里: python youtube_export_download.py --selftest
{ "responseContext": {...}, "zippedData": "<base64>" } python youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:/Users/<you>/AppData/Local/Google/Chrome/User Data"
实测 `zippedData` 以 `UEsDBBQ...` 开头(即 `PK\\x03\\x04`ZIP 魔数), python youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
base64 解码后得到 zip内含 `表格数据.csv`、`图表数据.csv`、`总计.csv`。
3. 前端把 `zippedData` base64 解码 → Blob → 触发浏览器下载。
由于没有出现指向下载文件的 GET/跳转,判定为「客户端 Blob 下载」而非服务端重定向。
4. 浏览器自身已自动保存到本机默认下载目录(本机为 `D:\\Downloads`),无需"另存为"确认;
且 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-DDinclusiveStart 与 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,27 +208,34 @@ 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(
"ytcp-account-item button, .account-switcher button" "ytcp-account-item button, .account-switcher button"
).first.inner_text(timeout=5000).strip() or account_name ).first.inner_text(timeout=5000).strip() or account_name
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")

View File

@@ -1,61 +1,95 @@
--- ---
name: "uv-env-setup" name: "uv-env-setup"
description: "用 uv 准备并维护本项目 Python 运行环境(pyproject.toml → uv sync → uv run)。当用户提到环境准备/环境初始化/装依赖/重建 venv脚本报 ModuleNotFoundErrorpandas/openpyxl/playwright.venv 缺失或损坏,或首次运行 scripts/、skills/*/scripts/ 下的 Python 脚本时使用。" description: "用 uv 准备并维护本项目与各技能脚本的 Python 运行环境(项目环境 uv sync / 单脚本 PEP 723 模式)。当用户提到环境准备/装依赖/重建 venv脚本报 ModuleNotFoundErrorpandas/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` 依赖
- 各 agenttrae-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)。

View File

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

View File

@@ -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 语义;每条失败可独立重跑。

View File

@@ -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 逻辑,不依赖浏览器。

View File

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

View File

@@ -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且生成结果文件。

View File

@@ -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 @@
## 回放查询 ## 回放查询
### 回放报 InvalidHeaderInvalid 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`

View File

@@ -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_idgroupId」批量解析脚本 YouTube Studio 内容管理器「群组名 -> entity_idgroupId」批量解析脚本
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/:schemerequests 作为普通头发送会抛
# 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):

View File

@@ -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>/` 路径标识。
@@ -29,8 +31,8 @@ description: "根据需求清单CSV/Excel批量生成 YouTube Studio 内
### 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 步的运行命令可直接执行。
@@ -60,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`

View File

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

View File

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

View File

@@ -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_zipzip 完整性校验
# ---------------------------------------------------------------------------
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 模拟)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

2
uv.lock generated
View File

@@ -815,7 +815,7 @@ wheels = [
[[package]] [[package]]
name = "studiolift" name = "studiolift"
version = "0.3.1" version = "0.3.4"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "openpyxl" }, { name = "openpyxl" },