@iducky/media-agent 1.1.0 → 1.1.1

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 (36) hide show
  1. package/README.md +3 -3
  2. package/SHA256SUMS +34 -30
  3. package/bin/media-agent.mjs +19 -7
  4. package/docs/guides/capabilities.md +6 -2
  5. package/docs/guides/installation.md +42 -6
  6. package/manifest.json +74 -58
  7. package/package.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/resources/capabilities.json +43 -1
  10. package/skills/douyin-creator-index/SKILL.md +2 -0
  11. package/skills/douyin-creator-publish/SKILL.md +3 -1
  12. package/skills/douyin-enterprise-short-video-export/SKILL.md +2 -0
  13. package/skills/douyin-enterprise-video-rankings/SKILL.md +2 -0
  14. package/skills/xiaohongshu-creator-publish/SKILL.md +3 -1
  15. package/src/media_agent/cli.py +5 -0
  16. package/src/media_agent/commands.sh +20 -20
  17. package/src/media_agent/platforms/douyin/check_login.py +8 -11
  18. package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +10 -1
  19. package/src/media_agent/platforms/douyin/collect_video_rankings.py +14 -5
  20. package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +12 -3
  21. package/src/media_agent/platforms/douyin/douyin_publish.py +65 -218
  22. package/src/media_agent/platforms/douyin/enterprise_login.py +6 -11
  23. package/src/media_agent/platforms/douyin/export_short_video.py +10 -1
  24. package/src/media_agent/platforms/douyin/login_controller.py +4 -15
  25. package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +4 -22
  26. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +8 -11
  27. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +5 -17
  28. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +63 -182
  29. package/src/media_agent/runtime/account_manager.py +16 -88
  30. package/src/media_agent/runtime/arguments.py +69 -0
  31. package/src/media_agent/runtime/diagnostics.py +186 -0
  32. package/src/media_agent/runtime/locking.py +74 -0
  33. package/src/media_agent/runtime/publish_control.py +98 -0
  34. package/src/node/skills.mjs +2 -1
  35. package/tools/artifacts.py +1 -0
  36. package/tools/install_runtime.py +5 -0
@@ -62,7 +62,7 @@ def _close_file(profile_id):
62
62
 
63
63
 
64
64
  # ─── Constants ───────────────────────────────────────────────────────────────
65
- DEDUP_STATES = {'submitted', 'reviewing', 'published'}
65
+ DEDUP_STATES = {'submitted', 'reviewing', 'published', 'indeterminate', 'confirming'}
66
66
  SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
67
67
  TZ_SHANGHAI = timezone(timedelta(hours=8))
68
68
  NOTE_POLL_INTERVAL = 18 # 交接文档要求 15–20 秒间隔只读检查
@@ -86,7 +86,6 @@ def sha256_file(path):
86
86
 
87
87
  def load_ledger():
88
88
  """Load all ledger entries as a list of dicts."""
89
- LEDGER_DIR.mkdir(parents=True, exist_ok=True)
90
89
  if not LEDGER_FILE.exists():
91
90
  return []
92
91
  entries = []
@@ -96,7 +95,7 @@ def load_ledger():
96
95
  try:
97
96
  entries.append(json.loads(line))
98
97
  except json.JSONDecodeError:
99
- pass
98
+ die(1, _kwargs(error='INVALID_LEDGER', path=str(LEDGER_FILE)))
100
99
  return entries
101
100
 
102
101
 
@@ -110,10 +109,11 @@ def find_ledger_entry(sha256):
110
109
 
111
110
  def check_duplicate(sha256):
112
111
  """Return (is_blocked, existing_entry) — True if SHA-256 is in a terminal dedup state."""
113
- entry = find_ledger_entry(sha256)
114
- if entry and entry.get('state') in DEDUP_STATES:
115
- return True, entry
116
- return False, entry
112
+ matching = [e for e in load_ledger() if e.get('sha256') == sha256]
113
+ for entry in reversed(matching):
114
+ if entry.get('state') in DEDUP_STATES:
115
+ return True, entry
116
+ return False, matching[-1] if matching else None
117
117
 
118
118
 
119
119
  def append_ledger(entry):
@@ -123,31 +123,24 @@ def append_ledger(entry):
123
123
  f.write(json.dumps(entry, ensure_ascii=False) + '\n')
124
124
 
125
125
 
126
+ _owned_locks = {}
127
+
128
+
126
129
  def acquire_lock(profile_id, task_id):
127
- """Acquire the profile lock. Returns (ok, existing_owner)."""
128
- lf = _lock_file(profile_id)
129
- LOCKS_DIR.mkdir(parents=True, exist_ok=True)
130
- if lf.exists():
131
- try:
132
- old = json.loads(lf.read_text())
133
- os.kill(old.get('pid', 0), 0)
134
- return False, old
135
- except (OSError, json.JSONDecodeError):
136
- lf.unlink()
137
- lf.write_text(json.dumps({
138
- 'task_id': task_id,
139
- 'pid': os.getpid(),
140
- 'time': datetime.now().isoformat(),
141
- 'phase': 'publish',
142
- }, indent=2))
143
- return True, None
130
+ from media_agent.runtime.locking import acquire
131
+ def initialize():
132
+ for signal_file in (_confirm_file(profile_id), _close_file(profile_id)):
133
+ signal_file.unlink(missing_ok=True)
134
+ ok = acquire(_lock_file(profile_id), task_id, initialize=initialize, phase='publish')
135
+ if ok:
136
+ _owned_locks[profile_id] = task_id
137
+ return ok, None
144
138
 
145
139
 
146
140
  def release_lock(profile_id):
147
- """Release the profile lock."""
148
- lf = _lock_file(profile_id)
149
- if lf.exists():
150
- lf.unlink(missing_ok=True)
141
+ from media_agent.runtime.locking import release
142
+ task_id = _owned_locks.get(profile_id)
143
+ return release(_lock_file(profile_id), task_id) if task_id else False
151
144
 
152
145
 
153
146
  def now_iso():
@@ -429,6 +422,7 @@ def phase_prepare(profile_id, media_path, title):
429
422
  if not ok:
430
423
  die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
431
424
 
425
+ confirmation_used = False
432
426
  log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
433
427
 
434
428
  try:
@@ -518,9 +512,6 @@ def phase_prepare(profile_id, media_path, title):
518
512
  SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
519
513
  cf = _confirm_file(profile_id)
520
514
  clf = _close_file(profile_id)
521
- for f in [cf, clf]:
522
- if f.exists():
523
- f.unlink(missing_ok=True)
524
515
 
525
516
  log(_kwargs(
526
517
  phase='prepare', step='awaiting_confirm', task_id=task_id,
@@ -529,29 +520,35 @@ def phase_prepare(profile_id, media_path, title):
529
520
 
530
521
  while True:
531
522
  if cf.exists():
532
- cf.unlink(missing_ok=True)
523
+ from media_agent.runtime.publish_control import consume_confirmation
524
+ valid = consume_confirmation(cf, profile_id, task_id, sha)
525
+ if not valid or confirmation_used:
526
+ log(_kwargs(step='confirmation_rejected', task_id=task_id))
527
+ continue
528
+ confirmation_used = True
529
+ ledger_entry['state'] = 'confirming'
530
+ append_ledger(ledger_entry)
533
531
  log(_kwargs(phase='prepare', step='confirm_received'))
534
532
 
535
533
  # 门禁:按钮必须唯一、可见且启用,否则不点击继续等待
536
534
  if not _inspect_publish_button(page):
537
535
  log(_kwargs(step='publish_button_not_ready', warning='waiting, no click performed'))
536
+ confirmation_used = False
537
+ ledger_entry['state'] = 'prepared'
538
+ append_ledger(ledger_entry)
538
539
  continue
539
540
 
540
541
  # 单次真实点击"发布"
541
- clicked = page.evaluate('''() => {
542
- for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
543
- const text = (el.textContent || '').trim();
544
- if (text === '发布' && el.offsetParent !== null) {
545
- el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
546
- el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
547
- el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
548
- return 'clicked:' + el.tagName + ':' + text;
549
- }
550
- }
551
- return 'not_found';
552
- }''')
542
+ from media_agent.runtime.publish_control import click_publish_once
543
+ clicked = click_publish_once(page)
544
+ if not clicked:
545
+ confirmation_used = False
546
+ ledger_entry['state'] = 'prepared'
547
+ append_ledger(ledger_entry)
548
+ log(_kwargs(step='publish_button_not_ready', task_id=task_id))
549
+ continue
553
550
  ledger_entry['click_count'] = 1
554
- log(_kwargs(step='publish_click', result=clicked, click_count=1))
551
+ log(_kwargs(step='publish_click', result='clicked', click_count=1))
555
552
  time.sleep(5)
556
553
 
557
554
  # ── 等待发布成功页(最长 180 秒)──
@@ -653,9 +650,11 @@ def phase_prepare(profile_id, media_path, title):
653
650
  break
654
651
 
655
652
  if clf.exists():
656
- clf.unlink(missing_ok=True)
653
+ from media_agent.runtime.publish_control import consume_close
654
+ if not consume_close(clf, task_id):
655
+ continue
657
656
  log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
658
- ledger_entry['state'] = 'indeterminate'
657
+ ledger_entry['state'] = 'indeterminate' if confirmation_used else 'aborted'
659
658
  ledger_entry['closed_at'] = now_iso()
660
659
  append_ledger(ledger_entry)
661
660
  break
@@ -667,7 +666,7 @@ def phase_prepare(profile_id, media_path, title):
667
666
  try:
668
667
  append_ledger(_kwargs(
669
668
  task_id=task_id, sha256=sha, path=str(path),
670
- state='failed', timestamp=now_iso(),
669
+ state='indeterminate' if confirmation_used else 'failed', timestamp=now_iso(),
671
670
  error=str(e)[:200], profile_id=profile_id,
672
671
  ))
673
672
  except Exception:
@@ -683,91 +682,22 @@ def phase_prepare(profile_id, media_path, title):
683
682
 
684
683
  # ─── Phase: confirm-submit ───────────────────────────────────────────────────
685
684
 
686
- def phase_confirm_submit(profile_id):
687
- """Send confirm signal to a running prepare process, or launch browser and click submit once."""
688
- SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
689
- lf = _lock_file(profile_id)
690
- cf = _confirm_file(profile_id)
691
-
692
- if lf.exists():
693
- try:
694
- lock_data = json.loads(lf.read_text())
695
- pid = lock_data.get('pid')
696
- if pid:
697
- os.kill(pid, 0)
698
- cf.write_text(json.dumps({
699
- 'action': 'confirm',
700
- 'timestamp': now_iso(),
701
- }))
702
- log(_kwargs(phase='confirm-submit', step='signal_sent', target_pid=pid))
703
- time.sleep(10)
704
- if not cf.exists():
705
- log(_kwargs(phase='confirm-submit', step='signal_consumed', status='ok'))
706
- else:
707
- log(_kwargs(phase='confirm-submit', step='signal_pending', status='waiting'))
708
- return
709
- except (OSError, json.JSONDecodeError):
710
- log(_kwargs(phase='confirm-submit', step='process_dead', action='launch_new'))
711
-
712
- log(_kwargs(phase='confirm-submit', step='launch_browser'))
713
-
685
+ def phase_confirm_submit(profile_id, task_id=None):
686
+ from media_agent.runtime.publish_control import send_confirmation
714
687
  try:
715
- ctx, cfg = launch_browser(profile_id)
716
- page = ctx.pages[0] if ctx.pages else ctx.new_page()
717
- except Exception as e:
718
- die(1, _kwargs(phase='confirm-submit', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
719
-
720
- try:
721
- page.goto(PUBLISH_URL, wait_until='load', timeout=60000)
722
- time.sleep(5)
723
-
724
- current_url = page.url
725
- if _is_login_page(current_url):
726
- ctx.close()
727
- die(2, _kwargs(phase='confirm-submit', error='LOGIN_REQUIRED', current_url=current_url[:120]))
728
-
729
- if not _inspect_publish_button(page):
730
- log(_kwargs(step='no_publish_button',
731
- warning='Upload page may not have a prepared video. Run xhs-publish-prepare first.'))
732
-
733
- clicked = page.evaluate('''() => {
734
- for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
735
- const text = (el.textContent || '').trim();
736
- if (text === '发布' && el.offsetParent !== null) {
737
- el.click();
738
- return 'clicked:' + text;
739
- }
740
- }
741
- return 'not_found';
742
- }''')
743
- log(_kwargs(step='confirm_fallback_click', result=clicked, click_count=1 if clicked != 'not_found' else 0))
744
- time.sleep(5)
745
- log(_kwargs(step='post_submit', url=page.url[:120], title=page.title()))
746
-
747
- entries = load_ledger()
748
- if entries:
749
- latest = entries[-1]
750
- latest['state'] = 'submitted'
751
- latest['submitted_at'] = now_iso()
752
- append_ledger(latest)
753
- log(_kwargs(step='ledger_updated', task_id=latest.get('task_id')))
754
-
755
- except Exception as e:
756
- log(_kwargs(phase='confirm-submit', error=str(e)[:300]))
757
- finally:
758
- try:
759
- ctx.close()
760
- except Exception:
761
- pass
762
- release_lock(profile_id)
763
- log(_kwargs(phase='confirm-submit', step='browser_closed'))
688
+ send_confirmation(_lock_file(profile_id), _confirm_file(profile_id),
689
+ load_ledger(), profile_id, task_id)
690
+ except ValueError as exc:
691
+ die(1, _kwargs(phase='confirm-submit', error=str(exc)))
692
+ log(_kwargs(phase='confirm-submit', step='signal_sent', task_id=task_id,
693
+ status='pending', hint='Query task status; a signal is not a publication result'))
764
694
 
765
695
 
766
696
  # ─── Phase: status ───────────────────────────────────────────────────────────
767
697
 
768
698
  def phase_status(profile_id, task_id=None):
769
699
  """Query publish status from the ledger."""
770
- entries = load_ledger()
700
+ entries = [e for e in load_ledger() if e.get('profile_id') == profile_id]
771
701
 
772
702
  if task_id:
773
703
  matching = [e for e in entries if e.get('task_id') == task_id]
@@ -809,61 +739,12 @@ def phase_status(profile_id, task_id=None):
809
739
  # ─── Phase: close ────────────────────────────────────────────────────────────
810
740
 
811
741
  def phase_close(profile_id):
812
- """Close the browser and release the lock."""
813
- SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
814
- lf = _lock_file(profile_id)
815
- clf = _close_file(profile_id)
816
-
817
- result = _kwargs(phase='close', action='none')
818
-
819
- if lf.exists():
820
- try:
821
- lock_data = json.loads(lf.read_text())
822
- pid = lock_data.get('pid')
823
- if pid:
824
- try:
825
- os.kill(pid, 0)
826
- clf.write_text(json.dumps({
827
- 'action': 'close',
828
- 'timestamp': now_iso(),
829
- }))
830
- result['action'] = 'signal_sent'
831
- result['pid'] = pid
832
- log(result)
833
-
834
- for _ in range(15):
835
- time.sleep(2)
836
- if not lf.exists():
837
- result['lock_released'] = True
838
- break
839
- try:
840
- os.kill(pid, 0)
841
- except OSError:
842
- result['lock_released'] = True
843
- release_lock(profile_id)
844
- break
845
-
846
- if not result.get('lock_released'):
847
- try:
848
- os.kill(pid, signal.SIGTERM)
849
- time.sleep(2)
850
- except OSError:
851
- pass
852
- release_lock(profile_id)
853
- result['action'] = 'force_killed'
854
- result['lock_released'] = True
855
-
856
- log(result)
857
- return
858
- except OSError:
859
- pass
860
- except (json.JSONDecodeError, ValueError):
861
- pass
862
-
863
- release_lock(profile_id)
864
- result['action'] = 'lock_released'
865
- result['lock_released'] = True
866
- log(result)
742
+ from media_agent.runtime.publish_control import request_close
743
+ try:
744
+ request_close(_lock_file(profile_id), _close_file(profile_id))
745
+ except ValueError as exc:
746
+ die(1, _kwargs(phase='close', error=str(exc), lock_released=False))
747
+ log(_kwargs(phase='close', action='signal_sent', lock_released=False))
867
748
 
868
749
 
869
750
  # ─── CLI ─────────────────────────────────────────────────────────────────────
@@ -908,7 +789,7 @@ Examples:
908
789
  phase_prepare(profile_id, args.media_path, args.title)
909
790
 
910
791
  elif args.confirm_submit:
911
- phase_confirm_submit(profile_id)
792
+ phase_confirm_submit(profile_id, args.task_id)
912
793
 
913
794
  elif args.status:
914
795
  phase_status(profile_id, args.task_id)
@@ -54,6 +54,7 @@ def save_accounts(accounts: list):
54
54
  """保存 accounts.yaml"""
55
55
  import yaml
56
56
  data = {"accounts": accounts}
57
+ ACCOUNTS_YAML.parent.mkdir(parents=True, exist_ok=True)
57
58
  with open(ACCOUNTS_YAML, "w", encoding="utf-8") as f:
58
59
  yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
59
60
  print(f"✅ 已保存 {len(accounts)} 个账号到 {ACCOUNTS_YAML}")
@@ -153,6 +154,8 @@ def init_profile(alias: str, account: dict):
153
154
  },
154
155
  "account": {
155
156
  "alias": alias,
157
+ "account_type": account.get("account_type", "personal"),
158
+ "capabilities": account.get("capabilities", []),
156
159
  "platform": account.get("platform", "unknown"),
157
160
  "label": account.get("label", alias),
158
161
  "login_method": account.get("login_method", "unknown"),
@@ -205,94 +208,13 @@ def lock_path(alias: str) -> Path:
205
208
 
206
209
 
207
210
  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
211
+ from media_agent.runtime.locking import acquire
212
+ return acquire(lock_path(alias), task_id)
270
213
 
271
214
 
272
215
  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
216
+ from media_agent.runtime.locking import release
217
+ return release(lock_path(alias), task_id)
296
218
 
297
219
 
298
220
  def list_locks() -> list[dict]:
@@ -394,9 +316,13 @@ def human_intervene(alias: str, reason: str, screenshot_path=None):
394
316
  # ═══════════════════════════════════════════════════════════════
395
317
  # CLI 入口
396
318
  # ═══════════════════════════════════════════════════════════════
397
- def cmd_init(alias: str, platform: str, label: str, login_method: str, notes: str = ""):
319
+ def cmd_init(alias: str, platform: str, label: str, login_method: str, notes: str = "", account_type: str = "personal", capabilities=None):
398
320
  """初始化新账号: init_account <alias> --platform <p> --label <l> --login <m>"""
321
+ from media_agent.runtime.arguments import profile_id
322
+ profile_id(alias)
399
323
  account = {
324
+ "account_type": account_type,
325
+ "capabilities": capabilities or [],
400
326
  "alias": alias,
401
327
  "platform": platform,
402
328
  "label": label,
@@ -441,7 +367,7 @@ def cmd_status(alias: str):
441
367
 
442
368
  if not config:
443
369
  print(f"❌ Profile [{alias}] 不存在")
444
- return
370
+ raise SystemExit(5)
445
371
 
446
372
  acct = find_account(alias) or config.get("account", {})
447
373
 
@@ -637,6 +563,8 @@ if __name__ == "__main__":
637
563
  p_init.add_argument("--label", required=True, help="中文备注")
638
564
  p_init.add_argument("--login", required=True, help="登录方式: phone_qr/phone_code/password/wechat")
639
565
  p_init.add_argument("--notes", default="", help="备注")
566
+ p_init.add_argument("--account-type", choices=["personal", "enterprise"], default="personal")
567
+ p_init.add_argument("--capability", action="append", default=[], choices=["creator_publish", "creator_index_collect", "leads_export", "leads_rankings", "leads_taxonomy"])
640
568
 
641
569
  # list
642
570
  sub.add_parser("list", help="列出所有账号")
@@ -659,7 +587,7 @@ if __name__ == "__main__":
659
587
  args = parser.parse_args()
660
588
 
661
589
  if args.command == "init":
662
- cmd_init(args.alias, args.platform, args.label, args.login, args.notes)
590
+ cmd_init(args.alias, args.platform, args.label, args.login, args.notes, args.account_type, args.capability)
663
591
  elif args.command == "list":
664
592
  cmd_list()
665
593
  elif args.command == "open":
@@ -0,0 +1,69 @@
1
+ """Validate public arguments before the legacy shell dispatcher can discard them."""
2
+ import argparse
3
+ import re
4
+
5
+
6
+ def profile_id(value):
7
+ if not re.fullmatch(r'[A-Za-z0-9_-]+', value):
8
+ raise argparse.ArgumentTypeError('invalid profile_id')
9
+ return value
10
+
11
+
12
+ def validate(argv):
13
+ if not argv or argv[0] in ('help', '--help', '-h', 'run-script'):
14
+ return argv
15
+ cmd = argv[0]
16
+ if cmd.startswith('validate-'):
17
+ return argv # These validators have their own argparse contracts.
18
+ p = argparse.ArgumentParser(prog='media-agent ' + cmd, allow_abbrev=False)
19
+ if cmd in ('list', 'ls', 'locks'):
20
+ p.parse_args(argv[1:])
21
+ return argv
22
+ known = {'init', 'status', 'st', 'close', 'enterprise-login', 'check-login',
23
+ 'collect-douyin-index', 'collect-video-rankings', 'collect-industry-taxonomy', 'export-short-video'}
24
+ for prefix in ('', 'xhs-', 'toutiao-'):
25
+ known.update(prefix + 'login-' + x for x in ('start', 'status', 'scan-done', 'refresh-qr', 'close'))
26
+ known.add(prefix + 'check-login')
27
+ known.update(('login-submit-code', 'login-resend-code', 'toutiao-login-verify-account'))
28
+ known.update(prefix + 'publish-' + x for prefix in ('', 'xhs-')
29
+ for x in ('dry-run', 'prepare', 'confirm', 'status', 'close'))
30
+ if cmd not in known:
31
+ p.error('unknown command')
32
+ p.add_argument('profile_id', type=profile_id)
33
+ if cmd == 'init':
34
+ for opt in ('platform', 'label', 'login'):
35
+ p.add_argument('--' + opt, required=True)
36
+ p.add_argument('--notes', default='')
37
+ p.add_argument('--account-type', choices=['personal', 'enterprise'], default='personal')
38
+ p.add_argument('--capability', action='append', default=[])
39
+ if cmd in ('check-login', 'enterprise-login'):
40
+ p.add_argument('--site', choices=['creator', 'leads', 'all'])
41
+ if cmd in ('toutiao-login-scan-done', 'toutiao-login-verify-account'):
42
+ p.add_argument('--expected-account-name', required=cmd.endswith('verify-account'))
43
+ if cmd.startswith('collect-') or cmd == 'export-short-video':
44
+ p.add_argument('--dry-run', action='store_true')
45
+ if cmd in ('export-short-video', 'collect-video-rankings'):
46
+ p.add_argument('--date')
47
+ if cmd in ('collect-douyin-index', 'collect-video-rankings'):
48
+ p.add_argument('--pages', type=int)
49
+ if cmd == 'collect-douyin-index':
50
+ p.add_argument('--lists')
51
+ if cmd == 'collect-video-rankings':
52
+ p.add_argument('--industry-l1')
53
+ p.add_argument('--industry-l2')
54
+ if 'publish-' in cmd:
55
+ if cmd.endswith(('dry-run', 'prepare')):
56
+ p.add_argument('--media-path', required=True)
57
+ if not cmd.startswith('xhs-'):
58
+ p.add_argument('--scheduled-at')
59
+ if cmd.endswith('prepare'):
60
+ p.add_argument('--title', required=cmd.startswith('xhs-'))
61
+ if not cmd.startswith('xhs-'):
62
+ p.add_argument('--description')
63
+ elif cmd.endswith(('confirm', 'status')):
64
+ p.add_argument('--task-id', required=cmd.endswith('confirm'))
65
+ p.parse_args(argv[1:])
66
+ normalized = []
67
+ for value in argv:
68
+ normalized.extend(value.split('=', 1) if value.startswith('--') and '=' in value else [value])
69
+ return normalized