"""步骤:右侧栏(评论图标 / tab / 评论数预检)。""" from __future__ import annotations import re import time from playwright.sync_api import Page from collectors.douyin.common import human_pause def _active_video_root_selector() -> str: return ( '[data-e2e="feed-active-video"], ' '[data-e2e="modal-video-container"]' ) def _parse_count_text(raw: str) -> int: """解析评论数文案:4198 / 1.2万;抢首评/空文案视为 0。""" text = (raw or "").strip().replace(",", "").replace(" ", "") if not text or text in {"评论", "-", "抢首评"}: return 0 if "抢首评" in text: return 0 m = re.fullmatch(r"(\d+(?:\.\d+)?)(万)?", text) if not m: # 允许夹杂换行后的纯数字行 m = re.search(r"^(\d+(?:\.\d+)?)(万)?$", text) if not m: return 0 num = float(m.group(1)) if m.group(2): num *= 10000 return int(num) def _read_feed_comment_label(page: Page) -> str: """ 读取当前活跃视频「评论图标」下方/内部文案。 只取 [data-e2e=feed-comment-icon] 自身文本,绝不上溯到含点赞数的父容器。 """ raw = page.evaluate( """() => { const root = document.querySelector('[data-e2e="feed-active-video"]') || document.querySelector('[data-e2e="modal-video-container"]') || document; const icon = root.querySelector('[data-e2e="feed-comment-icon"]'); if (!icon) return ''; // 只看评论图标节点内部(数字/抢首评一般在图标下方,仍属于该节点) const lines = (icon.innerText || '') .split('\\n') .map(s => s.trim()) .filter(Boolean); if (lines.some(t => t.includes('抢首评'))) return '抢首评'; // 优先取像数量的一行;否则取最后一行(通常是图标下方数字) for (const t of lines) { if (/^\\d+(?:\\.\\d+)?万?$/.test(t)) return t; } if (lines.length) return lines[lines.length - 1]; // 图标内部无字时:在图标正下方、同一竖列找最近短文本(仍不碰点赞区) const ir = icon.getBoundingClientRect(); const icx = ir.x + ir.width / 2; let best = ''; let bestDist = Infinity; const nodes = Array.from(root.querySelectorAll('span, div, p, em')); for (const el of nodes) { if (icon.contains(el) && el !== icon) { const t = (el.innerText || '').trim(); if (t && t.length <= 8) { if (t.includes('抢首评')) return '抢首评'; if (/^\\d+(?:\\.\\d+)?万?$/.test(t)) return t; } } // 仅接受位于评论图标正下方很近的节点 if (icon.contains(el)) continue; const t = (el.innerText || '').trim(); if (!t || t.length > 8) continue; const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) continue; const cx = r.x + r.width / 2; const cy = r.y + r.height / 2; if (Math.abs(cx - icx) > 28) continue; if (cy < ir.bottom - 2 || cy > ir.bottom + 48) continue; // 排除明显属于点赞/收藏等其它 e2e const e2e = el.getAttribute('data-e2e') || ''; if (/like|collect|share|favor/i.test(e2e)) continue; const dist = Math.abs(cy - ir.bottom) + Math.abs(cx - icx); if (dist < bestDist) { bestDist = dist; best = t; } } return best; }""" ) return (raw or "").strip() def _read_feed_comment_count(page: Page) -> tuple[int, str]: """ 不打开评论区,读取评论图标文案。 返回 (count, raw_label): - raw 含「抢首评」时 count=0 - 读不到图标文案时 count=-1 """ label = _read_feed_comment_label(page) if not label: return -1, "" if "抢首评" in label: return 0, "抢首评" return _parse_count_text(label), label def _ensure_video_page_focus(page: Page) -> None: """作者主页关掉后,把焦点拉回搜索结果当前视频。""" try: page.bring_to_front() except Exception: pass human_pause(0.5, 1.0) try: box = page.locator(_active_video_root_selector()).first.bounding_box() if box: page.mouse.click( box["x"] + min(box["width"] * 0.3, 200), box["y"] + box["height"] * 0.4, ) human_pause(0.4, 0.8) except Exception: pass def _is_side_panel_open(page: Page) -> bool: """ 判断视频右侧栏是否已打开(含「评论 / AI抖音」等 tab)。 注意:切到 AI抖音 后 comment-list 会隐藏,不能只靠评论列表判断。 """ try: return bool( page.evaluate( """() => { const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab')); const rightTabs = tabs.filter(t => { const text = (t.innerText || '').trim(); const r = t.getBoundingClientRect(); return r.x > 600 && r.width > 0 && r.height > 0 && (text === '评论' || text === 'AI抖音' || text === '详情'); }); return rightTabs.length >= 2; }""" ) ) except Exception: return False def _wait_side_panel_open(page: Page, timeout_s: float = 12.0) -> bool: """等待右侧栏 tab 出现,期间不再次点击评论图标(避免开关误关)。""" deadline = time.time() + timeout_s while time.time() < deadline: if _is_side_panel_open(page): return True time.sleep(0.35) return _is_side_panel_open(page) def open_comment_panel(page: Page) -> None: """ 点击「当前活跃视频」上的评论图标打开右侧栏。 评论图标是开关:已打开再点会收起。 """ _ensure_video_page_focus(page) if _is_side_panel_open(page): print("右侧栏已打开,跳过点击。") return # 必须锁当前活跃视频,否则后续视频会点到上一滑页残留的隐藏图标而卡住 comment_btn = page.locator( '[data-e2e="feed-active-video"] [data-e2e="feed-comment-icon"]' ).first try: comment_btn.wait_for(state="visible", timeout=8_000) except Exception: comment_btn = page.locator( '[data-e2e="modal-video-container"] [data-e2e="feed-comment-icon"]' ).first comment_btn.wait_for(state="visible", timeout=10_000) human_pause(0.4, 0.8) comment_btn.click(force=True) print("已点击当前视频评论图标,等待右侧栏...") if _wait_side_panel_open(page, timeout_s=10.0): print("右侧栏已确认打开。") return print("右侧栏仍未打开,再点击评论图标一次...") comment_btn.click(force=True) if _wait_side_panel_open(page, timeout_s=10.0): print("右侧栏已确认打开。") return raise RuntimeError("无法打开右侧栏(评论图标)") def _click_side_panel_tab(page: Page, tab_name: str) -> bool: """点击右侧栏 semi-tabs(评论 / AI抖音),点击后短暂等待。""" # 等目标 tab 出现 deadline = time.time() + 8 while time.time() < deadline: found = page.evaluate( """(tabName) => { const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab')); return tabs.some(n => { const t = (n.innerText || '').trim(); const r = n.getBoundingClientRect(); return t === tabName && r.x > 600 && r.width > 0; }); }""", tab_name, ) if found: break time.sleep(0.3) clicked = page.evaluate( """(tabName) => { const tabs = Array.from(document.querySelectorAll('.semi-tabs-tab')); const cands = tabs .map(n => ({ n, r: n.getBoundingClientRect(), t: (n.innerText || '').trim() })) .filter(o => o.t === tabName && o.r.x > 600 && o.r.width > 0 && o.r.height > 0) .sort((a, b) => b.r.x - a.r.x); if (!cands.length) return false; if ((cands[0].n.className || '').includes('active')) return true; cands[0].n.click(); return true; }""", tab_name, ) if not clicked: print(f"未找到右侧栏「{tab_name}」。") return False print(f"已切换到「{tab_name}」。") human_pause(1.2, 2.0) return True def close_comment_panel(page: Page) -> None: """若右侧栏已打开则点当前视频评论图标关掉。""" if not _is_side_panel_open(page): return comment_btn = page.locator( '[data-e2e="feed-active-video"] [data-e2e="feed-comment-icon"]' ).first try: if comment_btn.count() == 0: comment_btn = page.locator( '[data-e2e="modal-video-container"] [data-e2e="feed-comment-icon"]' ).first comment_btn.click(timeout=5_000, force=True) human_pause(0.8, 1.5) print("已关闭右侧栏。") except Exception as exc: print(f"关闭右侧栏失败(继续尝试切视频): {exc}")