From 3a1ea6d97a496329496a715d3649fdac8dfe26ee Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sun, 12 Apr 2026 10:56:50 +0800 Subject: [PATCH] Release v1.0.53: account docs split, remove login flow, SCHEMA reference --- SKILL.md | 75 ++--- assets/README.md | 3 +- references/CLI.md | 17 +- references/ERRORS.md | 1 - references/PLATFORMS.md | 1 + references/README.md | 59 +++- references/RUNTIME.md | 11 +- references/SCHEMA.md | 28 ++ scripts/cli/app.py | 8 +- scripts/db/accounts_repo.py | 12 - scripts/service/account_service.py | 1 - scripts/service/browser_service.py | 296 +------------------ scripts/service/login_child_runner.py | 390 -------------------------- 13 files changed, 126 insertions(+), 776 deletions(-) create mode 100644 references/SCHEMA.md delete mode 100644 scripts/service/login_child_runner.py diff --git a/SKILL.md b/SKILL.md index 1a374f0..f74640c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号管理 -description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。 -version: 1.0.52 +description: "多平台账号登记与浏览器 Profile 目录管理。当用户要添加/列出/删除各平台账号、或编排侧需要按平台取一条账号 JSON(pick-web/get)用于网页自动化时,加载本技能。本技能不持久化「是否已登录」,不在此做登录预检;登录由业务侧(如 llm-manager 网页引擎)在使用 profile 时处理。" +version: 1.0.53 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -12,60 +12,43 @@ allowed-tools: - bash --- -# 账号管理 +# 账号管理(account-manager) -## JIANGCHANG 共享 Python(匠厂桌面) +登记各平台账号与 **Chrome/Edge 持久化用户目录(`profile_dir`)** 的对应关系,供发布类、大模型网页类等 Skill 通过子进程 CLI 取号。**库中不保存「已登录」状态**;`pick-web` 仅按更新时间等选一条候选,**不**表示浏览器已登录。 -依赖由宿主安装到 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`(技能市场安装时自动 `uv sync`)。勿在技能目录内创建 `.venv`。Playwright 使用本机 **Chrome / Edge**(`channel=chrome|msedge`),**不**下载自带 Chromium。 +## 何时使用本技能 -## 使用时机 +用户或编排表达以下意图之一时加载: -当用户发送以下内容的时候触发本Skill: -- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号" -- 说"列出账号"、"查看所有账号"、"有哪些账号" -- 说"添加账号"、"删除账号"、"获取账号信息" +1. **添加 / 删除 / 列出账号** —— 多平台(搜狐号、头条、知乎、微信、豆包、Kimi、DeepSeek 等)的账号记录与数据目录管理。 +2. **按 id 取账号 JSON** —— 跨技能需要单行 JSON(`get`)。 +3. **按平台取网页自动化候选账号** —— 跨技能需要 `pick-web` / `list-json` 的机器可读输出。 +4. **仅打开浏览器核对页面** —— `open`(不写库、不做登录 DOM 检测)。 -## 执行步骤 +## CLI 调用 -### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号;同平台下同手机号不可重复;ID 自增,名称如「搜狐1号」) -```bash -python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx -python3 {baseDir}/scripts/main.py add 知乎 13800138000 -``` -支持的平台称呼示例:搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝(亦支持英文键如 sohu、toutiao)。 +所有子命令、参数、简写形式与行为细节见 `references/CLI.md`。**执行或拼写任何命令前先读该文件**,不要凭记忆臆造参数。 -### 仅打开浏览器核对是否已登录(不写数据库) -```bash -python3 {baseDir}/scripts/main.py open -``` +**重要约束**:CLI 由宿主或工具执行;**不要把命令原文贴给最终用户看**。对用户用自然语言说明「添加某平台账号、提供大陆手机号」等即可。 -### 登录并自动检测(成功则刷新该账号 `updated_at`,不在库中保存「是否已登录」) -```bash -python3 {baseDir}/scripts/main.py login -``` +## 平台名称与别名 -### 删除账号(同时删库里的记录与 profile 用户数据目录) -```bash -python3 {baseDir}/scripts/main.py delete id -python3 {baseDir}/scripts/main.py delete platform <平台> -python3 {baseDir}/scripts/main.py delete platform <平台> <手机号> -``` +入库使用英文 `platform` 键;用户话术可用中文展示名或别名。完整表与解析规则见 `references/PLATFORMS.md`。平台无法识别时会输出 `ERROR:INVALID_PLATFORM*` 系列前缀,详见 `references/ERRORS.md`。 -### 列出某平台所有账号(platform 可为中文名、all 或 全部) -```bash -python3 {baseDir}/scripts/main.py list all -python3 {baseDir}/scripts/main.py list 搜狐号 -``` +## 数据与环境 -### 跨技能:单行 JSON(get / list-json / pick-web) -```bash -python3 {baseDir}/scripts/main.py get -python3 {baseDir}/scripts/main.py list-json all --limit 50 -python3 {baseDir}/scripts/main.py pick-web 搜狐号 -``` -约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。是否已登录由调用方网页会话处理,本 skill 不做编排用预检。 +数据库路径、profile 布局、日志与环境变量见 `references/RUNTIME.md`。`accounts` 表字段与 JSON 形状摘要见 `references/SCHEMA.md`。 -## 扩展文档(便于 AI 精确调用) +## 跨技能集成 -- **references/**:完整 CLI、平台表、运行时环境、错误码、集成协议 — 见 [references/README.md](references/README.md)。 -- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。 \ No newline at end of file +`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/`。 diff --git a/assets/README.md b/assets/README.md index 63b1189..8c663ad 100644 --- a/assets/README.md +++ b/assets/README.md @@ -3,4 +3,5 @@ - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 - `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 -详细命令与错误码见仓库根目录 `references/`。 +- 面向用户的介绍见 `references/README.md`。 +- 面向编排/CLI 的细节见 `references/CLI.md`、`ERRORS.md`、`INTEGRATION.md`(由 `SKILL.md` 渐进引用)。 diff --git a/references/CLI.md b/references/CLI.md index b614e3d..ebd2c2f 100644 --- a/references/CLI.md +++ b/references/CLI.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,23 +65,14 @@ python main.py pick-web <平台> - 按该平台 `updated_at`、`created_at` 倒序选一条;**不**表示当前浏览器已登录。 - 失败:首行 `ERROR:`。 -### `open` — 仅打开浏览器(**不写库**) +### `open` — 仅打开浏览器(**不写库、不检测登录**) ```bash python main.py open ``` - 需本机 Chrome/Edge + Playwright;详见运行环境说明。 - -### `login` — 打开浏览器并**自动检测登录**(成功则刷新 `updated_at`) - -```bash -python main.py login -``` - -- 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。 -- **不**在库中持久化「是否已登录」;仅刷新 `updated_at` 供 `pick-web` 等排序参考。 -- 跨技能编排(如 llm-manager 网页生成)**不再**通过本 skill 单独做「登录预检」;由业务侧在同一浏览器会话内处理未登录态。 +- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。 ### `delete` — 删除库记录并尽量删除 profile 目录 diff --git a/references/ERRORS.md b/references/ERRORS.md index e465ad9..6f8f85f 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -9,7 +9,6 @@ | `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_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 | diff --git a/references/PLATFORMS.md b/references/PLATFORMS.md index b1bba99..0826ca6 100644 --- a/references/PLATFORMS.md +++ b/references/PLATFORMS.md @@ -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 | ## 解析规则(摘要) diff --git a/references/README.md b/references/README.md index 593c964..d988b6d 100644 --- a/references/README.md +++ b/references/README.md @@ -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 位手机号**,系统生成账号名与专属目录。 +- **查看列表** —— 按平台或全部列出已登记的账号。 +- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录。 +- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)。 + +## 你可以这样对它说 + +- 「帮我添加一个豆包账号,手机号 138xxxx。」 +- 「列出我所有的搜狐号账号。」 +- 「删除头条号里手机尾号 3701 的那条。」 +- 「我想看看 3 号账号在浏览器里打开是什么页面。」 + +## 你需要提供什么 + +| 场景 | 你需要给的 | +|---|---| +| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) | +| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 | +| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) | + +## 失败了怎么办 + +- **提示手机号格式不对**:需 **1 开头、第二位 3~9、共 11 位数字**。 +- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。 +- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。 + +## 它不做什么 + +- ❌ 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责) +- ❌ 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息) +- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责) + +## 相关技能 + +- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。 +- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。 + +--- + +**作者**:深圳匠厂科技有限公司 · **版本**:1.0.53 diff --git a/references/RUNTIME.md b/references/RUNTIME.md index 9dd53cc..de4b65c 100644 --- a/references/RUNTIME.md +++ b/references/RUNTIME.md @@ -36,15 +36,9 @@ | `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` | | `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 | -## 登录 / 浏览器子进程(login) +## 浏览器(open) -`service/browser_service.py` 与子进程 `service/login_child_runner.py`: - -| 变量 | 作用 | -|------|------| -| `JIANGCHANG_LOGIN_TIMEOUT_SECONDS` | 登录检测最长秒数,默认 `300`,不少于 `60` | -| `JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS` | DOM 轮询间隔,默认 `1.5`,限制在 `0.5..10` | -| `JIANGCHANG_LOGIN_DOM_GRACE_SECONDS` | 打开 URL 后等待再检测,默认 `4`,减轻跳转误判 | +`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)。 @@ -52,4 +46,3 @@ - **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge。 - **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。 -- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看统一日志文件。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md new file mode 100644 index 0000000..55fd726 --- /dev/null +++ b/references/SCHEMA.md @@ -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/`。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index ae040cd..e9e3a43 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -11,7 +11,7 @@ from service.account_service import ( cmd_list_json, cmd_pick_web, ) -from service.browser_service import cmd_login, cmd_open +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 @@ -27,7 +27,6 @@ def _cli_print_full_usage() -> None: print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get") print(" python main.py get ") print(" python main.py open ") - print(" python main.py login ") print(" python main.py delete id ") print(" python main.py delete platform <平台>") print(" python main.py delete platform <平台> <手机号>") @@ -133,11 +132,6 @@ def main() -> None: _cli_fail_need_account_id("open") sys.exit(1) cmd_open(sys.argv[2]) - 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() diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 49130f0..06b1c9c 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -77,18 +77,6 @@ def get_account_by_id(account_id): 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): """platform_key 'all' or platform slug; returns list of row tuples (full row).""" conn = get_conn() diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index ec11674..9a00a93 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -213,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, diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index 6daa8eb..50fc8ba 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -1,23 +1,16 @@ -"""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, touch_account_updated_at +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__)) @@ -69,284 +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 判定,减轻跳转中途误判。默认 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 _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 Python 包:请在匠厂共享环境中已含 playwright;本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 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}") - if platform == "gemini": - print( - "Gemini / Google 登录:若提示「浏览器不安全」或「无法登录」,请关闭本窗口后," - "用普通 Chrome 启动参数 --user-data-dir= 指向下面 profile 目录,在普通浏览器里完成" - " Google 登录后完全退出 Chrome,再执行本 login;详见 llm-manager Gemini 引擎说明。" - ) - print(f" (本账号 profile 目录){profile_dir}") - print( - "请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;" - "成功后自动关闭窗口并写入状态(无需手动关浏览器)。" - ) - print( - f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。" - ) - print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。") - - log_file = get_skill_log_file_path() - log.info( - "login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s " - "dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s", - account_id, - platform, - channel, - timeout_sec, - poll_sec, - dom_grace_sec, - profile_dir, - url, - log_file, - ) - cfg = { - "channel": channel, - "profile_dir": profile_dir, - "url": url, - "timeout_sec": timeout_sec, - "poll_interval": poll_sec, - "dom_grace_sec": dom_grace_sec, - "login_detect_bundle": _login_out_bundle_for(platform), - "log_file": log_file, - } - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False, encoding="utf-8" - ) as rf: - result_path = rf.name - cfg["result_path"] = result_path - with tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False, encoding="utf-8" - ) as jf: - json.dump(cfg, jf, ensure_ascii=False) - cfg_path = jf.name - proc_rc = None - child_hard_timeout = False - child_stderr = "" - child_stdout = "" - try: - r = subprocess.run( - [sys.executable, "-m", "service.login_child_runner", cfg_path], - cwd=_SCRIPTS_ROOT, - timeout=timeout_sec + 180, - env=subprocess_env_with_trace(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - proc_rc = r.returncode - child_stderr = (r.stderr or "").strip() - child_stdout = (r.stdout or "").strip() - if child_stderr: - log.warning( - "login_child_stderr account_id=%s len=%s tail=%s", - account_id, - len(child_stderr), - child_stderr[-6000:], - ) - if child_stdout and proc_rc != 0: - log.warning( - "login_child_stdout account_id=%s len=%s tail=%s", - account_id, - len(child_stdout), - child_stdout[-2000:], - ) - if proc_rc != 0: - if verbose_ui: - print("⚠️ 浏览器子进程非零退出,以结果 JSON 为准(若已写入)") - log.info( - "login_subprocess_nonzero_return code=%s (exit code 不单独作为成败依据,见 result JSON)", - proc_rc, - ) - except subprocess.TimeoutExpired as ex: - child_hard_timeout = True - if verbose_ui: - print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理") - log.warning("login_subprocess_hard_timeout err=%s", ex) - finally: - try: - os.unlink(cfg_path) - except OSError: - pass - - interactive_ok = False - end_reason = "timeout" if child_hard_timeout else "subprocess_error" - child_detail = "" - try: - with open(result_path, encoding="utf-8") as rf: - data = json.load(rf) - interactive_ok = bool(data.get("interactive_ok")) - er = str(data.get("end_reason") or "").strip() - if er: - end_reason = er - elif interactive_ok: - end_reason = "login_ok" - elif not child_hard_timeout: - end_reason = "timeout" - child_detail = str(data.get("detail") or "").strip() - if child_detail: - log.warning("login_child_result_detail account_id=%s detail=%s", account_id, child_detail[:8000]) - except Exception as read_ex: - log.error( - "login_child_result_read_failed account_id=%s err=%s proc_rc=%s stderr_tail=%s", - account_id, - read_ex, - proc_rc, - (child_stderr or "")[-3000:], - ) - if child_hard_timeout: - end_reason = "timeout" - interactive_ok = False - elif proc_rc is not None and proc_rc != 0: - end_reason = "child_crash" - else: - end_reason = "subprocess_error" - try: - os.unlink(result_path) - except OSError: - pass - - time.sleep(0.5) - ok = interactive_ok - log.info( - "login_finished account_id=%s interactive_ok=%s end_reason=%s subprocess_rc=%s marking_db=%s", - account_id, - interactive_ok, - end_reason, - proc_rc, - ok, - ) - if ok: - touch_account_updated_at(account_id) - return ok, end_reason if not ok else "login_ok" - - -def cmd_login(account_id): - log = get_skill_logger() - log.info("login_command account_id=%r", account_id) - target = get_account_by_id(account_id) - if not target: - log.warning("login_aborted account_not_found account_id=%r", account_id) - print("ERROR:ACCOUNT_NOT_FOUND") - return - - timeout_sec = _login_timeout_seconds() - ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True) - log_file = get_skill_log_file_path() - if ok: - print("✅ 已判定页面登录成功") - else: - log.warning( - "login_not_detected account_id=%s platform=%s see_log=%s", - target.get("id"), - (target.get("platform") or "").strip().lower(), - log_file, - ) - print("⚠️ 未检测到有效登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") - print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)") - - def cmd_open(account_id): - """打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。""" + """打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。""" get_skill_logger().info("open account_id=%r", account_id) target = get_account_by_id(account_id) if not target: @@ -362,7 +84,10 @@ def cmd_open(account_id): try: import playwright # noqa: F401 except ImportError: - print("ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright;本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 playwright install chromium)") + print( + "ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright;" + "本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 playwright install chromium)" + ) return profile_dir = (target.get("profile_dir") or "").strip() @@ -376,8 +101,7 @@ 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 ") + print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。") cfg = {"channel": channel, "profile_dir": profile_dir, "url": url} with tempfile.NamedTemporaryFile( diff --git a/scripts/service/login_child_runner.py b/scripts/service/login_child_runner.py deleted file mode 100644 index 09fb9b1..0000000 --- a/scripts/service/login_child_runner.py +++ /dev/null @@ -1,390 +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) - -from util.playwright_stealth import ( - STEALTH_INIT_SCRIPT, - persistent_context_launch_parts, - stealth_enabled, -) - - -def _is_context_closed_error(ex: BaseException) -> bool: - s = str(ex).lower() - if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s: - return True - if "closed" in s or "destroyed" in s: - return True - return False - - -def _write_result_file( - result_path: str, - interactive_ok: bool, - end_reason: str, - detail: str = "", -) -> None: - if not (result_path or "").strip(): - return - payload: dict = {"interactive_ok": interactive_ok, "end_reason": end_reason} - d = (detail or "").strip() - if d: - payload["detail"] = d[:8000] - try: - with open(result_path, "w", encoding="utf-8") as rf: - json.dump(payload, rf, ensure_ascii=False) - except Exception as write_ex: - print(f"login_child: failed to write result file: {write_ex}", file=sys.stderr) - - -def page_location_href(p): - 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: - args, ignore_automation = persistent_context_launch_parts() - lc_kw = dict( - user_data_dir=profile_dir, - headless=False, - channel=c["channel"], - no_viewport=True, - args=args, - ) - if ignore_automation is not None: - lc_kw["ignore_default_args"] = ignore_automation - ctx = p.chromium.launch_persistent_context(**lc_kw) - if stealth_enabled(): - ctx.add_init_script(STEALTH_INIT_SCRIPT) - except Exception as e: - if _is_context_closed_error(e): - end_reason = "user_closed" - else: - end_reason = "launch_failed" - detail_msg = f"{type(e).__name__}: {e}" - if lg is not None: - lg.exception("launch_persistent_context failed: %s", e) - print(detail_msg, file=sys.stderr) - return - - try: - 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()