新增图集 scraper(core/scraper_photos.py,继承 XChinaScraper 复用导航/反爬 helper):
系列页 .item.photo 翻页 → 详情页解析图片 URL 模板 + 总张数 + 模特/系列/日期/标签 →
construct-or-paginate 收集 images[](默认每套图最多 max_images=100 张)。
- ScrapedItem 加 media_kind(video|photo)/images/image_total/image_capped;to_ingest_dict 按
media_kind 分流(photo → source_type=image / content_type=article / images[] / video_url=None)
- media.py 加 download_image / head_ok;extract_video_id 兼容 /photo/id-...(=extract_detail_id)
- jobs.py _process_uploads 按 media_kind 分流(视频:yt-dlp/ffmpeg;图集:逐张下载上传,边下边传边删)
- app.py /agents/{id}/invoke 按 content_url 路径 / agent_id / 载荷 media_kind 自动选 scraper;
新增 max_images 参数(默认 DEFAULT_MAX_IMAGES=100,上限 MAX_IMAGES_CAP=2000)
- 主站不用改:复用现有采集 Skill,harvester 自动判图集/视频。图集回传 images[] 后主站自动跑图片 OCR 审核
- 已在 dev 实测:单图集详情页 / 系列页 / 视频回归 均通过
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
5.6 KiB
Python
145 lines
5.6 KiB
Python
"""content-harvester —— 内容采集服务,实现主站 lobster 执行器的调用契约。
|
|
|
|
当前内置一个 xchina.co 的 scraper(core/scraper.py),以后可加更多来源。
|
|
对接 app/services/lobster_client.py::call_lobster_agent:
|
|
POST /agents/{agent_id}/invoke body = 主站合并后的 payload, 返回 {"task_id": ...}
|
|
GET /tasks/{task_id} 返回 {"status": running|succeeded|failed, "output": ..., "error": ...}
|
|
|
|
鉴权: 主站带 `Authorization: Bearer <lobster_api_key>`,需与本服务 env HARVESTER_TOKEN 一致。
|
|
SSRF 防护: 只接受 ALLOWED_HOSTS(默认 xchina.co 及其子域) 的 content_url。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import FastAPI, Header, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from core.media import MinioConfig
|
|
from server import jobs
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
logger = logging.getLogger("harvester.app")
|
|
|
|
SERVICE_TOKEN = os.environ.get("HARVESTER_TOKEN", "")
|
|
HEADLESS = os.environ.get("HEADLESS", "1") not in ("0", "false", "False")
|
|
ALLOWED_HOSTS = tuple(
|
|
h.strip().lower() for h in os.environ.get("ALLOWED_HOSTS", "xchina.co").split(",") if h.strip()
|
|
)
|
|
MAX_PAGES = int(os.environ.get("MAX_PAGES", "50"))
|
|
DEFAULT_MAX_IMAGES = int(os.environ.get("DEFAULT_MAX_IMAGES", "100"))
|
|
MAX_IMAGES_CAP = int(os.environ.get("MAX_IMAGES_CAP", "2000"))
|
|
|
|
app = FastAPI(title="content-harvester", version="1.0")
|
|
|
|
|
|
def _check_auth(authorization: str | None) -> None:
|
|
if not SERVICE_TOKEN:
|
|
# 未配置 token 时不鉴权(仅适合内网/本地);生产务必设 HARVESTER_TOKEN
|
|
return
|
|
expected = f"Bearer {SERVICE_TOKEN}"
|
|
if authorization != expected:
|
|
raise HTTPException(status_code=401, detail="invalid token")
|
|
|
|
|
|
def _host_allowed(url: str) -> bool:
|
|
try:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
except Exception:
|
|
return False
|
|
if not host:
|
|
return False
|
|
return any(host == h or host.endswith("." + h) for h in ALLOWED_HOSTS)
|
|
|
|
|
|
class TaskStatusResponse(BaseModel):
|
|
status: str
|
|
output: dict | None = None
|
|
error: str | None = None
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"ok": True, "headless": HEADLESS, "allowed_hosts": list(ALLOWED_HOSTS)}
|
|
|
|
|
|
@app.post("/agents/{agent_id}/invoke")
|
|
async def invoke(agent_id: str, payload: dict, authorization: str | None = Header(default=None)):
|
|
_check_auth(authorization)
|
|
|
|
# payload 即主站 task.input_params:含 content_url / platform_name / source_kind / start_page / end_page / _crawl_config ...
|
|
source_kind = payload.get("source_kind")
|
|
if source_kind is not None and str(source_kind) != "website":
|
|
raise HTTPException(status_code=400, detail=f"unsupported source_kind: {source_kind}(本服务只处理 website)")
|
|
|
|
content_url = (payload.get("content_url") or payload.get("series_url") or payload.get("url") or "").strip()
|
|
if not content_url:
|
|
raise HTTPException(status_code=400, detail="missing content_url")
|
|
if not _host_allowed(content_url):
|
|
raise HTTPException(status_code=400, detail=f"host not allowed: {content_url}")
|
|
|
|
def _as_int(v, default):
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
start_page = max(1, _as_int(payload.get("start_page"), 1))
|
|
end_page = max(start_page, _as_int(payload.get("end_page"), start_page))
|
|
if end_page - start_page + 1 > MAX_PAGES:
|
|
end_page = start_page + MAX_PAGES - 1
|
|
|
|
source_name = str(payload.get("platform_name") or "xchina")[:100]
|
|
|
|
# 媒体类型:可由 payload.media_kind 显式指定(video/photo);否则按 content_url 自动判断;
|
|
# 再否则按 agent_id 兜底(含 photo → photo)。
|
|
media_kind = str(payload.get("media_kind") or "").strip().lower()
|
|
if media_kind not in ("video", "photo"):
|
|
path_low = urlparse(content_url).path.lower()
|
|
if "/photos/" in path_low or "/photo/id-" in path_low:
|
|
media_kind = "photo"
|
|
elif "/videos/" in path_low or "/video/id-" in path_low:
|
|
media_kind = "video"
|
|
elif "photo" in agent_id.lower():
|
|
media_kind = "photo"
|
|
else:
|
|
media_kind = "auto"
|
|
# 图集每个套图最多抓多少张
|
|
max_images = _as_int(payload.get("max_images"), DEFAULT_MAX_IMAGES)
|
|
max_images = max(1, min(max_images, MAX_IMAGES_CAP))
|
|
|
|
# MinIO 配置由主站经 lobster payload 下发(主站把绑定的 minio_* Skill 变量并入 payload);
|
|
# 缺失则降级:不上传,媒体地址保留原始来源地址
|
|
minio_cfg = MinioConfig.from_payload(payload)
|
|
|
|
job = jobs.submit(
|
|
content_url, source_name, start_page, end_page,
|
|
headless=HEADLESS, minio_cfg=minio_cfg, media_kind=media_kind, max_images=max_images,
|
|
)
|
|
logger.info(
|
|
"invoke agent=%s job=%s kind=%s url=%s pages=%s-%s max_images=%s minio=%s",
|
|
agent_id, job.id, media_kind, content_url, start_page, end_page, max_images,
|
|
"on" if minio_cfg.is_complete else "off",
|
|
)
|
|
return {"task_id": job.id, "status": job.status}
|
|
|
|
|
|
@app.get("/tasks/{task_id}")
|
|
async def get_task(task_id: str, authorization: str | None = Header(default=None)):
|
|
_check_auth(authorization)
|
|
job = jobs.get_job(task_id)
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="task not found")
|
|
return job.to_dict()
|
|
|
|
|
|
@app.post("/tasks/{task_id}/cancel")
|
|
async def cancel_task(task_id: str, authorization: str | None = Header(default=None)):
|
|
_check_auth(authorization)
|
|
ok = jobs.cancel(task_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=409, detail="not cancellable")
|
|
return {"ok": True}
|