weixin.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. # -*- coding: utf-8 -*-
  2. """
  3. 微信视频号发布器
  4. 参考: matrix/tencent_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. def format_short_title(origin_title: str) -> str:
  15. """
  16. 格式化短标题
  17. - 移除特殊字符
  18. - 长度限制在 6-16 字符
  19. """
  20. allowed_special_chars = "《》"":+?%°"
  21. filtered_chars = [
  22. char if char.isalnum() or char in allowed_special_chars
  23. else ' ' if char == ',' else ''
  24. for char in origin_title
  25. ]
  26. formatted_string = ''.join(filtered_chars)
  27. if len(formatted_string) > 16:
  28. formatted_string = formatted_string[:16]
  29. elif len(formatted_string) < 6:
  30. formatted_string += ' ' * (6 - len(formatted_string))
  31. return formatted_string
  32. class WeixinPublisher(BasePublisher):
  33. """
  34. 微信视频号发布器
  35. 使用 Playwright 自动化操作视频号创作者中心
  36. 注意: 需要使用 Chrome 浏览器,否则可能出现 H264 编码错误
  37. """
  38. platform_name = "weixin"
  39. login_url = "https://channels.weixin.qq.com/platform"
  40. publish_url = "https://channels.weixin.qq.com/platform/post/create"
  41. # 视频号域名为 channels.weixin.qq.com,cookie 常见 domain 为 .qq.com / .weixin.qq.com 等
  42. # 这里默认用更宽泛的 .qq.com,避免“字符串 cookie”场景下 domain 兜底不生效
  43. cookie_domain = ".qq.com"
  44. async def init_browser(self, storage_state: str = None):
  45. """初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
  46. from playwright.async_api import async_playwright
  47. playwright = await async_playwright().start()
  48. # 参考 matrix: 使用系统内的 Chrome 浏览器,避免 H264 编码错误
  49. # 如果没有安装 Chrome,则使用默认 Chromium
  50. try:
  51. self.browser = await playwright.chromium.launch(
  52. headless=self.headless,
  53. channel="chrome" # 使用系统 Chrome
  54. )
  55. print(f"[{self.platform_name}] 使用系统 Chrome 浏览器")
  56. except Exception as e:
  57. print(f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}")
  58. self.browser = await playwright.chromium.launch(headless=self.headless)
  59. if storage_state and os.path.exists(storage_state):
  60. self.context = await self.browser.new_context(storage_state=storage_state)
  61. else:
  62. self.context = await self.browser.new_context()
  63. self.page = await self.context.new_page()
  64. return self.page
  65. async def set_schedule_time(self, publish_date: datetime):
  66. """设置定时发布"""
  67. if not self.page:
  68. return
  69. print(f"[{self.platform_name}] 设置定时发布...")
  70. # 点击定时选项
  71. label_element = self.page.locator("label").filter(has_text="定时").nth(1)
  72. await label_element.click()
  73. # 选择日期
  74. await self.page.click('input[placeholder="请选择发表时间"]')
  75. publish_month = f"{publish_date.month:02d}"
  76. current_month = f"{publish_month}月"
  77. # 检查月份
  78. page_month = await self.page.inner_text('span.weui-desktop-picker__panel__label:has-text("月")')
  79. if page_month != current_month:
  80. await self.page.click('button.weui-desktop-btn__icon__right')
  81. # 选择日期
  82. elements = await self.page.query_selector_all('table.weui-desktop-picker__table a')
  83. for element in elements:
  84. class_name = await element.evaluate('el => el.className')
  85. if 'weui-desktop-picker__disabled' in class_name:
  86. continue
  87. text = await element.inner_text()
  88. if text.strip() == str(publish_date.day):
  89. await element.click()
  90. break
  91. # 输入时间
  92. await self.page.click('input[placeholder="请选择时间"]')
  93. await self.page.keyboard.press("Control+KeyA")
  94. await self.page.keyboard.type(str(publish_date.hour))
  95. # 点击其他地方确认
  96. await self.page.locator("div.input-editor").click()
  97. async def handle_upload_error(self, video_path: str):
  98. """处理上传错误"""
  99. if not self.page:
  100. return
  101. print(f"[{self.platform_name}] 视频出错了,重新上传中...")
  102. await self.page.locator('div.media-status-content div.tag-inner:has-text("删除")').click()
  103. await self.page.get_by_role('button', name="删除", exact=True).click()
  104. file_input = self.page.locator('input[type="file"]')
  105. await file_input.set_input_files(video_path)
  106. async def add_title_tags(self, params: PublishParams):
  107. """添加标题和话题"""
  108. if not self.page:
  109. return
  110. await self.page.locator("div.input-editor").click()
  111. await self.page.keyboard.type(params.title)
  112. if params.tags:
  113. await self.page.keyboard.press("Enter")
  114. for tag in params.tags:
  115. await self.page.keyboard.type("#" + tag)
  116. await self.page.keyboard.press("Space")
  117. print(f"[{self.platform_name}] 成功添加标题和 {len(params.tags)} 个话题")
  118. async def add_short_title(self):
  119. """添加短标题"""
  120. if not self.page:
  121. return
  122. try:
  123. short_title_element = self.page.get_by_text("短标题", exact=True).locator("..").locator(
  124. "xpath=following-sibling::div").locator('span input[type="text"]')
  125. if await short_title_element.count():
  126. # 获取已有内容作为短标题
  127. pass
  128. except:
  129. pass
  130. async def upload_cover(self, cover_path: str):
  131. """上传封面图"""
  132. if not self.page or not cover_path or not os.path.exists(cover_path):
  133. return
  134. try:
  135. await asyncio.sleep(2)
  136. preview_btn_info = await self.page.locator(
  137. 'div.finder-tag-wrap.btn:has-text("更换封面")').get_attribute('class')
  138. if "disabled" not in preview_btn_info:
  139. await self.page.locator('div.finder-tag-wrap.btn:has-text("更换封面")').click()
  140. await self.page.locator('div.single-cover-uploader-wrap > div.wrap').hover()
  141. # 删除现有封面
  142. if await self.page.locator(".del-wrap > .svg-icon").count():
  143. await self.page.locator(".del-wrap > .svg-icon").click()
  144. # 上传新封面
  145. preview_div = self.page.locator("div.single-cover-uploader-wrap > div.wrap")
  146. async with self.page.expect_file_chooser() as fc_info:
  147. await preview_div.click()
  148. preview_chooser = await fc_info.value
  149. await preview_chooser.set_files(cover_path)
  150. await asyncio.sleep(2)
  151. await self.page.get_by_role("button", name="确定").click()
  152. await asyncio.sleep(1)
  153. await self.page.get_by_role("button", name="确认").click()
  154. print(f"[{self.platform_name}] 封面上传成功")
  155. except Exception as e:
  156. print(f"[{self.platform_name}] 封面上传失败: {e}")
  157. async def check_captcha(self) -> dict:
  158. """检查页面是否需要验证码"""
  159. if not self.page:
  160. return {'need_captcha': False, 'captcha_type': ''}
  161. try:
  162. # 检查各种验证码
  163. captcha_selectors = [
  164. 'text="请输入验证码"',
  165. 'text="滑动验证"',
  166. '[class*="captcha"]',
  167. '[class*="verify"]',
  168. ]
  169. for selector in captcha_selectors:
  170. try:
  171. if await self.page.locator(selector).count() > 0:
  172. print(f"[{self.platform_name}] 检测到验证码: {selector}")
  173. return {'need_captcha': True, 'captcha_type': 'image'}
  174. except:
  175. pass
  176. # 检查登录弹窗
  177. login_selectors = [
  178. 'text="请登录"',
  179. 'text="扫码登录"',
  180. '[class*="login-dialog"]',
  181. ]
  182. for selector in login_selectors:
  183. try:
  184. if await self.page.locator(selector).count() > 0:
  185. print(f"[{self.platform_name}] 检测到需要登录: {selector}")
  186. return {'need_captcha': True, 'captcha_type': 'login'}
  187. except:
  188. pass
  189. except Exception as e:
  190. print(f"[{self.platform_name}] 验证码检测异常: {e}")
  191. return {'need_captcha': False, 'captcha_type': ''}
  192. async def publish(self, cookies: str, params: PublishParams) -> PublishResult:
  193. """发布视频到视频号"""
  194. print(f"\n{'='*60}")
  195. print(f"[{self.platform_name}] 开始发布视频")
  196. print(f"[{self.platform_name}] 视频路径: {params.video_path}")
  197. print(f"[{self.platform_name}] 标题: {params.title}")
  198. print(f"[{self.platform_name}] Headless: {self.headless}")
  199. print(f"{'='*60}")
  200. self.report_progress(5, "正在初始化浏览器...")
  201. # 初始化浏览器(使用 Chrome)
  202. await self.init_browser()
  203. print(f"[{self.platform_name}] 浏览器初始化完成")
  204. # 解析并设置 cookies
  205. cookie_list = self.parse_cookies(cookies)
  206. print(f"[{self.platform_name}] 解析到 {len(cookie_list)} 个 cookies")
  207. await self.set_cookies(cookie_list)
  208. if not self.page:
  209. raise Exception("Page not initialized")
  210. # 检查视频文件
  211. if not os.path.exists(params.video_path):
  212. raise Exception(f"视频文件不存在: {params.video_path}")
  213. print(f"[{self.platform_name}] 视频文件存在,大小: {os.path.getsize(params.video_path)} bytes")
  214. self.report_progress(10, "正在打开上传页面...")
  215. # 访问上传页面
  216. await self.page.goto(self.publish_url, wait_until="domcontentloaded", timeout=60000)
  217. await asyncio.sleep(3)
  218. # 检查是否跳转到登录页
  219. current_url = self.page.url
  220. print(f"[{self.platform_name}] 当前页面: {current_url}")
  221. if "login" in current_url:
  222. screenshot_base64 = await self.capture_screenshot()
  223. return PublishResult(
  224. success=False,
  225. platform=self.platform_name,
  226. error="Cookie 已过期,需要重新登录",
  227. need_captcha=True,
  228. captcha_type='login',
  229. screenshot_base64=screenshot_base64,
  230. page_url=current_url,
  231. status='need_captcha'
  232. )
  233. # 使用 AI 检查验证码
  234. ai_captcha = await self.ai_check_captcha()
  235. if ai_captcha['has_captcha']:
  236. print(f"[{self.platform_name}] AI检测到验证码: {ai_captcha['captcha_type']}", flush=True)
  237. screenshot_base64 = await self.capture_screenshot()
  238. return PublishResult(
  239. success=False,
  240. platform=self.platform_name,
  241. error=f"检测到{ai_captcha['captcha_type']}验证码,需要使用有头浏览器完成验证",
  242. need_captcha=True,
  243. captcha_type=ai_captcha['captcha_type'],
  244. screenshot_base64=screenshot_base64,
  245. page_url=current_url,
  246. status='need_captcha'
  247. )
  248. # 传统方式检查验证码
  249. captcha_result = await self.check_captcha()
  250. if captcha_result['need_captcha']:
  251. screenshot_base64 = await self.capture_screenshot()
  252. return PublishResult(
  253. success=False,
  254. platform=self.platform_name,
  255. error=f"需要{captcha_result['captcha_type']}验证码,请使用有头浏览器完成验证",
  256. need_captcha=True,
  257. captcha_type=captcha_result['captcha_type'],
  258. screenshot_base64=screenshot_base64,
  259. page_url=current_url,
  260. status='need_captcha'
  261. )
  262. self.report_progress(15, "正在选择视频文件...")
  263. # 上传视频 - 参考 matrix/tencent_uploader/main.py
  264. # matrix 使用: div.upload-content 点击后触发文件选择器
  265. upload_success = False
  266. # 方法1: 参考 matrix - 点击 div.upload-content
  267. try:
  268. upload_div = self.page.locator("div.upload-content")
  269. if await upload_div.count() > 0:
  270. print(f"[{self.platform_name}] 找到 upload-content 上传区域")
  271. async with self.page.expect_file_chooser(timeout=10000) as fc_info:
  272. await upload_div.click()
  273. file_chooser = await fc_info.value
  274. await file_chooser.set_files(params.video_path)
  275. upload_success = True
  276. print(f"[{self.platform_name}] 通过 upload-content 上传成功")
  277. except Exception as e:
  278. print(f"[{self.platform_name}] upload-content 上传失败: {e}")
  279. # 方法2: 尝试其他选择器
  280. if not upload_success:
  281. upload_selectors = [
  282. 'div[class*="upload-area"]',
  283. 'div[class*="drag-upload"]',
  284. 'div.add-wrap',
  285. '[class*="uploader"]',
  286. ]
  287. for selector in upload_selectors:
  288. if upload_success:
  289. break
  290. try:
  291. upload_area = self.page.locator(selector).first
  292. if await upload_area.count() > 0:
  293. print(f"[{self.platform_name}] 尝试点击上传区域: {selector}")
  294. async with self.page.expect_file_chooser(timeout=10000) as fc_info:
  295. await upload_area.click()
  296. file_chooser = await fc_info.value
  297. await file_chooser.set_files(params.video_path)
  298. upload_success = True
  299. print(f"[{self.platform_name}] 通过点击上传区域成功")
  300. break
  301. except Exception as e:
  302. print(f"[{self.platform_name}] 选择器 {selector} 失败: {e}")
  303. # 方法3: 直接设置 file input
  304. if not upload_success:
  305. try:
  306. file_input = self.page.locator('input[type="file"]')
  307. if await file_input.count() > 0:
  308. await file_input.first.set_input_files(params.video_path)
  309. upload_success = True
  310. print(f"[{self.platform_name}] 通过 file input 上传成功")
  311. except Exception as e:
  312. print(f"[{self.platform_name}] file input 上传失败: {e}")
  313. if not upload_success:
  314. screenshot_base64 = await self.capture_screenshot()
  315. return PublishResult(
  316. success=False,
  317. platform=self.platform_name,
  318. error="未找到上传入口",
  319. screenshot_base64=screenshot_base64,
  320. page_url=await self.get_page_url(),
  321. status='failed'
  322. )
  323. self.report_progress(20, "正在填充标题和话题...")
  324. # 添加标题和话题
  325. await self.add_title_tags(params)
  326. self.report_progress(30, "等待视频上传完成...")
  327. # 等待上传完成
  328. for _ in range(120):
  329. try:
  330. button_info = await self.page.get_by_role("button", name="发表").get_attribute('class')
  331. if "weui-desktop-btn_disabled" not in button_info:
  332. print(f"[{self.platform_name}] 视频上传完毕")
  333. # 上传封面
  334. self.report_progress(50, "正在上传封面...")
  335. await self.upload_cover(params.cover_path)
  336. break
  337. else:
  338. # 检查上传错误
  339. if await self.page.locator('div.status-msg.error').count():
  340. if await self.page.locator('div.media-status-content div.tag-inner:has-text("删除")').count():
  341. await self.handle_upload_error(params.video_path)
  342. await asyncio.sleep(3)
  343. except:
  344. await asyncio.sleep(3)
  345. self.report_progress(60, "处理视频设置...")
  346. # 添加短标题
  347. try:
  348. short_title_el = self.page.get_by_text("短标题", exact=True).locator("..").locator(
  349. "xpath=following-sibling::div").locator('span input[type="text"]')
  350. if await short_title_el.count():
  351. short_title = format_short_title(params.title)
  352. await short_title_el.fill(short_title)
  353. except:
  354. pass
  355. # 定时发布
  356. if params.publish_date:
  357. self.report_progress(70, "设置定时发布...")
  358. await self.set_schedule_time(params.publish_date)
  359. self.report_progress(80, "正在发布...")
  360. # 点击发布 - 参考 matrix
  361. for i in range(30):
  362. try:
  363. # 参考 matrix: div.form-btns button:has-text("发表")
  364. publish_btn = self.page.locator('div.form-btns button:has-text("发表")')
  365. if await publish_btn.count():
  366. print(f"[{self.platform_name}] 点击发布按钮...")
  367. await publish_btn.click()
  368. # 等待跳转到作品列表页面 - 参考 matrix
  369. await self.page.wait_for_url(
  370. "https://channels.weixin.qq.com/platform/post/list",
  371. timeout=10000
  372. )
  373. self.report_progress(100, "发布成功")
  374. print(f"[{self.platform_name}] 视频发布成功!")
  375. screenshot_base64 = await self.capture_screenshot()
  376. return PublishResult(
  377. success=True,
  378. platform=self.platform_name,
  379. message="发布成功",
  380. screenshot_base64=screenshot_base64,
  381. page_url=self.page.url,
  382. status='success'
  383. )
  384. except Exception as e:
  385. current_url = self.page.url
  386. if "https://channels.weixin.qq.com/platform/post/list" in current_url:
  387. self.report_progress(100, "发布成功")
  388. print(f"[{self.platform_name}] 视频发布成功!")
  389. screenshot_base64 = await self.capture_screenshot()
  390. return PublishResult(
  391. success=True,
  392. platform=self.platform_name,
  393. message="发布成功",
  394. screenshot_base64=screenshot_base64,
  395. page_url=current_url,
  396. status='success'
  397. )
  398. else:
  399. print(f"[{self.platform_name}] 视频正在发布中... {i+1}/30, URL: {current_url}")
  400. await asyncio.sleep(1)
  401. # 发布超时
  402. screenshot_base64 = await self.capture_screenshot()
  403. page_url = await self.get_page_url()
  404. return PublishResult(
  405. success=False,
  406. platform=self.platform_name,
  407. error="发布超时,请检查发布状态",
  408. screenshot_base64=screenshot_base64,
  409. page_url=page_url,
  410. status='need_action'
  411. )
  412. async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
  413. """获取视频号作品列表"""
  414. print(f"\n{'='*60}")
  415. print(f"[{self.platform_name}] 获取作品列表")
  416. print(f"[{self.platform_name}] page={page}, page_size={page_size}")
  417. print(f"{'='*60}")
  418. works: List[WorkItem] = []
  419. total = 0
  420. has_more = False
  421. try:
  422. await self.init_browser()
  423. cookie_list = self.parse_cookies(cookies)
  424. await self.set_cookies(cookie_list)
  425. if not self.page:
  426. raise Exception("Page not initialized")
  427. # 访问视频号创作者中心
  428. await self.page.goto("https://channels.weixin.qq.com/platform/post/list")
  429. await asyncio.sleep(5)
  430. # 检查登录状态
  431. current_url = self.page.url
  432. if "login" in current_url:
  433. raise Exception("Cookie 已过期,请重新登录")
  434. # 视频号使用页面爬取方式获取作品列表
  435. # 等待作品列表加载
  436. await self.page.wait_for_selector('div.post-feed-wrap', timeout=10000)
  437. # 获取所有作品项
  438. post_items = self.page.locator('div.post-feed-item')
  439. item_count = await post_items.count()
  440. print(f"[{self.platform_name}] 找到 {item_count} 个作品项")
  441. for i in range(min(item_count, page_size)):
  442. try:
  443. item = post_items.nth(i)
  444. # 获取封面
  445. cover_el = item.locator('div.cover-wrap img').first
  446. cover_url = ''
  447. if await cover_el.count() > 0:
  448. cover_url = await cover_el.get_attribute('src') or ''
  449. # 获取标题
  450. title_el = item.locator('div.content').first
  451. title = ''
  452. if await title_el.count() > 0:
  453. title = await title_el.text_content() or ''
  454. title = title.strip()[:50]
  455. # 获取统计数据
  456. stats_el = item.locator('div.post-data')
  457. play_count = 0
  458. like_count = 0
  459. comment_count = 0
  460. if await stats_el.count() > 0:
  461. stats_text = await stats_el.text_content() or ''
  462. # 解析统计数据(格式可能是: 播放 100 点赞 50 评论 10)
  463. import re
  464. play_match = re.search(r'播放[\s]*([\d.]+[万]?)', stats_text)
  465. like_match = re.search(r'点赞[\s]*([\d.]+[万]?)', stats_text)
  466. comment_match = re.search(r'评论[\s]*([\d.]+[万]?)', stats_text)
  467. def parse_count(match):
  468. if not match:
  469. return 0
  470. val = match.group(1)
  471. if '万' in val:
  472. return int(float(val.replace('万', '')) * 10000)
  473. return int(val)
  474. play_count = parse_count(play_match)
  475. like_count = parse_count(like_match)
  476. comment_count = parse_count(comment_match)
  477. # 获取发布时间
  478. time_el = item.locator('div.time')
  479. publish_time = ''
  480. if await time_el.count() > 0:
  481. publish_time = await time_el.text_content() or ''
  482. publish_time = publish_time.strip()
  483. # 生成临时 work_id(视频号可能需要从详情页获取)
  484. work_id = f"weixin_{i}_{hash(title)}"
  485. works.append(WorkItem(
  486. work_id=work_id,
  487. title=title or '无标题',
  488. cover_url=cover_url,
  489. duration=0,
  490. status='published',
  491. publish_time=publish_time,
  492. play_count=play_count,
  493. like_count=like_count,
  494. comment_count=comment_count,
  495. ))
  496. except Exception as e:
  497. print(f"[{self.platform_name}] 解析作品 {i} 失败: {e}")
  498. continue
  499. total = len(works)
  500. has_more = item_count > page_size
  501. print(f"[{self.platform_name}] 获取到 {total} 个作品")
  502. except Exception as e:
  503. import traceback
  504. traceback.print_exc()
  505. return WorksResult(success=False, platform=self.platform_name, error=str(e))
  506. return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more)
  507. async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
  508. """获取视频号作品评论"""
  509. print(f"\n{'='*60}")
  510. print(f"[{self.platform_name}] 获取作品评论")
  511. print(f"[{self.platform_name}] work_id={work_id}")
  512. print(f"{'='*60}")
  513. comments: List[CommentItem] = []
  514. total = 0
  515. has_more = False
  516. try:
  517. await self.init_browser()
  518. cookie_list = self.parse_cookies(cookies)
  519. await self.set_cookies(cookie_list)
  520. if not self.page:
  521. raise Exception("Page not initialized")
  522. # 访问评论管理页面
  523. await self.page.goto("https://channels.weixin.qq.com/platform/comment/index")
  524. await asyncio.sleep(5)
  525. # 检查登录状态
  526. current_url = self.page.url
  527. if "login" in current_url:
  528. raise Exception("Cookie 已过期,请重新登录")
  529. # 等待评论列表加载
  530. try:
  531. await self.page.wait_for_selector('div.comment-list', timeout=10000)
  532. except:
  533. print(f"[{self.platform_name}] 未找到评论列表")
  534. return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=[], total=0, has_more=False)
  535. # 获取所有评论项
  536. comment_items = self.page.locator('div.comment-item')
  537. item_count = await comment_items.count()
  538. print(f"[{self.platform_name}] 找到 {item_count} 个评论项")
  539. for i in range(item_count):
  540. try:
  541. item = comment_items.nth(i)
  542. # 获取作者信息
  543. author_name = ''
  544. author_avatar = ''
  545. name_el = item.locator('div.nick-name')
  546. if await name_el.count() > 0:
  547. author_name = await name_el.text_content() or ''
  548. author_name = author_name.strip()
  549. avatar_el = item.locator('img.avatar')
  550. if await avatar_el.count() > 0:
  551. author_avatar = await avatar_el.get_attribute('src') or ''
  552. # 获取评论内容
  553. content = ''
  554. content_el = item.locator('div.comment-content')
  555. if await content_el.count() > 0:
  556. content = await content_el.text_content() or ''
  557. content = content.strip()
  558. # 获取时间
  559. create_time = ''
  560. time_el = item.locator('div.time')
  561. if await time_el.count() > 0:
  562. create_time = await time_el.text_content() or ''
  563. create_time = create_time.strip()
  564. # 生成评论 ID
  565. comment_id = f"weixin_comment_{i}_{hash(content)}"
  566. comments.append(CommentItem(
  567. comment_id=comment_id,
  568. work_id=work_id,
  569. content=content,
  570. author_id='',
  571. author_name=author_name,
  572. author_avatar=author_avatar,
  573. like_count=0,
  574. reply_count=0,
  575. create_time=create_time,
  576. ))
  577. except Exception as e:
  578. print(f"[{self.platform_name}] 解析评论 {i} 失败: {e}")
  579. continue
  580. total = len(comments)
  581. print(f"[{self.platform_name}] 获取到 {total} 条评论")
  582. except Exception as e:
  583. import traceback
  584. traceback.print_exc()
  585. return CommentsResult(success=False, platform=self.platform_name, work_id=work_id, error=str(e))
  586. return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=comments, total=total, has_more=has_more)