@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
@@ -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 | SUPPORTED_IMAGE_FORMATS
65
- DEFAULT_SCHEDULED_AT = '2026-08-04T23:50:00+08:00'
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
- pass
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
- entry = find_ledger_entry(sha256)
111
- if entry and entry.get('state') in DEDUP_STATES:
112
- return True, entry
113
- return False, entry
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
- """Acquire the profile lock. Returns (ok, existing_owner)."""
125
- lf = _lock_file(profile_id)
126
- LOCKS_DIR.mkdir(parents=True, exist_ok=True)
127
- if lf.exists():
128
- try:
129
- old = json.loads(lf.read_text())
130
- os.kill(old.get('pid', 0), 0)
131
- return False, old
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
- """Release the profile lock."""
145
- lf = _lock_file(profile_id)
146
- if lf.exists():
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
- cf.unlink(missing_ok=True)
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
- clicked = page.evaluate('''() => {
720
- for (const el of document.querySelectorAll('button')) {
721
- const text = (el.textContent || '').trim();
722
- if (text === '发布' && el.offsetParent !== null) {
723
- // Dispatch full mouse event sequence for React button
724
- el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
725
- el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
726
- el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
727
- return 'clicked:button:' + text;
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'] = 'failed'
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
- clf.unlink(missing_ok=True)
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
- """Send confirm signal to a running prepare process, or launch browser and click submit."""
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
- ctx, cfg = launch_browser(profile_id)
905
- page = ctx.pages[0] if ctx.pages else ctx.new_page()
906
- except Exception as e:
907
- die(1, _kwargs(phase='confirm-submit', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
908
-
909
- try:
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
- """Close the browser and release the lock."""
1023
- SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
1024
- lf = _lock_file(profile_id)
1025
- clf = _close_file(profile_id)
1026
-
1027
- result = _kwargs(phase='close', action='none')
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', default=DEFAULT_PROFILE_ID, help='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
- LOCK_FILE.write_text(json.dumps({'task_id': TASK_ID, 'locked_at': datetime.now(timezone.utc).isoformat(), 'pid': os.getpid(), 'site': SITE}, indent=2))
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.unlink(missing_ok=True)
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
- self.lock_file.parent.mkdir(parents=True, exist_ok=True)
37
- if self.lock_file.exists():
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
- if self.lock_file.exists():
52
- self.lock_file.unlink()
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
- self.lock_path.parent.mkdir(parents=True, exist_ok=True)
69
- if self.lock_path.exists():
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
- if not self.lock_path.exists():
83
- return True
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
- LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
27
- if LOCK_FILE.exists():
28
- try:
29
- os.kill(json.loads(LOCK_FILE.read_text()).get('pid', 0), 0)
30
- print(json.dumps({'profile_id': ALIAS, 'login_status': 'locked', 'lock_status': 'locked'}, indent=2))
31
- sys.exit(3)
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.unlink(missing_ok=True)
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.unlink(missing_ok=True)
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
- if LOCK_FILE.exists():
72
- try:
73
- old = json.loads(LOCK_FILE.read_text())
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
- try:
87
- LOCK_FILE.unlink(missing_ok=True)
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."""