The WeChat Channels login page now embeds the QR code in an open.weixin.qq.com/connect/qrconnect iframe whose img.qrcode src is a relative path (e.g. /connect/qrcode/xxxx) instead of an inline data:image/ URL. The previous logic looked for a login-for-iframe frame and only accepted data:image/ sources, so login always failed with '未获取到视频号登录二维码地址'. Changes: - Detect the qrconnect iframe and resolve the relative img src, download the QR image via the browser context and convert it to a data URL so downstream save/decode logic stays unchanged - Keep the legacy login-for-iframe and selector fallbacks - Tolerate QR extraction failure: the login flow now continues and lets the user scan directly in the headed browser instead of aborting the whole login
1057 lines
43 KiB
Python
1057 lines
43 KiB
Python
# -*- coding: utf-8 -*-
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import inspect
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import urljoin
|
|
|
|
from patchright.async_api import Page
|
|
from patchright.async_api import Playwright
|
|
from patchright.async_api import async_playwright
|
|
|
|
from conf import BASE_DIR, DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
|
|
from uploader.base_video import BaseVideoUploader
|
|
from utils.base_social_media import set_init_script
|
|
from utils.log import tencent_logger
|
|
|
|
TENCENT_LOGIN_URL = "https://channels.weixin.qq.com"
|
|
TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create"
|
|
TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list"
|
|
TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
|
|
TENCENT_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
|
|
|
|
|
|
def _msg(emoji: str, text: str) -> str:
|
|
return f"{emoji} {text}"
|
|
|
|
|
|
def _resolve_account_file(account_file: str | Path) -> str:
|
|
path = Path(account_file).expanduser()
|
|
if path.is_absolute():
|
|
return str(path)
|
|
|
|
if len(path.parts) == 1:
|
|
return str((Path(BASE_DIR) / "cookies" / "tencent_uploader" / path).resolve())
|
|
|
|
return str(path.resolve())
|
|
|
|
|
|
async def _emit_qrcode_callback(qrcode_callback, payload: dict):
|
|
if not qrcode_callback:
|
|
return
|
|
|
|
callback_result = qrcode_callback(payload)
|
|
if inspect.isawaitable(callback_result):
|
|
await callback_result
|
|
|
|
|
|
def _build_login_result(
|
|
success: bool,
|
|
status: str,
|
|
message: str,
|
|
account_file: str,
|
|
qrcode: dict | None = None,
|
|
current_url: str = "",
|
|
) -> dict:
|
|
return {
|
|
"success": success,
|
|
"status": status,
|
|
"message": message,
|
|
"account_file": str(account_file),
|
|
"qrcode": qrcode,
|
|
"current_url": current_url,
|
|
}
|
|
|
|
|
|
def _build_launch_kwargs(headless: bool) -> dict:
|
|
launch_kwargs = {"headless": headless}
|
|
if LOCAL_CHROME_PATH:
|
|
launch_kwargs["executable_path"] = LOCAL_CHROME_PATH
|
|
else:
|
|
launch_kwargs["channel"] = "chrome"
|
|
return launch_kwargs
|
|
|
|
|
|
def _get_qrcode_utils():
|
|
from utils.login_qrcode import build_login_qrcode_path
|
|
from utils.login_qrcode import decode_qrcode_from_path
|
|
from utils.login_qrcode import print_terminal_qrcode
|
|
from utils.login_qrcode import remove_qrcode_file
|
|
from utils.login_qrcode import save_data_url_image
|
|
|
|
return {
|
|
"build_login_qrcode_path": build_login_qrcode_path,
|
|
"decode_qrcode_from_path": decode_qrcode_from_path,
|
|
"print_terminal_qrcode": print_terminal_qrcode,
|
|
"remove_qrcode_file": remove_qrcode_file,
|
|
"save_data_url_image": save_data_url_image,
|
|
}
|
|
|
|
|
|
def format_str_for_short_title(origin_title: str) -> str:
|
|
allowed_special_chars = "《》“”:+?%°"
|
|
filtered_chars = [char if char.isalnum() or char in allowed_special_chars else " " if char == "," else "" for char in origin_title]
|
|
formatted_string = "".join(filtered_chars)
|
|
|
|
if len(formatted_string) > 16:
|
|
formatted_string = formatted_string[:16]
|
|
elif len(formatted_string) < 6:
|
|
formatted_string += " " * (6 - len(formatted_string))
|
|
|
|
return formatted_string
|
|
|
|
|
|
async def cookie_auth(account_file):
|
|
account_file = _resolve_account_file(account_file)
|
|
async with async_playwright() as playwright:
|
|
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=True))
|
|
try:
|
|
context = await browser.new_context(storage_state=account_file)
|
|
context = await set_init_script(context)
|
|
page = await context.new_page()
|
|
await page.goto(TENCENT_UPLOAD_URL)
|
|
await page.wait_for_url(TENCENT_UPLOAD_URL, timeout=5000)
|
|
|
|
login_markers = [
|
|
page.get_by_text("扫码登录", exact=True).first,
|
|
page.get_by_text("发表视频", exact=True).first,
|
|
page.get_by_role("button", name="发表").first,
|
|
]
|
|
|
|
if await login_markers[0].count():
|
|
tencent_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下"))
|
|
return False
|
|
|
|
tencent_logger.success(_msg("🥳", "cookie 有效"))
|
|
return True
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
|
|
return False
|
|
finally:
|
|
await browser.close()
|
|
|
|
|
|
async def _extract_tencent_qrcode_src(page: Page) -> str:
|
|
if hasattr(page, "frame_locator"):
|
|
try:
|
|
iframe_locator = page.frame_locator('[src*="login-for-iframe"]')
|
|
qr_code_img = iframe_locator.locator('div#app img.qrcode').first
|
|
await qr_code_img.wait_for(state="visible", timeout=8000)
|
|
src = await qr_code_img.get_attribute("src")
|
|
if src and src.startswith("data:image/"):
|
|
return src
|
|
except Exception:
|
|
pass
|
|
|
|
# 2026 新版登录页: 二维码在 open.weixin.qq.com/connect/qrconnect 的 iframe 里,
|
|
# img.qrcode 的 src 是相对路径(如 /connect/qrcode/xxxx), 需要下载后转成 data URL
|
|
for frame in page.frames:
|
|
if "open.weixin.qq.com/connect/qrconnect" not in frame.url:
|
|
continue
|
|
try:
|
|
qr_img = frame.locator("img.qrcode").first
|
|
await qr_img.wait_for(state="attached", timeout=15000)
|
|
src = None
|
|
for _ in range(20):
|
|
src = await qr_img.get_attribute("src")
|
|
if src:
|
|
break
|
|
await page.wait_for_timeout(500)
|
|
if not src:
|
|
continue
|
|
if src.startswith("data:image/"):
|
|
return src
|
|
abs_url = urljoin(frame.url, src)
|
|
resp = await page.context.request.get(abs_url)
|
|
if resp.ok:
|
|
body = await resp.body()
|
|
content_type = resp.headers.get("content-type", "image/png").split(";")[0]
|
|
return f"data:{content_type};base64,{base64.b64encode(body).decode()}"
|
|
except Exception:
|
|
continue
|
|
|
|
selector_candidates = [
|
|
"div.login-qrcode-wrap img.qrcode",
|
|
"div.qrcode-wrap img.qrcode",
|
|
"img.qrcode",
|
|
'img[src^="data:image/"]',
|
|
]
|
|
for selector in selector_candidates:
|
|
qr_code_img = page.locator(selector).first
|
|
try:
|
|
if not await qr_code_img.count() or not await qr_code_img.is_visible():
|
|
continue
|
|
src = await qr_code_img.get_attribute("src")
|
|
if src and src.startswith("data:image/"):
|
|
return src
|
|
except Exception:
|
|
continue
|
|
|
|
raise RuntimeError("未获取到视频号登录二维码地址")
|
|
|
|
|
|
async def _save_tencent_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict:
|
|
qrcode_utils = _get_qrcode_utils()
|
|
qrcode_src = await _extract_tencent_qrcode_src(page)
|
|
qrcode_path = qrcode_utils["save_data_url_image"](
|
|
qrcode_src,
|
|
qrcode_utils["build_login_qrcode_path"](account_file, suffix="tencent_login_qrcode"),
|
|
)
|
|
if previous_qrcode_path and previous_qrcode_path != qrcode_path:
|
|
if qrcode_utils["remove_qrcode_file"](previous_qrcode_path):
|
|
tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
|
|
|
|
tencent_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
|
|
qrcode_content = qrcode_utils["decode_qrcode_from_path"](qrcode_path)
|
|
if qrcode_content:
|
|
qrcode_utils["print_terminal_qrcode"](qrcode_content, qrcode_path, "微信")
|
|
else:
|
|
tencent_logger.warning(
|
|
_msg(
|
|
"😵",
|
|
f"没能从二维码图片里解析出可打印内容,所以这次没法在终端重绘二维码;请直接打开 {qrcode_path} 扫码",
|
|
)
|
|
)
|
|
|
|
qrcode_info = {
|
|
"image_path": str(qrcode_path),
|
|
"image_data_url": qrcode_src,
|
|
}
|
|
await _emit_qrcode_callback(qrcode_callback, qrcode_info)
|
|
return qrcode_info
|
|
|
|
|
|
async def _is_tencent_login_completed(page: Page) -> bool:
|
|
publish_markers = [
|
|
page.locator('div:has-text("发表视频")').first,
|
|
page.locator('button:has-text("发表")').first,
|
|
page.locator('button:has-text("保存草稿")').first,
|
|
]
|
|
for marker in publish_markers:
|
|
try:
|
|
if await marker.count() and await marker.is_visible():
|
|
return True
|
|
except Exception:
|
|
continue
|
|
|
|
if not (page.url.startswith(TENCENT_UPLOAD_URL) or page.url.startswith(TENCENT_MANAGE_URL)):
|
|
return False
|
|
|
|
login_markers = [
|
|
page.locator("div.login-qrcode-wrap").first,
|
|
page.locator("div.qrcode-wrap").first,
|
|
page.locator("img.qrcode").first,
|
|
page.locator('span:has-text("微信扫码登录 视频号助手")').first,
|
|
]
|
|
for marker in login_markers:
|
|
try:
|
|
if await marker.count() and await marker.is_visible():
|
|
return False
|
|
except Exception:
|
|
continue
|
|
|
|
return True
|
|
|
|
|
|
async def _is_tencent_qrcode_expired(page: Page) -> bool:
|
|
tip_selectors = [
|
|
'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
|
|
'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
|
|
'p.refresh-tip:has-text("二维码已过期,点击刷新")',
|
|
'p.refresh-tip:has-text("网络不可用,点击刷新")',
|
|
]
|
|
for selector in tip_selectors:
|
|
tip = page.locator(selector).first
|
|
try:
|
|
if await tip.count() and await tip.is_visible():
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
async def _is_tencent_qrcode_scanned(page: Page) -> bool:
|
|
scanned_tips = [
|
|
'div.qr-tip div:has-text("已扫码")',
|
|
'div.qr-tip div:has-text("需在手机上进行确认")',
|
|
]
|
|
for selector in scanned_tips:
|
|
tip = page.locator(selector).first
|
|
try:
|
|
if await tip.count() and await tip.is_visible():
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
async def _refresh_tencent_qrcode(page: Page) -> None:
|
|
visible_refresh_selectors = [
|
|
"div.login-qrcode-wrap div.mask.show div.refresh-wrap",
|
|
"div.login-qrcode-wrap div.mask.show .refresh-wrap",
|
|
]
|
|
for selector in visible_refresh_selectors:
|
|
refresh_wrap = page.locator(selector).first
|
|
try:
|
|
if not await refresh_wrap.count() or not await refresh_wrap.is_visible():
|
|
continue
|
|
await refresh_wrap.click()
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
tip_selectors = [
|
|
'div.mask.show p.refresh-tip:has-text("二维码已过期,点击刷新")',
|
|
'div.mask.show p.refresh-tip:has-text("网络不可用,点击刷新")',
|
|
'p.refresh-tip:has-text("二维码已过期,点击刷新")',
|
|
'p.refresh-tip:has-text("网络不可用,点击刷新")',
|
|
]
|
|
for selector in tip_selectors:
|
|
tip = page.locator(selector).first
|
|
try:
|
|
if not await tip.count() or not await tip.is_visible():
|
|
continue
|
|
refresh_wrap = tip.locator("xpath=ancestor::div[contains(@class, 'refresh-wrap')]").first
|
|
if await refresh_wrap.count():
|
|
await refresh_wrap.click()
|
|
else:
|
|
await tip.click()
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
fallback_refresh = page.locator("div.login-qrcode-wrap div.refresh-wrap").first
|
|
if await fallback_refresh.count():
|
|
await fallback_refresh.click()
|
|
return
|
|
|
|
raise RuntimeError("未找到可点击的视频号二维码刷新区域")
|
|
|
|
|
|
async def _wait_for_tencent_login(
|
|
page: Page,
|
|
account_file: str,
|
|
qrcode_info: dict | None,
|
|
qrcode_callback=None,
|
|
poll_interval: int = 3,
|
|
max_checks: int = 100,
|
|
) -> dict:
|
|
qrcode_path = Path(qrcode_info["image_path"]) if qrcode_info else None
|
|
scanned_logged = False
|
|
for _ in range(max_checks):
|
|
if await _is_tencent_login_completed(page):
|
|
tencent_logger.info(_msg("🥳", f"扫码成功,已经跳转到登录后页面: {page.url}"))
|
|
return _build_login_result(True, "success", "视频号扫码登录成功", account_file, qrcode_info, page.url)
|
|
|
|
if not scanned_logged and await _is_tencent_qrcode_scanned(page):
|
|
tencent_logger.info(_msg("📱", "已经扫码啦,还差手机端确认一下"))
|
|
scanned_logged = True
|
|
|
|
if await _is_tencent_qrcode_expired(page):
|
|
tencent_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新"))
|
|
await _refresh_tencent_qrcode(page)
|
|
await asyncio.sleep(1)
|
|
try:
|
|
qrcode_info = await _save_tencent_qrcode(
|
|
page,
|
|
account_file,
|
|
previous_qrcode_path=qrcode_path,
|
|
qrcode_callback=qrcode_callback,
|
|
)
|
|
qrcode_path = Path(qrcode_info["image_path"])
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("⚠️", f"刷新后未能重新提取二维码({exc}),请直接在浏览器窗口中扫码"))
|
|
|
|
await asyncio.sleep(poll_interval)
|
|
|
|
return _build_login_result(False, "timeout", "等待视频号扫码登录超时", account_file, qrcode_info, page.url)
|
|
|
|
|
|
async def tencent_cookie_gen(
|
|
account_file,
|
|
qrcode_callback=None,
|
|
poll_interval: int = 3,
|
|
max_checks: int = 100,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
):
|
|
account_file = _resolve_account_file(account_file)
|
|
Path(account_file).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
async with async_playwright() as playwright:
|
|
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=headless))
|
|
context = await browser.new_context()
|
|
qrcode_path = None
|
|
result = _build_login_result(False, "failed", "视频号登录失败", account_file)
|
|
try:
|
|
page = await context.new_page()
|
|
await page.goto(TENCENT_LOGIN_URL)
|
|
try:
|
|
qrcode_info = await _save_tencent_qrcode(page, account_file, qrcode_callback=qrcode_callback)
|
|
qrcode_path = Path(qrcode_info["image_path"])
|
|
except Exception as exc:
|
|
tencent_logger.warning(
|
|
_msg("⚠️", f"提取二维码图片失败({exc}),请直接在弹出的浏览器窗口中扫码,登录流程不受影响")
|
|
)
|
|
qrcode_info = None
|
|
qrcode_path = None
|
|
tencent_logger.info(_msg("🧍", "请扫码,小人正在耐心等待登录完成"))
|
|
result = await _wait_for_tencent_login(
|
|
page,
|
|
account_file,
|
|
qrcode_info,
|
|
qrcode_callback=qrcode_callback,
|
|
poll_interval=poll_interval,
|
|
max_checks=max_checks,
|
|
)
|
|
if result["success"]:
|
|
await asyncio.sleep(2)
|
|
await context.storage_state(path=account_file)
|
|
if not await cookie_auth(account_file):
|
|
result = _build_login_result(
|
|
False,
|
|
"cookie_invalid",
|
|
"视频号扫码流程结束,但 cookie 校验失败",
|
|
account_file,
|
|
qrcode_info,
|
|
page.url,
|
|
)
|
|
return result
|
|
except Exception as exc:
|
|
result = _build_login_result(
|
|
False,
|
|
"failed",
|
|
str(exc),
|
|
account_file,
|
|
current_url=page.url if "page" in locals() else "",
|
|
)
|
|
return result
|
|
finally:
|
|
qrcode_utils = _get_qrcode_utils()
|
|
if qrcode_utils["remove_qrcode_file"](qrcode_path):
|
|
tencent_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
|
|
if not result["success"]:
|
|
tencent_logger.error(_msg("😢", f"登录失败: {result['message']}"))
|
|
await context.close()
|
|
await browser.close()
|
|
|
|
|
|
async def tencent_setup(
|
|
account_file,
|
|
handle=False,
|
|
return_detail=False,
|
|
qrcode_callback=None,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
):
|
|
account_file = _resolve_account_file(account_file)
|
|
if not os.path.exists(account_file) or not await cookie_auth(account_file):
|
|
if not handle:
|
|
result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
|
|
return result if return_detail else False
|
|
|
|
tencent_logger.info(_msg("🥹", "cookie 失效了,准备打开浏览器重新登录"))
|
|
result = await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
|
|
return result if return_detail else result["success"]
|
|
|
|
result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
|
|
return result if return_detail else True
|
|
|
|
|
|
async def get_tencent_cookie(account_file, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS):
|
|
return await tencent_cookie_gen(account_file, qrcode_callback=qrcode_callback, headless=headless)
|
|
|
|
|
|
async def weixin_setup(
|
|
account_file,
|
|
handle=False,
|
|
return_detail=False,
|
|
qrcode_callback=None,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
):
|
|
return await tencent_setup(
|
|
account_file,
|
|
handle=handle,
|
|
return_detail=return_detail,
|
|
qrcode_callback=qrcode_callback,
|
|
headless=headless,
|
|
)
|
|
|
|
|
|
class TencentBaseUploader(BaseVideoUploader):
|
|
def __init__(
|
|
self,
|
|
publish_date: datetime | int,
|
|
account_file,
|
|
publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
|
|
debug: bool = DEBUG_MODE,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
):
|
|
self.publish_date = publish_date
|
|
self.account_file = _resolve_account_file(account_file)
|
|
self.publish_strategy = publish_strategy
|
|
self.debug = debug
|
|
self.headless = headless
|
|
self.local_executable_path = LOCAL_CHROME_PATH
|
|
|
|
async def validate_base_args(self):
|
|
if not os.path.exists(self.account_file):
|
|
raise RuntimeError(f"cookie文件不存在,请先完成视频号登录: {self.account_file}")
|
|
if not await cookie_auth(self.account_file):
|
|
raise RuntimeError(f"cookie文件已失效,请先完成视频号登录: {self.account_file}")
|
|
if self.publish_strategy not in {TENCENT_PUBLISH_STRATEGY_IMMEDIATE, TENCENT_PUBLISH_STRATEGY_SCHEDULED}:
|
|
raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
|
|
|
|
if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED:
|
|
self.publish_date = self.validate_publish_date(self.publish_date)
|
|
else:
|
|
self.publish_date = 0
|
|
|
|
async def set_schedule_time_tencent(self, page: Page, publish_date: datetime):
|
|
label_element = page.locator("label").filter(has_text="定时").nth(1)
|
|
await label_element.click()
|
|
await page.click('input[placeholder="请选择发表时间"]')
|
|
|
|
current_month = publish_date.strftime("%m月")
|
|
page_month = await page.inner_text('span.weui-desktop-picker__panel__label:has-text("月")')
|
|
if page_month != current_month:
|
|
await page.click("button.weui-desktop-btn__icon__right")
|
|
|
|
elements = await page.query_selector_all("table.weui-desktop-picker__table a")
|
|
for element in elements:
|
|
if "weui-desktop-picker__disabled" in await element.evaluate("el => el.className"):
|
|
continue
|
|
text = await element.inner_text()
|
|
if text.strip() == str(publish_date.day):
|
|
await element.click()
|
|
break
|
|
|
|
await page.click('input[placeholder="请选择时间"]')
|
|
await page.keyboard.press("Control+KeyA")
|
|
await page.keyboard.type(publish_date.strftime("%H"))
|
|
await page.keyboard.press("Enter") # 确认小时并关闭时间下拉
|
|
await page.wait_for_timeout(500)
|
|
# 收起时间选择浮层:直接点描述区可能被 weui-desktop-dialog 遮挡,做容错
|
|
try:
|
|
await page.locator("div.input-editor").click(timeout=5000)
|
|
except Exception:
|
|
await page.keyboard.press("Escape")
|
|
|
|
async def open_upload_page(self, page: Page) -> None:
|
|
await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
|
|
await page.wait_for_url(TENCENT_UPLOAD_URL, timeout=120000)
|
|
|
|
async def upload_video_file(self, page: Page, file_path: str) -> None:
|
|
async def find_file_input():
|
|
for fr in page.frames: # 主 frame + 所有 iframe(视频号编辑器可能在 iframe 内)
|
|
try:
|
|
fi = fr.locator('input[type="file"]')
|
|
if await fi.count():
|
|
return fi.first
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
fi = await find_file_input()
|
|
if fi is None:
|
|
# 助手落在首页:先点「发表视频」唤出编辑器与上传控件
|
|
publish_btn = page.get_by_text("发表视频").first
|
|
if await publish_btn.count():
|
|
await publish_btn.click()
|
|
await asyncio.sleep(3)
|
|
for _ in range(20):
|
|
fi = await find_file_input()
|
|
if fi is not None:
|
|
break
|
|
await asyncio.sleep(1)
|
|
if fi is None:
|
|
raise RuntimeError("未找到视频号文件上传框")
|
|
await fi.set_input_files(file_path)
|
|
|
|
async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
|
|
short_title_element = (
|
|
page.get_by_text("短标题", exact=True)
|
|
.locator("..")
|
|
.locator("xpath=following-sibling::div")
|
|
.locator('span input[type="text"]')
|
|
)
|
|
if await short_title_element.count():
|
|
await short_title_element.fill(short_title or format_str_for_short_title(title))
|
|
|
|
async def fill_title_and_tags(self, page: Page) -> None:
|
|
await page.locator("div.input-editor").click()
|
|
await page.keyboard.type(self.title)
|
|
await page.keyboard.press("Enter")
|
|
for tag in self.tags:
|
|
await page.keyboard.type("#" + tag)
|
|
await page.keyboard.press("Space")
|
|
tencent_logger.info(_msg("🏷️", f"成功添加 hashtag: {len(self.tags)}"))
|
|
|
|
async def fill_description(self, page: Page) -> None:
|
|
await page.keyboard.press("Enter")
|
|
await page.keyboard.type(self.desc)
|
|
tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}"))
|
|
|
|
async def apply_collection(self, page: Page) -> None:
|
|
collection_elements = (
|
|
page.get_by_text("添加到合集")
|
|
.locator("xpath=following-sibling::div")
|
|
.locator(".option-list-wrap > div")
|
|
)
|
|
if await collection_elements.count() > 1:
|
|
await page.get_by_text("添加到合集").locator("xpath=following-sibling::div").click()
|
|
await collection_elements.first.click()
|
|
|
|
async def apply_original_statement(self, page: Page) -> None:
|
|
original_set = False
|
|
if await page.get_by_label("视频为原创").count():
|
|
await page.get_by_label("视频为原创").check()
|
|
original_set = True
|
|
|
|
try:
|
|
label_locator = await page.locator('label:has-text("我已阅读并同意 《视频号原创声明使用条款》")').is_visible()
|
|
except Exception:
|
|
label_locator = False
|
|
|
|
if label_locator:
|
|
await page.get_by_label("我已阅读并同意 《视频号原创声明使用条款》").check()
|
|
await page.get_by_role("button", name="声明原创").click()
|
|
original_set = True
|
|
|
|
declaration_entry = page.locator(
|
|
'div.label span:has-text("声明原创"), '
|
|
'div:has-text("声明原创"):has(input.ant-checkbox-input), '
|
|
'div:has-text("原创声明"):has(input.ant-checkbox-input)'
|
|
).first
|
|
if await declaration_entry.count():
|
|
original_checkbox = page.locator("div.declare-original-checkbox input.ant-checkbox-input").first
|
|
if await original_checkbox.count() and not await original_checkbox.is_disabled():
|
|
await original_checkbox.click()
|
|
await page.wait_for_timeout(500)
|
|
checked_locator = page.locator(
|
|
"div.declare-original-dialog "
|
|
"label.ant-checkbox-wrapper.ant-checkbox-wrapper-checked:visible"
|
|
)
|
|
if not await checked_locator.count():
|
|
await page.locator("div.declare-original-dialog input.ant-checkbox-input:visible").first.click()
|
|
|
|
original_type_form = page.locator('div.original-type-form > div.form-label:has-text("原创类型"):visible')
|
|
if await original_type_form.count():
|
|
category = getattr(self, "category", None)
|
|
await page.locator("div.form-content:visible").click()
|
|
option = None
|
|
if category:
|
|
option = page.locator(
|
|
"ul.weui-desktop-dropdown__list "
|
|
f'li.weui-desktop-dropdown__list-ele:has-text("{category}")'
|
|
).first
|
|
if not await option.count():
|
|
option = None
|
|
if option is None:
|
|
option = page.locator(
|
|
"ul.weui-desktop-dropdown__list "
|
|
"li.weui-desktop-dropdown__list-ele:visible"
|
|
).first
|
|
if await option.count():
|
|
await option.click()
|
|
await page.wait_for_timeout(1000)
|
|
|
|
declare_button = page.locator('button:has-text("声明原创"):visible')
|
|
if await declare_button.count():
|
|
await declare_button.first.click()
|
|
original_set = True
|
|
await page.wait_for_timeout(1000)
|
|
|
|
if not original_set:
|
|
for original_text in ("声明原创", "原创声明", "视频为原创"):
|
|
try:
|
|
modern_original = page.locator(f'text="{original_text}"').first
|
|
if await modern_original.count() and await modern_original.is_visible():
|
|
await modern_original.click()
|
|
original_set = True
|
|
await page.wait_for_timeout(1000)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
content_declaration = page.locator('text="内容声明"').first
|
|
try:
|
|
if await content_declaration.count() and await content_declaration.is_visible():
|
|
await content_declaration.click()
|
|
for option_text in ("无需声明", "不声明", "无"):
|
|
option = page.locator(f'text="{option_text}"').first
|
|
if await option.count() and await option.is_visible():
|
|
await option.click()
|
|
tencent_logger.info(_msg("🧾", f"内容声明已选择: {option_text}"))
|
|
break
|
|
else:
|
|
tencent_logger.info(_msg("🧾", "当前页面未发现内容声明字段"))
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("😵", f"内容声明设置失败,继续前先人工确认页面: {exc}"))
|
|
|
|
if not original_set:
|
|
try:
|
|
diagnostic_path = Path(BASE_DIR) / "debug_tencent_original_missing.png"
|
|
await page.screenshot(path=str(diagnostic_path), full_page=True)
|
|
visible_text = (await page.locator("body").first.inner_text())[-4000:]
|
|
tencent_logger.warning(_msg("😵", f"未确认声明原创,诊断截图: {diagnostic_path}"))
|
|
tencent_logger.warning(_msg("🧾", f"页面末尾文本: {visible_text}"))
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("😵", f"生成原创声明诊断信息失败: {exc}"))
|
|
# 视频号「声明原创」为可选项:页面无对应入口时跳过并继续发布,而非中止。
|
|
tencent_logger.warning(_msg("📭", "本视频未声明原创(页面无入口或为可选项),跳过并继续发布"))
|
|
|
|
async def wait_for_upload_complete(self, page: Page) -> None:
|
|
while True:
|
|
try:
|
|
publish_button = page.get_by_role("button", name="发表")
|
|
button_class = await publish_button.get_attribute("class")
|
|
if button_class and "weui-desktop-btn_disabled" not in button_class:
|
|
tencent_logger.info(_msg("🥳", "视频上传完毕"))
|
|
break
|
|
|
|
tencent_logger.info(_msg("🏃", "正在上传视频中..."))
|
|
await asyncio.sleep(2)
|
|
|
|
upload_failed = await page.locator("div.status-msg.error").count()
|
|
delete_button = await page.locator('div.media-status-content div.tag-inner:has-text("删除")').count()
|
|
if upload_failed and delete_button:
|
|
tencent_logger.error(_msg("😵", "发现上传出错了,准备重试"))
|
|
await self.handle_upload_error(page)
|
|
except Exception:
|
|
tencent_logger.info(_msg("🏃", "正在上传视频中..."))
|
|
await asyncio.sleep(2)
|
|
|
|
async def submit_publish(self, page: Page) -> None:
|
|
while True:
|
|
try:
|
|
if getattr(self, "is_draft", False):
|
|
draft_button = page.locator('div.form-btns button:has-text("保存草稿")')
|
|
if await draft_button.count():
|
|
await draft_button.click()
|
|
await page.wait_for_url("**/post/list**", timeout=5000)
|
|
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
|
else:
|
|
publish_button = page.locator('div.form-btns button:has-text("发表")')
|
|
if await publish_button.count():
|
|
await publish_button.click()
|
|
await page.wait_for_url(TENCENT_MANAGE_URL, timeout=5000)
|
|
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
|
break
|
|
except Exception as exc:
|
|
current_url = page.url
|
|
if getattr(self, "is_draft", False):
|
|
if "post/list" in current_url or "draft" in current_url:
|
|
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
|
break
|
|
else:
|
|
if TENCENT_MANAGE_URL in current_url:
|
|
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
|
break
|
|
tencent_logger.exception(f" [-] Exception: {exc}")
|
|
tencent_logger.info(_msg("🏃", "视频正在发布中..."))
|
|
await asyncio.sleep(0.5)
|
|
|
|
|
|
class TencentVideo(TencentBaseUploader):
|
|
def __init__(
|
|
self,
|
|
title,
|
|
file_path,
|
|
tags,
|
|
publish_date: datetime | int,
|
|
account_file,
|
|
category=None,
|
|
is_draft=False,
|
|
desc: str | None = None,
|
|
thumbnail_path: str | None = None,
|
|
thumbnail_landscape_path: str | None = None,
|
|
thumbnail_portrait_path: str | None = None,
|
|
short_title: str | None = None,
|
|
publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
|
|
debug: bool = DEBUG_MODE,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
):
|
|
super().__init__(
|
|
publish_date=publish_date,
|
|
account_file=account_file,
|
|
publish_strategy=publish_strategy,
|
|
debug=debug,
|
|
headless=headless,
|
|
)
|
|
self.title = title
|
|
self.file_path = file_path
|
|
self.tags = tags or []
|
|
self.category = category
|
|
self.is_draft = is_draft
|
|
self.desc = desc or ""
|
|
self.thumbnail_path = thumbnail_path
|
|
self.thumbnail_landscape_path = thumbnail_landscape_path
|
|
self.thumbnail_portrait_path = thumbnail_portrait_path or thumbnail_path
|
|
self.short_title = short_title
|
|
|
|
async def validate_upload_args(self):
|
|
await self.validate_base_args()
|
|
if not self.title or not str(self.title).strip():
|
|
raise ValueError("视频模式下,title 是必须的")
|
|
self.file_path = str(self.validate_video_file(self.file_path))
|
|
if self.thumbnail_landscape_path:
|
|
self.thumbnail_landscape_path = str(self.validate_image_file(self.thumbnail_landscape_path))
|
|
if self.thumbnail_portrait_path:
|
|
self.thumbnail_portrait_path = str(self.validate_image_file(self.thumbnail_portrait_path))
|
|
|
|
async def handle_upload_error(self, page: Page) -> None:
|
|
tencent_logger.info(_msg("😵", "视频出错了,重新上传中"))
|
|
await page.locator('div.media-status-content div.tag-inner:has-text("删除")').click()
|
|
await page.get_by_role("button", name="删除", exact=True).click()
|
|
await self.upload_video_file(page, self.file_path)
|
|
|
|
async def open_thumbnail_dialog(self, page: Page, selectors: list[str], dialog_titles: list[str]):
|
|
for selector in selectors:
|
|
cover_entry = page.locator(selector).first
|
|
try:
|
|
if not await cover_entry.count():
|
|
continue
|
|
await cover_entry.wait_for(state="visible", timeout=3000)
|
|
await cover_entry.click()
|
|
await page.wait_for_timeout(500)
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
for title in dialog_titles:
|
|
cover_dialog = page.locator("div.weui-desktop-dialog").filter(has_text=title).first
|
|
if await cover_dialog.count():
|
|
return cover_dialog
|
|
return None
|
|
|
|
async def confirm_thumbnail_crop(self, page: Page) -> None:
|
|
crop_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="裁剪封面图").first
|
|
if not await crop_dialog.count():
|
|
return
|
|
|
|
try:
|
|
await crop_dialog.wait_for(state="visible", timeout=10000)
|
|
crop_confirm_button = crop_dialog.locator(
|
|
'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确定")'
|
|
).first
|
|
if await crop_confirm_button.count():
|
|
await crop_confirm_button.wait_for(state="visible", timeout=5000)
|
|
await crop_confirm_button.click()
|
|
await page.wait_for_timeout(1000)
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("😵", f"封面裁剪确认时出错,小人继续尝试保存主弹窗: {exc}"))
|
|
|
|
async def upload_thumbnail_in_dialog(self, page: Page, cover_dialog, thumbnail_path: str) -> None:
|
|
await cover_dialog.wait_for(state="visible", timeout=5000)
|
|
file_input = cover_dialog.locator('.single-cover-uploader-wrap input[type="file"]').first
|
|
await file_input.wait_for(state="attached", timeout=10000)
|
|
await file_input.set_input_files(thumbnail_path)
|
|
await page.wait_for_timeout(1000)
|
|
await self.confirm_thumbnail_crop(page)
|
|
|
|
confirm_button = cover_dialog.locator(
|
|
'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")'
|
|
).first
|
|
await confirm_button.wait_for(state="visible", timeout=10000)
|
|
await confirm_button.click()
|
|
|
|
async def set_single_thumbnail(
|
|
self,
|
|
page: Page,
|
|
thumbnail_path: str,
|
|
selectors: list[str],
|
|
dialog_titles: list[str],
|
|
label: str,
|
|
) -> None:
|
|
cover_dialog = await self.open_thumbnail_dialog(page, selectors, dialog_titles)
|
|
if not cover_dialog:
|
|
tencent_logger.info(_msg("🧍", f"当前页面没有出现{label}封面编辑弹窗,小人先跳过"))
|
|
return
|
|
|
|
try:
|
|
await self.upload_thumbnail_in_dialog(page, cover_dialog, thumbnail_path)
|
|
tencent_logger.success(_msg("🥳", f"{label}封面已经设置完成"))
|
|
except Exception as exc:
|
|
tencent_logger.warning(_msg("😵", f"{label}封面设置失败,这次先跳过: {exc}"))
|
|
|
|
async def set_thumbnail(self, page: Page) -> None:
|
|
if not self.thumbnail_landscape_path and not self.thumbnail_portrait_path:
|
|
return
|
|
|
|
tencent_logger.info(_msg("🖼️", "小人准备设置封面"))
|
|
|
|
landscape_selectors = [
|
|
'div.horizontal-cover-wrap:has-text("4:3")',
|
|
'div[class*="cover-wrap"]:has-text("4:3"):has-text("动态")',
|
|
'div:has-text("视频号动态"):has-text("4:3")',
|
|
'div:has-text("横版封面"):has-text("4:3")',
|
|
]
|
|
portrait_selectors = [
|
|
'div.vertical-cover-wrap:has-text("个人主页卡片"):has-text("3:4")',
|
|
'div.vertical-cover-wrap:has-text("3:4")',
|
|
'div.vertical-cover-wrap:has-text("个人主页卡片")',
|
|
]
|
|
|
|
if self.thumbnail_landscape_path:
|
|
await self.set_single_thumbnail(
|
|
page,
|
|
self.thumbnail_landscape_path,
|
|
landscape_selectors,
|
|
["编辑视频号动态封面", "编辑动态封面", "编辑封面"],
|
|
"4:3 横版",
|
|
)
|
|
if self.thumbnail_portrait_path:
|
|
await self.set_single_thumbnail(
|
|
page,
|
|
self.thumbnail_portrait_path,
|
|
portrait_selectors,
|
|
["编辑个人主页卡片", "编辑封面"],
|
|
"3:4 竖版",
|
|
)
|
|
|
|
async def prepare_video_for_publish(self, page: Page) -> None:
|
|
await self.fill_title_and_tags(page)
|
|
await self.fill_description(page)
|
|
await self.apply_collection(page)
|
|
|
|
async def upload(self, playwright: Playwright) -> None:
|
|
tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间"))
|
|
await self.validate_upload_args()
|
|
tencent_logger.info(_msg("🥳", "上传前检查通过"))
|
|
|
|
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
|
|
context = await browser.new_context(storage_state=self.account_file)
|
|
|
|
try:
|
|
page = await context.new_page()
|
|
await self.open_upload_page(page)
|
|
tencent_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}"))
|
|
|
|
await self.upload_video_file(page, self.file_path)
|
|
await self.prepare_video_for_publish(page)
|
|
await self.wait_for_upload_complete(page)
|
|
await self.apply_original_statement(page)
|
|
await self.set_thumbnail(page)
|
|
|
|
if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
|
await self.set_schedule_time_tencent(page, self.publish_date)
|
|
|
|
await self.set_short_title(page, self.title, self.short_title)
|
|
await self.submit_publish(page)
|
|
|
|
await context.storage_state(path=self.account_file)
|
|
tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
|
|
finally:
|
|
await context.close()
|
|
await browser.close()
|
|
|
|
async def tencent_upload_video(self):
|
|
async with async_playwright() as playwright:
|
|
await self.upload(playwright)
|
|
|
|
async def main(self):
|
|
await self.tencent_upload_video()
|
|
|
|
|
|
class TencentNote(TencentBaseUploader):
|
|
def __init__(
|
|
self,
|
|
image_paths,
|
|
note,
|
|
tags,
|
|
publish_date: datetime | int,
|
|
account_file,
|
|
title: str | None = None,
|
|
publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE,
|
|
debug: bool = DEBUG_MODE,
|
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
|
is_draft: bool = False,
|
|
):
|
|
super().__init__(
|
|
publish_date=publish_date,
|
|
account_file=account_file,
|
|
publish_strategy=publish_strategy,
|
|
debug=debug,
|
|
headless=headless,
|
|
)
|
|
self.image_paths = image_paths
|
|
self.note = note or ""
|
|
self.title = title or (self.note[:30] if self.note else "")
|
|
self.tags = tags or []
|
|
self.is_draft = is_draft
|
|
|
|
async def validate_upload_args(self):
|
|
await self.validate_base_args()
|
|
if not self.title or not str(self.title).strip():
|
|
raise ValueError("图文模式下,title 是必须的")
|
|
if not self.image_paths:
|
|
raise ValueError("图文模式下,图片是必须的")
|
|
|
|
if isinstance(self.image_paths, (str, Path)):
|
|
self.image_paths = [self.image_paths]
|
|
|
|
normalized_image_paths = []
|
|
for image_path in self.image_paths:
|
|
normalized_image_paths.append(str(self.validate_image_file(image_path)))
|
|
self.image_paths = normalized_image_paths
|
|
|
|
async def switch_to_note_mode(self, page: Page) -> None:
|
|
raise NotImplementedError("请在 TencentNote.switch_to_note_mode 中补充视频号切换到图文发布模式的逻辑")
|
|
|
|
async def upload_note_images(self, page: Page) -> None:
|
|
raise NotImplementedError("请在 TencentNote.upload_note_images 中补充视频号图文图片上传逻辑")
|
|
|
|
async def fill_note_title_and_tags(self, page: Page) -> None:
|
|
raise NotImplementedError("请在 TencentNote.fill_note_title_and_tags 中补充视频号图文标题/话题填写逻辑")
|
|
|
|
async def fill_note_body(self, page: Page) -> None:
|
|
return None
|
|
|
|
async def prepare_note_for_publish(self, page: Page) -> None:
|
|
await self.fill_note_title_and_tags(page)
|
|
await self.fill_note_body(page)
|
|
await self.apply_collection(page)
|
|
await self.apply_original_statement(page)
|
|
|
|
async def upload_note_content(self, page: Page) -> None:
|
|
await self.switch_to_note_mode(page)
|
|
await self.upload_note_images(page)
|
|
await self.prepare_note_for_publish(page)
|
|
|
|
async def upload(self, playwright: Playwright) -> None:
|
|
tencent_logger.info(_msg("🧍", "小人先检查 cookie、图文图片和发布时间"))
|
|
await self.validate_upload_args()
|
|
tencent_logger.info(_msg("🥳", "图文上传前检查通过"))
|
|
|
|
browser = await playwright.chromium.launch(**_build_launch_kwargs(headless=self.headless))
|
|
context = await browser.new_context(storage_state=self.account_file)
|
|
context = await set_init_script(context)
|
|
|
|
try:
|
|
page = await context.new_page()
|
|
await self.open_upload_page(page)
|
|
tencent_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
|
|
|
|
await self.upload_note_content(page)
|
|
|
|
if self.publish_strategy == TENCENT_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
|
await self.set_schedule_time_tencent(page, self.publish_date)
|
|
|
|
await self.submit_publish(page)
|
|
|
|
await context.storage_state(path=self.account_file)
|
|
tencent_logger.success(_msg("🥳", "cookie 更新完毕"))
|
|
finally:
|
|
await context.close()
|
|
await browser.close()
|
|
|
|
async def tencent_upload_note(self):
|
|
async with async_playwright() as playwright:
|
|
await self.upload(playwright)
|
|
|
|
async def main(self):
|
|
await self.tencent_upload_note()
|