Compare commits

...

9 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
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
22 changed files with 381 additions and 1002 deletions

View File

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

@@ -1,6 +1,7 @@
# assets
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/``get` / `list-json` / `pick-*` 单条对象的轻量 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

@@ -5,8 +5,6 @@
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"login_status": 1,
"last_login_at": "2026-04-01T12:00:00",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
}

View File

@@ -6,8 +6,6 @@
"phone": "13900001111",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
"url": "https://www.zhihu.com",
"login_status": 0,
"last_login_at": null,
"created_at": "2026-04-02T09:00:00",
"updated_at": "2026-04-02T09:00:00"
},
@@ -18,8 +16,6 @@
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"login_status": 1,
"last_login_at": "2026-04-01T12:00:00",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
}

View File

@@ -2,9 +2,9 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
"title": "AccountRecord",
"description": "account-manager get / list-json / pick-* 返回的单条账号对象核心字段extra_json 合并键不枚举)",
"description": "account-manager get / list-json / pick-web 返回的单条账号对象核心字段extra_json 合并键不枚举)",
"type": "object",
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "login_status", "created_at", "updated_at"],
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
@@ -12,11 +12,6 @@
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
"url": { "type": "string", "format": "uri" },
"login_status": { "type": "integer", "enum": [0, 1] },
"last_login_at": {
"type": ["string", "null"],
"description": "本地时区 ISO8601 字符串;未登录过为 null"
},
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},

View File

@@ -1,12 +1,26 @@
# 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` → 上表。
## 通用说明
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu``搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读子命令**`get``list-json``pick-logged-in``pick-web``set-login-status` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
- **机器可读子命令**`get``list-json``pick-web` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
@@ -56,48 +70,23 @@ python main.py list-json <平台|all|全部> [--limit N]
- 默认 `limit=200`,实际 clamp 为 `1..500`
- 排序:按 `updated_at` 倒序。
### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON
```bash
python main.py pick-logged-in <平台>
```
- 筛选:`login_status = 1`,按 `last_login_at` 优先。
- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。
### `pick-web` — 网页自动化候选账号(单行 JSON
```bash
python main.py pick-web <平台>
```
- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条
- 按该平台 `updated_at``created_at` 倒序选一条;**不**表示当前浏览器已登录
- 失败:首行 `ERROR:`
### `set-login-status` — 回写登录态(跨技能
```bash
python main.py set-login-status <id> <0|1>
```
- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS``OK:CLEARED_LOGIN_STATUS`,退出码 `0`
- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`
### `open` — 仅打开浏览器(**不写库**
### `open` — 仅打开浏览器(**不写库、不检测登录**
```bash
python main.py open <id>
```
- 需本机 Chrome/Edge + Playwright详见运行环境说明。
### `login` — 打开浏览器并**自动检测登录**,写回 `login_status`
```bash
python main.py login <id>
```
- 依赖 Playwright超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。
### `delete` — 删除库记录并尽量删除 profile 目录

View File

@@ -1,13 +1,6 @@
# 终端前缀约定account-manager
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:``OK:` 开头;多行说明可忽略或仅作展示。
## `OK:`(成功标记)
| 首行 | 场景 |
|------|------|
| `OK:SET_LOGIN_STATUS` | `set-login-status <id> 1` 成功 |
| `OK:CLEARED_LOGIN_STATUS` | `set-login-status <id> 0` 成功 |
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
## `ERROR:` — CLI 参数 / 路由
@@ -16,12 +9,9 @@
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS` | `set-login-status` 参数不足 |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS` | `pick-logged-in` 缺少平台 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
@@ -38,19 +28,16 @@
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in``login_status=1` 的账号 |
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 |
| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 |
## `ERROR:` — 浏览器 / 环境
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwrightpip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
| `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edgechannel 模式,**不**需 `playwright install chromium` |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
## 非前缀输出
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
- **`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 | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 |
| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 |
| 回写登录态 | `set-login-status <id> <0或1>` | 首行 `OK:...` |
| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录) |
## 解析协议(强建议)
1. 读取子进程 **stdout** 全文或首行。
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`
4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容
## JSON 对象字段(与 `get` 一致)
典型字段见仓库 `assets/examples/get-response.json``assets/schemas/account-record.schema.json`
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `login_status`, `last_login_at`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
**库中不再持久化「是否已登录」**;编排侧在网页自动化中自行处理登录(与 `pick-web` / `get` 返回的 `profile_dir` 同一浏览器会话)。
## Publisher / 自动化流水线示例
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url`
2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login <id>` 或先 `open <id>` 人工登录。
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
2. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录。
## 退出码
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀****JSON 可解析性** 为准,不要仅依赖退出码。
请以 **stdout 首行前缀****JSON 可解析性** 为准;不要仅依赖退出码。

View File

@@ -16,6 +16,7 @@
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.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:` / `OK:` 前缀一览 |
| [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_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 |
## 登录 / 浏览器子进程login
## 浏览器open
`service/browser_service.py` 与子进程 `service/login_child_runner.py`
`open``service/browser_service.py` 与子进程 `service/open_child_runner.py` 启动持久化上下文,仅用于人工查看;**不做**登录态 DOM 检测。
| 变量 | 作用 |
|------|------|
| `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 输出)。
依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright` 包**(匠厂共享 `python-runtime` 已包含)。运行时使用 **Playwright `channel=chrome|msedge`** 调系统浏览器,**不要**执行 `playwright install chromium`(避免下载自带 Chromium
## 常见问题(简要)
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核
- **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge
- **`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

@@ -9,11 +9,9 @@ from service.account_service import (
cmd_get,
cmd_list,
cmd_list_json,
cmd_pick_logged_in,
cmd_pick_web,
cmd_set_login_status,
)
from service.browser_service import cmd_login, cmd_open, cmd_wait_login
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
@@ -25,14 +23,10 @@ def _cli_print_full_usage() -> None:
print("用法概览(将 main.py 换为你的路径):")
print(" python main.py add <平台中文名> <手机号>")
print(" python main.py list [平台|all|全部]")
print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR")
print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号")
print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON")
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
print(" python main.py get <id>")
print(" python main.py set-login-status <id> <0|1> # 跨技能回写登录态")
print(" python main.py open <id>")
print(" python main.py login <id>")
print(" python main.py wait-login <id> [--force] # 跨技能:限时内等待网页登录(默认 180s--force 忽略库内已登录标记")
print(" python main.py delete id <id>")
print(" python main.py delete platform <平台>")
print(" python main.py delete platform <平台> <手机号>")
@@ -109,13 +103,6 @@ def main() -> None:
_cli_fail_need_account_id("get")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "set-login-status":
if len(sys.argv) < 4:
print("ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS")
print("用法python main.py set-login-status <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":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
@@ -133,18 +120,11 @@ def main() -> None:
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-logged-in":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS")
print("用法python main.py pick-logged-in <平台中文名或英文键>")
print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。")
sys.exit(1)
cmd_pick_logged_in(sys.argv[2])
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
print("用法python main.py pick-web <平台中文名或英文键>")
print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)")
print("说明:按 updated_at 选一条账号;是否已登录由调用方网页会话自行处理")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
@@ -152,22 +132,6 @@ def main() -> None:
_cli_fail_need_account_id("open")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "wait-login":
av = [a for a in sys.argv[2:] if a]
force = "--force" in av or "-f" in av
av = [a for a in av if a not in ("--force", "-f")]
if len(av) < 1:
print("ERROR:CLI_WAIT_LOGIN_MISSING_ID")
print("用法python main.py wait-login <账号 id> [--force]")
print("说明:供 llm-manager / 搜狐发布等编排;未登录时打开浏览器,限时内等待登录或用户关窗即结束。")
print("--force即使库内已标为登录也重新打开浏览器检测Cookie 失效时用)。")
sys.exit(1)
sys.exit(cmd_wait_login(av[0], force=force))
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":
if len(sys.argv) < 4:
_cli_fail_delete()

View File

@@ -46,7 +46,7 @@ def get_account_by_id(account_id):
cur = conn.cursor()
cur.execute(
"""
SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json,
SELECT id, name, platform, phone, profile_dir, url, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
@@ -62,14 +62,12 @@ def get_account_by_id(account_id):
"phone": row[3] or "",
"profile_dir": (row[4] or "").strip(),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"login_status": int(row[6] or 0),
"last_login_at": _unix_to_iso_local(row[7]),
"created_at": _unix_to_iso_local(row[9]),
"updated_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[7]),
"updated_at": _unix_to_iso_local(row[8]),
}
if row[8]:
if row[6]:
try:
extra = json.loads(row[8])
extra = json.loads(row[6])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
@@ -79,34 +77,14 @@ def get_account_by_id(account_id):
conn.close()
def mark_login_status(account_id, success: bool):
now = _now_unix()
conn = get_conn()
try:
cur = conn.cursor()
if success:
cur.execute(
"UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?",
(now, now, account_id),
)
else:
cur.execute(
"UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?",
(now, account_id),
)
conn.commit()
finally:
conn.close()
def fetch_list_rows(platform_key: str, limit: int):
"""platform_key 'all' or platform slug; returns list of row tuples (full row)."""
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, name, platform, phone, profile_dir, url, login_status, "
"last_login_at, extra_json, created_at, updated_at FROM accounts "
"SELECT id, name, platform, phone, profile_dir, url, "
"extra_json, created_at, updated_at FROM accounts "
)
if platform_key == "all":
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
@@ -140,50 +118,21 @@ def fetch_ids_for_list_json(platform_key: str, lim: int):
conn.close()
def fetch_pick_logged_in_id(platform_key: str) -> Optional[int]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ? AND login_status = 1
ORDER BY (last_login_at IS NULL), last_login_at DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
return int(row[0]) if row else None
finally:
conn.close()
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
"""该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ? AND login_status = 1
ORDER BY (last_login_at IS NULL), last_login_at DESC
WHERE platform = ?
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
if not row:
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
return int(row[0]) if row else None
finally:
conn.close()
@@ -216,8 +165,8 @@ def insert_account_row(
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)
INSERT INTO accounts (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
""",
(name, platform_key, phone_store, profile_dir, url, now, now),
)

View File

@@ -1,7 +1,7 @@
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
ACCOUNTS_TABLE_SQL = """
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定, 供发布类技能读取登录态 */
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
@@ -9,8 +9,6 @@ CREATE TABLE accounts (
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
url TEXT, -- 平台入口或登录页 URL
login_status INTEGER NOT NULL DEFAULT 0, -- 展示/排序用尽力同步;编排上请以 Playwright DOM 检测为准,勿单独依赖本字段
last_login_at INTEGER, -- 最近一次登录成功时间, Unix 秒 UTC; 未登录过为 NULL
extra_json TEXT, -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC

View File

@@ -1,127 +1,137 @@
"""
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 即根
"""
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
各技能 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
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",
"toutiao-publisher",
"logistics-tracker",
"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 目录推断并列根;若目录下能识别出兄弟技能
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
避免全局 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

@@ -1,4 +1,4 @@
"""账号用例list / get / add / delete / pick / set-login-status(含终端输出,供 CLI 调用)。"""
"""账号用例list / get / add / delete / pick-web(含终端输出,供 CLI 调用)。"""
import json
import os
import shutil
@@ -15,13 +15,11 @@ from db.accounts_repo import (
fetch_ids_for_list_json,
fetch_ids_profile_for_platform_conn,
fetch_list_rows,
fetch_pick_logged_in_id,
fetch_pick_web_candidate_id,
fetch_row_platform_phone_conn,
get_account_by_id,
has_duplicate_phone_conn,
insert_account_row,
mark_login_status,
normalize_account_id,
)
from db.connection import get_conn, init_db
@@ -78,8 +76,6 @@ def cmd_list(platform="all", limit: int = 10):
phone,
profile_dir,
url,
login_status,
last_login_at,
extra_json,
created_at,
updated_at,
@@ -90,8 +86,6 @@ def cmd_list(platform="all", limit: int = 10):
print(f"phone{phone or ''}")
print(f"profile_dir{profile_dir or ''}")
print(f"url{url or ''}")
print(f"login_status{int(login_status) if login_status is not None else ''}")
print(f"last_login_at{int(last_login_at) if last_login_at is not None else ''}")
print(f"extra_json{extra_json or ''}")
print(f"created_at{int(created_at) if created_at is not None else ''}")
print(f"updated_at{int(updated_at) if updated_at is not None else ''}")
@@ -141,40 +135,10 @@ def cmd_list_json(platform_input: str, limit: int = 200) -> None:
print(json.dumps(out, ensure_ascii=False))
def cmd_pick_logged_in(platform_input: str):
"""
机器可读跨技能接口查询指定平台下「已登录」的一条账号login_status=1按 last_login_at 优先)。
成功stdout 仅输出一行 JSON结构与 get 子命令一致。
失败stdout 首行以 ERROR: 开头(由调用方判断,勿解析为 JSON
"""
get_skill_logger().info("pick_logged_in platform_input=%r", platform_input)
key = resolve_platform_key((platform_input or "").strip())
if not key:
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
init_db()
picked = fetch_pick_logged_in_id(key)
if picked is None:
print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号login_status=1")
print("请先 list 查看账号 id再执行python main.py login <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):
"""
供 llm-manager 等:取该平台用于网页自动化的账号候选。
优先 login_status=1与 pick-logged-in 一致);若无,则取该平台 updated_at 最新的一条,
便于「已 add 未标登录」时仍打开 profile在浏览器内登录后继续任务。
按 updated_at、created_at 倒序选一条;是否已登录由调用方(如网页自动化)在同一会话内处理。
成功stdout 仅一行 JSON与 get 一致);失败:首行 ERROR:。
"""
get_skill_logger().info("pick_web platform_input=%r", platform_input)
@@ -249,7 +213,6 @@ def cmd_add(platform_input: str, phone: str):
print(
f"✅ 已保存账号ID {new_id} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_store}"
)
print(f" 登录请执行python main.py login {new_id}")
log.info(
"add_success id=%s platform=%s profile_dir=%s",
new_id,
@@ -352,29 +315,3 @@ def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
f"✅ 已删除账号ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}"
)
log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key)
def cmd_set_login_status(account_id_str: str, status_str: str) -> None:
"""
跨技能机器接口:回写 accounts.login_status。
成功stdout 首行 OK:SET_LOGIN_STATUS 或 OK:CLEARED_LOGIN_STATUS进程退出码 0。
失败stdout 首行 ERROR:...,退出码 1。
"""
aid_s = (account_id_str or "").strip()
st_s = (status_str or "").strip()
if not aid_s.isdigit():
print("ERROR:INVALID_ACCOUNT_ID")
sys.exit(1)
if st_s not in ("0", "1"):
print("ERROR:INVALID_STATUS 须为 0 或 1")
sys.exit(1)
aid = int(aid_s)
init_db()
if get_account_by_id(aid) is None:
get_skill_logger().warning("set_login_status_not_found account_id=%r", aid)
print("ERROR:ACCOUNT_NOT_FOUND")
sys.exit(1)
ok = st_s == "1"
mark_login_status(aid, ok)
get_skill_logger().info("set_login_status account_id=%s success=%s", aid, ok)
print("OK:SET_LOGIN_STATUS" if ok else "OK:CLEARED_LOGIN_STATUS")

View File

@@ -1,26 +1,22 @@
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。"""
"""Chromium/Edge 检测与 Playwright仅打开浏览器(不写库、不做登录态 DOM 检测)"""
import json
import os
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlparse
from db.accounts_repo import get_account_by_id, mark_login_status
from db.accounts_repo import get_account_by_id
from util.logging_config import (
get_skill_logger,
get_skill_log_file_path,
subprocess_env_with_trace,
)
from util.platforms import (
PLATFORMS,
PLATFORM_URLS,
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
)
from util.platforms import PLATFORM_URLS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_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):
@@ -66,296 +62,13 @@ def resolve_chromium_channel():
def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装后再登录")
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")
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 判定为准**。
成功后尽力 `mark_login_status` 供 list/排序展示,**跨技能编排请勿再依赖该字段**(可能与真实 Cookie 会话脱节)。
返回 (是否已登录成功, 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,
)
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
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
try:
r = subprocess.run(
[sys.executable, login_runner_path, cfg_path],
timeout=timeout_sec + 180,
env=subprocess_env_with_trace(),
)
proc_rc = r.returncode
if proc_rc != 0:
if verbose_ui:
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
except subprocess.TimeoutExpired as ex:
if verbose_ui:
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_timeout err=%s", ex)
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
interactive_ok = False
end_reason = "subprocess_error"
try:
with open(result_path, encoding="utf-8") as rf:
data = json.load(rf)
interactive_ok = bool(data.get("interactive_ok"))
end_reason = str(data.get("end_reason") or "").strip() or (
"login_ok" if interactive_ok else "timeout"
)
except Exception:
end_reason = "subprocess_error"
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,
)
mark_login_status(account_id, ok)
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, force: bool = False) -> int:
"""
跨技能编排:若未登录则打开浏览器并在限时内等待登录。
返回 0已登录原本已登录或本次登录成功1失败超时 / 用户关浏览器 / 其它)。
stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。
force=True即使库内 login_status=1 也仍打开浏览器并限时检测(供搜狐发布等「页面仍要求登录」场景)。
"""
log = get_skill_logger()
log.info("wait_login_command account_id=%r force=%s", account_id, force)
target = get_account_by_id(account_id)
if not target:
log.warning("wait_login_aborted account_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return 1
# 库内 login_status=1 时跳过开窗(与真实 Cookie 可能不同步时用 force=True
if int(target.get("login_status") or 0) == 1 and not force:
print("OK:WAIT_LOGIN")
return 0
if not resolve_chromium_channel():
_print_browser_install_hint()
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()
print(
f"⏳ 账号「{target.get('name', target['id'])}」尚未登录,将打开浏览器;请在 {sec} 秒内完成登录。"
"关闭浏览器将视为放弃。"
)
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
print(f"ERROR:WAIT_LOGIN_FAILED end_reason={end_reason}")
return 1
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库"""
"""打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录"""
get_skill_logger().info("open account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
@@ -371,7 +84,10 @@ def cmd_open(account_id):
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 playwright install chromium"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
@@ -385,10 +101,8 @@ def cmd_open(account_id):
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束")
print("需要自动检测并写入数据库时请执行python main.py login <id>")
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测")
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
@@ -397,7 +111,8 @@ def cmd_open(account_id):
cfg_path = jf.name
try:
subprocess.run(
[sys.executable, open_runner_path, cfg_path],
[sys.executable, "-m", "service.open_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
finally:

View File

@@ -1,265 +0,0 @@
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动argv[1] 为 JSON 配置路径。"""
import json
import os
import sys
import time
from urllib.parse import urlparse
_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from jiangchang_skill_core.unified_logging import attach_unified_file_handler
from playwright.sync_api import sync_playwright
try:
from playwright.sync_api import Error as PlaywrightError
except ImportError:
PlaywrightError = Exception # type: ignore[misc,assignment]
def _is_context_closed_error(ex: BaseException) -> bool:
s = str(ex).lower()
if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s:
return True
if isinstance(ex, PlaywrightError) and (
"closed" in s or "destroyed" in s
):
return True
return False
def page_location_href(p):
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
try:
href = p.evaluate("() => location.href")
if href and str(href).strip():
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 main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
bundle = c.get("login_detect_bundle") or {}
poll = float(c.get("poll_interval", 1.5))
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"])
interactive_ok = False
end_reason = "timeout"
result_path = c.get("result_path") or ""
log_path = (c.get("log_file") or "").strip()
lg = None
if log_path:
lg = attach_unified_file_handler(
log_path,
skill_slug="account-manager",
logger_name="openclaw.skill.account_manager.login_child",
)
last_eval_detail = ""
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=["--start-maximized"],
)
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, detail = evaluate_logged_out_dom(ctx, page, bundle)
ok = not still_logged_out
last_eval_detail = 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 iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
iter_start - t0,
deadline - iter_start,
len(ctx.pages or []),
poll,
still_logged_out,
ok,
detail,
" ; ".join(parts) if parts else "no_tabs",
)
if ok:
interactive_ok = True
end_reason = "login_ok"
if lg is not None:
lg.info("login_detected_ok %s", detail)
break
except Exception as ex:
if _is_context_closed_error(ex):
end_reason = "user_closed"
if lg is not None:
lg.warning("login_poll_browser_closed err=%s", ex)
break
if lg is not None:
lg.warning("poll_exception err=%s", ex, exc_info=True)
spent = time.time() - iter_start
time.sleep(max(0.0, poll - spent))
if not interactive_ok and end_reason != "user_closed":
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
else:
if lg is not None:
lg.info("login_dom_ok_preparing_to_release_profile_dir")
if interactive_ok:
# 给 Cookie/会话落盘一点时间;随后必须关闭上下文,否则 llm-manager 无法再对同一 profile 启动持久化浏览器
print(
"INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。",
flush=True,
)
time.sleep(2.0)
except Exception as e:
if _is_context_closed_error(e):
end_reason = "user_closed"
interactive_ok = False
if lg is not None:
lg.warning("login_runner_browser_closed err=%s", e)
else:
if lg is not None:
lg.exception("login_runner_fatal err=%s", e)
print(e, file=sys.stderr)
raise
finally:
if result_path:
try:
with open(result_path, "w", encoding="utf-8") as rf:
json.dump(
{
"interactive_ok": interactive_ok,
"end_reason": end_reason,
},
rf,
ensure_ascii=False,
)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -4,18 +4,30 @@ import sys
from playwright.sync_api import sync_playwright
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
def main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
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"],
headless=False,
channel=c["channel"],
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:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)

View File

@@ -62,6 +62,12 @@ PLATFORMS = {
"prefix": "元宝",
"aliases": ["元宝"],
},
"gemini": {
"url": "https://gemini.google.com",
"label": "Gemini",
"prefix": "Gemini",
"aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"],
},
}
# 未登录页共性主行动点含「登录」——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"]