feat(scripts): 支持动态指标和维度参数配置

为 build_studio_urls.py 添加指标和维度的可配置支持,包括:
- 新增 metrics.json 和 dimensions.json 映射文件加载
- 支持中文/英文别名映射及指标代码透传
- 列别名扩展以识别"指标"和"维度"列
- 空值时自动回退到 CONFIG 默认值
This commit is contained in:
2026-08-24 15:28:48 +08:00
parent ba00651fa9
commit b8bd749f43
16 changed files with 996 additions and 36 deletions

View File

@@ -20,6 +20,8 @@ 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"
METRICS_JSON = URL_BUILDER_SCRIPT.parent / "metrics.json"
DIMENSIONS_JSON = URL_BUILDER_SCRIPT.parent / "dimensions.json"
DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
LOOKUP_SCRIPT = ROOT / "scripts" / "lookup_groups.py"
@@ -57,6 +59,18 @@ def real_countries(url_builder):
return url_builder.load_country_map(str(COUNTRIES_JSON))
@pytest.fixture(scope="session")
def real_metrics(url_builder):
"""脚本自带 metrics.json 加载出的指标映射(含指标代码透传)。"""
return url_builder.load_metric_map(str(METRICS_JSON))
@pytest.fixture(scope="session")
def real_dimensions(url_builder):
"""脚本自带 dimensions.json 加载出的维度映射(含维度代码透传)。"""
return url_builder.load_dimension_map(str(DIMENSIONS_JSON))
def run_script(script, args, cwd=None):
"""以子进程运行被测脚本uv run pytest 下 sys.executable 即 venv python

View File

@@ -18,7 +18,7 @@ import math
import pandas as pd
import pytest
from conftest import COUNTRIES_JSON
from conftest import COUNTRIES_JSON, DIMENSIONS_JSON, METRICS_JSON
# 文档化锚点2026-06-15 = 1781506800000见 docs/adr/0001脚本内 ANCHOR_MS
ANCHOR_MS = 1781506800000
@@ -183,8 +183,121 @@ class TestLoadCountryMap:
# ---------------------------------------------------------------------------
# parse_countries国家列解析
# load_metric_map / parse_metric指标映射与解析
# ---------------------------------------------------------------------------
class TestLoadMetricMap:
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
path = tmp_path / "not_exists.json"
assert url_builder.load_metric_map(str(path)) == {}
assert "未找到指标映射文件" in capsys.readouterr().err
def test_bom_file_loads(self, url_builder, tmp_path):
p = tmp_path / "m.json"
p.write_bytes('{"订阅净增长": "subscribers_net_change"}'.encode("utf-8-sig"))
assert url_builder.load_metric_map(str(p))["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
def test_known_code_passthrough_added(self, url_builder, tmp_path):
p = tmp_path / "m.json"
p.write_text("{}", encoding="utf-8")
m = url_builder.load_metric_map(str(p))
assert m["subscribers_net_change"] == "SUBSCRIBERS_NET_CHANGE"
assert m["total_estimated_earnings"] == "TOTAL_ESTIMATED_EARNINGS"
def test_real_metrics_json(self, real_metrics):
assert real_metrics["订阅净增长"] == "SUBSCRIBERS_NET_CHANGE"
assert real_metrics["观看时长"] == "EXTERNAL_WATCH_TIME"
assert real_metrics["预计收益"] == "TOTAL_ESTIMATED_EARNINGS"
# 全量:估算的合作伙伴收入 + 新增「税前收益/税前收入」别名
assert real_metrics["估算的合作伙伴收入"] == "TOTAL_ESTIMATED_EARNINGS"
assert real_metrics["税前收益"] == "TOTAL_ESTIMATED_EARNINGS"
assert real_metrics["税前收入"] == "TOTAL_ESTIMATED_EARNINGS"
# 覆盖足够多的指标(>50 条)
assert len(real_metrics) > 50
class TestParseMetric:
@pytest.mark.parametrize("empty", [None, "", float("nan")])
def test_blank_returns_none(self, url_builder, empty):
assert url_builder.parse_metric(empty, {}) is None
def test_known_code_passthrough_case_insensitive(self, url_builder):
assert url_builder.parse_metric("SUBSCRIBERS_NET_CHANGE", {}) == "SUBSCRIBERS_NET_CHANGE"
assert url_builder.parse_metric("subscribers_net_change", {}) == "SUBSCRIBERS_NET_CHANGE"
def test_chinese_name_mapped(self, url_builder):
assert url_builder.parse_metric("订阅净增长", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
== "SUBSCRIBERS_NET_CHANGE"
def test_english_synonym_mapped(self, url_builder):
assert url_builder.parse_metric("watch_time", {"watch_time": "EXTERNAL_WATCH_TIME"}) \
== "EXTERNAL_WATCH_TIME"
def test_quotes_stripped(self, url_builder):
assert url_builder.parse_metric("'订阅净增长'", {"订阅净增长": "SUBSCRIBERS_NET_CHANGE"}) \
== "SUBSCRIBERS_NET_CHANGE"
def test_unknown_raises(self, url_builder):
with pytest.raises(ValueError, match="未识别的指标"):
url_builder.parse_metric("神秘指标", {})
def test_real_map_common(self, url_builder, real_metrics):
assert url_builder.parse_metric("平均观看时长", real_metrics) == "AVERAGE_WATCH_TIME"
assert url_builder.parse_metric("收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
def test_estimated_partner_revenue_aliases(self, url_builder, real_metrics):
assert url_builder.parse_metric("估算的合作伙伴收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
assert url_builder.parse_metric("税前收益", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
assert url_builder.parse_metric("税前收入", real_metrics) == "TOTAL_ESTIMATED_EARNINGS"
# ---------------------------------------------------------------------------
# load_dimension_map / parse_dimension维度映射与解析
# ---------------------------------------------------------------------------
class TestLoadDimensionMap:
def test_missing_file_returns_empty_and_warns(self, url_builder, tmp_path, capsys):
path = tmp_path / "not_exists.json"
assert url_builder.load_dimension_map(str(path)) == {}
assert "未找到维度映射文件" in capsys.readouterr().err
def test_known_code_passthrough_added(self, url_builder, tmp_path):
p = tmp_path / "d.json"
p.write_text("{}", encoding="utf-8")
d = url_builder.load_dimension_map(str(p))
assert d["user"] == "USER"
def test_real_dimensions_json(self, real_dimensions):
assert real_dimensions["内容"] == "VIDEO"
assert real_dimensions["地理位置"] == "COUNTRY"
assert real_dimensions["频道"] == "USER"
assert len(real_dimensions) >= 30
class TestParseDimension:
@pytest.mark.parametrize("empty", [None, "", float("nan")])
def test_blank_returns_none(self, url_builder, empty):
assert url_builder.parse_dimension(empty, {}) is None
def test_known_code_passthrough_case_insensitive(self, url_builder):
# 仅 CONFIG 默认维度USER在无映射时也能透传
assert url_builder.parse_dimension("USER", {}) == "USER"
assert url_builder.parse_dimension("user", {}) == "USER"
def test_chinese_name_mapped(self, url_builder):
assert url_builder.parse_dimension("地理位置", {"地理位置": "COUNTRY"}) == "COUNTRY"
def test_quotes_stripped(self, url_builder):
assert url_builder.parse_dimension("'内容'", {"内容": "VIDEO"}) == "VIDEO"
def test_unknown_raises(self, url_builder):
with pytest.raises(ValueError, match="未识别的维度"):
url_builder.parse_dimension("神秘维度", {})
def test_real_map_common(self, url_builder, real_dimensions):
assert url_builder.parse_dimension("内容", real_dimensions) == "VIDEO"
assert url_builder.parse_dimension("设备类型", real_dimensions) == "DEVICE_PLATFORM_TYPE"
class TestParseCountries:
def test_none_returns_empty(self, url_builder):
assert url_builder.parse_countries(None, {}) == []
@@ -319,6 +432,62 @@ class TestBuildUrl:
for m in url_builder.CONFIG["t_metrics"]:
assert "t_metrics=%s" % m in url
# ---- 指标(每行可选)----
def test_metric_blank_uses_config_default(self, url_builder):
"""指标列留空:主指标与排序字段回退 CONFIG 默认(向后兼容)。"""
url, status, _ = url_builder.build_url(make_row(), {})
assert status == "ok"
assert "metric=%s" % url_builder.CONFIG["metric"] in url
assert "o_column=%s" % url_builder.CONFIG["o_column"] in url
def test_metric_chinese_sets_metric_and_o_column(self, url_builder, real_metrics):
"""指标列选中"预计收益"metric 与 o_column 都跟随所选指标。"""
url, status, _ = url_builder.build_url(
make_row(metric="预计收益"), {}, real_metrics)
assert status == "ok"
assert "metric=TOTAL_ESTIMATED_EARNINGS" in url
assert "o_column=TOTAL_ESTIMATED_EARNINGS" in url
def test_metric_code_passthrough(self, url_builder):
"""指标列填已知代码:原样透传。"""
url, status, _ = url_builder.build_url(
make_row(metric="EXTERNAL_VIEWS"), {})
assert status == "ok"
assert "metric=EXTERNAL_VIEWS" in url
assert "o_column=EXTERNAL_VIEWS" in url
def test_metric_unknown_raises(self, url_builder):
with pytest.raises(ValueError, match="未识别的指标"):
url_builder.build_url(make_row(metric="神秘指标"), {}, {})
def test_metric_case_insensitive_code(self, url_builder):
url, status, _ = url_builder.build_url(make_row(metric="external_views"), {})
assert status == "ok"
assert "metric=EXTERNAL_VIEWS" in url
# ---- 维度(每行可选)----
def test_dimension_blank_uses_config_default(self, url_builder):
"""维度列留空:回退 CONFIG 默认(向后兼容)。"""
url, status, _ = url_builder.build_url(make_row(), {})
assert status == "ok"
assert "dimension=%s" % url_builder.CONFIG["dimension"] in url
def test_dimension_chinese_sets_dimension(self, url_builder, real_dimensions):
url, status, _ = url_builder.build_url(
make_row(dimension="地理位置"), {}, dimension_map=real_dimensions)
assert status == "ok"
assert "dimension=COUNTRY" in url
def test_dimension_code_passthrough(self, url_builder, real_dimensions):
url, status, _ = url_builder.build_url(
make_row(dimension="video"), {}, dimension_map=real_dimensions)
assert status == "ok"
assert "dimension=VIDEO" in url
def test_dimension_unknown_raises(self, url_builder):
with pytest.raises(ValueError, match="未识别的维度"):
url_builder.build_url(make_row(dimension="神秘维度"), {}, {})
def test_no_country_omits_country_params(self, url_builder):
url, _, _ = url_builder.build_url(make_row(), {})
assert "ur_dimensions" not in url

View File

@@ -19,7 +19,8 @@ ASSET_XLSX = ROOT / "assets" / "需求输入示例.xlsx"
HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"]
OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID",
"数据周期", "国家", "国家代码", "开始时间戳", "结束时间戳", "URL"]
"数据周期", "国家", "国家代码", "指标", "指标代码", "维度", "维度代码",
"开始时间戳", "结束时间戳", "URL"]
def run_builder(args):
@@ -183,6 +184,123 @@ class TestHappyPath:
assert len(read_output(out)) == 1
# ---------------------------------------------------------------------------
# 指标(每行可选)
# ---------------------------------------------------------------------------
class TestMetricColumn:
def test_metric_selected_and_default(self, tmp_path):
"""指标列:一行选"观看时长",一行留空走 CONFIG 默认。"""
src = tmp_path / "需求.csv"
pd.DataFrame([
{"所有者ID": "MC123", "实体ID": "G001", "数据周期": "2026.07.01-2026.08.01",
"指标": "观看时长"},
{"所有者ID": "MC123", "实体ID": "G002", "数据周期": "2026.07.01-2026.08.01",
"指标": ""},
]).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 list(df.columns) == OUTPUT_COLUMNS
assert df.loc[0, "指标"] == "观看时长"
assert df.loc[0, "指标代码"] == "EXTERNAL_WATCH_TIME"
assert "metric=EXTERNAL_WATCH_TIME" in df.loc[0, "URL"]
assert "o_column=EXTERNAL_WATCH_TIME" in df.loc[0, "URL"]
# 空白行:回退 CONFIG 默认
assert df.loc[1, "指标代码"] == "SUBSCRIBERS_NET_CHANGE"
assert "metric=SUBSCRIBERS_NET_CHANGE" in df.loc[1, "URL"]
def test_unknown_metric_exits_2(self, tmp_path):
src = tmp_path / "需求.csv"
src.write_text(
"所有者ID,实体ID,数据周期,指标\nMC123,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 == 2
assert "未识别的指标" in r.stderr
assert "第2行" in r.stderr
def test_metric_code_passthrough(self, tmp_path):
src = tmp_path / "需求.csv"
src.write_text(
"所有者ID,实体ID,数据周期,指标\nMC123,G001,2026.07.01-2026.08.01,external_views\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
df = read_output(out)
assert df.loc[0, "指标代码"] == "EXTERNAL_VIEWS"
assert "metric=EXTERNAL_VIEWS" in df.loc[0, "URL"]
def test_metric_alias_tax_revenue(self, tmp_path):
"""「税前收益/税前收入」别名 -> TOTAL_ESTIMATED_EARNINGS。"""
src = tmp_path / "需求.csv"
src.write_text(
"所有者ID,实体ID,数据周期,指标\nMC123,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
df = read_output(out)
assert df.loc[0, "指标代码"] == "TOTAL_ESTIMATED_EARNINGS"
assert "metric=TOTAL_ESTIMATED_EARNINGS" in df.loc[0, "URL"]
# ---------------------------------------------------------------------------
# 维度(每行可选)
# ---------------------------------------------------------------------------
class TestDimensionColumn:
def test_dimension_selected_and_default(self, tmp_path):
"""维度列:一行选"地理位置",一行留空走 CONFIG 默认。"""
src = tmp_path / "需求.csv"
pd.DataFrame([
{"所有者ID": "MC123", "实体ID": "G001", "数据周期": "2026.07.01-2026.08.01",
"维度": "地理位置"},
{"所有者ID": "MC123", "实体ID": "G002", "数据周期": "2026.07.01-2026.08.01",
"维度": ""},
]).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 list(df.columns) == OUTPUT_COLUMNS
assert df.loc[0, "维度"] == "地理位置"
assert df.loc[0, "维度代码"] == "COUNTRY"
assert "dimension=COUNTRY" in df.loc[0, "URL"]
# 空白行:回退 CONFIG 默认
assert df.loc[1, "维度代码"] == "USER"
assert "dimension=USER" in df.loc[1, "URL"]
def test_unknown_dimension_exits_2(self, tmp_path):
src = tmp_path / "需求.csv"
src.write_text(
"所有者ID,实体ID,数据周期,维度\nMC123,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 == 2
assert "未识别的维度" in r.stderr
assert "第2行" in r.stderr
def test_dimension_code_passthrough(self, tmp_path):
src = tmp_path / "需求.csv"
src.write_text(
"所有者ID,实体ID,数据周期,维度\nMC123,G001,2026.07.01-2026.08.01,video\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
df = read_output(out)
assert df.loc[0, "维度代码"] == "VIDEO"
assert "dimension=VIDEO" in df.loc[0, "URL"]
# ---------------------------------------------------------------------------
# 失败行与退出码
# ---------------------------------------------------------------------------