返回 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 import time
9 from datetime import datetime
10 from pathlib import Path
11 from urllib.parse import urljoin
12
13 from patchright.async_api import Page
14 from patchright.async_api import Playwright
15 from patchright.async_api import async_playwright
16
17 from conf import BASE_DIR, DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
18 from uploader.base_video import BaseVideoUploader
19 from utils.base_social_media import set_init_script
20 from utils.log import tencent_logger
21
22 TENCENT_LOGIN_URL = "https://channels.weixin.qq.com"
23 TENCENT_HOME_URL = "https://channels.weixin.qq.com/platform"
24 TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create"
25 TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list"
26 TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
27 TENCENT_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
28
29
30 def _msg(emoji: str, text: str) -> str:
31 return f"{emoji} {text}"
32
33
34 def _resolve_account_file(account_file: str | Path) -> str:
35 path = Path(account_file).expanduser()
36 if path.is_absolute():
37 return str(path)
38
39 if len(path.parts) == 1:
40 return str((Path(BASE_DIR) / "cookies" / "tencent_uploader" / path).resolve())
41
42 return str(path.resolve())
43
44
45 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
46 if not qrcode_callback:
47 return
48
49 callback_result = qrcode_callback(payload)
50 if inspect.isawaitable(callback_result):
51 await callback_result
52
53
54 def _build_login_result(
55 success: bool,
56 status: str,
57 message: str,
58 account_file: str,
59 qrcode: dict | None = None,
60 current_url: str = "",
61 ) -> dict:
62 return {
63 "success": success,
64 "status": status,
65 "message": message,
66 "account_file": str(account_file),
67 "qrcode": qrcode,
68 "current_url": current_url,
69 }
70
71
72 def _build_launch_kwargs(headless: bool) -> dict:
73 launch_kwargs = {"headless": headless}
74 if LOCAL_CHROME_PATH:
75 launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
76 else:
77 launch_kwargs["channel"] = "chrome"
78 return launch_kwargs
79
80
81 def _get_qrcode_utils():
82 from utils.login_qrcode import build_login_qrcode_path
83 from utils.login_qrcode import decode_qrcode_from_path
84 from utils.login_qrcode import print_terminal_qrcode
85 from utils.login_qrcode import remove_qrcode_file
86 from utils.login_qrcode import save_data_url_image
87
88 return {
89 "build_login_qrcode_path": build_login_qrcode_path,
90 "decode_qrcode_from_path": decode_qrcode_from_path,
91 "print_terminal_qrcode": print_terminal_qrcode,
92 "remove_qrcode_file": remove_qrcode_file,
93 "save_data_url_image": save_data_url_image,
94 }
95
96
97 def format_str_for_short_title(origin_title: str) -> str:
98 allowed_special_chars = "《》“”:+?%°"
99 filtered_chars = [char if char.isalnum() or char in allowed_special_chars else " " if char == "," else "" for char in origin_title]
100 formatted_string = "".join(filtered_chars)
101
102 # 视频号「短标题」要求 6~16 个字符/汉字;本项目按 >6 且 <16 从严控制在 7~15。
103 formatted_string = formatted_string.strip()
104 if len(formatted_string) > 15:
105 formatted_string = formatted_string[:15]
106 if len(formatted_string) < 7:
107 # 不足下限时补足到 7;不能用尾部空格(会被平台 trim 掉导致仍不达标)
108 filler = ",精彩内容分享"
109 formatted_string = (formatted_string + filler)[:7] if formatted_string else "精彩视频内容分享"
110
111 return formatted_string
112
113
114 async def cookie_auth(account_file):
115 account_file = _resolve_account_file(account_file)
116 async with async_playwright() as playwright:
117 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
118 try:
119 context = await browser.new_context(storage_state=account_file)
120 context = await set_init_script(context)
121 page = await context.new_page()
122 await page.goto(TENCENT_UPLOAD_URL, wait_until="domcontentloaded")
123
124 # cookie 失效时, 页面先停在 post/create, 随后由前端 JS 跳转到登录页;
125 # 必须等待跳转完成再判断, 否则会误报"cookie 有效"
126 try:
127 await page.wait_for_url("**/login.html**", timeout=8000)
128 tencent_logger.info(_msg("🥹", "cookie 已失效(页面跳转到登录页),得重新登录一下"))
129 return False
130 except Exception:
131 pass # 8 秒内未跳转, 大概率已登录
132
133 # 双保险: 页面里出现微信扫码登录 iframe 也视为失效
134 for fr in page.frames:
135 if "open.weixin.qq.com/connect/qrconnect" in fr.url:
136 tencent_logger.info(_msg("🥹", "cookie 已失效(页面出现扫码登录框),得重新登录一下"))
137 return False
138
139 tencent_logger.success(_msg("🥳", "cookie 有效"))
140 return True
141 except Exception as exc:
142 tencent_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
143 return False
144 finally:
145 await browser.close()
146
147
148 async def _extract_tencent_qrcode_src(page: Page) -> str:
149 if hasattr(page, "frame_locator"):
150 try:
151 iframe_locator = page.frame_locator('[src*="login-for-iframe"]')
152 qr_code_img = iframe_locator.locator('div#app img.qrcode').first
153 await qr_code_img.wait_for(state="visible", timeout=8000)
154 src = await qr_code_img.get_attribute("src")
155 if src and src.startswith("data:image/"):
156 return src
157 except Exception:
158 pass
159
160 # 2026 新版登录页: 二维码在 open.weixin.qq.com/connect/qrconnect 的 iframe 里,
161 # img.qrcode 的 src 是相对路径(如 /connect/qrcode/xxxx), 需要下载后转成 data URL
162 for frame in page.frames:
163 if "open.weixin.qq.com/connect/qrconnect" not in frame.url:
164 continue
165 try:
166 qr_img = frame.locator("img.qrcode").first
167 await qr_img.wait_for(state="attached", timeout=15000)
168 src = None
169 for _ in range(20):
170 src = await qr_img.get_attribute("src")
171 if src:
172 break
173 await page.wait_for_timeout(500)
174 if not src:
175 continue
176 if src.startswith("data:image/"):
177 return src
178 abs_url = urljoin(frame.url, src)
179 resp = await page.context.request.get(abs_url)
180 if resp.ok:
181 body = await resp.body()
182 content_type = resp.headers.get("content-type", "image/png").split(";")[0]
183 return f"data:{content_type};base64,{base64.b64encode(body).decode()}"
184 except Exception:
185 continue
186
187 selector_candidates = [
188 "div.login-qrcode-wrap img.qrcode",
189 "div.qrcode-wrap img.qrcode",
190 "img.qrcode",
191 'img[src^="data:image/"]',
192 ]
193 for selector in selector_candidates:
194 qr_code_img = page.locator(selector).first
195 try:
196 if not await qr_code_img.count() or not await qr_code_img.is_visible():
197 continue
198 src = await qr_code_img.get_attribute("src")
199 if src and src.startswith("data:image/"):
200 return src
201 except Exception:
202 continue
203
204 raise RuntimeError("未获取到视频号登录二维码地址")
205
206
207 async def _save_tencent_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict:
208 qrcode_utils = _get_qrcode_utils()
209 qrcode_src = await _extract_tencent_qrcode_src(page)
210 qrcode_path = qrcode_utils["save_data_url_image"](
211 qrcode_src,
212 qrcode_utils["build_login_qrcode_path"](account_file, suffix="tencent_login_qrcode"),
213 )
214 if previous_qrcode_path and previous_qrcode_path != qrcode_path:
215 if qrcode_utils["remove_qrcode_file"](previous_qrcode_path):
216 tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
217
218 tencent_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
219 qrcode_content = qrcode_utils["decode_qrcode_from_path"](qrcode_path)
220 if qrcode_content:
221 qrcode_utils["print_terminal_qrcode"](qrcode_content, qrcode_path, "微信")
222 else:
223 tencent_logger.warning(
224 _msg(
225 "😵",
226 f"没能从二维码图片里解析出可打印内容,所以这次没法在终端重绘二维码;请直接打开 {qrcode_path} 扫码",
227 )
228 )
229
230 qrcode_info = {
231 "image_path": str(qrcode_path),
232 "image_data_url": qrcode_src,
233 }
234 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
235 return qrcode_info
236
237
238 async def _is_tencent_login_completed(page: Page) -> bool:
239 publish_markers = [
240 page.locator('div:has-text("发表视频")').first,
241 page.locator('button:has-text("发表")').first,
242 page.locator('button:has-text("保存草稿")').first,
243 ]
244 for marker in publish_markers:
245 try:
246 if await marker.count() and await marker.is_visible():
247 return True
248 except Exception:
249 continue
250
251 if not (page.url.startswith(TENCENT_UPLOAD_URL) or page.url.startswith(TENCENT_MANAGE_URL)):
252 return False
253
254 login_markers = [
255 page.locator("div.login-qrcode-wrap").first,
256 page.locator("div.qrcode-wrap").first,
257 page.locator("img.qrcode").first,
258 page.locator('span:has-text("微信扫码登录 视频号助手")').first,
259 ]
260 for marker in login_markers:
261 try:
262 if await marker.count() and await marker.is_visible():
263 return False
264 except Exception:
265 continue
266
267 return True
268
269
270 async def _is_tencent_qrcode_expired(page: Page) -> bool:
271 tip_selectors = [
272 'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
273 'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
274 'p.refresh-tip:has-text("二维码已过期,点击刷新")',
275 'p.refresh-tip:has-text("网络不可用,点击刷新")',
276 ]
277 for selector in tip_selectors:
278 tip = page.locator(selector).first
279 try:
280 if await tip.count() and await tip.is_visible():
281 return True
282 except Exception:
283 continue
284 return False
285
286
287 async def _is_tencent_qrcode_scanned(page: Page) -> bool:
288 scanned_tips = [
289 'div.qr-tip div:has-text("已扫码")',
290 'div.qr-tip div:has-text("需在手机上进行确认")',
291 ]
292 for selector in scanned_tips:
293 tip = page.locator(selector).first
294 try:
295 if await tip.count() and await tip.is_visible():
296 return True
297 except Exception:
298 continue
299 return False
300
301
302 async def _refresh_tencent_qrcode(page: Page) -> None:
303 visible_refresh_selectors = [
304 "div.login-qrcode-wrap div.mask.show div.refresh-wrap",
305 "div.login-qrcode-wrap div.mask.show .refresh-wrap",
306 ]
307 for selector in visible_refresh_selectors:
308 refresh_wrap = page.locator(selector).first
309 try:
310 if not await refresh_wrap.count() or not await refresh_wrap.is_visible():
311 continue
312 await refresh_wrap.click()
313 return
314 except Exception:
315 continue
316
317 tip_selectors = [
318 'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
319 'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
320 'p.refresh-tip:has-text("二维码已过期,点击刷新")',
321 'p.refresh-tip:has-text("网络不可用,点击刷新")',
322 ]
323 for selector in tip_selectors:
324 tip = page.locator(selector).first
325 try:
326 if not await tip.count() or not await tip.is_visible():
327 continue
328 refresh_wrap = tip.locator("xpath=ancestor::div[contains(@class, 'refresh-wrap')]").first
329 if await refresh_wrap.count():
330 await refresh_wrap.click()
331 else:
332 await tip.click()
333 return
334 except Exception:
335 continue
336
337 fallback_refresh = page.locator("div.login-qrcode-wrap div.refresh-wrap").first
338 if await fallback_refresh.count():
339 await fallback_refresh.click()
340 return
341
342 raise RuntimeError("未找到可点击的视频号二维码刷新区域")
343
344
345 async def _wait_for_tencent_login(
346 page: Page,
347 account_file: str,
348 qrcode_info: dict | None,
349 qrcode_callback=None,
350 poll_interval: int = 3,
351 max_checks: int = 100,
352 ) -> dict:
353 qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info else None
354 scanned_logged = False
355 for _ in range(max_checks):
356 if await _is_tencent_login_completed(page):
357 tencent_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}"))
358 return _build_login_result(True, "success", "视频号扫码登录成功", account_file, qrcode_info, page.url)
359
360 if not scanned_logged and await _is_tencent_qrcode_scanned(page):
361 tencent_logger.info(_msg("📱", "已经扫码啦,还差手机端确认一下"))
362 scanned_logged = True
363
364 if await _is_tencent_qrcode_expired(page):
365 tencent_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新"))
366 await _refresh_tencent_qrcode(page)
367 await asyncio.sleep(1)
368 try:
369 qrcode_info = await _save_tencent_qrcode(
370 page,
371 account_file,
372 previous_qrcode_path=qrcode_path,
373 qrcode_callback=qrcode_callback,
374 )
375 qrcode_path = Path(qrcode_info["image_path"])
376 except Exception as exc:
377 tencent_logger.warning(_msg("⚠️", f"刷新后未能重新提取二维码({exc}),请直接在浏览器窗口中扫码"))
378
379 await asyncio.sleep(poll_interval)
380
381 return _build_login_result(False, "timeout", "等待视频号扫码登录超时", account_file, qrcode_info, page.url)
382
383
384 async def tencent_cookie_gen(
385 account_file,
386 qrcode_callback=None,
387 poll_interval: int = 3,
388 max_checks: int = 100,
389 headless: bool = LOCAL_CHROME_HEADLESS,
390 ):
391 account_file = _resolve_account_file(account_file)
392 Path(account_file).parent.mkdir(parents=True, exist_ok=True)
393
394 async with async_playwright() as playwright:
395 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
396 context = await browser.new_context()
397 qrcode_path = None
398 result = _build_login_result(False, "failed", "视频号登录失败", account_file)
399 try:
400 page = await context.new_page()
401 await page.goto(TENCENT_LOGIN_URL)
402 try:
403 qrcode_info = await _save_tencent_qrcode(page, account_file, qrcode_callback=qrcode_callback)
404 qrcode_path = Path(qrcode_info["image_path"])
405 except Exception as exc:
406 tencent_logger.warning(
407 _msg("⚠️", f"提取二维码图片失败({exc}),请直接在弹出的浏览器窗口中扫码,登录流程不受影响")
408 )
409 qrcode_info = None
410 qrcode_path = None
411 tencent_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成"))
412 result = await _wait_for_tencent_login(
413 page,
414 account_file,
415 qrcode_info,
416 qrcode_callback=qrcode_callback,
417 poll_interval=poll_interval,
418 max_checks=max_checks,
419 )
420 if result["success"]:
421 await asyncio.sleep(2)
422 await context.storage_state(path=account_file)
423 if not await cookie_auth(account_file):
424 result = _build_login_result(
425 False,
426 "cookie_invalid",
427 "视频号扫码流程结束,但 cookie 校验失败",
428 account_file,
429 qrcode_info,
430 page.url,
431 )
432 return result
433 except Exception as exc:
434 result = _build_login_result(
435 False,
436 "failed",
437 str(exc),
438 account_file,
439 current_url=page.url if "page" in locals() else "",
440 )
441 return result
442 finally:
443 qrcode_utils = _get_qrcode_utils()
444 if qrcode_utils["remove_qrcode_file"](qrcode_path):
445 tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
446 if not result["success"]:
447 tencent_logger.error(_msg("😢", f"登录失败: {result['message']}"))
448 await context.close()
449 await browser.close()
450
451
452 async def tencent_setup(
453 account_file,
454 handle=False,
455 return_detail=False,
456 qrcode_callback=None,
457 headless: bool = LOCAL_CHROME_HEADLESS,
458 ):
459 account_file = _resolve_account_file(account_file)
460 if not os.path.exists(account_file) or not await cookie_auth(account_file):
461 if not handle:
462 result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
463 return result if return_detail else False
464
465 tencent_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录"))
466 result = await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
467 return result if return_detail else result["success"]
468
469 result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
470 return result if return_detail else True
471
472
473 async def get_tencent_cookie(account_file, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
474 return await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
475
476
477 async def weixin_setup(
478 account_file,
479 handle=False,
480 return_detail=False,
481 qrcode_callback=None,
482 headless: bool = LOCAL_CHROME_HEADLESS,
483 ):
484 return await tencent_setup(
485 account_file,
486 handle=handle,
487 return_detail=return_detail,
488 qrcode_callback=qrcode_callback,
489 headless=headless,
490 )
491
492
493 class TencentBaseUploader(BaseVideoUploader):
494 def __init__(
495 self,
496 publish_date: datetime | int,
497 account_file,
498 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
499 debug: bool = DEBUG_MODE,
500 headless: bool = LOCAL_CHROME_HEADLESS,
501 collection_name: str | None = None,
502 ):
503 self.publish_date = publish_date
504 self.account_file = _resolve_account_file(account_file)
505 self.publish_strategy = publish_strategy
506 self.debug = debug
507 self.headless = headless
508 self.collection_name = collection_name
509 self.local_executable_path = LOCAL_CHROME_PATH
510
511 async def validate_base_args(self):
512 if not os.path.exists(self.account_file):
513 raise RuntimeError(f"cookie文件不存在,请先完成视频号登录: {self.account_file}")
514 if not await cookie_auth(self.account_file):
515 raise RuntimeError(f"cookie文件已失效,请先完成视频号登录: {self.account_file}")
516 if self.publish_strategy not in {TENCENT_PUBLISH_STRATEGY_IMMEDIATE, TENCENT_PUBLISH_STRATEGY_SCHEDULED}:
517 raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
518
519 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED:
520 self.publish_date = self.validate_publish_date(self.publish_date)
521 else:
522 self.publish_date = 0
523
524 async def wait_for_realtime_verification(
525 self,
526 page: Page,
527 qr_path: str | Path | None = None,
528 timeout_seconds: float = 10 * 60,
529 poll_interval_seconds: float = 2,
530 ) -> Path | None:
531 dialog = page.locator("div.weui-desktop-dialog__wrp:visible").filter(has_text="实名验证").first
532 if not await dialog.count() or not await dialog.is_visible():
533 return None
534
535 output_path = Path(qr_path) if qr_path else Path(self.account_file).with_name(
536 f"{Path(self.account_file).stem}_verification_qr.png"
537 )
538 output_path.parent.mkdir(parents=True, exist_ok=True)
539 await dialog.screenshot(path=str(output_path))
540 tencent_logger.warning(_msg("📱", f"需要管理员微信扫码完成实名验证: {output_path}"))
541
542 deadline = asyncio.get_running_loop().time() + timeout_seconds
543 while await dialog.count() and await dialog.is_visible():
544 if asyncio.get_running_loop().time() >= deadline:
545 raise TimeoutError("等待视频号管理员实名验证超时")
546 await asyncio.sleep(poll_interval_seconds)
547
548 tencent_logger.success(_msg("🥳", "管理员实名验证已完成,继续发表"))
549 return output_path
550
551 async def set_schedule_time_tencent(self, page: Page, publish_date: datetime):
552 label_element = page.locator("label").filter(has_text="定时").nth(1)
553 await label_element.click()
554 await page.click('input[placeholder="请选择发表时间"]')
555
556 current_month = publish_date.strftime("%m月")
557 page_month = await page.inner_text('span.weui-desktop-picker__panel__label:has-text("月")')
558 if page_month != current_month:
559 await page.click("button.weui-desktop-btn__icon__right")
560
561 elements = await page.query_selector_all("table.weui-desktop-picker__table a")
562 for element in elements:
563 if "weui-desktop-picker__disabled" in await element.evaluate("el => el.className"):
564 continue
565 text = await element.inner_text()
566 if text.strip() == str(publish_date.day):
567 await element.click()
568 break
569
570 await page.click('input[placeholder="请选择时间"]')
571 await page.keyboard.press("Control+KeyA")
572 await page.keyboard.type(publish_date.strftime("%H"))
573 await page.keyboard.press("Enter") # 确认小时并关闭时间下拉
574 await page.wait_for_timeout(500)
575 # 收起时间选择浮层:直接点描述区可能被 weui-desktop-dialog 遮挡,做容错
576 try:
577 await page.locator("div.input-editor").click(timeout=5000)
578 except Exception:
579 await page.keyboard.press("Escape")
580
581 async def open_upload_page(self, page: Page) -> None:
582 # 视频号已改版:直接全页加载 /platform/post/create 会被跳回 /platform 首页,
583 # 发布表单 iframe 只加载空壳(Vue 不挂载),页面上没有任何 input[type=file]。
584 # 正确入口:先进首页,再点可见的「发表视频」按钮做客户端跳转,表单才会真正挂载。
585 await page.goto(TENCENT_HOME_URL, timeout=120000, wait_until="domcontentloaded")
586 # cookie 失效时前端 JS 会跳转到登录页, 提前发现并报明确的错误
587 try:
588 await page.wait_for_url("**/login.html**", timeout=8000)
589 raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
590 except TimeoutError:
591 pass # 8 秒内未跳转, 正常
592 except RuntimeError:
593 raise
594 except Exception:
595 pass
596 if any("open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames):
597 raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
598 try:
599 await page.wait_for_load_state("networkidle", timeout=15000)
600 except Exception:
601 pass
602 # 注意:get_by_text("发表视频") 会命中一个隐藏的说明 <p>(不可点);
603 # 首页真正可点的入口是 button.weui-desktop-btn。
604 publish_entry = page.locator("button.weui-desktop-btn", has_text="发表视频").first
605 try:
606 await publish_entry.wait_for(state="visible", timeout=30000)
607 await publish_entry.click()
608 except Exception:
609 # 兜底:按钮没点到时退回老逻辑直接跳转(可能仍是空壳,但保持向后兼容)
610 await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
611 try:
612 await page.wait_for_url("**/platform/post/create", timeout=120000)
613 except Exception:
614 pass
615
616 # 上传表单在 micro/content/post/create 这个 iframe 里,domcontentloaded 时它还是空的。
617 # 不等网络静默就去找 input[type=file],会误报「未找到视频号文件上传框」——
618 # 失败截图上左栏渲染正常、主内容区一片空白,看起来完全不像加载没完成。
619 try:
620 await page.wait_for_load_state("networkidle", timeout=30000)
621 except Exception:
622 pass # 静默不了就算了,下面还有重试兜底
623
624 async def upload_video_file(self, page: Page, file_path: str) -> None:
625 async def find_file_input():
626 for fr in page.frames: # 主 frame + 所有 iframe(视频号编辑器可能在 iframe 内)
627 try:
628 fi = fr.locator('input[type="file"]')
629 if await fi.count():
630 return fi.first
631 except Exception:
632 continue
633 return None
634
635 fi = await find_file_input()
636 clicked_publish = False
637 for _ in range(60):
638 if fi is not None:
639 break
640 if not clicked_publish:
641 # 新版视频号助手可能先落在首页,且「发表视频」按钮异步出现。
642 # 持续轮询所有可访问 button;Patchright 在当前页面上按名称精确匹配不稳定。
643 try:
644 publish_buttons = await page.get_by_role("button").all()
645 except Exception:
646 publish_buttons = []
647 for candidate in publish_buttons:
648 try:
649 button_text = (await candidate.inner_text()).strip()
650 is_visible = await candidate.is_visible()
651 except Exception:
652 continue
653 if "发表视频" in button_text and is_visible:
654 await candidate.click(force=True)
655 clicked_publish = True
656 break
657 fi = await find_file_input()
658 if fi is None:
659 await asyncio.sleep(1)
660 if fi is None:
661 # 留现场:这个错误的可能原因太多(没登录 / 落到首页 / iframe 没加载完 /
662 # 平台改版),只看错误字符串没法区分,截图能一眼看出是哪种。
663 try:
664 shot = Path(BASE_DIR) / "debug_tencent_no_file_input.png"
665 await page.screenshot(path=str(shot), full_page=True)
666 tencent_logger.info(_msg(
667 "📸",
668 f"失败现场已截图 {shot}; url={page.url}; "
669 f"frames={[fr.url[:80] for fr in page.frames]}",
670 ))
671 except Exception:
672 pass
673 raise RuntimeError("未找到视频号文件上传框")
674 await fi.set_input_files(file_path)
675
676 async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
677 # 视频号「短标题」即界面上要求填写的“标题”(那个大编辑区其实是“视频描述”)。
678 # 走 format_str_for_short_title 保证长度落在 7~15,避免发布时被校验拦下。
679 value = format_str_for_short_title(short_title or title)
680 # 优先用 placeholder 定位(已 dump 验证更稳),兜底旧的“短标题”相邻 input。
681 field = page.locator('input[placeholder="填写短标题有机会获得更多流量"]').first
682 if not await field.count():
683 field = (
684 page.get_by_text("短标题", exact=True)
685 .locator("..")
686 .locator("xpath=following-sibling::div")
687 .locator('span input[type="text"]')
688 )
689 if await field.count():
690 await field.fill(value)
691 tencent_logger.info(_msg("🏷️", f"短标题已填写({len(value)}字):{value}"))
692 else:
693 tencent_logger.info(_msg("🧾", "未找到短标题输入框,跳过短标题"))
694
695 async def _dismiss_switch_account_dialog(self, page: Page) -> None:
696 # 视频号上传后偶发弹出「切换视频号」对话框(.changeAccount-dialog / .common-dialog)遮挡发布表单。
697 # 它带「取消」按钮、非强制,点「取消」/ 右上角 × / Esc 跳过即可,用当前账号继续发布。
698 cancel = page.locator('.changeAccount-dialog button:has-text("取消")').first
699 closeb = page.locator('.changeAccount-dialog .weui-desktop-dialog__close-btn').first
700 for cand in (cancel, closeb):
701 try:
702 if await cand.count() and await cand.is_visible():
703 await cand.click(timeout=2000)
704 await page.wait_for_timeout(600)
705 return
706 except Exception:
707 continue
708 try:
709 await page.keyboard.press("Escape")
710 await page.wait_for_timeout(600)
711 except Exception:
712 pass
713
714 async def fill_title_and_tags(self, page: Page) -> None:
715 # 上传后偶发「切换视频号」弹窗遮挡描述框,点不动就关弹窗重试(以能点中描述框为成功标志)。
716 for _ in range(4):
717 try:
718 await page.locator("div.input-editor").click(timeout=5000)
719 break
720 except Exception:
721 await self._dismiss_switch_account_dialog(page)
722 await page.wait_for_timeout(500)
723 else:
724 await page.locator("div.input-editor").click(timeout=8000)
725 await page.keyboard.type(self.title)
726 await page.keyboard.press("Enter")
727 for tag in self.tags:
728 await page.keyboard.type("#" + tag)
729 await page.keyboard.press("Space")
730 tencent_logger.info(_msg("🏷️", f"成功添加 hashtag: {len(self.tags)}"))
731
732 async def fill_description(self, page: Page) -> None:
733 await page.keyboard.press("Enter")
734 await page.keyboard.type(self.desc)
735 tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}"))
736
737 async def apply_collection(self, page: Page) -> None:
738 """在发布表单页"添加到合集"下拉框按合集名精确选中(页面结构:option-item > .item > .name/.desc)。
739
740 找不到匹配名字的合集时不展开/不选(保持未选状态直接发布,界面允许留空,
741 不阻断主发布流程)。旧实现是"下拉项数>1就选第一项",等价于随机选,已改为精确匹配。
742 """
743 if not self.collection_name:
744 return
745 try:
746 trigger = page.get_by_text("添加到合集").first
747 if await trigger.count() == 0:
748 tencent_logger.info(_msg("🧾", "当前页面未发现「添加到合集」入口,跳过归集"))
749 return
750 dropdown = trigger.locator("xpath=following-sibling::div").first
751 await dropdown.click(timeout=8000)
752 await page.wait_for_timeout(800)
753
754 option = dropdown.locator(".option-list-wrap .option-item").filter(
755 has=page.locator(f'.name:text-is("{self.collection_name}")')
756 )
757 if await option.count() == 0:
758 tencent_logger.warning(
759 _msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态")
760 )
761 await page.keyboard.press("Escape")
762 await page.wait_for_timeout(300)
763 return
764
765 # headless 下 option 常报 "element is not visible":下拉列表开在视口外/
766 # 在 .option-list-wrap 滚动容器内,headful(大窗口)时在视野里能直接点中,
767 # headless 默认视口小就点不中。先把目标 option 滚进视野再点;普通 click
768 # 仍被判不可见时 → force 点击(跳过可见性 actionability)→ 派发原生 click 兜底。
769 target = option.first
770 try:
771 await target.scroll_into_view_if_needed(timeout=3000)
772 except Exception:
773 pass
774 try:
775 await target.click(timeout=4000)
776 except Exception:
777 try:
778 await target.click(force=True, timeout=4000)
779 except Exception:
780 await target.dispatch_event("click")
781 await page.wait_for_timeout(500)
782 tencent_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
783 except Exception as exc:
784 tencent_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}"))
785 try:
786 await page.keyboard.press("Escape")
787 except Exception:
788 pass
789
790 async def apply_original_statement(self, page: Page) -> None:
791 # 视频号「视频标注」下拉:本项目成片经 AI 处理(TTS 配音、AI 字幕、AI 前贴片),
792 # 依平台合规要求如实选「含AI生成内容」(与「内容为转载」等并列,选定即可、无需填写来源)。
793 # 注意:这与上方独立的「声明原创」复选框是两个不同字段,本项目走 AI 标注、不勾原创声明。
794 label_text = getattr(self, "content_label", None) or "含AI生成内容"
795 try:
796 entry = page.get_by_text("选择视频标注", exact=True).first
797 if not await entry.count():
798 tencent_logger.info(_msg("🧾", "当前页面未发现「视频标注」入口,跳过标注继续发布"))
799 return
800 await entry.click()
801 await page.wait_for_timeout(800)
802 option = page.get_by_text(label_text, exact=True).first
803 await option.wait_for(state="visible", timeout=5000)
804 await option.click()
805 await page.wait_for_timeout(500)
806 tencent_logger.success(_msg("🏷️", f"视频标注已选择:{label_text}"))
807 except Exception as exc:
808 tencent_logger.warning(_msg("😵", f"设置视频标注「{label_text}」失败,跳过继续发布:{exc}"))
809
810 async def wait_for_upload_complete(
811 self, page: Page, timeout_seconds: int = 3600, max_retries: int = 3
812 ) -> None:
813 """等上传完成。
814
815 **必须有个头,而且重试必须有上限。** 原来是没有出口的 while True:上传出错就
816 删掉重传,失败再删再传,永远循环;中间每 2 秒打一行「正在上传视频中...」——
817 这条日志和真的在传一模一样,从外面完全分不出。
818
819 实测(2026-08-11,172MB / 上行 ~0.5Mbps):每次传到 4 分钟左右报错,然后重来,
820 整整循环了近 2 小时也不会停,进程也不会退。用户看到的只有「正在上传视频中」,
821 真相是同一段视频被反复上传了 20 多次。
822
823 默认 1 小时 / 3 次重试:慢网络上大文件确实会传很久,上限要给够;
824 但到点、或者重试用完,就带现场截图明确报错,不要静默地转下去。
825 """
826 deadline = time.monotonic() + timeout_seconds
827 last_report = 0.0
828 retries = 0
829 while True:
830 if time.monotonic() > deadline:
831 try:
832 shot = Path(BASE_DIR) / "debug_tencent_upload_timeout.png"
833 await page.screenshot(path=str(shot), full_page=True)
834 tencent_logger.error(_msg("📸", f"上传超时现场已截图 {shot}"))
835 except Exception:
836 pass
837 raise RuntimeError(
838 f"视频号上传超过 {timeout_seconds} 秒仍未完成(「发表」按钮一直不可用)"
839 )
840 try:
841 publish_button = page.locator('div.form-btns button:has-text("发表"):visible').first
842 if await publish_button.count():
843 button_class = await publish_button.get_attribute("class")
844 if (
845 not await publish_button.is_disabled()
846 and (not button_class or "weui-desktop-btn_disabled" not in button_class)
847 ):
848 tencent_logger.info(_msg("🥳", "视频上传完毕"))
849 break
850
851 # 每 2 秒刷一行同样的话没有信息量,只是把日志冲爆(实测 50 分钟刷了 1600 行)。
852 # 30 秒一行,并且带上已等多久——「还要多久」是这里唯一有用的信息。
853 now = time.monotonic()
854 if now - last_report >= 30:
855 waited = int(timeout_seconds - (deadline - now))
856 tencent_logger.info(_msg("🏃", f"正在上传视频中...(已等 {waited} 秒)"))
857 last_report = now
858 await asyncio.sleep(2)
859
860 upload_failed = await page.locator("div.status-msg.error").count()
861 delete_button = await page.locator('div.media-status-content div.tag-inner:has-text("删除")').count()
862 if upload_failed and delete_button:
863 retries += 1
864 if retries > max_retries:
865 try:
866 shot = Path(BASE_DIR) / "debug_tencent_upload_failed.png"
867 await page.screenshot(path=str(shot), full_page=True)
868 tencent_logger.error(_msg("📸", f"上传反复失败,现场已截图 {shot}"))
869 except Exception:
870 pass
871 raise RuntimeError(
872 f"视频号上传连续失败 {max_retries} 次,已停止重试"
873 "(常见原因:文件过大、上行带宽太慢导致平台侧超时,或走了代理/VPN)"
874 )
875 tencent_logger.error(_msg("😵", f"发现上传出错了,准备重试(第 {retries}/{max_retries} 次)"))
876 await self.handle_upload_error(page)
877 except RuntimeError:
878 raise
879 except Exception:
880 await asyncio.sleep(2)
881
882 async def submit_publish(self, page: Page) -> None:
883 is_draft = getattr(self, "is_draft", False)
884 # 先等待并清理遮罩/弹窗,再等发表按钮出现
885 for wait_round in range(60):
886 await self._dismiss_switch_account_dialog(page)
887 try:
888 await page.evaluate("""() => document.querySelectorAll('.mask, .changeAccount-dialog, .common-dialog').forEach(e => e.remove())""")
889 except Exception:
890 pass
891 publish_btn = page.get_by_role("button", name="发表", exact=True).first if not is_draft else page.get_by_role("button", name="保存草稿").first
892 try:
893 if await publish_btn.count() and await publish_btn.is_visible():
894 break
895 except Exception:
896 pass
897 await asyncio.sleep(1)
898 else:
899 tencent_logger.warning(_msg("😵", "60s 内未找到可见的发表/草稿按钮,尝试强制继续"))
900 # 点发表/草稿
901 for attempt in range(20):
902 try:
903 if await publish_btn.count():
904 try:
905 await publish_btn.click(timeout=4000)
906 except Exception:
907 await publish_btn.evaluate("el => el.click()")
908 if is_draft:
909 await page.wait_for_url("**/post/list**", timeout=5000)
910 tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
911 else:
912 # 发表成功后视频号可能跳 /platform(首页)、/post/list 或留在 create 页但按钮消失。
913 # 综合判断:URL 离开 /post/create 或 发表按钮不再存在。
914 for _ in range(10):
915 await asyncio.sleep(1)
916 cur = page.url
917 if "/post/create" not in cur:
918 tencent_logger.success(_msg("🥳", "视频发布成功"))
919 return
920 if not await publish_btn.count():
921 tencent_logger.success(_msg("🥳", "视频发布成功(按钮已消失)"))
922 return
923 raise Exception("发表后 10s 页面未变化")
924 return
925 except Exception as exc:
926 current_url = page.url
927 if is_draft and ("post/list" in current_url or "draft" in current_url):
928 tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
929 return
930 if (not is_draft) and "/post/create" not in current_url:
931 tencent_logger.success(_msg("🥳", "视频发布成功"))
932 return
933 if attempt and attempt % 5 == 0:
934 tencent_logger.warning(_msg("😵", f"发布仍未完成(第{attempt}次),异常: {str(exc)[:60]}"))
935 tencent_logger.info(_msg("🏃", "视频正在发布中..."))
936 await asyncio.sleep(1)
937 raise RuntimeError("发布未在预期时间内完成,请检查发布页面")
938
939
940 class TencentVideo(TencentBaseUploader):
941 def __init__(
942 self,
943 title,
944 file_path,
945 tags,
946 publish_date: datetime | int,
947 account_file,
948 category=None,
949 is_draft=False,
950 desc: str | None = None,
951 thumbnail_path: str | None = None,
952 thumbnail_landscape_path: str | None = None,
953 thumbnail_portrait_path: str | None = None,
954 short_title: str | None = None,
955 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
956 debug: bool = DEBUG_MODE,
957 headless: bool = LOCAL_CHROME_HEADLESS,
958 collection_name: str | None = None,
959 ):
960 super().__init__(
961 publish_date=publish_date,
962 account_file=account_file,
963 publish_strategy=publish_strategy,
964 debug=debug,
965 headless=headless,
966 collection_name=collection_name,
967 )
968 self.title = title
969 self.file_path = file_path
970 self.tags = tags or []
971 self.category = category
972 self.is_draft = is_draft
973 self.desc = desc or ""
974 self.thumbnail_path = thumbnail_path
975 self.thumbnail_landscape_path = thumbnail_landscape_path
976 self.thumbnail_portrait_path = thumbnail_portrait_path or thumbnail_path
977 self.short_title = short_title
978
979 async def validate_upload_args(self):
980 await self.validate_base_args()
981 if not self.title or not str(self.title).strip():
982 raise ValueError("视频模式下,title 是必须的")
983 self.file_path = str(self.validate_video_file(self.file_path))
984 if self.thumbnail_landscape_path:
985 self.thumbnail_landscape_path = str(self.validate_image_file(self.thumbnail_landscape_path))
986 if self.thumbnail_portrait_path:
987 self.thumbnail_portrait_path = str(self.validate_image_file(self.thumbnail_portrait_path))
988
989 async def handle_upload_error(self, page: Page) -> None:
990 tencent_logger.info(_msg("😵", "视频出错了,重新上传中"))
991 await page.locator('div.media-status-content div.tag-inner:has-text("删除")').click()
992 await page.get_by_role("button", name="删除", exact=True).click()
993 await self.upload_video_file(page, self.file_path)
994
995 async def open_thumbnail_dialog(self, page: Page, selectors: list[str], dialog_titles: list[str]):
996 for selector in selectors:
997 cover_entry = page.locator(selector).first
998 try:
999 if not await cover_entry.count():
1000 continue
1001 await cover_entry.wait_for(state="visible", timeout=3000)
1002 await cover_entry.click()
1003 await page.wait_for_timeout(500)
1004 break
1005 except Exception:
1006 continue
1007
1008 for title in dialog_titles:
1009 cover_dialog = page.locator("div.weui-desktop-dialog").filter(has_text=title).first
1010 if await cover_dialog.count():
1011 return cover_dialog
1012 return None
1013
1014 async def confirm_thumbnail_crop(self, page: Page) -> None:
1015 crop_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="裁剪封面图").first
1016 if not await crop_dialog.count():
1017 return
1018
1019 try:
1020 await crop_dialog.wait_for(state="visible", timeout=10000)
1021 crop_confirm_button = crop_dialog.locator(
1022 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确定")'
1023 ).first
1024 if await crop_confirm_button.count():
1025 await crop_confirm_button.wait_for(state="visible", timeout=5000)
1026 await crop_confirm_button.click()
1027 await page.wait_for_timeout(1000)
1028 except Exception as exc:
1029 tencent_logger.warning(_msg("😵", f"封面裁剪确认时出错,小人继续尝试保存主弹窗: {exc}"))
1030
1031 async def upload_thumbnail_in_dialog(self, page: Page, cover_dialog, thumbnail_path: str) -> None:
1032 await cover_dialog.wait_for(state="visible", timeout=5000)
1033 file_input = cover_dialog.locator('.single-cover-uploader-wrap input[type="file"]').first
1034 await file_input.wait_for(state="attached", timeout=10000)
1035 await file_input.set_input_files(thumbnail_path)
1036 await page.wait_for_timeout(2000)
1037
1038 confirm_button = cover_dialog.locator(
1039 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")'
1040 ).first
1041 await confirm_button.wait_for(state="visible", timeout=10000)
1042 await confirm_button.click()
1043
1044 async def set_single_thumbnail(
1045 self,
1046 page: Page,
1047 thumbnail_path: str,
1048 selectors: list[str],
1049 dialog_titles: list[str],
1050 label: str,
1051 ) -> None:
1052 cover_dialog = await self.open_thumbnail_dialog(page, selectors, dialog_titles)
1053 if not cover_dialog:
1054 tencent_logger.info(_msg("🧍", f"当前页面没有出现{label}封面编辑弹窗,小人先跳过"))
1055 return
1056
1057 try:
1058 await self.upload_thumbnail_in_dialog(page, cover_dialog, thumbnail_path)
1059 tencent_logger.success(_msg("🥳", f"{label}封面已经设置完成"))
1060 except Exception as exc:
1061 tencent_logger.warning(_msg("😵", f"{label}封面设置失败,这次先跳过: {exc}"))
1062
1063 async def set_thumbnail(self, page: Page) -> None:
1064 if not self.thumbnail_landscape_path and not self.thumbnail_portrait_path:
1065 return
1066
1067 tencent_logger.info(_msg("🖼️", "小人准备设置封面"))
1068
1069 landscape_selectors = [
1070 'div.horizontal-cover-wrap:has-text("4:3")',
1071 'div[class*="cover-wrap"]:has-text("4:3"):has-text("动态")',
1072 'div:has-text("视频号动态"):has-text("4:3")',
1073 'div:has-text("横版封面"):has-text("4:3")',
1074 ]
1075 portrait_selectors = [
1076 'div.vertical-cover-wrap:has-text("个人主页卡片"):has-text("3:4")',
1077 'div.vertical-cover-wrap:has-text("3:4")',
1078 'div.vertical-cover-wrap:has-text("个人主页卡片")',
1079 ]
1080
1081 if self.thumbnail_landscape_path:
1082 await self.set_single_thumbnail(
1083 page,
1084 self.thumbnail_landscape_path,
1085 landscape_selectors,
1086 ["编辑视频号动态封面", "编辑动态封面", "编辑封面"],
1087 "4:3 横版",
1088 )
1089 if self.thumbnail_portrait_path:
1090 await self.set_single_thumbnail(
1091 page,
1092 self.thumbnail_portrait_path,
1093 portrait_selectors,
1094 ["编辑个人主页卡片", "编辑封面"],
1095 "3:4 竖版",
1096 )
1097
1098 async def prepare_video_for_publish(self, page: Page) -> None:
1099 await self.wait_for_realtime_verification(page)
1100 await self.fill_title_and_tags(page)
1101 await self.fill_description(page)
1102 # 合集不在这里选:此时视频还在上传,上传完成后表单会刷新,
1103 # 上传中选的合集会被重置/不绑定("日志说选了、后台没加"的根因)。
1104 # 改到 wait_for_upload_complete 之后再选,见 upload()。
1105
1106 async def upload(self, playwright: Playwright) -> None:
1107 tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间"))
1108 await self.validate_upload_args()
1109 tencent_logger.info(_msg("🥳", "上传前检查通过"))
1110
1111 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
1112 context = await browser.new_context(storage_state=self.account_file)
1113
1114 try:
1115 page = await context.new_page()
1116 await self.open_upload_page(page)
1117 tencent_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}"))
1118
1119 await self.upload_video_file(page, self.file_path)
1120 await self.prepare_video_for_publish(page)
1121 await self.wait_for_upload_complete(page)
1122 # 上传完成、表单稳定后再选合集(否则上传中选的会被重置)
1123 await self.apply_collection(page)
1124 await self.apply_original_statement(page)
1125 await self.set_thumbnail(page)
1126
1127 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
1128 await self.set_schedule_time_tencent(page, self.publish_date)
1129
1130 await self.set_short_title(page, self.title, self.short_title)
1131 await self.submit_publish(page)
1132
1133 await context.storage_state(path=self.account_file)
1134 tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
1135 finally:
1136 await context.close()
1137 await browser.close()
1138
1139 async def tencent_upload_video(self):
1140 async with async_playwright() as playwright:
1141 await self.upload(playwright)
1142
1143 async def main(self):
1144 await self.tencent_upload_video()
1145
1146
1147 class TencentNote(TencentBaseUploader):
1148 def __init__(
1149 self,
1150 image_paths,
1151 note,
1152 tags,
1153 publish_date: datetime | int,
1154 account_file,
1155 title: str | None = None,
1156 publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
1157 debug: bool = DEBUG_MODE,
1158 headless: bool = LOCAL_CHROME_HEADLESS,
1159 is_draft: bool = False,
1160 ):
1161 super().__init__(
1162 publish_date=publish_date,
1163 account_file=account_file,
1164 publish_strategy=publish_strategy,
1165 debug=debug,
1166 headless=headless,
1167 )
1168 self.image_paths = image_paths
1169 self.note = note or ""
1170 self.title = title or (self.note[:30] if self.note else "")
1171 self.tags = tags or []
1172 self.is_draft = is_draft
1173
1174 async def validate_upload_args(self):
1175 await self.validate_base_args()
1176 if not self.title or not str(self.title).strip():
1177 raise ValueError("图文模式下,title 是必须的")
1178 if not self.image_paths:
1179 raise ValueError("图文模式下,图片是必须的")
1180
1181 if isinstance(self.image_paths, (str, Path)):
1182 self.image_paths = [self.image_paths]
1183
1184 normalized_image_paths = []
1185 for image_path in self.image_paths:
1186 normalized_image_paths.append(str(self.validate_image_file(image_path)))
1187 self.image_paths = normalized_image_paths
1188
1189 async def switch_to_note_mode(self, page: Page) -> None:
1190 raise NotImplementedError("请在 TencentNote.switch_to_note_mode 中补充视频号切换到图文发布模式的逻辑")
1191
1192 async def upload_note_images(self, page: Page) -> None:
1193 raise NotImplementedError("请在 TencentNote.upload_note_images 中补充视频号图文图片上传逻辑")
1194
1195 async def fill_note_title_and_tags(self, page: Page) -> None:
1196 raise NotImplementedError("请在 TencentNote.fill_note_title_and_tags 中补充视频号图文标题/话题填写逻辑")
1197
1198 async def fill_note_body(self, page: Page) -> None:
1199 return None
1200
1201 async def prepare_note_for_publish(self, page: Page) -> None:
1202 await self.fill_note_title_and_tags(page)
1203 await self.fill_note_body(page)
1204 await self.apply_collection(page)
1205 await self.apply_original_statement(page)
1206
1207 async def upload_note_content(self, page: Page) -> None:
1208 await self.switch_to_note_mode(page)
1209 await self.upload_note_images(page)
1210 await self.prepare_note_for_publish(page)
1211
1212 async def upload(self, playwright: Playwright) -> None:
1213 tencent_logger.info(_msg("🧍", "小人先检查 cookie、图文图片和发布时间"))
1214 await self.validate_upload_args()
1215 tencent_logger.info(_msg("🥳", "图文上传前检查通过"))
1216
1217 browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
1218 context = await browser.new_context(storage_state=self.account_file)
1219 context = await set_init_script(context)
1220
1221 try:
1222 page = await context.new_page()
1223 await self.open_upload_page(page)
1224 tencent_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
1225
1226 await self.upload_note_content(page)
1227
1228 if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
1229 await self.set_schedule_time_tencent(page, self.publish_date)
1230
1231 await self.submit_publish(page)
1232
1233 await context.storage_state(path=self.account_file)
1234 tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
1235 finally:
1236 await context.close()
1237 await browser.close()
1238
1239 async def tencent_upload_note(self):
1240 async with async_playwright() as playwright:
1241 await self.upload(playwright)
1242
1243 async def main(self):
1244 await self.tencent_upload_note()
1245
1245 lines PYTHON