chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s
All checks were successful
技能自动化发布 / release (push) Successful in 43s
This commit is contained in:
8
SKILL.md
8
SKILL.md
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 账号管理
|
name: 账号管理
|
||||||
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。
|
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。
|
||||||
version: 1.0.4
|
version: 1.0.5
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -35,7 +35,7 @@ python3 {baseDir}/scripts/main.py add 知乎 13800138000
|
|||||||
python3 {baseDir}/scripts/main.py open <id>
|
python3 {baseDir}/scripts/main.py open <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 登录并自动检测、写入数据库
|
### 登录并自动检测(成功则刷新该账号 `updated_at`,不在库中保存「是否已登录」)
|
||||||
```bash
|
```bash
|
||||||
python3 {baseDir}/scripts/main.py login <id>
|
python3 {baseDir}/scripts/main.py login <id>
|
||||||
```
|
```
|
||||||
@@ -53,12 +53,12 @@ python3 {baseDir}/scripts/main.py list all
|
|||||||
python3 {baseDir}/scripts/main.py list 搜狐号
|
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
|
```bash
|
||||||
python3 {baseDir}/scripts/main.py get <id>
|
python3 {baseDir}/scripts/main.py get <id>
|
||||||
python3 {baseDir}/scripts/main.py list-json all --limit 50
|
python3 {baseDir}/scripts/main.py list-json all --limit 50
|
||||||
python3 {baseDir}/scripts/main.py pick-web 搜狐号
|
python3 {baseDir}/scripts/main.py pick-web 搜狐号
|
||||||
python3 {baseDir}/scripts/main.py set-login-status <id> 1
|
python3 {baseDir}/scripts/main.py wait-login <id>
|
||||||
```
|
```
|
||||||
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。
|
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# assets
|
# assets
|
||||||
|
|
||||||
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
||||||
- `schemas/`:`get` / `list-json` / `pick-*` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||||
|
|
||||||
详细命令与错误码见仓库根目录 `references/`。
|
详细命令与错误码见仓库根目录 `references/`。
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||||
"url": "https://mp.sohu.com",
|
"url": "https://mp.sohu.com",
|
||||||
"login_status": 1,
|
|
||||||
"last_login_at": "2026-04-01T12:00:00",
|
|
||||||
"created_at": "2026-03-01T10:00:00",
|
"created_at": "2026-03-01T10:00:00",
|
||||||
"updated_at": "2026-04-01T12:00:00"
|
"updated_at": "2026-04-01T12:00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
"phone": "13900001111",
|
"phone": "13900001111",
|
||||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
|
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
|
||||||
"url": "https://www.zhihu.com",
|
"url": "https://www.zhihu.com",
|
||||||
"login_status": 0,
|
|
||||||
"last_login_at": null,
|
|
||||||
"created_at": "2026-04-02T09:00:00",
|
"created_at": "2026-04-02T09:00:00",
|
||||||
"updated_at": "2026-04-02T09:00:00"
|
"updated_at": "2026-04-02T09:00:00"
|
||||||
},
|
},
|
||||||
@@ -18,8 +16,6 @@
|
|||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||||
"url": "https://mp.sohu.com",
|
"url": "https://mp.sohu.com",
|
||||||
"login_status": 1,
|
|
||||||
"last_login_at": "2026-04-01T12:00:00",
|
|
||||||
"created_at": "2026-03-01T10:00:00",
|
"created_at": "2026-03-01T10:00:00",
|
||||||
"updated_at": "2026-04-01T12:00:00"
|
"updated_at": "2026-04-01T12:00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
|
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
|
||||||
"title": "AccountRecord",
|
"title": "AccountRecord",
|
||||||
"description": "account-manager get / list-json / pick-* 返回的单条账号对象(核心字段;extra_json 合并键不枚举)",
|
"description": "account-manager get / list-json / pick-web 返回的单条账号对象(核心字段;extra_json 合并键不枚举)",
|
||||||
"type": "object",
|
"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": {
|
"properties": {
|
||||||
"id": { "type": "integer" },
|
"id": { "type": "integer" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
@@ -12,11 +12,6 @@
|
|||||||
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
|
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
|
||||||
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
|
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
|
||||||
"url": { "type": "string", "format": "uri" },
|
"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"] },
|
"created_at": { "type": ["string", "null"] },
|
||||||
"updated_at": { "type": ["string", "null"] }
|
"updated_at": { "type": ["string", "null"] }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||||
- **数据与路径**:库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.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)。
|
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
|
||||||
|
|
||||||
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
|
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
|
||||||
@@ -56,33 +56,15 @@ python main.py list-json <平台|all|全部> [--limit N]
|
|||||||
- 默认 `limit=200`,实际 clamp 为 `1..500`。
|
- 默认 `limit=200`,实际 clamp 为 `1..500`。
|
||||||
- 排序:按 `updated_at` 倒序。
|
- 排序:按 `updated_at` 倒序。
|
||||||
|
|
||||||
### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py pick-logged-in <平台>
|
|
||||||
```
|
|
||||||
|
|
||||||
- 筛选:`login_status = 1`,按 `last_login_at` 优先。
|
|
||||||
- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。
|
|
||||||
|
|
||||||
### `pick-web` — 网页自动化候选账号(单行 JSON)
|
### `pick-web` — 网页自动化候选账号(单行 JSON)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py pick-web <平台>
|
python main.py pick-web <平台>
|
||||||
```
|
```
|
||||||
|
|
||||||
- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条。
|
- 按该平台 `updated_at`、`created_at` 倒序选一条;**不**表示当前浏览器已登录。
|
||||||
- 失败:首行 `ERROR:`。
|
- 失败:首行 `ERROR:`。
|
||||||
|
|
||||||
### `set-login-status` — 回写登录态(跨技能)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py set-login-status <id> <0|1>
|
|
||||||
```
|
|
||||||
|
|
||||||
- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS` 或 `OK:CLEARED_LOGIN_STATUS`,退出码 `0`。
|
|
||||||
- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`。
|
|
||||||
|
|
||||||
### `open` — 仅打开浏览器(**不写库**)
|
### `open` — 仅打开浏览器(**不写库**)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,13 +73,24 @@ python main.py open <id>
|
|||||||
|
|
||||||
- 需本机 Chrome/Edge + Playwright;详见运行环境说明。
|
- 需本机 Chrome/Edge + Playwright;详见运行环境说明。
|
||||||
|
|
||||||
### `login` — 打开浏览器并**自动检测登录**,写回 `login_status`
|
### `login` — 打开浏览器并**自动检测登录**(成功则刷新 `updated_at`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py login <id>
|
python main.py login <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
- 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
|
- 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
|
||||||
|
- **不**在库中持久化「是否已登录」;仅刷新 `updated_at` 供 `pick-web` 等排序参考。
|
||||||
|
|
||||||
|
### `wait-login` — 跨技能:**始终以本次 DOM 检测为准**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py wait-login <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 由**账号管理**打开浏览器并完成 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 目录
|
### `delete` — 删除库记录并尽量删除 profile 目录
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
# 终端前缀约定(account-manager)
|
# 终端前缀约定(account-manager)
|
||||||
|
|
||||||
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 或 `OK:` 开头;多行说明可忽略或仅作展示。
|
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
|
||||||
|
|
||||||
## `OK:`(成功标记)
|
|
||||||
|
|
||||||
| 首行 | 场景 |
|
|
||||||
|------|------|
|
|
||||||
| `OK:SET_LOGIN_STATUS` | `set-login-status <id> 1` 成功 |
|
|
||||||
| `OK:CLEARED_LOGIN_STATUS` | `set-login-status <id> 0` 成功 |
|
|
||||||
|
|
||||||
## `ERROR:` — CLI 参数 / 路由
|
## `ERROR:` — CLI 参数 / 路由
|
||||||
|
|
||||||
@@ -19,10 +12,9 @@
|
|||||||
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
|
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
|
||||||
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
|
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
|
||||||
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
|
| `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_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_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
|
||||||
|
| `ERROR:CLI_WAIT_LOGIN_MISSING_ID` | `wait-login` 缺少 id |
|
||||||
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
|
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
|
||||||
|
|
||||||
## `ERROR:` — 业务与数据
|
## `ERROR:` — 业务与数据
|
||||||
@@ -38,10 +30,7 @@
|
|||||||
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
|
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
|
||||||
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
|
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
|
||||||
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
|
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
|
||||||
| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in` 无 `login_status=1` 的账号 |
|
|
||||||
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
|
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
|
||||||
| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 |
|
|
||||||
| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 |
|
|
||||||
|
|
||||||
## `ERROR:` — 浏览器 / 环境
|
## `ERROR:` — 浏览器 / 环境
|
||||||
|
|
||||||
@@ -50,7 +39,20 @@
|
|||||||
| `ERROR:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|
| `ERROR:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|
||||||
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
| `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)。
|
||||||
|
|||||||
@@ -10,30 +10,27 @@
|
|||||||
|------|------|----------------|
|
|------|------|----------------|
|
||||||
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
|
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
|
||||||
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
|
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
|
||||||
| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 |
|
| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录) |
|
||||||
| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 |
|
|
||||||
| 回写登录态 | `set-login-status <id> <0或1>` | 首行 `OK:...` |
|
|
||||||
|
|
||||||
## 解析协议(强建议)
|
## 解析协议(强建议)
|
||||||
|
|
||||||
1. 读取子进程 **stdout** 全文或首行。
|
1. 读取子进程 **stdout** 全文或首行。
|
||||||
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
|
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
|
||||||
3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`)。
|
3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
|
||||||
4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
|
|
||||||
|
|
||||||
## JSON 对象字段(与 `get` 一致)
|
## JSON 对象字段(与 `get` 一致)
|
||||||
|
|
||||||
典型字段见仓库 `assets/examples/get-response.json` 与 `assets/schemas/account-record.schema.json`。
|
典型字段见仓库 `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 <id>`(始终以本次 Playwright DOM 检测为准)或在业务页内自行判断。
|
||||||
|
|
||||||
## Publisher / 自动化流水线示例
|
## Publisher / 自动化流水线示例
|
||||||
|
|
||||||
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。
|
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。
|
||||||
2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login <id>` 或先 `open <id>` 人工登录。
|
2. 需要确保会话有效时:调用 `wait-login <id>`,成功则 stdout 首行含 `OK:WAIT_LOGIN`。
|
||||||
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
|
|
||||||
|
|
||||||
## 退出码
|
## 退出码
|
||||||
|
|
||||||
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。
|
请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准;不要仅依赖退出码。
|
||||||
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准,不要仅依赖退出码。
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
|
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
|
||||||
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
|
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
|
||||||
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
|
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
|
||||||
| [ERRORS.md](ERRORS.md) | `ERROR:` / `OK:` 前缀一览 |
|
| [ERRORS.md](ERRORS.md) | `ERROR:` 前缀一览 |
|
||||||
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
|
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
|
||||||
|
|
||||||
示例 JSON 与 Schema 见仓库 `assets/`。
|
示例 JSON 与 Schema 见仓库 `assets/`。
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ from service.account_service import (
|
|||||||
cmd_get,
|
cmd_get,
|
||||||
cmd_list,
|
cmd_list,
|
||||||
cmd_list_json,
|
cmd_list_json,
|
||||||
cmd_pick_logged_in,
|
|
||||||
cmd_pick_web,
|
cmd_pick_web,
|
||||||
cmd_set_login_status,
|
|
||||||
)
|
)
|
||||||
from service.browser_service import cmd_login, cmd_open, cmd_wait_login
|
from service.browser_service import cmd_login, cmd_open, cmd_wait_login
|
||||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||||
@@ -25,14 +23,12 @@ def _cli_print_full_usage() -> None:
|
|||||||
print("用法概览(将 main.py 换为你的路径):")
|
print("用法概览(将 main.py 换为你的路径):")
|
||||||
print(" python main.py add <平台中文名> <手机号>")
|
print(" python main.py add <平台中文名> <手机号>")
|
||||||
print(" python main.py list [平台|all|全部]")
|
print(" python main.py list [平台|all|全部]")
|
||||||
print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR")
|
print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON")
|
||||||
print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号")
|
|
||||||
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
|
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
|
||||||
print(" python main.py get <id>")
|
print(" python main.py get <id>")
|
||||||
print(" python main.py set-login-status <id> <0|1> # 跨技能回写登录态")
|
|
||||||
print(" python main.py open <id>")
|
print(" python main.py open <id>")
|
||||||
print(" python main.py login <id>")
|
print(" python main.py login <id>")
|
||||||
print(" python main.py wait-login <id> [--force] # 跨技能:限时内等待网页登录(默认 180s);--force 忽略库内已登录标记")
|
print(" python main.py wait-login <id> # 跨技能:限时内 DOM 检测登录(默认 180s)")
|
||||||
print(" python main.py delete id <id>")
|
print(" python main.py delete id <id>")
|
||||||
print(" python main.py delete platform <平台>")
|
print(" python main.py delete platform <平台>")
|
||||||
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")
|
_cli_fail_need_account_id("get")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_get(sys.argv[2])
|
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 <account_id> <0|1>")
|
|
||||||
print("说明:供 llm-manager 等脚本回写登录态;成功时 stdout 首行以 OK: 开头。")
|
|
||||||
sys.exit(1)
|
|
||||||
cmd_set_login_status(sys.argv[2], sys.argv[3])
|
|
||||||
elif cmd == "list-json":
|
elif cmd == "list-json":
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
|
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
|
||||||
@@ -133,18 +122,11 @@ def main() -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
cmd_list_json(platform_arg, limit=lim)
|
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":
|
elif cmd == "pick-web":
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
|
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
|
||||||
print("用法:python main.py pick-web <平台中文名或英文键>")
|
print("用法:python main.py pick-web <平台中文名或英文键>")
|
||||||
print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)。")
|
print("说明:按 updated_at 选一条账号;是否已登录由 wait-login / 页面检测决定。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_pick_web(sys.argv[2])
|
cmd_pick_web(sys.argv[2])
|
||||||
elif cmd == "open":
|
elif cmd == "open":
|
||||||
@@ -154,15 +136,12 @@ def main() -> None:
|
|||||||
cmd_open(sys.argv[2])
|
cmd_open(sys.argv[2])
|
||||||
elif cmd == "wait-login":
|
elif cmd == "wait-login":
|
||||||
av = [a for a in sys.argv[2:] if a]
|
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:
|
if len(av) < 1:
|
||||||
print("ERROR:CLI_WAIT_LOGIN_MISSING_ID")
|
print("ERROR:CLI_WAIT_LOGIN_MISSING_ID")
|
||||||
print("用法:python main.py wait-login <账号 id> [--force]")
|
print("用法:python main.py wait-login <账号 id>")
|
||||||
print("说明:供 llm-manager / 搜狐发布等编排;未登录时打开浏览器,限时内等待登录或用户关窗即结束。")
|
print("说明:始终以本次 DOM 检测为准;供 llm-manager / 搜狐发布等编排。")
|
||||||
print("--force:即使库内已标为登录也重新打开浏览器检测(Cookie 失效时用)。")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sys.exit(cmd_wait_login(av[0], force=force))
|
sys.exit(cmd_wait_login(av[0]))
|
||||||
elif cmd == "login":
|
elif cmd == "login":
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
_cli_fail_need_account_id("login")
|
_cli_fail_need_account_id("login")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def get_account_by_id(account_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
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
|
created_at, updated_at
|
||||||
FROM accounts WHERE id = ?
|
FROM accounts WHERE id = ?
|
||||||
""",
|
""",
|
||||||
@@ -62,14 +62,12 @@ def get_account_by_id(account_id):
|
|||||||
"phone": row[3] or "",
|
"phone": row[3] or "",
|
||||||
"profile_dir": (row[4] or "").strip(),
|
"profile_dir": (row[4] or "").strip(),
|
||||||
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
|
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
|
||||||
"login_status": int(row[6] or 0),
|
"created_at": _unix_to_iso_local(row[7]),
|
||||||
"last_login_at": _unix_to_iso_local(row[7]),
|
"updated_at": _unix_to_iso_local(row[8]),
|
||||||
"created_at": _unix_to_iso_local(row[9]),
|
|
||||||
"updated_at": _unix_to_iso_local(row[10]),
|
|
||||||
}
|
}
|
||||||
if row[8]:
|
if row[6]:
|
||||||
try:
|
try:
|
||||||
extra = json.loads(row[8])
|
extra = json.loads(row[6])
|
||||||
if isinstance(extra, dict):
|
if isinstance(extra, dict):
|
||||||
acc.update(extra)
|
acc.update(extra)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -79,21 +77,13 @@ def get_account_by_id(account_id):
|
|||||||
conn.close()
|
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()
|
now = _now_unix()
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
if success:
|
cur.execute("UPDATE accounts SET updated_at = ? WHERE id = ?", (now, account_id))
|
||||||
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),
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -105,8 +95,8 @@ def fetch_list_rows(platform_key: str, limit: int):
|
|||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
sql = (
|
sql = (
|
||||||
"SELECT id, name, platform, phone, profile_dir, url, login_status, "
|
"SELECT id, name, platform, phone, profile_dir, url, "
|
||||||
"last_login_at, extra_json, created_at, updated_at FROM accounts "
|
"extra_json, created_at, updated_at FROM accounts "
|
||||||
)
|
)
|
||||||
if platform_key == "all":
|
if platform_key == "all":
|
||||||
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
|
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
|
||||||
@@ -140,45 +130,16 @@ def fetch_ids_for_list_json(platform_key: str, lim: int):
|
|||||||
conn.close()
|
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]:
|
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
|
||||||
|
"""该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
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()
|
|
||||||
if not row:
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM accounts
|
SELECT id FROM accounts
|
||||||
WHERE platform = ?
|
WHERE platform = ?
|
||||||
ORDER BY updated_at DESC, id DESC
|
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(platform_key,),
|
(platform_key,),
|
||||||
@@ -216,8 +177,8 @@ def insert_account_row(
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
|
INSERT INTO accounts (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
|
||||||
""",
|
""",
|
||||||
(name, platform_key, phone_store, profile_dir, url, now, now),
|
(name, platform_key, phone_store, profile_dir, url, now, now),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
|
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
|
||||||
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
|
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
|
||||||
ACCOUNTS_TABLE_SQL = """
|
ACCOUNTS_TABLE_SQL = """
|
||||||
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定, 供发布类技能读取登录态 */
|
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */
|
||||||
CREATE TABLE accounts (
|
CREATE TABLE accounts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
|
||||||
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
|
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
|
||||||
@@ -9,8 +9,6 @@ CREATE TABLE accounts (
|
|||||||
phone TEXT, -- 可选绑定手机号
|
phone TEXT, -- 可选绑定手机号
|
||||||
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
|
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
|
||||||
url TEXT, -- 平台入口或登录页 URL
|
url TEXT, -- 平台入口或登录页 URL
|
||||||
login_status INTEGER NOT NULL DEFAULT 0, -- 展示/排序用尽力同步;编排上请以 Playwright DOM 检测为准,勿单独依赖本字段
|
|
||||||
last_login_at INTEGER, -- 最近一次登录成功时间, Unix 秒 UTC; 未登录过为 NULL
|
|
||||||
extra_json TEXT, -- 扩展字段 JSON
|
extra_json TEXT, -- 扩展字段 JSON
|
||||||
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
|
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
|
||||||
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
|
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
|
||||||
|
|||||||
@@ -1,127 +1,258 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
|
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
|
# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
|
||||||
|
|
||||||
CLI_LOCAL_DEV_ENABLED = True
|
CLI_LOCAL_DEV_ENABLED = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
|
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
|
||||||
|
|
||||||
DEFAULT_LOCAL_USER_ID = "10032"
|
DEFAULT_LOCAL_USER_ID = "10032"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
|
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
|
||||||
|
|
||||||
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
|
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
|
||||||
|
|
||||||
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def platform_default_data_root() -> str:
|
def platform_default_data_root() -> str:
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
|
|
||||||
return WIN_DEFAULT_DATA_ROOT
|
return WIN_DEFAULT_DATA_ROOT
|
||||||
|
|
||||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_data_root() -> str:
|
def get_data_root() -> str:
|
||||||
|
|
||||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||||
|
|
||||||
if env:
|
if env:
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
return platform_default_data_root()
|
return platform_default_data_root()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_id() -> str:
|
def get_user_id() -> str:
|
||||||
|
|
||||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_skills_root(path: str) -> bool:
|
def _looks_like_skills_root(path: str) -> bool:
|
||||||
|
|
||||||
if not path or not os.path.isdir(path):
|
if not path or not os.path.isdir(path):
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for marker in (
|
for marker in (
|
||||||
|
|
||||||
"llm-manager",
|
"llm-manager",
|
||||||
|
|
||||||
"content-manager",
|
"content-manager",
|
||||||
|
|
||||||
"account-manager",
|
"account-manager",
|
||||||
|
|
||||||
"sohu-publisher",
|
"sohu-publisher",
|
||||||
|
|
||||||
"api-key-vault",
|
"api-key-vault",
|
||||||
|
|
||||||
):
|
):
|
||||||
|
|
||||||
if os.path.isdir(os.path.join(path, marker)):
|
if os.path.isdir(os.path.join(path, marker)):
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_skills_root() -> str:
|
def get_skills_root() -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
并列技能安装目录(其下为「技能 slug」子目录)。
|
并列技能安装目录(其下为「技能 slug」子目录)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
优先级:
|
优先级:
|
||||||
|
|
||||||
1) JIANGCHANG_SKILLS_ROOT
|
1) JIANGCHANG_SKILLS_ROOT
|
||||||
|
|
||||||
2) CLAW_SKILLS_ROOT
|
2) CLAW_SKILLS_ROOT
|
||||||
|
|
||||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||||
|
|
||||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||||
|
|
||||||
5) 其他平台:~/.openclaw/skills
|
5) 其他平台:~/.openclaw/skills
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||||
|
|
||||||
v = (os.getenv(key) or "").strip()
|
v = (os.getenv(key) or "").strip()
|
||||||
|
|
||||||
if v:
|
if v:
|
||||||
|
|
||||||
return os.path.normpath(v)
|
return os.path.normpath(v)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||||
|
|
||||||
if sys.platform == "win32" and not app:
|
if sys.platform == "win32" and not app:
|
||||||
|
|
||||||
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
||||||
|
|
||||||
if app:
|
if app:
|
||||||
|
|
||||||
nested = os.path.join(app, "skills")
|
nested = os.path.join(app, "skills")
|
||||||
|
|
||||||
if _looks_like_skills_root(nested):
|
if _looks_like_skills_root(nested):
|
||||||
|
|
||||||
return os.path.normpath(nested)
|
return os.path.normpath(nested)
|
||||||
|
|
||||||
if _looks_like_skills_root(app):
|
if _looks_like_skills_root(app):
|
||||||
|
|
||||||
return os.path.normpath(app)
|
return os.path.normpath(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
|
|
||||||
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
||||||
|
|
||||||
if _looks_like_skills_root(nested):
|
if _looks_like_skills_root(nested):
|
||||||
|
|
||||||
return os.path.normpath(nested)
|
return os.path.normpath(nested)
|
||||||
|
|
||||||
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
||||||
|
|
||||||
return os.path.normpath(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"))
|
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
"account-manager",
|
|
||||||
|
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||||
|
|
||||||
|
则用之(开发仓与安装目录均满足「技能根之上一级 = 并列根」),
|
||||||
|
|
||||||
|
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 与当前检出不一致时错调兄弟进程。
|
||||||
|
|
||||||
|
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
"api-key-vault",
|
|
||||||
|
|
||||||
):
|
|
||||||
|
|
||||||
if skill_scripts_dir:
|
if skill_scripts_dir:
|
||||||
|
|
||||||
scripts = os.path.abspath(skill_scripts_dir)
|
scripts = os.path.abspath(skill_scripts_dir)
|
||||||
|
|
||||||
skill_root = os.path.dirname(scripts)
|
skill_root = os.path.dirname(scripts)
|
||||||
|
|
||||||
inferred = os.path.dirname(skill_root)
|
inferred = os.path.dirname(skill_root)
|
||||||
|
|
||||||
if _looks_like_skills_root(inferred):
|
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()
|
return get_skills_root()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def apply_cli_local_defaults() -> None:
|
def apply_cli_local_defaults() -> None:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
在 CLI 最早阶段调用(main.py 在 import 业务包之前)。
|
在 CLI 最早阶段调用(main.py 在 import 业务包之前)。
|
||||||
|
|
||||||
宿主已设置 JIANGCHANG_* 时不会覆盖。
|
宿主已设置 JIANGCHANG_* 时不会覆盖。
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
enabled = CLI_LOCAL_DEV_ENABLED
|
enabled = CLI_LOCAL_DEV_ENABLED
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
|
|
||||||
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
||||||
|
|
||||||
enabled = v in ("1", "true", "yes", "on")
|
enabled = v in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||||
|
|
||||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||||
|
|
||||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||||
|
|
||||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""账号用例:list / get / add / delete / pick / set-login-status(含终端输出,供 CLI 调用)。"""
|
"""账号用例:list / get / add / delete / pick-web(含终端输出,供 CLI 调用)。"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -15,13 +15,11 @@ from db.accounts_repo import (
|
|||||||
fetch_ids_for_list_json,
|
fetch_ids_for_list_json,
|
||||||
fetch_ids_profile_for_platform_conn,
|
fetch_ids_profile_for_platform_conn,
|
||||||
fetch_list_rows,
|
fetch_list_rows,
|
||||||
fetch_pick_logged_in_id,
|
|
||||||
fetch_pick_web_candidate_id,
|
fetch_pick_web_candidate_id,
|
||||||
fetch_row_platform_phone_conn,
|
fetch_row_platform_phone_conn,
|
||||||
get_account_by_id,
|
get_account_by_id,
|
||||||
has_duplicate_phone_conn,
|
has_duplicate_phone_conn,
|
||||||
insert_account_row,
|
insert_account_row,
|
||||||
mark_login_status,
|
|
||||||
normalize_account_id,
|
normalize_account_id,
|
||||||
)
|
)
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
@@ -78,8 +76,6 @@ def cmd_list(platform="all", limit: int = 10):
|
|||||||
phone,
|
phone,
|
||||||
profile_dir,
|
profile_dir,
|
||||||
url,
|
url,
|
||||||
login_status,
|
|
||||||
last_login_at,
|
|
||||||
extra_json,
|
extra_json,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
@@ -90,8 +86,6 @@ def cmd_list(platform="all", limit: int = 10):
|
|||||||
print(f"phone:{phone or ''}")
|
print(f"phone:{phone or ''}")
|
||||||
print(f"profile_dir:{profile_dir or ''}")
|
print(f"profile_dir:{profile_dir or ''}")
|
||||||
print(f"url:{url 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"extra_json:{extra_json or ''}")
|
||||||
print(f"created_at:{int(created_at) if created_at is not None else ''}")
|
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 ''}")
|
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))
|
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 <id>")
|
|
||||||
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):
|
def cmd_pick_web(platform_input: str):
|
||||||
"""
|
"""
|
||||||
供 llm-manager 等:取该平台用于网页自动化的账号候选。
|
供 llm-manager 等:取该平台用于网页自动化的账号候选。
|
||||||
优先 login_status=1(与 pick-logged-in 一致);若无,则取该平台 updated_at 最新的一条,
|
按 updated_at、created_at 倒序选一条,与库内「登录态」无关;实际是否已登录由 wait-login / 页面 DOM 判定。
|
||||||
便于「已 add 未标登录」时仍打开 profile,在浏览器内登录后继续任务。
|
|
||||||
成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。
|
成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。
|
||||||
"""
|
"""
|
||||||
get_skill_logger().info("pick_web platform_input=%r", platform_input)
|
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}"
|
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)
|
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")
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse
|
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 (
|
from util.logging_config import (
|
||||||
get_skill_logger,
|
get_skill_logger,
|
||||||
get_skill_log_file_path,
|
get_skill_log_file_path,
|
||||||
@@ -132,7 +132,7 @@ def _run_login_browser_session(
|
|||||||
"""
|
"""
|
||||||
启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。
|
启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。
|
||||||
|
|
||||||
成功后尽力 `mark_login_status` 供 list/排序展示,**跨技能编排请勿再依赖该字段**(可能与真实 Cookie 会话脱节)。
|
若 DOM 判定成功则刷新该账号 `updated_at`,便于 `pick-web` 等按最近活跃选号;**是否已登录仅以本次检测为准**。
|
||||||
|
|
||||||
返回 (是否已登录成功, end_reason):end_reason 为 login_child 写入的 reason,失败时可能为
|
返回 (是否已登录成功, end_reason):end_reason 为 login_child 写入的 reason,失败时可能为
|
||||||
timeout / user_closed / subprocess_error。
|
timeout / user_closed / subprocess_error。
|
||||||
@@ -218,21 +218,48 @@ def _run_login_browser_session(
|
|||||||
json.dump(cfg, jf, ensure_ascii=False)
|
json.dump(cfg, jf, ensure_ascii=False)
|
||||||
cfg_path = jf.name
|
cfg_path = jf.name
|
||||||
proc_rc = None
|
proc_rc = None
|
||||||
|
child_hard_timeout = False
|
||||||
|
child_stderr = ""
|
||||||
|
child_stdout = ""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[sys.executable, login_runner_path, cfg_path],
|
[sys.executable, login_runner_path, cfg_path],
|
||||||
timeout=timeout_sec + 180,
|
timeout=timeout_sec + 180,
|
||||||
env=subprocess_env_with_trace(),
|
env=subprocess_env_with_trace(),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
)
|
)
|
||||||
proc_rc = r.returncode
|
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 proc_rc != 0:
|
||||||
if verbose_ui:
|
if verbose_ui:
|
||||||
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
|
print("⚠️ 浏览器子进程非零退出,以结果 JSON 为准(若已写入)")
|
||||||
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
|
log.info(
|
||||||
|
"login_subprocess_nonzero_return code=%s (exit code 不单独作为成败依据,见 result JSON)",
|
||||||
|
proc_rc,
|
||||||
|
)
|
||||||
except subprocess.TimeoutExpired as ex:
|
except subprocess.TimeoutExpired as ex:
|
||||||
|
child_hard_timeout = True
|
||||||
if verbose_ui:
|
if verbose_ui:
|
||||||
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
|
print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理")
|
||||||
log.warning("login_subprocess_timeout err=%s", ex)
|
log.warning("login_subprocess_hard_timeout err=%s", ex)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(cfg_path)
|
os.unlink(cfg_path)
|
||||||
@@ -240,15 +267,36 @@ def _run_login_browser_session(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
interactive_ok = False
|
interactive_ok = False
|
||||||
end_reason = "subprocess_error"
|
end_reason = "timeout" if child_hard_timeout else "subprocess_error"
|
||||||
|
child_detail = ""
|
||||||
try:
|
try:
|
||||||
with open(result_path, encoding="utf-8") as rf:
|
with open(result_path, encoding="utf-8") as rf:
|
||||||
data = json.load(rf)
|
data = json.load(rf)
|
||||||
interactive_ok = bool(data.get("interactive_ok"))
|
interactive_ok = bool(data.get("interactive_ok"))
|
||||||
end_reason = str(data.get("end_reason") or "").strip() or (
|
er = str(data.get("end_reason") or "").strip()
|
||||||
"login_ok" if interactive_ok else "timeout"
|
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:],
|
||||||
)
|
)
|
||||||
except Exception:
|
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"
|
end_reason = "subprocess_error"
|
||||||
try:
|
try:
|
||||||
os.unlink(result_path)
|
os.unlink(result_path)
|
||||||
@@ -265,7 +313,8 @@ def _run_login_browser_session(
|
|||||||
proc_rc,
|
proc_rc,
|
||||||
ok,
|
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"
|
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)
|
ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True)
|
||||||
log_file = get_skill_log_file_path()
|
log_file = get_skill_log_file_path()
|
||||||
if ok:
|
if ok:
|
||||||
print("✅ 已判定登录成功,状态已写入数据库")
|
print("✅ 已判定页面登录成功")
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
"login_not_detected account_id=%s platform=%s see_log=%s",
|
"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(),
|
(target.get("platform") or "").strip().lower(),
|
||||||
log_file,
|
log_file,
|
||||||
)
|
)
|
||||||
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
print("⚠️ 未检测到有效登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
||||||
print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
|
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:
|
||||||
"""
|
"""
|
||||||
跨技能编排:若未登录则打开浏览器并在限时内等待登录。
|
跨技能编排:始终以本次 Playwright DOM 检测为准;限时内打开浏览器并等待用户完成登录。
|
||||||
返回 0:已登录(原本已登录或本次登录成功);1:失败(超时 / 用户关浏览器 / 其它)。
|
返回 0:本次检测判定已登录;1:失败(超时 / 用户关浏览器 / 其它)。
|
||||||
stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。
|
stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。
|
||||||
|
|
||||||
force=True:即使库内 login_status=1 也仍打开浏览器并限时检测(供搜狐发布等「页面仍要求登录」场景)。
|
|
||||||
"""
|
"""
|
||||||
log = get_skill_logger()
|
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)
|
target = get_account_by_id(account_id)
|
||||||
if not target:
|
if not target:
|
||||||
log.warning("wait_login_aborted account_not_found account_id=%r", account_id)
|
log.warning("wait_login_aborted account_not_found account_id=%r", account_id)
|
||||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||||
return 1
|
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():
|
if not resolve_chromium_channel():
|
||||||
_print_browser_install_hint()
|
_print_browser_install_hint()
|
||||||
@@ -327,9 +370,28 @@ def cmd_wait_login(account_id, force: bool = False) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
sec = _wait_login_timeout_seconds()
|
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(
|
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)
|
ok, end_reason = _run_login_browser_session(target, sec, verbose_ui=False)
|
||||||
if ok:
|
if ok:
|
||||||
@@ -350,6 +412,37 @@ def cmd_wait_login(account_id, force: bool = False) -> int:
|
|||||||
if end_reason == "no_channel":
|
if end_reason == "no_channel":
|
||||||
_print_browser_install_hint()
|
_print_browser_install_hint()
|
||||||
return 1
|
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}")
|
print(f"ERROR:WAIT_LOGIN_FAILED end_reason={end_reason}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -386,7 +479,7 @@ def cmd_open(account_id):
|
|||||||
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||||
print(f"地址:{url}")
|
print(f"地址:{url}")
|
||||||
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
|
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
|
||||||
print("需要自动检测并写入数据库时,请执行:python main.py login <id>")
|
print("需要自动检测登录时,请执行:python main.py login <id>")
|
||||||
|
|
||||||
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
|
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
|
||||||
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
||||||
|
|||||||
@@ -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 json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
if _scripts_dir not in sys.path:
|
if _scripts_dir not in sys.path:
|
||||||
sys.path.insert(0, _scripts_dir)
|
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:
|
def _is_context_closed_error(ex: BaseException) -> bool:
|
||||||
s = str(ex).lower()
|
s = str(ex).lower()
|
||||||
if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s:
|
if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s:
|
||||||
return True
|
return True
|
||||||
if isinstance(ex, PlaywrightError) and (
|
if "closed" in s or "destroyed" in s:
|
||||||
"closed" in s or "destroyed" in s
|
|
||||||
):
|
|
||||||
return True
|
return True
|
||||||
return False
|
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):
|
def page_location_href(p):
|
||||||
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
|
|
||||||
try:
|
try:
|
||||||
href = p.evaluate("() => location.href")
|
href = p.evaluate("() => location.href")
|
||||||
if href and str(href).strip():
|
if href and str(href).strip():
|
||||||
@@ -124,9 +139,78 @@ def evaluate_logged_out_dom(ctx, main_page, bundle):
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def _run_login_child(c: dict) -> None:
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
"""单次登录检测;结果一律写入 c['result_path'](由 finally 保证)。"""
|
||||||
c = json.load(f)
|
result_path = (c.get("result_path") or "").strip()
|
||||||
|
try:
|
||||||
|
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"
|
||||||
|
ctx = None
|
||||||
|
lg = None
|
||||||
|
last_eval_detail = ""
|
||||||
|
detail_msg = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
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 {}
|
bundle = c.get("login_detect_bundle") or {}
|
||||||
poll = float(c.get("poll_interval", 1.5))
|
poll = float(c.get("poll_interval", 1.5))
|
||||||
try:
|
try:
|
||||||
@@ -137,26 +221,27 @@ def main():
|
|||||||
dom_grace = 0.0
|
dom_grace = 0.0
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
deadline = t0 + float(c["timeout_sec"])
|
deadline = t0 + float(c["timeout_sec"])
|
||||||
interactive_ok = False
|
|
||||||
end_reason = "timeout"
|
|
||||||
result_path = c.get("result_path") or ""
|
|
||||||
log_path = (c.get("log_file") or "").strip()
|
|
||||||
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:
|
with sync_playwright() as p:
|
||||||
|
try:
|
||||||
ctx = p.chromium.launch_persistent_context(
|
ctx = p.chromium.launch_persistent_context(
|
||||||
user_data_dir=c["profile_dir"],
|
user_data_dir=profile_dir,
|
||||||
headless=False,
|
headless=False,
|
||||||
channel=c["channel"],
|
channel=c["channel"],
|
||||||
no_viewport=True,
|
no_viewport=True,
|
||||||
args=["--start-maximized"],
|
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:
|
||||||
|
lg.exception("launch_persistent_context failed: %s", e)
|
||||||
|
print(detail_msg, file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
||||||
@@ -172,9 +257,9 @@ def main():
|
|||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
iter_start = time.time()
|
iter_start = time.time()
|
||||||
try:
|
try:
|
||||||
still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle)
|
still_logged_out, dom_detail = evaluate_logged_out_dom(ctx, page, bundle)
|
||||||
ok = not still_logged_out
|
ok = not still_logged_out
|
||||||
last_eval_detail = detail
|
last_eval_detail = dom_detail
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
parts = []
|
parts = []
|
||||||
for tab in list(ctx.pages or []):
|
for tab in list(ctx.pages or []):
|
||||||
@@ -185,21 +270,20 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
parts.append("(tab_read_error)")
|
parts.append("(tab_read_error)")
|
||||||
lg.debug(
|
lg.debug(
|
||||||
"poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
|
"poll elapsed=%.1fs left=%.1fs tabs=%s logged_out=%s ok=%s detail=%s | %s",
|
||||||
iter_start - t0,
|
iter_start - t0,
|
||||||
deadline - iter_start,
|
deadline - iter_start,
|
||||||
len(ctx.pages or []),
|
len(ctx.pages or []),
|
||||||
poll,
|
|
||||||
still_logged_out,
|
still_logged_out,
|
||||||
ok,
|
ok,
|
||||||
detail,
|
dom_detail,
|
||||||
" ; ".join(parts) if parts else "no_tabs",
|
" ; ".join(parts) if parts else "no_tabs",
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
interactive_ok = True
|
interactive_ok = True
|
||||||
end_reason = "login_ok"
|
end_reason = "login_ok"
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
lg.info("login_detected_ok %s", detail)
|
lg.info("login_detected_ok %s", dom_detail)
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
if _is_context_closed_error(ex):
|
if _is_context_closed_error(ex):
|
||||||
@@ -211,6 +295,7 @@ def main():
|
|||||||
lg.warning("poll_exception err=%s", ex, exc_info=True)
|
lg.warning("poll_exception err=%s", ex, exc_info=True)
|
||||||
spent = time.time() - iter_start
|
spent = time.time() - iter_start
|
||||||
time.sleep(max(0.0, poll - spent))
|
time.sleep(max(0.0, poll - spent))
|
||||||
|
|
||||||
if not interactive_ok and end_reason != "user_closed":
|
if not interactive_ok and end_reason != "user_closed":
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
|
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
|
||||||
@@ -220,46 +305,74 @@ def main():
|
|||||||
ctx.wait_for_event("close", timeout=rem * 1000)
|
ctx.wait_for_event("close", timeout=rem * 1000)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
elif interactive_ok:
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
lg.info("login_dom_ok_preparing_to_release_profile_dir")
|
lg.info("login_dom_ok_preparing_to_release_profile_dir")
|
||||||
if interactive_ok:
|
|
||||||
# 给 Cookie/会话落盘一点时间;随后必须关闭上下文,否则 llm-manager 无法再对同一 profile 启动持久化浏览器
|
|
||||||
print(
|
print(
|
||||||
"INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。",
|
"INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
interactive_ok = False
|
||||||
if _is_context_closed_error(e):
|
if _is_context_closed_error(e):
|
||||||
end_reason = "user_closed"
|
end_reason = "user_closed"
|
||||||
interactive_ok = False
|
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
lg.warning("login_runner_browser_closed err=%s", e)
|
lg.warning("login_runner_browser_closed err=%s", e)
|
||||||
else:
|
else:
|
||||||
|
end_reason = "page_error"
|
||||||
|
detail_msg = traceback.format_exc()
|
||||||
if lg is not None:
|
if lg is not None:
|
||||||
lg.exception("login_runner_fatal err=%s", e)
|
lg.exception("login_runner_page_err %s", e)
|
||||||
print(e, file=sys.stderr)
|
print(detail_msg, file=sys.stderr)
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
if result_path:
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
ctx.close()
|
ctx.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
_entrypoint()
|
||||||
|
|||||||
Reference in New Issue
Block a user