Compare commits

...

7 Commits

Author SHA1 Message Date
6b6d2969c3 release: v1.0.55 SKILL description for routing, CLI mapping table, when-to-use cleanup
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:41:54 +08:00
82720ef2fa release: v1.0.54 SKILL/README: add account via chat, auto profile_dir
All checks were successful
技能自动化发布 / release (push) Successful in 30s
2026-04-12 16:08:33 +08:00
3a1ea6d97a Release v1.0.53: account docs split, remove login flow, SCHEMA reference
All checks were successful
技能自动化发布 / release (push) Successful in 37s
2026-04-12 10:56:50 +08:00
75a332e7b8 release: docs, browser_service, runtime_env, SKILL v1.0.52
All checks were successful
技能自动化发布 / release (push) Successful in 28s
2026-04-08 14:00:42 +08:00
4605d3d0e4 release: runtime_env marker, SKILL v1.0.51
All checks were successful
技能自动化发布 / release (push) Successful in 25s
2026-04-08 12:05:14 +08:00
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
18 changed files with 221 additions and 1039 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号管理 name: 账号管理
description: 多平台账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。 description: "多平台账号搜狐号、头条、知乎、微信公众号、豆包、Kimi、DeepSeek 等)的本地登记与查询。当用户或编排要添加/删除账号、列出全部或某平台账号、按 id 查看单条账号信息、为网页自动化批量取账号 JSON 或从中选一条候选、在浏览器中打开已登记账号以登录或核对页面时,加载本技能。本技能不在库中记录「是否已登录」;具体子命令与参数以 references/CLI.md 为准。"
version: 1.0.5 version: 1.0.55
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -12,57 +12,46 @@ allowed-tools:
- bash - bash
--- ---
# 账号管理 # 账号管理account-manager
## 使用时机 在本地 SQLite 登记各平台账号与持久化用户目录路径,供其它技能通过子进程调用 `scripts/main.py` 读取或维护。**不在库中持久化「是否已登录」**`pick-web` 仅按规则选一条候选,**不表示**当前浏览器已登录。
当用户发送以下内容的时候触发本Skill ## 何时使用本技能
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
- 说"列出账号"、"查看所有账号"、"有哪些账号"
- 说"添加账号"、"删除账号"、"获取账号信息"
## 执行步骤 出现下列**业务场景**时加载;真正执行时严格按 **`references/CLI.md`** 拼 argv勿臆造参数。子命令与源码函数的对应关系见该文件篇首映射表。
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号同平台下同手机号不可重复ID 自增名称如「搜狐1号」 1. **登记新账号**:用户给出平台名与大陆 11 位手机号 —— `add`
```bash 2. **人读查看列表**:要看全部或某一平台的账号台账(多行文本);首参若能识别为平台,可用「只写平台名」或「平台名 + list」的简写 —— `list`(路由见 `cli/app.py`)。
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx 3. **按 id 取一条机器可读记录**:编排或调试需要单行 JSON —— `get`
python3 {baseDir}/scripts/main.py add 知乎 13800138000 4. **按平台或全部拉列表机器可读**stdout 一行 JSON 数组,元素同 `get`,供跨技能编排 —— `list-json`
``` 5. **为某平台挑一条网页自动化用号**stdout 一行 JSON按更新时间等规则选一条 —— `pick-web`
支持的平台称呼示例搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝亦支持英文键如 sohu、toutiao)。 6. **在浏览器里打开已登记账号**:用户说「打开某平台这个账号」「用浏览器打开 id 为 x 的号」等 —— `open`(须已知账号 id本机 Chrome/Edge + Playwright channel不写库、不做登录检测)。
7. **删除账号**:按 id 删一条、按平台清空、或按平台+手机号删一条 —— `delete``id` / `platform` / `platform`+手机号 三种形式)。
### 仅打开浏览器核对是否已登录(不写数据库) ## CLI 调用
```bash
python3 {baseDir}/scripts/main.py open <id>
```
### 登录并自动检测(成功则刷新该账号 `updated_at`,不在库中保存「是否已登录」) 语法、默认值、错误约定、**CLI → service** 映射见 **`references/CLI.md`**。
```bash
python3 {baseDir}/scripts/main.py login <id>
```
### 删除账号(同时删库里的记录与 profile 用户数据目录) **重要约束**CLI 由宿主或工具执行;**不要把命令原文贴给最终用户看**。添加账号时用户只需在对话中提供**平台名与 11 位完整手机号****不要**引导具体宿主菜单路径;**不要**让用户填写 `profile_dir``add` 时由实现分配路径)。
```bash
python3 {baseDir}/scripts/main.py delete id <id>
python3 {baseDir}/scripts/main.py delete platform <平台>
python3 {baseDir}/scripts/main.py delete platform <平台> <手机号>
```
### 列出某平台所有账号platform 可为中文名、all 或 全部) ## 平台名称与别名
```bash
python3 {baseDir}/scripts/main.py list all
python3 {baseDir}/scripts/main.py list 搜狐号
```
### 跨技能:单行 JSONget / list-json / pick-web需会话有效时用 wait-login 入库为英文 `platform` 键;用户话术可为中文名或别名。见 `references/PLATFORMS.md`;错误前缀见 `references/ERRORS.md`
```bash
python3 {baseDir}/scripts/main.py get <id>
python3 {baseDir}/scripts/main.py list-json all --limit 50
python3 {baseDir}/scripts/main.py pick-web 搜狐号
python3 {baseDir}/scripts/main.py wait-login <id>
```
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`
## 扩展文档(便于 AI 精确调用) ## 数据与环境
- **references/**:完整 CLI、平台表、运行时环境、错误码、集成协议 — 见 [references/README.md](references/README.md) 库路径、profile 布局、环境变量见 `references/RUNTIME.md`;表结构见 `references/SCHEMA.md`
- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。
## 跨技能集成
`get` / `list-json` / `pick-web` 的 stdout 协议见 `references/INTEGRATION.md`
## 依赖与运行时
- **Python**:共享宿主运行时;勿在技能目录内自建 `.venv`
- **浏览器**`open` 依赖本机 **Chrome 或 Edge****playwright**channel 模式)。详见 `references/RUNTIME.md`
- **环境变量**`JIANGCHANG_*` / `CLAW_*` 等与兄弟技能一致。
## 资产与示例
`assets/README.md``assets/examples/``assets/schemas/`

View File

@@ -3,4 +3,5 @@
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/``get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema`additionalProperties: true` 允许扩展字段)。 - `schemas/``get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema`additionalProperties: true` 允许扩展字段)。
详细命令与错误码见仓库根目录 `references/` - 面向用户的介绍见 `references/README.md`
- 面向编排/CLI 的细节见 `references/CLI.md``ERRORS.md``INTEGRATION.md`(由 `SKILL.md` 渐进引用)。

View File

@@ -1,6 +1,20 @@
# account-manager CLI 参考 # account-manager CLI 速查
入口:`{skillRoot}/scripts/main.py`(宿主可`{baseDir}` 技能根目录代入)。下文用 `python main.py` 表示在 `scripts/` 目录执行,或写成 `python {skillRoot}/scripts/main.py` `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py` 表示在 `scripts/` 下执行
## 子命令与实现(与代码一致)
| CLI`main.py` 第一参或等价路由) | 实现(`scripts/service/` |
|----------------------------------|---------------------------|
| `add` | `account_service.cmd_add` |
| `list`;或首参为平台且 `list`/单参 | `account_service.cmd_list`(路由见 `cli/app.py` |
| `get` | `account_service.cmd_get` |
| `list-json` | `account_service.cmd_list_json` |
| `pick-web` | `account_service.cmd_pick_web` |
| `open` | `browser_service.cmd_open` |
| `delete` | `account_service.cmd_delete_by_id` / `cmd_delete_by_platform` / `cmd_delete_by_platform_phone` |
入口:`scripts/main.py``cli/app.py` → 上表。
## 通用说明 ## 通用说明
@@ -65,32 +79,14 @@ python main.py pick-web <平台>
- 按该平台 `updated_at``created_at` 倒序选一条;**不**表示当前浏览器已登录。 - 按该平台 `updated_at``created_at` 倒序选一条;**不**表示当前浏览器已登录。
- 失败:首行 `ERROR:` - 失败:首行 `ERROR:`
### `open` — 仅打开浏览器(**不写库** ### `open` — 仅打开浏览器(**不写库、不检测登录**
```bash ```bash
python main.py open <id> python main.py open <id>
``` ```
- 需本机 Chrome/Edge + Playwright详见运行环境说明。 - 需本机 Chrome/Edge + Playwright详见运行环境说明。
- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。
### `login` — 打开浏览器并**自动检测登录**(成功则刷新 `updated_at`
```bash
python main.py login <id>
```
- 依赖 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 目录

View File

@@ -9,12 +9,10 @@
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 | | `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id | | `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id | | `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 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_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 | | `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `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:` — 业务与数据
@@ -36,22 +34,9 @@
| 前缀 | 含义 | | 前缀 | 含义 |
|------|------| |------|------|
| `ERROR:需要 playwrightpip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 | | `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edgechannel 模式,**不**需 `playwright install chromium` |
| `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`:等待本进程整体超过上限(登录窗口 + 子进程缓冲),已中止。
## 非前缀输出 ## 非前缀输出
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。 - **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。

View File

@@ -24,12 +24,12 @@
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。 常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
**库中不再持久化「是否已登录」**;编排侧需登录时请调用 `wait-login <id>`(始终以本次 Playwright DOM 检测为准)或在业务页内自行判断 **库中不再持久化「是否已登录」**;编排侧在网页自动化中自行处理登录(与 `pick-web` / `get` 返回的 `profile_dir` 同一浏览器会话)
## Publisher / 自动化流水线示例 ## Publisher / 自动化流水线示例
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url` 1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url`
2. 需要确保会话有效时:调用 `wait-login <id>`,成功则 stdout 首行含 `OK:WAIT_LOGIN` 2. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录
## 退出码 ## 退出码

View File

@@ -16,6 +16,7 @@
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com | | `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com | | `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com | | `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | Gemini | 谷歌Gemini、Google Gemini、google gemini、Bard | https://gemini.google.com |
## 解析规则(摘要) ## 解析规则(摘要)

View File

@@ -1,13 +1,52 @@
# referencesaccount-manager ---
description: "统一管理搜狐、头条、知乎、微信、豆包、Kimi、DeepSeek 等多平台账号与浏览器配置目录。添加账号后,其它技能(如大模型写作、平台发布)可按平台自动选用对应环境。"
---
供 Agent 按需加载的说明文档(与 `SKILL.md` 配合,渐进式披露)。 # 👤 账号管理
| 文件 | 内容 | 把你在各平台的账号**登记到本地**,后续「用大模型写稿」「发布到搜狐号」等技能才能按平台选用对应账号。
|------|------|
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
| [ERRORS.md](ERRORS.md) | `ERROR:` 前缀一览 |
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
示例 JSON 与 Schema 见仓库 `assets/` > 本技能**只负责登记账号**,不在此处判断「有没有登录网站」。真正打开网页时,由对应业务(例如大模型网页版)在同窗口里提示你完成登录。
## 它能帮你做什么
- **添加账号** —— 选一个平台(如豆包、搜狐号),在对话里说出 **11 位大陆手机号**即可。
- **查看列表** —— 按平台或全部列出已登记的账号。
- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录。
- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)。
## 你可以这样对它说
- 「帮我添加一个豆包账号,手机号 138xxxx。」
- 「列出我所有的搜狐号账号。」
- 「删除头条号里手机尾号 3701 的那条。」
- 「我想看看 3 号账号在浏览器里打开是什么页面。」
## 你需要提供什么
| 场景 | 你需要给的 |
|---|---|
| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) |
| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 |
| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
## 失败了怎么办
- **提示手机号格式不对**:需 **1 开头、第二位 39、共 11 位数字**
- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。
- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。
## 它不做什么
- ❌ 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责)
- ❌ 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息)
- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责)
## 相关技能
- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。
- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。
---
**作者**:深圳匠厂科技有限公司 · **版本**:1.0.55

View File

@@ -36,20 +36,13 @@
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` | | `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 | | `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 |
## 登录 / 浏览器子进程login ## 浏览器open
`service/browser_service.py` 与子进程 `service/login_child_runner.py` `open``service/browser_service.py` 与子进程 `service/open_child_runner.py` 启动持久化上下文,仅用于人工查看;**不做**登录态 DOM 检测。
| 变量 | 作用 | 依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright` 包**(匠厂共享 `python-runtime` 已包含)。运行时使用 **Playwright `channel=chrome|msedge`** 调系统浏览器,**不要**执行 `playwright install chromium`(避免下载自带 Chromium
|------|------|
| `JIANGCHANG_LOGIN_TIMEOUT_SECONDS` | 登录检测最长秒数,默认 `300`,不少于 `60` |
| `JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS` | DOM 轮询间隔,默认 `1.5`,限制在 `0.5..10` |
| `JIANGCHANG_LOGIN_DOM_GRACE_SECONDS` | 打开 URL 后等待再检测,默认 `4`,减轻跳转误判 |
依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright`** 且已执行 `playwright install chromium`(错误信息见 CLI 输出)。
## 常见问题(简要) ## 常见问题(简要)
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核 - **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。 - **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看统一日志文件。

28
references/SCHEMA.md Normal file
View File

@@ -0,0 +1,28 @@
# 数据存储(account-manager)
## 数据库路径
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
`JIANGCHANG_DATA_ROOT``JIANGCHANG_USER_ID` 由宿主注入,与兄弟技能保持一致。路径解析细节见 [RUNTIME.md](RUNTIME.md)。
## `accounts` 表(核心字段)
- `id` —— 自增主键,CLI 与跨技能常称「账号 id」
- `name` —— 展示用名称(如「豆包1号」)
- `platform` —— 平台英文键(小写,如 `doubao``sohu`),见 [PLATFORMS.md](PLATFORMS.md)
- `phone` —— 归一化后的 11 位数字串
- `profile_dir` —— 持久化浏览器用户数据目录绝对路径,供 Playwright 等复用
- `url` —— 该平台默认入口 URL
- `extra_json` —— 可选扩展(JSON 文本),`get` 成功时可能并入返回对象
- `created_at` / `updated_at` —— Unix 秒;`pick-web` 等按更新时间排序选候选
**说明**:表中**不**存「是否已登录」字段;业务侧在真实浏览器会话中自行处理登录态。
## Profile 文件布局
浏览器配置目录通常位于技能数据目录下的 `profiles/` 分层路径,由实现生成。删除账号时实现会尽量同时删除对应目录,见 [CLI.md](CLI.md) `delete` 说明。
## 与 JSON 输出的对应关系
`get` / `list-json` / `pick-web` 单行 JSON 的键名与上表及 `extra_json` 展开规则见 [INTEGRATION.md](INTEGRATION.md) 与仓库 `assets/schemas/`

View File

@@ -11,7 +11,7 @@ from service.account_service import (
cmd_list_json, cmd_list_json,
cmd_pick_web, cmd_pick_web,
) )
from service.browser_service import cmd_login, cmd_open, cmd_wait_login from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging 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
@@ -27,8 +27,6 @@ def _cli_print_full_usage() -> None:
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 open <id>") print(" python main.py open <id>")
print(" python main.py login <id>")
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 <平台> <手机号>")
@@ -126,7 +124,7 @@ def main() -> None:
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("说明:按 updated_at 选一条账号;是否已登录由 wait-login / 页面检测决定") 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":
@@ -134,19 +132,6 @@ def main() -> None:
_cli_fail_need_account_id("open") _cli_fail_need_account_id("open")
sys.exit(1) sys.exit(1)
cmd_open(sys.argv[2]) cmd_open(sys.argv[2])
elif cmd == "wait-login":
av = [a for a in sys.argv[2:] if a]
if len(av) < 1:
print("ERROR:CLI_WAIT_LOGIN_MISSING_ID")
print("用法python main.py wait-login <账号 id>")
print("说明:始终以本次 DOM 检测为准;供 llm-manager / 搜狐发布等编排。")
sys.exit(1)
sys.exit(cmd_wait_login(av[0]))
elif cmd == "login":
if len(sys.argv) < 3:
_cli_fail_need_account_id("login")
sys.exit(1)
cmd_login(sys.argv[2])
elif cmd == "delete": elif cmd == "delete":
if len(sys.argv) < 4: if len(sys.argv) < 4:
_cli_fail_delete() _cli_fail_delete()

View File

@@ -77,18 +77,6 @@ def get_account_by_id(account_id):
conn.close() conn.close()
def touch_account_updated_at(account_id) -> None:
"""登录 DOM 检测成功等场景下刷新 updated_at供 pick-web 等按最近活跃选号。"""
now = _now_unix()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("UPDATE accounts SET updated_at = ? WHERE id = ?", (now, account_id))
conn.commit()
finally:
conn.close()
def fetch_list_rows(platform_key: str, limit: int): def fetch_list_rows(platform_key: str, limit: int):
"""platform_key 'all' or platform slug; returns list of row tuples (full row).""" """platform_key 'all' or platform slug; returns list of row tuples (full row)."""
conn = get_conn() conn = get_conn()

View File

@@ -1,258 +1,137 @@
""" """
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。 各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
宿主在 skills.entries 与 Gateway 中注入:
- PATH 前缀:{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/(Scripts|bin)
- VIRTUAL_ENV、JIANGCHANG_PYTHON_EXE
Playwright 使用本机 Chrome/Edgelaunch 时 channel=chrome|msedge不依赖宿主下载的 Chromium 包。
技能勿在仓库内维护独立 .venv依赖以 jiangchang-platform-kit/python-runtime 锁文件为准。
""" """
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",
"toutiao-publisher",
"logistics-tracker",
"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:
""" """
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能 编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
则用之(开发仓与安装目录均满足「技能根之上一级 = 并列根」), 避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 与当前检出不一致时错调兄弟进程。
推断失败时再读上述环境变量,最后回落 get_skills_root()。 推断失败时再读上述环境变量,最后回落 get_skills_root()。
""" """
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) return os.path.normpath(inferred)
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)
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()

View File

@@ -138,7 +138,7 @@ def cmd_list_json(platform_input: str, limit: int = 200) -> None:
def cmd_pick_web(platform_input: str): def cmd_pick_web(platform_input: str):
""" """
供 llm-manager 等:取该平台用于网页自动化的账号候选。 供 llm-manager 等:取该平台用于网页自动化的账号候选。
按 updated_at、created_at 倒序选一条,与库内「登录态」无关;实际是否已登录由 wait-login / 页面 DOM 判定 按 updated_at、created_at 倒序选一条;是否已登录由调用方(如网页自动化)在同一会话内处理
成功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)
@@ -213,7 +213,6 @@ def cmd_add(platform_input: str, phone: str):
print( print(
f"✅ 已保存账号ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}" f"✅ 已保存账号ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}"
) )
print(f" 登录请执行python main.py login {new_id}")
log.info( log.info(
"add_success id=%s platform=%s profile_dir=%s", "add_success id=%s platform=%s profile_dir=%s",
new_id, new_id,

View File

@@ -1,23 +1,16 @@
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。""" """Chromium/Edge 检测与 Playwright仅打开浏览器(不写库、不做登录态 DOM 检测)"""
import json import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from urllib.parse import urlparse
from db.accounts_repo import get_account_by_id, touch_account_updated_at from db.accounts_repo import get_account_by_id
from util.logging_config import ( from util.logging_config import (
get_skill_logger, get_skill_logger,
get_skill_log_file_path,
subprocess_env_with_trace, subprocess_env_with_trace,
) )
from util.platforms import ( from util.platforms import PLATFORM_URLS
PLATFORMS,
PLATFORM_URLS,
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
)
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import # 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__)) _SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -69,389 +62,13 @@ def resolve_chromium_channel():
def _print_browser_install_hint(): def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装后再登录") print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/") print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge") print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def _login_timeout_seconds():
try:
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
except ValueError:
return 300
def _login_poll_interval_seconds():
"""轮询间隔,默认 1.5s。"""
try:
v = float((os.getenv("JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS") or "1.5").strip())
return max(0.5, min(v, 10.0))
except ValueError:
return 1.5
def _login_dom_grace_seconds() -> float:
"""goto 后等待再开始 DOM 判定,减轻跳转中途误判。默认 4sJIANGCHANG_LOGIN_DOM_GRACE_SECONDS。"""
try:
v = float((os.getenv("JIANGCHANG_LOGIN_DOM_GRACE_SECONDS") or "4").strip())
return max(0.0, min(v, 60.0))
except ValueError:
return 4.0
def _wait_login_timeout_seconds() -> int:
"""供 wait-login / llm 编排:默认 180 秒JIANGCHANG_WAIT_LOGIN_TIMEOUT_SECONDS。"""
try:
return max(30, min(600, int((os.getenv("JIANGCHANG_WAIT_LOGIN_TIMEOUT_SECONDS") or "180").strip())))
except ValueError:
return 180
def _login_out_bundle_for(platform_key: str) -> dict:
"""传给子进程anchor + 未登录可见的选择器列表。"""
spec = PLATFORMS.get(platform_key, {}) if platform_key else {}
anchor = (urlparse(PLATFORM_URLS.get(platform_key, "") or "").netloc or "").lower()
spec_lo_dom = [
str(s).strip()
for s in (spec.get("login_logged_out_selectors") or [])
if s is not None and str(s).strip()
]
if bool(spec.get("login_skip_generic_logged_out_dom")):
logged_out_dom_selectors = list(spec_lo_dom)
else:
logged_out_dom_selectors = list(_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS) + spec_lo_dom
return {
"logged_out_dom_selectors": logged_out_dom_selectors,
"anchor_host": anchor,
}
def _run_login_browser_session(
target: dict,
timeout_sec: int,
*,
verbose_ui: bool = True,
) -> tuple[bool, str]:
"""
启动 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()
if not channel:
log.warning("login_aborted no_chromium_channel")
if verbose_ui:
_print_browser_install_hint()
return False, "no_channel"
try:
import playwright # noqa: F401
except ImportError:
log.error("login_aborted playwright_missing")
if verbose_ui:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return False, "playwright_missing"
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("login_aborted profile_dir_empty account_id=%s", account_id)
if verbose_ui:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return False, "profile_missing"
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
)
platform = (target.get("platform") or "").strip().lower()
poll_sec = _login_poll_interval_seconds()
dom_grace_sec = _login_dom_grace_seconds()
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
if verbose_ui:
print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"访问地址:{url}")
print(
"请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;"
"成功后自动关闭窗口并写入状态(无需手动关浏览器)。"
)
print(
f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。"
)
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
log_file = get_skill_log_file_path()
log.info(
"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",
account_id,
platform,
channel,
timeout_sec,
poll_sec,
dom_grace_sec,
profile_dir,
url,
log_file,
)
cfg = {
"channel": channel,
"profile_dir": profile_dir,
"url": url,
"timeout_sec": timeout_sec,
"poll_interval": poll_sec,
"dom_grace_sec": dom_grace_sec,
"login_detect_bundle": _login_out_bundle_for(platform),
"log_file": log_file,
}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as rf:
result_path = rf.name
cfg["result_path"] = result_path
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
proc_rc = None
child_hard_timeout = False
child_stderr = ""
child_stdout = ""
try:
r = subprocess.run(
[sys.executable, "-m", "service.login_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
timeout=timeout_sec + 180,
env=subprocess_env_with_trace(),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
proc_rc = r.returncode
child_stderr = (r.stderr or "").strip()
child_stdout = (r.stdout or "").strip()
if child_stderr:
log.warning(
"login_child_stderr account_id=%s len=%s tail=%s",
account_id,
len(child_stderr),
child_stderr[-6000:],
)
if child_stdout and proc_rc != 0:
log.warning(
"login_child_stdout account_id=%s len=%s tail=%s",
account_id,
len(child_stdout),
child_stdout[-2000:],
)
if proc_rc != 0:
if verbose_ui:
print("⚠️ 浏览器子进程非零退出,以结果 JSON 为准(若已写入)")
log.info(
"login_subprocess_nonzero_return code=%s (exit code 不单独作为成败依据,见 result JSON)",
proc_rc,
)
except subprocess.TimeoutExpired as ex:
child_hard_timeout = True
if verbose_ui:
print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理")
log.warning("login_subprocess_hard_timeout err=%s", ex)
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
interactive_ok = False
end_reason = "timeout" if child_hard_timeout else "subprocess_error"
child_detail = ""
try:
with open(result_path, encoding="utf-8") as rf:
data = json.load(rf)
interactive_ok = bool(data.get("interactive_ok"))
er = str(data.get("end_reason") or "").strip()
if er:
end_reason = er
elif interactive_ok:
end_reason = "login_ok"
elif not child_hard_timeout:
end_reason = "timeout"
child_detail = str(data.get("detail") or "").strip()
if child_detail:
log.warning("login_child_result_detail account_id=%s detail=%s", account_id, child_detail[:8000])
except Exception as read_ex:
log.error(
"login_child_result_read_failed account_id=%s err=%s proc_rc=%s stderr_tail=%s",
account_id,
read_ex,
proc_rc,
(child_stderr or "")[-3000:],
)
if child_hard_timeout:
end_reason = "timeout"
interactive_ok = False
elif proc_rc is not None and proc_rc != 0:
end_reason = "child_crash"
else:
end_reason = "subprocess_error"
try:
os.unlink(result_path)
except OSError:
pass
time.sleep(0.5)
ok = interactive_ok
log.info(
"login_finished account_id=%s interactive_ok=%s end_reason=%s subprocess_rc=%s marking_db=%s",
account_id,
interactive_ok,
end_reason,
proc_rc,
ok,
)
if ok:
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:
log.warning(
"login_not_detected account_id=%s platform=%s see_log=%s",
target.get("id"),
(target.get("platform") or "").strip().lower(),
log_file,
)
print("⚠️ 未检测到有效登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
print(f" 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
def cmd_wait_login(account_id) -> int:
"""
跨技能编排:始终以本次 Playwright DOM 检测为准;限时内打开浏览器并等待用户完成登录。
返回 0本次检测判定已登录1失败超时 / 用户关浏览器 / 其它)。
stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。
"""
log = get_skill_logger()
log.info("wait_login_command account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("wait_login_aborted account_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return 1
if not resolve_chromium_channel():
_print_browser_install_hint()
return 1
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return 1
if not (target.get("profile_dir") or "").strip():
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return 1
sec = _wait_login_timeout_seconds()
log.info(
"wait_login_orchestration account_id=%s name=%r timeout_sec=%s",
target.get("id"),
target.get("name"),
sec,
)
print("INFO:WAIT_LOGIN_BEGIN", flush=True)
print(
f"账号管理account-manager正在为本账号执行登录检测将打开浏览器请在 {sec} 秒内在窗口中完成登录。",
flush=True,
)
print(
"说明:调用方/编排技能只需等待本进程结束;打开浏览器与 DOM 检测均由账号管理负责。",
flush=True,
)
print(
"· 超时未登录成功 → 本进程报错退出,请中止后续业务。",
flush=True,
)
print(
"· 提前关闭浏览器 → 视为放弃登录,本进程立即报错退出。",
flush=True,
)
ok, end_reason = _run_login_browser_session(target, sec, verbose_ui=False)
if ok:
print("OK:WAIT_LOGIN")
return 0
if end_reason == "user_closed":
print("ERROR:WAIT_LOGIN_ABORTED 已关闭浏览器,未完成登录。")
return 1
if end_reason == "timeout":
print("ERROR:WAIT_LOGIN_TIMEOUT 限时内未完成登录。")
return 1
if end_reason == "playwright_missing":
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return 1
if end_reason == "profile_missing":
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return 1
if end_reason == "no_channel":
_print_browser_install_hint()
return 1
if end_reason == "launch_failed":
print(
"ERROR:WAIT_LOGIN_LAUNCH_FAILED 无法启动浏览器或创建用户数据上下文,请确认本机 Chrome/Edge 可用、profile 目录未被占用,详见统一日志。"
)
return 1
if end_reason == "page_error":
print(
"ERROR:WAIT_LOGIN_PAGE_ERROR 打开登录页或轮询过程异常,请查看上方 stderr 与统一日志。"
)
return 1
if end_reason == "config_error":
print("ERROR:WAIT_LOGIN_CONFIG_ERROR 登录子进程配置异常。")
return 1
if end_reason == "child_crash":
print("ERROR:WAIT_LOGIN_CHILD_CRASH 登录检测子进程异常退出且未写入有效结果。")
return 1
if end_reason == "profile_dir_unusable":
print(
"ERROR:WAIT_LOGIN_PROFILE_DIR 用户数据目录不可用或无法访问(权限/路径等),详见统一日志中的 login_child_result_detail / stderr。"
)
return 1
if end_reason == "logging_import_failed":
print(
"ERROR:WAIT_LOGIN_INTERNAL 登录子进程无法加载日志模块,请检查 account-manager 安装是否完整。"
)
return 1
if end_reason == "child_fatal":
print(
"ERROR:WAIT_LOGIN_CHILD_FATAL 登录子进程异常,详见统一日志 login_child_result_detail 或 login_child_stderr。"
)
return 1
print(f"ERROR:WAIT_LOGIN_FAILED end_reason={end_reason}")
return 1
def cmd_open(account_id): def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库""" """打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录"""
get_skill_logger().info("open account_id=%r", account_id) get_skill_logger().info("open 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:
@@ -467,7 +84,10 @@ def cmd_open(account_id):
try: try:
import playwright # noqa: F401 import playwright # noqa: F401
except ImportError: except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium") print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 playwright install chromium"
)
return return
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
@@ -481,8 +101,7 @@ def cmd_open(account_id):
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
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>")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(

View File

@@ -1,378 +0,0 @@
"""Playwright 登录检测子进程:由 browser_service 以独立解释器启动argv[1] 为 JSON 配置路径。
注意:不得在模块顶层 import playwright / jiangchang_skill_core —— 若导入失败,进程会在 main() 之前以退出码 1 退出,
且写不了 result_path父进程只能得到 subprocess_error。所有重依赖均在 _run_login_child 内延迟加载。
"""
from __future__ import annotations
import json
import os
import sys
import time
import traceback
from urllib.parse import urlparse
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
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):
try:
href = p.evaluate("() => location.href")
if href and str(href).strip():
return str(href).strip()
except Exception:
pass
try:
return (p.url or "").strip()
except Exception:
return ""
def host_matches_anchor(netloc, anchor):
h = (netloc or "").lower().strip()
a = (anchor or "").lower().strip()
if not a:
return True
if not h:
return False
return h == a or h.endswith("." + a) or a.endswith("." + h)
def loc_first_visible(pg, selectors):
for sel in selectors:
s = str(sel).strip()
if not s:
continue
try:
loc = pg.locator(s).first
if loc.is_visible(timeout=500):
return True, s
except Exception:
pass
return False, None
def list_pages_on_anchor(ctx, main_page, bundle):
anchor = (bundle.get("anchor_host") or "").strip().lower()
seen_ids = set()
pages = []
for pg in list(ctx.pages or []):
try:
i = id(pg)
if i not in seen_ids:
seen_ids.add(i)
pages.append(pg)
except Exception:
pages.append(pg)
if main_page is not None:
try:
i = id(main_page)
if i not in seen_ids:
seen_ids.add(i)
pages.append(main_page)
except Exception:
pages.append(main_page)
out = []
for pg in pages:
try:
href = page_location_href(pg)
except Exception:
continue
if not href or str(href).lower().startswith("about:"):
continue
try:
host = urlparse(href).netloc.lower()
except Exception:
continue
if anchor and not host_matches_anchor(host, anchor):
continue
out.append(pg)
return out
def evaluate_logged_out_dom(ctx, main_page, bundle):
selectors = [
str(x).strip()
for x in (bundle.get("logged_out_dom_selectors") or [])
if x is not None and str(x).strip()
]
if not selectors:
return True, "no_logged_out_dom_selectors_configured"
for pg in list_pages_on_anchor(ctx, main_page, bundle):
try:
href = page_location_href(pg)
except Exception:
href = ""
hit, which = loc_first_visible(pg, selectors)
if hit:
return True, "logged_out_dom=%r url=%r" % (which, href)
return False, ""
def _run_login_child(c: dict) -> None:
"""单次登录检测;结果一律写入 c['result_path'](由 finally 保证)。"""
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 {}
poll = float(c.get("poll_interval", 1.5))
try:
dom_grace = float(c.get("dom_grace_sec", 4.0))
except (TypeError, ValueError):
dom_grace = 4.0
if dom_grace < 0:
dom_grace = 0.0
t0 = time.time()
deadline = t0 + float(c["timeout_sec"])
with sync_playwright() as p:
try:
ctx = p.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=False,
channel=c["channel"],
no_viewport=True,
args=["--start-maximized"],
)
except Exception as e:
if _is_context_closed_error(e):
end_reason = "user_closed"
else:
end_reason = "launch_failed"
detail_msg = f"{type(e).__name__}: {e}"
if lg is not None:
lg.exception("launch_persistent_context failed: %s", e)
print(detail_msg, file=sys.stderr)
return
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
if dom_grace > 0:
time.sleep(dom_grace)
if lg is not None:
lg.info(
"goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s",
len(ctx.pages or []),
poll,
dom_grace,
)
while time.time() < deadline:
iter_start = time.time()
try:
still_logged_out, dom_detail = evaluate_logged_out_dom(ctx, page, bundle)
ok = not still_logged_out
last_eval_detail = dom_detail
if lg is not None:
parts = []
for tab in list(ctx.pages or []):
try:
href = page_location_href(tab)
pu = (tab.url or "").strip()
parts.append("href=%r playwright_url=%r" % (href, pu))
except Exception:
parts.append("(tab_read_error)")
lg.debug(
"poll elapsed=%.1fs left=%.1fs tabs=%s logged_out=%s ok=%s detail=%s | %s",
iter_start - t0,
deadline - iter_start,
len(ctx.pages or []),
still_logged_out,
ok,
dom_detail,
" ; ".join(parts) if parts else "no_tabs",
)
if ok:
interactive_ok = True
end_reason = "login_ok"
if lg is not None:
lg.info("login_detected_ok %s", dom_detail)
break
except Exception as ex:
if _is_context_closed_error(ex):
end_reason = "user_closed"
if lg is not None:
lg.warning("login_poll_browser_closed err=%s", ex)
break
if lg is not None:
lg.warning("poll_exception err=%s", ex, exc_info=True)
spent = time.time() - iter_start
time.sleep(max(0.0, poll - spent))
if not interactive_ok and end_reason != "user_closed":
if lg is not None:
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
rem = max(0.0, deadline - time.time())
if rem > 0:
try:
ctx.wait_for_event("close", timeout=rem * 1000)
except Exception:
pass
elif interactive_ok:
if lg is not None:
lg.info("login_dom_ok_preparing_to_release_profile_dir")
print(
"INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。",
flush=True,
)
time.sleep(2.0)
except Exception as e:
interactive_ok = False
if _is_context_closed_error(e):
end_reason = "user_closed"
if lg is not None:
lg.warning("login_runner_browser_closed err=%s", e)
else:
end_reason = "page_error"
detail_msg = traceback.format_exc()
if lg is not None:
lg.exception("login_runner_page_err %s", e)
print(detail_msg, file=sys.stderr)
finally:
try:
ctx.close()
except Exception:
pass
ctx = None
except Exception as e:
interactive_ok = False
end_reason = "child_fatal"
detail_msg = traceback.format_exc()
if lg is not None:
lg.exception("login_child_unhandled %s", e)
print(detail_msg, file=sys.stderr)
finally:
if ctx is not None:
try:
ctx.close()
except Exception:
pass
_write_result_file(result_path, interactive_ok, end_reason, detail_msg)
def _entrypoint() -> None:
"""读取配置 → 运行检测;任意异常都写入 result JSON进程以 0 退出(由父进程读 JSON 判定成败)。"""
result_path = ""
if len(sys.argv) < 2:
print("login_child: missing cfg path argv", file=sys.stderr)
return
cfg_path = sys.argv[1]
try:
with open(cfg_path, encoding="utf-8") as f:
c = json.load(f)
except Exception:
tb = traceback.format_exc()
print(tb, file=sys.stderr)
return
result_path = (c.get("result_path") or "").strip()
try:
_run_login_child(c)
except Exception:
tb = traceback.format_exc()
print(tb, file=sys.stderr)
_write_result_file(result_path, False, "child_fatal", tb)
if __name__ == "__main__":
_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

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