diff --git a/README.md b/README.md index 846c479..9b87748 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ PC UA 在搜索页、详情页、店铺页上都能拿到完整模板。因此 | 接口 | 说明 | | --- | --- | -| `GET /health` | 健康检查,含两站登录态(只读缓存,不打站点) | +| `GET /health` | 健康检查,含乐天账号登录态(只读缓存,不打站点) | | `POST /api/auth/status` | 查询登录态,默认真实探测一次 | | `POST /api/auth/reload` | 人工重新登录后免重启换上新 cookie | diff --git a/account.yaml.example b/account.yaml.example index 937df8d..f7718d9 100644 --- a/account.yaml.example +++ b/account.yaml.example @@ -32,9 +32,3 @@ rakuten: # - id: secondary # username: another@example.com # password: REPLACE_ME - -rakuma: - - id: primary - username: your-email@example.com - password: REPLACE_ME - default: true diff --git a/app/trading/api/routes/auth.py b/app/trading/api/routes/auth.py index 8c06a2c..11c9f9b 100644 --- a/app/trading/api/routes/auth.py +++ b/app/trading/api/routes/auth.py @@ -1,6 +1,6 @@ """登录态路由:查询与重新加载账号登录态 -抓取接口全部匿名,只有加购与下单需要账号。登录本身不在这里做——两站登录都要过 +抓取接口全部匿名,只有加购与下单需要账号。登录本身不在这里做——乐天登录要过 reCAPTCHA 与设备验证,由 `scripts/login.py` 起有头浏览器人工完成一次, 本服务只读取落盘的 cookie。这里提供的是运维视角的两个动作: diff --git a/app/trading/api/routes/health.py b/app/trading/api/routes/health.py index 937215d..87fabfd 100644 --- a/app/trading/api/routes/health.py +++ b/app/trading/api/routes/health.py @@ -14,7 +14,7 @@ async def health( ) -> ApiResponse[TradingHealthData]: """交易服务健康状态 - auth 给出两站账号登录态。这里只读缓存、不触发网络探测,避免健康检查被 + auth 给出账号登录态。这里只读缓存、不触发网络探测,避免健康检查被 上游高频轮询时反复打站点;要实时结果请用 POST /api/auth/status。 注意 `logged_in=null` 表示服务启动后还没探测过,不等于未登录。 diff --git a/app/trading/core/auth_site.py b/app/trading/core/auth_site.py index 3fe50f6..35282e3 100644 --- a/app/trading/core/auth_site.py +++ b/app/trading/core/auth_site.py @@ -4,13 +4,18 @@ 「匿名抓取怎么拿到页面」,这里描述的是「怎么判断这套 cookie 还登录着」以及登录 入口在哪。 -登录态探针的选取原则:挑一个**未登录时行为明确可辨**的页面,而不是靠首页上有没有 -用户名这类会随改版漂移的文案。两站各自的信号(均为实测): +交易服务只管理乐天市场的购物车购买、支付与订单监控;ラクマ 的抓取仍在抓取服务里 +提供,但不进入交易链路(没有加购契约,也永不实现付款/订单监控)。 -- 乐天:手机版购物车页始终返回 200,未登录时正文含「現在ログインしていません」, - 登录后该串消失。购物车页同时是加购结果的校验页,一页两用。 -- ラクマ:`/mypage` 未登录时 302 到 `/users/sign_in`,登录后停在 `/mypage`。 - 用落地 URL 判断比翻文案稳。 +登录态探针的选取原则:挑一个**未登录时行为明确可辨**的页面,而不是靠首页上有没有 +用户名这类会随改版漂移的文案。乐天的信号(实测):手机版购物车页始终返回 200, +未登录时正文含「現在ログインしていません」,登录后该串消失。购物车页同时是加购 +结果的校验页,一页两用。 + +> 注意:`checkout-flow-probe-findings §2` 已确认新版 SP cart SPA 不再渲染这个 +> marker,`auth_session._check_rakuten` 仍用它的原因是 httpx 拿到的是 SSR 落地 +> HTML(SPA bundle 之前的响应体),在 SSR 阶段 marker 仍在。Playwright 渲染后的 +> 页面判据换走 `__INITIAL_STATE__.user.isLoggedIn`,详见 site_interact.py。 每站的这组事实收在 `PROFILES` 里,`scripts/login.py`(起浏览器人工登录)与 `AuthSession`(在 httpx 里探测)共用同一份,避免两处各写一遍判据后悄悄漂移。 @@ -32,15 +37,8 @@ RAKUTEN_LOGIN_URL: Final = "https://www.rakuten.co.jp/myrakuten/" # 购物车页上表示「当前会话未登录」的文案。出现即判定登录态失效。 RAKUTEN_LOGGED_OUT_MARKER: Final = "現在ログインしていません" -# ---- ラクマ ---- -RAKUMA_MYPAGE_URL: Final = "https://fril.jp/mypage" -RAKUMA_LOGIN_URL: Final = "https://fril.jp/users/sign_in" - -# 未登录时会被重定向到的登录页路径特征 -RAKUMA_SIGN_IN_PATH: Final = "/users/sign_in" - # ---- 登录态请求指纹 ---- -# 这两个 UA 必须与 scripts/login.py 起浏览器时用的一致:cookie 是在那个 UA 下拿到的, +# 这个 UA 必须与 scripts/login.py 起浏览器时用的一致:cookie 是在那个 UA 下拿到的, # 服务端再拿它发请求时换了 UA,可能触发站点的设备校验让登录态提前失效。 # # 值与抓取侧当前相同,但**刻意不复用**抓取侧的常量:抓取 UA 是为绕反爬服务的, @@ -49,10 +47,6 @@ RAKUTEN_USER_AGENT: Final = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" ) -RAKUMA_USER_AGENT: Final = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" -) @dataclass(frozen=True, slots=True) @@ -82,15 +76,6 @@ PROFILES: Final[dict[str, SiteAuthProfile]] = { mobile=True, state_filename="rakuten_state.json", ), - "rakuma": SiteAuthProfile( - name="rakuma", - label="ラクマ", - login_url=RAKUMA_LOGIN_URL, - probe_url=RAKUMA_MYPAGE_URL, - user_agent=RAKUMA_USER_AGENT, - mobile=False, - state_filename="rakuma_state.json", - ), } SITES: Final = tuple(PROFILES) @@ -107,9 +92,12 @@ def profile(site: str) -> SiteAuthProfile: def is_logged_in(site: str, *, final_url: str, body: str) -> bool: """按该站判据,从探针页的落地 URL 与正文判断是否仍登录着 + `site` 参数目前恒为 `"rakuten"`,保留参数形态是为了与 `scripts/login.py` 共用 + 签名,未来若要加站点不必同时改两处调用方。 + 两处共用:`AuthSession` 传 httpx 响应的 URL 与文本,`scripts/login.py` 传 浏览器页面的 URL 与内容。判据只写一遍,两条链路不会各判各的。 """ - if site == "rakuten": - return RAKUTEN_LOGGED_OUT_MARKER not in body - return RAKUMA_SIGN_IN_PATH not in final_url + if site != "rakuten": + raise ValueError(f"未知站点:{site}") + return RAKUTEN_LOGGED_OUT_MARKER not in body diff --git a/app/trading/models.py b/app/trading/models.py index 4ac4bdf..aea549b 100644 --- a/app/trading/models.py +++ b/app/trading/models.py @@ -15,16 +15,18 @@ from pydantic import BaseModel, Field class AuthSite(StrEnum): - """支持登录的站点""" + """支持登录的站点 + + 交易服务只管理乐天市场的购物车购买、支付与订单监控;ラクマ 不进入交易链路。 + """ RAKUTEN = "rakuten" - RAKUMA = "rakuma" class AuthStatusRequest(BaseModel): """登录态查询请求""" - site: AuthSite | None = None # 不传则返回两站 + site: AuthSite | None = None # 不传则返回所有已配置站点 refresh: bool = True # 是否真实打一次请求探测;false 时只读缓存 @@ -47,7 +49,7 @@ class AuthStatusData(BaseModel): class AuthReloadRequest(BaseModel): """重新加载登录态请求(人工登录完成后调用,免重启服务)""" - site: AuthSite | None = None # 不传则两站都重载 + site: AuthSite | None = None # 不传则重载所有已配置站点 class AuthReloadData(BaseModel): diff --git a/app/trading/services/auth_session.py b/app/trading/services/auth_session.py index d699be5..390e80e 100644 --- a/app/trading/services/auth_session.py +++ b/app/trading/services/auth_session.py @@ -4,12 +4,15 @@ 而是分进程运行,原因: - 抓取是**匿名**的,cookie 只用来过 Akamai 限速,丢了重新预热即可,无状态可言。 -- 加购下单必须**带账号**,cookie 一旦失效需要重登——乐天与 ラクマ 登录都有 - reCAPTCHA 与设备验证,全自动不可靠。本服务支持「失效时自动重登 + 撞验证码弹 - 有头浏览器让人工接管」的降级路径(依赖 account.yaml 与 relogin_enabled)。 +- 加购下单必须**带账号**,cookie 一旦失效需要重登——乐天登录有 reCAPTCHA 与设备 + 验证,全自动不可靠。本服务支持「失效时自动重登 + 撞验证码弹有头浏览器让人工接管」 + 的降级路径(依赖 account.yaml 与 relogin_enabled)。 - 这份登录态在整个系统里只能有一份。跟抓取同进程的话,抓取一扩容就会复制出 N 份 登录态与 N 个订单轮询,同一个账号被并发操作。 +交易服务只管理乐天市场的购物车购买、支付与订单监控;ラクマ 抓取仍在抓取服务里 +提供,但不进入交易链路。本模块只持有 rakuten 一个站点的登录态。 + 登录态来源是 Playwright 的 storage_state: - 首次登录由 scripts/login.py 完成; - 运行时失效且 relogin_enabled=true 时,本模块自动触发 login_runner.login_one() @@ -75,7 +78,7 @@ class _SiteAuth: class AuthSession: - """持有两站登录态的会话 + """持有登录态的会话 职责边界:只负责「有没有登录态、cookie 是什么、还有效吗」, 具体加购/下单的业务请求由各自的 client 组装后借这里的 HTTP 客户端发出。 @@ -90,7 +93,7 @@ class AuthSession: # ---- 生命周期 ---- async def start(self) -> None: - """为两站创建带登录 cookie 的 HTTP 客户端 + """为站点创建带登录 cookie 的 HTTP 客户端 登录态文件不存在时同样创建客户端(只是没有 cookie),这样 /health 与 /api/auth/status 能如实回报「未登录」,而不是整个服务起不来。 @@ -175,10 +178,9 @@ class AuthSession: auth = self._require_site(site) async with auth.lock: try: - if site == "rakuten": - logged_in, detail = await self._check_rakuten(auth) - else: - logged_in, detail = await self._check_rakuma(auth) + if site != "rakuten": + raise ValueError(f"未知站点:{site}") + logged_in, detail = await self._check_rakuten(auth) except httpx.HTTPError as exc: raise UpstreamRequestError( f"登录态探测请求失败:site={site} err={type(exc).__name__}: {exc}" @@ -201,17 +203,6 @@ class AuthSession: return False, "购物车页显示未登录" return True, "购物车页未出现未登录标记" - async def _check_rakuma(self, auth: _SiteAuth) -> tuple[bool, str]: - """/mypage 被重定向到 /users/sign_in 即未登录""" - response = await auth.client.get(auth_site.PROFILES["rakuma"].probe_url) - if response.status_code >= 400: - return False, f"mypage 返回 status {response.status_code}" - if not auth_site.is_logged_in( - "rakuma", final_url=str(response.url), body=response.text - ): - return False, "mypage 被重定向至登录页" - return True, "mypage 正常返回" - async def require_logged_in(self, site: str) -> None: """确保某站处于登录态,否则抛错 @@ -338,7 +329,7 @@ class AuthSession: ) def status_all(self) -> dict[str, dict[str, Any]]: - """两站登录态快照,供健康检查展示""" + """登录态快照,供健康检查展示""" return {name: self.status(name).to_dict() for name in self._sites} def _require_site(self, site: str) -> _SiteAuth: diff --git a/app/trading/services/login_runner.py b/app/trading/services/login_runner.py index b913b8c..bbd98b7 100644 --- a/app/trading/services/login_runner.py +++ b/app/trading/services/login_runner.py @@ -176,15 +176,16 @@ def default_account_for(site: str, accounts_by_site: dict[str, list[Account]]) - async def _is_logged_in(page, site: str) -> bool: """访问 profile.login_url 看落地是否被踢到 SSO - - rakuten:未登录会被重定向到 login.account.rakuten.com 或 /login 路径 - - rakuma:未登录会被重定向到 /users/sign_in + `site` 目前恒为 `"rakuten"`:未登录时会被重定向到 login.account.rakuten.com + 或 /login 路径;登录后停在 my.rakuten.co.jp。保留 site 参数形态是为了与 + SiteAuthProfile 共用签名,方便未来加站点。 """ profile = auth_site.profile(site) await page.goto(profile.login_url, wait_until="domcontentloaded", timeout=60_000) final_url = page.url - if site == "rakuten": - return "login.account.rakuten.com" not in final_url and "/login" not in final_url - return auth_site.RAKUMA_SIGN_IN_PATH not in final_url + if site != "rakuten": + raise ValueError(f"未知站点:{site}") + return "login.account.rakuten.com" not in final_url and "/login" not in final_url # ---- 自动填表(启发式选择器)---- @@ -380,7 +381,7 @@ async def cli_main(argv: list[str] | None = None) -> int: """ import argparse - parser = argparse.ArgumentParser(description="按 account.yaml 自动登录乐天/ラクマ") + parser = argparse.ArgumentParser(description="按 account.yaml 自动登录乐天市场") parser.add_argument( "--site", choices=[*auth_site.SITES, "all"], diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py index 50d7e37..24d799c 100644 --- a/app/trading/worker/runner.py +++ b/app/trading/worker/runner.py @@ -227,9 +227,9 @@ class WorkerRunner: """ await self._db.ensure_started(task.task_id, task.site, task.intent) - # ラクマ 加购契约未实现,首版只对乐天。其他站点直接转人工 + # 交易服务只覆盖乐天市场。其他站点直接转人工 if task.site != "rakuten": - raise NotImplementedError(f"site={task.site} 暂未实现下单(首版仅 rakuten)") + raise NotImplementedError(f"site={task.site} 暂不在交易服务范围内(仅 rakuten)") # 步骤 1:加购 await self._run_step( diff --git a/docs/order-gateway.md b/docs/order-gateway.md index c7d48b6..7927f31 100644 --- a/docs/order-gateway.md +++ b/docs/order-gateway.md @@ -28,7 +28,7 @@ order-gateway(服务器:任务队列 + 状态镜像,本文要新建的服 trading worker(本地,app/trading 内,零入站端口) │ ④ 加购 → 下单 → 付款,每步落证据到本地磁盘 ▼ - 乐天 / ラクマ + 乐天市场 ``` 抓取服务(:31107)不参与这条链路的编排。本地需要商品数据时,直接出站请求抓取服务的 @@ -67,7 +67,7 @@ queued ──lease──> leased ──首次 report──> running ──termin ```sql CREATE TABLE tasks ( task_id TEXT PRIMARY KEY, -- 幂等键,见 §4.2 - site TEXT NOT NULL, -- rakuten | rakuma + site TEXT NOT NULL, -- rakuten(交易服务仅覆盖乐天市场) intent_json TEXT NOT NULL, -- 下单意图原文,gateway 不解释内容 status TEXT NOT NULL, -- 见 §3 lease_owner TEXT, -- worker_id @@ -325,4 +325,6 @@ trading 侧新增: 「下单到 `awaiting_payment` + 上报 `needs_human` 交人工」,其余环节不变。 2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。 3. 订单列表页能否按商品 + 时间窗口可靠地反查出「这单下没下」(§5 的恢复核对依赖它)。 -4. ラクマ 侧尚无加购契约(README 已注明未实现),首版只做乐天市场。 + +> **交易范围**:交易服务只覆盖乐天市场(rakuten)。ラクマ 的抓取仍在抓取服务里提供, +> 但不进入交易链路——没有加购契约,也永不实现下单/付款/订单监控。 diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py index 141cd09..b4435e6 100644 --- a/tests/test_auth_session.py +++ b/tests/test_auth_session.py @@ -22,8 +22,6 @@ from app.trading.services.auth_session import AuthSession CART_LOGGED_OUT = f"買い物かご {auth_site.RAKUTEN_LOGGED_OUT_MARKER}" CART_LOGGED_IN = "買い物かご 商品が1点入っています" -MYPAGE_HTML = "マイページ" - def make_settings(tmp_path: Path, **overrides) -> Settings: base = { @@ -45,7 +43,7 @@ def write_state(settings: Settings, site: str, cookies: list[dict]) -> Path: async def build_session(settings: Settings, handler) -> AuthSession: - """构建 AuthSession 并把两站 client 换成 MockTransport 版本 + """构建 AuthSession 并把 client 换成 MockTransport 版本 换掉 client 会丢掉 start() 时灌进去的 cookie,因此重新走一次 reload 把登录态读回新客户端;reload 同时清空探测缓存,正好是测试想要的干净起点。 @@ -79,8 +77,6 @@ async def test_loads_cookies_from_state_file(tmp_path): try: names = {c.name for c in session.client("rakuten").cookies.jar} assert "SESSION" in names - # 没有 state 文件的那一站应为空,而不是报错 - assert not list(session.client("rakuma").cookies.jar) finally: await session.close() @@ -139,38 +135,6 @@ async def test_rakuten_detects_logged_in(tmp_path): await session.close() -async def test_rakuma_detects_logged_out_by_redirect(tmp_path): - """/mypage 被重定向到登录页即判定未登录 - - ラクマ 未登录时返回 302 而非改文案,因此判据是落地 URL 不是页面内容。 - """ - settings = make_settings(tmp_path) - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/mypage": - return httpx.Response(302, headers={"Location": auth_site.RAKUMA_LOGIN_URL}) - return httpx.Response(200, text="ログイン") - - session = await build_session(settings, handler) - try: - status = await session.check("rakuma") - assert status.logged_in is False - assert "登录页" in status.detail - finally: - await session.close() - - -async def test_rakuma_detects_logged_in(tmp_path): - """/mypage 正常返回即判定已登录""" - settings = make_settings(tmp_path) - session = await build_session(settings, lambda r: httpx.Response(200, text=MYPAGE_HTML)) - try: - status = await session.check("rakuma") - assert status.logged_in is True - finally: - await session.close() - - async def test_error_status_counts_as_logged_out(tmp_path): """探测页返回 4xx/5xx 时保守判定为未登录,不放行下单""" settings = make_settings(tmp_path) diff --git a/tests/test_trading_api.py b/tests/test_trading_api.py index eeb5d67..1456aa3 100644 --- a/tests/test_trading_api.py +++ b/tests/test_trading_api.py @@ -27,7 +27,7 @@ class StubAuthSession: @property def sites(self) -> tuple[str, ...]: - return ("rakuten", "rakuma") + return ("rakuten",) def _status(self, site: str) -> AuthStatus: return AuthStatus( @@ -87,7 +87,7 @@ def test_health_needs_no_token(client): assert response.status_code == 200 body = response.json() assert body["data"]["status"] == "ok" - assert set(body["data"]["auth"]) == {"rakuten", "rakuma"} + assert set(body["data"]["auth"]) == {"rakuten"} def test_health_does_not_probe_the_site(client, stub): @@ -116,12 +116,12 @@ def test_auth_endpoints_reject_wrong_token(client): # ---- 登录态查询 ---- -def test_status_probes_both_sites_by_default(client, stub): +def test_status_probes_site_by_default(client, stub): response = client.post("/api/auth/status", json={}, headers=AUTH) assert response.status_code == 200 body = response.json() - assert [item["site"] for item in body["data"]["sites"]] == ["rakuten", "rakuma"] - assert stub.checked == ["rakuten", "rakuma"] + assert [item["site"] for item in body["data"]["sites"]] == ["rakuten"] + assert stub.checked == ["rakuten"] def test_status_can_skip_the_probe(client, stub): @@ -132,9 +132,9 @@ def test_status_can_skip_the_probe(client, stub): def test_status_accepts_a_single_site(client, stub): - response = client.post("/api/auth/status", json={"site": "rakuma"}, headers=AUTH) + response = client.post("/api/auth/status", json={"site": "rakuten"}, headers=AUTH) assert response.status_code == 200 - assert stub.checked == ["rakuma"] + assert stub.checked == ["rakuten"] def test_status_rejects_unknown_site(client): diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py index 0e78b8c..a280121 100644 --- a/tests/test_worker_runner.py +++ b/tests/test_worker_runner.py @@ -252,11 +252,11 @@ async def test_unimplemented_site_interaction_becomes_needs_human( assert "未实现" in terminal["detail"] -# ---- ラクマ 不实现 → needs_human ---- +# ---- ラクマ 不在交易范围 → needs_human ---- async def test_rakuma_site_reports_needs_human(runner: WorkerRunner): - """首版只对乐天;其他站点直接转人工""" + """交易服务只对乐天;其他站点(含 ラクマ)直接转人工""" await runner.handle(_make_task(task_id="t1", site="rakuma")) gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]