Files
content-harvester/core/media.py
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

173 lines
6.3 KiB
Python

"""下载 m3u8/mp4 视频 + 封面图,上传到 MinIO。
MinIO 配置由调用方(主站经 lobster payload)传入,本模块不读全局配置。
敏感值(secret_key)不打印。
"""
from __future__ import annotations
import logging
import mimetypes
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger("harvester.media")
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
# --------------------------------------------------------------------------
# MinIO 配置
# --------------------------------------------------------------------------
@dataclass
class MinioConfig:
endpoint: str = "" # 形如 https://minio.example.com
access_key: str = ""
secret_key: str = ""
bucket: str = ""
@classmethod
def from_payload(cls, payload: dict) -> "MinioConfig":
def g(*keys: str) -> str:
for k in keys:
v = payload.get(k)
if v:
return str(v).strip()
return ""
cfg = cls(
endpoint=g("minio_endpoint", "MINIO_ENDPOINT") or os.environ.get("MINIO_ENDPOINT", "").strip(),
access_key=g("minio_access_key", "MINIO_ACCESS_KEY") or os.environ.get("MINIO_ACCESS_KEY", "").strip(),
secret_key=g("minio_secret_key", "MINIO_SECRET_KEY") or os.environ.get("MINIO_SECRET_KEY", "").strip(),
bucket=g("minio_bucket", "MINIO_BUCKET") or os.environ.get("MINIO_BUCKET", "").strip(),
)
return cfg
@property
def is_complete(self) -> bool:
return bool(self.endpoint and self.access_key and self.secret_key and self.bucket)
def safe_repr(self) -> str:
return f"MinioConfig(endpoint={self.endpoint!r}, bucket={self.bucket!r}, access_key={self.access_key[:4]+'***' if self.access_key else ''!r}, secret_key=***)"
def _endpoint_host_secure(endpoint: str) -> tuple[str, bool]:
"""https://minio.example.com -> ('minio.aidfxn.com', True);裸 host 也兼容。"""
e = endpoint.strip()
if e.startswith("http://"):
return e[len("http://"):].strip("/"), False
if e.startswith("https://"):
return e[len("https://"):].strip("/"), True
# 没带 scheme,默认 https
return e.strip("/"), True
def build_public_url(endpoint: str, bucket: str, object_key: str) -> str:
return f"{endpoint.rstrip('/')}/{bucket.strip('/')}/{object_key.lstrip('/')}"
def upload_to_minio(local_path: Path, object_key: str, content_type: str, cfg: MinioConfig) -> str:
"""上传一个文件到 MinIO,返回公开 URL。失败抛异常。"""
from minio import Minio # 延迟 import,缺依赖时报错更清晰
host, secure = _endpoint_host_secure(cfg.endpoint)
client = Minio(host, access_key=cfg.access_key, secret_key=cfg.secret_key, secure=secure)
client.fput_object(cfg.bucket, object_key, str(local_path), content_type=content_type or "application/octet-stream")
return build_public_url(cfg.endpoint, cfg.bucket, object_key)
# --------------------------------------------------------------------------
# 下载
# --------------------------------------------------------------------------
_VIDEO_EXTS = (".mp4", ".mkv", ".webm", ".mov", ".ts", ".m4v")
def extract_video_id(detail_url: str) -> str:
m = re.search(r"/video[s]?/id-([A-Za-z0-9_-]+)\.html", detail_url)
if m:
return m.group(1)
# 兜底:用 url 的 hash
import hashlib
return "u" + hashlib.sha1(detail_url.encode("utf-8")).hexdigest()[:16]
def download_video(media_url: str, referer: str, dest_dir: Path, video_id: str,
concurrent_fragments: int = 8, max_mb: int = 0) -> Path:
"""用 yt-dlp 下载 m3u8/mp4 → dest_dir/<video_id>.mp4(尽量)。返回最终文件路径。
max_mb > 0 时,下完发现超过则删文件并抛 ValueError("video too large: ...")。
"""
import yt_dlp
dest_dir.mkdir(parents=True, exist_ok=True)
outtmpl = str(dest_dir / f"{video_id}.%(ext)s")
opts = {
"outtmpl": outtmpl,
"format": "best",
"merge_output_format": "mp4",
"concurrent_fragment_downloads": max(1, int(concurrent_fragments)),
"http_headers": {"Referer": referer, "User-Agent": _UA},
"quiet": True,
"no_warnings": True,
"noprogress": True,
"retries": 3,
"fragment_retries": 3,
"noplaylist": True,
}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.download([media_url])
# 找产物:dest_dir/<video_id>.<ext>
candidates = sorted(
(p for p in dest_dir.glob(f"{video_id}.*") if p.suffix.lower() in _VIDEO_EXTS),
key=lambda p: p.stat().st_size,
reverse=True,
)
if not candidates:
raise RuntimeError("yt-dlp 未产出视频文件")
path = candidates[0]
size_mb = path.stat().st_size / (1024 * 1024)
if max_mb and size_mb > max_mb:
try:
path.unlink()
except OSError:
pass
raise ValueError(f"video too large: {size_mb:.0f}mb > {max_mb}mb")
return path
def download_cover(cover_url: str, referer: str, dest_dir: Path, video_id: str) -> Optional[Path]:
"""流式下载封面图 → dest_dir/<video_id><ext>。失败返回 None(非致命)。"""
import requests
try:
ext = os.path.splitext(urlparse(cover_url).path)[1].lower()
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
ext = ".jpg"
dest_dir.mkdir(parents=True, exist_ok=True)
path = dest_dir / f"{video_id}{ext}"
with requests.get(cover_url, headers={"Referer": referer, "User-Agent": _UA}, stream=True, timeout=30) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=64 * 1024):
if chunk:
f.write(chunk)
if path.stat().st_size == 0:
path.unlink(missing_ok=True)
return None
return path
except Exception as exc: # noqa: BLE001
logger.warning("cover download failed %s: %s", cover_url, exc)
return None
def guess_content_type(path: Path, default: str = "application/octet-stream") -> str:
return mimetypes.guess_type(str(path))[0] or default