panel.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """步骤:右侧栏(评论图标 / tab / 评论数预检)。"""
  2. from __future__ import annotations
  3. import re
  4. import time
  5. from playwright.sync_api import Page
  6. from collectors.douyin.common import human_pause
  7. def _active_video_root_selector() -> str:
  8. return (
  9. '[data-e2e="feed-active-video"], '
  10. '[data-e2e="modal-video-container"]'
  11. )
  12. def _parse_count_text(raw: str) -> int:
  13. """解析评论数文案:4198 / 1.2万;抢首评/空文案视为 0。"""
  14. text = (raw or "").strip().replace(",", "").replace(" ", "")
  15. if not text or text in {"评论", "-", "抢首评"}:
  16. return 0
  17. if "抢首评" in text:
  18. return 0
  19. m = re.fullmatch(r"(\d+(?:\.\d+)?)(万)?", text)
  20. if not m:
  21. # 允许夹杂换行后的纯数字行
  22. m = re.search(r"^(\d+(?:\.\d+)?)(万)?$", text)
  23. if not m:
  24. return 0
  25. num = float(m.group(1))
  26. if m.group(2):
  27. num *= 10000
  28. return int(num)
  29. def _read_feed_comment_label(page: Page) -> str:
  30. """
  31. 读取当前活跃视频「评论图标」下方/内部文案。
  32. 只取 [data-e2e=feed-comment-icon] 自身文本,绝不上溯到含点赞数的父容器。
  33. """
  34. raw = page.evaluate(
  35. """() => {
  36. const root = document.querySelector('[data-e2e="feed-active-video"]')
  37. || document.querySelector('[data-e2e="modal-video-container"]')
  38. || document;
  39. const icon = root.querySelector('[data-e2e="feed-comment-icon"]');
  40. if (!icon) return '';
  41. // 只看评论图标节点内部(数字/抢首评一般在图标下方,仍属于该节点)
  42. const lines = (icon.innerText || '')
  43. .split('\\n')
  44. .map(s => s.trim())
  45. .filter(Boolean);
  46. if (lines.some(t => t.includes('抢首评'))) return '抢首评';
  47. // 优先取像数量的一行;否则取最后一行(通常是图标下方数字)
  48. for (const t of lines) {
  49. if (/^\\d+(?:\\.\\d+)?万?$/.test(t)) return t;
  50. }
  51. if (lines.length) return lines[lines.length - 1];
  52. // 图标内部无字时:在图标正下方、同一竖列找最近短文本(仍不碰点赞区)
  53. const ir = icon.getBoundingClientRect();
  54. const icx = ir.x + ir.width / 2;
  55. let best = '';
  56. let bestDist = Infinity;
  57. const nodes = Array.from(root.querySelectorAll('span, div, p, em'));
  58. for (const el of nodes) {
  59. if (icon.contains(el) && el !== icon) {
  60. const t = (el.innerText || '').trim();
  61. if (t && t.length <= 8) {
  62. if (t.includes('抢首评')) return '抢首评';
  63. if (/^\\d+(?:\\.\\d+)?万?$/.test(t)) return t;
  64. }
  65. }
  66. // 仅接受位于评论图标正下方很近的节点
  67. if (icon.contains(el)) continue;
  68. const t = (el.innerText || '').trim();
  69. if (!t || t.length > 8) continue;
  70. const r = el.getBoundingClientRect();
  71. if (r.width <= 0 || r.height <= 0) continue;
  72. const cx = r.x + r.width / 2;
  73. const cy = r.y + r.height / 2;
  74. if (Math.abs(cx - icx) > 28) continue;
  75. if (cy < ir.bottom - 2 || cy > ir.bottom + 48) continue;
  76. // 排除明显属于点赞/收藏等其它 e2e
  77. const e2e = el.getAttribute('data-e2e') || '';
  78. if (/like|collect|share|favor/i.test(e2e)) continue;
  79. const dist = Math.abs(cy - ir.bottom) + Math.abs(cx - icx);
  80. if (dist < bestDist) {
  81. bestDist = dist;
  82. best = t;
  83. }
  84. }
  85. return best;
  86. }"""
  87. )
  88. return (raw or "").strip()
  89. def _read_feed_comment_count(page: Page) -> tuple[int, str]:
  90. """
  91. 不打开评论区,读取评论图标文案。
  92. 返回 (count, raw_label):
  93. - raw 含「抢首评」时 count=0
  94. - 读不到图标文案时 count=-1
  95. """
  96. label = _read_feed_comment_label(page)
  97. if not label:
  98. return -1, ""
  99. if "抢首评" in label:
  100. return 0, "抢首评"
  101. return _parse_count_text(label), label
  102. def _ensure_video_page_focus(page: Page) -> None:
  103. """作者主页关掉后,把焦点拉回搜索结果当前视频。"""
  104. try:
  105. page.bring_to_front()
  106. except Exception:
  107. pass
  108. human_pause(0.5, 1.0)
  109. try:
  110. box = page.locator(_active_video_root_selector()).first.bounding_box()
  111. if box:
  112. page.mouse.click(
  113. box["x"] + min(box["width"] * 0.3, 200),
  114. box["y"] + box["height"] * 0.4,
  115. )
  116. human_pause(0.4, 0.8)
  117. except Exception:
  118. pass
  119. def _is_side_panel_open(page: Page) -> bool:
  120. """
  121. 判断视频右侧栏是否已打开(含「评论 / AI抖音」等 tab)。
  122. 注意:切到 AI抖音 后 comment-list 会隐藏,不能只靠评论列表判断。
  123. """
  124. try:
  125. return bool(
  126. page.evaluate(
  127. """() => {
  128. const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab'));
  129. const rightTabs = tabs.filter(t => {
  130. const text = (t.innerText || '').trim();
  131. const r = t.getBoundingClientRect();
  132. return r.x > 600 && r.width > 0 && r.height > 0
  133. && (text === '评论' || text === 'AI抖音' || text === '详情');
  134. });
  135. return rightTabs.length >= 2;
  136. }"""
  137. )
  138. )
  139. except Exception:
  140. return False
  141. def _wait_side_panel_open(page: Page, timeout_s: float = 12.0) -> bool:
  142. """等待右侧栏 tab 出现,期间不再次点击评论图标(避免开关误关)。"""
  143. deadline = time.time() + timeout_s
  144. while time.time() < deadline:
  145. if _is_side_panel_open(page):
  146. return True
  147. time.sleep(0.35)
  148. return _is_side_panel_open(page)
  149. def open_comment_panel(page: Page) -> None:
  150. """
  151. 点击「当前活跃视频」上的评论图标打开右侧栏。
  152. 评论图标是开关:已打开再点会收起。
  153. """
  154. _ensure_video_page_focus(page)
  155. if _is_side_panel_open(page):
  156. print("右侧栏已打开,跳过点击。")
  157. return
  158. # 必须锁当前活跃视频,否则后续视频会点到上一滑页残留的隐藏图标而卡住
  159. comment_btn = page.locator(
  160. '[data-e2e="feed-active-video"] [data-e2e="feed-comment-icon"]'
  161. ).first
  162. try:
  163. comment_btn.wait_for(state="visible", timeout=8_000)
  164. except Exception:
  165. comment_btn = page.locator(
  166. '[data-e2e="modal-video-container"] [data-e2e="feed-comment-icon"]'
  167. ).first
  168. comment_btn.wait_for(state="visible", timeout=10_000)
  169. human_pause(0.4, 0.8)
  170. comment_btn.click(force=True)
  171. print("已点击当前视频评论图标,等待右侧栏...")
  172. if _wait_side_panel_open(page, timeout_s=10.0):
  173. print("右侧栏已确认打开。")
  174. return
  175. print("右侧栏仍未打开,再点击评论图标一次...")
  176. comment_btn.click(force=True)
  177. if _wait_side_panel_open(page, timeout_s=10.0):
  178. print("右侧栏已确认打开。")
  179. return
  180. raise RuntimeError("无法打开右侧栏(评论图标)")
  181. def _click_side_panel_tab(page: Page, tab_name: str) -> bool:
  182. """点击右侧栏 semi-tabs(评论 / AI抖音),点击后短暂等待。"""
  183. # 等目标 tab 出现
  184. deadline = time.time() + 8
  185. while time.time() < deadline:
  186. found = page.evaluate(
  187. """(tabName) => {
  188. const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab'));
  189. return tabs.some(n => {
  190. const t = (n.innerText || '').trim();
  191. const r = n.getBoundingClientRect();
  192. return t === tabName && r.x > 600 && r.width > 0;
  193. });
  194. }""",
  195. tab_name,
  196. )
  197. if found:
  198. break
  199. time.sleep(0.3)
  200. clicked = page.evaluate(
  201. """(tabName) => {
  202. const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab'));
  203. const cands = tabs
  204. .map(n => ({ n, r: n.getBoundingClientRect(), t: (n.innerText || '').trim() }))
  205. .filter(o => o.t === tabName && o.r.x > 600 && o.r.width > 0 && o.r.height > 0)
  206. .sort((a, b) => b.r.x - a.r.x);
  207. if (!cands.length) return false;
  208. if ((cands[0].n.className || '').includes('active')) return true;
  209. cands[0].n.click();
  210. return true;
  211. }""",
  212. tab_name,
  213. )
  214. if not clicked:
  215. print(f"未找到右侧栏「{tab_name}」。")
  216. return False
  217. print(f"已切换到「{tab_name}」。")
  218. human_pause(1.2, 2.0)
  219. return True
  220. def close_comment_panel(page: Page) -> None:
  221. """若右侧栏已打开则点当前视频评论图标关掉。"""
  222. if not _is_side_panel_open(page):
  223. return
  224. comment_btn = page.locator(
  225. '[data-e2e="feed-active-video"] [data-e2e="feed-comment-icon"]'
  226. ).first
  227. try:
  228. if comment_btn.count() == 0:
  229. comment_btn = page.locator(
  230. '[data-e2e="modal-video-container"] [data-e2e="feed-comment-icon"]'
  231. ).first
  232. comment_btn.click(timeout=5_000, force=True)
  233. human_pause(0.8, 1.5)
  234. print("已关闭右侧栏。")
  235. except Exception as exc:
  236. print(f"关闭右侧栏失败(继续尝试切视频): {exc}")