2 Commits

Author SHA1 Message Date
b8bd749f43 feat(scripts): 支持动态指标和维度参数配置
为 build_studio_urls.py 添加指标和维度的可配置支持,包括:
- 新增 metrics.json 和 dimensions.json 映射文件加载
- 支持中文/英文别名映射及指标代码透传
- 列别名扩展以识别"指标"和"维度"列
- 空值时自动回退到 CONFIG 默认值
2026-08-24 15:28:48 +08:00
ba00651fa9 docs: 添加 YouTube Studio 技能使用指南 2026-08-24 14:49:57 +08:00
17 changed files with 1031 additions and 36 deletions

View File

@@ -102,12 +102,34 @@ uv 自动创建 `.venv\` 并安装全部依赖pandas、openpyxl、playwright
### 改 URL 固定参数 ### 改 URL 固定参数
指标、粒度、维度、排序等固定参数集中在 `skills/yt-studio-url-builder/scripts/build_studio_urls.py` 顶部的 `CONFIG` 字典中,**单点维护**——改完重跑脚本即生效,需求清单无需变动。 粒度、表格列指标等固定参数集中在 `skills/yt-studio-url-builder/scripts/build_studio_urls.py` 顶部的 `CONFIG` 字典中,**单点维护**——改完重跑脚本即生效,需求清单无需变动。
主指标 `metric`、细分维度 `dimension` 可**按行选择**:在需求清单加一列 `指标`(映射见同目录 `metrics.json` 或填已知指标代码)、一列 `维度`(映射见同目录 `dimensions.json` 或填已知维度代码),留空则走 `CONFIG` 默认。
### 扩充国家映射 ### 扩充国家映射
中文国家名 → ISO 代码的映射在 `skills/yt-studio-url-builder/scripts/countries.json`,直接追加条目即可;映射外的两位 ISO 代码(如 `US`)会原样透传。 中文国家名 → ISO 代码的映射在 `skills/yt-studio-url-builder/scripts/countries.json`,直接追加条目即可;映射外的两位 ISO 代码(如 `US`)会原样透传。
### 维护映射文件(指标 / 维度 / 国家)
`skills/yt-studio-url-builder/scripts/` 下有三个映射文件,把「中文/英文名」翻译成「URL 内部代码」,均可自行扩充:
| 文件 | 作用 | 新增示例 |
|---|---|---|
| `metrics.json` | 指标名 → 指标代码(如 `观看时长``EXTERNAL_WATCH_TIME` | `"新指标名": "SOME_METRIC"` |
| `dimensions.json` | 细分维度名 → 维度代码(如 `地理位置``COUNTRY` | `"新维度名": "SOME_DIMENSION"` |
| `countries.json` | 中文国家名 → ISO 两位代码(如 `美国``US` | `"新国名": "XX"` |
方法和规则:
- 都是扁平 `key -> value` 的 JSON 对象UTF-8 编码;键为人类可读名、值为大写代码。
- **同一代码可有多个别名**(多对一),例如 `metrics.json``税前收益` / `税前收入` / `预计收益` 都指向 `TOTAL_ESTIMATED_EARNINGS`
- 新增一个指标/维度后,其代码会被脚本自动加入「可透传集合」,可直接在需求清单里填裸代码,无需改 `build_studio_urls.py`
- **默认主指标/维度/粒度不在这些 JSON 里**,而在 `build_studio_urls.py` 顶部的 `CONFIG`(见上文「改 URL 固定参数」)。
- `dimensions.json``metrics.json` 的基准数据源是 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx`(「细分维度」「数据指标」两张表);`countries.json` 基于 ISO 3166-1 标准手工维护。
完整说明(加载方式、从 Excel 重新同步的脚本片段、更新后校验清单与常见坑)见 [skills/yt-studio-url-builder/references/maintenance-guide.md](skills/yt-studio-url-builder/references/maintenance-guide.md)。
### 新增/修改依赖 ### 新增/修改依赖
只改根目录 `pyproject.toml``dependencies`,然后 `uv sync` 更新 `uv.lock`。不要 `pip install` 直装(绕过 lock环境不可复现 只改根目录 `pyproject.toml``dependencies`,然后 `uv sync` 更新 `uv.lock`。不要 `pip install` 直装(绕过 lock环境不可复现
@@ -138,4 +160,4 @@ uv 自动创建 `.venv\` 并安装全部依赖pandas、openpyxl、playwright
----- -----
UPDATE: 2026-08-21 UPDATE: 2026-08-24

35
docs/usage-guide.md Normal file
View File

@@ -0,0 +1,35 @@
# YouTube Studio 技能的使用指南
这一组技能都是为了能更好的驱动Agent完成YouTube Studio数据的下载。一个常见的场景就是基于一个被授权的所有者数据访问权之后从指定的群组中提取指定的一组数据保存到本地。
如果你需要完成上面这个常见的应用场景,那么,本技能组中的所有技能都是必须的,包括:
- `uv-env-setup`:用于设置必要的 Python 运行环境,以便下面的技能所带的脚本可以正确运行。
- `yt-studio-url-builder`:用于构建 YouTube Studio 分析页面 URL。
- `youtube-studio-csv-download`:用于下载 YouTube Studio 分析数据。
- `yt-studio-groupid-lookup`:用于从群组名称中提取实体 ID。
对于这样一个任务对于只有一个群组数据需要下载的情况这组技能并没有人工下载来的快更适合批量下载众多群组的数据。也就是你需要Agent帮你从YouTube Studio的分析页面中下载众多群组的众多情况的数据时这组技能可以更好的指导Agent完成工作也能很好的加速下载。
### 流程说明
首先,你需要准备好以下几个东西:
1. 一个被授权的所有者数据访问权。
2. 一个包含群组名称的名单文件json / txt / csv / xlsx 均可)和所需的数据需求。
这里,数据需求,就是你需要下载哪些指标的数据,以及你需要哪些时间段的数据,也包括需要筛选什么信息,同时,你需要的是什么细分的数据,例如:频道数据、内容数据还是其他的数据。
下一步,是获取群组的实体 IDgroupid。你可以让Agent使用 `yt-studio-groupid-lookup` 技能,来从群组名称中提取实体 ID。这样前期的准备就算基本完成了。
然后指示Agent基于你的需求与获取的实体ID创建符合需求的分析页面 URL。
拿到这些URL之后就可以让Agent基于这些URL自动下载你需要的数据了。
这个流程你可以分步指示Agent完成也可以一次性指示Agent完成所有步骤但需要注意的是**每次指示Agent完成一个步骤后都需要确认Agent是否正确完成了任务以及是否有任何错误或异常情况发生**尽可能提供你需要的检查模式让Agent自己去检查它的结果以保证下载的数据是正确的。
### 检查与确认
常用的检查就是需要下载的文件数量需要对应于生成的URL数量。如果数量不对应那么就需要检查Agent是否正确生成了URL或者是否有任何错误或异常情况发生。
另外在URL生成过程中也需要检查Agent是否正确解析了群组名称以及是否有任何错误或异常情况发生。可以要求Agent抽样几个URL打开浏览器进行浏览确认。这个方式也可以确认Agent当前是否正确的读取了浏览器的登录状态以避免因为登录状态过期而导致的下载失败。

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "StudioLift" name = "StudioLift"
version = "0.3.0" version = "0.3.4"
description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。" description = "StudioLift :一个 YouTube Studio 工具集,提供 URL 批量拼接、分析 CSV 导出下载等功能。"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View File

@@ -15,12 +15,18 @@
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO) 实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d) 数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码) 国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
指标 : 指标 / 主指标 / 数据指标 / metric (可留空,则用 CONFIG 默认;中文名或指标代码)
维度 : 维度 / 细分维度 / dimension (可留空,则用 CONFIG 默认;中文名或维度代码)
说明: 说明:
- 数据周期起止日期均包含在数据范围内time_period 结束值取结束日后一天的日界线。 - 数据周期起止日期均包含在数据范围内time_period 结束值取结束日后一天的日界线。
- 国家为多个时ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27 - 国家为多个时ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json可自行扩充已是两位代码的原样透传。 - 中文国家名 -> ISO 代码的映射放在同目录 countries.json可自行扩充已是两位代码的原样透传。
- 固定参数metric/granularity/dimension/t_metrics 等)在本文件 CONFIG 中统一配置。 - 指标每行可选metric 参数,中文名 -> 代码的映射放在同目录 metrics.json可自行扩充
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
- 维度每行可选dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json可自行扩充
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
- 其余固定参数granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
""" """
import argparse import argparse
@@ -55,6 +61,11 @@ CONFIG = {
"comparison_type": "NONE", "comparison_type": "NONE",
} }
# 常用指标代码集合供指标代码透传metrics.json 提供中文/英文别名映射)
KNOWN_METRICS = {CONFIG["metric"], CONFIG["o_column"], *CONFIG["t_metrics"]}
# 常见维度代码供维度代码透传dimensions.json 提供中文别名映射)
KNOWN_DIMENSIONS = {CONFIG["dimension"]}
# 实体类型 中文/代码 -> URL 参数值(可扩充) # 实体类型 中文/代码 -> URL 参数值(可扩充)
ENTITY_TYPE_MAP = { ENTITY_TYPE_MAP = {
"群组": "GROUP", "GROUP": "GROUP", "群组": "GROUP", "GROUP": "GROUP",
@@ -95,6 +106,12 @@ COLUMN_ALIASES = {
# 国家 # 国家
"国家": "countries", "国家/地区": "countries", "国家地区": "countries", "国家": "countries", "国家/地区": "countries", "国家地区": "countries",
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries", "countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
# 指标(每行可选;缺省用 CONFIG
"指标": "metric", "主指标": "metric", "主要指标": "metric", "数据指标": "metric",
"metric": "metric", "metric_name": "metric", "metricname": "metric", "指标名": "metric",
# 维度(每行可选;缺省用 CONFIG
"维度": "dimension", "细分维度": "dimension", "dimension": "dimension",
"dim": "dimension", "细分": "dimension", "维度名": "dimension",
} }
@@ -160,6 +177,66 @@ def parse_countries(text, country_map):
return codes return codes
def load_metric_map(path):
"""加载 指标中文/英文名 -> 指标代码 映射。文件缺失则仅支持已知指标代码透传。"""
if not os.path.exists(path):
print("[提示] 未找到指标映射文件 %s,仅支持直接填写指标代码" % path, file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
if str(k).strip() and str(v).strip()}
# 允许映射中出现的所有指标代码原样透传(大小写不敏感)
for code in set(mapped.values()) | KNOWN_METRICS:
mapped.setdefault(code.lower(), code)
return mapped
def parse_metric(text, metric_map):
"""指标列 -> 指标代码。空则返回 None走 CONFIG 默认);具体代码透传;未知则报错。"""
if text is None or (isinstance(text, float) and str(text) == "nan"):
return None
raw = str(text).strip().strip("'\"").strip()
if not raw:
return None
if raw.upper() in KNOWN_METRICS: # 已是已知指标代码
return raw.upper()
key = raw.lower()
if key in metric_map: # 中文/英文别名
return metric_map[key]
raise ValueError("未识别的指标: %r(不在映射文件中,也不是已知指标代码)" % raw)
def load_dimension_map(path):
"""加载 细分维度中文名 -> 代码 映射。文件缺失则仅支持已知维度代码透传。"""
if not os.path.exists(path):
print("[提示] 未找到维度映射文件 %s,仅支持直接填写维度代码" % path, file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
if str(k).strip() and str(v).strip()}
# 允许映射中出现的所有维度代码原样透传(大小写不敏感)
for code in set(mapped.values()) | KNOWN_DIMENSIONS:
mapped.setdefault(code.lower(), code)
return mapped
def parse_dimension(text, dimension_map):
"""维度列 -> 维度代码。空则返回 None走 CONFIG 默认);具体代码透传;未知则报错。"""
if text is None or (isinstance(text, float) and str(text) == "nan"):
return None
raw = str(text).strip().strip("'\"").strip()
if not raw:
return None
if raw.upper() in KNOWN_DIMENSIONS: # 已是已知维度代码
return raw.upper()
key = raw.lower()
if key in dimension_map: # 中文别名
return dimension_map[key]
raise ValueError("未识别的维度: %r(不在映射文件中,也不是已知维度代码)" % raw)
def resolve_entity_type(text): def resolve_entity_type(text):
"""实体类型 -> URL 参数值。空则用默认群组。""" """实体类型 -> URL 参数值。空则用默认群组。"""
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "": if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
@@ -170,8 +247,11 @@ def resolve_entity_type(text):
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key) raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key)
def build_url(row, country_map): def build_url(row, country_map, metric_map=None, dimension_map=None):
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。""" """根据一行需求生成 URL。返回 (url, 状态, 错误信息)。
metric_map / dimension_map 为 名 -> 代码 映射;对应列缺省时回退到 CONFIG 默认。
"""
owner_id = row.get("owner_id", "").strip() owner_id = row.get("owner_id", "").strip()
entity_type = resolve_entity_type(row.get("entity_type", "")) entity_type = resolve_entity_type(row.get("entity_type", ""))
entity_id = row.get("entity_id", "").strip() entity_id = row.get("entity_id", "").strip()
@@ -191,6 +271,15 @@ def build_url(row, country_map):
codes = parse_countries(row.get("countries", ""), country_map) codes = parse_countries(row.get("countries", ""), country_map)
# 指标:每行可选;空 -> 主指标/排序字段用 CONFIG 默认;非空 -> 二者都跟随所选指标
sel_metric = parse_metric(row.get("metric", ""), metric_map or {})
metric = sel_metric or CONFIG["metric"]
o_column = sel_metric or CONFIG["o_column"]
# 维度:每行可选;空 -> 用 CONFIG 默认细分维度
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map or {})
dimension = sel_dimension or CONFIG["dimension"]
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
params = [] params = []
params.append("o=%s" % owner_id) params.append("o=%s" % owner_id)
@@ -204,12 +293,12 @@ def build_url(row, country_map):
params.append("ur_exclusive_ends=") params.append("ur_exclusive_ends=")
params.append("time_period=%d%%2C%d" % (start_ms, end_ms)) params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
params.append("explore_type=%s" % CONFIG["explore_type"]) params.append("explore_type=%s" % CONFIG["explore_type"])
params.append("metric=%s" % CONFIG["metric"]) params.append("metric=%s" % metric)
params.append("granularity=%s" % CONFIG["granularity"]) params.append("granularity=%s" % CONFIG["granularity"])
for m in CONFIG["t_metrics"]: for m in CONFIG["t_metrics"]:
params.append("t_metrics=%s" % m) params.append("t_metrics=%s" % m)
params.append("dimension=%s" % CONFIG["dimension"]) params.append("dimension=%s" % dimension)
params.append("o_column=%s" % CONFIG["o_column"]) params.append("o_column=%s" % o_column)
params.append("o_direction=%s" % CONFIG["o_direction"]) params.append("o_direction=%s" % CONFIG["o_direction"])
params.append("comparison_type=%s" % CONFIG["comparison_type"]) params.append("comparison_type=%s" % CONFIG["comparison_type"])
@@ -222,6 +311,8 @@ def main():
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls") parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls")
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv") parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv")
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json") parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json")
parser.add_argument("--metrics", default=None, help="指标映射 JSON 路径(默认:脚本同目录 metrics.json")
parser.add_argument("--dimensions", default=None, help="维度映射 JSON 路径(默认:脚本同目录 dimensions.json")
args = parser.parse_args() args = parser.parse_args()
if not os.path.exists(args.input): if not os.path.exists(args.input):
@@ -252,18 +343,26 @@ def main():
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json") countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
country_map = load_country_map(countries_file) country_map = load_country_map(countries_file)
metrics_file = args.metrics or os.path.join(os.path.dirname(os.path.abspath(__file__)), "metrics.json")
metric_map = load_metric_map(metrics_file)
dimensions_file = args.dimensions or os.path.join(os.path.dirname(os.path.abspath(__file__)), "dimensions.json")
dimension_map = load_dimension_map(dimensions_file)
records = [] records = []
errors = [] errors = []
for idx, raw in df.iterrows(): for idx, raw in df.iterrows():
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping} row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
try: try:
url, status, msg = build_url(row, country_map) url, status, msg = build_url(row, country_map, metric_map, dimension_map)
except ValueError as e: except ValueError as e:
url, status, msg = None, "error", str(e) url, status, msg = None, "error", str(e)
if status == "ok": if status == "ok":
start_ms, end_ms = parse_period(row.get("period", "")) start_ms, end_ms = parse_period(row.get("period", ""))
codes = parse_countries(row.get("countries", ""), country_map) codes = parse_countries(row.get("countries", ""), country_map)
sel_metric = parse_metric(row.get("metric", ""), metric_map)
metric_code = sel_metric or CONFIG["metric"]
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map)
dimension_code = sel_dimension or CONFIG["dimension"]
records.append({ records.append({
"所有者名称": row.get("owner_name", ""), "所有者名称": row.get("owner_name", ""),
"所有者ID": row.get("owner_id", ""), "所有者ID": row.get("owner_id", ""),
@@ -273,6 +372,10 @@ def main():
"数据周期": row.get("period", ""), "数据周期": row.get("period", ""),
"国家": row.get("countries", ""), "国家": row.get("countries", ""),
"国家代码": ",".join(codes), "国家代码": ",".join(codes),
"指标": row.get("metric", ""),
"指标代码": metric_code,
"维度": row.get("dimension", ""),
"维度代码": dimension_code,
"开始时间戳": start_ms, "开始时间戳": start_ms,
"结束时间戳": end_ms, "结束时间戳": end_ms,
"URL": url, "URL": url,

35
scripts/dimensions.json Normal file
View File

@@ -0,0 +1,35 @@
{
"内容": "VIDEO",
"流量来源": "TRAFFIC_SOURCE_TYPE",
"地理位置": "COUNTRY",
"频道": "USER",
"资产": "ASSET",
"频道所有权": "UPLOADER_TYPE",
"版权声明状态": "CLAIMED_STATUS",
"内容类型": "CREATOR_CONTENT_TYPE",
"播放列表": "PLAYLIST",
"观看者年龄": "VIEWER_AGE",
"观看者性别": "VIEWER_GENDER",
"新观看者和回访观看者": "LOYALTY_STATE",
"按观看行为细分的观众群": "AUDIENCE_LOYALTY_SEGMENT",
"订阅状态": "SUBSCRIBED_TO_UPLOADER_STATE",
"订阅来源": "SUBSCRIPTION_SOURCE_TYPE",
"YouTube 产品": "PLAYER_APP_TYPE",
"设备类型": "DEVICE_PLATFORM_TYPE",
"操作系统": "DEVICE_OS_TYPE",
"收入来源": "EARNINGS_SOURCE_ALL",
"广告类型": "ADTYPES",
"交易类型": "TRANSACTION_BUSINESS_MODEL",
"自然流量和付费流量": "AD_STATUS",
"日期": "DAY",
"字幕": "CAPTION_LANGUAGE",
"视频信息语言": "VIDEO_METADATA_LANGUAGE",
"是否使用翻译": "IS_CROSS_LANGUAGE",
"片尾画面元素": "ENDSCREEN_ELEMENT_ID",
"片尾画面元素类型": "ENDSCREEN_ELEMENT_TYPE",
"卡片": "INFO_CARD_ID",
"卡片类型": "INFO_CARD_TYPE",
"播放位置": "PLAYBACK_LOCATION_TYPE",
"播放器类型": "EMBEDDED_PLAYER_MODE",
"分享服务": "SHARING_SERVICE"
}

86
scripts/metrics.json Normal file
View File

@@ -0,0 +1,86 @@
{
"观看次数": "EXTERNAL_VIEWS",
"感兴趣的观看次数": "ENGAGED_VIEWS",
"观看时长(小时)": "EXTERNAL_WATCH_TIME",
"订阅人数": "SUBSCRIBERS_NET_CHANGE",
"平均观看时长": "AVERAGE_WATCH_TIME",
"平均观看百分比": "AVERAGE_WATCH_PERCENTAGE",
"添加的视频数": "VIDEO_COUNT_NEW",
"发布的视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
"展示次数": "VIDEO_THUMBNAIL_IMPRESSIONS",
"展示点击率": "VIDEO_THUMBNAIL_IMPRESSIONS_VTR",
"继续观看": "SHORTS_FEED_IMPRESSIONS_VTR",
"唯一身份观看者人数": "ESTIMATED_UNIQUE_VIEWERS",
"人均观看次数": "AVERAGE_VIEWS_PER_VIEWER",
"获得的订阅人数": "SUBSCRIBERS_GAINED",
"流失的订阅人数": "SUBSCRIBERS_LOST",
"赞": "RATINGS_LIKES",
"不喜欢": "RATINGS_DISLIKES",
"赞和不喜欢的比率": "LIKES_PER_LIKES_PLUS_DISLIKES_PERCENT",
"分享数": "SHARINGS",
"添加的评论数": "COMMENTS",
"估算的合作伙伴收入": "TOTAL_ESTIMATED_EARNINGS",
"合作伙伴的交易收入": "TRANSACTION_EARNINGS_ALL",
"交易次数": "TRANSACTION_COUNT",
"合作伙伴的每笔交易收入": "AVERAGE_TRANSACTION_AMOUNT",
"YouTube Premium 合作伙伴收入": "SUBSCRIPTION_EARNINGS",
"估算的合作伙伴广告收入": "AD_EARNINGS",
"估算的合作伙伴 DoubleClick 收入": "YOUTUBE_EARNINGS",
"估算的合作伙伴 AdSense 收入": "AFV_EARNINGS",
"YouTube 广告收入": "AD_GROSS_REVENUE",
"广告展示次数": "IMPRESSIONS",
"基于播放的每千次展示费用": "CPM",
"每千次展示费用": "IMPRESSIONS_CPM",
"估算的获利播放次数": "PLAYBACKS",
"每千次展示收入 (RPM)": "EPM",
"YouTube Premium 观看次数": "EXTERNAL_YOUTUBE_RED_VIEWS",
"YouTube Premium 观看时长(小时)": "EXTERNAL_YOUTUBE_RED_WATCH_TIME",
"播放列表观看时长(小时)": "PLAYLIST_WATCH_TIME_HOURS",
"播放列表观看次数": "PLAYLIST_VIEWS",
"播放列表平均观看时长": "PLAYLIST_AVERAGE_WATCH_TIME",
"播放列表平均观看百分比": "PLAYLIST_AVERAGE_WATCH_PERCENTAGE",
"播放列表开始播放的次数": "PLAYLIST_STARTS",
"播放列表退出次数": "PLAYLIST_EXITS",
"播放列表退出率": "PLAYLIST_EXIT_RATE",
"播放列表的平均观看时长": "PLAYLIST_AVERAGE_START_DURATION",
"播放列表每次开始播放产生的观看次数": "PLAYLIST_AVERAGE_VIEWS_PER_START",
"播放列表保存次数": "PLAYLIST_SAVES_NET_CHANGE",
"社区剪辑片段观看次数": "CLIP_VIEWS",
"社区剪辑片段带来的观看时长(小时)": "CLIP_VIDEO_WATCHTIME",
"卡片点击次数": "INFO_CARD_CLICKS",
"卡片展示次数": "INFO_CARD_IMPRESSIONS",
"卡片每次展示所获得的点击次数": "INFO_CARD_CLICK_RATE",
"卡片宣传语点击次数": "INFO_CARD_TEASER_CLICKS",
"卡片宣传语展示次数": "INFO_CARD_TEASER_IMPRESSIONS",
"卡片宣传语每次展示所获得的点击次数": "INFO_CARD_TEASER_CLICK_RATE",
"片尾画面元素点击次数": "ENDSCREEN_ELEMENT_CLICKS",
"片尾画面元素展示次数": "ENDSCREEN_ELEMENT_IMPRESSIONS",
"片尾画面元素每次展示所获得的点击次数": "ENDSCREEN_ELEMENT_CLICK_RATE",
"税前收益": "TOTAL_ESTIMATED_EARNINGS",
"税前收入": "TOTAL_ESTIMATED_EARNINGS",
"预计收益": "TOTAL_ESTIMATED_EARNINGS",
"订阅净增长": "SUBSCRIBERS_NET_CHANGE",
"订阅净变化": "SUBSCRIBERS_NET_CHANGE",
"净订阅": "SUBSCRIBERS_NET_CHANGE",
"订阅变化": "SUBSCRIBERS_NET_CHANGE",
"视频发布数": "VIDEO_COUNT_FIRST_PUBLISHED",
"发布视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
"互动观看": "ENGAGED_VIEWS",
"互动观看时长": "ENGAGED_VIEWS",
"参与观看": "ENGAGED_VIEWS",
"外部观看次数": "EXTERNAL_VIEWS",
"外部观看时长": "EXTERNAL_WATCH_TIME",
"观看时长": "EXTERNAL_WATCH_TIME",
"收益": "TOTAL_ESTIMATED_EARNINGS",
"收入": "TOTAL_ESTIMATED_EARNINGS",
"estimated_earnings": "TOTAL_ESTIMATED_EARNINGS",
"earnings": "TOTAL_ESTIMATED_EARNINGS",
"subscribers_net_change": "SUBSCRIBERS_NET_CHANGE",
"net_change": "SUBSCRIBERS_NET_CHANGE",
"video_count_first_published": "VIDEO_COUNT_FIRST_PUBLISHED",
"engaged_views": "ENGAGED_VIEWS",
"external_views": "EXTERNAL_VIEWS",
"external_watch_time": "EXTERNAL_WATCH_TIME",
"average_watch_time": "AVERAGE_WATCH_TIME",
"total_estimated_earnings": "TOTAL_ESTIMATED_EARNINGS"
}

View File

@@ -22,6 +22,8 @@ description: "根据需求清单CSV/Excel批量生成 YouTube Studio 内
- **数据周期Period**`yyyy.mm.dd-yyyy.mm.dd``yyyy.m.d-yyyy.m.d` 的日期区间,起始日与结束日均包含。 - **数据周期Period**`yyyy.mm.dd-yyyy.mm.dd``yyyy.m.d-yyyy.m.d` 的日期区间,起始日与结束日均包含。
- **日界线Day Boundary**:周期中日期对应的 Unix 毫秒,采用「锚点 2026-06-15 = 1781506800000 + 整日偏移」计算,不做时区换算。 - **日界线Day Boundary**:周期中日期对应的 Unix 毫秒,采用「锚点 2026-06-15 = 1781506800000 + 整日偏移」计算,不做时区换算。
- **国家筛选Country Filter**`ur_dimensions=COUNTRY` + `ur_values`;多国以 `'``%27`)包裹、`|``%7C`)连接;国家用 ISO 3166-1 alpha-2 代码。 - **国家筛选Country Filter**`ur_dimensions=COUNTRY` + `ur_values`;多国以 `'``%27`)包裹、`|``%7C`)连接;国家用 ISO 3166-1 alpha-2 代码。
- **指标Metric**报表主指标URL 中的 `metric` 参数;每行可选(`指标` 列),中文名/英文名写在同目录 `metrics.json` 里映射到内部代码,留空走 `CONFIG` 默认。选中后排序字段 `o_column` 一并跟随。
- **维度Dimension**报表细分维度URL 中的 `dimension` 参数;每行可选(`维度` 列),中文名写在同目录 `dimensions.json` 里映射到内部代码,留空走 `CONFIG` 默认。
## 工作流 ## 工作流
@@ -44,6 +46,8 @@ description: "根据需求清单CSV/Excel批量生成 YouTube Studio 内
| 实体ID | 视类型 | 群组/频道/节目必填所有者场景可留空回退用所有者ID | | 实体ID | 视类型 | 群组/频道/节目必填所有者场景可留空回退用所有者ID |
| 数据周期 | 是 | 见领域词汇「数据周期」 | | 数据周期 | 是 | 见领域词汇「数据周期」 |
| 国家 | 可空 | 一个或多个,中文名或两位 ISO 代码 | | 国家 | 可空 | 一个或多个,中文名或两位 ISO 代码 |
| 指标 | 可默认 | 主指标(`metric` 参数);中文名/英文名或已知指标代码;留空走 CONFIG 默认 |
| 维度 | 可默认 | 细分维度(`dimension` 参数);中文名或已知维度代码;留空走 CONFIG 默认 |
完成标准:对每一条需求,上表要素已确定,或明确「留空走默认」。 完成标准:对每一条需求,上表要素已确定,或明确「留空走默认」。
@@ -68,17 +72,22 @@ uv run python scripts\build_studio_urls.py -i <输入文件> [-o <输出csv>]
### 5. 校验输出并处理失败行 ### 5. 校验输出并处理失败行
- 检查输出列所有者名称、所有者ID、实体类型、实体名称、实体ID、数据周期、国家、国家代码、开始时间戳、结束时间戳、URL。 - 检查输出列所有者名称、所有者ID、实体类型、实体名称、实体ID、数据周期、国家、国家代码、指标、指标代码、维度、维度代码、开始时间戳、结束时间戳、URL。
- 抽查 URL 是否包含正确的 `entity_type` / `entity_id` / `time_period` / `ur_values`(国家筛选)。 - 抽查 URL 是否包含正确的 `entity_type` / `entity_id` / `time_period` / `ur_values`(国家筛选)`metric`(指标)、`dimension`(维度)
- 有失败行时脚本以退出码 2 结束,并在 stderr 逐行打印「第N行 …:原因」;逐条按 [references/troubleshooting.md](references/troubleshooting.md) 修复后重跑。 - 有失败行时脚本以退出码 2 结束,并在 stderr 逐行打印「第N行 …:原因」;逐条按 [references/troubleshooting.md](references/troubleshooting.md) 修复后重跑。
完成标准:所有需求行均生成 URL或失败行已定位原因并修复。 完成标准:所有需求行均生成 URL或失败行已定位原因并修复。
## 固定参数CONFIG ## 固定参数CONFIG
metric / granularity / dimension / t_metrics / o_column / o_direction / explore_type / comparison_type 等固定参数集中在脚本顶部 `CONFIG` 一处维护。**修改固定参数只需改 `CONFIG`(单点维护)**,不要逐行携带到需求清单中。 granularity / dimension / t_metrics / o_direction / explore_type / comparison_type 等固定参数集中在脚本顶部 `CONFIG` 一处维护。**修改这些固定参数只需改 `CONFIG`(单点维护)**,不要逐行携带到需求清单中。
`metric`(主指标)**可按行选择**:在需求清单加一列 `指标`(别名 `主指标` / `数据指标` / `metric`),填中文名/英文名(`metrics.json` 映射)或已知指标代码均可;留空则回退 `CONFIG["metric"]`,选中时 `o_column`(排序字段)一并跟随。
`dimension`(细分维度)**同样可按行选择**:在需求清单加一列 `维度`(别名 `细分维度` / `dimension`),填中文名(`dimensions.json` 映射)或已知维度代码均可;留空则回退 `CONFIG["dimension"]`
## 参考 ## 参考
- [需求输入制作指南](references/input-guide.md):列名别名、日期/国家/实体类型写法、完整示例、制作核对清单。 - [需求输入制作指南](references/input-guide.md):列名别名、日期/国家/实体类型写法、完整示例、制作核对清单。
- [常见问题排查](references/troubleshooting.md):按 现象 → 原因 → 解决 逐条排查。 - [常见问题排查](references/troubleshooting.md):按 现象 → 原因 → 解决 逐条排查。
- [映射文件维护指南](references/maintenance-guide.md)`countries.json` / `metrics.json` / `dimensions.json` 三个映射文件的来源、结构与新增/更新条目方法。

View File

@@ -21,10 +21,12 @@
| entity_type 实体类型 | 否 | 实体类型 / 类型 / entity_type / type | 群组 | | entity_type 实体类型 | 否 | 实体类型 / 类型 / entity_type / type | 群组 |
| period 数据周期 | **是** | 数据周期 / 周期 / period / time_period / 日期范围 | 2026.08.01-2026.08.31 | | period 数据周期 | **是** | 数据周期 / 周期 / period / time_period / 日期范围 | 2026.08.01-2026.08.31 |
| countries 国家 | 否 | 国家 / 国家/地区 / countries / country / 筛选国家 / 地区 | 美国,日本 | | countries 国家 | 否 | 国家 / 国家/地区 / countries / country / 筛选国家 / 地区 | 美国,日本 |
| metric 指标 | 否 | 指标 / 主指标 / 数据指标 / metric / 指标名 | 观看时长 |
| dimension 维度 | 否 | 维度 / 细分维度 / dimension / 细分 | 地理位置 |
> 注意: > 注意:
> - 「所有者名称 / 实体名称」只进输出回显,不参与 URL 拼装,缺列不影响生成。 > - 「所有者名称 / 实体名称」只进输出回显,不参与 URL 拼装,缺列不影响生成。
> - **必要列只有两列所有者ID、数据周期**。 > - **必要列只有两列所有者ID、数据周期**。指标列留空则用脚本 `CONFIG` 默认主指标。
## 3. 数据周期格式 ## 3. 数据周期格式
@@ -56,16 +58,16 @@
## 6. 完整示例(对应 assets/需求输入示例.xlsx ## 6. 完整示例(对应 assets/需求输入示例.xlsx
| 所有者名称 | 所有者ID | 实体类型 | 实体名称 | 实体ID | 数据周期 | 国家 | | 所有者名称 | 所有者ID | 实体类型 | 实体名称 | 实体ID | 数据周期 | 国家 | 指标 | 维度 |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-1 | NCy9C2QPQ1E | 2026.08.01-2026.08.31 | 美国 | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-1 | NCy9C2QPQ1E | 2026.08.01-2026.08.31 | 美国 | 观看时长 | (留空,走默认) |
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-2 | NCyxxxxxxxxx | 2026.8.1-2026.8.31 | 美国,日本 | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-2 | NCyxxxxxxxxx | 2026.8.1-2026.8.31 | 美国,日本 | (留空,走默认) | 地理位置 |
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-3 | NCyYYYYYYYYY | 2026.07.01-2026.07.31 | GB,DE,FR | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 群组 | 示例群组-3 | NCyYYYYYYYYY | 2026.07.01-2026.07.31 | GB,DE,FR | 预计收益 | (留空,走默认) |
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 所有者 | 账号整体 | (留空) | 2026.06.01-2026.06.30 | US | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 所有者 | 账号整体 | (留空) | 2026.06.01-2026.06.30 | US | (留空,走默认) | 内容 |
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 频道 | 示例频道 | UCxxxxxUCxxxxx | 2026.08.01-2026.08.15 | 日本 | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 频道 | 示例频道 | UCxxxxxUCxxxxx | 2026.08.01-2026.08.15 | 日本 | 外部展示 | (留空,走默认) |
| 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 节目 | 示例节目 | video123456 | 2026.08.01-2026.08.31 | 韩国,日本 | | 示例内容所有者 | bqSUnNpU67xJ51TxH4PKpQ | 节目 | 示例节目 | video123456 | 2026.08.01-2026.08.31 | 韩国,日本 | (留空,走默认) | (留空,走默认) |
覆盖场景:单/多国家、中文名与 ISO 代码混用、四种实体类型、带/不带前导零的日期、所有者场景留空实体ID。 覆盖场景:单/多国家、中文名与 ISO 代码混用、四种实体类型、带/不带前导零的日期、所有者场景留空实体ID、指标可选(中文名或留空走默认)、维度可选(中文名或留空走默认)
## 7. 制作核对清单 ## 7. 制作核对清单
@@ -74,6 +76,8 @@
- [ ] 群组/频道/节目行已填实体ID所有者行可留空实体ID。 - [ ] 群组/频道/节目行已填实体ID所有者行可留空实体ID。
- [ ] 中文国家名已在 countries.json 中,否则改用两位 ISO 代码。 - [ ] 中文国家名已在 countries.json 中,否则改用两位 ISO 代码。
- [ ] 多国分隔符正确。 - [ ] 多国分隔符正确。
- [ ] 指标列:中文名/英文名已在 metrics.json 中,或用已知指标代码;留空表示走默认。
- [ ] 维度列:中文名已在 dimensions.json 中,或用已知维度代码;留空表示走默认。
- [ ] 试跑无失败行、无「缺必要列」错误。 - [ ] 试跑无失败行、无「缺必要列」错误。
试跑命令(项目根目录): 试跑命令(项目根目录):
@@ -81,3 +85,36 @@
```bash ```bash
uv run python scripts\build_studio_urls.py -i <输入文件> -o <输出csv> uv run python scripts\build_studio_urls.py -i <输入文件> -o <输出csv>
``` ```
## 8. 指标格式
指标列(`指标` / `主指标` / `数据指标` / `metric`**每行可选**,决定 URL 的主指标 `metric` 参数与排序字段 `o_column`
- **留空**:用脚本 `CONFIG["metric"]` 默认主指标(`SUBSCRIBERS_NET_CHANGE`),排序字段用 `CONFIG["o_column"]`
- **中文/英文名**:须在 `scripts/metrics.json` 映射内(可自行扩充),如 `观看时长``EXTERNAL_WATCH_TIME``预计收益``TOTAL_ESTIMATED_EARNINGS`
- **指标代码**:已知代码原样透传,大小写不敏感,如 `external_views``EXTERNAL_VIEWS`
`metrics.json` 内已覆盖完整清单(与 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 的「数据指标」表一致57 条),并额外收编了常用别名。下面仅列一部分;完整可用映射见该文件,也可自行扩充:
| 中文名 | 指标代码 | 说明 |
|---|---|---|
| 订阅人数 / 订阅净增长 | SUBSCRIBERS_NET_CHANGE | 主指标/排序默认 |
| 观看次数 | EXTERNAL_VIEWS | 表格列指标 |
| 观看时长(小时) | EXTERNAL_WATCH_TIME | 表格列指标 |
| 估算的合作伙伴收入 / 预计收益 / 税前收益 / 税前收入 | TOTAL_ESTIMATED_EARNINGS | 表格列指标 |
| 平均观看时长 | AVERAGE_WATCH_TIME | 表格列指标 |
| 发布的视频数 | VIDEO_COUNT_FIRST_PUBLISHED | 表格列指标 |
> 指标只影响 `metric` 与 `o_column``t_metrics`(表格列指标集合)、粒度等仍是 `CONFIG` 固定值。
## 9. 细分维度
维度列(`维度` / `细分维度` / `dimension`**每行可选**,决定 URL 的 `dimension` 参数。
- **留空**:用脚本 `CONFIG["dimension"]` 默认细分维度(`USER`,即「频道」)。
- **中文名**:须在 `scripts/dimensions.json` 映射内(可自行扩充),如 `内容``VIDEO``地理位置``COUNTRY``频道``USER`
- **维度代码**:已知代码原样透传,大小写不敏感,如 `video``VIDEO`
`dimensions.json` 内已覆盖完整清单与「细分维度」表一致33 条),例如:内容 `VIDEO`、流量来源 `TRAFFIC_SOURCE_TYPE`、地理位置 `COUNTRY`、频道 `USER`、资产 `ASSET`、设备类型 `DEVICE_PLATFORM_TYPE`、操作系统 `DEVICE_OS_TYPE`、收入来源 `EARNINGS_SOURCE_ALL`、日期 `DAY` 等。
> 维度只影响 `dimension` 参数;`metric`、`t_metrics`、粒度等不受影响。

View File

@@ -0,0 +1,135 @@
# 映射文件维护指南
`scripts/` 下有三个映射文件,把「人类可读的中文/英文名」翻译成「URL 内部代码」。本指南说明它们各自的作用、数据来源、结构规则,以及如何新增/更新条目。
- `countries.json`:中文国家名 → ISO 3166-1 alpha-2 代码(两位大写)。
- `metrics.json`:指标中文/英文名 → 指标代码(大写、下划线风格)。
- `dimensions.json`:细分维度中文名 → 维度代码(大写)。
三者都是**扁平 `key -> value` 的 JSON 对象**UTF-8 编码。脚本按 UTF-8-SIG 读取,带 BOM 无影响。
## 1. 脚本如何加载它们
| 文件 | 加载函数 | 参数 | 缺失/文件不存在时的行为 |
|---|---|---|---|
| countries.json | `load_country_map` | `--countries` | 仅支持两位 ISO 代码透传,中文名会报「未识别的国家」 |
| metrics.json | `load_metric_map` | `--metrics` | 仅支持 `CONFIG` 里的已知指标代码透传,中文/英文名会报「未识别的指标」 |
| dimensions.json | `load_dimension_map` | `--dimensions` | 仅支持 `CONFIG["dimension"]` 已知维度代码透传,中文名会报「未识别的维度」 |
加载时统一做了`strip` + 值 `upper()`,因此**文件里大小写、首尾空格不影响结果**——但建议源文件就保持规范,便于 diff 和阅读。
## 2. 数据来源Source of Truth
内部代码的真实来源是 YouTube Studio 后台 explore 报告 URL 里的 `metric=` / `dimension=` / `t_metrics=` 参数(或捕获的 `search_groups`/analytics 请求)。仓库内 `assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 已把已知清单整理好,是当前映射的**基准数据源**
| 映射文件 | 对应 Excel 表 | 取「同名」列 → 值列 |
|---|---|---|
| dimensions.json | `细分维度` 表 | `中文名称(显示名)``URL 参数值dimension=` |
| metrics.json | `数据指标` 表 | `中文名称(显示名)``URL 参数值t_metrics=/metric=` |
| countries.json | 无 Excel 源 | 基于 ISO 3166-1 alpha-2 标准手工维护 |
`时间颗粒度` 表对应脚本 `CONFIG["granularity"]`**不落入 JSON 文件**`URL参数总览` 表是对 URL 各参数的说明,供核对。
## 3. 三个文件的结构与规则
### 3.1 dimensions.json细分维度
- 键:维度中文显示名,如 `内容``地理位置``收入来源`
- 值:维度代码(大写),如 `VIDEO``COUNTRY``EARNINGS_SOURCE_ALL`
规则:
- 一个中文名对应一个代码;多个不同中文名可指向**同一**代码(如需,但避免冗余)。
- 新增一个维度后,其代码会被 `load_dimension_map` 自动加入「可透传集合」——即该代码立刻可作为裸代码在 `维度` 列直接填写,**无需改 `build_studio_urls.py`**。
- 维度代码只影响 URL 的 `dimension` 参数。
### 3.2 metrics.json数据指标
标准条目(来自 Excel「数据指标」表57 条):
- 键:指标中文名,如 `观看次数``估算的合作伙伴收入`
- 值:指标代码(大写),如 `EXTERNAL_VIEWS``TOTAL_ESTIMATED_EARNINGS`
别名条目(手工追加,用于同义词/英文名,**不会出现在 Excel 中**
- 多个中文同义词 → 同一代码。如 `税前收益``税前收入``预计收益``收益``收入` 都指向 `TOTAL_ESTIMATED_EARNINGS`
- 英文 snake_case → 同一代码。如 `subscribers_net_change``SUBSCRIBERS_NET_CHANGE`
规则:
- 一个键对应唯一一个代码JSON 键唯一,重复键后者覆盖前者,应避免手写重复)。
- 一个代码可有多个键(同义词),方向只能多对一。
- 新增一个指标后,其代码会被 `load_metric_map` 自动加入「可透传集合」,可直接作为裸代码填写。
- 指标只影响 URL 的 `metric``o_column`(排序字段)两个参数;`t_metrics`(表格列指标集合)仍是 `CONFIG` 固定值,**不在 JSON 里维护**。
### 3.3 countries.json国家
- 键:中文国家名(可含别名,如 `台湾``中国台湾` 都指向 `TW`)。
-ISO 3166-1 alpha-2 两位大写代码,如 `US``JP``CN`
规则:
- 值必须为**两位大写字母**。多字/小写虽能被脚本 upper 后处理,但透传逻辑依赖 `[A-Z]{2}` 正则,非两位会走中文名映射路径。
- 两位 ISO 代码本身**无需在此文件登记**即可透传(`parse_countries` 正则直接识别),所以国家列填 `US` 永远可行;只有在需要「中文名 → 代码」时才在这加一行。
## 4. 当改什么 / 怎么改
| 场景 | 改动位置 | 例子 |
|---|---|---|
| 新增一个细分维度 | dimensions.json 加 `"中文名": "代码"` | `"播放来源": "PLAYBACK_SOURCE_TYPE"` |
| 新增一个指标 | metrics.json 加 `"中文名": "代码"` | `"新增指标": "SOME_NEW_METRIC"` |
| 给指标加中文同义词/英文别名 | metrics.json 加 `"别名": "同一代码"` | `"税前收益": "TOTAL_ESTIMATED_EARNINGS"` |
| 给维度加别名 | dimensions.json 加 `"别名": "同一代码"` | `"地区": "COUNTRY"` |
| 新增中文国家名 | countries.json 加 `"中文名": "XX"` | `"瑞典": "SE"` |
| 调整默认主指标/维度/粒度 | 改 `build_studio_urls.py` 顶部 `CONFIG`**不是 JSON 文件** | `CONFIG["metric"] = "..."` |
## 5. 从 Excel 重新同步(可选脚本)
`assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx` 更新后,可用以下片段把「数据指标」「细分维度」两张表重新导出为 JSON再合并回文件别名条目需要手工保留
```python
import openpyxl, json
xlsx = r"assets/YouTube_Studio_细分维度_时间颗粒度_完整清单.xlsx"
wb = openpyxl.load_workbook(xlsx, data_only=True)
def dump(sheet_name, name_col, code_col):
ws = wb[sheet_name]
rows = list(ws.iter_rows(values_only=True))
header = rows[0]
ni, ci = header.index(name_col), header.index(code_col)
return {str(r[ni]).strip(): str(r[ci]).strip().upper()
for r in rows[1:] if r[ni] and r[ci]}
metrics = dump("数据指标", "中文名称(显示名)", "URL 参数值t_metrics=/metric=")
dims = dump("细分维度", "中文名称(显示名)", "URL 参数值dimension=")
json.dump(metrics, open("skills/yt-studio-url-builder/scripts/metrics.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
json.dump(dims, open("skills/yt-studio-url-builder/scripts/dimensions.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
```
> 该片段生成的是「标准条目全量覆盖」,会**丢失**你在 metrics.json 里追加的别名;建议先备份,再把别名手工合并回去。
## 6. 更新后的校验
改完任一 JSON按下面顺序自检
1. **JSON 语法有效**
```bash
uv run python -m json.tool scripts\metrics.json > $null
uv run python -m json.tool scripts\dimensions.json > $null
uv run python -m json.tool scripts\countries.json > $null
```
2. **键非空、值非空**:加载函数已过滤空键值,但建议源文件里就不要出现空串。
3. **值大写**:脚本会 `upper()`,但提交前统一大写更清晰;国家值必须是两位字母。
4. **无重复键**JSON 标准允许重复键但后者覆盖,属于隐性错误;手改时避免。
5. **跑一条真实需求验证**:造一行含新中文名/新代码的输入,确认输出 CSV 的「指标代码/维度代码/国家代码」解析正确、URL 里 `metric`/`dimension`/`ur_values` 正确。
6. **回归测试**
```bash
uv run python -m pytest tests/test_build_studio_urls.py tests/test_build_studio_urls_cli.py -q
```
## 7. 常见坑
- **文件缺失**三个文件单个缺失只会降级stderr 提示),名称/代码透传受限;务必恢复同目录下的文件,不要依赖降级路径长期运行。
- **以为改 JSON 能改默认值**:默认主指标/维度/粒度在 `build_studio_urls.py` 的 `CONFIG`,不在 JSON。JSON 只提供「可按行选择」的名称→代码映射。
- **`t_metrics` 不在 metrics.json 里**:表格列指标集合是 `CONFIG["t_metrics"]` 固定数组,加指标时若想让其进入表格列,需同时在 `CONFIG["t_metrics"]` 追加(否则只影响 `metric`/`o_column`)。
- **国家值写三位或带空格**:透传按 `[A-Z]{2}` 匹配,非两位值不会命中透传分支,中文名若也查不到就会报「未识别的国家」。
- **别名覆盖了标准名**:手动追加别名时不要与现有键重复,否则后者覆盖,标准名会失效。

View File

@@ -46,6 +46,14 @@
- 原因:`entity_type` 不在映射表。 - 原因:`entity_type` 不在映射表。
- 解决:使用 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO。 - 解决:使用 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO。
### 未识别的指标
- 原因:`指标` 列的值不在 `metrics.json` 映射里,也不是已知指标代码。
- 解决:改用 `metrics.json` 中的中文/英文名或已知指标代码(如 `SUBSCRIBERS_NET_CHANGE`),或向 `scripts/metrics.json` 追加映射;留空该列则走 `CONFIG` 默认。
### 未识别的维度
- 原因:`维度` 列的值不在 `dimensions.json` 映射里,也不是已知维度代码。
- 解决:改用 `dimensions.json` 中的中文名或已知维度代码(如 `VIDEO`),或向 `scripts/dimensions.json` 追加映射;留空该列则走 `CONFIG` 默认。
## 输出问题 ## 输出问题
### 输出行数 < 输入行数 ### 输出行数 < 输入行数
@@ -67,5 +75,5 @@
## 改了固定参数不生效 ## 改了固定参数不生效
- 原因:固定参数集中在脚本顶部 `CONFIG`,改后需重跑脚本才会生效。 - 原因:粒度、维度、`t_metrics`固定参数集中在脚本顶部 `CONFIG`,改后需重跑脚本才会生效。
- 提示:改 `CONFIG` 只影响 URL 参数,不需要改需求清单。 - 提示:改 `CONFIG` 只影响 URL 参数,不需要改需求清单;若想按行选主指标,改用需求清单的 `指标` 列(无需改 `CONFIG`

View File

@@ -15,12 +15,18 @@
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO) 实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d) 数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码) 国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
指标 : 指标 / 主指标 / 数据指标 / metric (可留空,则用 CONFIG 默认;中文名或指标代码)
维度 : 维度 / 细分维度 / dimension (可留空,则用 CONFIG 默认;中文名或维度代码)
说明: 说明:
- 数据周期起止日期均包含在数据范围内time_period 结束值取结束日后一天的日界线。 - 数据周期起止日期均包含在数据范围内time_period 结束值取结束日后一天的日界线。
- 国家为多个时ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27 - 国家为多个时ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json可自行扩充已是两位代码的原样透传。 - 中文国家名 -> ISO 代码的映射放在同目录 countries.json可自行扩充已是两位代码的原样透传。
- 固定参数metric/granularity/dimension/t_metrics 等)在本文件 CONFIG 中统一配置。 - 指标每行可选metric 参数,中文名 -> 代码的映射放在同目录 metrics.json可自行扩充
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
- 维度每行可选dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json可自行扩充
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
- 其余固定参数granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
""" """
import argparse import argparse
@@ -55,6 +61,11 @@ CONFIG = {
"comparison_type": "NONE", "comparison_type": "NONE",
} }
# 常用指标代码集合供指标代码透传metrics.json 提供中文/英文别名映射)
KNOWN_METRICS = {CONFIG["metric"], CONFIG["o_column"], *CONFIG["t_metrics"]}
# 常见维度代码供维度代码透传dimensions.json 提供中文别名映射)
KNOWN_DIMENSIONS = {CONFIG["dimension"]}
# 实体类型 中文/代码 -> URL 参数值(可扩充) # 实体类型 中文/代码 -> URL 参数值(可扩充)
ENTITY_TYPE_MAP = { ENTITY_TYPE_MAP = {
"群组": "GROUP", "GROUP": "GROUP", "群组": "GROUP", "GROUP": "GROUP",
@@ -95,6 +106,12 @@ COLUMN_ALIASES = {
# 国家 # 国家
"国家": "countries", "国家/地区": "countries", "国家地区": "countries", "国家": "countries", "国家/地区": "countries", "国家地区": "countries",
"countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries", "countries": "countries", "country": "countries", "筛选国家": "countries", "地区": "countries",
# 指标(每行可选;缺省用 CONFIG
"指标": "metric", "主指标": "metric", "主要指标": "metric", "数据指标": "metric",
"metric": "metric", "metric_name": "metric", "metricname": "metric", "指标名": "metric",
# 维度(每行可选;缺省用 CONFIG
"维度": "dimension", "细分维度": "dimension", "dimension": "dimension",
"dim": "dimension", "细分": "dimension", "维度名": "dimension",
} }
@@ -160,6 +177,66 @@ def parse_countries(text, country_map):
return codes return codes
def load_metric_map(path):
"""加载 指标中文/英文名 -> 指标代码 映射。文件缺失则仅支持已知指标代码透传。"""
if not os.path.exists(path):
print("[提示] 未找到指标映射文件 %s,仅支持直接填写指标代码" % path, file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
if str(k).strip() and str(v).strip()}
# 允许映射中出现的所有指标代码原样透传(大小写不敏感)
for code in set(mapped.values()) | KNOWN_METRICS:
mapped.setdefault(code.lower(), code)
return mapped
def parse_metric(text, metric_map):
"""指标列 -> 指标代码。空则返回 None走 CONFIG 默认);具体代码透传;未知则报错。"""
if text is None or (isinstance(text, float) and str(text) == "nan"):
return None
raw = str(text).strip().strip("'\"").strip()
if not raw:
return None
if raw.upper() in KNOWN_METRICS: # 已是已知指标代码
return raw.upper()
key = raw.lower()
if key in metric_map: # 中文/英文别名
return metric_map[key]
raise ValueError("未识别的指标: %r(不在映射文件中,也不是已知指标代码)" % raw)
def load_dimension_map(path):
"""加载 细分维度中文名 -> 代码 映射。文件缺失则仅支持已知维度代码透传。"""
if not os.path.exists(path):
print("[提示] 未找到维度映射文件 %s,仅支持直接填写维度代码" % path, file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
mapped = {str(k).strip(): str(v).strip().upper() for k, v in data.items()
if str(k).strip() and str(v).strip()}
# 允许映射中出现的所有维度代码原样透传(大小写不敏感)
for code in set(mapped.values()) | KNOWN_DIMENSIONS:
mapped.setdefault(code.lower(), code)
return mapped
def parse_dimension(text, dimension_map):
"""维度列 -> 维度代码。空则返回 None走 CONFIG 默认);具体代码透传;未知则报错。"""
if text is None or (isinstance(text, float) and str(text) == "nan"):
return None
raw = str(text).strip().strip("'\"").strip()
if not raw:
return None
if raw.upper() in KNOWN_DIMENSIONS: # 已是已知维度代码
return raw.upper()
key = raw.lower()
if key in dimension_map: # 中文别名
return dimension_map[key]
raise ValueError("未识别的维度: %r(不在映射文件中,也不是已知维度代码)" % raw)
def resolve_entity_type(text): def resolve_entity_type(text):
"""实体类型 -> URL 参数值。空则用默认群组。""" """实体类型 -> URL 参数值。空则用默认群组。"""
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "": if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
@@ -170,8 +247,11 @@ def resolve_entity_type(text):
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key) raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key)
def build_url(row, country_map): def build_url(row, country_map, metric_map=None, dimension_map=None):
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。""" """根据一行需求生成 URL。返回 (url, 状态, 错误信息)。
metric_map / dimension_map 为 名 -> 代码 映射;对应列缺省时回退到 CONFIG 默认。
"""
owner_id = row.get("owner_id", "").strip() owner_id = row.get("owner_id", "").strip()
entity_type = resolve_entity_type(row.get("entity_type", "")) entity_type = resolve_entity_type(row.get("entity_type", ""))
entity_id = row.get("entity_id", "").strip() entity_id = row.get("entity_id", "").strip()
@@ -191,6 +271,15 @@ def build_url(row, country_map):
codes = parse_countries(row.get("countries", ""), country_map) codes = parse_countries(row.get("countries", ""), country_map)
# 指标:每行可选;空 -> 主指标/排序字段用 CONFIG 默认;非空 -> 二者都跟随所选指标
sel_metric = parse_metric(row.get("metric", ""), metric_map or {})
metric = sel_metric or CONFIG["metric"]
o_column = sel_metric or CONFIG["o_column"]
# 维度:每行可选;空 -> 用 CONFIG 默认细分维度
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map or {})
dimension = sel_dimension or CONFIG["dimension"]
base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id base = "https://studio.youtube.com/owner/%s/analytics/tab-overview/period-default/explore" % owner_id
params = [] params = []
params.append("o=%s" % owner_id) params.append("o=%s" % owner_id)
@@ -204,12 +293,12 @@ def build_url(row, country_map):
params.append("ur_exclusive_ends=") params.append("ur_exclusive_ends=")
params.append("time_period=%d%%2C%d" % (start_ms, end_ms)) params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
params.append("explore_type=%s" % CONFIG["explore_type"]) params.append("explore_type=%s" % CONFIG["explore_type"])
params.append("metric=%s" % CONFIG["metric"]) params.append("metric=%s" % metric)
params.append("granularity=%s" % CONFIG["granularity"]) params.append("granularity=%s" % CONFIG["granularity"])
for m in CONFIG["t_metrics"]: for m in CONFIG["t_metrics"]:
params.append("t_metrics=%s" % m) params.append("t_metrics=%s" % m)
params.append("dimension=%s" % CONFIG["dimension"]) params.append("dimension=%s" % dimension)
params.append("o_column=%s" % CONFIG["o_column"]) params.append("o_column=%s" % o_column)
params.append("o_direction=%s" % CONFIG["o_direction"]) params.append("o_direction=%s" % CONFIG["o_direction"])
params.append("comparison_type=%s" % CONFIG["comparison_type"]) params.append("comparison_type=%s" % CONFIG["comparison_type"])
@@ -222,6 +311,8 @@ def main():
parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls") parser.add_argument("-i", "--input", required=True, help="需求清单文件(.csv / .xlsx / .xls")
parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv") parser.add_argument("-o", "--output", default=None, help="输出 CSV 路径(默认:输入同目录 studio_urls_output.csv")
parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json") parser.add_argument("--countries", default=None, help="国家映射 JSON 路径(默认:脚本同目录 countries.json")
parser.add_argument("--metrics", default=None, help="指标映射 JSON 路径(默认:脚本同目录 metrics.json")
parser.add_argument("--dimensions", default=None, help="维度映射 JSON 路径(默认:脚本同目录 dimensions.json")
args = parser.parse_args() args = parser.parse_args()
if not os.path.exists(args.input): if not os.path.exists(args.input):
@@ -252,18 +343,26 @@ def main():
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json") countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
country_map = load_country_map(countries_file) country_map = load_country_map(countries_file)
metrics_file = args.metrics or os.path.join(os.path.dirname(os.path.abspath(__file__)), "metrics.json")
metric_map = load_metric_map(metrics_file)
dimensions_file = args.dimensions or os.path.join(os.path.dirname(os.path.abspath(__file__)), "dimensions.json")
dimension_map = load_dimension_map(dimensions_file)
records = [] records = []
errors = [] errors = []
for idx, raw in df.iterrows(): for idx, raw in df.iterrows():
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping} row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
try: try:
url, status, msg = build_url(row, country_map) url, status, msg = build_url(row, country_map, metric_map, dimension_map)
except ValueError as e: except ValueError as e:
url, status, msg = None, "error", str(e) url, status, msg = None, "error", str(e)
if status == "ok": if status == "ok":
start_ms, end_ms = parse_period(row.get("period", "")) start_ms, end_ms = parse_period(row.get("period", ""))
codes = parse_countries(row.get("countries", ""), country_map) codes = parse_countries(row.get("countries", ""), country_map)
sel_metric = parse_metric(row.get("metric", ""), metric_map)
metric_code = sel_metric or CONFIG["metric"]
sel_dimension = parse_dimension(row.get("dimension", ""), dimension_map)
dimension_code = sel_dimension or CONFIG["dimension"]
records.append({ records.append({
"所有者名称": row.get("owner_name", ""), "所有者名称": row.get("owner_name", ""),
"所有者ID": row.get("owner_id", ""), "所有者ID": row.get("owner_id", ""),
@@ -273,6 +372,10 @@ def main():
"数据周期": row.get("period", ""), "数据周期": row.get("period", ""),
"国家": row.get("countries", ""), "国家": row.get("countries", ""),
"国家代码": ",".join(codes), "国家代码": ",".join(codes),
"指标": row.get("metric", ""),
"指标代码": metric_code,
"维度": row.get("dimension", ""),
"维度代码": dimension_code,
"开始时间戳": start_ms, "开始时间戳": start_ms,
"结束时间戳": end_ms, "结束时间戳": end_ms,
"URL": url, "URL": url,

View File

@@ -0,0 +1,35 @@
{
"内容": "VIDEO",
"流量来源": "TRAFFIC_SOURCE_TYPE",
"地理位置": "COUNTRY",
"频道": "USER",
"资产": "ASSET",
"频道所有权": "UPLOADER_TYPE",
"版权声明状态": "CLAIMED_STATUS",
"内容类型": "CREATOR_CONTENT_TYPE",
"播放列表": "PLAYLIST",
"观看者年龄": "VIEWER_AGE",
"观看者性别": "VIEWER_GENDER",
"新观看者和回访观看者": "LOYALTY_STATE",
"按观看行为细分的观众群": "AUDIENCE_LOYALTY_SEGMENT",
"订阅状态": "SUBSCRIBED_TO_UPLOADER_STATE",
"订阅来源": "SUBSCRIPTION_SOURCE_TYPE",
"YouTube 产品": "PLAYER_APP_TYPE",
"设备类型": "DEVICE_PLATFORM_TYPE",
"操作系统": "DEVICE_OS_TYPE",
"收入来源": "EARNINGS_SOURCE_ALL",
"广告类型": "ADTYPES",
"交易类型": "TRANSACTION_BUSINESS_MODEL",
"自然流量和付费流量": "AD_STATUS",
"日期": "DAY",
"字幕": "CAPTION_LANGUAGE",
"视频信息语言": "VIDEO_METADATA_LANGUAGE",
"是否使用翻译": "IS_CROSS_LANGUAGE",
"片尾画面元素": "ENDSCREEN_ELEMENT_ID",
"片尾画面元素类型": "ENDSCREEN_ELEMENT_TYPE",
"卡片": "INFO_CARD_ID",
"卡片类型": "INFO_CARD_TYPE",
"播放位置": "PLAYBACK_LOCATION_TYPE",
"播放器类型": "EMBEDDED_PLAYER_MODE",
"分享服务": "SHARING_SERVICE"
}

View File

@@ -0,0 +1,86 @@
{
"观看次数": "EXTERNAL_VIEWS",
"感兴趣的观看次数": "ENGAGED_VIEWS",
"观看时长(小时)": "EXTERNAL_WATCH_TIME",
"订阅人数": "SUBSCRIBERS_NET_CHANGE",
"平均观看时长": "AVERAGE_WATCH_TIME",
"平均观看百分比": "AVERAGE_WATCH_PERCENTAGE",
"添加的视频数": "VIDEO_COUNT_NEW",
"发布的视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
"展示次数": "VIDEO_THUMBNAIL_IMPRESSIONS",
"展示点击率": "VIDEO_THUMBNAIL_IMPRESSIONS_VTR",
"继续观看": "SHORTS_FEED_IMPRESSIONS_VTR",
"唯一身份观看者人数": "ESTIMATED_UNIQUE_VIEWERS",
"人均观看次数": "AVERAGE_VIEWS_PER_VIEWER",
"获得的订阅人数": "SUBSCRIBERS_GAINED",
"流失的订阅人数": "SUBSCRIBERS_LOST",
"赞": "RATINGS_LIKES",
"不喜欢": "RATINGS_DISLIKES",
"赞和不喜欢的比率": "LIKES_PER_LIKES_PLUS_DISLIKES_PERCENT",
"分享数": "SHARINGS",
"添加的评论数": "COMMENTS",
"估算的合作伙伴收入": "TOTAL_ESTIMATED_EARNINGS",
"合作伙伴的交易收入": "TRANSACTION_EARNINGS_ALL",
"交易次数": "TRANSACTION_COUNT",
"合作伙伴的每笔交易收入": "AVERAGE_TRANSACTION_AMOUNT",
"YouTube Premium 合作伙伴收入": "SUBSCRIPTION_EARNINGS",
"估算的合作伙伴广告收入": "AD_EARNINGS",
"估算的合作伙伴 DoubleClick 收入": "YOUTUBE_EARNINGS",
"估算的合作伙伴 AdSense 收入": "AFV_EARNINGS",
"YouTube 广告收入": "AD_GROSS_REVENUE",
"广告展示次数": "IMPRESSIONS",
"基于播放的每千次展示费用": "CPM",
"每千次展示费用": "IMPRESSIONS_CPM",
"估算的获利播放次数": "PLAYBACKS",
"每千次展示收入 (RPM)": "EPM",
"YouTube Premium 观看次数": "EXTERNAL_YOUTUBE_RED_VIEWS",
"YouTube Premium 观看时长(小时)": "EXTERNAL_YOUTUBE_RED_WATCH_TIME",
"播放列表观看时长(小时)": "PLAYLIST_WATCH_TIME_HOURS",
"播放列表观看次数": "PLAYLIST_VIEWS",
"播放列表平均观看时长": "PLAYLIST_AVERAGE_WATCH_TIME",
"播放列表平均观看百分比": "PLAYLIST_AVERAGE_WATCH_PERCENTAGE",
"播放列表开始播放的次数": "PLAYLIST_STARTS",
"播放列表退出次数": "PLAYLIST_EXITS",
"播放列表退出率": "PLAYLIST_EXIT_RATE",
"播放列表的平均观看时长": "PLAYLIST_AVERAGE_START_DURATION",
"播放列表每次开始播放产生的观看次数": "PLAYLIST_AVERAGE_VIEWS_PER_START",
"播放列表保存次数": "PLAYLIST_SAVES_NET_CHANGE",
"社区剪辑片段观看次数": "CLIP_VIEWS",
"社区剪辑片段带来的观看时长(小时)": "CLIP_VIDEO_WATCHTIME",
"卡片点击次数": "INFO_CARD_CLICKS",
"卡片展示次数": "INFO_CARD_IMPRESSIONS",
"卡片每次展示所获得的点击次数": "INFO_CARD_CLICK_RATE",
"卡片宣传语点击次数": "INFO_CARD_TEASER_CLICKS",
"卡片宣传语展示次数": "INFO_CARD_TEASER_IMPRESSIONS",
"卡片宣传语每次展示所获得的点击次数": "INFO_CARD_TEASER_CLICK_RATE",
"片尾画面元素点击次数": "ENDSCREEN_ELEMENT_CLICKS",
"片尾画面元素展示次数": "ENDSCREEN_ELEMENT_IMPRESSIONS",
"片尾画面元素每次展示所获得的点击次数": "ENDSCREEN_ELEMENT_CLICK_RATE",
"税前收益": "TOTAL_ESTIMATED_EARNINGS",
"税前收入": "TOTAL_ESTIMATED_EARNINGS",
"预计收益": "TOTAL_ESTIMATED_EARNINGS",
"订阅净增长": "SUBSCRIBERS_NET_CHANGE",
"订阅净变化": "SUBSCRIBERS_NET_CHANGE",
"净订阅": "SUBSCRIBERS_NET_CHANGE",
"订阅变化": "SUBSCRIBERS_NET_CHANGE",
"视频发布数": "VIDEO_COUNT_FIRST_PUBLISHED",
"发布视频数": "VIDEO_COUNT_FIRST_PUBLISHED",
"互动观看": "ENGAGED_VIEWS",
"互动观看时长": "ENGAGED_VIEWS",
"参与观看": "ENGAGED_VIEWS",
"外部观看次数": "EXTERNAL_VIEWS",
"外部观看时长": "EXTERNAL_WATCH_TIME",
"观看时长": "EXTERNAL_WATCH_TIME",
"收益": "TOTAL_ESTIMATED_EARNINGS",
"收入": "TOTAL_ESTIMATED_EARNINGS",
"estimated_earnings": "TOTAL_ESTIMATED_EARNINGS",
"earnings": "TOTAL_ESTIMATED_EARNINGS",
"subscribers_net_change": "SUBSCRIBERS_NET_CHANGE",
"net_change": "SUBSCRIBERS_NET_CHANGE",
"video_count_first_published": "VIDEO_COUNT_FIRST_PUBLISHED",
"engaged_views": "ENGAGED_VIEWS",
"external_views": "EXTERNAL_VIEWS",
"external_watch_time": "EXTERNAL_WATCH_TIME",
"average_watch_time": "AVERAGE_WATCH_TIME",
"total_estimated_earnings": "TOTAL_ESTIMATED_EARNINGS"
}

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" URL_BUILDER_SCRIPT = ROOT / "skills" / "yt-studio-url-builder" / "scripts" / "build_studio_urls.py"
COUNTRIES_JSON = URL_BUILDER_SCRIPT.parent / "countries.json" 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" DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
LOOKUP_SCRIPT = ROOT / "scripts" / "lookup_groups.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)) 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): def run_script(script, args, cwd=None):
"""以子进程运行被测脚本uv run pytest 下 sys.executable 即 venv python """以子进程运行被测脚本uv run pytest 下 sys.executable 即 venv python

View File

@@ -18,7 +18,7 @@ import math
import pandas as pd import pandas as pd
import pytest 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 # 文档化锚点2026-06-15 = 1781506800000见 docs/adr/0001脚本内 ANCHOR_MS
ANCHOR_MS = 1781506800000 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: class TestParseCountries:
def test_none_returns_empty(self, url_builder): def test_none_returns_empty(self, url_builder):
assert url_builder.parse_countries(None, {}) == [] assert url_builder.parse_countries(None, {}) == []
@@ -319,6 +432,62 @@ class TestBuildUrl:
for m in url_builder.CONFIG["t_metrics"]: for m in url_builder.CONFIG["t_metrics"]:
assert "t_metrics=%s" % m in url 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): def test_no_country_omits_country_params(self, url_builder):
url, _, _ = url_builder.build_url(make_row(), {}) url, _, _ = url_builder.build_url(make_row(), {})
assert "ur_dimensions" not in url assert "ur_dimensions" not in url

View File

@@ -19,7 +19,8 @@ ASSET_XLSX = ROOT / "assets" / "需求输入示例.xlsx"
HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"] HEADERS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", "数据周期", "国家"]
OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID", OUTPUT_COLUMNS = ["所有者名称", "所有者ID", "实体类型", "实体名称", "实体ID",
"数据周期", "国家", "国家代码", "开始时间戳", "结束时间戳", "URL"] "数据周期", "国家", "国家代码", "指标", "指标代码", "维度", "维度代码",
"开始时间戳", "结束时间戳", "URL"]
def run_builder(args): def run_builder(args):
@@ -183,6 +184,123 @@ class TestHappyPath:
assert len(read_output(out)) == 1 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"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 失败行与退出码 # 失败行与退出码
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

2
uv.lock generated
View File

@@ -815,7 +815,7 @@ wheels = [
[[package]] [[package]]
name = "studiolift" name = "studiolift"
version = "0.1.0" version = "0.3.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "openpyxl" }, { name = "openpyxl" },