Compare commits

...

4 Commits

Author SHA1 Message Date
52cef5a7c0 chore: sync from OpenClaw workspace (2026-04-05 09:17)
Some checks failed
技能自动化发布 / release (push) Failing after 11s
2026-04-05 09:17:20 +08:00
80e7de6183 chore: merge origin/main; resolve conflicts
Some checks failed
技能自动化发布 / release (push) Failing after 11s
2026-04-05 08:53:50 +08:00
27cfe1018e chore: initialize account-manager skill repository 2026-04-05 08:36:41 +08:00
ba1c21c0b6 chore: auto release commit (2026-03-31 18:37:04)
All checks were successful
技能自动化发布 / release (push) Successful in 20s
2026-03-31 18:37:04 +08:00
5 changed files with 1877 additions and 25 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.env

View File

@@ -1,7 +1,7 @@
---
name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.2
version: 1.0.4
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -23,12 +23,31 @@ allowed-tools:
## 执行步骤
### 添加账号(仅需平台,可选手机号ID 数据库自增,名称自动生成如「搜狐1号」
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号;同平台下同手机号不可重复ID 自增名称如「搜狐1号」
```bash
python3 {baseDir}/scripts/account.py add sohu
python3 {baseDir}/scripts/account.py add sohu 189xxxxxxxx
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
python3 {baseDir}/scripts/main.py add 知乎 13800138000
```
支持的平台称呼示例搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝亦支持英文键如 sohu、toutiao
### 仅打开浏览器核对是否已登录(不写数据库)
```bash
python3 {baseDir}/scripts/main.py open <id>
```
### 列出某平台所有账号
### 登录并自动检测、写入数据库
```bash
python3 {baseDir}/scripts/account.py list <platform>
python3 {baseDir}/scripts/main.py login <id>
```
### 删除账号(同时删库里的记录与 profile 用户数据目录)
```bash
python3 {baseDir}/scripts/main.py delete id <id>
python3 {baseDir}/scripts/main.py delete platform <平台>
python3 {baseDir}/scripts/main.py delete platform <平台> <手机号>
```
### 列出某平台所有账号platform 可为中文名、all 或 全部)
```bash
python3 {baseDir}/scripts/main.py list all
python3 {baseDir}/scripts/main.py list 搜狐号

View File

@@ -1,7 +1,9 @@
import sys
import json
import os
import re
import sqlite3
from urllib.parse import urlparse
import subprocess
import tempfile
import time
@@ -319,23 +321,95 @@ def _login_poll_interval_seconds():
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:
"""根据当前 URL 判断是否已进入后台(与交互窗口、无头校验共用逻辑)"""
u = (page_url or "").lower()
if not u:
"""是否判定为已登录:搜狐用白名单+负例;其它平台仅用「非明确登录页」宽松策略"""
if not page_url or not page_url.strip():
return False
if platform == "sohu":
if "passport" in u or "/login" in u:
return _sohu_url_logged_in(page_url)
if _definitely_logged_out_url(page_url):
return False
# 登录成功后常见:…/mpfe/v4/contentManagement/…
if "mpfe" in u or "contentmanagement" in u:
return True
return False
if "login" in u or "signin" in u or "passport" in u:
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
@@ -354,12 +428,11 @@ def _verify_login(profile_dir: str, platform: str, start_url: str, channel: str)
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 _url_looks_logged_in(platform, page.url):
if _verify_logged_in_page(platform, page):
return True
time.sleep(1.0)
return _url_looks_logged_in(platform, page.url)
return _verify_logged_in_page(platform, page)
finally:
ctx.close()
except Exception:
@@ -398,18 +471,41 @@ def cmd_login(account_id):
)
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
# 子进程:轮询 URL,已登录则立即关窗;勿仅用 wait_for_event(close),否则用户不关窗口会卡满整段超时
# 子进程:搜狐轮询 URL+页面;勿用「含 mpfe 即登录」,/mpfe/v4/login 会误判
runner = r"""import json, sys, time
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
def looks_sohu(u):
u = (u or "").lower()
def sohu_logged_in(url):
u = (url or "").strip()
if not u:
return False
if "passport" in u or "/login" in u:
try:
p = urlparse(u)
except Exception:
return False
if "mpfe" in u or "contentmanagement" in u:
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:
@@ -431,7 +527,7 @@ with sync_playwright() as p:
if platform == "sohu":
while time.time() < deadline:
try:
if looks_sohu(page.url):
if sohu_logged_in(page.url) and not sohu_not_login_page(page):
break
except Exception:
break
@@ -498,6 +594,84 @@ with sync_playwright() as p:
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:需要 playwrightpip 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 <id>")
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()
@@ -521,7 +695,7 @@ def _mark_login_status(account_id, success: bool):
if __name__ == "__main__":
if len(sys.argv) < 2:
print(
"用法python account.py add <platform> [phone] | list [platform] | get <id> | login <id>"
"用法python account.py add <platform> [phone] | list [platform] | get <id> | open <id> | login <id>"
)
sys.exit(1)
@@ -538,6 +712,8 @@ if __name__ == "__main__":
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:

1654
scripts/main.py Normal file

File diff suppressed because it is too large Load Diff