ai.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """步骤:AI抖音总结。"""
  2. from __future__ import annotations
  3. import re
  4. import time
  5. from playwright.sync_api import Page
  6. from collectors.douyin.common import AI_SUMMARY_PROMPT, human_pause
  7. from collectors.douyin.panel import _click_side_panel_tab
  8. def _get_ai_frame(page: Page):
  9. """获取 AI抖音 iframe(so-landing / search_ai_mobile)。"""
  10. deadline = time.time() + 15
  11. while time.time() < deadline:
  12. for frame in page.frames:
  13. url = frame.url or ""
  14. if "search_ai" in url or "so-landing.douyin.com" in url:
  15. return frame
  16. try:
  17. page.wait_for_selector(
  18. 'iframe[src*="search_ai_mobile"], iframe.D99qPWMG',
  19. timeout=2_000,
  20. )
  21. except Exception:
  22. human_pause(0.5, 0.9)
  23. return None
  24. def _parse_ai_summary_text(body_text: str, prompt: str) -> str:
  25. """从 AI iframe 文本中提取总结正文。"""
  26. text = (body_text or "").strip()
  27. if not text:
  28. return ""
  29. # 优先取「最后一次提问」之后的回复
  30. if prompt and prompt in text:
  31. reply = text.rsplit(prompt, 1)[-1].strip()
  32. else:
  33. reply = text
  34. # 若存在「以上为历史记录」,取其后内容
  35. if "以上为历史记录" in reply:
  36. reply = reply.split("以上为历史记录", 1)[-1].strip()
  37. if prompt and prompt in reply:
  38. reply = reply.split(prompt, 1)[-1].strip()
  39. skip_exact = {
  40. "AI 总结",
  41. "高光片段",
  42. "换一换",
  43. "下载抖音精选",
  44. "以上为历史记录",
  45. }
  46. skip_prefix = (
  47. "以上结果",
  48. "看到视频",
  49. "分享一些",
  50. "有哪些",
  51. "如何",
  52. "推荐一些",
  53. "核心原理",
  54. "无效输入",
  55. "i+1",
  56. "自学可行性",
  57. "视频提到",
  58. )
  59. lines = [ln.strip() for ln in reply.splitlines() if ln.strip()]
  60. for line in lines:
  61. if line in skip_exact:
  62. continue
  63. if line.startswith(skip_prefix):
  64. continue
  65. if re.match(r"^\d{1,2}:\d{2}", line):
  66. continue
  67. if line == prompt:
  68. continue
  69. if 8 <= len(line) <= 200:
  70. return line[:512]
  71. # 回退:页面上「AI 总结」后的首段
  72. if "AI 总结" in text:
  73. after = text.split("AI 总结", 1)[-1]
  74. for line in after.splitlines():
  75. line = line.strip()
  76. if 8 <= len(line) <= 200 and line not in skip_exact:
  77. return line[:512]
  78. return ""
  79. def collect_ai_summary(page: Page, timeout_ms: int = 60_000) -> str:
  80. """
  81. 前提:已点击评论图标打开右侧栏。
  82. 切换到「AI抖音」→ iframe 输入发送 → 返回总结文本。
  83. """
  84. print("[AI抖音] 切换到 AI抖音 tab...")
  85. if not _click_side_panel_tab(page, "AI抖音"):
  86. raise RuntimeError("无法切换到「AI抖音」")
  87. frame = _get_ai_frame(page)
  88. if frame is None:
  89. raise RuntimeError("未找到 AI抖音 iframe")
  90. try:
  91. frame.wait_for_load_state("domcontentloaded")
  92. except Exception:
  93. pass
  94. frame.wait_for_selector('[contenteditable="true"], .input_blVmyq', timeout=20_000)
  95. human_pause(0.8, 1.2)
  96. before_text = frame.evaluate("() => document.body.innerText || ''") or ""
  97. box = frame.locator('[contenteditable="true"], .input_blVmyq').first
  98. box.click(timeout=8_000)
  99. page.keyboard.press("Control+A")
  100. page.keyboard.press("Backspace")
  101. page.keyboard.type(AI_SUMMARY_PROMPT, delay=30)
  102. human_pause(0.6, 1.0)
  103. typed = (
  104. frame.evaluate(
  105. """() => {
  106. const el = document.querySelector('[contenteditable="true"], .input_blVmyq');
  107. return el ? (el.innerText || el.textContent || '').trim() : '';
  108. }"""
  109. )
  110. or ""
  111. )
  112. if not typed:
  113. raise RuntimeError("AI抖音输入框未能写入总结指令")
  114. print(f"[AI抖音] 已输入: {typed[:40]}")
  115. # 等发送按钮激活再点
  116. sent = False
  117. for _ in range(10):
  118. sent = bool(
  119. frame.evaluate(
  120. """() => {
  121. const btn = document.querySelector('.searchButtonActive_viYBkl')
  122. || document.querySelector('.searchButton_IMT5UH');
  123. if (!btn) return false;
  124. const r = btn.getBoundingClientRect();
  125. if (r.width <= 0 || r.height <= 0) return false;
  126. const msg = btn.getAttribute('data-msg') || '';
  127. if (/上传|截图|图片/.test(msg)) return false;
  128. btn.click();
  129. return true;
  130. }"""
  131. )
  132. )
  133. if sent:
  134. break
  135. time.sleep(0.35)
  136. if not sent:
  137. page.keyboard.press("Enter")
  138. print("[AI抖音] 未定位发送按钮,已回车发送")
  139. else:
  140. print("[AI抖音] 已发送,等待回复...")
  141. deadline = time.time() + timeout_ms / 1000
  142. summary = ""
  143. while time.time() < deadline:
  144. human_pause(1.2, 1.8)
  145. body = frame.evaluate("() => document.body.innerText || ''") or ""
  146. candidate = _parse_ai_summary_text(body, AI_SUMMARY_PROMPT)
  147. if not candidate:
  148. continue
  149. if "以上为历史记录" in body or "以上结果由问问AI" in body:
  150. summary = candidate
  151. break
  152. if candidate not in (before_text or ""):
  153. summary = candidate
  154. break
  155. if not summary:
  156. body = frame.evaluate("() => document.body.innerText || ''") or ""
  157. summary = _parse_ai_summary_text(body, AI_SUMMARY_PROMPT)
  158. if not summary:
  159. raise RuntimeError("AI抖音未返回总结内容")
  160. summary = summary.strip()[:512]
  161. print(f"[AI抖音] 总结: {summary}")
  162. return summary