"""应用配置:通过环境变量和 .env 文件加载所有配置项 配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。 支持 .env 文件自动加载。 抓取、交易与下单任务网关是三个进程,但共用这一个 Settings 类:三方都要日志、 代理、超时与同一个 Bearer token,拆成三份配置只会让部署时多维护一套。下面按 「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。 """ import socket from fnmatch import fnmatchcase from functools import lru_cache from pathlib import Path from typing import Literal from urllib.parse import quote, urlsplit from pydantic import Field 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 # 下单任务网关监听地址(第三个部署单元)。网关部署在服务器侧,本地 worker # 通过出站长轮询从这里领任务。详见 docs/order-gateway.md。 gateway_host: str = "0.0.0.0" gateway_port: int = 31109 # ---- 日志配置 ---- 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 # 逗号分隔的直连主机名或通配符。服务间请求不应绕到公网代理。 proxy_bypass: str = "localhost,127.0.0.1,::1,rakuten-api,rakuten-trading,rakuten-gateway" # ---- 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 # 自动下单时在支付方式页选择的选项文案(按可见文案匹配,非 value/id)。 # 2026-08-11 未经真实确认页验证:见 site_interact.py::_select_payment_method。 order_payment_method: str = "クレジットカード" # 付款后监控(订单列表页轮询):间隔与最大轮询次数。2026-08-13 用真实订单号 # 实测过 order.my.rakuten.co.jp 的结构,见 site_interact.py::check_order_status。 # 间隔不宜太短——同一账号频繁访问订单页有被风控盯上的风险;默认 3 小时一次, # 最多轮询 80 次(约 10 天,覆盖绝大多数国内配送时长),到期仍未到「配達完了」 # 就停止(不是失败,只是不再继续追踪,详见 runner.py::_monitor_order)。 order_monitor_poll_interval_seconds: int = 3 * 3600 order_monitor_max_checks: int = 80 # ---- 自动重登(仅交易服务,需要 account.yaml)---- # 检测到登录态失效时,是否在 require_logged_in 内自动触发重登。需要项目根 # 存在 account.yaml(含明文密码,已在 .gitignore 排除)。未配置或文件缺失 # 时退化为原行为(抛 NotLoggedInError 让 worker 转 needs_human)。 relogin_enabled: bool = True # 自动重登(含人工 fallback 等待验证码)的最长总耗时。超时即判定失败。 # worker 内触发时整个任务会被卡住这段时间,所以不宜过长;本地机无人值守时 # 可以设小(如 60)尽快失败转 needs_human。 relogin_timeout_seconds: int = 300 # 启动时是否自动登录一次。容器部署用:镜像里没有落盘的 storage_state, # 开启后启动即按 account.yaml 自己登一次,不必先在宿主机跑 scripts/login.py。 # 后台执行(不阻塞 HTTP 端口),失败只记日志——服务照常提供 /health 与 # /api/auth/*,撞验证码可人工接管后调 /api/auth/login 重试。 auto_login_on_start: bool = False # ---- 下单任务网关(仅网关进程 app.gateway.main 使用)---- # 网关的 SQLite 文件路径(相对项目根目录)。任务队列与状态镜像都在这里, # 部署时务必放在持久化卷上,丢了等于丢了一批下单任务。 gateway_db_path: str = "data/gateway.db" # 任务租约 TTL(秒)。worker 领取后必须在此时间内首次 report 或 renew, # 否则网关把任务标记为 stale。**绝不自动重投**(见 docs/order-gateway.md §5)。 lease_ttl_seconds: int = 300 # 长轮询单次最长挂起秒数。worker 端的 wait 参数会被夹到这个上限。 lease_max_wait_seconds: int = Field(default=60, ge=1, le=300) # worker 心跳超时阈值(秒)。网关 /health 据此判断 worker 是否失联: # 正常 worker 每 30 秒来一次 lease,超过该阈值未来 lease 即视为异常。 worker_offline_alert_seconds: int = 300 # 终结类事件回调(callback_url)的单次 HTTP 超时(秒)。投递是 best-effort: # 超时/失败只记日志不重试,权威状态以 GET /api/orders/{task_id} 为准。 callback_timeout_seconds: float = 10.0 # ---- 账号只读查询通道(网关 + worker 两侧共用,见 docs/order-gateway.md §11)---- # 查询单租约 TTL(秒)。worker 领走后必须在此时间内回结果,否则网关把它 # **重投回 queued**——只读操作重复执行没有副作用,与下单任务的 stale 语义相反。 query_lease_ttl_seconds: int = 180 # 查询单整体存活上限(秒)。从创建算起,超过仍未拿到结果即置 expired # (多半是本地 worker 不在线)。上游据此判断「这张单不用再等了」。 query_ttl_seconds: int = 900 # 同一张查询单最多被领取几次。租约过期重投累计到这个次数仍无结果即置 failed, # 避免某条查询让 worker 反复卡死在同一个页面上。 query_max_attempts: int = 3 # 终态查询单的保留时长(秒)。结果里带站点原始 JSON,体积不小,超期由 sweep # 清掉;默认 7 天,够上游对账与排查。 query_retention_seconds: int = 7 * 24 * 3600 # worker 侧单次站点读取的耗时上限(秒)。查询与下单共用同一把账号锁,正在 # 跑的下单会让查询排队等待,这个上限保证查询不会一直挂着——超时按失败回报, # 上游重发即可(只读,重发无副作用)。 account_query_timeout_seconds: int = 120 # 订单列表查询缺省翻页上限。上游可在 params.max_pages 覆盖(1..20)。 account_query_default_max_pages: int = 3 # 回报给网关的结果 JSON 体积上限(字节)。超过时丢掉站点原始 JSON、只回 # 规范化字段并标记 raw_omitted——避免把几 MB 的页面状态塞进网关 SQLite。 query_result_max_bytes: int = 1024 * 1024 # ---- 定时下派通道(仅网关进程 app.gateway.main 使用,见 docs/order-gateway.md §12)---- # 网关周期性派 order_list / order_detail 查询、把账号真实订单沉淀进编目 # account_orders 表(回写网关)。关闭则 collector 完全不启动。 account_discovery_enabled: bool = True # 两轮 order_list 扫描的间隔(秒)。间隔也是「该订单详情何时到期需刷新」的 # 参照之一,默认 6 小时一次列表、24 小时刷新一次单笔详情,符合「周期性盘点 # 账号」的节奏,也不至于频繁戳订单页触发风控。 account_discovery_interval_seconds: int = 6 * 3600 account_discovery_max_pages: int = 3 # 已编目订单的详情刷新间隔(秒)。detail_fetched_at 距今超过这个值就再派一张 # order_detail 查询更新配送阶段。 account_detail_refresh_seconds: int = 24 * 3600 # ---- 本地下单 worker(仅交易服务内的 worker 子模块使用)---- # 网关 URL。**留空则不启动 worker**,交易服务只跑登录态接口。 # 部署形态:本地机(NAT 后无公网入口)通过出站长轮询领任务,详见 # docs/order-gateway.md。 order_gateway_url: str = "" # worker 标识。同一时间只能有一个 worker 持有 lease,这个值用来区分不同 # 本地机;留空时取主机名。 worker_id: str | None = None # 本地订单 SQLite 文件路径。订单主表 + 状态事件 + 证据索引都在这里, # 是执行事实的权威记录;网关上只是镜像。 trading_db_path: str = "data/trading.db" # 页面证据落盘目录(HTML 快照 + 截图 + meta.json),按 task_id 分子目录。 evidence_dir: str = "data/evidence" # 抓取服务基地址。worker 需要商品数据(加购要用 purchase 块)时出站请求这里, # 不直接 import 解析器——抓取与交易是两个进程,详见 README「两个部署单元」。 scraper_base_url: str = "" # ---- 目标站点 ---- 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 if self.proxy_bypass.strip(): proxy["bypass"] = self.proxy_bypass 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"{quote(self.proxy_username, safe='')}:{quote(self.proxy_password or '', safe='')}" return f"{scheme}://{credentials}@{rest}" def proxy_bypasses(self, url: str) -> bool: """目标 URL 的主机是否应绕过外部代理。""" host = urlsplit(url).hostname if not host: return False normalized_host = host.rstrip(".").lower() for raw_pattern in self.proxy_bypass.split(","): pattern = raw_pattern.strip().rstrip(".").lower() if not pattern: continue if pattern.startswith("."): suffix = pattern[1:] if normalized_host == suffix or normalized_host.endswith(f".{suffix}"): return True continue if fnmatchcase(normalized_host, pattern): return True return False @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 @property def gateway_db_path_resolved(self) -> Path: """网关 SQLite 文件的绝对路径,父目录不存在时创建""" path = Path(self.gateway_db_path) if not path.is_absolute(): path = BASE_DIR / path path.parent.mkdir(parents=True, exist_ok=True) return path @property def trading_db_path_resolved(self) -> Path: """本地订单 SQLite 文件的绝对路径,父目录不存在时创建""" path = Path(self.trading_db_path) if not path.is_absolute(): path = BASE_DIR / path path.parent.mkdir(parents=True, exist_ok=True) return path @property def evidence_path(self) -> Path: """证据目录的绝对路径,不存在时创建""" path = Path(self.evidence_dir) if not path.is_absolute(): path = BASE_DIR / path path.mkdir(parents=True, exist_ok=True) return path @property def worker_id_effective(self) -> str: """worker 标识:显式配置优先,否则取主机名""" return self.worker_id or socket.gethostname() @lru_cache(maxsize=1) def get_settings() -> Settings: """获取全局配置单例""" return Settings()