- try_relogin 的并发去重原本读 status().logged_in,但重登成功后的 reload() 会把它重置成 None,排队在 site 锁上的调用方一律判「还没人登上」,N 个并发 调用会串行触发 N 次真实登录(各自最长 relogin_timeout)。改用 _relogin_epochs 计数:等锁期间 epoch 变过就真探测一次,已登录即跳过;仍未登录说明这轮站点侧 就是登不上(验证码/密码错/风控),直接失败,不在同一波并发里重复触发。 原并发测试的桩自相矛盾(login_one 返回成功、探针页始终回未登录),断言只能 松到 count >= 1;桩改为登录成功时翻转探针页,断言收紧到 count == 1。 - check_order_status / list_recent_orders 执行中掉登录此前会被静默吞掉:订单页 被踢到 SSO 后既不报错也没订单号,_parse_order_status 返回 found=False 被 _monitor_order 当成「订单还没反映出来」继续轮询(默认 3 小时一轮), _parse_order_list 则退化成空列表让 verify_on_site 转 unknown 卡住等人工。 新增 SiteInteractor._read_with_relogin_retry 外壳:只读操作中途判定掉登录时 重登一次并整个重跑,第二次仍失败抛 NotLoggedInError。只给读操作用——写操作 中途掉登录不能重跑(上次动作可能已在站点侧生效),这条边界在两边文档里写明。 - 判据是新增的 auth_site.looks_logged_out:与探针页上权威的 is_logged_in 分开, 它是业务页上的单边启发式(返回 False 不代表登录着),只用于「判错最多多花一次 重登」的重试决策。刻意排除 session/upgrade——那是已登录时的站点风控复核密码, 不是 cookie 过期,误判会把风控当掉登录去重登。 判据里「掉登录会跳到 SSO 域」这一步没有真实探测证据(要复现得先让一份真实登录态 过期),是按站点通行行为的推断,已在常量注释标注;新增测试用替身页面,不是真实 站点 HTML。399 测试全绿(仓库未配 ruff/flake8/mypy,只跑了 pytest)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
7.4 KiB
Python
152 lines
7.4 KiB
Python
"""登录态相关的站点常量
|
|
|
|
与抓取用的 `scraping/core/site.py` / `rakuma_site.py` 分开:那两个模块描述的是
|
|
「匿名抓取怎么拿到页面」,这里描述的是「怎么判断这套 cookie 还登录着」以及登录
|
|
入口在哪。
|
|
|
|
交易服务只管理乐天市场的购物车购买、支付与订单监控;ラクマ 的抓取仍在抓取服务里
|
|
提供,但不进入交易链路(没有加购契约,也永不实现付款/订单监控)。
|
|
|
|
登录态探针的选取原则:挑一个**未登录时行为明确可辨**的页面,而不是靠首页上有没有
|
|
用户名这类会随改版漂移的文案。乐天的信号(实测):手机版购物车页始终返回 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 里探测)共用同一份,避免两处各写一遍判据后悄悄漂移。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
|
|
from app.shared import headers
|
|
|
|
# ---- 乐天市场 ----
|
|
# 手机版购物车。加购走的是 sp.basket 集群,校验也用手机版,保持同一套指纹。
|
|
RAKUTEN_CART_URL: Final = "https://sp.cart.step.rakuten.co.jp/cart"
|
|
|
|
# 登录入口。人工登录时打开这个地址,站点会在登录成功后跳回 my.rakuten.co.jp。
|
|
RAKUTEN_LOGIN_URL: Final = "https://www.rakuten.co.jp/myrakuten/"
|
|
|
|
# 购物车页上表示「当前会话未登录」的文案。出现即判定登录态失效。
|
|
RAKUTEN_LOGGED_OUT_MARKER: Final = "現在ログインしていません"
|
|
|
|
# 未登录时访问「需要登录才能看」的页面(订单列表/详情等)会被整页跳到 SSO 域。
|
|
# 判据来源:login_runner 模块文档记录的实测——SSO 落地域名确实是这两个之一。
|
|
# 但「订单页在登录态失效时一定跳到这里」这一步**没有**真实探测证据(要复现得
|
|
# 先让一份真实登录态过期),是按站点通行行为的推断,用途也限定在
|
|
# `looks_logged_out` 那种「判错只多花一次重登」的场景,不作为正向结论使用。
|
|
RAKUTEN_SSO_URL_MARKERS: Final = (
|
|
"login.account.rakuten.com",
|
|
"grp01.id.rakuten.co.jp",
|
|
)
|
|
|
|
# SSO 域下的「已登录但要求复核密码」路径。这**不是**登录态失效:结算流程里即使
|
|
# 会话有效也会被站点风控要求重输密码(见 site_interact.py 模块文档与
|
|
# _SESSION_UPGRADE_URL_MARKERS)。落在这些路径上时不能判未登录,否则会把风控
|
|
# 拦截误当成 cookie 过期去重登。
|
|
RAKUTEN_SSO_UPGRADE_PATH_MARKERS: Final = ("session/upgrade",)
|
|
|
|
# ---- 登录态请求指纹 ----
|
|
# 这个 UA 必须与 scripts/login.py 起浏览器时用的一致:cookie 是在那个 UA 下拿到的,
|
|
# 服务端再拿它发请求时换了 UA,可能触发站点的设备校验让登录态提前失效。
|
|
#
|
|
# 值与抓取侧当前相同,但**刻意不复用**抓取侧的常量:抓取 UA 是为绕反爬服务的,
|
|
# 随时可能为了成功率被调整,那种调整不该波及已落盘的账号 cookie。
|
|
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"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SiteAuthProfile:
|
|
"""一个站点的登录态配置:登录入口、探针、指纹与落盘文件名"""
|
|
|
|
name: str
|
|
label: str
|
|
login_url: str
|
|
probe_url: str # 判断登录态是否仍有效的页面
|
|
user_agent: str
|
|
mobile: bool
|
|
state_filename: str # storage_state 落盘文件名(放在 settings.auth_state_dir 下)
|
|
|
|
def headers(self) -> dict[str, str]:
|
|
"""该站登录态请求用的完整导航请求头"""
|
|
return headers.navigation_headers(self.user_agent, mobile=self.mobile)
|
|
|
|
|
|
PROFILES: Final[dict[str, SiteAuthProfile]] = {
|
|
"rakuten": SiteAuthProfile(
|
|
name="rakuten",
|
|
label="楽天市場",
|
|
login_url=RAKUTEN_LOGIN_URL,
|
|
probe_url=RAKUTEN_CART_URL,
|
|
user_agent=RAKUTEN_USER_AGENT,
|
|
mobile=True,
|
|
state_filename="rakuten_state.json",
|
|
),
|
|
}
|
|
|
|
SITES: Final = tuple(PROFILES)
|
|
|
|
|
|
def profile(site: str) -> SiteAuthProfile:
|
|
"""取某站的登录态配置,未知站点直接报错"""
|
|
try:
|
|
return PROFILES[site]
|
|
except KeyError:
|
|
raise ValueError(f"未知站点:{site}") from None
|
|
|
|
|
|
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":
|
|
raise ValueError(f"未知站点:{site}")
|
|
return RAKUTEN_LOGGED_OUT_MARKER not in body
|
|
|
|
|
|
def looks_logged_out(site: str, *, final_url: str, body: str) -> bool:
|
|
"""在**非探针页**上判断「这次拿到的页面像是因为掉登录才长这样」
|
|
|
|
与 `is_logged_in` 的区别,别混用:
|
|
|
|
- `is_logged_in` 是探针页(购物车页)上的**正向**判据,用来回答「这套 cookie
|
|
还有效吗」,是登录态的权威结论。
|
|
- 本函数是订单列表页 / 订单详情页这类业务页上的**单边启发式**:返回 True 只
|
|
表示「值得回探针页复核一次登录态」,返回 False **不代表登录着**(业务页
|
|
正常渲染时本来就没有任何未登录信号)。所以它只该用在「判错了最多多花一次
|
|
探测/重登」的重试决策上,不能拿来当登录态结论对外报告。
|
|
|
|
判据(前者是推断,后者是实测):
|
|
1. 落地 URL 进了 SSO 域(`RAKUTEN_SSO_URL_MARKERS`),且不是「已登录但要求
|
|
复核密码」的 session upgrade 路径——后者是站点风控,不是掉登录,
|
|
误判会把风控拦截当 cookie 过期去重登。
|
|
注意 SSO 域下的 `sign_in/password`(真正的密码输入页)**不**在豁免里:
|
|
它既可能是全新登录的密码步、也可能是 upgrade 的后续步,含义有歧义,
|
|
按「像掉登录」处理——代价只是多跑一次 `login_one`,而它自己会先探测
|
|
登录态、已登录就直接跳过。
|
|
2. 正文出现旧版未登录 marker(SSR 落地 HTML 上实测有效,新 SPA 渲染后不再
|
|
输出,所以这条同样是单边的)。
|
|
"""
|
|
if site != "rakuten":
|
|
raise ValueError(f"未知站点:{site}")
|
|
lowered = (final_url or "").lower()
|
|
if any(marker in lowered for marker in RAKUTEN_SSO_URL_MARKERS):
|
|
if not any(path in lowered for path in RAKUTEN_SSO_UPGRADE_PATH_MARKERS):
|
|
return True
|
|
return RAKUTEN_LOGGED_OUT_MARKER in body
|