diff --git a/yunke-server/scripts/scrape_via_page.py b/yunke-server/scripts/scrape_via_page.py index d4ac919..0037cc5 100644 --- a/yunke-server/scripts/scrape_via_page.py +++ b/yunke-server/scripts/scrape_via_page.py @@ -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 风控). ``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: - return await page.evaluate( + resp = await page.evaluate( """async ([path, body]) => { const resp = await fetch(path, { method: 'POST', @@ -1145,19 +1147,42 @@ async def _browser_fetch(page, path: str, json_body: dict | None = None, form_bo }""", [path, json_body], ) - return await page.evaluate( - """async ([path, params]) => { - const ps = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) ps.append(k, String(v)); - const resp = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: ps.toString(), - }); - return await resp.json(); - }""", - [path, form_body or {}], - ) + else: + resp = await page.evaluate( + """async ([path, params]) => { + const ps = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) ps.append(k, String(v)); + const resp = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: ps.toString(), + }); + return await resp.json(); + }""", + [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]: @@ -2224,4 +2249,9 @@ async def 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) diff --git a/yunke-server/tests/test_scrape_auth_guard.py b/yunke-server/tests/test_scrape_auth_guard.py new file mode 100644 index 0000000..597dcfa --- /dev/null +++ b/yunke-server/tests/test_scrape_auth_guard.py @@ -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)