返回 Social Auto Upload
main.py
1 # -*- coding: utf-8 -*-
2 from __future__ import annotations
3
4 import asyncio
5 import base64
6 import inspect
7 import os
8 from datetime import datetime
9 from pathlib import Path
10 from urllib.parse import urljoin
11
12 from patchright.async_api import Page
13 from patchright.async_api import Playwright
14 from patchright.async_api import async_playwright
15
16 from conf import BASE_DIR, DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
17 from uploader.base_video import BaseVideoUploader
18 from utils.base_social_media import set_init_script
19 from utils.log import tencent_logger
20
21 TENCENT_LOGIN_URL = "https://channels.weixin.qq.com"
22 TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create"
23 TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list"
24 TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
25 TENCENT_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
26
27
28 def _msg(emoji: str, text: str) -> str:
29 return f"{emoji} {text}"
30
31
32 def _resolve_account_file(account_file: str | Path) -> str:
33 path = Path(account_file).expanduser()
34 if path.is_absolute():
35 return str(path)
36
37 if len(path.parts) == 1:
38 return str((Path(BASE_DIR) / "cookies" / "tencent_uploader" / path).resolve())
39
40 return str(path.resolve())
41
42
43 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
44 if not qrcode_callback:
45 return
46
47 callback_result = qrcode_callback(payload)
48 if inspect.isawaitable(callback_result):
49 await callback_result
50
51
52 def _build_login_result(
53 success: bool,
54 status: str,
55 message: str,
56 account_file: str,
57 qrcode: dict | None = None,
58 current_url: str = "",
59 ) -> dict:
60 return {
61 "success": success,
62 "status": status,
63 "message": message,
64 "account_file": str(account_file),
65 "qrcode": qrcode,
66 "current_url": current_url,
67 }
68
69
70 def _build_launch_kwargs(headless: bool) -> dict:
71 launch_kwargs = {"headless": headless}
72 if LOCAL_CHROME_PATH:
73 launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
74 else:
75 launch_kwargs["channel"] = "chrome"
76 return launch_kwargs
77
78
79 def _get_qrcode_utils():
80 from utils.login_qrcode import build_login_qrcode_path
81 from utils.login_qrcode import decode_qrcode_from_path
82 from utils.login_qrcode import print_terminal_qrcode
83 from utils.login_qrcode import remove_qrcode_file
84 from utils.login_qrcode import save_data_url_image
85
86 return {
87 "build_login_qrcode_path": build_login_qrcode_path,
88 "decode_qrcode_from_path": decode_qrcode_from_path,
89 "print_terminal_qrcode": print_terminal_qrcode,
90 "remove_qrcode_file": remove_qrcode_file,
91 "save_data_url_image": save_data_url_image,
92 }
93
94
95 def format_str_for_short_title(origin_title: str) -> str:
96 allowed_special_chars = "《》“”:+?%°"
97 filtered_chars = [char if char.isalnum() or char in allowed_special_chars else " " if char == "," else "" for char in origin_title]
98 formatted_string = "".join(filtered_chars)
99
100 if len(formatted_string) > 16:
101 formatted_string = formatted_string[:16]
102 elif len(formatted_string) < 6:
103 formatted_string += " " * (6 - len(formatted_string))
104
105 return formatted_string
106
107
108 async def cookie_auth(account_file):
109 account_file = _resolve_account_file(account_file)
110 async with async_playwright() as playwright:
111 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
112 try:
113 context = await browser.new_context(storage_state=account_file)
114 context = await set_init_script(context)
115 page = await context.new_page()
116 await page.goto(TENCENT_UPLOAD_URL, wait_until="domcontentloaded")
117
118 # cookie 失效时, 页面先停在 post/create, 随后由前端 JS 跳转到登录页;
119 # 必须等待跳转完成再判断, 否则会误报"cookie 有效"
120 try:
121 await page.wait_for_url("**/login.html**", timeout=8000)
122 tencent_logger.info(_msg("🥹", "cookie 已失效(页面跳转到登录页),得重新登录一下"))
123 return False
124 except Exception:
125 pass # 8 秒内未跳转, 大概率已登录
126
127 # 双保险: 页面里出现微信扫码登录 iframe 也视为失效
128 for fr in page.frames:
129 if "open.weixin.qq.com/connect/qrconnect" in fr.url:
130 tencent_logger.info(_msg("🥹", "cookie 已失效(页面出现扫码登录框),得重新登录一下"))
131 return False
132
133 tencent_logger.success(_msg("🥳", "cookie 有效"))
134 return True
135 except Exception as exc:
136 tencent_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
137 return False
138 finally:
139 await browser.close()
140
141
142 async def _extract_tencent_qrcode_src(page: Page) -> str:
143 if hasattr(page, "frame_locator"):
144 try:
145 iframe_locator = page.frame_locator('[src*="login-for-iframe"]')
146 qr_code_img = iframe_locator.locator('div#app img.qrcode').first
147 await qr_code_img.wait_for(state="visible", timeout=8000)
148 src = await qr_code_img.get_attribute("src")
149 if src and src.startswith("data:image/"):
150 return src
151 except Exception:
152 pass
153
154 # 2026 新版登录页: 二维码在 open.weixin.qq.com/connect/qrconnect 的 iframe 里,
155 # img.qrcode 的 src 是相对路径(如 /connect/qrcode/xxxx), 需要下载后转成 data URL
156 for frame in page.frames:
157 if "open.weixin.qq.com/connect/qrconnect" not in frame.url:
158 continue
159 try:
160 qr_img = frame.locator("img.qrcode").first
161 await qr_img.wait_for(state="attached", timeout=15000)
162 src = None
163 for _ in range(20):
164 src = await qr_img.get_attribute("src")
165 if src:
166 break
167 await page.wait_for_timeout(500)
168 if not src:
169 continue
170 if src.startswith("data:image/"):
171 return src
172 abs_url = urljoin(frame.url, src)
173 resp = await page.context.request.get(abs_url)
174 if resp.ok:
175 body = await resp.body()
176 content_type = resp.headers.get("content-type", "image/png").split(";")[0]
177 return f"data:{content_type};base64,{base64.b64encode(body).decode()}"
178 except Exception:
179 continue
180
181 selector_candidates = [
182 "div.login-qrcode-wrap img.qrcode",
183 "div.qrcode-wrap img.qrcode",
184 "img.qrcode",
185 'img[src^="data:image/"]',
186 ]
187 for selector in selector_candidates:
188 qr_code_img = page.locator(selector).first
189 try:
190 if not await qr_code_img.count() or not await qr_code_img.is_visible():
191 continue
192 src = await qr_code_img.get_attribute("src")
193 if src and src.startswith("data:image/"):
194 return src
195 except Exception:
196 continue
197
198 raise RuntimeError("未获取到视频号登录二维码地址")
199
200
201 async def _save_tencent_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict:
202 qrcode_utils = _get_qrcode_utils()
203 qrcode_src = await _extract_tencent_qrcode_src(page)
204 qrcode_path = qrcode_utils["save_data_url_image"](
205 qrcode_src,
206 qrcode_utils["build_login_qrcode_path"](account_file, suffix="tencent_login_qrcode"),
207 )
208 if previous_qrcode_path and previous_qrcode_path != qrcode_path:
209 if qrcode_utils["remove_qrcode_file"](previous_qrcode_path):
210 tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
211
212 tencent_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
213 qrcode_content = qrcode_utils["decode_qrcode_from_path"](qrcode_path)
214 if qrcode_content:
215 qrcode_utils["print_terminal_qrcode"](qrcode_content, qrcode_path, "微信")
216 else:
217 tencent_logger.warning(
218 _msg(
219 "😵",
220 f"没能从二维码图片里解析出可打印内容,所以这次没法在终端重绘二维码;请直接打开 {qrcode_path} 扫码",
221 )
222 )
223
224 qrcode_info = {
225 "image_path": str(qrcode_path),
226 "image_data_url": qrcode_src,
227 }
228 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
229 return qrcode_info
230
231
232 async def _is_tencent_login_completed(page: Page) -> bool:
233 publish_markers = [
234 page.locator('div:has-text("发表视频")').first,
235 page.locator('button:has-text("发表")').first,
236 page.locator('button:has-text("保存草稿")').first,
237 ]
238 for marker in publish_markers:
239 try:
240 if await marker.count() and await marker.is_visible():
241 return True
242 except Exception:
243 continue
244
245 if not (page.url.startswith(TENCENT_UPLOAD_URL) or page.url.startswith(TENCENT_MANAGE_URL)):
246 return False
247
248 login_markers = [
249 page.locator("div.login-qrcode-wrap").first,
250 page.locator("div.qrcode-wrap").first,
251 page.locator("img.qrcode").first,
252 page.locator('span:has-text("微信扫码登录 视频号助手")').first,
253 ]
254 for marker in login_markers:
255 try:
256 if await marker.count() and await marker.is_visible():
257 return False
258 except Exception:
259 continue
260
261 return True
262
263
264 async def _is_tencent_qrcode_expired(page: Page) -> bool:
265 tip_selectors = [
266 'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
267 'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
268 'p.refresh-tip:has-text("二维码已过期,点击刷新")',
269 'p.refresh-tip:has-text("网络不可用,点击刷新")',
270 ]
271 for selector in tip_selectors:
272 tip = page.locator(selector).first
273 try:
274 if await tip.count() and await tip.is_visible():
275 return True
276 except Exception:
277 continue
278 return False
279
280
281 async def _is_tencent_qrcode_scanned(page: Page) -> bool:
282 scanned_tips = [
283 'div.qr-tip div:has-text("已扫码")',
284 'div.qr-tip div:has-text("需在手机上进行确认")',
285 ]
286 for selector in scanned_tips:
287 tip = page.locator(selector).first
288 try:
289 if await tip.count() and await tip.is_visible():
290 return True
291 except Exception:
292 continue
293 return False
294
295
296 async def _refresh_tencent_qrcode(page: Page) -> None:
297 visible_refresh_selectors = [
298 "div.login-qrcode-wrap div.mask.show div.refresh-wrap",
299 "div.login-qrcode-wrap div.mask.show .refresh-wrap",
300 ]
301 for selector in visible_refresh_selectors:
302 refresh_wrap = page.locator(selector).first
303 try:
304 if not await refresh_wrap.count() or not await refresh_wrap.is_visible():
305 continue
306 await refresh_wrap.click()
307 return
308 except Exception:
309 continue
310
311 tip_selectors = [
312 'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
313 'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
314 'p.refresh-tip:has-text("二维码已过期,点击刷新")',
315 'p.refresh-tip:has-text("网络不可用,点击刷新")',
316 ]
317 for selector in tip_selectors:
318 tip = page.locator(selector).first
319 try:
320 if not await tip.count() or not await tip.is_visible():
321 continue
322 refresh_wrap = tip.locator("xpath=ancestor::div[contains(@class, 'refresh-wrap')]").first
323 if await refresh_wrap.count():
324 await refresh_wrap.click()
325 else:
326 await tip.click()
327 return
328 except Exception:
329 continue
330
331 fallback_refresh = page.locator("div.login-qrcode-wrap div.refresh-wrap").first
332 if await fallback_refresh.count():
333 await fallback_refresh.click()
334 return
335
336 raise RuntimeError("未找到可点击的视频号二维码刷新区域")
337
338
339 async def _wait_for_tencent_login(
340 page: Page,
341 account_file: str,
342 qrcode_info: dict | None,
343 qrcode_callback=None,
344 poll_interval: int = 3,
345 max_checks: int = 100,
346 ) -> dict:
347 qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info else None
348 scanned_logged = False
349 for _ in range(max_checks):
350 if await _is_tencent_login_completed(page):
351 tencent_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}"))
352 return _build_login_result(True, "success", "视频号扫码登录成功", account_file, qrcode_info, page.url)
353
354 if not scanned_logged and await _is_tencent_qrcode_scanned(page):
355 tencent_logger.info(_msg("📱", "已经扫码啦,还差手机端确认一下"))
356 scanned_logged = True
357
358 if await _is_tencent_qrcode_expired(page):
359 tencent_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新"))
360 await _refresh_tencent_qrcode(page)
361 await asyncio.sleep(1)
362 try:
363 qrcode_info = await _save_tencent_qrcode(
364 page,
365 account_file,
366 previous_qrcode_path=qrcode_path,
367 qrcode_callback=qrcode_callback,
368 )
369 qrcode_path = Path(qrcode_info["image_path"])
370 except Exception as exc:
371 tencent_logger.warning(_msg("⚠️", f"刷新后未能重新提取二维码({exc}),请直接在浏览器窗口中扫码"))
372
373 await asyncio.sleep(poll_interval)
374
375 return _build_login_result(False, "timeout", "等待视频号扫码登录超时", account_file, qrcode_info, page.url)
376
377
378 async def tencent_cookie_gen(
379 account_file,
380 qrcode_callback=None,
381 poll_interval: int = 3,
382 max_checks: int = 100,
383 headless: bool = LOCAL_CHROME_HEADLESS,
384 ):
385 account_file = _resolve_account_file(account_file)
386 Path(account_file).parent.mkdir(parents=True, exist_ok=True)
387
388 async with async_playwright() as playwright:
389 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
390 context = await browser.new_context()
391 qrcode_path = None
392 result = _build_login_result(False, "failed", "视频号登录失败", account_file)
393 try:
394 page = await context.new_page()
395 await page.goto(TENCENT_LOGIN_URL)
396 try:
397 qrcode_info = await _save_tencent_qrcode(page, account_file, qrcode_callback=qrcode_callback)
398 qrcode_path = Path(qrcode_info["image_path"])
399 except Exception as exc:
400 tencent_logger.warning(
401 _msg("⚠️", f"提取二维码图片失败({exc}),请直接在弹出的浏览器窗口中扫码,登录流程不受影响")
402 )
403 qrcode_info = None
404 qrcode_path = None
405 tencent_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成"))
406 result = await _wait_for_tencent_login(
407 page,
408 account_file,
409 qrcode_info,
410 qrcode_callback=qrcode_callback,
411 poll_interval=poll_interval,
412 max_checks=max_checks,
413 )
414 if result["success"]:
415 await asyncio.sleep(2)
416 await context.storage_state(path=account_file)
417 if not await cookie_auth(account_file):
418 result = _build_login_result(
419 False,
420 "cookie_invalid",
421 "视频号扫码流程结束,但 cookie 校验失败",
422 account_file,
423 qrcode_info,
424 page.url,
425 )
426 return result
427 except Exception as exc:
428 result = _build_login_result(
429 False,
430 "failed",
431 str(exc),
432 account_file,
433 current_url=page.url if "page" in locals() else "",
434 )
435 return result
436 finally:
437 qrcode_utils = _get_qrcode_utils()
438 if qrcode_utils["remove_qrcode_file"](qrcode_path):
439 tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
440 if not result["success"]:
441 tencent_logger.error(_msg("😢", f"登录失败: {result['message']}"))
442 await context.close()
443 await browser.close()
444
445
446 async def tencent_setup(
447 account_file,
448 handle=False,
449 return_detail=False,
450 qrcode_callback=None,
451 headless: bool = LOCAL_CHROME_HEADLESS,
452 ):
453 account_file = _resolve_account_file(account_file)
454 if not os.path.exists(account_file) or not await cookie_auth(account_file):
455 if not handle:
456 result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
457 return result if return_detail else False
458
459 tencent_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录"))
460 result = await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
461 return result if return_detail else result["success"]
462
463 result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
464 return result if return_detail else True
465
466
467 async def get_tencent_cookie(account_file, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
468 return await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
469
470
471 async def weixin_setup(
472 account_file,
473 handle=False,
474 return_detail=False,
475 qrcode_callback=None,
476 headless: bool = LOCAL_CHROME_HEADLESS,
477 ):
478 return await tencent_setup(
479 account_file,
480 handle=handle,
481 return_detail=return_detail,
482 qrcode_callback=qrcode_callback,
483 headless=headless,
484 )
485
486
487 class TencentBaseUploader(BaseVideoUploader):
488 def __init__(
489 self,
490 publish_date: datetime | int,
491 account_file,
492 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
493 debug: bool = DEBUG_MODE,
494 headless: bool = LOCAL_CHROME_HEADLESS,
495 ):
496 self.publish_date = publish_date
497 self.account_file = _resolve_account_file(account_file)
498 self.publish_strategy = publish_strategy
499 self.debug = debug
500 self.headless = headless
501 self.local_executable_path = LOCAL_CHROME_PATH
502
503 async def validate_base_args(self):
504 if not os.path.exists(self.account_file):
505 raise RuntimeError(f"cookie文件不存在,请先完成视频号登录: {self.account_file}")
506 if not await cookie_auth(self.account_file):
507 raise RuntimeError(f"cookie文件已失效,请先完成视频号登录: {self.account_file}")
508 if self.publish_strategy not in {TENCENT_PUBLISH_STRATEGY_IMMEDIATE, TENCENT_PUBLISH_STRATEGY_SCHEDULED}:
509 raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
510
511 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED:
512 self.publish_date = self.validate_publish_date(self.publish_date)
513 else:
514 self.publish_date = 0
515
516 async def set_schedule_time_tencent(self, page: Page, publish_date: datetime):
517 label_element = page.locator("label").filter(has_text="定时").nth(1)
518 await label_element.click()
519 await page.click('input[placeholder="请选择发表时间"]')
520
521 current_month = publish_date.strftime("%m月")
522 page_month = await page.inner_text('span.weui-desktop-picker__panel__label:has-text("月")')
523 if page_month != current_month:
524 await page.click("button.weui-desktop-btn__icon__right")
525
526 elements = await page.query_selector_all("table.weui-desktop-picker__table a")
527 for element in elements:
528 if "weui-desktop-picker__disabled" in await element.evaluate("el => el.className"):
529 continue
530 text = await element.inner_text()
531 if text.strip() == str(publish_date.day):
532 await element.click()
533 break
534
535 await page.click('input[placeholder="请选择时间"]')
536 await page.keyboard.press("Control+KeyA")
537 await page.keyboard.type(publish_date.strftime("%H"))
538 await page.keyboard.press("Enter") # 确认小时并关闭时间下拉
539 await page.wait_for_timeout(500)
540 # 收起时间选择浮层:直接点描述区可能被 weui-desktop-dialog 遮挡,做容错
541 try:
542 await page.locator("div.input-editor").click(timeout=5000)
543 except Exception:
544 await page.keyboard.press("Escape")
545
546 async def open_upload_page(self, page: Page) -> None:
547 await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
548 # cookie 失效时前端 JS 会跳转到登录页, 提前发现并报明确的错误
549 redirected = True
550 try:
551 await page.wait_for_url("**/login.html**", timeout=8000)
552 except Exception:
553 redirected = False # 8 秒内未跳转, 正常
554 if redirected or any(
555 "open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames
556 ):
557 raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
558
559 async def upload_video_file(self, page: Page, file_path: str) -> None:
560 async def find_file_input():
561 for fr in page.frames: # 主 frame + 所有 iframe(视频号编辑器可能在 iframe 内)
562 try:
563 fi = fr.locator('input[type="file"]')
564 if await fi.count():
565 return fi.first
566 except Exception:
567 continue
568 return None
569
570 fi = await find_file_input()
571 if fi is None:
572 # 助手落在首页:先点「发表视频」唤出编辑器与上传控件
573 publish_btn = page.get_by_text("发表视频").first
574 if await publish_btn.count():
575 await publish_btn.click()
576 await asyncio.sleep(3)
577 for _ in range(20):
578 fi = await find_file_input()
579 if fi is not None:
580 break
581 await asyncio.sleep(1)
582 if fi is None:
583 raise RuntimeError("未找到视频号文件上传框")
584 await fi.set_input_files(file_path)
585
586 async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
587 short_title_element = (
588 page.get_by_text("短标题", exact=True)
589 .locator("..")
590 .locator("xpath=following-sibling::div")
591 .locator('span input[type="text"]')
592 )
593 if await short_title_element.count():
594 await short_title_element.fill(short_title or format_str_for_short_title(title))
595
596 async def fill_title_and_tags(self, page: Page) -> None:
597 await page.locator("div.input-editor").click()
598 await page.keyboard.type(self.title)
599 await page.keyboard.press("Enter")
600 for tag in self.tags:
601 await page.keyboard.type("#" + tag)
602 await page.keyboard.press("Space")
603 tencent_logger.info(_msg("🏷️", f"成功添加 hashtag: {len(self.tags)}"))
604
605 async def fill_description(self, page: Page) -> None:
606 await page.keyboard.press("Enter")
607 await page.keyboard.type(self.desc)
608 tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}"))
609
610 async def apply_collection(self, page: Page) -> None:
611 collection_elements = (
612 page.get_by_text("添加到合集")
613 .locator("xpath=following-sibling::div")
614 .locator(".option-list-wrap > div")
615 )
616 if await collection_elements.count() > 1:
617 await page.get_by_text("添加到合集").locator("xpath=following-sibling::div").click()
618 await collection_elements.first.click()
619
620 async def apply_original_statement(self, page: Page) -> None:
621 original_set = False
622 if await page.get_by_label("视频为原创").count():
623 await page.get_by_label("视频为原创").check()
624 original_set = True
625
626 try:
627 label_locator = await page.locator('label:has-text("我已阅读并同意 《视频号原创声明使用条款》")').is_visible()
628 except Exception:
629 label_locator = False
630
631 if label_locator:
632 await page.get_by_label("我已阅读并同意 《视频号原创声明使用条款》").check()
633 await page.get_by_role("button", name="声明原创").click()
634 original_set = True
635
636 declaration_entry = page.locator(
637 'div.label span:has-text("声明原创"), '
638 'div:has-text("声明原创"):has(input.ant-checkbox-input), '
639 'div:has-text("原创声明"):has(input.ant-checkbox-input)'
640 ).first
641 if await declaration_entry.count():
642 original_checkbox = page.locator("div.declare-original-checkbox input.ant-checkbox-input").first
643 if await original_checkbox.count() and not await original_checkbox.is_disabled():
644 await original_checkbox.click()
645 await page.wait_for_timeout(500)
646 checked_locator = page.locator(
647 "div.declare-original-dialog "
648 "label.ant-checkbox-wrapper.ant-checkbox-wrapper-checked:visible"
649 )
650 if not await checked_locator.count():
651 await page.locator("div.declare-original-dialog input.ant-checkbox-input:visible").first.click()
652
653 original_type_form = page.locator('div.original-type-form > div.form-label:has-text("原创类型"):visible')
654 if await original_type_form.count():
655 category = getattr(self, "category", None)
656 await page.locator("div.form-content:visible").click()
657 option = None
658 if category:
659 option = page.locator(
660 "ul.weui-desktop-dropdown__list "
661 f'li.weui-desktop-dropdown__list-ele:has-text("{category}")'
662 ).first
663 if not await option.count():
664 option = None
665 if option is None:
666 option = page.locator(
667 "ul.weui-desktop-dropdown__list "
668 "li.weui-desktop-dropdown__list-ele:visible"
669 ).first
670 if await option.count():
671 await option.click()
672 await page.wait_for_timeout(1000)
673
674 declare_button = page.locator('button:has-text("声明原创"):visible')
675 if await declare_button.count():
676 await declare_button.first.click()
677 original_set = True
678 await page.wait_for_timeout(1000)
679
680 if not original_set:
681 for original_text in ("声明原创", "原创声明", "视频为原创"):
682 try:
683 modern_original = page.locator(f'text="{original_text}"').first
684 if await modern_original.count() and await modern_original.is_visible():
685 await modern_original.click()
686 original_set = True
687 await page.wait_for_timeout(1000)
688 break
689 except Exception:
690 continue
691
692 content_declaration = page.locator('text="内容声明"').first
693 try:
694 if await content_declaration.count() and await content_declaration.is_visible():
695 await content_declaration.click()
696 for option_text in ("无需声明", "不声明", "无"):
697 option = page.locator(f'text="{option_text}"').first
698 if await option.count() and await option.is_visible():
699 await option.click()
700 tencent_logger.info(_msg("🧾", f"内容声明已选择: {option_text}"))
701 break
702 else:
703 tencent_logger.info(_msg("🧾", "当前页面未发现内容声明字段"))
704 except Exception as exc:
705 tencent_logger.warning(_msg("😵", f"内容声明设置失败,继续前先人工确认页面: {exc}"))
706
707 if not original_set:
708 try:
709 diagnostic_path = Path(BASE_DIR) / "debug_tencent_original_missing.png"
710 await page.screenshot(path=str(diagnostic_path), full_page=True)
711 visible_text = (await page.locator("body").first.inner_text())[-4000:]
712 tencent_logger.warning(_msg("😵", f"未确认声明原创,诊断截图: {diagnostic_path}"))
713 tencent_logger.warning(_msg("🧾", f"页面末尾文本: {visible_text}"))
714 except Exception as exc:
715 tencent_logger.warning(_msg("😵", f"生成原创声明诊断信息失败: {exc}"))
716 # 视频号「声明原创」为可选项:页面无对应入口时跳过并继续发布,而非中止。
717 tencent_logger.warning(_msg("📭", "本视频未声明原创(页面无入口或为可选项),跳过并继续发布"))
718
719 async def wait_for_upload_complete(self, page: Page) -> None:
720 while True:
721 try:
722 publish_button = page.get_by_role("button", name="发表")
723 button_class = await publish_button.get_attribute("class")
724 if button_class and "weui-desktop-btn_disabled" not in button_class:
725 tencent_logger.info(_msg("🥳", "视频上传完毕"))
726 break
727
728 tencent_logger.info(_msg("🏃", "正在上传视频中..."))
729 await asyncio.sleep(2)
730
731 upload_failed = await page.locator("div.status-msg.error").count()
732 delete_button = await page.locator('div.media-status-content div.tag-inner:has-text("删除")').count()
733 if upload_failed and delete_button:
734 tencent_logger.error(_msg("😵", "发现上传出错了,准备重试"))
735 await self.handle_upload_error(page)
736 except Exception:
737 tencent_logger.info(_msg("🏃", "正在上传视频中..."))
738 await asyncio.sleep(2)
739
740 async def submit_publish(self, page: Page) -> None:
741 while True:
742 try:
743 if getattr(self, "is_draft", False):
744 draft_button = page.locator('div.form-btns button:has-text("保存草稿")')
745 if await draft_button.count():
746 await draft_button.click()
747 await page.wait_for_url("**/post/list**", timeout=5000)
748 tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
749 else:
750 publish_button = page.locator('div.form-btns button:has-text("发表")')
751 if await publish_button.count():
752 await publish_button.click()
753 await page.wait_for_url(TENCENT_MANAGE_URL, timeout=5000)
754 tencent_logger.success(_msg("🥳", "视频发布成功"))
755 break
756 except Exception as exc:
757 current_url = page.url
758 if getattr(self, "is_draft", False):
759 if "post/list" in current_url or "draft" in current_url:
760 tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
761 break
762 else:
763 if TENCENT_MANAGE_URL in current_url:
764 tencent_logger.success(_msg("🥳", "视频发布成功"))
765 break
766 tencent_logger.exception(f" [-] Exception: {exc}")
767 tencent_logger.info(_msg("🏃", "视频正在发布中..."))
768 await asyncio.sleep(0.5)
769
770
771 class TencentVideo(TencentBaseUploader):
772 def __init__(
773 self,
774 title,
775 file_path,
776 tags,
777 publish_date: datetime | int,
778 account_file,
779 category=None,
780 is_draft=False,
781 desc: str | None = None,
782 thumbnail_path: str | None = None,
783 thumbnail_landscape_path: str | None = None,
784 thumbnail_portrait_path: str | None = None,
785 short_title: str | None = None,
786 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
787 debug: bool = DEBUG_MODE,
788 headless: bool = LOCAL_CHROME_HEADLESS,
789 ):
790 super().__init__(
791 publish_date=publish_date,
792 account_file=account_file,
793 publish_strategy=publish_strategy,
794 debug=debug,
795 headless=headless,
796 )
797 self.title = title
798 self.file_path = file_path
799 self.tags = tags or []
800 self.category = category
801 self.is_draft = is_draft
802 self.desc = desc or ""
803 self.thumbnail_path = thumbnail_path
804 self.thumbnail_landscape_path = thumbnail_landscape_path
805 self.thumbnail_portrait_path = thumbnail_portrait_path or thumbnail_path
806 self.short_title = short_title
807
808 async def validate_upload_args(self):
809 await self.validate_base_args()
810 if not self.title or not str(self.title).strip():
811 raise ValueError("视频模式下,title 是必须的")
812 self.file_path = str(self.validate_video_file(self.file_path))
813 if self.thumbnail_landscape_path:
814 self.thumbnail_landscape_path = str(self.validate_image_file(self.thumbnail_landscape_path))
815 if self.thumbnail_portrait_path:
816 self.thumbnail_portrait_path = str(self.validate_image_file(self.thumbnail_portrait_path))
817
818 async def handle_upload_error(self, page: Page) -> None:
819 tencent_logger.info(_msg("😵", "视频出错了,重新上传中"))
820 await page.locator('div.media-status-content div.tag-inner:has-text("删除")').click()
821 await page.get_by_role("button", name="删除", exact=True).click()
822 await self.upload_video_file(page, self.file_path)
823
824 async def open_thumbnail_dialog(self, page: Page, selectors: list[str], dialog_titles: list[str]):
825 for selector in selectors:
826 cover_entry = page.locator(selector).first
827 try:
828 if not await cover_entry.count():
829 continue
830 await cover_entry.wait_for(state="visible", timeout=3000)
831 await cover_entry.click()
832 await page.wait_for_timeout(500)
833 break
834 except Exception:
835 continue
836
837 for title in dialog_titles:
838 cover_dialog = page.locator("div.weui-desktop-dialog").filter(has_text=title).first
839 if await cover_dialog.count():
840 return cover_dialog
841 return None
842
843 async def confirm_thumbnail_crop(self, page: Page) -> None:
844 crop_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="裁剪封面图").first
845 if not await crop_dialog.count():
846 return
847
848 try:
849 await crop_dialog.wait_for(state="visible", timeout=10000)
850 crop_confirm_button = crop_dialog.locator(
851 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确定")'
852 ).first
853 if await crop_confirm_button.count():
854 await crop_confirm_button.wait_for(state="visible", timeout=5000)
855 await crop_confirm_button.click()
856 await page.wait_for_timeout(1000)
857 except Exception as exc:
858 tencent_logger.warning(_msg("😵", f"封面裁剪确认时出错,小人继续尝试保存主弹窗: {exc}"))
859
860 async def upload_thumbnail_in_dialog(self, page: Page, cover_dialog, thumbnail_path: str) -> None:
861 await cover_dialog.wait_for(state="visible", timeout=5000)
862 file_input = cover_dialog.locator('.single-cover-uploader-wrap input[type="file"]').first
863 await file_input.wait_for(state="attached", timeout=10000)
864 await file_input.set_input_files(thumbnail_path)
865 await page.wait_for_timeout(1000)
866 await self.confirm_thumbnail_crop(page)
867
868 confirm_button = cover_dialog.locator(
869 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")'
870 ).first
871 await confirm_button.wait_for(state="visible", timeout=10000)
872 await confirm_button.click()
873
874 async def set_single_thumbnail(
875 self,
876 page: Page,
877 thumbnail_path: str,
878 selectors: list[str],
879 dialog_titles: list[str],
880 label: str,
881 ) -> None:
882 cover_dialog = await self.open_thumbnail_dialog(page, selectors, dialog_titles)
883 if not cover_dialog:
884 tencent_logger.info(_msg("🧍", f"当前页面没有出现{label}封面编辑弹窗,小人先跳过"))
885 return
886
887 try:
888 await self.upload_thumbnail_in_dialog(page, cover_dialog, thumbnail_path)
889 tencent_logger.success(_msg("🥳", f"{label}封面已经设置完成"))
890 except Exception as exc:
891 tencent_logger.warning(_msg("😵", f"{label}封面设置失败,这次先跳过: {exc}"))
892
893 async def set_thumbnail(self, page: Page) -> None:
894 if not self.thumbnail_landscape_path and not self.thumbnail_portrait_path:
895 return
896
897 tencent_logger.info(_msg("🖼️", "小人准备设置封面"))
898
899 landscape_selectors = [
900 'div.horizontal-cover-wrap:has-text("4:3")',
901 'div[class*="cover-wrap"]:has-text("4:3"):has-text("动态")',
902 'div:has-text("视频号动态"):has-text("4:3")',
903 'div:has-text("横版封面"):has-text("4:3")',
904 ]
905 portrait_selectors = [
906 'div.vertical-cover-wrap:has-text("个人主页卡片"):has-text("3:4")',
907 'div.vertical-cover-wrap:has-text("3:4")',
908 'div.vertical-cover-wrap:has-text("个人主页卡片")',
909 ]
910
911 if self.thumbnail_landscape_path:
912 await self.set_single_thumbnail(
913 page,
914 self.thumbnail_landscape_path,
915 landscape_selectors,
916 ["编辑视频号动态封面", "编辑动态封面", "编辑封面"],
917 "4:3 横版",
918 )
919 if self.thumbnail_portrait_path:
920 await self.set_single_thumbnail(
921 page,
922 self.thumbnail_portrait_path,
923 portrait_selectors,
924 ["编辑个人主页卡片", "编辑封面"],
925 "3:4 竖版",
926 )
927
928 async def prepare_video_for_publish(self, page: Page) -> None:
929 await self.fill_title_and_tags(page)
930 await self.fill_description(page)
931 await self.apply_collection(page)
932
933 async def upload(self, playwright: Playwright) -> None:
934 tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间"))
935 await self.validate_upload_args()
936 tencent_logger.info(_msg("🥳", "上传前检查通过"))
937
938 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
939 context = await browser.new_context(storage_state=self.account_file)
940
941 try:
942 page = await context.new_page()
943 await self.open_upload_page(page)
944 tencent_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}"))
945
946 await self.upload_video_file(page, self.file_path)
947 await self.prepare_video_for_publish(page)
948 await self.wait_for_upload_complete(page)
949 await self.apply_original_statement(page)
950 await self.set_thumbnail(page)
951
952 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
953 await self.set_schedule_time_tencent(page, self.publish_date)
954
955 await self.set_short_title(page, self.title, self.short_title)
956 await self.submit_publish(page)
957
958 await context.storage_state(path=self.account_file)
959 tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
960 finally:
961 await context.close()
962 await browser.close()
963
964 async def tencent_upload_video(self):
965 async with async_playwright() as playwright:
966 await self.upload(playwright)
967
968 async def main(self):
969 await self.tencent_upload_video()
970
971
972 class TencentNote(TencentBaseUploader):
973 def __init__(
974 self,
975 image_paths,
976 note,
977 tags,
978 publish_date: datetime | int,
979 account_file,
980 title: str | None = None,
981 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
982 debug: bool = DEBUG_MODE,
983 headless: bool = LOCAL_CHROME_HEADLESS,
984 is_draft: bool = False,
985 ):
986 super().__init__(
987 publish_date=publish_date,
988 account_file=account_file,
989 publish_strategy=publish_strategy,
990 debug=debug,
991 headless=headless,
992 )
993 self.image_paths = image_paths
994 self.note = note or ""
995 self.title = title or (self.note[:30] if self.note else "")
996 self.tags = tags or []
997 self.is_draft = is_draft
998
999 async def validate_upload_args(self):
1000 await self.validate_base_args()
1001 if not self.title or not str(self.title).strip():
1002 raise ValueError("图文模式下,title 是必须的")
1003 if not self.image_paths:
1004 raise ValueError("图文模式下,图片是必须的")
1005
1006 if isinstance(self.image_paths, (str, Path)):
1007 self.image_paths = [self.image_paths]
1008
1009 normalized_image_paths = []
1010 for image_path in self.image_paths:
1011 normalized_image_paths.append(str(self.validate_image_file(image_path)))
1012 self.image_paths = normalized_image_paths
1013
1014 async def switch_to_note_mode(self, page: Page) -> None:
1015 raise NotImplementedError("请在 TencentNote.switch_to_note_mode 中补充视频号切换到图文发布模式的逻辑")
1016
1017 async def upload_note_images(self, page: Page) -> None:
1018 raise NotImplementedError("请在 TencentNote.upload_note_images 中补充视频号图文图片上传逻辑")
1019
1020 async def fill_note_title_and_tags(self, page: Page) -> None:
1021 raise NotImplementedError("请在 TencentNote.fill_note_title_and_tags 中补充视频号图文标题/话题填写逻辑")
1022
1023 async def fill_note_body(self, page: Page) -> None:
1024 return None
1025
1026 async def prepare_note_for_publish(self, page: Page) -> None:
1027 await self.fill_note_title_and_tags(page)
1028 await self.fill_note_body(page)
1029 await self.apply_collection(page)
1030 await self.apply_original_statement(page)
1031
1032 async def upload_note_content(self, page: Page) -> None:
1033 await self.switch_to_note_mode(page)
1034 await self.upload_note_images(page)
1035 await self.prepare_note_for_publish(page)
1036
1037 async def upload(self, playwright: Playwright) -> None:
1038 tencent_logger.info(_msg("🧍", "小人先检查 cookie、图文图片和发布时间"))
1039 await self.validate_upload_args()
1040 tencent_logger.info(_msg("🥳", "图文上传前检查通过"))
1041
1042 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
1043 context = await browser.new_context(storage_state=self.account_file)
1044 context = await set_init_script(context)
1045
1046 try:
1047 page = await context.new_page()
1048 await self.open_upload_page(page)
1049 tencent_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
1050
1051 await self.upload_note_content(page)
1052
1053 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
1054 await self.set_schedule_time_tencent(page, self.publish_date)
1055
1056 await self.submit_publish(page)
1057
1058 await context.storage_state(path=self.account_file)
1059 tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
1060 finally:
1061 await context.close()
1062 await browser.close()
1063
1064 async def tencent_upload_note(self):
1065 async with async_playwright() as playwright:
1066 await self.upload(playwright)
1067
1068 async def main(self):
1069 await self.tencent_upload_note()
1070
1070 lines PYTHON