把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」, 而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、 订单监控是常驻轮询——同进程时抓取一扩容就会复制出 N 份登录态与 N 个轮询, 同一账号会被并发操作。 - app/shared:配置、错误码、日志、ApiResponse 信封 + Bearer 鉴权 + 异常处理器、 导航请求头构造器 - app/scraping:站点常量、会话、解析器与 10 个抓取接口,:31107,可多开 - app/trading:登录态查询/重载与健康检查,:31108,只能单实例 - 依赖方向锁为 scraping→shared、trading→shared,两侧互不 import; tests/test_architecture.py 用 AST 检查 import 并校验两个 app 的路径不串 - 登录态 UA 在 trading 独立持有:与抓取 UA 值相同但变更理由不同,抓取 UA 为绕 反爬可随时调整,登录 UA 一改可能触发设备校验使已落盘 cookie 失效 - scripts/login.py 与 AuthSession 共用 auth_site.PROFILES 与 is_logged_in,判据只写一遍 - 同一镜像两个启动命令,交易容器覆盖 command 并设 RAKUTEN_HEALTH_PORT 同时带上此前未提交的 ラクマ 分类接口与登录态基础设施。 验证:239 个离线用例全绿;两个入口真实启动,/health 与鉴权正常。 未验证:真实探测登录态(当前开发机无外网,对站点的连接全部超时)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
5.9 KiB
Python
151 lines
5.9 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
|
|
|
|
# ---- 登录态与下单配置(仅交易服务)----
|
|
# 人工登录一次后落盘的 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()
|