diff --git a/references/RUNTIME.md b/references/RUNTIME.md index b432149..7405b41 100644 --- a/references/RUNTIME.md +++ b/references/RUNTIME.md @@ -2,38 +2,39 @@ ## 数据落盘位置(当前实现) -代码见 `scripts/util/runtime_paths.py`、`scripts/util/constants.py`。 +`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(与 `jiangchang-platform-kit` 同源);`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。 -- **数据根目录** `get_data_root()`:优先环境变量 **`JIANGCHANG_DATA_ROOT`**;未设置时 Windows 默认 `D:\jiangchang-data`,其它系统默认 `~/.jiangchang-data`。 -- **用户/工作空间 id** `get_user_id()`:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。 +- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**;未设置且未走本地注入时,Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`。 +- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置且未走本地注入时为 **`_anon`**。 - **技能数据目录**:`{数据根}/{用户id}/account-manager/` - **SQLite**:`account-manager.db` - **Profile**:`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成) - - **日志目录**:`logs/`(见下) - -> OpenClaw 工作区文档中的 **`CLAW_*`** 变量:本技能**当前仍以 `JIANGCHANG_*` 为准**读取数据根与用户 id;若宿主已统一注入 `CLAW_*`,需在代码层做别名兼容(未做前请只依赖本文列出的变量)。 + - **日志**:与其它技能共用**用户级**目录 `{数据根}/{用户id}/logs/jiangchang.log`(按日轮转) ## 调试:为什么「list 没数据」 -- 本地终端未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID` 时,会落到 **默认盘路径** 与 **`_anon`**,与网关中运行的实例**不是同一个库文件**。 -- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际使用的根目录、用户 id 与数据库路径(由 `_maybe_print_paths_debug_cli` 输出)。 +- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**,库文件不是同一份。 +- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli`)。 -## 本地 CLI 开发注入(慎用) +## 本地 CLI 默认值(`runtime_env`) -`util/constants.py` 中 **`_apply_cli_local_dev_env()`** 仅在 CLI 入口调用: +各技能 **`main.py`** 在 `sys.path` 就绪后、import 业务包之前调用 **`apply_cli_local_defaults()`**(实现见 `jiangchang_skill_core.runtime_env`): -- 若开启本地开发开关(代码内 `_ACCOUNT_MANAGER_CLI_LOCAL_DEV` 或环境变量 **`JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1`**),且当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入代码内配置的默认路径与用户 id。 -- **发版前务必关闭**,避免个人路径进入制品。详见该文件内注释。 +- 代码内 **`CLI_LOCAL_DEV_ENABLED = True`** 时,若当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入平台默认数据根与 **`DEFAULT_LOCAL_USER_ID`**(与 kit 中常量一致)。 +- 代码内为 **`False`** 时,可设环境变量 **`JIANGCHANG_CLI_LOCAL_DEV=1`** 达到同样效果。 +- **发版/嵌入宿主前**:将各技能 vendored 的 `runtime_env.py` 中 `CLI_LOCAL_DEV_ENABLED` 改为 **`False`**,或完全依赖宿主注入。 ## 日志 -`scripts/util/logging_config.py`: +实现见 `scripts/jiangchang_skill_core/unified_logging.py`,`util/logging_config.py` 仅 re-export。 | 变量 | 作用 | |------|------| | `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` | | `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr | -| `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{技能数据目录}/logs/account-manager.log`) | +| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径(默认 `{数据根}/{用户id}/logs/jiangchang.log`) | +| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` | +| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 | ## 登录 / 浏览器子进程(login) @@ -51,4 +52,4 @@ - **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。 - **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。 -- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看技能日志文件。 +- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看统一日志文件。 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index b162db3..5fb1934 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -13,9 +13,9 @@ from service.account_service import ( cmd_pick_web, cmd_set_login_status, ) -from service.browser_service import cmd_login, cmd_open -from util.constants import _apply_cli_local_dev_env -from util.logging_config import get_skill_logger +from service.browser_service import cmd_login, cmd_open, cmd_wait_login +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 from util.runtime_paths import _maybe_print_paths_debug_cli @@ -32,6 +32,7 @@ def _cli_print_full_usage() -> None: print(" python main.py set-login-status <0|1> # 跨技能回写登录态") print(" python main.py open ") print(" python main.py login ") + print(" python main.py wait-login # 跨技能:限时内等待网页登录(默认 180s)") print(" python main.py delete id ") print(" python main.py delete platform <平台>") print(" python main.py delete platform <平台> <手机号>") @@ -85,7 +86,7 @@ def main() -> None: _cli_print_full_usage() sys.exit(1) - _apply_cli_local_dev_env() + setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) get_skill_logger().info("cli_start argv=%s", sys.argv) cmd = sys.argv[1] _maybe_print_paths_debug_cli() @@ -151,6 +152,13 @@ def main() -> None: _cli_fail_need_account_id("open") sys.exit(1) cmd_open(sys.argv[2]) + elif cmd == "wait-login": + if len(sys.argv) < 3: + print("ERROR:CLI_WAIT_LOGIN_MISSING_ID") + print("用法:python main.py wait-login <账号 id>") + print("说明:供 llm-manager 等编排;未登录时打开浏览器,限时内等待登录或用户关窗即结束。") + sys.exit(1) + sys.exit(cmd_wait_login(sys.argv[2])) elif cmd == "login": if len(sys.argv) < 3: _cli_fail_need_account_id("login") diff --git a/scripts/db/schema.py b/scripts/db/schema.py index 66f5762..f4c34df 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -9,7 +9,7 @@ CREATE TABLE accounts ( phone TEXT, -- 可选绑定手机号 profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/ url TEXT, -- 平台入口或登录页 URL - login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录: 0 否 1 是 (由脚本校验后写入) + login_status INTEGER NOT NULL DEFAULT 0, -- 展示/排序用尽力同步;编排上请以 Playwright DOM 检测为准,勿单独依赖本字段 last_login_at INTEGER, -- 最近一次登录成功时间, Unix 秒 UTC; 未登录过为 NULL extra_json TEXT, -- 扩展字段 JSON created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC diff --git a/scripts/jiangchang_skill_core/__init__.py b/scripts/jiangchang_skill_core/__init__.py new file mode 100644 index 0000000..61cf993 --- /dev/null +++ b/scripts/jiangchang_skill_core/__init__.py @@ -0,0 +1 @@ +# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync. diff --git a/scripts/jiangchang_skill_core/runtime_env.py b/scripts/jiangchang_skill_core/runtime_env.py new file mode 100644 index 0000000..dcde0ab --- /dev/null +++ b/scripts/jiangchang_skill_core/runtime_env.py @@ -0,0 +1,53 @@ +""" +JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 + +各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。 +""" + +from __future__ import annotations + +import os +import sys + +# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启 +CLI_LOCAL_DEV_ENABLED = True + +# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改) +DEFAULT_LOCAL_USER_ID = "10032" + +# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径 +WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data" + + +def platform_default_data_root() -> str: + if sys.platform == "win32": + return WIN_DEFAULT_DATA_ROOT + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_data_root() -> str: + env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() + if env: + return env + return platform_default_data_root() + + +def get_user_id() -> str: + return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon" + + +def apply_cli_local_defaults() -> None: + """ + 在 CLI 最早阶段调用(main.py 在 import 业务包之前)。 + 宿主已设置 JIANGCHANG_* 时不会覆盖。 + """ + enabled = CLI_LOCAL_DEV_ENABLED + if not enabled: + v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower() + enabled = v in ("1", "true", "yes", "on") + if not enabled: + return + if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): + os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root() + if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): + os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip() diff --git a/scripts/jiangchang_skill_core/unified_logging.py b/scripts/jiangchang_skill_core/unified_logging.py new file mode 100644 index 0000000..ea90eb3 --- /dev/null +++ b/scripts/jiangchang_skill_core/unified_logging.py @@ -0,0 +1,158 @@ +""" +统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log +按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。 + +实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。 +""" + +from __future__ import annotations + +import logging +import os +import sys +import uuid +from logging.handlers import TimedRotatingFileHandler +from typing import Optional + +from .runtime_env import get_data_root, get_user_id + +_skill_slug: str = "" +_logger_name: str = "" + + +def get_unified_logs_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), "logs") + os.makedirs(path, exist_ok=True) + return path + + +def get_skill_log_file_path() -> str: + """与 setup 写入的主日志文件一致(供终端提示等)。""" + override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip() + if override: + parent = os.path.dirname(os.path.abspath(override)) + if parent: + os.makedirs(parent, exist_ok=True) + return os.path.abspath(override) + return os.path.join(get_unified_logs_dir(), "jiangchang.log") + + +def ensure_trace_for_process() -> str: + """本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。""" + existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() + if existing: + return existing + tid = uuid.uuid4().hex[:12] + os.environ["JIANGCHANG_TRACE_ID"] = tid + return tid + + +def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict: + """subprocess.run(..., env=...) 时使用,保证带上当前 trace。""" + ensure_trace_for_process() + tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() + if not tid: + tid = ensure_trace_for_process() + base = os.environ if environ is None else environ + return {**base, "JIANGCHANG_TRACE_ID": tid} + + +class _SkillContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-" + record.skill_slug = _skill_slug or "-" + return True + + +_FORMAT = ( + "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s" +) +_DATEFMT = "%Y-%m-%dT%H:%M:%S" + + +def _log_level_from_env() -> int: + v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper() + return getattr(logging, v, None) or logging.INFO + + +def _backup_count() -> int: + try: + n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip()) + return max(1, min(n, 365)) + except ValueError: + return 30 + + +def setup_skill_logging(skill_slug: str, logger_name: str) -> None: + """ + 幂等:为指定 logger 挂载统一文件日志与可选 stderr。 + 须在进程早期调用(如 CLI main 在业务日志之前)。 + """ + global _skill_slug, _logger_name + ensure_trace_for_process() + _skill_slug = skill_slug + _logger_name = logger_name + + log = logging.getLogger(logger_name) + if log.handlers: + return + log.setLevel(_log_level_from_env()) + path = get_skill_log_file_path() + fh = TimedRotatingFileHandler( + path, + when="midnight", + interval=1, + backupCount=_backup_count(), + encoding="utf-8", + delay=True, + ) + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + log.addHandler(fh) + if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ): + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(logging.WARNING) + sh.setFormatter(fmt) + sh.addFilter(_SkillContextFilter()) + log.addHandler(sh) + log.propagate = False + + +def get_skill_logger() -> logging.Logger: + if not _logger_name: + raise RuntimeError("get_skill_logger: call setup_skill_logging first") + return logging.getLogger(_logger_name) + + +def attach_unified_file_handler( + log_path: str, + *, + skill_slug: str, + logger_name: str, + level: int = logging.DEBUG, +) -> logging.Logger: + """ + 独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。 + """ + global _skill_slug + ensure_trace_for_process() + _skill_slug = skill_slug + lg = logging.getLogger(logger_name) + lg.handlers.clear() + lg.setLevel(level) + parent = os.path.dirname(os.path.abspath(log_path)) + if parent: + os.makedirs(parent, exist_ok=True) + fh = logging.FileHandler(log_path, encoding="utf-8") + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + lg.addHandler(fh) + lg.propagate = False + return lg diff --git a/scripts/main.py b/scripts/main.py index ce0089c..6993fbc 100644 --- a/scripts/main.py +++ b/scripts/main.py @@ -3,8 +3,17 @@ account-manager CLI 入口。 分层:cli(argv)→ service(用例)→ db(SQLite);共用工具在 util/。 """ +import os import sys +_scripts_dir = os.path.dirname(os.path.abspath(__file__)) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from jiangchang_skill_core.runtime_env import apply_cli_local_defaults + +apply_cli_local_defaults() + # Windows GBK 编码兼容修复(须在任何业务 import 与 print 之前) if sys.platform == "win32": import io diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index dca105c..813a158 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -8,7 +8,11 @@ import time from urllib.parse import urlparse from db.accounts_repo import get_account_by_id, mark_login_status -from util.logging_config import get_skill_logger, get_skill_log_file_path +from util.logging_config import ( + get_skill_logger, + get_skill_log_file_path, + subprocess_env_with_trace, +) from util.platforms import ( PLATFORMS, PLATFORM_URLS, @@ -92,6 +96,14 @@ def _login_dom_grace_seconds() -> float: 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 {} @@ -111,57 +123,70 @@ def _login_out_bundle_for(platform_key: str) -> dict: } -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 +def _run_login_browser_session( + target: dict, + timeout_sec: int, + *, + verbose_ui: bool = True, +) -> tuple[bool, str]: + """ + 启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。 + 成功后尽力 `mark_login_status` 供 list/排序展示,**跨技能编排请勿再依赖该字段**(可能与真实 Cookie 会话脱节)。 + + 返回 (是否已登录成功, end_reason):end_reason 为 login_child 写入的 reason,失败时可能为 + timeout / user_closed / subprocess_error。 + """ + log = get_skill_logger() + account_id = target.get("id") channel = resolve_chromium_channel() if not channel: log.warning("login_aborted no_chromium_channel") - _print_browser_install_hint() - return + if verbose_ui: + _print_browser_install_hint() + return False, "no_channel" try: import playwright # noqa: F401 except ImportError: log.error("login_aborted playwright_missing") - print("ERROR:需要 playwright:pip install playwright && playwright install chromium") - return + 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", target.get("id")) - print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。") - return + 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") + url = (target.get("url") or "").strip() or PLATFORM_URLS.get( + target["platform"], "https://www.google.com" + ) platform = (target.get("platform") or "").strip().lower() - timeout_sec = _login_timeout_seconds() poll_sec = _login_poll_interval_seconds() dom_grace_sec = _login_dom_grace_seconds() browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" - 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} 秒。") + 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", - target.get("id"), + account_id, platform, channel, timeout_sec, @@ -197,13 +222,16 @@ def cmd_login(account_id): r = subprocess.run( [sys.executable, login_runner_path, cfg_path], timeout=timeout_sec + 180, + env=subprocess_env_with_trace(), ) proc_rc = r.returncode if proc_rc != 0: - print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态") + if verbose_ui: + print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态") log.warning("login_subprocess_nonzero_return code=%s", proc_rc) except subprocess.TimeoutExpired as ex: - print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态") + if verbose_ui: + print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态") log.warning("login_subprocess_timeout err=%s", ex) finally: try: @@ -212,11 +240,16 @@ def cmd_login(account_id): pass interactive_ok = False + end_reason = "subprocess_error" try: with open(result_path, encoding="utf-8") as rf: - interactive_ok = bool(json.load(rf).get("interactive_ok")) + data = json.load(rf) + interactive_ok = bool(data.get("interactive_ok")) + end_reason = str(data.get("end_reason") or "").strip() or ( + "login_ok" if interactive_ok else "timeout" + ) except Exception: - pass + end_reason = "subprocess_error" try: os.unlink(result_path) except OSError: @@ -225,26 +258,100 @@ def cmd_login(account_id): time.sleep(0.5) ok = interactive_ok log.info( - "login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s", - target.get("id"), + "login_finished account_id=%s interactive_ok=%s end_reason=%s subprocess_rc=%s marking_db=%s", + account_id, interactive_ok, + end_reason, proc_rc, ok, ) - mark_login_status(target["id"], ok) + mark_login_status(account_id, ok) + return ok, end_reason if not ok else "login_ok" + + +def cmd_login(account_id): + log = get_skill_logger() + log.info("login_command account_id=%r", account_id) + target = get_account_by_id(account_id) + if not target: + log.warning("login_aborted account_not_found account_id=%r", account_id) + print("ERROR:ACCOUNT_NOT_FOUND") + return + + timeout_sec = _login_timeout_seconds() + ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True) + log_file = get_skill_log_file_path() if ok: print("✅ 已判定登录成功,状态已写入数据库") else: log.warning( "login_not_detected account_id=%s platform=%s see_log=%s", target.get("id"), - platform, + (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: + """ + 跨技能编排:若未登录则打开浏览器并在限时内等待登录。 + 返回 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 + # 库内 login_status=1 时跳过开窗(仅性能提示;与真实 Cookie 可能不同步,此时应再执行 wait-login 覆盖) + if int(target.get("login_status") or 0) == 1: + print("OK:WAIT_LOGIN") + return 0 + + if not resolve_chromium_channel(): + _print_browser_install_hint() + return 1 + try: + import playwright # noqa: F401 + except ImportError: + print("ERROR:需要 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() + print( + f"⏳ 账号「{target.get('name', target['id'])}」尚未登录,将打开浏览器;请在 {sec} 秒内完成登录。" + "关闭浏览器将视为放弃。" + ) + ok, end_reason = _run_login_browser_session(target, sec, verbose_ui=False) + if ok: + print("OK:WAIT_LOGIN") + return 0 + if end_reason == "user_closed": + print("ERROR:WAIT_LOGIN_ABORTED 已关闭浏览器,未完成登录。") + return 1 + if end_reason == "timeout": + print("ERROR:WAIT_LOGIN_TIMEOUT 限时内未完成登录。") + return 1 + if end_reason == "playwright_missing": + print("ERROR:需要 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 + print(f"ERROR:WAIT_LOGIN_FAILED end_reason={end_reason}") + return 1 + + def cmd_open(account_id): """打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。""" get_skill_logger().info("open account_id=%r", account_id) @@ -287,7 +394,10 @@ def cmd_open(account_id): json.dump(cfg, jf, ensure_ascii=False) cfg_path = jf.name try: - subprocess.run([sys.executable, open_runner_path, cfg_path]) + subprocess.run( + [sys.executable, open_runner_path, cfg_path], + env=subprocess_env_with_trace(), + ) finally: try: os.unlink(cfg_path) diff --git a/scripts/service/login_child_runner.py b/scripts/service/login_child_runner.py index fd5bbfe..995e0eb 100644 --- a/scripts/service/login_child_runner.py +++ b/scripts/service/login_child_runner.py @@ -1,12 +1,33 @@ """Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动,argv[1] 为 JSON 配置路径。""" import json -import logging +import os import sys import time from urllib.parse import urlparse +_scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from jiangchang_skill_core.unified_logging import attach_unified_file_handler from playwright.sync_api import sync_playwright +try: + from playwright.sync_api import Error as PlaywrightError +except ImportError: + PlaywrightError = Exception # type: ignore[misc,assignment] + + +def _is_context_closed_error(ex: BaseException) -> bool: + s = str(ex).lower() + if "target closed" in s or "browser has been closed" in s or "context was destroyed" in s: + return True + if isinstance(ex, PlaywrightError) and ( + "closed" in s or "destroyed" in s + ): + return True + return False + def page_location_href(p): # Prefer location.href over page.url for SPA (e.g. Sohu shell path lag). @@ -117,21 +138,16 @@ def main(): t0 = time.time() deadline = t0 + float(c["timeout_sec"]) interactive_ok = False + end_reason = "timeout" result_path = c.get("result_path") or "" log_path = (c.get("log_file") or "").strip() - lg = logging.getLogger("openclaw.skill.account_manager.login_child") + lg = None if log_path: - lg.handlers.clear() - lg.setLevel(logging.DEBUG) - _fh = logging.FileHandler(log_path, encoding="utf-8") - _fh.setFormatter( - logging.Formatter( - "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - ) + lg = attach_unified_file_handler( + log_path, + skill_slug="account-manager", + logger_name="openclaw.skill.account_manager.login_child", ) - lg.addHandler(_fh) - lg.propagate = False last_eval_detail = "" with sync_playwright() as p: ctx = p.chromium.launch_persistent_context( @@ -146,7 +162,7 @@ def main(): page.goto(c["url"], wait_until="domcontentloaded", timeout=60000) if dom_grace > 0: time.sleep(dom_grace) - if lg.handlers: + if lg is not None: lg.info( "goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s", len(ctx.pages or []), @@ -159,7 +175,7 @@ def main(): still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle) ok = not still_logged_out last_eval_detail = detail - if lg.handlers: + if lg is not None: parts = [] for tab in list(ctx.pages or []): try: @@ -181,16 +197,22 @@ def main(): ) if ok: interactive_ok = True - if lg.handlers: + end_reason = "login_ok" + if lg is not None: lg.info("login_detected_ok %s", detail) break except Exception as ex: - if lg.handlers: + if _is_context_closed_error(ex): + end_reason = "user_closed" + if lg is not None: + lg.warning("login_poll_browser_closed err=%s", ex) + break + if lg is not None: lg.warning("poll_exception err=%s", ex, exc_info=True) spent = time.time() - iter_start time.sleep(max(0.0, poll - spent)) - if not interactive_ok: - if lg.handlers: + if not interactive_ok and end_reason != "user_closed": + if lg is not None: lg.warning("login_poll_exhausted detail=%s", last_eval_detail) rem = max(0.0, deadline - time.time()) if rem > 0: @@ -199,18 +221,38 @@ def main(): except Exception: pass else: - if lg.handlers: - lg.info("login_success_closing_browser_immediately") + if lg is not None: + lg.info("login_dom_ok_preparing_to_release_profile_dir") + if interactive_ok: + # 给 Cookie/会话落盘一点时间;随后必须关闭上下文,否则 llm-manager 无法再对同一 profile 启动持久化浏览器 + print( + "INFO:LOGIN_DOM_OK 页面检测已判定登录成功;约 2 秒后关闭本窗口以释放用户目录,供后续大模型网页会话使用。", + flush=True, + ) + time.sleep(2.0) except Exception as e: - if lg.handlers: - lg.exception("login_runner_fatal err=%s", e) - print(e, file=sys.stderr) - raise + if _is_context_closed_error(e): + end_reason = "user_closed" + interactive_ok = False + if lg is not None: + lg.warning("login_runner_browser_closed err=%s", e) + else: + if lg is not None: + lg.exception("login_runner_fatal err=%s", e) + print(e, file=sys.stderr) + raise finally: if result_path: try: with open(result_path, "w", encoding="utf-8") as rf: - json.dump({"interactive_ok": interactive_ok}, rf, ensure_ascii=False) + json.dump( + { + "interactive_ok": interactive_ok, + "end_reason": end_reason, + }, + rf, + ensure_ascii=False, + ) except Exception: pass try: diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 9224dbe..1622c04 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,4 +1,4 @@ -"""技能级常量与本地 CLI 调试环境注入(仅 CLI 入口显式调用 _apply_cli_local_dev_env 时生效)。""" +"""技能级常量。本地 CLI 默认值见 jiangchang_skill_core.runtime_env(main.py 最早调用 apply_cli_local_defaults)。""" import os # scripts/util/constants.py -> skill root = parents[2] @@ -7,29 +7,3 @@ BASE_DIR = _BASE SKILL_SLUG = "account-manager" # 与其它 OpenClaw 技能对齐:openclaw.skill. LOG_LOGGER_NAME = "openclaw.skill.account_manager" - -# --------------------------------------------------------------------------- -# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。 -# - 仅当 CLI 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。 -# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。 -# - 开启方式(二选一): -# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True; -# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1(无需改代码)。 -# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。 -# --------------------------------------------------------------------------- -_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True -_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data" -_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032" - - -def _apply_cli_local_dev_env() -> None: - enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV - if not enabled: - v = (os.getenv("JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV") or "").strip().lower() - enabled = v in ("1", "true", "yes", "on") - if not enabled: - return - if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): - os.environ["JIANGCHANG_DATA_ROOT"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip() - if not (os.getenv("JIANGCHANG_USER_ID") or "").strip(): - os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip() diff --git a/scripts/util/logging_config.py b/scripts/util/logging_config.py index d072e5f..3328400 100644 --- a/scripts/util/logging_config.py +++ b/scripts/util/logging_config.py @@ -1,69 +1,21 @@ -"""技能级文件日志。""" -import logging -import os -import sys -from logging.handlers import TimedRotatingFileHandler +"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging).""" -from util.constants import LOG_LOGGER_NAME, SKILL_SLUG -from util.runtime_paths import get_skill_logs_dir +from jiangchang_skill_core.unified_logging import ( + attach_unified_file_handler, + ensure_trace_for_process, + get_skill_log_file_path, + get_skill_logger, + get_unified_logs_dir, + setup_skill_logging, + subprocess_env_with_trace, +) - -def _skill_log_file_path() -> str: - """主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。""" - override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip() - if override: - parent = os.path.dirname(os.path.abspath(override)) - if parent: - os.makedirs(parent, exist_ok=True) - return os.path.abspath(override) - return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log") - - -def _log_level_from_env() -> int: - v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper() - return getattr(logging, v, None) or logging.INFO - - -def get_skill_log_file_path() -> str: - """主日志文件绝对路径(与 get_skill_logger 写入文件一致)。""" - return _skill_log_file_path() - - -def get_skill_logger() -> logging.Logger: - """ - 技能级日志:单文件按日切分(TimedRotatingFileHandler midnight),UTF-8。 - 环境变量:JIANGCHANG_LOG_LEVEL(默认 INFO)、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、 - JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。 - """ - log = logging.getLogger(LOG_LOGGER_NAME) - if log.handlers: - return log - log.setLevel(_log_level_from_env()) - path = _skill_log_file_path() - fh = TimedRotatingFileHandler( - path, - when="midnight", - interval=1, - backupCount=30, - encoding="utf-8", - delay=True, - ) - fh.setFormatter( - logging.Formatter( - "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S", - ) - ) - log.addHandler(fh) - if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ): - sh = logging.StreamHandler(sys.stderr) - sh.setLevel(logging.WARNING) - sh.setFormatter(fh.formatter) - log.addHandler(sh) - log.propagate = False - return log +__all__ = [ + "attach_unified_file_handler", + "ensure_trace_for_process", + "get_skill_log_file_path", + "get_skill_logger", + "get_unified_logs_dir", + "setup_skill_logging", + "subprocess_env_with_trace", +] diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py index 1255527..bf855e9 100644 --- a/scripts/util/runtime_paths.py +++ b/scripts/util/runtime_paths.py @@ -4,37 +4,17 @@ import re import sys from typing import Optional +from jiangchang_skill_core.runtime_env import get_data_root, get_user_id from util.constants import SKILL_SLUG from util.platforms import _PLATFORM_PRIMARY_CN -def get_data_root(): - env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() - if env: - return env - if sys.platform == "win32": - return r"D:\jiangchang-data" - return os.path.join(os.path.expanduser("~"), ".jiangchang-data") - - -def get_user_id(): - uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() - return uid or "_anon" - - def get_skill_data_dir(): path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) os.makedirs(path, exist_ok=True) return path -def get_skill_logs_dir() -> str: - """{DATA_ROOT}/{USER_ID}/account-manager/logs/""" - path = os.path.join(get_skill_data_dir(), "logs") - os.makedirs(path, exist_ok=True) - return path - - def get_db_path(): return os.path.join(get_skill_data_dir(), "account-manager.db")