@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.
- package/README.md +3 -3
- package/SHA256SUMS +34 -30
- package/bin/media-agent.mjs +19 -7
- package/docs/guides/capabilities.md +6 -2
- package/docs/guides/installation.md +42 -6
- package/manifest.json +74 -58
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/resources/capabilities.json +43 -1
- package/skills/douyin-creator-index/SKILL.md +2 -0
- package/skills/douyin-creator-publish/SKILL.md +3 -1
- package/skills/douyin-enterprise-short-video-export/SKILL.md +2 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +2 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +3 -1
- package/src/media_agent/cli.py +5 -0
- package/src/media_agent/commands.sh +20 -20
- package/src/media_agent/platforms/douyin/check_login.py +8 -11
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +10 -1
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +14 -5
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +12 -3
- package/src/media_agent/platforms/douyin/douyin_publish.py +65 -218
- package/src/media_agent/platforms/douyin/enterprise_login.py +6 -11
- package/src/media_agent/platforms/douyin/export_short_video.py +10 -1
- package/src/media_agent/platforms/douyin/login_controller.py +4 -15
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +4 -22
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +8 -11
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +5 -17
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +63 -182
- package/src/media_agent/runtime/account_manager.py +16 -88
- package/src/media_agent/runtime/arguments.py +69 -0
- package/src/media_agent/runtime/diagnostics.py +186 -0
- package/src/media_agent/runtime/locking.py +74 -0
- package/src/media_agent/runtime/publish_control.py +98 -0
- package/src/node/skills.mjs +2 -1
- package/tools/artifacts.py +1 -0
- 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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
except
|
|
718
|
-
die(1, _kwargs(phase='confirm-submit', error=
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|