Files
live-hub-py/web/backend/services/weibo_image_service.py
T

119 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微博商品图片下载与内嵌服务。"""
from __future__ import annotations
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
import requests
from loguru import logger
from core.weibo import normalize_weibo_image_url
from .file_service import sniff_content_type
IMAGE_CONCURRENCY = 6
IMAGE_TIMEOUT_SECONDS = 3.0
MAX_IMAGE_BYTES = 5 << 20
_IMAGE_CONTENT_TYPES = frozenset({"image/jpeg", "image/png", "image/webp"})
_IMAGE_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/156.0.0.0 Safari/537.36"
),
"Referer": "https://vipclub.e.weibo.com/",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
@dataclass(frozen=True, slots=True)
class WeiboImageLoadResult:
total: int
loaded: int
@property
def failed(self) -> int:
return max(0, self.total - self.loaded)
def fetch_weibo_image_data_url(raw_url: str) -> str:
"""下载一张微博图片,校验后转成浏览器可直接显示的 data URL。"""
image_url = normalize_weibo_image_url(raw_url)
parsed = urlsplit(image_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("图片地址必须是 HTTP(S) URL")
with requests.get(
image_url,
headers=_IMAGE_HEADERS,
timeout=IMAGE_TIMEOUT_SECONDS,
stream=True,
) as response:
if not 200 <= response.status_code < 300:
raise ValueError(f"图片 HTTP 状态异常: {response.status_code}")
declared_size = response.headers.get("Content-Length", "").strip()
if declared_size.isdigit() and int(declared_size) > MAX_IMAGE_BYTES:
raise ValueError("图片超过 5 MiB")
data = bytearray()
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
data.extend(chunk)
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("图片超过 5 MiB")
if not data:
raise ValueError("图片内容为空")
content_type = sniff_content_type(bytes(data))
if content_type not in _IMAGE_CONTENT_TYPES:
raise ValueError(f"响应不是支持的图片类型: {content_type or 'unknown'}")
encoded = base64.b64encode(data).decode("ascii")
return f"data:{content_type};base64,{encoded}"
def embed_weibo_goods_images(
goods: list[dict[str, Any]],
*,
account_id: int | None = None,
) -> WeiboImageLoadResult:
"""并发把商品列表图片转成 data URL;单张失败时保留 HTTPS 兜底地址。"""
total = len(goods)
if total == 0:
logger.info("微博商品图片加载完成:图片 0/0")
return WeiboImageLoadResult(total=0, loaded=0)
loaded = 0
jobs: dict[Any, int] = {}
workers = min(IMAGE_CONCURRENCY, total)
with ThreadPoolExecutor(max_workers=workers) as executor:
for index, item in enumerate(goods):
image_url = normalize_weibo_image_url(item.get("goods_image"))
item["goods_image"] = image_url
if not image_url:
continue
if image_url.startswith("data:image/"):
loaded += 1
continue
jobs[executor.submit(fetch_weibo_image_data_url, image_url)] = index
for future in as_completed(jobs):
index = jobs[future]
try:
goods[index]["goods_image"] = future.result()
except Exception as exc: # noqa: BLE001 - 单张图片失败不能影响商品列表
logger.debug(f"微博商品图片下载失败:{type(exc).__name__}: {exc}")
else:
loaded += 1
result = WeiboImageLoadResult(total=total, loaded=loaded)
context = f"(account_id={account_id})" if account_id is not None else ""
logger.info(f"微博商品图片加载完成:图片 {result.loaded}/{result.total}{context}")
return result