一句话:整条链是好几个「闹钟」(cron 定时任务)首尾接力、彼此不对齐,所以端到端时间 = 每一环等待时间叠加,不是某一环的频率。
查重一共 4 层:工单级、视频级、评论级、触达级 —— 其中只有「视频级」是真·数据库唯一约束,评论级全靠代码比对。
8 步全流程一屏看
0
派活 · 给每个号源生成抓取工单
Vercel cron 写 scrape_jobs 表,status=pending
全量 每小时热号 每 3 分钟
几秒查重① 5 分钟内已抓跳过
1
worker 领工单 · poller 轮询
原子认领 pending → running,按 pri 优先级排序
每 30 秒入队→开抓 中位 1.4 分
查重 原子 claim 防重领
2
发现视频 · 这个号最近发了哪些
f2 签名 API 拉作品列表,失败降级 Playwright 滚页
几秒 ~ 30 秒
查重② videos.url 数据库 UNIQUE(唯一真约束)→ upsert
3
拉评论原文 · 翻页 + 楼中楼
默认只要近 7 天评论;带回 cid / 正文 / 昵称 / 用户 ID
每视频 2~10 秒本层不去重
4
写库前建查重表(内存)
查该视频 DB 已有评论 → 建 (正文,昵称) 配对表
毫秒准备 查重主键 = (content, author)
5
内容过滤 · 标 skipped 省钱(非去重)
竞品广告 / 同行噪音 → status=skipped 不送 LLM
毫秒过滤 ≠ 去重
6
Phase 1 写顶层评论
insert(非 upsert),插完补 cid→UUID 映射
一次批量 insert
查重③ 代码比 (content,author):DB 已存 + 同批已见
7
Phase 2 写楼中楼 · 挂父评论
找不到顶层父就 skip 不孤悬
一次批量 insert
查重④ 同 (content,author) + parent 反查
4 层查重 · 一张表记牢
| 层 | 在哪 | 查重 key | 约束类型 | insert / upsert |
| 工单级 | 第 0/1 步 | 5 分钟内同视频 + 原子 claim | 机制 | — |
| 视频级 | 第 2 步 | videos.url | 数据库 UNIQUE(唯一真约束) | upsert |
| 评论·顶层 | 第 6 步 | (content, author) | 纯应用层代码 | insert |
| 评论·楼中楼 | 第 7 步 | (content, author) + parent_cid 反查 | 纯应用层代码 | insert |
最该记住:comments 表本身没有数据库唯一约束。评论去重完全压在 worker 那段 (content, author) 内存比对上。绕过这段代码直接写库,DB 拦不住。douyin_cid(抖音原生评论 ID)入库了,但没拿来做唯一键,只用于楼中楼找父评论。
端到端要多久
| 场景 | 时间 | 说明 |
| Tier-1 好号 最快 | ~9 分钟 | 采集5 + 打分2 + 派单2 + 领单1(各闸不对齐叠加) |
| 全量普通号 | ~1 小时起 | tier-0 走每小时 cron |
| 高意向慢尾 | 中位 34 分,但 >6h 占 42% | 老视频覆盖盲区,已上 cold-intent 回扫止血(✅ 6-19 复诊:回扫 pri=3 实测 p99 1.9h / max 2.0h,封顶在 ≤2h 容差内、高/中意向 leads 未掉,止血有效,详见下方 pri 防饿死) |
想看每个英文词的中文注释、tier/pri 等级分值表、每步代码位置、6 天延迟根因 → 点上方 🔬 事无巨细详细版。
先打底:英文词中文注释
cron(克朗)= 定时任务,按时间表自动跑;*/3 * * * * 这种叫 cron 表达式。
scrape(斯克瑞普)= 抓取 / 采集,把抖音评论扒回来。
worker(沃克)= 跑在 Fly.io 云上的 Python 后台程序,专管抓取。
poller(破勒)= 轮询器,worker 里一直转的循环,每隔几十秒看一眼有没有活。
tier(提尔)= 号源分层等级(这个号值不值钱)。
pri / priority(普赖 / 普赖奥瑞提)= 抓取工单的优先级分值(谁先被抓)。
dedup(迪迪普,de-duplicate)= 去重 / 查重。
upsert = update + insert,存在就更新、不存在才插,不重复。
UUID = 数据库里每行的唯一主键字符串。cid = 抖音原生评论 ID。sec_uid = 抖音用户唯一 ID。
第 0 步 · 派活:给每个号源生成抓取工单
| 谁干 | Vercel(韦赛尔,跑前端和定时任务的云平台)上的 cron |
| 全量派活 | /api/cron/scrape · 10 * * * * = 每小时的第 10 分跑(每小时 1 次) |
| 热号派活 | /api/cron/scrape-hot · */3 * * * * = 每 3 分钟一次(只派 Tier-1 优质号) |
| 干啥 | 每个 active(活跃)源账号写一行 scrape_jobs(抓取工单表),status=pending(待办) |
| 耗时 | 几秒,纯写库 |
这里分 tier 等级(号源分层,只有 2 级)
| tier | 含义 | 走哪个 cron | 默认 pri | 速度 |
| tier=0 | 默认普通号源(约 1019 个长尾号) | scrape 每小时 | pri=5 慢道 | 慢 |
| tier=1 | 优质好号(约 176 个;近 3 天高意向 ≥5 自动升,只升不降) | scrape-hot 每 3 分钟 | pri=1 快道 | 快 |
升 tier1 两条路:① 运营手工标;② auto-tier1 cron(0 1 * * * 每天凌晨 1 点)自动升。每次升档写一行 source_tier_change_log 留痕。
查重①(工单级):SCRAPE_SKIP_RECENT_MINUTES(最近多少分钟内不重抓,野荞机器上已从 30 调到 5)跳过 5 分钟内已抓过的视频,防白抓。
第 1 步 · worker 领工单:poller 每 30 秒抢一次
| 谁干 | Fly.io(弗莱,东京机)上的 worker/services/queue.py |
| 频率 | POLL_INTERVAL_SECONDS = 30 = 每 30 秒转一圈(空队列睡 30 秒,有活立刻干) |
| 怎么领 | 调数据库函数 claim_pending_scrape_jobs_batch(原子批量认领),pending → running,attempts(尝试次数)+1 |
| 重试上限 | MAX_ATTEMPTS = 3 = 抓 3 次失败就排除(变「墓碑」永久 pending) |
| 实测延迟 | 入队 → 开抓 中位 1.4 分钟(worker 有大余量,每小时空转约 50 分钟) |
pri 优先级体系(查代码坐实,无配置表,硬编码 7 个入队点)
| pri | 哪种活 | 含义 |
| 0 最急 | 高意向注入(#363) / VIP 视频复扫 / UI 手动「立即抓」 | 插队最前 |
| 1 | 常规单视频 fresh-catch(新鲜捕捞) | 快 |
| 3 | tier-1 整号活 / rescan-recent / cold-intent 回扫 | 中 |
| 5 最慢 | tier-0 每小时(默认值 priority INT DEFAULT 5)/ hot-rotation | 慢 |
领单排序 ORDER BY priority ASC, scheduled_at ASC(pri 数字小的先,同 pri 先到先抓)。
已知坑(pri 防饿死):原来严格抢占 → 只要 pri=1 源源不断,pri=5 的 tier-0 会被饿死。6/11 出过 P1 事故:普通号连续 27 小时一条没抓到。修法 migration 20260615144043 加 aging(等久升档):每等 6 小时有效优先级升 1 级,等满 24 小时追平 pri=1 但永不超过。
⚠️ 同一个坑又复发(2026-06-19 实测,更隐蔽):#413 早停修复上线后,快道 pri=1 量暴涨 +59%(视频不再被判空 → 不再退避 → 每 tick 更多候选入队),把低优先级 scrape_video(含 cold-intent 老视频回扫 pri=3)挤到队尾饿死 —— 6-18→19 全天 p99 领单等待 6202 秒(≈1.7h)。aging 最终能把它们捞上来,但要先等一两小时,这段等待就是这条尾巴。aging 是「等久了再救」,挡不住「一开始就被插队」。
✅ 2026-06-19 复诊(pri 防饿死配额暂不做):复跑 36h(覆盖 6-18→19)按 pri 段拆 scrape_video,印证又收窄了上面的判断——cold-intent pri=3 全部认领,claim-wait p50 27m / p99 1.9h / max 2.0h、0 条超 6h、pending 仅 15 条且都没等满 1h。也就是说那条 6202s≈1.7h 的尾巴是真的,但封顶在 2.0h,正好踩在 cold-intent 自己「激进追平 ≤2h」的设计容差里,没有超出 2h 的长尾。同期 tier-0 pri=5 已被 aging 压到 p99 1.1h / max 13.4h(仅 8 条,0.01%),不再是早先的 12h / 27h 积压。高/中意向入库近 7 天稳在 370–463/天(446→380 是日间方差、不是这条尾巴的连带伤)。结论:pri 防饿死配额(worker 槽给 pri≥3 留位)是高危改动,当前无指标支撑,降级为留观;真要再压这条 1.7h 尾,对症杠杆是 env COLD_INTENT_RESCAN_INTERVAL_MIN(120→60) / COLD_INTENT_RESCAN_MAX,纯 fly secret、不开 PR。
第 2 步 · 发现视频:这个号最近发了哪些
| 函数 | worker/services/discoverer.py → discover_videos_from_profile() |
| 快路径 | f2(抖音 API 签名库)签名调 fetch_user_posts()(拉作品列表),快 |
| 慢降级 | 快路径失败才启 Playwright(普雷赖特,浏览器自动化)滚 DOM(页面元素)收视频卡 |
| 抓几条 | 默认每号最近 ~20 条;tier1 好号抓到第 11–20 条仍贡献约 26% 高意向 |
| 写库 | _persist_discovered_videos():先查 DB 已有 url → 过滤新的 → videos.upsert(rows, on_conflict="url") |
| 耗时 | 快路径几秒;Playwright 降级每号约 10–30 秒 |
查重②(视频级,全链唯一一处真·数据库约束):videos.url 在 001_initial_schema.sql 上有 UNIQUE(唯一)约束。同一视频 url 撞了走 upsert,只刷新 scraped_at(抓取时间)。
第 3 步 · 拉评论原文:翻页 + 楼中楼
| 函数 | worker/services/comment_api.py → fetch_comments() |
| 怎么拉 | f2 签名调抖音 web 评论 API,循环翻页(cursor 游标分页) |
| 时间窗 | 默认只要近 7 天评论(cutoff 截止线丢老评论,这是过滤不是去重) |
| 楼中楼 | 顶层评论有子回复的,再调 _fetch_replies() 拉嵌套 |
| 带回字段 | cid(原生评论 ID)/ parent_cid(父评论 cid)/ content(正文)/ author(昵称)/ douyin_user_id(sec_uid)/ ip_location(IP 属地) |
| 耗时 | 每视频约 2–10 秒(看评论数与翻页轮数) |
| 去重 | 本层不去重,纯拿数据 |
6/13 最大发现:抖音发视频接口本身有 ~6 天延迟,导致 99% 评论一抓回来就已超 7 天窗、过期。这是端到端慢尾真因之一(不是排队问题)。
第 4 步 · 写库前在内存里建查重表
进 worker/services/scraper.py → scrape_video_comments() 后,查该 video_id 下 DB 已有评论,建两张内存表:
existing_by_ca = {(content, author): id} ← 这就是查重主键:用「评论正文 + 昵称」两字段配对。
cid_to_uuid = {douyin_cid: id} ← 不是用来去重的,是给楼中楼找顶层父评论的 UUID(数据库主键)用。
第 5 步 · 内容过滤:标 skipped 省 LLM 钱(不是去重)
每条过 _shape_row() 跑两个判定:
is_competitor_ad()(是不是竞品广告)→ status=skipped(跳过),intent_label=无关,不送 LLM。
is_low_value_noise()(低价值噪音:同行昵称 / 全 emoji / @AI 调用)→ 同样 skipped。
- 正常评论 →
status=pending,留给打分。
背景:7 天窗数据里「无关」占 57%,主要是这类噪音,先砍掉省钱。
第 6 步 · Phase 1 写顶层评论:查重落地
| 怎么查重 | 每条算 key=(content, author),命中 existing_by_ca(DB 已有)或 seen_in_batch(本批已见)就跳过 |
| 写库 | 城市富化后 comments.insert(top_rows) —— 是 insert 不是 upsert |
| 插完 | 把新行 douyin_cid → id 补进 cid_to_uuid,给 Phase 2 用 |
查重③(评论·顶层):纯应用层代码,(content, author) 双重比对 —— 既比 DB 已存、也比同批翻页重叠。
为什么要 seen_in_batch(本批已见集合):f2 翻页 cursor + 顶层/嵌套返回会重叠,2026-05-20 实测有 13.5% 入库行是同毫秒完全相同的重复,靠这个集合挡住。
第 7 步 · Phase 2 写楼中楼:同款查重 + 挂父评论
| 查重 | 同 (content, author) 比对(DB + seen_in_batch) |
| 挂父 | parent_uuid = cid_to_uuid.get(parent_cid),找不到顶层父就 skip 不孤悬 |
| 写库 | comments.insert(nested_rows),带 parent_comment_id(父评论外键) |
查重④(评论·楼中楼):同 (content, author) 应用层逻辑 + parent_cid 反查兜底。至此评论落库,status=pending,等下游打分。
下游(采集之外,顺带看清全链时间)
| 段 | cron / 机制 | 频率 | 干啥 |
| ②打分 analyze | /api/cron/analyze | */2 每 2 分钟 | 给新评论打意向分(高/中/低/无关),高/中意向生成草稿 |
| ③派单 dispatch | /api/cron/cloud-pc-dispatch | * * * * * 每分钟 | 喂 DM 队列给云电脑 agent |
| 库存补单 | /api/cron/feed-backlog | 每 2 小时 | 喂未触达高意向库存 |
| 云电脑领单 | wuying_poll_agent.py | 60 秒 | 领队列 → GUI 自动发 |
端到端实测时间
| 场景 | 时间 | 说明 |
| Tier-1 好号 最快 | ~9 分钟 | 采集5 + 打分2 + 派单2 + 领单1(各闸不对齐叠加) |
| 全量普通号 | ~1 小时起 | tier-0 走每小时 cron |
| 高意向慢尾 | 中位 34 分,>1h 占 47% / >6h 占 42% / >12h 占 38% | 就是日报里标 expired_at_claim(领单时已过期)被跳过那批 |
慢尾真因 = 老视频覆盖盲区(tier-1 只重扫最近 ≤20 条视频,老视频被挤出就不再定期扫),已上 cold-intent-rescan(*/20 每 20 分钟回扫近 14 天仍冒意向的老视频)止血。
✅ 2026-06-19 复诊更正:早先一版担心「止血被快道洪水在排队层抵消」——6-19 复跑 36h 实测
没那么严重。cold-intent 回扫
pri=3 的排队尾巴封顶在
p99 1.9h / max 2.0h、0 条超 6h,在它「激进追平 ≤2h」的容差内;高/中意向入库近 7 天稳在 370–463/天,没掉。所以慢尾真治还是靠「找回老视频」(cold-intent 做的,pri=3 也确实排得动),
pri 防饿死配额(高危 PR)暂不做、留观;要再压尾用 env
COLD_INTENT_RESCAN_INTERVAL_MIN /
MAX。详见
时效深挖页 ④ 终判。
触达层还有第二套查重(和采集无关,别混)
| 查重点 | key | 机制 |
| lead 锁 | (org_id, douyin_user_id) | lead_claims 表锁 4 小时,防多人重复 claim 同一用户 |
| 派单在途 | (org_id, douyin_user_id) where status in (pending,claimed) | dispatch_queue 唯一索引,撞了报 23505(PG 唯一冲突码)→ 标 dedup |
| 消息幂等 | (conversation_id, content, role=ai, status=sent) 15 分钟内 | mark_lead_contacted RPC,重复调不双发 |
一句话总览查重在哪:
1. 工单级:5 分钟内不重抓 + 原子 claim 防重领。
2. 视频级:videos.url 数据库 UNIQUE(唯一真约束)→ upsert。
3. 评论级(顶层+楼中楼):纯代码 (content, author) 配对,DB 比 + 同批比。comments 表本身没有数据库唯一约束 —— 最该记住的一点。
4. 触达级:lead_claims + dispatch_queue(23505) + 消息 15 分钟幂等,三重。