Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82720ef2fa | |||
| 3a1ea6d97a | |||
| 75a332e7b8 | |||
| 4605d3d0e4 | |||
| beb95c1415 | |||
| e2eafb7407 | |||
| 96e5b9ea14 |
76
SKILL.md
76
SKILL.md
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 账号管理
|
name: 账号管理
|
||||||
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。
|
description: "多平台账号登记与浏览器 Profile 目录管理。当用户要添加/列出/删除各平台账号、或编排侧需要按平台取一条账号 JSON(pick-web/get)用于网页自动化时,加载本技能。本技能不持久化「是否已登录」,不在此做登录预检;登录由业务侧(如 llm-manager 网页引擎)在使用 profile 时处理。"
|
||||||
version: 1.0.5
|
version: 1.0.54
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -12,57 +12,43 @@ allowed-tools:
|
|||||||
- bash
|
- bash
|
||||||
---
|
---
|
||||||
|
|
||||||
# 账号管理
|
# 账号管理(account-manager)
|
||||||
|
|
||||||
## 使用时机
|
登记各平台账号与 **Chrome/Edge 持久化用户目录(`profile_dir`)** 的对应关系,供发布类、大模型网页类等 Skill 通过子进程 CLI 取号。**库中不保存「已登录」状态**;`pick-web` 仅按更新时间等选一条候选,**不**表示浏览器已登录。
|
||||||
|
|
||||||
当用户发送以下内容的时候触发本Skill:
|
## 何时使用本技能
|
||||||
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
|
|
||||||
- 说"列出账号"、"查看所有账号"、"有哪些账号"
|
|
||||||
- 说"添加账号"、"删除账号"、"获取账号信息"
|
|
||||||
|
|
||||||
## 执行步骤
|
用户或编排表达以下意图之一时加载:
|
||||||
|
|
||||||
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号;同平台下同手机号不可重复;ID 自增,名称如「搜狐1号」)
|
1. **添加 / 删除 / 列出账号** —— 多平台(搜狐号、头条、知乎、微信、豆包、Kimi、DeepSeek 等)的账号记录与数据目录管理。
|
||||||
```bash
|
2. **按 id 取账号 JSON** —— 跨技能需要单行 JSON(`get`)。
|
||||||
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
|
3. **按平台取网页自动化候选账号** —— 跨技能需要 `pick-web` / `list-json` 的机器可读输出。
|
||||||
python3 {baseDir}/scripts/main.py add 知乎 13800138000
|
4. **仅打开浏览器核对页面** —— `open`(不写库、不做登录 DOM 检测)。
|
||||||
```
|
|
||||||
支持的平台称呼示例:搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝(亦支持英文键如 sohu、toutiao)。
|
|
||||||
|
|
||||||
### 仅打开浏览器核对是否已登录(不写数据库)
|
## CLI 调用
|
||||||
```bash
|
|
||||||
python3 {baseDir}/scripts/main.py open <id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 登录并自动检测(成功则刷新该账号 `updated_at`,不在库中保存「是否已登录」)
|
所有子命令、参数、简写形式与行为细节见 `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 搜狐号
|
|
||||||
```
|
|
||||||
|
|
||||||
### 跨技能:单行 JSON(get / list-json / pick-web);需会话有效时用 wait-login
|
入库使用英文 `platform` 键;用户话术可用中文展示名或别名。完整表与解析规则见 `references/PLATFORMS.md`。平台无法识别时会输出 `ERROR:INVALID_PLATFORM*` 系列前缀,详见 `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`。`accounts` 表字段与 JSON 形状摘要见 `references/SCHEMA.md`。
|
||||||
- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。
|
|
||||||
|
## 跨技能集成
|
||||||
|
|
||||||
|
`get` / `list-json` / `pick-web` 的 stdout 协议、JSON 字段说明、推荐 `subprocess` 用法见 `references/INTEGRATION.md`。解析失败时首行多为 `ERROR:` —— 与 `references/ERRORS.md` 对照。
|
||||||
|
|
||||||
|
## 依赖与运行时
|
||||||
|
|
||||||
|
- **Python**:共享宿主 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`,**不要**在技能目录内创建 `.venv`。
|
||||||
|
- **浏览器**:网页相关能力依赖本机 **Chrome / Edge**(Playwright `channel` 模式,**不**下载自带 Chromium)。`open` 另需 `playwright` 包。
|
||||||
|
- **环境变量**:`JIANGCHANG_DATA_ROOT`、`JIANGCHANG_USER_ID` 与兄弟技能一致;细节见 `references/RUNTIME.md`。
|
||||||
|
|
||||||
|
## 资产与示例
|
||||||
|
|
||||||
|
示例 JSON 与 JSON Schema 见 `assets/README.md` 及 `assets/examples/`、`assets/schemas/`。
|
||||||
|
|||||||
@@ -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` 渐进引用)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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/` 下执行。
|
||||||
|
|
||||||
## 通用说明
|
## 通用说明
|
||||||
|
|
||||||
@@ -65,32 +65,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 目录
|
||||||
|
|
||||||
|
|||||||
@@ -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:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|
| `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edge(channel 模式,**不**需 `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`:等待本进程整体超过上限(登录窗口 + 子进程缓冲),已中止。
|
|
||||||
|
|
||||||
## 非前缀输出
|
## 非前缀输出
|
||||||
|
|
||||||
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
|
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
|
||||||
|
|||||||
@@ -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` 启动持久化浏览器上下文,在业务页面内完成或等待登录。
|
||||||
|
|
||||||
## 退出码
|
## 退出码
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
## 解析规则(摘要)
|
## 解析规则(摘要)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,52 @@
|
|||||||
# references(account-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 位完整手机号**,系统生成账号名并**自动创建**专属浏览器用户目录;**不需要**你自行指定 Profile 路径。
|
||||||
|
- **查看列表** —— 按平台或全部列出已登记的账号。
|
||||||
|
- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录。
|
||||||
|
- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)。
|
||||||
|
|
||||||
|
## 你可以这样对它说
|
||||||
|
|
||||||
|
- 「帮我添加一个豆包账号,手机号 138xxxx。」
|
||||||
|
- 「列出我所有的搜狐号账号。」
|
||||||
|
- 「删除头条号里手机尾号 3701 的那条。」
|
||||||
|
- 「我想看看 3 号账号在浏览器里打开是什么页面。」
|
||||||
|
|
||||||
|
## 你需要提供什么
|
||||||
|
|
||||||
|
| 场景 | 你需要给的 |
|
||||||
|
|---|---|
|
||||||
|
| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) |
|
||||||
|
| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 |
|
||||||
|
| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
|
||||||
|
|
||||||
|
## 失败了怎么办
|
||||||
|
|
||||||
|
- **提示手机号格式不对**:需 **1 开头、第二位 3~9、共 11 位数字**。
|
||||||
|
- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。
|
||||||
|
- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。
|
||||||
|
|
||||||
|
## 它不做什么
|
||||||
|
|
||||||
|
- ❌ 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责)
|
||||||
|
- ❌ 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息)
|
||||||
|
- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责)
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。
|
||||||
|
- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**作者**:深圳匠厂科技有限公司 · **版本**:1.0.54
|
||||||
|
|||||||
@@ -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
28
references/SCHEMA.md
Normal 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/`。
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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/Edge(launch 时 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
"""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__))
|
||||||
|
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import;若以「脚本路径」启动子进程,
|
||||||
|
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
|
||||||
|
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
|
||||||
|
|
||||||
|
|
||||||
def _win_find_exe(candidates):
|
def _win_find_exe(candidates):
|
||||||
@@ -66,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 判定,减轻跳转中途误判。默认 4s,JIANGCHANG_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:需要 playwright:pip 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
|
|
||||||
child_hard_timeout = False
|
|
||||||
child_stderr = ""
|
|
||||||
child_stdout = ""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, login_runner_path, cfg_path],
|
|
||||||
timeout=timeout_sec + 180,
|
|
||||||
env=subprocess_env_with_trace(),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
proc_rc = r.returncode
|
|
||||||
child_stderr = (r.stderr or "").strip()
|
|
||||||
child_stdout = (r.stdout or "").strip()
|
|
||||||
if child_stderr:
|
|
||||||
log.warning(
|
|
||||||
"login_child_stderr account_id=%s len=%s tail=%s",
|
|
||||||
account_id,
|
|
||||||
len(child_stderr),
|
|
||||||
child_stderr[-6000:],
|
|
||||||
)
|
|
||||||
if child_stdout and proc_rc != 0:
|
|
||||||
log.warning(
|
|
||||||
"login_child_stdout account_id=%s len=%s tail=%s",
|
|
||||||
account_id,
|
|
||||||
len(child_stdout),
|
|
||||||
child_stdout[-2000:],
|
|
||||||
)
|
|
||||||
if proc_rc != 0:
|
|
||||||
if verbose_ui:
|
|
||||||
print("⚠️ 浏览器子进程非零退出,以结果 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:需要 playwright:pip 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:需要 playwright:pip 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:
|
||||||
@@ -464,7 +84,10 @@ def cmd_open(account_id):
|
|||||||
try:
|
try:
|
||||||
import playwright # noqa: F401
|
import playwright # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("ERROR:需要 playwright:pip install playwright && playwright install chromium")
|
print(
|
||||||
|
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright;"
|
||||||
|
"本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 playwright install chromium)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
profile_dir = (target.get("profile_dir") or "").strip()
|
profile_dir = (target.get("profile_dir") or "").strip()
|
||||||
@@ -478,10 +101,8 @@ 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>")
|
|
||||||
|
|
||||||
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
|
|
||||||
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||||
@@ -490,7 +111,8 @@ def cmd_open(account_id):
|
|||||||
cfg_path = jf.name
|
cfg_path = jf.name
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
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(),
|
env=subprocess_env_with_trace(),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 可覆盖多数站点。
|
||||||
|
|||||||
40
scripts/util/playwright_stealth.py
Normal file
40
scripts/util/playwright_stealth.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user