# 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(`` 桶),资源库 `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//.mp4 │ minio.fput_object(bucket, "xchina/.mp4", mp4, content_type="video/mp4") │ requests 下载封面图 → minio.fput_object(bucket, "xchina/.jpg", img, content_type=...) │ video_url = "{endpoint}/{bucket}/xchina/.mp4" │ cover_image = "{endpoint}/{bucket}/xchina/.jpg" │ extra_data += { original_m3u8: <原签名URL>, original_cover: <原图床URL>, minio_object_video, minio_object_cover, video_bytes } │ 删 /tmp//.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_access_key` / `minio_secret_key` | Skill 变量 | MinIO 鉴权 | | `minio_bucket` | Skill 变量 | 形如 `` | > 注: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/.jpg`(上传成功时) | | `video_url` | xchina m3u8 签名 URL | `{endpoint}/{bucket}/xchina/.mp4`(上传成功时) | | `video_id` | 从 `source_url` 的 `id-(\w+)\.html` 提取 | 同;作 MinIO object key 前缀 | | `page_no` | 列表页页码 | 同 | ### 5.3 采集服务 → 主站:`GET /tasks/{job_id}` 响应里的 `output` ```jsonc { "resources": [ { "source_type": "video", "source_name": "xchina", "source_url": "https://xchina.co/video/id-XXXXXXXXXXXXX.html", "content_type": "video", "title": "0145 依依", "cover_image": "//xchina/XXXXXXXXXXXXX.jpg", "video_url": "//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/