video.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. """步骤:视频/作者信息采集、预检评论数、AI总结后入库。"""
  2. from __future__ import annotations
  3. import re
  4. from urllib.parse import parse_qs, urlparse
  5. from playwright.sync_api import Page
  6. from config import settings
  7. from collectors.douyin.ai import collect_ai_summary
  8. from collectors.douyin.common import SkipVideoError, human_pause
  9. from collectors.douyin.panel import (
  10. _click_side_panel_tab,
  11. _ensure_video_page_focus,
  12. _read_feed_comment_count,
  13. open_comment_panel,
  14. )
  15. from collectors.douyin.profile import (
  16. _extract_douyin_id,
  17. _open_author_profile,
  18. _open_profile_by_url,
  19. _to_abs_url,
  20. )
  21. from db.repository import save_video_only
  22. from db.session import session_scope
  23. def _extract_video_meta(page: Page, keyword: str) -> dict:
  24. """从当前活跃视频弹层提取视频与作者信息。"""
  25. raw = page.evaluate(
  26. """() => {
  27. const modalId = new URLSearchParams(location.search).get('modal_id') || '';
  28. // 评论打开后 DOM 里可能有多个视频卡片,必须锁定当前活跃视频
  29. const root = document.querySelector('[data-e2e="feed-active-video"]')
  30. || document.querySelector('[data-e2e="modal-video-container"]')
  31. || document;
  32. const infoEl = root.querySelector('[data-e2e="video-info"][data-e2e-aweme-id]')
  33. || root.querySelector('[data-e2e="video-info"]')
  34. || document.querySelector(`[data-e2e="video-info"][data-e2e-aweme-id="${modalId}"]`);
  35. const awemeId = infoEl ? (infoEl.getAttribute('data-e2e-aweme-id') || '') : '';
  36. const titleEl = root.querySelector('[data-e2e="video-desc"]');
  37. const nicknameEl = root.querySelector('[data-e2e="feed-video-nickname"]');
  38. const avatarEl = root.querySelector('[data-e2e="video-avatar"]');
  39. const title = titleEl ? (titleEl.innerText || '').trim() : '';
  40. let authorNickname = nicknameEl ? (nicknameEl.innerText || '').trim() : '';
  41. if (authorNickname.startsWith('@')) {
  42. authorNickname = authorNickname.slice(1);
  43. }
  44. const authorLink = avatarEl ? (avatarEl.getAttribute('href') || '') : '';
  45. return {
  46. videoId: awemeId || modalId || '',
  47. title,
  48. authorNickname,
  49. authorLink,
  50. };
  51. }"""
  52. )
  53. video_id = raw.get("videoId") or ""
  54. if not video_id:
  55. parsed = urlparse(page.url)
  56. video_id = (parse_qs(parsed.query).get("modal_id") or [""])[0]
  57. if not video_id:
  58. match = re.search(r"/video/(\d+)", page.url)
  59. video_id = match.group(1) if match else ""
  60. author_link = _to_abs_url(raw.get("authorLink") or "") if raw.get("authorLink") else ""
  61. author_douyin_id = ""
  62. if author_link:
  63. print(f"[视频作者] 准备进入主页: {author_link}")
  64. human_pause(1.0, 2.0)
  65. profile = None
  66. try:
  67. profile = _open_author_profile(page, author_link)
  68. human_pause(1.5, 2.5)
  69. author_douyin_id = _extract_douyin_id(profile)
  70. print(f"[视频作者] 抖音号: {author_douyin_id}")
  71. except Exception as exc:
  72. print(f"[视频作者] 读取抖音号失败: {exc}")
  73. # 再兜底一次:直接 goto 主页重试
  74. try:
  75. if profile is not None:
  76. try:
  77. profile.close()
  78. except Exception:
  79. pass
  80. profile = _open_profile_by_url(page, author_link)
  81. human_pause(2.0, 3.5)
  82. author_douyin_id = _extract_douyin_id(profile)
  83. print(f"[视频作者] 重试成功,抖音号: {author_douyin_id}")
  84. except Exception as exc2:
  85. print(f"[视频作者] 重试仍失败: {exc2}")
  86. finally:
  87. if profile is not None:
  88. try:
  89. profile.close()
  90. except Exception:
  91. pass
  92. _ensure_video_page_focus(page)
  93. else:
  94. print("[视频作者] 未找到作者主页链接")
  95. return {
  96. "keyword": keyword,
  97. "video_id": video_id,
  98. "title": raw.get("title") or "",
  99. "author_nickname": raw.get("authorNickname") or "",
  100. "author_douyin_id": author_douyin_id,
  101. "author_link": author_link,
  102. "video_url": page.url,
  103. }
  104. def _print_video_meta(meta: dict) -> None:
  105. print("=" * 48)
  106. print("[视频信息]")
  107. print(f" 搜索关键词: {meta.get('keyword', '')}")
  108. print(f" 视频ID: {meta.get('video_id', '')}")
  109. print(f" 标题: {meta.get('title', '')}")
  110. print(f" 作者昵称: {meta.get('author_nickname', '')}")
  111. print(f" 作者抖音号: {meta.get('author_douyin_id', '') or '(未获取)'}")
  112. print(f" 作者主页: {meta.get('author_link', '')}")
  113. print(f" 视频链接: {meta.get('video_url', '')}")
  114. print(f" AI总结: {meta.get('summary', '') or '(未获取)'}")
  115. print("=" * 48)
  116. def collect_and_save_video_meta(
  117. page: Page,
  118. keyword: str,
  119. comment_limit: int = settings.COMMENT_LIMIT,
  120. ) -> dict:
  121. """
  122. 1) 读取评论数(不打开评论区),不足则 SkipVideoError
  123. 2) 采集视频/作者信息
  124. 3) 点评论图标 → AI抖音总结 → 切回评论
  125. 4) 入库,返回 meta(含 db_id=videos.id)
  126. """
  127. _ensure_video_page_focus(page)
  128. # 打开评论区之前:用侧栏数字判断是否值得采
  129. # 打开评论区之前:只读评论图标下方文案(勿与点赞数混淆)
  130. comment_total, comment_label = _read_feed_comment_count(page)
  131. print(f"[预检] 评论图标文案={comment_label or '(空)'} → 解析数={comment_total}")
  132. if comment_label and "抢首评" in comment_label:
  133. raise SkipVideoError("评论区显示「抢首评」,打开评论区前跳过")
  134. if comment_total >= 0 and comment_total < comment_limit:
  135. raise SkipVideoError(
  136. f"评论数 {comment_total} < {comment_limit},打开评论区前跳过"
  137. )
  138. video_meta = _extract_video_meta(page, keyword)
  139. if not video_meta.get("video_id"):
  140. raise RuntimeError("未获取到 video_id,无法入库")
  141. _ensure_video_page_focus(page)
  142. # 固定流程:评论图标 → AI抖音 → 回评论
  143. print("[流程] 点击评论图标打开右侧栏...")
  144. open_comment_panel(page)
  145. try:
  146. video_meta["summary"] = collect_ai_summary(page)
  147. except Exception as exc:
  148. print(f"[AI抖音] 获取总结失败: {exc}")
  149. video_meta["summary"] = ""
  150. print("[流程] 切回评论 tab...")
  151. if not _click_side_panel_tab(page, "评论"):
  152. raise RuntimeError("AI总结后无法切回评论栏")
  153. try:
  154. page.wait_for_selector(
  155. '[data-e2e="comment-list"], [data-e2e="comment-item"]',
  156. timeout=10_000,
  157. )
  158. except Exception:
  159. print("[流程] 警告:切回评论后未见评论列表,后续采集可能失败")
  160. _print_video_meta(video_meta)
  161. with session_scope() as session:
  162. result = save_video_only(session, video_meta)
  163. video_meta["db_id"] = result["id"]
  164. print(
  165. f"[视频入库完成] platform_video_id={result['video_id']} "
  166. f"db_id={result['id']} summary={'有' if video_meta.get('summary') else '无'}"
  167. )
  168. return video_meta