1499 lines
51 KiB
Python
1499 lines
51 KiB
Python
import sys
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
from logging.handlers import TimedRotatingFileHandler
|
||
from urllib.parse import urlparse
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
# Windows GBK 编码兼容修复
|
||
if sys.platform == "win32":
|
||
import io
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
SKILL_SLUG = "account-manager"
|
||
# 与其它 OpenClaw 技能对齐:openclaw.skill.<slug_下划线>
|
||
LOG_LOGGER_NAME = "openclaw.skill.account_manager"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。
|
||
# - 仅当 __main__ 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。
|
||
# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。
|
||
# - 开启方式(二选一):
|
||
# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True;
|
||
# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1(无需改代码)。
|
||
# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。
|
||
# ---------------------------------------------------------------------------
|
||
_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True
|
||
_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data"
|
||
_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032"
|
||
|
||
|
||
def _apply_cli_local_dev_env() -> None:
|
||
enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV
|
||
if not enabled:
|
||
v = (os.getenv("JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV") or "").strip().lower()
|
||
enabled = v in ("1", "true", "yes", "on")
|
||
if not enabled:
|
||
return
|
||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||
os.environ["JIANGCHANG_DATA_ROOT"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip()
|
||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||
os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip()
|
||
|
||
|
||
# SQLite 无独立 DATETIME 类型:时间统一存 INTEGER Unix 秒(UTC),查询/JSON 再转 ISO8601。
|
||
# 下列 DDL 含注释,会原样写入 sqlite_master,便于 Navicat / DBeaver 等查看建表语句。
|
||
ACCOUNTS_TABLE_SQL = """
|
||
/* 多平台账号表:与本地 Chromium/Edge 用户数据目录(profile)绑定,供发布类技能读取登录态 */
|
||
CREATE TABLE accounts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
|
||
name TEXT NOT NULL, -- 展示名称,如「搜狐1号」
|
||
platform TEXT NOT NULL, -- 平台标识,与内置 PLATFORMS 键一致,如 sohu
|
||
phone TEXT, -- 可选绑定手机号
|
||
profile_dir TEXT, -- Playwright 用户数据目录(绝对路径);默认可读结构 profiles/<平台展示名>/<手机号>/
|
||
url TEXT, -- 平台入口或登录页 URL
|
||
login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录:0 否 1 是(由脚本校验后写入)
|
||
last_login_at INTEGER, -- 最近一次登录成功时间,Unix 秒 UTC;未登录过为 NULL
|
||
extra_json TEXT, -- 扩展字段 JSON
|
||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||
);
|
||
"""
|
||
|
||
|
||
def _now_unix() -> int:
|
||
return int(time.time())
|
||
|
||
|
||
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
|
||
"""对外 JSON:本地时区 ISO8601 字符串,便于人读。"""
|
||
if ts is None:
|
||
return None
|
||
try:
|
||
return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
|
||
except (ValueError, OSError, OverflowError):
|
||
return None
|
||
|
||
|
||
# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名;
|
||
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label;
|
||
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
|
||
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
|
||
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
|
||
# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
|
||
PLATFORMS = {
|
||
"sohu": {
|
||
"url": "https://mp.sohu.com",
|
||
"label": "搜狐号",
|
||
"prefix": "搜狐",
|
||
"aliases": ["搜狐"],
|
||
},
|
||
"toutiao": {
|
||
"url": "https://mp.toutiao.com/",
|
||
"label": "头条号",
|
||
"prefix": "头条",
|
||
"aliases": ["头条"],
|
||
},
|
||
"zhihu": {
|
||
"url": "https://www.zhihu.com",
|
||
"label": "知乎",
|
||
"aliases": ["知乎号"],
|
||
},
|
||
"wechat": {
|
||
"url": "https://mp.weixin.qq.com",
|
||
"label": "微信公众号",
|
||
"prefix": "微信",
|
||
"aliases": ["公众号", "微信"],
|
||
},
|
||
"kimi": {
|
||
"url": "https://kimi.moonshot.cn",
|
||
"label": "Kimi",
|
||
"aliases": ["月之暗面"],
|
||
},
|
||
"deepseek": {
|
||
"url": "https://chat.deepseek.com",
|
||
"label": "DeepSeek",
|
||
},
|
||
"doubao": {
|
||
"url": "https://www.doubao.com",
|
||
"label": "豆包",
|
||
},
|
||
"qianwen": {
|
||
"url": "https://tongyi.aliyun.com",
|
||
"label": "通义千问",
|
||
"prefix": "通义",
|
||
"aliases": ["通义", "千问"],
|
||
},
|
||
"yiyan": {
|
||
"url": "https://yiyan.baidu.com",
|
||
"label": "文心一言",
|
||
"prefix": "文心",
|
||
"aliases": ["文心", "一言"],
|
||
},
|
||
"yuanbao": {
|
||
"url": "https://yuanbao.tencent.com",
|
||
"label": "腾讯元宝",
|
||
"prefix": "元宝",
|
||
"aliases": ["元宝"],
|
||
},
|
||
}
|
||
|
||
# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。
|
||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
|
||
'button:has-text("登录")',
|
||
'role=button[name="登录"]',
|
||
'a:has-text("登录")',
|
||
'h4:has-text("登录")',
|
||
'span.semi-button-content:has-text("登录")',
|
||
)
|
||
|
||
|
||
def _build_platform_derived():
|
||
urls = {}
|
||
name_prefix = {}
|
||
primary_cn = {}
|
||
alias_to_key = {}
|
||
|
||
def _register_alias(alias: str, key: str) -> None:
|
||
if not alias or not str(alias).strip():
|
||
return
|
||
a = str(alias).strip()
|
||
if a not in alias_to_key:
|
||
alias_to_key[a] = key
|
||
lo = a.lower()
|
||
if lo not in alias_to_key:
|
||
alias_to_key[lo] = key
|
||
|
||
for key, spec in PLATFORMS.items():
|
||
urls[key] = spec["url"]
|
||
primary_cn[key] = spec["label"]
|
||
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
|
||
_register_alias(key, key)
|
||
_register_alias(spec["label"], key)
|
||
for a in spec.get("aliases") or []:
|
||
_register_alias(a, key)
|
||
|
||
return urls, name_prefix, primary_cn, alias_to_key
|
||
|
||
|
||
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
|
||
|
||
|
||
def resolve_platform_key(name: str):
|
||
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
|
||
if name is None:
|
||
return None
|
||
s = str(name).strip()
|
||
if not s:
|
||
return None
|
||
sl = s.lower()
|
||
if sl in PLATFORM_URLS:
|
||
return sl
|
||
return _PLATFORM_ALIAS_TO_KEY.get(s)
|
||
|
||
|
||
def _normalize_phone_digits(phone: str) -> str:
|
||
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
|
||
d = re.sub(r"\D", "", (phone or "").strip())
|
||
if d.startswith("86") and len(d) >= 13:
|
||
d = d[2:]
|
||
return d
|
||
|
||
|
||
def _is_valid_cn_mobile11(digits: str) -> bool:
|
||
"""中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。"""
|
||
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
|
||
|
||
|
||
def _platform_list_cn_for_help() -> str:
|
||
"""帮助文案:一行中文展示名(不重复内部键)。"""
|
||
parts = []
|
||
for k in sorted(PLATFORM_URLS.keys()):
|
||
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
|
||
return "、".join(parts)
|
||
|
||
|
||
def get_data_root():
|
||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||
if env:
|
||
return env
|
||
if sys.platform == "win32":
|
||
return r"D:\jiangchang-data"
|
||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||
|
||
|
||
def get_user_id():
|
||
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||
return uid or "_anon"
|
||
|
||
|
||
def get_skill_data_dir():
|
||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||
os.makedirs(path, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def get_skill_logs_dir() -> str:
|
||
"""{DATA_ROOT}/{USER_ID}/account-manager/logs/"""
|
||
path = os.path.join(get_skill_data_dir(), "logs")
|
||
os.makedirs(path, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def get_skill_log_file_path() -> str:
|
||
"""主日志文件绝对路径(与 get_skill_logger 写入文件一致)。"""
|
||
return _skill_log_file_path()
|
||
|
||
|
||
def _skill_log_file_path() -> str:
|
||
"""主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。"""
|
||
override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip()
|
||
if override:
|
||
parent = os.path.dirname(os.path.abspath(override))
|
||
if parent:
|
||
os.makedirs(parent, exist_ok=True)
|
||
return os.path.abspath(override)
|
||
return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log")
|
||
|
||
|
||
def _log_level_from_env() -> int:
|
||
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
|
||
return getattr(logging, v, None) or logging.INFO
|
||
|
||
|
||
def get_skill_logger() -> logging.Logger:
|
||
"""
|
||
技能级日志:单文件按日切分(TimedRotatingFileHandler midnight),UTF-8。
|
||
环境变量:JIANGCHANG_LOG_LEVEL(默认 INFO)、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、
|
||
JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。
|
||
"""
|
||
log = logging.getLogger(LOG_LOGGER_NAME)
|
||
if log.handlers:
|
||
return log
|
||
log.setLevel(_log_level_from_env())
|
||
path = _skill_log_file_path()
|
||
fh = TimedRotatingFileHandler(
|
||
path,
|
||
when="midnight",
|
||
interval=1,
|
||
backupCount=30,
|
||
encoding="utf-8",
|
||
delay=True,
|
||
)
|
||
fh.setFormatter(
|
||
logging.Formatter(
|
||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||
)
|
||
)
|
||
log.addHandler(fh)
|
||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
):
|
||
sh = logging.StreamHandler(sys.stderr)
|
||
sh.setLevel(logging.WARNING)
|
||
sh.setFormatter(fh.formatter)
|
||
log.addHandler(sh)
|
||
log.propagate = False
|
||
return log
|
||
|
||
|
||
def get_db_path():
|
||
return os.path.join(get_skill_data_dir(), "account-manager.db")
|
||
|
||
|
||
def _runtime_paths_debug_text():
|
||
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
|
||
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
|
||
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
|
||
return (
|
||
"[account-manager] "
|
||
f"JIANGCHANG_DATA_ROOT={env_root} | "
|
||
f"JIANGCHANG_USER_ID={env_uid} | "
|
||
f"实际数据根={get_data_root()} | "
|
||
f"实际用户目录={get_user_id()} | "
|
||
f"数据库文件={get_db_path()}"
|
||
)
|
||
|
||
|
||
def _maybe_print_paths_debug_cli():
|
||
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
|
||
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
|
||
if v in ("1", "true", "yes", "on"):
|
||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||
|
||
|
||
_WIN_FS_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||
_WIN_RESERVED_NAMES = frozenset(
|
||
["CON", "PRN", "AUX", "NUL"]
|
||
+ [f"COM{i}" for i in range(1, 10)]
|
||
+ [f"LPT{i}" for i in range(1, 10)]
|
||
)
|
||
|
||
|
||
def _fs_safe_segment(name: str, fallback: str) -> str:
|
||
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。"""
|
||
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
|
||
t = t.rstrip(" .")
|
||
if not t:
|
||
t = fallback
|
||
if t.upper() in _WIN_RESERVED_NAMES:
|
||
t = f"_{t}"
|
||
return t
|
||
|
||
|
||
def get_default_profile_dir(
|
||
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
|
||
) -> str:
|
||
"""
|
||
默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
|
||
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
|
||
"""
|
||
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
|
||
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
|
||
ph = (phone or "").strip()
|
||
if ph:
|
||
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
|
||
else:
|
||
phone_seg = _fs_safe_segment(
|
||
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
|
||
)
|
||
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
|
||
os.makedirs(path, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def get_conn():
|
||
return sqlite3.connect(get_db_path())
|
||
|
||
|
||
def init_db():
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
||
if not cur.fetchone():
|
||
cur.executescript(ACCOUNTS_TABLE_SQL)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _normalize_account_id(account_id):
|
||
if account_id is None:
|
||
return None
|
||
s = str(account_id).strip()
|
||
if s.isdigit():
|
||
return int(s)
|
||
return s
|
||
|
||
|
||
def get_account_by_id(account_id):
|
||
init_db()
|
||
aid = _normalize_account_id(account_id)
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json,
|
||
created_at, updated_at
|
||
FROM accounts WHERE id = ?
|
||
""",
|
||
(aid,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return None
|
||
acc = {
|
||
"id": row[0],
|
||
"name": row[1],
|
||
"platform": row[2],
|
||
"phone": row[3] or "",
|
||
"profile_dir": (row[4] or "").strip(),
|
||
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
|
||
"login_status": int(row[6] or 0),
|
||
"last_login_at": _unix_to_iso_local(row[7]),
|
||
"created_at": _unix_to_iso_local(row[9]),
|
||
"updated_at": _unix_to_iso_local(row[10]),
|
||
}
|
||
if row[8]:
|
||
try:
|
||
extra = json.loads(row[8])
|
||
if isinstance(extra, dict):
|
||
acc.update(extra)
|
||
except Exception:
|
||
pass
|
||
return acc
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _default_name_for_platform(platform: str, index: int) -> str:
|
||
cn = _PLATFORM_NAME_CN.get(platform)
|
||
if cn:
|
||
return f"{cn}{index}号"
|
||
return f"{platform}_{index}"
|
||
|
||
|
||
# list 表头:与 accounts 表列顺序、列名一致(便于对照库结构)
|
||
_LIST_TABLE_COLUMNS = (
|
||
"id",
|
||
"name",
|
||
"platform",
|
||
"phone",
|
||
"profile_dir",
|
||
"url",
|
||
"login_status",
|
||
"last_login_at",
|
||
"extra_json",
|
||
"created_at",
|
||
"updated_at",
|
||
)
|
||
|
||
|
||
def _list_row_to_cells(row: tuple) -> list:
|
||
"""将 SELECT 整行转为展示用字符串列表(与 _LIST_TABLE_COLUMNS 顺序一致)。"""
|
||
rid, name, plat, phone, pdir, url, lstat, lla, exj, cat, uat = row
|
||
return [
|
||
str(rid) if rid is not None else "",
|
||
name if name is not None else "",
|
||
plat if plat is not None else "",
|
||
phone if phone is not None else "",
|
||
pdir if pdir is not None else "",
|
||
url if url is not None else "",
|
||
str(int(lstat)) if lstat is not None else "",
|
||
str(int(lla)) if lla is not None else "",
|
||
(exj or "").replace("\n", "\\n").replace("\r", ""),
|
||
str(int(cat)) if cat is not None else "",
|
||
str(int(uat)) if uat is not None else "",
|
||
]
|
||
|
||
|
||
def _print_accounts_table(rows: list) -> None:
|
||
"""表头 + 分隔线 + 每账号一行;列宽按内容对齐(终端等宽字体)。"""
|
||
headers = list(_LIST_TABLE_COLUMNS)
|
||
body = [_list_row_to_cells(r) for r in rows]
|
||
n = len(headers)
|
||
widths = [len(headers[j]) for j in range(n)]
|
||
for cells in body:
|
||
for j in range(n):
|
||
widths[j] = max(widths[j], len(cells[j]))
|
||
sep = " | "
|
||
|
||
def fmt(cells):
|
||
return sep.join(cells[j].ljust(widths[j]) for j in range(n))
|
||
|
||
print(fmt(headers))
|
||
print(sep.join("-" * widths[j] for j in range(n)))
|
||
for cells in body:
|
||
print(fmt(cells))
|
||
|
||
|
||
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
|
||
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
sql = (
|
||
"SELECT id, name, platform, phone, profile_dir, url, login_status, "
|
||
"last_login_at, extra_json, created_at, updated_at FROM accounts "
|
||
)
|
||
if key == "all":
|
||
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
|
||
else:
|
||
cur.execute(
|
||
sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||
(key, int(limit)),
|
||
)
|
||
rows = cur.fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
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_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()
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
SELECT id FROM accounts
|
||
WHERE platform = ? AND login_status = 1
|
||
ORDER BY (last_login_at IS NULL), last_login_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(key,),
|
||
)
|
||
row = cur.fetchone()
|
||
finally:
|
||
conn.close()
|
||
|
||
if not row:
|
||
print("ERROR:NO_LOGGED_IN_ACCOUNT 该平台暂无已登录账号(login_status=1)。")
|
||
print("请先 list 查看账号 id,再执行:python main.py login <id>")
|
||
return
|
||
|
||
acc = get_account_by_id(row[0])
|
||
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 = _now_unix()
|
||
phone_store = phone_norm
|
||
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"SELECT id FROM accounts WHERE platform = ? AND phone = ?",
|
||
(key, phone_store),
|
||
)
|
||
if cur.fetchone():
|
||
log.warning("add_duplicate platform=%s phone_suffix=%s", key, phone_store[-4:])
|
||
print(
|
||
f"ERROR:DUPLICATE_PHONE_PLATFORM 该平台下已存在手机号 {phone_store},请勿重复添加。"
|
||
)
|
||
return
|
||
|
||
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (key,))
|
||
next_idx = cur.fetchone()[0] + 1
|
||
name = _default_name_for_platform(key, next_idx)
|
||
profile_dir = get_default_profile_dir(key, phone_store, None)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)
|
||
""",
|
||
(name, key, phone_store, profile_dir, url, now, now),
|
||
)
|
||
new_id = cur.lastrowid
|
||
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 _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_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:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"SELECT id, name, platform, phone, profile_dir FROM accounts WHERE id = ?",
|
||
(int(aid),),
|
||
)
|
||
row = cur.fetchone()
|
||
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
|
||
cur.execute("DELETE FROM accounts WHERE id = ?", (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:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"SELECT id, profile_dir FROM accounts WHERE platform = ?",
|
||
(key,),
|
||
)
|
||
rows = cur.fetchall()
|
||
if not rows:
|
||
log.warning("delete_by_platform_empty platform=%s", key)
|
||
print("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。")
|
||
return
|
||
cur.execute("DELETE FROM accounts WHERE platform = ?", (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:
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?",
|
||
(key, phone_norm),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
print("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。")
|
||
return
|
||
rid, name, profile_dir = row
|
||
cur.execute(
|
||
"DELETE FROM accounts WHERE platform = ? AND phone = ?",
|
||
(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 _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} 秒。")
|
||
|
||
# 子进程:login_detect_bundle + 多 tab DOM;日志与主进程同一文件(追加)。
|
||
log_file = _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,
|
||
)
|
||
runner = r"""import json, logging, sys, 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, ""
|
||
|
||
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
|
||
"""
|
||
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
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||
) as pf:
|
||
pf.write(runner)
|
||
py_path = pf.name
|
||
proc_rc = None
|
||
try:
|
||
r = subprocess.run(
|
||
[sys.executable, py_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
|
||
try:
|
||
os.unlink(py_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>")
|
||
|
||
runner_open = r"""import json, sys
|
||
from playwright.sync_api import sync_playwright
|
||
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
|
||
"""
|
||
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
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w", suffix=".py", delete=False, encoding="utf-8"
|
||
) as pf:
|
||
pf.write(runner_open)
|
||
py_path = pf.name
|
||
try:
|
||
subprocess.run([sys.executable, py_path, cfg_path])
|
||
finally:
|
||
try:
|
||
os.unlink(cfg_path)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.unlink(py_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _mark_login_status(account_id, success: bool):
|
||
now = _now_unix()
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
if success:
|
||
cur.execute(
|
||
"UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?",
|
||
(now, now, account_id),
|
||
)
|
||
else:
|
||
cur.execute(
|
||
"UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?",
|
||
(now, account_id),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _cli_print_full_usage() -> None:
|
||
"""总览(未带子命令或需要完整说明时)。"""
|
||
print("用法概览(将 main.py 换为你的路径):")
|
||
print(" python main.py add <平台中文名> <手机号>")
|
||
print(" python main.py list [平台|all|全部]")
|
||
print(" python main.py pick-logged-in <平台> # 跨技能用:输出一行 JSON 或 ERROR")
|
||
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 <平台> <手机号>")
|
||
print(" python main.py <平台> # 等价于只列该平台")
|
||
print(" python main.py <平台> list # 同上")
|
||
print()
|
||
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):")
|
||
print(" " + _platform_list_cn_for_help())
|
||
|
||
|
||
def _cli_fail_add() -> None:
|
||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||
print()
|
||
print("【add 添加账号】参数不足。")
|
||
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
|
||
print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复")
|
||
print()
|
||
print("示例:")
|
||
print(" python main.py add 搜狐号 13800138000")
|
||
print(" python main.py add 知乎 13900001111")
|
||
print()
|
||
print("支持的平台:" + _platform_list_cn_for_help())
|
||
|
||
|
||
def _cli_fail_need_account_id(subcmd: str) -> None:
|
||
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
|
||
print()
|
||
print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。")
|
||
print("示例:")
|
||
print(f" python main.py {subcmd} 1")
|
||
|
||
|
||
def _cli_fail_delete() -> None:
|
||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||
print()
|
||
print("【delete 删除账号】参数不足。支持三种方式:")
|
||
print(" 按 id:python main.py delete id <id>")
|
||
print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>")
|
||
print(" 按平台+手机号:python main.py delete platform <平台> <手机号>")
|
||
print()
|
||
print("示例:")
|
||
print(" python main.py delete id 3")
|
||
print(" python main.py delete platform 搜狐号")
|
||
print(" python main.py delete platform 头条号 18925203701")
|
||
print()
|
||
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if len(sys.argv) < 2:
|
||
_cli_print_full_usage()
|
||
sys.exit(1)
|
||
|
||
_apply_cli_local_dev_env()
|
||
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
||
cmd = sys.argv[1]
|
||
_maybe_print_paths_debug_cli()
|
||
|
||
plat_from_first = resolve_platform_key(cmd)
|
||
|
||
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
|
||
cmd_list(plat_from_first)
|
||
elif plat_from_first and len(sys.argv) == 2:
|
||
cmd_list(plat_from_first)
|
||
elif cmd == "add":
|
||
if len(sys.argv) < 4:
|
||
_cli_fail_add()
|
||
sys.exit(1)
|
||
cmd_add(sys.argv[2], sys.argv[3])
|
||
elif cmd == "list":
|
||
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
|
||
elif cmd == "get":
|
||
if len(sys.argv) < 3:
|
||
_cli_fail_need_account_id("get")
|
||
sys.exit(1)
|
||
cmd_get(sys.argv[2])
|
||
elif cmd == "pick-logged-in":
|
||
if len(sys.argv) < 3:
|
||
print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS")
|
||
print("用法:python main.py pick-logged-in <平台中文名或英文键>")
|
||
print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。")
|
||
sys.exit(1)
|
||
cmd_pick_logged_in(sys.argv[2])
|
||
elif cmd == "open":
|
||
if len(sys.argv) < 3:
|
||
_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()
|
||
sys.exit(1)
|
||
mode = (sys.argv[2] or "").strip().lower()
|
||
if mode == "id":
|
||
cmd_delete_by_id(sys.argv[3])
|
||
elif mode == "platform":
|
||
if len(sys.argv) >= 5:
|
||
cmd_delete_by_platform_phone(sys.argv[3], sys.argv[4])
|
||
else:
|
||
cmd_delete_by_platform(sys.argv[3])
|
||
else:
|
||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。")
|
||
_cli_fail_delete()
|
||
sys.exit(1)
|
||
else:
|
||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。")
|
||
print()
|
||
_cli_print_full_usage()
|
||
print()
|
||
print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
|
||
sys.exit(1)
|