@iducky/media-agent 1.1.0

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 (96) hide show
  1. package/README.md +90 -0
  2. package/SHA256SUMS +94 -0
  3. package/bin/media-agent.mjs +89 -0
  4. package/docs/guides/capabilities.md +103 -0
  5. package/docs/guides/image-text-publishing.md +81 -0
  6. package/docs/guides/installation.md +103 -0
  7. package/docs/guides/runtime.md +13 -0
  8. package/manifest.json +382 -0
  9. package/package.json +42 -0
  10. package/pyproject.toml +20 -0
  11. package/resources/capabilities.json +34 -0
  12. package/resources/configs/accounts.yaml +21 -0
  13. package/resources/configs/profile.template.json +20 -0
  14. package/resources/configs/ranking-profiles.yaml +21 -0
  15. package/resources/configs/toutiao-profile.template.json +22 -0
  16. package/resources/configs/xiaohongshu-profile.template.json +27 -0
  17. package/resources/data/industry-taxonomy.yaml +326 -0
  18. package/skills/douyin-competitor-collect/SKILL.md +177 -0
  19. package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
  20. package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
  21. package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
  22. package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
  23. package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  24. package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
  25. package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
  26. package/skills/douyin-creator-index/SKILL.md +32 -0
  27. package/skills/douyin-creator-index/agents/openai.yaml +4 -0
  28. package/skills/douyin-creator-login/SKILL.md +58 -0
  29. package/skills/douyin-creator-login/agents/openai.yaml +4 -0
  30. package/skills/douyin-creator-publish/SKILL.md +56 -0
  31. package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
  32. package/skills/douyin-enterprise-leads/SKILL.md +26 -0
  33. package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
  34. package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
  35. package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
  36. package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
  37. package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
  38. package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
  39. package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
  40. package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
  41. package/skills/douyin-web-login/SKILL.md +102 -0
  42. package/skills/douyin-web-login/agents/openai.yaml +4 -0
  43. package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
  44. package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
  45. package/skills/toutiao-web-login/SKILL.md +96 -0
  46. package/skills/toutiao-web-login/agents/openai.yaml +4 -0
  47. package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
  48. package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
  49. package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  50. package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
  51. package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
  52. package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
  53. package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
  54. package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
  55. package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
  56. package/src/media_agent/__init__.py +1 -0
  57. package/src/media_agent/cli.py +28 -0
  58. package/src/media_agent/commands.sh +436 -0
  59. package/src/media_agent/platforms/__init__.py +1 -0
  60. package/src/media_agent/platforms/douyin/__init__.py +1 -0
  61. package/src/media_agent/platforms/douyin/check_login.py +196 -0
  62. package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
  63. package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
  64. package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
  65. package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
  66. package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
  67. package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
  68. package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
  69. package/src/media_agent/platforms/douyin/login_controller.py +508 -0
  70. package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
  71. package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
  72. package/src/media_agent/platforms/toutiao/__init__.py +1 -0
  73. package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
  74. package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
  75. package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
  76. package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
  77. package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
  78. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
  79. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
  80. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
  81. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
  82. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
  83. package/src/media_agent/runtime/__init__.py +1 -0
  84. package/src/media_agent/runtime/account_manager.py +672 -0
  85. package/src/media_agent/runtime/browser.py +5 -0
  86. package/src/media_agent/runtime/paths.py +7 -0
  87. package/src/media_agent/script_map.json +23 -0
  88. package/src/node/config.mjs +48 -0
  89. package/src/node/integrity.mjs +34 -0
  90. package/src/node/skills.mjs +85 -0
  91. package/tools/archive_releases.py +83 -0
  92. package/tools/artifacts.py +56 -0
  93. package/tools/build_release.py +82 -0
  94. package/tools/check_catalog.py +23 -0
  95. package/tools/install_runtime.py +144 -0
  96. package/tools/run_tests.py +21 -0
@@ -0,0 +1,1135 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Douyin Creator Center 发布脚本 — 五阶段发布流水线。
4
+
5
+ Phases:
6
+ --dry-run 检查素材 SHA-256 去重,不执行实际操作
7
+ --prepare <path> 上传素材、配置元数据、定时发布,写账本后保持浏览器
8
+ --confirm-submit 确认提交(点击发布按钮)
9
+ --status 查询发布状态
10
+ --close 关闭浏览器并释放锁
11
+
12
+ Ledger: tasks/douyin_publish/publish-ledger.jsonl
13
+ State machine: discovered → reserved → uploading → scheduled → submitted → reviewing → published/rejected/indeterminate/failed
14
+ SHA-256 dedup: submitted / scheduled / reviewing / published 永久阻止重复提交。
15
+ """
16
+ from media_agent.runtime.paths import runtime_home
17
+
18
+ import argparse
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import signal
23
+ import sys
24
+ import time
25
+ import uuid
26
+ from datetime import datetime, timedelta, timezone
27
+ from pathlib import Path
28
+
29
+
30
+ # ─── Paths ───────────────────────────────────────────────────────────────────
31
+ BASE = runtime_home()
32
+ DEFAULT_PROFILE_ID = 'douyin_creator_mengjun_ecommerce'
33
+ LOCKS_DIR = BASE / 'locks'
34
+ LEDGER_DIR = BASE / 'tasks' / 'douyin_publish'
35
+ LEDGER_FILE = LEDGER_DIR / 'publish-ledger.jsonl'
36
+ UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload'
37
+ SIGNAL_DIR = BASE / 'signals'
38
+
39
+
40
+ def _profile_dir(profile_id):
41
+ return BASE / 'profiles' / profile_id
42
+
43
+
44
+ def _browser_data(profile_id):
45
+ return str(_profile_dir(profile_id) / 'browser_data')
46
+
47
+
48
+ def _lock_file(profile_id):
49
+ return LOCKS_DIR / f'{profile_id}.lock'
50
+
51
+
52
+ def _confirm_file(profile_id):
53
+ return SIGNAL_DIR / f'{profile_id}.confirm'
54
+
55
+
56
+ def _close_file(profile_id):
57
+ return SIGNAL_DIR / f'{profile_id}.close'
58
+
59
+
60
+ # ─── Constants ───────────────────────────────────────────────────────────────
61
+ DEDUP_STATES = {'submitted', 'scheduled', 'reviewing', 'published'}
62
+ SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
63
+ 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'
66
+ TZ_SHANGHAI = timezone(timedelta(hours=8))
67
+
68
+
69
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
70
+
71
+ def sha256_file(path):
72
+ """Compute SHA-256 digest of a file."""
73
+ h = hashlib.sha256()
74
+ with open(path, 'rb') as f:
75
+ while True:
76
+ chunk = f.read(65536)
77
+ if not chunk:
78
+ break
79
+ h.update(chunk)
80
+ return h.hexdigest()
81
+
82
+
83
+ def load_ledger():
84
+ """Load all ledger entries as a list of dicts."""
85
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
86
+ if not LEDGER_FILE.exists():
87
+ return []
88
+ entries = []
89
+ for line in LEDGER_FILE.read_text().splitlines():
90
+ line = line.strip()
91
+ if line:
92
+ try:
93
+ entries.append(json.loads(line))
94
+ except json.JSONDecodeError:
95
+ pass
96
+ return entries
97
+
98
+
99
+ def find_ledger_entry(sha256):
100
+ """Return the most recent ledger entry matching a SHA-256 (or None)."""
101
+ entries = load_ledger()
102
+ for entry in reversed(entries):
103
+ if entry.get('sha256') == sha256:
104
+ return entry
105
+ return None
106
+
107
+
108
+ def check_duplicate(sha256):
109
+ """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
114
+
115
+
116
+ def append_ledger(entry):
117
+ """Atomically append a JSON line to the ledger."""
118
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
119
+ with open(LEDGER_FILE, 'a') as f:
120
+ f.write(json.dumps(entry, ensure_ascii=False) + '\n')
121
+
122
+
123
+ 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
141
+
142
+
143
+ 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)
148
+
149
+
150
+ def now_iso():
151
+ """Return current time in Asia/Shanghai ISO format."""
152
+ return datetime.now(TZ_SHANGHAI).isoformat()
153
+
154
+
155
+ def log(msg):
156
+ """Print a structured log line to stdout."""
157
+ print(json.dumps({'ts': now_iso(), **msg}, ensure_ascii=False), flush=True)
158
+
159
+
160
+ def die(code, msg):
161
+ """Print error and exit."""
162
+ log({'level': 'ERROR', 'exit_code': code, **msg})
163
+ sys.exit(code)
164
+
165
+
166
+ def _kwargs(**kw):
167
+ """Helper to return a dict of kwargs — for readability."""
168
+ return kw
169
+
170
+
171
+ # ─── Browser ─────────────────────────────────────────────────────────────────
172
+
173
+ def launch_browser(profile_id):
174
+ """Launch a CloakBrowser persistent context for the profile."""
175
+ from media_agent.runtime.browser import launch_persistent_context
176
+
177
+ pdir = _profile_dir(profile_id)
178
+ cfg = json.loads((pdir / 'config.json').read_text())
179
+ fp = cfg.get('fingerprint', {})
180
+
181
+ ctx = launch_persistent_context(
182
+ user_data_dir=_browser_data(profile_id),
183
+ headless=False,
184
+ stealth_args=True,
185
+ viewport=fp.get('viewport', {'width': 1440, 'height': 900}),
186
+ locale=fp.get('locale', 'zh-CN'),
187
+ timezone=fp.get('timezone', 'Asia/Shanghai'),
188
+ humanize=False,
189
+ geoip=False,
190
+ )
191
+ return ctx, cfg
192
+
193
+
194
+ # ─── Phase: dry-run ──────────────────────────────────────────────────────────
195
+
196
+ def phase_dry_run(profile_id, media_path, scheduled_at=None):
197
+ """Check media file validity and SHA-256 dedup status."""
198
+ path = Path(media_path).resolve()
199
+
200
+ if not path.exists():
201
+ die(1, _kwargs(phase='dry-run', error='FILE_NOT_FOUND', path=str(path)))
202
+
203
+ if not path.is_file():
204
+ die(1, _kwargs(phase='dry-run', error='NOT_A_FILE', path=str(path)))
205
+
206
+ ext = path.suffix.lower()
207
+ if ext not in SUPPORTED_FORMATS:
208
+ die(1, _kwargs(
209
+ phase='dry-run', error='UNSUPPORTED_FORMAT',
210
+ format=ext, supported=sorted(SUPPORTED_FORMATS),
211
+ ))
212
+
213
+ file_size = path.stat().st_size
214
+ sha = sha256_file(str(path))
215
+ blocked, existing = check_duplicate(sha)
216
+
217
+ schedule = scheduled_at or DEFAULT_SCHEDULED_AT
218
+
219
+ result = _kwargs(
220
+ phase='dry-run',
221
+ media_path=str(path),
222
+ file_size=file_size,
223
+ file_size_mb=round(file_size / 1024 / 1024, 2),
224
+ sha256=sha,
225
+ format=ext,
226
+ scheduled_at=schedule,
227
+ duplicate_blocked=blocked,
228
+ profile_id=profile_id,
229
+ )
230
+
231
+ if blocked:
232
+ result['existing_state'] = existing.get('state')
233
+ result['existing_task_id'] = existing.get('task_id')
234
+ result['existing_timestamp'] = existing.get('timestamp')
235
+ log(result)
236
+ sys.exit(8) # DUPLICATE_BLOCKED
237
+
238
+ log(result)
239
+ return sha, str(path), schedule
240
+
241
+
242
+ # ─── Phase: prepare ──────────────────────────────────────────────────────────
243
+
244
+ def _wait_for_upload_complete(page, timeout=300):
245
+ """Wait for the video upload/processing to complete."""
246
+ start = time.time()
247
+ while time.time() - start < timeout:
248
+ # Check multiple indicators
249
+ status = page.evaluate('''() => {
250
+ const body = document.body.textContent || '';
251
+ // Check if upload area is still showing
252
+ const uploadArea = document.querySelector('input[type="file"]');
253
+ const uploadVisible = uploadArea && uploadArea.offsetParent !== null;
254
+ // Check for processing indicators
255
+ const processing = body.includes('上传中') || body.includes('处理中') || body.includes('转码中');
256
+ // Check for edit form (title/description)
257
+ const hasTitle = document.querySelector('input[placeholder*="标题"]') ||
258
+ document.querySelector('textarea[placeholder*="简介"]') ||
259
+ document.querySelector('[class*="title"] input');
260
+ // Check for cover/settings area
261
+ const hasCover = body.includes('封面') || body.includes('选择封面');
262
+ if (hasTitle && hasCover && !uploadVisible) {
263
+ return 'done';
264
+ }
265
+ if (processing) {
266
+ return 'processing';
267
+ }
268
+ return 'waiting';
269
+ }''')
270
+ if status == 'done':
271
+ return True
272
+ if status == 'processing':
273
+ log(_kwargs(step='upload_progress', status='processing', elapsed=round(time.time() - start)))
274
+ time.sleep(3)
275
+ return False
276
+
277
+
278
+ def _dismiss_location_permission(page):
279
+ """Dismiss the location permission popup by choosing '一律不允许'."""
280
+ time.sleep(2)
281
+ dismissed = page.evaluate('''() => {
282
+ const keywords = ['一律不允许', '不允许', '禁止', '拒绝', "Don't Allow", 'Block', 'Deny'];
283
+ for (const kw of keywords) {
284
+ for (const el of document.querySelectorAll('button, span, div, a')) {
285
+ const text = (el.textContent || '').trim();
286
+ if (text === kw || text.includes(kw)) {
287
+ if (el.offsetParent !== null) {
288
+ el.click();
289
+ return 'clicked:' + kw;
290
+ }
291
+ }
292
+ }
293
+ }
294
+ return 'not_found';
295
+ }''')
296
+ log(_kwargs(step='location_permission', result=dismissed))
297
+ time.sleep(1)
298
+
299
+
300
+ def _set_cover_natural_frame(page):
301
+ """Select a natural frame as cover, avoiding AI-text overlays."""
302
+ time.sleep(3)
303
+ result = page.evaluate('''() => {
304
+ const coverAreas = document.querySelectorAll('[class*="cover"], [class*="poster"], [class*="thumbnail"]');
305
+ const info = [];
306
+ for (const el of coverAreas) {
307
+ if (el.offsetParent === null) continue;
308
+ const rect = el.getBoundingClientRect();
309
+ info.push({
310
+ tag: el.tagName,
311
+ class: (el.className || '').substring(0, 40),
312
+ text: (el.textContent || '').trim().substring(0, 30),
313
+ bounds: {x: Math.round(rect.x), y: Math.round(rect.y),
314
+ w: Math.round(rect.width), h: Math.round(rect.height)},
315
+ });
316
+ }
317
+ return {found: info.length, covers: info.slice(0, 5)};
318
+ }''')
319
+ log(_kwargs(step='cover_inspection', **result))
320
+
321
+ picked = page.evaluate('''() => {
322
+ for (const el of document.querySelectorAll('div, span, button')) {
323
+ const text = (el.textContent || '').trim();
324
+ if ((text.includes('选择封面') || text.includes('推荐封面') || text.includes('默认封面')) &&
325
+ el.offsetParent !== null) {
326
+ el.click();
327
+ return 'clicked:' + text.substring(0, 20);
328
+ }
329
+ }
330
+ return 'no_cover_ui';
331
+ }''')
332
+ log(_kwargs(step='cover_selection', result=picked))
333
+ time.sleep(2)
334
+
335
+
336
+ def _configure_default_settings(page):
337
+ """Set default publish settings: public, allow save, no collection, no location, no hotspot, no event, no music."""
338
+ settings_log = []
339
+
340
+ # Ensure visibility is public
341
+ r = page.evaluate('''() => {
342
+ const results = [];
343
+ for (const el of document.querySelectorAll('div, span, label')) {
344
+ const text = (el.textContent || '').trim();
345
+ if ((text === '公开' || text === '私密' || text === '好友可见') && el.offsetParent !== null) {
346
+ results.push({text: text, tag: el.tagName});
347
+ }
348
+ }
349
+ return results;
350
+ }''')
351
+ settings_log.append({'visibility_options': r})
352
+
353
+ # Uncheck: 原创声明, 营销声明, 广告声明
354
+ r = page.evaluate('''() => {
355
+ const unchecked = [];
356
+ const declKeywords = ['原创', '营销', '广告', '声明', '品牌'];
357
+ const checkboxes = document.querySelectorAll('input[type="checkbox"]');
358
+ for (const cb of checkboxes) {
359
+ const label = cb.closest('label') || cb.parentElement;
360
+ const text = (label?.textContent || '').trim();
361
+ for (const kw of declKeywords) {
362
+ if (text.includes(kw) && cb.checked && cb.offsetParent !== null) {
363
+ cb.click();
364
+ unchecked.push(kw);
365
+ break;
366
+ }
367
+ }
368
+ }
369
+ return unchecked;
370
+ }''')
371
+ settings_log.append({'declarations_unchecked': r})
372
+
373
+ # Disable: 加入合集, 添加位置, 关联热点, 参加活动, 添加音乐
374
+ r = page.evaluate('''() => {
375
+ const actions = [];
376
+ const disableKeywords = ['加入合集', '添加位置', '关联热点', '参加活动', '添加音乐', '位置', '合集', '活动', '音乐', '热点'];
377
+ for (const kw of disableKeywords) {
378
+ for (const el of document.querySelectorAll('div, span, button')) {
379
+ const text = (el.textContent || '').trim();
380
+ if (text === kw && el.offsetParent !== null) {
381
+ const parent = el.closest('div, label, section') || el.parentElement;
382
+ if (parent) {
383
+ const cb = parent.querySelector('input[type="checkbox"]');
384
+ if (cb && cb.checked) {
385
+ cb.click();
386
+ actions.push('unchecked:' + kw);
387
+ } else {
388
+ actions.push('found:' + kw + '(already_off)');
389
+ }
390
+ }
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ return actions;
396
+ }''')
397
+ settings_log.append({'extras_disabled': r})
398
+
399
+ log(_kwargs(step='configure_settings', settings=settings_log))
400
+
401
+
402
+ def _fill_title_description(page, title, description):
403
+ """Fill in the video title and description."""
404
+ if not title and not description:
405
+ return
406
+ if title:
407
+ r = page.evaluate(f'''(t) => {{
408
+ const selectors = ['input[placeholder*="标题"]', 'input[placeholder*="title"]',
409
+ 'textarea[placeholder*="标题"]', '[class*="title"] input', '[class*="title"] textarea'];
410
+ for (const sel of selectors) {{
411
+ for (const inp of document.querySelectorAll(sel)) {{
412
+ if (inp.offsetParent !== null) {{
413
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set ||
414
+ Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
415
+ setter.call(inp, t);
416
+ inp.dispatchEvent(new Event('input', {{ bubbles: true }}));
417
+ inp.dispatchEvent(new Event('change', {{ bubbles: true }}));
418
+ return 'title_set';
419
+ }}
420
+ }}
421
+ }}
422
+ return 'no_title_input';
423
+ }}''', title)
424
+ log(_kwargs(step='fill_title', result=r))
425
+ if description:
426
+ r = page.evaluate(f'''(d) => {{
427
+ // Try standard textarea first
428
+ for (const ta of document.querySelectorAll('textarea')) {{
429
+ if (ta.offsetParent !== null) {{
430
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
431
+ setter.call(ta, d);
432
+ ta.dispatchEvent(new Event('input', {{ bubbles: true }}));
433
+ ta.dispatchEvent(new Event('change', {{ bubbles: true }}));
434
+ return ta.placeholder || 'textarea_set';
435
+ }}
436
+ }}
437
+ // Try contenteditable div (editor-kit-container)
438
+ for (const el of document.querySelectorAll('[class*="editor-kit"], [class*="zone-container"], [contenteditable="true"]')) {{
439
+ if (el.offsetParent !== null) {{
440
+ el.textContent = d;
441
+ el.dispatchEvent(new Event('input', {{ bubbles: true }}));
442
+ el.dispatchEvent(new Event('change', {{ bubbles: true }}));
443
+ return 'editor_set';
444
+ }}
445
+ }}
446
+ return 'no_desc_input';
447
+ }}''', description)
448
+ log(_kwargs(step='fill_desc', result=r))
449
+
450
+
451
+ def _set_scheduled_publish(page, scheduled_at):
452
+ """Select scheduled publish and set the datetime by typing directly."""
453
+ dt_obj = datetime.fromisoformat(scheduled_at)
454
+ datetime_str = dt_obj.strftime('%Y-%m-%d %H:%M')
455
+
456
+ # 1. Scroll to bottom to reveal publish settings
457
+ page.evaluate('window.scrollTo(0, document.documentElement.scrollHeight)')
458
+ time.sleep(1)
459
+
460
+ # 2. Click "定时发布"
461
+ r = page.evaluate('''() => {
462
+ for (const el of document.querySelectorAll('div, span, label, button')) {
463
+ const text = (el.textContent || '').trim();
464
+ if (text === '定时发布' && el.offsetParent !== null) {
465
+ el.click();
466
+ return 'clicked';
467
+ }
468
+ }
469
+ return 'not_found';
470
+ }''')
471
+ log(_kwargs(step='scheduled_publish_option', click_result=r))
472
+ time.sleep(2)
473
+
474
+ # 3. After clicking "定时发布", find the datetime input field next to it
475
+ # The Semi Design DateTimePicker renders as an input-like element
476
+ datetime_str = dt_obj.strftime('%Y-%m-%d %H:%M')
477
+ result = page.evaluate(f'''(datetimeVal) => {{
478
+ // Dump all inputs and text near "定时发布"
479
+ const dump = [];
480
+ for (const el of document.querySelectorAll('input, textarea, [contenteditable="true"]')) {{
481
+ if (el.offsetParent) {{
482
+ dump.push({{tag: el.tagName, type: el.type, value: el.value, placeholder: el.placeholder, className: (el.className||'').substring(0,40)}});
483
+ }}
484
+ }}
485
+ return {{all_inputs: dump, count: dump.length}};
486
+ }}''', datetime_str)
487
+ log(_kwargs(step='datetime_inputs', **result))
488
+
489
+ # 4. Try to find and set the datetime via the input that appears next to "定时发布"
490
+ set_result = page.evaluate(f'''(datetimeVal) => {{
491
+ // Strategy: find the last visible input element that's near the bottom of the page
492
+ // This should be the datetime picker's input field
493
+ const allInputs = [...document.querySelectorAll('input')].filter(el => el.offsetParent);
494
+ // Try inputs near the bottom (y > 800)
495
+ for (const inp of allInputs) {{
496
+ const rect = inp.getBoundingClientRect();
497
+ if (rect.y > 600 && inp.type === 'text') {{
498
+ inp.focus();
499
+ inp.value = '';
500
+ inp.value = datetimeVal;
501
+ inp.dispatchEvent(new Event('input', {{bubbles: true}}));
502
+ inp.dispatchEvent(new Event('change', {{bubbles: true}}));
503
+ inp.dispatchEvent(new Event('blur', {{bubbles: true}}));
504
+ return {{set: true, tag: inp.tagName, rect: rect, value: inp.value}};
505
+ }}
506
+ }}
507
+ // Fallback: try all text inputs
508
+ for (const inp of allInputs) {{
509
+ if (inp.type === 'text' && inp.placeholder !== '填写作品标题') {{
510
+ inp.focus();
511
+ inp.value = '';
512
+ inp.value = datetimeVal;
513
+ inp.dispatchEvent(new Event('input', {{bubbles: true}}));
514
+ inp.dispatchEvent(new Event('change', {{bubbles: true}}));
515
+ inp.dispatchEvent(new Event('blur', {{bubbles: true}}));
516
+ return {{set: true, tag: inp.tagName, fallback: true, value: inp.value}};
517
+ }}
518
+ }}
519
+ return {{set: false, reason: 'no_suitable_input'}};
520
+ }}''', datetime_str)
521
+ log(_kwargs(step='datetime_set', **set_result))
522
+
523
+ time.sleep(1)
524
+
525
+ # 5. Read back
526
+ readback = page.evaluate('''() => {
527
+ const re = /\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}/;
528
+ for (const el of document.querySelectorAll('span, div, input')) {
529
+ const text = (el.textContent || el.value || '').trim();
530
+ if (re.test(text) && el.offsetParent) {
531
+ return {value: text, tag: el.tagName};
532
+ }
533
+ }
534
+ return null;
535
+ }''')
536
+ log(_kwargs(step='datetime_readback', value=readback))
537
+
538
+ # Read back the trigger box value
539
+ readback_dt = page.evaluate('''() => {
540
+ const re = /\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}/;
541
+ for (const el of document.querySelectorAll('div, span, input')) {
542
+ const text = (el.textContent || '').trim();
543
+ if (re.test(text) && el.offsetParent) {
544
+ return text;
545
+ }
546
+ }
547
+ return null;
548
+ }''')
549
+ log(_kwargs(step='datetime_readback', value=readback_dt))
550
+ time.sleep(1)
551
+
552
+
553
+ def _readback_title_and_time(page):
554
+ """Read back the video title and scheduled time from the page."""
555
+ info = page.evaluate('''() => {
556
+ const result = {};
557
+
558
+ const titleInputs = document.querySelectorAll('input[placeholder*="标题"], input[placeholder*="title"], textarea[placeholder*="标题"], input[class*="title"]');
559
+ for (const inp of titleInputs) {
560
+ if (inp.offsetParent !== null) {
561
+ result.title = inp.value || inp.placeholder || '';
562
+ result.title_tag = inp.tagName;
563
+ break;
564
+ }
565
+ }
566
+
567
+ const timeElements = document.querySelectorAll('div, span');
568
+ for (const el of timeElements) {
569
+ const text = (el.textContent || '').trim();
570
+ if (text.includes('定时') && text.includes(':') && el.offsetParent !== null) {
571
+ result.scheduled_display = text.substring(0, 60);
572
+ break;
573
+ }
574
+ }
575
+
576
+ for (const el of document.querySelectorAll('button, div, span')) {
577
+ const text = (el.textContent || '').trim();
578
+ if ((text === '发布' || text.includes('发布')) && el.offsetParent !== null) {
579
+ result.publish_button = text.substring(0, 20);
580
+ result.publish_button_visible = true;
581
+ break;
582
+ }
583
+ }
584
+
585
+ return result;
586
+ }''')
587
+ log(_kwargs(step='readback', **info))
588
+ return info
589
+
590
+
591
+ def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=None):
592
+ """Upload media, configure settings, schedule publish, write ledger, keep browser alive."""
593
+ schedule = scheduled_at or DEFAULT_SCHEDULED_AT
594
+ path = Path(media_path).resolve()
595
+ sha = sha256_file(str(path))
596
+
597
+ blocked, existing = check_duplicate(sha)
598
+ if blocked:
599
+ die(8, _kwargs(
600
+ phase='prepare', error='DUPLICATE_BLOCKED', sha256=sha,
601
+ existing_state=existing.get('state'),
602
+ existing_task_id=existing.get('task_id'),
603
+ ))
604
+
605
+ task_id = f'publish_{profile_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
606
+
607
+ ok, owner = acquire_lock(profile_id, task_id)
608
+ if not ok:
609
+ die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
610
+
611
+ log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
612
+
613
+ try:
614
+ ctx, cfg = launch_browser(profile_id)
615
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
616
+ except Exception as e:
617
+ release_lock(profile_id)
618
+ die(1, _kwargs(phase='prepare', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
619
+
620
+ try:
621
+ # ── Navigate to upload page ──
622
+ log(_kwargs(step='navigate', url=UPLOAD_URL))
623
+ page.goto(UPLOAD_URL, wait_until='load', timeout=60000)
624
+ time.sleep(5)
625
+
626
+ current_url = page.url
627
+ if 'login' in current_url.lower() or 'passport' in current_url.lower():
628
+ ctx.close()
629
+ release_lock(profile_id)
630
+ die(2, _kwargs(phase='prepare', error='LOGIN_REQUIRED', current_url=current_url[:120]))
631
+
632
+ log(_kwargs(step='landed', url=current_url[:120], title=page.title()))
633
+
634
+ # ── Upload file ──
635
+ log(_kwargs(step='upload', path=str(path)))
636
+ try:
637
+ file_input = page.locator('input[type="file"]').first
638
+ file_input.set_input_files(str(path))
639
+ log(_kwargs(step='file_set', result='ok'))
640
+ except Exception as e:
641
+ log(_kwargs(step='file_set', result='locator_failed', error=str(e)[:100]))
642
+ # Make inputs visible and retry
643
+ page.evaluate('''() => {
644
+ const inputs = document.querySelectorAll('input[type="file"]');
645
+ for (const inp of inputs) {
646
+ inp.style.display = 'block';
647
+ inp.style.visibility = 'visible';
648
+ inp.style.position = 'static';
649
+ }
650
+ return inputs.length;
651
+ }''')
652
+ try:
653
+ file_input = page.locator('input[type="file"]').first
654
+ file_input.set_input_files(str(path))
655
+ except Exception as e2:
656
+ ctx.close()
657
+ release_lock(profile_id)
658
+ die(1, _kwargs(phase='prepare', error='UPLOAD_FAILED', detail=str(e2)[:200]))
659
+
660
+ time.sleep(3)
661
+
662
+ # ── Wait for upload complete ──
663
+ if not _wait_for_upload_complete(page):
664
+ log(_kwargs(step='upload_wait', warning='timeout — proceeding anyway'))
665
+
666
+ # ── Fill title and description ──
667
+ _fill_title_description(page, title, description)
668
+
669
+ # ── Dismiss location permission ──
670
+ _dismiss_location_permission(page)
671
+
672
+ # ── Set cover ──
673
+ _set_cover_natural_frame(page)
674
+
675
+ # ── Configure default settings ──
676
+ _configure_default_settings(page)
677
+
678
+ # ── Set scheduled publish ──
679
+ if scheduled_at:
680
+ _set_scheduled_publish(page, schedule)
681
+
682
+ # ── Readback ──
683
+ readback = _readback_title_and_time(page)
684
+
685
+ # ── Write ledger entry ──
686
+ ledger_entry = _kwargs(
687
+ task_id=task_id,
688
+ sha256=sha,
689
+ path=str(path),
690
+ state='prepared',
691
+ timestamp=now_iso(),
692
+ scheduled_at=schedule,
693
+ profile_id=profile_id,
694
+ file_size=path.stat().st_size,
695
+ readback=readback,
696
+ pid=os.getpid(),
697
+ )
698
+ append_ledger(ledger_entry)
699
+ log(_kwargs(phase='prepare', step='ledger_written', state='prepared', task_id=task_id))
700
+
701
+ # ── Wait for confirm or close signal ──
702
+ SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
703
+ cf = _confirm_file(profile_id)
704
+ clf = _close_file(profile_id)
705
+ for f in [cf, clf]:
706
+ if f.exists():
707
+ f.unlink(missing_ok=True)
708
+
709
+ log(_kwargs(
710
+ phase='prepare', step='awaiting_confirm', task_id=task_id,
711
+ hint='Run publish-confirm to submit, or publish-close to abort',
712
+ ))
713
+
714
+ while True:
715
+ if cf.exists():
716
+ cf.unlink(missing_ok=True)
717
+ log(_kwargs(phase='prepare', step='confirm_received'))
718
+
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))
742
+ time.sleep(5)
743
+
744
+ # Wait for platform auto-navigation (up to 3 minutes)
745
+ # Don't manually navigate - wait for platform to redirect
746
+ url_after = page.url
747
+ log(_kwargs(step='post_click_wait', url=url_after[:120], title=page.title()))
748
+
749
+ # Wait for auto-navigation or platform response
750
+ max_wait = 180 # 3 minutes
751
+ start_wait = time.time()
752
+ navigated = False
753
+ while time.time() - start_wait < max_wait:
754
+ current_url = page.url
755
+ if 'content/manage' in current_url:
756
+ navigated = True
757
+ log(_kwargs(step='auto_navigated', url=current_url[:120], elapsed=time.time()-start_wait))
758
+ break
759
+ # Check for error messages or submit confirmation
760
+ page_text = page.evaluate('() => document.body.innerText.substring(0,500)')
761
+ if '发布成功' in page_text or '提交成功' in page_text or '审核中' in page_text:
762
+ log(_kwargs(step='platform_success_hint', text=page_text[:200]))
763
+ if '失败' in page_text or '错误' in page_text or 'error' in page_text.lower():
764
+ log(_kwargs(step='platform_error_hint', text=page_text[:200]))
765
+ time.sleep(5)
766
+
767
+ if not navigated:
768
+ # Still not navigated - check if page shows any result
769
+ url_after = page.url
770
+ page_text = page.evaluate('() => document.body.innerText.substring(0,1000)')
771
+ log(_kwargs(step='no_navigation', url=url_after[:120], page_text=page_text[:300]))
772
+ # Write indeterminate - keep browser for manual inspection
773
+ ledger_entry['state'] = 'indeterminate'
774
+ ledger_entry['indeterminate_at'] = now_iso()
775
+ ledger_entry['indeterminate_reason'] = 'no_auto_navigation_after_click'
776
+ ledger_entry['post_click_url'] = url_after[:200]
777
+ append_ledger(ledger_entry)
778
+ log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id))
779
+ # Keep browser open - don't break/close
780
+ continue # Stay in loop waiting for close signal
781
+
782
+ # Navigated to content management - always wait and refresh
783
+ time.sleep(10)
784
+ log(_kwargs(step='manage_landed', url=page.url[:120]))
785
+
786
+ # Always refresh once after waiting
787
+ page.reload(timeout=15000)
788
+ time.sleep(5)
789
+ log(_kwargs(step='post_refresh', url=page.url[:120]))
790
+
791
+ # Search for target title
792
+ title_to_find = title
793
+ found = page.evaluate(f'''(targetTitle) => {{
794
+ for (const el of document.querySelectorAll('div, span, a')) {{
795
+ const text = (el.textContent || '').trim();
796
+ if (text.includes(targetTitle)) {{
797
+ return {{found: true, element: text.substring(0, 80)}};
798
+ }}
799
+ }}
800
+ return {{found: false}};
801
+ }}''', title_to_find)
802
+ log(_kwargs(step='verify_work', **found))
803
+
804
+ if found.get('found'):
805
+ ledger_entry['state'] = 'submitted'
806
+ ledger_entry['submitted_at'] = now_iso()
807
+ append_ledger(ledger_entry)
808
+ log(_kwargs(phase='prepare', step='submitted', task_id=task_id))
809
+ else:
810
+ # Poll up to 5 minutes
811
+ start_poll = time.time()
812
+ poll_found = False
813
+ while time.time() - start_poll < 300:
814
+ time.sleep(15)
815
+ page.reload(timeout=15000)
816
+ time.sleep(3)
817
+ found = page.evaluate(f'''(targetTitle) => {{
818
+ for (const el of document.querySelectorAll('div, span, a')) {{
819
+ const text = (el.textContent || '').trim();
820
+ if (text.includes(targetTitle)) {{
821
+ return {{found: true, element: text.substring(0, 80)}};
822
+ }}
823
+ }}
824
+ return {{found: false}};
825
+ }}''', title_to_find)
826
+ log(_kwargs(step='poll_check', **found, elapsed=time.time()-start_poll))
827
+ if found.get('found'):
828
+ poll_found = True
829
+ break
830
+
831
+ if poll_found:
832
+ ledger_entry['state'] = 'submitted'
833
+ ledger_entry['submitted_at'] = now_iso()
834
+ append_ledger(ledger_entry)
835
+ log(_kwargs(phase='prepare', step='submitted_after_poll', task_id=task_id))
836
+ else:
837
+ ledger_entry['state'] = 'failed'
838
+ ledger_entry['failed_at'] = now_iso()
839
+ ledger_entry['failed_reason'] = 'work_not_found_in_manage'
840
+ append_ledger(ledger_entry)
841
+ log(_kwargs(phase='prepare', step='submit_failed', task_id=task_id, reason='work_not_found'))
842
+ break
843
+
844
+ if clf.exists():
845
+ clf.unlink(missing_ok=True)
846
+ log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
847
+ ledger_entry['state'] = 'indeterminate'
848
+ ledger_entry['closed_at'] = now_iso()
849
+ append_ledger(ledger_entry)
850
+ break
851
+
852
+ time.sleep(2)
853
+
854
+ except Exception as e:
855
+ log(_kwargs(phase='prepare', error=str(e)[:300]))
856
+ try:
857
+ append_ledger(_kwargs(
858
+ task_id=task_id, sha256=sha, path=str(path),
859
+ state='failed', timestamp=now_iso(),
860
+ error=str(e)[:200], profile_id=profile_id,
861
+ ))
862
+ except Exception:
863
+ pass
864
+ finally:
865
+ try:
866
+ ctx.close()
867
+ except Exception:
868
+ pass
869
+ release_lock(profile_id)
870
+ log(_kwargs(phase='prepare', step='browser_closed', task_id=task_id))
871
+
872
+
873
+ # ─── Phase: confirm-submit ───────────────────────────────────────────────────
874
+
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
+
903
+ 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'))
974
+
975
+
976
+ # ─── Phase: status ───────────────────────────────────────────────────────────
977
+
978
+ def phase_status(profile_id, task_id=None):
979
+ """Query publish status from the ledger."""
980
+ entries = load_ledger()
981
+
982
+ if task_id:
983
+ matching = [e for e in entries if e.get('task_id') == task_id]
984
+ else:
985
+ matching = entries[-10:]
986
+
987
+ if not matching:
988
+ log(_kwargs(phase='status', entries=[], count=0))
989
+ return
990
+
991
+ states = {}
992
+ for e in matching:
993
+ s = e.get('state', 'unknown')
994
+ states[s] = states.get(s, 0) + 1
995
+
996
+ result = _kwargs(
997
+ phase='status',
998
+ total_entries=len(entries),
999
+ shown=len(matching),
1000
+ state_summary=states,
1001
+ entries=matching,
1002
+ )
1003
+
1004
+ lf = _lock_file(profile_id)
1005
+ if lf.exists():
1006
+ try:
1007
+ lock_data = json.loads(lf.read_text())
1008
+ pid = lock_data.get('pid')
1009
+ os.kill(pid, 0)
1010
+ result['browser_running'] = True
1011
+ result['browser_pid'] = pid
1012
+ except (OSError, json.JSONDecodeError):
1013
+ result['browser_running'] = False
1014
+
1015
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1016
+ sys.exit(0)
1017
+
1018
+
1019
+ # ─── Phase: close ────────────────────────────────────────────────────────────
1020
+
1021
+ 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)
1077
+
1078
+
1079
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
1080
+
1081
+ def main():
1082
+ parser = argparse.ArgumentParser(
1083
+ description='Douyin Creator Center Publish — 五阶段发布流水线',
1084
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1085
+ epilog='''
1086
+ Examples:
1087
+ python3 douyin_publish.py --dry-run --media-path /path/to/video.mp4
1088
+ python3 douyin_publish.py --prepare --media-path /path/to/video.mp4 --scheduled-at 2026-08-04T23:50:00+08:00
1089
+ python3 douyin_publish.py --confirm-submit
1090
+ python3 douyin_publish.py --status
1091
+ python3 douyin_publish.py --status --task-id publish_xxx
1092
+ python3 douyin_publish.py --close
1093
+ ''',
1094
+ )
1095
+
1096
+ parser.add_argument('--dry-run', action='store_true', help='Check media without executing')
1097
+ parser.add_argument('--prepare', action='store_true', help='Upload and configure media')
1098
+ parser.add_argument('--confirm-submit', action='store_true', help='Confirm final submission')
1099
+ parser.add_argument('--status', action='store_true', help='Query publish status')
1100
+ parser.add_argument('--close', action='store_true', help='Close browser')
1101
+ parser.add_argument('--media-path', help='Path to media file (required for --dry-run, --prepare)')
1102
+ parser.add_argument('--scheduled-at', help=f'Scheduled publish time (default: {DEFAULT_SCHEDULED_AT})')
1103
+ 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')
1105
+ parser.add_argument('--title', default=None, help='Video title')
1106
+ parser.add_argument('--description', default=None, help='Video description')
1107
+ args = parser.parse_args()
1108
+ profile_id = args.profile_id
1109
+
1110
+ if args.dry_run:
1111
+ if not args.media_path:
1112
+ die(1, _kwargs(error='--media-path required for --dry-run'))
1113
+ phase_dry_run(profile_id, args.media_path, args.scheduled_at)
1114
+
1115
+ elif args.prepare:
1116
+ if not args.media_path:
1117
+ die(1, _kwargs(error='--media-path required for --prepare'))
1118
+ phase_prepare(profile_id, args.media_path, args.scheduled_at, args.title, args.description)
1119
+
1120
+ elif args.confirm_submit:
1121
+ phase_confirm_submit(profile_id)
1122
+
1123
+ elif args.status:
1124
+ phase_status(profile_id, args.task_id)
1125
+
1126
+ elif args.close:
1127
+ phase_close(profile_id)
1128
+
1129
+ else:
1130
+ parser.print_help()
1131
+ sys.exit(1)
1132
+
1133
+
1134
+ if __name__ == '__main__':
1135
+ main()