#!/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 两位代码) 说明: - 数据周期起止日期均包含在数据范围内,time_period 结束值取结束日后一天的日界线。 - 国家为多个时,ur_values 以 '%27' 包裹、'%7C' 连接(如 美国,日本 -> %27US%27%7C%27JP%27)。 - 中文国家名 -> ISO 代码的映射放在同目录 countries.json(可自行扩充);已是两位代码的原样透传。 - 固定参数(metric/granularity/dimension/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", } # 实体类型 中文/代码 -> 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", } 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 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): """根据一行需求生成 URL。返回 (url, 状态, 错误信息)。""" 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) 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" % CONFIG["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("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)") 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) 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) 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) 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), "开始时间戳": 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()