chore: account-manager — runtime_env sibling root, wait-login and browser fixes
All checks were successful
技能自动化发布 / release (push) Successful in 43s

This commit is contained in:
2026-04-07 10:36:40 +08:00
parent e3da478116
commit 97d970dd1b
16 changed files with 687 additions and 493 deletions

View File

@@ -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()