@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.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/SHA256SUMS +94 -0
  3. package/bin/media-agent.mjs +89 -0
  4. package/docs/guides/capabilities.md +103 -0
  5. package/docs/guides/image-text-publishing.md +81 -0
  6. package/docs/guides/installation.md +103 -0
  7. package/docs/guides/runtime.md +13 -0
  8. package/manifest.json +382 -0
  9. package/package.json +42 -0
  10. package/pyproject.toml +20 -0
  11. package/resources/capabilities.json +34 -0
  12. package/resources/configs/accounts.yaml +21 -0
  13. package/resources/configs/profile.template.json +20 -0
  14. package/resources/configs/ranking-profiles.yaml +21 -0
  15. package/resources/configs/toutiao-profile.template.json +22 -0
  16. package/resources/configs/xiaohongshu-profile.template.json +27 -0
  17. package/resources/data/industry-taxonomy.yaml +326 -0
  18. package/skills/douyin-competitor-collect/SKILL.md +177 -0
  19. package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
  20. package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
  21. package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
  22. package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
  23. package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  24. package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
  25. package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
  26. package/skills/douyin-creator-index/SKILL.md +32 -0
  27. package/skills/douyin-creator-index/agents/openai.yaml +4 -0
  28. package/skills/douyin-creator-login/SKILL.md +58 -0
  29. package/skills/douyin-creator-login/agents/openai.yaml +4 -0
  30. package/skills/douyin-creator-publish/SKILL.md +56 -0
  31. package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
  32. package/skills/douyin-enterprise-leads/SKILL.md +26 -0
  33. package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
  34. package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
  35. package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
  36. package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
  37. package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
  38. package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
  39. package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
  40. package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
  41. package/skills/douyin-web-login/SKILL.md +102 -0
  42. package/skills/douyin-web-login/agents/openai.yaml +4 -0
  43. package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
  44. package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
  45. package/skills/toutiao-web-login/SKILL.md +96 -0
  46. package/skills/toutiao-web-login/agents/openai.yaml +4 -0
  47. package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
  48. package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
  49. package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  50. package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
  51. package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
  52. package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
  53. package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
  54. package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
  55. package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
  56. package/src/media_agent/__init__.py +1 -0
  57. package/src/media_agent/cli.py +28 -0
  58. package/src/media_agent/commands.sh +436 -0
  59. package/src/media_agent/platforms/__init__.py +1 -0
  60. package/src/media_agent/platforms/douyin/__init__.py +1 -0
  61. package/src/media_agent/platforms/douyin/check_login.py +196 -0
  62. package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
  63. package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
  64. package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
  65. package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
  66. package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
  67. package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
  68. package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
  69. package/src/media_agent/platforms/douyin/login_controller.py +508 -0
  70. package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
  71. package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
  72. package/src/media_agent/platforms/toutiao/__init__.py +1 -0
  73. package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
  74. package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
  75. package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
  76. package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
  77. package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
  78. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
  79. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
  80. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
  81. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
  82. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
  83. package/src/media_agent/runtime/__init__.py +1 -0
  84. package/src/media_agent/runtime/account_manager.py +672 -0
  85. package/src/media_agent/runtime/browser.py +5 -0
  86. package/src/media_agent/runtime/paths.py +7 -0
  87. package/src/media_agent/script_map.json +23 -0
  88. package/src/node/config.mjs +48 -0
  89. package/src/node/integrity.mjs +34 -0
  90. package/src/node/skills.mjs +85 -0
  91. package/tools/archive_releases.py +83 -0
  92. package/tools/artifacts.py +56 -0
  93. package/tools/build_release.py +82 -0
  94. package/tools/check_catalog.py +23 -0
  95. package/tools/install_runtime.py +144 -0
  96. 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,5 @@
1
+ """Load the optional browser dependency only when an operation launches it."""
2
+
3
+ def launch_persistent_context(*args, **kwargs):
4
+ from cloakbrowser import launch_persistent_context as launch
5
+ return launch(*args, **kwargs)
@@ -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()