FastAPI + Playwright(headed Chrome/xvfb 过 Cloudflare)抓内容详情页元数据; 可选把 m3u8 视频转 mp4 + 封面上传对象存储(配置由调用方下发,本服务不持密钥); 走主站 lobster 执行器契约(invoke/poll),回传 ResourceIngestItem 形式的 resources。 当前内置 xchina.co 的 scraper(core/scraper.py)。
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_FRAGMENTSenv。不要把 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—— basemcr.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 都注意)。