Merge pull request #266: Kuaishou upload fixes
This commit is contained in:
@@ -101,6 +101,7 @@ class KuaishouVideoUploadRequest:
|
|||||||
publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE
|
publish_strategy: str = KUAISHOU_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)
|
||||||
@@ -428,6 +429,7 @@ async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> 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.main()
|
await app.main()
|
||||||
return account_file
|
return account_file
|
||||||
@@ -694,6 +696,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
|
kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
|
||||||
kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
|
kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
|
||||||
kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
|
kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
|
||||||
|
kuaishou_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name to add the work into (must already exist)")
|
||||||
add_runtime_flags(kuaishou_upload_video_parser)
|
add_runtime_flags(kuaishou_upload_video_parser)
|
||||||
|
|
||||||
kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou")
|
kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou")
|
||||||
@@ -912,6 +915,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_kuaishou_video(request)
|
await upload_kuaishou_video(request)
|
||||||
print(f"Kuaishou video upload submitted: {request.video_file}")
|
print(f"Kuaishou video upload submitted: {request.video_file}")
|
||||||
|
|||||||
+174
-18
@@ -36,6 +36,62 @@ def _msg(emoji: str, text: str) -> str:
|
|||||||
return f"{emoji} {text}"
|
return f"{emoji} {text}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _dump_page_debug(page, tag: str) -> str:
|
||||||
|
"""出错时保存整页截图 + 当前 HTML,返回保存目录,便于对照新 DOM 修选择器。"""
|
||||||
|
import time
|
||||||
|
base = Path("ks_debug")
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
try:
|
||||||
|
await page.screenshot(path=str(base / f"{tag}_{ts}.png"), full_page=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
(base / f"{tag}_{ts}.html").write_text(await page.content(), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(base.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
async def _focus_desc_editor(page) -> None:
|
||||||
|
"""定位并聚焦快手发布页的『描述』编辑区。
|
||||||
|
|
||||||
|
快手创作者中心 DOM 时有改版,旧的
|
||||||
|
get_by_text("描述").locator("xpath=following-sibling::div")
|
||||||
|
一旦结构变化就会干等 30s 超时。这里按多种策略依次尝试(都锚定在「描述」
|
||||||
|
标签附近,避免误点到标题框),每种短超时快速失败;全部失败则保存截图/HTML
|
||||||
|
供排查后抛出明确错误,而不是无脑超时。
|
||||||
|
"""
|
||||||
|
label = page.get_by_text("描述") # 默认子串匹配,"作品描述" 等也能命中
|
||||||
|
strategies = [
|
||||||
|
# 旧结构:『描述』相邻 div
|
||||||
|
lambda: label.locator("xpath=following-sibling::div"),
|
||||||
|
# 新版描述区通常是紧随其后的富文本可编辑区
|
||||||
|
lambda: label.locator("xpath=following::div[@contenteditable='true'][1]"),
|
||||||
|
# 同容器内的可编辑区
|
||||||
|
lambda: label.locator("xpath=ancestor::*[1]//div[@contenteditable='true'][1]"),
|
||||||
|
# 兜底:其后第一个任意可编辑元素
|
||||||
|
lambda: label.locator("xpath=following::*[@contenteditable='true'][1]"),
|
||||||
|
]
|
||||||
|
last_err = None
|
||||||
|
for i, make in enumerate(strategies):
|
||||||
|
try:
|
||||||
|
loc = make().first
|
||||||
|
await loc.wait_for(state="visible", timeout=8000)
|
||||||
|
await loc.click(force=True)
|
||||||
|
if i > 0:
|
||||||
|
kuaishou_logger.warning(_msg(
|
||||||
|
"⚠️", f"描述区改用回退策略#{i}定位成功(快手可能已改版,建议核对选择器)"))
|
||||||
|
return
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
last_err = e
|
||||||
|
dbg = await _dump_page_debug(page, "desc_not_found")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"未能定位快手『描述』编辑区(疑似发布页改版)。已保存截图/HTML 到 {dbg},"
|
||||||
|
f"请据此更新选择器。最后错误: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _print_ks_qrcode(qrcode_content: str, qrcode_path: Path) -> None:
|
def _print_ks_qrcode(qrcode_content: str, qrcode_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP", compact=False, border=2)
|
print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP", compact=False, border=2)
|
||||||
@@ -161,12 +217,33 @@ async def cookie_auth(account_file):
|
|||||||
context = await set_init_script(context)
|
context = await set_init_script(context)
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
await page.goto(KUAISHOU_UPLOAD_URL)
|
await page.goto(KUAISHOU_UPLOAD_URL)
|
||||||
if await _is_ks_cookie_invalid(page):
|
await page.wait_for_timeout(3000)
|
||||||
kuaishou_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下"))
|
|
||||||
|
# 检查是否被重定向到登录页
|
||||||
|
if "passport.kuaishou.com" in page.url:
|
||||||
|
kuaishou_logger.info(_msg("🥹", "cookie 已失效(跳到登录页)"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
kuaishou_logger.success(_msg("🥳", "cookie 有效"))
|
# 检查是否停留在介绍页(未登录状态显示"立即登录"按钮)
|
||||||
return True
|
login_btn = page.get_by_text("立即登录")
|
||||||
|
if await login_btn.count() > 0:
|
||||||
|
kuaishou_logger.info(_msg("🥹", "cookie 已失效(介绍页)"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 正向证明:上传按钮存在 = 真正已登录
|
||||||
|
try:
|
||||||
|
upload_btn = page.locator("button[class^='_upload-btn']")
|
||||||
|
await upload_btn.wait_for(state="visible", timeout=10000)
|
||||||
|
kuaishou_logger.success(_msg("🥳", "cookie 有效"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# 兜底:旧版检测("机构服务"元素出现在未登录介绍页)
|
||||||
|
if await _is_ks_cookie_invalid(page):
|
||||||
|
kuaishou_logger.info(_msg("🥹", "cookie 已失效(机构服务页)"))
|
||||||
|
return False
|
||||||
|
# 都没命中:保守判定为失效,避免假阳性
|
||||||
|
kuaishou_logger.warning(_msg("😵", "无法确认 cookie 有效性,按失效处理"))
|
||||||
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
kuaishou_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
|
kuaishou_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
|
||||||
return False
|
return False
|
||||||
@@ -359,25 +436,59 @@ class KSBaseUploader(BaseVideoUploader):
|
|||||||
kuaishou_logger.info(f"✅ 定时发布时间已设置为 {publish_date_str}")
|
kuaishou_logger.info(f"✅ 定时发布时间已设置为 {publish_date_str}")
|
||||||
|
|
||||||
async def close_guide_overlay(self, page: Page) -> bool:
|
async def close_guide_overlay(self, page: Page) -> bool:
|
||||||
joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]')
|
"""关闭快手创作者平台的 Joyride 引导遮罩。
|
||||||
|
|
||||||
# 判断是否显示
|
Joyride 有两个关键元素:
|
||||||
|
1. tooltip (alertdialog) — 引导提示框,有关闭按钮
|
||||||
|
2. spotlight (react-joyride__spotlight) — 聚光灯遮罩层,拦截点击事件
|
||||||
|
两者可能独立存在。必须都关掉才能正常操作页面。
|
||||||
|
"""
|
||||||
|
closed = False
|
||||||
|
|
||||||
|
# 方式1:点击 tooltip 的关闭/跳过按钮
|
||||||
|
joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]')
|
||||||
if await joyride_tooltip.count() > 0 and await joyride_tooltip.first.is_visible():
|
if await joyride_tooltip.count() > 0 and await joyride_tooltip.first.is_visible():
|
||||||
print("检测到 Joyride 引导遮罩,正在关闭...")
|
print("检测到 Joyride 引导遮罩,正在关闭...")
|
||||||
|
# 尝试多种关闭按钮 selector
|
||||||
|
close_selectors = [
|
||||||
|
'[aria-label="Skip"], [data-action="skip"], button[title="Skip"]',
|
||||||
|
'button:text("跳过")',
|
||||||
|
'button:text("我知道了")',
|
||||||
|
'button:text("关闭")',
|
||||||
|
'button:text("下一步")', # 有时需要多步跳过
|
||||||
|
]
|
||||||
|
for sel in close_selectors:
|
||||||
|
btn = page.locator('div[role="alertdialog"]').locator(sel)
|
||||||
|
if await btn.count() > 0:
|
||||||
|
await btn.first.click(force=True)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
break
|
||||||
|
closed = True
|
||||||
|
|
||||||
# 点击关闭按钮(X),使用多个可靠特征
|
# 方式2:直接移除 Joyride portal(兜底,确保 spotlight 不再拦截)
|
||||||
close_button = page.locator('div[role="alertdialog"]').locator(
|
joyride_portal = page.locator('div#react-joyride-portal')
|
||||||
'[aria-label="Skip"], [data-action="skip"], button[title="Skip"]'
|
if await joyride_portal.count() > 0:
|
||||||
)
|
try:
|
||||||
|
await page.evaluate("document.getElementById('react-joyride-portal')?.remove()")
|
||||||
|
print("✅ 已移除 Joyride portal 遮罩")
|
||||||
|
closed = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
await close_button.click(force=True)
|
# 方式3:移除 spotlight 元素
|
||||||
|
spotlight = page.locator('div.react-joyride__spotlight')
|
||||||
|
if await spotlight.count() > 0:
|
||||||
|
try:
|
||||||
|
await page.evaluate("document.querySelectorAll('.react-joyride__spotlight').forEach(e => e.remove())")
|
||||||
|
print("✅ 已移除 Joyride spotlight")
|
||||||
|
closed = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 等待遮罩消失
|
if not closed:
|
||||||
await joyride_tooltip.wait_for(state="hidden", timeout=5000)
|
|
||||||
|
|
||||||
print("✅ 已关闭 Joyride 遮罩")
|
|
||||||
else:
|
|
||||||
print("未检测到 Joyride 遮罩,继续执行")
|
print("未检测到 Joyride 遮罩,继续执行")
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
class KSVideo(KSBaseUploader):
|
class KSVideo(KSBaseUploader):
|
||||||
@@ -393,6 +504,7 @@ class KSVideo(KSBaseUploader):
|
|||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
thumbnail_path=None,
|
thumbnail_path=None,
|
||||||
desc: str | None = None,
|
desc: str | None = None,
|
||||||
|
collection_name: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
publish_date=publish_date,
|
publish_date=publish_date,
|
||||||
@@ -406,6 +518,46 @@ class KSVideo(KSBaseUploader):
|
|||||||
self.tags = tags or []
|
self.tags = tags or []
|
||||||
self.thumbnail_path = thumbnail_path
|
self.thumbnail_path = thumbnail_path
|
||||||
self.desc = desc or ""
|
self.desc = desc or ""
|
||||||
|
self.collection_name = collection_name
|
||||||
|
|
||||||
|
async def apply_collection(self, page: Page) -> None:
|
||||||
|
"""在发布表单页选择"加入合集"下拉框(Ant Design Select,label 属性=合集名)。
|
||||||
|
|
||||||
|
锚点用 label 文字"加入合集"精确定位紧邻的 ant-select 容器,避免误选页面上
|
||||||
|
其它下拉框(服务类型/关联热点/作者声明/添加地点,同页面还有好几个 ant-select)。
|
||||||
|
找不到匹配名字的合集选项时按 Escape 收起下拉,保持未选状态直接发布(界面允许留空,
|
||||||
|
不阻断主发布流程)。
|
||||||
|
"""
|
||||||
|
if not self.collection_name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
trigger = page.locator(
|
||||||
|
'label:text-is("加入合集")'
|
||||||
|
).locator("xpath=following-sibling::div[contains(@class,'ant-select')]").first
|
||||||
|
if await trigger.count() == 0:
|
||||||
|
kuaishou_logger.warning(_msg("😵", "未找到\"加入合集\"下拉框,跳过归集"))
|
||||||
|
return
|
||||||
|
await trigger.locator(".ant-select-selector").click(timeout=8000)
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
option = page.locator(f'div.ant-select-item-option[label="{self.collection_name}"]')
|
||||||
|
if await option.count() == 0:
|
||||||
|
kuaishou_logger.warning(
|
||||||
|
_msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态")
|
||||||
|
)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_timeout(300)
|
||||||
|
return
|
||||||
|
|
||||||
|
await option.first.click(timeout=8000)
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
kuaishou_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
|
||||||
|
except Exception as exc:
|
||||||
|
kuaishou_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}"))
|
||||||
|
try:
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def validate_upload_args(self):
|
async def validate_upload_args(self):
|
||||||
await self.validate_base_args()
|
await self.validate_base_args()
|
||||||
@@ -494,7 +646,9 @@ class KSVideo(KSBaseUploader):
|
|||||||
await self.close_guide_overlay(page)
|
await self.close_guide_overlay(page)
|
||||||
|
|
||||||
kuaishou_logger.info(_msg("✍️", "小人开始填描述和话题"))
|
kuaishou_logger.info(_msg("✍️", "小人开始填描述和话题"))
|
||||||
await page.get_by_text("描述").locator("xpath=following-sibling::div").click()
|
# 再次检查并关闭 Joyride(可能在文件上传后才弹出)
|
||||||
|
await self.close_guide_overlay(page)
|
||||||
|
await _focus_desc_editor(page)
|
||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.press("Control+KeyA")
|
await page.keyboard.press("Control+KeyA")
|
||||||
await page.keyboard.press("Delete")
|
await page.keyboard.press("Delete")
|
||||||
@@ -532,6 +686,8 @@ class KSVideo(KSBaseUploader):
|
|||||||
|
|
||||||
await self.set_thumbnail(page)
|
await self.set_thumbnail(page)
|
||||||
|
|
||||||
|
await self.apply_collection(page)
|
||||||
|
|
||||||
if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
||||||
await self.set_schedule_time(page, self.publish_date)
|
await self.set_schedule_time(page, self.publish_date)
|
||||||
|
|
||||||
@@ -634,7 +790,7 @@ class KSNote(KSBaseUploader):
|
|||||||
await self.close_guide_overlay(page)
|
await self.close_guide_overlay(page)
|
||||||
|
|
||||||
kuaishou_logger.info(_msg("✍️", "小人开始填写图文内容和话题"))
|
kuaishou_logger.info(_msg("✍️", "小人开始填写图文内容和话题"))
|
||||||
await page.get_by_text("描述").locator("xpath=following-sibling::div").click()
|
await _focus_desc_editor(page)
|
||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.press("Control+KeyA")
|
await page.keyboard.press("Control+KeyA")
|
||||||
await page.keyboard.press("Delete")
|
await page.keyboard.press("Delete")
|
||||||
|
|||||||
Reference in New Issue
Block a user