"""应用配置:通过环境变量和 .env 文件加载所有配置项 配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。 支持 .env 文件自动加载。 抓取服务与交易服务是两个进程,但共用这一个 Settings 类:两边都要日志、代理、 超时与同一个 Bearer token,拆成两份配置只会让部署时多维护一套。下面按 「通用 / 仅抓取 / 仅交易」分区标注,各进程只读自己那部分。 """ from functools import lru_cache from pathlib import Path from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict BASE_DIR = Path(__file__).resolve().parent.parent.parent # 乐天市场首页。这里不 import app.scraping —— shared 不能反向依赖两侧任何一方, # 否则交易服务也会被迫加载整套抓取模块。 DEFAULT_HOME_URL = "https://www.rakuten.co.jp/" class Settings(BaseSettings): """应用全局配置 所有配置项均可通过环境变量覆盖,前缀为 RAKUTEN_。 例如:RAKUTEN_APP_PORT=31107 对应 app_port 配置项。 """ model_config = SettingsConfigDict( env_file=str(BASE_DIR / ".env"), env_file_encoding="utf-8", env_prefix="RAKUTEN_", extra="ignore", ) # ---- 服务基本配置(通用)---- app_name: str = "Rakuten Scraper Service" app_env: Literal["dev", "prod", "test"] = "dev" # 抓取服务监听地址;交易服务用下面的 trading_host / trading_port app_host: str = "0.0.0.0" app_port: int = 31107 # 交易服务监听地址。两个服务同机部署时端口必须错开;交易服务只能单实例, # 不要在它前面挂多副本负载均衡。 trading_host: str = "0.0.0.0" trading_port: int = 31108 # ---- 日志配置 ---- log_level: str = "INFO" log_to_file: bool | None = None # None 表示根据环境自动决定 log_dir: str = "logs" log_rotation: str = "100 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 # ---- 鉴权配置(通用:两个服务共用同一个对外 token)---- bearer_token: str = "REPLACE_WITH_TOKEN_32CHARS" # ---- HTTP 抓取配置(仅抓取服务)---- request_timeout_seconds: float = 30.0 max_site_concurrency: int = 8 # 对站点的最大并发请求数 http_max_attempts: int = 3 # 单次抓取的最大尝试次数(含首次) session_ttl_seconds: float = 1800.0 # Akamai cookie 会话最长复用时长,超时后重新预热 # ---- 浏览器兜底配置(仅抓取服务)---- # 纯 HTTP 被 Akamai 拦截时,用 Playwright 打开页面取回 cookie 再回灌给 # HTTP 客户端重试。日常流量不会触发;未安装 playwright 时自动降级为不兜底。 browser_fallback_enabled: bool = True browser_headless: bool | None = None # None 表示根据环境自动决定 browser_channel: str | None = None # 例如 chrome;留空使用 bundled chromium browser_launch_timeout_seconds: float = 60.0 browser_nav_timeout_seconds: float = 60.0 # ---- 代理配置(通用,可选,用于日本 IP)---- # 两个服务同机部署时通常各配各的:抓取高频匿名,出口 IP 被限速换掉即可; # 交易带账号,出口 IP 频繁漂移反而会触发风控。 proxy_server: str | None = None proxy_username: str | None = None proxy_password: str | None = None # ---- OpenTelemetry traces(通用,可选;默认关闭)---- # 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到 # 内容能否被解析"。两侧 main.py 在 lifespan 启动时按服务名分别初始化。 otel_enabled: bool = False otel_endpoint: str | None = None # OTLP/HTTP traces endpoint,例如 https://oltp.jerryyan.top/v1/traces otel_service_name: str = "rakuten" # 仅作兜底;实际值由两侧 main.py 显式覆盖 otel_headers: str | None = None # OTLP 鉴权头,形如 "k=v,k=v";当前 endpoint 裸跑,留空 otel_export_interval_ms: int = 5000 # 解析失败时把页面 HTML 作为 span event 上报的上限字节;超出截断并标注。 # 单个搜索页 HTML 可达 200KB-2MB,调高时同步关注 OTLP 单次请求大小限制。 otel_snapshot_max_bytes: int = 2_000_000 # ---- 登录态与下单配置(仅交易服务)---- # 人工登录一次后落盘的 Playwright storage_state 目录(相对项目根目录)。 # 目录里是可直接冒充账号的 cookie,务必不要提交到版本库。 auth_state_dir: str = ".auth" # 下单金额上限(日元)。实际应付金额超过该值时拒绝提交,防止解析出错或 # 页面改版导致买到远超预期的订单。设为 0 表示不设上限(不建议)。 order_max_total_yen: int = 30000 # ---- 目标站点 ---- home_url: str = DEFAULT_HOME_URL @property def browser_headless_effective(self) -> bool: """浏览器无头模式:显式配置优先,否则开发环境使用有头模式方便调试""" if self.browser_headless is not None: return self.browser_headless return self.app_env != "dev" @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 playwright_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 @property def httpx_proxy(self) -> str | None: """构建 httpx 代理 URL(含认证信息)""" if not self.proxy_server: return None if not self.proxy_username: return self.proxy_server scheme, _, rest = self.proxy_server.partition("://") if not rest: return self.proxy_server credentials = f"{self.proxy_username}:{self.proxy_password or ''}" return f"{scheme}://{credentials}@{rest}" @property def auth_state_path(self) -> Path: """登录态目录的绝对路径,不存在时创建""" path = Path(self.auth_state_dir) if not path.is_absolute(): path = BASE_DIR / path path.mkdir(parents=True, exist_ok=True) return path @lru_cache(maxsize=1) def get_settings() -> Settings: """获取全局配置单例""" return Settings()