交易服务收敛为仅 rakuten:移除 rakuma 登录态与下单入口
trading 不再管理 ラクマ 的购物车购买、支付与订单监控。删除 auth_site / auth_session / login_runner / models.AuthSite 中的 rakuma 分支与常量、 account.yaml.example 的 rakuma 段,并清理相关测试。scraping 侧的 ラクマ 抓取 API(/api/rakuma/*)保留不动。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""登录态路由:查询与重新加载账号登录态
|
||||
|
||||
抓取接口全部匿名,只有加购与下单需要账号。登录本身不在这里做——两站登录都要过
|
||||
抓取接口全部匿名,只有加购与下单需要账号。登录本身不在这里做——乐天登录要过
|
||||
reCAPTCHA 与设备验证,由 `scripts/login.py` 起有头浏览器人工完成一次,
|
||||
本服务只读取落盘的 cookie。这里提供的是运维视角的两个动作:
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ async def health(
|
||||
) -> ApiResponse[TradingHealthData]:
|
||||
"""交易服务健康状态
|
||||
|
||||
auth 给出两站账号登录态。这里只读缓存、不触发网络探测,避免健康检查被
|
||||
auth 给出账号登录态。这里只读缓存、不触发网络探测,避免健康检查被
|
||||
上游高频轮询时反复打站点;要实时结果请用 POST /api/auth/status。
|
||||
|
||||
注意 `logged_in=null` 表示服务启动后还没探测过,不等于未登录。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user