@iducky/media-agent 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/SHA256SUMS +94 -0
- package/bin/media-agent.mjs +89 -0
- package/docs/guides/capabilities.md +103 -0
- package/docs/guides/image-text-publishing.md +81 -0
- package/docs/guides/installation.md +103 -0
- package/docs/guides/runtime.md +13 -0
- package/manifest.json +382 -0
- package/package.json +42 -0
- package/pyproject.toml +20 -0
- package/resources/capabilities.json +34 -0
- package/resources/configs/accounts.yaml +21 -0
- package/resources/configs/profile.template.json +20 -0
- package/resources/configs/ranking-profiles.yaml +21 -0
- package/resources/configs/toutiao-profile.template.json +22 -0
- package/resources/configs/xiaohongshu-profile.template.json +27 -0
- package/resources/data/industry-taxonomy.yaml +326 -0
- package/skills/douyin-competitor-collect/SKILL.md +177 -0
- package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
- package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
- package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
- package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
- package/skills/douyin-creator-index/SKILL.md +32 -0
- package/skills/douyin-creator-index/agents/openai.yaml +4 -0
- package/skills/douyin-creator-login/SKILL.md +58 -0
- package/skills/douyin-creator-login/agents/openai.yaml +4 -0
- package/skills/douyin-creator-publish/SKILL.md +56 -0
- package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads/SKILL.md +26 -0
- package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
- package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
- package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
- package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
- package/skills/douyin-web-login/SKILL.md +102 -0
- package/skills/douyin-web-login/agents/openai.yaml +4 -0
- package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
- package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
- package/skills/toutiao-web-login/SKILL.md +96 -0
- package/skills/toutiao-web-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
- package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
- package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
- package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
- package/src/media_agent/__init__.py +1 -0
- package/src/media_agent/cli.py +28 -0
- package/src/media_agent/commands.sh +436 -0
- package/src/media_agent/platforms/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/check_login.py +196 -0
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
- package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
- package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
- package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
- package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
- package/src/media_agent/platforms/douyin/login_controller.py +508 -0
- package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
- package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
- package/src/media_agent/platforms/toutiao/__init__.py +1 -0
- package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
- package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
- package/src/media_agent/runtime/__init__.py +1 -0
- package/src/media_agent/runtime/account_manager.py +672 -0
- package/src/media_agent/runtime/browser.py +5 -0
- package/src/media_agent/runtime/paths.py +7 -0
- package/src/media_agent/script_map.json +23 -0
- package/src/node/config.mjs +48 -0
- package/src/node/integrity.mjs +34 -0
- package/src/node/skills.mjs +85 -0
- package/tools/archive_releases.py +83 -0
- package/tools/artifacts.py +56 -0
- package/tools/build_release.py +82 -0
- package/tools/check_catalog.py +23 -0
- package/tools/install_runtime.py +144 -0
- package/tools/run_tests.py +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Media Agent capabilities."""
|
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
from media_agent.runtime.paths import runtime_home
|
|
2
|
+
# ============================================================
|
|
3
|
+
# 社交媒体账号管理 — 核心脚本
|
|
4
|
+
# 依赖: CloakBrowser (pip install cloakbrowser)
|
|
5
|
+
# ============================================================
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Optional, List, Dict, Any
|
|
14
|
+
|
|
15
|
+
# ── 路径常量 ────────────────────────────────────────────────
|
|
16
|
+
BASE_DIR = runtime_home()
|
|
17
|
+
ACCOUNTS_YAML = BASE_DIR / "accounts.yaml"
|
|
18
|
+
PROFILES_DIR = BASE_DIR / "profiles"
|
|
19
|
+
LOCKS_DIR = BASE_DIR / "locks"
|
|
20
|
+
SCREENSHOTS_DIR = BASE_DIR / "screenshots"
|
|
21
|
+
TASKS_DIR = BASE_DIR / "tasks"
|
|
22
|
+
|
|
23
|
+
# 默认指纹参数
|
|
24
|
+
DEFAULT_FINGERPRINT = {
|
|
25
|
+
"platform": "macos",
|
|
26
|
+
"locale": "zh-CN",
|
|
27
|
+
"timezone": "Asia/Shanghai",
|
|
28
|
+
"viewport": {"width": 1440, "height": 900},
|
|
29
|
+
"humanize": True,
|
|
30
|
+
"human_preset": "default",
|
|
31
|
+
"stealth_args": True,
|
|
32
|
+
"color_scheme": "light",
|
|
33
|
+
"headless": False,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# 锁超时(秒)— 超过此时间视为僵死锁
|
|
37
|
+
LOCK_STALE_TIMEOUT = 7200 # 2小时
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════
|
|
41
|
+
# 1. 账号注册表操作
|
|
42
|
+
# ═══════════════════════════════════════════════════════════════
|
|
43
|
+
def load_accounts() -> list:
|
|
44
|
+
"""加载 accounts.yaml"""
|
|
45
|
+
import yaml
|
|
46
|
+
if not ACCOUNTS_YAML.exists():
|
|
47
|
+
return []
|
|
48
|
+
with open(ACCOUNTS_YAML, "r", encoding="utf-8") as f:
|
|
49
|
+
data = yaml.safe_load(f) or {}
|
|
50
|
+
return data.get("accounts", [])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_accounts(accounts: list):
|
|
54
|
+
"""保存 accounts.yaml"""
|
|
55
|
+
import yaml
|
|
56
|
+
data = {"accounts": accounts}
|
|
57
|
+
with open(ACCOUNTS_YAML, "w", encoding="utf-8") as f:
|
|
58
|
+
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
|
59
|
+
print(f"✅ 已保存 {len(accounts)} 个账号到 {ACCOUNTS_YAML}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_account(alias: str):
|
|
63
|
+
"""按 alias 查找账号"""
|
|
64
|
+
for a in load_accounts():
|
|
65
|
+
if a["alias"] == alias:
|
|
66
|
+
return a
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ═══════════════════════════════════════════════════════════════
|
|
71
|
+
# 2. Profile 配置操作
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════
|
|
73
|
+
def profile_path(alias: str) -> Path:
|
|
74
|
+
return PROFILES_DIR / alias
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def profile_config_path(alias: str) -> Path:
|
|
78
|
+
return profile_path(alias) / "config.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def profile_state_path(alias: str) -> Path:
|
|
82
|
+
return profile_path(alias) / "state.json"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def profile_user_data_dir(alias: str) -> Path:
|
|
86
|
+
return profile_path(alias) / "browser_data"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_profile_config(alias: str) -> dict:
|
|
90
|
+
"""加载 profile 的 config.json"""
|
|
91
|
+
p = profile_config_path(alias)
|
|
92
|
+
if not p.exists():
|
|
93
|
+
return {}
|
|
94
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
95
|
+
return json.load(f)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_profile_config(alias: str, config: dict):
|
|
99
|
+
"""保存 profile 的 config.json"""
|
|
100
|
+
p = profile_config_path(alias)
|
|
101
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
103
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
104
|
+
print(f" ✅ config.json 已写入: {p}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_profile_state(alias: str) -> dict:
|
|
108
|
+
"""加载 profile 的 state.json"""
|
|
109
|
+
p = profile_state_path(alias)
|
|
110
|
+
if not p.exists():
|
|
111
|
+
return {
|
|
112
|
+
"is_logged_in": False,
|
|
113
|
+
"last_login_check": None,
|
|
114
|
+
"last_activity": None,
|
|
115
|
+
"login_method": None,
|
|
116
|
+
"notes": "",
|
|
117
|
+
}
|
|
118
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
119
|
+
return json.load(f)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_profile_state(alias: str, state: dict):
|
|
123
|
+
"""保存 profile 的 state.json"""
|
|
124
|
+
p = profile_state_path(alias)
|
|
125
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
127
|
+
json.dump(state, f, indent=2, ensure_ascii=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def init_profile(alias: str, account: dict):
|
|
131
|
+
"""初始化一个新 profile"""
|
|
132
|
+
profile_dir = profile_path(alias)
|
|
133
|
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
# 生成唯一指纹 seed
|
|
136
|
+
fingerprint_seed = str(uuid.uuid4())
|
|
137
|
+
|
|
138
|
+
# 构建 config.json
|
|
139
|
+
config = {
|
|
140
|
+
"fingerprint": {
|
|
141
|
+
"seed": fingerprint_seed,
|
|
142
|
+
"platform": DEFAULT_FINGERPRINT["platform"],
|
|
143
|
+
"locale": DEFAULT_FINGERPRINT["locale"],
|
|
144
|
+
"timezone": DEFAULT_FINGERPRINT["timezone"],
|
|
145
|
+
"viewport": DEFAULT_FINGERPRINT["viewport"],
|
|
146
|
+
"color_scheme": DEFAULT_FINGERPRINT["color_scheme"],
|
|
147
|
+
},
|
|
148
|
+
"browser": {
|
|
149
|
+
"headless": DEFAULT_FINGERPRINT["headless"],
|
|
150
|
+
"stealth_args": DEFAULT_FINGERPRINT["stealth_args"],
|
|
151
|
+
"humanize": DEFAULT_FINGERPRINT["humanize"],
|
|
152
|
+
"human_preset": DEFAULT_FINGERPRINT["human_preset"],
|
|
153
|
+
},
|
|
154
|
+
"account": {
|
|
155
|
+
"alias": alias,
|
|
156
|
+
"platform": account.get("platform", "unknown"),
|
|
157
|
+
"label": account.get("label", alias),
|
|
158
|
+
"login_method": account.get("login_method", "unknown"),
|
|
159
|
+
"status": account.get("status", "active"),
|
|
160
|
+
"notes": account.get("notes", ""),
|
|
161
|
+
},
|
|
162
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
163
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
164
|
+
}
|
|
165
|
+
save_profile_config(alias, config)
|
|
166
|
+
|
|
167
|
+
# 初始化 state.json
|
|
168
|
+
state = {
|
|
169
|
+
"is_logged_in": False,
|
|
170
|
+
"last_login_check": None,
|
|
171
|
+
"last_activity": None,
|
|
172
|
+
"login_method": account.get("login_method", "unknown"),
|
|
173
|
+
"notes": account.get("notes", ""),
|
|
174
|
+
}
|
|
175
|
+
save_profile_state(alias, state)
|
|
176
|
+
|
|
177
|
+
# 创建 browser_data 目录(CloakBrowser 自动创建,但先建好)
|
|
178
|
+
browser_data = profile_user_data_dir(alias)
|
|
179
|
+
browser_data.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
# 创建空 cookie 快照文件
|
|
182
|
+
cookies_path = profile_path(alias) / "cookies.json"
|
|
183
|
+
if not cookies_path.exists():
|
|
184
|
+
with open(cookies_path, "w", encoding="utf-8") as f:
|
|
185
|
+
json.dump([], f)
|
|
186
|
+
|
|
187
|
+
print(f"\n{'='*60}")
|
|
188
|
+
print(f" ✅ Profile 初始化完成: {alias}")
|
|
189
|
+
print(f" 📁 目录: {profile_dir}")
|
|
190
|
+
print(f" 🆔 指纹 seed: {fingerprint_seed[:16]}...")
|
|
191
|
+
print(f" 🌐 平台: {config['fingerprint']['platform']}")
|
|
192
|
+
print(f" 📍 locale: {config['fingerprint']['locale']}")
|
|
193
|
+
print(f" 🕐 timezone: {config['fingerprint']['timezone']}")
|
|
194
|
+
print(f" 🖥 viewport: {config['fingerprint']['viewport']}")
|
|
195
|
+
print(f" 👤 humanize: {config['browser']['humanize']}")
|
|
196
|
+
print(f" 🔑 登录方式: {config['account']['login_method']}")
|
|
197
|
+
print(f"{'='*60}\n")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ═══════════════════════════════════════════════════════════════
|
|
201
|
+
# 3. Profile Lock 机制
|
|
202
|
+
# ═══════════════════════════════════════════════════════════════
|
|
203
|
+
def lock_path(alias: str) -> Path:
|
|
204
|
+
return LOCKS_DIR / f"{alias}.lock"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def acquire_lock(alias: str, task_id: str) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
尝试获取 profile 锁。
|
|
210
|
+
返回 True 成功,False 失败(已被其他任务占用)。
|
|
211
|
+
"""
|
|
212
|
+
lock_file = lock_path(alias)
|
|
213
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
# 检查是否存在僵死锁
|
|
216
|
+
if lock_file.exists():
|
|
217
|
+
try:
|
|
218
|
+
data = json.loads(lock_file.read_text())
|
|
219
|
+
locked_at = data.get("locked_at", "")
|
|
220
|
+
locked_pid = data.get("pid", 0)
|
|
221
|
+
locked_task = data.get("task_id", "unknown")
|
|
222
|
+
|
|
223
|
+
# 检查进程是否还在运行
|
|
224
|
+
import subprocess
|
|
225
|
+
if locked_pid:
|
|
226
|
+
try:
|
|
227
|
+
os.kill(locked_pid, 0) # 信号 0 只检测进程存在
|
|
228
|
+
process_alive = True
|
|
229
|
+
except (OSError, ProcessLookupError):
|
|
230
|
+
process_alive = False
|
|
231
|
+
else:
|
|
232
|
+
process_alive = False
|
|
233
|
+
|
|
234
|
+
# 检查超时
|
|
235
|
+
if locked_at:
|
|
236
|
+
locked_dt = datetime.fromisoformat(locked_at)
|
|
237
|
+
elapsed = (datetime.now(timezone.utc) - locked_dt).total_seconds()
|
|
238
|
+
is_stale = elapsed > LOCK_STALE_TIMEOUT
|
|
239
|
+
else:
|
|
240
|
+
is_stale = False
|
|
241
|
+
|
|
242
|
+
if process_alive and not is_stale:
|
|
243
|
+
print(f" ⛔ Profile [{alias}] 已被占用 (task: {locked_task}, pid: {locked_pid})")
|
|
244
|
+
return False
|
|
245
|
+
elif is_stale:
|
|
246
|
+
print(f" ⚠️ 检测到僵死锁 (task: {locked_task}, {elapsed:.0f}s 超时),正在释放...")
|
|
247
|
+
release_lock(alias, locked_task)
|
|
248
|
+
else:
|
|
249
|
+
# 进程已死但尚未超时
|
|
250
|
+
print(f" ⚠️ 检测到过期锁 (task: {locked_task}, 进程已结束),正在释放...")
|
|
251
|
+
release_lock(alias, locked_task)
|
|
252
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
253
|
+
print(f" ⚠️ 锁文件异常 ({e}),覆盖...")
|
|
254
|
+
lock_file.unlink(missing_ok=True)
|
|
255
|
+
|
|
256
|
+
# 创建锁
|
|
257
|
+
lock_data = {
|
|
258
|
+
"task_id": task_id,
|
|
259
|
+
"locked_at": datetime.now(timezone.utc).isoformat(),
|
|
260
|
+
"pid": os.getpid(),
|
|
261
|
+
"hostname": os.uname().nodename,
|
|
262
|
+
}
|
|
263
|
+
try:
|
|
264
|
+
lock_file.write_text(json.dumps(lock_data, indent=2))
|
|
265
|
+
print(f" 🔒 已锁定 profile [{alias}] (task: {task_id})")
|
|
266
|
+
return True
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(f" ❌ 锁写入失败: {e}")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def release_lock(alias: str, task_id: str):
|
|
273
|
+
"""释放 profile 锁"""
|
|
274
|
+
lock_file = lock_path(alias)
|
|
275
|
+
if not lock_file.exists():
|
|
276
|
+
print(f" 🔓 Profile [{alias}] 没有被锁定")
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
data = json.loads(lock_file.read_text())
|
|
281
|
+
if data.get("task_id") != task_id:
|
|
282
|
+
print(f" ⚠️ 锁不属于当前任务 ({data.get('task_id')} != {task_id}),跳过")
|
|
283
|
+
return False
|
|
284
|
+
lock_file.unlink()
|
|
285
|
+
print(f" 🔓 已释放 profile [{alias}] (task: {task_id})")
|
|
286
|
+
return True
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f" ❌ 解锁失败: {e}")
|
|
289
|
+
# 强制删除
|
|
290
|
+
try:
|
|
291
|
+
lock_file.unlink(missing_ok=True)
|
|
292
|
+
print(f" 🔓 强制释放 profile [{alias}]")
|
|
293
|
+
return True
|
|
294
|
+
except Exception:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def list_locks() -> list[dict]:
|
|
299
|
+
"""列出所有当前锁"""
|
|
300
|
+
locks = []
|
|
301
|
+
if not LOCKS_DIR.exists():
|
|
302
|
+
return locks
|
|
303
|
+
for f in sorted(LOCKS_DIR.glob("*.lock")):
|
|
304
|
+
try:
|
|
305
|
+
data = json.loads(f.read_text())
|
|
306
|
+
data["alias"] = f.stem.replace(".lock", "")
|
|
307
|
+
locks.append(data)
|
|
308
|
+
except Exception:
|
|
309
|
+
locks.append({"alias": f.stem.replace(".lock", ""), "error": "unreadable"})
|
|
310
|
+
return locks
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ═══════════════════════════════════════════════════════════════
|
|
314
|
+
# 4. 浏览器启动
|
|
315
|
+
# ═══════════════════════════════════════════════════════════════
|
|
316
|
+
def launch_browser(alias: str, task_id: str, headless: bool = False):
|
|
317
|
+
"""
|
|
318
|
+
使用 CloakBrowser 启动一个持久化 profile 浏览器。
|
|
319
|
+
返回 (context, pages) 或抛出异常。
|
|
320
|
+
"""
|
|
321
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
322
|
+
|
|
323
|
+
config = load_profile_config(alias)
|
|
324
|
+
if not config:
|
|
325
|
+
raise RuntimeError(f"Profile [{alias}] 未初始化,请先运行 init_account")
|
|
326
|
+
|
|
327
|
+
fp = config.get("fingerprint", {})
|
|
328
|
+
br = config.get("browser", {})
|
|
329
|
+
|
|
330
|
+
print(f" 🚀 启动 CloakBrowser: {alias}")
|
|
331
|
+
print(f" user_data_dir: {profile_user_data_dir(alias)}")
|
|
332
|
+
print(f" viewport: {fp.get('viewport', DEFAULT_FINGERPRINT['viewport'])}")
|
|
333
|
+
print(f" locale: {fp.get('locale', 'zh-CN')}")
|
|
334
|
+
print(f" timezone: {fp.get('timezone', 'Asia/Shanghai')}")
|
|
335
|
+
|
|
336
|
+
# 构建 viewport 参数
|
|
337
|
+
viewport = fp.get("viewport", DEFAULT_FINGERPRINT["viewport"])
|
|
338
|
+
|
|
339
|
+
context = launch_persistent_context(
|
|
340
|
+
user_data_dir=str(profile_user_data_dir(alias)),
|
|
341
|
+
headless=headless,
|
|
342
|
+
stealth_args=br.get("stealth_args", True),
|
|
343
|
+
viewport=viewport,
|
|
344
|
+
locale=fp.get("locale", "zh-CN"),
|
|
345
|
+
timezone=fp.get("timezone", "Asia/Shanghai"),
|
|
346
|
+
color_scheme=fp.get("color_scheme", "light"),
|
|
347
|
+
humanize=br.get("humanize", True),
|
|
348
|
+
human_preset=br.get("human_preset", "default"),
|
|
349
|
+
# 显式不启用 geoip,保持指纹固定
|
|
350
|
+
geoip=False,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# 更新最后活动时间
|
|
354
|
+
state = load_profile_state(alias)
|
|
355
|
+
state["last_activity"] = datetime.now(timezone.utc).isoformat()
|
|
356
|
+
save_profile_state(alias, state)
|
|
357
|
+
|
|
358
|
+
return context
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ═══════════════════════════════════════════════════════════════
|
|
362
|
+
# 5. 登录状态检查
|
|
363
|
+
# ═══════════════════════════════════════════════════════════════
|
|
364
|
+
def check_login_status(alias: str, page) -> bool:
|
|
365
|
+
"""
|
|
366
|
+
检查当前页面是否已登录。
|
|
367
|
+
需传入对应平台的 page 对象,子类化实现。
|
|
368
|
+
返回 True 已登录 / False 未登录。
|
|
369
|
+
"""
|
|
370
|
+
# 由各平台具体实现
|
|
371
|
+
raise NotImplementedError("由各平台子类实现")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ═══════════════════════════════════════════════════════════════
|
|
375
|
+
# 6. 人工接管流程
|
|
376
|
+
# ═══════════════════════════════════════════════════════════════
|
|
377
|
+
def human_intervene(alias: str, reason: str, screenshot_path=None):
|
|
378
|
+
"""
|
|
379
|
+
暂停任务,通知用户人工处理。
|
|
380
|
+
输出格式供 Hermes Agent 解析并通知用户。
|
|
381
|
+
"""
|
|
382
|
+
msg = [
|
|
383
|
+
f"\n{'🔴'*30}",
|
|
384
|
+
f" 需要人工介入!",
|
|
385
|
+
f" Profile: {alias}",
|
|
386
|
+
f" 原因: {reason}",
|
|
387
|
+
f" 截图: {screenshot_path or '无'}",
|
|
388
|
+
f"{'🔴'*30}\n",
|
|
389
|
+
]
|
|
390
|
+
print("\n".join(msg))
|
|
391
|
+
return msg
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ═══════════════════════════════════════════════════════════════
|
|
395
|
+
# CLI 入口
|
|
396
|
+
# ═══════════════════════════════════════════════════════════════
|
|
397
|
+
def cmd_init(alias: str, platform: str, label: str, login_method: str, notes: str = ""):
|
|
398
|
+
"""初始化新账号: init_account <alias> --platform <p> --label <l> --login <m>"""
|
|
399
|
+
account = {
|
|
400
|
+
"alias": alias,
|
|
401
|
+
"platform": platform,
|
|
402
|
+
"label": label,
|
|
403
|
+
"login_method": login_method,
|
|
404
|
+
"status": "active",
|
|
405
|
+
"notes": notes,
|
|
406
|
+
}
|
|
407
|
+
accounts = load_accounts()
|
|
408
|
+
|
|
409
|
+
# 检查是否已存在
|
|
410
|
+
existing = find_account(alias)
|
|
411
|
+
if existing:
|
|
412
|
+
print(f"⚠️ 账号 [{alias}] 已存在,跳过")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
accounts.append(account)
|
|
416
|
+
save_accounts(accounts)
|
|
417
|
+
init_profile(alias, account)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cmd_list():
|
|
421
|
+
"""列出所有账号"""
|
|
422
|
+
accounts = load_accounts()
|
|
423
|
+
locks = {l["alias"]: l for l in list_locks()}
|
|
424
|
+
|
|
425
|
+
print(f"\n{'='*60}")
|
|
426
|
+
print(f" 社交媒体账号列表 ({len(accounts)} 个)")
|
|
427
|
+
print(f"{'='*60}")
|
|
428
|
+
for a in accounts:
|
|
429
|
+
alias = a["alias"]
|
|
430
|
+
state = load_profile_state(alias)
|
|
431
|
+
locked = "🔒" if alias in locks else " "
|
|
432
|
+
logged = "✅" if state.get("is_logged_in") else "⬜"
|
|
433
|
+
print(f" {locked} {logged} [{alias:20s}] {a.get('label',''):10s} {a['platform']:12s} {a.get('status','')}")
|
|
434
|
+
print(f"{'='*60}\n")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def cmd_status(alias: str):
|
|
438
|
+
"""查看单个 profile 状态"""
|
|
439
|
+
config = load_profile_config(alias)
|
|
440
|
+
state = load_profile_state(alias)
|
|
441
|
+
|
|
442
|
+
if not config:
|
|
443
|
+
print(f"❌ Profile [{alias}] 不存在")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
acct = find_account(alias) or config.get("account", {})
|
|
447
|
+
|
|
448
|
+
print(f"\n{'='*60}")
|
|
449
|
+
print(f" Profile: {alias}")
|
|
450
|
+
print(f"{'='*60}")
|
|
451
|
+
print(f" accounts.yaml status: {acct.get('status', 'unknown')}")
|
|
452
|
+
print(f" state.json is_logged_in: {state.get('is_logged_in', False)}")
|
|
453
|
+
print(f" last_login_check: {state.get('last_login_check', 'never')}")
|
|
454
|
+
print(f" profile_path: {profile_path(alias)}")
|
|
455
|
+
locked = "是"
|
|
456
|
+
locks = list_locks()
|
|
457
|
+
l = next((l for l in locks if l["alias"] == alias), None)
|
|
458
|
+
if not l:
|
|
459
|
+
locked = "否"
|
|
460
|
+
print(f" 锁定状态: {locked}")
|
|
461
|
+
if l:
|
|
462
|
+
print(f" 锁信息: task={l.get('task_id','?')} pid={l.get('pid','?')} locked_at={l.get('locked_at','?')}")
|
|
463
|
+
print(f" 平台: {acct.get('platform', '?')} 标签: {acct.get('label', '?')} 登录方式: {acct.get('login_method', '?')}")
|
|
464
|
+
print(f" 指纹 seed: {config.get('fingerprint', {}).get('seed', '?')[:16]}...")
|
|
465
|
+
print(f" viewport: {config.get('fingerprint', {}).get('viewport', '?')}")
|
|
466
|
+
print(f"{'='*60}\n")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_locks():
|
|
470
|
+
"""查看所有锁"""
|
|
471
|
+
locks = list_locks()
|
|
472
|
+
if not locks:
|
|
473
|
+
print(" 当前没有活跃锁")
|
|
474
|
+
return
|
|
475
|
+
print(f"\n 当前活跃锁 ({len(locks)} 个):")
|
|
476
|
+
now = datetime.now(timezone.utc)
|
|
477
|
+
for l in locks:
|
|
478
|
+
alias = l.get("alias", "?")
|
|
479
|
+
task = l.get("task_id", "?")
|
|
480
|
+
locked_at = l.get("locked_at", "?")
|
|
481
|
+
pid = l.get("pid", "?")
|
|
482
|
+
stale = ""
|
|
483
|
+
try:
|
|
484
|
+
locked_dt = datetime.fromisoformat(locked_at)
|
|
485
|
+
elapsed = (now - locked_dt).total_seconds()
|
|
486
|
+
if elapsed > LOCK_STALE_TIMEOUT:
|
|
487
|
+
stale = " ⚠️ 僵尸锁 (已超时)"
|
|
488
|
+
except:
|
|
489
|
+
stale = " ⚠️ 时间解析失败"
|
|
490
|
+
print(f" 🔒 {alias:20s} task={task:20s} pid={pid} locked_at={locked_at}{stale}")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def cmd_open(alias: str):
|
|
494
|
+
"""打开 profile 进入企业号后台"""
|
|
495
|
+
import uuid
|
|
496
|
+
task_id = f"open_{alias}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
497
|
+
|
|
498
|
+
# acquire lock
|
|
499
|
+
if not acquire_lock(alias, task_id):
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
504
|
+
|
|
505
|
+
config = load_profile_config(alias)
|
|
506
|
+
if not config:
|
|
507
|
+
print(f"❌ Profile [{alias}] 未初始化")
|
|
508
|
+
release_lock(alias, task_id)
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
fp = config.get("fingerprint", {})
|
|
512
|
+
br = config.get("browser", {})
|
|
513
|
+
|
|
514
|
+
print(f" 🚀 启动 CloakBrowser: {alias}")
|
|
515
|
+
context = launch_persistent_context(
|
|
516
|
+
user_data_dir=str(profile_user_data_dir(alias)),
|
|
517
|
+
headless=False,
|
|
518
|
+
stealth_args=br.get("stealth_args", True),
|
|
519
|
+
viewport=fp.get("viewport", {"width": 1440, "height": 900}),
|
|
520
|
+
locale=fp.get("locale", "zh-CN"),
|
|
521
|
+
timezone=fp.get("timezone", "Asia/Shanghai"),
|
|
522
|
+
color_scheme=fp.get("color_scheme", "light"),
|
|
523
|
+
humanize=True,
|
|
524
|
+
human_preset="default",
|
|
525
|
+
geoip=False,
|
|
526
|
+
)
|
|
527
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
528
|
+
page.goto("https://e.douyin.com/", wait_until="domcontentloaded", timeout=30000)
|
|
529
|
+
import time
|
|
530
|
+
time.sleep(3)
|
|
531
|
+
|
|
532
|
+
logged_in = "login" not in page.url.lower() and "passport" not in page.url.lower()
|
|
533
|
+
if logged_in:
|
|
534
|
+
print(f" ✅ 已进入后台: {page.url}")
|
|
535
|
+
else:
|
|
536
|
+
print(f" ⚠️ 未登录,当前页面: {page.url}")
|
|
537
|
+
print(f" ⚠️ 浏览器保持打开,请手动扫码登录")
|
|
538
|
+
|
|
539
|
+
# 保持浏览器打开
|
|
540
|
+
print(f" 🔄 浏览器保持打开中... (按 Ctrl+C 关闭)")
|
|
541
|
+
while True:
|
|
542
|
+
time.sleep(10)
|
|
543
|
+
cu = page.url
|
|
544
|
+
if "login" not in cu.lower() and "passport" not in cu.lower():
|
|
545
|
+
if not logged_in:
|
|
546
|
+
print(f" ✅ 检测到登录成功: {cu}")
|
|
547
|
+
logged_in = True
|
|
548
|
+
except KeyboardInterrupt:
|
|
549
|
+
print(f"\n 🛑 用户中断")
|
|
550
|
+
finally:
|
|
551
|
+
try:
|
|
552
|
+
context.close()
|
|
553
|
+
except:
|
|
554
|
+
pass
|
|
555
|
+
release_lock(alias, task_id)
|
|
556
|
+
print(f" ✅ 浏览器已关闭,锁已释放")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def cmd_check_login(alias: str):
|
|
560
|
+
"""检查登录状态,更新 state.json,截图留痕"""
|
|
561
|
+
import uuid
|
|
562
|
+
import time
|
|
563
|
+
task_id = f"check_{alias}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
564
|
+
|
|
565
|
+
if not acquire_lock(alias, task_id):
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
570
|
+
|
|
571
|
+
config = load_profile_config(alias)
|
|
572
|
+
if not config:
|
|
573
|
+
print(f"❌ Profile [{alias}] 未初始化")
|
|
574
|
+
release_lock(alias, task_id)
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
fp = config.get("fingerprint", {})
|
|
578
|
+
br = config.get("browser", {})
|
|
579
|
+
|
|
580
|
+
context = launch_persistent_context(
|
|
581
|
+
user_data_dir=str(profile_user_data_dir(alias)),
|
|
582
|
+
headless=False,
|
|
583
|
+
stealth_args=br.get("stealth_args", True),
|
|
584
|
+
viewport=fp.get("viewport", {"width": 1440, "height": 900}),
|
|
585
|
+
locale=fp.get("locale", "zh-CN"),
|
|
586
|
+
timezone=fp.get("timezone", "Asia/Shanghai"),
|
|
587
|
+
color_scheme=fp.get("color_scheme", "light"),
|
|
588
|
+
humanize=True,
|
|
589
|
+
human_preset="default",
|
|
590
|
+
geoip=False,
|
|
591
|
+
)
|
|
592
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
593
|
+
page.goto("https://e.douyin.com/", wait_until="domcontentloaded", timeout=30000)
|
|
594
|
+
time.sleep(3)
|
|
595
|
+
|
|
596
|
+
logged_in = "login" not in page.url.lower() and "passport" not in page.url.lower()
|
|
597
|
+
now_ts = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
|
|
598
|
+
|
|
599
|
+
# 截图
|
|
600
|
+
screenshot = str(BASE_DIR / "screenshots" / f"{alias}_check_login_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
|
|
601
|
+
page.screenshot(path=screenshot, full_page=True)
|
|
602
|
+
|
|
603
|
+
# 更新 state.json
|
|
604
|
+
state = load_profile_state(alias)
|
|
605
|
+
state["is_logged_in"] = logged_in
|
|
606
|
+
state["last_login_check"] = now_ts
|
|
607
|
+
state["last_activity"] = now_ts
|
|
608
|
+
save_profile_state(alias, state)
|
|
609
|
+
|
|
610
|
+
if logged_in:
|
|
611
|
+
print(f" ✅ 登录状态正常 - 免扫码进入后台")
|
|
612
|
+
print(f" URL: {page.url}")
|
|
613
|
+
else:
|
|
614
|
+
print(f" ⚠️ 未登录 - 当前页面: {page.url}")
|
|
615
|
+
print(f" 📸 截图: {screenshot}")
|
|
616
|
+
|
|
617
|
+
print(f" 📸 截图: {screenshot}")
|
|
618
|
+
print(f" 📝 state.json: is_logged_in={logged_in}, last_login_check={now_ts}")
|
|
619
|
+
|
|
620
|
+
context.close()
|
|
621
|
+
print(f" ✅ 浏览器已关闭")
|
|
622
|
+
finally:
|
|
623
|
+
release_lock(alias, task_id)
|
|
624
|
+
print(f" ✅ 锁已释放")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
if __name__ == "__main__":
|
|
628
|
+
import argparse
|
|
629
|
+
|
|
630
|
+
parser = argparse.ArgumentParser(description="社交媒体账号 Profile 管理")
|
|
631
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
632
|
+
|
|
633
|
+
# init
|
|
634
|
+
p_init = sub.add_parser("init", help="初始化新账号 profile")
|
|
635
|
+
p_init.add_argument("alias", help="账号别名")
|
|
636
|
+
p_init.add_argument("--platform", required=True, help="平台名称")
|
|
637
|
+
p_init.add_argument("--label", required=True, help="中文备注")
|
|
638
|
+
p_init.add_argument("--login", required=True, help="登录方式: phone_qr/phone_code/password/wechat")
|
|
639
|
+
p_init.add_argument("--notes", default="", help="备注")
|
|
640
|
+
|
|
641
|
+
# list
|
|
642
|
+
sub.add_parser("list", help="列出所有账号")
|
|
643
|
+
|
|
644
|
+
# open
|
|
645
|
+
p_open = sub.add_parser("open", help="打开 profile 进入后台")
|
|
646
|
+
p_open.add_argument("alias", help="账号别名")
|
|
647
|
+
|
|
648
|
+
# check-login
|
|
649
|
+
p_check = sub.add_parser("check-login", help="检查登录状态")
|
|
650
|
+
p_check.add_argument("alias", help="账号别名")
|
|
651
|
+
|
|
652
|
+
# status
|
|
653
|
+
p_status = sub.add_parser("status", help="查看 profile 状态")
|
|
654
|
+
p_status.add_argument("alias", help="账号别名")
|
|
655
|
+
|
|
656
|
+
# locks
|
|
657
|
+
sub.add_parser("locks", help="查看所有锁")
|
|
658
|
+
|
|
659
|
+
args = parser.parse_args()
|
|
660
|
+
|
|
661
|
+
if args.command == "init":
|
|
662
|
+
cmd_init(args.alias, args.platform, args.label, args.login, args.notes)
|
|
663
|
+
elif args.command == "list":
|
|
664
|
+
cmd_list()
|
|
665
|
+
elif args.command == "open":
|
|
666
|
+
cmd_open(args.alias)
|
|
667
|
+
elif args.command == "check-login":
|
|
668
|
+
cmd_check_login(args.alias)
|
|
669
|
+
elif args.command == "status":
|
|
670
|
+
cmd_status(args.alias)
|
|
671
|
+
elif args.command == "locks":
|
|
672
|
+
cmd_locks()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""One data root shared by CLI, controllers and direct script entry points."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def runtime_home():
|
|
7
|
+
return Path(os.environ.get('MEDIA_AGENT_HOME') or Path.home() / '.media-agent' / 'social-accounts').expanduser().resolve()
|