Files
StudioLift/scripts/build_studio_urls.py
Sidney Zhang b8bd749f43 feat(scripts): 支持动态指标和维度参数配置
为 build_studio_urls.py 添加指标和维度的可配置支持,包括:
- 新增 metrics.json 和 dimensions.json 映射文件加载
- 支持中文/英文别名映射及指标代码透传
- 列别名扩展以识别"指标"和"维度"列
- 空值时自动回退到 CONFIG 默认值
2026-08-24 15:28:48 +08:00

401 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
根据需求清单CSV/Excel批量拼接 YouTube Studio 内容管理器 explore URL。
用法:
python build_studio_urls.py -i 需求清单.csv -o 输出.csv
python build_studio_urls.py -i 需求清单.xlsx -o 输出.csv
输入列(支持中英文别名,未填可留空):
所有者名称 : 所有者名称 / owner_name
所有者ID : 所有者ID / owner_id / o
实体名称 : 实体名称 / 群组名称 / 频道名称 / 节目名称 / entity_name / group_name
实体ID : 实体ID / 群组ID / group_id / entity_id / id
实体类型 : 实体类型 / 类型 / entity_type (群组/所有者/频道/节目,或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO)
数据周期 : 数据周期 / 周期 / period / time_period (yyyy.mm.dd-yyyy.mm.dd 或 yyyy.m.d-yyyy.m.d)
国家 : 国家 / 国家/地区 / country / countries (一个或多个,中文名或 ISO 两位代码)
指标 : 指标 / 主指标 / 数据指标 / metric (可留空,则用 CONFIG 默认;中文名或指标代码)
维度 : 维度 / 细分维度 / dimension (可留空,则用 CONFIG 默认;中文名或维度代码)
说明:
- 数据周期起止日期均包含在数据范围内time_period 结束值取结束日后一天的日界线。
- 国家为多个时ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27
- 中文国家名 -> ISO 代码的映射放在同目录 countries.json可自行扩充已是两位代码的原样透传。
- 指标每行可选metric 参数,中文名 -> 代码的映射放在同目录 metrics.json可自行扩充
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
- 维度每行可选dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json可自行扩充
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
- 其余固定参数granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
"""
import argparse
import json
import os
import re
import sys
from datetime import date
import pandas as pd
from urllib.parse import quote
# ---------------------------------------------------------------------------
# 固定参数(所有需求共用,按需修改)
# ---------------------------------------------------------------------------
CONFIG = {
"explore_type": "TABLE_AND_CHART",
"metric": "SUBSCRIBERS_NET_CHANGE", # 主指标
"granularity": "DAY", # DAY / WEEK / MONTH / YEAR
"dimension": "USER", # 细分维度
"t_metrics": [ # 表格列指标(可多个)
"SUBSCRIBERS_NET_CHANGE",
"VIDEO_COUNT_FIRST_PUBLISHED",
"ENGAGED_VIEWS",
"EXTERNAL_VIEWS",
"EXTERNAL_WATCH_TIME",
"AVERAGE_WATCH_TIME",
"TOTAL_ESTIMATED_EARNINGS",
],
"o_column": "SUBSCRIBERS_NET_CHANGE", # 排序字段
"o_direction": "ANALYTICS_ORDER_DIRECTION_DESC", # DESC / ASC
"comparison_type": "NONE",
}
# 常用指标代码集合供指标代码透传metrics.json 提供中文/英文别名映射)
KNOWN_METRICS = {CONFIG["metric"], CONFIG["o_column"], *CONFIG["t_metrics"]}
# 常见维度代码供维度代码透传dimensions.json 提供中文别名映射)
KNOWN_DIMENSIONS = {CONFIG["dimension"]}
# 实体类型 中文/代码 -> URL 参数值(可扩充)
ENTITY_TYPE_MAP = {
"群组": "GROUP", "GROUP": "GROUP",
"所有者": "CONTENT_OWNER", "账号": "CONTENT_OWNER", "CONTENT_OWNER": "CONTENT_OWNER",
"频道": "CHANNEL", "CHANNEL": "CHANNEL",
"节目": "VIDEO", "视频": "VIDEO", "VIDEO": "VIDEO",
}
DEFAULT_ENTITY_TYPE = "GROUP"
# 日界线锚点 + 整日偏移(见 docs/adr/0001
ANCHOR_DATE = date(2026, 6, 15)
ANCHOR_MS = 1781506800000
MS_PER_DAY = 86400000
# 数据周期正则:支持 2026.07.01-2026.08.01 / 2026.7.1-2026.8.1 / 2026-07-01~2026-08-01
PERIOD_RE = re.compile(
r"(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})\s*[-~]\s*(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})"
)
# 输入列名 -> 规范字段(键为去空白、转小写后的列名)
COLUMN_ALIASES = {
# 所有者
"所有者名称": "owner_name", "ownername": "owner_name", "owner_name": "owner_name",
"所有者": "owner_name",
"所有者id": "owner_id", "ownerid": "owner_id", "owner_id": "owner_id", "o": "owner_id",
# 实体
"实体名称": "entity_name", "entity_name": "entity_name",
"群组名称": "entity_name", "group_name": "entity_name", "groupname": "entity_name",
"频道名称": "entity_name", "节目名称": "entity_name",
"实体id": "entity_id", "entity_id": "entity_id", "entityid": "entity_id",
"群组id": "entity_id", "group_id": "entity_id", "groupid": "entity_id",
"群组": "entity_id", "id": "entity_id",
"实体类型": "entity_type", "entity_type": "entity_type", "entitytype": "entity_type",
"类型": "entity_type", "type": "entity_type",
# 数据周期
"数据周期": "period", "周期": "period", "period": "period",
"time_period": "period", "timeperiod": "period", "日期范围": "period",
# 国家
"国家": "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",
}
def ts(y, m, d):
"""日期 -> 日界线 Unix 毫秒(锚点 + 整日偏移)。"""
return ANCHOR_MS + (date(y, m, d) - ANCHOR_DATE).days * MS_PER_DAY
def normalize_columns(df):
"""按别名把输入列映射到规范字段。返回 (field->column_index, 未识别列名列表)。"""
mapping = {}
unknown = []
for col in df.columns:
key = str(col).strip().lower()
field = COLUMN_ALIASES.get(key)
if field:
mapping[field] = col
else:
unknown.append(str(col))
return mapping, unknown
def parse_period(text):
"""解析数据周期,返回 (start_ms, end_ms),起止日期均含。"""
text = str(text).strip()
m = PERIOD_RE.search(text)
if not m:
raise ValueError("无法解析数据周期: %r(应为 yyyy.mm.dd-yyyy.mm.dd" % text)
y1, m1, d1, y2, m2, d2 = (int(g) for g in m.groups())
start_ms = ts(y1, m1, d1)
end_ms = ts(y2, m2, d2) + MS_PER_DAY # 结束日包含
return start_ms, end_ms
def load_country_map(path):
"""加载 中文国家名 -> ISO 代码 映射。文件缺失则仅支持两位代码透传。"""
if not os.path.exists(path):
print("[提示] 未找到国家映射文件 %s,仅支持直接填写两位 ISO 代码" % path, file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
return {str(k).strip(): str(v).strip().upper() for k, v in data.items()}
def parse_countries(text, country_map):
"""解析国家列(一个或多个,中文名或 ISO 代码),返回 ISO 代码列表。"""
if text is None or (isinstance(text, float) and str(text) == "nan"):
return []
raw = str(text)
parts = re.split(r"[,,、;\s|]+", raw)
codes = []
for part in parts:
p = part.strip().strip("'\"").strip()
if not p:
continue
upper = p.upper()
if re.fullmatch(r"[A-Z]{2}", upper): # 已是两位 ISO 代码
codes.append(upper)
elif p in country_map:
codes.append(country_map[p])
else:
raise ValueError("未识别的国家: %r(不在映射文件中,也不是两位 ISO 代码)" % p)
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):
"""实体类型 -> URL 参数值。空则用默认群组。"""
if text is None or (isinstance(text, float) and str(text) == "nan") or str(text).strip() == "":
return DEFAULT_ENTITY_TYPE
key = str(text).strip()
if key in ENTITY_TYPE_MAP:
return ENTITY_TYPE_MAP[key]
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key)
def build_url(row, country_map, metric_map=None, dimension_map=None):
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。
metric_map / dimension_map 为 名 -> 代码 映射;对应列缺省时回退到 CONFIG 默认。
"""
owner_id = row.get("owner_id", "").strip()
entity_type = resolve_entity_type(row.get("entity_type", ""))
entity_id = row.get("entity_id", "").strip()
if not owner_id:
return None, "error", "缺少所有者ID"
if not entity_id:
if entity_type == "CONTENT_OWNER":
entity_id = owner_id # 账号整体场景回退
else:
return None, "error", "缺少实体ID"
period = row.get("period", "").strip()
if not period:
return None, "error", "缺少数据周期"
start_ms, end_ms = parse_period(period)
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
params = []
params.append("o=%s" % owner_id)
params.append("entity_type=%s" % entity_type)
params.append("entity_id=%s" % entity_id)
if codes:
ur_values = quote("|".join("'%s'" % c for c in codes), safe="") # 'US'|'JP' -> %27US%27%7C%27JP%27
params.append("ur_dimensions=COUNTRY")
params.append("ur_values=%s" % ur_values)
params.append("ur_inclusive_starts=")
params.append("ur_exclusive_ends=")
params.append("time_period=%d%%2C%d" % (start_ms, end_ms))
params.append("explore_type=%s" % CONFIG["explore_type"])
params.append("metric=%s" % metric)
params.append("granularity=%s" % CONFIG["granularity"])
for m in CONFIG["t_metrics"]:
params.append("t_metrics=%s" % m)
params.append("dimension=%s" % dimension)
params.append("o_column=%s" % o_column)
params.append("o_direction=%s" % CONFIG["o_direction"])
params.append("comparison_type=%s" % CONFIG["comparison_type"])
url = base + "?" + "&".join(params)
return url, "ok", ""
def main():
parser = argparse.ArgumentParser(description="批量拼接 YouTube Studio explore URL")
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("--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()
if not os.path.exists(args.input):
print("错误:输入文件不存在: %s" % args.input, file=sys.stderr)
sys.exit(1)
ext = os.path.splitext(args.input)[1].lower()
if ext == ".csv":
try:
df = pd.read_csv(args.input, encoding="utf-8-sig")
except UnicodeDecodeError:
df = pd.read_csv(args.input, encoding="gbk") # Excel 另存的 ANSI/GBK
elif ext in (".xlsx", ".xls"):
df = pd.read_excel(args.input)
else:
print("错误:不支持的输入格式: %s(支持 .csv/.xlsx/.xls" % ext, file=sys.stderr)
sys.exit(1)
df = df.dropna(how="all") # 去掉全空行
mapping, unknown = normalize_columns(df)
if unknown:
print("[提示] 未识别的列(忽略): %s" % ", ".join(unknown), file=sys.stderr)
missing = [f for f in ("owner_id", "period") if f not in mapping]
if missing:
print("错误:输入缺少必要列: %s" % ", ".join(missing), file=sys.stderr)
sys.exit(1)
countries_file = args.countries or os.path.join(os.path.dirname(os.path.abspath(__file__)), "countries.json")
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 = []
errors = []
for idx, raw in df.iterrows():
row = {f: ("" if pd.isna(raw[mapping[f]]) else str(raw[mapping[f]])) for f in mapping}
try:
url, status, msg = build_url(row, country_map, metric_map, dimension_map)
except ValueError as e:
url, status, msg = None, "error", str(e)
if status == "ok":
start_ms, end_ms = parse_period(row.get("period", ""))
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({
"所有者名称": row.get("owner_name", ""),
"所有者ID": row.get("owner_id", ""),
"实体类型": row.get("entity_type", ""),
"实体名称": row.get("entity_name", ""),
"实体ID": row.get("entity_id", ""),
"数据周期": row.get("period", ""),
"国家": row.get("countries", ""),
"国家代码": ",".join(codes),
"指标": row.get("metric", ""),
"指标代码": metric_code,
"维度": row.get("dimension", ""),
"维度代码": dimension_code,
"开始时间戳": start_ms,
"结束时间戳": end_ms,
"URL": url,
})
else:
errors.append((idx + 2, row.get("owner_name", ""), row.get("entity_name", ""), msg))
out_path = args.output or os.path.join(
os.path.dirname(os.path.abspath(args.input)), "studio_urls_output.csv")
out_df = pd.DataFrame(records)
out_df.to_csv(out_path, index=False, encoding="utf-8-sig")
print("已生成 %d 条 URL -> %s" % (len(records), out_path))
if errors:
print("\n以下 %d 行生成失败:" % len(errors), file=sys.stderr)
for r in errors:
print("%d行 所有者=%s 实体=%s%s" % r, file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()