"""登录失败分型:把「失败消息」映射成稳定的型别。 ## 为什么要单独一个模块 这个分类原先只活在 `automation/jobs/ck_relogin.py`(自动重登作业)里。但 **手动/批量登录**同样需要它: 1. 账号时间线要写「登录失败」事件,写的时候得带型别,否则以后想筛 「验证码失败的账号」只能去猜中文; 2. 导出 CSV 要给运营一列可直接筛选的型别。 放 service 而不是留在 job 里,是沿用仓库已有的分层决定 (`MANUAL_KINDS` 当初就是从 job 挪到 `account_lifecycle.py` 的, 注释写着「service 不该反过来依赖 job」)。 ## 一条重要推论:`interrupted` ≠ 失败 分型把「已停止 / 被中断 / 服务重启」都归到 `interrupted`。**这不是账号的失败**: 调用方(`_mark_attempt_failed`)据此决定**不往账号时间线写失败事件** —— 否则运营按一次停止就能把几十个账号的时间线刷满假失败。 """ from __future__ import annotations #: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。 FAILURE_KIND_LABELS: dict[str, str] = { "must_bind_mobile": "必须绑定手机号", "captcha": "极验/验证码拦截", "credential": "账号凭证问题", "email_code": "邮箱取码失败", "email_quota": "邮箱配额已满", "network": "网络/未知异常", "interrupted": "被中断(停止/重启)", } #: 不写进账号时间线的型别:这些是**人的动作或进程重启**,不是账号状况。 NOT_AN_ACCOUNT_FAULT: frozenset[str] = frozenset({"interrupted"}) def classify_failure(message: str | None) -> str: """把重登/登录失败消息映射成分型(见规划 §7.4.3)。 顺序很重要: 1. 「必须绑定手机号 / 跳过手机号绑定失败」优先归入 ``must_bind_mobile``; 2. 「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,必须先在 ``email_quota`` 命中; 3. 极验滑块命中 ``captcha``。 """ text = message or "" if "必须绑定手机号" in text or "跳过手机号绑定失败" in text or "绑定手机" in text: return "must_bind_mobile" if "上限" in text or "配额" in text: return "email_quota" if "极验" in text or ("验证" in text and "滑块" in text): return "captcha" if "验证码" in text or "取码" in text: return "email_code" if "中断" in text or "已停止" in text or "任务已停止" in text: return "interrupted" if any( k in text for k in ( "密码", "账号不存在", "缺少密码或邮箱", "注销", "已注销", "封禁", "冻结", "限制登录", "禁止登录", ) ): return "credential" # 请求失败/超时/连接异常,以及未知文案:都按可重试处理。 # 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。 return "network" def is_account_fault(kind: str | None) -> bool: """这个型别算不算「账号出了问题」——不算就不该写进账号一生。""" return (kind or "") not in NOT_AN_ACCOUNT_FAULT def failure_kind_label(kind: str | None) -> str: return FAILURE_KIND_LABELS.get(kind or "", "")