抓取-解析链路原本只有日志,出现"抓回内容但解析不出预期字段"时定位慢。 接入 OpenTelemetry traces(FastAPI/httpx 自动 + 手写 fetch/parse span), 解析失败时把页面 HTML 作为 span event 上报,便于事后复现。 Dockerfile 默认开启,镜像一启动即导出到自建 OTLP endpoint。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
6.8 KiB
Python
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()
|