blog-collect:把一个外部博客 URL 变成长期托管的双语文章
local/blog-collect
本站博客采集线 v2.2.0,数字孪生办公室里的执行者是 blog-crawler。原文图片与视频全部下载落地,正文由 agent 逐篇完整翻译/改编(不是摘要),发布走 publish_blog.py → POST /api/blog/media + /api/blog/publish → Postgres articles 表,前端运行时经 /api/articles 取数。选题判据写死在 skill 里。
我们的判断把选题判据写进 skill 而不是靠人记,因为一条跑几个月的采集线最先漂移的就是选题。两条踩出来的硬规则值得抄:封面绝不能是 .mp4(cover_image 会被渲染成 img src,2026-08-25 两张卡片全黑的真实事故),以及 KaTeX 里的裸尖括号必须写成 HTML 实体,否则公式从那里被截断、后半篇静默消失且不报错。
这条线管的是什么
blog-collect 是本站的博客采集线,当前版本 v2.2.0,在数字孪生办公室里的执行者是 blog-crawler。它做的事只有一件:把一个外部博客 URL 变成一篇可以长期托管在我们自己存储上的双语文章。「长期托管」是重点——原文的图片与视频全部下载落地,正文由 agent 逐篇完整翻译/改编(不是摘要),发布后进 Postgres,前端运行时经 /api/articles 取数。
它刻意不是通用抓取器。内容定位写死在 skill 里:具身智能、机器人学习与操作导航、世界模型、VLA/VLM、第一人称数据、Sim-to-real、机器人数据集与硬件传感器、物理 AI 基础设施。纯 SaaS 营销、非机器人领域的 AI 应用、纯商业推广一律不收。把选题判据写进 skill 而不是靠人记,是因为一条跑几个月的采集线最容易漂移的就是选题。
五条不可协商的口径
| 口径 | 规定 | 为什么这么定 |
|---|---|---|
| 正文 | 中文是英文原文的完整翻译/改编,不是摘要 | 摘要型转载没有检索价值也没有留存价值,读者点进来发现少了三段就走了 |
| 商业推广 | 原文的「Work With Us」「联系销售」「定制服务」段落一律删除 | 我们不为别人导流,读者也不该在我们页面上看到与我们无关的销售话术 |
| 来源 | 每篇末尾标注原文 URL | 归属明确是这条线能长期跑下去的前提 |
| 媒体 | 图片/视频下载到 data/static/img/blog/<id>/,本地引用不外链 | 外链随原站改版失效;本地路径吃 Caddy 的 /static/img/* → max-age=86400,视频 Range 请求也走同一规则 |
| 封面 | 必须是 .jpg/.png,绝不能是 .mp4 | cover_image 被前端渲染成 <img src>,视频文件渲染不出来,封面直接碎 |
封面这条不是凭空写的。2026-08-25 采集 Figure AI Helix 02 与 Helix 折叠衣物两篇时踩的就是它:原文页头是视频,把 .mp4 塞进 cover_image,列表页两张卡片全黑。现在的处置是原文只有视频时用 ffmpeg 从视频里提一帧当封面,提完还要过一遍目检(scripts/rw_tools.py small 降采样后再看,绝不用原图喂视觉模型)。
规格闸门:视频 ≤720p,图片 ≤1280px
原文视频常是 4K,直接入库既拖慢播放又浪费带宽。skill 的口径是「压上限,不压质量」:优先选长边 ≤1280px 的源,源只有更高分辨率时在服务器上用 ffmpeg 转码,源本身低于 720p 就保持原样,不主动选低清。图片同理,长边超 1280px 先用 Pillow 等比缩小(保留格式与透明通道)再入库——大图会撑爆后续 LLM 审核环节的视觉上下文,图越大 token 越多。
# 手动补的视频先探分辨率,长边 >1280 就转码(nice 限速,别把生产机 CPU 打满)
ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 in.mp4
nice -n 19 ffmpeg -i in.mp4 -vf "scale='min(1280,iw)':'min(1280,ih)':force_original_aspect_ratio=decrease" \
-c:v libx264 -preset veryfast -crf 20 -c:a aac -b:a 128k -movflags +faststart out.mp4
这里有个反直觉的点:scale 必须写成 min(BOX,iw) 语义,写成 scale=1280:1280 是无条件插值,会把小图放大——线上真发生过 712x294 的演示 GIF 被放成 1400x578,画质更糊还多送十几 MB。
公式里的裸 < 会吃掉半篇文章
正文以预渲染 HTML 存进 body_zh/body_en,前端用 dangerouslySetInnerHTML 注入,再由 KaTeX/Mermaid 渲染。这意味着 KaTeX 里的 <、>、& 必须写成 HTML 实体:z_{<t} 要写作 z_{{<t},否则浏览器把裸 < 当成标签起始,公式从那里被截断,后面整段消失且不报错。\leq、\geq 这类不含裸尖括号的命令不用管。同一个坑在论文线更严重,所以两条线共用这条规则。
发布链路:DB + API,不是 SSH 传文件
2026-09-01 起博客发布走 scripts/publish_blog.py → POST /api/blog/media + POST /api/blog/publish → Postgres articles 表(含检索向量重嵌)。服务端会强制图片长边 ≤1280px,写完立即回读验证,任一步失败非零退出并打印服务端错误;重复执行安全(整体 upsert + 媒体同名覆盖)。/api/* 全局 Cache-Control: no-store,国内 CDN 不缓存,发布即对所有用户生效。
export AGENTS_API_KEY="$(ssh -p 2222 root@43.162.123.152 \
'docker exec robotinfo-api sh -c "echo \$AGENTS_API_KEY"')"
python3 scripts/publish_blog.py <article-id>
python3 scripts/publish_blog.py <article-id> --skip-media # 只更新正文/元数据
旧的「SSH 传文件 → 服务器 cp → docker exec seed_blog.py」链路已废(无事务、无校验、易漂移),只作离线兜底保留。前端列表页与详情页都是 force-dynamic,纯新增文章不需要重建前端;只有改 frontend/ 代码才等 CI 出镜像再 docker compose pull && up -d,服务器上永远不 build。
发布不是终点:缩略图、卡片、打卡
页面重量的 99% 在图上(实测单详情页图片负载中位 2.64MB、最大 19.89MB,正文 HTML 只有 9KB),所以步骤 10.5 必须跑两档 webp:build_card_thumbs.py --only articles(列表卡 800px/q78)与 build_body_thumbs.py --only articles(正文图与 hero 1400px/q80)。两个脚本幂等增量、在 api 容器内跑、不重启容器也不发代码。webp 只是派生兄弟文件,原图一个字节不动,点击放大由前端换回原图。
步骤 11 补社交物料:POST /api/social/card?kind=article&id=<id>&both_langs=true 生成中英两张 1200x630 OG 卡,再写一条 agent 逐条撰写的社交重写稿(顺带渲 1080x1350 竖版知识卡),最后取六平台英文帖子包。OG 卡不能攒着后补——og:image 指向 404 时社媒抓取器会永久放弃这张卡且不重试。整个任务在开始与结束时用 scripts/agent_report.py start/finish blog-crawler --task-key blog:<slug> 打卡,成功失败都要打,失败会在 /agentsview 显示红色。
能抄走什么,不能抄走什么
诚实地说:这是本站的内部 skill,绑定了我们的目录约定、API 端点、Caddy 缓存规则与容器名,装不到别人的 agent 上直接用,也没有对外的一行安装命令。值得抄走的是四条方法论——完整翻译而非摘要、媒体一律本地化、封面必须是图片且要目检、发布走 DB+API 而不是文件同步。这四条与具体站点无关,任何做双语内容站的 agent 都能照搬。