chore: auto release commit (2026-04-06 12:10:31)
All checks were successful
技能自动化发布 / release (push) Successful in 13s
All checks were successful
技能自动化发布 / release (push) Successful in 13s
This commit is contained in:
1
scripts/service/__init__.py
Normal file
1
scripts/service/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application services (use cases)."""
|
||||
380
scripts/service/account_service.py
Normal file
380
scripts/service/account_service.py
Normal 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 开头、第二位为 3~9(示例 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 开头、第二位为 3~9。"
|
||||
)
|
||||
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")
|
||||
295
scripts/service/browser_service.py
Normal file
295
scripts/service/browser_service.py
Normal 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 判定,减轻跳转中途误判。默认 4s,JIANGCHANG_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:需要 playwright:pip 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:需要 playwright:pip 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
|
||||
223
scripts/service/login_child_runner.py
Normal file
223
scripts/service/login_child_runner.py
Normal 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()
|
||||
34
scripts/service/open_child_runner.py
Normal file
34
scripts/service/open_child_runner.py
Normal 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()
|
||||
Reference in New Issue
Block a user