返回 Social Auto Upload
main.py
1 # -*- coding: utf-8 -*-
2 from __future__ import annotations
3
4 import asyncio
5 import inspect
6 import os
7 from datetime import datetime
8 from pathlib import Path
9
10 from patchright.async_api import Page
11 from patchright.async_api import Playwright
12 from patchright.async_api import async_playwright
13
14 from conf import DEBUG_MODE, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH
15 from uploader.base_video import BaseVideoUploader
16 from utils.base_social_media import set_init_script
17 from utils.files_times import get_absolute_path
18 from utils.login_qrcode import build_login_qrcode_path
19 from utils.login_qrcode import decode_qrcode_from_path
20 from utils.login_qrcode import print_terminal_qrcode
21 from utils.login_qrcode import remove_qrcode_file
22 from utils.login_qrcode import save_data_url_image
23 from utils.log import kuaishou_logger
24
25 KUAISHOU_UPLOAD_URL = "https://cp.kuaishou.com/article/publish/video"
26 KUAISHOU_MANAGE_URL = "https://cp.kuaishou.com/article/manage/video?status=2&from=publish"
27 KUAISHOU_LOGIN_URL = "https://passport.kuaishou.com/pc/account/login/?sid=kuaishou.web.cp.api&callback=https%3A%2F%2Fcp.kuaishou.com%2Frest%2Finfra%2Fsts%3FfollowUrl%3Dhttps%253A%252F%252Fcp.kuaishou.com%252Farticle%252Fpublish%252Fvideo%26setRootDomain%3Dtrue"
28 KUAISHOU_UPLOAD_URL_PATTERN = "**/article/publish/video**"
29 KUAISHOU_MANAGE_URL_PATTERN = "**/article/manage/video?status=2&from=publish**"
30 KUAISHOU_COOKIE_INVALID_SELECTOR = "div.names div.container div.name:text('机构服务')"
31 KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE = "immediate"
32 KUAISHOU_PUBLISH_STRATEGY_SCHEDULED = "scheduled"
33 KUAISHOU_UPLOAD_TIMEOUT_SECONDS = 480
34 KUAISHOU_PUBLISH_ATTEMPTS = 3
35
36
37 def _msg(emoji: str, text: str) -> str:
38 return f"{emoji} {text}"
39
40
41 async def _dump_page_debug(page, tag: str) -> str:
42 """出错时保存整页截图 + 当前 HTML,返回保存目录,便于对照新 DOM 修选择器。"""
43 import time
44 base = Path("ks_debug")
45 base.mkdir(parents=True, exist_ok=True)
46 ts = time.strftime("%Y%m%d_%H%M%S")
47 try:
48 await page.screenshot(path=str(base / f"{tag}_{ts}.png"), full_page=True)
49 except Exception:
50 pass
51 try:
52 (base / f"{tag}_{ts}.html").write_text(await page.content(), encoding="utf-8")
53 except Exception:
54 pass
55 return str(base.resolve())
56
57
58 async def _focus_desc_editor(page) -> None:
59 """定位并聚焦快手发布页的『描述』编辑区。
60
61 快手创作者中心 DOM 时有改版,旧的
62 get_by_text("描述").locator("xpath=following-sibling::div")
63 一旦结构变化就会干等 30s 超时。这里按多种策略依次尝试(都锚定在「描述」
64 标签附近,避免误点到标题框),每种短超时快速失败;全部失败则保存截图/HTML
65 供排查后抛出明确错误,而不是无脑超时。
66 """
67 label = page.get_by_text("描述") # 默认子串匹配,"作品描述" 等也能命中
68 strategies = [
69 # 旧结构:『描述』相邻 div
70 lambda: label.locator("xpath=following-sibling::div"),
71 # 新版描述区通常是紧随其后的富文本可编辑区
72 lambda: label.locator("xpath=following::div[@contenteditable='true'][1]"),
73 # 同容器内的可编辑区
74 lambda: label.locator("xpath=ancestor::*[1]//div[@contenteditable='true'][1]"),
75 # 兜底:其后第一个任意可编辑元素
76 lambda: label.locator("xpath=following::*[@contenteditable='true'][1]"),
77 ]
78 last_err = None
79 for i, make in enumerate(strategies):
80 try:
81 loc = make().first
82 await loc.wait_for(state="visible", timeout=8000)
83 await loc.click(force=True)
84 if i > 0:
85 kuaishou_logger.warning(_msg(
86 "⚠️", f"描述区改用回退策略#{i}定位成功(快手可能已改版,建议核对选择器)"))
87 return
88 except Exception as e: # noqa: BLE001
89 last_err = e
90 dbg = await _dump_page_debug(page, "desc_not_found")
91 raise RuntimeError(
92 f"未能定位快手『描述』编辑区(疑似发布页改版)。已保存截图/HTML 到 {dbg},"
93 f"请据此更新选择器。最后错误: {last_err}")
94
95
96 async def _click_visible_publish_confirm(page: Page) -> bool:
97 """Confirm an already-open Ant Design publish dialog before touching the page behind it."""
98 modal = page.locator("div.ant-modal-confirm-centered:visible").first
99 if not await modal.count():
100 return False
101
102 primary_button = modal.locator("button.ant-btn-primary:visible").first
103 if not await primary_button.count():
104 raise RuntimeError("快手发布确认弹窗已显示,但未找到可点击的主按钮")
105
106 await primary_button.click(timeout=8000)
107 return True
108
109
110 def _print_ks_qrcode(qrcode_content: str, qrcode_path: Path) -> None:
111 try:
112 print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP", compact=False, border=2)
113 except TypeError as exc:
114 if "unexpected keyword argument 'compact'" not in str(exc):
115 raise
116 kuaishou_logger.warning(_msg("😵", "检测到旧版二维码打印函数,小人切回兼容模式继续登录"))
117 print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP")
118
119
120 async def _emit_qrcode_callback(qrcode_callback, payload: dict):
121 if not qrcode_callback:
122 return
123
124 callback_result = qrcode_callback(payload)
125 if inspect.isawaitable(callback_result):
126 await callback_result
127
128
129 def _build_login_result(
130 success: bool,
131 status: str,
132 message: str,
133 account_file: str,
134 qrcode: dict | None = None,
135 current_url: str = "",
136 ) -> dict:
137 return {
138 "success": success,
139 "status": status,
140 "message": message,
141 "account_file": str(account_file),
142 "qrcode": qrcode,
143 "current_url": current_url,
144 }
145
146
147 async def _is_ks_cookie_invalid(page: Page, timeout: int = 5000) -> bool:
148 try:
149 await page.wait_for_selector(KUAISHOU_COOKIE_INVALID_SELECTOR, timeout=timeout)
150 return True
151 except Exception:
152 return False
153
154
155 async def _extract_ks_qrcode_src(page: Page) -> str:
156 login_form = page.locator("main#login-form").first
157 await login_form.wait_for(state="visible", timeout=30000)
158
159 qrcode_img = login_form.locator('div.qr-login img[alt="qrcode"]').first
160 try:
161 if not await qrcode_img.count() or not await qrcode_img.is_visible():
162 platform_switch = login_form.locator("div.platform-switch").first
163 await platform_switch.wait_for(state="visible", timeout=10000)
164 await platform_switch.click()
165 await asyncio.sleep(1)
166 except Exception:
167 platform_switch = login_form.locator("div.platform-switch").first
168 await platform_switch.wait_for(state="visible", timeout=10000)
169 await platform_switch.click()
170 await asyncio.sleep(1)
171
172 await qrcode_img.wait_for(state="visible", timeout=15000)
173
174 qrcode_src = await qrcode_img.get_attribute("src")
175 if not qrcode_src:
176 raise RuntimeError("未获取到快手登录二维码地址")
177
178 return qrcode_src
179
180
181 async def _save_ks_qrcode(page: Page, account_file: str, previous_qrcode_path: Path | None = None, qrcode_callback=None) -> dict:
182 qrcode_src = await _extract_ks_qrcode_src(page)
183 qrcode_path = save_data_url_image(qrcode_src, build_login_qrcode_path(account_file, suffix="ks_login_qrcode"))
184
185 if previous_qrcode_path and previous_qrcode_path != qrcode_path:
186 if remove_qrcode_file(previous_qrcode_path):
187 kuaishou_logger.info(_msg("🧹", f"临时二维码文件已清理: {previous_qrcode_path}"))
188
189 kuaishou_logger.info(_msg("🖼️", f"二维码已经准备好啦,已保存到: {qrcode_path}"))
190 qrcode_content = decode_qrcode_from_path(qrcode_path)
191 if qrcode_content:
192 _print_ks_qrcode(qrcode_content, qrcode_path)
193 else:
194 kuaishou_logger.warning(_msg("😵", f"终端没法完整显示二维码,请打开 {qrcode_path} 扫码"))
195
196 qrcode_info = {
197 "image_path": str(qrcode_path),
198 "image_data_url": qrcode_src,
199 }
200 await _emit_qrcode_callback(qrcode_callback, qrcode_info)
201 return qrcode_info
202
203
204 async def _is_ks_qrcode_expired(page: Page) -> bool:
205 expired_box = page.locator("div.qrcode-status.qrcode-status-timeout").first
206 try:
207 if not await expired_box.count():
208 return False
209 return await expired_box.is_visible()
210 except Exception:
211 return False
212
213
214 async def _is_ks_login_page_gone(page: Page) -> bool:
215 try:
216 login_form = page.locator("main#login-form").first
217 if not await login_form.count():
218 return True
219 return not await login_form.is_visible()
220 except Exception:
221 return True
222
223
224 async def cookie_auth(account_file):
225 async with async_playwright() as playwright:
226 if LOCAL_CHROME_PATH:
227 browser = await playwright.chromium.launch(headless=True, executable_path=LOCAL_CHROME_PATH)
228 else:
229 browser = await playwright.chromium.launch(headless=True, channel="chromium")
230 try:
231 context = await browser.new_context(storage_state=account_file)
232 context = await set_init_script(context)
233 page = await context.new_page()
234 await page.goto(KUAISHOU_UPLOAD_URL)
235 await page.wait_for_timeout(3000)
236
237 # 检查是否被重定向到登录页
238 if "passport.kuaishou.com" in page.url:
239 kuaishou_logger.info(_msg("🥹", "cookie 已失效(跳到登录页)"))
240 return False
241
242 # 检查是否停留在介绍页(未登录状态显示"立即登录"按钮)
243 login_btn = page.get_by_text("立即登录")
244 if await login_btn.count() > 0:
245 kuaishou_logger.info(_msg("🥹", "cookie 已失效(介绍页)"))
246 return False
247
248 # 正向证明:上传按钮存在 = 真正已登录
249 try:
250 upload_btn = page.locator("button[class^='_upload-btn']")
251 await upload_btn.wait_for(state="visible", timeout=10000)
252 kuaishou_logger.success(_msg("🥳", "cookie 有效"))
253 return True
254 except Exception:
255 # 兜底:旧版检测("机构服务"元素出现在未登录介绍页)
256 if await _is_ks_cookie_invalid(page):
257 kuaishou_logger.info(_msg("🥹", "cookie 已失效(机构服务页)"))
258 return False
259 # 都没命中:保守判定为失效,避免假阳性
260 kuaishou_logger.warning(_msg("😵", "无法确认 cookie 有效性,按失效处理"))
261 return False
262 except Exception as exc:
263 kuaishou_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}"))
264 return False
265 finally:
266 await browser.close()
267
268
269 async def ks_setup(account_file, handle=False, return_detail=False, qrcode_callback=None, headless: bool = LOCAL_CHROME_HEADLESS, cdp_url: str | None = None):
270 account_file = get_absolute_path(account_file, "ks_uploader")
271 if not os.path.exists(account_file) or not await cookie_auth(account_file):
272 if not handle:
273 result = _build_login_result(False, "cookie_invalid", "cookie文件不存在或已失效", account_file)
274 return result if return_detail else False
275 kuaishou_logger.info(_msg("🥹", "cookie 失效了,准备重新登录快手创作者平台"))
276 result = await get_ks_cookie(account_file, qrcode_callback=qrcode_callback, headless=headless, cdp_url=cdp_url)
277 return result if return_detail else result["success"]
278
279 result = _build_login_result(True, "cookie_valid", "cookie有效", account_file)
280 return result if return_detail else True
281
282
283 async def get_ks_cookie(
284 account_file,
285 qrcode_callback=None,
286 headless: bool = LOCAL_CHROME_HEADLESS,
287 poll_interval: int = 3,
288 max_checks: int = 100,
289 cdp_url: str | None = None,
290 ):
291 if headless:
292 kuaishou_logger.info(_msg("🖼️", "快手登录将以无头模式运行,小人会输出终端二维码并保存本地二维码图片"))
293
294 async with async_playwright() as playwright:
295 if cdp_url:
296 browser = await playwright.chromium.connect_over_cdp(cdp_url)
297 context = browser.contexts[0] if browser.contexts else await browser.new_context()
298 should_close_context = False
299 else:
300 if LOCAL_CHROME_PATH:
301 browser = await playwright.chromium.launch(headless=headless, executable_path=LOCAL_CHROME_PATH)
302 else:
303 browser = await playwright.chromium.launch(headless=headless, channel="chromium")
304 context = await browser.new_context()
305 should_close_context = True
306 context = await set_init_script(context)
307 qrcode_path = None
308 qrcode_info = None
309 result = _build_login_result(False, "failed", "快手登录失败", account_file)
310 try:
311 page = await context.new_page()
312 await page.goto(KUAISHOU_LOGIN_URL)
313 kuaishou_logger.info(_msg("🧍", "请在浏览器里扫码登录快手,小人正在耐心等待"))
314
315 qrcode_info = await _save_ks_qrcode(page, account_file, qrcode_callback=qrcode_callback)
316 qrcode_path = Path(qrcode_info["image_path"])
317
318 for _ in range(max_checks):
319 if page.url.startswith(KUAISHOU_UPLOAD_URL) or await _is_ks_login_page_gone(page):
320 await context.storage_state(path=account_file)
321 if await cookie_auth(account_file):
322 kuaishou_logger.success(_msg("🥳", "快手扫码登录成功,小人开心收工"))
323 result = _build_login_result(True, "success", "快手扫码登录成功", account_file, qrcode_info, page.url)
324 else:
325 kuaishou_logger.error(_msg("😢", "快手扫码完成了,但 cookie 校验失败"))
326 result = _build_login_result(
327 False,
328 "cookie_invalid",
329 "快手扫码流程结束,但 cookie 校验失败",
330 account_file,
331 qrcode_info,
332 page.url,
333 )
334 return result
335
336 if qrcode_info and await _is_ks_qrcode_expired(page):
337 kuaishou_logger.warning(_msg("😵", "二维码失效了,小人马上去刷新"))
338 refresh_button = page.locator("p.qrcode-refresh").first
339 if await refresh_button.count():
340 await refresh_button.click()
341 await asyncio.sleep(1)
342 qrcode_info = await _save_ks_qrcode(
343 page,
344 account_file,
345 qrcode_path,
346 qrcode_callback=qrcode_callback,
347 )
348 qrcode_path = Path(qrcode_info["image_path"])
349
350 await asyncio.sleep(poll_interval)
351
352 result = _build_login_result(
353 False,
354 "timeout",
355 "等待快手扫码登录超时",
356 account_file,
357 qrcode_info,
358 page.url,
359 )
360 except Exception as exc:
361 result = _build_login_result(False, "failed", str(exc), account_file, current_url=page.url if "page" in locals() else "")
362 finally:
363 if remove_qrcode_file(qrcode_path):
364 kuaishou_logger.info(_msg("🧹", f"临时二维码文件已清理: {qrcode_path}"))
365 if not result["success"]:
366 kuaishou_logger.error(_msg("😢", f"登录失败: {result['message']}"))
367 if should_close_context:
368 await context.close()
369 await browser.close()
370
371 return result
372
373
374 class KSBaseUploader(BaseVideoUploader):
375 def __init__(
376 self,
377 publish_date: datetime | int,
378 account_file,
379 publish_strategy: str | None = None,
380 debug: bool = DEBUG_MODE,
381 headless: bool = LOCAL_CHROME_HEADLESS,
382 ):
383 self.publish_date = publish_date
384 self.account_file = str(account_file)
385 self.publish_strategy = publish_strategy
386 self.debug = debug
387 self.headless = headless
388 self.local_executable_path = LOCAL_CHROME_PATH
389 self.date_format = "%Y-%m-%d %H:%M"
390
391 async def validate_base_args(self):
392 if not os.path.exists(self.account_file):
393 raise RuntimeError(f"cookie文件不存在,请先完成快手登录: {self.account_file}")
394 if not await cookie_auth(self.account_file):
395 raise RuntimeError(f"cookie文件已失效,请先完成快手登录: {self.account_file}")
396
397 if self.publish_strategy is None:
398 self.publish_strategy = (
399 KUAISHOU_PUBLISH_STRATEGY_SCHEDULED
400 if self.publish_date != 0
401 else KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE
402 )
403
404 if self.publish_strategy not in {
405 KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE,
406 KUAISHOU_PUBLISH_STRATEGY_SCHEDULED,
407 }:
408 raise ValueError(f"不支持的发布策略: {self.publish_strategy}")
409
410 if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED:
411 self.publish_date = self.validate_publish_date(self.publish_date)
412 else:
413 self.publish_date = 0
414
415 async def set_schedule_time(self, page: Page, publish_date: datetime):
416 kuaishou_logger.info(_msg("🕒", "小人准备设置定时发布时间"))
417 publish_date_str = publish_date.strftime("%Y-%m-%d %H:%M:%S")
418
419 # 1. 切换到"定时发布"radio (用文本匹配更稳)
420 await page.locator('label.ant-radio-wrapper').filter(has_text="定时发布").click()
421 await asyncio.sleep(2)
422
423 # 2. 点击 picker 打开下拉面板
424 await page.locator('input[placeholder="选择日期时间"]').click()
425 await asyncio.sleep(1)
426
427 # 3. 用 React 兼容的方式直接设置 input 的 value
428 # (ant-design DatePicker 是 controlled component, 必须用 native setter + bubbling event)
429 js_code = """
430 (newValue) => {
431 const input = document.querySelector('input[placeholder="选择日期时间"]');
432 if (!input) return false;
433 const nativeSetter = Object.getOwnPropertyDescriptor(
434 window.HTMLInputElement.prototype, 'value'
435 ).set;
436 nativeSetter.call(input, newValue);
437 input.dispatchEvent(new Event('input', { bubbles: true }));
438 input.dispatchEvent(new Event('change', { bubbles: true }));
439 return true;
440 }
441 """
442 ok = await page.evaluate(js_code, publish_date_str)
443 if not ok:
444 kuaishou_logger.error("❌ 找不到时间选择器输入框")
445 return
446
447 await asyncio.sleep(1)
448 # 4. 按 Enter 确认
449 await page.keyboard.press("Enter")
450 await asyncio.sleep(2)
451 kuaishou_logger.info(f"✅ 定时发布时间已设置为 {publish_date_str}")
452
453 async def close_guide_overlay(self, page: Page) -> bool:
454 """关闭快手创作者平台的 Joyride 引导遮罩。
455
456 Joyride 有两个关键元素:
457 1. tooltip (alertdialog) — 引导提示框,有关闭按钮
458 2. spotlight (react-joyride__spotlight) — 聚光灯遮罩层,拦截点击事件
459 两者可能独立存在。必须都关掉才能正常操作页面。
460 """
461 closed = False
462
463 # 方式1:点击 tooltip 的关闭/跳过按钮
464 joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]')
465 if await joyride_tooltip.count() > 0 and await joyride_tooltip.first.is_visible():
466 print("检测到 Joyride 引导遮罩,正在关闭...")
467 # 尝试多种关闭按钮 selector
468 close_selectors = [
469 '[aria-label="Skip"], [data-action="skip"], button[title="Skip"]',
470 'button:text("跳过")',
471 'button:text("我知道了")',
472 'button:text("关闭")',
473 'button:text("下一步")', # 有时需要多步跳过
474 ]
475 for sel in close_selectors:
476 btn = page.locator('div[role="alertdialog"]').locator(sel)
477 if await btn.count() > 0:
478 await btn.first.click(force=True)
479 await asyncio.sleep(0.5)
480 break
481 closed = True
482
483 # 方式2:直接移除 Joyride portal(兜底,确保 spotlight 不再拦截)
484 joyride_portal = page.locator('div#react-joyride-portal')
485 if await joyride_portal.count() > 0:
486 try:
487 await page.evaluate("document.getElementById('react-joyride-portal')?.remove()")
488 print("✅ 已移除 Joyride portal 遮罩")
489 closed = True
490 except Exception:
491 pass
492
493 # 方式3:移除 spotlight 元素
494 spotlight = page.locator('div.react-joyride__spotlight')
495 if await spotlight.count() > 0:
496 try:
497 await page.evaluate("document.querySelectorAll('.react-joyride__spotlight').forEach(e => e.remove())")
498 print("✅ 已移除 Joyride spotlight")
499 closed = True
500 except Exception:
501 pass
502
503 if not closed:
504 print("未检测到 Joyride 遮罩,继续执行")
505 else:
506 await asyncio.sleep(0.5)
507
508
509 class KSVideo(KSBaseUploader):
510 def __init__(
511 self,
512 title,
513 file_path,
514 tags,
515 publish_date: datetime | int,
516 account_file,
517 publish_strategy: str | None = None,
518 debug: bool = DEBUG_MODE,
519 headless: bool = LOCAL_CHROME_HEADLESS,
520 thumbnail_path=None,
521 desc: str | None = None,
522 collection_name: str | None = None,
523 ):
524 super().__init__(
525 publish_date=publish_date,
526 account_file=account_file,
527 publish_strategy=publish_strategy,
528 debug=debug,
529 headless=headless,
530 )
531 self.title = title
532 self.file_path = file_path
533 self.tags = tags or []
534 self.thumbnail_path = thumbnail_path
535 self.desc = desc or ""
536 self.collection_name = collection_name
537
538 async def apply_collection(self, page: Page) -> None:
539 """在发布表单页选择"加入合集"下拉框(Ant Design Select,label 属性=合集名)。
540
541 锚点用 label 文字"加入合集"精确定位紧邻的 ant-select 容器,避免误选页面上
542 其它下拉框(服务类型/关联热点/作者声明/添加地点,同页面还有好几个 ant-select)。
543 找不到匹配名字的合集选项时按 Escape 收起下拉,保持未选状态直接发布(界面允许留空,
544 不阻断主发布流程)。
545 """
546 if not self.collection_name:
547 return
548 try:
549 trigger = page.locator(
550 'label:text-is("加入合集")'
551 ).locator("xpath=following-sibling::div[contains(@class,'ant-select')]").first
552 if await trigger.count() == 0:
553 kuaishou_logger.warning(_msg("😵", "未找到\"加入合集\"下拉框,跳过归集"))
554 return
555 await trigger.locator(".ant-select-selector").click(timeout=8000)
556 await page.wait_for_timeout(800)
557
558 option = page.locator(f'div.ant-select-item-option[label="{self.collection_name}"]')
559 if await option.count() == 0:
560 kuaishou_logger.warning(
561 _msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态")
562 )
563 await page.keyboard.press("Escape")
564 await page.wait_for_timeout(300)
565 return
566
567 await option.first.click(timeout=8000)
568 await page.wait_for_timeout(500)
569 kuaishou_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}"))
570 except Exception as exc:
571 kuaishou_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}"))
572 try:
573 await page.keyboard.press("Escape")
574 except Exception:
575 pass
576
577 async def validate_upload_args(self):
578 await self.validate_base_args()
579 if not self.title or not str(self.title).strip():
580 raise ValueError("快手视频上传时,title 是必须的")
581 self.file_path = str(self.validate_video_file(self.file_path))
582 if self.thumbnail_path:
583 self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
584
585 async def handle_upload_error(self, page: Page):
586 kuaishou_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传"))
587 await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path)
588
589 async def set_thumbnail(self, page: Page):
590 if not self.thumbnail_path:
591 return
592
593 kuaishou_logger.info(_msg("🖼️", "小人准备设置封面"))
594
595 cover_label = page.locator("span").filter(has_text="封面设置")
596 await cover_label.wait_for(state="visible", timeout=30000)
597 await cover_label.locator("xpath=../following-sibling::div[1]").locator('div').nth(0).click()
598
599 modal = page.locator('div[role="document"].ant-modal')
600 await modal.wait_for(state="visible", timeout=30000)
601
602 upload_cover_tab = modal.get_by_text("上传封面", exact=True)
603 await upload_cover_tab.wait_for(state="visible", timeout=10000)
604 await upload_cover_tab.click()
605
606 file_input = modal.locator('input[type="file"]')
607 await file_input.wait_for(state="attached", timeout=30000)
608 await file_input.set_input_files(self.thumbnail_path)
609 await asyncio.sleep(1)
610
611 confirm_button = modal.get_by_role("button", name="确认", exact=True)
612 await confirm_button.wait_for(state="visible", timeout=10000)
613 await confirm_button.click()
614
615 await modal.wait_for(state="hidden", timeout=30000)
616 kuaishou_logger.success(_msg("🥳", "封面已经设置完成"))
617
618 async def upload(self, playwright: Playwright) -> None:
619 kuaishou_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
620 await self.validate_upload_args()
621 kuaishou_logger.info(_msg("🥳", "上传前检查通过"))
622
623 if self.local_executable_path:
624 browser = await playwright.chromium.launch(
625 headless=self.headless,
626 executable_path=self.local_executable_path,
627 )
628 else:
629 browser = await playwright.chromium.launch(
630 headless=self.headless,
631 channel="chromium",
632 )
633 context = await browser.new_context(storage_state=self.account_file)
634 context = await set_init_script(context)
635
636 upload_success = False
637 try:
638 page = await context.new_page()
639 await page.goto(KUAISHOU_UPLOAD_URL)
640 kuaishou_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))
641 kuaishou_logger.info(_msg("🧭", "小人正在赶往快手上传主页"))
642 await page.wait_for_url(KUAISHOU_UPLOAD_URL_PATTERN)
643
644 upload_button = page.locator("button[class^='_upload-btn']")
645 await upload_button.wait_for(state="visible", timeout=10000)
646
647 async with page.expect_file_chooser() as fc_info:
648 await upload_button.click()
649 file_chooser = await fc_info.value
650 await file_chooser.set_files(self.file_path)
651
652 await asyncio.sleep(2)
653
654 know_button = page.locator('button[type="button"] span:text("我知道了")').first
655 try:
656 if await know_button.count() and await know_button.is_visible():
657 await know_button.click()
658 except Exception:
659 pass
660
661 await self.close_guide_overlay(page)
662
663 kuaishou_logger.info(_msg("✍️", "小人开始填描述和话题"))
664 # 再次检查并关闭 Joyride(可能在文件上传后才弹出)
665 await self.close_guide_overlay(page)
666 await _focus_desc_editor(page)
667 await page.keyboard.press("Backspace")
668 await page.keyboard.press("Control+KeyA")
669 await page.keyboard.press("Delete")
670 await page.keyboard.type(self.desc or self.title)
671 await page.keyboard.press("Enter")
672
673 for index, tag in enumerate(self.tags[:3], start=1):
674 kuaishou_logger.info(_msg("🏷️", f"小人正在添加第 {index} 个话题: #{tag}"))
675 await page.keyboard.type(f"#{tag} ")
676 await asyncio.sleep(2)
677
678 loop = asyncio.get_running_loop()
679 upload_deadline = loop.time() + KUAISHOU_UPLOAD_TIMEOUT_SECONDS
680 retry_count = 0
681 while loop.time() < upload_deadline:
682 try:
683 number = await page.locator("text=上传中").count()
684 if number == 0:
685 kuaishou_logger.success(_msg("🥳", "视频已经传完啦"))
686 break
687
688 if retry_count % 5 == 0:
689 kuaishou_logger.info(_msg("🏃", "小人正在努力上传视频"))
690
691 if await page.locator("text=上传失败").count():
692 await self.handle_upload_error(page)
693
694 await asyncio.sleep(2)
695 except Exception as exc:
696 kuaishou_logger.warning(_msg("😵", f"检查上传状态时出错,小人继续重试: {exc}"))
697 await asyncio.sleep(2)
698 retry_count += 1
699 else:
700 raise TimeoutError(
701 f"等待快手视频上传完成超时(>{KUAISHOU_UPLOAD_TIMEOUT_SECONDS}秒),已停止发布"
702 )
703
704 await self.set_thumbnail(page)
705
706 await self.apply_collection(page)
707
708 if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
709 await self.set_schedule_time(page, self.publish_date)
710
711 last_publish_error = None
712 for attempt in range(1, KUAISHOU_PUBLISH_ATTEMPTS + 1):
713 try:
714 confirmed = await _click_visible_publish_confirm(page)
715 if not confirmed:
716 publish_button = page.get_by_text("发布", exact=True)
717 if await publish_button.count() == 0:
718 raise RuntimeError("未找到快手发布按钮")
719 await publish_button.click()
720
721 await asyncio.sleep(1)
722 await _click_visible_publish_confirm(page)
723
724 await page.wait_for_url(KUAISHOU_MANAGE_URL_PATTERN, timeout=5000)
725 kuaishou_logger.success(_msg("🥳", "视频发布成功,小人开心收工"))
726 break
727 except Exception as exc:
728 last_publish_error = exc
729 kuaishou_logger.info(_msg(
730 "🏃", f"小人正在冲刺发布视频({attempt}/{KUAISHOU_PUBLISH_ATTEMPTS}): {exc}"
731 ))
732 if self.debug:
733 await page.screenshot(full_page=True)
734 await asyncio.sleep(1)
735 else:
736 raise RuntimeError(
737 f"快手发布连续失败 {KUAISHOU_PUBLISH_ATTEMPTS} 次,已停止重试: {last_publish_error}"
738 )
739
740 upload_success = True
741 finally:
742 if upload_success:
743 await context.storage_state(path=self.account_file)
744 kuaishou_logger.success(_msg("🥳", "cookie 更新完毕"))
745 await asyncio.sleep(2)
746 await context.close()
747 await browser.close()
748
749 async def main(self):
750 async with async_playwright() as playwright:
751 await self.upload(playwright)
752
753
754 class KSNote(KSBaseUploader):
755 def __init__(
756 self,
757 image_paths,
758 note,
759 tags,
760 publish_date: datetime | int,
761 account_file,
762 title: str | None = None,
763 publish_strategy: str | None = None,
764 debug: bool = DEBUG_MODE,
765 headless: bool = LOCAL_CHROME_HEADLESS,
766 ):
767 super().__init__(
768 publish_date=publish_date,
769 account_file=account_file,
770 publish_strategy=publish_strategy,
771 debug=debug,
772 headless=headless,
773 )
774 self.image_paths = image_paths
775 self.note = note or ""
776 self.title = title or (self.note[:20] if self.note else "")
777 self.tags = tags or []
778
779 async def validate_upload_args(self):
780 await self.validate_base_args()
781 if not self.title or not str(self.title).strip():
782 raise ValueError("快手图文上传时,title 是必须的")
783 if not self.image_paths:
784 raise ValueError("快手图文上传时,图片是必须的")
785
786 if isinstance(self.image_paths, (str, Path)):
787 self.image_paths = [self.image_paths]
788
789 normalized_image_paths = []
790 for image_path in self.image_paths:
791 normalized_image_paths.append(str(self.validate_image_file(image_path)))
792 self.image_paths = normalized_image_paths
793
794 async def upload_note_content(self, page: Page) -> None:
795 kuaishou_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
796 kuaishou_logger.info(_msg("🔀", "小人正在切换到图文发布"))
797 await page.locator('div[role="tablist"] div[role="tab"]:has-text("图文")').click()
798 await page.wait_for_timeout(1000)
799
800 kuaishou_logger.info(_msg("📤", "小人正在上传图片"))
801 upload_button = page.locator("button[class^='_upload-btn']").filter(has_text="上传图片")
802 await upload_button.wait_for(state="visible", timeout=10000)
803
804 async with page.expect_file_chooser() as fc_info:
805 await upload_button.click()
806 file_chooser = await fc_info.value
807 await file_chooser.set_files(self.image_paths)
808
809 know_button = page.locator('button[type="button"] span:text("我知道了")').first
810 try:
811 if await know_button.count() and await know_button.is_visible():
812 await know_button.click()
813 except Exception:
814 pass
815
816 await self.close_guide_overlay(page)
817
818 kuaishou_logger.info(_msg("✍️", "小人开始填写图文内容和话题"))
819 await _focus_desc_editor(page)
820 await page.keyboard.press("Backspace")
821 await page.keyboard.press("Control+KeyA")
822 await page.keyboard.press("Delete")
823 await page.keyboard.type(self.note)
824 await page.keyboard.press("Enter")
825
826 for index, tag in enumerate(self.tags[:3], start=1):
827 kuaishou_logger.info(_msg("🏷️", f"小人正在添加第 {index} 个话题: #{tag}"))
828 await page.keyboard.type(f"#{tag} ")
829 await asyncio.sleep(2)
830
831 max_retries = 60
832 retry_count = 0
833 while retry_count < max_retries:
834 try:
835 number = await page.locator("text=上传中").count()
836 if number == 0:
837 kuaishou_logger.success(_msg("🥳", "图文素材已经传完啦"))
838 break
839
840 if retry_count % 5 == 0:
841 kuaishou_logger.info(_msg("🏃", "小人正在努力上传图文素材"))
842
843 if await page.locator("text=上传失败").count():
844 kuaishou_logger.warning(_msg("😵", "图文素材上传摔了一跤,小人马上重新上传"))
845 await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.image_paths)
846
847 await asyncio.sleep(2)
848 except Exception as exc:
849 kuaishou_logger.warning(_msg("😵", f"检查图文上传状态时出错,小人继续重试: {exc}"))
850 await asyncio.sleep(2)
851 retry_count += 1
852
853 if retry_count == max_retries:
854 kuaishou_logger.warning(_msg("😵", "超过最大重试次数,图文上传可能未完成"))
855
856 if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
857 await self.set_schedule_time(page, self.publish_date)
858
859 while True:
860 try:
861 publish_button = page.get_by_text("发布", exact=True)
862 if await publish_button.count() > 0:
863 await publish_button.click()
864
865 await asyncio.sleep(1)
866 confirm_button = page.get_by_text("确认发布")
867 if await confirm_button.count() > 0:
868 await confirm_button.click()
869
870 await page.wait_for_url(KUAISHOU_MANAGE_URL_PATTERN, timeout=5000)
871 kuaishou_logger.success(_msg("🥳", "图文发布成功,小人开心收工"))
872 break
873 except Exception as exc:
874 kuaishou_logger.info(_msg("🏃", f"小人正在冲刺发布图文: {exc}"))
875 if self.debug:
876 await page.screenshot(full_page=True)
877 await asyncio.sleep(1)
878
879 async def upload(self, playwright: Playwright) -> None:
880 kuaishou_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
881 await self.validate_upload_args()
882 kuaishou_logger.info(_msg("🥳", "图文上传前检查通过"))
883
884 if self.local_executable_path:
885 browser = await playwright.chromium.launch(
886 headless=self.headless,
887 executable_path=self.local_executable_path,
888 )
889 else:
890 browser = await playwright.chromium.launch(
891 headless=self.headless,
892 channel="chromium",
893 )
894 context = await browser.new_context(storage_state=self.account_file)
895 context = await set_init_script(context)
896
897 upload_success = False
898 try:
899 page = await context.new_page()
900 await page.goto(KUAISHOU_UPLOAD_URL)
901 kuaishou_logger.info(_msg("🧭", "小人正在赶往快手图文发布页"))
902 await page.wait_for_url(KUAISHOU_UPLOAD_URL_PATTERN)
903
904 await self.upload_note_content(page)
905 upload_success = True
906 finally:
907 if upload_success:
908 await context.storage_state(path=self.account_file)
909 kuaishou_logger.success(_msg("🥳", "cookie 更新完毕"))
910 await asyncio.sleep(2)
911 await context.close()
912 await browser.close()
913
914 async def main(self):
915 async with async_playwright() as playwright:
916 await self.upload(playwright)
917
917 lines PYTHON