Release v1.0.53: account docs split, remove login flow, SCHEMA reference
All checks were successful
技能自动化发布 / release (push) Successful in 37s

This commit is contained in:
2026-04-12 10:56:50 +08:00
parent 75a332e7b8
commit 3a1ea6d97a
13 changed files with 126 additions and 776 deletions

View File

@@ -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 <id>")
print(" python main.py open <id>")
print(" python main.py login <id>")
print(" python main.py delete id <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()

View File

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

View File

@@ -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,

View File

@@ -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 判定,减轻跳转中途误判。默认 4sJIANGCHANG_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 Edgechannel 模式,无需 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 Edgechannel 模式,无需 playwright install chromium")
print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 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 <id>")
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(

View File

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