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. 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_/。 """ 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 ") 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 ") 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 ") print(" python main.py open ") print(" python main.py login ") print(" python main.py delete 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 ") 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)