拆分抓取与交易服务

把需要账号登录态的链路从抓取服务里拆出成独立进程。分界线不是「要不要登录」,
而是抓取无状态、幂等、可多开实例,而交易的写操作不可逆、登录态全局唯一、
订单监控是常驻轮询——同进程时抓取一扩容就会复制出 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>
This commit is contained in:
2026-07-27 15:05:01 +08:00
co-authored by Claude Opus 5
parent 4250388762
commit 104d7fef6b
80 changed files with 2330 additions and 402 deletions
+135
View File
@@ -0,0 +1,135 @@
"""人工登录:起一个有头浏览器,由人完成登录,落盘 cookie 供服务复用
为什么必须人工:乐天与 ラクマ 的登录都带 reCAPTCHA 与设备验证(短信/邮箱 OTP),
自动填表的成功率既低又不稳定,还要在本地存明文密码。这里的取舍是——**密码只经过
你和站点,不经过本服务**:脚本只负责把浏览器打开、等你登录完,然后把 cookie 存下来。
用法:
.venv/Scripts/python.exe scripts/login.py --site rakuten
.venv/Scripts/python.exe scripts/login.py --site rakuma
.venv/Scripts/python.exe scripts/login.py --site all
浏览器窗口打开后手动完成登录,脚本会自动轮询登录态;检测到已登录即保存并退出。
也可以登录完成后回到终端按回车立即保存。
产物落在 settings.auth_state_dir(默认 .auth/),内含可直接冒充账号的 cookie,
已在 .gitignore 里排除,不要提交、不要外传。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
# 允许以 `python scripts/login.py` 直接运行
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.shared.config import get_settings # noqa: E402
from app.trading.core import auth_site # noqa: E402
# 轮询间隔与总时长:给足人工过验证码、收短信的时间
_POLL_INTERVAL_SECONDS = 5
_POLL_TIMEOUT_SECONDS = 600
async def _is_logged_in(page, site: str) -> bool:
"""在浏览器里探测登录态
判据与 AuthSession 共用 auth_site.is_logged_in,只是这里喂浏览器页面内容、
那里喂 httpx 响应,避免两条链路对「算不算登录」各判各的。
"""
await page.goto(
auth_site.profile(site).probe_url, wait_until="domcontentloaded", timeout=60_000
)
return auth_site.is_logged_in(site, final_url=page.url, body=await page.content())
async def login(site: str) -> bool:
"""打开浏览器让用户登录指定站点,成功则保存 storage_state"""
from playwright.async_api import async_playwright
settings = get_settings()
profile = auth_site.profile(site)
state_path = settings.auth_state_path / profile.state_filename
print(f"\n=== {profile.label}{site})登录 ===")
print(f"即将打开浏览器:{profile.login_url}")
print("请在浏览器窗口里完成登录(账号密码只在浏览器与站点之间传递,本脚本不读取)。")
print(f"登录完成后脚本会自动检测,最长等待 {_POLL_TIMEOUT_SECONDS // 60} 分钟。\n")
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(
headless=False, # 人工登录必须有头
channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
)
try:
context = await browser.new_context(
# UA 取自 auth_site:服务端后续用同一个 UA 发请求,换了可能触发
# 站点的设备校验,让这次辛苦登来的 cookie 提前失效。
user_agent=profile.user_agent,
locale="ja-JP",
timezone_id="Asia/Tokyo",
viewport=(
{"width": 390, "height": 844}
if profile.mobile
else {"width": 1440, "height": 900}
),
is_mobile=profile.mobile,
has_touch=profile.mobile,
)
page = await context.new_page()
await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000)
waited = 0
while waited < _POLL_TIMEOUT_SECONDS:
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
waited += _POLL_INTERVAL_SECONDS
try:
if await _is_logged_in(page, site):
break
except Exception as exc: # 页面正在跳转时探测可能失败,继续等
print(f" 探测中({waited}s):{type(exc).__name__}")
continue
print(f" 等待登录中…({waited}s)")
else:
print(f"{profile.label} 等待超时,未检测到登录态。未保存。")
return False
state = await context.storage_state()
state_path.write_text(
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"{profile.label} 登录成功,已保存 {len(state.get('cookies', []))} 条 cookie")
print(f"{state_path}")
return True
finally:
await browser.close()
async def main() -> int:
parser = argparse.ArgumentParser(description="人工登录并保存乐天/ラクマ 登录态")
parser.add_argument(
"--site",
choices=[*auth_site.SITES, "all"],
default="all",
help="要登录的站点,默认两站都登录",
)
args = parser.parse_args()
targets = list(auth_site.SITES) if args.site == "all" else [args.site]
results = {site: await login(site) for site in targets}
print("\n=== 结果 ===")
for site, ok in results.items():
print(f" {site}: {'已登录' if ok else '失败'}")
print("\n登录态已就绪,可启动服务并用 POST /api/auth/status 复核。")
return 0 if all(results.values()) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))