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") 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", } # 新账号默认名称:搜狐1号 / sohu_2 等同平台序号 _PLATFORM_NAME_CN = { "sohu": "搜狐", "zhihu": "知乎", "wechat": "微信", "kimi": "Kimi", "deepseek": "DeepSeek", "doubao": "豆包", "qianwen": "通义", "yiyan": "文心", "yuanbao": "元宝", } 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_db_path(): return os.path.join(get_skill_data_dir(), "account-manager.db") def get_default_profile_dir(account_id): path = os.path.join(get_skill_data_dir(), "profiles", str(account_id)) os.makedirs(path, exist_ok=True) return path def get_conn(): return sqlite3.connect(get_db_path()) 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()] if col_name not in cols: 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, url, login_status, last_login_at, extra_json 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 get_default_profile_dir(row[0]), "url": row[5] or PLATFORM_URLS.get(row[2], ""), "login_status": int(row[6] or 0), "last_login_at": row[7], } 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}" def cmd_list(platform="all"): init_db() conn = get_conn() try: cur = conn.cursor() if platform == "all": cur.execute( "SELECT id, name, phone, platform, login_status FROM accounts ORDER BY platform, id" ) else: cur.execute( "SELECT id, name, phone, platform, login_status FROM accounts WHERE platform = ? ORDER BY id", (platform,), ) rows = cur.fetchall() finally: conn.close() found = False for row in rows: phone = row[2] or "未绑定手机" login_text = "已登录" if int(row[4] or 0) == 1 else "未登录" print(f"账号ID:{row[0]} | 名称:{row[1]} | 手机号:{phone} | 平台:{row[3]} | 状态:{login_text}") found = True if not found: print("ERROR:NO_ACCOUNTS_FOUND") def cmd_get(account_id): acc = get_account_by_id(account_id) if acc: print(json.dumps(acc, ensure_ascii=False)) return print("ERROR:ACCOUNT_NOT_FOUND") 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 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 (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at) VALUES (?, ?, ?, '', ?, 0, NULL, NULL, ?, ?) """, (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"✅ 已保存账号: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): 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 = (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() 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 同步 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 """ 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 time.sleep(1.5) print("正在检测登录状态…") ok = _verify_login(profile_dir, platform, url, channel) _mark_login_status(target["id"], ok) if ok: print("✅ 检测到已登录,状态已写入数据库") else: print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。") def _mark_login_status(account_id, success: bool): now = datetime.now().isoformat(timespec="seconds") 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() if __name__ == "__main__": if len(sys.argv) < 2: print( "用法:python account.py add [phone] | list [platform] | get | login " ) sys.exit(1) cmd = sys.argv[1] 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) >= 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": 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]) elif cmd == "login" and len(sys.argv) >= 3: cmd_login(sys.argv[2]) else: print("参数错误") sys.exit(1)