Files
StudioLift/skills/youtube-studio-csv-download/scripts/youtube_export_download.py

252 lines
10 KiB
Python
Raw Permalink 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.

# -*- coding: utf-8 -*-
r"""
YouTube Studio 内容管理器「导出当前视图 → 逗号分隔值 (.csv)」下载脚本
(拦截响应 + 解码 zip + 自动保存到下载目录 + 重名去重)。
机制(已实证):前端点击导出后向后端
POST https://studio.youtube.com/youtubei/v1/yta_web/csv_export?alt=json
后端把打好的 zip 以 base64 内联在响应 `zippedData` 字段里(开头 `UEsDBBQ` 即 ZIP 文件头 `PK`)。
本脚本拦截该响应base64 解码后按 `<维度标签> <起始日>_<结束日> <账号名>.zip` 写盘,
重名自动加 ` (n)` 后缀n 从 1 起。
登录态:脚本不负责登录,必须复用已登录 YouTube Studio 的浏览器会话,二选一:
- --user-data-dir + --channel chrome|msedge :用已登录的用户数据目录启动(需先关闭该浏览器)
- --connect http://localhost:9222 :附加到已在调试端口运行的浏览器
用法:
python youtube_export_download.py --selftest
python youtube_export_download.py --url "<explore URL>" --channel chrome --user-data-dir "C:\Users\<you>\AppData\Local\Google\Chrome\User Data"
python youtube_export_download.py --url "<explore URL>" --connect http://localhost:9222
"""
import argparse
import base64
import json
import os
import re
import sys
DOWNLOAD_DIR = r"D:\Downloads" # 默认下载目录,可用 --download-dir 覆盖
# 维度类型 -> 文件名前缀标签(生产环境建议从页面「维度」按钮文本读取,这里兜底映射)。
DIMENSION_LABEL = {
"VIDEO": "内容",
"USER": "频道",
"CONTENT_OWNER": "内容",
}
CSV_EXPORT_PATH = "/youtubei/v1/yta_web/csv_export"
def dedup_path(directory, filename):
"""返回不冲突的落盘路径;重名按 `名称 (n).后缀` 递增n 从 1 起。"""
directory = os.path.abspath(directory)
base, ext = os.path.splitext(filename)
candidate = os.path.join(directory, filename)
n = 1
while os.path.exists(candidate):
candidate = os.path.join(directory, f"{base} ({n}){ext}")
n += 1
return candidate
def build_export_filename(export_query, account_name, dimension_label=None):
"""从 csv_export 请求体 `exportQuery` 反推文件名。"""
def fmt_dateid(yyyymmdd):
s = str(yyyymmdd)
return f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
date_range = None
dimension = None
nodes = export_query.get("joinRequest", {}).get("nodes") or []
for node in nodes:
q = node.get("value", {}).get("query") or {}
if not date_range:
tr = q.get("timeRange", {}).get("dateIdRange")
if tr and tr.get("inclusiveStart"):
date_range = (tr["inclusiveStart"], tr.get("exclusiveEnd"))
dims = q.get("dimensions") or []
if dimension is None and dims:
dimension = dims[0].get("type")
if not date_range:
raise ValueError("无法从 exportQuery 解析日期范围")
if dimension_label is None:
dimension_label = DIMENSION_LABEL.get(dimension or "", "")
start, end = date_range
return f"{dimension_label} {fmt_dateid(start)}_{fmt_dateid(end)} {account_name}.zip"
def decode_zipped_data(payload):
"""把 csv_export 响应 payload 里的 zippedData 解码为 zip 字节流。"""
zipped = payload.get("zippedData")
if not zipped:
raise ValueError("响应中缺少 zippedData 字段")
return base64.b64decode(zipped)
def intercept_and_save(page, account_name, download_dir):
"""给 page 绑定 response 拦截器:命中 csv_export 就把 zip 保存到下载目录。"""
import pathlib
saved = []
def on_response(response):
if CSV_EXPORT_PATH not in response.url:
return
try:
payload = response.json()
data = decode_zipped_data(payload)
filename = None
try:
body = json.loads(response.request.post_data or "{}")
filename = build_export_filename(body.get("exportQuery", {}), account_name)
except Exception:
filename = "export.zip" # 反推失败兜底,避免丢内容
filename = re.sub(r"[\\/:*?\"<>|]", "_", filename) # Windows 非法字符
path = dedup_path(download_dir, filename)
pathlib.Path(path).write_bytes(data)
saved.append((filename, len(data), path))
except Exception as e: # noqa: BLE001
print(f"[interceptor] 处理 csv_export 响应失败: {e}", file=sys.stderr)
page.on("response", on_response)
return saved
def _default_user_data_dir(channel):
"""返回指定浏览器的默认用户数据目录Windows用于复用已登录会话。"""
_local = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
if channel == "msedge":
return os.path.join(_local, "Microsoft", "Edge", "User Data")
return os.path.join(_local, "Google", "Chrome", "User Data")
def run(url, user_data_dir=None, channel=None, cdp_url=None,
download_dir=None, account_name=None):
"""启动/连接浏览器并触发导出。必须复用已登录会话,否则跳 Google 登录页。"""
from playwright.sync_api import sync_playwright
download_dir = download_dir or DOWNLOAD_DIR
with sync_playwright() as p:
browser = None
context = None
if cdp_url:
browser = p.chromium.connect_over_cdp(cdp_url)
context = browser.contexts[0] if browser.contexts else \
browser.new_context(accept_downloads=True)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded")
elif user_data_dir:
context = p.chromium.launch_persistent_context(
user_data_dir=user_data_dir or _default_user_data_dir(channel),
channel=channel,
headless=False,
accept_downloads=True,
args=["--disable-blink-features=AutomationControlled"],
)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded")
else:
browser = p.chromium.launch(headless=False, channel=channel)
context = browser.new_context(accept_downloads=True)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded")
if "accounts.google" in page.url:
print("[!] 当前会话未登录,已跳转到 Google 登录页。", file=sys.stderr)
print(" 请用 --user-data-dir 或 --connect 复用已登录浏览器后重试。",
file=sys.stderr)
if not account_name:
account_name = "WL Media"
try:
account_name = page.locator(
"ytcp-account-item button, .account-switcher button"
).first.inner_text(timeout=5000).strip() or account_name
except Exception:
pass
saved = intercept_and_save(page, account_name, download_dir)
# 触发导出:点「导出当前视图」→「逗号分隔值 (.csv)」
page.get_by_text("导出当前视图").click()
page.get_by_text("逗号分隔值 (.csv)").click()
page.wait_for_timeout(3000)
if not saved:
print("未捕获到 csv_export 响应,请确认已点击导出且登录态有效。")
else:
for name, size, path in saved:
print(f"已保存: {path} ({size} bytes)")
if browser is not None:
browser.close()
elif context is not None:
context.close()
def selftest():
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as td:
p0 = Path(dedup_path(td, "需求文件.zip"))
assert p0.name == "需求文件.zip", p0.name
p0.write_bytes(b"a")
p1 = Path(dedup_path(td, "需求文件.zip"))
assert p1.name == "需求文件 (1).zip", p1.name
p1.write_bytes(b"b")
p2 = Path(dedup_path(td, "需求文件.zip"))
assert p2.name == "需求文件 (2).zip", p2.name
p2.write_bytes(b"c")
assert Path(dedup_path(td, "需求文件.zip")).name == "需求文件 (3).zip"
assert Path(dedup_path(td, "其他.zip")).name == "其他.zip"
export_query = {
"joinRequest": {"nodes": [{"value": {"query": {
"dimensions": [{"type": "VIDEO"}],
"timeRange": {"dateIdRange": {
"inclusiveStart": 20260723, "exclusiveEnd": 20260820}},
}}}]},
}
name = build_export_filename(export_query, "WL Media")
assert name == "内容 2026-07-23_2026-08-20 WL Media.zip", name
print("selftest OK去重与文件名反推逻辑全部通过")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--selftest", action="store_true", help="仅跑去重/命名自测")
ap.add_argument("--url", help="explore URL")
ap.add_argument("--user-data-dir", help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)")
ap.add_argument("--channel", choices=["chrome", "msedge"],
help="浏览器品牌,复用登录态时必填其一")
ap.add_argument("--connect", help="通过 CDP 附加到已打开浏览器,如 http://localhost:9222")
ap.add_argument("--download-dir", help=f"下载目录,默认 {DOWNLOAD_DIR}")
ap.add_argument("--account-name", help="账号名(用于文件名),默认从页面读取")
args = ap.parse_args()
if args.selftest:
selftest()
elif args.url:
run(args.url, user_data_dir=args.user_data_dir, channel=args.channel,
cdp_url=args.connect, download_dir=args.download_dir,
account_name=args.account_name)
else:
selftest()
print("\n实际运行需先 pip install playwright再复用登录态二选一\n"
" 方式 A: python youtube_export_download.py --url \"<explore URL>\" "
"--channel chrome --user-data-dir <你的 Chrome User Data 目录>\n"
" 方式 B: python youtube_export_download.py --url \"<explore URL>\" "
"--connect http://localhost:9222")