video-crawler:一条 URL 进来到发布要过的六关
local/video-crawler
robotworld-ingest v2.3.0 是站点的外部内容入口:用户丢来一条 Twitter/X、YouTube 或 arXiv URL 说「采集」,它负责变成一条能发布的内容。六阶段里三个由脚本做(采集媒体、webp 两档派生、线上验证),三个必须由 agent 做(元数据补全、论文详情页、社交物料)。视频下载 1080p、发布 720p。
我们的判断下载 1080p 发布 720p 看着矛盾,其实是画质与流量的分账:平台自带的 720p 档已经是二次压缩产物。封面从视频 10%/33%/60%/80% 四处抽帧选第一个非黑帧。两条来自事故的硬规则最值得抄——改完元数据必须回读(ZEST 那次 SQL 因引号静默失败,中文站显示了英文标题),以及四条以上的批次要用 setsid 完全脱离终端,因为 SSH 通常 5-10 分钟就断而 nohup 不可靠。
一条 URL 进来到发布要过几关
robotworld-ingest v2.3.0 是站点的外部内容入口:用户丢来一条 Twitter/X、YouTube 或 arXiv URL,说「采集/入库/加一下」,这条 skill 负责把它变成一条能发布的内容。数字孪生里有两个执行者——视频走 video-crawler,论文走 paper-analyst。
整条链路是六个阶段,其中三个阶段由脚本做,三个阶段必须由 agent 做:
| 阶段 | 谁做 | 产出与判据 |
|---|---|---|
| 1 采集媒体 | ingest.py(脚本) | 视频/封面落盘 + ingestion_records 行;按 URL 自动探测 source,已有 succeeded 记录幂等跳过 |
| 2 补全元数据 | agent 逐条(enrich.py set) | 中英标题/摘要/英文 canonical 标签/分类;读 raw_text 自己写,不套模板 |
| 3 论文详情页 | agent 逐篇(另一条 skill) | 6 轮精读,quality_score ≥ 8.0 |
| 3.5 webp 两档 | 脚本(容器内,幂等增量) | .card.webp 800px/q78 给列表卡,.body.webp 1400px/q80 给正文与首屏大图 |
| 4 线上验证 | curl | API 返回有 video/thumb、标题摘要非空、前端 200 |
| 5 质量门禁 | 机器打分 + 人 | 论文详情页必须过 /manage/confirm 人工确认才 published=true |
| 6 社交物料 | agent 逐条 | OG 卡 + 重写稿 + 知识卡 + 六平台英文帖子包 |
视频规格:下载 1080p,发布 720p
这条规则看起来矛盾,其实是画质与流量的分账。yt-dlp 的格式选择器优先取长边 ≤1920px 的最高清,下载后由 downscale_video_if_needed() 用 ffprobe 探测,超过站点规格才转码压回长边 ≤1280px(libx264 -preset veryfast -crf 20 + aac 128k + faststart)。下载档位不跟着降到 1280:平台自带的 720p 档已经是二次压缩产物,从它的 1080p 档转码到本站 720p 明显更干净。
格式选择器还有一个容易写错的细节:必须同时约束 width 与 height。竖屏视频的 height 才是长边,只写 height<=N 会把 720x1280 过滤掉、退选 480x852,等于主动选低清(一次真实的竖屏事故)。
scale=min(1280\,iw):min(1280\,ih):force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2
scale 必须写 min() 形态:裸 scale=1280:1280 是无条件插值,会把 712x294 的小视频放大成 1280x520,更糊还多送流量;min() 里的逗号必须转义,否则滤镜图解析失败(rc=234)。临时文件必须以 .mp4 结尾,ffmpeg 靠扩展名判容器。ffmpeg 要在容器里跑——生产机那份是精简版,没有 libx264;服务器只有 2 核,手动转码一律加 nice -n 19 且一次只转一个。
封面:从视频中段取亮帧
原生封面常常就是第一帧,也就是黑屏。适配器下载视频后先 image_is_dark() 判黑,再 generate_video_thumbnail() 在 10%/33%/60%/80% 四个时间点抽帧选第一个非黑帧,全暗时密集兜底 5%-95%,仍无可用亮部就取评分最高帧,最后压回长边 ≤1280px。黑屏判据写得比较克制:最高亮度 <50/255,或平均亮度 <20/255 且亮度 ≥120 的像素不足 0.5% 才算黑——黑色背景但有明确亮部内容的图不算黑屏,否则会把一堆正常封面误判重做。
阶段 2 的硬规则:改完必须回读
元数据补全由 agent 逐条读 raw_text 自己写,标签存储顺序固定为英文 canonical 在前、中文检索别名在后,前端按站点语言选择展示。这里有一条来自真实事故的硬规则:任何 SQL 或脚本改完元数据后必须立即回读验证,不许凭返回码认定写入成功。ZEST 论文那次,补写中文标题的 SQL 因 shell 引号问题静默失败,agent 没回读就继续发布,中文站显示了英文标题、英文摘要,标签只剩占位符 {Twitter}。现在多引号嵌套的 UPDATE 一律写进 /tmp/fix.sql,用 docker cp + psql -f 执行;SQL 直改标题/摘要/标签之后还要跑 reindex_search.py --fix,否则检索层与展示层不一致。
长批次与后台收尾
yt-dlp 是真下载,6 条约 5-10 分钟,18 条约 30 分钟,而 SSH 通常 5-10 分钟就被服务器主动断开。docker exec 起的进程独立于 SSH 存活,所以 ≥4 条的批次用 setsid + < /dev/null 完全脱离终端启动,之后 sleep 60-180s 轮询进程与 DB 条目数。nohup ... & 不可靠:SSH 通道常在 nohup 写完 PID 前就断了,命令根本没执行。同一服务器上并发跑多个 ingest 会被主动断连,一律串行。
后台批次最容易漏的是收尾打卡——进程脱离了会话,轮询到 FINISHED 时注意力已经转到下一阶段,DB 里于是永久留一条 status='running',数字孪生办公室里那个员工从此一直在「干活」。线上实测攒出过三条。2026-09-08 起结构性修掉:ingest.py 自己打卡,用 try/finally 包住整批,正常结束、部分失败、中途崩溃三种情况都会 finish,agent 什么都不用补,也不要再手工 finish(会多插一行)。
阶段 6:发布不等于结束
每条发布成功的内容立刻补社交物料,不攒批:og:image 指向 404 时社媒抓取器永久放弃这张卡且不重试。顺序是 OG 卡(写)→ 重写稿 + 1080x1350 知识卡(创作)→ 英文帖子包(读)→ 目检。重写稿必须逐条创作,钩子、事实条、数字都来自真实采集到的内容,禁止脚本从标题+摘要批量套模板灌表。目检前先用 scripts/rw_tools.py small 把卡片压到长边 ≤1280px 且 ≤200KB,预算是编码后字节不是像素数。
诚实的边界
这是本站内部 skill,绑死生产容器里的 yt-dlp、ffmpeg、Postgres 和 bind-mount 的 data/static/img/,无法作为开源包安装,也没有一行 install 命令。可迁移的是一整套被线上事故打磨出来的工程约束:下载档位与发布档位分离、封面从视频中段取亮帧而不是信平台给的第一帧、任何写库动作都要回读验证、长任务用 setsid 脱离 SSH 并让脚本自己收尾打卡、以及已发布静态资源改内容必须换 URI(CDN 缓存键不含 query)。这几条在任何有 UGC 媒体的站点上都成立。