chore: auto release commit (2026-03-31 17:00:42)
All checks were successful
技能自动化发布 / release (push) Successful in 15s

This commit is contained in:
2026-03-31 17:00:42 +08:00
parent 469b9c8bc7
commit ae1da709ef
3 changed files with 366 additions and 121 deletions

View File

@@ -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 <platform>

View File

@@ -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:需要 playwrightpip 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}",
# 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="chrome", # 【修改处】也加上这行
no_viewport=True, # 确保浏览器内容视口也最大化
args=["--start-maximized"]
channel=c["channel"],
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())
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()
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 <id> <name> <platform> [phone] [profile_dir] | list [platform] | get <id> | login <id>")
print(
"用法python account.py add <platform> [phone] | list [platform] | get <id> | login <id>"
)
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])