| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- """步骤:视频/作者信息采集、预检评论数、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
|