@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
@@ -0,0 +1,186 @@
1
+ """Read-only inventory for task inspection and upgrade planning."""
2
+ import argparse
3
+ from datetime import datetime, timezone
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import tempfile
10
+ from .locking import guard, process_alive
11
+ from .paths import runtime_home
12
+
13
+ UNFINISHED = {'prepared', 'preparing', 'uploading', 'confirming', 'submitting',
14
+ 'verifying', 'indeterminate'}
15
+ SETTLED = {'submitted', 'reviewing', 'published', 'scheduled', 'rejected', 'failed', 'aborted'}
16
+
17
+
18
+ def tasks(home):
19
+ latest, errors = {}, []
20
+ for ledger in sorted((Path(home) / 'tasks').glob('**/*ledger*.jsonl')):
21
+ try:
22
+ for number, line in enumerate(ledger.read_text().splitlines(), 1):
23
+ if not line.strip():
24
+ continue
25
+ try:
26
+ entry = json.loads(line)
27
+ if not isinstance(entry, dict) or not entry.get('task_id'):
28
+ raise ValueError('task_id missing')
29
+ platform = ledger.parent.name
30
+ latest[(platform, entry.get('profile_id'), entry['task_id'])] = {
31
+ **entry, 'ledger': str(ledger.relative_to(home)), 'platform': platform}
32
+ except (ValueError, TypeError):
33
+ errors.append(f'{ledger.relative_to(home)}:{number}: invalid task record')
34
+ except OSError as exc:
35
+ errors.append(f'{ledger.relative_to(home)}: {exc.strerror}')
36
+ retired_path = Path(home) / 'tasks/.retired-publish-tasks.json'
37
+ retired = {}
38
+ if retired_path.exists():
39
+ try:
40
+ retired = json.loads(retired_path.read_text())
41
+ if not isinstance(retired, dict):
42
+ raise ValueError('invalid retirement records')
43
+ except (OSError, ValueError):
44
+ errors.append('Invalid retirement records; upgrade blocked')
45
+ retired = {}
46
+ for entry in latest.values():
47
+ fingerprint = hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
48
+ entry['record_hash'] = fingerprint
49
+ entry['retired'] = fingerprint in retired
50
+ return list(latest.values()), errors
51
+
52
+
53
+ def upgrade_report(home):
54
+ home = Path(home).expanduser().resolve()
55
+ active, stale, errors = [], [], []
56
+ for p in sorted((home / 'locks').glob('*.lock')):
57
+ try:
58
+ owner = json.loads(p.read_text())
59
+ if not isinstance(owner, dict):
60
+ raise ValueError('invalid owner')
61
+ record = {k: owner.get(k) for k in ('pid', 'task_id', 'phase')}
62
+ record['profile_id'] = p.stem
63
+ (active if process_alive(owner.get('pid')) else stale).append(record)
64
+ except (OSError, ValueError):
65
+ errors.append(f'locks/{p.name}: unreadable owner')
66
+ events, task_errors = tasks(home)
67
+ errors.extend(task_errors)
68
+ unfinished = [e for e in events if not e['retired'] and e.get('state') not in SETTLED]
69
+ processes = []
70
+ try:
71
+ output = subprocess.check_output(['ps', '-axo', 'pid=,args='], text=True)
72
+ for line in output.splitlines():
73
+ bits = line.strip().split(None, 1)
74
+ if len(bits) == 2 and str(home / 'profiles') + '/' in bits[1] and 'browser_data' in bits[1]:
75
+ processes.append({'pid': int(bits[0]), 'kind': 'profile-browser'})
76
+ except (OSError, ValueError, subprocess.SubprocessError):
77
+ errors.append('Cannot inspect active browser processes')
78
+ active_version = None
79
+ current = home / '.app/current'
80
+ if current.exists() or current.is_symlink():
81
+ try:
82
+ active_version = json.loads((current / 'package.json').read_text())['version']
83
+ except (OSError, ValueError, KeyError):
84
+ errors.append('Activated runtime is incomplete')
85
+ return {'runtime_home': str(home), 'active_version': active_version,
86
+ 'legacy_entries': [n for n in ('social.sh', 'scripts') if (home / n).exists()],
87
+ 'active_locks': active, 'stale_locks': stale, 'active_browsers': processes,
88
+ 'unfinished_tasks': unfinished, 'retired_tasks': [e for e in events if e['retired']], 'errors': errors,
89
+ 'safe_to_upgrade': not (active or processes or unfinished or errors),
90
+ 'note': 'Snapshot only. Drain callers before setup; preserve and resolve unfinished publishing without retrying.'}
91
+
92
+
93
+ def retire_task(home, profile_id, task_id, reason):
94
+ """Record an explicit decision to stop a historical task; never rewrite its result."""
95
+ report = upgrade_report(home)
96
+ if report['active_locks'] or report['active_browsers'] or report['errors']:
97
+ raise ValueError('Cannot retire tasks while runtime is active or unreadable')
98
+ entries, _ = tasks(home)
99
+ matches = [e for e in entries if e.get('profile_id') == profile_id and e.get('task_id') == task_id]
100
+ if len(matches) != 1:
101
+ raise ValueError('Select exactly one existing task with --profile-id')
102
+ record = matches[0]
103
+ path = Path(home) / 'tasks/.retired-publish-tasks.json'
104
+ with guard(path):
105
+ data = json.loads(path.read_text()) if path.exists() else {}
106
+ data[record['record_hash']] = {'profile_id': profile_id, 'task_id': task_id,
107
+ 'state_preserved': record.get('state'), 'reason': reason,
108
+ 'retired_at': datetime.now(timezone.utc).isoformat()}
109
+ fd, temporary = tempfile.mkstemp(prefix='.retired-', dir=path.parent)
110
+ try:
111
+ with os.fdopen(fd, 'w') as f:
112
+ json.dump(data, f, ensure_ascii=False, indent=2)
113
+ os.replace(temporary, path)
114
+ finally:
115
+ if os.path.exists(temporary):
116
+ os.unlink(temporary)
117
+ return {'task_id': task_id, 'profile_id': profile_id, 'retired': True,
118
+ 'state_preserved': record.get('state'), 'reason': reason}
119
+
120
+
121
+ def validate_config(home):
122
+ errors, profiles, warnings = [], [], []
123
+ for p in sorted((Path(home) / 'profiles').glob('*/config.json')):
124
+ issues = []
125
+ notes = []
126
+ try:
127
+ d = json.loads(p.read_text())
128
+ account = d.get('account', {})
129
+ if account.get('platform') is None:
130
+ notes.append('legacy config has no platform; verify before platform-specific operations')
131
+ elif account.get('platform') not in ('douyin', 'xiaohongshu', 'toutiao'):
132
+ issues.append('unsupported or missing platform')
133
+ if 'capabilities' not in account:
134
+ notes.append('legacy capability fallback; explicit capabilities recommended')
135
+ elif not isinstance(account['capabilities'], list):
136
+ issues.append('capabilities must be a list')
137
+ if not isinstance(d.get('fingerprint'), dict):
138
+ issues.append('fingerprint missing')
139
+ except (OSError, ValueError, AttributeError):
140
+ issues.append('invalid config.json')
141
+ profiles.append({'profile_id': p.parent.name, 'issues': issues, 'warnings': notes})
142
+ errors.extend(f'{p.parent.name}: {issue}' for issue in issues)
143
+ warnings.extend(f'{p.parent.name}: {note}' for note in notes)
144
+ return {'valid': not errors, 'profiles': profiles, 'errors': errors, 'warnings': warnings}
145
+
146
+
147
+ def main(argv):
148
+ parser = argparse.ArgumentParser(prog='media-agent ' + argv[0])
149
+ command = argv[0]
150
+ if command == 'upgrade':
151
+ parser.add_argument('--check', action='store_true', required=True)
152
+ elif command == 'tasks':
153
+ parser.add_argument('operation', choices=['list', 'show', 'retire'])
154
+ parser.add_argument('task_id', nargs='?')
155
+ parser.add_argument('--profile-id')
156
+ parser.add_argument('--reason')
157
+ elif command == 'config':
158
+ parser.add_argument('operation', choices=['validate'])
159
+ parser.add_argument('--json', action='store_true')
160
+ args = parser.parse_args(argv[1:])
161
+ home = runtime_home()
162
+ if command == 'upgrade':
163
+ result = upgrade_report(home)
164
+ code = 0 if result['safe_to_upgrade'] else 6
165
+ elif command == 'config':
166
+ result = validate_config(home)
167
+ code = 0 if result['valid'] else 1
168
+ else:
169
+ if args.operation in ('show', 'retire') and not args.task_id:
170
+ parser.error(args.operation + ' requires task_id')
171
+ if args.operation == 'retire':
172
+ if not args.profile_id or not args.reason or not args.reason.strip():
173
+ parser.error('retire requires --profile-id and --reason; explicit user disposition is required')
174
+ try:
175
+ result = retire_task(home, args.profile_id, args.task_id, args.reason)
176
+ except ValueError as exc:
177
+ parser.error(str(exc))
178
+ print(json.dumps(result, ensure_ascii=False, indent=2))
179
+ return 0
180
+ events, errors = tasks(home)
181
+ events = [e for e in events if (not args.profile_id or e.get('profile_id') == args.profile_id)
182
+ and (not args.task_id or e.get('task_id') == args.task_id)]
183
+ result = {'tasks': events, 'errors': errors}
184
+ code = 1 if errors else (5 if args.operation == 'show' and not events else 0)
185
+ print(json.dumps(result, ensure_ascii=False, indent=2))
186
+ return code
@@ -0,0 +1,74 @@
1
+ """Process-owned profile locks shared by all supported runtime entry points."""
2
+ from contextlib import contextmanager
3
+ from datetime import datetime, timezone
4
+ import errno
5
+ import fcntl
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+
11
+ def process_alive(pid):
12
+ # Missing/invalid ownership is unknown, never permission to steal a lock.
13
+ if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 1:
14
+ return True
15
+ try:
16
+ os.kill(pid, 0)
17
+ return True
18
+ except OSError as exc:
19
+ return exc.errno != errno.ESRCH
20
+
21
+
22
+ @contextmanager
23
+ def guard(path):
24
+ path = Path(path)
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ fd = os.open(str(path) + '.guard', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
27
+ try:
28
+ fcntl.flock(fd, fcntl.LOCK_EX)
29
+ yield
30
+ finally:
31
+ fcntl.flock(fd, fcntl.LOCK_UN)
32
+ os.close(fd)
33
+
34
+
35
+ def acquire(path, task_id, initialize=None, **metadata):
36
+ path = Path(path)
37
+ with guard(path):
38
+ if path.is_symlink():
39
+ return False
40
+ if path.exists():
41
+ try:
42
+ old = json.loads(path.read_text())
43
+ if not isinstance(old, dict) or process_alive(old.get('pid')):
44
+ return False
45
+ except (OSError, ValueError):
46
+ return False
47
+ path.unlink()
48
+ if initialize is not None:
49
+ initialize()
50
+ data = {**metadata, 'pid': os.getpid(), 'task_id': task_id,
51
+ 'locked_at': datetime.now(timezone.utc).isoformat()}
52
+ try:
53
+ with path.open('x') as f:
54
+ json.dump(data, f)
55
+ return True
56
+ except FileExistsError:
57
+ return False
58
+
59
+
60
+ def release(path, task_id):
61
+ path = Path(path)
62
+ with guard(path):
63
+ if path.is_symlink():
64
+ return False
65
+ if not path.exists():
66
+ return True
67
+ try:
68
+ owner = json.loads(path.read_text())
69
+ except (OSError, ValueError):
70
+ return False
71
+ if not isinstance(owner, dict) or owner.get('pid') != os.getpid() or owner.get('task_id') != task_id:
72
+ return False
73
+ path.unlink()
74
+ return True
@@ -0,0 +1,98 @@
1
+ """Task-bound signals: confirmation never starts a new browser or guesses a task."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import tempfile
6
+ from .locking import guard, process_alive
7
+
8
+
9
+ def click_publish_once(page):
10
+ button = page.get_by_role('button', name='发布', exact=True)
11
+ if button.count() != 1 or not button.is_visible() or not button.is_enabled():
12
+ return False
13
+ # Playwright additionally verifies actionability/occlusion. If this raises,
14
+ # the caller records an indeterminate result and must not retry the click.
15
+ button.click(timeout=5000)
16
+ return True
17
+
18
+
19
+ def send_confirmation(lock_file, signal_file, entries, profile_id, task_id):
20
+ if not task_id:
21
+ raise ValueError('TASK_ID_REQUIRED')
22
+ with guard(lock_file):
23
+ try:
24
+ owner = json.loads(Path(lock_file).read_text())
25
+ except (OSError, ValueError):
26
+ raise ValueError('PREPARED_PROCESS_REQUIRED')
27
+ if not isinstance(owner, dict):
28
+ raise ValueError('PREPARED_PROCESS_REQUIRED')
29
+ if (owner.get('task_id') != task_id or owner.get('phase') != 'publish'
30
+ or not isinstance(owner.get('pid'), int) or owner['pid'] <= 1
31
+ or not process_alive(owner['pid'])):
32
+ raise ValueError('PREPARED_PROCESS_REQUIRED')
33
+ matching = [e for e in entries if e.get('profile_id') == profile_id and e.get('task_id') == task_id]
34
+ if not matching or matching[-1].get('state') != 'prepared' or not matching[-1].get('sha256'):
35
+ raise ValueError('MATCHING_PREPARED_TASK_REQUIRED')
36
+ signal_file = Path(signal_file)
37
+ signal_file.parent.mkdir(parents=True, exist_ok=True)
38
+ if signal_file.exists():
39
+ raise ValueError('CONFIRMATION_ALREADY_PENDING')
40
+ # Link a complete file atomically, without replacing an existing signal.
41
+ fd, temp = tempfile.mkstemp(prefix='.confirm-', dir=signal_file.parent)
42
+ try:
43
+ with os.fdopen(fd, 'w') as f:
44
+ json.dump({'action': 'confirm', 'task_id': task_id,
45
+ 'profile_id': profile_id, 'sha256': matching[-1].get('sha256'),
46
+ 'pid': owner['pid']}, f)
47
+ try:
48
+ os.link(temp, signal_file)
49
+ except FileExistsError:
50
+ raise ValueError('CONFIRMATION_ALREADY_PENDING')
51
+ finally:
52
+ os.unlink(temp)
53
+
54
+
55
+ def consume_confirmation(signal_file, profile_id, task_id, sha256):
56
+ try:
57
+ p = Path(signal_file)
58
+ data = json.loads(p.read_text())
59
+ p.unlink()
60
+ return (data.get('action') == 'confirm' and data.get('profile_id') == profile_id
61
+ and data.get('task_id') == task_id and data.get('sha256') == sha256
62
+ and data.get('pid') == os.getpid())
63
+ except (OSError, ValueError, AttributeError):
64
+ return False
65
+
66
+
67
+ def consume_close(signal_file, task_id):
68
+ p = Path(signal_file)
69
+ try:
70
+ data = json.loads(p.read_text())
71
+ return (data.get('action') == 'close' and data.get('task_id') == task_id
72
+ and data.get('pid') == os.getpid())
73
+ except (OSError, ValueError, AttributeError):
74
+ return False
75
+ finally:
76
+ p.unlink(missing_ok=True)
77
+
78
+
79
+ def request_close(lock_file, signal_file):
80
+ with guard(lock_file):
81
+ try:
82
+ owner = json.loads(Path(lock_file).read_text())
83
+ except (OSError, ValueError):
84
+ raise ValueError('PUBLISH_PROCESS_REQUIRED')
85
+ if (not isinstance(owner, dict) or owner.get('phase') != 'publish'
86
+ or not isinstance(owner.get('pid'), int) or owner['pid'] <= 1
87
+ or not owner.get('task_id') or not process_alive(owner['pid'])):
88
+ raise ValueError('PUBLISH_PROCESS_REQUIRED')
89
+ destination = Path(signal_file)
90
+ destination.parent.mkdir(parents=True, exist_ok=True)
91
+ fd, temporary = tempfile.mkstemp(prefix='.close-', dir=destination.parent)
92
+ try:
93
+ with os.fdopen(fd, 'w') as f:
94
+ json.dump({'action': 'close', 'task_id': owner['task_id'], 'pid': owner['pid']}, f)
95
+ os.replace(temporary, destination)
96
+ finally:
97
+ if os.path.exists(temporary):
98
+ os.unlink(temporary)
@@ -16,6 +16,7 @@ function inventory(root) {
16
16
  const result = {};
17
17
  function visit(dir, prefix = '') {
18
18
  for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a,b)=>a.name.localeCompare(b.name))) {
19
+ if (e.name === '.DS_Store') continue;
19
20
  const relative = prefix + e.name, full = path.join(dir,e.name);
20
21
  if (e.isSymbolicLink()) throw new Error(`Symlink in Skill: ${full}`);
21
22
  if (e.isDirectory()) visit(full, relative + '/');
@@ -71,7 +72,7 @@ export function installSkills(root, target, selected, dryRun=false) {
71
72
  const stage=fs.mkdtempSync(path.join(path.dirname(target),'.media-agent-skill-'));
72
73
  const staged=path.join(stage,name); let backup;
73
74
  try {
74
- fs.cpSync(src,staged,{recursive:true,errorOnExist:true,force:false});
75
+ fs.cpSync(src,staged,{recursive:true,errorOnExist:true,force:false,filter:source=>path.basename(source)!=='.DS_Store'});
75
76
  if (!equal(inventory(staged),hashes)) throw new Error(`Skill copy verification failed: ${name}`);
76
77
  fs.mkdirSync(target,{recursive:true});
77
78
  if (prior) { backup=archiveLocation(target,name,'upgrade-'); fs.renameSync(dst,backup); }
@@ -23,6 +23,7 @@ def source_payload(root):
23
23
  for name in catalog['skills']:
24
24
  for p in (root/'skills'/name).rglob('*'):
25
25
  if p.is_symlink(): raise ValueError(f'Linked source: {p}')
26
+ if p.name == '.DS_Store': continue
26
27
  if p.is_file():
27
28
  if p.suffix not in ('.md','.yaml') and not p.name.startswith('LICENSE.'):
28
29
  raise ValueError(f'Unexpected Skill resource: {p}')
@@ -74,6 +74,11 @@ def install(target, dry_run=False, no_deps=False):
74
74
  if p.exists() and not p.is_file():
75
75
  raise ValueError(f'Expected regular file: {p}')
76
76
  reject_links(destination)
77
+ sys.path.insert(0, str(ROOT / 'src'))
78
+ from media_agent.runtime.diagnostics import upgrade_report
79
+ readiness = upgrade_report(target)
80
+ if not readiness['safe_to_upgrade']:
81
+ raise ValueError('Upgrade blocked: active or unfinished tasks, or unreadable state. Run media-agent upgrade --check --json; no dependencies or runtime code were changed.')
77
82
  if dry_run:
78
83
  print(f'[DRY-RUN] runtime {version} -> {destination}; dependencies={not no_deps}')
79
84
  return