Merge branch 'main' into fix/douyin-cli-login-overhaul

This commit is contained in:
Alleria
2026-06-27 01:15:42 +08:00
committed by GitHub
12 changed files with 581 additions and 29 deletions
+21 -21
View File
@@ -49,28 +49,28 @@ def _build_login_result(success: bool, status: str, message: str, account_file:
async def cookie_auth(account_file):
async with async_playwright() as playwright:
# 抖音无头会撞反爬墙→content/upload 跳登录→误判 cookie 失效(间歇性)。校验必须有头。
# 允许 linux server 用户通过 env var 强制无头: DOUYIN_COOKIE_AUTH_HEADLESS=true
use_headless = os.environ.get("DOUYIN_COOKIE_AUTH_HEADLESS", "").lower() in ("1", "true", "yes")
launch_kwargs = {"headless": use_headless, "channel": "chrome", "args": ["--no-sandbox", "--disable-blink-features=AutomationControlled"]}
browser = await playwright.chromium.launch(**launch_kwargs)
try:
context = await browser.new_context(storage_state=account_file)
context = await set_init_script(context)
page = await context.new_page()
await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=15000)
# SPA 跳转是异步的,count() 在 race window 内可能为 0 → 假阳性。
# 改为轮询:最多 10s 内任一稳定文字出现即返回;超时保守判失效。
for _ in range(20):
if await page.get_by_text("扫码登录").count() or await page.get_by_text("手机号登录").count():
return False
if await page.get_by_text("上传视频").count():
# 抖音无头会撞反爬墙→content/upload 跳登录→误判 cookie 失效(间歇性)。校验必须有头。
# 即便有头,页面慢/瞬时跳转仍会让 wait_for_url(精确URL,5s) 误判→重试3次+宽松判定(URL含 content/upload 且无登录文案)。
# 允许 linux server 用户通过 env var 强制无头: DOUYIN_COOKIE_AUTH_HEADLESS=true
use_headless = os.environ.get("DOUYIN_COOKIE_AUTH_HEADLESS", "").lower() in ("1", "true", "yes")
launch_kwargs = {"headless": use_headless, "channel": "chrome", "args": ["--no-sandbox", "--disable-blink-features=AutomationControlled"]}
for _attempt in range(3):
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(**launch_kwargs)
try:
context = await browser.new_context(storage_state=account_file)
context = await set_init_script(context)
page = await context.new_page()
await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000)
await page.wait_for_timeout(2500) # 等页面稳定,避免瞬时跳转误判
has_login = await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count()
if "content/upload" in page.url and not has_login:
return True
await asyncio.sleep(0.5)
return False
finally:
await browser.close()
except Exception:
pass
finally:
await browser.close()
return False
async def douyin_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS, cdp_url: str | None = None):
+34 -5
View File
@@ -492,15 +492,44 @@ class TencentBaseUploader(BaseVideoUploader):
await page.click('input[placeholder="请选择时间"]')
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(publish_date.strftime("%H"))
await page.locator("div.input-editor").click()
await page.keyboard.press("Enter") # 确认小时并关闭时间下拉
await page.wait_for_timeout(500)
# 收起时间选择浮层:直接点描述区可能被 weui-desktop-dialog 遮挡,做容错
try:
await page.locator("div.input-editor").click(timeout=5000)
except Exception:
await page.keyboard.press("Escape")
async def open_upload_page(self, page: Page) -> None:
await page.goto(TENCENT_UPLOAD_URL)
await page.wait_for_url(TENCENT_UPLOAD_URL)
await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
await page.wait_for_url(TENCENT_UPLOAD_URL, timeout=120000)
async def upload_video_file(self, page: Page, file_path: str) -> None:
file_input = page.locator('input[type="file"]')
await file_input.set_input_files(file_path)
async def find_file_input():
for fr in page.frames: # 主 frame + 所有 iframe(视频号编辑器可能在 iframe 内)
try:
fi = fr.locator('input[type="file"]')
if await fi.count():
return fi.first
except Exception:
continue
return None
fi = await find_file_input()
if fi is None:
# 助手落在首页:先点「发表视频」唤出编辑器与上传控件
publish_btn = page.get_by_text("发表视频").first
if await publish_btn.count():
await publish_btn.click()
await asyncio.sleep(3)
for _ in range(20):
fi = await find_file_input()
if fi is not None:
break
await asyncio.sleep(1)
if fi is None:
raise RuntimeError("未找到视频号文件上传框")
await fi.set_input_files(file_path)
async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
short_title_element = (
+5
View File
@@ -0,0 +1,5 @@
from pathlib import Path
from conf import BASE_DIR
Path(BASE_DIR / "cookies" / "youtube_uploader").mkdir(exist_ok=True)
+333
View File
@@ -0,0 +1,333 @@
# -*- 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)