| 1 | # -*- coding: utf-8 -*- |
| 2 | """YouTube uploader (browser automation via YouTube Studio). |
| 3 | |
| 4 | Unlike the other platforms here, YouTube also offers an official Data API. We deliberately |
| 5 | use browser automation instead, because videos uploaded through an *unaudited* API project |
| 6 | are force-locked to private and cannot be made public without passing Google's compliance |
| 7 | audit (which is impractical for personal/single-channel use). Browser automation has no such |
| 8 | restriction and can publish public videos right away, and it matches the cookie-based pattern |
| 9 | used by every other uploader in this project. |
| 10 | |
| 11 | Login is interactive (Google account, no QR code): the browser opens, the user signs in, and |
| 12 | the storage_state is saved. Reuse it afterwards for fully unattended uploads. |
| 13 | """ |
| 14 | import asyncio |
| 15 | from pathlib import Path |
| 16 | |
| 17 | from patchright.async_api import Page, Playwright, async_playwright |
| 18 | |
| 19 | from conf import DEBUG_MODE |
| 20 | from uploader.base_video import BaseVideoUploader |
| 21 | from utils.base_social_media import set_init_script |
| 22 | from utils.log import youtube_logger |
| 23 | |
| 24 | try: |
| 25 | # 国内直连 youtube.com 会超时,且 patchright 启的 chromium 不吃系统代理。 |
| 26 | # 在 conf.py 设 YT_PROXY = "http://127.0.0.1:7890"(本地代理端口)即可;不设则不走代理。 |
| 27 | from conf import YT_PROXY |
| 28 | except Exception: |
| 29 | YT_PROXY = None |
| 30 | |
| 31 | STUDIO_URL = "https://studio.youtube.com" |
| 32 | UPLOAD_URL = "https://www.youtube.com/upload" |
| 33 | VISIBILITY = {"public": "PUBLIC", "unlisted": "UNLISTED", "private": "PRIVATE"} |
| 34 | |
| 35 | |
| 36 | def _msg(emoji: str, text: str) -> str: |
| 37 | return f"{emoji} {text}" |
| 38 | |
| 39 | |
| 40 | def _build_login_result(success, status, message, account_file, current_url=""): |
| 41 | return { |
| 42 | "success": success, |
| 43 | "status": status, |
| 44 | "message": message, |
| 45 | "account_file": str(account_file), |
| 46 | "current_url": current_url, |
| 47 | } |
| 48 | |
| 49 | |
| 50 | async def cookie_auth(account_file) -> bool: |
| 51 | """登录态是否仍有效:带 cookie 打开 Studio,没被踢到 Google 登录页且进入了频道页即有效。""" |
| 52 | async with async_playwright() as playwright: |
| 53 | browser = await playwright.chromium.launch(headless=True, channel="chrome") |
| 54 | try: |
| 55 | context = await browser.new_context(storage_state=account_file) |
| 56 | context = await set_init_script(context) |
| 57 | page = await context.new_page() |
| 58 | await page.goto(STUDIO_URL, wait_until="domcontentloaded") |
| 59 | await page.wait_for_timeout(3000) |
| 60 | url = page.url |
| 61 | if "accounts.google.com" in url or "/signin" in url.lower(): |
| 62 | return False |
| 63 | return "/channel/" in url |
| 64 | except Exception: |
| 65 | return False |
| 66 | finally: |
| 67 | await browser.close() |
| 68 | |
| 69 | |
| 70 | async def youtube_cookie_gen(account_file, headless: bool = False): |
| 71 | """交互式登录:开浏览器让用户登录 Google/YouTube,进入频道页后保存 storage_state。""" |
| 72 | async with async_playwright() as playwright: |
| 73 | # 登录必须显形,让用户输账号密码/二步验证 |
| 74 | browser = await playwright.chromium.launch(headless=False, channel="chrome") |
| 75 | context = await browser.new_context() |
| 76 | context = await set_init_script(context) |
| 77 | page = await context.new_page() |
| 78 | await page.goto(STUDIO_URL, wait_until="domcontentloaded") |
| 79 | youtube_logger.info(_msg("🔐", "请在弹出的浏览器里登录 Google / YouTube 账号,登录后会自动保存")) |
| 80 | ok = False |
| 81 | for _ in range(600): # 最多等 10 分钟 |
| 82 | if "/channel/" in page.url: |
| 83 | await page.wait_for_timeout(2000) # 让 cookie 落定 |
| 84 | ok = True |
| 85 | break |
| 86 | await asyncio.sleep(1) |
| 87 | if ok: |
| 88 | await context.storage_state(path=account_file) |
| 89 | youtube_logger.success(_msg("✅", f"YouTube 登录态已保存: {account_file}")) |
| 90 | else: |
| 91 | youtube_logger.error(_msg("😵", "等待登录超时,未保存登录态")) |
| 92 | await browser.close() |
| 93 | return _build_login_result(ok, "logged_in" if ok else "timeout", |
| 94 | "登录成功" if ok else "登录超时", account_file, page.url) |
| 95 | |
| 96 | |
| 97 | async def youtube_setup(account_file, handle: bool = False, return_detail: bool = False, headless: bool = False): |
| 98 | """校验登录态,失效且 handle=True 时拉起交互式登录。""" |
| 99 | if not Path(account_file).exists() or not await cookie_auth(account_file): |
| 100 | if not handle: |
| 101 | result = _build_login_result(False, "cookie_invalid", "登录态不存在或已失效", account_file) |
| 102 | return result if return_detail else False |
| 103 | youtube_logger.info(_msg("🥹", "YouTube 登录态不存在或失效,准备打开浏览器登录")) |
| 104 | result = await youtube_cookie_gen(account_file, headless=headless) |
| 105 | return result if return_detail else result["success"] |
| 106 | result = _build_login_result(True, "cookie_valid", "登录态有效", account_file) |
| 107 | return result if return_detail else True |
| 108 | |
| 109 | |
| 110 | async def _dismiss_autocomplete(page: Page): |
| 111 | """关掉 # 话题 / @ 提及 自动补全下拉浮层(会挡住后续“继续/发布”按钮)。 |
| 112 | |
| 113 | 先 blur 失焦;若浮层仍可见再补一次 Escape——仅在检测到浮层时才按, |
| 114 | 避免在没有浮层时误关掉整个上传对话框。""" |
| 115 | try: |
| 116 | await page.evaluate("() => { const a = document.activeElement; if (a && a.blur) a.blur(); }") |
| 117 | except Exception: |
| 118 | pass |
| 119 | try: |
| 120 | dropdown = page.locator("tp-yt-iron-dropdown:visible") |
| 121 | if await dropdown.count() > 0: |
| 122 | await page.keyboard.press("Escape") |
| 123 | await page.wait_for_timeout(200) |
| 124 | except Exception: |
| 125 | pass |
| 126 | |
| 127 | |
| 128 | async def _fill_editable(page: Page, selector: str, text: str): |
| 129 | """填 YouTube Studio 的 contenteditable 富文本框(标题/简介),先清空再输入。 |
| 130 | |
| 131 | 用 fill() 一次性灌入而非逐字 type():标题/简介里的 # 字符(如 #Shorts)会触发 |
| 132 | YouTube 的话题自动补全下拉浮层;逐字输入会让浮层持续跟随光标弹出、盖住输入框与 |
| 133 | 后续“继续/发布”按钮,导致上传流程卡死。fill() 一次性写入不会逐字触发补全。""" |
| 134 | box = page.locator(selector).first |
| 135 | await box.wait_for(state="visible", timeout=30000) |
| 136 | await box.click() |
| 137 | await page.keyboard.press("Control+A") |
| 138 | await page.keyboard.press("Delete") |
| 139 | try: |
| 140 | await box.fill(text) # 一次性灌入,不逐字触发 # 话题自动补全 |
| 141 | except Exception: |
| 142 | await box.type(text, delay=6) # 个别 contenteditable 不支持 fill 时退回逐字输入 |
| 143 | await page.wait_for_timeout(400) |
| 144 | await _dismiss_autocomplete(page) # 收尾关掉可能弹出的补全浮层 |
| 145 | |
| 146 | |
| 147 | async def _click_if_present(page: Page, selector: str, timeout: int = 4000) -> bool: |
| 148 | try: |
| 149 | el = page.locator(selector).first |
| 150 | await el.wait_for(state="visible", timeout=timeout) |
| 151 | await el.click() |
| 152 | return True |
| 153 | except Exception: |
| 154 | return False |
| 155 | |
| 156 | |
| 157 | async def _wait_upload_complete(page: Page, max_polls: int = 360) -> bool: |
| 158 | """等网页上传从 X% 跑到 100% 再发布。浏览器上传靠窗口开着才传得完, |
| 159 | 若上传到一半就点发布并关闭浏览器,上传会被掐断卡在中途(如 76%)。 |
| 160 | 出现“处理/检查/上传完成”或不再“正在上传”即视为传完。max_polls*5s=30min 上限。""" |
| 161 | last = "" |
| 162 | for _ in range(max_polls): |
| 163 | txt = "" |
| 164 | for sel in (".progress-label", "span.progress-label", "ytcp-video-upload-progress"): |
| 165 | loc = page.locator(sel).first |
| 166 | try: |
| 167 | if await loc.count(): |
| 168 | txt = (await loc.inner_text()).strip() |
| 169 | if txt: |
| 170 | break |
| 171 | except Exception: |
| 172 | pass |
| 173 | if txt: |
| 174 | if any(k in txt for k in ("处理", "检查", "上传完成", "已上传", "Processing", "complete", "Checks", "Finished")): |
| 175 | youtube_logger.info(_msg("✅", f"上传完成: {txt[:40]}")) |
| 176 | return True |
| 177 | if txt != last: |
| 178 | youtube_logger.info(_msg("⏳", f"上传中: {txt[:40]}")) |
| 179 | last = txt |
| 180 | await page.wait_for_timeout(5000) |
| 181 | youtube_logger.warning(_msg("⚠️", "等上传超时(30min),仍尝试发布")) |
| 182 | return False |
| 183 | |
| 184 | |
| 185 | class YouTubeVideo(BaseVideoUploader): |
| 186 | def __init__(self, title, file_path, tags, account_file, *, |
| 187 | description="", thumbnail_path=None, playlist=None, |
| 188 | visibility="public", debug=DEBUG_MODE, headless=False): |
| 189 | self.title = title |
| 190 | self.file_path = str(file_path) |
| 191 | self.tags = tags or [] |
| 192 | self.account_file = str(account_file) |
| 193 | self.description = description or "" |
| 194 | self.thumbnail_path = str(thumbnail_path) if thumbnail_path else None |
| 195 | self.playlist = playlist |
| 196 | self.visibility = visibility if visibility in VISIBILITY else "public" |
| 197 | self.debug = debug |
| 198 | self.headless = headless |
| 199 | |
| 200 | async def upload(self, playwright: Playwright) -> None: |
| 201 | browser = await playwright.chromium.launch( |
| 202 | headless=self.headless, channel="chrome", |
| 203 | proxy={"server": YT_PROXY} if YT_PROXY else None, |
| 204 | ) |
| 205 | context = await browser.new_context(storage_state=self.account_file) |
| 206 | context = await set_init_script(context) |
| 207 | page = await context.new_page() |
| 208 | page.set_default_timeout(60000) |
| 209 | |
| 210 | youtube_logger.info(_msg("🎬", f"开始上传: {Path(self.file_path).name}")) |
| 211 | await page.goto(UPLOAD_URL, wait_until="domcontentloaded") |
| 212 | await page.wait_for_timeout(3000) |
| 213 | if "accounts.google.com" in page.url or "signin" in page.url.lower(): |
| 214 | await browser.close() |
| 215 | raise RuntimeError("YouTube 登录态失效,请重新执行 login") |
| 216 | |
| 217 | # 1) 选择视频文件 |
| 218 | file_input = page.locator('input[type="file"]').first |
| 219 | await file_input.wait_for(state="attached", timeout=60000) |
| 220 | await file_input.set_input_files(self.file_path) |
| 221 | |
| 222 | # 2) 等详情对话框 |
| 223 | await page.locator("#title-textarea").wait_for(state="visible", timeout=120000) |
| 224 | |
| 225 | # 3) 标题 |
| 226 | youtube_logger.info(_msg("✍️", "填写标题")) |
| 227 | await _fill_editable(page, "#title-textarea #textbox", self.title[:100]) |
| 228 | |
| 229 | # 4) 简介 |
| 230 | if self.description.strip(): |
| 231 | youtube_logger.info(_msg("✍️", "填写简介")) |
| 232 | await _fill_editable(page, "#description-textarea #textbox", self.description) |
| 233 | |
| 234 | # 5) 封面(处理到一定进度才允许传,失败不致命) |
| 235 | if self.thumbnail_path and Path(self.thumbnail_path).exists(): |
| 236 | try: |
| 237 | thumb_input = page.locator( |
| 238 | "#file-loader input[type='file'], ytcp-thumbnail-uploader input[type='file']" |
| 239 | ).first |
| 240 | await thumb_input.wait_for(state="attached", timeout=20000) |
| 241 | await thumb_input.set_input_files(self.thumbnail_path) |
| 242 | await page.wait_for_timeout(2000) |
| 243 | youtube_logger.info(_msg("🖼️", "封面已上传")) |
| 244 | except Exception as exc: |
| 245 | youtube_logger.warning(_msg("⚠️", f"封面上传跳过(不影响发布): {exc}")) |
| 246 | |
| 247 | # 6) 加入播放列表(连载/系列追更)。弹窗务必关闭,否则挡住后续步骤。 |
| 248 | if self.playlist: |
| 249 | try: |
| 250 | await _click_if_present( |
| 251 | page, "#basics ytcp-text-dropdown-trigger, ytcp-video-metadata-playlists ytcp-dropdown-trigger", 8000) |
| 252 | await page.wait_for_timeout(1200) |
| 253 | existing = page.locator( |
| 254 | f"tp-yt-paper-checkbox:has-text('{self.playlist}'), " |
| 255 | f"ytcp-checkbox-group:has-text('{self.playlist}')").first |
| 256 | if await existing.count(): |
| 257 | await existing.click() |
| 258 | else: |
| 259 | if await _click_if_present(page, "ytcp-button:has-text('New playlist'), ytcp-button:has-text('创建播放列表')", 4000): |
| 260 | await page.wait_for_timeout(800) |
| 261 | await _click_if_present(page, "tp-yt-paper-item:has-text('New playlist'), tp-yt-paper-item:has-text('新建播放列表')", 3000) |
| 262 | title_box = page.locator("ytcp-playlist-metadata-editor #textbox, #create-playlist-form #textbox").first |
| 263 | if await title_box.count(): |
| 264 | await title_box.click() |
| 265 | await title_box.type(self.playlist, delay=6) |
| 266 | 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) |
| 267 | except Exception as exc: |
| 268 | youtube_logger.warning(_msg("⚠️", f"播放列表处理跳过(不影响发布): {exc}")) |
| 269 | finally: |
| 270 | await _click_if_present(page, "ytcp-playlist-dialog #save-button, ytcp-button:has-text('Done'), ytcp-button:has-text('完成')", 3000) |
| 271 | await page.keyboard.press("Escape") |
| 272 | await page.wait_for_timeout(600) |
| 273 | |
| 274 | # 7) 受众:非儿童向(必填) |
| 275 | if not await _click_if_present(page, "tp-yt-paper-radio-button[name='VIDEO_MADE_FOR_KIDS_NOT_MFK']", 10000): |
| 276 | await _click_if_present(page, "tp-yt-paper-radio-button:has-text('not made for kids'), tp-yt-paper-radio-button:has-text('不是面向儿童')", 6000) |
| 277 | |
| 278 | # 8) 标签(“显示更多”里) |
| 279 | if self.tags: |
| 280 | try: |
| 281 | await _click_if_present(page, "#toggle-button", 6000) |
| 282 | await page.wait_for_timeout(800) |
| 283 | tag_input = page.locator("#tags-container #text-input, ytcp-form-input-container#tags-container input").first |
| 284 | await tag_input.click() |
| 285 | await tag_input.type(",".join(self.tags)[:500] + ",", delay=4) |
| 286 | except Exception as exc: |
| 287 | youtube_logger.warning(_msg("⚠️", f"标签填写跳过(不影响发布): {exc}")) |
| 288 | |
| 289 | # 9) 连点 Next 到“可见性”步骤 |
| 290 | for _ in range(5): |
| 291 | vis = page.locator("tp-yt-paper-radio-button[name='PUBLIC']") |
| 292 | if await vis.count() and await vis.first.is_visible(): |
| 293 | break |
| 294 | if not await _click_if_present(page, "#next-button", 6000): |
| 295 | await page.wait_for_timeout(1200) |
| 296 | await page.wait_for_timeout(1000) |
| 297 | |
| 298 | # 10) 可见性 |
| 299 | youtube_logger.info(_msg("🌐", f"设置可见性 = {self.visibility}")) |
| 300 | await _click_if_present(page, f"tp-yt-paper-radio-button[name='{VISIBILITY[self.visibility]}']", 10000) |
| 301 | |
| 302 | # 10.5) 关键:等上传真正传完再发布。浏览器上传靠窗口开着传, |
| 303 | # 传到一半就点发布+关浏览器 = 上传被掐断卡在中途(如 76%)。 |
| 304 | youtube_logger.info(_msg("📤", "等待上传完成(传完才发布)…")) |
| 305 | await _wait_upload_complete(page) |
| 306 | |
| 307 | # 11) 发布 |
| 308 | await page.wait_for_timeout(1200) |
| 309 | if not await _click_if_present(page, "#done-button", 15000): |
| 310 | youtube_logger.warning(_msg("🤔", "未找到发布按钮,可能上传未到可发布进度;请在窗口里手动发布")) |
| 311 | else: |
| 312 | await page.wait_for_timeout(4000) |
| 313 | video_url = "" |
| 314 | try: |
| 315 | link = page.locator("a[href*='youtu.be'], a[href*='watch?v=']").first |
| 316 | if await link.count(): |
| 317 | video_url = await link.get_attribute("href") or "" |
| 318 | except Exception: |
| 319 | pass |
| 320 | await _click_if_present(page, "ytcp-button:has-text('Close'), ytcp-button:has-text('关闭'), #close-button", 8000) |
| 321 | youtube_logger.success(_msg("🥳", f"发布完成({self.visibility}){(' ' + video_url) if video_url else ''}")) |
| 322 | |
| 323 | # 刷新 cookie |
| 324 | try: |
| 325 | await context.storage_state(path=self.account_file) |
| 326 | except Exception: |
| 327 | pass |
| 328 | await page.wait_for_timeout(2000) |
| 329 | await browser.close() |
| 330 | |
| 331 | async def main(self): |
| 332 | async with async_playwright() as playwright: |
| 333 | await self.upload(playwright) |
| 334 |