Compare commits

..

7 Commits

Author SHA1 Message Date
beb95c1415 release: v1.0.50 playwright stealth gemini
All checks were successful
技能自动化发布 / release (push) Successful in 34s
2026-04-07 15:18:14 +08:00
e2eafb7407 feat: remove wait-login; pick-web only; docs sync
All checks were successful
技能自动化发布 / release (push) Successful in 33s
2026-04-07 11:58:27 +08:00
96e5b9ea14 fix: spawn login/open child with python -m for PyArmor runtime path
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-07 10:58:32 +08:00
97d970dd1b chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s
2026-04-07 10:36:40 +08:00
e3da478116 feat: release ZIP keeps scripts/ tree; JIANGCHANG_SKILLS_ROOT and sibling skills root
All checks were successful
技能自动化发布 / release (push) Successful in 31s
2026-04-06 18:38:09 +08:00
ac76f89dc8 feat: wait-login 支持 --force,供搜狐发布等 Cookie 失效仍开窗检测
All checks were successful
技能自动化发布 / release (push) Successful in 31s
Made-with: Cursor
2026-04-06 17:30:50 +08:00
c83841cf90 chore: auto release commit (2026-04-06 16:44:58)
All checks were successful
技能自动化发布 / release (push) Successful in 39s
2026-04-06 16:44:59 +08:00
26 changed files with 993 additions and 496 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号管理 name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。 description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.4 version: 1.0.50
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,14 +53,13 @@ python3 {baseDir}/scripts/main.py list all
python3 {baseDir}/scripts/main.py list 搜狐号 python3 {baseDir}/scripts/main.py list 搜狐号
``` ```
### 跨技能:单行 JSONget / list-json / pick-web / pick-logged-in / set-login-status ### 跨技能:单行 JSONget / list-json / pick-web
```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
``` ```
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md` 约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`是否已登录由调用方网页会话处理,本 skill 不做编排用预检。
## 扩展文档(便于 AI 精确调用) ## 扩展文档(便于 AI 精确调用)

View File

@@ -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/`

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"] }
}, },

View File

@@ -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,15 @@ 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` 等排序参考。
- 跨技能编排(如 llm-manager 网页生成)**不再**通过本 skill 单独做「登录预检」;由业务侧在同一浏览器会话内处理未登录态。
### `delete` — 删除库记录并尽量删除 profile 目录 ### `delete` — 删除库记录并尽量删除 profile 目录

View File

@@ -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,9 +12,7 @@
| `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_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 | | `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
@@ -38,10 +29,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:` — 浏览器 / 环境
@@ -53,4 +41,4 @@
## 非前缀输出 ## 非前缀输出
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。 - **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
- **`get` / `list-json` / `pick-*` 成功**`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON - **`get` / `list-json` / `pick-web` 成功**`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON

View File

@@ -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` 列中的扁平扩展可能并入同一对象。
**库中不再持久化「是否已登录」**;编排侧在网页自动化中自行处理登录(与 `pick-web` / `get` 返回的 `profile_dir` 同一浏览器会话)。
## 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. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录。
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
## 退出码 ## 退出码
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。 请以 **stdout 首行前缀****JSON 可解析性** 为准;不要仅依赖退出码。
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀****JSON 可解析性** 为准,不要仅依赖退出码。

View File

@@ -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/`

View File

@@ -2,38 +2,39 @@
## 数据落盘位置(当前实现) ## 数据落盘位置(当前实现)
代码见 `scripts/util/runtime_paths.py``scripts/util/constants.py` `get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(与 `jiangchang-platform-kit` 同源);`scripts/util/runtime_paths.py` 在此基础上拼技能子目录
- **数据根目录** `get_data_root()`:优先环境变量 **`JIANGCHANG_DATA_ROOT`**;未设置Windows 默认 `D:\jiangchang-data`,其它系统默认 `~/.jiangchang-data` - **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**;未设置且未走本地注入时,Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`
- **用户/工作空间 id** `get_user_id()`:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。 - **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置且未走本地注入时为 **`_anon`**。
- **技能数据目录**`{数据根}/{用户id}/account-manager/` - **技能数据目录**`{数据根}/{用户id}/account-manager/`
- **SQLite**`account-manager.db` - **SQLite**`account-manager.db`
- **Profile**`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成) - **Profile**`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成)
- **日志目录**`logs/`(见下 - **日志**与其它技能共用**用户级**目录 `{数据根}/{用户id}/logs/jiangchang.log`(按日轮转
> OpenClaw 工作区文档中的 **`CLAW_*`** 变量:本技能**当前仍以 `JIANGCHANG_*` 为准**读取数据根与用户 id若宿主已统一注入 `CLAW_*`,需在代码层做别名兼容(未做前请只依赖本文列出的变量)。
## 调试为什么「list 没数据」 ## 调试为什么「list 没数据」
- 本地终端未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID` 时,会落到 **默认盘路径****`_anon`**,与网关中运行的实例**不是同一个库文件** - 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**,库文件不是同一份
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际使用的根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli` 输出)。 - 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli`)。
## 本地 CLI 开发注入(慎用 ## 本地 CLI 默认值(`runtime_env`
`util/constants.py` **`_apply_cli_local_dev_env()`** 仅在 CLI 入口调用 各技能 **`main.py`** 在 `sys.path` 就绪后、import 业务包之前调用 **`apply_cli_local_defaults()`**(实现见 `jiangchang_skill_core.runtime_env`
- 若开启本地开发开关(代码内 `_ACCOUNT_MANAGER_CLI_LOCAL_DEV` 或环境变量 **`JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1`**),且当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入代码内配置的默认路径与用户 id - 代码内 **`CLI_LOCAL_DEV_ENABLED = True`** 时,若当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入平台默认数据根与 **`DEFAULT_LOCAL_USER_ID`**(与 kit 中常量一致)
- **发版前务必关闭**,避免个人路径进入制品。详见该文件内注释 - 代码内为 **`False`** 时,可设环境变量 **`JIANGCHANG_CLI_LOCAL_DEV=1`** 达到同样效果
- **发版/嵌入宿主前**:将各技能 vendored 的 `runtime_env.py``CLI_LOCAL_DEV_ENABLED` 改为 **`False`**,或完全依赖宿主注入。
## 日志 ## 日志
`scripts/util/logging_config.py` 实现见 `scripts/jiangchang_skill_core/unified_logging.py``util/logging_config.py` 仅 re-export。
| 变量 | 作用 | | 变量 | 作用 |
|------|------| |------|------|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` | | `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr | | `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{技能数据目录}/logs/account-manager.log` | | `JIANGCHANG_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{数据根}/{用户id}/logs/jiangchang.log` |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 |
## 登录 / 浏览器子进程login ## 登录 / 浏览器子进程login
@@ -51,4 +52,4 @@
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。 - **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。 - **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看技能日志文件。 - **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看统一日志文件。

View File

@@ -9,13 +9,11 @@ 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 from service.browser_service import cmd_login, cmd_open
from util.constants import _apply_cli_local_dev_env from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key from util.platforms import _platform_list_cn_for_help, resolve_platform_key
from util.runtime_paths import _maybe_print_paths_debug_cli from util.runtime_paths import _maybe_print_paths_debug_cli
@@ -25,11 +23,9 @@ 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 delete id <id>") print(" python main.py delete id <id>")
@@ -85,7 +81,7 @@ def main() -> None:
_cli_print_full_usage() _cli_print_full_usage()
sys.exit(1) sys.exit(1)
_apply_cli_local_dev_env() setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
get_skill_logger().info("cli_start argv=%s", sys.argv) get_skill_logger().info("cli_start argv=%s", sys.argv)
cmd = sys.argv[1] cmd = sys.argv[1]
_maybe_print_paths_debug_cli() _maybe_print_paths_debug_cli()
@@ -108,13 +104,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")
@@ -132,18 +121,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 选一条账号;是否已登录由调用方网页会话自行处理")
sys.exit(1) sys.exit(1)
cmd_pick_web(sys.argv[2]) cmd_pick_web(sys.argv[2])
elif cmd == "open": elif cmd == "open":

View File

@@ -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),
) )

View File

@@ -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, -- 是否已登录: 0 否 1 是 (由脚本校验后写入)
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

View File

@@ -0,0 +1 @@
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.

View File

@@ -0,0 +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 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓与安装目录均满足「技能根之上一级 = 并列根」),
避免全局 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()

View File

@@ -0,0 +1,158 @@
"""
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
按日轮转;行内带 trace_id 与 skill_slug便于跨技能排查。
实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。
"""
from __future__ import annotations
import logging
import os
import sys
import uuid
from logging.handlers import TimedRotatingFileHandler
from typing import Optional
from .runtime_env import get_data_root, get_user_id
_skill_slug: str = ""
_logger_name: str = ""
def get_unified_logs_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), "logs")
os.makedirs(path, exist_ok=True)
return path
def get_skill_log_file_path() -> str:
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
if override:
parent = os.path.dirname(os.path.abspath(override))
if parent:
os.makedirs(parent, exist_ok=True)
return os.path.abspath(override)
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
def ensure_trace_for_process() -> str:
"""本进程调用链 ID沿用 JIANGCHANG_TRACE_ID否则生成并写入环境子进程可继承"""
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if existing:
return existing
tid = uuid.uuid4().hex[:12]
os.environ["JIANGCHANG_TRACE_ID"] = tid
return tid
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
ensure_trace_for_process()
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
if not tid:
tid = ensure_trace_for_process()
base = os.environ if environ is None else environ
return {**base, "JIANGCHANG_TRACE_ID": tid}
class _SkillContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
record.skill_slug = _skill_slug or "-"
return True
_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
)
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def _backup_count() -> int:
try:
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
return max(1, min(n, 365))
except ValueError:
return 30
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
"""
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
须在进程早期调用(如 CLI main 在业务日志之前)。
"""
global _skill_slug, _logger_name
ensure_trace_for_process()
_skill_slug = skill_slug
_logger_name = logger_name
log = logging.getLogger(logger_name)
if log.handlers:
return
log.setLevel(_log_level_from_env())
path = get_skill_log_file_path()
fh = TimedRotatingFileHandler(
path,
when="midnight",
interval=1,
backupCount=_backup_count(),
encoding="utf-8",
delay=True,
)
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
log.addHandler(fh)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fmt)
sh.addFilter(_SkillContextFilter())
log.addHandler(sh)
log.propagate = False
def get_skill_logger() -> logging.Logger:
if not _logger_name:
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
return logging.getLogger(_logger_name)
def attach_unified_file_handler(
log_path: str,
*,
skill_slug: str,
logger_name: str,
level: int = logging.DEBUG,
) -> logging.Logger:
"""
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
"""
global _skill_slug
ensure_trace_for_process()
_skill_slug = skill_slug
lg = logging.getLogger(logger_name)
lg.handlers.clear()
lg.setLevel(level)
parent = os.path.dirname(os.path.abspath(log_path))
if parent:
os.makedirs(parent, exist_ok=True)
fh = logging.FileHandler(log_path, encoding="utf-8")
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
fh.setFormatter(fmt)
fh.addFilter(_SkillContextFilter())
lg.addHandler(fh)
lg.propagate = False
return lg

View File

@@ -3,8 +3,17 @@ account-manager CLI 入口。
分层cliargv→ service用例→ dbSQLite共用工具在 util/。 分层cliargv→ service用例→ dbSQLite共用工具在 util/。
""" """
import os
import sys import sys
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
apply_cli_local_defaults()
# Windows GBK 编码兼容修复(须在任何业务 import 与 print 之前) # Windows GBK 编码兼容修复(须在任何业务 import 与 print 之前)
if sys.platform == "win32": if sys.platform == "win32":
import io import io

View File

@@ -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 倒序选一条;是否已登录由调用方(如网页自动化)在同一会话内处理。
便于「已 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")

View File

@@ -7,8 +7,12 @@ 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 get_skill_logger, get_skill_log_file_path from util.logging_config import (
get_skill_logger,
get_skill_log_file_path,
subprocess_env_with_trace,
)
from util.platforms import ( from util.platforms import (
PLATFORMS, PLATFORMS,
PLATFORM_URLS, PLATFORM_URLS,
@@ -17,6 +21,9 @@ from util.platforms import (
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import # 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) _SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import若以「脚本路径」启动子进程
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates): def _win_find_exe(candidates):
@@ -111,43 +118,63 @@ def _login_out_bundle_for(platform_key: str) -> dict:
} }
def cmd_login(account_id): def _run_login_browser_session(
log = get_skill_logger() target: dict,
log.info("login_command account_id=%r", account_id) timeout_sec: int,
target = get_account_by_id(account_id) *,
if not target: verbose_ui: bool = True,
log.warning("login_aborted account_not_found account_id=%r", account_id) ) -> tuple[bool, str]:
print("ERROR:ACCOUNT_NOT_FOUND") """
return 启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。
若 DOM 判定成功则刷新该账号 `updated_at`,便于 `pick-web` 等按最近活跃选号;**是否已登录仅以本次检测为准**。
返回 (是否已登录成功, end_reason)end_reason 为 login_child 写入的 reason失败时可能为
timeout / user_closed / subprocess_error。
"""
log = get_skill_logger()
account_id = target.get("id")
channel = resolve_chromium_channel() channel = resolve_chromium_channel()
if not channel: if not channel:
log.warning("login_aborted no_chromium_channel") log.warning("login_aborted no_chromium_channel")
if verbose_ui:
_print_browser_install_hint() _print_browser_install_hint()
return return False, "no_channel"
try: try:
import playwright # noqa: F401 import playwright # noqa: F401
except ImportError: except ImportError:
log.error("login_aborted playwright_missing") log.error("login_aborted playwright_missing")
if verbose_ui:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium") print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return return False, "playwright_missing"
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
log.warning("login_aborted profile_dir_empty account_id=%s", target.get("id")) log.warning("login_aborted profile_dir_empty account_id=%s", account_id)
if verbose_ui:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。") print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return return False, "profile_missing"
os.makedirs(profile_dir, exist_ok=True) os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com") url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
)
platform = (target.get("platform") or "").strip().lower() platform = (target.get("platform") or "").strip().lower()
timeout_sec = _login_timeout_seconds()
poll_sec = _login_poll_interval_seconds() poll_sec = _login_poll_interval_seconds()
dom_grace_sec = _login_dom_grace_seconds() dom_grace_sec = _login_dom_grace_seconds()
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
if verbose_ui:
print(f"正在为账号 [{target['name']}] 打开 {browser_name}") print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"访问地址:{url}") print(f"访问地址:{url}")
if platform == "gemini":
print(
"Gemini / Google 登录:若提示「浏览器不安全」或「无法登录」,请关闭本窗口后,"
"用普通 Chrome 启动参数 --user-data-dir= 指向下面 profile 目录,在普通浏览器里完成"
" Google 登录后完全退出 Chrome再执行本 login详见 llm-manager Gemini 引擎说明。"
)
print(f" (本账号 profile 目录){profile_dir}")
print( print(
"请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;" "请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;"
"成功后自动关闭窗口并写入状态(无需手动关浏览器)。" "成功后自动关闭窗口并写入状态(无需手动关浏览器)。"
@@ -161,7 +188,7 @@ def cmd_login(account_id):
log.info( log.info(
"login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s " "login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s "
"dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s", "dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s",
target.get("id"), account_id,
platform, platform,
channel, channel,
timeout_sec, timeout_sec,
@@ -171,7 +198,6 @@ def cmd_login(account_id):
url, url,
log_file, log_file,
) )
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
cfg = { cfg = {
"channel": channel, "channel": channel,
"profile_dir": profile_dir, "profile_dir": profile_dir,
@@ -193,18 +219,49 @@ def cmd_login(account_id):
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, "-m", "service.login_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
timeout=timeout_sec + 180, timeout=timeout_sec + 180,
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:
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态") if verbose_ui:
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: except subprocess.TimeoutExpired as ex:
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态") child_hard_timeout = True
log.warning("login_subprocess_timeout err=%s", ex) if verbose_ui:
print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理")
log.warning("login_subprocess_hard_timeout err=%s", ex)
finally: finally:
try: try:
os.unlink(cfg_path) os.unlink(cfg_path)
@@ -212,11 +269,37 @@ def cmd_login(account_id):
pass pass
interactive_ok = False interactive_ok = False
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:
interactive_ok = bool(json.load(rf).get("interactive_ok")) data = json.load(rf)
except Exception: interactive_ok = bool(data.get("interactive_ok"))
pass 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: try:
os.unlink(result_path) os.unlink(result_path)
except OSError: except OSError:
@@ -225,23 +308,40 @@ def cmd_login(account_id):
time.sleep(0.5) time.sleep(0.5)
ok = interactive_ok ok = interactive_ok
log.info( log.info(
"login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s", "login_finished account_id=%s interactive_ok=%s end_reason=%s subprocess_rc=%s marking_db=%s",
target.get("id"), account_id,
interactive_ok, interactive_ok,
end_reason,
proc_rc, proc_rc,
ok, ok,
) )
mark_login_status(target["id"], ok)
if ok: if ok:
print("✅ 已判定登录成功,状态已写入数据库") touch_account_updated_at(account_id)
return ok, end_reason if not ok else "login_ok"
def cmd_login(account_id):
log = get_skill_logger()
log.info("login_command account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("login_aborted account_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
timeout_sec = _login_timeout_seconds()
ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True)
log_file = get_skill_log_file_path()
if ok:
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",
target.get("id"), target.get("id"),
platform, (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 打开更细粒度)")
@@ -277,9 +377,8 @@ 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")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8" mode="w", suffix=".json", delete=False, encoding="utf-8"
@@ -287,7 +386,11 @@ def cmd_open(account_id):
json.dump(cfg, jf, ensure_ascii=False) json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name cfg_path = jf.name
try: try:
subprocess.run([sys.executable, open_runner_path, cfg_path]) subprocess.run(
[sys.executable, "-m", "service.open_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
finally: finally:
try: try:
os.unlink(cfg_path) os.unlink(cfg_path)

View File

@@ -1,15 +1,57 @@
"""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 logging import os
import sys import sys
import time import time
import traceback
from urllib.parse import urlparse from urllib.parse import urlparse
from playwright.sync_api import sync_playwright _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 util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
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 "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): 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():
@@ -103,9 +145,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:
@@ -116,37 +227,39 @@ 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
result_path = c.get("result_path") or ""
log_path = (c.get("log_file") or "").strip()
lg = logging.getLogger("openclaw.skill.account_manager.login_child")
if log_path:
lg.handlers.clear()
lg.setLevel(logging.DEBUG)
_fh = logging.FileHandler(log_path, encoding="utf-8")
_fh.setFormatter(
logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
)
lg.addHandler(_fh)
lg.propagate = False
last_eval_detail = ""
with sync_playwright() as p: with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context( try:
user_data_dir=c["profile_dir"], args, ignore_automation = persistent_context_launch_parts()
lc_kw = dict(
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=args,
) )
if ignore_automation is not None:
lc_kw["ignore_default_args"] = ignore_automation
ctx = p.chromium.launch_persistent_context(**lc_kw)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
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)
if dom_grace > 0: if dom_grace > 0:
time.sleep(dom_grace) time.sleep(dom_grace)
if lg.handlers: if lg is not None:
lg.info( lg.info(
"goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s", "goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s",
len(ctx.pages or []), len(ctx.pages or []),
@@ -156,10 +269,10 @@ 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.handlers: if lg is not None:
parts = [] parts = []
for tab in list(ctx.pages or []): for tab in list(ctx.pages or []):
try: try:
@@ -169,28 +282,34 @@ 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
if lg.handlers: end_reason = "login_ok"
lg.info("login_detected_ok %s", detail) if lg is not None:
lg.info("login_detected_ok %s", dom_detail)
break break
except Exception as ex: except Exception as ex:
if lg.handlers: 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) 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:
if lg.handlers: if not interactive_ok and end_reason != "user_closed":
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)
rem = max(0.0, deadline - time.time()) rem = max(0.0, deadline - time.time())
if rem > 0: if rem > 0:
@@ -198,26 +317,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.handlers: if lg is not None:
lg.info("login_success_closing_browser_immediately") 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: except Exception as e:
if lg.handlers: interactive_ok = False
lg.exception("login_runner_fatal err=%s", e) if _is_context_closed_error(e):
print(e, file=sys.stderr) end_reason = "user_closed"
raise 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: finally:
if result_path:
try:
with open(result_path, "w", encoding="utf-8") as rf:
json.dump({"interactive_ok": interactive_ok}, 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()

View File

@@ -4,18 +4,30 @@ import sys
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
def main(): def main():
with open(sys.argv[1], encoding="utf-8") as f: with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f) c = json.load(f)
with sync_playwright() as p: with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context( args, ignore_automation = persistent_context_launch_parts()
lc_kw = dict(
user_data_dir=c["profile_dir"], user_data_dir=c["profile_dir"],
headless=False, headless=False,
channel=c["channel"], channel=c["channel"],
no_viewport=True, no_viewport=True,
args=["--start-maximized"], args=args,
) )
if ignore_automation is not None:
lc_kw["ignore_default_args"] = ignore_automation
ctx = p.chromium.launch_persistent_context(**lc_kw)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
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)

View File

@@ -1,4 +1,4 @@
"""技能级常量本地 CLI 调试环境注入(仅 CLI 入口显式调用 _apply_cli_local_dev_env 时生效)。""" """技能级常量本地 CLI 默认值见 jiangchang_skill_core.runtime_envmain.py 最早调用 apply_cli_local_defaults)。"""
import os import os
# scripts/util/constants.py -> skill root = parents[2] # scripts/util/constants.py -> skill root = parents[2]
@@ -7,29 +7,3 @@ BASE_DIR = _BASE
SKILL_SLUG = "account-manager" SKILL_SLUG = "account-manager"
# 与其它 OpenClaw 技能对齐openclaw.skill.<slug_下划线> # 与其它 OpenClaw 技能对齐openclaw.skill.<slug_下划线>
LOG_LOGGER_NAME = "openclaw.skill.account_manager" LOG_LOGGER_NAME = "openclaw.skill.account_manager"
# ---------------------------------------------------------------------------
# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。
# - 仅当 CLI 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。
# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。
# - 开启方式(二选一):
# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True
# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1无需改代码
# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。
# ---------------------------------------------------------------------------
_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True
_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data"
_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032"
def _apply_cli_local_dev_env() -> None:
enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV
if not enabled:
v = (os.getenv("JIANGCHANG_ACCOUNT_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"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip()
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip()

View File

@@ -1,69 +1,21 @@
"""技能级文件日志。""" """Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
import logging
import os
import sys
from logging.handlers import TimedRotatingFileHandler
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG from jiangchang_skill_core.unified_logging import (
from util.runtime_paths import get_skill_logs_dir attach_unified_file_handler,
ensure_trace_for_process,
get_skill_log_file_path,
get_skill_logger,
get_unified_logs_dir,
setup_skill_logging,
subprocess_env_with_trace,
)
__all__ = [
def _skill_log_file_path() -> str: "attach_unified_file_handler",
"""主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。""" "ensure_trace_for_process",
override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip() "get_skill_log_file_path",
if override: "get_skill_logger",
parent = os.path.dirname(os.path.abspath(override)) "get_unified_logs_dir",
if parent: "setup_skill_logging",
os.makedirs(parent, exist_ok=True) "subprocess_env_with_trace",
return os.path.abspath(override) ]
return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log")
def _log_level_from_env() -> int:
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
return getattr(logging, v, None) or logging.INFO
def get_skill_log_file_path() -> str:
"""主日志文件绝对路径(与 get_skill_logger 写入文件一致)。"""
return _skill_log_file_path()
def get_skill_logger() -> logging.Logger:
"""
技能级日志单文件按日切分TimedRotatingFileHandler midnightUTF-8。
环境变量JIANGCHANG_LOG_LEVEL默认 INFO、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、
JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。
"""
log = logging.getLogger(LOG_LOGGER_NAME)
if log.handlers:
return log
log.setLevel(_log_level_from_env())
path = _skill_log_file_path()
fh = TimedRotatingFileHandler(
path,
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
delay=True,
)
fh.setFormatter(
logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
)
log.addHandler(fh)
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
sh = logging.StreamHandler(sys.stderr)
sh.setLevel(logging.WARNING)
sh.setFormatter(fh.formatter)
log.addHandler(sh)
log.propagate = False
return log

View File

@@ -62,6 +62,12 @@ PLATFORMS = {
"prefix": "元宝", "prefix": "元宝",
"aliases": ["元宝"], "aliases": ["元宝"],
}, },
"gemini": {
"url": "https://gemini.google.com",
"label": "Gemini",
"prefix": "Gemini",
"aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"],
},
} }
# 未登录页共性主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等Playwright :has-text 可覆盖多数站点。 # 未登录页共性主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等Playwright :has-text 可覆盖多数站点。

View File

@@ -0,0 +1,40 @@
"""
淡化 Playwright 启动时的自动化指纹,减轻 Google 账号页「此浏览器或应用可能不安全」类拦截。
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0或 false/off/no
"""
from __future__ import annotations
import os
STEALTH_INIT_SCRIPT = """
(() => {
try {
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
} catch (e) {}
})();
"""
def stealth_enabled() -> bool:
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
return v not in ("0", "false", "no", "off")
def persistent_context_launch_parts(
*,
extra_args: list[str] | None = None,
) -> tuple[list[str], list[str] | None]:
"""
返回 (args, ignore_default_args)。
ignore_default_args 为 None 时不应传给 launch_persistent_context。
"""
args = ["--start-maximized"]
if extra_args:
args = args + list(extra_args)
if not stealth_enabled():
return args, None
if "--disable-blink-features=AutomationControlled" not in args:
args.append("--disable-blink-features=AutomationControlled")
return args, ["--enable-automation"]

View File

@@ -4,37 +4,17 @@ import re
import sys import sys
from typing import Optional from typing import Optional
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
from util.constants import SKILL_SLUG from util.constants import SKILL_SLUG
from util.platforms import _PLATFORM_PRIMARY_CN from util.platforms import _PLATFORM_PRIMARY_CN
def get_data_root():
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
if env:
return env
if sys.platform == "win32":
return r"D:\jiangchang-data"
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
def get_user_id():
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
return uid or "_anon"
def get_skill_data_dir(): def get_skill_data_dir():
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return path return path
def get_skill_logs_dir() -> str:
"""{DATA_ROOT}/{USER_ID}/account-manager/logs/"""
path = os.path.join(get_skill_data_dir(), "logs")
os.makedirs(path, exist_ok=True)
return path
def get_db_path(): def get_db_path():
return os.path.join(get_skill_data_dir(), "account-manager.db") return os.path.join(get_skill_data_dir(), "account-manager.db")