Merge pull request #265: Tencent revamped editor fixes
This commit is contained in:
@@ -172,6 +172,7 @@ class TencentVideoUploadRequest:
|
|||||||
publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE
|
publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE
|
||||||
debug: bool = True
|
debug: bool = True
|
||||||
headless: bool = True
|
headless: bool = True
|
||||||
|
collection_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -564,6 +565,7 @@ async def upload_tencent_video(request: TencentVideoUploadRequest) -> Path:
|
|||||||
publish_strategy=request.publish_strategy,
|
publish_strategy=request.publish_strategy,
|
||||||
debug=request.debug,
|
debug=request.debug,
|
||||||
headless=request.headless,
|
headless=request.headless,
|
||||||
|
collection_name=request.collection_name,
|
||||||
)
|
)
|
||||||
await app.tencent_upload_video()
|
await app.tencent_upload_video()
|
||||||
return account_file
|
return account_file
|
||||||
@@ -770,6 +772,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
tencent_upload_video_parser.add_argument("--short-title", help="Optional WeChat Channels short title")
|
tencent_upload_video_parser.add_argument("--short-title", help="Optional WeChat Channels short title")
|
||||||
tencent_upload_video_parser.add_argument("--category", help="Optional original content category")
|
tencent_upload_video_parser.add_argument("--category", help="Optional original content category")
|
||||||
tencent_upload_video_parser.add_argument("--draft", action="store_true", help="Save as draft instead of publishing")
|
tencent_upload_video_parser.add_argument("--draft", action="store_true", help="Save as draft instead of publishing")
|
||||||
|
tencent_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name to add the work into (must already exist)")
|
||||||
add_runtime_flags(tencent_upload_video_parser)
|
add_runtime_flags(tencent_upload_video_parser)
|
||||||
|
|
||||||
youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations")
|
youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations")
|
||||||
@@ -1054,6 +1057,7 @@ async def dispatch(args: argparse.Namespace) -> int:
|
|||||||
publish_strategy=publish_strategy,
|
publish_strategy=publish_strategy,
|
||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
headless=args.headless,
|
headless=args.headless,
|
||||||
|
collection_name=args.collection,
|
||||||
)
|
)
|
||||||
await upload_tencent_video(request)
|
await upload_tencent_video(request)
|
||||||
print(f"Tencent/WeChat Channels video upload submitted: {request.video_file}")
|
print(f"Tencent/WeChat Channels video upload submitted: {request.video_file}")
|
||||||
|
|||||||
+203
-141
@@ -20,6 +20,7 @@ from utils.base_social_media import set_init_script
|
|||||||
from utils.log import tencent_logger
|
from utils.log import tencent_logger
|
||||||
|
|
||||||
TENCENT_LOGIN_URL = "https://channels.weixin.qq.com"
|
TENCENT_LOGIN_URL = "https://channels.weixin.qq.com"
|
||||||
|
TENCENT_HOME_URL = "https://channels.weixin.qq.com/platform"
|
||||||
TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create"
|
TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create"
|
||||||
TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list"
|
TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list"
|
||||||
TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
|
TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
|
||||||
@@ -98,10 +99,14 @@ def format_str_for_short_title(origin_title: str) -> str:
|
|||||||
filtered_chars = [char if char.isalnum() or char in allowed_special_chars else " " if char == "," else "" for char in origin_title]
|
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)
|
formatted_string = "".join(filtered_chars)
|
||||||
|
|
||||||
if len(formatted_string) > 16:
|
# 视频号「短标题」要求 6~16 个字符/汉字;本项目按 >6 且 <16 从严控制在 7~15。
|
||||||
formatted_string = formatted_string[:16]
|
formatted_string = formatted_string.strip()
|
||||||
elif len(formatted_string) < 6:
|
if len(formatted_string) > 15:
|
||||||
formatted_string += " " * (6 - len(formatted_string))
|
formatted_string = formatted_string[:15]
|
||||||
|
if len(formatted_string) < 7:
|
||||||
|
# 不足下限时补足到 7;不能用尾部空格(会被平台 trim 掉导致仍不达标)
|
||||||
|
filler = ",精彩内容分享"
|
||||||
|
formatted_string = (formatted_string + filler)[:7] if formatted_string else "精彩视频内容分享"
|
||||||
|
|
||||||
return formatted_string
|
return formatted_string
|
||||||
|
|
||||||
@@ -545,17 +550,39 @@ class TencentBaseUploader(BaseVideoUploader):
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
async def open_upload_page(self, page: Page) -> None:
|
async def open_upload_page(self, page: Page) -> None:
|
||||||
await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
|
# 视频号已改版:直接全页加载 /platform/post/create 会被跳回 /platform 首页,
|
||||||
|
# 发布表单 iframe 只加载空壳(Vue 不挂载),页面上没有任何 input[type=file]。
|
||||||
|
# 正确入口:先进首页,再点可见的「发表视频」按钮做客户端跳转,表单才会真正挂载。
|
||||||
|
await page.goto(TENCENT_HOME_URL, timeout=120000, wait_until="domcontentloaded")
|
||||||
# cookie 失效时前端 JS 会跳转到登录页, 提前发现并报明确的错误
|
# cookie 失效时前端 JS 会跳转到登录页, 提前发现并报明确的错误
|
||||||
redirected = True
|
|
||||||
try:
|
try:
|
||||||
await page.wait_for_url("**/login.html**", timeout=8000)
|
await page.wait_for_url("**/login.html**", timeout=8000)
|
||||||
except Exception:
|
|
||||||
redirected = False # 8 秒内未跳转, 正常
|
|
||||||
if redirected or any(
|
|
||||||
"open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames
|
|
||||||
):
|
|
||||||
raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
|
raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
|
||||||
|
except TimeoutError:
|
||||||
|
pass # 8 秒内未跳转, 正常
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if any("open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames):
|
||||||
|
raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布")
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 注意:get_by_text("发表视频") 会命中一个隐藏的说明 <p>(不可点);
|
||||||
|
# 首页真正可点的入口是 button.weui-desktop-btn。
|
||||||
|
publish_entry = page.locator("button.weui-desktop-btn", has_text="发表视频").first
|
||||||
|
try:
|
||||||
|
await publish_entry.wait_for(state="visible", timeout=30000)
|
||||||
|
await publish_entry.click()
|
||||||
|
except Exception:
|
||||||
|
# 兜底:按钮没点到时退回老逻辑直接跳转(可能仍是空壳,但保持向后兼容)
|
||||||
|
await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded")
|
||||||
|
try:
|
||||||
|
await page.wait_for_url("**/platform/post/create", timeout=120000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 上传表单在 micro/content/post/create 这个 iframe 里,domcontentloaded 时它还是空的。
|
# 上传表单在 micro/content/post/create 这个 iframe 里,domcontentloaded 时它还是空的。
|
||||||
# 不等网络静默就去找 input[type=file],会误报「未找到视频号文件上传框」——
|
# 不等网络静默就去找 input[type=file],会误报「未找到视频号文件上传框」——
|
||||||
@@ -578,12 +605,16 @@ class TencentBaseUploader(BaseVideoUploader):
|
|||||||
|
|
||||||
fi = await find_file_input()
|
fi = await find_file_input()
|
||||||
if fi is None:
|
if fi is None:
|
||||||
# 助手落在首页:先点「发表视频」唤出编辑器与上传控件
|
# 助手落在首页:点可见的「发表视频」按钮(button.weui-desktop-btn)唤出发布表单。
|
||||||
publish_btn = page.get_by_text("发表视频").first
|
# 不能用 get_by_text("发表视频")——它会命中隐藏的说明 <p>,点了无效。
|
||||||
if await publish_btn.count():
|
publish_btn = page.locator("button.weui-desktop-btn", has_text="发表视频").first
|
||||||
|
try:
|
||||||
|
if await publish_btn.count() and await publish_btn.is_visible():
|
||||||
await publish_btn.click()
|
await publish_btn.click()
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
# 60 秒:实测这个 iframe 挂上 input 要几十秒,20 秒会误报「找不到上传框」
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 60 秒:实测这个 iframe 挂上 input 要几十秒,20 秒会误报「找不到上传框」。
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
fi = await find_file_input()
|
fi = await find_file_input()
|
||||||
if fi is not None:
|
if fi is not None:
|
||||||
@@ -606,17 +637,54 @@ class TencentBaseUploader(BaseVideoUploader):
|
|||||||
await fi.set_input_files(file_path)
|
await fi.set_input_files(file_path)
|
||||||
|
|
||||||
async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
|
async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None:
|
||||||
short_title_element = (
|
# 视频号「短标题」即界面上要求填写的“标题”(那个大编辑区其实是“视频描述”)。
|
||||||
|
# 走 format_str_for_short_title 保证长度落在 7~15,避免发布时被校验拦下。
|
||||||
|
value = format_str_for_short_title(short_title or title)
|
||||||
|
# 优先用 placeholder 定位(已 dump 验证更稳),兜底旧的“短标题”相邻 input。
|
||||||
|
field = page.locator('input[placeholder="填写短标题有机会获得更多流量"]').first
|
||||||
|
if not await field.count():
|
||||||
|
field = (
|
||||||
page.get_by_text("短标题", exact=True)
|
page.get_by_text("短标题", exact=True)
|
||||||
.locator("..")
|
.locator("..")
|
||||||
.locator("xpath=following-sibling::div")
|
.locator("xpath=following-sibling::div")
|
||||||
.locator('span input[type="text"]')
|
.locator('span input[type="text"]')
|
||||||
)
|
)
|
||||||
if await short_title_element.count():
|
if await field.count():
|
||||||
await short_title_element.fill(short_title or format_str_for_short_title(title))
|
await field.fill(value)
|
||||||
|
tencent_logger.info(_msg("🏷️", f"短标题已填写({len(value)}字):{value}"))
|
||||||
|
else:
|
||||||
|
tencent_logger.info(_msg("🧾", "未找到短标题输入框,跳过短标题"))
|
||||||
|
|
||||||
|
async def _dismiss_switch_account_dialog(self, page: Page) -> None:
|
||||||
|
# 视频号上传后偶发弹出「切换视频号」对话框(.changeAccount-dialog / .common-dialog)遮挡发布表单。
|
||||||
|
# 它带「取消」按钮、非强制,点「取消」/ 右上角 × / Esc 跳过即可,用当前账号继续发布。
|
||||||
|
cancel = page.locator('.changeAccount-dialog button:has-text("取消")').first
|
||||||
|
closeb = page.locator('.changeAccount-dialog .weui-desktop-dialog__close-btn').first
|
||||||
|
for cand in (cancel, closeb):
|
||||||
|
try:
|
||||||
|
if await cand.count() and await cand.is_visible():
|
||||||
|
await cand.click(timeout=2000)
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def fill_title_and_tags(self, page: Page) -> None:
|
async def fill_title_and_tags(self, page: Page) -> None:
|
||||||
await page.locator("div.input-editor").click()
|
# 上传后偶发「切换视频号」弹窗遮挡描述框,点不动就关弹窗重试(以能点中描述框为成功标志)。
|
||||||
|
for _ in range(4):
|
||||||
|
try:
|
||||||
|
await page.locator("div.input-editor").click(timeout=5000)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
await self._dismiss_switch_account_dialog(page)
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
else:
|
||||||
|
await page.locator("div.input-editor").click(timeout=8000)
|
||||||
await page.keyboard.type(self.title)
|
await page.keyboard.type(self.title)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
for tag in self.tags:
|
for tag in self.tags:
|
||||||
@@ -630,113 +698,77 @@ class TencentBaseUploader(BaseVideoUploader):
|
|||||||
tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}"))
|
tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}"))
|
||||||
|
|
||||||
async def apply_collection(self, page: Page) -> None:
|
async def apply_collection(self, page: Page) -> None:
|
||||||
collection_elements = (
|
"""在发布表单页"添加到合集"下拉框按合集名精确选中(页面结构:option-item > .item > .name/.desc)。
|
||||||
page.get_by_text("添加到合集")
|
|
||||||
.locator("xpath=following-sibling::div")
|
找不到匹配名字的合集时不展开/不选(保持未选状态直接发布,界面允许留空,
|
||||||
.locator(".option-list-wrap > div")
|
不阻断主发布流程)。旧实现是"下拉项数>1就选第一项",等价于随机选,已改为精确匹配。
|
||||||
|
"""
|
||||||
|
if not self.collection_name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
trigger = page.get_by_text("添加到合集").first
|
||||||
|
if await trigger.count() == 0:
|
||||||
|
tencent_logger.info(_msg("🧾", "当前页面未发现「添加到合集」入口,跳过归集"))
|
||||||
|
return
|
||||||
|
dropdown = trigger.locator("xpath=following-sibling::div").first
|
||||||
|
await dropdown.click(timeout=8000)
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
option = dropdown.locator(".option-list-wrap .option-item").filter(
|
||||||
|
has=page.locator(f'.name:text-is("{self.collection_name}")')
|
||||||
)
|
)
|
||||||
if await collection_elements.count() > 1:
|
if await option.count() == 0:
|
||||||
await page.get_by_text("添加到合集").locator("xpath=following-sibling::div").click()
|
tencent_logger.warning(
|
||||||
await collection_elements.first.click()
|
_msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态")
|
||||||
|
)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_timeout(300)
|
||||||
|
return
|
||||||
|
|
||||||
|
# headless 下 option 常报 "element is not visible":下拉列表开在视口外/
|
||||||
|
# 在 .option-list-wrap 滚动容器内,headful(大窗口)时在视野里能直接点中,
|
||||||
|
# headless 默认视口小就点不中。先把目标 option 滚进视野再点;普通 click
|
||||||
|
# 仍被判不可见时 → force 点击(跳过可见性 actionability)→ 派发原生 click 兜底。
|
||||||
|
target = option.first
|
||||||
|
try:
|
||||||
|
await target.scroll_into_view_if_needed(timeout=3000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await target.click(timeout=4000)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await target.click(force=True, timeout=4000)
|
||||||
|
except Exception:
|
||||||
|
await target.dispatch_event("click")
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
tencent_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
|
||||||
|
except Exception as exc:
|
||||||
|
tencent_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}"))
|
||||||
|
try:
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def apply_original_statement(self, page: Page) -> None:
|
async def apply_original_statement(self, page: Page) -> None:
|
||||||
original_set = False
|
# 视频号「视频标注」下拉:本项目成片经 AI 处理(TTS 配音、AI 字幕、AI 前贴片),
|
||||||
if await page.get_by_label("视频为原创").count():
|
# 依平台合规要求如实选「含AI生成内容」(与「内容为转载」等并列,选定即可、无需填写来源)。
|
||||||
await page.get_by_label("视频为原创").check()
|
# 注意:这与上方独立的「声明原创」复选框是两个不同字段,本项目走 AI 标注、不勾原创声明。
|
||||||
original_set = True
|
label_text = getattr(self, "content_label", None) or "含AI生成内容"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
label_locator = await page.locator('label:has-text("我已阅读并同意 《视频号原创声明使用条款》")').is_visible()
|
entry = page.get_by_text("选择视频标注", exact=True).first
|
||||||
except Exception:
|
if not await entry.count():
|
||||||
label_locator = False
|
tencent_logger.info(_msg("🧾", "当前页面未发现「视频标注」入口,跳过标注继续发布"))
|
||||||
|
return
|
||||||
if label_locator:
|
await entry.click()
|
||||||
await page.get_by_label("我已阅读并同意 《视频号原创声明使用条款》").check()
|
await page.wait_for_timeout(800)
|
||||||
await page.get_by_role("button", name="声明原创").click()
|
option = page.get_by_text(label_text, exact=True).first
|
||||||
original_set = True
|
await option.wait_for(state="visible", timeout=5000)
|
||||||
|
await option.click()
|
||||||
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)
|
await page.wait_for_timeout(500)
|
||||||
checked_locator = page.locator(
|
tencent_logger.success(_msg("🏷️", f"视频标注已选择:{label_text}"))
|
||||||
"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:
|
except Exception as exc:
|
||||||
tencent_logger.warning(_msg("😵", f"内容声明设置失败,继续前先人工确认页面: {exc}"))
|
tencent_logger.warning(_msg("😵", f"设置视频标注「{label_text}」失败,跳过继续发布:{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(
|
async def wait_for_upload_complete(
|
||||||
self, page: Page, timeout_seconds: int = 3600, max_retries: int = 3
|
self, page: Page, timeout_seconds: int = 3600, max_retries: int = 3
|
||||||
@@ -807,34 +839,61 @@ class TencentBaseUploader(BaseVideoUploader):
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
async def submit_publish(self, page: Page) -> None:
|
async def submit_publish(self, page: Page) -> None:
|
||||||
while True:
|
is_draft = getattr(self, "is_draft", False)
|
||||||
|
# 先等待并清理遮罩/弹窗,再等发表按钮出现
|
||||||
|
for wait_round in range(60):
|
||||||
|
await self._dismiss_switch_account_dialog(page)
|
||||||
try:
|
try:
|
||||||
if getattr(self, "is_draft", False):
|
await page.evaluate("""() => document.querySelectorAll('.mask, .changeAccount-dialog, .common-dialog').forEach(e => e.remove())""")
|
||||||
draft_button = page.locator('div.form-btns button:has-text("保存草稿")')
|
except Exception:
|
||||||
if await draft_button.count():
|
pass
|
||||||
await draft_button.click()
|
publish_btn = page.get_by_role("button", name="发表", exact=True).first if not is_draft else page.get_by_role("button", name="保存草稿").first
|
||||||
|
try:
|
||||||
|
if await publish_btn.count() and await publish_btn.is_visible():
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
tencent_logger.warning(_msg("😵", "60s 内未找到可见的发表/草稿按钮,尝试强制继续"))
|
||||||
|
# 点发表/草稿
|
||||||
|
for attempt in range(20):
|
||||||
|
try:
|
||||||
|
if await publish_btn.count():
|
||||||
|
try:
|
||||||
|
await publish_btn.click(timeout=4000)
|
||||||
|
except Exception:
|
||||||
|
await publish_btn.evaluate("el => el.click()")
|
||||||
|
if is_draft:
|
||||||
await page.wait_for_url("**/post/list**", timeout=5000)
|
await page.wait_for_url("**/post/list**", timeout=5000)
|
||||||
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
||||||
else:
|
else:
|
||||||
publish_button = page.locator('div.form-btns button:has-text("发表")')
|
# 发表成功后视频号可能跳 /platform(首页)、/post/list 或留在 create 页但按钮消失。
|
||||||
if await publish_button.count():
|
# 综合判断:URL 离开 /post/create 或 发表按钮不再存在。
|
||||||
await publish_button.click()
|
for _ in range(10):
|
||||||
await page.wait_for_url(TENCENT_MANAGE_URL, timeout=5000)
|
await asyncio.sleep(1)
|
||||||
|
cur = page.url
|
||||||
|
if "/post/create" not in cur:
|
||||||
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
||||||
break
|
return
|
||||||
|
if not await publish_btn.count():
|
||||||
|
tencent_logger.success(_msg("🥳", "视频发布成功(按钮已消失)"))
|
||||||
|
return
|
||||||
|
raise Exception("发表后 10s 页面未变化")
|
||||||
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
current_url = page.url
|
current_url = page.url
|
||||||
if getattr(self, "is_draft", False):
|
if is_draft and ("post/list" in current_url or "draft" in current_url):
|
||||||
if "post/list" in current_url or "draft" in current_url:
|
|
||||||
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
tencent_logger.success(_msg("🥳", "视频草稿保存成功"))
|
||||||
break
|
return
|
||||||
else:
|
if (not is_draft) and "/post/create" not in current_url:
|
||||||
if TENCENT_MANAGE_URL in current_url:
|
|
||||||
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
tencent_logger.success(_msg("🥳", "视频发布成功"))
|
||||||
break
|
return
|
||||||
tencent_logger.exception(f" [-] Exception: {exc}")
|
if attempt and attempt % 5 == 0:
|
||||||
|
tencent_logger.warning(_msg("😵", f"发布仍未完成(第{attempt}次),异常: {str(exc)[:60]}"))
|
||||||
tencent_logger.info(_msg("🏃", "视频正在发布中..."))
|
tencent_logger.info(_msg("🏃", "视频正在发布中..."))
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(1)
|
||||||
|
raise RuntimeError("发布未在预期时间内完成,请检查发布页面")
|
||||||
|
|
||||||
|
|
||||||
class TencentVideo(TencentBaseUploader):
|
class TencentVideo(TencentBaseUploader):
|
||||||
@@ -931,8 +990,7 @@ class TencentVideo(TencentBaseUploader):
|
|||||||
file_input = cover_dialog.locator('.single-cover-uploader-wrap input[type="file"]').first
|
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.wait_for(state="attached", timeout=10000)
|
||||||
await file_input.set_input_files(thumbnail_path)
|
await file_input.set_input_files(thumbnail_path)
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(2000)
|
||||||
await self.confirm_thumbnail_crop(page)
|
|
||||||
|
|
||||||
confirm_button = cover_dialog.locator(
|
confirm_button = cover_dialog.locator(
|
||||||
'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")'
|
'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")'
|
||||||
@@ -997,7 +1055,9 @@ class TencentVideo(TencentBaseUploader):
|
|||||||
async def prepare_video_for_publish(self, page: Page) -> None:
|
async def prepare_video_for_publish(self, page: Page) -> None:
|
||||||
await self.fill_title_and_tags(page)
|
await self.fill_title_and_tags(page)
|
||||||
await self.fill_description(page)
|
await self.fill_description(page)
|
||||||
await self.apply_collection(page)
|
# 合集不在这里选:此时视频还在上传,上传完成后表单会刷新,
|
||||||
|
# 上传中选的合集会被重置/不绑定("日志说选了、后台没加"的根因)。
|
||||||
|
# 改到 wait_for_upload_complete 之后再选,见 upload()。
|
||||||
|
|
||||||
async def upload(self, playwright: Playwright) -> None:
|
async def upload(self, playwright: Playwright) -> None:
|
||||||
tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间"))
|
tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间"))
|
||||||
@@ -1015,6 +1075,8 @@ class TencentVideo(TencentBaseUploader):
|
|||||||
await self.upload_video_file(page, self.file_path)
|
await self.upload_video_file(page, self.file_path)
|
||||||
await self.prepare_video_for_publish(page)
|
await self.prepare_video_for_publish(page)
|
||||||
await self.wait_for_upload_complete(page)
|
await self.wait_for_upload_complete(page)
|
||||||
|
# 上传完成、表单稳定后再选合集(否则上传中选的会被重置)
|
||||||
|
await self.apply_collection(page)
|
||||||
await self.apply_original_statement(page)
|
await self.apply_original_statement(page)
|
||||||
await self.set_thumbnail(page)
|
await self.set_thumbnail(page)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user