Files
rakuten-api/app/shared/config.py
T
q792602257andClaude Sonnet 5 51e2538438 实现付款后订单监控:真实探测 order.my.rakuten.co.jp 配送阶段
用真实订单号 306087-20260813-0863947697 探测 order.my.rakuten.co.jp(订单列表/
详情页),拿到真实 DOM 结构后实现 SiteInteractor.check_order_status:

- 详情页 URL 可直接从 site_order_id 构造(shop_id 是订单号第一段)
- 配送阶段用「进度条」组件的 4 个固定阶段(ショップ/出荷/配達店/配達完了),
  当前阶段的 class 带 -active-- 中缀,映射到 OrderState.SHIPPED/DELIVERED
- 查不到订单号、进度条解析不出新阶段都不算错误,交给轮询循环继续重试

runner.WorkerRunner 新增 _monitor_order 后台轮询:付款成功上报后以
asyncio.create_task 起后台任务(不阻塞主循环领下一单,因为 SiteInteractor 的
Playwright 操作全程持锁串行化),状态变化时用 terminal=False 追加 report;
新增 cancel_monitors() 在服务关闭时于 SiteInteractor.close() 之前收尾。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:01:35 +08:00

252 lines
12 KiB
Python

"""应用配置:通过环境变量和 .env 文件加载所有配置项
配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。
支持 .env 文件自动加载。
抓取、交易与下单任务网关是三个进程,但共用这一个 Settings 类:三方都要日志、
代理、超时与同一个 Bearer token,拆成三份配置只会让部署时多维护一套。下面按
「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。
"""
import socket
from functools import lru_cache
from pathlib import Path
from typing import Literal
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
# ---- 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
# ---- 下单任务网关(仅网关进程 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
# ---- 本地下单 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
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
@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()