| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- """步骤:右侧栏(评论图标 / 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}")
|