This commit is contained in:
2026-06-08 16:09:13 +08:00
commit 518c6b3c7a
32 changed files with 4156 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core configuration and infrastructure helpers."""
+131
View File
@@ -0,0 +1,131 @@
"""应用配置:通过环境变量和 .env 文件加载所有配置项
配置项统一使用 SURUGAYA_ 前缀,例如 SURUGAYA_APP_PORT=31106。
支持 .env 文件自动加载。
"""
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
"""应用全局配置
所有配置项均可通过环境变量覆盖,前缀为 SURUGAYA_。
例如:SURUGAYA_APP_PORT=31106 对应 app_port 配置项。
"""
model_config = SettingsConfigDict(
env_file=str(BASE_DIR / ".env"), # 使用绝对路径
env_file_encoding="utf-8",
env_prefix="SURUGAYA_",
extra="ignore",
)
# ---- 服务基本配置 ----
app_name: str = "Surugaya Scraper Service"
app_env: Literal["dev", "prod", "test"] = "dev"
app_host: str = "0.0.0.0"
app_port: int = 31106
# ---- 日志配置 ----
log_level: str = "DEBUG"
log_to_file: bool | None = None # None 表示根据环境自动决定
log_dir: str = "logs"
log_rotation: str = "10 MB"
log_retention: str = "14 days"
log_compression: str = "zip"
log_format: str = "{time:YYYY-MM-DD HH:mm:ss} {level} {message}"
log_enqueue: bool = True
# ---- 鉴权配置 ----
bearer_token: str = "NilsO8Il8yoT1wazJFs2eeOmlPRfWuSx"
# ---- 浏览器配置 ----
browser_enabled: bool = True
browser_headless: bool | None = None # None 表示根据环境自动决定
browser_keep_open: bool | None = None # None 表示根据环境自动决定
browser_keep_open_on_error: bool | None = None # None 表示根据环境自动决定
browser_channel: str | None = "chrome"
browser_pool_size: int = 3
max_site_concurrency: int = 5 # 最大并发页面数
page_acquire_timeout_seconds: float = 60.0 # 获取页面槽位超时
request_timeout_seconds: float = 120.0 # 页面请求超时
cloudflare_wait_timeout_seconds: float = 60.0 # 等待 Cloudflare 挑战超时
session_ttl_seconds: int = 900 # 会话过期时间(秒)
browser_locale: str = "ja-JP"
browser_timezone_id: str = "Asia/Tokyo"
browser_user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
browser_stealth_enabled: bool = True # 是否启用浏览器 Stealth 模式
# ---- Redis 配置(可选,未配置时使用进程内存)----
redis_host: str = ""
redis_port: int = 6379
redis_password: str | None = None
redis_db: int = 0
session_namespace: str = "jp_surugaya"
# ---- 代理配置(可选,用于日本 IP)----
proxy_server: str | None = None
proxy_username: str | None = None
proxy_password: str | None = None
# ---- 目标站点 ----
base_url: str = "https://www.suruga-ya.jp"
@property
def browser_headless_effective(self) -> bool:
"""浏览器无头模式:显式配置优先,否则开发环境使用有头模式方便调试"""
if self.browser_headless is not None:
return self.browser_headless
if self.app_env == "dev":
return False
return True
@property
def browser_keep_open_on_error_effective(self) -> bool:
"""出错时是否保留浏览器窗口:仅在开发环境有头模式下保留"""
if self.browser_keep_open_on_error is not None:
return self.browser_keep_open_on_error
return self.app_env == "dev" and not self.browser_headless_effective
@property
def browser_keep_open_effective(self) -> bool:
"""成功后是否保留浏览器窗口:仅在开发环境有头模式下保留"""
if self.browser_keep_open is not None:
return self.browser_keep_open
return self.app_env == "dev" and not self.browser_headless_effective
@property
def log_to_file_effective(self) -> bool:
"""是否写日志文件:显式配置优先,否则生产环境默认写文件"""
if self.log_to_file is not None:
return self.log_to_file
return self.app_env == "prod"
@property
def proxy(self) -> dict[str, str] | None:
"""构建 Playwright 代理配置字典"""
if not self.proxy_server:
return None
proxy: dict[str, str] = {"server": self.proxy_server}
if self.proxy_username:
proxy["username"] = self.proxy_username
if self.proxy_password:
proxy["password"] = self.proxy_password
return proxy
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""获取全局配置单例"""
return Settings()
+25
View File
@@ -0,0 +1,25 @@
"""服务容器:集中管理所有服务实例,用于依赖注入"""
from dataclasses import dataclass
from app.core.config import Settings
from app.services.browser_pool import BrowserPool
from app.services.cloudflare_session import CloudflareSessionManager
from app.services.session_store import SessionStore
from app.services.surugaya_client import SurugayaClient
from app.services.cargo_service import CargoService
@dataclass(slots=True)
class ServiceContainer:
"""服务容器,持有所有核心服务实例
通过 FastAPI 的 app.state.container 在请求间共享,
各路由通过依赖注入获取容器中的服务。
"""
settings: Settings
session_store: SessionStore
browser_pool: BrowserPool
session_manager: CloudflareSessionManager
surugaya_client: SurugayaClient
cargo_service: CargoService
+78
View File
@@ -0,0 +1,78 @@
"""应用异常定义:所有业务异常均继承自 AppError
错误码规范:
- 1xxx: 请求/鉴权错误
- 2xxx: 浏览器资源错误
- 3xxx: Cloudflare/反爬相关错误
- 4xxx: 页面解析错误
"""
class AppError(Exception):
"""应用基础异常,携带错误码、HTTP 状态码和是否可重试信息"""
def __init__(
self,
message: str,
code: str,
err_code: int,
retryable: bool = False,
status_code: int = 400,
headers: dict[str, str] | None = None,
):
super().__init__(message)
self.message = message
self.code = code
self.err_code = err_code
self.retryable = retryable
self.status_code = status_code
self.headers = headers
class AuthenticationError(AppError):
"""鉴权失败(Bearer Token 无效或缺失)"""
def __init__(self, message: str = "Invalid credentials"):
super().__init__(
message=message,
code="AUTH_INVALID",
err_code=1001,
retryable=False,
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
class BrowserUnavailableError(AppError):
"""浏览器不可用(未启用或启动失败)"""
def __init__(self, message: str = "Browser pool is unavailable"):
super().__init__(message=message, code="BROWSER_UNAVAILABLE", err_code=2001, retryable=True)
class ResourceBusyError(AppError):
"""浏览器资源繁忙(获取页面槽位超时)"""
def __init__(self, message: str = "Timed out while waiting for a browser slot"):
super().__init__(message=message, code="RESOURCE_BUSY", err_code=2002, retryable=True)
class CloudflareChallengeError(AppError):
"""Cloudflare 挑战未在规定时间内通过"""
def __init__(self, message: str = "Cloudflare challenge did not clear in time"):
super().__init__(message=message, code="CLOUDFLARE_CHALLENGE", err_code=3001, retryable=True)
class UpstreamBlockedError(AppError):
"""上游请求被 Cloudflare 阻断(如 1020/403 错误)"""
def __init__(self, message: str = "The upstream request was blocked"):
super().__init__(message=message, code="UPSTREAM_BLOCKED", err_code=3002, retryable=True)
class ScrapeParseError(AppError):
"""页面解析失败"""
def __init__(self, message: str = "Failed to parse the target page"):
super().__init__(message=message, code="PARSE_ERROR", err_code=4001, retryable=False)
+82
View File
@@ -0,0 +1,82 @@
"""日志配置:使用 loguru 替代标准 logging,统一管理日志输出
- 控制台输出:始终启用
- 文件输出:生产环境默认启用,支持日志轮转、压缩和保留策略
- 拦截标准 logging:将 uvicorn 等第三方库的日志转发到 loguru
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from types import FrameType
from loguru import logger as loguru_logger
from app.core.config import Settings
class InterceptHandler(logging.Handler):
"""拦截标准 logging 日志,转发到 loguru"""
def emit(self, record: logging.LogRecord) -> None:
try:
level: str | int = loguru_logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = logging.currentframe()
depth = 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def configure_logging(settings: Settings) -> None:
"""根据配置初始化日志系统
- 移除 loguru 默认 handler
- 添加控制台输出
- 生产环境添加文件输出(轮转、压缩、保留策略)
- 拦截标准 logging,统一走 loguru 输出
"""
loguru_logger.remove()
loguru_logger.add(
sys.stdout,
level=settings.log_level.upper(),
format=settings.log_format,
enqueue=settings.log_enqueue,
backtrace=False,
diagnose=False,
)
if settings.log_to_file_effective:
log_dir = Path(settings.log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"app_{settings.app_env}.log"
loguru_logger.add(
str(log_path),
level=settings.log_level.upper(),
format=settings.log_format,
rotation=settings.log_rotation,
retention=settings.log_retention,
compression=settings.log_compression,
enqueue=settings.log_enqueue,
encoding="utf-8",
backtrace=False,
diagnose=False,
)
intercept = InterceptHandler()
logging.basicConfig(handlers=[intercept], level=getattr(logging, settings.log_level.upper(), logging.INFO), force=True)
# 将 uvicorn 的日志也转发到 loguru
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.handlers = [intercept]
logger.propagate = False
# 降低 asyncio 的日志级别,避免过多输出
logging.getLogger("asyncio").setLevel(logging.WARNING)