Files
content-harvester/PRD-minio-upload.md
huyue 497fae99c6 init: content-harvester —— 内容采集服务
FastAPI + Playwright(headed Chrome/xvfb 过 Cloudflare)抓内容详情页元数据;
可选把 m3u8 视频转 mp4 + 封面上传对象存储(配置由调用方下发,本服务不持密钥);
走主站 lobster 执行器契约(invoke/poll),回传 ResourceIngestItem 形式的 resources。
当前内置 xchina.co 的 scraper(core/scraper.py)。
2026-05-12 16:20:39 +08:00

21 KiB
Raw Permalink Blame History

PRD —— 采集服务下载视频并上传 MinIO,资源库存 MinIO 地址

状态:已实现 · 范围:本工程(content-harvester) + 主站 agent-system-backend 少量改动 · 实现说明见 CLAUDE.md

1. 背景

现状:主站「多内容数据采集 / 来源=内容网站」任务 → 走 lobster 执行器调本采集服务 → 服务用无头 Chrome(xvfb)抓 xchina 详情页,回传每条视频的 {title, source_url(详情页), cover_image(图床URL), video_url(m3u8 签名URL)} → 主站 _maybe_ingest_to_resource_pool 写入 ResourcePool。

问题:video_url 现在存的是 xchina CDN 上带 expires + md5 的限时签名 m3u8 地址,大约 1 小时后 xchina 自己返回 403。资源库里的视频地址会失效,下游(转码 / 搬运 / 发布到 smnqae)无法稳定使用。

目标:采集时就把 m3u8 拉成 mp4、连同封面图一起传到主站现有的 MinIO(<bucket> 桶),资源库 video_url / cover_image 改存 MinIO 上的稳定地址。MinIO 配置不在采集服务里硬编码,而是由主站通过 lobster payload 下发(复用主站已有的 minio_* Skill 变量),采集服务保持"不持有密钥"。

2. 范围

In scope

  • 采集服务:m3u8 → mp4 下载(yt-dlp 并发分片 + ffmpeg 合并)、封面图下载、上传 MinIO、video_url/cover_image 改为 MinIO 地址。
  • 采集服务:从 lobster payload 读取 MinIO 配置(minio_endpoint/minio_access_key/minio_secret_key/minio_bucket);缺失时降级(见 §9)。
  • 主站:_dispatch_lobster 把解析后的 Skill 变量并入发给采集服务的 payload(目前只有 script 执行器这么做);把 crawl 类 lobster 任务的轮询超时调到足够长(下载转码耗时)。
  • 配置:给 xchina 那个 Skill 绑定 4 个 minio_* Skill 变量(运维操作,非代码)。

Out of scope

  • 不改主站 ResourcePool 表结构、不改 ResourceIngestItem 字段(仍是 video_url/cover_image 两个字符串,只是值变了)。
  • 不在主站后端加 ffmpeg/yt-dlp/minio(媒体重活全在采集服务)。
  • 不做断点续传、不做 MinIO 去重(同 source_url 重复采集会覆盖同名对象,可接受)。
  • 不动 OCR / 人工复核 / 发布流程。

3. 参与系统

系统 角色
主站后端 agent-system-backend 创建任务、派发 lobster 调用、轮询、把回传 resources 入库;持有 MinIO 凭据(作为 Skill 变量)
本采集服务 content-harvester 抓 xchina 元数据 + 下载 m3u8→mp4 + 下载封面 + 上传 MinIO + 回传 resources
xchina.co 数据源(列表页开放,详情页 Cloudflare JS 质询,媒体 m3u8 限时签名)
对象存储(MinIO/S3) 视频/封面对象存储,产出稳定可访问 URL;endpoint/bucket/凭据由主站作为 Skill 变量提供

4. 端到端流程

用户在主站创建任务(选 xchina Skill,来源大类=内容网站,填系列页/详情页 URL)
        │  task.input_params = { source_kind:"website", content_url:"...", platform_name?, start_page?, end_page?, _crawl_config:{...} }
        ▼
主站 create() → 后台 _run_lobster_task_bg → _dispatch_lobster
        │  ① 解析该 Skill 绑定的 Skill 变量(含 minio_endpoint/access_key/secret_key/bucket)→ var_env
        │  ② payload = { ...task.input_params, ...var_env }      (Skill 变量同名覆盖 input_params,与 script 执行器一致)
        │  ③ POST {lobster_api_url}/agents/{agent_id}/invoke   body=payload   Authorization: Bearer {lobster_api_key}
        ▼
采集服务 /agents/{id}/invoke
        │  校验 token、source_kind=website、content_url host 白名单
        │  从 payload 取 minio 配置(4 个 key) + start_page/end_page/platform_name
        │  生成 job_id,后台 asyncio 任务开跑,立即返回 {"task_id": job_id, "status":"running"}
        ▼
后台采集作业(单并发串行)
   for 每页(start..end):
     打开列表页(headed Chrome/xvfb,过 Cloudflare)→ 取 .item.video 卡片里的详情链接
     for 每个详情链接:
        打开详情页(过 Cloudflare、处理广告中转页)
        抓 title / cover_image(图床URL) / video_url(m3u8 签名URL,带 referer)
        ┌─ 若拿到 minio 配置且拿到 m3u8:
        │    yt-dlp(并发分片, 带 Referer 头)下载 → ffmpeg 合并 → /tmp/<jobid>/<videoid>.mp4
        │    minio.fput_object(bucket, "xchina/<videoid>.mp4", mp4, content_type="video/mp4")
        │    requests 下载封面图 → minio.fput_object(bucket, "xchina/<videoid>.jpg", img, content_type=...)
        │    video_url = "{endpoint}/{bucket}/xchina/<videoid>.mp4"
        │    cover_image = "{endpoint}/{bucket}/xchina/<videoid>.jpg"
        │    extra_data += { original_m3u8: <原签名URL>, original_cover: <原图床URL>, minio_object_video, minio_object_cover, video_bytes }
        │    删 /tmp/<jobid>/<videoid>.mp4
        └─ 否则(无 minio 配置 / 下载失败 / 超大):降级 —— 保留原 m3u8 / 原图床 URL,extra_data += { upload_skipped_reason: "..." }
        累积一条 ScrapedItem
   作业结束:job.status = succeeded(只要至少抓到 1 条)/ failed(0 条且有错误)
        ▼
主站轮询 GET {lobster_api_url}/tasks/{job_id}  直到 status ∈ {succeeded, failed}
        │  succeeded → body.output = { resources:[...], pages_scanned, item_errors }
        ▼
主站 _dispatch_lobster 写回 task.result(={success,output,error,duration_ms});task.status=SUCCEEDED
        │  _run_lobster_task_bg 再调 _maybe_ingest_to_resource_pool(task)
        │    取 task.result.output.resources(已兼容)→ 逐条 ResourceIngestItem.model_validate
        │    组 ResourceIngestRequest(task_id/skill_id/created_by/product_id + _crawl_config 里的 dedupe_strategy/default_status/visibility)
        │    ResourcePoolService.ingest_resources() → 写 ResourcePool 表(每条 video_url/cover_image 已是 MinIO 地址)
        ▼
完成。资源库列表里出现这些视频,video_url 指向 MinIO,长期有效。

5. 数据流转(字段级)

5.1 主站 → 采集服务:POST /agents/{agent_id}/invoke 请求体(= 合并后的 payload)

字段 来源 说明
source_kind 任务表单(CrawlerTaskBlock) 必须 "website",否则 400
content_url 任务表单「地址」 系列页或单个详情页 URL;host 必须在采集服务白名单(默认 xchina.co)
platform_name 任务表单 可选 → 作为 source_name,默认 "xchina"
start_page / end_page 任务表单(可选,Skill input_schema 里声明) 默认 1 / start_page —— 即只爬给定 URL 这一页/这一条
_crawl_config 主站任务创建页注入 {dedupe_strategy, default_status, visibility},采集服务不用,原样回传给主站也行;实际由主站 ingest 用
minio_endpoint Skill 变量(主站 _dispatch_lobster 注入) 形如 <minio-endpoint>
minio_access_key / minio_secret_key Skill 变量 MinIO 鉴权
minio_bucket Skill 变量 形如 <bucket>

注:Skill 变量同名覆盖 input_params(与 script 执行器 _resolve_skill_variables 后的合并优先级一致)。minio_secret_key 是敏感值,采集服务日志里不打印。

5.2 采集服务内部:ScrapedItem(每条视频)

字段 上传前 上传后
source_url xchina 详情页 URL(不变) 同
title 抓到的标题 同
source_name platform_name 或 "xchina" 同
cover_image xchina 图床 URL(img.xchina.io/...) {endpoint}/{bucket}/xchina/<videoid>.jpg(上传成功时)
video_url xchina m3u8 签名 URL {endpoint}/{bucket}/xchina/<videoid>.mp4(上传成功时)
video_id 从 source_url 的 id-(\w+)\.html 提取 同;作 MinIO object key 前缀
page_no 列表页页码 同

5.3 采集服务 → 主站:GET /tasks/{job_id} 响应里的 output

{
  "resources": [
    {
      "source_type": "video",
      "source_name": "xchina",
      "source_url": "https://xchina.co/video/id-XXXXXXXXXXXXX.html",
      "content_type": "video",
      "title": "0145 依依",
      "cover_image": "<minio-endpoint>/<bucket>/xchina/XXXXXXXXXXXXX.jpg",
      "video_url":   "<minio-endpoint>/<bucket>/xchina/XXXXXXXXXXXXX.mp4",
      "extra_data": {
        "referer": "https://xchina.co/video/id-XXXXXXXXXXXXX.html",
        "page_no": 1,
        "collector": "xchina",
        "original_m3u8": "https://video.xchina.download/m3u8/XXXXXXXXXXXXX/720.m3u8?expires=...&md5=...",
        "original_cover": "https://img.xchina.io/photos/.../00060.jpg",
        "minio_object_video": "xchina/XXXXXXXXXXXXX.mp4",
        "minio_object_cover": "xchina/XXXXXXXXXXXXX.jpg",
        "video_bytes": 41234567
      }
    }
    // ...
  ],
  "pages_scanned": 1,
  "item_errors": [ { "source_url": "...", "reason": "..." } ]
}

每条 resources[i] 的顶层字段就是主站 ResourceIngestItem 的字段集,主站可直接 model_validate。

5.4 主站入库:ResourcePool 行(节选)

source_type=video, content_type=video, source_url(去重键), title, cover_image(MinIO URL), video_url(MinIO URL), extra_data(含 original_m3u8 等), task_id/skill_id/created_by/product_id(主站补), status= _crawl_config.default_status(ready/pending), visibility= _crawl_config.visibility(普通用户强制 private)。

6. 各步骤逻辑

6.1 主站 —— Skill 配置(运维一次性)

  • 「Skill 管理」里那个采集 Skill(executor_type=LOBSTER、skill_category=CRAWL、lobster_api_url 指向采集服务):在变量关联里勾上 minio_endpoint、minio_access_key、minio_secret_key、minio_bucket 这 4 个 Skill 变量(它们的实际值由运维在主站配置,本服务不持有)。input_schema 仍需含 source_kind 字段(前端才渲染采集表单),并声明 content_url(必填),可选 start_page/end_page。

6.2 主站 —— _dispatch_lobster 注入 Skill 变量(代码,~5–8 行)

  • 在 call_lobster_agent 之前:var_env, var_files_dir = await self._resolve_skill_variables(skill, task);payload = {**(task.input_params or {}), **var_env};把 payload 传给 call_lobster_agent(原来传 task.input_params)。var_files_dir 若产生则 finally 里 shutil.rmtree(本场景 minio 变量是 TEXT,通常不产生 FILE 目录)。
  • crawl 类任务轮询超时:timeout_sec = CRAWL_LOBSTER_TIMEOUT(默认 7200) if skill.skill_category == "crawl" else 30(下载转码 + 上传可能几十分钟到小时级;call_lobster_agent 在 asyncio.create_task 后台跑,长时持有连接可接受)。

6.3 采集服务 —— /agents/{id}/invoke

  • 鉴权(Bearer == HARVESTER_TOKEN,未配则不校验)。
  • 校验 source_kind(若有)== website;content_url 非空且 host 在 ALLOWED_HOSTS。
  • 从 payload 取 MinIO 配置:minio_endpoint/minio_access_key/minio_secret_key/minio_bucket 四者齐全才启用上传;任一缺失 → 本次作业不上传(降级)。也支持从采集服务自身 env 兜底(可选,默认不配)。
  • 取 start_page/end_page(默认 1..1,上限 MAX_PAGES)、source_name=platform_name or "xchina"。
  • jobs.submit(...) → 后台跑;立即返回 {"task_id": job_id}。

6.4 采集服务 —— 抓取(不变,已实现)

  • headed Chromium + Xvfb 过 Cloudflare(质询页等其自动放行,最多 ~24s);列表页取 .item.video 详情链接;详情页处理广告中转页;抓 title / cover(截屏tab图 → og:image/JSON-LD thumbnail/<video poster> → 页面最大图,排除 logo/占位)/ video(扫 page.content() 的 .m3u8/.mp4 + 网络响应嗅探)。

6.5 采集服务 —— 下载 + 上传(新增)

  • video_id = re.search(r"/video/id-([A-Za-z0-9_-]+)\.html", source_url);取不到则用 url hash 兜底。
  • 下载视频:m3u8 → 用 yt-dlp Python API(format 默认、concurrent_fragments=N(env CONCURRENT_FRAGMENTS,默认 8)、http_headers={"Referer": source_url, "User-Agent": ...}、outtmpl=/tmp/<jobid>/<videoid>.%(ext)s)→ 落 mp4(yt-dlp 内部调 ffmpeg 合并)。直链 mp4 则直接流式下载。
    • 大小保护:env MAX_VIDEO_MB(默认 0=不限);超过则跳过上传、降级保留 m3u8,记 extra_data.upload_skipped_reason="video too large: Xmb"。
  • 下载封面:requests.get(cover_image, headers={Referer:..., UA:...}, stream=True) → 落 /tmp/<jobid>/<videoid>.<ext>(从 URL 后缀猜,默认 .jpg)。失败不致命:cover_image 保留原图床 URL。
  • 上传 MinIO:Minio(host, access_key, secret_key, secure=endpoint以https开头).fput_object(bucket, "xchina/<videoid>.mp4", path, content_type="video/mp4");封面同理 content_type 按扩展名。公开 URL = endpoint.rstrip('/') + '/' + bucket + '/' + object_key(与脚本侧 upload_file_to_minio 的 public_base/relative_path 拼法一致)。
  • 回写:video_url/cover_image 改 MinIO URL;extra_data 加 original_m3u8 / original_cover / minio_object_video / minio_object_cover / video_bytes。
  • 清理:无论成败,作业结束(或每条处理完)删 /tmp/<jobid>/。采集服务容器不长期落盘。

6.6 采集服务 —— 组装回传 & 主站入库(不变,已实现)

  • output = {resources:[ScrapedItem.to_ingest_dict()...], pages_scanned, item_errors};job.status succeeded/failed。
  • 主站 _run_lobster_task_bg 成功后 _maybe_ingest_to_resource_pool(已兼容 result.output.resources)→ ResourcePoolService.ingest_resources 写库。

7. 接口契约(汇总)

接口 方向 关键约定
POST /agents/{agent_id}/invoke 主站→采集 body=合并后 payload(§5.1);返回 {"task_id": str, "status":"running"};鉴权 Authorization: Bearer <token>
GET /tasks/{task_id} 主站→采集 `{"status":"running"
POST /tasks/{task_id}/cancel 主站→采集 标记取消(仅 running)
GET /healthz 任意 健康检查

与主站 app/services/lobster_client.py::call_lobster_agent 完全对齐(POST /agents/{id}/invoke 取 task_id → 轮询 /tasks/{id} 直到 status∈{succeeded,failed} → 用 output/error)。

8. MinIO 对象命名约定

  • 桶:由 payload minio_bucket 指定(默认 <bucket>)。
  • object key:xchina/<videoid>.mp4、xchina/<videoid>.jpg(或封面原扩展名)。<videoid> = xchina 详情页 URL 里的 id 段,稳定且唯一。
  • 同一视频重复采集 → 覆盖同名对象(幂等;不另起版本)。
  • 公开 URL:<minio_endpoint>/<bucket>/<key>(假定该桶对象可匿名 GET —— 与现有 clawler 脚本写入的对象同桶同策略)。

9. 异常与降级

情况 行为
payload 里 MinIO 4 个 key 不齐 本次作业整体不上传;video_url 保留原 m3u8,extra_data.upload_skipped_reason="minio config absent";作业仍可 succeeded
某条详情页被 Cloudflare 卡住(等不出来) 该条进 item_errors,不计入 resources;不影响其它条
m3u8 下载/合并失败 该条降级:video_url 保留原 m3u8,extra_data.upload_skipped_reason="download failed: ...";仍作为一条 resource 回传
视频超过 MAX_VIDEO_MB 同上,reason="video too large"
封面下载失败 cover_image 保留原图床 URL;不致命
MinIO 上传失败(网络/鉴权/桶不存在) 该条降级保留原 URL,reason="minio upload failed: ...";连续多条失败可考虑整作业 failed(可选,先不做)
整作业:0 条 resources 且有 item_errors job.status="failed",error 汇总前几条原因
主站轮询超时(超过 CRAWL_LOBSTER_TIMEOUT) 主站 LobsterResult(success=False, error="polling timeout") → task FAILED;采集服务那边作业可能还在跑(无害,结果丢弃);可调大超时

降级策略的意义:即使 MinIO 这条链路出问题,也不要让整个采集白跑 —— 至少把元数据 + 原始(限时)URL 入库,人工/后续可补救。

10. 配置项

采集服务(env)

变量 默认 说明
HARVESTER_TOKEN 空 Bearer token(= 主站 Skill 的 lobster_api_key)
HEADLESS 0 0=有头(容器内靠 Xvfb);过 Cloudflare 必须
BROWSER_CHANNEL 空 x86_64 服务器可设 chrome 用真实 Chrome(arm64 无 Chrome 包)
BROWSER_PROFILE_DIR /data/browser_profile 持久浏览器资料目录(挂卷)
ALLOWED_HOSTS xchina.co content_url host 白名单(SSRF)
MAX_PAGES 50 单作业最多翻页数
CONCURRENT_FRAGMENTS 8 yt-dlp 并发分片数
MAX_VIDEO_MB 0 单视频大小上限(0=不限),超过则跳过上传降级
TMP_DIR /tmp 临时下载目录(用完即删)
MINIO_ENDPOINT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY / MINIO_BUCKET 空 仅作 payload 缺失时的兜底;生产默认不配,由主站下发

主站(已有 Skill 变量,运维勾选关联即可)

minio_endpoint、minio_bucket、minio_access_key、minio_secret_key(实际值由运维在主站配置,本仓库不含)。

主站(新增 env / 常量,可选)

CRAWL_LOBSTER_TIMEOUT(默认 7200s):crawl 类 lobster 任务的轮询超时。

11. 性能 / 时延

  • 每条:抓元数据 15s + 下载 m3u8→mp4(随视频大小,几十秒几分钟)+ 上传 MinIO(几秒~几十秒)。一页 11 条估计 30 分钟 ~ 1 小时+。
  • 单并发(进程内串行),避免 xchina 风控;多页/大量视频会很慢,属预期。
  • 主站侧靠 asyncio.create_task 后台跑 + 轮询,不阻塞任何用户请求。

12. 安全

  • minio_secret_key 等敏感值:采集服务不写日志(log_tail / logger 都过滤);只在内存里用。
  • SSRF:content_url host 白名单;封面图下载也只允许 xchina/img.xchina.io 域(或宽松些但限制 scheme=https)。
  • token 鉴权:/agents/* /tasks/* 都校验 Bearer。
  • 采集服务容器不长期落盘媒体文件(临时目录用完即删);浏览器 profile 卷里可能含 Cloudflare cookie,按敏感卷管理。

13. 改动清单(文件级)

采集服务(本工程)

  • requirements.txt:加 yt-dlp、minio、requests。
  • Dockerfile:apt-get install -y ffmpeg(与 xvfb 一起);其余不变。
  • core/media.py(新):download_video(m3u8_or_mp4_url, referer, dest_dir, video_id, max_mb, concurrent_fragments) -> Path、download_cover(url, referer, dest_dir, video_id) -> Path | None、upload_to_minio(local_path, object_key, content_type, cfg) -> public_url、build_public_url(endpoint, bucket, key)、MinioConfig dataclass + from_payload(payload) / is_complete。
  • core/models.py:ScrapedItem 加 video_id 字段;to_ingest_dict 的 extra_data 支持附加 original_m3u8/original_cover/minio_object_*/video_bytes/upload_skipped_reason。
  • server/jobs.py:Job 带 minio_cfg;_run 里每条 scrape_detail 后,若 minio_cfg.is_complete 则 download+upload+回写,失败降级;作业结束清临时目录;敏感值不入 log_lines。
  • server/app.py:invoke 从 payload 解析 MinioConfig.from_payload(payload),传给 jobs.submit。
  • README.md / CLAUDE.md:更新(新增依赖、env、行为、对接说明)。

主站 agent-system-backend

  • app/services/task_service.py::_dispatch_lobster:① var_env = await self._resolve_skill_variables(skill, task) 后 payload = {**(task.input_params or {}), **var_env} 传给 call_lobster_agent;② crawl 类用 CRAWL_LOBSTER_TIMEOUT(默认 7200)。(_run_lobster_task_bg 成功后调 _maybe_ingest_to_resource_pool 与 _maybe_ingest_to_resource_pool 读 output.resources 已在前面做过。)
  • app/config.py(可选):加 CRAWL_LOBSTER_TIMEOUT。

配置(非代码)

  • 主站「Skill 管理」给 xchina Skill 绑定 4 个 minio_* Skill 变量。
  • 采集服务部署到 x86_64 时:Dockerfile 改 playwright install chromium chrome + 设 BROWSER_CHANNEL=chrome(更稳过 Cloudflare)。

14. 验收

采集服务本地(docker):

  1. docker build + docker run(HEADLESS=0),/healthz 返回 {"ok":true,"headless":false}。
  2. curl -X POST /agents/xchina/invoke -d '{"source_kind":"website","content_url":"<xchina系列页URL>","minio_endpoint":"<minio-endpoint>","minio_access_key":"...","minio_secret_key":"...","minio_bucket":"<bucket>"}' → 拿 task_id。
  3. 轮询 /tasks/{id} 到 succeeded:output.resources 每条 video_url 形如 <minio-endpoint>/<bucket>/xchina/<id>.mp4,cover_image 形如 .../xchina/<id>.jpg,extra_data 含 original_m3u8、minio_object_video、video_bytes。
  4. curl -I <那个 mp4 URL> 返回 200(对象确实上传成功且可访问);随便取一个验证能播放。
  5. 不带 minio 字段再跑一次 → video_url 仍是 m3u8,extra_data.upload_skipped_reason="minio config absent",作业仍 succeeded。
  6. 负向:错误 token → 401;source_kind:"platform" → 400;非白名单 host → 400。

主站联调(dev 环境): 7. 给 xchina Skill 绑定 4 个 minio 变量;创建任务(选该 Skill、来源大类=内容网站、地址填 xchina 系列页、入库后状态=ready)。 8. 等任务 SUCCEEDED;GET /resource-pool 出现这些视频,详情里 video_url 是 MinIO 地址、cover_image 是 MinIO 地址、extra_data 含 original_m3u8、task_id/skill_id/created_by 正确、status=ready。 9. curl -I 资源里的 video_url → 200,且过 1 小时后再 curl -I 仍 200(证明不再依赖 xchina 限时签名)。 10. 同 URL 再跑一次,dedupe_strategy=skip 时资源库不重复入库;MinIO 上同名对象被覆盖。 11. UI/e2e 步骤丢给子 agent 跑,主线程只收结论摘要。