chore: auto release commit (2026-04-06 12:10:31)
All checks were successful
技能自动化发布 / release (push) Successful in 13s

This commit is contained in:
2026-04-06 12:10:31 +08:00
parent c428bedfd5
commit 39daeaca59
21 changed files with 728 additions and 1420 deletions

View File

@@ -0,0 +1 @@
"""Application services (use cases)."""

View File

@@ -0,0 +1,380 @@
"""账号用例list / get / add / delete / pick / set-login-status含终端输出供 CLI 调用)。"""
import json
import os
import shutil
import sys
import time
from db.accounts_repo import (
count_platform_accounts_conn,
default_name_for_platform,
delete_account_by_id_conn,
delete_account_platform_phone_conn,
delete_accounts_by_platform_conn,
fetch_delete_row_by_id_conn,
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
from util.logging_config import get_skill_logger
from util.platforms import (
PLATFORM_URLS,
_PLATFORM_PRIMARY_CN,
_is_valid_cn_mobile11,
_normalize_phone_digits,
_platform_list_cn_for_help,
resolve_platform_key,
)
from util.runtime_paths import _runtime_paths_debug_text, get_default_profile_dir
def _remove_profile_dir(path: str) -> None:
p = (path or "").strip()
if not p or not os.path.isdir(p):
return
try:
shutil.rmtree(p)
except OSError as e:
print(f"⚠️ 未能删除用户数据目录(可手工删):{p}\n {e}", file=sys.stderr)
def cmd_list(platform="all", limit: int = 10):
get_skill_logger().info("list filter=%r", platform)
init_db()
raw = (platform or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = "all"
else:
key = resolve_platform_key(raw)
if not key:
get_skill_logger().warning("list_invalid_platform raw=%r", raw)
print(f"ERROR:INVALID_PLATFORM_LIST 无法识别的平台「{raw}")
print("支持:" + _platform_list_cn_for_help())
return
if limit <= 0:
limit = 10
rows = fetch_list_rows(key, int(limit))
found = bool(rows)
if found:
get_skill_logger().info("list_ok rows=%s key=%s", len(rows), key)
sep_line = "_" * 39
for idx, row in enumerate(rows):
(
aid,
name,
plat,
phone,
profile_dir,
url,
login_status,
last_login_at,
extra_json,
created_at,
updated_at,
) = row
print(f"id{aid}")
print(f"name{name or ''}")
print(f"platform{plat or ''}")
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 ''}")
if idx != len(rows) - 1:
print(sep_line)
print()
if not found:
print("ERROR:NO_ACCOUNTS_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
def cmd_get(account_id):
get_skill_logger().info("get account_id=%r", account_id)
acc = get_account_by_id(account_id)
if acc:
print(json.dumps(acc, ensure_ascii=False))
return
get_skill_logger().warning("get_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
"""
跨技能接口stdout 仅输出一行 JSON 数组,元素结构与 get 单条一致。
平台可为 all/全部 或各平台中文名/英文键;按 updated_at 倒序,最多 limit 条(上限 500
"""
get_skill_logger().info("list_json filter=%r limit=%s", platform_input, limit)
init_db()
raw = (platform_input or "all").strip()
if not raw or raw.lower() == "all" or raw == "全部":
key = "all"
else:
key = resolve_platform_key(raw)
if not key:
print("ERROR:INVALID_PLATFORM_LIST_JSON 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
lim = max(1, min(int(limit), 500))
ids = fetch_ids_for_list_json(key, lim)
out = []
for aid in ids:
acc = get_account_by_id(aid)
if acc:
out.append(acc)
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在浏览器内登录后继续任务。
成功stdout 仅一行 JSON与 get 一致);失败:首行 ERROR:。
"""
get_skill_logger().info("pick_web 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_web_candidate_id(key)
if picked is None:
print("ERROR:NO_ACCOUNT 该平台在账号库中没有任何记录,请先执行 add。")
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_add(platform_input: str, phone: str):
"""添加账号platform 可为中文展示名或英文键;手机号必填;同平台下同手机号不可重复。"""
log = get_skill_logger()
log.info("add_attempt platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
log.warning("add_invalid_platform input=%r", platform_input)
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
phone_raw = (phone or "").strip()
if not phone_raw:
log.warning("add_phone_missing")
print("ERROR:PHONE_REQUIRED 手机号为必填。")
return
phone_norm = _normalize_phone_digits(phone_raw)
if not _is_valid_cn_mobile11(phone_norm):
log.warning("add_phone_invalid digits_len=%s", len(phone_norm or ""))
print(
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
"以 1 开头、第二位为 39示例 13800138000"
)
return
url = PLATFORM_URLS[key]
now = int(time.time())
phone_store = phone_norm
conn = get_conn()
try:
if has_duplicate_phone_conn(conn, key, phone_store):
log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:])
print(
f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。"
)
return
next_idx = count_platform_accounts_conn(conn, key) + 1
name = default_name_for_platform(key, next_idx)
profile_dir = get_default_profile_dir(key, phone_store, None)
new_id = insert_account_row(conn, name, key, phone_store, profile_dir, url, now)
conn.commit()
finally:
conn.close()
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,
key,
profile_dir,
)
def cmd_delete_by_id(account_id) -> None:
log = get_skill_logger()
log.info("delete_by_id account_id=%r", account_id)
init_db()
aid = normalize_account_id(account_id)
if aid is None or (isinstance(aid, str) and not str(aid).isdigit()):
log.warning("delete_invalid_id")
print("ERROR:DELETE_INVALID_ID 账号 id 须为正整数。")
return
conn = get_conn()
try:
row = fetch_delete_row_by_id_conn(conn, int(aid))
if not row:
log.warning("delete_by_id_not_found id=%s", aid)
print("ERROR:ACCOUNT_NOT_FOUND")
print(_runtime_paths_debug_text(), file=sys.stderr)
return
rid, name, plat, phone, profile_dir = row
delete_account_by_id_conn(conn, rid)
conn.commit()
finally:
conn.close()
_remove_profile_dir(profile_dir)
print(
f"✅ 已删除账号ID {rid} | {name} | {_PLATFORM_PRIMARY_CN.get(plat, plat)} | 手机 {phone or '(无)'}"
)
log.info("delete_by_id_done id=%s platform=%s", rid, plat)
def cmd_delete_by_platform(platform_input: str) -> None:
log = get_skill_logger()
log.info("delete_by_platform platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
log.warning("delete_by_platform_invalid_platform")
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
conn = get_conn()
try:
rows = fetch_ids_profile_for_platform_conn(conn, key)
if not rows:
log.warning("delete_by_platform_empty platform=%s", key)
print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。")
return
delete_accounts_by_platform_conn(conn, key)
conn.commit()
finally:
conn.close()
for _rid, pdir in rows:
_remove_profile_dir(pdir)
print(
f"✅ 已删除 {_PLATFORM_PRIMARY_CN.get(key, key)} 下共 {len(rows)} 条账号及对应用户数据目录。"
)
log.info("delete_by_platform_done platform=%s count=%s", key, len(rows))
def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None:
log = get_skill_logger()
log.info("delete_by_platform_phone platform_input=%r", platform_input)
init_db()
key = resolve_platform_key((platform_input or "").strip())
if not key:
print("ERROR:INVALID_PLATFORM 无法识别的平台名称。")
print("支持:" + _platform_list_cn_for_help())
return
phone_raw = (phone or "").strip()
if not phone_raw:
print("ERROR:PHONE_REQUIRED 手机号为必填。")
return
phone_norm = _normalize_phone_digits(phone_raw)
if not _is_valid_cn_mobile11(phone_norm):
print(
"ERROR:PHONE_INVALID 手机号格式不正确:须为中国大陆 11 位号码,"
"以 1 开头、第二位为 39。"
)
return
conn = get_conn()
try:
row = fetch_row_platform_phone_conn(conn, key, phone_norm)
if not row:
print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。")
return
rid, name, profile_dir = row
delete_account_platform_phone_conn(conn, key, phone_norm)
conn.commit()
finally:
conn.close()
_remove_profile_dir(profile_dir)
print(
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")

View File

@@ -0,0 +1,295 @@
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。"""
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, mark_login_status
from util.logging_config import get_skill_logger, get_skill_log_file_path
from util.platforms import (
PLATFORMS,
PLATFORM_URLS,
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
)
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
def _win_find_exe(candidates):
for p in candidates:
if p and os.path.isfile(p):
return p
return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LocalAppData", "")
chrome = _win_find_exe(
[
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
]
)
if chrome:
return "chrome"
edge = _win_find_exe(
[
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
]
)
if edge:
return "msedge"
return None
def _print_browser_install_hint():
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 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
channel = resolve_chromium_channel()
if not channel:
log.warning("login_aborted no_chromium_channel")
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
log.error("login_aborted playwright_missing")
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return
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
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()
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} 秒。")
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"),
platform,
channel,
timeout_sec,
poll_sec,
dom_grace_sec,
profile_dir,
url,
log_file,
)
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
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
try:
r = subprocess.run(
[sys.executable, login_runner_path, cfg_path],
timeout=timeout_sec + 180,
)
proc_rc = r.returncode
if proc_rc != 0:
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
except subprocess.TimeoutExpired as ex:
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
log.warning("login_subprocess_timeout err=%s", ex)
finally:
try:
os.unlink(cfg_path)
except OSError:
pass
interactive_ok = False
try:
with open(result_path, encoding="utf-8") as rf:
interactive_ok = bool(json.load(rf).get("interactive_ok"))
except Exception:
pass
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 subprocess_rc=%s marking_db=%s",
target.get("id"),
interactive_ok,
proc_rc,
ok,
)
mark_login_status(target["id"], ok)
if ok:
print("✅ 已判定登录成功,状态已写入数据库")
else:
log.warning(
"login_not_detected account_id=%s platform=%s see_log=%s",
target.get("id"),
platform,
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:
get_skill_logger().warning("open_not_found account_id=%r", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print("ERROR:需要 playwrightpip install playwright && playwright install chromium")
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
)
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>")
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
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
try:
subprocess.run([sys.executable, open_runner_path, cfg_path])
finally:
try:
os.unlink(cfg_path)
except OSError:
pass

View File

@@ -0,0 +1,223 @@
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动argv[1] 为 JSON 配置路径。"""
import json
import logging
import sys
import time
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
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():
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 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))
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"])
interactive_ok = False
result_path = c.get("result_path") or ""
log_path = (c.get("log_file") or "").strip()
lg = logging.getLogger("openclaw.skill.account_manager.login_child")
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.addHandler(_fh)
lg.propagate = False
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"],
)
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.handlers:
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, detail = evaluate_logged_out_dom(ctx, page, bundle)
ok = not still_logged_out
last_eval_detail = detail
if lg.handlers:
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
if lg.handlers:
lg.info("login_detected_ok %s", detail)
break
except Exception as ex:
if lg.handlers:
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:
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
else:
if lg.handlers:
lg.info("login_success_closing_browser_immediately")
except Exception as e:
if lg.handlers:
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)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,34 @@
"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动argv[1] 为 JSON 配置路径。"""
import json
import sys
from playwright.sync_api import sync_playwright
def main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
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"],
)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
ctx.wait_for_event("close", timeout=86400000)
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()