diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/scripts/__pycache__/account.cpython-312.pyc b/scripts/__pycache__/account.cpython-312.pyc deleted file mode 100644 index 5225768..0000000 Binary files a/scripts/__pycache__/account.cpython-312.pyc and /dev/null differ diff --git a/scripts/__pycache__/main.cpython-312.pyc b/scripts/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 7489a1d..0000000 Binary files a/scripts/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/scripts/account.py b/scripts/account.py new file mode 100644 index 0000000..d4d9282 --- /dev/null +++ b/scripts/account.py @@ -0,0 +1,721 @@ +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)