From 97d970dd1bd7bdf017dd533d08bc430612f19f05 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Tue, 7 Apr 2026 10:36:40 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20account-manager=20=E2=80=94=20runtime?= =?UTF-8?q?=5Fenv=20sibling=20root,=20wait-login=20and=20browser=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 8 +- assets/README.md | 2 +- assets/examples/get-response.json | 2 - assets/examples/list-json-array.json | 4 - assets/schemas/account-record.schema.json | 9 +- references/CLI.md | 35 +- references/ERRORS.md | 30 +- references/INTEGRATION.md | 17 +- references/README.md | 2 +- scripts/cli/app.py | 33 +- scripts/db/accounts_repo.py | 69 +--- scripts/db/schema.py | 4 +- scripts/jiangchang_skill_core/runtime_env.py | 385 +++++++++++++------ scripts/service/account_service.py | 66 +--- scripts/service/browser_service.py | 149 +++++-- scripts/service/login_child_runner.py | 365 ++++++++++++------ 16 files changed, 687 insertions(+), 493 deletions(-) diff --git a/SKILL.md b/SKILL.md index 9da11a2..3c0988d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号管理 description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。 -version: 1.0.4 +version: 1.0.5 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -35,7 +35,7 @@ python3 {baseDir}/scripts/main.py add 知乎 13800138000 python3 {baseDir}/scripts/main.py open ``` -### 登录并自动检测、写入数据库 +### 登录并自动检测(成功则刷新该账号 `updated_at`,不在库中保存「是否已登录」) ```bash python3 {baseDir}/scripts/main.py login ``` @@ -53,12 +53,12 @@ python3 {baseDir}/scripts/main.py list all python3 {baseDir}/scripts/main.py list 搜狐号 ``` -### 跨技能:单行 JSON(get / list-json / pick-web / pick-logged-in / set-login-status) +### 跨技能:单行 JSON(get / list-json / pick-web);需会话有效时用 wait-login ```bash python3 {baseDir}/scripts/main.py get python3 {baseDir}/scripts/main.py list-json all --limit 50 python3 {baseDir}/scripts/main.py pick-web 搜狐号 -python3 {baseDir}/scripts/main.py set-login-status 1 +python3 {baseDir}/scripts/main.py wait-login ``` 约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。 diff --git a/assets/README.md b/assets/README.md index efc64ea..63b1189 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,6 +1,6 @@ # assets - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 -- `schemas/`:`get` / `list-json` / `pick-*` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 +- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 详细命令与错误码见仓库根目录 `references/`。 diff --git a/assets/examples/get-response.json b/assets/examples/get-response.json index 3dc017c..4f491eb 100644 --- a/assets/examples/get-response.json +++ b/assets/examples/get-response.json @@ -5,8 +5,6 @@ "phone": "13800138000", "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000", "url": "https://mp.sohu.com", - "login_status": 1, - "last_login_at": "2026-04-01T12:00:00", "created_at": "2026-03-01T10:00:00", "updated_at": "2026-04-01T12:00:00" } diff --git a/assets/examples/list-json-array.json b/assets/examples/list-json-array.json index c4b0e31..2046510 100644 --- a/assets/examples/list-json-array.json +++ b/assets/examples/list-json-array.json @@ -6,8 +6,6 @@ "phone": "13900001111", "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111", "url": "https://www.zhihu.com", - "login_status": 0, - "last_login_at": null, "created_at": "2026-04-02T09:00:00", "updated_at": "2026-04-02T09:00:00" }, @@ -18,8 +16,6 @@ "phone": "13800138000", "profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000", "url": "https://mp.sohu.com", - "login_status": 1, - "last_login_at": "2026-04-01T12:00:00", "created_at": "2026-03-01T10:00:00", "updated_at": "2026-04-01T12:00:00" } diff --git a/assets/schemas/account-record.schema.json b/assets/schemas/account-record.schema.json index f8afebf..4f3e9d4 100644 --- a/assets/schemas/account-record.schema.json +++ b/assets/schemas/account-record.schema.json @@ -2,9 +2,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://openclaw.local/account-manager/account-record.schema.json", "title": "AccountRecord", - "description": "account-manager get / list-json / pick-* 返回的单条账号对象(核心字段;extra_json 合并键不枚举)", + "description": "account-manager get / list-json / pick-web 返回的单条账号对象(核心字段;extra_json 合并键不枚举)", "type": "object", - "required": ["id", "name", "platform", "phone", "profile_dir", "url", "login_status", "created_at", "updated_at"], + "required": ["id", "name", "platform", "phone", "profile_dir", "url", "created_at", "updated_at"], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, @@ -12,11 +12,6 @@ "phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" }, "profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" }, "url": { "type": "string", "format": "uri" }, - "login_status": { "type": "integer", "enum": [0, 1] }, - "last_login_at": { - "type": ["string", "null"], - "description": "本地时区 ISO8601 字符串;未登录过为 null" - }, "created_at": { "type": ["string", "null"] }, "updated_at": { "type": ["string", "null"] } }, diff --git a/references/CLI.md b/references/CLI.md index 31dc825..d5d50c4 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -6,7 +6,7 @@ - **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。 - **数据与路径**:库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。 -- **机器可读子命令**:`get`、`list-json`、`pick-logged-in`、`pick-web`、`set-login-status` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。 +- **机器可读子命令**:`get`、`list-json`、`pick-web` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。 - **错误码前缀**:见 [ERRORS.md](ERRORS.md)。 > 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。 @@ -56,33 +56,15 @@ python main.py list-json <平台|all|全部> [--limit N] - 默认 `limit=200`,实际 clamp 为 `1..500`。 - 排序:按 `updated_at` 倒序。 -### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON) - -```bash -python main.py pick-logged-in <平台> -``` - -- 筛选:`login_status = 1`,按 `last_login_at` 优先。 -- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。 - ### `pick-web` — 网页自动化候选账号(单行 JSON) ```bash python main.py pick-web <平台> ``` -- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条。 +- 按该平台 `updated_at`、`created_at` 倒序选一条;**不**表示当前浏览器已登录。 - 失败:首行 `ERROR:`。 -### `set-login-status` — 回写登录态(跨技能) - -```bash -python main.py set-login-status <0|1> -``` - -- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS` 或 `OK:CLEARED_LOGIN_STATUS`,退出码 `0`。 -- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`。 - ### `open` — 仅打开浏览器(**不写库**) ```bash @@ -91,13 +73,24 @@ python main.py open - 需本机 Chrome/Edge + Playwright;详见运行环境说明。 -### `login` — 打开浏览器并**自动检测登录**,写回 `login_status` +### `login` — 打开浏览器并**自动检测登录**(成功则刷新 `updated_at`) ```bash python main.py login ``` - 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。 +- **不**在库中持久化「是否已登录」;仅刷新 `updated_at` 供 `pick-web` 等排序参考。 + +### `wait-login` — 跨技能:**始终以本次 DOM 检测为准** + +```bash +python main.py wait-login +``` + +- 由**账号管理**打开浏览器并完成 DOM 检测;调用方只需 `subprocess` 等待本进程结束,成功时 stdout 含 `OK:WAIT_LOGIN`。 +- 会先打印若干行说明(含 `INFO:WAIT_LOGIN_BEGIN`),再进入检测;超时、关窗、启动失败等见 [ERRORS.md](ERRORS.md)。 +- 时间窗由 `JIANGCHANG_WAIT_LOGIN_TIMEOUT_SECONDS` 控制,见 [RUNTIME.md](RUNTIME.md)。 ### `delete` — 删除库记录并尽量删除 profile 目录 diff --git a/references/ERRORS.md b/references/ERRORS.md index cbdf75d..3e4c24b 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -1,13 +1,6 @@ # 终端前缀约定(account-manager) -约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 或 `OK:` 开头;多行说明可忽略或仅作展示。 - -## `OK:`(成功标记) - -| 首行 | 场景 | -|------|------| -| `OK:SET_LOGIN_STATUS` | `set-login-status 1` 成功 | -| `OK:CLEARED_LOGIN_STATUS` | `set-login-status 0` 成功 | +约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。 ## `ERROR:` — CLI 参数 / 路由 @@ -19,10 +12,9 @@ | `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id | | `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 | | `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` | -| `ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS` | `set-login-status` 参数不足 | | `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 | -| `ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS` | `pick-logged-in` 缺少平台 | | `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 | +| `ERROR:CLI_WAIT_LOGIN_MISSING_ID` | `wait-login` 缺少 id | | `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 | ## `ERROR:` — 业务与数据 @@ -38,10 +30,7 @@ | `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 | | `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 | | `ERROR:DELETE_INVALID_ID` | 删除 id 非法 | -| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in` 无 `login_status=1` 的账号 | | `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 | -| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 | -| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 | ## `ERROR:` — 浏览器 / 环境 @@ -50,7 +39,20 @@ | `ERROR:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 | | `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 | +## `ERROR:` — `wait-login`(跨技能编排) + +| 前缀 | 含义 | +|------|------| +| `ERROR:WAIT_LOGIN_ABORTED` | 用户关闭浏览器,放弃登录 | +| `ERROR:WAIT_LOGIN_TIMEOUT` | 限时内未完成登录(默认由 `JIANGCHANG_WAIT_LOGIN_TIMEOUT_SECONDS` 控制,约 180s) | +| `ERROR:WAIT_LOGIN_LAUNCH_FAILED` | 无法启动浏览器或创建持久化上下文(端口/占用/安装问题等) | +| `ERROR:WAIT_LOGIN_PAGE_ERROR` | 打开登录页或轮询 DOM 时异常 | +| `ERROR:WAIT_LOGIN_CONFIG_ERROR` | 子进程配置异常 | +| `ERROR:WAIT_LOGIN_CHILD_CRASH` | 子进程异常退出且未写入有效结果文件 | + +编排技能(如 llm-manager)另可能打印 `ERROR:WAIT_LOGIN_OUTER_TIMEOUT`:等待本进程整体超过上限(登录窗口 + 子进程缓冲),已中止。 + ## 非前缀输出 - **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。 -- **`get` / `list-json` / `pick-*` 成功**:`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON)。 +- **`get` / `list-json` / `pick-web` 成功**:`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON)。 diff --git a/references/INTEGRATION.md b/references/INTEGRATION.md index ed806f0..2e3b372 100644 --- a/references/INTEGRATION.md +++ b/references/INTEGRATION.md @@ -10,30 +10,27 @@ |------|------|----------------| | 取单条账号 JSON | `get ` | 单行 JSON 对象 | | 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 | -| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 | -| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 | -| 回写登录态 | `set-login-status <0或1>` | 首行 `OK:...` | +| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录) | ## 解析协议(强建议) 1. 读取子进程 **stdout** 全文或首行。 2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。 -3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`)。 -4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。 +3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。 ## JSON 对象字段(与 `get` 一致) 典型字段见仓库 `assets/examples/get-response.json` 与 `assets/schemas/account-record.schema.json`。 -常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `login_status`, `last_login_at`, `created_at`, `updated_at`;`extra_json` 列中的扁平扩展可能并入同一对象。 +常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at`;`extra_json` 列中的扁平扩展可能并入同一对象。 + +**库中不再持久化「是否已登录」**;编排侧需登录时请调用 `wait-login `(始终以本次 Playwright DOM 检测为准)或在业务页内自行判断。 ## Publisher / 自动化流水线示例 1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。 -2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login ` 或先 `open ` 人工登录。 -3. 外部脚本在确认登录后,可调用 `set-login-status 1` 保持与库一致(若你们自有检测逻辑)。 +2. 需要确保会话有效时:调用 `wait-login `,成功则 stdout 首行含 `OK:WAIT_LOGIN`。 ## 退出码 -- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。 -- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准,不要仅依赖退出码。 +请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准;不要仅依赖退出码。 diff --git a/references/README.md b/references/README.md index f741f9e..593c964 100644 --- a/references/README.md +++ b/references/README.md @@ -7,7 +7,7 @@ | [CLI.md](CLI.md) | 子命令、参数、行为说明 | | [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 | | [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 | -| [ERRORS.md](ERRORS.md) | `ERROR:` / `OK:` 前缀一览 | +| [ERRORS.md](ERRORS.md) | `ERROR:` 前缀一览 | | [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 | 示例 JSON 与 Schema 见仓库 `assets/`。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index ea20a33..0f68596 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -9,9 +9,7 @@ from service.account_service import ( cmd_get, cmd_list, cmd_list_json, - cmd_pick_logged_in, cmd_pick_web, - cmd_set_login_status, ) from service.browser_service import cmd_login, cmd_open, cmd_wait_login from util.constants import LOG_LOGGER_NAME, SKILL_SLUG @@ -25,14 +23,12 @@ def _cli_print_full_usage() -> None: print("用法概览(将 main.py 换为你的路径):") print(" python main.py add <平台中文名> <手机号>") print(" python main.py list [平台|all|全部]") - print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR") - print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号") + print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON") print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get") print(" python main.py get ") - print(" python main.py set-login-status <0|1> # 跨技能回写登录态") print(" python main.py open ") print(" python main.py login ") - print(" python main.py wait-login [--force] # 跨技能:限时内等待网页登录(默认 180s);--force 忽略库内已登录标记") + print(" python main.py wait-login # 跨技能:限时内 DOM 检测登录(默认 180s)") print(" python main.py delete id ") print(" python main.py delete platform <平台>") print(" python main.py delete platform <平台> <手机号>") @@ -109,13 +105,6 @@ def main() -> None: _cli_fail_need_account_id("get") sys.exit(1) cmd_get(sys.argv[2]) - elif cmd == "set-login-status": - if len(sys.argv) < 4: - print("ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS") - print("用法:python main.py set-login-status <0|1>") - print("说明:供 llm-manager 等脚本回写登录态;成功时 stdout 首行以 OK: 开头。") - sys.exit(1) - cmd_set_login_status(sys.argv[2], sys.argv[3]) elif cmd == "list-json": if len(sys.argv) < 3: print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM") @@ -133,18 +122,11 @@ def main() -> None: except ValueError: pass cmd_list_json(platform_arg, limit=lim) - elif cmd == "pick-logged-in": - if len(sys.argv) < 3: - print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS") - print("用法:python main.py pick-logged-in <平台中文名或英文键>") - print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。") - sys.exit(1) - cmd_pick_logged_in(sys.argv[2]) elif cmd == "pick-web": if len(sys.argv) < 3: print("ERROR:CLI_PICK_WEB_MISSING_ARGS") print("用法:python main.py pick-web <平台中文名或英文键>") - print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)。") + print("说明:按 updated_at 选一条账号;是否已登录由 wait-login / 页面检测决定。") sys.exit(1) cmd_pick_web(sys.argv[2]) elif cmd == "open": @@ -154,15 +136,12 @@ def main() -> None: cmd_open(sys.argv[2]) elif cmd == "wait-login": av = [a for a in sys.argv[2:] if a] - force = "--force" in av or "-f" in av - av = [a for a in av if a not in ("--force", "-f")] if len(av) < 1: print("ERROR:CLI_WAIT_LOGIN_MISSING_ID") - print("用法:python main.py wait-login <账号 id> [--force]") - print("说明:供 llm-manager / 搜狐发布等编排;未登录时打开浏览器,限时内等待登录或用户关窗即结束。") - print("--force:即使库内已标为登录也重新打开浏览器检测(Cookie 失效时用)。") + print("用法:python main.py wait-login <账号 id>") + print("说明:始终以本次 DOM 检测为准;供 llm-manager / 搜狐发布等编排。") sys.exit(1) - sys.exit(cmd_wait_login(av[0], force=force)) + sys.exit(cmd_wait_login(av[0])) elif cmd == "login": if len(sys.argv) < 3: _cli_fail_need_account_id("login") diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index f03f5a5..49130f0 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -46,7 +46,7 @@ def get_account_by_id(account_id): cur = conn.cursor() cur.execute( """ - SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, + SELECT id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at FROM accounts WHERE id = ? """, @@ -62,14 +62,12 @@ def get_account_by_id(account_id): "phone": row[3] or "", "profile_dir": (row[4] or "").strip(), "url": row[5] or PLATFORM_URLS.get(row[2], ""), - "login_status": int(row[6] or 0), - "last_login_at": _unix_to_iso_local(row[7]), - "created_at": _unix_to_iso_local(row[9]), - "updated_at": _unix_to_iso_local(row[10]), + "created_at": _unix_to_iso_local(row[7]), + "updated_at": _unix_to_iso_local(row[8]), } - if row[8]: + if row[6]: try: - extra = json.loads(row[8]) + extra = json.loads(row[6]) if isinstance(extra, dict): acc.update(extra) except Exception: @@ -79,21 +77,13 @@ def get_account_by_id(account_id): conn.close() -def mark_login_status(account_id, success: bool): +def touch_account_updated_at(account_id) -> None: + """登录 DOM 检测成功等场景下刷新 updated_at,供 pick-web 等按最近活跃选号。""" now = _now_unix() conn = get_conn() try: cur = conn.cursor() - if success: - cur.execute( - "UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?", - (now, now, account_id), - ) - else: - cur.execute( - "UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?", - (now, account_id), - ) + cur.execute("UPDATE accounts SET updated_at = ? WHERE id = ?", (now, account_id)) conn.commit() finally: conn.close() @@ -105,8 +95,8 @@ def fetch_list_rows(platform_key: str, limit: int): try: cur = conn.cursor() sql = ( - "SELECT id, name, platform, phone, profile_dir, url, login_status, " - "last_login_at, extra_json, created_at, updated_at FROM accounts " + "SELECT id, name, platform, phone, profile_dir, url, " + "extra_json, created_at, updated_at FROM accounts " ) if platform_key == "all": cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),)) @@ -140,50 +130,21 @@ def fetch_ids_for_list_json(platform_key: str, lim: int): conn.close() -def fetch_pick_logged_in_id(platform_key: str) -> Optional[int]: - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - """ - SELECT id FROM accounts - WHERE platform = ? AND login_status = 1 - ORDER BY (last_login_at IS NULL), last_login_at DESC - LIMIT 1 - """, - (platform_key,), - ) - row = cur.fetchone() - return int(row[0]) if row else None - finally: - conn.close() - - def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]: + """该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。""" conn = get_conn() try: cur = conn.cursor() cur.execute( """ SELECT id FROM accounts - WHERE platform = ? AND login_status = 1 - ORDER BY (last_login_at IS NULL), last_login_at DESC + WHERE platform = ? + ORDER BY updated_at DESC, created_at DESC, id DESC LIMIT 1 """, (platform_key,), ) row = cur.fetchone() - if not row: - cur.execute( - """ - SELECT id FROM accounts - WHERE platform = ? - ORDER BY updated_at DESC, id DESC - LIMIT 1 - """, - (platform_key,), - ) - row = cur.fetchone() return int(row[0]) if row else None finally: conn.close() @@ -216,8 +177,8 @@ def insert_account_row( cur = conn.cursor() cur.execute( """ - INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?) + INSERT INTO accounts (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?) """, (name, platform_key, phone_store, profile_dir, url, now, now), ) diff --git a/scripts/db/schema.py b/scripts/db/schema.py index f4c34df..69e1c88 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -1,7 +1,7 @@ # SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601. # 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句. ACCOUNTS_TABLE_SQL = """ -/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定, 供发布类技能读取登录态 */ +/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */ CREATE TABLE accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增) name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」 @@ -9,8 +9,6 @@ CREATE TABLE accounts ( phone TEXT, -- 可选绑定手机号 profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/ url TEXT, -- 平台入口或登录页 URL - login_status INTEGER NOT NULL DEFAULT 0, -- 展示/排序用尽力同步;编排上请以 Playwright DOM 检测为准,勿单独依赖本字段 - last_login_at INTEGER, -- 最近一次登录成功时间, Unix 秒 UTC; 未登录过为 NULL extra_json TEXT, -- 扩展字段 JSON created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC diff --git a/scripts/jiangchang_skill_core/runtime_env.py b/scripts/jiangchang_skill_core/runtime_env.py index 7ea0169..b0e92ef 100644 --- a/scripts/jiangchang_skill_core/runtime_env.py +++ b/scripts/jiangchang_skill_core/runtime_env.py @@ -1,127 +1,258 @@ -""" -JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 - -各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。 -""" - -from __future__ import annotations - -import os -import sys - -# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启 -CLI_LOCAL_DEV_ENABLED = True - -# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改) -DEFAULT_LOCAL_USER_ID = "10032" - -# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径 -WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data" - -# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列) -WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang" - - -def platform_default_data_root() -> str: - if sys.platform == "win32": - return WIN_DEFAULT_DATA_ROOT - return os.path.join(os.path.expanduser("~"), ".jiangchang-data") - - -def get_data_root() -> str: - env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() - if env: - return env - return platform_default_data_root() - - -def get_user_id() -> str: - return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" - - -def _looks_like_skills_root(path: str) -> bool: - if not path or not os.path.isdir(path): - return False - for marker in ( - "llm-manager", - "content-manager", - "account-manager", - "sohu-publisher", - "api-key-vault", - ): - if os.path.isdir(os.path.join(path, marker)): - return True - return False - - -def get_skills_root() -> str: - """ - 并列技能安装目录(其下为「技能 slug」子目录)。 - - 优先级: - 1) JIANGCHANG_SKILLS_ROOT - 2) CLAW_SKILLS_ROOT - 3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根 - 4) Windows:默认 D:\\AI\\jiangchang 同上规则 - 5) 其他平台:~/.openclaw/skills - """ - for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): - v = (os.getenv(key) or "").strip() - if v: - return os.path.normpath(v) - - app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() - if sys.platform == "win32" and not app: - app = WIN_DEFAULT_JIANGCHANG_APP_ROOT - if app: - nested = os.path.join(app, "skills") - if _looks_like_skills_root(nested): - return os.path.normpath(nested) - if _looks_like_skills_root(app): - return os.path.normpath(app) - - if sys.platform == "win32": - nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills") - if _looks_like_skills_root(nested): - return os.path.normpath(nested) - if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT): - return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT) - - return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills")) - - -def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str: - """ - 编排子进程调用兄弟技能时用:优先环境变量;否则从本技能 scripts 目录推断并列根 - (OpenClaw 开发仓与网关 skills//scripts 均满足「技能根之上一级 = 并列根」)。 - """ - for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): - v = (os.getenv(key) or "").strip() - if v: - return os.path.normpath(v) - - if skill_scripts_dir: - scripts = os.path.abspath(skill_scripts_dir) - skill_root = os.path.dirname(scripts) - inferred = os.path.dirname(skill_root) - if _looks_like_skills_root(inferred): - return inferred - - return get_skills_root() - - -def apply_cli_local_defaults() -> None: - """ - 在 CLI 最早阶段调用(main.py 在 import 业务包之前)。 - 宿主已设置 JIANGCHANG_* 时不会覆盖。 - """ - enabled = CLI_LOCAL_DEV_ENABLED - if not enabled: - v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower() - enabled = v in ("1", "true", "yes", "on") - if not enabled: - return - if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): - os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root() - if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): - os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip() +""" + +JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 + + + +各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。 + +""" + + + +from __future__ import annotations + + + +import os + +import sys + + + +# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启 + +CLI_LOCAL_DEV_ENABLED = True + + + +# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改) + +DEFAULT_LOCAL_USER_ID = "10032" + + + +# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径 + +WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data" + + + +# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列) + +WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang" + + + + + +def platform_default_data_root() -> str: + + if sys.platform == "win32": + + return WIN_DEFAULT_DATA_ROOT + + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + + + + +def get_data_root() -> str: + + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + + if env: + + return env + + return platform_default_data_root() + + + + + +def get_user_id() -> str: + + return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" + + + + + +def _looks_like_skills_root(path: str) -> bool: + + if not path or not os.path.isdir(path): + + return False + + for marker in ( + + "llm-manager", + + "content-manager", + + "account-manager", + + "sohu-publisher", + + "api-key-vault", + + ): + + if os.path.isdir(os.path.join(path, marker)): + + return True + + return False + + + + + +def get_skills_root() -> str: + + """ + + 并列技能安装目录(其下为「技能 slug」子目录)。 + + + + 优先级: + + 1) JIANGCHANG_SKILLS_ROOT + + 2) CLAW_SKILLS_ROOT + + 3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根 + + 4) Windows:默认 D:\\AI\\jiangchang 同上规则 + + 5) 其他平台:~/.openclaw/skills + + """ + + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + + v = (os.getenv(key) or "").strip() + + if v: + + return os.path.normpath(v) + + + + app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() + + if sys.platform == "win32" and not app: + + app = WIN_DEFAULT_JIANGCHANG_APP_ROOT + + if app: + + nested = os.path.join(app, "skills") + + if _looks_like_skills_root(nested): + + return os.path.normpath(nested) + + if _looks_like_skills_root(app): + + return os.path.normpath(app) + + + + if sys.platform == "win32": + + nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills") + + if _looks_like_skills_root(nested): + + return os.path.normpath(nested) + + if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT): + + return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT) + + + + return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills")) + + + + + +def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str: + + """ + + 编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能 + + 则用之(开发仓与安装目录均满足「技能根之上一级 = 并列根」), + + 避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 与当前检出不一致时错调兄弟进程。 + + 推断失败时再读上述环境变量,最后回落 get_skills_root()。 + + """ + + if skill_scripts_dir: + + scripts = os.path.abspath(skill_scripts_dir) + + skill_root = os.path.dirname(scripts) + + inferred = os.path.dirname(skill_root) + + if _looks_like_skills_root(inferred): + + return os.path.normpath(inferred) + + + + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + + v = (os.getenv(key) or "").strip() + + if v: + + return os.path.normpath(v) + + + + return get_skills_root() + + + + + +def apply_cli_local_defaults() -> None: + + """ + + 在 CLI 最早阶段调用(main.py 在 import 业务包之前)。 + + 宿主已设置 JIANGCHANG_* 时不会覆盖。 + + """ + + enabled = CLI_LOCAL_DEV_ENABLED + + if not enabled: + + v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower() + + enabled = v in ("1", "true", "yes", "on") + + if not enabled: + + return + + if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): + + os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root() + + if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): + + os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip() + diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index 620e6ef..e9c058d 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -1,4 +1,4 @@ -"""账号用例:list / get / add / delete / pick / set-login-status(含终端输出,供 CLI 调用)。""" +"""账号用例:list / get / add / delete / pick-web(含终端输出,供 CLI 调用)。""" import json import os import shutil @@ -15,13 +15,11 @@ from db.accounts_repo import ( fetch_ids_for_list_json, fetch_ids_profile_for_platform_conn, fetch_list_rows, - fetch_pick_logged_in_id, fetch_pick_web_candidate_id, fetch_row_platform_phone_conn, get_account_by_id, has_duplicate_phone_conn, insert_account_row, - mark_login_status, normalize_account_id, ) from db.connection import get_conn, init_db @@ -78,8 +76,6 @@ def cmd_list(platform="all", limit: int = 10): phone, profile_dir, url, - login_status, - last_login_at, extra_json, created_at, updated_at, @@ -90,8 +86,6 @@ def cmd_list(platform="all", limit: int = 10): print(f"phone:{phone or ''}") print(f"profile_dir:{profile_dir or ''}") print(f"url:{url or ''}") - print(f"login_status:{int(login_status) if login_status is not None else ''}") - print(f"last_login_at:{int(last_login_at) if last_login_at is not None else ''}") print(f"extra_json:{extra_json or ''}") print(f"created_at:{int(created_at) if created_at is not None else ''}") print(f"updated_at:{int(updated_at) if updated_at is not None else ''}") @@ -141,40 +135,10 @@ def cmd_list_json(platform_input: str, limit: int = 200) -> None: print(json.dumps(out, ensure_ascii=False)) -def cmd_pick_logged_in(platform_input: str): - """ - 机器可读跨技能接口:查询指定平台下「已登录」的一条账号(login_status=1,按 last_login_at 优先)。 - 成功:stdout 仅输出一行 JSON,结构与 get 子命令一致。 - 失败:stdout 首行以 ERROR: 开头(由调用方判断,勿解析为 JSON)。 - """ - get_skill_logger().info("pick_logged_in platform_input=%r", platform_input) - key = resolve_platform_key((platform_input or "").strip()) - if not key: - print("ERROR:INVALID_PLATFORM 无法识别的平台名称。") - print("支持:" + _platform_list_cn_for_help()) - return - - init_db() - picked = fetch_pick_logged_in_id(key) - - if picked is None: - print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号(login_status=1)。") - print("请先 list 查看账号 id,再执行:python main.py login ") - return - - acc = get_account_by_id(picked) - if not acc: - print("ERROR:ACCOUNT_NOT_FOUND") - print(_runtime_paths_debug_text(), file=sys.stderr) - return - print(json.dumps(acc, ensure_ascii=False)) - - def cmd_pick_web(platform_input: str): """ 供 llm-manager 等:取该平台用于网页自动化的账号候选。 - 优先 login_status=1(与 pick-logged-in 一致);若无,则取该平台 updated_at 最新的一条, - 便于「已 add 未标登录」时仍打开 profile,在浏览器内登录后继续任务。 + 按 updated_at、created_at 倒序选一条,与库内「登录态」无关;实际是否已登录由 wait-login / 页面 DOM 判定。 成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。 """ get_skill_logger().info("pick_web platform_input=%r", platform_input) @@ -352,29 +316,3 @@ def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None: f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}" ) log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key) - - -def cmd_set_login_status(account_id_str: str, status_str: str) -> None: - """ - 跨技能机器接口:回写 accounts.login_status。 - 成功:stdout 首行 OK:SET_LOGIN_STATUS 或 OK:CLEARED_LOGIN_STATUS,进程退出码 0。 - 失败:stdout 首行 ERROR:...,退出码 1。 - """ - aid_s = (account_id_str or "").strip() - st_s = (status_str or "").strip() - if not aid_s.isdigit(): - print("ERROR:INVALID_ACCOUNT_ID") - sys.exit(1) - if st_s not in ("0", "1"): - print("ERROR:INVALID_STATUS 须为 0 或 1") - sys.exit(1) - aid = int(aid_s) - init_db() - if get_account_by_id(aid) is None: - get_skill_logger().warning("set_login_status_not_found account_id=%r", aid) - print("ERROR:ACCOUNT_NOT_FOUND") - sys.exit(1) - ok = st_s == "1" - mark_login_status(aid, ok) - get_skill_logger().info("set_login_status account_id=%s success=%s", aid, ok) - print("OK:SET_LOGIN_STATUS" if ok else "OK:CLEARED_LOGIN_STATUS") diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index 91ba241..ec18769 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -7,7 +7,7 @@ import tempfile import time from urllib.parse import urlparse -from db.accounts_repo import get_account_by_id, mark_login_status +from db.accounts_repo import get_account_by_id, touch_account_updated_at from util.logging_config import ( get_skill_logger, get_skill_log_file_path, @@ -132,7 +132,7 @@ def _run_login_browser_session( """ 启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。 - 成功后尽力 `mark_login_status` 供 list/排序展示,**跨技能编排请勿再依赖该字段**(可能与真实 Cookie 会话脱节)。 + 若 DOM 判定成功则刷新该账号 `updated_at`,便于 `pick-web` 等按最近活跃选号;**是否已登录仅以本次检测为准**。 返回 (是否已登录成功, end_reason):end_reason 为 login_child 写入的 reason,失败时可能为 timeout / user_closed / subprocess_error。 @@ -218,21 +218,48 @@ def _run_login_browser_session( json.dump(cfg, jf, ensure_ascii=False) cfg_path = jf.name proc_rc = None + child_hard_timeout = False + child_stderr = "" + child_stdout = "" try: r = subprocess.run( [sys.executable, login_runner_path, cfg_path], timeout=timeout_sec + 180, env=subprocess_env_with_trace(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", ) proc_rc = r.returncode + child_stderr = (r.stderr or "").strip() + child_stdout = (r.stdout or "").strip() + if child_stderr: + log.warning( + "login_child_stderr account_id=%s len=%s tail=%s", + account_id, + len(child_stderr), + child_stderr[-6000:], + ) + if child_stdout and proc_rc != 0: + log.warning( + "login_child_stdout account_id=%s len=%s tail=%s", + account_id, + len(child_stdout), + child_stdout[-2000:], + ) if proc_rc != 0: if verbose_ui: - print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态") - log.warning("login_subprocess_nonzero_return code=%s", proc_rc) + print("⚠️ 浏览器子进程非零退出,以结果 JSON 为准(若已写入)") + log.info( + "login_subprocess_nonzero_return code=%s (exit code 不单独作为成败依据,见 result JSON)", + proc_rc, + ) except subprocess.TimeoutExpired as ex: + child_hard_timeout = True if verbose_ui: - print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态") - log.warning("login_subprocess_timeout err=%s", ex) + print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理") + log.warning("login_subprocess_hard_timeout err=%s", ex) finally: try: os.unlink(cfg_path) @@ -240,16 +267,37 @@ def _run_login_browser_session( pass interactive_ok = False - end_reason = "subprocess_error" + end_reason = "timeout" if child_hard_timeout else "subprocess_error" + child_detail = "" try: with open(result_path, encoding="utf-8") as rf: data = json.load(rf) interactive_ok = bool(data.get("interactive_ok")) - end_reason = str(data.get("end_reason") or "").strip() or ( - "login_ok" if interactive_ok else "timeout" - ) - except Exception: - end_reason = "subprocess_error" + er = str(data.get("end_reason") or "").strip() + if er: + end_reason = er + elif interactive_ok: + end_reason = "login_ok" + elif not child_hard_timeout: + end_reason = "timeout" + child_detail = str(data.get("detail") or "").strip() + if child_detail: + log.warning("login_child_result_detail account_id=%s detail=%s", account_id, child_detail[:8000]) + except Exception as read_ex: + log.error( + "login_child_result_read_failed account_id=%s err=%s proc_rc=%s stderr_tail=%s", + account_id, + read_ex, + proc_rc, + (child_stderr or "")[-3000:], + ) + if child_hard_timeout: + end_reason = "timeout" + interactive_ok = False + elif proc_rc is not None and proc_rc != 0: + end_reason = "child_crash" + else: + end_reason = "subprocess_error" try: os.unlink(result_path) except OSError: @@ -265,7 +313,8 @@ def _run_login_browser_session( proc_rc, ok, ) - mark_login_status(account_id, ok) + if ok: + touch_account_updated_at(account_id) return ok, end_reason if not ok else "login_ok" @@ -282,7 +331,7 @@ def cmd_login(account_id): ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True) log_file = get_skill_log_file_path() if ok: - print("✅ 已判定登录成功,状态已写入数据库") + print("✅ 已判定页面登录成功") else: log.warning( "login_not_detected account_id=%s platform=%s see_log=%s", @@ -290,29 +339,23 @@ def cmd_login(account_id): (target.get("platform") or "").strip().lower(), log_file, ) - print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") + print("⚠️ 未检测到有效登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)") -def cmd_wait_login(account_id, force: bool = False) -> int: +def cmd_wait_login(account_id) -> int: """ - 跨技能编排:若未登录则打开浏览器并在限时内等待登录。 - 返回 0:已登录(原本已登录或本次登录成功);1:失败(超时 / 用户关浏览器 / 其它)。 + 跨技能编排:始终以本次 Playwright DOM 检测为准;限时内打开浏览器并等待用户完成登录。 + 返回 0:本次检测判定已登录;1:失败(超时 / 用户关浏览器 / 其它)。 stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。 - - force=True:即使库内 login_status=1 也仍打开浏览器并限时检测(供搜狐发布等「页面仍要求登录」场景)。 """ log = get_skill_logger() - log.info("wait_login_command account_id=%r force=%s", account_id, force) + log.info("wait_login_command account_id=%r", account_id) target = get_account_by_id(account_id) if not target: log.warning("wait_login_aborted account_not_found account_id=%r", account_id) print("ERROR:ACCOUNT_NOT_FOUND") return 1 - # 库内 login_status=1 时跳过开窗(与真实 Cookie 可能不同步时用 force=True) - if int(target.get("login_status") or 0) == 1 and not force: - print("OK:WAIT_LOGIN") - return 0 if not resolve_chromium_channel(): _print_browser_install_hint() @@ -327,9 +370,28 @@ def cmd_wait_login(account_id, force: bool = False) -> int: return 1 sec = _wait_login_timeout_seconds() + log.info( + "wait_login_orchestration account_id=%s name=%r timeout_sec=%s", + target.get("id"), + target.get("name"), + sec, + ) + print("INFO:WAIT_LOGIN_BEGIN", flush=True) print( - f"⏳ 账号「{target.get('name', target['id'])}」尚未登录,将打开浏览器;请在 {sec} 秒内完成登录。" - "关闭浏览器将视为放弃。" + f"账号管理(account-manager)正在为本账号执行登录检测:将打开浏览器,请在 {sec} 秒内在窗口中完成登录。", + flush=True, + ) + print( + "说明:调用方/编排技能只需等待本进程结束;打开浏览器与 DOM 检测均由账号管理负责。", + flush=True, + ) + print( + "· 超时未登录成功 → 本进程报错退出,请中止后续业务。", + flush=True, + ) + print( + "· 提前关闭浏览器 → 视为放弃登录,本进程立即报错退出。", + flush=True, ) ok, end_reason = _run_login_browser_session(target, sec, verbose_ui=False) if ok: @@ -350,6 +412,37 @@ def cmd_wait_login(account_id, force: bool = False) -> int: if end_reason == "no_channel": _print_browser_install_hint() return 1 + if end_reason == "launch_failed": + print( + "ERROR:WAIT_LOGIN_LAUNCH_FAILED 无法启动浏览器或创建用户数据上下文,请确认本机 Chrome/Edge 可用、profile 目录未被占用,详见统一日志。" + ) + return 1 + if end_reason == "page_error": + print( + "ERROR:WAIT_LOGIN_PAGE_ERROR 打开登录页或轮询过程异常,请查看上方 stderr 与统一日志。" + ) + return 1 + if end_reason == "config_error": + print("ERROR:WAIT_LOGIN_CONFIG_ERROR 登录子进程配置异常。") + return 1 + if end_reason == "child_crash": + print("ERROR:WAIT_LOGIN_CHILD_CRASH 登录检测子进程异常退出且未写入有效结果。") + return 1 + if end_reason == "profile_dir_unusable": + print( + "ERROR:WAIT_LOGIN_PROFILE_DIR 用户数据目录不可用或无法访问(权限/路径等),详见统一日志中的 login_child_result_detail / stderr。" + ) + return 1 + if end_reason == "logging_import_failed": + print( + "ERROR:WAIT_LOGIN_INTERNAL 登录子进程无法加载日志模块,请检查 account-manager 安装是否完整。" + ) + return 1 + if end_reason == "child_fatal": + print( + "ERROR:WAIT_LOGIN_CHILD_FATAL 登录子进程异常,详见统一日志 login_child_result_detail 或 login_child_stderr。" + ) + return 1 print(f"ERROR:WAIT_LOGIN_FAILED end_reason={end_reason}") return 1 @@ -386,7 +479,7 @@ def cmd_open(account_id): print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)") print(f"地址:{url}") print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。") - print("需要自动检测并写入数据库时,请执行:python main.py login ") + print("需要自动检测登录时,请执行:python main.py login ") open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py") cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} diff --git a/scripts/service/login_child_runner.py b/scripts/service/login_child_runner.py index 995e0eb..d43fc2f 100644 --- a/scripts/service/login_child_runner.py +++ b/scripts/service/login_child_runner.py @@ -1,36 +1,51 @@ -"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动,argv[1] 为 JSON 配置路径。""" +"""Playwright 登录检测子进程:由 browser_service 以独立解释器启动,argv[1] 为 JSON 配置路径。 + +注意:不得在模块顶层 import playwright / jiangchang_skill_core —— 若导入失败,进程会在 main() 之前以退出码 1 退出, +且写不了 result_path,父进程只能得到 subprocess_error。所有重依赖均在 _run_login_child 内延迟加载。 +""" +from __future__ import annotations + import json import os import sys import time +import traceback from urllib.parse import urlparse _scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _scripts_dir not in sys.path: sys.path.insert(0, _scripts_dir) -from jiangchang_skill_core.unified_logging import attach_unified_file_handler -from playwright.sync_api import sync_playwright - -try: - from playwright.sync_api import Error as PlaywrightError -except ImportError: - PlaywrightError = Exception # type: ignore[misc,assignment] - def _is_context_closed_error(ex: BaseException) -> bool: s = str(ex).lower() if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s: return True - if isinstance(ex, PlaywrightError) and ( - "closed" in s or "destroyed" in s - ): + if "closed" in s or "destroyed" in s: return True return False +def _write_result_file( + result_path: str, + interactive_ok: bool, + end_reason: str, + detail: str = "", +) -> None: + if not (result_path or "").strip(): + return + payload: dict = {"interactive_ok": interactive_ok, "end_reason": end_reason} + d = (detail or "").strip() + if d: + payload["detail"] = d[:8000] + try: + with open(result_path, "w", encoding="utf-8") as rf: + json.dump(payload, rf, ensure_ascii=False) + except Exception as write_ex: + print(f"login_child: failed to write result file: {write_ex}", file=sys.stderr) + + def page_location_href(p): - # Prefer location.href over page.url for SPA (e.g. Sohu shell path lag). try: href = p.evaluate("() => location.href") if href and str(href).strip(): @@ -124,142 +139,240 @@ def evaluate_logged_out_dom(ctx, main_page, bundle): return False, "" -def main(): - with open(sys.argv[1], encoding="utf-8") as f: - c = json.load(f) - bundle = c.get("login_detect_bundle") or {} - poll = float(c.get("poll_interval", 1.5)) +def _run_login_child(c: dict) -> None: + """单次登录检测;结果一律写入 c['result_path'](由 finally 保证)。""" + result_path = (c.get("result_path") or "").strip() try: - dom_grace = float(c.get("dom_grace_sec", 4.0)) - except (TypeError, ValueError): - dom_grace = 4.0 - if dom_grace < 0: - dom_grace = 0.0 - t0 = time.time() - deadline = t0 + float(c["timeout_sec"]) + from playwright.sync_api import sync_playwright + except ImportError: + _write_result_file( + result_path, + False, + "playwright_missing", + traceback.format_exc(), + ) + return + interactive_ok = False end_reason = "timeout" - result_path = c.get("result_path") or "" - log_path = (c.get("log_file") or "").strip() + ctx = None lg = None - if log_path: - lg = attach_unified_file_handler( - log_path, - skill_slug="account-manager", - logger_name="openclaw.skill.account_manager.login_child", - ) last_eval_detail = "" - with sync_playwright() as p: - ctx = p.chromium.launch_persistent_context( - user_data_dir=c["profile_dir"], - headless=False, - channel=c["channel"], - no_viewport=True, - args=["--start-maximized"], - ) + detail_msg = "" + + try: try: - page = ctx.pages[0] if ctx.pages else ctx.new_page() - page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) - if dom_grace > 0: - time.sleep(dom_grace) - if lg is not None: - lg.info( - "goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s", - len(ctx.pages or []), - poll, - dom_grace, + from jiangchang_skill_core.unified_logging import attach_unified_file_handler + except Exception as e: + end_reason = "logging_import_failed" + detail_msg = traceback.format_exc() + print(detail_msg, file=sys.stderr) + return + + try: + profile_dir = (c.get("profile_dir") or "").strip() + if not profile_dir: + end_reason = "profile_missing" + detail_msg = "profile_dir empty in config" + return + + os.makedirs(profile_dir, exist_ok=True) + try: + n_entries = len(os.listdir(profile_dir)) + except OSError as e: + end_reason = "profile_dir_unusable" + detail_msg = f"{type(e).__name__}: {e}" + return + except Exception as e: + end_reason = "profile_dir_unusable" + detail_msg = traceback.format_exc() + print(detail_msg, file=sys.stderr) + return + + log_path = (c.get("log_file") or "").strip() + if log_path: + try: + lg = attach_unified_file_handler( + log_path, + skill_slug="account-manager", + logger_name="openclaw.skill.account_manager.login_child", ) - while time.time() < deadline: - iter_start = time.time() - try: - still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle) - ok = not still_logged_out - last_eval_detail = detail + except Exception as e: + lg = None + print(f"login_child: attach_unified_file_handler failed: {e}", file=sys.stderr) + + if lg is not None: + lg.info( + "login_child_start profile_dir=%r profile_entry_count=%s channel=%r url=%r timeout_sec=%r", + profile_dir, + n_entries, + c.get("channel"), + c.get("url"), + c.get("timeout_sec"), + ) + + bundle = c.get("login_detect_bundle") or {} + poll = float(c.get("poll_interval", 1.5)) + try: + dom_grace = float(c.get("dom_grace_sec", 4.0)) + except (TypeError, ValueError): + dom_grace = 4.0 + if dom_grace < 0: + dom_grace = 0.0 + t0 = time.time() + deadline = t0 + float(c["timeout_sec"]) + + with sync_playwright() as p: + try: + ctx = p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=False, + channel=c["channel"], + no_viewport=True, + args=["--start-maximized"], + ) + except Exception as e: + if _is_context_closed_error(e): + end_reason = "user_closed" + else: + end_reason = "launch_failed" + detail_msg = f"{type(e).__name__}: {e}" if lg is not None: - parts = [] - for tab in list(ctx.pages or []): - try: - href = page_location_href(tab) - pu = (tab.url or "").strip() - parts.append("href=%r playwright_url=%r" % (href, pu)) - except Exception: - parts.append("(tab_read_error)") - lg.debug( - "poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s", - iter_start - t0, - deadline - iter_start, - len(ctx.pages or []), - poll, - still_logged_out, - ok, - detail, - " ; ".join(parts) if parts else "no_tabs", - ) - if ok: - interactive_ok = True - end_reason = "login_ok" - if lg is not None: - lg.info("login_detected_ok %s", detail) - break - except Exception as ex: - if _is_context_closed_error(ex): - end_reason = "user_closed" - if lg is not None: - lg.warning("login_poll_browser_closed err=%s", ex) - break - if lg is not None: - lg.warning("poll_exception err=%s", ex, exc_info=True) - spent = time.time() - iter_start - time.sleep(max(0.0, poll - spent)) - if not interactive_ok and end_reason != "user_closed": + lg.exception("launch_persistent_context failed: %s", e) + print(detail_msg, file=sys.stderr) + return + + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) + if dom_grace > 0: + time.sleep(dom_grace) if lg is not None: - lg.warning("login_poll_exhausted detail=%s", last_eval_detail) - rem = max(0.0, deadline - time.time()) - if rem > 0: + lg.info( + "goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s", + len(ctx.pages or []), + poll, + dom_grace, + ) + while time.time() < deadline: + iter_start = time.time() try: - ctx.wait_for_event("close", timeout=rem * 1000) - except Exception: - pass - else: - if lg is not None: - lg.info("login_dom_ok_preparing_to_release_profile_dir") - if interactive_ok: - # 给 Cookie/会话落盘一点时间;随后必须关闭上下文,否则 llm-manager 无法再对同一 profile 启动持久化浏览器 + still_logged_out, dom_detail = evaluate_logged_out_dom(ctx, page, bundle) + ok = not still_logged_out + last_eval_detail = dom_detail + if lg is not None: + parts = [] + for tab in list(ctx.pages or []): + try: + href = page_location_href(tab) + pu = (tab.url or "").strip() + parts.append("href=%r playwright_url=%r" % (href, pu)) + except Exception: + parts.append("(tab_read_error)") + lg.debug( + "poll elapsed=%.1fs left=%.1fs tabs=%s logged_out=%s ok=%s detail=%s | %s", + iter_start - t0, + deadline - iter_start, + len(ctx.pages or []), + still_logged_out, + ok, + dom_detail, + " ; ".join(parts) if parts else "no_tabs", + ) + if ok: + interactive_ok = True + end_reason = "login_ok" + if lg is not None: + lg.info("login_detected_ok %s", dom_detail) + break + except Exception as ex: + if _is_context_closed_error(ex): + end_reason = "user_closed" + if lg is not None: + lg.warning("login_poll_browser_closed err=%s", ex) + break + if lg is not None: + lg.warning("poll_exception err=%s", ex, exc_info=True) + spent = time.time() - iter_start + time.sleep(max(0.0, poll - spent)) + + if not interactive_ok and end_reason != "user_closed": + if lg is not None: + lg.warning("login_poll_exhausted detail=%s", last_eval_detail) + rem = max(0.0, deadline - time.time()) + if rem > 0: + try: + ctx.wait_for_event("close", timeout=rem * 1000) + except Exception: + pass + elif interactive_ok: + if lg is not None: + lg.info("login_dom_ok_preparing_to_release_profile_dir") print( "INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。", flush=True, ) time.sleep(2.0) - except Exception as e: - if _is_context_closed_error(e): - end_reason = "user_closed" + except Exception as e: interactive_ok = False - if lg is not None: - lg.warning("login_runner_browser_closed err=%s", e) - else: - if lg is not None: - lg.exception("login_runner_fatal err=%s", e) - print(e, file=sys.stderr) - raise - finally: - if result_path: + if _is_context_closed_error(e): + end_reason = "user_closed" + if lg is not None: + lg.warning("login_runner_browser_closed err=%s", e) + else: + end_reason = "page_error" + detail_msg = traceback.format_exc() + if lg is not None: + lg.exception("login_runner_page_err %s", e) + print(detail_msg, file=sys.stderr) + finally: try: - with open(result_path, "w", encoding="utf-8") as rf: - json.dump( - { - "interactive_ok": interactive_ok, - "end_reason": end_reason, - }, - rf, - ensure_ascii=False, - ) + ctx.close() except Exception: pass + ctx = None + + except Exception as e: + interactive_ok = False + end_reason = "child_fatal" + detail_msg = traceback.format_exc() + if lg is not None: + lg.exception("login_child_unhandled %s", e) + print(detail_msg, file=sys.stderr) + finally: + if ctx is not None: try: ctx.close() except Exception: pass + _write_result_file(result_path, interactive_ok, end_reason, detail_msg) + + +def _entrypoint() -> None: + """读取配置 → 运行检测;任意异常都写入 result JSON,进程以 0 退出(由父进程读 JSON 判定成败)。""" + result_path = "" + if len(sys.argv) < 2: + print("login_child: missing cfg path argv", file=sys.stderr) + return + + cfg_path = sys.argv[1] + try: + with open(cfg_path, encoding="utf-8") as f: + c = json.load(f) + except Exception: + tb = traceback.format_exc() + print(tb, file=sys.stderr) + return + + result_path = (c.get("result_path") or "").strip() + + try: + _run_login_child(c) + except Exception: + tb = traceback.format_exc() + print(tb, file=sys.stderr) + _write_result_file(result_path, False, "child_fatal", tb) if __name__ == "__main__": - main() + _entrypoint()