Compare commits

...

17 Commits

Author SHA1 Message Date
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
58dc81449d chore: auto release commit (2026-03-31 18:19:36)
All checks were successful
技能自动化发布 / release (push) Successful in 14s
2026-03-31 18:19:37 +08:00
ae1da709ef chore: auto release commit (2026-03-31 17:00:42)
All checks were successful
技能自动化发布 / release (push) Successful in 15s
2026-03-31 17:00:42 +08:00
469b9c8bc7 chore: auto release commit (2026-03-31 15:24:21)
All checks were successful
技能自动化发布 / release (push) Successful in 15s
2026-03-31 15:24:22 +08:00
9fcf0aaf78 chore: auto release commit (2026-03-31 14:18:51)
All checks were successful
技能自动化发布 / release (push) Successful in 50s
2026-03-31 14:18:51 +08:00
01d9c82f80 chore: auto release commit (2026-03-31 11:50:55)
All checks were successful
技能自动化发布 / release (push) Successful in 14s
2026-03-31 11:50:56 +08:00
66d3801e43 chore: auto release commit (2026-03-30 19:25:21)
All checks were successful
技能自动化发布 / release (push) Successful in 17s
2026-03-30 19:25:21 +08:00
973547b6fb chore: auto release commit (2026-03-30 19:10:46)
Some checks failed
技能自动化发布 / release (push) Failing after 0s
2026-03-30 19:10:46 +08:00
edaac8aa45 chore: auto release commit (2026-03-24 18:22:46)
All checks were successful
技能自动化发布 (测试版-无鉴权) / build-and-deploy (push) Successful in 9s
2026-03-24 18:22:46 +08:00
157f51c765 chore: auto release commit (2026-03-24 18:07:44)
All checks were successful
技能自动化发布 (测试版-无鉴权) / build-and-deploy (push) Successful in 9s
2026-03-24 18:07:44 +08:00
d54a69cba8 chore: auto release commit (2026-03-24 18:06:10) 2026-03-24 18:06:10 +08:00
3b6f15ecbb chore: auto release commit (2026-03-24 18:05:25) 2026-03-24 18:05:25 +08:00
cd131697d5 修改版本
All checks were successful
技能自动化发布 (测试版-无鉴权) / build-and-deploy (push) Successful in 14s
2026-03-24 16:42:34 +08:00
2e3ac2cfe7 feat: separate skill display name from slug
All checks were successful
技能自动化发布 (测试版-无鉴权) / build-and-deploy (push) Successful in 11s
Set SKILL name to Chinese display text and add openclaw.slug for stable machine identifier, so database records can keep readable names while preserving stable install path keys.

Made-with: Cursor
2026-03-24 16:24:09 +08:00
bea22d5e9e ci: enforce tag-based version and strict sync checks
Use Git tag as release version, validate SKILL.md version consistency, and fail workflow when API business code is not success.
2026-03-24 16:14:06 +08:00
8 changed files with 2382 additions and 237 deletions

View File

@@ -1,75 +1,11 @@
name: 技能自动化发布 (测试版-无鉴权) name: 技能自动化发布
on: on:
push: push:
tags: ["v*"] tags: ["v*"]
jobs: jobs:
build-and-deploy: release:
runs-on: ubuntu-latest uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
steps: with:
# 1. 下载源码 artifact_platform: windows
- uses: http://120.25.191.12:3000/admin/actions-checkout@v4 pyarmor_platform: windows.x86_64
# 2. 环境及工具准备
- name: Setup Tools
# 优化:使用国内清华源加速安装速度
run: pip install pyarmor requests python-frontmatter --break-system-packages -i https://pypi.tuna.tsinghua.edu.cn/simple
# 3. 【核心步骤】PyArmor 源代码混淆加密
- name: Encrypt Source Code
run: |
mkdir -p dist/package
pyarmor gen -O dist/package scripts/*.py
cp SKILL.md dist/package/
# 4. 提取元数据并物理打包
- name: Parse Metadata and Pack
id: build_task
run: |
python -c "
import frontmatter, os, json, shutil
post = frontmatter.load('SKILL.md')
metadata = post.metadata
slug = metadata['name']
version = metadata['version']
# 安全写入输出
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f'slug={slug}\n')
f.write(f'version={version}\n')
f.write(f'metadata={json.dumps(metadata)}\n')
# 制作最终的发布 ZIP 包
shutil.make_archive(slug, 'zip', 'dist/package')
"
# 5. 同步数据库记录
- name: Sync Database (V2.0)
env:
METADATA_JSON: ${{ steps.build_task.outputs.metadata }}
run: |
python -c "
import requests, json, os
metadata = json.loads(os.environ['METADATA_JSON'])
url = 'https://jc2009.com/api/skill/update'
res = requests.post(url, json=metadata)
print(f'DB Sync Result: {res.text}')
if res.status_code != 200: exit(1)
"
# 6. 上传物理包
- name: Upload Encrypted ZIP
env:
SLUG: ${{ steps.build_task.outputs.slug }}
VERSION: ${{ steps.build_task.outputs.version }}
run: |
python -c "
import requests, os
url = 'https://jc2009.com/api/upload'
slug = os.environ['SLUG']
version = os.environ['VERSION']
payload = {'plugin_name': slug, 'version': version, 'artifact_type': 'skill'}
with open(f'{slug}.zip', 'rb') as f:
res = requests.post(url, data=payload, files={'file': f})
print(f'Upload Result: {res.text}')
if res.status_code != 200: exit(1)
"

0
.gitignore vendored Normal file
View File

View File

@@ -1,20 +1,18 @@
--- ---
name: account-manager name: 账号管理
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。 description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系供publisher类Skill调用获取账号信息。
version: 1.0.1 version: 1.0.4
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
slug: account-manager
emoji: "👤" emoji: "👤"
category: "通用" category: "通用"
skill_type: 1
monthly_price: 0
yearly_price: 0
allowed-tools: allowed-tools:
- bash - bash
--- ---
# 账号管理 # 账号管理
## 使用时机 ## 使用时机
@@ -25,6 +23,31 @@ allowed-tools:
## 执行步骤 ## 执行步骤
### 列出某平台所有账号 ### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号同平台下同手机号不可重复ID 自增名称如「搜狐1号」
```bash ```bash
python3 {baseDir}/scripts/account.py list <platform> 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/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,81 +0,0 @@
{
"sohu": [
{
"id": "sohu_account1",
"name": "搜狐账号1",
"phone": "13800000000",
"platform": "sohu",
"profile_dir": "D:/OpenClaw/chrome-profiles/sohu_account1"
},
{
"id": "sohu_account2",
"name": "搜狐账号2",
"phone": "13900000000",
"platform": "sohu",
"profile_dir": "D:/OpenClaw/chrome-profiles/sohu_account2"
}
],
"zhihu": [
{
"id": "zhihu_account1",
"name": "知乎账号1",
"phone": "",
"platform": "zhihu",
"profile_dir": "D:/OpenClaw/chrome-profiles/zhihu_account1"
}
],
"kimi": [
{
"id": "kimi_account1",
"name": "Kimi账号1",
"phone": "13800000000",
"platform": "kimi",
"profile_dir": "D:/OpenClaw/chrome-profiles/kimi_account1"
}
],
"deepseek": [
{
"id": "deepseek_account1",
"name": "DeepSeek账号1",
"phone": "",
"platform": "deepseek",
"profile_dir": "D:/OpenClaw/chrome-profiles/deepseek_account1"
}
],
"doubao": [
{
"id": "doubao_account1",
"name": "豆包账号1",
"phone": "",
"platform": "doubao",
"profile_dir": "D:/OpenClaw/chrome-profiles/doubao_account1"
}
],
"qianwen": [
{
"id": "qianwen_account1",
"name": "千问账号1",
"phone": "",
"platform": "qianwen",
"profile_dir": "D:/OpenClaw/chrome-profiles/qianwen_account1"
}
],
"yiyan": [
{
"id": "yiyan_account1",
"name": "一言账号1",
"phone": "",
"platform": "yiyan",
"profile_dir": "D:/OpenClaw/chrome-profiles/yiyan_account1"
}
],
"yuanbao": [
{
"id": "yuanbao_account1",
"name": "元宝账号1",
"phone": "",
"platform": "yuanbao",
"profile_dir": "D:/OpenClaw/chrome-profiles/yuanbao_account1"
}
]
}

23
release.ps1 Normal file
View File

@@ -0,0 +1,23 @@
[CmdletBinding()]
param(
[string]$Prefix = "v",
[string]$Message = "正式发布",
[switch]$AutoCommit,
[switch]$RequireClean,
[string]$CommitMessage,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
if (-not (Test-Path $sharedScript)) {
throw "Shared release script not found: $sharedScript"
}
& $sharedScript @PSBoundParameters
exit $LASTEXITCODE

View File

@@ -1,131 +1,721 @@
import sys import sys
import json import json
import os import os
import re
import sqlite3
from urllib.parse import urlparse
import subprocess import subprocess
import tempfile
import time
from datetime import datetime
from typing import Optional
# Windows GBK 编码兼容修复 # Windows GBK 编码兼容修复
if sys.platform == "win32": if sys.platform == "win32":
import io import io
sys.stdout = io.TextIOWrapper(sys.stdout.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') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ACCOUNTS_FILE = os.path.join(BASE_DIR, "accounts.json") 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 = { PLATFORM_URLS = {
# 自媒体/图文平台
"sohu": "https://mp.sohu.com", "sohu": "https://mp.sohu.com",
"zhihu": "https://www.zhihu.com", "zhihu": "https://www.zhihu.com",
"wechat": "https://mp.weixin.qq.com", "wechat": "https://mp.weixin.qq.com",
# 大模型平台
"kimi": "https://kimi.moonshot.cn", "kimi": "https://kimi.moonshot.cn",
"deepseek": "https://chat.deepseek.com", "deepseek": "https://chat.deepseek.com",
"doubao": "https://www.doubao.com", "doubao": "https://www.doubao.com",
"qianwen": "https://tongyi.aliyun.com", "qianwen": "https://tongyi.aliyun.com",
"yiyan": "https://yiyan.baidu.com", "yiyan": "https://yiyan.baidu.com",
"yuanbao": "https://yuanbao.tencent.com" "yuanbao": "https://yuanbao.tencent.com",
} }
def load_accounts(): # 新账号默认名称搜狐1号 / sohu_2 等同平台序号
with open(ACCOUNTS_FILE, "r", encoding="utf-8") as f: _PLATFORM_NAME_CN = {
return json.load(f) "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"): def cmd_list(platform="all"):
""" init_db()
修改后的 list 功能能直观打出带着【手机号】的详细台账供AI查询匹配 conn = get_conn()
支持查全部(all),或指定(sohu) try:
""" cur = conn.cursor()
accounts = load_accounts() if platform == "all":
if platform == "all": cur.execute(
platforms_to_check = accounts.keys() "SELECT id, name, phone, platform, login_status FROM accounts ORDER BY platform, id"
else: )
platforms_to_check = [platform] 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 found = False
for plat in platforms_to_check: for row in rows:
platform_accounts = accounts.get(plat, []) phone = row[2] or "未绑定手机"
for acc in platform_accounts: login_text = "已登录" if int(row[4] or 0) == 1 else "未登录"
phone = acc.get('phone', '未绑定手机') print(f"账号ID:{row[0]} | 名称:{row[1]} | 手机号:{phone} | 平台:{row[3]} | 状态:{login_text}")
print(f"账号ID:{acc['id']} | 名称:{acc['name']} | 手机号:{phone} | 平台:{acc['platform']}") found = True
found = True
if not found: if not found:
print(f"ERROR:NO_ACCOUNTS_FOUND") print("ERROR:NO_ACCOUNTS_FOUND")
def cmd_get(account_id): def cmd_get(account_id):
accounts = load_accounts() acc = get_account_by_id(account_id)
for platform_accounts in accounts.values(): if acc:
for acc in platform_accounts: print(json.dumps(acc, ensure_ascii=False))
if acc["id"] == account_id: return
print(json.dumps(acc, ensure_ascii=False))
return
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
def cmd_login(account_id):
"""首次登录打开浏览器用户手动登录登录态自动保存到Profile目录"""
accounts = load_accounts()
target = None
for platform_accounts in accounts.values():
for acc in platform_accounts:
if acc["id"] == account_id:
target = acc
break
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: if not target:
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
return 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"] profile_dir = target["profile_dir"]
os.makedirs(profile_dir, exist_ok=True) 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()
poll_sec = _login_poll_interval_seconds()
print(f"正在为账号 [{target['name']}] 打开浏览器...") browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"请在浏览器中完成登录,登录后直接关闭浏览器窗口即可。") print(f"正在为账号 [{target['name']}] 打开 {browser_name}")
print(f"登录态将自动保存到:{profile_dir}") print(f"访问地址:{url}")
print(
"请在窗口内完成登录。系统会轮询地址栏,进入后台后将自动关闭窗口并写入状态(无需手动关浏览器)。"
)
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
# 用Playwright打开持久化浏览器等待用户手动登录 # 子进程:搜狐轮询 URL+页面;勿用「含 mpfe 即登录」,/mpfe/v4/login 会误判。
login_script = f""" runner = r"""import json, sys, time
import asyncio from urllib.parse import urlparse
from playwright.async_api import async_playwright from playwright.sync_api import sync_playwright
async def main():
async with async_playwright() as p: def sohu_logged_in(url):
browser = await p.chromium.launch_persistent_context( u = (url or "").strip()
user_data_dir=r"{profile_dir}", if not u:
headless=False, return False
channel="chrome", # 【修改处】也加上这行 try:
no_viewport=True, # 确保浏览器内容视口也最大化 p = urlparse(u)
args=["--start-maximized"] except Exception:
) return False
page = browser.pages[0] if browser.pages else await browser.new_page() path = (p.path or "").lower()
await page.goto("{url}") host = (p.netloc or "").lower()
print("浏览器已打开,请完成登录后关闭窗口...") if "passport" in host:
# 移除自动超时,无限等待直到用户扫码完手动关闭 return False
await browser.wait_for_event("close", timeout=0) segs = [s for s in path.split("/") if s]
print("登录态已保存!") if any(s in ("login", "signin", "signup", "register", "authorize") for s in segs):
asyncio.run(main()) 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
""" """
import tempfile cfg = {
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', "channel": channel,
delete=False, encoding='utf-8') as f: "profile_dir": profile_dir,
f.write(login_script) "url": url,
tmp_path = f.name "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:需要 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()
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()
os.system(f'python "{tmp_path}"')
os.unlink(tmp_path)
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("用法python account.py list [platform] | get <id> | login <id>") print(
"用法python account.py add <platform> [phone] | list [platform] | get <id> | open <id> | login <id>"
)
sys.exit(1) sys.exit(1)
cmd = sys.argv[1] cmd = sys.argv[1]
if cmd == "list": if len(sys.argv) >= 3 and sys.argv[2] == "list" and cmd in PLATFORM_URLS:
# 默认不传 platform 时打出 all 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") cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
elif cmd == "get" and len(sys.argv) >= 3: elif cmd == "get" and len(sys.argv) >= 3:
cmd_get(sys.argv[2]) 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: elif cmd == "login" and len(sys.argv) >= 3:
cmd_login(sys.argv[2]) cmd_login(sys.argv[2])
else: else:
print("参数错误") print("参数错误")
sys.exit(1) sys.exit(1)

1654
scripts/main.py Normal file

File diff suppressed because it is too large Load Diff