chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s
All checks were successful
技能自动化发布 / release (push) Successful in 43s
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""账号用例:list / get / add / delete / pick / set-login-status(含终端输出,供 CLI 调用)。"""
|
||||
"""账号用例:list / get / add / delete / pick-web(含终端输出,供 CLI 调用)。"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -15,13 +15,11 @@ from db.accounts_repo import (
|
||||
fetch_ids_for_list_json,
|
||||
fetch_ids_profile_for_platform_conn,
|
||||
fetch_list_rows,
|
||||
fetch_pick_logged_in_id,
|
||||
fetch_pick_web_candidate_id,
|
||||
fetch_row_platform_phone_conn,
|
||||
get_account_by_id,
|
||||
has_duplicate_phone_conn,
|
||||
insert_account_row,
|
||||
mark_login_status,
|
||||
normalize_account_id,
|
||||
)
|
||||
from db.connection import get_conn, init_db
|
||||
@@ -78,8 +76,6 @@ def cmd_list(platform="all", limit: int = 10):
|
||||
phone,
|
||||
profile_dir,
|
||||
url,
|
||||
login_status,
|
||||
last_login_at,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
@@ -90,8 +86,6 @@ def cmd_list(platform="all", limit: int = 10):
|
||||
print(f"phone:{phone or ''}")
|
||||
print(f"profile_dir:{profile_dir or ''}")
|
||||
print(f"url:{url or ''}")
|
||||
print(f"login_status:{int(login_status) if login_status is not None else ''}")
|
||||
print(f"last_login_at:{int(last_login_at) if last_login_at is not None else ''}")
|
||||
print(f"extra_json:{extra_json or ''}")
|
||||
print(f"created_at:{int(created_at) if created_at is not None else ''}")
|
||||
print(f"updated_at:{int(updated_at) if updated_at is not None else ''}")
|
||||
@@ -141,40 +135,10 @@ def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_pick_logged_in(platform_input: str):
|
||||
"""
|
||||
机器可读跨技能接口:查询指定平台下「已登录」的一条账号(login_status=1,按 last_login_at 优先)。
|
||||
成功:stdout 仅输出一行 JSON,结构与 get 子命令一致。
|
||||
失败:stdout 首行以 ERROR: 开头(由调用方判断,勿解析为 JSON)。
|
||||
"""
|
||||
get_skill_logger().info("pick_logged_in platform_input=%r", platform_input)
|
||||
key = resolve_platform_key((platform_input or "").strip())
|
||||
if not key:
|
||||
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
|
||||
print("支持:" + _platform_list_cn_for_help())
|
||||
return
|
||||
|
||||
init_db()
|
||||
picked = fetch_pick_logged_in_id(key)
|
||||
|
||||
if picked is None:
|
||||
print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号(login_status=1)。")
|
||||
print("请先 list 查看账号 id,再执行:python main.py login <id>")
|
||||
return
|
||||
|
||||
acc = get_account_by_id(picked)
|
||||
if not acc:
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
return
|
||||
print(json.dumps(acc, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_pick_web(platform_input: str):
|
||||
"""
|
||||
供 llm-manager 等:取该平台用于网页自动化的账号候选。
|
||||
优先 login_status=1(与 pick-logged-in 一致);若无,则取该平台 updated_at 最新的一条,
|
||||
便于「已 add 未标登录」时仍打开 profile,在浏览器内登录后继续任务。
|
||||
按 updated_at、created_at 倒序选一条,与库内「登录态」无关;实际是否已登录由 wait-login / 页面 DOM 判定。
|
||||
成功:stdout 仅一行 JSON(与 get 一致);失败:首行 ERROR:。
|
||||
"""
|
||||
get_skill_logger().info("pick_web platform_input=%r", platform_input)
|
||||
@@ -352,29 +316,3 @@ def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
|
||||
f"✅ 已删除账号:ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(key, key)} | 手机 {phone_norm}"
|
||||
)
|
||||
log.info("delete_by_platform_phone_done id=%s platform=%s", rid, key)
|
||||
|
||||
|
||||
def cmd_set_login_status(account_id_str: str, status_str: str) -> None:
|
||||
"""
|
||||
跨技能机器接口:回写 accounts.login_status。
|
||||
成功:stdout 首行 OK:SET_LOGIN_STATUS 或 OK:CLEARED_LOGIN_STATUS,进程退出码 0。
|
||||
失败:stdout 首行 ERROR:...,退出码 1。
|
||||
"""
|
||||
aid_s = (account_id_str or "").strip()
|
||||
st_s = (status_str or "").strip()
|
||||
if not aid_s.isdigit():
|
||||
print("ERROR:INVALID_ACCOUNT_ID")
|
||||
sys.exit(1)
|
||||
if st_s not in ("0", "1"):
|
||||
print("ERROR:INVALID_STATUS 须为 0 或 1")
|
||||
sys.exit(1)
|
||||
aid = int(aid_s)
|
||||
init_db()
|
||||
if get_account_by_id(aid) is None:
|
||||
get_skill_logger().warning("set_login_status_not_found account_id=%r", aid)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
sys.exit(1)
|
||||
ok = st_s == "1"
|
||||
mark_login_status(aid, ok)
|
||||
get_skill_logger().info("set_login_status account_id=%s success=%s", aid, ok)
|
||||
print("OK:SET_LOGIN_STATUS" if ok else "OK:CLEARED_LOGIN_STATUS")
|
||||
|
||||
@@ -7,7 +7,7 @@ import tempfile
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from db.accounts_repo import get_account_by_id, mark_login_status
|
||||
from db.accounts_repo import get_account_by_id, touch_account_updated_at
|
||||
from util.logging_config import (
|
||||
get_skill_logger,
|
||||
get_skill_log_file_path,
|
||||
@@ -132,7 +132,7 @@ def _run_login_browser_session(
|
||||
"""
|
||||
启动 Playwright 登录检测子进程;**是否已登录以子进程 DOM 判定为准**。
|
||||
|
||||
成功后尽力 `mark_login_status` 供 list/排序展示,**跨技能编排请勿再依赖该字段**(可能与真实 Cookie 会话脱节)。
|
||||
若 DOM 判定成功则刷新该账号 `updated_at`,便于 `pick-web` 等按最近活跃选号;**是否已登录仅以本次检测为准**。
|
||||
|
||||
返回 (是否已登录成功, end_reason):end_reason 为 login_child 写入的 reason,失败时可能为
|
||||
timeout / user_closed / subprocess_error。
|
||||
@@ -218,21 +218,48 @@ def _run_login_browser_session(
|
||||
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("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
|
||||
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
|
||||
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_timeout err=%s", ex)
|
||||
print("⚠️ 浏览器子进程等待超时,按「限时内未完成登录」处理")
|
||||
log.warning("login_subprocess_hard_timeout err=%s", ex)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
@@ -240,16 +267,37 @@ def _run_login_browser_session(
|
||||
pass
|
||||
|
||||
interactive_ok = False
|
||||
end_reason = "subprocess_error"
|
||||
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"))
|
||||
end_reason = str(data.get("end_reason") or "").strip() or (
|
||||
"login_ok" if interactive_ok else "timeout"
|
||||
)
|
||||
except Exception:
|
||||
end_reason = "subprocess_error"
|
||||
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:
|
||||
@@ -265,7 +313,8 @@ def _run_login_browser_session(
|
||||
proc_rc,
|
||||
ok,
|
||||
)
|
||||
mark_login_status(account_id, ok)
|
||||
if ok:
|
||||
touch_account_updated_at(account_id)
|
||||
return ok, end_reason if not ok else "login_ok"
|
||||
|
||||
|
||||
@@ -282,7 +331,7 @@ def cmd_login(account_id):
|
||||
ok, _end_reason = _run_login_browser_session(target, timeout_sec, verbose_ui=True)
|
||||
log_file = get_skill_log_file_path()
|
||||
if ok:
|
||||
print("✅ 已判定登录成功,状态已写入数据库")
|
||||
print("✅ 已判定页面登录成功")
|
||||
else:
|
||||
log.warning(
|
||||
"login_not_detected account_id=%s platform=%s see_log=%s",
|
||||
@@ -290,29 +339,23 @@ def cmd_login(account_id):
|
||||
(target.get("platform") or "").strip().lower(),
|
||||
log_file,
|
||||
)
|
||||
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
||||
print("⚠️ 未检测到有效登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
||||
print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
|
||||
|
||||
|
||||
def cmd_wait_login(account_id, force: bool = False) -> int:
|
||||
def cmd_wait_login(account_id) -> int:
|
||||
"""
|
||||
跨技能编排:若未登录则打开浏览器并在限时内等待登录。
|
||||
返回 0:已登录(原本已登录或本次登录成功);1:失败(超时 / 用户关浏览器 / 其它)。
|
||||
跨技能编排:始终以本次 Playwright DOM 检测为准;限时内打开浏览器并等待用户完成登录。
|
||||
返回 0:本次检测判定已登录;1:失败(超时 / 用户关浏览器 / 其它)。
|
||||
stdout 含 OK:WAIT_LOGIN 或 ERROR:WAIT_LOGIN_*。
|
||||
|
||||
force=True:即使库内 login_status=1 也仍打开浏览器并限时检测(供搜狐发布等「页面仍要求登录」场景)。
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info("wait_login_command account_id=%r force=%s", account_id, force)
|
||||
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 可能不同步时用 force=True)
|
||||
if int(target.get("login_status") or 0) == 1 and not force:
|
||||
print("OK:WAIT_LOGIN")
|
||||
return 0
|
||||
|
||||
if not resolve_chromium_channel():
|
||||
_print_browser_install_hint()
|
||||
@@ -327,9 +370,28 @@ def cmd_wait_login(account_id, force: bool = False) -> int:
|
||||
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"⏳ 账号「{target.get('name', target['id'])}」尚未登录,将打开浏览器;请在 {sec} 秒内完成登录。"
|
||||
"关闭浏览器将视为放弃。"
|
||||
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:
|
||||
@@ -350,6 +412,37 @@ def cmd_wait_login(account_id, force: bool = False) -> int:
|
||||
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
|
||||
|
||||
@@ -386,7 +479,7 @@ def cmd_open(account_id):
|
||||
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"地址:{url}")
|
||||
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
|
||||
print("需要自动检测并写入数据库时,请执行:python main.py login <id>")
|
||||
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}
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动,argv[1] 为 JSON 配置路径。"""
|
||||
"""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 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
|
||||
):
|
||||
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):
|
||||
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
|
||||
try:
|
||||
href = p.evaluate("() => location.href")
|
||||
if href and str(href).strip():
|
||||
@@ -124,142 +139,240 @@ def evaluate_logged_out_dom(ctx, main_page, bundle):
|
||||
return False, ""
|
||||
|
||||
|
||||
def main():
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
bundle = c.get("login_detect_bundle") or {}
|
||||
poll = float(c.get("poll_interval", 1.5))
|
||||
def _run_login_child(c: dict) -> None:
|
||||
"""单次登录检测;结果一律写入 c['result_path'](由 finally 保证)。"""
|
||||
result_path = (c.get("result_path") or "").strip()
|
||||
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"])
|
||||
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"
|
||||
result_path = c.get("result_path") or ""
|
||||
log_path = (c.get("log_file") or "").strip()
|
||||
ctx = None
|
||||
lg = None
|
||||
if log_path:
|
||||
lg = attach_unified_file_handler(
|
||||
log_path,
|
||||
skill_slug="account-manager",
|
||||
logger_name="openclaw.skill.account_manager.login_child",
|
||||
)
|
||||
last_eval_detail = ""
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=c["profile_dir"],
|
||||
headless=False,
|
||||
channel=c["channel"],
|
||||
no_viewport=True,
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
detail_msg = ""
|
||||
|
||||
try:
|
||||
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,
|
||||
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",
|
||||
)
|
||||
while time.time() < deadline:
|
||||
iter_start = time.time()
|
||||
try:
|
||||
still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle)
|
||||
ok = not still_logged_out
|
||||
last_eval_detail = detail
|
||||
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:
|
||||
parts = []
|
||||
for tab in list(ctx.pages or []):
|
||||
try:
|
||||
href = page_location_href(tab)
|
||||
pu = (tab.url or "").strip()
|
||||
parts.append("href=%r playwright_url=%r" % (href, pu))
|
||||
except Exception:
|
||||
parts.append("(tab_read_error)")
|
||||
lg.debug(
|
||||
"poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
|
||||
iter_start - t0,
|
||||
deadline - iter_start,
|
||||
len(ctx.pages or []),
|
||||
poll,
|
||||
still_logged_out,
|
||||
ok,
|
||||
detail,
|
||||
" ; ".join(parts) if parts else "no_tabs",
|
||||
)
|
||||
if ok:
|
||||
interactive_ok = True
|
||||
end_reason = "login_ok"
|
||||
if lg is not None:
|
||||
lg.info("login_detected_ok %s", detail)
|
||||
break
|
||||
except Exception as ex:
|
||||
if _is_context_closed_error(ex):
|
||||
end_reason = "user_closed"
|
||||
if lg is not None:
|
||||
lg.warning("login_poll_browser_closed err=%s", ex)
|
||||
break
|
||||
if lg is not None:
|
||||
lg.warning("poll_exception err=%s", ex, exc_info=True)
|
||||
spent = time.time() - iter_start
|
||||
time.sleep(max(0.0, poll - spent))
|
||||
if not interactive_ok and end_reason != "user_closed":
|
||||
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.warning("login_poll_exhausted detail=%s", last_eval_detail)
|
||||
rem = max(0.0, deadline - time.time())
|
||||
if rem > 0:
|
||||
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:
|
||||
ctx.wait_for_event("close", timeout=rem * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if lg is not None:
|
||||
lg.info("login_dom_ok_preparing_to_release_profile_dir")
|
||||
if interactive_ok:
|
||||
# 给 Cookie/会话落盘一点时间;随后必须关闭上下文,否则 llm-manager 无法再对同一 profile 启动持久化浏览器
|
||||
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:
|
||||
if _is_context_closed_error(e):
|
||||
end_reason = "user_closed"
|
||||
except Exception as e:
|
||||
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:
|
||||
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:
|
||||
with open(result_path, "w", encoding="utf-8") as rf:
|
||||
json.dump(
|
||||
{
|
||||
"interactive_ok": interactive_ok,
|
||||
"end_reason": end_reason,
|
||||
},
|
||||
rf,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
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__":
|
||||
main()
|
||||
_entrypoint()
|
||||
|
||||
Reference in New Issue
Block a user