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

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

View File

@@ -15,12 +15,18 @@
实体类型 : 实体类型 / 类型 / 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/granularity/dimension/t_metrics 等)在本文件 CONFIG 中统一配置。
- 指标每行可选metric 参数,中文名 -> 代码的映射放在同目录 metrics.json可自行扩充
已是已知指标代码的原样透传)。留空则用 CONFIG 默认,且只影响 metric / o_column。
- 维度每行可选dimension 参数,中文名 -> 代码的映射放在同目录 dimensions.json可自行扩充
已是已知维度代码的原样透传)。留空则用 CONFIG 默认。
- 其余固定参数granularity/t_metrics 等)在本文件 CONFIG 中统一配置。
"""
import argparse
@@ -55,6 +61,11 @@ CONFIG = {
"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",
@@ -95,6 +106,12 @@ COLUMN_ALIASES = {
# 国家
"国家": "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
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() == "":
@@ -170,8 +247,11 @@ def resolve_entity_type(text):
raise ValueError("未识别的实体类型: %r(应为 群组/所有者/频道/节目 或 GROUP/CONTENT_OWNER/CHANNEL/VIDEO" % key)
def build_url(row, country_map):
"""根据一行需求生成 URL。返回 (url, 状态, 错误信息)。"""
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()
@@ -191,6 +271,15 @@ def build_url(row, 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
params = []
params.append("o=%s" % owner_id)
@@ -204,12 +293,12 @@ def build_url(row, country_map):
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" % CONFIG["metric"])
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" % CONFIG["dimension"])
params.append("o_column=%s" % CONFIG["o_column"])
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"])
@@ -222,6 +311,8 @@ def main():
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):
@@ -252,18 +343,26 @@ def main():
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)
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", ""),
@@ -273,6 +372,10 @@ def main():
"数据周期": row.get("period", ""),
"国家": row.get("countries", ""),
"国家代码": ",".join(codes),
"指标": row.get("metric", ""),
"指标代码": metric_code,
"维度": row.get("dimension", ""),
"维度代码": dimension_code,
"开始时间戳": start_ms,
"结束时间戳": end_ms,
"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"
}