返回 Social Auto Upload
main.py
1 # -*- coding: utf-8 -*-
2 from datetime import datetime
3
4 import asyncio
5 import inspect
6 import os
7 import sys
8 from pathlib import Path
9
10 from patchright.async_api import Page
11 from patchright.async_api import Playwright
12 from patchright.async_api import async_playwright
13
14 from conf import BASE_DIR, DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
15 from uploader.base_video import BaseVideoUploader
16 from utils.base_social_media import set_init_script
17 from utils.login_qrcode import build_login_qrcode_path
18 from utils.login_qrcode import decode_qrcode_from_path
19 from utils.login_qrcode import print_terminal_qrcode
20 from utils.login_qrcode import remove_qrcode_file
21 from utils.login_qrcode import save_data_url_image
22 from utils.log import douyin_logger
23
24 DOUYIN_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
25 DOUYIN_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
26
27
28 def _msg(emoji: str, text: str) -> str:
29 return f"{emoji} {text}"
30
31
32 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
33 if not qrcode_callback:
34 return
35
36 callback_result = qrcode_callback(payload)
37 if inspect.isawaitable(callback_result):
38 await callback_result
39
40
41 def _build_login_result(success: bool, status: str, message: str, account_file: str, qrcode: dict | None = None, current_url: str = "") -> dict:
42 return {
43 "success": success,
44 "status": status,
45 "message": message,
46 "account_file": str(account_file),
47 "qrcode": qrcode,
48 "current_url": current_url,
49 }
50
51
52 async def _read_verify_code(code_file: str) -> str:
53 if os.path.exists(code_file):
54 with open(code_file, encoding="utf-8") as file_obj:
55 return file_obj.read().strip()
56
57 if not sys.stdin or not sys.stdin.isatty():
58 return ""
59
60 try:
61 return (await asyncio.to_thread(input, "请输入抖音短信验证码(直接回车可稍后重试): ")).strip()
62 except (EOFError, OSError):
63 return ""
64
65
66 async def cookie_auth(account_file):
67 # 抖音无头会撞反爬墙→content/upload 跳登录→误判 cookie 失效(间歇性)。校验必须有头。
68 # 即便有头,页面慢/瞬时跳转仍会让 wait_for_url(精确URL,5s) 误判→重试3次+宽松判定(URL含 content/upload 且无登录文案)。
69 # 允许 linux server 用户通过 env var 强制无头: DOUYIN_COOKIE_AUTH_HEADLESS=true
70 use_headless = os.environ.get("DOUYIN_COOKIE_AUTH_HEADLESS", "").lower() in ("1", "true", "yes")
71 launch_kwargs = {"headless": use_headless, "channel": "chrome", "args": ["--no-sandbox", "--disable-blink-features=AutomationControlled"]}
72 for _attempt in range(3):
73 async with async_playwright() as playwright:
74 browser = await playwright.chromium.launch(**launch_kwargs)
75 try:
76 context = await browser.new_context(storage_state=account_file)
77 context = await set_init_script(context)
78 page = await context.new_page()
79 await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000)
80 await page.wait_for_timeout(2500) # 等页面稳定,避免瞬时跳转误判
81 has_login = await page.get_by_text("手机号登录").count() or await page.get_by_text("扫码登录").count()
82 if "content/upload" in page.url and not has_login:
83 return True
84 except Exception:
85 pass
86 finally:
87 await browser.close()
88 return False
89
90
91 async def douyin_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS, cdp_url: str | None = None):
92 if not os.path.exists(account_file) or not await cookie_auth(account_file):
93 if not handle:
94 result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
95 return result if return_detail else False
96 douyin_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录"))
97 result = await douyin_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless, cdp_url=cdp_url)
98 return result if return_detail else result["success"]
99
100 result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
101 return result if return_detail else True
102
103
104 async def _extract_douyin_qrcode_src(page: Page) -> str:
105 # 等 SPA 加载完成(不只等"扫码登录"文字,否则抖音慢加载时 30s 就超时)。
106 # 给 domcontentloaded 后足够时间让客户端 JS 注入登录卡。
107 try:
108 await page.wait_for_load_state("networkidle", timeout=15000)
109 except Exception:
110 pass
111 scan_login_tab = page.get_by_text("扫码登录", exact=True).first
112 # attached 状态:DOM 里出现即可,不要求 visible/渲染完整,避免 race
113 await scan_login_tab.wait_for(state="attached", timeout=60000)
114
115 # 新版抖音创作者中心 (single_tab + animate_qrcode_container) 不再用 aria-label="二维码"。
116 # 按优先级兜底多个 selector,至少一个能命中即可。
117 qrcode_selectors = [
118 'div#animate_qrcode_container img[src^="data:image"]',
119 'div[class*="animate_qrcode_container"] img[src^="data:image"]',
120 'div[class*="scan_qrcode_login_content"] img[src^="data:image"]',
121 'img[aria-label="二维码"]',
122 ]
123 last_err: Exception | None = None
124 for sel in qrcode_selectors:
125 qrcode_img = page.locator(sel).first
126 try:
127 await qrcode_img.wait_for(state="attached", timeout=10000)
128 except Exception as e:
129 last_err = e
130 continue
131 src = await qrcode_img.get_attribute("src")
132 if src:
133 return src
134 last_err = RuntimeError(f"selector {sel} 命中但 src 为空")
135
136 raise RuntimeError(f"未获取到抖音登录二维码地址 (last_err={last_err})")
137
138
139 async def _save_douyin_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict:
140 # 提取二维码 src 仅为了保存/终端显示;定位不到时不致命——有头浏览器里二维码可见,直接扫码即可
141 try:
142 qrcode_src = await _extract_douyin_qrcode_src(page)
143 except Exception as exc:
144 douyin_logger.warning(_msg("😵", f"没定位到二维码元素({str(exc)[:50]})——请直接在弹出的浏览器里扫码,小人继续等登录跳转"))
145 return {"image_path": "", "image_data_url": ""}
146 qrcode_path = save_data_url_image(qrcode_src, build_login_qrcode_path(account_file))
147 if previous_qrcode_path and previous_qrcode_path != qrcode_path:
148 if remove_qrcode_file(previous_qrcode_path):
149 douyin_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
150 douyin_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
151 qrcode_content = decode_qrcode_from_path(qrcode_path)
152 if qrcode_content:
153 print_terminal_qrcode(qrcode_content, qrcode_path, "抖音APP")
154 else:
155 douyin_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码"))
156 qrcode_info = {
157 "image_path": str(qrcode_path),
158 "image_data_url": qrcode_src,
159 }
160 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
161 return qrcode_info
162
163
164 async def _is_douyin_login_completed(page: Page) -> bool:
165 # 登录后会跳到 creator-micro 下任意页(home/content 等);登录页是 creator.douyin.com/ 根路径
166 if "creator.douyin.com/creator-micro" not in page.url:
167 return False
168
169 login_markers = [
170 page.get_by_text("扫码登录", exact=True).first,
171 page.get_by_text("手机号登录", exact=True).first,
172 page.get_by_text("二维码失效", exact=True).first,
173 page.get_by_role("img", name="二维码").first,
174 ]
175
176 for marker in login_markers:
177 if not await marker.count():
178 continue
179 try:
180 if await marker.is_visible():
181 return False
182 except Exception:
183 continue
184
185 return True
186
187
188 async def _wait_for_douyin_login(page: Page, account_file: str, qrcode_info: dict, qrcode_callback=None, poll_interval: int = 3, max_checks: int = 100) -> dict:
189 qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None
190 original_url = page.url
191 saw_2fa = False
192 for _ in range(max_checks):
193 if await _is_douyin_login_completed(page):
194 douyin_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}"))
195 return _build_login_result(True, "success", "抖音扫码登录成功", account_file, qrcode_info, page.url)
196
197 # URL 变化 + sessionid 未到位 → 二验流程,继续等
198 if page.url != original_url and not await _is_douyin_login_completed(page):
199 sms_input = page.locator('input[placeholder*="验证码"], input[type="tel"], input[placeholder*="短信"], input[placeholder*="手机号"]')
200 if await sms_input.count() > 0:
201 if not saw_2fa:
202 douyin_logger.warning(_msg("⚠️", f"检测到抖音短信/安全二次验证,请在弹出的浏览器中手动输入。等待 sessionid ({i}/{max_checks})"))
203 saw_2fa = True
204 await asyncio.sleep(poll_interval)
205 continue
206
207 expired_box = page.get_by_text("二维码失效", exact=True).locator("..").first
208 if await expired_box.count() and await expired_box.is_visible():
209 douyin_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新"))
210 await expired_box.click()
211 await asyncio.sleep(1)
212 qrcode_info = await _save_douyin_qrcode(page, account_file, qrcode_path, qrcode_callback=qrcode_callback)
213 qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None
214
215 await asyncio.sleep(poll_interval)
216
217 return _build_login_result(False, "timeout", "等待抖音扫码登录超时", account_file, qrcode_info, page.url)
218
219
220 async def douyin_cookie_gen(
221 account_file,
222 qrcode_callback=None,
223 poll_interval: int = 2,
224 max_checks: int = 60,
225 headless: bool = LOCAL_CHROME_HEADLESS,
226 cdp_url: str | None = None,
227 ):
228 async with async_playwright() as playwright:
229 if cdp_url:
230 browser = await playwright.chromium.connect_over_cdp(cdp_url)
231 context = browser.contexts[0] if browser.contexts else await browser.new_context()
232 should_close_context = False
233 else:
234 browser = await playwright.chromium.launch(headless=headless, channel="chromium")
235 context = await browser.new_context()
236 should_close_context = True
237 context = await set_init_script(context)
238 qrcode_path = None
239 result = _build_login_result(False, "failed", "抖音登录失败", account_file)
240 try:
241 page = await context.new_page()
242 await page.goto("https://creator.douyin.com/")
243 qrcode_info = await _save_douyin_qrcode(page, account_file, qrcode_callback=qrcode_callback)
244 qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info.get("image_path") else None
245 douyin_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成"))
246 result = await _wait_for_douyin_login(
247 page,
248 account_file,
249 qrcode_info,
250 qrcode_callback=qrcode_callback,
251 poll_interval=poll_interval,
252 max_checks=max_checks,
253 )
254 if result["success"]:
255 await asyncio.sleep(2)
256 await context.storage_state(path=account_file)
257 if not await cookie_auth(account_file):
258 result = _build_login_result(
259 False,
260 "cookie_invalid",
261 "抖音扫码流程结束,但 cookie 校验失败",
262 account_file,
263 qrcode_info,
264 page.url,
265 )
266 except Exception as exc:
267 result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
268 finally:
269 if remove_qrcode_file(qrcode_path):
270 douyin_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
271 if not result["success"]:
272 douyin_logger.error(_msg("😢", f"登录失败: {result['message']}"))
273 if should_close_context:
274 await context.close()
275 await browser.close()
276 return result
277
278
279 class DouYinBaseUploader(BaseVideoUploader):
280 def __init__(
281 self,
282 publish_date: datetime | int,
283 account_file,
284 publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
285 debug: bool = DEBUG_MODE,
286 headless: bool = LOCAL_CHROME_HEADLESS,
287 ):
288 self.publish_date = publish_date
289 self.account_file = account_file
290 self.publish_strategy = publish_strategy
291 self.debug = debug
292 self.date_format = "%Y年%m月%d日 %H:%M"
293 self.local_executable_path = LOCAL_CHROME_PATH
294 self.headless = headless
295
296 async def validate_base_args(self):
297 if not os.path.exists(self.account_file):
298 raise RuntimeError(f"cookie文件不存在,请先完成抖音登录: {self.account_file}")
299 if not await cookie_auth(self.account_file):
300 raise RuntimeError(f"cookie文件已失效,请先完成抖音登录: {self.account_file}")
301 if self.publish_strategy not in {DOUYIN_PUBLISH_STRATEGY_IMMEDIATE, DOUYIN_PUBLISH_STRATEGY_SCHEDULED}:
302 raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
303
304 if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED:
305 self.publish_date = self.validate_publish_date(self.publish_date)
306 else:
307 self.publish_date = 0
308
309 async def set_schedule_time_douyin(self, page, publish_date):
310 label_element = page.locator("[class^='radio']:has-text('定时发布')")
311 await label_element.click()
312 await asyncio.sleep(1)
313 publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
314
315 await asyncio.sleep(1)
316 await page.locator('.semi-input[placeholder="日期和时间"]').click()
317 await page.keyboard.press("Control+KeyA")
318 await page.keyboard.type(str(publish_date_hour))
319 await page.keyboard.press("Enter")
320 await asyncio.sleep(1)
321
322 async def fill_title_and_description(self, page: Page, title: str, description: str, tags: list[str] | None = None):
323 # 2026-06 抖音发布页 DOM:标题=input[placeholder*=填写作品标题],描述=div.zone-container[contenteditable]
324 # version_2(post/video) 发布页要等视频上传完才渲染表单(实测约 40s),故等待超时给到 120s
325 title_input = page.locator('input[placeholder*="填写作品标题"]').first
326 await title_input.wait_for(state="visible", timeout=120000)
327 await title_input.fill(title[:30])
328
329 description_editor = page.locator('div.zone-container[contenteditable="true"]').first
330 await description_editor.wait_for(state="visible", timeout=120000)
331 await description_editor.click()
332 await page.keyboard.press("Control+KeyA")
333 await page.keyboard.press("Delete")
334
335 for tag in tags or []:
336 await page.keyboard.type(" #" + tag)
337 await page.keyboard.press("Space")
338 await page.keyboard.press("Escape") # 收起话题下拉,避免浮层拦截后续点击
339
340 async def set_location(self, page: Page, location: str = ""):
341 if not location:
342 return
343 await page.locator('div.semi-select span:has-text("输入地理位置")').click()
344 await page.keyboard.press("Backspace")
345 await page.wait_for_timeout(2000)
346 await page.keyboard.type(location)
347 await page.wait_for_selector('div[role="listbox"] [role="option"]', timeout=5000)
348 await page.locator('div[role="listbox"] [role="option"]').first.click()
349
350 async def handle_product_dialog(self, page: Page, product_title: str):
351 await page.wait_for_timeout(2000)
352 await page.wait_for_selector('input[placeholder="请输入商品短标题"]', timeout=10000)
353 short_title_input = page.locator('input[placeholder="请输入商品短标题"]')
354 if not await short_title_input.count():
355 douyin_logger.error(_msg("😵", "没找到商品短标题输入框"))
356 return False
357
358 product_title = product_title[:10]
359 await short_title_input.fill(product_title)
360 await page.wait_for_timeout(1000)
361
362 finish_button = page.locator('button:has-text("完成编辑")')
363 if "disabled" not in await finish_button.get_attribute("class"):
364 await finish_button.click()
365 douyin_logger.debug(_msg("🥳", "已点击“完成编辑”按钮"))
366 await page.wait_for_selector(".semi-modal-content", state="hidden", timeout=5000)
367 return True
368
369 douyin_logger.error(_msg("😵", "“完成编辑”按钮是灰的,小人先把弹窗关掉"))
370 cancel_button = page.locator('button:has-text("取消")')
371 if await cancel_button.count():
372 await cancel_button.click()
373 else:
374 close_button = page.locator(".semi-modal-close")
375 await close_button.click()
376 await page.wait_for_selector(".semi-modal-content", state="hidden", timeout=5000)
377 return False
378
379 async def set_product_link(self, page: Page, product_link: str, product_title: str):
380 await page.wait_for_timeout(2000)
381 try:
382 await page.wait_for_selector("text=添加标签", timeout=10000)
383 dropdown = page.get_by_text("添加标签").locator("..").locator("..").locator("..").locator(".semi-select").first
384 if not await dropdown.count():
385 douyin_logger.error(_msg("😵", "没找到标签下拉框"))
386 return False
387 douyin_logger.debug(_msg("🧍", "找到标签下拉框,小人准备选择“购物车”"))
388 await dropdown.click()
389 await page.wait_for_selector('[role="listbox"]', timeout=5000)
390 await page.locator('[role="option"]:has-text("购物车")').click()
391 douyin_logger.debug(_msg("🥳", "已经选中“购物车”"))
392
393 await page.wait_for_selector('input[placeholder="粘贴商品链接"]', timeout=5000)
394 input_field = page.locator('input[placeholder="粘贴商品链接"]')
395 await input_field.fill(product_link)
396 douyin_logger.debug(_msg("🔗", f"商品链接已经填好了: {product_link}"))
397
398 add_button = page.locator('span:has-text("添加链接")')
399 button_class = await add_button.get_attribute("class")
400 if "disable" in button_class:
401 douyin_logger.error(_msg("😵", "“添加链接”按钮现在点不了"))
402 return False
403 await add_button.click()
404 douyin_logger.debug(_msg("🥳", "已点击“添加链接”按钮"))
405
406 await page.wait_for_timeout(2000)
407 error_modal = page.locator("text=未搜索到对应商品")
408 if await error_modal.count():
409 confirm_button = page.locator('button:has-text("确定")')
410 await confirm_button.click()
411 douyin_logger.error(_msg("😢", "这个商品链接无效"))
412 return False
413
414 if not await self.handle_product_dialog(page, product_title):
415 return False
416
417 douyin_logger.debug(_msg("🥳", "商品链接设置好了"))
418 return True
419 except Exception as e:
420 douyin_logger.error(_msg("😢", f"设置商品链接时出错: {str(e)}"))
421 return False
422
423 async def set_self_declaration(self, page: Page, declaration: str) -> bool:
424 """按调用方给出的平台原文选择自主声明;失败返回 False。"""
425 try:
426 # 发布页底部「自主声明」行,未选时显示占位文案「请选择自主声明」
427 entry = page.get_by_text("请选择自主声明").first
428 await entry.wait_for(state="visible", timeout=6000)
429 await entry.click()
430
431 # 弹窗标题「对作品内容添加声明」
432 dialog = page.locator(".semi-modal-content").filter(has_text="对作品内容添加声明").first
433 await dialog.wait_for(state="visible", timeout=6000)
434
435 # 单选项:Semi 的文字是 .semi-radio-addon(常带 pointer-events:none,直接点会卡 30s 超时),
436 # 要点可交互的 .semi-radio 外层;找不到外层再退回 force 强制点文字。exact 避免误命中预览「作者声明:…」。
437 option = dialog.locator(".semi-radio").filter(has_text=declaration).first
438 if await option.count():
439 await option.click(timeout=6000)
440 else:
441 await dialog.get_by_text(declaration, exact=True).first.click(timeout=6000, force=True)
442 await dialog.get_by_role("button", name="确定").click(timeout=6000)
443 await dialog.wait_for(state="hidden", timeout=6000)
444 douyin_logger.info(_msg("🧾", f"自主声明已选择「{declaration}」"))
445 return True
446 except Exception as exc:
447 douyin_logger.warning(_msg("🧾", f"自主声明设置失败:{exc}"))
448 return False
449
450 async def select_bgm(self, page: Page, bgm_name: str) -> bool:
451 """为图文发布选择 BGM:可选增强功能,搜索无结果或异常均跳过不中断发布。"""
452 try:
453 # 点击「选择音乐」按钮
454 music_entry = page.locator('text="选择音乐"').nth(1)
455 if not await music_entry.count():
456 music_entry = page.locator('text="选择音乐"').first
457 await music_entry.wait_for(state="visible", timeout=10000)
458 await music_entry.click()
459
460 # 等待侧边栏出现并搜索
461 sidesheet = page.locator(".semi-sidesheet-content").first
462 await sidesheet.wait_for(state="visible", timeout=8000)
463 search_input = sidesheet.locator('input.semi-input[placeholder="搜索音乐"]').first
464 await search_input.wait_for(state="visible", timeout=5000)
465 await search_input.fill(bgm_name)
466 await search_input.press("Enter")
467
468 # 等待搜索结果
469 await asyncio.sleep(2)
470 first_card = sidesheet.locator(".card-container-tmocjc").first
471 try:
472 await first_card.wait_for(state="visible", timeout=8000)
473 except Exception:
474 douyin_logger.warning(_msg("🎵", f"音乐「{bgm_name}」搜索结果为空,小人跳过"))
475 await self._close_music_sidesheet(page)
476 return False
477
478 # 打印找到的音乐名称
479 try:
480 song_name_el = first_card.locator(".song-name-oRge4d").first
481 if await song_name_el.count():
482 song_name = await song_name_el.inner_text()
483 douyin_logger.info(_msg("🎵", f"小人找到了: {song_name}"))
484 except Exception:
485 pass
486
487 # JS 点击「使用」(按钮 visibility:hidden,普通 click 无效)
488 apply_btn = first_card.locator(".apply-btn-LUPP0D").first
489 await apply_btn.evaluate("el => el.click()")
490 douyin_logger.info(_msg("🥳", f"BGM「{bgm_name}」已应用"))
491
492 # 等待侧边栏关闭,超时则手动关闭
493 try:
494 await sidesheet.wait_for(state="hidden", timeout=5000)
495 except Exception:
496 await self._close_music_sidesheet(page)
497
498 return True
499 except Exception as exc:
500 douyin_logger.warning(_msg("🎵", f"添加 BGM 时出错,跳过该步骤继续发布:{exc}"))
501 try:
502 await self._close_music_sidesheet(page)
503 except Exception:
504 pass
505 return False
506
507 async def _close_music_sidesheet(self, page: Page) -> None:
508 try:
509 close_btn = page.locator(".semi-sidesheet-close").first
510 if await close_btn.count() and await close_btn.is_visible():
511 await close_btn.click()
512 await asyncio.sleep(1)
513 except Exception:
514 pass
515
516
517 class DouYinVideo(DouYinBaseUploader):
518 def __init__(
519 self,
520 title,
521 file_path,
522 tags,
523 publish_date: datetime | int,
524 account_file,
525 thumbnail_landscape_path=None,
526 productLink="",
527 productTitle="",
528 thumbnail_portrait_path=None,
529 desc: str | None = None,
530 publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
531 debug: bool = DEBUG_MODE,
532 headless: bool = LOCAL_CHROME_HEADLESS,
533 declaration: str | None = None,
534 ):
535 super().__init__(
536 publish_date=publish_date,
537 account_file=account_file,
538 publish_strategy=publish_strategy,
539 debug=debug,
540 headless=headless,
541 )
542 self.title = title
543 self.file_path = file_path
544 self.tags = tags
545 self.thumbnail_landscape_path = thumbnail_landscape_path
546 self.thumbnail_portrait_path = thumbnail_portrait_path
547 self.productLink = productLink
548 self.productTitle = productTitle
549 self.desc = desc or ""
550 self.declaration = declaration.strip() if declaration and declaration.strip() else None
551
552 async def apply_self_declaration(self, page: Page) -> None:
553 if not self.declaration:
554 return
555 if not await self.set_self_declaration(page, self.declaration):
556 raise RuntimeError(f"自主声明「{self.declaration}」设置失败,拒绝继续发布")
557
558 async def _submit_sms_verify_code(self, page: Page, sms_input, code: str, code_file: str) -> bool:
559 douyin_logger.info(_msg("✍️", f"已获取验证码,准备填入: {code}"))
560 await sms_input.click()
561 await sms_input.fill(code)
562 douyin_logger.info(_msg("✅", "验证码已填入输入框"))
563 await page.wait_for_timeout(500)
564
565 verify_btn = page.locator('div.uc-ui-verify_sms-verify_button:has-text("验证")').first
566 if await verify_btn.count() and await verify_btn.is_visible():
567 try:
568 await verify_btn.click(force=True)
569 douyin_logger.success(_msg("✅", "已点击「验证」按钮 (force)"))
570 except Exception:
571 await page.eval_on_selector('div.uc-ui-verify_sms-verify_button', 'el => el.click()')
572 douyin_logger.success(_msg("✅", "已点击「验证」按钮 (JS)"))
573 else:
574 verify_by_text = page.get_by_text("验证", exact=True).first
575 if await verify_by_text.count():
576 await verify_by_text.click(force=True)
577 douyin_logger.success(_msg("✅", "已点击「验证」按钮 (text)"))
578 else:
579 douyin_logger.warning(_msg("⚠️", "未找到验证按钮,尝试按Enter"))
580 await page.keyboard.press("Enter")
581
582 if os.path.exists(code_file):
583 os.remove(code_file)
584 douyin_logger.info(_msg("🧹", "验证码文件已清理"))
585
586 await page.wait_for_timeout(3000)
587 douyin_logger.info(_msg("🔄", "验证码处理完成,继续发布流程"))
588 return True
589
590 async def validate_upload_args(self):
591 await self.validate_base_args()
592 if not self.title or not str(self.title).strip():
593 raise ValueError("视频模式下,title 是必须的")
594
595 self.file_path = str(self.validate_video_file(self.file_path))
596 if self.thumbnail_landscape_path:
597 self.thumbnail_landscape_path = str(self.validate_image_file(self.thumbnail_landscape_path))
598 if self.thumbnail_portrait_path:
599 self.thumbnail_portrait_path = str(self.validate_image_file(self.thumbnail_portrait_path))
600
601 async def handle_upload_error(self, page):
602 douyin_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传"))
603 await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path)
604
605 async def handle_auto_video_cover(self, page):
606 if await page.get_by_text("请设置封面后再发布").first.is_visible():
607 douyin_logger.info(_msg("🧍", "发布前还得先把封面弄好"))
608 recommend_cover = page.locator('[class^="recommendCover-"]').first
609 if await recommend_cover.count():
610 douyin_logger.info(_msg("🏃", "小人去选第一个推荐封面"))
611 try:
612 await recommend_cover.click()
613 await asyncio.sleep(1)
614 confirm_text = "是否确认应用此封面?"
615 if await page.get_by_text(confirm_text).first.is_visible():
616 douyin_logger.info(_msg("🪟", f"弹出确认框了: {confirm_text}"))
617 await page.get_by_role("button", name="确定").click()
618 douyin_logger.info(_msg("🥳", "推荐封面已经应用"))
619 await asyncio.sleep(1)
620 douyin_logger.info(_msg("🥳", "封面选择流程完成"))
621 return True
622 except Exception as e:
623 douyin_logger.warning(_msg("😵", f"推荐封面没选成功: {e}"))
624 return False
625
626 async def set_thumbnail(self, page: Page):
627 if not self.thumbnail_landscape_path and not self.thumbnail_portrait_path:
628 return
629
630 douyin_logger.info(_msg("🏃", "小人正在设置视频封面"))
631 # 先清掉 shepherd 新手引导浮层,否则它会拦截“选择封面”点击导致弹窗打不开
632 await page.evaluate(
633 "() => { document.querySelectorAll('.shepherd-element, .shepherd-modal-overlay-container, [class*=\"mention-wrapper\"]').forEach(e => e.remove()); }"
634 )
635 await page.get_by_text("选择封面", exact=True).first.click(force=True)
636 cover_locator_str = 'div.dy-creator-content-modal'
637 cover_locator = page.locator(cover_locator_str).first
638 await page.wait_for_selector(cover_locator_str, timeout=20000)
639
640 await page.wait_for_timeout(1500)
641 # version_2 封面弹窗有 4 个隐藏 file input:
642 # [0]/[1] 左侧“AI生成参考图”上传/替换,[2]/[3] 才是“上传封面”/替换。
643 # 旧代码用 .first 传到了 AI 参考图(不会成为封面)→ 这就是“传了却没封面”的根因。
644 # 取 input.semi-upload-hidden-input 的第 2 个(nth(1)),即真正的封面上传输入。
645 cover_upload = cover_locator.locator("input.semi-upload-hidden-input").nth(1)
646
647 if self.thumbnail_portrait_path:
648 # 弹窗默认就在“设置竖封面”页;防御性点一下 tab(已激活则忽略)
649 try:
650 await cover_locator.get_by_text("设置竖封面", exact=True).first.click(timeout=3000)
651 await page.wait_for_timeout(800)
652 except Exception:
653 pass
654 await cover_upload.set_input_files(self.thumbnail_portrait_path)
655 await page.wait_for_timeout(3000)
656 douyin_logger.info(_msg("🖼️", "竖版封面已上传到预览"))
657 elif self.thumbnail_landscape_path:
658 try:
659 await cover_locator.get_by_text("设置横封面", exact=True).first.click(timeout=3000)
660 await page.wait_for_timeout(800)
661 except Exception:
662 pass
663 await cover_upload.set_input_files(self.thumbnail_landscape_path)
664 await page.wait_for_timeout(3000)
665 douyin_logger.info(_msg("🖼️", "横版封面已上传到预览"))
666
667 # 点红色主按钮“完成”应用封面(exact 避免误中“完成编辑”)
668 await cover_locator.get_by_role("button", name="完成", exact=True).first.click()
669 douyin_logger.info(_msg("🥳", "视频封面设置完成"))
670 await cover_locator.wait_for(state="detached", timeout=20000)
671
672 async def upload(self, playwright: Playwright) -> None:
673 douyin_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
674 await self.validate_upload_args()
675 douyin_logger.info(_msg("🥳", "上传前检查通过"))
676
677 browser = await playwright.chromium.launch(headless=self.headless, channel="chromium")
678 context = await browser.new_context(
679 storage_state=f"{self.account_file}",
680 permissions=["geolocation"],
681 )
682 context = await set_init_script(context)
683
684 page = await context.new_page()
685 await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000)
686 douyin_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))
687 douyin_logger.info(_msg("🧭", "小人正在赶往上传主页"))
688 await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=90000)
689 # wait_for_url 完成时上传页可能尚未渲染出文件 input(实测偶发),先等它挂载再 set_input_files
690 await page.wait_for_selector("div[class^='container'] input", state="attached", timeout=60000)
691 await page.locator("div[class^='container'] input").set_input_files(self.file_path)
692
693 while True:
694 try:
695 await page.wait_for_url(
696 "https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
697 timeout=3000,
698 )
699 douyin_logger.info(_msg("🥳", "已经进入 version_1 发布页面"))
700 break
701 except Exception:
702 try:
703 await page.wait_for_url(
704 "https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
705 timeout=3000,
706 )
707 douyin_logger.info(_msg("🥳", "已经进入 version_2 发布页面"))
708 break
709 except Exception:
710 douyin_logger.debug(_msg("🧍", "还没进到视频发布页面,小人继续等一会"))
711 await asyncio.sleep(0.5)
712
713 await asyncio.sleep(1)
714 douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
715 await self.fill_title_and_description(page, self.title, self.desc or self.title, self.tags)
716 douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
717
718 while True:
719 try:
720 number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
721 if number > 0:
722 douyin_logger.success(_msg("🥳", "视频已经传完啦"))
723 break
724 douyin_logger.info(_msg("🏃", "小人正在努力上传视频"))
725 await asyncio.sleep(2)
726 if await page.locator('div.progress-div > div:has-text("上传失败")').count():
727 douyin_logger.error(_msg("😵", "检测到上传失败,小人准备重试"))
728 await self.handle_upload_error(page)
729 except Exception:
730 douyin_logger.debug(_msg("🧍", "小人还在等视频上传完成"))
731 await asyncio.sleep(2)
732
733 if self.productLink and self.productTitle:
734 douyin_logger.info(_msg("🛒", "小人正在设置商品链接"))
735 await self.set_product_link(page, self.productLink, self.productTitle)
736 douyin_logger.info(_msg("🥳", "商品链接设置完成"))
737
738 await self.set_thumbnail(page)
739
740 try:
741 await self.apply_self_declaration(page)
742 except Exception:
743 try:
744 await context.close()
745 except Exception:
746 pass
747 try:
748 await browser.close()
749 except Exception:
750 pass
751 raise
752
753 third_part_element = '[class^="info"] > [class^="first-part"] div div.semi-switch'
754 if await page.locator(third_part_element).count():
755 if "semi-switch-checked" not in await page.eval_on_selector(third_part_element, "div => div.className"):
756 await page.locator(third_part_element).locator("input.semi-switch-native-control").click()
757
758 if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
759 await self.set_schedule_time_douyin(page, self.publish_date)
760
761 sms_prompt_logged = False
762 while True:
763 try:
764 # 移除会拦截发布按钮点击的新手引导/话题下拉浮层
765 await page.evaluate(
766 "() => { document.querySelectorAll('.shepherd-element, .shepherd-modal-overlay-container, [class*=\"mention-wrapper\"]').forEach(e => e.remove()); }"
767 )
768 # 检测并处理短信验证码弹窗
769 sms_input = page.locator('input[placeholder*="验证码"], input[type="tel"], input[placeholder*="短信"], input[placeholder*="手机号"]').first
770 if await sms_input.count() and await sms_input.is_visible():
771 douyin_logger.warning(_msg("📱", "检测到短信验证码弹窗"))
772 # 点击「获取验证码」按钮(仅首次)
773 get_code_btn = page.get_by_text("获取验证码").first
774 if await get_code_btn.count() and await get_code_btn.is_visible():
775 await get_code_btn.click()
776 douyin_logger.info(_msg("📤", "已点击「获取验证码」,请查看手机短信"))
777 code_file = os.path.join(BASE_DIR, "verify_code.txt")
778 code = await _read_verify_code(code_file)
779 if code:
780 sms_prompt_logged = False
781 await self._submit_sms_verify_code(page, sms_input, code, code_file)
782 elif not sms_prompt_logged:
783 douyin_logger.warning(_msg("⏳", f"等待验证码输入;可在交互终端直接输入,或写入文件: {code_file}"))
784 sms_prompt_logged = True
785 publish_button = page.get_by_role("button", name="发布", exact=True)
786 if await publish_button.count():
787 await publish_button.click(force=True)
788 await page.wait_for_url(
789 "https://creator.douyin.com/creator-micro/content/manage**",
790 timeout=3000,
791 )
792 douyin_logger.success(_msg("🥳", "视频发布成功,小人开心收工"))
793 break
794 except Exception:
795 await self.handle_auto_video_cover(page)
796 douyin_logger.info(_msg("🏃", "小人正在冲刺发布视频"))
797 if self.debug:
798 await page.screenshot(full_page=True)
799 await asyncio.sleep(0.5)
800
801 await context.storage_state(path=self.account_file)
802 douyin_logger.success(_msg("🥳", "cookie 更新完毕"))
803 await asyncio.sleep(2)
804 await context.close()
805 await browser.close()
806
807 async def douyin_upload_video(self):
808 async with async_playwright() as playwright:
809 await self.upload(playwright)
810
811 async def main(self):
812 await self.douyin_upload_video()
813
814
815 class DouYinNote(DouYinBaseUploader):
816 def __init__(
817 self,
818 image_paths,
819 note,
820 tags,
821 publish_date: datetime | int,
822 account_file,
823 title: str | None = None,
824 publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
825 debug: bool = DEBUG_MODE,
826 headless: bool = LOCAL_CHROME_HEADLESS,
827 bgm: str = "",
828 ):
829 super().__init__(
830 publish_date=publish_date,
831 account_file=account_file,
832 publish_strategy=publish_strategy,
833 debug=debug,
834 headless=headless,
835 )
836 self.image_paths = image_paths
837 self.note = note or ""
838 self.title = title or (self.note[:30] if self.note else "")
839 self.tags = tags or []
840 self.bgm = bgm or ""
841
842 async def validate_upload_args(self):
843 await self.validate_base_args()
844 if not self.title or not str(self.title).strip():
845 raise ValueError("图文模式下,title 是必须的")
846
847 if len(self.title) > 20:
848 raise ValueError(f"标题不能超过20字符,当前: {len(self.title)}字符")
849
850 if not self.image_paths:
851 raise ValueError("图文模式下,图片是必须的")
852
853 if isinstance(self.image_paths, (str, Path)):
854 self.image_paths = [self.image_paths]
855
856 if len(self.image_paths) > 35:
857 raise ValueError("图文模式下最多只支持上传 35 张图片")
858
859 note_len = len(self.note) if self.note else 0
860 if note_len > 1000:
861 raise ValueError(f"正文不能超过1000字符,当前: {note_len}字符")
862
863 normalized_image_paths = []
864 for image_path in self.image_paths:
865 normalized_image_paths.append(str(self.validate_image_file(image_path)))
866 self.image_paths = normalized_image_paths
867
868 async def upload_note_content(self, page: Page) -> None:
869 douyin_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
870 douyin_logger.info(_msg("🔀", "小人正在切换到图文发布"))
871 await page.get_by_text("发布图文", exact=True).click()
872 await page.wait_for_timeout(1000)
873
874 douyin_logger.info(_msg("📤", "小人正在上传图片"))
875 await page.locator("div[class^='container'] input[accept*='image']").set_input_files(self.image_paths)
876
877 while True:
878 try:
879 await page.wait_for_url(
880 "**/creator-micro/content/post/image?**",
881 timeout=3000,
882 )
883 douyin_logger.info(_msg("🥳", "已经进入图文发布页面"))
884 break
885 except Exception:
886 douyin_logger.debug(_msg("🧍", "小人还在等图片上传完成"))
887 await asyncio.sleep(0.5)
888
889 await asyncio.sleep(1)
890 douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
891 await self.fill_title_and_description(page, self.title, self.note, self.tags)
892 title_len = len(self.title) if self.title else 0
893 tags_text = " ".join(f"#{t}" for t in self.tags) if self.tags else ""
894 desc_and_tags_len = len(self.note or "") + (len(tags_text) + 2 if self.tags else 0)
895 douyin_logger.info(_msg("📝", f"标题总字数: {title_len},描述+话题总字数: {desc_and_tags_len}"))
896 douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
897
898 if self.bgm:
899 await self.select_bgm(page, self.bgm)
900
901 if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
902 await self.set_schedule_time_douyin(page, self.publish_date)
903
904 while True:
905 try:
906 publish_button = page.get_by_role("button", name="发布", exact=True)
907 if await publish_button.count():
908 await publish_button.click()
909 await page.wait_for_url(
910 "**/creator-micro/content/manage?enter_from=publish**",
911 timeout=3000,
912 )
913 douyin_logger.success(_msg("🥳", "图文发布成功,小人开心收工"))
914 break
915 except Exception:
916 douyin_logger.info(_msg("🏃", "小人正在冲刺发布图文"))
917 await asyncio.sleep(0.5)
918
919 async def upload(self, playwright: Playwright) -> None:
920 douyin_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
921 await self.validate_upload_args()
922 douyin_logger.info(_msg("🥳", "图文上传前检查通过"))
923
924 browser = await playwright.chromium.launch(headless=self.headless, channel="chromium")
925 context = await browser.new_context(
926 storage_state=f"{self.account_file}",
927 permissions=["geolocation"],
928 )
929 context = await set_init_script(context)
930
931 upload_success = False
932 try:
933 page = await context.new_page()
934 await page.goto("https://creator.douyin.com/creator-micro/content/upload", wait_until="domcontentloaded", timeout=90000)
935 douyin_logger.info(_msg("🧭", "小人正在赶往图文发布页"))
936 await page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=90000)
937
938 await self.upload_note_content(page)
939 upload_success = True
940 finally:
941 if upload_success:
942 await context.storage_state(path=self.account_file)
943 douyin_logger.success(_msg("🥳", "cookie 更新完毕"))
944 await asyncio.sleep(2)
945 await context.close()
946 await browser.close()
947
948 async def douyin_upload_note(self):
949 async with async_playwright() as playwright:
950 await self.upload(playwright)
951
951 lines PYTHON