feat(scripts): 支持动态指标和维度参数配置
为 build_studio_urls.py 添加指标和维度的可配置支持,包括: - 新增 metrics.json 和 dimensions.json 映射文件加载 - 支持中文/英文别名映射及指标代码透传 - 列别名扩展以识别"指标"和"维度"列 - 空值时自动回退到 CONFIG 默认值
This commit is contained in:
@@ -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)。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 失败行与退出码
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user