comments.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. """步骤:评论区滚动采集与入库。"""
  2. from __future__ import annotations
  3. import re
  4. from playwright.sync_api import Page
  5. from config import settings
  6. from collectors.douyin.common import SkipVideoError, human_pause
  7. from collectors.douyin.panel import (
  8. _click_side_panel_tab,
  9. _is_side_panel_open,
  10. open_comment_panel,
  11. )
  12. from collectors.douyin.profile import (
  13. _extract_douyin_id,
  14. _normalize_user_key,
  15. _open_user_profile,
  16. _to_abs_url,
  17. )
  18. from db.repository import save_comments_batch
  19. from db.session import session_scope
  20. # 评论旁「作者xxx」状态 tag,不应写入 content
  21. _AUTHOR_TAG_RE = re.compile(
  22. r"(?:\s*作者(?:回复过|赞过|置顶|喜欢过)\s*)+$"
  23. )
  24. _AUTHOR_TAG_ONLY_RE = re.compile(
  25. r"^作者(?:回复过|赞过|置顶|喜欢过)?$"
  26. )
  27. def _clean_comment_content(raw: str) -> str:
  28. """去掉末尾/整段的作者互动 tag,只保留真实评论文案。"""
  29. text = (raw or "").strip()
  30. if not text:
  31. return ""
  32. text = _AUTHOR_TAG_RE.sub("", text).strip()
  33. if _AUTHOR_TAG_ONLY_RE.match(text):
  34. return ""
  35. return text
  36. def _find_comment_scroll_box(page: Page) -> dict | None:
  37. """定位评论区可滚动容器及其中心坐标。"""
  38. return page.evaluate(
  39. """() => {
  40. const list = document.querySelector('[data-e2e="comment-list"]');
  41. if (!list) return null;
  42. let el = list;
  43. let scrollEl = null;
  44. while (el && el !== document.documentElement) {
  45. const s = getComputedStyle(el);
  46. const oy = s.overflowY;
  47. if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay')
  48. && el.scrollHeight > el.clientHeight + 5) {
  49. scrollEl = el;
  50. break;
  51. }
  52. el = el.parentElement;
  53. }
  54. const target = scrollEl || list;
  55. const r = target.getBoundingClientRect();
  56. if (r.width <= 0 || r.height <= 0) return null;
  57. return {
  58. x: Math.round(r.x + r.width / 2),
  59. y: Math.round(r.y + Math.min(r.height / 2, 280)),
  60. scrollTop: target.scrollTop || 0,
  61. scrollHeight: target.scrollHeight || 0,
  62. clientHeight: target.clientHeight || 0,
  63. };
  64. }"""
  65. )
  66. def _scroll_comment_panel_once(page: Page) -> None:
  67. """
  68. 滚动评论区一次(虚拟列表:真正滚的是可滚动父容器 + 滚轮)。
  69. 绝不点击「展开N条回复」。
  70. """
  71. box = _find_comment_scroll_box(page)
  72. page.evaluate(
  73. """() => {
  74. const list = document.querySelector('[data-e2e="comment-list"]');
  75. if (!list) return;
  76. let el = list;
  77. let scrollEl = null;
  78. while (el && el !== document.documentElement) {
  79. const s = getComputedStyle(el);
  80. const oy = s.overflowY;
  81. if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay')
  82. && el.scrollHeight > el.clientHeight + 5) {
  83. scrollEl = el;
  84. break;
  85. }
  86. el = el.parentElement;
  87. }
  88. const target = scrollEl || list;
  89. target.scrollTop = Math.min(
  90. target.scrollTop + Math.max(240, Math.floor(target.clientHeight * 0.85)),
  91. target.scrollHeight
  92. );
  93. const items = Array.from(list.querySelectorAll('[data-e2e="comment-item"]'));
  94. const last = items[items.length - 1];
  95. if (last) last.scrollIntoView({ block: 'end', inline: 'nearest' });
  96. }"""
  97. )
  98. if box and box.get("x") and box.get("y"):
  99. try:
  100. page.mouse.move(box["x"], box["y"])
  101. page.mouse.wheel(0, 900)
  102. except Exception:
  103. pass
  104. human_pause(0.9, 1.5)
  105. def _comment_row_key(row: dict) -> str:
  106. return (
  107. f"{row.get('href') or ''}|"
  108. f"{row.get('content') or ''}|"
  109. f"{1 if row.get('is_reply') else 0}"
  110. )
  111. def _collect_comments_with_scroll(page: Page, limit: int) -> list[dict]:
  112. """
  113. 边滚动边采集:抖音评论是虚拟列表,DOM 里通常只有几条,
  114. 必须滚动后继续读,凑满 limit 条一级评论;已展开回复一并收(不计数)。
  115. """
  116. collected: list[dict] = []
  117. seen: set[str] = set()
  118. top_count = 0
  119. stagnant = 0
  120. for round_i in range(25):
  121. batch = _list_visible_comments(page, max(limit * 2, 20))
  122. added_top = 0
  123. last_top_accepted = False
  124. for row in batch:
  125. key = _comment_row_key(row)
  126. if row.get("is_reply"):
  127. if key in seen or not last_top_accepted:
  128. continue
  129. seen.add(key)
  130. collected.append(row)
  131. continue
  132. # 一级评论
  133. if key in seen:
  134. last_top_accepted = True
  135. continue
  136. if top_count >= limit:
  137. last_top_accepted = False
  138. continue
  139. seen.add(key)
  140. collected.append(row)
  141. top_count += 1
  142. added_top += 1
  143. last_top_accepted = True
  144. print(
  145. f"[评论滚动] 第 {round_i + 1} 轮:本轮新增一级 {added_top},"
  146. f"累计一级 {top_count}/{limit},合计条目 {len(collected)}"
  147. )
  148. if top_count >= limit:
  149. break
  150. if added_top == 0:
  151. stagnant += 1
  152. else:
  153. stagnant = 0
  154. if stagnant >= 4:
  155. print(f"[评论滚动] 连续无新增,停止。已采一级 {top_count}/{limit}")
  156. break
  157. _scroll_comment_panel_once(page)
  158. for i, row in enumerate(collected, start=1):
  159. row["index"] = i
  160. return collected
  161. def _list_visible_comments(page: Page, limit: int) -> list[dict]:
  162. """
  163. 读取当前 DOM 中可见评论(虚拟列表仅含屏上几条)。
  164. - 取最多 limit 条一级评论
  165. - 若某条下已展开可见的回复,按顺序一并采集(不点「展开」)
  166. """
  167. comments = page.evaluate(
  168. """(limit) => {
  169. const list = document.querySelector('[data-e2e="comment-list"]');
  170. if (!list) return [];
  171. const skipExact = new Set([
  172. '分享', '回复', '作者', '...', '', '收起', '展开',
  173. '作者回复过', '作者赞过', '作者置顶', '作者喜欢过',
  174. ]);
  175. // 评论旁状态 tag(整行都是 tag,或夹在正文末尾)
  176. const authorTagExact = /^(作者(回复过|赞过|置顶|喜欢过)?)$/;
  177. const authorTagSuffix = /\\s*作者(回复过|赞过|置顶|喜欢过)\\s*$/;
  178. const isVisible = (el) => {
  179. const r = el.getBoundingClientRect();
  180. const s = getComputedStyle(el);
  181. return r.width > 0 && r.height > 0
  182. && s.display !== 'none' && s.visibility !== 'hidden';
  183. };
  184. const cleanContent = (raw) => {
  185. let s = String(raw || '').trim();
  186. // 反复剥离末尾作者互动 tag,避免「正文作者回复过」入库
  187. for (let i = 0; i < 3; i++) {
  188. const next = s.replace(authorTagSuffix, '').trim();
  189. if (next === s) break;
  190. s = next;
  191. }
  192. if (authorTagExact.test(s) || skipExact.has(s)) return '';
  193. return s;
  194. };
  195. const isMetaOrNoise = (line, nickname) => {
  196. if (!line || skipExact.has(line)) return true;
  197. if (nickname && line === nickname) return true;
  198. if (authorTagExact.test(line)) return true;
  199. // 短行且含「作者」:作者 / 作者赞 等徽章,不当正文
  200. if (line.includes('作者') && line.length <= 6
  201. && !/[??!!。,,、]/.test(line)) return true;
  202. if (/^\\d+$/.test(line)) return true;
  203. if (/^展开\\d*条?回复/.test(line)) return true;
  204. if (/^收起/.test(line)) return true;
  205. return false;
  206. };
  207. const isTimeLine = (line) => (
  208. /刚刚|\\d+\\s*(秒|分钟|小时|天|周)前/.test(line)
  209. || (/·/.test(line) && /前/.test(line))
  210. );
  211. const pickContent = (textRoot, nickname) => {
  212. // 优先从独立正文节点取,减少把昵称/徽章/时间拼进 content
  213. const contentCandidates = [
  214. textRoot.querySelector('[data-e2e="comment-content"]'),
  215. textRoot.querySelector('[class*="CommentContent"]'),
  216. textRoot.querySelector('span[class*="comment"]'),
  217. ].filter(Boolean);
  218. let content = '';
  219. for (const el of contentCandidates) {
  220. const t = cleanContent(el.innerText || '');
  221. if (t && !isMetaOrNoise(t, nickname) && !isTimeLine(t)) {
  222. content = t;
  223. break;
  224. }
  225. }
  226. const lines = (textRoot.innerText || '')
  227. .split('\\n')
  228. .map(s => s.trim())
  229. .filter(Boolean);
  230. let commentTime = '';
  231. for (const line of lines) {
  232. if (isMetaOrNoise(line, nickname)) continue;
  233. if (isTimeLine(line)) {
  234. if (!commentTime) commentTime = line;
  235. continue;
  236. }
  237. if (!content) {
  238. const cleaned = cleanContent(line);
  239. if (cleaned) content = cleaned;
  240. }
  241. }
  242. return { content: cleanContent(content), comment_time: commentTime };
  243. };
  244. const results = [];
  245. const seen = new Set();
  246. const push = (row) => {
  247. if (!row || !row.content || !row.href) return;
  248. const key = `${row.href}|${row.content}|${row.is_reply ? 1 : 0}`;
  249. if (seen.has(key)) return;
  250. seen.add(key);
  251. results.push(row);
  252. };
  253. const items = Array.from(list.querySelectorAll('[data-e2e="comment-item"]'));
  254. let topLevelCount = 0;
  255. for (const item of items) {
  256. if (topLevelCount >= limit) break;
  257. if (!isVisible(item)) continue;
  258. if (item.parentElement && item.parentElement.closest('[data-e2e="comment-item"]')) {
  259. continue;
  260. }
  261. // 头像在左侧容器,正文在 info-wrap —— 必须分开取
  262. const avatarA = item.querySelector('.comment-item-avatar a[href*="/user/"]')
  263. || Array.from(item.querySelectorAll('a[href*="/user/"]')).find(a => {
  264. const r = a.getBoundingClientRect();
  265. return r.width >= 28 && r.width <= 56 && r.height >= 28 && r.height <= 56;
  266. });
  267. if (!avatarA) continue;
  268. const href = avatarA.getAttribute('href') || '';
  269. if (!href || href.includes('/user/self')) continue;
  270. const infoWrap = item.querySelector('.comment-item-info-wrap');
  271. // 正文不在 info-wrap 内,而在其兄弟节点;用右侧整块 F7ubq_7y / 整条 item
  272. const textRoot = item.querySelector('.F7ubq_7y') || item;
  273. let nickname = '';
  274. const nickRoot = infoWrap || textRoot;
  275. for (const a of nickRoot.querySelectorAll('a[href*="/user/"]')) {
  276. const t = (a.innerText || '').trim().replace(/作者/g, '').trim();
  277. if (t && t !== '...') {
  278. nickname = t;
  279. break;
  280. }
  281. }
  282. const { content, comment_time } = pickContent(textRoot, nickname);
  283. if (!content) continue;
  284. const ar = avatarA.getBoundingClientRect();
  285. const main = {
  286. nickname,
  287. content,
  288. href,
  289. comment_time,
  290. is_reply: false,
  291. avatar_x: Math.round(ar.x + ar.width / 2),
  292. avatar_y: Math.round(ar.y + ar.height / 2),
  293. };
  294. push(main);
  295. topLevelCount += 1;
  296. const mainX = main.avatar_x;
  297. const replyAvatars = Array.from(item.querySelectorAll('a[href*="/user/"]'))
  298. .filter(a => {
  299. if (a === avatarA) return false;
  300. if (a.closest('button.comment-reply-expand-btn')) return false;
  301. const r = a.getBoundingClientRect();
  302. if (!isVisible(a)) return false;
  303. if (r.width < 28 || r.width > 56 || r.height < 28 || r.height > 56) return false;
  304. return (r.x + r.width / 2) > mainX + 24;
  305. })
  306. .sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
  307. for (const a of replyAvatars) {
  308. let replyRoot = a.parentElement;
  309. for (let i = 0; i < 8 && replyRoot && replyRoot !== item; i++) {
  310. const t = (replyRoot.innerText || '');
  311. if ((/前/.test(t) || t.includes('回复')) && t.length < 500) break;
  312. replyRoot = replyRoot.parentElement;
  313. }
  314. if (!replyRoot || replyRoot === item) replyRoot = a.parentElement;
  315. let replyNick = '';
  316. for (const link of replyRoot.querySelectorAll('a[href*="/user/"]')) {
  317. const t = (link.innerText || '').trim().replace(/作者/g, '').trim();
  318. if (t && t !== '...') {
  319. replyNick = t;
  320. break;
  321. }
  322. }
  323. const parsed = pickContent(replyRoot, replyNick);
  324. if (!parsed.content) continue;
  325. const rr = a.getBoundingClientRect();
  326. push({
  327. nickname: replyNick,
  328. content: parsed.content,
  329. href: a.getAttribute('href') || '',
  330. comment_time: parsed.comment_time,
  331. is_reply: true,
  332. avatar_x: Math.round(rr.x + rr.width / 2),
  333. avatar_y: Math.round(rr.y + rr.height / 2),
  334. });
  335. }
  336. }
  337. return results.map((row, index) => ({
  338. index: index + 1,
  339. nickname: row.nickname,
  340. content: row.content,
  341. href: row.href,
  342. comment_time: row.comment_time,
  343. is_reply: row.is_reply,
  344. avatar_x: row.avatar_x,
  345. avatar_y: row.avatar_y,
  346. }));
  347. }""",
  348. limit,
  349. )
  350. return comments or []
  351. def collect_comments_and_save(
  352. page: Page,
  353. video_meta: dict,
  354. limit: int = settings.COMMENT_LIMIT,
  355. ) -> None:
  356. """
  357. 在「评论」tab 下滚动采集前 limit 条一级评论并入库。
  358. 若右侧栏不在评论 tab,则切过去(不再点评论图标,避免误关)。
  359. """
  360. video_pk = video_meta.get("db_id")
  361. if not video_pk:
  362. raise RuntimeError("缺少 videos.id(db_id),无法入库评论")
  363. print("[评论采集] 确认在评论 tab...")
  364. if not _is_side_panel_open(page):
  365. open_comment_panel(page)
  366. if not _click_side_panel_tab(page, "评论"):
  367. raise RuntimeError("无法切换到评论栏")
  368. try:
  369. page.wait_for_selector('[data-e2e="comment-item"]', timeout=10_000)
  370. except Exception as exc:
  371. raise SkipVideoError("评论数为 0,跳过该视频") from exc
  372. comments = _collect_comments_with_scroll(page, limit)
  373. top_n = sum(1 for c in comments if not c.get("is_reply"))
  374. reply_n = sum(1 for c in comments if c.get("is_reply"))
  375. if top_n < limit:
  376. raise SkipVideoError(f"一级评论仅 {top_n} 条,不足 {limit},跳过该视频")
  377. print(
  378. f"\n按顺序处理:一级评论 {top_n} 条(目标 {limit},回复不计数),"
  379. f"已展开回复 {reply_n} 条,合计入库候选 {len(comments)} 条:"
  380. )
  381. id_cache: dict[str, str] = {}
  382. rows_for_db: list[dict] = []
  383. for item in comments:
  384. idx = item.get("index")
  385. nickname = item.get("nickname") or ""
  386. content = _clean_comment_content(item.get("content") or "")
  387. href = item.get("href") or ""
  388. user_key = _normalize_user_key(href)
  389. user_link = _to_abs_url(href) if href else ""
  390. comment_time = item.get("comment_time") or ""
  391. is_reply = bool(item.get("is_reply"))
  392. douyin_id = ""
  393. if not content:
  394. print("-" * 40)
  395. print(f"[{idx}] 跳过: 评论内容为空(已剔除作者互动 tag)")
  396. continue
  397. print("-" * 40)
  398. print(f"[{idx}] 类型: {'回复' if is_reply else '一级评论'}")
  399. print(f"[{idx}] 视频ID: {video_meta.get('video_id', '')} (db_id={video_pk})")
  400. print(f"[{idx}] 评论昵称: {nickname}")
  401. print(f"[{idx}] 评论内容: {content}")
  402. print(f"[{idx}] 评论时间: {comment_time or '(未获取)'}")
  403. print(f"[{idx}] 用户主页: {user_link or '(未找到)'}")
  404. if user_key and user_key in id_cache:
  405. douyin_id = id_cache[user_key]
  406. print(f"[{idx}] 抖音号: {douyin_id} (缓存)")
  407. elif user_key and "/user/" in user_link:
  408. human_pause(1.0, 2.0)
  409. profile = None
  410. try:
  411. profile = _open_user_profile(page, href)
  412. opened = profile.url or ""
  413. if user_key not in opened and "user" not in opened:
  414. raise RuntimeError(f"主页跳转异常: expect {user_key}, got {opened}")
  415. human_pause(1.8, 3.0)
  416. douyin_id = _extract_douyin_id(profile)
  417. if nickname:
  418. page_text = profile.evaluate("() => document.body.innerText || ''") or ""
  419. if nickname not in page_text:
  420. print(
  421. f"[{idx}] 警告: 主页未出现评论昵称「{nickname}」,"
  422. f"仍按主页抖音号入库: {douyin_id}"
  423. )
  424. id_cache[user_key] = douyin_id
  425. print(f"[{idx}] 抖音号: {douyin_id}")
  426. print(f"[{idx}] 主页URL: {opened}")
  427. except Exception as exc:
  428. print(f"[{idx}] 抖音号: (读取失败: {exc})")
  429. douyin_id = ""
  430. finally:
  431. if profile is not None and profile != page:
  432. try:
  433. profile.close()
  434. except Exception:
  435. pass
  436. try:
  437. page.bring_to_front()
  438. except Exception:
  439. pass
  440. human_pause(0.8, 1.5)
  441. else:
  442. print(f"[{idx}] 抖音号: (未找到用户主页链接)")
  443. if not douyin_id:
  444. print(f"[{idx}] 跳过入库: 无可靠抖音号")
  445. continue
  446. rows_for_db.append(
  447. {
  448. "nick_name": nickname,
  449. "content": content,
  450. "comment_time": comment_time or None,
  451. "user_link": user_link or None,
  452. "douyin_id": douyin_id,
  453. "is_reply": is_reply,
  454. }
  455. )
  456. print("-" * 40)
  457. print(f"评论采集结束,成功获取评论者抖音号 {len(id_cache)} 个。")
  458. with session_scope() as session:
  459. result = save_comments_batch(session, int(video_pk), rows_for_db)
  460. print(
  461. f"[评论入库完成] videos.id={result['video_pk']} "
  462. f"线索处理={result['leads']} "
  463. f"新增评论={result['comments']} "
  464. f"跳过={result['skipped_comments']} "
  465. f"(无抖音号={result.get('no_douyin_id', 0)})"
  466. )