Files
TrueGrowth/vendor/social-auto-upload/uploader/youtube_uploader/main.py
Jammy 52636c91ae
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled
Track bundled vendor runtime sources
2026-07-07 10:05:50 +08:00

334 lines
16 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.
# -*- coding: utf-8 -*-
"""YouTube uploader (browser automation via YouTube Studio).
Unlike the other platforms here, YouTube also offers an official Data API. We deliberately
use browser automation instead, because videos uploaded through an *unaudited* API project
are force-locked to private and cannot be made public without passing Google's compliance
audit (which is impractical for personal/single-channel use). Browser automation has no such
restriction and can publish public videos right away, and it matches the cookie-based pattern
used by every other uploader in this project.
Login is interactive (Google account, no QR code): the browser opens, the user signs in, and
the storage_state is saved. Reuse it afterwards for fully unattended uploads.
"""
import asyncio
from pathlib import Path
from patchright.async_api import Page, Playwright, async_playwright
from conf import DEBUG_MODE
from uploader.base_video import BaseVideoUploader
from utils.base_social_media import set_init_script
from utils.log import youtube_logger
try:
# 国内直连 youtube.com 会超时,且 patchright 启的 chromium 不吃系统代理。
# 在 conf.py 设 YT_PROXY = "http://127.0.0.1:7890"(本地代理端口)即可;不设则不走代理。
from conf import YT_PROXY
except Exception:
YT_PROXY = None
STUDIO_URL = "https://studio.youtube.com"
UPLOAD_URL = "https://www.youtube.com/upload"
VISIBILITY = {"public": "PUBLIC", "unlisted": "UNLISTED", "private": "PRIVATE"}
def _msg(emoji: str, text: str) -> str:
return f"{emoji} {text}"
def _build_login_result(success, status, message, account_file, current_url=""):
return {
"success": success,
"status": status,
"message": message,
"account_file": str(account_file),
"current_url": current_url,
}
async def cookie_auth(account_file) -> bool:
"""登录态是否仍有效:带 cookie 打开 Studio没被踢到 Google 登录页且进入了频道页即有效。"""
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True, channel="chrome")
try:
context = await browser.new_context(storage_state=account_file)
context = await set_init_script(context)
page = await context.new_page()
await page.goto(STUDIO_URL, wait_until="domcontentloaded")
await page.wait_for_timeout(3000)
url = page.url
if "accounts.google.com" in url or "/signin" in url.lower():
return False
return "/channel/" in url
except Exception:
return False
finally:
await browser.close()
async def youtube_cookie_gen(account_file, headless: bool = False):
"""交互式登录:开浏览器让用户登录 Google/YouTube进入频道页后保存 storage_state。"""
async with async_playwright() as playwright:
# 登录必须显形,让用户输账号密码/二步验证
browser = await playwright.chromium.launch(headless=False, channel="chrome")
context = await browser.new_context()
context = await set_init_script(context)
page = await context.new_page()
await page.goto(STUDIO_URL, wait_until="domcontentloaded")
youtube_logger.info(_msg("🔐", "请在弹出的浏览器里登录 Google / YouTube 账号,登录后会自动保存"))
ok = False
for _ in range(600): # 最多等 10 分钟
if "/channel/" in page.url:
await page.wait_for_timeout(2000) # 让 cookie 落定
ok = True
break
await asyncio.sleep(1)
if ok:
await context.storage_state(path=account_file)
youtube_logger.success(_msg("", f"YouTube 登录态已保存: {account_file}"))
else:
youtube_logger.error(_msg("😵", "等待登录超时,未保存登录态"))
await browser.close()
return _build_login_result(ok, "logged_in" if ok else "timeout",
"登录成功" if ok else "登录超时", account_file, page.url)
async def youtube_setup(account_file, handle: bool = False, return_detail: bool = False, headless: bool = False):
"""校验登录态,失效且 handle=True 时拉起交互式登录。"""
if not Path(account_file).exists() or not await cookie_auth(account_file):
if not handle:
result = _build_login_result(False, "cookie_invalid", "登录态不存在或已失效", account_file)
return result if return_detail else False
youtube_logger.info(_msg("🥹", "YouTube 登录态不存在或失效,准备打开浏览器登录"))
result = await youtube_cookie_gen(account_file, headless=headless)
return result if return_detail else result["success"]
result = _build_login_result(True, "cookie_valid", "登录态有效", account_file)
return result if return_detail else True
async def _dismiss_autocomplete(page: Page):
"""关掉 # 话题 / @ 提及 自动补全下拉浮层(会挡住后续“继续/发布”按钮)。
先 blur 失焦;若浮层仍可见再补一次 Escape——仅在检测到浮层时才按
避免在没有浮层时误关掉整个上传对话框。"""
try:
await page.evaluate("() => { const a = document.activeElement; if (a && a.blur) a.blur(); }")
except Exception:
pass
try:
dropdown = page.locator("tp-yt-iron-dropdown:visible")
if await dropdown.count() > 0:
await page.keyboard.press("Escape")
await page.wait_for_timeout(200)
except Exception:
pass
async def _fill_editable(page: Page, selector: str, text: str):
"""填 YouTube Studio 的 contenteditable 富文本框(标题/简介),先清空再输入。
用 fill() 一次性灌入而非逐字 type():标题/简介里的 # 字符(如 #Shorts会触发
YouTube 的话题自动补全下拉浮层;逐字输入会让浮层持续跟随光标弹出、盖住输入框与
后续“继续/发布”按钮导致上传流程卡死。fill() 一次性写入不会逐字触发补全。"""
box = page.locator(selector).first
await box.wait_for(state="visible", timeout=30000)
await box.click()
await page.keyboard.press("Control+A")
await page.keyboard.press("Delete")
try:
await box.fill(text) # 一次性灌入,不逐字触发 # 话题自动补全
except Exception:
await box.type(text, delay=6) # 个别 contenteditable 不支持 fill 时退回逐字输入
await page.wait_for_timeout(400)
await _dismiss_autocomplete(page) # 收尾关掉可能弹出的补全浮层
async def _click_if_present(page: Page, selector: str, timeout: int = 4000) -> bool:
try:
el = page.locator(selector).first
await el.wait_for(state="visible", timeout=timeout)
await el.click()
return True
except Exception:
return False
async def _wait_upload_complete(page: Page, max_polls: int = 360) -> bool:
"""等网页上传从 X% 跑到 100% 再发布。浏览器上传靠窗口开着才传得完,
若上传到一半就点发布并关闭浏览器,上传会被掐断卡在中途(如 76%)。
出现“处理/检查/上传完成”或不再“正在上传”即视为传完。max_polls*5s=30min 上限。"""
last = ""
for _ in range(max_polls):
txt = ""
for sel in (".progress-label", "span.progress-label", "ytcp-video-upload-progress"):
loc = page.locator(sel).first
try:
if await loc.count():
txt = (await loc.inner_text()).strip()
if txt:
break
except Exception:
pass
if txt:
if any(k in txt for k in ("处理", "检查", "上传完成", "已上传", "Processing", "complete", "Checks", "Finished")):
youtube_logger.info(_msg("", f"上传完成: {txt[:40]}"))
return True
if txt != last:
youtube_logger.info(_msg("", f"上传中: {txt[:40]}"))
last = txt
await page.wait_for_timeout(5000)
youtube_logger.warning(_msg("⚠️", "等上传超时(30min),仍尝试发布"))
return False
class YouTubeVideo(BaseVideoUploader):
def __init__(self, title, file_path, tags, account_file, *,
description="", thumbnail_path=None, playlist=None,
visibility="public", debug=DEBUG_MODE, headless=False):
self.title = title
self.file_path = str(file_path)
self.tags = tags or []
self.account_file = str(account_file)
self.description = description or ""
self.thumbnail_path = str(thumbnail_path) if thumbnail_path else None
self.playlist = playlist
self.visibility = visibility if visibility in VISIBILITY else "public"
self.debug = debug
self.headless = headless
async def upload(self, playwright: Playwright) -> None:
browser = await playwright.chromium.launch(
headless=self.headless, channel="chrome",
proxy={"server": YT_PROXY} if YT_PROXY else None,
)
context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context)
page = await context.new_page()
page.set_default_timeout(60000)
youtube_logger.info(_msg("🎬", f"开始上传: {Path(self.file_path).name}"))
await page.goto(UPLOAD_URL, wait_until="domcontentloaded")
await page.wait_for_timeout(3000)
if "accounts.google.com" in page.url or "signin" in page.url.lower():
await browser.close()
raise RuntimeError("YouTube 登录态失效,请重新执行 login")
# 1) 选择视频文件
file_input = page.locator('input[type="file"]').first
await file_input.wait_for(state="attached", timeout=60000)
await file_input.set_input_files(self.file_path)
# 2) 等详情对话框
await page.locator("#title-textarea").wait_for(state="visible", timeout=120000)
# 3) 标题
youtube_logger.info(_msg("✍️", "填写标题"))
await _fill_editable(page, "#title-textarea #textbox", self.title[:100])
# 4) 简介
if self.description.strip():
youtube_logger.info(_msg("✍️", "填写简介"))
await _fill_editable(page, "#description-textarea #textbox", self.description)
# 5) 封面(处理到一定进度才允许传,失败不致命)
if self.thumbnail_path and Path(self.thumbnail_path).exists():
try:
thumb_input = page.locator(
"#file-loader input[type='file'], ytcp-thumbnail-uploader input[type='file']"
).first
await thumb_input.wait_for(state="attached", timeout=20000)
await thumb_input.set_input_files(self.thumbnail_path)
await page.wait_for_timeout(2000)
youtube_logger.info(_msg("🖼️", "封面已上传"))
except Exception as exc:
youtube_logger.warning(_msg("⚠️", f"封面上传跳过(不影响发布): {exc}"))
# 6) 加入播放列表(连载/系列追更)。弹窗务必关闭,否则挡住后续步骤。
if self.playlist:
try:
await _click_if_present(
page, "#basics ytcp-text-dropdown-trigger, ytcp-video-metadata-playlists ytcp-dropdown-trigger", 8000)
await page.wait_for_timeout(1200)
existing = page.locator(
f"tp-yt-paper-checkbox:has-text('{self.playlist}'), "
f"ytcp-checkbox-group:has-text('{self.playlist}')").first
if await existing.count():
await existing.click()
else:
if await _click_if_present(page, "ytcp-button:has-text('New playlist'), ytcp-button:has-text('创建播放列表')", 4000):
await page.wait_for_timeout(800)
await _click_if_present(page, "tp-yt-paper-item:has-text('New playlist'), tp-yt-paper-item:has-text('新建播放列表')", 3000)
title_box = page.locator("ytcp-playlist-metadata-editor #textbox, #create-playlist-form #textbox").first
if await title_box.count():
await title_box.click()
await title_box.type(self.playlist, delay=6)
await _click_if_present(page, "ytcp-button#create-button, tp-yt-paper-dialog ytcp-button:has-text('Create'), tp-yt-paper-dialog ytcp-button:has-text('创建')", 4000)
except Exception as exc:
youtube_logger.warning(_msg("⚠️", f"播放列表处理跳过(不影响发布): {exc}"))
finally:
await _click_if_present(page, "ytcp-playlist-dialog #save-button, ytcp-button:has-text('Done'), ytcp-button:has-text('完成')", 3000)
await page.keyboard.press("Escape")
await page.wait_for_timeout(600)
# 7) 受众:非儿童向(必填)
if not await _click_if_present(page, "tp-yt-paper-radio-button[name='VIDEO_MADE_FOR_KIDS_NOT_MFK']", 10000):
await _click_if_present(page, "tp-yt-paper-radio-button:has-text('not made for kids'), tp-yt-paper-radio-button:has-text('不是面向儿童')", 6000)
# 8) 标签(“显示更多”里)
if self.tags:
try:
await _click_if_present(page, "#toggle-button", 6000)
await page.wait_for_timeout(800)
tag_input = page.locator("#tags-container #text-input, ytcp-form-input-container#tags-container input").first
await tag_input.click()
await tag_input.type(",".join(self.tags)[:500] + ",", delay=4)
except Exception as exc:
youtube_logger.warning(_msg("⚠️", f"标签填写跳过(不影响发布): {exc}"))
# 9) 连点 Next 到“可见性”步骤
for _ in range(5):
vis = page.locator("tp-yt-paper-radio-button[name='PUBLIC']")
if await vis.count() and await vis.first.is_visible():
break
if not await _click_if_present(page, "#next-button", 6000):
await page.wait_for_timeout(1200)
await page.wait_for_timeout(1000)
# 10) 可见性
youtube_logger.info(_msg("🌐", f"设置可见性 = {self.visibility}"))
await _click_if_present(page, f"tp-yt-paper-radio-button[name='{VISIBILITY[self.visibility]}']", 10000)
# 10.5) 关键:等上传真正传完再发布。浏览器上传靠窗口开着传,
# 传到一半就点发布+关浏览器 = 上传被掐断卡在中途(如 76%)。
youtube_logger.info(_msg("📤", "等待上传完成(传完才发布)…"))
await _wait_upload_complete(page)
# 11) 发布
await page.wait_for_timeout(1200)
if not await _click_if_present(page, "#done-button", 15000):
youtube_logger.warning(_msg("🤔", "未找到发布按钮,可能上传未到可发布进度;请在窗口里手动发布"))
else:
await page.wait_for_timeout(4000)
video_url = ""
try:
link = page.locator("a[href*='youtu.be'], a[href*='watch?v=']").first
if await link.count():
video_url = await link.get_attribute("href") or ""
except Exception:
pass
await _click_if_present(page, "ytcp-button:has-text('Close'), ytcp-button:has-text('关闭'), #close-button", 8000)
youtube_logger.success(_msg("🥳", f"发布完成({self.visibility}{(' ' + video_url) if video_url else ''}"))
# 刷新 cookie
try:
await context.storage_state(path=self.account_file)
except Exception:
pass
await page.wait_for_timeout(2000)
await browser.close()
async def main(self):
async with async_playwright() as playwright:
await self.upload(playwright)