kuaishou.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. # -*- coding: utf-8 -*-
  2. """
  3. 快手视频发布器
  4. 参考: matrix/ks_uploader/main.py
  5. """
  6. import asyncio
  7. import os
  8. from datetime import datetime
  9. from typing import List
  10. from .base import (
  11. BasePublisher, PublishParams, PublishResult,
  12. WorkItem, WorksResult, CommentItem, CommentsResult
  13. )
  14. class KuaishouPublisher(BasePublisher):
  15. """
  16. 快手视频发布器
  17. 使用 Playwright 自动化操作快手创作者中心
  18. """
  19. platform_name = "kuaishou"
  20. login_url = "https://cp.kuaishou.com/"
  21. publish_url = "https://cp.kuaishou.com/article/publish/video"
  22. cookie_domain = ".kuaishou.com"
  23. async def set_schedule_time(self, publish_date: datetime):
  24. """设置定时发布"""
  25. if not self.page:
  26. return
  27. # 选择定时发布
  28. label_element = self.page.locator("label.radio--4Gpx6:has-text('定时发布')")
  29. await label_element.click()
  30. await asyncio.sleep(1)
  31. # 输入时间
  32. publish_date_str = publish_date.strftime("%Y-%m-%d %H:%M")
  33. await self.page.locator('.semi-input[placeholder="日期和时间"]').click()
  34. await self.page.keyboard.press("Control+KeyA")
  35. await self.page.keyboard.type(str(publish_date_str))
  36. await self.page.keyboard.press("Enter")
  37. await asyncio.sleep(1)
  38. async def upload_cover(self, cover_path: str):
  39. """上传封面图"""
  40. if not self.page or not cover_path or not os.path.exists(cover_path):
  41. return
  42. try:
  43. await self.page.get_by_role("button", name="编辑封面").click()
  44. await asyncio.sleep(1)
  45. await self.page.get_by_role("tab", name="上传封面").click()
  46. preview_div = self.page.get_by_role("tabpanel", name="上传封面").locator("div").nth(1)
  47. async with self.page.expect_file_chooser() as fc_info:
  48. await preview_div.click()
  49. preview_chooser = await fc_info.value
  50. await preview_chooser.set_files(cover_path)
  51. await self.page.get_by_role("button", name="确认").click()
  52. await asyncio.sleep(3)
  53. print(f"[{self.platform_name}] 封面上传成功")
  54. except Exception as e:
  55. print(f"[{self.platform_name}] 封面上传失败: {e}")
  56. async def publish(self, cookies: str, params: PublishParams) -> PublishResult:
  57. """发布视频到快手 - 参考 matrix/ks_uploader/main.py"""
  58. print(f"\n{'='*60}")
  59. print(f"[{self.platform_name}] 开始发布视频")
  60. print(f"[{self.platform_name}] 视频路径: {params.video_path}")
  61. print(f"[{self.platform_name}] 标题: {params.title}")
  62. print(f"[{self.platform_name}] Headless: {self.headless}")
  63. print(f"{'='*60}")
  64. self.report_progress(5, "正在初始化浏览器...")
  65. # 初始化浏览器
  66. await self.init_browser()
  67. print(f"[{self.platform_name}] 浏览器初始化完成")
  68. # 解析并设置 cookies
  69. cookie_list = self.parse_cookies(cookies)
  70. print(f"[{self.platform_name}] 解析到 {len(cookie_list)} 个 cookies")
  71. await self.set_cookies(cookie_list)
  72. if not self.page:
  73. raise Exception("Page not initialized")
  74. # 检查视频文件
  75. if not os.path.exists(params.video_path):
  76. raise Exception(f"视频文件不存在: {params.video_path}")
  77. print(f"[{self.platform_name}] 视频文件存在,大小: {os.path.getsize(params.video_path)} bytes")
  78. self.report_progress(10, "正在打开上传页面...")
  79. # 访问上传页面 - 参考 matrix
  80. await self.page.goto("https://cp.kuaishou.com/article/publish/video")
  81. print(f"[{self.platform_name}] 等待页面加载...")
  82. try:
  83. await self.page.wait_for_url("https://cp.kuaishou.com/article/publish/video", timeout=30000)
  84. except:
  85. pass
  86. await asyncio.sleep(3)
  87. # 检查是否跳转到登录页
  88. current_url = self.page.url
  89. print(f"[{self.platform_name}] 当前 URL: {current_url}")
  90. if "passport" in current_url or "login" in current_url:
  91. screenshot_base64 = await self.capture_screenshot()
  92. return PublishResult(
  93. success=False,
  94. platform=self.platform_name,
  95. error="Cookie 已过期,需要重新登录",
  96. need_captcha=True,
  97. captcha_type='login',
  98. screenshot_base64=screenshot_base64,
  99. page_url=current_url,
  100. status='need_captcha'
  101. )
  102. # 使用 AI 检查验证码
  103. ai_captcha = await self.ai_check_captcha()
  104. if ai_captcha['has_captcha']:
  105. print(f"[{self.platform_name}] AI检测到验证码: {ai_captcha['captcha_type']}", flush=True)
  106. screenshot_base64 = await self.capture_screenshot()
  107. return PublishResult(
  108. success=False,
  109. platform=self.platform_name,
  110. error=f"检测到{ai_captcha['captcha_type']}验证码,需要使用有头浏览器完成验证",
  111. need_captcha=True,
  112. captcha_type=ai_captcha['captcha_type'],
  113. screenshot_base64=screenshot_base64,
  114. page_url=current_url,
  115. status='need_captcha'
  116. )
  117. self.report_progress(15, "正在选择视频文件...")
  118. # 点击上传按钮 - 参考 matrix: page.get_by_role("button", name="上传视频")
  119. upload_btn = self.page.get_by_role("button", name="上传视频")
  120. async with self.page.expect_file_chooser(timeout=10000) as fc_info:
  121. await upload_btn.click()
  122. file_chooser = await fc_info.value
  123. await file_chooser.set_files(params.video_path)
  124. print(f"[{self.platform_name}] 视频文件已选择")
  125. await asyncio.sleep(1)
  126. # 关闭可能的弹窗 - 参考 matrix
  127. known_btn = self.page.get_by_role("button", name="我知道了")
  128. if await known_btn.count():
  129. await known_btn.click()
  130. print(f"[{self.platform_name}] 关闭弹窗")
  131. self.report_progress(20, "正在填充标题...")
  132. # 填写标题 - 参考 matrix
  133. await asyncio.sleep(1)
  134. title_input = self.page.get_by_placeholder('添加合适的话题和描述,作品能获得更多推荐~')
  135. if await title_input.count():
  136. await title_input.click()
  137. await title_input.fill(params.title[:30])
  138. print(f"[{self.platform_name}] 标题已填写")
  139. self.report_progress(30, "等待视频上传完成...")
  140. # 等待上传完成 - 参考 matrix: span:has-text("上传成功")
  141. for i in range(120):
  142. try:
  143. count = await self.page.locator('span:has-text("上传成功")').count()
  144. if count > 0:
  145. print(f"[{self.platform_name}] 视频上传完毕")
  146. break
  147. else:
  148. print(f"[{self.platform_name}] 正在上传视频中... {i+1}/120")
  149. await asyncio.sleep(3)
  150. except:
  151. print(f"[{self.platform_name}] 正在上传视频中...")
  152. await asyncio.sleep(3)
  153. self.report_progress(50, "正在上传封面...")
  154. # 上传封面 - 参考 matrix
  155. await self.upload_cover(params.cover_path)
  156. await asyncio.sleep(5)
  157. self.report_progress(80, "正在发布...")
  158. # 点击发布 - 参考 matrix
  159. for i in range(30):
  160. try:
  161. publish_btn = self.page.get_by_role('button', name="发布", exact=True)
  162. if await publish_btn.count():
  163. print(f"[{self.platform_name}] 点击发布按钮...")
  164. await publish_btn.click()
  165. # 等待跳转到管理页面 - 参考 matrix: https://cp.kuaishou.com/article/manage/video?status=2&from=publish
  166. await self.page.wait_for_url(
  167. "https://cp.kuaishou.com/article/manage/video*",
  168. timeout=1500
  169. )
  170. self.report_progress(100, "发布成功")
  171. print(f"[{self.platform_name}] 视频发布成功!")
  172. screenshot_base64 = await self.capture_screenshot()
  173. return PublishResult(
  174. success=True,
  175. platform=self.platform_name,
  176. message="发布成功",
  177. screenshot_base64=screenshot_base64,
  178. page_url=self.page.url,
  179. status='success'
  180. )
  181. except Exception as e:
  182. current_url = self.page.url
  183. if "manage/video" in current_url:
  184. self.report_progress(100, "发布成功")
  185. print(f"[{self.platform_name}] 视频发布成功!")
  186. screenshot_base64 = await self.capture_screenshot()
  187. return PublishResult(
  188. success=True,
  189. platform=self.platform_name,
  190. message="发布成功",
  191. screenshot_base64=screenshot_base64,
  192. page_url=current_url,
  193. status='success'
  194. )
  195. else:
  196. print(f"[{self.platform_name}] 视频正在发布中... {i+1}/30")
  197. await asyncio.sleep(0.5)
  198. # 发布超时
  199. screenshot_base64 = await self.capture_screenshot()
  200. page_url = await self.get_page_url()
  201. return PublishResult(
  202. success=False,
  203. platform=self.platform_name,
  204. error="发布超时,请检查发布状态",
  205. screenshot_base64=screenshot_base64,
  206. page_url=page_url,
  207. status='need_action'
  208. )
  209. async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
  210. """获取快手作品列表"""
  211. print(f"\n{'='*60}")
  212. print(f"[{self.platform_name}] 获取作品列表")
  213. print(f"[{self.platform_name}] page={page}, page_size={page_size}")
  214. print(f"{'='*60}")
  215. works: List[WorkItem] = []
  216. total = 0
  217. has_more = False
  218. try:
  219. await self.init_browser()
  220. cookie_list = self.parse_cookies(cookies)
  221. await self.set_cookies(cookie_list)
  222. if not self.page:
  223. raise Exception("Page not initialized")
  224. # 访问创作者中心
  225. await self.page.goto("https://cp.kuaishou.com/")
  226. await asyncio.sleep(3)
  227. # 检查登录状态
  228. current_url = self.page.url
  229. if "passport" in current_url or "login" in current_url:
  230. raise Exception("Cookie 已过期,请重新登录")
  231. # 调用作品列表 API
  232. pcursor = "" if page == 0 else str(page)
  233. api_url = f"https://cp.kuaishou.com/rest/cp/works/v2/video/pc/photo/list?count={page_size}&pcursor={pcursor}&status=public"
  234. js_code = f"""
  235. async () => {{
  236. const resp = await fetch("{api_url}", {{
  237. credentials: 'include',
  238. headers: {{ 'Accept': 'application/json' }}
  239. }});
  240. return await resp.json();
  241. }}
  242. """
  243. response = await self.page.evaluate(js_code)
  244. if response.get('result') == 1:
  245. data = response.get('data', {})
  246. photo_list = data.get('list', [])
  247. has_more = len(photo_list) >= page_size
  248. for photo in photo_list:
  249. photo_id = photo.get('photoId', '')
  250. if not photo_id:
  251. continue
  252. # 封面
  253. cover_url = photo.get('coverUrl', '')
  254. if cover_url.startswith('http://'):
  255. cover_url = cover_url.replace('http://', 'https://')
  256. # 时长
  257. duration = photo.get('duration', 0) // 1000 # 毫秒转秒
  258. # 发布时间
  259. create_time = photo.get('timestamp', 0) // 1000
  260. publish_time = ''
  261. if create_time:
  262. from datetime import datetime
  263. publish_time = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S')
  264. works.append(WorkItem(
  265. work_id=str(photo_id),
  266. title=photo.get('caption', '') or '无标题',
  267. cover_url=cover_url,
  268. duration=duration,
  269. status='published',
  270. publish_time=publish_time,
  271. play_count=photo.get('viewCount', 0),
  272. like_count=photo.get('likeCount', 0),
  273. comment_count=photo.get('commentCount', 0),
  274. share_count=photo.get('shareCount', 0),
  275. ))
  276. print(f"[{self.platform_name}] 获取到 {len(works)} 个作品")
  277. except Exception as e:
  278. import traceback
  279. traceback.print_exc()
  280. return WorksResult(success=False, platform=self.platform_name, error=str(e))
  281. return WorksResult(success=True, platform=self.platform_name, works=works, total=total or len(works), has_more=has_more)
  282. async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
  283. """获取快手作品评论"""
  284. print(f"\n{'='*60}")
  285. print(f"[{self.platform_name}] 获取作品评论")
  286. print(f"[{self.platform_name}] work_id={work_id}")
  287. print(f"{'='*60}")
  288. comments: List[CommentItem] = []
  289. total = 0
  290. has_more = False
  291. try:
  292. await self.init_browser()
  293. cookie_list = self.parse_cookies(cookies)
  294. await self.set_cookies(cookie_list)
  295. if not self.page:
  296. raise Exception("Page not initialized")
  297. await self.page.goto("https://cp.kuaishou.com/")
  298. await asyncio.sleep(3)
  299. current_url = self.page.url
  300. if "passport" in current_url or "login" in current_url:
  301. raise Exception("Cookie 已过期,请重新登录")
  302. # 调用评论列表 API
  303. pcursor = cursor or ""
  304. api_url = f"https://cp.kuaishou.com/rest/cp/works/comment/list?photoId={work_id}&pcursor={pcursor}&count=20"
  305. js_code = f"""
  306. async () => {{
  307. const resp = await fetch("{api_url}", {{
  308. credentials: 'include',
  309. headers: {{ 'Accept': 'application/json' }}
  310. }});
  311. return await resp.json();
  312. }}
  313. """
  314. response = await self.page.evaluate(js_code)
  315. if response.get('result') == 1:
  316. data = response.get('data', {})
  317. comment_list = data.get('list', [])
  318. has_more = data.get('pcursor', '') != ''
  319. for comment in comment_list:
  320. cid = comment.get('commentId', '')
  321. if not cid:
  322. continue
  323. author = comment.get('author', {})
  324. # 解析子评论
  325. replies = []
  326. sub_list = comment.get('subComments', []) or []
  327. for sub in sub_list:
  328. sub_author = sub.get('author', {})
  329. replies.append(CommentItem(
  330. comment_id=str(sub.get('commentId', '')),
  331. work_id=work_id,
  332. content=sub.get('content', ''),
  333. author_id=str(sub_author.get('id', '')),
  334. author_name=sub_author.get('name', ''),
  335. author_avatar=sub_author.get('headurl', ''),
  336. like_count=sub.get('likeCount', 0),
  337. create_time=str(sub.get('timestamp', '')),
  338. ))
  339. comments.append(CommentItem(
  340. comment_id=str(cid),
  341. work_id=work_id,
  342. content=comment.get('content', ''),
  343. author_id=str(author.get('id', '')),
  344. author_name=author.get('name', ''),
  345. author_avatar=author.get('headurl', ''),
  346. like_count=comment.get('likeCount', 0),
  347. reply_count=comment.get('subCommentCount', 0),
  348. create_time=str(comment.get('timestamp', '')),
  349. replies=replies,
  350. ))
  351. total = len(comments)
  352. print(f"[{self.platform_name}] 获取到 {total} 条评论")
  353. except Exception as e:
  354. import traceback
  355. traceback.print_exc()
  356. return CommentsResult(success=False, platform=self.platform_name, work_id=work_id, error=str(e))
  357. return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=comments, total=total, has_more=has_more)