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:
@@ -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,且生成结果文件。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user