新增图集 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>
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""采集结果的数据结构。
|
|
|
|
ScrapedItem 的字段刻意与主站 `ResourceIngestItem`(app/schemas/resource_pool.py)
|
|
对齐,这样主站 `_maybe_ingest_to_resource_pool` 能直接 `model_validate` 每一条。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class ScrapedItem:
|
|
# 详情页 URL —— 主站资源库按它去重
|
|
source_url: str
|
|
title: str
|
|
# 来源站点名,默认 "xchina"(可被 invoke 载荷里的 platform_name 覆盖)
|
|
source_name: str = "xchina"
|
|
# 资源类型: "video"(视频) | "photo"(图集)
|
|
media_kind: str = "video"
|
|
# 封面图 URL(上传 MinIO 后改为 MinIO 地址,否则是 xchina 图床地址)
|
|
cover_image: str | None = None
|
|
# 视频 URL(media_kind=video 时;上传 MinIO 后改为 MinIO 地址,否则是 xchina 限时签名 m3u8)
|
|
video_url: str | None = None
|
|
# 图集图片 URL 列表(media_kind=photo 时;上传 MinIO 后逐个改为 MinIO 地址)
|
|
images: list[str] = field(default_factory=list)
|
|
# 图集原始总张数(images 可能因 max_images 被截断)
|
|
image_total: int = 0
|
|
image_capped: bool = False
|
|
# 简介 / 正文(主站资源库要求 content 非空才入库;为空时回退用 title)
|
|
summary: str | None = None
|
|
content: str | None = None
|
|
# 列表页页码(单详情页直采时为 1)
|
|
page_no: int = 1
|
|
# xchina 详情页里的 id 段;作 MinIO object key 前缀
|
|
video_id: str = ""
|
|
# 附加进 extra_data 的字段(original_m3u8 / minio_object_video / video_bytes / upload_skipped_reason ...)
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_ingest_dict(self) -> dict[str, Any]:
|
|
extra_data: dict[str, Any] = {
|
|
"referer": self.source_url,
|
|
"page_no": self.page_no,
|
|
"collector": "xchina",
|
|
}
|
|
if self.video_id:
|
|
extra_data["video_id"] = self.video_id
|
|
if self.media_kind == "photo":
|
|
extra_data["image_total"] = self.image_total or len(self.images)
|
|
extra_data["image_count"] = len(self.images)
|
|
extra_data["image_capped"] = bool(self.image_capped)
|
|
extra_data.update(self.extra or {})
|
|
# 主站 ingest 对空 content 直接拒收 —— 兜底用 title,确保非空
|
|
content = (self.content or "").strip() or (self.title or "").strip() or self.source_url
|
|
if self.media_kind == "photo":
|
|
return {
|
|
# 主站 SourceTypeEnum: image;ContentTypeEnum 只有 article/video,图集归 article
|
|
"source_type": "image",
|
|
"source_name": self.source_name,
|
|
"source_url": self.source_url,
|
|
"content_type": "article",
|
|
"title": self.title,
|
|
"cover_image": self.cover_image or (self.images[0] if self.images else None),
|
|
"summary": (self.summary or None),
|
|
"content": content,
|
|
"images": list(self.images),
|
|
"video_url": None,
|
|
"extra_data": extra_data,
|
|
}
|
|
return {
|
|
"source_type": "video",
|
|
"source_name": self.source_name,
|
|
"source_url": self.source_url,
|
|
"content_type": "video",
|
|
"title": self.title,
|
|
"cover_image": self.cover_image,
|
|
"summary": (self.summary or None),
|
|
"content": content,
|
|
"video_url": self.video_url,
|
|
"extra_data": extra_data,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ScrapeResult:
|
|
resources: list[ScrapedItem] = field(default_factory=list)
|
|
pages_scanned: int = 0
|
|
item_errors: list[dict[str, str]] = field(default_factory=list)
|
|
|
|
def to_output(self) -> dict[str, Any]:
|
|
return {
|
|
"resources": [it.to_ingest_dict() for it in self.resources],
|
|
"pages_scanned": self.pages_scanned,
|
|
"item_errors": self.item_errors,
|
|
}
|