Browse Source

初始化
获取评论自动化

huangsiyang 3 days ago
commit
df40fda669

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+.env
+.venv/
+venv/
+__pycache__/
+*.py[cod]
+*.egg-info/
+.idea/
+browser_data/
+_inspect_out.txt
+*.log

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 10 - 0
.idea/AcquireLead.iml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <excludeFolder url="file://$MODULE_DIR$/.venv" />
+    </content>
+    <orderEntry type="jdk" jdkName="Python 3.10 (AcquireLead)" jdkType="Python SDK" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 5 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,5 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+  </profile>
+</component>

+ 6 - 0
.idea/inspectionProfiles/profiles_settings.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <settings>
+    <option name="USE_PROJECT_PROFILE" value="false" />
+    <version value="1.0" />
+  </settings>
+</component>

+ 7 - 0
.idea/misc.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Black">
+    <option name="sdkName" value="Python 3.10 (AcquireLead)" />
+  </component>
+  <component name="ExternalStorageConfigurationManager" enabled="true" />
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/AcquireLead.iml" filepath="$PROJECT_DIR$/.idea/AcquireLead.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>

+ 1 - 0
collectors/__init__.py

@@ -0,0 +1 @@
+"""collectors 包。"""

+ 5 - 0
collectors/douyin/__init__.py

@@ -0,0 +1,5 @@
+"""抖音采集:按步骤拆分的子模块。"""
+
+from collectors.douyin.runner import run
+
+__all__ = ["run"]

+ 185 - 0
collectors/douyin/ai.py

@@ -0,0 +1,185 @@
+"""步骤:AI抖音总结。"""
+
+from __future__ import annotations
+
+import re
+import time
+
+from playwright.sync_api import Page
+
+from collectors.douyin.common import AI_SUMMARY_PROMPT, human_pause
+from collectors.douyin.panel import _click_side_panel_tab
+
+def _get_ai_frame(page: Page):
+    """获取 AI抖音 iframe(so-landing / search_ai_mobile)。"""
+    deadline = time.time() + 15
+    while time.time() < deadline:
+        for frame in page.frames:
+            url = frame.url or ""
+            if "search_ai" in url or "so-landing.douyin.com" in url:
+                return frame
+        try:
+            page.wait_for_selector(
+                'iframe[src*="search_ai_mobile"], iframe.D99qPWMG',
+                timeout=2_000,
+            )
+        except Exception:
+            human_pause(0.5, 0.9)
+    return None
+
+
+def _parse_ai_summary_text(body_text: str, prompt: str) -> str:
+    """从 AI iframe 文本中提取总结正文。"""
+    text = (body_text or "").strip()
+    if not text:
+        return ""
+
+    # 优先取「最后一次提问」之后的回复
+    if prompt and prompt in text:
+        reply = text.rsplit(prompt, 1)[-1].strip()
+    else:
+        reply = text
+
+    # 若存在「以上为历史记录」,取其后内容
+    if "以上为历史记录" in reply:
+        reply = reply.split("以上为历史记录", 1)[-1].strip()
+        if prompt and prompt in reply:
+            reply = reply.split(prompt, 1)[-1].strip()
+
+    skip_exact = {
+        "AI 总结",
+        "高光片段",
+        "换一换",
+        "下载抖音精选",
+        "以上为历史记录",
+    }
+    skip_prefix = (
+        "以上结果",
+        "看到视频",
+        "分享一些",
+        "有哪些",
+        "如何",
+        "推荐一些",
+        "核心原理",
+        "无效输入",
+        "i+1",
+        "自学可行性",
+        "视频提到",
+    )
+    lines = [ln.strip() for ln in reply.splitlines() if ln.strip()]
+    for line in lines:
+        if line in skip_exact:
+            continue
+        if line.startswith(skip_prefix):
+            continue
+        if re.match(r"^\d{1,2}:\d{2}", line):
+            continue
+        if line == prompt:
+            continue
+        if 8 <= len(line) <= 200:
+            return line[:512]
+
+    # 回退:页面上「AI 总结」后的首段
+    if "AI 总结" in text:
+        after = text.split("AI 总结", 1)[-1]
+        for line in after.splitlines():
+            line = line.strip()
+            if 8 <= len(line) <= 200 and line not in skip_exact:
+                return line[:512]
+    return ""
+
+
+def collect_ai_summary(page: Page, timeout_ms: int = 60_000) -> str:
+    """
+    前提:已点击评论图标打开右侧栏。
+    切换到「AI抖音」→ iframe 输入发送 → 返回总结文本。
+    """
+    print("[AI抖音] 切换到 AI抖音 tab...")
+    if not _click_side_panel_tab(page, "AI抖音"):
+        raise RuntimeError("无法切换到「AI抖音」")
+
+    frame = _get_ai_frame(page)
+    if frame is None:
+        raise RuntimeError("未找到 AI抖音 iframe")
+    try:
+        frame.wait_for_load_state("domcontentloaded")
+    except Exception:
+        pass
+
+    frame.wait_for_selector('[contenteditable="true"], .input_blVmyq', timeout=20_000)
+    human_pause(0.8, 1.2)
+    before_text = frame.evaluate("() => document.body.innerText || ''") or ""
+
+    box = frame.locator('[contenteditable="true"], .input_blVmyq').first
+    box.click(timeout=8_000)
+    page.keyboard.press("Control+A")
+    page.keyboard.press("Backspace")
+    page.keyboard.type(AI_SUMMARY_PROMPT, delay=30)
+    human_pause(0.6, 1.0)
+
+    typed = (
+        frame.evaluate(
+            """() => {
+                const el = document.querySelector('[contenteditable="true"], .input_blVmyq');
+                return el ? (el.innerText || el.textContent || '').trim() : '';
+            }"""
+        )
+        or ""
+    )
+    if not typed:
+        raise RuntimeError("AI抖音输入框未能写入总结指令")
+    print(f"[AI抖音] 已输入: {typed[:40]}")
+
+    # 等发送按钮激活再点
+    sent = False
+    for _ in range(10):
+        sent = bool(
+            frame.evaluate(
+                """() => {
+                    const btn = document.querySelector('.searchButtonActive_viYBkl')
+                        || document.querySelector('.searchButton_IMT5UH');
+                    if (!btn) return false;
+                    const r = btn.getBoundingClientRect();
+                    if (r.width <= 0 || r.height <= 0) return false;
+                    const msg = btn.getAttribute('data-msg') || '';
+                    if (/上传|截图|图片/.test(msg)) return false;
+                    btn.click();
+                    return true;
+                }"""
+            )
+        )
+        if sent:
+            break
+        time.sleep(0.35)
+
+    if not sent:
+        page.keyboard.press("Enter")
+        print("[AI抖音] 未定位发送按钮,已回车发送")
+    else:
+        print("[AI抖音] 已发送,等待回复...")
+
+    deadline = time.time() + timeout_ms / 1000
+    summary = ""
+    while time.time() < deadline:
+        human_pause(1.2, 1.8)
+        body = frame.evaluate("() => document.body.innerText || ''") or ""
+        candidate = _parse_ai_summary_text(body, AI_SUMMARY_PROMPT)
+        if not candidate:
+            continue
+        if "以上为历史记录" in body or "以上结果由问问AI" in body:
+            summary = candidate
+            break
+        if candidate not in (before_text or ""):
+            summary = candidate
+            break
+
+    if not summary:
+        body = frame.evaluate("() => document.body.innerText || ''") or ""
+        summary = _parse_ai_summary_text(body, AI_SUMMARY_PROMPT)
+    if not summary:
+        raise RuntimeError("AI抖音未返回总结内容")
+
+    summary = summary.strip()[:512]
+    print(f"[AI抖音] 总结: {summary}")
+    return summary
+

+ 34 - 0
collectors/douyin/auth.py

@@ -0,0 +1,34 @@
+"""步骤:登录。"""
+
+from __future__ import annotations
+
+from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
+
+from collectors.douyin.common import human_pause
+
+def click_login_if_needed(page: Page) -> None:
+    login = page.get_by_role("button", name="登录").or_(page.get_by_text("登录", exact=True)).first
+    try:
+        if login.is_visible(timeout=3000):
+            human_pause(0.8, 1.5)
+            login.click()
+            print("已点击登录按钮,请在浏览器中完成人工登录。")
+        else:
+            print("未看到登录按钮,可能已登录。")
+    except PlaywrightTimeoutError:
+        print("未看到登录按钮,可能已登录。")
+
+
+def wait_until_logged_in(page: Page, timeout_ms: int = 300_000) -> None:
+    print("等待人工登录完成(最多 5 分钟)...")
+    page.wait_for_function(
+        """() => {
+            const texts = Array.from(document.querySelectorAll('button, a, span, div'))
+                .map(el => (el.innerText || '').trim())
+                .filter(Boolean);
+            return !texts.some(t => t === '登录');
+        }""",
+        timeout=timeout_ms,
+    )
+    print("检测到登录态已就绪。")
+

+ 528 - 0
collectors/douyin/comments.py

@@ -0,0 +1,528 @@
+"""步骤:评论区滚动采集与入库。"""
+
+from __future__ import annotations
+
+import re
+
+from playwright.sync_api import Page
+
+from config import settings
+from collectors.douyin.common import SkipVideoError, human_pause
+from collectors.douyin.panel import (
+    _click_side_panel_tab,
+    _is_side_panel_open,
+    open_comment_panel,
+)
+from collectors.douyin.profile import (
+    _extract_douyin_id,
+    _normalize_user_key,
+    _open_user_profile,
+    _to_abs_url,
+)
+from db.repository import save_comments_batch
+from db.session import session_scope
+
+# 评论旁「作者xxx」状态 tag,不应写入 content
+_AUTHOR_TAG_RE = re.compile(
+    r"(?:\s*作者(?:回复过|赞过|置顶|喜欢过)\s*)+$"
+)
+_AUTHOR_TAG_ONLY_RE = re.compile(
+    r"^作者(?:回复过|赞过|置顶|喜欢过)?$"
+)
+
+
+def _clean_comment_content(raw: str) -> str:
+    """去掉末尾/整段的作者互动 tag,只保留真实评论文案。"""
+    text = (raw or "").strip()
+    if not text:
+        return ""
+    text = _AUTHOR_TAG_RE.sub("", text).strip()
+    if _AUTHOR_TAG_ONLY_RE.match(text):
+        return ""
+    return text
+
+
+def _find_comment_scroll_box(page: Page) -> dict | None:
+    """定位评论区可滚动容器及其中心坐标。"""
+    return page.evaluate(
+        """() => {
+            const list = document.querySelector('[data-e2e="comment-list"]');
+            if (!list) return null;
+            let el = list;
+            let scrollEl = null;
+            while (el && el !== document.documentElement) {
+                const s = getComputedStyle(el);
+                const oy = s.overflowY;
+                if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay')
+                    && el.scrollHeight > el.clientHeight + 5) {
+                    scrollEl = el;
+                    break;
+                }
+                el = el.parentElement;
+            }
+            const target = scrollEl || list;
+            const r = target.getBoundingClientRect();
+            if (r.width <= 0 || r.height <= 0) return null;
+            return {
+                x: Math.round(r.x + r.width / 2),
+                y: Math.round(r.y + Math.min(r.height / 2, 280)),
+                scrollTop: target.scrollTop || 0,
+                scrollHeight: target.scrollHeight || 0,
+                clientHeight: target.clientHeight || 0,
+            };
+        }"""
+    )
+
+
+def _scroll_comment_panel_once(page: Page) -> None:
+    """
+    滚动评论区一次(虚拟列表:真正滚的是可滚动父容器 + 滚轮)。
+    绝不点击「展开N条回复」。
+    """
+    box = _find_comment_scroll_box(page)
+    page.evaluate(
+        """() => {
+            const list = document.querySelector('[data-e2e="comment-list"]');
+            if (!list) return;
+            let el = list;
+            let scrollEl = null;
+            while (el && el !== document.documentElement) {
+                const s = getComputedStyle(el);
+                const oy = s.overflowY;
+                if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay')
+                    && el.scrollHeight > el.clientHeight + 5) {
+                    scrollEl = el;
+                    break;
+                }
+                el = el.parentElement;
+            }
+            const target = scrollEl || list;
+            target.scrollTop = Math.min(
+                target.scrollTop + Math.max(240, Math.floor(target.clientHeight * 0.85)),
+                target.scrollHeight
+            );
+            const items = Array.from(list.querySelectorAll('[data-e2e="comment-item"]'));
+            const last = items[items.length - 1];
+            if (last) last.scrollIntoView({ block: 'end', inline: 'nearest' });
+        }"""
+    )
+    if box and box.get("x") and box.get("y"):
+        try:
+            page.mouse.move(box["x"], box["y"])
+            page.mouse.wheel(0, 900)
+        except Exception:
+            pass
+    human_pause(0.9, 1.5)
+
+
+def _comment_row_key(row: dict) -> str:
+    return (
+        f"{row.get('href') or ''}|"
+        f"{row.get('content') or ''}|"
+        f"{1 if row.get('is_reply') else 0}"
+    )
+
+
+def _collect_comments_with_scroll(page: Page, limit: int) -> list[dict]:
+    """
+    边滚动边采集:抖音评论是虚拟列表,DOM 里通常只有几条,
+    必须滚动后继续读,凑满 limit 条一级评论;已展开回复一并收(不计数)。
+    """
+    collected: list[dict] = []
+    seen: set[str] = set()
+    top_count = 0
+    stagnant = 0
+
+    for round_i in range(25):
+        batch = _list_visible_comments(page, max(limit * 2, 20))
+        added_top = 0
+        last_top_accepted = False
+
+        for row in batch:
+            key = _comment_row_key(row)
+            if row.get("is_reply"):
+                if key in seen or not last_top_accepted:
+                    continue
+                seen.add(key)
+                collected.append(row)
+                continue
+
+            # 一级评论
+            if key in seen:
+                last_top_accepted = True
+                continue
+            if top_count >= limit:
+                last_top_accepted = False
+                continue
+            seen.add(key)
+            collected.append(row)
+            top_count += 1
+            added_top += 1
+            last_top_accepted = True
+
+        print(
+            f"[评论滚动] 第 {round_i + 1} 轮:本轮新增一级 {added_top},"
+            f"累计一级 {top_count}/{limit},合计条目 {len(collected)}"
+        )
+        if top_count >= limit:
+            break
+
+        if added_top == 0:
+            stagnant += 1
+        else:
+            stagnant = 0
+        if stagnant >= 4:
+            print(f"[评论滚动] 连续无新增,停止。已采一级 {top_count}/{limit}")
+            break
+
+        _scroll_comment_panel_once(page)
+
+    for i, row in enumerate(collected, start=1):
+        row["index"] = i
+    return collected
+
+
+def _list_visible_comments(page: Page, limit: int) -> list[dict]:
+    """
+    读取当前 DOM 中可见评论(虚拟列表仅含屏上几条)。
+    - 取最多 limit 条一级评论
+    - 若某条下已展开可见的回复,按顺序一并采集(不点「展开」)
+    """
+    comments = page.evaluate(
+        """(limit) => {
+            const list = document.querySelector('[data-e2e="comment-list"]');
+            if (!list) return [];
+
+            const skipExact = new Set([
+                '分享', '回复', '作者', '...', '', '收起', '展开',
+                '作者回复过', '作者赞过', '作者置顶', '作者喜欢过',
+            ]);
+
+            // 评论旁状态 tag(整行都是 tag,或夹在正文末尾)
+            const authorTagExact = /^(作者(回复过|赞过|置顶|喜欢过)?)$/;
+            const authorTagSuffix = /\\s*作者(回复过|赞过|置顶|喜欢过)\\s*$/;
+
+            const isVisible = (el) => {
+                const r = el.getBoundingClientRect();
+                const s = getComputedStyle(el);
+                return r.width > 0 && r.height > 0
+                    && s.display !== 'none' && s.visibility !== 'hidden';
+            };
+
+            const cleanContent = (raw) => {
+                let s = String(raw || '').trim();
+                // 反复剥离末尾作者互动 tag,避免「正文作者回复过」入库
+                for (let i = 0; i < 3; i++) {
+                    const next = s.replace(authorTagSuffix, '').trim();
+                    if (next === s) break;
+                    s = next;
+                }
+                if (authorTagExact.test(s) || skipExact.has(s)) return '';
+                return s;
+            };
+
+            const isMetaOrNoise = (line, nickname) => {
+                if (!line || skipExact.has(line)) return true;
+                if (nickname && line === nickname) return true;
+                if (authorTagExact.test(line)) return true;
+                // 短行且含「作者」:作者 / 作者赞 等徽章,不当正文
+                if (line.includes('作者') && line.length <= 6
+                    && !/[??!!。,,、]/.test(line)) return true;
+                if (/^\\d+$/.test(line)) return true;
+                if (/^展开\\d*条?回复/.test(line)) return true;
+                if (/^收起/.test(line)) return true;
+                return false;
+            };
+
+            const isTimeLine = (line) => (
+                /刚刚|\\d+\\s*(秒|分钟|小时|天|周)前/.test(line)
+                || (/·/.test(line) && /前/.test(line))
+            );
+
+            const pickContent = (textRoot, nickname) => {
+                // 优先从独立正文节点取,减少把昵称/徽章/时间拼进 content
+                const contentCandidates = [
+                    textRoot.querySelector('[data-e2e="comment-content"]'),
+                    textRoot.querySelector('[class*="CommentContent"]'),
+                    textRoot.querySelector('span[class*="comment"]'),
+                ].filter(Boolean);
+
+                let content = '';
+                for (const el of contentCandidates) {
+                    const t = cleanContent(el.innerText || '');
+                    if (t && !isMetaOrNoise(t, nickname) && !isTimeLine(t)) {
+                        content = t;
+                        break;
+                    }
+                }
+
+                const lines = (textRoot.innerText || '')
+                    .split('\\n')
+                    .map(s => s.trim())
+                    .filter(Boolean);
+                let commentTime = '';
+                for (const line of lines) {
+                    if (isMetaOrNoise(line, nickname)) continue;
+                    if (isTimeLine(line)) {
+                        if (!commentTime) commentTime = line;
+                        continue;
+                    }
+                    if (!content) {
+                        const cleaned = cleanContent(line);
+                        if (cleaned) content = cleaned;
+                    }
+                }
+                return { content: cleanContent(content), comment_time: commentTime };
+            };
+
+            const results = [];
+            const seen = new Set();
+            const push = (row) => {
+                if (!row || !row.content || !row.href) return;
+                const key = `${row.href}|${row.content}|${row.is_reply ? 1 : 0}`;
+                if (seen.has(key)) return;
+                seen.add(key);
+                results.push(row);
+            };
+
+            const items = Array.from(list.querySelectorAll('[data-e2e="comment-item"]'));
+            let topLevelCount = 0;
+
+            for (const item of items) {
+                if (topLevelCount >= limit) break;
+                if (!isVisible(item)) continue;
+                if (item.parentElement && item.parentElement.closest('[data-e2e="comment-item"]')) {
+                    continue;
+                }
+
+                // 头像在左侧容器,正文在 info-wrap —— 必须分开取
+                const avatarA = item.querySelector('.comment-item-avatar a[href*="/user/"]')
+                    || Array.from(item.querySelectorAll('a[href*="/user/"]')).find(a => {
+                        const r = a.getBoundingClientRect();
+                        return r.width >= 28 && r.width <= 56 && r.height >= 28 && r.height <= 56;
+                    });
+                if (!avatarA) continue;
+
+                const href = avatarA.getAttribute('href') || '';
+                if (!href || href.includes('/user/self')) continue;
+
+                const infoWrap = item.querySelector('.comment-item-info-wrap');
+                // 正文不在 info-wrap 内,而在其兄弟节点;用右侧整块 F7ubq_7y / 整条 item
+                const textRoot = item.querySelector('.F7ubq_7y') || item;
+
+                let nickname = '';
+                const nickRoot = infoWrap || textRoot;
+                for (const a of nickRoot.querySelectorAll('a[href*="/user/"]')) {
+                    const t = (a.innerText || '').trim().replace(/作者/g, '').trim();
+                    if (t && t !== '...') {
+                        nickname = t;
+                        break;
+                    }
+                }
+                const { content, comment_time } = pickContent(textRoot, nickname);
+                if (!content) continue;
+
+                const ar = avatarA.getBoundingClientRect();
+                const main = {
+                    nickname,
+                    content,
+                    href,
+                    comment_time,
+                    is_reply: false,
+                    avatar_x: Math.round(ar.x + ar.width / 2),
+                    avatar_y: Math.round(ar.y + ar.height / 2),
+                };
+                push(main);
+                topLevelCount += 1;
+
+                const mainX = main.avatar_x;
+                const replyAvatars = Array.from(item.querySelectorAll('a[href*="/user/"]'))
+                    .filter(a => {
+                        if (a === avatarA) return false;
+                        if (a.closest('button.comment-reply-expand-btn')) return false;
+                        const r = a.getBoundingClientRect();
+                        if (!isVisible(a)) return false;
+                        if (r.width < 28 || r.width > 56 || r.height < 28 || r.height > 56) return false;
+                        return (r.x + r.width / 2) > mainX + 24;
+                    })
+                    .sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
+
+                for (const a of replyAvatars) {
+                    let replyRoot = a.parentElement;
+                    for (let i = 0; i < 8 && replyRoot && replyRoot !== item; i++) {
+                        const t = (replyRoot.innerText || '');
+                        if ((/前/.test(t) || t.includes('回复')) && t.length < 500) break;
+                        replyRoot = replyRoot.parentElement;
+                    }
+                    if (!replyRoot || replyRoot === item) replyRoot = a.parentElement;
+
+                    let replyNick = '';
+                    for (const link of replyRoot.querySelectorAll('a[href*="/user/"]')) {
+                        const t = (link.innerText || '').trim().replace(/作者/g, '').trim();
+                        if (t && t !== '...') {
+                            replyNick = t;
+                            break;
+                        }
+                    }
+                    const parsed = pickContent(replyRoot, replyNick);
+                    if (!parsed.content) continue;
+                    const rr = a.getBoundingClientRect();
+                    push({
+                        nickname: replyNick,
+                        content: parsed.content,
+                        href: a.getAttribute('href') || '',
+                        comment_time: parsed.comment_time,
+                        is_reply: true,
+                        avatar_x: Math.round(rr.x + rr.width / 2),
+                        avatar_y: Math.round(rr.y + rr.height / 2),
+                    });
+                }
+            }
+
+            return results.map((row, index) => ({
+                index: index + 1,
+                nickname: row.nickname,
+                content: row.content,
+                href: row.href,
+                comment_time: row.comment_time,
+                is_reply: row.is_reply,
+                avatar_x: row.avatar_x,
+                avatar_y: row.avatar_y,
+            }));
+        }""",
+        limit,
+    )
+    return comments or []
+
+def collect_comments_and_save(
+    page: Page,
+    video_meta: dict,
+    limit: int = settings.COMMENT_LIMIT,
+) -> None:
+    """
+    在「评论」tab 下滚动采集前 limit 条一级评论并入库。
+    若右侧栏不在评论 tab,则切过去(不再点评论图标,避免误关)。
+    """
+    video_pk = video_meta.get("db_id")
+    if not video_pk:
+        raise RuntimeError("缺少 videos.id(db_id),无法入库评论")
+
+    print("[评论采集] 确认在评论 tab...")
+    if not _is_side_panel_open(page):
+        open_comment_panel(page)
+    if not _click_side_panel_tab(page, "评论"):
+        raise RuntimeError("无法切换到评论栏")
+
+    try:
+        page.wait_for_selector('[data-e2e="comment-item"]', timeout=10_000)
+    except Exception as exc:
+        raise SkipVideoError("评论数为 0,跳过该视频") from exc
+
+    comments = _collect_comments_with_scroll(page, limit)
+    top_n = sum(1 for c in comments if not c.get("is_reply"))
+    reply_n = sum(1 for c in comments if c.get("is_reply"))
+
+    if top_n < limit:
+        raise SkipVideoError(f"一级评论仅 {top_n} 条,不足 {limit},跳过该视频")
+
+    print(
+        f"\n按顺序处理:一级评论 {top_n} 条(目标 {limit},回复不计数),"
+        f"已展开回复 {reply_n} 条,合计入库候选 {len(comments)} 条:"
+    )
+
+    id_cache: dict[str, str] = {}
+    rows_for_db: list[dict] = []
+
+    for item in comments:
+        idx = item.get("index")
+        nickname = item.get("nickname") or ""
+        content = _clean_comment_content(item.get("content") or "")
+        href = item.get("href") or ""
+        user_key = _normalize_user_key(href)
+        user_link = _to_abs_url(href) if href else ""
+        comment_time = item.get("comment_time") or ""
+        is_reply = bool(item.get("is_reply"))
+        douyin_id = ""
+
+        if not content:
+            print("-" * 40)
+            print(f"[{idx}] 跳过: 评论内容为空(已剔除作者互动 tag)")
+            continue
+
+        print("-" * 40)
+        print(f"[{idx}] 类型:       {'回复' if is_reply else '一级评论'}")
+        print(f"[{idx}] 视频ID:     {video_meta.get('video_id', '')} (db_id={video_pk})")
+        print(f"[{idx}] 评论昵称:   {nickname}")
+        print(f"[{idx}] 评论内容:   {content}")
+        print(f"[{idx}] 评论时间:   {comment_time or '(未获取)'}")
+        print(f"[{idx}] 用户主页:   {user_link or '(未找到)'}")
+
+        if user_key and user_key in id_cache:
+            douyin_id = id_cache[user_key]
+            print(f"[{idx}] 抖音号:     {douyin_id} (缓存)")
+        elif user_key and "/user/" in user_link:
+            human_pause(1.0, 2.0)
+            profile = None
+            try:
+                profile = _open_user_profile(page, href)
+                opened = profile.url or ""
+                if user_key not in opened and "user" not in opened:
+                    raise RuntimeError(f"主页跳转异常: expect {user_key}, got {opened}")
+                human_pause(1.8, 3.0)
+                douyin_id = _extract_douyin_id(profile)
+                if nickname:
+                    page_text = profile.evaluate("() => document.body.innerText || ''") or ""
+                    if nickname not in page_text:
+                        print(
+                            f"[{idx}] 警告: 主页未出现评论昵称「{nickname}」,"
+                            f"仍按主页抖音号入库: {douyin_id}"
+                        )
+                id_cache[user_key] = douyin_id
+                print(f"[{idx}] 抖音号:     {douyin_id}")
+                print(f"[{idx}] 主页URL:    {opened}")
+            except Exception as exc:
+                print(f"[{idx}] 抖音号:     (读取失败: {exc})")
+                douyin_id = ""
+            finally:
+                if profile is not None and profile != page:
+                    try:
+                        profile.close()
+                    except Exception:
+                        pass
+                try:
+                    page.bring_to_front()
+                except Exception:
+                    pass
+                human_pause(0.8, 1.5)
+        else:
+            print(f"[{idx}] 抖音号:     (未找到用户主页链接)")
+
+        if not douyin_id:
+            print(f"[{idx}] 跳过入库: 无可靠抖音号")
+            continue
+
+        rows_for_db.append(
+            {
+                "nick_name": nickname,
+                "content": content,
+                "comment_time": comment_time or None,
+                "user_link": user_link or None,
+                "douyin_id": douyin_id,
+                "is_reply": is_reply,
+            }
+        )
+
+    print("-" * 40)
+    print(f"评论采集结束,成功获取评论者抖音号 {len(id_cache)} 个。")
+
+    with session_scope() as session:
+        result = save_comments_batch(session, int(video_pk), rows_for_db)
+    print(
+        f"[评论入库完成] videos.id={result['video_pk']} "
+        f"线索处理={result['leads']} "
+        f"新增评论={result['comments']} "
+        f"跳过={result['skipped_comments']} "
+        f"(无抖音号={result.get('no_douyin_id', 0)})"
+    )
+
+

+ 36 - 0
collectors/douyin/common.py

@@ -0,0 +1,36 @@
+"""公共工具:异常、停顿、步骤重试。"""
+
+from __future__ import annotations
+
+import random
+import time
+
+class SkipVideoError(Exception):
+    """当前视频不满足采集条件(如一级评论不足),应跳过并切下一条。"""
+
+
+AI_SUMMARY_PROMPT = "用20~30个字总结该视频内容"
+
+
+def human_pause(min_s: float = 1.5, max_s: float = 3.5) -> None:
+    seconds = random.uniform(min_s, max_s)
+    print(f"等待 {seconds:.1f}s ...")
+    time.sleep(seconds)
+
+
+def run_step(name: str, action) -> bool:
+    for attempt in (1, 2):
+        try:
+            print(f"[{name}] 开始(第 {attempt} 次)")
+            action()
+            print(f"[{name}] 完成")
+            return True
+        except Exception as exc:
+            print(f"[{name}] 失败: {exc}")
+            if attempt == 1:
+                print(f"[{name}] 准备重试一次...")
+                human_pause(2.0, 4.0)
+            else:
+                print(f"[{name}] 重试仍失败,跳过该步骤,浏览器保持打开。")
+    return False
+

+ 113 - 0
collectors/douyin/navigate.py

@@ -0,0 +1,113 @@
+"""步骤:在搜索结果中切换下一条视频。"""
+
+from __future__ import annotations
+
+from playwright.sync_api import Page
+
+from collectors.douyin.common import human_pause
+from collectors.douyin.panel import (
+    _ensure_video_page_focus,
+    close_comment_panel,
+)
+
+def _ensure_search_video_modal(page: Page) -> None:
+    """确认仍在搜索结果的视频弹层,而不是作者作品流。"""
+    url = page.url or ""
+    if "/search/" not in url:
+        raise RuntimeError(f"当前不在搜索结果页,无法在搜索列表中切视频: {url}")
+    if "modal_id=" not in url and "/video/" not in url:
+        # 有的状态只有 aweme 节点;仍要求停留在 search
+        print(f"[切视频] 警告:URL 无 modal_id,但仍在搜索页: {url}")
+
+
+def _focus_search_video_player(page: Page) -> None:
+    """把焦点放到搜索弹层左侧视频区,避免方向键作用在评论/AI 输入框。"""
+    try:
+        box = page.locator(
+            '[data-e2e="feed-active-video"], [data-e2e="modal-video-container"]'
+        ).first.bounding_box()
+        if box:
+            page.mouse.click(
+                box["x"] + min(box["width"] * 0.35, 220),
+                box["y"] + box["height"] * 0.45,
+            )
+            human_pause(0.4, 0.8)
+            return
+    except Exception:
+        pass
+    page.mouse.click(360, 360)
+    human_pause(0.3, 0.6)
+
+
+def _read_current_video_id(page: Page) -> str:
+    """读取当前活跃视频 ID(modal_id / aweme-id)。"""
+    return (
+        page.evaluate(
+            """() => {
+                const modalId = new URLSearchParams(location.search).get('modal_id') || '';
+                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"]');
+                const awemeId = infoEl ? (infoEl.getAttribute('data-e2e-aweme-id') || '') : '';
+                return awemeId || modalId || '';
+            }"""
+        )
+        or ""
+    )
+
+
+def go_to_next_video(page: Page, prev_video_id: str = "") -> str:
+    """
+    在「搜索结果」视频弹层中切换到下一条(不是作者作品流)。
+    返回新 video_id;失败抛错。
+    """
+    _ensure_search_video_modal(page)
+    close_comment_panel(page)
+    human_pause(0.8, 1.5)
+    _focus_search_video_player(page)
+
+    before = (prev_video_id or "").strip() or _read_current_video_id(page)
+
+    def _changed() -> str:
+        _ensure_search_video_modal(page)
+        now = _read_current_video_id(page)
+        return now if now and now != before else ""
+
+    page.keyboard.press("ArrowDown")
+    human_pause(1.5, 2.5)
+    new_id = _changed()
+    if new_id:
+        print(f"已切换到下一条搜索视频: {new_id}")
+        _ensure_video_page_focus(page)
+        return new_id
+
+    page.keyboard.press("ArrowDown")
+    human_pause(1.5, 2.5)
+    new_id = _changed()
+    if new_id:
+        print(f"已切换到下一条搜索视频(二次方向键): {new_id}")
+        _ensure_video_page_focus(page)
+        return new_id
+
+    # 滚轮只作用在左侧视频区
+    try:
+        box = page.locator(
+            '[data-e2e="feed-active-video"], [data-e2e="modal-video-container"]'
+        ).first.bounding_box()
+        if box:
+            page.mouse.move(box["x"] + box["width"] * 0.3, box["y"] + box["height"] * 0.5)
+            page.mouse.wheel(0, 1400)
+            human_pause(1.5, 2.5)
+    except Exception:
+        pass
+
+    new_id = _changed()
+    if new_id:
+        print(f"已切换到下一条搜索视频(滚轮): {new_id}")
+        _ensure_video_page_focus(page)
+        return new_id
+
+    raise RuntimeError(f"未能在搜索结果中切换到下一条视频(仍停留在 {before or '未知'})")
+

+ 270 - 0
collectors/douyin/panel.py

@@ -0,0 +1,270 @@
+"""步骤:右侧栏(评论图标 / 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}")
+

+ 106 - 0
collectors/douyin/profile.py

@@ -0,0 +1,106 @@
+"""步骤:打开用户/作者主页并读取抖音号。"""
+
+from __future__ import annotations
+
+import re
+
+from playwright.sync_api import Page
+
+from collectors.douyin.common import human_pause
+
+def _to_abs_url(href: str) -> str:
+    if href.startswith("http"):
+        return href
+    if href.startswith("//"):
+        return f"https:{href}"
+    if href.startswith("/"):
+        return f"https://www.douyin.com{href}"
+    return f"https://www.douyin.com/{href}"
+
+
+def _normalize_user_key(href: str) -> str:
+    """用 /user/xxx 路径作为同一用户缓存键,避免 query 差异导致重复开页。"""
+    if not href:
+        return ""
+    m = re.search(r"/user/[^/?#]+", href)
+    return m.group(0) if m else href.strip()
+
+
+def _extract_douyin_id(profile: Page) -> str:
+    """从用户主页提取抖音号;先等到页面出现「抖音号」文案,并做二次确认。"""
+    try:
+        profile.wait_for_function(
+            """() => /抖音号/.test(document.body.innerText || '')""",
+            timeout=20_000,
+        )
+    except Exception:
+        human_pause(2.5, 4.0)
+
+    def _read_once() -> str:
+        return (
+            profile.evaluate(
+                """() => {
+                    const bodyText = document.body.innerText || '';
+                    const m = bodyText.match(/抖音号[::\\s]*([A-Za-z0-9_.\\-]+)/);
+                    if (m) return m[1];
+                    const spans = Array.from(document.querySelectorAll('span, p'));
+                    for (const el of spans) {
+                        const t = (el.innerText || '').trim();
+                        const mm = t.match(/^抖音号[::\\s]*([A-Za-z0-9_.\\-]+)$/);
+                        if (mm) return mm[1];
+                    }
+                    return '';
+                }"""
+            )
+            or ""
+        ).strip()
+
+    douyin_id = _read_once()
+    if not douyin_id:
+        human_pause(1.5, 2.5)
+        douyin_id = _read_once()
+    if not douyin_id:
+        raise RuntimeError("主页上未解析到抖音号")
+
+    # 二次读取,避免偶发读到未渲染完的占位
+    human_pause(0.4, 0.8)
+    again = _read_once()
+    if again and again != douyin_id:
+        print(f"[抖音号校验] 两次读取不一致: {douyin_id} vs {again},采用第二次")
+        douyin_id = again
+    return douyin_id
+
+
+def _open_profile_by_url(page: Page, url: str) -> Page:
+    profile = page.context.new_page()
+    profile.goto(url, wait_until="domcontentloaded")
+    profile.wait_for_load_state("domcontentloaded")
+    return profile
+
+
+def _open_author_profile(page: Page, author_link: str) -> Page:
+    """
+    在新标签打开作者主页,避免同页跳进作者作品流,
+    保证后续仍在「搜索结果」视频弹层里上下切换。
+    """
+    return _open_profile_by_url(page, author_link)
+
+def _open_user_profile(
+    page: Page,
+    href: str,
+    avatar_x: int | None = None,
+    avatar_y: int | None = None,
+) -> Page:
+    """
+    打开评论者主页。
+    一律用主页链接在新标签打开,避免点错头像/坐标过期导致抖音号错配。
+    """
+    _ = avatar_x, avatar_y
+    url = _to_abs_url(href)
+    if "/user/" not in url:
+        raise RuntimeError(f"无效用户主页链接: {url}")
+    profile = page.context.new_page()
+    profile.goto(url, wait_until="domcontentloaded")
+    profile.wait_for_load_state("domcontentloaded")
+    return profile
+

+ 148 - 0
collectors/douyin/runner.py

@@ -0,0 +1,148 @@
+"""编排入口:按步骤跑完整采集流程。"""
+
+from __future__ import annotations
+
+from playwright.sync_api import sync_playwright
+
+from config import settings
+from collectors.douyin.auth import click_login_if_needed, wait_until_logged_in
+from collectors.douyin.comments import collect_comments_and_save
+from collectors.douyin.common import SkipVideoError, human_pause, run_step
+from collectors.douyin.navigate import go_to_next_video
+from collectors.douyin.search import (
+    click_video_filter,
+    open_first_video,
+    search_keyword,
+)
+from collectors.douyin.video import collect_and_save_video_meta
+
+
+def run() -> None:
+    settings.BROWSER_DATA_DIR.mkdir(parents=True, exist_ok=True)
+    keyword = settings.SEARCH_KEYWORD
+    limit = settings.COMMENT_LIMIT
+    video_limit = settings.VIDEO_LIMIT
+    max_attempts = max(video_limit * 3, video_limit + 5)
+
+    with sync_playwright() as p:
+        context = p.chromium.launch_persistent_context(
+            user_data_dir=str(settings.BROWSER_DATA_DIR),
+            headless=False,
+            viewport={"width": 1280, "height": 800},
+            locale="zh-CN",
+            args=["--disable-blink-features=AutomationControlled"],
+        )
+        page = context.pages[0] if context.pages else context.new_page()
+        page.goto(settings.DOUYIN_URL, wait_until="domcontentloaded")
+        print(f"已打开: {page.url}")
+
+        human_pause(2.0, 3.5)
+        run_step("点击登录", lambda: click_login_if_needed(page))
+
+        try:
+            wait_until_logged_in(page)
+        except Exception as exc:
+            print(f"[等待登录] 超时或失败: {exc}")
+            print("登录未完成,后续步骤将跳过;浏览器保持打开,可手动继续。")
+        else:
+            human_pause(2.0, 4.0)
+            if not run_step("搜索关键词", lambda: search_keyword(page, keyword)):
+                pass
+            elif not run_step("点击视频筛选", lambda: click_video_filter(page, keyword)):
+                pass
+            elif not run_step("打开第一个视频", lambda: open_first_video(page)):
+                pass
+            else:
+                human_pause(2.0, 4.0)
+                prev_video_id = ""
+                success = 0
+                attempt = 0
+                need_switch = False
+
+                while success < video_limit and attempt < max_attempts:
+                    attempt += 1
+                    print("=" * 48)
+                    print(
+                        f"尝试采集第 {attempt} 个候选 "
+                        f"(已成功 {success}/{video_limit})"
+                    )
+                    print("=" * 48)
+
+                    if need_switch:
+                        switched = {"id": ""}
+
+                        def _switch() -> None:
+                            switched["id"] = go_to_next_video(page, prev_video_id)
+
+                        if not run_step("切换到下一条搜索视频", _switch):
+                            print("无法继续在搜索结果中切视频,结束采集。")
+                            break
+                        if switched["id"]:
+                            prev_video_id = switched["id"]
+                        human_pause(2.0, 3.5)
+                    need_switch = True
+
+                    video_meta_box: dict = {}
+                    try:
+                        print("[获取作者信息/AI总结并入库] 开始")
+                        video_meta_box["meta"] = collect_and_save_video_meta(
+                            page, keyword, limit
+                        )
+                        print("[获取作者信息/AI总结并入库] 完成")
+                    except SkipVideoError as exc:
+                        print(f"[跳过视频] {exc}")
+                        human_pause(1.0, 2.0)
+                        continue
+                    except Exception as exc:
+                        print(f"[获取作者信息/AI总结并入库] 失败: {exc}")
+                        print("准备重试一次...")
+                        human_pause(2.0, 4.0)
+                        try:
+                            video_meta_box["meta"] = collect_and_save_video_meta(
+                                page, keyword, limit
+                            )
+                            print("[获取作者信息/AI总结并入库] 完成")
+                        except SkipVideoError as exc2:
+                            print(f"[跳过视频] {exc2}")
+                            continue
+                        except Exception as exc2:
+                            print(f"[获取作者信息/AI总结并入库] 重试仍失败: {exc2}")
+                            continue
+
+                    prev_video_id = video_meta_box["meta"].get("video_id") or prev_video_id
+                    human_pause(1.5, 2.5)
+
+                    try:
+                        print("[打开评论区并按序采集入库] 开始")
+                        collect_comments_and_save(
+                            page, video_meta_box["meta"], limit
+                        )
+                        print("[打开评论区并按序采集入库] 完成")
+                        success += 1
+                    except SkipVideoError as exc:
+                        print(f"[跳过视频] {exc}")
+                    except Exception as exc:
+                        print(f"[打开评论区并按序采集入库] 失败: {exc}")
+                        print("准备重试一次...")
+                        human_pause(2.0, 4.0)
+                        try:
+                            collect_comments_and_save(
+                                page, video_meta_box["meta"], limit
+                            )
+                            print("[打开评论区并按序采集入库] 完成")
+                            success += 1
+                        except SkipVideoError as exc2:
+                            print(f"[跳过视频] {exc2}")
+                        except Exception as exc2:
+                            print(f"[打开评论区并按序采集入库] 重试仍失败: {exc2}")
+
+                    human_pause(1.5, 3.0)
+
+                print(f"多视频采集结束:成功 {success}/{video_limit},尝试 {attempt} 次。")
+
+        print("流程结束(失败步骤已跳过)。浏览器保持打开,关闭窗口或按 Ctrl+C 结束。")
+        try:
+            page.wait_for_event("close")
+        except Exception:
+            pass
+        context.close()

+ 91 - 0
collectors/douyin/search.py

@@ -0,0 +1,91 @@
+"""步骤:搜索关键词 / 视频筛选 / 打开首个视频。"""
+
+from __future__ import annotations
+
+import random
+from urllib.parse import quote
+
+from playwright.sync_api import Page
+
+from collectors.douyin.common import human_pause
+
+def search_keyword(page: Page, keyword: str) -> None:
+    search = page.get_by_placeholder("搜索你感兴趣的内容").or_(
+        page.locator('input[type="search"], input[enterkeyhint="search"]').first
+    ).first
+    search.wait_for(state="visible", timeout=15_000)
+
+    human_pause(0.8, 1.6)
+    search.click()
+    human_pause(0.5, 1.2)
+    search.fill("")
+    human_pause(0.3, 0.8)
+    search.press_sequentially(keyword, delay=random.randint(80, 180))
+    print(f"已输入关键词: {keyword}")
+    human_pause(1.2, 2.5)
+    search.press("Enter")
+    page.wait_for_load_state("domcontentloaded")
+    print(f"已搜索关键词: {keyword}")
+
+
+def click_video_filter(page: Page, keyword: str) -> None:
+    page.wait_for_function(
+        """() => {
+            const spans = Array.from(document.querySelectorAll('span'));
+            return spans.some(s => {
+                const t = (s.innerText || '').trim();
+                const y = s.getBoundingClientRect().y;
+                return t === '视频' && y > 40 && y < 120;
+            });
+        }""",
+        timeout=20_000,
+    )
+    human_pause(0.8, 1.6)
+
+    clicked = page.evaluate(
+        """() => {
+            const spans = Array.from(document.querySelectorAll('span'));
+            const el = spans.find(s => {
+                const t = (s.innerText || '').trim();
+                const rect = s.getBoundingClientRect();
+                return t === '视频' && rect.width > 0 && rect.y > 40 && rect.y < 120;
+            });
+            if (!el) return false;
+            el.click();
+            return true;
+        }"""
+    )
+    if not clicked:
+        url = f"https://www.douyin.com/search/{quote(keyword)}?type=video"
+        print(f"未点到「视频」栏,改为直接打开: {url}")
+        page.goto(url, wait_until="domcontentloaded")
+    else:
+        print("已点击顶部「视频」筛选项")
+        page.wait_for_load_state("domcontentloaded")
+    human_pause(2.5, 4.0)
+
+
+def open_first_video(page: Page) -> None:
+    page.wait_for_selector('a[href*="/video/"]', timeout=20_000)
+    human_pause(1.0, 2.0)
+
+    first_video = page.locator('a[href*="/video/"]:visible').first
+    first_video.wait_for(state="visible", timeout=10_000)
+    href = first_video.get_attribute("href") or ""
+    print(f"准备打开第一个视频: {href}")
+    human_pause(0.8, 1.5)
+
+    try:
+        first_video.click(timeout=5_000)
+    except Exception:
+        print("普通点击失败,改为 JS 点击第一个视频")
+        page.evaluate(
+            """() => {
+                const a = document.querySelector('a[href*="/video/"]');
+                if (a) a.click();
+            }"""
+        )
+
+    page.wait_for_load_state("domcontentloaded")
+    print(f"已进入视频页: {page.url}")
+

+ 191 - 0
collectors/douyin/video.py

@@ -0,0 +1,191 @@
+"""步骤:视频/作者信息采集、预检评论数、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
+

+ 1 - 0
config/__init__.py

@@ -0,0 +1 @@
+"""config 包。"""

+ 38 - 0
config/settings.py

@@ -0,0 +1,38 @@
+"""项目配置:从 .env 读取数据库与采集参数。"""
+
+from pathlib import Path
+
+from dotenv import load_dotenv
+import os
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+load_dotenv(ROOT_DIR / ".env")
+
+DB_HOST = os.getenv("DB_HOST", "localhost")
+DB_PORT = int(os.getenv("DB_PORT", "3306"))
+DB_USER = os.getenv("DB_USER", "root")
+DB_PASSWORD = os.getenv("DB_PASSWORD", "")
+DB_NAME = os.getenv("DB_NAME", "acquirelead")
+
+SEARCH_KEYWORD = os.getenv("SEARCH_KEYWORD", "怎么学好英语?")
+COMMENT_LIMIT = int(os.getenv("COMMENT_LIMIT", "10"))
+VIDEO_LIMIT = int(os.getenv("VIDEO_LIMIT", "5"))
+
+BROWSER_DATA_DIR = ROOT_DIR / "browser_data" / "douyin"
+DOUYIN_URL = "https://www.douyin.com"
+
+# leads.status
+LEAD_STATUS_COLLECTED = 1
+
+# 表名前缀(迁移后库表为 douyin_videos / douyin_leads 等)
+TABLE_PREFIX = os.getenv("TABLE_PREFIX", "douyin_")
+
+
+def database_url() -> str:
+    from urllib.parse import quote_plus
+
+    password = quote_plus(DB_PASSWORD)
+    return (
+        f"mysql+pymysql://{DB_USER}:{password}"
+        f"@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
+    )

+ 1 - 0
db/__init__.py

@@ -0,0 +1 @@
+"""db 包。"""

+ 87 - 0
db/models.py

@@ -0,0 +1,87 @@
+"""SQLAlchemy 模型:字段名对齐当前数据库实际列名。"""
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+from config.settings import TABLE_PREFIX
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+def _now() -> datetime:
+    return datetime.now()
+
+
+class Video(Base):
+    __tablename__ = f"{TABLE_PREFIX}videos"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    video_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
+    keyword: Mapped[str | None] = mapped_column(String(255))
+    title: Mapped[str | None] = mapped_column(String(255))
+    author_nickname: Mapped[str | None] = mapped_column(String(128))
+    author_douyin_id: Mapped[str | None] = mapped_column(String(255))
+    author_link: Mapped[str | None] = mapped_column(String(512))
+    video_url: Mapped[str | None] = mapped_column(String(512))
+    summary: Mapped[str | None] = mapped_column(String(512))
+    create_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now)
+    update_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now, onupdate=_now)
+
+
+class Lead(Base):
+    __tablename__ = f"{TABLE_PREFIX}leads"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    douyin_id: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
+    nick_name: Mapped[str | None] = mapped_column(String(255))
+    user_link: Mapped[str | None] = mapped_column(String(512))
+    status: Mapped[int | None] = mapped_column(SmallInteger, default=1)
+    ai_score: Mapped[int | None] = mapped_column(Integer)
+    ai_reason: Mapped[str | None] = mapped_column(String(255))
+    score_create: Mapped[datetime | None] = mapped_column(DateTime)
+    create_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now)
+    update_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now, onupdate=_now)
+
+
+class Comment(Base):
+    __tablename__ = f"{TABLE_PREFIX}comments"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    # 关联 videos.id(表主键),不是平台 video_id 字符串
+    video_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
+    lead_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
+    douyin_id: Mapped[str | None] = mapped_column(String(255), index=True)
+    nick_name: Mapped[str | None] = mapped_column(String(255))
+    content: Mapped[str | None] = mapped_column(String(1024))
+    comment_time: Mapped[str | None] = mapped_column(String(64))
+    # 1=一级评论,0=回复
+    type: Mapped[int | None] = mapped_column(SmallInteger, default=1)
+    create_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now)
+
+
+class Conversation(Base):
+    __tablename__ = f"{TABLE_PREFIX}conversations"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    lead_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False)
+    douyin_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+    nickname: Mapped[str | None] = mapped_column(String(128))
+    last_message_time: Mapped[datetime | None] = mapped_column(DateTime)
+    create_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now)
+    update_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now, onupdate=_now)
+
+
+class Message(Base):
+    __tablename__ = f"{TABLE_PREFIX}messages"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    conversation_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
+    lead_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
+    douyin_id: Mapped[str | None] = mapped_column(String(255))
+    direction: Mapped[str | None] = mapped_column(String(16))
+    content: Mapped[str | None] = mapped_column(String(1024))
+    create_at: Mapped[datetime | None] = mapped_column(DateTime, default=_now)

+ 186 - 0
db/repository.py

@@ -0,0 +1,186 @@
+"""采集结果入库:videos / leads / comments。"""
+
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from config.settings import LEAD_STATUS_COLLECTED
+from db.models import Comment, Lead, Video
+
+
+def upsert_video(session: Session, meta: dict) -> Video:
+    """按平台 video_id 插入或更新视频,返回 ORM 对象(含主键 id)。"""
+    platform_video_id = (meta.get("video_id") or "").strip()
+    if not platform_video_id:
+        raise ValueError("video_id 为空,无法入库")
+
+    now = datetime.now()
+    video = session.scalar(select(Video).where(Video.video_id == platform_video_id))
+    if video is None:
+        video = Video(video_id=platform_video_id, create_at=now, update_at=now)
+        session.add(video)
+    else:
+        video.update_at = now
+
+    video.keyword = meta.get("keyword") or video.keyword
+    video.title = meta.get("title") or video.title
+    video.author_nickname = meta.get("author_nickname") or video.author_nickname
+    video.author_douyin_id = meta.get("author_douyin_id") or video.author_douyin_id
+    video.author_link = meta.get("author_link") or video.author_link
+    video.video_url = meta.get("video_url") or video.video_url
+    if meta.get("summary"):
+        video.summary = str(meta["summary"])[:512]
+    session.flush()
+    return video
+
+
+def upsert_lead(
+    session: Session,
+    *,
+    douyin_id: str,
+    nick_name: str | None,
+    user_link: str | None,
+) -> Lead:
+    """按抖音号插入或更新线索;已存在时不覆盖更高阶 status。"""
+    douyin_id = douyin_id.strip()
+    if not douyin_id:
+        raise ValueError("douyin_id 为空,无法入库")
+
+    now = datetime.now()
+    lead = session.scalar(select(Lead).where(Lead.douyin_id == douyin_id))
+    if lead is None:
+        lead = Lead(
+            douyin_id=douyin_id,
+            nick_name=nick_name,
+            user_link=user_link,
+            status=LEAD_STATUS_COLLECTED,
+            create_at=now,
+            update_at=now,
+        )
+        session.add(lead)
+    else:
+        if nick_name:
+            lead.nick_name = nick_name
+        if user_link:
+            lead.user_link = user_link
+        if lead.status is None:
+            lead.status = LEAD_STATUS_COLLECTED
+        lead.update_at = now
+    session.flush()
+    return lead
+
+
+def insert_comment_if_new(
+    session: Session,
+    *,
+    video_pk: int,
+    lead_id: int | None,
+    douyin_id: str | None,
+    nick_name: str | None,
+    content: str | None,
+    comment_time: str | None,
+    is_reply: bool = False,
+) -> Comment | None:
+    """
+    写入评论。
+    comments.video_id = videos.id(主键)。
+    唯一性:同一视频主键 + 同一抖音号 + 同一正文。
+    """
+    if not video_pk:
+        raise ValueError("video_pk 为空,评论无法入库")
+
+    content_norm = (content or "").strip()
+    douyin_norm = (douyin_id or "").strip() or None
+    if not douyin_norm or not content_norm:
+        return None
+
+    exists = session.scalar(
+        select(Comment.id).where(
+            Comment.video_id == video_pk,
+            Comment.douyin_id == douyin_norm,
+            Comment.content == content_norm,
+        )
+    )
+    if exists:
+        return None
+
+    comment = Comment(
+        video_id=video_pk,
+        lead_id=lead_id,
+        douyin_id=douyin_norm,
+        nick_name=nick_name,
+        content=content_norm,
+        comment_time=comment_time,
+        type=0 if is_reply else 1,
+        create_at=datetime.now(),
+    )
+    session.add(comment)
+    session.flush()
+    return comment
+
+
+def save_video_only(session: Session, video_meta: dict) -> dict:
+    """仅 upsert 视频;返回平台 video_id 与表主键 id。"""
+    video = upsert_video(session, video_meta)
+    return {"video_id": video.video_id, "id": video.id}
+
+
+def save_comments_batch(session: Session, video_pk: int, comments: list[dict]) -> dict:
+    """
+    写入线索与评论。
+    video_pk: videos.id
+    用户唯一键:douyin_id
+    评论唯一键:videos.id + douyin_id + content
+    """
+    if not video_pk:
+        raise ValueError("video_pk 为空")
+
+    lead_count = 0
+    comment_count = 0
+    skip_count = 0
+    no_id_count = 0
+
+    for item in comments:
+        douyin_id = (item.get("douyin_id") or "").strip()
+        if not douyin_id:
+            no_id_count += 1
+            skip_count += 1
+            continue
+
+        lead = upsert_lead(
+            session,
+            douyin_id=douyin_id,
+            nick_name=item.get("nick_name"),
+            user_link=item.get("user_link"),
+        )
+        lead_count += 1
+
+        saved = insert_comment_if_new(
+            session,
+            video_pk=video_pk,
+            lead_id=lead.id,
+            douyin_id=douyin_id,
+            nick_name=item.get("nick_name"),
+            content=item.get("content"),
+            comment_time=item.get("comment_time"),
+            is_reply=bool(item.get("is_reply")),
+        )
+        if saved is None:
+            skip_count += 1
+        else:
+            comment_count += 1
+
+    return {
+        "video_pk": video_pk,
+        "leads": lead_count,
+        "comments": comment_count,
+        "skipped_comments": skip_count,
+        "no_douyin_id": no_id_count,
+    }
+
+
+def save_collect_batch(session: Session, video_meta: dict, comments: list[dict]) -> dict:
+    """兼容旧调用:一次事务写入视频 + 评论。"""
+    video = upsert_video(session, video_meta)
+    return save_comments_batch(session, video.id, comments)

+ 45 - 0
db/session.py

@@ -0,0 +1,45 @@
+"""数据库引擎与会话。"""
+
+from collections.abc import Generator
+from contextlib import contextmanager
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from config.settings import database_url
+
+_engine = None
+_SessionLocal = None
+
+
+def get_engine():
+    global _engine, _SessionLocal
+    if _engine is None:
+        _engine = create_engine(
+            database_url(),
+            pool_pre_ping=True,
+            pool_recycle=3600,
+            echo=False,
+        )
+        _SessionLocal = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
+    return _engine
+
+
+def get_session_factory() -> sessionmaker[Session]:
+    get_engine()
+    assert _SessionLocal is not None
+    return _SessionLocal
+
+
+@contextmanager
+def session_scope() -> Generator[Session, None, None]:
+    factory = get_session_factory()
+    session = factory()
+    try:
+        yield session
+        session.commit()
+    except Exception:
+        session.rollback()
+        raise
+    finally:
+        session.close()

+ 7 - 0
goto_douyin.py

@@ -0,0 +1,7 @@
+"""入口:运行抖音采集并入库。"""
+
+from collectors.douyin import run
+
+
+if __name__ == "__main__":
+    run()

+ 5 - 0
requirements.txt

@@ -0,0 +1,5 @@
+playwright>=1.40.0
+sqlalchemy>=2.0.0
+pymysql>=1.1.0
+python-dotenv>=1.0.0
+cryptography>=42.0.0

+ 76 - 0
sql/schema.sql

@@ -0,0 +1,76 @@
+-- AcquireLead 数据库表结构(与当前库表字段对齐)
+-- 表名前缀:douyin_
+-- 整体拆分:douyin_videos -> douyin_comments -> douyin_leads -> douyin_conversations -> douyin_messages
+
+CREATE TABLE IF NOT EXISTS `douyin_videos` (
+    `id`               BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `video_id`         VARCHAR(255) NOT NULL COMMENT '平台视频ID(如 modal_id / aweme_id)',
+    `keyword`          VARCHAR(255) DEFAULT NULL COMMENT '采集时搜索关键词',
+    `title`            VARCHAR(255) DEFAULT NULL COMMENT '视频标题/描述',
+    `author_nickname`  VARCHAR(128) DEFAULT NULL COMMENT '作者昵称',
+    `author_douyin_id` VARCHAR(255) DEFAULT NULL COMMENT '作者抖音号',
+    `author_link`      VARCHAR(512) DEFAULT NULL COMMENT '作者主页链接',
+    `video_url`        VARCHAR(512) DEFAULT NULL COMMENT '视频链接',
+    `summary`          VARCHAR(512) DEFAULT NULL COMMENT 'AI抖音视频内容总结',
+    `create_at`        DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_at`        DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_douyin_videos_video_id` (`video_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='来源视频';
+
+CREATE TABLE IF NOT EXISTS `douyin_leads` (
+    `id`           BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `douyin_id`    VARCHAR(255) NOT NULL COMMENT '抖音号(唯一身份)',
+    `nick_name`    VARCHAR(255) DEFAULT NULL COMMENT '用户昵称',
+    `user_link`    VARCHAR(512) DEFAULT NULL COMMENT '用户主页链接',
+    `status`       TINYINT      DEFAULT 1 COMMENT '1已采集 -1低价值 2高价值待私信 3已私信待回复 -2私信失败 4已人工对接',
+    `ai_score`     INT          DEFAULT NULL COMMENT 'AI意向分',
+    `ai_reason`    VARCHAR(255) DEFAULT NULL COMMENT '评分原因',
+    `score_create` DATETIME     DEFAULT NULL COMMENT '评分时间',
+    `create_at`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_at`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_douyin_leads_douyin_id` (`douyin_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='线索用户';
+
+CREATE TABLE IF NOT EXISTS `douyin_comments` (
+    `id`           BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `lead_id`      BIGINT        DEFAULT NULL COMMENT '关联 douyin_leads.id,入库后可回填',
+    `douyin_id`    VARCHAR(255)  DEFAULT NULL COMMENT '评论者抖音号',
+    `video_id`     BIGINT        NOT NULL COMMENT '关联 douyin_videos.id',
+    `nick_name`    VARCHAR(255)  DEFAULT NULL COMMENT '评论时昵称',
+    `content`      VARCHAR(1024) DEFAULT NULL COMMENT '评论内容',
+    `comment_time` VARCHAR(64)   DEFAULT NULL COMMENT '页面显示时间(如 1天前·上海)',
+    `type`         TINYINT       DEFAULT 1 COMMENT '1=一级评论,0=回复',
+    `create_at`    DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '系统入库时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_douyin_comments_video_id` (`video_id`),
+    KEY `idx_douyin_comments_douyin_id` (`douyin_id`),
+    KEY `idx_douyin_comments_lead_id` (`lead_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='视频评论';
+
+CREATE TABLE IF NOT EXISTS `douyin_conversations` (
+    `id`                BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `lead_id`           BIGINT       NOT NULL COMMENT '关联 douyin_leads.id',
+    `douyin_id`         VARCHAR(255) NOT NULL COMMENT '对方抖音号(冗余,便于校验)',
+    `nickname`          VARCHAR(128) DEFAULT NULL COMMENT '对方昵称',
+    `last_message_time` DATETIME     DEFAULT NULL COMMENT '最近消息时间',
+    `create_at`         DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_at`         DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_douyin_conversations_lead_id` (`lead_id`),
+    KEY `idx_douyin_conversations_douyin_id` (`douyin_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='私信会话';
+
+CREATE TABLE IF NOT EXISTS `douyin_messages` (
+    `id`              BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `conversation_id` BIGINT        NOT NULL COMMENT '关联 douyin_conversations.id',
+    `lead_id`         BIGINT        DEFAULT NULL COMMENT '关联 douyin_leads.id',
+    `douyin_id`       VARCHAR(255)  DEFAULT NULL COMMENT '对方抖音号',
+    `direction`       VARCHAR(16)   DEFAULT NULL COMMENT 'in=对方 out=我方',
+    `content`         VARCHAR(1024) DEFAULT NULL COMMENT '消息文本',
+    `create_at`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '系统抓取入库时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_douyin_messages_conversation_id` (`conversation_id`),
+    KEY `idx_douyin_messages_lead_id` (`lead_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='私信消息';

+ 518 - 0
产品手册.md

@@ -0,0 +1,518 @@
+# 产品手册
+
+## 1. 文档目的
+
+本文档用于沉淀当前已确认的产品方案,面向后续产品、开发、测试和运营协作。
+
+当前方案聚焦于:
+
+- 使用 `Playwright` 做自动化采集与自动化私信操作
+- 使用 `AI` 做线索筛选与聊天辅助
+- 人工仅介入登录/验证,以及最终转平台对接
+
+本文档只整理当前已经明确的方案和约束,不包含数据库字段设计细节。数据库表字段后续商榷后再定。
+
+## 2. 项目目标
+
+目标是从抖音行业关键词视频的评论区中找到高意向用户,进行私信沟通,并在合适时机将用户转到企业微信进行后续合作。
+
+本期方案不依赖官方 API,主流程基于浏览器自动化实现。
+
+## 3. 当前范围
+
+### 3.1 本期包含
+
+- 行业关键词视频搜索
+- 评论区采集
+- 用户信息识别
+- 线索入库
+- AI 线索筛选
+- 自动私信触达
+- 私信内容读取
+- AI 会话辅助
+- 人工转企业微信
+
+### 3.2 本期不包含
+
+- 抖音开放平台 API 对接
+- 数据库字段最终定稿
+- 反检测、反风控、绕过平台限制的实现细节
+- 企业微信侧 CRM、SOP、销售流程细节
+
+## 4. 总体方案
+
+整体采用双 `Playwright` 架构,将采集与私信分离,降低相互影响。
+
+### 4.1 角色划分
+
+#### 采集 Worker
+
+负责:
+
+- 搜索行业关键词视频
+- 打开评论区
+- 读取评论内容
+- 进入评论用户主页
+- 识别用户唯一信息
+- 保存线索
+
+#### 私信 Worker
+
+负责:
+
+- 从线索池中取高价值用户
+- 搜索用户并发起私信
+- 轮询私信列表
+- 读取聊天内容
+- 调用 AI 生成回复
+- 发送回复
+- 在合适时机转人工
+
+#### 人工
+
+负责:
+
+- 登录账号
+- 处理验证码、滑块、人机验证
+- 账号异常时恢复登录态
+- 对高意向用户转企业微信
+- 处理投诉、封禁、异常会话
+
+## 5. 登录态与调度策略
+
+### 5.1 登录态策略
+
+每次启动 `Playwright` 默认会打开一个新的、没有 Cookie 的浏览器环境。如果这样做,就会导致频繁重新登录,增加异常风险。
+
+因此当前方案要求:
+
+- 复用固定浏览器用户目录或固定登录态
+- 由人工首次登录
+- 登录态失效时暂停任务并通知人工
+- 自动化流程不主动反复尝试重新登录
+
+目标是让浏览器尽量表现为同一批长期使用的会话,而不是每天反复创建全新环境。
+
+### 5.2 调度策略
+
+自动化任务可以按每天指定时间范围运行,并在时间范围内随机触发。
+
+例如:
+
+- 上午工作时段内随机启动一次或多次
+- 下午工作时段内随机启动一次或多次
+- 单次任务内操作间隔带随机抖动
+
+目的:
+
+- 降低固定规律行为
+- 让操作节奏更接近日常人工使用
+
+说明:
+
+- `Playwright` 负责执行操作
+- 定时、随机、任务编排应由外层调度器负责
+
+## 6. 主流程
+
+本期主流程只保留“私信”路径,不包含“回复评论引导用户主动私信”路径。
+
+### 6.1 主流程概述
+
+1. 人工登录并完成验证
+2. 采集 Worker 搜索行业关键词视频
+3. 打开评论区并提取评论内容
+4. 点击评论用户主页,识别该用户的抖音号
+5. 将线索信息入库
+6. AI 对线索做筛选和打分
+7. 高价值线索进入私信队列
+8. 私信 Worker 搜索该用户并发起私信
+9. 私信 Worker 定时检查私信列表和会话
+10. 读取用户消息并入库
+11. AI 根据上下文生成回复
+12. 发送回复
+13. 用户表达合作意向后,转人工对接企业微信
+
+## 7. 采集流程说明
+
+### 7.1 采集来源
+
+线索来源于行业关键词视频,不是自有视频评论区。
+
+### 7.2 采集步骤
+
+采集 Worker 的标准步骤如下:
+
+1. 打开抖音
+2. 搜索预设行业关键词
+3. 进入目标视频
+4. 打开评论区
+5. 遍历评论
+6. 读取评论正文
+7. 点击评论者主页
+8. 读取评论者抖音号
+9. 返回评论区继续处理
+
+### 7.3 为什么要点主页
+
+因为后续系统需要识别“这个人到底是谁”。
+
+仅使用昵称不可靠,原因包括:
+
+- 昵称会修改
+- 可能重名
+- 头像会变化
+- 会话列表不保证有稳定唯一标识
+
+因此,当前确认的方案是:
+
+**通过再次点击该用户主页,读取抖音号,作为识别用户和关联会话的主要依据。**
+
+## 8. 会话识别的关键约束
+
+这是当前方案最重要的约束之一。
+
+### 8.1 已确认事实
+
+在 `Playwright` 自动化点击私信列表中的某条会话时:
+
+- 不会自动传入“对话 id”
+- 不会自动传入“会话唯一参数”
+- 不会自动传入“消息唯一参数”
+
+也就是说,系统不能指望在点击某条会话时,页面天然把该会话的唯一标识交给我们。
+
+### 8.2 当前确认的唯一性方案
+
+**查询对话唯一性时,只能再次点击该用户主页,通过抖音号来确定该用户与该会话的唯一性。**
+
+这条规则适用于:
+
+- 采集阶段识别用户
+- 私信阶段确认目标用户
+- 读取会话前做身份校验
+- 发送消息前再次确认当前会话对象
+
+### 8.3 通俗理解
+
+不要把“当前点开的聊天窗口”直接当成可信对象。
+
+必须理解成:
+
+- 列表里看到的是一个聊天入口
+- 点进去后,还要再核实“这是不是数据库里那个人”
+- 核实手段是:进入该用户主页,看抖音号是否一致
+
+也就是说,系统认人的依据不是“聊天列表第几条”,也不是“昵称看起来像”,而是“主页里的抖音号”。
+
+## 9. 私信流程说明
+
+### 9.1 私信前置条件
+
+只有经过 AI 筛选后的高价值线索,才进入私信队列。
+
+### 9.2 私信步骤
+
+私信 Worker 的标准步骤如下:
+
+1. 从高价值线索池中取一个用户
+2. 取到该用户在采集阶段识别到的抖音号
+3. 在抖音中搜索该用户
+4. 进入该用户主页
+5. 核对主页抖音号与数据库记录是否一致
+6. 若一致,则尝试打开私信入口
+7. 若可私信,则生成首条私信文案
+8. 发送首条私信
+9. 记录发送结果
+
+### 9.3 若无法私信
+
+可能存在以下情况:
+
+- 找不到用户
+- 抖音号变化
+- 无私信入口
+- 需要互关
+- 账号权限限制
+- 页面异常
+
+这些情况都不应直接忽略,而应标记为“无法触达”或“待人工判断”。
+
+## 10. 私信消息读取
+
+### 10.1 能读取什么
+
+`Playwright` 可以读取当前页面上已经渲染出来的聊天内容,主要包括:
+
+- 文本消息
+- 左右气泡方向
+- 页面显示的时间文字
+- 顶部会话对象信息
+
+### 10.2 读取方式
+
+私信 Worker 需要定时执行以下动作:
+
+1. 打开私信列表
+2. 检查是否存在疑似未读会话或预览变化
+3. 点击目标会话
+4. 再次进入该用户主页
+5. 核对抖音号是否和数据库中的目标用户一致
+6. 一致后,读取当前可见的消息气泡
+7. 将新消息写入数据库
+
+### 10.3 关于“未读消息”
+
+私信列表可以作为“是否可能有新消息”的参考,但不能作为绝对依据。
+
+原因包括:
+
+- 列表未读提示可能延迟刷新
+- 会话列表可能有虚拟滚动
+- 界面结构可能变化
+- 排序可能变化
+
+因此,本方案中:
+
+- 未读提示仅作为触发信号
+- 是否真的有新消息,以会话页面中读取到的新气泡为准
+
+## 11. 会话与聊天记录如何关联
+
+这是系统落地时最关键的一部分。
+
+### 11.1 核心原则
+
+**先认人,再存话。**
+
+也就是:
+
+1. 先确认当前聊天对象是谁
+2. 再把读取到的消息挂到该对象对应的会话下
+
+### 11.2 会话的本质
+
+通俗理解:
+
+- 一个用户,对应一场会话
+- 一场会话下面,挂很多条消息
+
+当前已确认的关联逻辑不是“按会话列表顺序”,而是:
+
+**按用户主页中的抖音号,去找到数据库中对应的那个人,再把消息存进去。**
+
+### 11.3 不能怎么做
+
+不能按下面这些方式关联:
+
+- 按聊天列表第几条
+- 按昵称
+- 按头像
+- 按未读顺序
+
+因为这些信息都不稳定,容易串会话。
+
+### 11.4 应该怎么做
+
+建议流程如下:
+
+1. 系统准备处理某个聊天对象
+2. 在数据库中先找到这个对象对应的线索记录
+3. 打开私信列表并点击候选会话
+4. 点击该用户主页
+5. 读取主页抖音号
+6. 与数据库中保存的抖音号做比对
+7. 若一致,认定“当前窗口就是这个人的会话”
+8. 再读取消息并写入该对象的会话记录中
+
+### 11.5 发送消息前也要再确认一次
+
+为了避免串号,系统在真正点击发送前,仍需要再做一次身份确认:
+
+- 当前聊天窗口对应主页抖音号
+- 是否仍等于数据库中该线索记录的抖音号
+
+只有一致时才允许发送。
+
+如果不一致,应立即停止发送并记录异常。
+
+## 12. 聊天记录存储原则
+
+当前不展开数据库字段设计,但产品层面已明确以下存储原则。
+
+### 12.1 存储层级
+
+聊天数据至少要分成两层理解:
+
+#### 第一层:会话
+
+表示“我们和哪一个用户在聊天”。
+
+#### 第二层:消息
+
+表示“在这场聊天中,说过哪些话”。
+
+### 12.2 每条消息入库时至少要明确
+
+- 这条消息属于哪个用户
+- 这条消息属于哪场会话
+- 这是对方说的,还是我们说的
+- 这条消息的内容是什么
+- 页面上显示的时间是什么
+- 系统什么时候读到并写入了这条消息
+
+### 12.3 存储原则
+
+#### 原则一:按用户归属会话
+
+消息必须挂在“该用户对应的会话”下面,而不是挂在“刚刚打开的那个窗口”下面。
+
+#### 原则二:自己发出的消息也要入库
+
+不只是对方发来的消息要存,我们自己通过自动化发出去的消息也必须记录。
+
+否则后续 AI 看上下文时会不完整,容易重复回复或答非所问。
+
+#### 原则三:只做增量写入
+
+每次打开聊天窗口,不应把整页内容全部重复写一遍,而应只写入数据库里没有的新消息。
+
+#### 原则四:时间要区分两种
+
+消息时间要区分:
+
+- 页面上显示的时间
+- 系统实际抓取并入库的时间
+
+因为页面上可能显示“刚刚”“昨天”“上午”等相对时间文本,而系统入库时间是绝对可记录的。
+
+## 13. AI 使用方式
+
+### 13.1 AI 在线索阶段
+
+AI 用于对评论区采集到的线索做判断,例如:
+
+- 是否具有合作意向
+- 是否与业务相关
+- 是否值得进入私信阶段
+
+### 13.2 AI 在聊天阶段
+
+AI 用于生成私信回复,目标是:
+
+- 根据上下文进行简短回复
+- 判断是否继续聊
+- 判断是否应转人工
+- 判断是否适合引导到企业微信
+
+### 13.3 AI 的边界
+
+AI 只负责辅助判断与生成回复,不负责:
+
+- 判断当前窗口是否点对人
+- 决定是否越过身份校验直接发送
+- 处理账号验证、封禁、投诉
+
+这些仍由自动化规则和人工兜底共同负责。
+
+## 14. 人工介入点
+
+当前方案中,人工介入点明确如下:
+
+### 14.1 登录与验证
+
+人工负责:
+
+- 首次登录
+- 验证码
+- 滑块
+- 二次校验
+- 登录态失效恢复
+
+### 14.2 企业微信转接
+
+当系统识别到用户有明确合作意向后,交由人工接手:
+
+- 查看聊天摘要
+- 查看最近上下文
+- 决定如何转企业微信
+- 做后续商务沟通
+
+### 14.3 异常处理
+
+人工负责处理:
+
+- 用户投诉风险
+- 页面结构变化导致的失败
+- 账号封禁
+- 会话身份无法确认
+- 高价值线索的特殊跟进
+
+## 15. 风险与现实约束
+
+当前方案虽可做产品原型,但存在明确风险和限制:
+
+### 15.1 账号风险
+
+- 私信频率过高容易异常
+- 重复登录会增加异常概率
+- 自动化行为本身存在平台识别风险
+
+### 15.2 消息读取限制
+
+- 无官方 API 时,消息读取依赖页面渲染
+- 不保证能拿到稳定的会话 id
+- 不保证能拿到稳定的消息 id
+- 图片、表情、历史消息读取可能不稳定
+
+### 15.3 会话识别限制
+
+- 必须依赖再次进入用户主页核对抖音号
+- 会比直接用官方会话 id 更慢
+- 但这是当前不调官方 API 前提下最稳妥的识别方案
+
+### 15.4 产品策略限制
+
+本期只保留“私信触达”路径,不走“评论区答疑引导私信”路径。
+
+原因是当前优先验证:
+
+- 能否稳定找到目标用户
+- 能否稳定读取消息
+- 能否稳定将消息和用户正确关联
+- 能否完成从私信到企微的转接
+
+## 16. 当前已确认结论
+
+截至当前,已确认的关键结论如下:
+
+1. 本期主路线为:关键词评论采集 -> AI 筛选 -> 自动私信 -> 读取聊天 -> AI 回复 -> 人工转企微
+2. 采用双 `Playwright` 架构,采集与私信分离
+3. 人工只负责登录验证、异常恢复、企微转接
+4. 不依赖官方 API
+5. 自动化点击会话时,不会天然拿到对话 id 或会话唯一参数
+6. 会话唯一性确认必须通过再次点击用户主页,读取抖音号完成
+7. 聊天记录的归属必须按“用户唯一身份”关联,而不是按列表顺序或昵称关联
+8. 自己发出的消息也必须入库
+9. 私信列表中的未读提示只能作为参考,不能作为唯一判断依据
+10. 数据库字段暂不在本文档中展开,后续单独商议
+
+## 17. 后续待商议事项
+
+以下内容当前尚未定稿,后续需要单独讨论:
+
+- 数据库表结构
+- 消息去重规则
+- AI 评分规则
+- 首条私信文案策略
+- 多轮聊天策略
+- 转企微时机
+- 调度窗口与频控参数
+- 失败重试策略
+- 人工审核比例
+
+## 18. 文档说明
+
+本文档是当前阶段的产品方案沉淀稿,用于统一认知,不代表所有技术细节已经完全确定。
+
+后续若方案调整,应优先更新本文档,再推进详细设计和开发实现。