@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
|
@@ -29,7 +29,6 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
31
31
|
BASE = runtime_home()
|
|
32
|
-
DEFAULT_PROFILE_ID = 'douyin_creator_mengjun_ecommerce'
|
|
33
32
|
LOCKS_DIR = BASE / 'locks'
|
|
34
33
|
LEDGER_DIR = BASE / 'tasks' / 'douyin_publish'
|
|
35
34
|
LEDGER_FILE = LEDGER_DIR / 'publish-ledger.jsonl'
|
|
@@ -58,11 +57,11 @@ def _close_file(profile_id):
|
|
|
58
57
|
|
|
59
58
|
|
|
60
59
|
# ─── Constants ───────────────────────────────────────────────────────────────
|
|
61
|
-
DEDUP_STATES = {'submitted', 'scheduled', 'reviewing', 'published'}
|
|
60
|
+
DEDUP_STATES = {'submitted', 'scheduled', 'reviewing', 'published', 'indeterminate', 'confirming'}
|
|
62
61
|
SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
|
|
63
62
|
SUPPORTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'}
|
|
64
|
-
SUPPORTED_FORMATS = SUPPORTED_VIDEO_FORMATS
|
|
65
|
-
DEFAULT_SCHEDULED_AT =
|
|
63
|
+
SUPPORTED_FORMATS = SUPPORTED_VIDEO_FORMATS
|
|
64
|
+
DEFAULT_SCHEDULED_AT = None
|
|
66
65
|
TZ_SHANGHAI = timezone(timedelta(hours=8))
|
|
67
66
|
|
|
68
67
|
|
|
@@ -82,7 +81,6 @@ def sha256_file(path):
|
|
|
82
81
|
|
|
83
82
|
def load_ledger():
|
|
84
83
|
"""Load all ledger entries as a list of dicts."""
|
|
85
|
-
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
84
|
if not LEDGER_FILE.exists():
|
|
87
85
|
return []
|
|
88
86
|
entries = []
|
|
@@ -92,7 +90,7 @@ def load_ledger():
|
|
|
92
90
|
try:
|
|
93
91
|
entries.append(json.loads(line))
|
|
94
92
|
except json.JSONDecodeError:
|
|
95
|
-
|
|
93
|
+
die(1, _kwargs(error='INVALID_LEDGER', path=str(LEDGER_FILE)))
|
|
96
94
|
return entries
|
|
97
95
|
|
|
98
96
|
|
|
@@ -107,10 +105,11 @@ def find_ledger_entry(sha256):
|
|
|
107
105
|
|
|
108
106
|
def check_duplicate(sha256):
|
|
109
107
|
"""Return (is_blocked, existing_entry) — True if SHA-256 is in a terminal dedup state."""
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
matching = [e for e in load_ledger() if e.get('sha256') == sha256]
|
|
109
|
+
for entry in reversed(matching):
|
|
110
|
+
if entry.get('state') in DEDUP_STATES:
|
|
111
|
+
return True, entry
|
|
112
|
+
return False, matching[-1] if matching else None
|
|
114
113
|
|
|
115
114
|
|
|
116
115
|
def append_ledger(entry):
|
|
@@ -120,31 +119,24 @@ def append_ledger(entry):
|
|
|
120
119
|
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
121
120
|
|
|
122
121
|
|
|
122
|
+
_owned_locks = {}
|
|
123
|
+
|
|
124
|
+
|
|
123
125
|
def acquire_lock(profile_id, task_id):
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
except (OSError, json.JSONDecodeError):
|
|
133
|
-
lf.unlink()
|
|
134
|
-
lf.write_text(json.dumps({
|
|
135
|
-
'task_id': task_id,
|
|
136
|
-
'pid': os.getpid(),
|
|
137
|
-
'time': datetime.now().isoformat(),
|
|
138
|
-
'phase': 'publish',
|
|
139
|
-
}, indent=2))
|
|
140
|
-
return True, None
|
|
126
|
+
from media_agent.runtime.locking import acquire
|
|
127
|
+
def initialize():
|
|
128
|
+
for signal_file in (_confirm_file(profile_id), _close_file(profile_id)):
|
|
129
|
+
signal_file.unlink(missing_ok=True)
|
|
130
|
+
ok = acquire(_lock_file(profile_id), task_id, initialize=initialize, phase='publish')
|
|
131
|
+
if ok:
|
|
132
|
+
_owned_locks[profile_id] = task_id
|
|
133
|
+
return ok, None
|
|
141
134
|
|
|
142
135
|
|
|
143
136
|
def release_lock(profile_id):
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
lf.unlink(missing_ok=True)
|
|
137
|
+
from media_agent.runtime.locking import release
|
|
138
|
+
task_id = _owned_locks.get(profile_id)
|
|
139
|
+
return release(_lock_file(profile_id), task_id) if task_id else False
|
|
148
140
|
|
|
149
141
|
|
|
150
142
|
def now_iso():
|
|
@@ -608,6 +600,7 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
608
600
|
if not ok:
|
|
609
601
|
die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
|
|
610
602
|
|
|
603
|
+
confirmation_used = False
|
|
611
604
|
log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
|
|
612
605
|
|
|
613
606
|
try:
|
|
@@ -702,9 +695,6 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
702
695
|
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
703
696
|
cf = _confirm_file(profile_id)
|
|
704
697
|
clf = _close_file(profile_id)
|
|
705
|
-
for f in [cf, clf]:
|
|
706
|
-
if f.exists():
|
|
707
|
-
f.unlink(missing_ok=True)
|
|
708
698
|
|
|
709
699
|
log(_kwargs(
|
|
710
700
|
phase='prepare', step='awaiting_confirm', task_id=task_id,
|
|
@@ -713,32 +703,26 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
713
703
|
|
|
714
704
|
while True:
|
|
715
705
|
if cf.exists():
|
|
716
|
-
|
|
706
|
+
from media_agent.runtime.publish_control import consume_confirmation
|
|
707
|
+
valid = consume_confirmation(cf, profile_id, task_id, sha)
|
|
708
|
+
if not valid or confirmation_used:
|
|
709
|
+
log(_kwargs(step='confirmation_rejected', task_id=task_id))
|
|
710
|
+
continue
|
|
711
|
+
confirmation_used = True
|
|
712
|
+
ledger_entry['state'] = 'confirming'
|
|
713
|
+
append_ledger(ledger_entry)
|
|
717
714
|
log(_kwargs(phase='prepare', step='confirm_received'))
|
|
718
715
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
for (const el of document.querySelectorAll('div, span')) {
|
|
731
|
-
const text = (el.textContent || '').trim();
|
|
732
|
-
if (text === '发布' && el.offsetParent !== null) {
|
|
733
|
-
el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
|
|
734
|
-
el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
|
|
735
|
-
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
|
736
|
-
return 'clicked:' + el.tagName + ':' + text;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
return 'not_found';
|
|
740
|
-
}''')
|
|
741
|
-
log(_kwargs(step='publish_click', result=clicked))
|
|
716
|
+
from media_agent.runtime.publish_control import click_publish_once
|
|
717
|
+
clicked = click_publish_once(page)
|
|
718
|
+
if not clicked:
|
|
719
|
+
confirmation_used = False
|
|
720
|
+
ledger_entry['state'] = 'prepared'
|
|
721
|
+
append_ledger(ledger_entry)
|
|
722
|
+
log(_kwargs(step='publish_button_not_ready', task_id=task_id))
|
|
723
|
+
continue
|
|
724
|
+
ledger_entry['click_count'] = 1
|
|
725
|
+
log(_kwargs(step='publish_click', result='clicked', click_count=1))
|
|
742
726
|
time.sleep(5)
|
|
743
727
|
|
|
744
728
|
# Wait for platform auto-navigation (up to 3 minutes)
|
|
@@ -834,7 +818,7 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
834
818
|
append_ledger(ledger_entry)
|
|
835
819
|
log(_kwargs(phase='prepare', step='submitted_after_poll', task_id=task_id))
|
|
836
820
|
else:
|
|
837
|
-
ledger_entry['state'] = '
|
|
821
|
+
ledger_entry['state'] = 'indeterminate'
|
|
838
822
|
ledger_entry['failed_at'] = now_iso()
|
|
839
823
|
ledger_entry['failed_reason'] = 'work_not_found_in_manage'
|
|
840
824
|
append_ledger(ledger_entry)
|
|
@@ -842,9 +826,11 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
842
826
|
break
|
|
843
827
|
|
|
844
828
|
if clf.exists():
|
|
845
|
-
|
|
829
|
+
from media_agent.runtime.publish_control import consume_close
|
|
830
|
+
if not consume_close(clf, task_id):
|
|
831
|
+
continue
|
|
846
832
|
log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
|
|
847
|
-
ledger_entry['state'] = 'indeterminate'
|
|
833
|
+
ledger_entry['state'] = 'indeterminate' if confirmation_used else 'aborted'
|
|
848
834
|
ledger_entry['closed_at'] = now_iso()
|
|
849
835
|
append_ledger(ledger_entry)
|
|
850
836
|
break
|
|
@@ -856,7 +842,7 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
856
842
|
try:
|
|
857
843
|
append_ledger(_kwargs(
|
|
858
844
|
task_id=task_id, sha256=sha, path=str(path),
|
|
859
|
-
state='failed', timestamp=now_iso(),
|
|
845
|
+
state='indeterminate' if confirmation_used else 'failed', timestamp=now_iso(),
|
|
860
846
|
error=str(e)[:200], profile_id=profile_id,
|
|
861
847
|
))
|
|
862
848
|
except Exception:
|
|
@@ -872,112 +858,22 @@ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=
|
|
|
872
858
|
|
|
873
859
|
# ─── Phase: confirm-submit ───────────────────────────────────────────────────
|
|
874
860
|
|
|
875
|
-
def phase_confirm_submit(profile_id):
|
|
876
|
-
|
|
877
|
-
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
878
|
-
lf = _lock_file(profile_id)
|
|
879
|
-
cf = _confirm_file(profile_id)
|
|
880
|
-
|
|
881
|
-
if lf.exists():
|
|
882
|
-
try:
|
|
883
|
-
lock_data = json.loads(lf.read_text())
|
|
884
|
-
pid = lock_data.get('pid')
|
|
885
|
-
if pid:
|
|
886
|
-
os.kill(pid, 0)
|
|
887
|
-
cf.write_text(json.dumps({
|
|
888
|
-
'action': 'confirm',
|
|
889
|
-
'timestamp': now_iso(),
|
|
890
|
-
}))
|
|
891
|
-
log(_kwargs(phase='confirm-submit', step='signal_sent', target_pid=pid))
|
|
892
|
-
time.sleep(10)
|
|
893
|
-
if not cf.exists():
|
|
894
|
-
log(_kwargs(phase='confirm-submit', step='signal_consumed', status='ok'))
|
|
895
|
-
else:
|
|
896
|
-
log(_kwargs(phase='confirm-submit', step='signal_pending', status='waiting'))
|
|
897
|
-
return
|
|
898
|
-
except (OSError, json.JSONDecodeError):
|
|
899
|
-
log(_kwargs(phase='confirm-submit', step='process_dead', action='launch_new'))
|
|
900
|
-
|
|
901
|
-
log(_kwargs(phase='confirm-submit', step='launch_browser'))
|
|
902
|
-
|
|
861
|
+
def phase_confirm_submit(profile_id, task_id=None):
|
|
862
|
+
from media_agent.runtime.publish_control import send_confirmation
|
|
903
863
|
try:
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
except
|
|
907
|
-
die(1, _kwargs(phase='confirm-submit', error=
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
page.goto(UPLOAD_URL, wait_until='load', timeout=60000)
|
|
911
|
-
time.sleep(5)
|
|
912
|
-
|
|
913
|
-
current_url = page.url
|
|
914
|
-
if 'login' in current_url.lower() or 'passport' in current_url.lower():
|
|
915
|
-
ctx.close()
|
|
916
|
-
die(2, _kwargs(phase='confirm-submit', error='LOGIN_REQUIRED', current_url=current_url[:120]))
|
|
917
|
-
|
|
918
|
-
time.sleep(3)
|
|
919
|
-
inspect = page.evaluate('''() => {
|
|
920
|
-
const buttons = [];
|
|
921
|
-
for (const el of document.querySelectorAll('button, div[role="button"], span')) {
|
|
922
|
-
const text = (el.textContent || '').trim();
|
|
923
|
-
if ((text === '发布' || text.includes('发布') || text === '提交') && el.offsetParent !== null) {
|
|
924
|
-
const rect = el.getBoundingClientRect();
|
|
925
|
-
buttons.push({
|
|
926
|
-
text: text.substring(0, 20),
|
|
927
|
-
tag: el.tagName,
|
|
928
|
-
bounds: {x: Math.round(rect.x), y: Math.round(rect.y),
|
|
929
|
-
w: Math.round(rect.width), h: Math.round(rect.height)},
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
return {button_count: buttons.length, buttons: buttons.slice(0, 5)};
|
|
934
|
-
}''')
|
|
935
|
-
log(_kwargs(step='inspect', **inspect))
|
|
936
|
-
|
|
937
|
-
if inspect.get('button_count', 0) == 0:
|
|
938
|
-
log(_kwargs(step='no_publish_button', warning='Upload page may not have a prepared video. Run prepare first.'))
|
|
939
|
-
|
|
940
|
-
clicked = page.evaluate('''() => {
|
|
941
|
-
for (const el of document.querySelectorAll('button, div, span')) {
|
|
942
|
-
const text = (el.textContent || '').trim();
|
|
943
|
-
if ((text === '发布' || text === '确认发布' || text === '提交') &&
|
|
944
|
-
el.offsetParent !== null) {
|
|
945
|
-
el.click();
|
|
946
|
-
return 'clicked:' + text;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
return 'not_found';
|
|
950
|
-
}''')
|
|
951
|
-
log(_kwargs(step='publish_click', result=clicked))
|
|
952
|
-
time.sleep(5)
|
|
953
|
-
|
|
954
|
-
url_after = page.url
|
|
955
|
-
log(_kwargs(step='post_submit', url=url_after[:120], title=page.title()))
|
|
956
|
-
|
|
957
|
-
entries = load_ledger()
|
|
958
|
-
if entries:
|
|
959
|
-
latest = entries[-1]
|
|
960
|
-
latest['state'] = 'submitted'
|
|
961
|
-
latest['submitted_at'] = now_iso()
|
|
962
|
-
append_ledger(latest)
|
|
963
|
-
log(_kwargs(step='ledger_updated', task_id=latest.get('task_id')))
|
|
964
|
-
|
|
965
|
-
except Exception as e:
|
|
966
|
-
log(_kwargs(phase='confirm-submit', error=str(e)[:300]))
|
|
967
|
-
finally:
|
|
968
|
-
try:
|
|
969
|
-
ctx.close()
|
|
970
|
-
except Exception:
|
|
971
|
-
pass
|
|
972
|
-
release_lock(profile_id)
|
|
973
|
-
log(_kwargs(phase='confirm-submit', step='browser_closed'))
|
|
864
|
+
send_confirmation(_lock_file(profile_id), _confirm_file(profile_id),
|
|
865
|
+
load_ledger(), profile_id, task_id)
|
|
866
|
+
except ValueError as exc:
|
|
867
|
+
die(1, _kwargs(phase='confirm-submit', error=str(exc)))
|
|
868
|
+
log(_kwargs(phase='confirm-submit', step='signal_sent', task_id=task_id,
|
|
869
|
+
status='pending', hint='Query task status; a signal is not a publication result'))
|
|
974
870
|
|
|
975
871
|
|
|
976
872
|
# ─── Phase: status ───────────────────────────────────────────────────────────
|
|
977
873
|
|
|
978
874
|
def phase_status(profile_id, task_id=None):
|
|
979
875
|
"""Query publish status from the ledger."""
|
|
980
|
-
entries = load_ledger()
|
|
876
|
+
entries = [e for e in load_ledger() if e.get('profile_id') == profile_id]
|
|
981
877
|
|
|
982
878
|
if task_id:
|
|
983
879
|
matching = [e for e in entries if e.get('task_id') == task_id]
|
|
@@ -1019,61 +915,12 @@ def phase_status(profile_id, task_id=None):
|
|
|
1019
915
|
# ─── Phase: close ────────────────────────────────────────────────────────────
|
|
1020
916
|
|
|
1021
917
|
def phase_close(profile_id):
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
if lf.exists():
|
|
1030
|
-
try:
|
|
1031
|
-
lock_data = json.loads(lf.read_text())
|
|
1032
|
-
pid = lock_data.get('pid')
|
|
1033
|
-
if pid:
|
|
1034
|
-
try:
|
|
1035
|
-
os.kill(pid, 0)
|
|
1036
|
-
clf.write_text(json.dumps({
|
|
1037
|
-
'action': 'close',
|
|
1038
|
-
'timestamp': now_iso(),
|
|
1039
|
-
}))
|
|
1040
|
-
result['action'] = 'signal_sent'
|
|
1041
|
-
result['pid'] = pid
|
|
1042
|
-
log(result)
|
|
1043
|
-
|
|
1044
|
-
for _ in range(15):
|
|
1045
|
-
time.sleep(2)
|
|
1046
|
-
if not lf.exists():
|
|
1047
|
-
result['lock_released'] = True
|
|
1048
|
-
break
|
|
1049
|
-
try:
|
|
1050
|
-
os.kill(pid, 0)
|
|
1051
|
-
except OSError:
|
|
1052
|
-
result['lock_released'] = True
|
|
1053
|
-
release_lock(profile_id)
|
|
1054
|
-
break
|
|
1055
|
-
|
|
1056
|
-
if not result.get('lock_released'):
|
|
1057
|
-
try:
|
|
1058
|
-
os.kill(pid, signal.SIGTERM)
|
|
1059
|
-
time.sleep(2)
|
|
1060
|
-
except OSError:
|
|
1061
|
-
pass
|
|
1062
|
-
release_lock(profile_id)
|
|
1063
|
-
result['action'] = 'force_killed'
|
|
1064
|
-
result['lock_released'] = True
|
|
1065
|
-
|
|
1066
|
-
log(result)
|
|
1067
|
-
return
|
|
1068
|
-
except OSError:
|
|
1069
|
-
pass
|
|
1070
|
-
except (json.JSONDecodeError, ValueError):
|
|
1071
|
-
pass
|
|
1072
|
-
|
|
1073
|
-
release_lock(profile_id)
|
|
1074
|
-
result['action'] = 'lock_released'
|
|
1075
|
-
result['lock_released'] = True
|
|
1076
|
-
log(result)
|
|
918
|
+
from media_agent.runtime.publish_control import request_close
|
|
919
|
+
try:
|
|
920
|
+
request_close(_lock_file(profile_id), _close_file(profile_id))
|
|
921
|
+
except ValueError as exc:
|
|
922
|
+
die(1, _kwargs(phase='close', error=str(exc), lock_released=False))
|
|
923
|
+
log(_kwargs(phase='close', action='signal_sent', lock_released=False))
|
|
1077
924
|
|
|
1078
925
|
|
|
1079
926
|
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
@@ -1101,7 +948,7 @@ Examples:
|
|
|
1101
948
|
parser.add_argument('--media-path', help='Path to media file (required for --dry-run, --prepare)')
|
|
1102
949
|
parser.add_argument('--scheduled-at', help=f'Scheduled publish time (default: {DEFAULT_SCHEDULED_AT})')
|
|
1103
950
|
parser.add_argument('--task-id', default=None, help='Task ID for status query')
|
|
1104
|
-
parser.add_argument('--profile-id',
|
|
951
|
+
parser.add_argument('--profile-id', required=True, help='Profile ID')
|
|
1105
952
|
parser.add_argument('--title', default=None, help='Video title')
|
|
1106
953
|
parser.add_argument('--description', default=None, help='Video description')
|
|
1107
954
|
args = parser.parse_args()
|
|
@@ -1118,7 +965,7 @@ Examples:
|
|
|
1118
965
|
phase_prepare(profile_id, args.media_path, args.scheduled_at, args.title, args.description)
|
|
1119
966
|
|
|
1120
967
|
elif args.confirm_submit:
|
|
1121
|
-
phase_confirm_submit(profile_id)
|
|
968
|
+
phase_confirm_submit(profile_id, args.task_id)
|
|
1122
969
|
|
|
1123
970
|
elif args.status:
|
|
1124
971
|
phase_status(profile_id, args.task_id)
|
|
@@ -23,17 +23,12 @@ def main():
|
|
|
23
23
|
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
24
|
|
|
25
25
|
# ===== Lock =====
|
|
26
|
-
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
-
if LOCK_FILE.exists():
|
|
28
|
-
try:
|
|
29
|
-
d = json.loads(LOCK_FILE.read_text()); pid = d.get('pid')
|
|
30
|
-
if pid:
|
|
31
|
-
try: os.kill(pid, 0); print(f'STATUS|PROFILE_LOCKED|pid={pid}', flush=True); sys.exit(3)
|
|
32
|
-
except OSError: LOCK_FILE.unlink()
|
|
33
|
-
except: LOCK_FILE.unlink()
|
|
34
|
-
|
|
35
26
|
TASK_ID = f'login_{SITE}_{ALIAS}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
|
36
|
-
|
|
27
|
+
from media_agent.runtime.locking import acquire, release
|
|
28
|
+
if not acquire(LOCK_FILE, TASK_ID):
|
|
29
|
+
print('PROFILE_LOCKED')
|
|
30
|
+
raise SystemExit(3)
|
|
31
|
+
|
|
37
32
|
print(f'LOCK|acquired|{TASK_ID}', flush=True)
|
|
38
33
|
|
|
39
34
|
# ===== Launch =====
|
|
@@ -120,7 +115,7 @@ def main():
|
|
|
120
115
|
# Close
|
|
121
116
|
ctx.close()
|
|
122
117
|
print('CLOSED', flush=True)
|
|
123
|
-
LOCK_FILE
|
|
118
|
+
release(LOCK_FILE, TASK_ID)
|
|
124
119
|
print('LOCK_RELEASED', flush=True)
|
|
125
120
|
print('DONE', flush=True)
|
|
126
121
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
"""Legacy operation, executed only through main()."""
|
|
2
2
|
|
|
3
3
|
def main():
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
print(json.dumps({'state': 'IMPLEMENTATION_REQUIRED',
|
|
7
|
+
'operation': 'export_short_video',
|
|
8
|
+
'reason': 'Legacy browser flow cannot yet verify all requested parameters; use the documented Skill workflow.'}))
|
|
9
|
+
raise SystemExit(6)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _legacy_reference():
|
|
4
13
|
#!/usr/bin/env python3
|
|
5
14
|
"""Export short video detail data. Usage: python3 export_short_video.py <profile_id> <date>"""
|
|
6
15
|
from media_agent.runtime.paths import runtime_home
|
|
@@ -22,7 +31,7 @@ def main():
|
|
|
22
31
|
try:
|
|
23
32
|
os.kill(json.loads(LOCK_FILE.read_text()).get('pid', 0), 0)
|
|
24
33
|
print('PROFILE_LOCKED'); sys.exit(3)
|
|
25
|
-
except:
|
|
34
|
+
except Exception:
|
|
26
35
|
LOCK_FILE.unlink()
|
|
27
36
|
LOCK_FILE.write_text(json.dumps({'task_id': 'export-short-video', 'pid': os.getpid()}, indent=2))
|
|
28
37
|
|
|
@@ -33,23 +33,12 @@ class LoginController:
|
|
|
33
33
|
self._resp_queues = {}
|
|
34
34
|
|
|
35
35
|
def acquire_lock(self):
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
old = json.loads(self.lock_file.read_text())
|
|
40
|
-
os.kill(old.get('pid', 0), 0)
|
|
41
|
-
return False
|
|
42
|
-
except (OSError, json.JSONDecodeError):
|
|
43
|
-
self.lock_file.unlink()
|
|
44
|
-
self.lock_file.write_text(json.dumps({
|
|
45
|
-
'task_id': self.task_id, 'pid': self.controller_pid,
|
|
46
|
-
'time': datetime.now().isoformat(), 'controller': True
|
|
47
|
-
}, indent=2))
|
|
48
|
-
return True
|
|
36
|
+
from media_agent.runtime.locking import acquire
|
|
37
|
+
return acquire(self.lock_file, self.task_id, controller=True)
|
|
49
38
|
|
|
50
39
|
def release_lock(self):
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
from media_agent.runtime.locking import release
|
|
41
|
+
return release(self.lock_file, self.task_id)
|
|
53
42
|
|
|
54
43
|
def launch_browser(self):
|
|
55
44
|
cfg = json.loads((self.profile_dir / 'config.json').read_text())
|
|
@@ -65,30 +65,12 @@ class ToutiaoLoginController:
|
|
|
65
65
|
return False
|
|
66
66
|
|
|
67
67
|
def acquire_lock(self) -> bool:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
current = json.loads(self.lock_path.read_text())
|
|
72
|
-
except (OSError, json.JSONDecodeError):
|
|
73
|
-
return False
|
|
74
|
-
if self._alive(current.get("pid")):
|
|
75
|
-
return False
|
|
76
|
-
self.lock_path.unlink()
|
|
77
|
-
self.lock_path.write_text(json.dumps({"pid": self.pid, "task_id": self.task_id,
|
|
78
|
-
"created_at": datetime.now(timezone.utc).isoformat()}))
|
|
79
|
-
return True
|
|
68
|
+
from media_agent.runtime.locking import acquire
|
|
69
|
+
return acquire(self.lock_path, self.task_id, controller=True)
|
|
80
70
|
|
|
81
71
|
def release_owned_lock(self) -> bool:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
current = json.loads(self.lock_path.read_text())
|
|
86
|
-
except (OSError, json.JSONDecodeError):
|
|
87
|
-
return False
|
|
88
|
-
if current.get("pid") != self.pid or current.get("task_id") != self.task_id:
|
|
89
|
-
return False
|
|
90
|
-
self.lock_path.unlink()
|
|
91
|
-
return True
|
|
72
|
+
from media_agent.runtime.locking import release
|
|
73
|
+
return release(self.lock_path, self.task_id)
|
|
92
74
|
|
|
93
75
|
def launch(self, config: dict) -> None:
|
|
94
76
|
if self.launcher is None:
|
|
@@ -23,15 +23,12 @@ def main():
|
|
|
23
23
|
cfg = json.loads((PROFILE_DIR / 'config.json').read_text())
|
|
24
24
|
state = json.loads((PROFILE_DIR / 'state.json').read_text()) if (PROFILE_DIR / 'state.json').exists() else {}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
except:
|
|
33
|
-
LOCK_FILE.unlink()
|
|
34
|
-
LOCK_FILE.write_text(json.dumps({'task_id': 'xhs-check-login', 'pid': os.getpid(), 'time': datetime.now().isoformat()}, indent=2))
|
|
26
|
+
TASK_ID = 'xhs-check-login'
|
|
27
|
+
from media_agent.runtime.locking import acquire, release
|
|
28
|
+
if not acquire(LOCK_FILE, TASK_ID):
|
|
29
|
+
print('PROFILE_LOCKED')
|
|
30
|
+
raise SystemExit(3)
|
|
31
|
+
|
|
35
32
|
|
|
36
33
|
ctx = None
|
|
37
34
|
try:
|
|
@@ -143,7 +140,7 @@ def main():
|
|
|
143
140
|
dashboard = status == 'logged_in' or dom['dashboard_detected']
|
|
144
141
|
|
|
145
142
|
ctx.close()
|
|
146
|
-
LOCK_FILE
|
|
143
|
+
release(LOCK_FILE, TASK_ID)
|
|
147
144
|
|
|
148
145
|
# Update state
|
|
149
146
|
site_state = state.setdefault('xiaohongshu', {})
|
|
@@ -180,7 +177,7 @@ def main():
|
|
|
180
177
|
ctx.close()
|
|
181
178
|
except Exception:
|
|
182
179
|
pass
|
|
183
|
-
LOCK_FILE
|
|
180
|
+
release(LOCK_FILE, TASK_ID)
|
|
184
181
|
print(json.dumps({'profile_id': ALIAS, 'login_status': 'check_failed', 'error': str(e)[:100]}, indent=2))
|
|
185
182
|
sys.exit(1)
|
|
186
183
|
|
|
@@ -68,25 +68,13 @@ def process_alive(pid):
|
|
|
68
68
|
raise
|
|
69
69
|
|
|
70
70
|
def acquire_lock():
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
except (OSError, json.JSONDecodeError):
|
|
75
|
-
return False
|
|
76
|
-
if process_alive(old.get('pid')):
|
|
77
|
-
return False
|
|
78
|
-
try:
|
|
79
|
-
LOCK_FILE.unlink()
|
|
80
|
-
except OSError:
|
|
81
|
-
return False
|
|
82
|
-
LOCK_FILE.write_text(json.dumps({'pid': os.getpid(), 'time': now_iso(), 'task_id': task_id}))
|
|
83
|
-
return True
|
|
71
|
+
from media_agent.runtime.locking import acquire
|
|
72
|
+
return acquire(LOCK_FILE, task_id, controller=True)
|
|
73
|
+
|
|
84
74
|
|
|
85
75
|
def release_lock():
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
except:
|
|
89
|
-
pass
|
|
76
|
+
from media_agent.runtime.locking import release
|
|
77
|
+
return release(LOCK_FILE, task_id)
|
|
90
78
|
|
|
91
79
|
def check_logged_in():
|
|
92
80
|
"""Check if current page shows dashboard."""
|