546 lines
17 KiB
Python
546 lines
17 KiB
Python
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 <platform> [phone] | list [platform] | get <id> | login <id>"
|
||
)
|
||
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)
|