Files
rakuten-api/app/shared/config.py
T
q792602257andClaude Opus 4.6 2b7db521c4 接入 OTel 链路追踪 + 镜像默认启用
抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。
接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span),
解析失败时把页面 HTML 作为 span event 上报,便于事后复现。
Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 15:43:19 +08:00

163 lines
6.8 KiB
Python

"""应用配置:通过环境变量和 .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()