"""步骤:视频/作者信息采集、预检评论数、AI总结后入库。""" from __future__ import annotations import re from urllib.parse import parse_qs, urlparse from playwright.sync_api import Page from config import settings from collectors.douyin.ai import collect_ai_summary from collectors.douyin.common import SkipVideoError, human_pause from collectors.douyin.panel import ( _click_side_panel_tab, _ensure_video_page_focus, _read_feed_comment_count, open_comment_panel, ) from collectors.douyin.profile import ( _extract_douyin_id, _open_author_profile, _open_profile_by_url, _to_abs_url, ) from db.repository import save_video_only from db.session import session_scope def _extract_video_meta(page: Page, keyword: str) -> dict: """从当前活跃视频弹层提取视频与作者信息。""" raw = page.evaluate( """() => { const modalId = new URLSearchParams(location.search).get('modal_id') || ''; // 评论打开后 DOM 里可能有多个视频卡片,必须锁定当前活跃视频 const root = document.querySelector('[data-e2e="feed-active-video"]') || document.querySelector('[data-e2e="modal-video-container"]') || document; const infoEl = root.querySelector('[data-e2e="video-info"][data-e2e-aweme-id]') || root.querySelector('[data-e2e="video-info"]') || document.querySelector(`[data-e2e="video-info"][data-e2e-aweme-id="${modalId}"]`); const awemeId = infoEl ? (infoEl.getAttribute('data-e2e-aweme-id') || '') : ''; const titleEl = root.querySelector('[data-e2e="video-desc"]'); const nicknameEl = root.querySelector('[data-e2e="feed-video-nickname"]'); const avatarEl = root.querySelector('[data-e2e="video-avatar"]'); const title = titleEl ? (titleEl.innerText || '').trim() : ''; let authorNickname = nicknameEl ? (nicknameEl.innerText || '').trim() : ''; if (authorNickname.startsWith('@')) { authorNickname = authorNickname.slice(1); } const authorLink = avatarEl ? (avatarEl.getAttribute('href') || '') : ''; return { videoId: awemeId || modalId || '', title, authorNickname, authorLink, }; }""" ) video_id = raw.get("videoId") or "" if not video_id: parsed = urlparse(page.url) video_id = (parse_qs(parsed.query).get("modal_id") or [""])[0] if not video_id: match = re.search(r"/video/(\d+)", page.url) video_id = match.group(1) if match else "" author_link = _to_abs_url(raw.get("authorLink") or "") if raw.get("authorLink") else "" author_douyin_id = "" if author_link: print(f"[视频作者] 准备进入主页: {author_link}") human_pause(1.0, 2.0) profile = None try: profile = _open_author_profile(page, author_link) human_pause(1.5, 2.5) author_douyin_id = _extract_douyin_id(profile) print(f"[视频作者] 抖音号: {author_douyin_id}") except Exception as exc: print(f"[视频作者] 读取抖音号失败: {exc}") # 再兜底一次:直接 goto 主页重试 try: if profile is not None: try: profile.close() except Exception: pass profile = _open_profile_by_url(page, author_link) human_pause(2.0, 3.5) author_douyin_id = _extract_douyin_id(profile) print(f"[视频作者] 重试成功,抖音号: {author_douyin_id}") except Exception as exc2: print(f"[视频作者] 重试仍失败: {exc2}") finally: if profile is not None: try: profile.close() except Exception: pass _ensure_video_page_focus(page) else: print("[视频作者] 未找到作者主页链接") return { "keyword": keyword, "video_id": video_id, "title": raw.get("title") or "", "author_nickname": raw.get("authorNickname") or "", "author_douyin_id": author_douyin_id, "author_link": author_link, "video_url": page.url, } def _print_video_meta(meta: dict) -> None: print("=" * 48) print("[视频信息]") print(f" 搜索关键词: {meta.get('keyword', '')}") print(f" 视频ID: {meta.get('video_id', '')}") print(f" 标题: {meta.get('title', '')}") print(f" 作者昵称: {meta.get('author_nickname', '')}") print(f" 作者抖音号: {meta.get('author_douyin_id', '') or '(未获取)'}") print(f" 作者主页: {meta.get('author_link', '')}") print(f" 视频链接: {meta.get('video_url', '')}") print(f" AI总结: {meta.get('summary', '') or '(未获取)'}") print("=" * 48) def collect_and_save_video_meta( page: Page, keyword: str, comment_limit: int = settings.COMMENT_LIMIT, ) -> dict: """ 1) 读取评论数(不打开评论区),不足则 SkipVideoError 2) 采集视频/作者信息 3) 点评论图标 → AI抖音总结 → 切回评论 4) 入库,返回 meta(含 db_id=videos.id) """ _ensure_video_page_focus(page) # 打开评论区之前:用侧栏数字判断是否值得采 # 打开评论区之前:只读评论图标下方文案(勿与点赞数混淆) comment_total, comment_label = _read_feed_comment_count(page) print(f"[预检] 评论图标文案={comment_label or '(空)'} → 解析数={comment_total}") if comment_label and "抢首评" in comment_label: raise SkipVideoError("评论区显示「抢首评」,打开评论区前跳过") if comment_total >= 0 and comment_total < comment_limit: raise SkipVideoError( f"评论数 {comment_total} < {comment_limit},打开评论区前跳过" ) video_meta = _extract_video_meta(page, keyword) if not video_meta.get("video_id"): raise RuntimeError("未获取到 video_id,无法入库") _ensure_video_page_focus(page) # 固定流程:评论图标 → AI抖音 → 回评论 print("[流程] 点击评论图标打开右侧栏...") open_comment_panel(page) try: video_meta["summary"] = collect_ai_summary(page) except Exception as exc: print(f"[AI抖音] 获取总结失败: {exc}") video_meta["summary"] = "" print("[流程] 切回评论 tab...") if not _click_side_panel_tab(page, "评论"): raise RuntimeError("AI总结后无法切回评论栏") try: page.wait_for_selector( '[data-e2e="comment-list"], [data-e2e="comment-item"]', timeout=10_000, ) except Exception: print("[流程] 警告:切回评论后未见评论列表,后续采集可能失败") _print_video_meta(video_meta) with session_scope() as session: result = save_video_only(session, video_meta) video_meta["db_id"] = result["id"] print( f"[视频入库完成] platform_video_id={result['video_id']} " f"db_id={result['id']} summary={'有' if video_meta.get('summary') else '无'}" ) return video_meta