Files
content-harvester/CLAUDE.md
T
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

6.4 KiB

CLAUDE.md — content-harvester

内容采集服务(FastAPI + Playwright headed-Chrome/xvfb)。给一个来源页 URL,逐个打开内容详情页抓 标题 / 详情页URL / 封面图 / 媒体地址;若调用方下发了对象存储(MinIO/S3)配置,则把媒体(目前是 m3u8 视频)拉成 mp4 + 封面一起上传,video_url/cover_image 改成对象存储稳定地址(很多站点的媒体地址带 expires 签名、约 1h 失效)。以 resources 列表回传。给「启源 AI 平台」主站「多内容数据采集 / 来源=内容网站」任务(lobster 执行器)调用,主站把结果写进资源库。当前内置一个 xchina.co 的 scraper(core/scraper.py);加新来源就再加一个 scraper 模块。

前身是 Tkinter GUI 下载器,已删除。无单元测试。详细设计见 PRD-minio-upload.md。

结构

  • core/browser.py —— browser_context(headless) async ctxmgr,launch_persistent_context(资料目录 BROWSER_PROFILE_DIR)。HEADLESS=0 + BROWSER_CHANNEL(x86_64 可设 chrome)用真实有头浏览器过 Cloudflare;容器里靠 docker-entrypoint.sh 起 Xvfb。
  • core/scraper.py —— xchina 专属 scraper。XChinaScraper(context, is_cancelled, log),run(series_url,start,end) -> ScrapeResult;is_detail_url、_page_url_candidates、_collect_video_links(.item.video + 正则兜底)、scrape_detail(_extract_title / _find_cover_url(截屏tab→og:image/JSON-LD/poster→最大图)/ _find_video_url(扫 page.content() 的 .m3u8 + 网络嗅探))、_handle_interstitial_ad(广告中转页)、_assert_not_blocked(Cloudflare 质询页等其自动放行 ~24s 否则抛)。ScrapeCancelled 取消信号。加别的站就新建 core/scraper_<site>.py。
  • core/media.py —— MinioConfig(from_payload 从 invoke 载荷读 minio_endpoint/access_key/secret_key/bucket,任一缺则 is_complete=False;safe_repr 不露 secret)、download_video(yt-dlp 并发分片 + ffmpeg 合并 → <videoid>.mp4,max_mb 超限抛)、download_cover(requests 流式)、upload_to_minio(minio.Minio.fput_object → 返回 endpoint/bucket/key)、extract_video_id(从详情页 URL 的 id-(\w+)\.html)。
  • core/models.py —— ScrapedItem(source_url/title/source_name/cover_image/video_url/page_no/video_id/extra,to_ingest_dict() → source_type:"video"/content_type:"video"/extra_data:{referer,page_no,collector,video_id,**extra};extra 里放 original_m3u8/original_cover/minio_object_video/minio_object_cover/video_bytes/upload_skipped_reason)、ScrapeResult。
  • server/jobs.py —— 内存级 job 表 + asyncio.create_task 后台跑;_RUN_LOCK 串行化(单并发)。_run:① 浏览器开着 scraper.run() 抓元数据 → 关浏览器;② 若 job.minio_cfg.is_complete 则 _process_uploads 逐条 asyncio.to_thread 下载视频+封面、上传 MinIO、回写 URL(失败的条目 extra.upload_skipped_reason=... 降级保留原媒体地址);③ 临时目录 /tmp/content-harvester/<jobid>/ 用完即删。MAX_VIDEO_MB/CONCURRENT_FRAGMENTS env。不要把 secret_key 传进 _log。
  • server/app.py —— FastAPI。POST /agents/{id}/invoke(body=主站调用载荷:content_url/platform_name/source_kind/start_page/end_page + minio_*;MinioConfig.from_payload(payload) → 传给 jobs.submit;返回 {"task_id","status"})、GET /tasks/{id}、POST /tasks/{id}/cancel、GET /healthz。Bearer 鉴权 HARVESTER_TOKEN(空则不校验)、SSRF 白名单 ALLOWED_HOSTS(默认 xchina.co)。
  • Dockerfile —— base mcr.microsoft.com/playwright/python;apt 装 xvfb(有头)+ffmpeg(yt-dlp 合并);HEADLESS=0;挂卷 /data/browser_profile;ENTRYPOINT=docker-entrypoint.sh(起 Xvfb)→ uvicorn。x86_64 上可改 playwright install chromium chrome + BROWSER_CHANNEL=chrome。
  • requirements.txt —— playwright / fastapi / uvicorn / requests / yt-dlp / minio。

对接契约(主站 lobster 执行器)

主站 app/services/lobster_client.py::call_lobster_agent:POST {api_url}/agents/{agent_id}/invoke(body=payload)→ 取 task_id → 轮询 GET {api_url}/tasks/{task_id} 直到 status∈{succeeded,failed} → 用 body["output"]/body["error"]。本服务 output 成功时 = {"resources":[ResourceIngestItem字段...], "pages_scanned":n, "item_errors":[...]},每条 resources[i] 的 video_url/cover_image 若上传成功是对象存储地址(extra_data.original_m3u8 留原始签名URL),否则是原始来源地址(extra_data.upload_skipped_reason)。

主站侧配套改动(agent-system-backend/app/services/task_service.py):① 非批次 lobster 改 asyncio.create_task(_run_lobster_task_bg(...)) 后台跑;② _run_lobster_task_bg 成功后 _maybe_ingest_to_resource_pool;③ _maybe_ingest_to_resource_pool 兼容从 result["output"]["resources"] 取;④ _dispatch_lobster 把 _resolve_skill_variables 解出的 Skill 变量并入 payload(让远端拿到 minio_*);⑤ crawl 类用 CRAWL_LOBSTER_TIMEOUT(env,默认 7200s)。配置侧:给那个采集 Skill 绑定 minio_endpoint/minio_access_key/minio_secret_key/minio_bucket 4 个 Skill 变量(实际值由运维在主站配置,本服务不持有任何密钥)。

跑

python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt && python -m playwright install chromium
# 本地无 Xvfb 可设 HEADLESS=1(但那样过不了 Cloudflare 详情页);minio 配置走 payload 传
HEADLESS=1 uvicorn server.app:app --reload   # :8000

Docker:docker build -t content-harvester . && docker run -d -p 8000:8000 -v harvester_profile:/data/browser_profile -e HARVESTER_TOKEN=xxx --memory=2g --cpus=2 content-harvester

坑

  • 详情页被 Cloudflare 挡:必须有头浏览器(HEADLESS=0 + Xvfb,或真实 Chrome)才能自动过 JS 质询;无头/headless-shell 过不了。站点策略变了可能又挂 → 退路是预置一份过了验证的 browser_profile 卷。
  • 媒体地址(m3u8)常带 expires 签名(~1h 失效)→ 所以要在采集时就转 mp4 上传对象存储;不传的话资源库里那个 URL 一会儿就 403。
  • 下载+上传很慢:每条 15s 抓 + 几十秒几分钟下载 + 上传;一页 11 条估 30min~1h+。主站对 crawl lobster 任务给了 7200s 轮询超时。
  • 单并发是有意的;并发跑多个浏览器作业容易触发风控。
  • 站点结构变了改对应的 core/scraper*.py 的选择器/正则/JS 片段;媒体下载/上传逻辑改 core/media.py。
  • secret_key 等敏感值别打日志(_log / logger 都注意)。