From 5af217fbf5e874fab233388f88326303c07ac663 Mon Sep 17 00:00:00 2001 From: Attente <19653207+wikrin@users.noreply.github.com> Date: Sat, 6 Dec 2025 10:10:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=86=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E9=80=BB=E8=BE=91=E6=8A=BD=E8=B1=A1=E4=B8=BA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E7=9A=84=20ImageHelper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/login.py | 2 +- app/api/endpoints/system.py | 97 ++++------------- app/chain/recommend.py | 43 +------- app/helper/{wallpaper.py => graphics.py} | 128 ++++++++++++++++++++++- app/helper/torrent.py | 3 +- app/modules/telegram/telegram.py | 56 +++------- app/modules/wechat/__init__.py | 2 +- app/scheduler.py | 2 +- 8 files changed, 165 insertions(+), 168 deletions(-) rename app/helper/{wallpaper.py => graphics.py} (59%) diff --git a/app/api/endpoints/login.py b/app/api/endpoints/login.py index a6b0e77e..434aa34d 100644 --- a/app/api/endpoints/login.py +++ b/app/api/endpoints/login.py @@ -10,7 +10,7 @@ from app.core import security from app.core.config import settings from app.db.systemconfig_oper import SystemConfigOper from app.helper.sites import SitesHelper # noqa -from app.helper.wallpaper import WallpaperHelper +from app.helper.graphics import WallpaperHelper from app.schemas.types import SystemConfigKey router = APIRouter() diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 6a73245d..baa29ee5 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -1,15 +1,12 @@ import asyncio -import io import json import re from collections import deque from datetime import datetime -from pathlib import Path from typing import Optional, Union, Annotated import aiofiles import pillow_avif # noqa 用于自动注册AVIF支持 -from PIL import Image from anyio import Path as AsyncPath from app.helper.sites import SitesHelper # noqa # noqa from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response @@ -19,7 +16,6 @@ from app import schemas from app.chain.mediaserver import MediaServerChain from app.chain.search import SearchChain from app.chain.system import SystemChain -from app.core.cache import AsyncFileCache from app.core.config import global_vars, settings from app.core.event import eventmanager from app.core.metainfo import MetaInfo @@ -29,12 +25,14 @@ from app.db.models import User from app.db.systemconfig_oper import SystemConfigOper from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \ get_current_active_user_async +from app.helper.llm import LLMHelper from app.helper.mediaserver import MediaServerHelper from app.helper.message import MessageHelper from app.helper.progress import ProgressHelper from app.helper.rule import RuleHelper from app.helper.subscribe import SubscribeHelper from app.helper.system import SystemHelper +from app.helper.graphics import ImageHelper from app.log import logger from app.scheduler import Scheduler from app.schemas import ConfigChangeEventData @@ -44,14 +42,13 @@ from app.utils.http import RequestUtils, AsyncRequestUtils from app.utils.security import SecurityUtils from app.utils.url import UrlUtils from version import APP_VERSION -from app.helper.llm import LLMHelper router = APIRouter() async def fetch_image( url: str, - proxy: bool = False, + proxy: Optional[bool] = None, use_cache: bool = False, if_none_match: Optional[str] = None, cookies: Optional[str | dict] = None, @@ -70,77 +67,24 @@ async def fetch_image( logger.warn(f"Blocked unsafe image URL: {url}") return None - # 缓存路径 - sanitized_path = SecurityUtils.sanitize_url_path(url) - cache_path = Path("images") / sanitized_path - if not cache_path.suffix: - # 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择 - cache_path = cache_path.with_suffix(".jpg") - - # 缓存对像,缓存过期时间为全局图片缓存天数 - cache_backend = AsyncFileCache(base=settings.CACHE_PATH, - ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600) - - if use_cache: - content = await cache_backend.get(cache_path.as_posix(), region="images") - if content: - # 检查 If-None-Match - etag = HashUtils.md5(content) - headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7) - if if_none_match == etag: - return Response(status_code=304, headers=headers) - # 返回缓存图片 - return Response( - content=content, - media_type=UrlUtils.get_mime_type(url, "image/jpeg"), - headers=headers - ) - - # 请求远程图片 - referer = "https://movie.douban.com/" if "doubanio.com" in url else None - proxies = settings.PROXY if proxy else None - response = await AsyncRequestUtils( - ua=settings.NORMAL_USER_AGENT, - proxies=proxies, - referer=referer, + content = await ImageHelper().async_fetch_image( + url=url, + proxy=proxy, + use_cache=use_cache, cookies=cookies, - accept_type="image/avif,image/webp,image/apng,*/*", - ).get_res(url=url) - if not response: - logger.warn(f"Failed to fetch image from URL: {url}") - return None - - # 验证下载的内容是否为有效图片 - try: - content = response.content - Image.open(io.BytesIO(content)).verify() - except Exception as e: - logger.warn(f"Invalid image format for URL {url}: {e}") - return None - - # 获取请求响应头 - response_headers = response.headers - cache_control_header = response_headers.get("Cache-Control", "") - cache_directive, max_age = RequestUtils.parse_cache_control(cache_control_header) - - # 保存缓存 - if use_cache: - await cache_backend.set(cache_path.as_posix(), content, region="images") - logger.debug(f"Image cached at {cache_path.as_posix()}") - - # 检查 If-None-Match - etag = HashUtils.md5(content) - if if_none_match == etag: - headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age) - return Response(status_code=304, headers=headers) - - # 响应 - headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age) - return Response( - content=content, - media_type=response_headers.get("Content-Type") or UrlUtils.get_mime_type(url, "image/jpeg"), - headers=headers ) + if content: + # 检查 If-None-Match + etag = HashUtils.md5(content) + headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7) + if if_none_match == etag: + return Response(status_code=304, headers=headers) + # 返回缓存图片 + return Response( + content=content, + media_type=UrlUtils.get_mime_type(url, "image/jpeg"), + headers=headers + ) @router.get("/img/{proxy}", summary="图片代理") @@ -178,8 +122,7 @@ async def cache_img( 本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存 """ # 如果没有启用全局图片缓存,则不使用磁盘缓存 - proxy = "doubanio.com" not in url - return await fetch_image(url=url, proxy=proxy, use_cache=settings.GLOBAL_IMAGE_CACHE, + return await fetch_image(url=url, use_cache=settings.GLOBAL_IMAGE_CACHE, if_none_match=if_none_match) diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 9dba6f65..1d399132 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -1,21 +1,17 @@ -import io -from pathlib import Path from typing import List, Optional import pillow_avif # noqa 用于自动注册AVIF支持 -from PIL import Image from app.chain import ChainBase from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain from app.chain.tmdb import TmdbChain -from app.core.cache import cached, FileCache +from app.core.cache import cached from app.core.config import settings, global_vars +from app.helper.graphics import ImageHelper from app.log import logger from app.schemas import MediaType from app.utils.common import log_execution_time -from app.utils.http import RequestUtils -from app.utils.security import SecurityUtils from app.utils.singleton import Singleton @@ -103,40 +99,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): 请求并保存图片 :param url: 图片路径 """ - # 生成缓存路径 - sanitized_path = SecurityUtils.sanitize_url_path(url) - cache_path = Path("images") / sanitized_path - # 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择 - if not cache_path.suffix: - cache_path = cache_path.with_suffix(".jpg") - - # 获取缓存后端,并设置缓存时间为全局配置的缓存天数 - cache_backend = FileCache(base=settings.CACHE_PATH, - ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600) - - # 本地存在缓存图片,则直接跳过 - if cache_backend.get(cache_path.as_posix(), region="images"): - logger.debug(f"Cache hit: Image already exists at {cache_path}") - return - - # 请求远程图片 - referer = "https://movie.douban.com/" if "doubanio.com" in url else None - proxies = settings.PROXY if not referer else None - response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=url) - if not response: - logger.debug(f"Empty response for URL: {url}") - return - - # 验证下载的内容是否为有效图片 - try: - Image.open(io.BytesIO(response.content)).verify() - except Exception as e: - logger.debug(f"Invalid image format for URL {url}: {e}") - return - - # 保存缓存 - cache_backend.set(cache_path.as_posix(), response.content, region="images") - logger.debug(f"Successfully cached image at {cache_path} for URL: {url}") + ImageHelper().fetch_image(url=url) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region) diff --git a/app/helper/wallpaper.py b/app/helper/graphics.py similarity index 59% rename from app/helper/wallpaper.py rename to app/helper/graphics.py index 99c5786e..f3d1b28a 100644 --- a/app/helper/wallpaper.py +++ b/app/helper/graphics.py @@ -1,10 +1,17 @@ +import io +from pathlib import Path from typing import Optional, List +from PIL import Image + from app.chain.mediaserver import MediaServerChain from app.chain.tmdb import TmdbChain -from app.core.cache import cached +from app.core.cache import cached, FileCache, AsyncFileCache from app.core.config import settings -from app.utils.http import RequestUtils +from app.log import logger +from app.utils.http import RequestUtils, AsyncRequestUtils +from app.utils.ip import IpUtils +from app.utils.security import SecurityUtils from app.utils.singleton import Singleton @@ -161,3 +168,120 @@ class WallpaperHelper(metaclass=Singleton): return wallpaper_list else: return [] + + +class ImageHelper(metaclass=Singleton): + + def __init__(self): + _base_path = settings.CACHE_PATH + _ttl = settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600 + self.file_cache = FileCache(base=_base_path, ttl=_ttl) + self.async_file_cache = AsyncFileCache(base=_base_path, ttl=_ttl) + + @staticmethod + def _prepare_cache_path(url: str) -> str: + """缓存路径""" + sanitized_path = SecurityUtils.sanitize_url_path(url) + cache_path = Path(sanitized_path) + if not cache_path.suffix: + cache_path = cache_path.with_suffix(".jpg") + return cache_path.as_posix() + + @staticmethod + def _validate_image(content: bytes) -> bool: + """验证图片""" + if not content: + return False + try: + Image.open(io.BytesIO(content)).verify() + return True + except Exception as e: + logger.warn(f"Invalid image format: {e}") + return False + + def _get_request_params(self, url: str, proxy: Optional[bool], cookies: Optional[str | dict]) -> dict: + """获取参数""" + referer = "https://movie.douban.com/" if "doubanio.com" in url else None + if proxy is None: + proxies = settings.PROXY if not (referer or IpUtils.is_internal(url)) else None + else: + proxies = settings.PROXY if proxy else None + return { + "ua": settings.NORMAL_USER_AGENT, + "proxies": proxies, + "referer": referer, + "cookies": cookies, + "accept_type": "image/avif,image/webp,image/apng,*/*", + } + + def fetch_image( + self, + url: str, + proxy: Optional[bool] = None, + use_cache: bool = True, + cookies: Optional[str | dict] = None) -> Optional[bytes]: + """ + 获取图片(同步版本) + """ + if not url: + return None + + cache_path = self._prepare_cache_path(url) + + # 检查缓存 + if use_cache: + content = self.file_cache.get(cache_path, region="images") + if content: + return content + + # 请求远程图片 + params = self._get_request_params(url, proxy, cookies) + response = RequestUtils(**params).get_res(url=url) + if not response: + logger.warn(f"Failed to fetch image from URL: {url}") + return None + + content = response.content + # 验证图片 + if not self._validate_image(content): + return None + + # 保存缓存 + self.file_cache.set(cache_path, content, region="images") + return content + + async def async_fetch_image( + self, + url: str, + proxy: Optional[bool] = None, + use_cache: bool = True, + cookies: Optional[str | dict] = None) -> Optional[bytes]: + """ + 获取图片(异步版本) + """ + if not url: + return None + + cache_path = self._prepare_cache_path(url) + + # 检查缓存 + if use_cache: + content = await self.async_file_cache.get(cache_path, region="images") + if content: + return content + + # 请求远程图片 + params = self._get_request_params(url, proxy, cookies) + response = await AsyncRequestUtils(**params).get_res(url=url) + if not response: + logger.warn(f"Failed to fetch image from URL: {url}") + return None + + content = response.content + # 验证图片 + if not self._validate_image(content): + return None + + # 保存缓存 + await self.async_file_cache.set(cache_path, content, region="images") + return content diff --git a/app/helper/torrent.py b/app/helper/torrent.py index 08829ca6..2520c48d 100644 --- a/app/helper/torrent.py +++ b/app/helper/torrent.py @@ -6,8 +6,7 @@ from urllib.parse import unquote from torrentool.api import Torrent -from app.core.cache import FileCache -from app.core.cache import TTLCache +from app.core.cache import TTLCache, FileCache from app.core.config import settings from app.core.context import Context, TorrentInfo, MediaInfo from app.core.meta import MetaBase diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index f6d1b79c..807ddd6a 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -1,26 +1,22 @@ import asyncio -import io import re import threading -from pathlib import Path from typing import Optional, List, Dict, Callable from urllib.parse import urljoin -from PIL import Image from telebot import TeleBot, apihelper from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto from telegramify_markdown import standardize, telegramify from telegramify_markdown.type import ContentTypes, SentType -from app.core.cache import FileCache from app.core.config import settings from app.core.context import MediaInfo, Context from app.core.metainfo import MetaInfo from app.helper.thread import ThreadHelper +from app.helper.graphics import ImageHelper from app.log import logger from app.utils.common import retry from app.utils.http import RequestUtils -from app.utils.security import SecurityUtils from app.utils.string import StringUtils @@ -537,13 +533,10 @@ class Telegram: 'reply_markup': reply_markup } - try: - # 处理图片 - image = self.__process_image(image) if image else None - except RetryException as e: - logger.error(f"{str(e)}, 达到重试次数上限, 仅发送文本消息") - image = None + # 处理图片 + image = self.__process_image(image) + try: # 图片消息的标题长度限制为1024,文本消息为4096 caption_limit = 1024 if image else 4096 if len(caption) < caption_limit: @@ -557,42 +550,17 @@ class Telegram: logger.error(f"发送Telegram消息失败: {e}") return False - @retry(RetryException, logger=logger) - def __process_image(self, image_url: str) -> Optional[bytes]: + @staticmethod + def __process_image(image_url: Optional[str]) -> Optional[bytes]: """ 处理图片URL,获取图片内容 """ - # 缓存路径 - sanitized_path = SecurityUtils.sanitize_url_path(image_url) - cache_path = Path("images") / sanitized_path - # 没有文件类型,则添加后缀 - if not cache_path.suffix: - cache_path = cache_path.with_suffix(".jpg") - - cache_backend = FileCache(base=settings.CACHE_PATH, - ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600) - - content = cache_backend.get(cache_path.as_posix(), region="images") - if content: - return content - - # 请求远程图片 - referer = "https://movie.douban.com/" if "doubanio.com" in image_url else None - proxies = settings.PROXY if not referer else None - res = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=image_url) - if not res or not res.content: - raise RetryException("获取图片失败") - - try: - # 验证内容是否为有效图片 - Image.open(io.BytesIO(res.content)).verify() - # 保存缓存 - cache_backend.set(cache_path.as_posix(), res.content, region="images") - return res.content - except Exception as e: - logger.error(f"图片验证失败:{str(e)}, 仅发送文本消息") + if not image_url: return None - + image = ImageHelper().fetch_image(image_url) + if not image: + logger.warn(f"图片获取失败: {image_url},仅发送文本消息") + return image @retry(RetryException, logger=logger) def __send_short_message(self, image: Optional[bytes], caption: str, **kwargs): @@ -611,7 +579,7 @@ class Telegram: text=standardize(caption), **kwargs ) - except Exception as e: + except Exception: raise RetryException(f"发送{'图片' if image else '文本'}消息失败") @retry(RetryException, logger=logger) diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index a46757d2..2ff8a47d 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -8,7 +8,7 @@ from app.log import logger from app.modules import _ModuleBase, _MessageBase from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt from app.modules.wechat.wechat import WeChat -from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, ConfigChangeEventData +from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData from app.schemas.types import ModuleType, ChainEventType from app.utils.dom import DomUtils from app.utils.structures import DictUtils diff --git a/app/scheduler.py b/app/scheduler.py index dfc0067b..1da95e6f 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -27,7 +27,7 @@ from app.core.plugin import PluginManager from app.db.systemconfig_oper import SystemConfigOper from app.helper.message import MessageHelper from app.helper.sites import SitesHelper # noqa -from app.helper.wallpaper import WallpaperHelper +from app.helper.graphics import WallpaperHelper from app.log import logger from app.schemas import Notification, NotificationType, Workflow from app.schemas.types import EventType, SystemConfigKey