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

@@ -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 "<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
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离线回放套件已存盘无需浏览器
```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 "<owner 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且生成结果文件。

View File

@@ -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 @@
## 回放查询
### 回放报 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
- 现象:结果备注大量 `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`

View File

@@ -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_idgroupId」批量解析脚本
Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
@@ -88,6 +97,10 @@ HTTP_HINTS = {
429: "(限流:调低 --max-workers 或稍后重试)",
}
# HTTP/2 伪头(:authority/:method/:path/:schemerequests 作为普通头发送会抛
# 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):