diff --git a/SKILL.md b/SKILL.md index 9e5ad1b..a3225d7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号管理 description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。 -version: 1.0.1 +version: 1.0.2 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -23,6 +23,12 @@ allowed-tools: ## 执行步骤 +### 添加账号(仅需平台,可选手机号;ID 数据库自增,名称自动生成如「搜狐1号」) +```bash +python3 {baseDir}/scripts/account.py add sohu +python3 {baseDir}/scripts/account.py add sohu 189xxxxxxxx +``` + ### 列出某平台所有账号 ```bash python3 {baseDir}/scripts/account.py list \ No newline at end of file diff --git a/scripts/__pycache__/account.cpython-312.pyc b/scripts/__pycache__/account.cpython-312.pyc index 741a4cd..dbd6524 100644 Binary files a/scripts/__pycache__/account.cpython-312.pyc and b/scripts/__pycache__/account.cpython-312.pyc differ diff --git a/scripts/account.py b/scripts/account.py index d25318f..0e36a16 100644 --- a/scripts/account.py +++ b/scripts/account.py @@ -2,29 +2,42 @@ import sys import json import os import sqlite3 +import subprocess +import tempfile +import time from datetime import datetime # 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') + 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" PLATFORM_URLS = { - # 自媒体/图文平台 "sohu": "https://mp.sohu.com", "zhihu": "https://www.zhihu.com", "wechat": "https://mp.weixin.qq.com", - - # 大模型平台 "kimi": "https://kimi.moonshot.cn", "deepseek": "https://chat.deepseek.com", "doubao": "https://www.doubao.com", "qianwen": "https://tongyi.aliyun.com", "yiyan": "https://yiyan.baidu.com", - "yuanbao": "https://yuanbao.tencent.com" + "yuanbao": "https://yuanbao.tencent.com", +} + +# 新账号默认名称:搜狐1号 / sohu_2 等同平台序号 +_PLATFORM_NAME_CN = { + "sohu": "搜狐", + "zhihu": "知乎", + "wechat": "微信", + "kimi": "Kimi", + "deepseek": "DeepSeek", + "doubao": "豆包", + "qianwen": "通义", + "yiyan": "文心", + "yuanbao": "元宝", } @@ -53,7 +66,7 @@ def get_db_path(): def get_default_profile_dir(account_id): - path = os.path.join(get_skill_data_dir(), "profiles", account_id) + path = os.path.join(get_skill_data_dir(), "profiles", str(account_id)) os.makedirs(path, exist_ok=True) return path @@ -62,33 +75,6 @@ def get_conn(): return sqlite3.connect(get_db_path()) -def init_db(): - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - """ - CREATE TABLE IF NOT EXISTS accounts ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - platform TEXT NOT NULL, - phone TEXT, - profile_dir TEXT, - login_status INTEGER DEFAULT 0, - last_login_at TEXT, - extra_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - _ensure_column(cur, "accounts", "login_status", "INTEGER DEFAULT 0") - _ensure_column(cur, "accounts", "last_login_at", "TEXT") - conn.commit() - finally: - conn.close() - - def _ensure_column(cur, table_name, col_name, ddl): cur.execute(f"PRAGMA table_info({table_name})") cols = [row[1] for row in cur.fetchall()] @@ -96,14 +82,123 @@ def _ensure_column(cur, table_name, col_name, ddl): cur.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {ddl}") +def _has_text_primary_id(cur): + cur.execute("PRAGMA table_info(accounts)") + for row in cur.fetchall(): + if row[1] == "id": + t = (row[2] or "").upper() + return "TEXT" in t or "CHAR" in t + return False + + +def _migrate_text_id_to_integer(conn): + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE accounts_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + platform TEXT NOT NULL, + phone TEXT, + profile_dir TEXT, + url TEXT, + login_status INTEGER DEFAULT 0, + last_login_at TEXT, + extra_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + cur.execute( + "SELECT name, platform, phone, profile_dir, login_status, last_login_at, extra_json, created_at, updated_at FROM accounts ORDER BY rowid" + ) + rows = cur.fetchall() + for row in rows: + name, platform, phone, profile_dir, login_status, last_login_at, extra_json, created_at, updated_at = row + plat = (platform or "").strip().lower() + url = PLATFORM_URLS.get(plat, "") + cur.execute( + """ + INSERT INTO accounts_new + (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + name, + plat, + phone or "", + profile_dir or "", + url, + int(login_status or 0), + last_login_at, + extra_json, + created_at, + updated_at, + ), + ) + cur.execute("DROP TABLE accounts") + cur.execute("ALTER TABLE accounts_new RENAME TO accounts") + + +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.execute( + """ + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + platform TEXT NOT NULL, + phone TEXT, + profile_dir TEXT, + url TEXT, + login_status INTEGER DEFAULT 0, + last_login_at TEXT, + extra_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + else: + if _has_text_primary_id(cur): + _migrate_text_id_to_integer(conn) + cur = conn.cursor() + _ensure_column(cur, "accounts", "url", "TEXT") + cur.execute( + "SELECT id, platform FROM accounts WHERE url IS NULL OR TRIM(COALESCE(url,'')) = ''" + ) + for rid, plat in cur.fetchall(): + pu = PLATFORM_URLS.get((plat or "").strip().lower(), "") + if pu: + cur.execute("UPDATE accounts SET url = ? WHERE id = ?", (pu, rid)) + 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, login_status, last_login_at, extra_json FROM accounts WHERE id = ?", - (account_id,), + "SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json FROM accounts WHERE id = ?", + (aid,), ) row = cur.fetchone() if not row: @@ -114,12 +209,13 @@ def get_account_by_id(account_id): "platform": row[2], "phone": row[3] or "", "profile_dir": row[4] or get_default_profile_dir(row[0]), - "login_status": int(row[5] or 0), - "last_login_at": row[6], + "url": row[5] or PLATFORM_URLS.get(row[2], ""), + "login_status": int(row[6] or 0), + "last_login_at": row[7], } - if row[7]: + if row[8]: try: - extra = json.loads(row[7]) + extra = json.loads(row[8]) if isinstance(extra, dict): acc.update(extra) except Exception: @@ -128,14 +224,17 @@ def get_account_by_id(account_id): 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}" + + def cmd_list(platform="all"): - """ - 修改后的 list 功能,能直观打出带着【手机号】的详细台账供AI查询匹配! - 支持查全部(all),或指定(sohu) - """ init_db() conn = get_conn() - found = False try: cur = conn.cursor() if platform == "all": @@ -159,7 +258,8 @@ def cmd_list(platform="all"): found = True if not found: - print(f"ERROR:NO_ACCOUNTS_FOUND") + print("ERROR:NO_ACCOUNTS_FOUND") + def cmd_get(account_id): acc = get_account_by_id(account_id) @@ -169,133 +269,272 @@ def cmd_get(account_id): print("ERROR:ACCOUNT_NOT_FOUND") -def cmd_add(account_id, name, platform, phone="", profile_dir=""): +def cmd_add(platform: str, phone: str = ""): + """添加账号:仅平台 + 可选手机号;ID 自增,名称按平台序号自动生成。""" init_db() platform = (platform or "").strip().lower() if platform not in PLATFORM_URLS: print(f"ERROR:INVALID_PLATFORM (支持: {', '.join(sorted(PLATFORM_URLS.keys()))})") return - account_id = (account_id or "").strip() - name = (name or "").strip() or account_id - if not account_id: - print("ERROR:INVALID_ACCOUNT_ID") - return - - profile_dir = (profile_dir or "").strip() or get_default_profile_dir(account_id) - os.makedirs(profile_dir, exist_ok=True) + phone = (phone or "").strip() + url = PLATFORM_URLS[platform] now = datetime.now().isoformat(timespec="seconds") conn = get_conn() try: cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform,)) + next_idx = cur.fetchone()[0] + 1 + name = _default_name_for_platform(platform, next_idx) cur.execute( """ - INSERT INTO accounts (id, name, platform, phone, profile_dir, login_status, last_login_at, extra_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - platform = excluded.platform, - phone = excluded.phone, - profile_dir = excluded.profile_dir, - updated_at = excluded.updated_at + INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at) + VALUES (?, ?, ?, '', ?, 0, NULL, NULL, ?, ?) """, - (account_id, name, platform, phone, profile_dir, now, now), + (name, platform, phone, url, now, now), + ) + new_id = cur.lastrowid + profile_dir = get_default_profile_dir(new_id) + cur.execute( + "UPDATE accounts SET profile_dir = ?, updated_at = ? WHERE id = ?", + (profile_dir, now, new_id), ) conn.commit() finally: conn.close() - print(f"✅ 已保存账号:{account_id}({platform})") - print("ℹ️ 请继续执行登录:python account.py login <账号ID>") + print(f"✅ 已保存账号:ID {new_id} | {name} | {platform}") + print(f"ℹ️ 登录请执行:python account.py login {new_id}") + + +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 _verify_login(profile_dir: str, platform: str, start_url: str, channel: str) -> bool: + try: + from playwright.sync_api import sync_playwright + except ImportError: + print("⚠️ 未安装 playwright,跳过登录校验(请: pip install playwright && playwright install)") + return False + + try: + with sync_playwright() as p: + ctx = p.chromium.launch_persistent_context( + user_data_dir=profile_dir, + headless=True, + channel=channel, + args=["--disable-blink-features=AutomationControlled"], + ) + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(start_url, wait_until="domcontentloaded", timeout=45000) + time.sleep(2) + u = (page.url or "").lower() + if platform == "sohu": + if "passport" in u or "/login" in u: + return False + return True + if "login" in u or "signin" in u or "passport" in u: + return False + return True + finally: + ctx.close() + except Exception: + return False def cmd_login(account_id): - """首次登录:打开浏览器,用户手动登录,登录态自动保存到Profile目录""" target = get_account_by_id(account_id) - if not target: 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["profile_dir"] os.makedirs(profile_dir, exist_ok=True) - url = PLATFORM_URLS.get(target["platform"], "https://www.google.com") + 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() - print(f"正在为账号 [{target['name']}] 打开浏览器...") - print(f"请在浏览器中完成登录,登录后直接关闭浏览器窗口即可。") - print(f"登录态将自动保存到:{profile_dir}") + browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" + print(f"正在为账号 [{target['name']}] 打开 {browser_name} …") + print(f"访问地址:{url}") + print("请在窗口内完成登录(扫码/输入密码)。到达等待时间后将自动关闭窗口,您也可提前手动关闭。") + print(f"最长等待:{timeout_sec} 秒") - # 用Playwright打开持久化浏览器,等待用户手动登录 - login_script = f""" -import asyncio -from playwright.async_api import async_playwright -async def main(): - async with async_playwright() as p: - browser = await p.chromium.launch_persistent_context( - user_data_dir=r"{profile_dir}", - headless=False, - channel="chrome", # 【修改处】也加上这行 - no_viewport=True, # 确保浏览器内容视口也最大化 - args=["--start-maximized"] - ) - page = browser.pages[0] if browser.pages else await browser.new_page() - await page.goto("{url}") - print("浏览器已打开,请完成登录后关闭窗口...") - # 移除自动超时,无限等待直到用户扫码完手动关闭 - await browser.wait_for_event("close", timeout=0) - print("登录态已保存!") -asyncio.run(main()) + # Playwright 同步 API 宜在独立子进程中跑浏览器,避免线程与事件循环问题 + runner = 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) + try: + ctx.wait_for_event("close", timeout=c["timeout_sec"] * 1000) + except Exception: + pass + try: + ctx.close() + except Exception: + pass + except Exception as e: + print(e, file=sys.stderr) + raise """ - import tempfile - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', - delete=False, encoding='utf-8') as f: - f.write(login_script) - tmp_path = f.name + cfg = { + "channel": channel, + "profile_dir": profile_dir, + "url": url, + "timeout_sec": timeout_sec, + } + 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 + try: + r = subprocess.run( + [sys.executable, py_path, cfg_path], + timeout=timeout_sec + 180, + ) + if r.returncode != 0: + print("⚠️ 浏览器进程异常退出,仍将尝试检测登录状态") + except subprocess.TimeoutExpired: + print("⚠️ 等待浏览器超时,仍将尝试检测登录状态") + finally: + try: + os.unlink(cfg_path) + except OSError: + pass + try: + os.unlink(py_path) + except OSError: + pass - exit_code = os.system(f'python "{tmp_path}"') - os.unlink(tmp_path) - if exit_code == 0: - _mark_login_success(account_id) - print("✅ 登录状态已标记为已登录") + time.sleep(1.5) + print("正在检测登录状态…") + ok = _verify_login(profile_dir, platform, url, channel) + _mark_login_status(target["id"], ok) + if ok: + print("✅ 检测到已登录,状态已写入数据库") else: - print("⚠️ 浏览器登录流程异常退出,登录状态未更新") + print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") -def _mark_login_success(account_id): +def _mark_login_status(account_id, success: bool): now = datetime.now().isoformat(timespec="seconds") conn = get_conn() try: cur = conn.cursor() - cur.execute( - "UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?", - (now, now, account_id), - ) + 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() + if __name__ == "__main__": if len(sys.argv) < 2: - print("用法:python account.py add [phone] [profile_dir] | list [platform] | get | login ") + print( + "用法:python account.py add [phone] | list [platform] | get | login " + ) sys.exit(1) cmd = sys.argv[1] - # 兼容写反顺序:python account.py sohu list if len(sys.argv) >= 3 and sys.argv[2] == "list" and cmd in PLATFORM_URLS: cmd_list(cmd) elif cmd in PLATFORM_URLS and len(sys.argv) == 2: cmd_list(cmd) - elif cmd == "add" and len(sys.argv) >= 5: - cmd_add( - sys.argv[2], - sys.argv[3], - sys.argv[4], - sys.argv[5] if len(sys.argv) >= 6 else "", - sys.argv[6] if len(sys.argv) >= 7 else "", - ) + elif cmd == "add" and len(sys.argv) >= 3: + plat = sys.argv[2].lower() + phone_arg = sys.argv[3] if len(sys.argv) >= 4 else "" + cmd_add(plat, phone_arg) elif cmd == "list": - # 默认不传 platform 时打出 all cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all") elif cmd == "get" and len(sys.argv) >= 3: cmd_get(sys.argv[2]) @@ -303,4 +542,4 @@ if __name__ == "__main__": cmd_login(sys.argv[2]) else: print("参数错误") - sys.exit(1) \ No newline at end of file + sys.exit(1)