399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""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, touch_account_updated_at
|
||
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,
|
||
)
|
||
|
||
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
|
||
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import;若以「脚本路径」启动子进程,
|
||
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
|
||
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
|
||
|
||
|
||
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 _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:pip install playwright && 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:
|
||
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>")
|
||
|
||
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, "-m", "service.open_child_runner", cfg_path],
|
||
cwd=_SCRIPTS_ROOT,
|
||
env=subprocess_env_with_trace(),
|
||
)
|
||
finally:
|
||
try:
|
||
os.unlink(cfg_path)
|
||
except OSError:
|
||
pass
|