diff --git a/README.md b/README.md index 725e8bd..6992a27 100644 --- a/README.md +++ b/README.md @@ -48,45 +48,44 @@ ### 前置条件 - Windows -- [Trae CN](https://www.trae.cn/) 已安装 -- [uv](https://docs.astral.sh/uv/) 已安装(未装可 `winget install astral-sh.uv`,装完重开终端) +- agent 之一:[Trae CN](https://www.trae.cn/) 或 [ZCode](https://github.com/ZhipuAI)(技能装到对应用户级目录) +- [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 ``` -看到 `全部安装成功` 即完成。脚本会把 `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 -# 只安装指定的几个技能 -scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download +# 装到 ZCode +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 ``` ### 第 2 步:准备 Python 运行环境 -项目根目录执行: +两种模式按场景二选一(细节与回退链见 `skills/uv-env-setup/SKILL.md`): -```powershell -uv sync -``` - -uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright)。本机无兼容 Python 时 uv 会自动下载托管 Python(要求 ≥3.10)。 +- **项目环境**(在项目内跑整套流程):项目根执行 `uv sync`,之后统一 `uv run python <脚本>`。uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright、requests);本机无兼容 Python 时 uv 会自动下载托管 Python(要求 ≥3.10)。 +- **单脚本模式**(技能已装到用户级目录、脱离项目使用):什么都不用配——脚本头部带 PEP 723 内联依赖声明,直接 `uv run <脚本路径>` 即可。 ### 第 3 步:开始使用 -重启 Trae CN(或新建会话)后,在对话中直接说需求即可触发对应技能,例如: +重启对应 agent(或新建会话)后,在对话中直接说需求即可触发对应技能,例如: > 根据这份清单批量生成 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 @@ -94,9 +93,7 @@ uv 自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright > 帮我准备/重建环境 -技能会引导走 `uv sync` 与验证流程。 - -所有脚本统一用 `uv run python <脚本>` 执行(自动使用项目环境,免激活、免手动装依赖)。 +技能会引导选择运行分支(项目环境 / 单脚本模式)并完成验证。 ## 后续优化与修改 diff --git a/assets/skill-问题复盘与优化建议.md b/assets/skill-问题复盘与优化建议.md new file mode 100644 index 0000000..142a30e --- /dev/null +++ b/assets/skill-问题复盘与优化建议.md @@ -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 处脚本缺陷修复)尚未改动任何技能脚本,仅记录在本文档。是否执行优化、执行到哪些范围(仅修复缺陷 / 同时做增强),请确认后再实施。 \ No newline at end of file diff --git a/assets/需求输入示例.xlsx b/assets/需求输入示例.xlsx index 96dc0f7..e4a9711 100644 Binary files a/assets/需求输入示例.xlsx and b/assets/需求输入示例.xlsx differ diff --git a/docs/install-skills.md b/docs/install-skills.md index c49d014..f13c947 100644 --- a/docs/install-skills.md +++ b/docs/install-skills.md @@ -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 | -| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSV(zip) | | `yt-studio-groupid-lookup` | 把一批群组名批量解析为 entity_id(groupId),并标注归属的内容所有者 | +| `youtube-studio-csv-download` | 复用已登录浏览器会话,脚本化下载 YouTube Studio 分析 CSV(zip) | ## 安装(傻瓜式) @@ -19,7 +27,7 @@ scripts\install-skills.bat ``` -看到 `全部安装成功` 及逐项 `[OK]` 即完成,按任意键关闭窗口。**默认安装全部技能**;同名旧版自动备份到 `~/.trae-cn/skills-backup/`。 +看到 `全部安装成功` 及逐项 `[安装]` 即完成,按任意键关闭窗口。**默认安装全部技能到 trae-cn**;同名旧版自动备份到目标目录旁的 `skills-backup\`。 命令行等价方式(PowerShell / cmd 均可): @@ -31,30 +39,44 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-skills.ps1 | 参数 | 作用 | |---|---| +| `-Agent <名>` | 目标 agent:`trae-cn`(默认)/ `zcode` / `all` | | `-Skills <名1,名2>` | 只安装指定的技能(按技能文件夹名,不区分大小写);缺省=全部 | | `-DryRun` | 只预览将安装/覆盖/备份的技能,不实际复制 | 示例: ```powershell -scripts\install-skills.bat -Skills yt-studio-url-builder,youtube-studio-csv-download -scripts\install-skills.bat -DryRun +# 装到 ZCode +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` 的目录会提示「跳过」)。 -2. **默认安装全部技能**;若带 `-Skills` 则只装指定的,并提示未找到的技能名。 -3. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)。 -4. 逐个复制到 `%USERPROFILE%\.trae-cn\skills\`;目标已存在同名技能时,先把旧版移动到 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖。 -5. 逐个校验并打印 `[OK]` / `[FAIL]`;全部成功退出码 0,失败退出码 2。 -6. 带 `-DryRun` 时只打印将安装/覆盖/备份的技能清单,不做任何修改。 +2. 解析 `-Agent` 得到一个或多个目标目录;默认全部技能、-trae-cn。 +3. 若带 `-Skills` 则只装指定的,并提示未找到的技能名。 +4. 读取每个 `SKILL.md` 顶部的 `name`,与文件夹名比对——不一致或缺 `name` 时打警告(避免技能名歧义/旧名残留)。 +5. 逐个复制到各目标目录;目标已存在同名技能时,先把旧版移动到该目标旁的 `skills-backup\<技能名>-<时间戳>\` 再装新版,不直接覆盖。 +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. 对话中直接提及技能名或相关意图,例如: - 「帮我准备环境」→ `uv-env-setup` - 「根据这份清单批量生成 Studio URL」→ `yt-studio-url-builder` @@ -62,19 +84,19 @@ scripts\install-skills.bat -DryRun ## 更新与卸载 -- **更新**:项目技能有改动后,重新双击 `install-skills.bat`(旧版自动备份)。 -- **回滚**:把 `%USERPROFILE%\.trae-cn\skills-backup\<技能名>-<时间戳>\` 整个文件夹复制回 `%USERPROFILE%\.trae-cn\skills\<技能名>\`。 -- **卸载**:删除 `%USERPROFILE%\.trae-cn\skills\` 下对应技能文件夹。 -- **清理备份**:确认新版可用后,删除 `%USERPROFILE%\.trae-cn\skills-backup\` 整个文件夹。 +- **更新**:项目技能有改动后,重新运行安装脚本(可加 `-Agent all` 同步所有目标;旧版自动备份)。 +- **回滚**:把 `<目标目录旁>\skills-backup\<技能名>-<时间戳>\` 整个文件夹复制回对应技能目录。 +- **卸载**:删除对应技能目录下的技能文件夹。 +- **清理备份**:确认新版可用后,删除 skills-backup 整个文件夹。 ## 常见问题 - **双击 bat 闪退**:在 PowerShell 中手动运行 `scripts\install-skills.bat` 查看报错;最常见原因是 `install-skills.ps1` 没有和 bat 放在同一目录。 - **PowerShell 执行策略受限**:bat 已带 `-ExecutionPolicy Bypass`;若组策略仍拦截,以管理员身份运行。 -- **技能装了但不触发**:需重启 Trae CN 或新建会话后生效;确认对话窗口正常加载。 +- **技能装了但不触发**:需重启对应 agent 或新建会话后生效;先用 `-DryRun` 确认装到了你所用 agent 的目录。 - **中文输出乱码**:`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 编写)。 diff --git a/pyproject.toml b/pyproject.toml index ddf1be1..db9dd15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StudioLift" -version = "0.3.4" +version = "0.4.0" description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。" requires-python = ">=3.10" dependencies = [ diff --git a/scripts/build_studio_urls.py b/scripts/build_studio_urls.py index cb9d091..469611c 100644 --- a/scripts/build_studio_urls.py +++ b/scripts/build_studio_urls.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pandas>=2.0", +# "openpyxl>=3.1", +# ] +# /// """ 根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。 diff --git a/scripts/install-skills.bat b/scripts/install-skills.bat index 930615e..21b796e 100644 --- a/scripts/install-skills.bat +++ b/scripts/install-skills.bat @@ -1,9 +1,11 @@ @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 Usage: install-skills.bat (install ALL skills) -rem install-skills.bat -Skills a,b,c (install only those) -rem install-skills.bat -DryRun (preview only, no changes) +rem Usage: install-skills.bat (install ALL skills to trae-cn) +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) cd /d "%~dp0" if not exist "install-skills.ps1" ( echo [ERROR] install-skills.ps1 not found in %~dp0 diff --git a/scripts/install-skills.ps1 b/scripts/install-skills.ps1 index 528dc42..9e30f02 100644 --- a/scripts/install-skills.ps1 +++ b/scripts/install-skills.ps1 @@ -1,15 +1,19 @@ # ===================================================================== -# Trae CN 技能安装器(Windows) -# 作用:把本项目 skills\ 下全部技能安装到用户级技能目录 -# %USERPROFILE%\.trae-cn\skills\ -# 默认安装全部技能;也可用 -Skills 只装一部分,或用 -DryRun 预览。 +# 技能安装器(Windows,多 agent 目标) +# 作用:把本项目 skills\ 下全部技能安装到指定的用户级技能目录: +# trae-cn -> %USERPROFILE%\.trae-cn\skills\ (Trae CN / Trae SOLO) +# zcode -> %USERPROFILE%\.zcode\skills\ (ZCode CLI) +# 默认安装全部技能到全部已知目录;也可用 -Agent、-Skills 只装一部分, +# 或用 -DryRun 预览。 # 用法:双击同目录下的 install-skills.bat(推荐),或 # powershell -NoProfile -ExecutionPolicy Bypass -File install-skills.ps1 -# powershell ... -Skills yt-studio-url-builder,youtube-studio-csv-download -# powershell ... -DryRun +# powershell ... -Agent zcode -Skills yt-studio-url-builder,youtube-studio-csv-download +# powershell ... -Agent all -DryRun # ===================================================================== param( + # 目标 agent:trae-cn(默认)/ zcode / all。all = 装到下面全部已知目标。 + [string]$Agent = "trae-cn", # 只安装这些技能(逗号/分号/空格分隔,匹配技能文件夹名,不区分大小写)。缺省=全部。 [string[]]$Skills, # 只预览将要安装/备份/跳过的技能,不实际复制。 @@ -18,17 +22,30 @@ param( $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 $ProjectRoot = Split-Path -Parent $ScriptDir $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 "安装目标: $TraeSkills" +Write-Host ("安装目标: " + (($destRelList | ForEach-Object { "$env:USERPROFILE\$_" }) -join ", ")) # 把 -Skills 参数拆成规范化名字列表(兼容逗号/分号/空格分隔) $skillFilter = @() @@ -83,7 +100,7 @@ if ($targets.Count -eq 0) { } # --- 3. 读取每个技能的 frontmatter name,并校验其与文件夹名一致 --- -# 预测性关键:Trae 以 frontmatter 的 name 作为技能名,若与文件夹名不一致会引发歧义/旧名残留。 +# 预测性关键:各 agent 以 frontmatter 的 name 作为技能名,若与文件夹名不一致会引发歧义/旧名残留。 $warnCount = 0 foreach ($skill in $targets) { $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 "" -# --- 4. 计算操作(备份/安装)并可选预览 --- -function Test-SkillInstalled([string]$skillName) { - return Test-Path (Join-Path $TraeSkills $skillName) +# --- 4. 对每个目标目录执行安装(备份旧版 -> 复制) --- +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$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 }) -$toFresh = @($targets | Where-Object { -not (Test-SkillInstalled $_.Name) }) - +# --- 5. 预览模式收尾 --- 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 "" Write-Host "[DryRun] 完成,未做任何修改。" -ForegroundColor Green exit 0 } -# --- 5. 执行安装 --- -New-Item -ItemType Directory -Force -Path $TraeSkills | Out-Null - -$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" -$backupCount = 0 - -foreach ($skill in $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. 校验安装结果 --- +# --- 6. 汇总与后续提示 --- Write-Host "" -Write-Host "=== 校验结果 ===" -$failed = @() -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 +if ($failedTargets.Count -gt 0) { + Write-Host ("安装失败: {0}" -f ($failedTargets -join ", ")) -ForegroundColor Red exit 2 } 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) { Write-Host ("注意:{0} 处 name/文件夹不一致或缺 name 的可疑技能(见上方警告)。" -f $warnCount) -ForegroundColor Yellow } -if ($backupCount -gt 0) { - Write-Host "旧版本备份于: $BackupRoot(确认新版可用后可手动删除)" -ForegroundColor Yellow +if ($totalBackup -gt 0) { + Write-Host "旧版本备份于各目标目录旁的 skills-backup(确认新版可用后可手动删除)" -ForegroundColor Yellow } Write-Host "" -Write-Host "下一步: 重启 Trae CN 或新建会话后,在对话中提及技能名或相关意图即可触发:" +Write-Host "下一步: 重启对应 agent 或新建会话后,在对话中提及技能名或相关意图即可触发:" foreach ($s in $targets) { Write-Host " - $($s.Name)" } diff --git a/scripts/lookup_groups.py b/scripts/lookup_groups.py index c99577b..f2b0321 100644 --- a/scripts/lookup_groups.py +++ b/scripts/lookup_groups.py @@ -1,4 +1,13 @@ # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "playwright>=1.40", +# "requests>=2.31", +# "openpyxl>=3.1", +# "pandas>=2.0", +# ] +# /// r""" YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本 (Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。 @@ -88,6 +97,10 @@ HTTP_HINTS = { 429: "(限流:调低 --max-workers 或稍后重试)", } +# HTTP/2 伪头(:authority/:method/:path/:scheme):requests 作为普通头发送会抛 +# InvalidHeader。捕获时即过滤,不让伪头流入 bundles.json。 +PSEUDO_HEADER_RE = re.compile(r"^:") + REQUEST_TIMEOUT = 30 MANUAL_WAIT_SECONDS = 180 @@ -256,8 +269,10 @@ def search(bundle: dict, name: str, session=None) -> dict: session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。 """ + # 双保险:捕获时已滤伪头,这里再滤一次兜住手工维护的旧 bundles.json 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) url = bundle.get("url") or ENDPOINT 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): """按 interceptor 同款三种方式拿到 (browser, context, page)。""" if cdp_url: @@ -496,6 +531,12 @@ def _capture_bundle(page, url, wait_seconds, owner_display=""): headers = dict(req.all_headers()) except Exception: # noqa: BLE001 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: body = json.loads(req.post_data or "{}") 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 "全新会话(大概率未登录)") print(f"[捕获] 浏览器会话:{mode}") + if cdp_url: + _assert_cdp_reachable(cdp_url) bundles = [] 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 raise SystemExit( f"[!] 启动/连接浏览器失败: {e}\n" - " 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n" + " 方式 A 需先关闭对应浏览器(有残留进程会报目录被占用);" + "方式 B 先以调试端口启动:\n" " chrome.exe --remote-debugging-port=9222") try: for i, url in enumerate(urls, 1): diff --git a/scripts/youtube_export_interceptor.py b/scripts/youtube_export_interceptor.py index 4e14c16..0fa568f 100644 --- a/scripts/youtube_export_interceptor.py +++ b/scripts/youtube_export_interceptor.py @@ -1,60 +1,49 @@ # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "playwright>=1.40", +# ] +# /// 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)」后,向后端发起: - POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json - 请求体 `exportQuery` 内含 joinRequest 各节点(表格数据/图表数据/总计),以及 - 日期范围 `dateIdRange.inclusiveStart` / `dateIdRange.exclusiveEnd`(都是 YYYYMMDD)。 +登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一: + - --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器) + - --connect http://localhost:9222 :附加到已在调试端口运行的浏览器 -2. 后端**不在服务器上生成一个可下载的 URL**,而是直接把打好的 zip 以 - **base64 字符串**内联在响应里: - { "responseContext": {...}, "zippedData": "" } - 实测 `zippedData` 以 `UEsDBBQ...` 开头(即 `PK\\x03\\x04`,ZIP 魔数), - 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 中产物一致) - <维度标签> _ <账号名>.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 "" --channel chrome ^ - --user-data-dir "%LOCALAPPDATA%\Google\Chrome\User Data" - 方式 B(附加到已在调试端口运行的浏览器,无需关闭): - python youtube_export_interceptor.py --url "" --connect http://localhost:9222 - # 先启动: chrome.exe --remote-debugging-port=9222 或 msedge.exe --remote-debugging-port=9222 +用法: + python youtube_export_download.py --selftest + python youtube_export_download.py --url "" --channel chrome --user-data-dir "C:/Users//AppData/Local/Google/Chrome/User Data" + python youtube_export_download.py --url "" --connect http://localhost:9222 """ import argparse import base64 +import io import json import os import re import sys +import time +import zipfile -# 本机 Windows 下载目录。可改为 os.path.expanduser("~") / "Downloads"。 -DOWNLOAD_DIR = r"D:\Downloads" +DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖 -# 维度类型 -> 文件名前缀标签(生产环境请从页面「维度」按钮文本读取,这里兜底映射)。 +EXPORT_RESPONSE_TIMEOUT = 30 # 每次触发导出后等待 csv_export 响应的秒数 +MAX_EXPORT_ATTEMPTS = 2 # 未捕获到有效响应时的最大触发次数(1 次重试) + +# 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。 DIMENSION_LABEL = { "VIDEO": "内容", "USER": "频道", @@ -64,15 +53,8 @@ DIMENSION_LABEL = { CSV_EXPORT_PATH = "/youtubei/v1/yta_web/csv_export" -# --------------------------------------------------------------------------- # -# 重名去重:需求文件.zip -> 需求文件 (1).zip -> 需求文件 (2).zip ... # -# --------------------------------------------------------------------------- # def dedup_path(directory, filename): - """返回不冲突的落盘路径。重名时按 `名称 (n).后缀` 递增,n 从 1 开始。 - - 规则与用户要求一致:第二次同名保存 `xx (1).zip`,第三次 `xx (2).zip`,以此类推; - 若 `xx (1).zip` 也已存在,则继续找 `xx (2).zip`(即取最小无冲突的 n)。 - """ + """返回不冲突的落盘路径;重名按 `名称 (n).后缀` 递增,n 从 1 起。""" directory = os.path.abspath(directory) base, ext = os.path.splitext(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): - """从 csv_export 请求体 `exportQuery` 反推导出文件名。 - - 与实测产物命名一致:`<维度标签> _ <账号名>.zip` - """ + """从 csv_export 请求体 `exportQuery` 反推文件名。""" def fmt_dateid(yyyymmdd): s = str(yyyymmdd) return f"{s[0:4]}-{s[4:6]}-{s[6:8]}" - # 从任意一个 joinRequest 节点取 dateIdRange date_range = None dimension = None - nodes = (export_query.get("joinRequest", {}).get("nodes") or []) + nodes = export_query.get("joinRequest", {}).get("nodes") or [] for node in nodes: - q = (node.get("value", {}).get("query") or {}) + q = node.get("value", {}).get("query") or {} if not date_range: tr = q.get("timeRange", {}).get("dateIdRange") 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): - """把 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") if not zipped: 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 -# --------------------------------------------------------------------------- # -# Playwright 主流程(拦截响应 -> 解码 -> 去重 -> 落盘) # -# --------------------------------------------------------------------------- # -def intercept_and_save(page, account_name): +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): """给 page 绑定 response 拦截器:命中 csv_export 就把 zip 保存到下载目录。""" import pathlib @@ -139,18 +146,17 @@ def intercept_and_save(page, account_name): try: payload = response.json() data = decode_zipped_data(payload) + verify_zip(data) - # 反推文件名:请求体在 response.request.post_data 里不总是可读, - # 这里从已捕获的 body 兜底;找不到就用时间戳命名,保证不误覆盖。 filename = None try: body = json.loads(response.request.post_data or "{}") filename = build_export_filename(body.get("exportQuery", {}), account_name) except Exception: - filename = f"export-{response.request.headers.get('date', '')}.zip" + filename = "export.zip" # 反推失败兜底,避免丢内容 filename = re.sub(r"[\\/:*?\"<>|]", "_", filename) # Windows 非法字符 - path = dedup_path(DOWNLOAD_DIR, filename) + path = dedup_path(download_dir, filename) pathlib.Path(path).write_bytes(data) saved.append((filename, len(data), path)) 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") -def run(url, user_data_dir=None, channel=None, cdp_url=None): - """启动/连接浏览器并触发导出。 - - 关键:必须复用「已登录 YouTube Studio」的浏览器会话,否则会跳 Google 登录页。 - - cdp_url: 附加到已在调试端口运行的浏览器(推荐,无需关闭浏览器) - - user_data_dir:用已登录的用户数据目录启动持久化上下文(需先关闭该浏览器) - - 两者都不传: 新建空白会话(大概率未登录,仅作占位/调试) - """ +def run(url, user_data_dir=None, channel=None, cdp_url=None, + download_dir=None, account_name=None): + """启动/连接浏览器并触发导出。必须复用已登录会话,否则跳 Google 登录页。""" 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: browser = None context = None if cdp_url: - # 方式 B:附加到已打开、已登录的浏览器(先以调试端口启动浏览器) browser = p.chromium.connect_over_cdp(cdp_url) context = browser.contexts[0] if browser.contexts else \ browser.new_context(accept_downloads=True) page = context.new_page() page.goto(url, wait_until="domcontentloaded") elif user_data_dir: - # 方式 A:用已登录的用户数据目录启动(cookies 复用;必须先关闭同名浏览器) context = p.chromium.launch_persistent_context( user_data_dir=user_data_dir or _default_user_data_dir(channel), channel=channel, @@ -206,27 +208,34 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None): page = context.new_page() 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(" 请用 --user-data-dir 复用已登录浏览器,或先手动登录后再试。", + print(" 请用 --user-data-dir 或 --connect 复用已登录浏览器后重试。", file=sys.stderr) - # 账号名:右上角账号按钮(示例选择器,按实际页面微调) - account_name = "WL Media" - try: - account_name = page.locator( - "ytcp-account-item button, .account-switcher button" - ).first.inner_text(timeout=5000).strip() or account_name - except Exception: - pass + if not account_name: + account_name = "WL Media" + try: + account_name = page.locator( + "ytcp-account-item button, .account-switcher button" + ).first.inner_text(timeout=5000).strip() or account_name + except Exception: + pass - saved = intercept_and_save(page, account_name) + saved = intercept_and_save(page, account_name, download_dir) - # 触发导出:点「导出当前视图」→「逗号分隔值 (.csv)」 - page.get_by_text("导出当前视图").click() - page.get_by_text("逗号分隔值 (.csv)").click() + # 触发导出并等待响应;未捕获到有效 zip(未触发/响应无效)自动重试一次 + for attempt in range(1, MAX_EXPORT_ATTEMPTS + 1): + 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: print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。") else: @@ -239,15 +248,11 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None): context.close() -# --------------------------------------------------------------------------- # -# 自测 # -# --------------------------------------------------------------------------- # def selftest(): import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as td: - # 1) 用户示例:需求文件.zip 第二次 -> 需求文件 (1).zip,第三次 -> (2).zip p0 = Path(dedup_path(td, "需求文件.zip")) assert p0.name == "需求文件.zip", p0.name p0.write_bytes(b"a") @@ -260,14 +265,9 @@ def selftest(): assert p2.name == "需求文件 (2).zip", p2.name p2.write_bytes(b"c") - # 2) 若 (1) 已存在,也应跳到 (2) 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 = { "joinRequest": {"nodes": [{"value": {"query": { "dimensions": [{"type": "VIDEO"}], @@ -278,7 +278,25 @@ def selftest(): name = build_export_filename(export_query, "WL Media") 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__": @@ -287,20 +305,23 @@ if __name__ == "__main__": ap.add_argument("--url", help="explore URL") ap.add_argument("--user-data-dir", help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)") ap.add_argument("--channel", choices=["chrome", "msedge"], - help="浏览器品牌:chrome / msedge(复用登录态时必填其一)") - ap.add_argument("--connect", help="通过 CDP 附加到已打开的浏览器,如 http://localhost:9222") + help="浏览器品牌,复用登录态时必填其一") + 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() if args.selftest: selftest() elif args.url: - run(args.url, user_data_dir=args.user_data_dir, - channel=args.channel, cdp_url=args.connect) + run(args.url, user_data_dir=args.user_data_dir, channel=args.channel, + cdp_url=args.connect, download_dir=args.download_dir, + account_name=args.account_name) else: selftest() - print("\n实际运行请先: pip install playwright\n" - "复用登录态(必选其一,详见脚本顶部 docstring):\n" - " 方式 A: python youtube_export_interceptor.py --url \"\" " - "--channel chrome --user-data-dir \"%LOCALAPPDATA%\\Google\\Chrome\\User Data\"\n" - " 方式 B: python youtube_export_interceptor.py --url \"\" " + print("\n实际运行需先准备依赖环境(uv sync 或直接 uv run,见 uv-env-setup 技能)," + "再复用登录态(二选一):\n" + " 方式 A: python youtube_export_download.py --url \"\" " + "--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n" + " 方式 B: python youtube_export_download.py --url \"\" " "--connect http://localhost:9222") \ No newline at end of file diff --git a/skills/uv-env-setup/SKILL.md b/skills/uv-env-setup/SKILL.md index 0bf2a5f..229dbbc 100644 --- a/skills/uv-env-setup/SKILL.md +++ b/skills/uv-env-setup/SKILL.md @@ -1,61 +1,95 @@ --- 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 运行环境准备 -本项目所有 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 uv --version ``` -未安装时任选其一(Windows): +失败时**按顺序**尝试,命中即停: -- `winget install astral-sh.uv` -- `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` -- `pip install uv` +1. **会话内刷新 PATH 再试**(刚装完 uv、不想重开终端): + - PowerShell:`$env:Path = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Environment]::GetEnvironmentVariable("Path","User")` + - 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 uv sync ``` -- 首次运行自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright)。 +- 首次自动创建 `.venv\` 并安装全部依赖(pandas、openpyxl、playwright、requests)。 - 本机无兼容 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//.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:验证 +项目环境下三条自检: + ```powershell -uv run python -c "import pandas, openpyxl, playwright; print('env OK')" -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 -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/yt-studio-groupid-lookup/scripts/lookup_groups.py --selftest ``` -**完成判据**:三条命令均成功——打印 `env OK`、`selftest OK`、脚本的用法帮助。 +**完成判据**:打印 `env OK` 与两条 `selftest OK`。 ## 其他技能如何使用本环境 -- 统一在项目根目录用 `uv run python <脚本> [参数]` 执行,uv 自动向上定位 `pyproject.toml` 并复用其环境。 -- 不用裸 `python`(依赖 PATH 碰运气),不手动激活 venv,不 `pip install` 到全局。 +- 四个业务技能(url-builder、groupid-lookup、csv-download)的 SKILL.md 默认**本技能已跑通**;它们给出的 `uv run python <脚本>` 命令按所选分支执行即可。 +- 统一不裸调 `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)。 diff --git a/skills/uv-env-setup/references/troubleshooting.md b/skills/uv-env-setup/references/troubleshooting.md index e4c0cc2..711008b 100644 --- a/skills/uv-env-setup/references/troubleshooting.md +++ b/skills/uv-env-setup/references/troubleshooting.md @@ -1,9 +1,20 @@ # 环境排查 -## uv 安装与 PATH +## uv 不识别(按序排查) -- **`uv: command not found` / 无法识别**:装完未重开终端,PATH 未生效;重开终端或手动刷新 `$env:Path`。 -- **winget 安装失败**:改用官方脚本 `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`,或 `pip install uv`。 +1. **刚装完、当前会话不认识**:会话内刷新 PATH—— + - 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`。 - **`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 损坏 / 重建 症状:`uv run` 报奇怪的导入错误、DLL 加载失败,或 `.venv` 被移动过。 @@ -39,10 +58,20 @@ uv sync ## 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`。 - **`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。 diff --git a/skills/youtube-studio-csv-download/SKILL.md b/skills/youtube-studio-csv-download/SKILL.md index 0449527..89e611d 100644 --- a/skills/youtube-studio-csv-download/SKILL.md +++ b/skills/youtube-studio-csv-download/SKILL.md @@ -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` 定位) -- 依赖:playwright 已在根 `pyproject.toml` 统一声明,环境准备见 `uv-env-setup` 技能(复用系统 Chrome/Edge 时无需 `playwright install chromium`) +- 脚本:`scripts/youtube_export_download.py`(本技能目录下;若缺失,用代码库全局搜索按 `youtube_export_download.py` 定位) +- 依赖与环境:见 `uv-env-setup` 技能——项目环境走 `uv sync`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境(复用系统 Chrome/Edge 时无需 `playwright install chromium`)。 - 登录态必须已存在:脚本不登录,只复用已登录会话。 ## 步骤 1:确认登录态来源(二选一) @@ -32,30 +32,43 @@ description: "Download YouTube Studio analytics CSV export (zip) via a Playwrigh ## 步骤 2:运行脚本 +路径写法用正斜杠 + 引号,PowerShell / Git Bash / cmd 通用(`C:/Users//...` 即 `$env:LOCALAPPDATA` 指向的位置)。 + 方式 A: ```powershell -uv run python scripts\youtube_export_download.py --url "" --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data" +uv run python scripts/youtube_export_download.py --url "" --channel chrome --user-data-dir "C:/Users//AppData/Local/Google/Chrome/User Data" ``` 方式 B(先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222` 打开已登录浏览器): ```powershell -uv run python scripts\youtube_export_download.py --url "" --connect http://localhost:9222 +uv run python scripts/youtube_export_download.py --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。 ## 步骤 3:校验产物 +列出下载目录最新的 zip(按所用 shell 选一条): + ```powershell -Get-ChildItem -Path "D:\Downloads" -Filter "*.zip" | Sort-Object LastWriteTime -Descending | Select-Object -First 3 Name, Length, LastWriteTime +Get-ChildItem -Path "" -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 ""/*.zip | head -3 +``` + +**完成判据**:存在最新 `<维度标签> <起始日>_<结束日> <账号名>.zip`(如 `内容 2026-07-23_2026-08-20 WL Media.zip`);同名重复时后缀为 ` (1)`、` (2)`。脚本落盘前已做 zip 完整性校验,能写盘即可解压。 ## 遇到问题 -查 `references/troubleshooting.md`(未登录跳转、目录被占用、CDP 连不上、channel 不匹配、未捕获响应、文件名回退等)。 \ No newline at end of file +查 `references/troubleshooting.md`(未登录跳转、目录被占用、CDP 连不上、channel 不匹配、未捕获响应、文件名回退等)。 + +> **批量场景**:多份 URL 循环下载时,由调用方外层逐条调用本脚本并复用同一登录态,脚本保持单 URL 语义;每条失败可独立重跑。 \ No newline at end of file diff --git a/skills/youtube-studio-csv-download/references/troubleshooting.md b/skills/youtube-studio-csv-download/references/troubleshooting.md index c78d726..3052985 100644 --- a/skills/youtube-studio-csv-download/references/troubleshooting.md +++ b/skills/youtube-studio-csv-download/references/troubleshooting.md @@ -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,必要时重新登录后重跑。 +- **解出的 zip 报损坏 / `Incorrect padding`**:`zippedData` 是 URL-safe 无填充 base64,脚本已按此解码并做完整性校验后落盘;仍报错说明响应内容异常,检查是否被代理/网关改写。 - **文件名变成 `export.zip` 而非标准命名**:请求体解析失败触发兜底,内容仍完整;检查日期范围与账号名是否正常。 - **中文/特殊字符文件名**:已把 Windows 非法字符 `\/:*?"<>|` 自动替换为 `_`,不会因文件名失败。 ## 产物与去重 -- **下载目录不存在导致写盘失败**:脚本不自动建目录。先建 `D:\Downloads`,或用 `--download-dir` 指定已存在目录。 +- **下载目录不存在导致写盘失败**:脚本启动时会自动创建下载目录(`os.makedirs(..., exist_ok=True)`)。沙箱/受限环境看不到 `D:\Downloads` 时,用 `--download-dir` 指向工作区内目录即可。 - **重名文件**:自动按 `名称 (1).zip`、`名称 (2).zip` 递增;这是脚本自己的 dedup_path 逻辑,不依赖浏览器。 \ No newline at end of file diff --git a/skills/youtube-studio-csv-download/scripts/youtube_export_download.py b/skills/youtube-studio-csv-download/scripts/youtube_export_download.py index d3ddfd6..0fa568f 100644 --- a/skills/youtube-studio-csv-download/scripts/youtube_export_download.py +++ b/skills/youtube-studio-csv-download/scripts/youtube_export_download.py @@ -1,13 +1,22 @@ # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "playwright>=1.40", +# ] +# /// r""" YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载脚本 -(拦截响应 + 解码 zip + 自动保存到下载目录 + 重名去重)。 +(拦截响应 + 解码 zip + 完整性校验 + 自动保存到下载目录 + 重名去重)。 机制(已实证):前端点击导出后向后端 POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json 后端把打好的 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 的浏览器会话,二选一: - --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器) @@ -15,19 +24,25 @@ YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv) 用法: python youtube_export_download.py --selftest - python youtube_export_download.py --url "" --channel chrome --user-data-dir "C:\Users\\AppData\Local\Google\Chrome\User Data" + python youtube_export_download.py --url "" --channel chrome --user-data-dir "C:/Users//AppData/Local/Google/Chrome/User Data" python youtube_export_download.py --url "" --connect http://localhost:9222 """ import argparse import base64 +import io import json import os import re import sys +import time +import zipfile DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖 +EXPORT_RESPONSE_TIMEOUT = 30 # 每次触发导出后等待 csv_export 响应的秒数 +MAX_EXPORT_ATTEMPTS = 2 # 未捕获到有效响应时的最大触发次数(1 次重试) + # 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。 DIMENSION_LABEL = { "VIDEO": "内容", @@ -80,11 +95,43 @@ def build_export_filename(export_query, account_name, dimension_label=None): 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") if not zipped: 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): @@ -99,6 +146,7 @@ def intercept_and_save(page, account_name, download_dir): try: payload = response.json() data = decode_zipped_data(payload) + verify_zip(data) filename = None try: @@ -132,6 +180,7 @@ def run(url, user_data_dir=None, channel=None, cdp_url=None, 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: 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) - # 触发导出:点「导出当前视图」→「逗号分隔值 (.csv)」 - page.get_by_text("导出当前视图").click() - page.get_by_text("逗号分隔值 (.csv)").click() - page.wait_for_timeout(3000) + # 触发导出并等待响应;未捕获到有效 zip(未触发/响应无效)自动重试一次 + for attempt in range(1, MAX_EXPORT_ATTEMPTS + 1): + 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) if not saved: print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。") @@ -222,7 +278,25 @@ def selftest(): name = build_export_filename(export_query, "WL Media") 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__": @@ -245,7 +319,8 @@ if __name__ == "__main__": account_name=args.account_name) else: selftest() - print("\n实际运行需先 pip install playwright,再复用登录态(二选一):\n" + print("\n实际运行需先准备依赖环境(uv sync 或直接 uv run,见 uv-env-setup 技能)," + "再复用登录态(二选一):\n" " 方式 A: python youtube_export_download.py --url \"\" " "--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n" " 方式 B: python youtube_export_download.py --url \"\" " diff --git a/skills/yt-studio-groupid-lookup/SKILL.md b/skills/yt-studio-groupid-lookup/SKILL.md index 28cd297..eb2b782 100644 --- a/skills/yt-studio-groupid-lookup/SKILL.md +++ b/skills/yt-studio-groupid-lookup/SKILL.md @@ -14,6 +14,8 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并 - **建 URL 前置**:要生成 explore 报告 URL,但清单里只有群组名、没有 entity_id。 - **排查失败**:回放遇到 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,唯一可靠来源。 @@ -24,8 +26,8 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并 ## 脚本与依赖 -- 脚本:`scripts/lookup_groups.py`(本技能目录下;若缺失,用 SearchCodebase 按 `lookup_groups.py` 定位)。 -- 依赖:playwright、requests、openpyxl 已统一在根 `pyproject.toml` 声明(`uv sync` 自动装),环境准备见 `uv-env-setup` 技能。 +- 脚本:`scripts/lookup_groups.py`(本技能目录下;若缺失,用代码库全局搜索按 `lookup_groups.py` 定位)。 +- 依赖与环境:见 `uv-env-setup` 技能——项目环境走 `uv sync`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境。 - 登录态必须已存在:脚本不登录,只复用已登录 YouTube Studio 会话,否则跳 Google 登录页。 ## 选择运行分支 @@ -44,7 +46,7 @@ description: "把一批群组名批量解析为 entity_id(即 groupId),并 ### 步骤 0:环境与自测(分支 D) ```bash -uv run python scripts\lookup_groups.py --selftest +uv run python scripts/lookup_groups.py --selftest ``` **完成判据**:打印 `selftest OK`,退出码 0(无需浏览器/网络)。 @@ -54,7 +56,8 @@ uv run python scripts\lookup_groups.py --selftest 复用已登录 YouTube Studio 的浏览器会话,否则跳 Google 登录页。与 `youtube-studio-csv-download` 同一套约定: - 方式 A(能关浏览器):`--user-data-dir <目录> --channel chrome|msedge`,二者必须同品牌。 -- 方式 B(浏览器不能关):先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`,再加 `--connect http://localhost:9222`。 +- 方式 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:运行脚本 -分支 A(在线捕获 + 回放): +分支 A(在线捕获 + 回放;路径写法统一正斜杠 + 引号,PowerShell / Git Bash / cmd 通用): ```powershell # 单所有者 -uv run python scripts\lookup_groups.py --url "" --names 名单.xlsx --channel chrome --user-data-dir "$env:LOCALAPPDATA\Google\Chrome\User Data" +uv run python scripts/lookup_groups.py --url "" --names 名单.xlsx --channel chrome --user-data-dir "C:/Users//AppData/Local/Google/Chrome/User Data" # 多所有者:--url 重复;浏览器不能关就改用 --connect -uv run python scripts\lookup_groups.py --url "" --url "" --names 名单.csv --connect http://localhost:9222 --save-bundles bundles.json +uv run python scripts/lookup_groups.py --url "" --url "" --names 名单.csv --connect http://localhost:9222 --save-bundles bundles.json ``` 分支 B(离线回放,套件已存盘,无需浏览器): ```powershell -uv run python scripts\lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx +uv run python scripts/lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx ``` 分支 C(仅捕获/校验套件,暂不查询): @@ -90,8 +93,9 @@ uv run python scripts\lookup_groups.py --url "" --save-bundles bundle **关键行为**: - 打开页面后先**自动触发一次群组搜索**(猜搜索框);猜不中会提示「在浏览器顶部搜索框输入任意词并回车」,只需触发一次 `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 线程)逐个名字 × 逐个所有者,**首个精确命中即停**。 +- 套件是一次性凭据:鉴权头逐请求计算,回放前如间隔较久或全量 401,直接重新在线捕获更新 `bundles.json`。 **完成判据**:终端打印 `[输出] 精确命中 N/M -> <路径>`,退出码 0,且生成结果文件。 diff --git a/skills/yt-studio-groupid-lookup/references/troubleshooting.md b/skills/yt-studio-groupid-lookup/references/troubleshooting.md index 1354918..7e367f6 100644 --- a/skills/yt-studio-groupid-lookup/references/troubleshooting.md +++ b/skills/yt-studio-groupid-lookup/references/troubleshooting.md @@ -14,6 +14,12 @@ - 原因:方式 A 时浏览器未关闭(用户数据目录被占用);方式 B 时调试端口没起。 - 解决:方式 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 不匹配 - 现象:启动后用 --user-data-dir 报告浏览器品牌不符。 - 原因:`--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 - 现象:结果备注大量 `HTTP401(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)`。 - 原因:套件过期(鉴权头是逐请求计算的),或头不全。 @@ -93,4 +109,4 @@ ### 缺依赖报 ModuleNotFoundError - 现象:`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`。 diff --git a/skills/yt-studio-groupid-lookup/scripts/lookup_groups.py b/skills/yt-studio-groupid-lookup/scripts/lookup_groups.py index c99577b..f2b0321 100644 --- a/skills/yt-studio-groupid-lookup/scripts/lookup_groups.py +++ b/skills/yt-studio-groupid-lookup/scripts/lookup_groups.py @@ -1,4 +1,13 @@ # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "playwright>=1.40", +# "requests>=2.31", +# "openpyxl>=3.1", +# "pandas>=2.0", +# ] +# /// r""" YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本 (Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。 @@ -88,6 +97,10 @@ HTTP_HINTS = { 429: "(限流:调低 --max-workers 或稍后重试)", } +# HTTP/2 伪头(:authority/:method/:path/:scheme):requests 作为普通头发送会抛 +# InvalidHeader。捕获时即过滤,不让伪头流入 bundles.json。 +PSEUDO_HEADER_RE = re.compile(r"^:") + REQUEST_TIMEOUT = 30 MANUAL_WAIT_SECONDS = 180 @@ -256,8 +269,10 @@ def search(bundle: dict, name: str, session=None) -> dict: session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。 """ + # 双保险:捕获时已滤伪头,这里再滤一次兜住手工维护的旧 bundles.json 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) url = bundle.get("url") or ENDPOINT 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): """按 interceptor 同款三种方式拿到 (browser, context, page)。""" if cdp_url: @@ -496,6 +531,12 @@ def _capture_bundle(page, url, wait_seconds, owner_display=""): headers = dict(req.all_headers()) except Exception: # noqa: BLE001 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: body = json.loads(req.post_data or "{}") 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 "全新会话(大概率未登录)") print(f"[捕获] 浏览器会话:{mode}") + if cdp_url: + _assert_cdp_reachable(cdp_url) bundles = [] 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 raise SystemExit( f"[!] 启动/连接浏览器失败: {e}\n" - " 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n" + " 方式 A 需先关闭对应浏览器(有残留进程会报目录被占用);" + "方式 B 先以调试端口启动:\n" " chrome.exe --remote-debugging-port=9222") try: for i, url in enumerate(urls, 1): diff --git a/skills/yt-studio-url-builder/SKILL.md b/skills/yt-studio-url-builder/SKILL.md index b0ad0a9..c9eb266 100644 --- a/skills/yt-studio-url-builder/SKILL.md +++ b/skills/yt-studio-url-builder/SKILL.md @@ -13,6 +13,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内 - **制作输入清单**:用户有零散需求(所有者ID、实体、日期范围、国家),需先整理成脚本可读的输入文件。 - **排查问题**:运行后出现失败行、空输出、解析错误,需要定位并修复。 +> **前置依赖**:URL 的 `entity_id` 必须是现成 ID。清单里只有**群组名**时,先走 `yt-studio-groupid-lookup` 技能解析出 groupId 并回填清单,再执行本技能——群组名无法直接拼 URL,这是流程顺序要求而非脚本缺陷。 + ## 领域词汇 - **所有者(Content Owner)**:拥有内容管理器的账号实体,URL 中由 `o` 参数 + `/owner//` 路径标识。 @@ -29,8 +31,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内 ### 1. 定位脚本与依赖 -- 脚本默认位于 `scripts/build_studio_urls.py`;若缺失,用 SearchCodebase 按 `build_studio_urls.py` 定位。 -- 运行环境由 `uv-env-setup` 技能统一管理:依赖(pandas、openpyxl)在根 `pyproject.toml` 声明,`uv run` 自动同步。首次运行前先按该技能准备环境。 +- 脚本默认位于 `scripts/build_studio_urls.py`;若缺失,用代码库全局搜索按 `build_studio_urls.py` 定位。 +- 运行环境由 `uv-env-setup` 技能统一管理:项目环境走 `uv sync` + `uv run`;脱离项目时脚本头部带 PEP 723 声明,`uv run <脚本>` 自动备环境。首次运行前先按该技能选好分支。 - 中文国家名 → ISO 代码的映射在 `scripts/countries.json`,可自行扩充。 完成标准:脚本路径已确定,且第 4 步的运行命令可直接执行。 @@ -60,8 +62,8 @@ description: "根据需求清单(CSV/Excel)批量生成 YouTube Studio 内 ### 4. 运行脚本 ```bash -# 项目根目录下 -uv run python scripts\build_studio_urls.py -i <输入文件> [-o <输出csv>] +# 项目根目录下;路径写法用正斜杠,PowerShell / Git Bash / cmd 通用 +uv run python scripts/build_studio_urls.py -i <输入文件> [-o <输出csv>] ``` - 输入支持 `.csv`(UTF-8 或 GBK,自动尝试)与 `.xlsx` / `.xls`。 diff --git a/skills/yt-studio-url-builder/scripts/build_studio_urls.py b/skills/yt-studio-url-builder/scripts/build_studio_urls.py index cb9d091..469611c 100644 --- a/skills/yt-studio-url-builder/scripts/build_studio_urls.py +++ b/skills/yt-studio-url-builder/scripts/build_studio_urls.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pandas>=2.0", +# "openpyxl>=3.1", +# ] +# /// """ 根据需求清单(CSV/Excel)批量拼接 YouTube Studio 内容管理器 explore URL。 diff --git a/tests/test_lookup_groups.py b/tests/test_lookup_groups.py index 57811d8..1810b93 100644 --- a/tests/test_lookup_groups.py +++ b/tests/test_lookup_groups.py @@ -53,6 +53,63 @@ class FakeSession: 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() def bundle(): tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}}, diff --git a/tests/test_youtube_export_download.py b/tests/test_youtube_export_download.py index a789c55..9c659f1 100644 --- a/tests/test_youtube_export_download.py +++ b/tests/test_youtube_export_download.py @@ -185,6 +185,29 @@ class TestDecodeZippedData: with zipfile.ZipFile(io.BytesIO(out)) as zf: 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): with pytest.raises(ValueError, match="zippedData"): downloader.decode_zipped_data({"foo": "bar"}) @@ -195,6 +218,24 @@ class TestDecodeZippedData: 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 模拟) # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 09e3b12..374d492 100644 --- a/uv.lock +++ b/uv.lock @@ -815,7 +815,7 @@ wheels = [ [[package]] name = "studiolift" -version = "0.3.1" +version = "0.3.4" source = { virtual = "." } dependencies = [ { name = "openpyxl" },