import sys import json import os import re import sqlite3 from urllib.parse import urlparse 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" # 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, -- 平台标识,与内置 PLATFORM_URLS 键一致,如 sohu phone TEXT, -- 可选绑定手机号 profile_dir TEXT, -- Playwright 持久化用户数据目录(绝对路径) 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_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 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 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": _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}" 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 = _now_unix() 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_unix(), 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 _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 _definitely_logged_out_url(page_url: str) -> bool: """ 通用:是否「明显仍在登录/认证页」。 用路径段判断,避免 mpfe/v4/login 因含 mpfe 被误判为已登录。 """ if not page_url or not page_url.strip(): return True try: p = urlparse(page_url.strip()) except Exception: return True host = (p.netloc or "").lower() path = (p.path or "").lower() query = (p.query or "").lower() if "passport" in host or "login." in host or "signin." in host: return True seg = [s for s in path.split("/") if s] login_segments = ( "login", "signin", "signup", "register", "authorize", "authentication", ) if any(s in login_segments for s in seg): return True if re.search(r"/(login|signin)(/|$|\?)", path): return True if "redirect_uri" in query and ("login" in query or "passport" in query): return True return False def _sohu_url_logged_in(page_url: str) -> bool: """ 搜狐:仅白名单路径视为已进入后台(不用裸 mpfe,避免 /mpfe/v4/login 误判)。 """ if _definitely_logged_out_url(page_url): return False try: path = (urlparse(page_url).path or "").lower() except Exception: return False if "contentmanagement" in path: return True if "/mpfe/v4/main" in path or path.rstrip("/").endswith("/main"): return True if "/mpfe/v4/publish" in path or "/mpfe/v4/data" in path: return True return False def _sohu_page_shows_login_form(page) -> bool: """搜狐登录页常见密码框;可见则认为未登录(与 URL 交叉验证)。""" try: loc = page.locator('input[type="password"]') n = loc.count() if n == 0: return False return loc.first.is_visible(timeout=800) except Exception: return False def _url_looks_logged_in(platform: str, page_url: str) -> bool: """是否判定为已登录:搜狐用白名单+负例;其它平台仅用「非明确登录页」宽松策略。""" if not page_url or not page_url.strip(): return False if platform == "sohu": return _sohu_url_logged_in(page_url) if _definitely_logged_out_url(page_url): return False u = page_url.lower() if "passport" in u or "signin" in u: return False return True def _verify_logged_in_page(platform: str, page) -> bool: """结合 URL 与页面(搜狐加验登录表单)。""" url = page.url or "" if platform == "sohu": if _sohu_page_shows_login_form(page): return False return _sohu_url_logged_in(url) return _url_looks_logged_in(platform, url) 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) for _ in range(20): if _verify_logged_in_page(platform, page): return True time.sleep(1.0) return _verify_logged_in_page(platform, page) 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() poll_sec = _login_poll_interval_seconds() browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" print(f"正在为账号 [{target['name']}] 打开 {browser_name} …") print(f"访问地址:{url}") print( "请在窗口内完成登录。系统会轮询地址栏,进入后台后将自动关闭窗口并写入状态(无需手动关浏览器)。" ) print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。") # 子进程:搜狐轮询 URL+页面;勿用「含 mpfe 即登录」,/mpfe/v4/login 会误判。 runner = r"""import json, sys, time from urllib.parse import urlparse from playwright.sync_api import sync_playwright def sohu_logged_in(url): u = (url or "").strip() if not u: return False try: p = urlparse(u) except Exception: return False path = (p.path or "").lower() host = (p.netloc or "").lower() if "passport" in host: return False segs = [s for s in path.split("/") if s] if any(s in ("login", "signin", "signup", "register", "authorize") for s in segs): return False if "contentmanagement" in path: return True if "/mpfe/v4/main" in path or path.rstrip("/").endswith("/main"): return True if "/mpfe/v4/publish" in path or "/mpfe/v4/data" in path: return True return False def sohu_not_login_page(page): try: loc = page.locator('input[type="password"]') if loc.count() == 0: return False return loc.first.is_visible(timeout=600) except Exception: return False with open(sys.argv[1], encoding="utf-8") as f: c = json.load(f) platform = (c.get("platform") or "").lower() poll = float(c.get("poll_interval", 1.5)) deadline = time.time() + float(c["timeout_sec"]) 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 platform == "sohu": while time.time() < deadline: try: if sohu_logged_in(page.url) and not sohu_not_login_page(page): break except Exception: break time.sleep(poll) else: rem = max(0.0, deadline - time.time()) try: ctx.wait_for_event("close", timeout=rem * 1000) except Exception: pass 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, "timeout_sec": timeout_sec, "platform": platform, "poll_interval": poll_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 cmd_open(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" ) browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge" print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)") print(f"地址:{url}") print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。") print("需要自动检测并写入数据库时,请执行:python account.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() if __name__ == "__main__": if len(sys.argv) < 2: print( "用法:python account.py add [phone] | list [platform] | get | open | 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 == "open" and len(sys.argv) >= 3: cmd_open(sys.argv[2]) elif cmd == "login" and len(sys.argv) >= 3: cmd_login(sys.argv[2]) else: print("参数错误") sys.exit(1)