feat(scripts): 添加批量生成 YouTube Studio 内容管理器 URL 的脚本
This commit is contained in:
67
tests/conftest.py
Normal file
67
tests/conftest.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试公共设施:把两个 skill 目录下的独立脚本按路径加载为模块,供单元测试直接 import。
|
||||
|
||||
两个被测脚本均为独立脚本(非 Python 包),用 importlib 按文件路径加载:
|
||||
- skills/yt-studio-url-builder/scripts/build_studio_urls.py
|
||||
- skills/youtube-studio-csv-download/scripts/youtube_export_download.py
|
||||
|
||||
CLI 端到端测试则通过 subprocess 以 sys.executable 运行脚本(见 test_*_cli.py)。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
URL_BUILDER_SCRIPT = ROOT / "skills" / "yt-studio-url-builder" / "scripts" / "build_studio_urls.py"
|
||||
COUNTRIES_JSON = URL_BUILDER_SCRIPT.parent / "countries.json"
|
||||
DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
|
||||
|
||||
|
||||
def _load_module(name, path):
|
||||
"""按文件路径加载独立脚本为 Python 模块。"""
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def url_builder():
|
||||
"""build_studio_urls.py 模块(会话级,加载一次)。"""
|
||||
return _load_module("build_studio_urls_under_test", URL_BUILDER_SCRIPT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def downloader():
|
||||
"""youtube_export_download.py 模块(会话级,加载一次)。"""
|
||||
return _load_module("youtube_export_download_under_test", DOWNLOADER_SCRIPT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def real_countries(url_builder):
|
||||
"""脚本自带 countries.json 加载出的国家映射。"""
|
||||
return url_builder.load_country_map(str(COUNTRIES_JSON))
|
||||
|
||||
|
||||
def run_script(script, args, cwd=None):
|
||||
"""以子进程运行被测脚本(uv run pytest 下 sys.executable 即 venv python)。
|
||||
|
||||
强制子进程 PYTHONUTF8=1,避免 Windows 管道输出按 GBK 解码导致中文乱码。
|
||||
"""
|
||||
env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
400
tests/test_build_studio_urls.py
Normal file
400
tests/test_build_studio_urls.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""build_studio_urls.py 单元测试。
|
||||
|
||||
覆盖纯逻辑函数(不落盘、不起子进程):
|
||||
ts() 日期 -> 日界线毫秒(锚点 + 整日偏移)
|
||||
parse_period() 数据周期解析(多种写法、含首尾日)
|
||||
normalize_columns() 输入列名别名映射
|
||||
load_country_map() 国家映射加载
|
||||
parse_countries() 国家列解析(中文名/ISO 码/多分隔符)
|
||||
resolve_entity_type() 实体类型解析
|
||||
build_url() URL 拼接(参数完整性、编码、缺字段错误)
|
||||
|
||||
CLI 端到端流程见 test_build_studio_urls_cli.py。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from conftest import COUNTRIES_JSON
|
||||
|
||||
# 文档化锚点:2026-06-15 = 1781506800000(见 docs/adr/0001,脚本内 ANCHOR_MS)
|
||||
ANCHOR_MS = 1781506800000
|
||||
MS_PER_DAY = 86400000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ts:日界线毫秒
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTs:
|
||||
def test_anchor_date(self, url_builder):
|
||||
assert url_builder.ts(2026, 6, 15) == ANCHOR_MS
|
||||
|
||||
def test_next_day(self, url_builder):
|
||||
assert url_builder.ts(2026, 6, 16) == ANCHOR_MS + MS_PER_DAY
|
||||
|
||||
def test_prev_day(self, url_builder):
|
||||
assert url_builder.ts(2026, 6, 14) == ANCHOR_MS - MS_PER_DAY
|
||||
|
||||
def test_month_boundary(self, url_builder):
|
||||
assert url_builder.ts(2026, 7, 1) == url_builder.ts(2026, 6, 30) + MS_PER_DAY
|
||||
|
||||
def test_year_boundary(self, url_builder):
|
||||
assert url_builder.ts(2027, 1, 1) == url_builder.ts(2026, 12, 31) + MS_PER_DAY
|
||||
|
||||
def test_anchor_matches_module_constant(self, url_builder):
|
||||
assert url_builder.ts(2026, 6, 15) == url_builder.ANCHOR_MS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_period:数据周期
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestParsePeriod:
|
||||
def test_standard_dot_format(self, url_builder):
|
||||
start, end = url_builder.parse_period("2026.07.01-2026.08.01")
|
||||
assert start == url_builder.ts(2026, 7, 1)
|
||||
assert end == url_builder.ts(2026, 8, 1) + MS_PER_DAY # 结束日包含
|
||||
|
||||
def test_single_digit_month_day(self, url_builder):
|
||||
assert url_builder.parse_period("2026.7.1-2026.8.1") == \
|
||||
url_builder.parse_period("2026.07.01-2026.08.01")
|
||||
|
||||
def test_dash_date_with_tilde(self, url_builder):
|
||||
start, end = url_builder.parse_period("2026-07-01~2026-08-01")
|
||||
assert start == url_builder.ts(2026, 7, 1)
|
||||
assert end == url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||||
|
||||
def test_full_width_tilde(self, url_builder):
|
||||
start, _ = url_builder.parse_period("2026.07.01~2026.08.01")
|
||||
assert start == url_builder.ts(2026, 7, 1)
|
||||
|
||||
def test_spaces_around_separator(self, url_builder):
|
||||
start, _ = url_builder.parse_period("2026.07.01 - 2026.08.01")
|
||||
assert start == url_builder.ts(2026, 7, 1)
|
||||
|
||||
def test_same_day_period(self, url_builder):
|
||||
start, end = url_builder.parse_period("2026.07.01-2026.07.01")
|
||||
assert start == url_builder.ts(2026, 7, 1)
|
||||
assert end == start + MS_PER_DAY # 单日区间跨度正好一天
|
||||
|
||||
def test_end_date_inclusive(self, url_builder):
|
||||
"""结束日包含在范围内:time_period 结束值 = 结束日次日日界线。"""
|
||||
_, end = url_builder.parse_period("2026.07.01-2026.07.31")
|
||||
assert end == url_builder.ts(2026, 8, 1)
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"", # 空
|
||||
"2026.07.01", # 只有起始日
|
||||
"20260701-20260801", # 无分隔符
|
||||
"abc-def", # 非日期
|
||||
"2026.07-2026.08", # 缺日
|
||||
"2026.07.01 至 2026.08.01", # 不支持的连接词
|
||||
])
|
||||
def test_invalid_format_raises(self, url_builder, bad):
|
||||
with pytest.raises(ValueError):
|
||||
url_builder.parse_period(bad)
|
||||
|
||||
def test_invalid_month_raises(self, url_builder):
|
||||
with pytest.raises(ValueError):
|
||||
url_builder.parse_period("2026.13.01-2026.08.01")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_columns:列名别名映射
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNormalizeColumns:
|
||||
def test_chinese_aliases(self, url_builder):
|
||||
df = pd.DataFrame(columns=["所有者名称", "所有者ID", "群组名称", "群组ID",
|
||||
"实体类型", "数据周期", "国家"])
|
||||
mapping, unknown = url_builder.normalize_columns(df)
|
||||
assert mapping == {
|
||||
"owner_name": "所有者名称", "owner_id": "所有者ID",
|
||||
"entity_name": "群组名称", "entity_id": "群组ID",
|
||||
"entity_type": "实体类型", "period": "数据周期", "countries": "国家",
|
||||
}
|
||||
assert unknown == []
|
||||
|
||||
def test_english_aliases(self, url_builder):
|
||||
df = pd.DataFrame(columns=["owner_name", "owner_id", "group_name", "group_id",
|
||||
"entity_type", "period", "country"])
|
||||
mapping, unknown = url_builder.normalize_columns(df)
|
||||
assert mapping == {
|
||||
"owner_name": "owner_name", "owner_id": "owner_id",
|
||||
"entity_name": "group_name", "entity_id": "group_id",
|
||||
"entity_type": "entity_type", "period": "period", "countries": "country",
|
||||
}
|
||||
assert unknown == []
|
||||
|
||||
def test_short_aliases(self, url_builder):
|
||||
"""所有者ID 的超短别名 o、实体ID 的别名 id。"""
|
||||
df = pd.DataFrame(columns=["o", "id", "period"])
|
||||
mapping, _ = url_builder.normalize_columns(df)
|
||||
assert mapping["owner_id"] == "o"
|
||||
assert mapping["entity_id"] == "id"
|
||||
|
||||
def test_column_name_normalized_before_lookup(self, url_builder):
|
||||
"""列名先去空白、转小写再匹配:' Owner_ID ' 可识别。"""
|
||||
df = pd.DataFrame(columns=[" Owner_ID ", "Period"])
|
||||
mapping, _ = url_builder.normalize_columns(df)
|
||||
assert mapping["owner_id"] == " Owner_ID "
|
||||
assert mapping["period"] == "Period"
|
||||
|
||||
def test_unknown_columns_reported(self, url_builder):
|
||||
df = pd.DataFrame(columns=["所有者ID", "数据周期", "备注", "extra"])
|
||||
mapping, unknown = url_builder.normalize_columns(df)
|
||||
assert "owner_id" in mapping and "period" in mapping
|
||||
assert unknown == ["备注", "extra"]
|
||||
|
||||
def test_country_slash_alias(self, url_builder):
|
||||
df = pd.DataFrame(columns=["国家/地区"])
|
||||
mapping, _ = url_builder.normalize_columns(df)
|
||||
assert mapping["countries"] == "国家/地区"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_country_map:国家映射
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestLoadCountryMap:
|
||||
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
|
||||
path = tmp_path / "not_exists.json"
|
||||
assert url_builder.load_country_map(str(path)) == {}
|
||||
assert "未找到国家映射文件" in capsys.readouterr().err
|
||||
|
||||
def test_bom_file_loads(self, url_builder, tmp_path):
|
||||
"""utf-8-sig(Excel 另存带 BOM)可正常加载。"""
|
||||
p = tmp_path / "c.json"
|
||||
p.write_bytes('{"美国": "us"}'.encode("utf-8-sig"))
|
||||
assert url_builder.load_country_map(str(p)) == {"美国": "US"}
|
||||
|
||||
def test_values_trimmed_and_uppercased(self, url_builder, tmp_path):
|
||||
p = tmp_path / "c.json"
|
||||
p.write_text('{ "美国" : " us " , "日本" : "jp" }', encoding="utf-8")
|
||||
assert url_builder.load_country_map(str(p)) == {"美国": "US", "日本": "JP"}
|
||||
|
||||
def test_real_countries_json(self, real_countries):
|
||||
assert real_countries["美国"] == "US"
|
||||
assert real_countries["日本"] == "JP"
|
||||
assert real_countries["中国台湾"] == "TW"
|
||||
assert real_countries["香港"] == "HK"
|
||||
# 真实映射规模足够覆盖常用地区
|
||||
assert len(real_countries) > 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_countries:国家列解析
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestParseCountries:
|
||||
def test_none_returns_empty(self, url_builder):
|
||||
assert url_builder.parse_countries(None, {}) == []
|
||||
|
||||
def test_nan_returns_empty(self, url_builder):
|
||||
assert url_builder.parse_countries(float("nan"), {}) == []
|
||||
|
||||
def test_empty_string_returns_empty(self, url_builder):
|
||||
assert url_builder.parse_countries("", {}) == []
|
||||
|
||||
def test_single_iso_code(self, url_builder):
|
||||
assert url_builder.parse_countries("US", {}) == ["US"]
|
||||
|
||||
def test_lowercase_iso_uppercased(self, url_builder):
|
||||
assert url_builder.parse_countries("us", {}) == ["US"]
|
||||
|
||||
def test_chinese_name_mapped(self, url_builder):
|
||||
assert url_builder.parse_countries("美国", {"美国": "US"}) == ["US"]
|
||||
|
||||
@pytest.mark.parametrize("sep", [",", ",", "、", ";", ";", " ", "|"])
|
||||
def test_multiple_separators(self, url_builder, sep):
|
||||
text = sep.join(["美国", "日本"])
|
||||
assert url_builder.parse_countries(text, {"美国": "US", "日本": "JP"}) == ["US", "JP"]
|
||||
|
||||
def test_mixed_code_and_chinese(self, url_builder):
|
||||
assert url_builder.parse_countries("US,日本", {"日本": "JP"}) == ["US", "JP"]
|
||||
|
||||
def test_quotes_stripped(self, url_builder):
|
||||
assert url_builder.parse_countries("'US' \"JP\"", {}) == ["US", "JP"]
|
||||
|
||||
def test_unknown_name_raises(self, url_builder):
|
||||
with pytest.raises(ValueError, match="未识别的国家"):
|
||||
url_builder.parse_countries("亚特兰蒂斯", {"美国": "US"})
|
||||
|
||||
def test_chinese_name_without_map_raises(self, url_builder):
|
||||
with pytest.raises(ValueError, match="未识别的国家"):
|
||||
url_builder.parse_countries("美国", {})
|
||||
|
||||
def test_real_map_common_countries(self, url_builder, real_countries):
|
||||
assert url_builder.parse_countries("美国,日本,英国", real_countries) == ["US", "JP", "GB"]
|
||||
assert url_builder.parse_countries("中国台湾、香港", real_countries) == ["TW", "HK"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_entity_type:实体类型
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolveEntityType:
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("群组", "GROUP"), ("GROUP", "GROUP"),
|
||||
("所有者", "CONTENT_OWNER"), ("账号", "CONTENT_OWNER"), ("CONTENT_OWNER", "CONTENT_OWNER"),
|
||||
("频道", "CHANNEL"), ("CHANNEL", "CHANNEL"),
|
||||
("节目", "VIDEO"), ("视频", "VIDEO"), ("VIDEO", "VIDEO"),
|
||||
])
|
||||
def test_known_types(self, url_builder, text, expected):
|
||||
assert url_builder.resolve_entity_type(text) == expected
|
||||
|
||||
@pytest.mark.parametrize("empty", ["", None, float("nan")])
|
||||
def test_empty_defaults_to_group(self, url_builder, empty):
|
||||
assert url_builder.resolve_entity_type(empty) == "GROUP"
|
||||
|
||||
def test_whitespace_only_defaults_to_group(self, url_builder):
|
||||
assert url_builder.resolve_entity_type(" ") == "GROUP"
|
||||
|
||||
def test_unknown_raises(self, url_builder):
|
||||
with pytest.raises(ValueError, match="未识别的实体类型"):
|
||||
url_builder.resolve_entity_type("星球")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_url:URL 拼接
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_row(**overrides):
|
||||
row = {
|
||||
"owner_name": "示例所有者",
|
||||
"owner_id": "MC123",
|
||||
"entity_name": "示例群组",
|
||||
"entity_id": "NCy9C2QPQ1E",
|
||||
"entity_type": "",
|
||||
"period": "2026.07.01-2026.08.01",
|
||||
"countries": "",
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestBuildUrl:
|
||||
def test_full_url_exact(self, url_builder):
|
||||
"""完整 URL 精确匹配(含参数顺序与编码)。"""
|
||||
row = make_row(countries="美国,日本")
|
||||
url, status, msg = url_builder.build_url(row, {"美国": "US", "日本": "JP"})
|
||||
assert (status, msg) == ("ok", "")
|
||||
|
||||
c = url_builder.CONFIG
|
||||
start = url_builder.ts(2026, 7, 1)
|
||||
end = url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||||
expected = (
|
||||
"https://studio.youtube.com/owner/MC123/analytics/tab-overview/period-default/explore"
|
||||
f"?o=MC123&entity_type=GROUP&entity_id=NCy9C2QPQ1E"
|
||||
f"&ur_dimensions=COUNTRY&ur_values=%27US%27%7C%27JP%27"
|
||||
f"&ur_inclusive_starts=&ur_exclusive_ends="
|
||||
f"&time_period={start}%2C{end}"
|
||||
f"&explore_type={c['explore_type']}&metric={c['metric']}&granularity={c['granularity']}"
|
||||
+ "".join(f"&t_metrics={m}" for m in c["t_metrics"])
|
||||
+ f"&dimension={c['dimension']}&o_column={c['o_column']}"
|
||||
f"&o_direction={c['o_direction']}&comparison_type={c['comparison_type']}"
|
||||
)
|
||||
assert url == expected
|
||||
|
||||
def test_url_parts(self, url_builder):
|
||||
url, status, _ = url_builder.build_url(make_row(countries="US"), {})
|
||||
assert status == "ok"
|
||||
assert url.startswith(
|
||||
"https://studio.youtube.com/owner/MC123/analytics/tab-overview/period-default/explore?")
|
||||
assert "o=MC123" in url
|
||||
assert "entity_type=GROUP" in url # 空实体类型默认 GROUP
|
||||
assert "entity_id=NCy9C2QPQ1E" in url
|
||||
assert "ur_dimensions=COUNTRY" in url
|
||||
assert "ur_values=%27US%27" in url # 单国同样用 %27 包裹
|
||||
assert "granularity=DAY" in url
|
||||
assert "dimension=USER" in url
|
||||
assert "o_direction=ANALYTICS_ORDER_DIRECTION_DESC" in url
|
||||
|
||||
def test_time_period_values(self, url_builder):
|
||||
url, _, _ = url_builder.build_url(make_row(), {})
|
||||
start = url_builder.ts(2026, 7, 1)
|
||||
end = url_builder.ts(2026, 8, 1) + MS_PER_DAY
|
||||
assert "time_period=%d%%2C%d" % (start, end) in url # 逗号编码为 %2C
|
||||
|
||||
def test_t_metrics_repeat_per_config(self, url_builder):
|
||||
url, _, _ = url_builder.build_url(make_row(), {})
|
||||
assert url.count("t_metrics=") == len(url_builder.CONFIG["t_metrics"])
|
||||
for m in url_builder.CONFIG["t_metrics"]:
|
||||
assert "t_metrics=%s" % m in url
|
||||
|
||||
def test_no_country_omits_country_params(self, url_builder):
|
||||
url, _, _ = url_builder.build_url(make_row(), {})
|
||||
assert "ur_dimensions" not in url
|
||||
assert "ur_values" not in url
|
||||
assert "ur_inclusive_starts" not in url
|
||||
|
||||
def test_entity_type_channel(self, url_builder):
|
||||
url, _, _ = url_builder.build_url(
|
||||
make_row(entity_type="频道", entity_id="UCxyz"), {})
|
||||
assert "entity_type=CHANNEL" in url
|
||||
assert "entity_id=UCxyz" in url
|
||||
|
||||
def test_content_owner_falls_back_to_owner_id(self, url_builder):
|
||||
"""所有者整体场景:实体ID留空时回退用所有者ID。"""
|
||||
row = make_row(entity_type="CONTENT_OWNER", entity_id="")
|
||||
url, status, msg = url_builder.build_url(row, {})
|
||||
assert (status, msg) == ("ok", "")
|
||||
assert "entity_type=CONTENT_OWNER" in url
|
||||
assert "entity_id=MC123" in url
|
||||
|
||||
def test_missing_owner_id(self, url_builder):
|
||||
url, status, msg = url_builder.build_url(make_row(owner_id=""), {})
|
||||
assert url is None
|
||||
assert status == "error"
|
||||
assert msg == "缺少所有者ID"
|
||||
|
||||
def test_missing_entity_id_for_group(self, url_builder):
|
||||
url, status, msg = url_builder.build_url(make_row(entity_id=""), {})
|
||||
assert url is None
|
||||
assert status == "error"
|
||||
assert msg == "缺少实体ID"
|
||||
|
||||
def test_missing_entity_id_for_channel(self, url_builder):
|
||||
url, status, msg = url_builder.build_url(
|
||||
make_row(entity_type="频道", entity_id=""), {})
|
||||
assert url is None
|
||||
assert status == "error"
|
||||
assert msg == "缺少实体ID"
|
||||
|
||||
def test_missing_period(self, url_builder):
|
||||
url, status, msg = url_builder.build_url(make_row(period=""), {})
|
||||
assert url is None
|
||||
assert status == "error"
|
||||
assert msg == "缺少数据周期"
|
||||
|
||||
def test_bad_period_raises_value_error(self, url_builder):
|
||||
with pytest.raises(ValueError):
|
||||
url_builder.build_url(make_row(period="not-a-period"), {})
|
||||
|
||||
def test_unknown_country_raises_value_error(self, url_builder):
|
||||
with pytest.raises(ValueError, match="未识别的国家"):
|
||||
url_builder.build_url(make_row(countries="火星"), {"美国": "US"})
|
||||
|
||||
def test_unknown_entity_type_raises_value_error(self, url_builder):
|
||||
with pytest.raises(ValueError, match="未识别的实体类型"):
|
||||
url_builder.build_url(make_row(entity_type="星球"), {})
|
||||
|
||||
def test_owner_id_checked_before_entity(self, url_builder):
|
||||
"""所有者ID 缺失时报错优先于实体ID。"""
|
||||
_, status, msg = url_builder.build_url(make_row(owner_id="", entity_id=""), {})
|
||||
assert (status, msg) == ("error", "缺少所有者ID")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 其他:nan 判断辅助行为(build_url 入参可能来自 pandas)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNanHandling:
|
||||
def test_period_nan_string_is_error(self, url_builder):
|
||||
"""main() 会把 NaN 转 '',这里验证空串路径报「缺少数据周期」。"""
|
||||
_, status, msg = url_builder.build_url(make_row(period=""), {})
|
||||
assert msg == "缺少数据周期"
|
||||
|
||||
def test_parse_countries_nan(self, url_builder):
|
||||
assert url_builder.parse_countries(float("nan"), {}) == []
|
||||
|
||||
def test_plain_float_treated_as_text(self, url_builder):
|
||||
"""普通 float(如 3.14)不是 NaN:走字符串解析分支,非两位代码则报未识别。"""
|
||||
with pytest.raises(ValueError, match="未识别的国家"):
|
||||
url_builder.parse_countries(3.14, {})
|
||||
268
tests/test_build_studio_urls_cli.py
Normal file
268
tests/test_build_studio_urls_cli.py
Normal file
@@ -0,0 +1,268 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""build_studio_urls.py CLI 端到端测试。
|
||||
|
||||
以子进程运行脚本(与真实使用方式一致:uv run python build_studio_urls.py -i ... -o ...),
|
||||
覆盖输入格式(CSV UTF-8-BOM / GBK / XLSX)、成功输出、失败行与退出码约定:
|
||||
|
||||
退出码 0 :全部行生成成功
|
||||
退出码 1 :输入文件不存在 / 格式不支持 / 缺必要列
|
||||
退出码 2 :存在失败行(成功行仍写入输出,失败行逐行打印在 stderr)
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from conftest import ROOT, URL_BUILDER_SCRIPT, run_script
|
||||
|
||||
ASSET_XLSX = ROOT / "assets" / "需求输入示例.xlsx"
|
||||
|
||||
HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"]
|
||||
|
||||
OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID",
|
||||
"数据周期", "国家", "国家代码", "开始时间戳", "结束时间戳", "URL"]
|
||||
|
||||
|
||||
def run_builder(args):
|
||||
return run_script(URL_BUILDER_SCRIPT, args)
|
||||
|
||||
|
||||
def read_output(path):
|
||||
return pd.read_csv(path, encoding="utf-8-sig")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 输入格式
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestInputFormats:
|
||||
def test_csv_utf8_bom(self, tmp_path):
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
",".join(HEADERS) + "\n"
|
||||
"示例所有者,MC123,群组,示例群组,G001,2026.07.01-2026.08.01,美国\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "已生成 1 条 URL" in r.stdout
|
||||
df = read_output(out)
|
||||
assert list(df.columns) == OUTPUT_COLUMNS
|
||||
assert len(df) == 1
|
||||
assert df.loc[0, "国家代码"] == "US"
|
||||
assert df.loc[0, "URL"].startswith("https://studio.youtube.com/owner/MC123/")
|
||||
|
||||
def test_csv_gbk_fallback(self, tmp_path):
|
||||
"""Excel 另存 ANSI/GBK 编码 CSV 也能读。"""
|
||||
src = tmp_path / "需求_gbk.csv"
|
||||
src.write_text(
|
||||
",".join(HEADERS) + "\n"
|
||||
"示例所有者,MC123,群组,示例群组,G001,2026.07.01-2026.08.01,美国\n",
|
||||
encoding="gbk",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
df = read_output(out)
|
||||
assert len(df) == 1
|
||||
assert df.loc[0, "国家代码"] == "US"
|
||||
|
||||
def test_xlsx_input(self, tmp_path):
|
||||
src = tmp_path / "需求.xlsx"
|
||||
pd.DataFrame([
|
||||
{"所有者名称": "示例所有者", "所有者ID": "MC123", "实体类型": "群组",
|
||||
"实体名称": "示例群组", "实体ID": "G001",
|
||||
"数据周期": "2026.07.01-2026.08.01", "国家": "US"},
|
||||
]).to_excel(src, index=False)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
df = read_output(out)
|
||||
assert len(df) == 1
|
||||
assert "ur_values=%27US%27" in df.loc[0, "URL"]
|
||||
|
||||
def test_unsupported_extension_exits_1(self, tmp_path):
|
||||
src = tmp_path / "需求.txt"
|
||||
src.write_text("所有者ID,数据周期\nMC123,2026.07.01-2026.08.01", encoding="utf-8")
|
||||
r = run_builder(["-i", str(src), "-o", str(tmp_path / "out.csv")])
|
||||
assert r.returncode == 1
|
||||
assert "不支持的输入格式" in r.stderr
|
||||
|
||||
def test_missing_file_exits_1(self, tmp_path):
|
||||
r = run_builder(["-i", str(tmp_path / "不存在.csv"), "-o", str(tmp_path / "out.csv")])
|
||||
assert r.returncode == 1
|
||||
assert "输入文件不存在" in r.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 成功路径与输出内容
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestHappyPath:
|
||||
def test_output_content(self, tmp_path):
|
||||
src = tmp_path / "需求.csv"
|
||||
pd.DataFrame([
|
||||
{"所有者名称": "所有者A", "所有者ID": "MC123", "实体类型": "群组",
|
||||
"实体名称": "群组1", "实体ID": "G001",
|
||||
"数据周期": "2026.07.01-2026.08.01", "国家": "美国,日本"},
|
||||
{"所有者名称": "所有者B", "所有者ID": "MC456", "实体类型": "频道",
|
||||
"实体名称": "频道1", "实体ID": "UCxyz",
|
||||
"数据周期": "2026.8.1-2026.8.31", "国家": ""},
|
||||
]).to_csv(src, index=False, encoding="utf-8-sig")
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
df = read_output(out)
|
||||
assert len(df) == 2
|
||||
|
||||
row0, row1 = df.iloc[0], df.iloc[1]
|
||||
assert row0["国家代码"] == "US,JP"
|
||||
assert "ur_values=%27US%27%7C%27JP%27" in row0["URL"]
|
||||
# 时间戳 = 日界线毫秒(独立于模块重算:锚点 2026-06-15 + 整日偏移)
|
||||
anchor = 1781506800000
|
||||
assert row0["开始时间戳"] == anchor + 16 * 86400000 # 2026-07-01
|
||||
assert row0["结束时间戳"] == anchor + 48 * 86400000 # 2026-08-01 次日
|
||||
# 无国家行:不含国家筛选参数,实体类型为频道
|
||||
assert "ur_values" not in row1["URL"]
|
||||
assert "entity_type=CHANNEL" in row1["URL"]
|
||||
assert "entity_id=UCxyz" in row1["URL"]
|
||||
assert row1["国家代码"] == "" or pd.isna(row1["国家代码"])
|
||||
|
||||
def test_default_output_next_to_input(self, tmp_path):
|
||||
"""不传 -o 时输出到输入同目录 studio_urls_output.csv。"""
|
||||
src = tmp_path / "sub"
|
||||
src.mkdir()
|
||||
inp = src / "需求.csv"
|
||||
inp.write_text(
|
||||
"所有者ID,实体ID,数据周期\nMC123,G001,2026.07.01-2026.08.01",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
r = run_builder(["-i", str(inp)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (src / "studio_urls_output.csv").exists()
|
||||
assert "已生成 1 条 URL" in r.stdout
|
||||
|
||||
def test_blank_rows_dropped(self, tmp_path):
|
||||
"""全空行不产出 URL、不算失败。"""
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
"所有者ID,实体ID,数据周期\n"
|
||||
"MC123,G001,2026.07.01-2026.08.01\n"
|
||||
",,\n"
|
||||
"MC456,G002,2026.07.01-2026.08.01\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "已生成 2 条 URL" in r.stdout
|
||||
|
||||
def test_custom_countries_json(self, tmp_path):
|
||||
cj = tmp_path / "my_countries.json"
|
||||
cj.write_text('{"梦幻国": "ZZ"}', encoding="utf-8")
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
"所有者ID,实体ID,数据周期,国家\nMC123,G001,2026.07.01-2026.08.01,梦幻国",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out), "--countries", str(cj)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
df = read_output(out)
|
||||
assert df.loc[0, "国家代码"] == "ZZ"
|
||||
assert "ur_values=%27ZZ%27" in df.loc[0, "URL"]
|
||||
|
||||
def test_unknown_columns_warned_but_ok(self, tmp_path):
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
"所有者ID,实体ID,数据周期,备注\nMC123,G001,2026.07.01-2026.08.01,随便写",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "未识别的列" in r.stderr and "备注" in r.stderr
|
||||
assert len(read_output(out)) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 失败行与退出码
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestErrorRows:
|
||||
def test_error_row_exits_2_and_reports_line(self, tmp_path):
|
||||
"""失败行:退出码 2,stderr 报「第N行」(N 按文件行号,含表头);成功行照常输出。"""
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
"所有者ID,实体ID,数据周期\n"
|
||||
"MC123,G001,2026.07.01-2026.08.01\n" # 第2行 成功
|
||||
"MC123,,2026.07.01-2026.08.01\n" # 第3行 缺实体ID -> 失败
|
||||
"MC456,G002,2026.07.01-2026.08.01\n", # 第4行 成功
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 2
|
||||
assert "已生成 2 条 URL" in r.stdout
|
||||
assert "以下 1 行生成失败" in r.stderr
|
||||
assert "第3行" in r.stderr
|
||||
assert "缺少实体ID" in r.stderr
|
||||
assert len(read_output(out)) == 2
|
||||
|
||||
def test_missing_required_column_exits_1(self, tmp_path):
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text("所有者名称,实体名称\nA,B\n", encoding="utf-8-sig")
|
||||
r = run_builder(["-i", str(src), "-o", str(tmp_path / "out.csv")])
|
||||
assert r.returncode == 1
|
||||
# 提示缺失的规范字段名(owner_id 与 period 同时缺失时都列出)
|
||||
assert "缺少必要列" in r.stderr
|
||||
assert "owner_id" in r.stderr
|
||||
assert "period" in r.stderr
|
||||
|
||||
def test_all_rows_fail_still_writes_empty_output(self, tmp_path):
|
||||
src = tmp_path / "需求.csv"
|
||||
src.write_text(
|
||||
"所有者ID,实体ID,数据周期\nMC123,,2026.07.01-2026.08.01\n",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(src), "-o", str(out)])
|
||||
assert r.returncode == 2
|
||||
assert "已生成 0 条 URL" in r.stdout
|
||||
assert out.exists() # 空结果也落盘(仅表头)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 集成:真实资产文件
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRealAsset:
|
||||
def test_example_asset_generates_all_urls(self, tmp_path):
|
||||
"""assets/需求输入示例.xlsx:6 条示例(群组/所有者/频道/节目)全部成功。"""
|
||||
out = tmp_path / "out.csv"
|
||||
r = run_builder(["-i", str(ASSET_XLSX), "-o", str(out)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
df = read_output(out)
|
||||
assert len(df) == 6
|
||||
# 所有者整体行:实体ID 为空时回退用所有者ID
|
||||
owner_row = df[df["实体类型"] == "所有者"].iloc[0]
|
||||
assert "entity_type=CONTENT_OWNER" in owner_row["URL"]
|
||||
assert "entity_id=bqSUnNpU67xJ51TxH4PKpQ" in owner_row["URL"]
|
||||
# 实体类型枚举全覆盖
|
||||
assert set(df["实体类型"]) == {"群组", "所有者", "频道", "节目"}
|
||||
# 每行 URL 均含 owner 路径与 time_period
|
||||
for url in df["URL"]:
|
||||
assert url.startswith(
|
||||
"https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics/")
|
||||
assert "time_period=" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI 参数
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCliArgs:
|
||||
def test_help(self):
|
||||
r = run_builder(["--help"])
|
||||
assert r.returncode == 0
|
||||
assert "批量拼接 YouTube Studio explore URL" in r.stdout
|
||||
|
||||
def test_input_required(self):
|
||||
r = run_builder([])
|
||||
assert r.returncode != 0 # argparse 缺 -i 报错退出码 2
|
||||
assert "required" in r.stderr or "-i" in r.stderr
|
||||
344
tests/test_youtube_export_download.py
Normal file
344
tests/test_youtube_export_download.py
Normal file
@@ -0,0 +1,344 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""youtube_export_download.py 测试。
|
||||
|
||||
覆盖不依赖真实浏览器/登录态的全部逻辑:
|
||||
dedup_path() 重名 ` (n)` 去重
|
||||
build_export_filename() 从 exportQuery 反推 zip 文件名
|
||||
decode_zipped_data() zippedData base64 解码
|
||||
intercept_and_save() 响应拦截落盘(用 FakePage/FakeResponse 模拟 Playwright)
|
||||
_default_user_data_dir() 浏览器用户数据目录推导
|
||||
selftest() / --selftest 内置自测
|
||||
|
||||
run() 需要已登录 YouTube Studio 的真实浏览器会话,不在自动化测试范围(见 SKILL.md)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import DOWNLOADER_SCRIPT, run_script
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试替身:模拟 playwright Page / Response / Request
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeRequest:
|
||||
def __init__(self, post_data=None):
|
||||
self.post_data = post_data
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, url, payload, post_data=None):
|
||||
self.url = url
|
||||
self._payload = payload
|
||||
self.request = FakeRequest(post_data)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakePage:
|
||||
"""记录 page.on() 注册的回调,测试中手动触发。"""
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
|
||||
def on(self, event, handler):
|
||||
self.handlers[event] = handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_zip_bytes(files):
|
||||
"""构造内存 zip:{成员名: 内容(bytes/str)}。"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in files.items():
|
||||
zf.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def make_export_query(dimension="USER", start=20260723, end=20260820, with_time=True):
|
||||
query = {"dimensions": [{"type": dimension}]}
|
||||
if with_time:
|
||||
query["timeRange"] = {"dateIdRange": {
|
||||
"inclusiveStart": start, "exclusiveEnd": end}}
|
||||
return {"joinRequest": {"nodes": [{"value": {"query": query}}]}}
|
||||
|
||||
|
||||
def make_export_response(tmp_dir, files=None, account="WL Media",
|
||||
dimension="USER", post_data=None):
|
||||
"""构造一个命中 csv_export 的 FakeResponse(zip 为真实可解压内容)。"""
|
||||
data = make_zip_bytes(files or {"表格数据.csv": "a,b\n1,2"})
|
||||
payload = {"zippedData": base64.b64encode(data).decode("ascii")}
|
||||
if post_data is None:
|
||||
post_data = json.dumps({"exportQuery": make_export_query(dimension=dimension)})
|
||||
url = "https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json"
|
||||
return FakeResponse(url, payload, post_data), data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dedup_path:重名去重
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDedupPath:
|
||||
def test_no_conflict_returns_original(self, downloader, tmp_path):
|
||||
assert Path(downloader.dedup_path(str(tmp_path), "需求文件.zip")).name == "需求文件.zip"
|
||||
|
||||
def test_conflict_appends_suffix_in_order(self, downloader, tmp_path):
|
||||
d = str(tmp_path)
|
||||
Path(downloader.dedup_path(d, "需求文件.zip")).write_bytes(b"a") # 需求文件.zip
|
||||
p1 = Path(downloader.dedup_path(d, "需求文件.zip"))
|
||||
assert p1.name == "需求文件 (1).zip"
|
||||
p1.write_bytes(b"b")
|
||||
p2 = Path(downloader.dedup_path(d, "需求文件.zip"))
|
||||
assert p2.name == "需求文件 (2).zip"
|
||||
p2.write_bytes(b"c")
|
||||
assert Path(downloader.dedup_path(d, "需求文件.zip")).name == "需求文件 (3).zip"
|
||||
|
||||
def test_suffix_before_extension(self, downloader, tmp_path):
|
||||
"""后缀插在扩展名之前:`名称 (1).zip` 而非 `名称.zip (1)`。"""
|
||||
d = str(tmp_path)
|
||||
Path(downloader.dedup_path(d, "report.2026.zip")).write_bytes(b"x")
|
||||
assert Path(downloader.dedup_path(d, "report.2026.zip")).name == "report.2026 (1).zip"
|
||||
|
||||
def test_other_names_unaffected(self, downloader, tmp_path):
|
||||
d = str(tmp_path)
|
||||
Path(downloader.dedup_path(d, "A.zip")).write_bytes(b"x")
|
||||
assert Path(downloader.dedup_path(d, "B.zip")).name == "B.zip"
|
||||
|
||||
def test_gap_filling(self, downloader, tmp_path):
|
||||
"""(1) 被删后再次落盘优先复用空位。"""
|
||||
d = str(tmp_path)
|
||||
p0 = Path(downloader.dedup_path(d, "f.zip")); p0.write_bytes(b"0")
|
||||
p1 = Path(downloader.dedup_path(d, "f.zip")); p1.write_bytes(b"1")
|
||||
p1.unlink() # 删掉 (1)
|
||||
assert Path(downloader.dedup_path(d, "f.zip")).name == "f (1).zip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_export_filename:文件名反推
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestBuildExportFilename:
|
||||
def test_user_dimension(self, downloader):
|
||||
name = downloader.build_export_filename(make_export_query(), "WL Media")
|
||||
assert name == "频道 2026-07-23_2026-08-20 WL Media.zip"
|
||||
|
||||
def test_video_dimension(self, downloader):
|
||||
q = make_export_query(dimension="VIDEO")
|
||||
assert downloader.build_export_filename(q, "acct") == "内容 2026-07-23_2026-08-20 acct.zip"
|
||||
|
||||
def test_content_owner_dimension(self, downloader):
|
||||
q = make_export_query(dimension="CONTENT_OWNER")
|
||||
assert downloader.build_export_filename(q, "acct") == "内容 2026-07-23_2026-08-20 acct.zip"
|
||||
|
||||
def test_custom_dimension_label_overrides(self, downloader):
|
||||
q = make_export_query(dimension="VIDEO")
|
||||
name = downloader.build_export_filename(q, "acct", dimension_label="地区")
|
||||
assert name == "地区 2026-07-23_2026-08-20 acct.zip"
|
||||
|
||||
def test_unknown_dimension_empty_label(self, downloader):
|
||||
q = make_export_query(dimension="SOMETHING")
|
||||
assert downloader.build_export_filename(q, "acct") == " 2026-07-23_2026-08-20 acct.zip"
|
||||
|
||||
def test_date_id_formatting(self, downloader):
|
||||
"""dateId 20260101 -> 2026-01-01(8 位定长切分)。"""
|
||||
q = make_export_query(start=20260101, end=20260102)
|
||||
assert "2026-01-01_2026-01-02" in downloader.build_export_filename(q, "acct")
|
||||
|
||||
def test_missing_date_range_raises(self, downloader):
|
||||
q = make_export_query(with_time=False)
|
||||
with pytest.raises(ValueError, match="日期范围"):
|
||||
downloader.build_export_filename(q, "acct")
|
||||
|
||||
def test_empty_query_raises(self, downloader):
|
||||
with pytest.raises(ValueError, match="日期范围"):
|
||||
downloader.build_export_filename({}, "acct")
|
||||
|
||||
def test_multi_node_query(self, downloader):
|
||||
"""维度与日期分布在不同 node 时也能各取所需。"""
|
||||
q = {"joinRequest": {"nodes": [
|
||||
{"value": {"query": {"dimensions": [{"type": "VIDEO"}]}}},
|
||||
{"value": {"query": {"timeRange": {"dateIdRange": {
|
||||
"inclusiveStart": 20260723, "exclusiveEnd": 20260820}}}}},
|
||||
]}}
|
||||
assert downloader.build_export_filename(q, "acct") == "内容 2026-07-23_2026-08-20 acct.zip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# decode_zipped_data:zippedData 解码
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDecodeZippedData:
|
||||
def test_roundtrip(self, downloader):
|
||||
data = make_zip_bytes({"表格数据.csv": "a,b\n1,2"})
|
||||
payload = {"zippedData": base64.b64encode(data).decode("ascii")}
|
||||
assert downloader.decode_zipped_data(payload) == data
|
||||
|
||||
def test_decoded_bytes_are_valid_zip(self, downloader):
|
||||
data = make_zip_bytes({"x.csv": "1,2"})
|
||||
payload = {"zippedData": base64.b64encode(data).decode("ascii")}
|
||||
out = downloader.decode_zipped_data(payload)
|
||||
with zipfile.ZipFile(io.BytesIO(out)) as zf:
|
||||
assert zf.read("x.csv") == b"1,2"
|
||||
|
||||
def test_missing_field_raises(self, downloader):
|
||||
with pytest.raises(ValueError, match="zippedData"):
|
||||
downloader.decode_zipped_data({"foo": "bar"})
|
||||
|
||||
@pytest.mark.parametrize("empty", ["", None])
|
||||
def test_empty_field_raises(self, downloader, empty):
|
||||
with pytest.raises(ValueError, match="zippedData"):
|
||||
downloader.decode_zipped_data({"zippedData": empty})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# intercept_and_save:拦截器(FakePage 模拟)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestInterceptAndSave:
|
||||
def test_saves_zip_and_reports(self, downloader, tmp_path):
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "WL Media", str(tmp_path))
|
||||
resp, data = make_export_response(tmp_path, {"表格数据.csv": "a,b\n1,2"})
|
||||
|
||||
page.handlers["response"](resp)
|
||||
|
||||
assert len(saved) == 1
|
||||
filename, size, path = saved[0]
|
||||
assert filename == "频道 2026-07-23_2026-08-20 WL Media.zip"
|
||||
assert size == len(data)
|
||||
assert Path(path).read_bytes() == data # 落盘内容与响应一致
|
||||
with zipfile.ZipFile(path) as zf: # 且是可解压的有效 zip
|
||||
assert zf.read("表格数据.csv") == b"a,b\n1,2"
|
||||
|
||||
def test_second_export_deduped(self, downloader, tmp_path):
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "WL Media", str(tmp_path))
|
||||
resp, _ = make_export_response(tmp_path)
|
||||
|
||||
page.handlers["response"](resp)
|
||||
page.handlers["response"](resp)
|
||||
|
||||
# saved 记录的是请求体反推出的原始文件名;实际落盘路径(s[2])带去重后缀
|
||||
assert [s[0] for s in saved] == ["频道 2026-07-23_2026-08-20 WL Media.zip"] * 2
|
||||
assert [Path(s[2]).name for s in saved] == [
|
||||
"频道 2026-07-23_2026-08-20 WL Media.zip",
|
||||
"频道 2026-07-23_2026-08-20 WL Media (1).zip",
|
||||
]
|
||||
|
||||
def test_ignores_non_export_responses(self, downloader, tmp_path):
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "WL Media", str(tmp_path))
|
||||
other = FakeResponse("https://studio.youtube.com/youtubei/v1/other", {})
|
||||
page.handlers["response"](other)
|
||||
assert saved == []
|
||||
assert list(tmp_path.iterdir()) == [] # 无任何落盘
|
||||
|
||||
def test_illegal_chars_sanitized(self, downloader, tmp_path):
|
||||
"""Windows 非法字符 \\ / : * ? " < > | 替换为 _。"""
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, r'A/B:C*D?E"F<G>H|I', str(tmp_path))
|
||||
resp, _ = make_export_response(tmp_path)
|
||||
page.handlers["response"](resp)
|
||||
filename = saved[0][0]
|
||||
assert filename == "频道 2026-07-23_2026-08-20 A_B_C_D_E_F_G_H_I.zip"
|
||||
assert Path(saved[0][2]).exists()
|
||||
|
||||
def test_bad_post_data_falls_back_to_export_zip(self, downloader, tmp_path):
|
||||
"""exportQuery 反推失败时兜底 export.zip,内容不丢。"""
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "WL Media", str(tmp_path))
|
||||
resp, data = make_export_response(tmp_path, post_data="not-json-{{{")
|
||||
page.handlers["response"](resp)
|
||||
assert saved[0][0] == "export.zip"
|
||||
assert Path(saved[0][2]).read_bytes() == data
|
||||
|
||||
def test_empty_post_data_falls_back(self, downloader, tmp_path):
|
||||
"""request.post_data 为 None(或 "{}"):反推失败兜底 export.zip。"""
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "acct", str(tmp_path))
|
||||
data = make_zip_bytes({"x.csv": "1"})
|
||||
resp = FakeResponse(
|
||||
"https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json",
|
||||
{"zippedData": base64.b64encode(data).decode("ascii")},
|
||||
post_data=None,
|
||||
)
|
||||
page.handlers["response"](resp)
|
||||
assert saved[0][0] == "export.zip"
|
||||
assert Path(saved[0][2]).read_bytes() == data
|
||||
|
||||
def test_missing_zipped_data_no_save_with_stderr(self, downloader, tmp_path, capsys):
|
||||
page = FakePage()
|
||||
saved = downloader.intercept_and_save(page, "WL Media", str(tmp_path))
|
||||
resp = FakeResponse(
|
||||
"https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json",
|
||||
{"foo": "bar"},
|
||||
post_data=json.dumps({"exportQuery": make_export_query()}),
|
||||
)
|
||||
page.handlers["response"](resp)
|
||||
assert saved == []
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
assert "处理 csv_export 响应失败" in capsys.readouterr().err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _default_user_data_dir:用户数据目录推导
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDefaultUserDataDir:
|
||||
def test_chrome(self, downloader, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", r"C:\fake\Local")
|
||||
assert downloader._default_user_data_dir("chrome") == \
|
||||
r"C:\fake\Local\Google\Chrome\User Data"
|
||||
|
||||
def test_msedge(self, downloader, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", r"C:\fake\Local")
|
||||
assert downloader._default_user_data_dir("msedge") == \
|
||||
r"C:\fake\Local\Microsoft\Edge\User Data"
|
||||
|
||||
def test_default_is_chrome(self, downloader, monkeypatch):
|
||||
monkeypatch.setenv("LOCALAPPDATA", r"C:\fake\Local")
|
||||
assert downloader._default_user_data_dir(None) == \
|
||||
r"C:\fake\Local\Google\Chrome\User Data"
|
||||
|
||||
def test_fallback_without_env(self, downloader, monkeypatch):
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
expected = str(Path.home() / "AppData" / "Local" / "Google" / "Chrome" / "User Data")
|
||||
assert downloader._default_user_data_dir("chrome") == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内置自测与 CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSelftest:
|
||||
def test_module_selftest(self, downloader, capsys):
|
||||
downloader.selftest()
|
||||
assert "selftest OK" in capsys.readouterr().out
|
||||
|
||||
def test_cli_selftest(self):
|
||||
r = run_script(DOWNLOADER_SCRIPT, ["--selftest"])
|
||||
assert r.returncode == 0
|
||||
assert "selftest OK" in r.stdout
|
||||
|
||||
def test_cli_no_args_runs_selftest_and_prints_usage(self):
|
||||
"""无参数运行:先自测,再提示实际运行方式。"""
|
||||
r = run_script(DOWNLOADER_SCRIPT, [])
|
||||
assert r.returncode == 0
|
||||
assert "selftest OK" in r.stdout
|
||||
assert "--user-data-dir" in r.stdout
|
||||
assert "--connect" in r.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模块常量契约
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConstants:
|
||||
def test_csv_export_path(self, downloader):
|
||||
assert downloader.CSV_EXPORT_PATH == "/youtubei/v1/yta_web/csv_export"
|
||||
|
||||
def test_dimension_labels(self, downloader):
|
||||
assert downloader.DIMENSION_LABEL["VIDEO"] == "内容"
|
||||
assert downloader.DIMENSION_LABEL["USER"] == "频道"
|
||||
assert downloader.DIMENSION_LABEL["CONTENT_OWNER"] == "内容"
|
||||
Reference in New Issue
Block a user