feat(scripts): 添加 YouTube Studio 群组名到 entity_id 批量解析脚本

支持 Playwright 自动捕获鉴权、requests 回放查询、Excel/CSV 输出,包含在线捕获和离线回放两种模式
This commit is contained in:
2026-08-24 14:05:03 +08:00
parent c1b39e9466
commit bcfe0f4a29
6 changed files with 2074 additions and 23 deletions

274
tests/test_lookup_groups.py Normal file
View File

@@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
"""lookup_groups.py 测试。
覆盖不依赖真实浏览器/登录态/网络的全部逻辑:
parse_owner_id / parse_names_payload / load_names URL 与名单解析
build_body / pick_match / search / resolve 请求构造、匹配、多所有者归并
validate_bundle / merge_bundles 套件校验与合并
write_out csv / xlsx 输出
selftest() / --selftest / 无参 CLI 内置自测与引导输出
capture_bundles() 在线捕获需已登录浏览器会话,不在自动化测试范围。
"""
import json
import pytest
from conftest import LOOKUP_SCRIPT, run_script
# ---------------------------------------------------------------------------
# 测试替身
# ---------------------------------------------------------------------------
class FakeResp:
def __init__(self, code, payload=None):
self.status_code = code
self._payload = payload
def json(self):
if self._payload is None:
raise ValueError("no json")
return self._payload
class FakeSession:
"""按 `模板marker::query` 路由响应,可区分不同所有者的套件。
routing 键形如 "O1::名字"未命中键返回空结果NF
"""
def __init__(self, routing):
self.routing = routing
self.calls = []
def post(self, url, json=None, headers=None, timeout=None):
self.calls.append((url, json, headers))
body = json or {}
key = f"{body.get('marker', '')}::{body.get('query', '')}"
item = self.routing.get(key)
if isinstance(item, Exception):
raise item
if item is not None:
return item
return FakeResp(200, {"groupDatas": []})
@pytest.fixture()
def bundle():
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
"marker": "O1", "query": ""}
return {
"ownerId": "O1", "ownerDisplay": "Owner One",
"url": "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json",
"headers": {
"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
"X-YouTube-Delegation-Context": "ctx",
"Content-Length": "3", "Host": "studio.youtube.com",
},
"bodyTemplate": tpl,
}
# ---------------------------------------------------------------------------
# URL / 名单解析
# ---------------------------------------------------------------------------
class TestParse:
def test_parse_owner_id_path_first(self, lookup_groups):
url = ("https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics"
"?o=WRONG&explore=...")
assert lookup_groups.parse_owner_id(url) == "bqSUnNpU67xJ51TxH4PKpQ"
def test_parse_owner_id_query_fallback(self, lookup_groups):
assert lookup_groups.parse_owner_id(
"https://studio.youtube.com/analytics?o=AbCdEf123") == "AbCdEf123"
assert lookup_groups.parse_owner_id("https://studio.youtube.com/") == ""
def test_parse_names_payload_variants(self, lookup_groups):
assert lookup_groups.parse_names_payload(["A", "B", "A "]) == ["A", "B"]
assert lookup_groups.parse_names_payload(
[["GROUP_NAME"], ["A"], ["B"]]) == ["A", "B"]
assert lookup_groups.parse_names_payload(
[["群组名称", "备注"], ["A", "x"], ["B", "y"]]) == ["A", "B"]
assert lookup_groups.parse_names_payload({"names": ["A", "B"]}) == ["A", "B"]
assert lookup_groups.parse_names_payload({"foo": 1}) == []
assert lookup_groups.parse_names_payload("not-a-list") == []
def test_load_names_txt_csv(self, lookup_groups, tmp_path):
p_txt = tmp_path / "names.txt"
p_txt.write_text("A\n B \n\nA\n", encoding="utf-8")
assert lookup_groups.load_names(str(p_txt)) == ["A", "B"]
p_csv = tmp_path / "names.csv"
p_csv.write_text("group_name,备注\nA,x\nB,\n", encoding="utf-8-sig")
assert lookup_groups.load_names(str(p_csv)) == ["A", "B"]
p_single = tmp_path / "single.csv"
p_single.write_text("A\nB\n", encoding="utf-8")
assert lookup_groups.load_names(str(p_single)) == ["A", "B"]
def test_load_names_json(self, lookup_groups, tmp_path):
p = tmp_path / "names.json"
p.write_text(json.dumps(["A", "B"]), encoding="utf-8")
assert lookup_groups.load_names(str(p)) == ["A", "B"]
def test_load_names_xlsx(self, lookup_groups, tmp_path):
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["群组名称", "备注"])
ws.append(["A", "x"])
ws.append(["B", None])
ws.append([None, "空行跳过"])
p = tmp_path / "names.xlsx"
wb.save(str(p))
assert lookup_groups.load_names(str(p)) == ["A", "B"]
def test_load_names_unknown_column_raises(self, lookup_groups, tmp_path):
p = tmp_path / "bad.csv"
p.write_text("列一,列二\nA,B\n", encoding="utf-8")
with pytest.raises(ValueError, match="无法识别名字列"):
lookup_groups.load_names(str(p))
# ---------------------------------------------------------------------------
# 请求构造 / 匹配 / 回放
# ---------------------------------------------------------------------------
class TestSearch:
def test_build_body_isolation(self, lookup_groups):
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
"query": ""}
body = lookup_groups.build_body(tpl, "靓舟桃")
assert body["query"] == "靓舟桃"
assert tpl["query"] == "" # 模板不被污染
assert body["context"] is not tpl["context"] # 深拷贝
body["context"]["user"]["delegationContext"]["externalOwnerId"] = "CHANGED"
assert tpl["context"]["user"]["delegationContext"]["externalOwnerId"] == "O1"
def test_pick_match(self, lookup_groups):
gd = [{"displayName": "X 漫剧-1", "groupId": "G1"},
{"displayName": "X 漫剧-2", "groupId": "G2"}]
assert lookup_groups.pick_match(gd, "X 漫剧-2") == ("G2", [])
gid, cands = lookup_groups.pick_match(gd, "X 漫剧")
assert gid is None and cands == ["X 漫剧-1=G1", "X 漫剧-2=G2"]
def test_search_ok_nf_http_err(self, lookup_groups, bundle):
sess = FakeSession({
"O1::命中": FakeResp(200, {"groupDatas": [{"displayName": "命中", "groupId": "G9"}]}),
"O1::疑似": FakeResp(200, {"groupDatas": [
{"displayName": "X 漫剧-1", "groupId": "G1"}]}),
"O1::限流": FakeResp(429),
"O1::断网": OSError("refused"),
"O1::坏响应": FakeResp(200, None),
})
assert lookup_groups.search(bundle, "命中", sess) == {"status": "OK", "groupId": "G9"}
r = lookup_groups.search(bundle, "疑似", sess)
assert r["status"] == "NF" and "X 漫剧-1=G1" in r["cands"]
r = lookup_groups.search(bundle, "限流", sess)
assert r["status"] == "HTTP" and r["detail"] == 429 and "限流" in r["hint"]
assert lookup_groups.search(bundle, "断网", sess)["status"] == "ERR"
assert lookup_groups.search(bundle, "坏响应", sess)["status"] == "ERR"
def test_search_strips_hop_by_hop_headers(self, lookup_groups, bundle):
sess = FakeSession({})
lookup_groups.search(bundle, "任意", sess)
url, body, headers = sess.calls[0]
assert "Content-Length" not in headers and "Host" not in headers
assert headers["Authorization"] == "SAPISIDHASH x"
assert headers["X-YouTube-Delegation-Context"] == "ctx"
assert body["query"] == "任意"
def test_resolve_first_hit_and_miss(self, lookup_groups, bundle):
b1 = dict(bundle, ownerId="O1", ownerDisplay="一号")
tpl2 = dict(bundle["bodyTemplate"], marker="O2")
b2 = dict(bundle, ownerId="O2", ownerDisplay="二号", bodyTemplate=tpl2)
sess = FakeSession({
"O2::跨主": FakeResp(200, {"groupDatas": [
{"displayName": "跨主", "groupId": "G2"}]}),
})
row = lookup_groups.resolve("跨主", [b1, b2], sess)
assert row == ["跨主", "O2", "二号", "G2", "精确命中;此前 一号(O1) 无结果"]
row = lookup_groups.resolve("查无", [b1], sess)
assert row[:4] == ["查无", "", "", ""]
assert "未匹配任何 owner" in row[4] and "一号(O1)" in row[4]
def test_resolve_http_hint_in_note(self, lookup_groups, bundle):
sess = FakeSession({"O1::401名字": FakeResp(401)})
row = lookup_groups.resolve("401名字", [bundle], sess)
assert "HTTP401" in row[4] and "鉴权失败" in row[4]
# ---------------------------------------------------------------------------
# 套件校验 / 合并 / 输出
# ---------------------------------------------------------------------------
class TestBundleAndOutput:
def test_validate_bundle(self, lookup_groups):
ok = {"headers": {"Authorization": "x", "Cookie": "y",
"X-YouTube-Delegation-Context": "z"},
"bodyTemplate": {"context": {"user": {"delegationContext": {
"externalOwnerId": "O1"}}}}}
assert lookup_groups.validate_bundle(ok) == []
bad = {"headers": {"Authorization": "x"},
"bodyTemplate": {"context": {}}}
problems = lookup_groups.validate_bundle(bad)
assert any("Cookie" in p for p in problems)
assert any("X-YouTube-Delegation-Context" in p for p in problems)
assert any("delegationContext" in p for p in problems)
assert lookup_groups.validate_bundle({}) != []
def test_merge_bundles(self, lookup_groups):
merged = lookup_groups.merge_bundles(
[{"ownerId": "O1", "v": 1}, {"bad": 1}],
[{"ownerId": "O1", "v": 2}, {"ownerId": "O2", "v": 3}])
assert [b["ownerId"] for b in merged] == ["O1", "O2"]
assert merged[0]["v"] == 2 # captured 覆盖同名
def test_write_out_csv(self, lookup_groups, tmp_path):
out = tmp_path / "result.csv"
path = lookup_groups.write_out(
str(out), lookup_groups.RESULT_HEADER,
[["A", "O1", "一号", "G1", "精确命中"]])
assert path == str(out)
text = out.read_text(encoding="utf-8-sig")
assert "group_name,ownerid,owner_display,groupid,备注" in text
assert "精确命中" in text
def test_write_out_xlsx(self, lookup_groups, tmp_path):
openpyxl = pytest.importorskip("openpyxl")
out = tmp_path / "result.xlsx"
path = lookup_groups.write_out(
str(out), lookup_groups.RESULT_HEADER,
[["A", "O1", "一号", "G1", "精确命中"]])
assert path == str(out)
wb = openpyxl.load_workbook(str(out))
ws = wb["result"]
assert [c.value for c in ws[1]] == lookup_groups.RESULT_HEADER
assert ws["A2"].value == "A" and ws["D2"].value == "G1"
# ---------------------------------------------------------------------------
# selftest / CLI
# ---------------------------------------------------------------------------
class TestCli:
def test_selftest_function(self, lookup_groups, capsys):
lookup_groups.selftest()
out = capsys.readouterr().out
assert "selftest OK" in out
def test_cli_selftest(self):
r = run_script(LOOKUP_SCRIPT, ["--selftest"])
assert r.returncode == 0, r.stderr
assert "selftest OK" in r.stdout
def test_cli_noargs_runs_selftest_and_hint(self):
r = run_script(LOOKUP_SCRIPT, [])
assert r.returncode == 0, r.stderr
assert "selftest OK" in r.stdout
assert "方式 A" in r.stdout and "离线回放" in r.stdout
def test_cli_bad_bundles_file_exits(self, tmp_path):
r = run_script(LOOKUP_SCRIPT, [
"--bundles", str(tmp_path / "nope.json"),
"--names", str(tmp_path / "names.json")])
assert r.returncode != 0
assert "读取 bundles 失败" in r.stdout + r.stderr