fix(scraper): 云客鉴权失败信封统一拦截——cookie 过期响亮报错不再静默
事故(2026-09-03 手动同步实测):cookie 过期时 WA 路径对 str data 调 .get 崩 AttributeError,微信路径更糟——auth 错误信封被当空数据,55 员工全部 rows=0 无任何告警,整轮同步一无所获。 守卫:_browser_fetch 收口统一走 _check_auth_envelope,识别 success=false 且 code=±20020(实测信封)抛 CookieExpiredError;main 入口捕获后输出 一句话可读信息 + 退出码 3(不再天书堆栈)。业务错误(-90000 等)不受影响 照旧放行。测试 5 新增,全量 517 绿。 后续(等确认真实密码):CookieExpiredError 触发自动重新登录换 cookie, 过期彻底自愈。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1131,10 +1131,12 @@ async def _browser_fetch(page, path: str, json_body: dict | None = None, form_bo
|
|||||||
"""POST from inside the browser context (proper cookies/referrer => no 风控).
|
"""POST from inside the browser context (proper cookies/referrer => no 风控).
|
||||||
|
|
||||||
``json_body`` sends a JSON body; ``form_body`` sends form-urlencoded.
|
``json_body`` sends a JSON body; ``form_body`` sends form-urlencoded.
|
||||||
Returns the parsed JSON response.
|
Returns the parsed JSON response. 鉴权失败信封(云客 cookie 过期)在此
|
||||||
|
统一拦截抛 :class:`CookieExpiredError`(2026-09-03:过期时 WA 路径对
|
||||||
|
str data 调 .get 崩 AttributeError、微信路径静默当空数据 55 员工 0 行)。
|
||||||
"""
|
"""
|
||||||
if json_body is not None:
|
if json_body is not None:
|
||||||
return await page.evaluate(
|
resp = await page.evaluate(
|
||||||
"""async ([path, body]) => {
|
"""async ([path, body]) => {
|
||||||
const resp = await fetch(path, {
|
const resp = await fetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1145,19 +1147,42 @@ async def _browser_fetch(page, path: str, json_body: dict | None = None, form_bo
|
|||||||
}""",
|
}""",
|
||||||
[path, json_body],
|
[path, json_body],
|
||||||
)
|
)
|
||||||
return await page.evaluate(
|
else:
|
||||||
"""async ([path, params]) => {
|
resp = await page.evaluate(
|
||||||
const ps = new URLSearchParams();
|
"""async ([path, params]) => {
|
||||||
for (const [k, v] of Object.entries(params)) ps.append(k, String(v));
|
const ps = new URLSearchParams();
|
||||||
const resp = await fetch(path, {
|
for (const [k, v] of Object.entries(params)) ps.append(k, String(v));
|
||||||
method: 'POST',
|
const resp = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
method: 'POST',
|
||||||
body: ps.toString(),
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
});
|
body: ps.toString(),
|
||||||
return await resp.json();
|
});
|
||||||
}""",
|
return await resp.json();
|
||||||
[path, form_body or {}],
|
}""",
|
||||||
)
|
[path, form_body or {}],
|
||||||
|
)
|
||||||
|
return _check_auth_envelope(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class CookieExpiredError(RuntimeError):
|
||||||
|
"""云客会话过期(鉴权失败信封)。调用方应中止本轮同步,提示重新登录换 cookie。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_auth_envelope(resp):
|
||||||
|
"""鉴权失败信封 → CookieExpiredError;其余响应原样返回。
|
||||||
|
|
||||||
|
云客过期时的实测信封(code 时而 str 时而 int):
|
||||||
|
``{"code":"-20020","data":"账号认证已过期…","message":"鉴权失败…","success":false}``
|
||||||
|
业务错误(如 -90000)不是鉴权问题,照旧放行由调用方语义处理。
|
||||||
|
"""
|
||||||
|
if isinstance(resp, dict) and resp.get("success") is False:
|
||||||
|
code = str(resp.get("code", ""))
|
||||||
|
if code in {"-20020", "20020"}:
|
||||||
|
raise CookieExpiredError(
|
||||||
|
f"云客 cookie 已过期(code={code}:{resp.get('message')}),"
|
||||||
|
"需重新登录换取后更新 YK_COOKIE"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def _rows_flat(body: dict) -> list[dict]:
|
def _rows_flat(body: dict) -> list[dict]:
|
||||||
@@ -2224,4 +2249,9 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except CookieExpiredError as exc:
|
||||||
|
# 一句话可读 + 专用退出码,调度器/脚本日志第一眼就能定位;不再输出天书堆栈
|
||||||
|
print(f"[cookie-expired] {exc}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""云客鉴权失败信封检测(2026-09-03 wa-friends 崩溃事故的防护)。
|
||||||
|
|
||||||
|
事故:cookie 过期时云客返回 ``{"code":"-20020","data":"账号认证已过期…",
|
||||||
|
"success":false}``——WA 路径对 str data 调 .get 直接 AttributeError 天书,
|
||||||
|
微信路径更糟:静默当空数据(55 员工 rows=0 无任何告警,同步一无所获)。
|
||||||
|
|
||||||
|
守卫:_browser_fetch 统一走 :func:`_check_auth_envelope`,识别鉴权失败信封
|
||||||
|
抛 :class:`CookieExpiredError`(调用方中止本轮,日志一句话可读)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.scrape_via_page import CookieExpiredError, _check_auth_envelope
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_fail_envelope_raises():
|
||||||
|
resp = {
|
||||||
|
"code": "-20020",
|
||||||
|
"data": "账号认证已过期,请重新登录",
|
||||||
|
"message": "鉴权失败,token验证失败",
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
with pytest.raises(CookieExpiredError, match="cookie"):
|
||||||
|
_check_auth_envelope(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_dict_resp_passes_through():
|
||||||
|
# 响应体不是 dict(如字符串)时不应崩——原样放行由调用方语义处理
|
||||||
|
assert _check_auth_envelope("plain string") == "plain string"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_success_envelope_passes():
|
||||||
|
resp = {"code": "0", "data": {"totalCount": 5, "overviews": [{"a": 1}]}, "success": True}
|
||||||
|
assert _check_auth_envelope(resp) is resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_error_codes_do_not_raise():
|
||||||
|
# 业务错误(如 -90000 系统错误)不是鉴权问题——照旧返回,由调用方决定
|
||||||
|
resp = {"code": "-90000", "data": "系统错误", "success": False}
|
||||||
|
assert _check_auth_envelope(resp) is resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_as_int_20020_also_raises():
|
||||||
|
# 云客 code 时而是字符串时而是数字——两种都识别
|
||||||
|
resp = {"code": 20020, "data": "账号认证已过期,请重新登录", "success": False}
|
||||||
|
with pytest.raises(CookieExpiredError):
|
||||||
|
_check_auth_envelope(resp)
|
||||||
Reference in New Issue
Block a user