douyin.py 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446
  1. # -*- coding: utf-8 -*-
  2. """
  3. 抖音视频发布器
  4. 参考: matrix/douyin_uploader/main.py
  5. """
  6. import asyncio
  7. import os
  8. import json
  9. import re
  10. from datetime import datetime
  11. from typing import List
  12. from .base import (
  13. BasePublisher, PublishParams, PublishResult,
  14. WorkItem, WorksResult, CommentItem, CommentsResult
  15. )
  16. class DouyinPublisher(BasePublisher):
  17. """
  18. 抖音视频发布器
  19. 使用 Playwright 自动化操作抖音创作者中心
  20. """
  21. platform_name = "douyin"
  22. login_url = "https://creator.douyin.com/"
  23. publish_url = "https://creator.douyin.com/creator-micro/content/upload"
  24. cookie_domain = ".douyin.com"
  25. async def set_schedule_time(self, publish_date: datetime):
  26. """设置定时发布"""
  27. if not self.page:
  28. return
  29. # 选择定时发布
  30. label_element = self.page.locator("label.radio-d4zkru:has-text('定时发布')")
  31. await label_element.click()
  32. await asyncio.sleep(1)
  33. # 输入时间
  34. publish_date_str = publish_date.strftime("%Y-%m-%d %H:%M")
  35. await self.page.locator('.semi-input[placeholder="日期和时间"]').click()
  36. await self.page.keyboard.press("Control+KeyA")
  37. await self.page.keyboard.type(str(publish_date_str))
  38. await self.page.keyboard.press("Enter")
  39. await asyncio.sleep(1)
  40. async def handle_upload_error(self, video_path: str):
  41. """处理上传错误,重新上传"""
  42. if not self.page:
  43. return
  44. print(f"[{self.platform_name}] 视频出错了,重新上传中...")
  45. await self.page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(video_path)
  46. async def check_captcha(self) -> dict:
  47. """
  48. 检查页面是否需要验证码
  49. 返回: {'need_captcha': bool, 'captcha_type': str}
  50. """
  51. if not self.page:
  52. return {'need_captcha': False, 'captcha_type': ''}
  53. try:
  54. # 检查手机验证码弹窗
  55. phone_captcha_selectors = [
  56. 'text="请输入验证码"',
  57. 'text="输入手机验证码"',
  58. 'text="获取验证码"',
  59. 'text="手机号验证"',
  60. '[class*="captcha"][class*="phone"]',
  61. '[class*="verify"][class*="phone"]',
  62. '[class*="sms-code"]',
  63. 'input[placeholder*="验证码"]',
  64. ]
  65. for selector in phone_captcha_selectors:
  66. try:
  67. if await self.page.locator(selector).count() > 0:
  68. print(f"[{self.platform_name}] 检测到手机验证码: {selector}", flush=True)
  69. return {'need_captcha': True, 'captcha_type': 'phone'}
  70. except:
  71. pass
  72. # 检查滑块验证码
  73. slider_captcha_selectors = [
  74. '[class*="captcha"][class*="slider"]',
  75. '[class*="slide-verify"]',
  76. '[class*="drag-verify"]',
  77. 'text="按住滑块"',
  78. 'text="向右滑动"',
  79. 'text="拖动滑块"',
  80. ]
  81. for selector in slider_captcha_selectors:
  82. try:
  83. if await self.page.locator(selector).count() > 0:
  84. print(f"[{self.platform_name}] 检测到滑块验证码: {selector}", flush=True)
  85. return {'need_captcha': True, 'captcha_type': 'slider'}
  86. except:
  87. pass
  88. # 检查图片验证码
  89. image_captcha_selectors = [
  90. '[class*="captcha"][class*="image"]',
  91. '[class*="verify-image"]',
  92. 'text="点击图片"',
  93. 'text="选择正确的"',
  94. ]
  95. for selector in image_captcha_selectors:
  96. try:
  97. if await self.page.locator(selector).count() > 0:
  98. print(f"[{self.platform_name}] 检测到图片验证码: {selector}", flush=True)
  99. return {'need_captcha': True, 'captcha_type': 'image'}
  100. except:
  101. pass
  102. # 检查登录弹窗(Cookie 过期)
  103. login_selectors = [
  104. 'text="请先登录"',
  105. 'text="登录后继续"',
  106. '[class*="login-modal"]',
  107. '[class*="login-dialog"]',
  108. ]
  109. for selector in login_selectors:
  110. try:
  111. if await self.page.locator(selector).count() > 0:
  112. print(f"[{self.platform_name}] 检测到需要登录: {selector}", flush=True)
  113. return {'need_captcha': True, 'captcha_type': 'login'}
  114. except:
  115. pass
  116. except Exception as e:
  117. print(f"[{self.platform_name}] 验证码检测异常: {e}", flush=True)
  118. return {'need_captcha': False, 'captcha_type': ''}
  119. async def handle_phone_captcha(self) -> bool:
  120. if not self.page:
  121. return False
  122. try:
  123. body_text = ""
  124. try:
  125. body_text = await self.page.inner_text("body")
  126. except:
  127. body_text = ""
  128. phone_match = re.search(r"(1\d{2}\*{4}\d{4})", body_text or "")
  129. masked_phone = phone_match.group(1) if phone_match else ""
  130. async def _get_send_button():
  131. candidates = [
  132. self.page.get_by_role("button", name="获取验证码"),
  133. self.page.get_by_role("button", name="发送验证码"),
  134. self.page.locator('button:has-text("获取验证码")'),
  135. self.page.locator('button:has-text("发送验证码")'),
  136. self.page.locator('[role="button"]:has-text("获取验证码")'),
  137. self.page.locator('[role="button"]:has-text("发送验证码")'),
  138. ]
  139. for c in candidates:
  140. try:
  141. if await c.count() > 0 and await c.first.is_visible():
  142. return c.first
  143. except:
  144. continue
  145. return None
  146. async def _confirm_sent() -> bool:
  147. try:
  148. txt = ""
  149. try:
  150. txt = await self.page.inner_text("body")
  151. except:
  152. txt = ""
  153. if re.search(r"(\d+\s*秒)|(\d+\s*s)|后可重试|重新发送|已发送", txt or ""):
  154. return True
  155. except:
  156. pass
  157. try:
  158. btn = await _get_send_button()
  159. if btn:
  160. disabled = await btn.is_disabled()
  161. if disabled:
  162. return True
  163. label = (await btn.inner_text()) if btn else ""
  164. if re.search(r"(\d+\s*秒)|(\d+\s*s)|后可重试|重新发送|已发送", label or ""):
  165. return True
  166. except:
  167. pass
  168. return False
  169. did_click_send = False
  170. btn = await _get_send_button()
  171. if btn:
  172. try:
  173. if await btn.is_enabled():
  174. await btn.click(timeout=5000)
  175. did_click_send = True
  176. print(f"[{self.platform_name}] 已点击发送短信验证码", flush=True)
  177. except Exception as e:
  178. print(f"[{self.platform_name}] 点击发送验证码按钮失败: {e}", flush=True)
  179. if did_click_send:
  180. try:
  181. await self.page.wait_for_timeout(800)
  182. except:
  183. pass
  184. sent_confirmed = await _confirm_sent() if did_click_send else False
  185. ai_state = await self.ai_analyze_sms_send_state()
  186. try:
  187. if ai_state.get("sent_likely"):
  188. sent_confirmed = True
  189. except:
  190. pass
  191. if (not did_click_send or not sent_confirmed) and ai_state.get("suggested_action") == "click_send":
  192. btn2 = await _get_send_button()
  193. if btn2:
  194. try:
  195. if await btn2.is_enabled():
  196. await btn2.click(timeout=5000)
  197. did_click_send = True
  198. await self.page.wait_for_timeout(800)
  199. sent_confirmed = await _confirm_sent()
  200. ai_state = await self.ai_analyze_sms_send_state()
  201. if ai_state.get("sent_likely"):
  202. sent_confirmed = True
  203. except:
  204. pass
  205. code_hint = "请输入短信验证码。"
  206. if ai_state.get("block_reason") == "slider":
  207. code_hint = "检测到滑块/人机验证阻塞,请先在浏览器窗口完成验证后再发送短信验证码。"
  208. elif ai_state.get("block_reason") in ["rate_limit", "risk"]:
  209. code_hint = f"页面提示可能被限制/风控({ai_state.get('notes','') or '请稍后重试'})。可稍等后重新发送验证码。"
  210. elif not did_click_send:
  211. code_hint = "未找到或无法点击“发送验证码”按钮,请在弹出的浏览器页面手动点击发送后再输入验证码。"
  212. elif sent_confirmed:
  213. code_hint = f"已检测到短信验证码已发送({ai_state.get('notes','') or '请查收短信'})。"
  214. else:
  215. code_hint = f"已尝试点击发送验证码,但未确认发送成功({ai_state.get('notes','') or '请查看是否出现倒计时/重新发送'})。"
  216. code = await self.request_sms_code_from_frontend(masked_phone, message=code_hint)
  217. input_selectors = [
  218. 'input[placeholder*="验证码"]',
  219. 'input[placeholder*="短信"]',
  220. 'input[type="tel"]',
  221. 'input[type="text"]',
  222. ]
  223. filled = False
  224. for selector in input_selectors:
  225. try:
  226. el = self.page.locator(selector).first
  227. if await el.count() > 0:
  228. await el.fill(code)
  229. filled = True
  230. break
  231. except:
  232. continue
  233. if not filled:
  234. raise Exception("未找到验证码输入框")
  235. submit_selectors = [
  236. 'button:has-text("确定")',
  237. 'button:has-text("确认")',
  238. 'button:has-text("提交")',
  239. 'button:has-text("完成")',
  240. ]
  241. for selector in submit_selectors:
  242. try:
  243. btn = self.page.locator(selector).first
  244. if await btn.count() > 0:
  245. await btn.click()
  246. break
  247. except:
  248. continue
  249. try:
  250. await self.page.wait_for_timeout(1000)
  251. await self.page.wait_for_selector('text="请输入验证码"', state="hidden", timeout=15000)
  252. except:
  253. pass
  254. print(f"[{self.platform_name}] 短信验证码已提交,继续执行发布流程", flush=True)
  255. return True
  256. except Exception as e:
  257. print(f"[{self.platform_name}] 处理短信验证码失败: {e}", flush=True)
  258. return False
  259. async def publish(self, cookies: str, params: PublishParams) -> PublishResult:
  260. """发布视频到抖音 - 参考 matrix/douyin_uploader/main.py"""
  261. print(f"\n{'='*60}")
  262. print(f"[{self.platform_name}] 开始发布视频")
  263. print(f"[{self.platform_name}] 视频路径: {params.video_path}")
  264. print(f"[{self.platform_name}] 标题: {params.title}")
  265. print(f"[{self.platform_name}] Headless: {self.headless}")
  266. print(f"{'='*60}")
  267. self.report_progress(5, "正在初始化浏览器...")
  268. # 初始化浏览器
  269. await self.init_browser()
  270. print(f"[{self.platform_name}] 浏览器初始化完成")
  271. # 解析并设置 cookies
  272. cookie_list = self.parse_cookies(cookies)
  273. print(f"[{self.platform_name}] 解析到 {len(cookie_list)} 个 cookies")
  274. await self.set_cookies(cookie_list)
  275. if not self.page:
  276. raise Exception("Page not initialized")
  277. # 检查视频文件
  278. if not os.path.exists(params.video_path):
  279. raise Exception(f"视频文件不存在: {params.video_path}")
  280. print(f"[{self.platform_name}] 视频文件存在,大小: {os.path.getsize(params.video_path)} bytes")
  281. self.report_progress(10, "正在打开上传页面...")
  282. # 访问上传页面 - 参考 matrix
  283. await self.page.goto("https://creator.douyin.com/creator-micro/content/upload")
  284. print(f"[{self.platform_name}] 等待页面加载...")
  285. try:
  286. await self.page.wait_for_url("https://creator.douyin.com/creator-micro/content/upload", timeout=30000)
  287. except:
  288. pass
  289. await asyncio.sleep(3)
  290. # 检查当前 URL 和页面状态
  291. current_url = self.page.url
  292. print(f"[{self.platform_name}] 当前 URL: {current_url}")
  293. async def wait_for_manual_login(timeout_seconds: int = 300) -> bool:
  294. if not self.page:
  295. return False
  296. self.report_progress(8, "检测到需要登录,请在浏览器窗口完成登录...")
  297. try:
  298. await self.page.bring_to_front()
  299. except:
  300. pass
  301. waited = 0
  302. while waited < timeout_seconds:
  303. try:
  304. url = self.page.url
  305. if "login" not in url and "passport" not in url:
  306. if "creator.douyin.com" in url:
  307. return True
  308. await asyncio.sleep(2)
  309. waited += 2
  310. except:
  311. await asyncio.sleep(2)
  312. waited += 2
  313. return False
  314. # 检查是否在登录页面或需要登录
  315. if "login" in current_url or "passport" in current_url:
  316. if not self.headless:
  317. logged_in = await wait_for_manual_login()
  318. if logged_in:
  319. try:
  320. if self.context:
  321. cookies_after = await self.context.cookies()
  322. await self.sync_cookies_to_node(cookies_after)
  323. except:
  324. pass
  325. await self.page.goto("https://creator.douyin.com/creator-micro/content/upload")
  326. await asyncio.sleep(3)
  327. current_url = self.page.url
  328. else:
  329. screenshot_base64 = await self.capture_screenshot()
  330. return PublishResult(
  331. success=False,
  332. platform=self.platform_name,
  333. error="需要登录:请在浏览器窗口完成登录后重试",
  334. need_captcha=True,
  335. captcha_type='login',
  336. screenshot_base64=screenshot_base64,
  337. page_url=current_url,
  338. status='need_captcha'
  339. )
  340. else:
  341. screenshot_base64 = await self.capture_screenshot()
  342. return PublishResult(
  343. success=False,
  344. platform=self.platform_name,
  345. error="Cookie 已过期,需要重新登录",
  346. need_captcha=True,
  347. captcha_type='login',
  348. screenshot_base64=screenshot_base64,
  349. page_url=current_url,
  350. status='need_captcha'
  351. )
  352. # 使用 AI 检测验证码
  353. ai_captcha_result = await self.ai_check_captcha()
  354. if ai_captcha_result['has_captcha']:
  355. print(f"[{self.platform_name}] AI检测到验证码: {ai_captcha_result['captcha_type']}", flush=True)
  356. screenshot_base64 = await self.capture_screenshot()
  357. return PublishResult(
  358. success=False,
  359. platform=self.platform_name,
  360. error=f"检测到{ai_captcha_result['captcha_type']}验证码,需要使用有头浏览器完成验证",
  361. need_captcha=True,
  362. captcha_type=ai_captcha_result['captcha_type'],
  363. screenshot_base64=screenshot_base64,
  364. page_url=current_url,
  365. status='need_captcha'
  366. )
  367. # 传统方式检测验证码
  368. captcha_result = await self.check_captcha()
  369. if captcha_result['need_captcha']:
  370. print(f"[{self.platform_name}] 传统方式检测到验证码: {captcha_result['captcha_type']}", flush=True)
  371. if captcha_result['captcha_type'] == 'phone':
  372. handled = await self.handle_phone_captcha()
  373. if handled:
  374. self.report_progress(12, "短信验证码已处理,继续发布...")
  375. else:
  376. screenshot_base64 = await self.capture_screenshot()
  377. return PublishResult(
  378. success=False,
  379. platform=self.platform_name,
  380. error="检测到手机验证码,但自动处理失败",
  381. need_captcha=True,
  382. captcha_type='phone',
  383. screenshot_base64=screenshot_base64,
  384. page_url=current_url,
  385. status='need_captcha'
  386. )
  387. else:
  388. screenshot_base64 = await self.capture_screenshot()
  389. return PublishResult(
  390. success=False,
  391. platform=self.platform_name,
  392. error=f"需要{captcha_result['captcha_type']}验证码,请使用有头浏览器完成验证",
  393. need_captcha=True,
  394. captcha_type=captcha_result['captcha_type'],
  395. screenshot_base64=screenshot_base64,
  396. page_url=current_url,
  397. status='need_captcha'
  398. )
  399. self.report_progress(15, "正在选择视频文件...")
  400. # 点击上传区域 - 参考 matrix: div.container-drag-info-Tl0RGH 或带 container-drag 的 div
  401. upload_selectors = [
  402. "div[class*='container-drag-info']",
  403. "div[class*='container-drag']",
  404. "div.upload-btn",
  405. "div[class*='upload']",
  406. ]
  407. upload_success = False
  408. for selector in upload_selectors:
  409. try:
  410. upload_div = self.page.locator(selector).first
  411. if await upload_div.count() > 0:
  412. print(f"[{self.platform_name}] 找到上传区域: {selector}")
  413. async with self.page.expect_file_chooser(timeout=10000) as fc_info:
  414. await upload_div.click()
  415. file_chooser = await fc_info.value
  416. await file_chooser.set_files(params.video_path)
  417. upload_success = True
  418. print(f"[{self.platform_name}] 视频文件已选择")
  419. break
  420. except Exception as e:
  421. print(f"[{self.platform_name}] 选择器 {selector} 失败: {e}")
  422. if not upload_success:
  423. screenshot_base64 = await self.capture_screenshot()
  424. return PublishResult(
  425. success=False,
  426. platform=self.platform_name,
  427. error="未找到上传入口",
  428. screenshot_base64=screenshot_base64,
  429. page_url=await self.get_page_url(),
  430. status='failed'
  431. )
  432. # 等待跳转到发布页面 - 参考 matrix
  433. self.report_progress(20, "等待进入发布页面...")
  434. for i in range(60):
  435. try:
  436. # matrix 等待的 URL: https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page
  437. await self.page.wait_for_url(
  438. "https://creator.douyin.com/creator-micro/content/post/video*",
  439. timeout=2000
  440. )
  441. print(f"[{self.platform_name}] 已进入发布页面")
  442. break
  443. except:
  444. print(f"[{self.platform_name}] 等待进入发布页面... {i+1}/60")
  445. await asyncio.sleep(1)
  446. await asyncio.sleep(2)
  447. self.report_progress(30, "正在填充标题和话题...")
  448. # 填写标题 - 参考 matrix
  449. title_input = self.page.get_by_text('作品标题').locator("..").locator(
  450. "xpath=following-sibling::div[1]").locator("input")
  451. if await title_input.count():
  452. await title_input.fill(params.title[:30])
  453. print(f"[{self.platform_name}] 标题已填写")
  454. else:
  455. # 备用方式 - 参考 matrix
  456. title_container = self.page.locator(".notranslate")
  457. await title_container.click()
  458. await self.page.keyboard.press("Backspace")
  459. await self.page.keyboard.press("Control+KeyA")
  460. await self.page.keyboard.press("Delete")
  461. await self.page.keyboard.type(params.title)
  462. await self.page.keyboard.press("Enter")
  463. print(f"[{self.platform_name}] 标题已填写(备用方式)")
  464. # 添加话题标签 - 参考 matrix
  465. if params.tags:
  466. css_selector = ".zone-container"
  467. for index, tag in enumerate(params.tags, start=1):
  468. print(f"[{self.platform_name}] 正在添加第{index}个话题: #{tag}")
  469. await self.page.type(css_selector, "#" + tag)
  470. await self.page.press(css_selector, "Space")
  471. self.report_progress(40, "等待视频上传完成...")
  472. # 等待视频上传完成 - 参考 matrix: 检测"重新上传"按钮
  473. for i in range(120):
  474. try:
  475. count = await self.page.locator("div").filter(has_text="重新上传").count()
  476. if count > 0:
  477. print(f"[{self.platform_name}] 视频上传完毕")
  478. break
  479. else:
  480. print(f"[{self.platform_name}] 正在上传视频中... {i+1}/120")
  481. # 检查上传错误
  482. if await self.page.locator('div.progress-div > div:has-text("上传失败")').count():
  483. print(f"[{self.platform_name}] 发现上传出错了,重新上传...")
  484. await self.handle_upload_error(params.video_path)
  485. await asyncio.sleep(3)
  486. except:
  487. print(f"[{self.platform_name}] 正在上传视频中...")
  488. await asyncio.sleep(3)
  489. self.report_progress(60, "处理视频设置...")
  490. # 点击"我知道了"弹窗 - 参考 matrix
  491. known_count = await self.page.get_by_role("button", name="我知道了").count()
  492. if known_count > 0:
  493. await self.page.get_by_role("button", name="我知道了").nth(0).click()
  494. print(f"[{self.platform_name}] 关闭弹窗")
  495. await asyncio.sleep(5)
  496. # 设置位置 - 参考 matrix
  497. try:
  498. await self.page.locator('div.semi-select span:has-text("输入地理位置")').click()
  499. await asyncio.sleep(1)
  500. await self.page.keyboard.press("Backspace")
  501. await self.page.keyboard.press("Control+KeyA")
  502. await self.page.keyboard.press("Delete")
  503. await self.page.keyboard.type(params.location)
  504. await asyncio.sleep(1)
  505. await self.page.locator('div[role="listbox"] [role="option"]').first.click()
  506. print(f"[{self.platform_name}] 位置设置成功: {params.location}")
  507. except Exception as e:
  508. print(f"[{self.platform_name}] 设置位置失败: {e}")
  509. # 开启头条/西瓜同步 - 参考 matrix
  510. try:
  511. third_part_element = '[class^="info"] > [class^="first-part"] div div.semi-switch'
  512. if await self.page.locator(third_part_element).count():
  513. class_name = await self.page.eval_on_selector(
  514. third_part_element, 'div => div.className')
  515. if 'semi-switch-checked' not in class_name:
  516. await self.page.locator(third_part_element).locator(
  517. 'input.semi-switch-native-control').click()
  518. print(f"[{self.platform_name}] 已开启头条/西瓜同步")
  519. except:
  520. pass
  521. # 定时发布
  522. if params.publish_date:
  523. self.report_progress(70, "设置定时发布...")
  524. await self.set_schedule_time(params.publish_date)
  525. self.report_progress(80, "正在发布...")
  526. print(f"[{self.platform_name}] 查找发布按钮...")
  527. # 点击发布 - 参考 matrix
  528. for i in range(30):
  529. try:
  530. # 检查验证码(不要在每次循环都调 AI,太慢)
  531. if i % 5 == 0:
  532. ai_captcha = await self.ai_check_captcha()
  533. if ai_captcha['has_captcha']:
  534. print(f"[{self.platform_name}] AI检测到发布过程中需要验证码: {ai_captcha['captcha_type']}", flush=True)
  535. if ai_captcha['captcha_type'] == 'phone':
  536. handled = await self.handle_phone_captcha()
  537. if handled:
  538. continue
  539. screenshot_base64 = await self.capture_screenshot()
  540. page_url = await self.get_page_url()
  541. return PublishResult(
  542. success=False,
  543. platform=self.platform_name,
  544. error=f"发布过程中需要{ai_captcha['captcha_type']}验证码,请使用有头浏览器完成验证",
  545. need_captcha=True,
  546. captcha_type=ai_captcha['captcha_type'],
  547. screenshot_base64=screenshot_base64,
  548. page_url=page_url,
  549. status='need_captcha'
  550. )
  551. publish_btn = self.page.get_by_role('button', name="发布", exact=True)
  552. btn_count = await publish_btn.count()
  553. if btn_count > 0:
  554. print(f"[{self.platform_name}] 点击发布按钮...")
  555. await publish_btn.click()
  556. # 等待跳转到内容管理页面 - 参考 matrix
  557. await self.page.wait_for_url(
  558. "https://creator.douyin.com/creator-micro/content/manage",
  559. timeout=5000
  560. )
  561. self.report_progress(100, "发布成功")
  562. print(f"[{self.platform_name}] 视频发布成功!")
  563. screenshot_base64 = await self.capture_screenshot()
  564. page_url = await self.get_page_url()
  565. return PublishResult(
  566. success=True,
  567. platform=self.platform_name,
  568. message="发布成功",
  569. screenshot_base64=screenshot_base64,
  570. page_url=page_url,
  571. status='success'
  572. )
  573. except Exception as e:
  574. current_url = self.page.url
  575. # 检查是否已经在管理页面
  576. if "https://creator.douyin.com/creator-micro/content/manage" in current_url:
  577. self.report_progress(100, "发布成功")
  578. print(f"[{self.platform_name}] 视频发布成功!")
  579. screenshot_base64 = await self.capture_screenshot()
  580. return PublishResult(
  581. success=True,
  582. platform=self.platform_name,
  583. message="发布成功",
  584. screenshot_base64=screenshot_base64,
  585. page_url=current_url,
  586. status='success'
  587. )
  588. else:
  589. print(f"[{self.platform_name}] 视频正在发布中... {i+1}/30, URL: {current_url}")
  590. await asyncio.sleep(1)
  591. # 发布超时
  592. print(f"[{self.platform_name}] 发布超时,获取截图...")
  593. screenshot_base64 = await self.capture_screenshot()
  594. page_url = await self.get_page_url()
  595. return PublishResult(
  596. success=False,
  597. platform=self.platform_name,
  598. error="发布超时,请检查发布状态",
  599. screenshot_base64=screenshot_base64,
  600. page_url=page_url,
  601. status='need_action'
  602. )
  603. async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
  604. """获取抖音作品列表"""
  605. print(f"\n{'='*60}")
  606. print(f"[{self.platform_name}] 获取作品列表")
  607. print(f"[{self.platform_name}] page={page}, page_size={page_size}")
  608. print(f"{'='*60}")
  609. works: List[WorkItem] = []
  610. total = 0
  611. has_more = False
  612. try:
  613. await self.init_browser()
  614. cookie_list = self.parse_cookies(cookies)
  615. await self.set_cookies(cookie_list)
  616. if not self.page:
  617. raise Exception("Page not initialized")
  618. # 访问创作者中心首页以触发登录验证
  619. await self.page.goto("https://creator.douyin.com/creator-micro/home")
  620. await asyncio.sleep(3)
  621. # 检查登录状态
  622. current_url = self.page.url
  623. if "login" in current_url or "passport" in current_url:
  624. raise Exception("Cookie 已过期,请重新登录")
  625. # 调用作品列表 API
  626. cursor = page * page_size
  627. # 移除 scene=star_atlas 和 aid=1128,使用更通用的参数
  628. api_url = f"https://creator.douyin.com/janus/douyin/creator/pc/work_list?status=0&device_platform=android&count={page_size}&max_cursor={cursor}&cookie_enabled=true&browser_language=zh-CN&browser_platform=Win32&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FShanghai"
  629. response = await self.page.evaluate(f'''
  630. async () => {{
  631. try {{
  632. const resp = await fetch("{api_url}", {{
  633. credentials: 'include',
  634. headers: {{ 'Accept': 'application/json' }}
  635. }});
  636. return await resp.json();
  637. }} catch (e) {{
  638. return {{ error: e.toString() }};
  639. }}
  640. }}
  641. ''')
  642. if response.get('error'):
  643. print(f"[{self.platform_name}] API 请求失败: {response.get('error')}", flush=True)
  644. print(f"[{self.platform_name}] API 响应: has_more={response.get('has_more')}, aweme_list={len(response.get('aweme_list', []))}")
  645. aweme_list = response.get('aweme_list', [])
  646. has_more = response.get('has_more', False)
  647. for aweme in aweme_list:
  648. aweme_id = str(aweme.get('aweme_id', ''))
  649. if not aweme_id:
  650. continue
  651. statistics = aweme.get('statistics', {})
  652. # 打印调试信息,确认字段存在
  653. # print(f"[{self.platform_name}] 作品 {aweme_id} 统计: {statistics}", flush=True)
  654. # 获取封面
  655. cover_url = ''
  656. if aweme.get('Cover', {}).get('url_list'):
  657. cover_url = aweme['Cover']['url_list'][0]
  658. elif aweme.get('video', {}).get('cover', {}).get('url_list'):
  659. cover_url = aweme['video']['cover']['url_list'][0]
  660. # 获取标题
  661. title = aweme.get('item_title', '') or aweme.get('desc', '').split('\n')[0][:50] or '无标题'
  662. # 获取时长(毫秒转秒)
  663. duration = aweme.get('video', {}).get('duration', 0) // 1000
  664. # 获取发布时间
  665. create_time = aweme.get('create_time', 0)
  666. publish_time = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') if create_time else ''
  667. works.append(WorkItem(
  668. work_id=aweme_id,
  669. title=title,
  670. cover_url=cover_url,
  671. duration=duration,
  672. status='published',
  673. publish_time=publish_time,
  674. play_count=int(statistics.get('play_count', 0)),
  675. like_count=int(statistics.get('digg_count', 0)),
  676. comment_count=int(statistics.get('comment_count', 0)),
  677. share_count=int(statistics.get('share_count', 0)),
  678. ))
  679. total = len(works)
  680. print(f"[{self.platform_name}] 获取到 {total} 个作品")
  681. except Exception as e:
  682. import traceback
  683. traceback.print_exc()
  684. return WorksResult(
  685. success=False,
  686. platform=self.platform_name,
  687. error=str(e)
  688. )
  689. return WorksResult(
  690. success=True,
  691. platform=self.platform_name,
  692. works=works,
  693. total=total,
  694. has_more=has_more
  695. )
  696. async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
  697. """获取抖音作品评论 - 通过访问视频详情页拦截评论 API"""
  698. print(f"\n{'='*60}")
  699. print(f"[{self.platform_name}] 获取作品评论")
  700. print(f"[{self.platform_name}] work_id={work_id}, cursor={cursor}")
  701. print(f"{'='*60}")
  702. comments: List[CommentItem] = []
  703. total = 0
  704. has_more = False
  705. next_cursor = ""
  706. captured_data = {}
  707. try:
  708. await self.init_browser()
  709. cookie_list = self.parse_cookies(cookies)
  710. await self.set_cookies(cookie_list)
  711. if not self.page:
  712. raise Exception("Page not initialized")
  713. # 设置 API 响应监听器
  714. async def handle_response(response):
  715. nonlocal captured_data
  716. url = response.url
  717. # 监听评论列表 API - 抖音视频页面使用的 API
  718. # /aweme/v1/web/comment/list/ 或 /comment/list/
  719. if '/comment/list' in url and ('aweme_id' in url or work_id in url):
  720. try:
  721. json_data = await response.json()
  722. print(f"[{self.platform_name}] 捕获到评论 API: {url[:100]}...", flush=True)
  723. # 检查响应是否成功
  724. if json_data.get('status_code') == 0 or json_data.get('comments'):
  725. captured_data = json_data
  726. comment_count = len(json_data.get('comments', []))
  727. print(f"[{self.platform_name}] 评论 API 响应成功: comments={comment_count}, has_more={json_data.get('has_more')}", flush=True)
  728. except Exception as e:
  729. print(f"[{self.platform_name}] 解析评论响应失败: {e}", flush=True)
  730. self.page.on('response', handle_response)
  731. print(f"[{self.platform_name}] 已注册评论 API 响应监听器", flush=True)
  732. # 访问视频详情页 - 这会自动触发评论 API 请求
  733. video_url = f"https://www.douyin.com/video/{work_id}"
  734. print(f"[{self.platform_name}] 访问视频详情页: {video_url}", flush=True)
  735. await self.page.goto(video_url, wait_until="domcontentloaded", timeout=30000)
  736. await asyncio.sleep(5)
  737. # 检查登录状态
  738. current_url = self.page.url
  739. if "login" in current_url or "passport" in current_url:
  740. raise Exception("Cookie 已过期,请重新登录")
  741. # 等待评论加载
  742. if not captured_data:
  743. print(f"[{self.platform_name}] 等待评论 API 响应...", flush=True)
  744. # 尝试滚动页面触发评论加载
  745. await self.page.evaluate('window.scrollBy(0, 300)')
  746. await asyncio.sleep(3)
  747. if not captured_data:
  748. # 再等待一会
  749. await asyncio.sleep(3)
  750. # 移除监听器
  751. self.page.remove_listener('response', handle_response)
  752. # 解析评论数据
  753. if captured_data:
  754. comment_list = captured_data.get('comments') or []
  755. has_more = captured_data.get('has_more', False) or captured_data.get('has_more', 0) == 1
  756. next_cursor = str(captured_data.get('cursor', ''))
  757. total = captured_data.get('total', 0) or len(comment_list)
  758. print(f"[{self.platform_name}] 解析评论: total={total}, has_more={has_more}, comments={len(comment_list)}", flush=True)
  759. for comment in comment_list:
  760. cid = str(comment.get('cid', ''))
  761. if not cid:
  762. continue
  763. user = comment.get('user', {})
  764. # 解析回复列表
  765. replies = []
  766. reply_list = comment.get('reply_comment', []) or []
  767. for reply in reply_list:
  768. reply_user = reply.get('user', {})
  769. replies.append(CommentItem(
  770. comment_id=str(reply.get('cid', '')),
  771. work_id=work_id,
  772. content=reply.get('text', ''),
  773. author_id=str(reply_user.get('uid', '')),
  774. author_name=reply_user.get('nickname', ''),
  775. author_avatar=reply_user.get('avatar_thumb', {}).get('url_list', [''])[0] if reply_user.get('avatar_thumb') else '',
  776. like_count=int(reply.get('digg_count', 0)),
  777. create_time=datetime.fromtimestamp(reply.get('create_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if reply.get('create_time') else '',
  778. is_author=reply.get('is_author', False),
  779. ))
  780. comments.append(CommentItem(
  781. comment_id=cid,
  782. work_id=work_id,
  783. content=comment.get('text', ''),
  784. author_id=str(user.get('uid', '')),
  785. author_name=user.get('nickname', ''),
  786. author_avatar=user.get('avatar_thumb', {}).get('url_list', [''])[0] if user.get('avatar_thumb') else '',
  787. like_count=int(comment.get('digg_count', 0)),
  788. reply_count=int(comment.get('reply_comment_total', 0)),
  789. create_time=datetime.fromtimestamp(comment.get('create_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if comment.get('create_time') else '',
  790. is_author=comment.get('is_author', False),
  791. replies=replies,
  792. ))
  793. print(f"[{self.platform_name}] 解析到 {len(comments)} 条评论", flush=True)
  794. else:
  795. print(f"[{self.platform_name}] 未捕获到评论 API 响应", flush=True)
  796. except Exception as e:
  797. import traceback
  798. traceback.print_exc()
  799. return CommentsResult(
  800. success=False,
  801. platform=self.platform_name,
  802. work_id=work_id,
  803. error=str(e)
  804. )
  805. finally:
  806. await self.close_browser()
  807. result = CommentsResult(
  808. success=True,
  809. platform=self.platform_name,
  810. work_id=work_id,
  811. comments=comments,
  812. total=total,
  813. has_more=has_more
  814. )
  815. result.__dict__['cursor'] = next_cursor
  816. return result
  817. async def get_all_comments(self, cookies: str) -> dict:
  818. """获取所有作品的评论 - 通过评论管理页面"""
  819. print(f"\n{'='*60}")
  820. print(f"[{self.platform_name}] 获取所有作品评论")
  821. print(f"{'='*60}")
  822. all_work_comments = []
  823. captured_comments = []
  824. captured_works = {} # work_id -> work_info
  825. try:
  826. await self.init_browser()
  827. cookie_list = self.parse_cookies(cookies)
  828. await self.set_cookies(cookie_list)
  829. if not self.page:
  830. raise Exception("Page not initialized")
  831. # 设置 API 响应监听器
  832. async def handle_response(response):
  833. nonlocal captured_comments, captured_works
  834. url = response.url
  835. try:
  836. # 监听评论列表 API - 多种格式
  837. # /comment/list/select/ 或 /comment/read 或 /creator/comment/list
  838. if '/comment/list' in url or '/comment/read' in url or 'comment_list' in url:
  839. json_data = await response.json()
  840. print(f"[{self.platform_name}] 捕获到评论 API: {url[:100]}...", flush=True)
  841. # 格式1: comments 字段
  842. comments = json_data.get('comments', [])
  843. # 格式2: comment_info_list 字段
  844. if not comments:
  845. comments = json_data.get('comment_info_list', [])
  846. if comments:
  847. # 从 URL 中提取 aweme_id
  848. import re
  849. aweme_id_match = re.search(r'aweme_id=(\d+)', url)
  850. aweme_id = aweme_id_match.group(1) if aweme_id_match else ''
  851. for comment in comments:
  852. # 添加 aweme_id 到评论中
  853. if aweme_id and 'aweme_id' not in comment:
  854. comment['aweme_id'] = aweme_id
  855. captured_comments.append(comment)
  856. print(f"[{self.platform_name}] 捕获到 {len(comments)} 条评论 (aweme_id={aweme_id}),总计: {len(captured_comments)}", flush=True)
  857. # 监听作品列表 API
  858. if '/work_list' in url or '/item/list' in url or '/creator/item' in url:
  859. json_data = await response.json()
  860. aweme_list = json_data.get('aweme_list', []) or json_data.get('item_info_list', []) or json_data.get('item_list', [])
  861. print(f"[{self.platform_name}] 捕获到作品列表 API: {len(aweme_list)} 个作品", flush=True)
  862. for aweme in aweme_list:
  863. aweme_id = str(aweme.get('aweme_id', '') or aweme.get('item_id', '') or aweme.get('item_id_plain', ''))
  864. if aweme_id:
  865. cover_url = ''
  866. if aweme.get('Cover', {}).get('url_list'):
  867. cover_url = aweme['Cover']['url_list'][0]
  868. elif aweme.get('video', {}).get('cover', {}).get('url_list'):
  869. cover_url = aweme['video']['cover']['url_list'][0]
  870. elif aweme.get('cover_image_url'):
  871. cover_url = aweme['cover_image_url']
  872. captured_works[aweme_id] = {
  873. 'title': aweme.get('item_title', '') or aweme.get('title', '') or aweme.get('desc', ''),
  874. 'cover': cover_url,
  875. 'comment_count': aweme.get('statistics', {}).get('comment_count', 0) or aweme.get('comment_count', 0),
  876. }
  877. except Exception as e:
  878. print(f"[{self.platform_name}] 解析响应失败: {e}", flush=True)
  879. self.page.on('response', handle_response)
  880. print(f"[{self.platform_name}] 已注册 API 响应监听器", flush=True)
  881. # 访问评论管理页面
  882. print(f"[{self.platform_name}] 访问评论管理页面...", flush=True)
  883. await self.page.goto("https://creator.douyin.com/creator-micro/interactive/comment", wait_until="domcontentloaded", timeout=30000)
  884. await asyncio.sleep(5)
  885. # 检查登录状态
  886. current_url = self.page.url
  887. if "login" in current_url or "passport" in current_url:
  888. raise Exception("Cookie 已过期,请重新登录")
  889. print(f"[{self.platform_name}] 页面加载完成,当前捕获: {len(captured_comments)} 条评论, {len(captured_works)} 个作品", flush=True)
  890. # 尝试点击"选择作品"来加载作品列表
  891. try:
  892. select_btn = await self.page.query_selector('text="选择作品"')
  893. if select_btn:
  894. print(f"[{self.platform_name}] 点击选择作品按钮...", flush=True)
  895. await select_btn.click()
  896. await asyncio.sleep(3)
  897. # 获取作品列表
  898. work_items = await self.page.query_selector_all('[class*="work-item"], [class*="video-item"], [class*="aweme-item"]')
  899. print(f"[{self.platform_name}] 找到 {len(work_items)} 个作品元素", flush=True)
  900. # 点击每个作品加载其评论
  901. for i, item in enumerate(work_items[:10]): # 最多处理10个作品
  902. try:
  903. await item.click()
  904. await asyncio.sleep(2)
  905. print(f"[{self.platform_name}] 已点击作品 {i+1}/{min(len(work_items), 10)}", flush=True)
  906. except:
  907. pass
  908. # 关闭选择作品弹窗
  909. close_btn = await self.page.query_selector('[class*="close"], [class*="cancel"]')
  910. if close_btn:
  911. await close_btn.click()
  912. await asyncio.sleep(1)
  913. except Exception as e:
  914. print(f"[{self.platform_name}] 选择作品操作失败: {e}", flush=True)
  915. # 滚动加载更多评论
  916. for i in range(5):
  917. await self.page.evaluate('window.scrollBy(0, 500)')
  918. await asyncio.sleep(1)
  919. await asyncio.sleep(3)
  920. # 移除监听器
  921. self.page.remove_listener('response', handle_response)
  922. print(f"[{self.platform_name}] 最终捕获: {len(captured_comments)} 条评论, {len(captured_works)} 个作品", flush=True)
  923. # 按作品分组评论
  924. work_comments_map = {} # work_id -> work_comments
  925. for comment in captured_comments:
  926. # 从评论中获取作品信息
  927. aweme = comment.get('aweme', {}) or comment.get('item', {})
  928. aweme_id = str(comment.get('aweme_id', '') or aweme.get('aweme_id', '') or aweme.get('item_id', ''))
  929. if not aweme_id:
  930. continue
  931. if aweme_id not in work_comments_map:
  932. work_info = captured_works.get(aweme_id, {})
  933. work_comments_map[aweme_id] = {
  934. 'work_id': aweme_id,
  935. 'title': aweme.get('title', '') or aweme.get('desc', '') or work_info.get('title', ''),
  936. 'cover_url': aweme.get('cover', {}).get('url_list', [''])[0] if aweme.get('cover') else work_info.get('cover', ''),
  937. 'comments': []
  938. }
  939. cid = str(comment.get('cid', ''))
  940. if not cid:
  941. continue
  942. user = comment.get('user', {})
  943. work_comments_map[aweme_id]['comments'].append({
  944. 'comment_id': cid,
  945. 'author_id': str(user.get('uid', '')),
  946. 'author_name': user.get('nickname', ''),
  947. 'author_avatar': user.get('avatar_thumb', {}).get('url_list', [''])[0] if user.get('avatar_thumb') else '',
  948. 'content': comment.get('text', ''),
  949. 'like_count': int(comment.get('digg_count', 0)),
  950. 'create_time': datetime.fromtimestamp(comment.get('create_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if comment.get('create_time') else '',
  951. 'is_author': comment.get('is_author', False),
  952. })
  953. all_work_comments = list(work_comments_map.values())
  954. total_comments = sum(len(w['comments']) for w in all_work_comments)
  955. print(f"[{self.platform_name}] 获取到 {len(all_work_comments)} 个作品的 {total_comments} 条评论", flush=True)
  956. except Exception as e:
  957. import traceback
  958. traceback.print_exc()
  959. return {
  960. 'success': False,
  961. 'platform': self.platform_name,
  962. 'error': str(e),
  963. 'work_comments': []
  964. }
  965. finally:
  966. await self.close_browser()
  967. return {
  968. 'success': True,
  969. 'platform': self.platform_name,
  970. 'work_comments': all_work_comments,
  971. 'total': len(all_work_comments)
  972. }
  973. async def auto_reply_private_messages(self, cookies: str) -> dict:
  974. """自动回复抖音私信 - 适配新页面结构"""
  975. print(f"\n{'='*60}")
  976. print(f"[{self.platform_name}] 开始自动回复抖音私信")
  977. print(f"{'='*60}")
  978. try:
  979. await self.init_browser()
  980. cookie_list = self.parse_cookies(cookies)
  981. await self.set_cookies(cookie_list)
  982. if not self.page:
  983. raise Exception("Page not initialized")
  984. # 访问抖音私信页面
  985. await self.page.goto("https://creator.douyin.com/creator-micro/data/following/chat", timeout=30000)
  986. await asyncio.sleep(3)
  987. # 检查登录状态
  988. current_url = self.page.url
  989. print(f"[{self.platform_name}] 当前 URL: {current_url}")
  990. if "login" in current_url or "passport" in current_url:
  991. raise Exception("Cookie 已过期,请重新登录")
  992. replied_count = 0
  993. # 处理两个tab: 陌生人私信 和 朋友私信
  994. for tab_name in ["陌生人私信", "朋友私信"]:
  995. print(f"\n{'='*50}")
  996. print(f"[{self.platform_name}] 处理 {tab_name} ...")
  997. print(f"{'='*50}")
  998. # 点击对应tab
  999. tab_locator = self.page.locator(f'div.semi-tabs-tab:text-is("{tab_name}")')
  1000. if await tab_locator.count() > 0:
  1001. await tab_locator.click()
  1002. await asyncio.sleep(2)
  1003. else:
  1004. print(f"⚠️ 未找到 {tab_name} 标签,跳过")
  1005. continue
  1006. # 获取私信列表
  1007. session_items = self.page.locator('.semi-list-item')
  1008. session_count = await session_items.count()
  1009. print(f"[{self.platform_name}] {tab_name} 共找到 {session_count} 条会话")
  1010. if session_count == 0:
  1011. print(f"[{self.platform_name}] {tab_name} 无新私信")
  1012. continue
  1013. for idx in range(session_count):
  1014. try:
  1015. # 重新获取列表(防止 DOM 变化)
  1016. current_sessions = self.page.locator('.semi-list-item')
  1017. if idx >= await current_sessions.count():
  1018. break
  1019. session = current_sessions.nth(idx)
  1020. user_name = await session.locator('.item-header-name-vL_79m').inner_text()
  1021. last_msg = await session.locator('.text-whxV9A').inner_text()
  1022. print(f"\n ➤ [{idx+1}/{session_count}] 处理用户: {user_name} | 最后消息: {last_msg[:30]}...")
  1023. # 检查会话预览消息是否包含非文字内容
  1024. if "分享" in last_msg and ("视频" in last_msg or "图片" in last_msg or "链接" in last_msg):
  1025. print(" ➤ 会话预览为非文字消息,跳过")
  1026. continue
  1027. # 点击进入聊天
  1028. await session.click()
  1029. await asyncio.sleep(2)
  1030. # 提取聊天历史(判断最后一条是否是自己发的)
  1031. chat_messages = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
  1032. msg_count = await chat_messages.count()
  1033. should_reply = True
  1034. if msg_count > 0:
  1035. # 最后一条消息
  1036. last_msg_el = chat_messages.nth(msg_count - 1)
  1037. # 获取元素的 class 属性判断是否是自己发的
  1038. classes = await last_msg_el.get_attribute('class') or ''
  1039. is_my_message = 'is-me-' in classes # 包含 is-me- 表示是自己发的
  1040. should_reply = not is_my_message # 如果是自己发的就不回复
  1041. if should_reply:
  1042. # 提取完整聊天历史
  1043. chat_history = await self._extract_chat_history()
  1044. if chat_history:
  1045. # 生成回复
  1046. reply_text = await self._generate_reply_with_ai(chat_history)
  1047. if not reply_text:
  1048. reply_text = self._generate_reply(chat_history)
  1049. if reply_text:
  1050. print(f" 📝 回复内容: {reply_text}")
  1051. # 填充输入框
  1052. input_box = self.page.locator('div.chat-input-dccKiL[contenteditable="true"]')
  1053. send_btn = self.page.locator('button:has-text("发送")')
  1054. if await input_box.is_visible() and await send_btn.is_visible():
  1055. await input_box.fill(reply_text)
  1056. await asyncio.sleep(0.5)
  1057. await send_btn.click()
  1058. print(" ✅ 已发送")
  1059. replied_count += 1
  1060. await asyncio.sleep(2)
  1061. else:
  1062. print(" ❌ 输入框或发送按钮不可见")
  1063. else:
  1064. print(" ➤ 无需回复")
  1065. else:
  1066. print(" ➤ 聊天历史为空,跳过")
  1067. else:
  1068. print(" ➤ 最后一条是我发的,跳过")
  1069. except Exception as e:
  1070. print(f" ❌ 处理会话 {idx+1} 时出错: {e}")
  1071. continue
  1072. print(f"[{self.platform_name}] 自动回复完成,共回复 {replied_count} 条消息")
  1073. return {
  1074. 'success': True,
  1075. 'platform': self.platform_name,
  1076. 'replied_count': replied_count,
  1077. 'message': f'成功回复 {replied_count} 条私信'
  1078. }
  1079. except Exception as e:
  1080. import traceback
  1081. traceback.print_exc()
  1082. return {
  1083. 'success': False,
  1084. 'platform': self.platform_name,
  1085. 'error': str(e)
  1086. }
  1087. finally:
  1088. await self.close_browser()
  1089. # 辅助方法保持兼容(可复用)
  1090. def _generate_reply(self, chat_history: list) -> str:
  1091. """规则回复"""
  1092. if not chat_history:
  1093. return "你好!感谢联系~"
  1094. last_msg = chat_history[-1]["content"]
  1095. if "谢谢" in last_msg or "感谢" in last_msg:
  1096. return "不客气!欢迎常来交流~"
  1097. elif "你好" in last_msg or "在吗" in last_msg:
  1098. return "你好!请问有什么可以帮您的?"
  1099. elif "视频" in last_msg or "怎么拍" in last_msg:
  1100. return "视频是用手机拍摄的,注意光线和稳定哦!"
  1101. else:
  1102. return "收到!我会认真阅读您的留言~"
  1103. async def _extract_chat_history(self) -> list:
  1104. """精准提取聊天记录,区分作者(自己)和用户"""
  1105. if not self.page:
  1106. return []
  1107. history = []
  1108. # 获取所有聊天消息(排除时间戳元素)
  1109. message_wrappers = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
  1110. count = await message_wrappers.count()
  1111. for i in range(count):
  1112. try:
  1113. wrapper = message_wrappers.nth(i)
  1114. # 检查是否为自己发送的消息
  1115. classes = await wrapper.get_attribute('class') or ''
  1116. is_author = 'is-me-' in classes # 包含 is-me- 表示是自己发的
  1117. # 获取消息文本内容
  1118. text_element = wrapper.locator('.text-X2d7fS')
  1119. if await text_element.count() > 0:
  1120. content = await text_element.inner_text()
  1121. content = content.strip()
  1122. if content: # 只添加非空消息
  1123. # 获取用户名(如果是对方消息)
  1124. author_name = ''
  1125. if not is_author:
  1126. # 尝试获取对方用户名
  1127. name_elements = wrapper.locator('.aweme-author-name-m8uoXU')
  1128. if await name_elements.count() > 0:
  1129. author_name = await name_elements.nth(0).inner_text()
  1130. else:
  1131. author_name = '用户'
  1132. else:
  1133. author_name = '我'
  1134. history.append({
  1135. "author": author_name,
  1136. "content": content,
  1137. "is_author": is_author,
  1138. })
  1139. except Exception as e:
  1140. print(f" ⚠️ 解析第 {i+1} 条消息失败: {e}")
  1141. continue
  1142. return history
  1143. async def _generate_reply_with_ai(self, chat_history: list) -> str:
  1144. """使用 AI 生成回复(保留原逻辑)"""
  1145. import os, requests, json
  1146. try:
  1147. ai_api_key = os.environ.get('DASHSCOPE_API_KEY', '')
  1148. ai_base_url = os.environ.get('DASHSCOPE_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
  1149. ai_model = os.environ.get('AI_MODEL', 'qwen-plus')
  1150. if not ai_api_key:
  1151. return self._generate_reply(chat_history)
  1152. messages = [{"role": "system", "content": "你是一个友好的抖音创作者助手,负责回复粉丝私信。请保持简洁、友好、专业的语气。回复长度不超过20字。"}]
  1153. for msg in chat_history:
  1154. role = "assistant" if msg.get("is_author", False) else "user"
  1155. messages.append({"role": role, "content": msg["content"]})
  1156. headers = {'Authorization': f'Bearer {ai_api_key}', 'Content-Type': 'application/json'}
  1157. payload = {"model": ai_model, "messages": messages, "max_tokens": 150, "temperature": 0.8}
  1158. response = requests.post(f"{ai_base_url}/chat/completions", headers=headers, json=payload, timeout=30)
  1159. if response.status_code == 200:
  1160. ai_reply = response.json().get('choices', [{}])[0].get('message', {}).get('content', '').strip()
  1161. return ai_reply if ai_reply else self._generate_reply(chat_history)
  1162. else:
  1163. return self._generate_reply(chat_history)
  1164. except:
  1165. return self._generate_reply(chat_history)
  1166. async def get_work_comments_mapping(self, cookies: str) -> dict:
  1167. """获取所有作品及其评论的对应关系
  1168. Args:
  1169. cookies: 抖音创作者平台的cookies
  1170. Returns:
  1171. dict: 包含作品和评论对应关系的JSON数据
  1172. """
  1173. print(f"\n{'='*60}")
  1174. print(f"[{self.platform_name}] 获取作品和评论对应关系")
  1175. print(f"{'='*60}")
  1176. work_comments_mapping = []
  1177. try:
  1178. await self.init_browser()
  1179. cookie_list = self.parse_cookies(cookies)
  1180. await self.set_cookies(cookie_list)
  1181. if not self.page:
  1182. raise Exception("Page not initialized")
  1183. # 访问创作者中心首页
  1184. await self.page.goto("https://creator.douyin.com/creator-micro/home", timeout=30000)
  1185. await asyncio.sleep(3)
  1186. # 检查登录状态
  1187. current_url = self.page.url
  1188. if "login" in current_url or "passport" in current_url:
  1189. raise Exception("Cookie 已过期,请重新登录")
  1190. # 访问内容管理页面获取作品列表
  1191. print(f"[{self.platform_name}] 访问内容管理页面...")
  1192. await self.page.goto("https://creator.douyin.com/creator-micro/content/manage", timeout=30000)
  1193. await asyncio.sleep(5)
  1194. # 获取作品列表
  1195. works_result = await self.get_works(cookies, page=0, page_size=20)
  1196. if not works_result.success:
  1197. print(f"[{self.platform_name}] 获取作品列表失败: {works_result.error}")
  1198. return {
  1199. 'success': False,
  1200. 'platform': self.platform_name,
  1201. 'error': works_result.error,
  1202. 'work_comments': []
  1203. }
  1204. print(f"[{self.platform_name}] 获取到 {len(works_result.works)} 个作品")
  1205. # 对每个作品获取评论
  1206. for i, work in enumerate(works_result.works):
  1207. print(f"[{self.platform_name}] 正在获取作品 {i+1}/{len(works_result.works)} 的评论: {work.title[:20]}...")
  1208. # 获取单个作品的评论
  1209. comments_result = await self.get_comments(cookies, work.work_id)
  1210. if comments_result.success:
  1211. work_comments_mapping.append({
  1212. 'work_info': work.to_dict(),
  1213. 'comments': [comment.to_dict() for comment in comments_result.comments]
  1214. })
  1215. print(f"[{self.platform_name}] 作品 '{work.title[:20]}...' 获取到 {len(comments_result.comments)} 条评论")
  1216. else:
  1217. print(f"[{self.platform_name}] 获取作品 '{work.title[:20]}...' 评论失败: {comments_result.error}")
  1218. work_comments_mapping.append({
  1219. 'work_info': work.to_dict(),
  1220. 'comments': [],
  1221. 'error': comments_result.error
  1222. })
  1223. # 添加延时避免请求过于频繁
  1224. await asyncio.sleep(2)
  1225. print(f"[{self.platform_name}] 所有作品评论获取完成")
  1226. except Exception as e:
  1227. import traceback
  1228. traceback.print_exc()
  1229. return {
  1230. 'success': False,
  1231. 'platform': self.platform_name,
  1232. 'error': str(e),
  1233. 'work_comments': []
  1234. }
  1235. finally:
  1236. await self.close_browser()
  1237. return {
  1238. 'success': True,
  1239. 'platform': self.platform_name,
  1240. 'work_comments': work_comments_mapping,
  1241. 'summary': {
  1242. 'total_works': len(work_comments_mapping),
  1243. 'total_comments': sum(len(item['comments']) for item in work_comments_mapping),
  1244. }
  1245. }