@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,925 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 小红书创作服务平台发布脚本 — 五阶段发布流水线。
4
+
5
+ 注意:浏览器自动化部分为 IMPLEMENTED_UNVERIFIED,实现依据为
6
+ docs/xiaohongshu-creator-publishing-handoff.md 的实测流程,
7
+ 待 Level 2 在线实测后固化;CLI 底座(账本去重、素材校验、门禁、
8
+ 退出码)已经 Level 1 离线测试验证。
9
+
10
+ Phases:
11
+ --dry-run 检查素材 SHA-256 去重,不执行实际操作(不开浏览器、不取锁、不写账本)
12
+ --prepare 上传素材、填写并读回标题、写账本后保持浏览器等待确认
13
+ --confirm-submit 确认提交(单次真实点击"发布")
14
+ --status 查询发布状态
15
+ --close 关闭浏览器并释放锁
16
+
17
+ Ledger: tasks/xiaohongshu_publish/publish-ledger.jsonl
18
+ State machine: discovered → prepared → submitted → reviewing → published/rejected/indeterminate/failed
19
+ SHA-256 dedup: submitted / reviewing / published 永久阻止重复提交。
20
+ """
21
+ from media_agent.runtime.paths import runtime_home
22
+
23
+ import argparse
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import signal
28
+ import sys
29
+ import time
30
+ from datetime import datetime, timedelta, timezone
31
+ from pathlib import Path
32
+
33
+
34
+ # ─── Paths ───────────────────────────────────────────────────────────────────
35
+ BASE = runtime_home()
36
+ LOCKS_DIR = BASE / 'locks'
37
+ LEDGER_DIR = BASE / 'tasks' / 'xiaohongshu_publish'
38
+ LEDGER_FILE = LEDGER_DIR / 'publish-ledger.jsonl'
39
+ PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/video-publish'
40
+ NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager'
41
+ SIGNAL_DIR = BASE / 'signals'
42
+
43
+
44
+ def _profile_dir(profile_id):
45
+ return BASE / 'profiles' / profile_id
46
+
47
+
48
+ def _browser_data(profile_id):
49
+ return str(_profile_dir(profile_id) / 'browser_data')
50
+
51
+
52
+ def _lock_file(profile_id):
53
+ return LOCKS_DIR / f'{profile_id}.lock'
54
+
55
+
56
+ def _confirm_file(profile_id):
57
+ return SIGNAL_DIR / f'{profile_id}.confirm'
58
+
59
+
60
+ def _close_file(profile_id):
61
+ return SIGNAL_DIR / f'{profile_id}.close'
62
+
63
+
64
+ # ─── Constants ───────────────────────────────────────────────────────────────
65
+ DEDUP_STATES = {'submitted', 'reviewing', 'published'}
66
+ SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
67
+ TZ_SHANGHAI = timezone(timedelta(hours=8))
68
+ NOTE_POLL_INTERVAL = 18 # 交接文档要求 15–20 秒间隔只读检查
69
+ NOTE_POLL_TIMEOUT = 300 # 最长 5 分钟
70
+ SUCCESS_PAGE_WAIT = 180 # 点击后等待同域跳转最长 180 秒
71
+
72
+
73
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
74
+
75
+ def sha256_file(path):
76
+ """Compute SHA-256 digest of a file."""
77
+ h = hashlib.sha256()
78
+ with open(path, 'rb') as f:
79
+ while True:
80
+ chunk = f.read(65536)
81
+ if not chunk:
82
+ break
83
+ h.update(chunk)
84
+ return h.hexdigest()
85
+
86
+
87
+ def load_ledger():
88
+ """Load all ledger entries as a list of dicts."""
89
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
90
+ if not LEDGER_FILE.exists():
91
+ return []
92
+ entries = []
93
+ for line in LEDGER_FILE.read_text().splitlines():
94
+ line = line.strip()
95
+ if line:
96
+ try:
97
+ entries.append(json.loads(line))
98
+ except json.JSONDecodeError:
99
+ pass
100
+ return entries
101
+
102
+
103
+ def find_ledger_entry(sha256):
104
+ """Return the most recent ledger entry matching a SHA-256 (or None)."""
105
+ for entry in reversed(load_ledger()):
106
+ if entry.get('sha256') == sha256:
107
+ return entry
108
+ return None
109
+
110
+
111
+ def check_duplicate(sha256):
112
+ """Return (is_blocked, existing_entry) — True if SHA-256 is in a terminal dedup state."""
113
+ entry = find_ledger_entry(sha256)
114
+ if entry and entry.get('state') in DEDUP_STATES:
115
+ return True, entry
116
+ return False, entry
117
+
118
+
119
+ def append_ledger(entry):
120
+ """Append a JSON line to the ledger."""
121
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
122
+ with open(LEDGER_FILE, 'a') as f:
123
+ f.write(json.dumps(entry, ensure_ascii=False) + '\n')
124
+
125
+
126
+ def acquire_lock(profile_id, task_id):
127
+ """Acquire the profile lock. Returns (ok, existing_owner)."""
128
+ lf = _lock_file(profile_id)
129
+ LOCKS_DIR.mkdir(parents=True, exist_ok=True)
130
+ if lf.exists():
131
+ try:
132
+ old = json.loads(lf.read_text())
133
+ os.kill(old.get('pid', 0), 0)
134
+ return False, old
135
+ except (OSError, json.JSONDecodeError):
136
+ lf.unlink()
137
+ lf.write_text(json.dumps({
138
+ 'task_id': task_id,
139
+ 'pid': os.getpid(),
140
+ 'time': datetime.now().isoformat(),
141
+ 'phase': 'publish',
142
+ }, indent=2))
143
+ return True, None
144
+
145
+
146
+ def release_lock(profile_id):
147
+ """Release the profile lock."""
148
+ lf = _lock_file(profile_id)
149
+ if lf.exists():
150
+ lf.unlink(missing_ok=True)
151
+
152
+
153
+ def now_iso():
154
+ """Return current time in Asia/Shanghai ISO format."""
155
+ return datetime.now(TZ_SHANGHAI).isoformat()
156
+
157
+
158
+ def log(msg):
159
+ """Print a structured log line to stdout."""
160
+ print(json.dumps({'ts': now_iso(), **msg}, ensure_ascii=False), flush=True)
161
+
162
+
163
+ def die(code, msg):
164
+ """Print error and exit."""
165
+ log({'level': 'ERROR', 'exit_code': code, **msg})
166
+ sys.exit(code)
167
+
168
+
169
+ def _kwargs(**kw):
170
+ """Helper to return a dict of kwargs — for readability."""
171
+ return kw
172
+
173
+
174
+ def _validate_media(media_path):
175
+ """Validate media file existence and format. Returns resolved Path."""
176
+ path = Path(media_path).resolve()
177
+ if not path.exists():
178
+ die(1, _kwargs(error='FILE_NOT_FOUND', path=str(path)))
179
+ if not path.is_file():
180
+ die(1, _kwargs(error='NOT_A_FILE', path=str(path)))
181
+ ext = path.suffix.lower()
182
+ if ext not in SUPPORTED_VIDEO_FORMATS:
183
+ die(1, _kwargs(
184
+ error='UNSUPPORTED_FORMAT',
185
+ format=ext, supported=sorted(SUPPORTED_VIDEO_FORMATS),
186
+ ))
187
+ return path
188
+
189
+
190
+ # ─── Browser ─────────────────────────────────────────────────────────────────
191
+
192
+ def launch_browser(profile_id):
193
+ """Launch a CloakBrowser persistent context for the profile."""
194
+ from media_agent.runtime.browser import launch_persistent_context
195
+
196
+ pdir = _profile_dir(profile_id)
197
+ cfg = json.loads((pdir / 'config.json').read_text())
198
+ fp = cfg.get('fingerprint', {})
199
+
200
+ ctx = launch_persistent_context(
201
+ user_data_dir=_browser_data(profile_id),
202
+ headless=False,
203
+ stealth_args=True,
204
+ viewport=fp.get('viewport', {'width': 1440, 'height': 900}),
205
+ locale=fp.get('locale', 'zh-CN'),
206
+ timezone=fp.get('timezone', 'Asia/Shanghai'),
207
+ humanize=False,
208
+ geoip=False,
209
+ )
210
+ return ctx, cfg
211
+
212
+
213
+ def _is_login_page(url):
214
+ """Detect login/passport redirect."""
215
+ low = url.lower()
216
+ return 'login' in low or 'passport' in low
217
+
218
+
219
+ # ─── Phase: dry-run ──────────────────────────────────────────────────────────
220
+
221
+ def phase_dry_run(profile_id, media_path):
222
+ """Check media file validity and SHA-256 dedup status.
223
+
224
+ 纯离线检查:不开浏览器、不取锁、不写账本。
225
+ """
226
+ path = _validate_media(media_path)
227
+ file_size = path.stat().st_size
228
+ sha = sha256_file(str(path))
229
+ blocked, existing = check_duplicate(sha)
230
+
231
+ result = _kwargs(
232
+ phase='dry-run',
233
+ media_path=str(path),
234
+ file_size=file_size,
235
+ file_size_mb=round(file_size / 1024 / 1024, 2),
236
+ sha256=sha,
237
+ format=path.suffix.lower(),
238
+ duplicate_blocked=blocked,
239
+ profile_id=profile_id,
240
+ )
241
+
242
+ if blocked:
243
+ result['existing_state'] = existing.get('state')
244
+ result['existing_task_id'] = existing.get('task_id')
245
+ result['existing_timestamp'] = existing.get('timestamp')
246
+ log(result)
247
+ sys.exit(8) # DUPLICATE_BLOCKED
248
+
249
+ log(result)
250
+ return sha, str(path)
251
+
252
+
253
+ # ─── Prepare helpers ─────────────────────────────────────────────────────────
254
+
255
+ def _wait_for_upload_complete(page, timeout=300):
256
+ """Wait for the video upload/processing to complete."""
257
+ start = time.time()
258
+ while time.time() - start < timeout:
259
+ status = page.evaluate('''() => {
260
+ const body = document.body.textContent || '';
261
+ const processing = body.includes('上传中') || body.includes('视频上传中') || body.includes('处理中');
262
+ // 完成证据:发布按钮出现/启用、视频预览或智能封面
263
+ let publishReady = false;
264
+ for (const el of document.querySelectorAll('button, div, span')) {
265
+ const text = (el.textContent || '').trim();
266
+ if (text === '发布' && el.offsetParent !== null) { publishReady = true; break; }
267
+ }
268
+ const preview = document.querySelector('video');
269
+ if (publishReady && !processing) { return 'done'; }
270
+ if (preview && !processing) { return 'done'; }
271
+ if (processing) { return 'processing'; }
272
+ return 'waiting';
273
+ }''')
274
+ if status == 'done':
275
+ return True
276
+ if status == 'processing':
277
+ log(_kwargs(step='upload_progress', status='processing', elapsed=round(time.time() - start)))
278
+ time.sleep(3)
279
+ return False
280
+
281
+
282
+ def _dismiss_popups(page):
283
+ """位置权限选"一律不允许";"要恢复页面吗"提示关闭而非恢复。"""
284
+ time.sleep(2)
285
+ dismissed = page.evaluate('''() => {
286
+ const actions = [];
287
+ const denyKeywords = ['一律不允许', '不允许', '禁止', '拒绝', "Don't Allow", 'Block', 'Deny'];
288
+ for (const kw of denyKeywords) {
289
+ for (const el of document.querySelectorAll('button, span, div, a')) {
290
+ const text = (el.textContent || '').trim();
291
+ if ((text === kw || text.includes(kw)) && el.offsetParent !== null) {
292
+ el.click();
293
+ actions.push('denied:' + kw);
294
+ break;
295
+ }
296
+ }
297
+ if (actions.length) break;
298
+ }
299
+ // "要恢复页面吗?" → 点关闭图标,绝不点"恢复"
300
+ for (const el of document.querySelectorAll('[class*="close"], [aria-label="关闭"], [class*="Close"]')) {
301
+ const rect = el.getBoundingClientRect();
302
+ if (el.offsetParent !== null && rect.width > 0 && rect.width < 60) {
303
+ const nearby = (el.closest('div')?.textContent || '');
304
+ if (nearby.includes('恢复页面')) {
305
+ el.click();
306
+ actions.push('restore_prompt_closed');
307
+ break;
308
+ }
309
+ }
310
+ }
311
+ return actions;
312
+ }''')
313
+ log(_kwargs(step='dismiss_popups', result=dismissed))
314
+ time.sleep(1)
315
+
316
+
317
+ def _fill_title(page, title):
318
+ """Fill the note title input."""
319
+ r = page.evaluate('''(t) => {
320
+ const selectors = [
321
+ 'input[placeholder*="填写标题"]',
322
+ 'input[placeholder*="标题"]',
323
+ '#publishInput',
324
+ '[class*="title"] input',
325
+ '[class*="c-input_titleInput"] input',
326
+ ];
327
+ for (const sel of selectors) {
328
+ for (const inp of document.querySelectorAll(sel)) {
329
+ if (inp.offsetParent !== null) {
330
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set ||
331
+ Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
332
+ setter.call(inp, t);
333
+ inp.dispatchEvent(new Event('input', { bubbles: true }));
334
+ inp.dispatchEvent(new Event('change', { bubbles: true }));
335
+ return 'title_set';
336
+ }
337
+ }
338
+ }
339
+ return 'no_title_input';
340
+ }''', title)
341
+ log(_kwargs(step='fill_title', result=r))
342
+ return r == 'title_set'
343
+
344
+
345
+ def _readback_title(page):
346
+ """Read back the title value from the page."""
347
+ value = page.evaluate('''() => {
348
+ for (const inp of document.querySelectorAll('input, textarea')) {
349
+ if (inp.offsetParent !== null && inp.value && inp.value.trim()) {
350
+ const ph = inp.placeholder || '';
351
+ if (ph.includes('标题')) { return inp.value; }
352
+ }
353
+ }
354
+ for (const inp of document.querySelectorAll('input, textarea')) {
355
+ if (inp.offsetParent !== null && inp.value && inp.value.trim()) {
356
+ return inp.value;
357
+ }
358
+ }
359
+ return '';
360
+ }''')
361
+ log(_kwargs(step='title_readback', value=(value or '')[:60]))
362
+ return value or ''
363
+
364
+
365
+ def _inspect_publish_button(page):
366
+ """发布按钮必须唯一、可见且启用。"""
367
+ info = page.evaluate('''() => {
368
+ const buttons = [];
369
+ for (const el of document.querySelectorAll('button, [role="button"]')) {
370
+ const text = (el.textContent || '').trim();
371
+ if (text === '发布' && el.offsetParent !== null) {
372
+ buttons.push({
373
+ tag: el.tagName,
374
+ disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true',
375
+ });
376
+ }
377
+ }
378
+ return {count: buttons.length, buttons: buttons};
379
+ }''')
380
+ log(_kwargs(step='publish_button_inspect', **info))
381
+ ready = info.get('count') == 1 and not any(b.get('disabled') for b in info.get('buttons', []))
382
+ return ready
383
+
384
+
385
+ def _verify_note_in_manager(page, title):
386
+ """在"笔记管理 → 全部"按标题匹配目标卡片并读取状态。
387
+
388
+ 返回 dict:{note_found, note_status, evidence}。
389
+ note_status ∈ reviewing / published / rejected / unknown。
390
+ """
391
+ info = page.evaluate('''(targetTitle) => {
392
+ const cards = document.querySelectorAll('[class*="note"], [class*="card"], li, tr');
393
+ for (const card of cards) {
394
+ const text = (card.textContent || '');
395
+ if (!text.includes(targetTitle)) { continue; }
396
+ const status = text.includes('未通过') ? 'rejected'
397
+ : text.includes('审核中') ? 'reviewing'
398
+ : text.includes('已发布') ? 'published'
399
+ : 'unknown';
400
+ return {
401
+ note_found: true,
402
+ note_status: status,
403
+ evidence: text.replace(/\\s+/g, ' ').trim().substring(0, 120),
404
+ };
405
+ }
406
+ return {note_found: false, note_status: 'unknown', evidence: ''};
407
+ }''', title)
408
+ return info
409
+
410
+
411
+ # ─── Phase: prepare ──────────────────────────────────────────────────────────
412
+
413
+ def phase_prepare(profile_id, media_path, title):
414
+ """Upload media, fill title, write ledger, keep browser awaiting confirm."""
415
+ path = _validate_media(media_path)
416
+ sha = sha256_file(str(path))
417
+
418
+ blocked, existing = check_duplicate(sha)
419
+ if blocked:
420
+ die(8, _kwargs(
421
+ phase='prepare', error='DUPLICATE_BLOCKED', sha256=sha,
422
+ existing_state=existing.get('state'),
423
+ existing_task_id=existing.get('task_id'),
424
+ ))
425
+
426
+ task_id = f'xhs_publish_{profile_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
427
+
428
+ ok, owner = acquire_lock(profile_id, task_id)
429
+ if not ok:
430
+ die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
431
+
432
+ log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
433
+
434
+ try:
435
+ ctx, cfg = launch_browser(profile_id)
436
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
437
+ except Exception as e:
438
+ release_lock(profile_id)
439
+ die(1, _kwargs(phase='prepare', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
440
+
441
+ ledger_entry = None
442
+ try:
443
+ # ── 直接进入视频发布页(不点击"发布视频笔记"入口,避免触发原生文件选择器)──
444
+ log(_kwargs(step='navigate', url=PUBLISH_URL))
445
+ page.goto(PUBLISH_URL, wait_until='load', timeout=60000)
446
+ time.sleep(5)
447
+
448
+ current_url = page.url
449
+ if _is_login_page(current_url):
450
+ ctx.close()
451
+ release_lock(profile_id)
452
+ die(2, _kwargs(phase='prepare', error='LOGIN_REQUIRED', current_url=current_url[:120]))
453
+
454
+ log(_kwargs(step='landed', url=current_url[:120], title=page.title()))
455
+
456
+ # ── 上传:直接对 file input 设置路径,绕开原生文件选择器 ──
457
+ log(_kwargs(step='upload', path=str(path)))
458
+ try:
459
+ file_input = page.locator('input[type="file"]').first
460
+ file_input.set_input_files(str(path))
461
+ log(_kwargs(step='file_set', result='ok', native_chooser_bypassed=True))
462
+ except Exception as e:
463
+ log(_kwargs(step='file_set', result='locator_failed', error=str(e)[:100]))
464
+ page.evaluate('''() => {
465
+ const inputs = document.querySelectorAll('input[type="file"]');
466
+ for (const inp of inputs) {
467
+ inp.style.display = 'block';
468
+ inp.style.visibility = 'visible';
469
+ inp.style.position = 'static';
470
+ }
471
+ return inputs.length;
472
+ }''')
473
+ try:
474
+ file_input = page.locator('input[type="file"]').first
475
+ file_input.set_input_files(str(path))
476
+ log(_kwargs(step='file_set', result='ok_retry', native_chooser_bypassed=True))
477
+ except Exception as e2:
478
+ ctx.close()
479
+ release_lock(profile_id)
480
+ die(1, _kwargs(phase='prepare', error='UPLOAD_FAILED', detail=str(e2)[:200]))
481
+
482
+ time.sleep(3)
483
+
484
+ # ── 处理位置权限等弹窗 ──
485
+ _dismiss_popups(page)
486
+
487
+ # ── 等待上传与平台处理完成 ──
488
+ if not _wait_for_upload_complete(page):
489
+ log(_kwargs(step='upload_wait', warning='timeout — proceeding anyway'))
490
+
491
+ # ── 填写标题并读回校验 ──
492
+ filled = _fill_title(page, title)
493
+ time.sleep(1)
494
+ readback = _readback_title(page)
495
+ title_ok = filled and readback.strip() == title.strip()
496
+ if not title_ok:
497
+ log(_kwargs(step='title_readback_mismatch', expected=title[:40], actual=readback[:40]))
498
+
499
+ # ── Write ledger entry ──
500
+ ledger_entry = _kwargs(
501
+ task_id=task_id,
502
+ sha256=sha,
503
+ path=str(path),
504
+ state='prepared',
505
+ timestamp=now_iso(),
506
+ profile_id=profile_id,
507
+ title=title,
508
+ title_readback=readback,
509
+ title_ok=title_ok,
510
+ file_size=path.stat().st_size,
511
+ click_count=0,
512
+ pid=os.getpid(),
513
+ )
514
+ append_ledger(ledger_entry)
515
+ log(_kwargs(phase='prepare', step='ledger_written', state='prepared', task_id=task_id))
516
+
517
+ # ── Wait for confirm or close signal ──
518
+ SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
519
+ cf = _confirm_file(profile_id)
520
+ clf = _close_file(profile_id)
521
+ for f in [cf, clf]:
522
+ if f.exists():
523
+ f.unlink(missing_ok=True)
524
+
525
+ log(_kwargs(
526
+ phase='prepare', step='awaiting_confirm', task_id=task_id,
527
+ hint='Run xhs-publish-confirm to submit, or xhs-publish-close to abort',
528
+ ))
529
+
530
+ while True:
531
+ if cf.exists():
532
+ cf.unlink(missing_ok=True)
533
+ log(_kwargs(phase='prepare', step='confirm_received'))
534
+
535
+ # 门禁:按钮必须唯一、可见且启用,否则不点击继续等待
536
+ if not _inspect_publish_button(page):
537
+ log(_kwargs(step='publish_button_not_ready', warning='waiting, no click performed'))
538
+ continue
539
+
540
+ # 单次真实点击"发布"
541
+ clicked = page.evaluate('''() => {
542
+ for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
543
+ const text = (el.textContent || '').trim();
544
+ if (text === '发布' && el.offsetParent !== null) {
545
+ el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
546
+ el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
547
+ el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
548
+ return 'clicked:' + el.tagName + ':' + text;
549
+ }
550
+ }
551
+ return 'not_found';
552
+ }''')
553
+ ledger_entry['click_count'] = 1
554
+ log(_kwargs(step='publish_click', result=clicked, click_count=1))
555
+ time.sleep(5)
556
+
557
+ # ── 等待发布成功页(最长 180 秒)──
558
+ success_seen = False
559
+ start_wait = time.time()
560
+ while time.time() - start_wait < SUCCESS_PAGE_WAIT:
561
+ current_url = page.url
562
+ page_text = page.evaluate('() => document.body.innerText.substring(0,500)')
563
+ if '/publish/success' in current_url or '发布成功' in page_text:
564
+ success_seen = True
565
+ log(_kwargs(step='success_page', url=current_url[:120]))
566
+ break
567
+ if '失败' in page_text or '错误' in page_text:
568
+ log(_kwargs(step='platform_error_hint', text=page_text[:200]))
569
+ time.sleep(5)
570
+
571
+ if not success_seen:
572
+ # 成功页证据不足 → indeterminate,保持浏览器,禁止重发
573
+ ledger_entry['state'] = 'indeterminate'
574
+ ledger_entry['indeterminate_at'] = now_iso()
575
+ ledger_entry['indeterminate_reason'] = 'no_success_page_evidence'
576
+ ledger_entry['result'] = 'PUBLISH_INDETERMINATE'
577
+ append_ledger(ledger_entry)
578
+ log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id))
579
+ continue # Stay in loop waiting for close signal
580
+
581
+ # 成功页只证明平台接收提交 → SUBMITTED / VERIFYING_NOTE,不得直接判 PUBLISHED
582
+ ledger_entry['state'] = 'submitted'
583
+ ledger_entry['submitted_at'] = now_iso()
584
+ ledger_entry['success_page_seen'] = True
585
+ append_ledger(ledger_entry)
586
+ log(_kwargs(phase='prepare', step='VERIFYING_NOTE', task_id=task_id,
587
+ note='success page only proves receipt, verifying in note manager'))
588
+
589
+ # 等约 10 秒让作品列表同步
590
+ time.sleep(10)
591
+
592
+ # ── VERIFYING_NOTE: 进入 note-manager → 全部 核验 ──
593
+ page.goto(NOTE_MANAGER_URL, wait_until='load', timeout=60000)
594
+ time.sleep(5)
595
+ page.evaluate('''() => {
596
+ for (const el of document.querySelectorAll('div, span, a, li')) {
597
+ const text = (el.textContent || '').trim();
598
+ if (text === '全部' && el.offsetParent !== null) { el.click(); return 'clicked'; }
599
+ }
600
+ return 'not_found';
601
+ }''')
602
+ time.sleep(3)
603
+ log(_kwargs(step='note_manager_landed', url=page.url[:120], section='笔记管理/全部'))
604
+
605
+ verify = _verify_note_in_manager(page, title)
606
+ log(_kwargs(step='note_verify', **verify))
607
+
608
+ if not verify.get('note_found'):
609
+ # 轮询最长 5 分钟(15–20 秒间隔只读检查)
610
+ start_poll = time.time()
611
+ while time.time() - start_poll < NOTE_POLL_TIMEOUT:
612
+ time.sleep(NOTE_POLL_INTERVAL)
613
+ page.reload(timeout=15000)
614
+ time.sleep(3)
615
+ page.evaluate('''() => {
616
+ for (const el of document.querySelectorAll('div, span, a, li')) {
617
+ const text = (el.textContent || '').trim();
618
+ if (text === '全部' && el.offsetParent !== null) { el.click(); return 'clicked'; }
619
+ }
620
+ return 'not_found';
621
+ }''')
622
+ time.sleep(2)
623
+ verify = _verify_note_in_manager(page, title)
624
+ log(_kwargs(step='note_poll', **verify, elapsed=round(time.time() - start_poll)))
625
+ if verify.get('note_found'):
626
+ break
627
+
628
+ if verify.get('note_found'):
629
+ note_status = verify.get('note_status')
630
+ ledger_entry['note_found'] = True
631
+ ledger_entry['section'] = '笔记管理/全部'
632
+ ledger_entry['status_evidence'] = verify.get('evidence')
633
+ if note_status in ('published', 'rejected', 'reviewing'):
634
+ ledger_entry['state'] = note_status
635
+ append_ledger(ledger_entry)
636
+ log(_kwargs(phase='prepare', step='final_state', state=note_status, task_id=task_id))
637
+ else:
638
+ # 卡片已落地但状态标签无法识别 → submitted(已接收)
639
+ append_ledger(ledger_entry)
640
+ log(_kwargs(phase='prepare', step='final_state', state='submitted',
641
+ note='card_found_status_unknown', task_id=task_id))
642
+ else:
643
+ # 5 分钟仍未找到 → PUBLISH_INDETERMINATE,保持浏览器,禁止重发
644
+ ledger_entry['state'] = 'indeterminate'
645
+ ledger_entry['indeterminate_at'] = now_iso()
646
+ ledger_entry['indeterminate_reason'] = 'note_not_found_after_polling'
647
+ ledger_entry['result'] = 'PUBLISH_INDETERMINATE'
648
+ ledger_entry['note_found'] = False
649
+ append_ledger(ledger_entry)
650
+ log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id,
651
+ reason='note_not_found', no_resubmit=True))
652
+ continue # Keep browser for manual inspection
653
+ break
654
+
655
+ if clf.exists():
656
+ clf.unlink(missing_ok=True)
657
+ log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
658
+ ledger_entry['state'] = 'indeterminate'
659
+ ledger_entry['closed_at'] = now_iso()
660
+ append_ledger(ledger_entry)
661
+ break
662
+
663
+ time.sleep(2)
664
+
665
+ except Exception as e:
666
+ log(_kwargs(phase='prepare', error=str(e)[:300]))
667
+ try:
668
+ append_ledger(_kwargs(
669
+ task_id=task_id, sha256=sha, path=str(path),
670
+ state='failed', timestamp=now_iso(),
671
+ error=str(e)[:200], profile_id=profile_id,
672
+ ))
673
+ except Exception:
674
+ pass
675
+ finally:
676
+ try:
677
+ ctx.close()
678
+ except Exception:
679
+ pass
680
+ release_lock(profile_id)
681
+ log(_kwargs(phase='prepare', step='browser_closed', task_id=task_id))
682
+
683
+
684
+ # ─── Phase: confirm-submit ───────────────────────────────────────────────────
685
+
686
+ def phase_confirm_submit(profile_id):
687
+ """Send confirm signal to a running prepare process, or launch browser and click submit once."""
688
+ SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
689
+ lf = _lock_file(profile_id)
690
+ cf = _confirm_file(profile_id)
691
+
692
+ if lf.exists():
693
+ try:
694
+ lock_data = json.loads(lf.read_text())
695
+ pid = lock_data.get('pid')
696
+ if pid:
697
+ os.kill(pid, 0)
698
+ cf.write_text(json.dumps({
699
+ 'action': 'confirm',
700
+ 'timestamp': now_iso(),
701
+ }))
702
+ log(_kwargs(phase='confirm-submit', step='signal_sent', target_pid=pid))
703
+ time.sleep(10)
704
+ if not cf.exists():
705
+ log(_kwargs(phase='confirm-submit', step='signal_consumed', status='ok'))
706
+ else:
707
+ log(_kwargs(phase='confirm-submit', step='signal_pending', status='waiting'))
708
+ return
709
+ except (OSError, json.JSONDecodeError):
710
+ log(_kwargs(phase='confirm-submit', step='process_dead', action='launch_new'))
711
+
712
+ log(_kwargs(phase='confirm-submit', step='launch_browser'))
713
+
714
+ try:
715
+ ctx, cfg = launch_browser(profile_id)
716
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
717
+ except Exception as e:
718
+ die(1, _kwargs(phase='confirm-submit', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
719
+
720
+ try:
721
+ page.goto(PUBLISH_URL, wait_until='load', timeout=60000)
722
+ time.sleep(5)
723
+
724
+ current_url = page.url
725
+ if _is_login_page(current_url):
726
+ ctx.close()
727
+ die(2, _kwargs(phase='confirm-submit', error='LOGIN_REQUIRED', current_url=current_url[:120]))
728
+
729
+ if not _inspect_publish_button(page):
730
+ log(_kwargs(step='no_publish_button',
731
+ warning='Upload page may not have a prepared video. Run xhs-publish-prepare first.'))
732
+
733
+ clicked = page.evaluate('''() => {
734
+ for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
735
+ const text = (el.textContent || '').trim();
736
+ if (text === '发布' && el.offsetParent !== null) {
737
+ el.click();
738
+ return 'clicked:' + text;
739
+ }
740
+ }
741
+ return 'not_found';
742
+ }''')
743
+ log(_kwargs(step='confirm_fallback_click', result=clicked, click_count=1 if clicked != 'not_found' else 0))
744
+ time.sleep(5)
745
+ log(_kwargs(step='post_submit', url=page.url[:120], title=page.title()))
746
+
747
+ entries = load_ledger()
748
+ if entries:
749
+ latest = entries[-1]
750
+ latest['state'] = 'submitted'
751
+ latest['submitted_at'] = now_iso()
752
+ append_ledger(latest)
753
+ log(_kwargs(step='ledger_updated', task_id=latest.get('task_id')))
754
+
755
+ except Exception as e:
756
+ log(_kwargs(phase='confirm-submit', error=str(e)[:300]))
757
+ finally:
758
+ try:
759
+ ctx.close()
760
+ except Exception:
761
+ pass
762
+ release_lock(profile_id)
763
+ log(_kwargs(phase='confirm-submit', step='browser_closed'))
764
+
765
+
766
+ # ─── Phase: status ───────────────────────────────────────────────────────────
767
+
768
+ def phase_status(profile_id, task_id=None):
769
+ """Query publish status from the ledger."""
770
+ entries = load_ledger()
771
+
772
+ if task_id:
773
+ matching = [e for e in entries if e.get('task_id') == task_id]
774
+ else:
775
+ matching = entries[-10:]
776
+
777
+ if not matching:
778
+ log(_kwargs(phase='status', entries=[], count=0))
779
+ return
780
+
781
+ states = {}
782
+ for e in matching:
783
+ s = e.get('state', 'unknown')
784
+ states[s] = states.get(s, 0) + 1
785
+
786
+ result = _kwargs(
787
+ phase='status',
788
+ total_entries=len(entries),
789
+ shown=len(matching),
790
+ state_summary=states,
791
+ entries=matching,
792
+ )
793
+
794
+ lf = _lock_file(profile_id)
795
+ if lf.exists():
796
+ try:
797
+ lock_data = json.loads(lf.read_text())
798
+ pid = lock_data.get('pid')
799
+ os.kill(pid, 0)
800
+ result['browser_running'] = True
801
+ result['browser_pid'] = pid
802
+ except (OSError, json.JSONDecodeError):
803
+ result['browser_running'] = False
804
+
805
+ print(json.dumps(result, ensure_ascii=False, indent=2))
806
+ sys.exit(0)
807
+
808
+
809
+ # ─── Phase: close ────────────────────────────────────────────────────────────
810
+
811
+ def phase_close(profile_id):
812
+ """Close the browser and release the lock."""
813
+ SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
814
+ lf = _lock_file(profile_id)
815
+ clf = _close_file(profile_id)
816
+
817
+ result = _kwargs(phase='close', action='none')
818
+
819
+ if lf.exists():
820
+ try:
821
+ lock_data = json.loads(lf.read_text())
822
+ pid = lock_data.get('pid')
823
+ if pid:
824
+ try:
825
+ os.kill(pid, 0)
826
+ clf.write_text(json.dumps({
827
+ 'action': 'close',
828
+ 'timestamp': now_iso(),
829
+ }))
830
+ result['action'] = 'signal_sent'
831
+ result['pid'] = pid
832
+ log(result)
833
+
834
+ for _ in range(15):
835
+ time.sleep(2)
836
+ if not lf.exists():
837
+ result['lock_released'] = True
838
+ break
839
+ try:
840
+ os.kill(pid, 0)
841
+ except OSError:
842
+ result['lock_released'] = True
843
+ release_lock(profile_id)
844
+ break
845
+
846
+ if not result.get('lock_released'):
847
+ try:
848
+ os.kill(pid, signal.SIGTERM)
849
+ time.sleep(2)
850
+ except OSError:
851
+ pass
852
+ release_lock(profile_id)
853
+ result['action'] = 'force_killed'
854
+ result['lock_released'] = True
855
+
856
+ log(result)
857
+ return
858
+ except OSError:
859
+ pass
860
+ except (json.JSONDecodeError, ValueError):
861
+ pass
862
+
863
+ release_lock(profile_id)
864
+ result['action'] = 'lock_released'
865
+ result['lock_released'] = True
866
+ log(result)
867
+
868
+
869
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
870
+
871
+ def main():
872
+ parser = argparse.ArgumentParser(
873
+ description='小红书创作服务平台发布 — 五阶段发布流水线(浏览器自动化部分 IMPLEMENTED_UNVERIFIED)',
874
+ formatter_class=argparse.RawDescriptionHelpFormatter,
875
+ epilog='''
876
+ Examples:
877
+ python3 xiaohongshu_publish.py --dry-run --profile-id <id> --media-path /path/to/video.mp4
878
+ python3 xiaohongshu_publish.py --prepare --profile-id <id> --media-path /path/to/video.mp4 --title "标题"
879
+ python3 xiaohongshu_publish.py --confirm-submit --profile-id <id>
880
+ python3 xiaohongshu_publish.py --status --profile-id <id> [--task-id xhs_publish_xxx]
881
+ python3 xiaohongshu_publish.py --close --profile-id <id>
882
+ ''',
883
+ )
884
+
885
+ parser.add_argument('--dry-run', action='store_true', help='Check media without executing')
886
+ parser.add_argument('--prepare', action='store_true', help='Upload and fill title')
887
+ parser.add_argument('--confirm-submit', action='store_true', help='Confirm final submission')
888
+ parser.add_argument('--status', action='store_true', help='Query publish status')
889
+ parser.add_argument('--close', action='store_true', help='Close browser')
890
+ parser.add_argument('--media-path', help='Path to media file (required for --dry-run, --prepare)')
891
+ parser.add_argument('--title', default=None, help='Note title (required for --prepare, generated by Agent)')
892
+ parser.add_argument('--task-id', default=None, help='Task ID for status query')
893
+ parser.add_argument('--profile-id', required=True, help='Profile ID')
894
+ args = parser.parse_args()
895
+ profile_id = args.profile_id
896
+
897
+ if args.dry_run:
898
+ if not args.media_path:
899
+ die(1, _kwargs(error='MEDIA_PATH_REQUIRED', detail='--media-path required for --dry-run'))
900
+ phase_dry_run(profile_id, args.media_path)
901
+
902
+ elif args.prepare:
903
+ if not args.media_path:
904
+ die(1, _kwargs(error='MEDIA_PATH_REQUIRED', detail='--media-path required for --prepare'))
905
+ if not args.title or not args.title.strip():
906
+ die(1, _kwargs(error='TITLE_REQUIRED',
907
+ detail='--title required for --prepare; title is generated by Agent from video content'))
908
+ phase_prepare(profile_id, args.media_path, args.title)
909
+
910
+ elif args.confirm_submit:
911
+ phase_confirm_submit(profile_id)
912
+
913
+ elif args.status:
914
+ phase_status(profile_id, args.task_id)
915
+
916
+ elif args.close:
917
+ phase_close(profile_id)
918
+
919
+ else:
920
+ parser.print_help()
921
+ sys.exit(1)
922
+
923
+
924
+ if __name__ == '__main__':
925
+ main()