@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,449 @@
1
+ #!/usr/bin/env python3
2
+ """Xiaohongshu Creator persistent login controller with Unix Socket IPC.
3
+ Usage: python3 xiaohongshu_login_controller.py <profile_id>
4
+ Commands via socket: login-start, login-status, login-scan-done, login-refresh-qr, login-close
5
+ """
6
+ from media_agent.runtime.paths import runtime_home
7
+ import errno
8
+ import json
9
+ import os
10
+ import socket
11
+ import sys
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from datetime import datetime, timezone, timedelta
16
+ from media_agent.runtime.browser import launch_persistent_context
17
+ from media_agent.platforms.xiaohongshu.xiaohongshu_login_evidence import (
18
+ DASHBOARD_KEYWORDS,
19
+ LOGIN_KEYWORDS,
20
+ classify_login_evidence,
21
+ post_scan_timeout_state,
22
+ scan_poll_schedule,
23
+ )
24
+
25
+ BASE = runtime_home()
26
+ ALIAS = PROFILE_DIR = LOCK_FILE = SOCK_FILE = SCREENSHOT_DIR = cfg = None
27
+
28
+ def configure(profile_id):
29
+ global ALIAS, PROFILE_DIR, LOCK_FILE, SOCK_FILE, SCREENSHOT_DIR, cfg
30
+ ALIAS = profile_id
31
+
32
+ PROFILE_DIR = BASE / 'profiles' / ALIAS
33
+ LOCK_FILE = BASE / 'locks' / f'{ALIAS}.lock'
34
+ SOCK_FILE = BASE / 'controllers' / f'{ALIAS}.sock'
35
+ SCREENSHOT_DIR = BASE / 'screenshots'
36
+ SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
37
+ SOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
38
+
39
+ cfg = json.loads((PROFILE_DIR / 'config.json').read_text())
40
+
41
+ tz = timezone(timedelta(hours=8))
42
+
43
+ # Global state
44
+ controller_state = 'INIT'
45
+ browser = None
46
+ context = None
47
+ page = None
48
+ task_id = None
49
+ request_id = None
50
+
51
+ def now_iso():
52
+ return datetime.now(tz).isoformat()
53
+
54
+ def log(msg):
55
+ print(json.dumps(msg, ensure_ascii=False), flush=True)
56
+
57
+ def process_alive(pid):
58
+ if not isinstance(pid, int) or pid <= 1:
59
+ return False
60
+ try:
61
+ os.kill(pid, 0)
62
+ return True
63
+ except OSError as exc:
64
+ if exc.errno == errno.ESRCH:
65
+ return False
66
+ if exc.errno == errno.EPERM:
67
+ return True
68
+ raise
69
+
70
+ def acquire_lock():
71
+ if LOCK_FILE.exists():
72
+ try:
73
+ old = json.loads(LOCK_FILE.read_text())
74
+ except (OSError, json.JSONDecodeError):
75
+ return False
76
+ if process_alive(old.get('pid')):
77
+ return False
78
+ try:
79
+ LOCK_FILE.unlink()
80
+ except OSError:
81
+ return False
82
+ LOCK_FILE.write_text(json.dumps({'pid': os.getpid(), 'time': now_iso(), 'task_id': task_id}))
83
+ return True
84
+
85
+ def release_lock():
86
+ try:
87
+ LOCK_FILE.unlink(missing_ok=True)
88
+ except:
89
+ pass
90
+
91
+ def check_logged_in():
92
+ """Check if current page shows dashboard."""
93
+ global page
94
+ url = page.url
95
+ body = page.evaluate('() => document.body.innerText')
96
+ dashboard = [kw for kw in DASHBOARD_KEYWORDS if kw in body]
97
+ has_login = any(kw in body for kw in LOGIN_KEYWORDS)
98
+ return classify_login_evidence(
99
+ url=url,
100
+ dashboard_evidence=dashboard,
101
+ login_form_detected=has_login,
102
+ qr_detected='APP扫一扫登录' in body or '扫码登录' in body,
103
+ )
104
+
105
+ def navigate_to_qr():
106
+ """Navigate to login page and switch to QR scan mode. Returns state."""
107
+ global page, context
108
+ page.goto('https://creator.xiaohongshu.com/', wait_until='load', timeout=30000)
109
+ time.sleep(3)
110
+
111
+ url = page.url
112
+ body = page.evaluate('() => document.body.innerText')
113
+ # Check if already logged in
114
+ if '/new/home' in url or ('创作服务平台' in body and '发布笔记' in body):
115
+ return 'logged_in'
116
+
117
+ # Check if on SMS login page
118
+ if '短信登录' in body:
119
+ return 'sms_login_found'
120
+
121
+ # Check if QR mode already
122
+ if 'APP扫一扫登录' in body:
123
+ return 'qr_already'
124
+
125
+ return 'sms_login_found'
126
+
127
+ def switch_to_qr():
128
+ """Click the QR icon in the login card. Returns True if successful."""
129
+ global page
130
+ result = page.evaluate('''() => {
131
+ // Find login card with SMS login content
132
+ for (const el of document.querySelectorAll('div[class*="login"], div[class*="card"], div[class*="panel"]')) {
133
+ const rect = el.getBoundingClientRect();
134
+ const text = el.textContent || '';
135
+ if (rect.width > 300 && rect.height > 200 && text.includes('短信登录')) {
136
+ // Find the QR icon in the top-right corner of this card
137
+ const icons = el.querySelectorAll('img, svg');
138
+ for (const icon of icons) {
139
+ const ir = icon.getBoundingClientRect();
140
+ if (ir.width > 15 && ir.width < 80 && ir.height > 15 && ir.height < 80
141
+ && ir.x > rect.x + rect.width - 100) {
142
+ icon.dispatchEvent(new MouseEvent('click', {bubbles: true}));
143
+ return 'clicked_card_icon';
144
+ }
145
+ }
146
+ // Try clicking the last small element in the card
147
+ const children = el.querySelectorAll('*');
148
+ for (let i = children.length - 1; i >= 0; i--) {
149
+ const c = children[i];
150
+ const cr = c.getBoundingClientRect();
151
+ if (cr.width > 20 && cr.width < 80 && cr.height > 20 && cr.height < 80
152
+ && cr.x > rect.x + rect.width - 100) {
153
+ c.dispatchEvent(new MouseEvent('click', {bubbles: true}));
154
+ return 'clicked_small_element';
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return 'not_found';
160
+ }''')
161
+ log({'step': 'qr_switch', 'result': result})
162
+ time.sleep(2)
163
+ return result != 'not_found'
164
+
165
+ def verify_qr_mode():
166
+ """Verify QR mode is active: APP扫一扫 + QR visible + no phone input."""
167
+ global page
168
+ result = page.evaluate('''() => {
169
+ const body = document.body.innerText;
170
+ const hasQRText = body.includes('APP扫一扫登录') || body.includes('扫码登录');
171
+ const hasPhoneInput = document.querySelector('input[placeholder*="手机号"]');
172
+ const hasPhoneVisible = hasPhoneInput && hasPhoneInput.offsetParent !== null;
173
+ // Check for QR image
174
+ let hasQRImage = false;
175
+ for (const img of document.querySelectorAll('img')) {
176
+ const rect = img.getBoundingClientRect();
177
+ if (rect.width >= 100 && rect.width <= 400 && rect.height >= 100 && rect.height <= 400
178
+ && img.offsetParent !== null) {
179
+ hasQRImage = true;
180
+ break;
181
+ }
182
+ }
183
+ return {
184
+ qr_text: hasQRText,
185
+ qr_image: hasQRImage,
186
+ phone_input: hasPhoneVisible,
187
+ success: hasQRText && hasQRImage && !hasPhoneVisible
188
+ };
189
+ }''')
190
+ result['success'] = (
191
+ result.get('qr_text')
192
+ and result.get('qr_image')
193
+ and not result.get('phone_input')
194
+ )
195
+ return result
196
+
197
+ def capture_qr():
198
+ """Capture QR code screenshot."""
199
+ global page
200
+ qr_path = str(SCREENSHOT_DIR / f'{ALIAS}_qr.png')
201
+ page.screenshot(path=qr_path)
202
+ return qr_path
203
+
204
+ def remove_qr_capture():
205
+ try:
206
+ (SCREENSHOT_DIR / f'{ALIAS}_qr.png').unlink(missing_ok=True)
207
+ except OSError:
208
+ pass
209
+
210
+ def write_response(result):
211
+ resp_file = BASE / 'signals' / f'{ALIAS}_response.json'
212
+ resp_file.parent.mkdir(parents=True, exist_ok=True)
213
+ tmp_file = resp_file.with_suffix('.json.tmp')
214
+ tmp_file.write_text(json.dumps(result, ensure_ascii=False))
215
+ os.replace(tmp_file, resp_file)
216
+
217
+ def handle_command(cmd, data=None):
218
+ global controller_state, browser, context, page, task_id, request_id
219
+
220
+ if cmd == 'login-status':
221
+ if not page:
222
+ return {'state': controller_state, 'error': 'NO_PAGE'}
223
+ url = page.url
224
+ alive = True
225
+ try:
226
+ page.title()
227
+ except Exception:
228
+ alive = False
229
+ status = check_logged_in() if alive else 'unknown'
230
+ if status == 'logged_in':
231
+ controller_state = 'LOGGED_IN'
232
+ return {
233
+ 'state': controller_state,
234
+ 'controller_pid': os.getpid(),
235
+ 'browser_alive': alive,
236
+ 'page_count': len(context.pages) if context else 0,
237
+ 'current_url': url[:120],
238
+ 'lock_owner': os.getpid(),
239
+ 'task_id': task_id,
240
+ 'request_id': request_id,
241
+ 'login_status': status
242
+ }
243
+
244
+ elif cmd == 'login-scan-done':
245
+ if controller_state != 'WAIT_QR_SCAN':
246
+ return {'state': controller_state, 'error': 'NOT_WAITING_QR'}
247
+ # Wait 10-15s for platform to process, then poll up to 60s.
248
+ initial_wait, poll_interval, poll_count = scan_poll_schedule()
249
+ time.sleep(initial_wait)
250
+ for i in range(poll_count):
251
+ time.sleep(poll_interval)
252
+ try:
253
+ url = page.url
254
+ body = page.evaluate('() => document.body.innerText')
255
+ # SPA: URL may still be /login but dashboard already rendered
256
+ dashboard = [kw for kw in DASHBOARD_KEYWORDS if kw in body]
257
+ has_login = any(kw in body for kw in LOGIN_KEYWORDS)
258
+ status = classify_login_evidence(
259
+ url=url,
260
+ dashboard_evidence=dashboard,
261
+ login_form_detected=has_login,
262
+ qr_detected='APP扫一扫登录' in body or '扫码登录' in body,
263
+ )
264
+ if status == 'logged_in':
265
+ controller_state = 'LOGGED_IN'
266
+ remove_qr_capture()
267
+ return {
268
+ 'state': 'LOGGED_IN',
269
+ 'url': url[:120],
270
+ 'elapsed': initial_wait + (i + 1) * poll_interval,
271
+ 'spa': '/login' in url,
272
+ 'dashboard_evidence': dashboard,
273
+ }
274
+ except Exception as exc:
275
+ log({'step': 'scan_poll_error', 'error': str(exc)[:100]})
276
+ controller_state = post_scan_timeout_state()
277
+ return {
278
+ 'state': controller_state,
279
+ 'error': 'TIMEOUT_NO_BACKEND_EVIDENCE',
280
+ 'rescan_requested': False,
281
+ }
282
+
283
+ elif cmd == 'login-refresh-qr':
284
+ if controller_state != 'WAIT_QR_SCAN':
285
+ return {'state': controller_state, 'error': 'NOT_WAITING_QR'}
286
+ # Find and click refresh button on QR area
287
+ page.evaluate('''() => {
288
+ for (const el of document.querySelectorAll('div, span, a, button')) {
289
+ const t = el.textContent.trim();
290
+ if (t === '刷新' || t === '重新获取' || t.includes('点击刷新')) {
291
+ el.click(); return 'clicked';
292
+ }
293
+ }
294
+ return 'not_found';
295
+ }''')
296
+ time.sleep(2)
297
+ qr_path = capture_qr()
298
+ return {'state': 'WAIT_QR_SCAN', 'qr_path': qr_path}
299
+
300
+ elif cmd == 'login-close':
301
+ controller_state = 'CLOSING'
302
+ try:
303
+ if context:
304
+ context.close()
305
+ except Exception:
306
+ pass
307
+ try:
308
+ if browser:
309
+ browser.close()
310
+ except Exception:
311
+ pass
312
+ browser = None
313
+ context = None
314
+ page = None
315
+ remove_qr_capture()
316
+ release_lock()
317
+ controller_state = 'COMPLETE'
318
+ return {'state': 'COMPLETE'}
319
+
320
+ return {'error': 'UNKNOWN_COMMAND'}
321
+
322
+ def server_thread():
323
+ """Unix Socket IPC server."""
324
+ if SOCK_FILE.exists():
325
+ SOCK_FILE.unlink()
326
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
327
+ sock.bind(str(SOCK_FILE))
328
+ os.chmod(str(SOCK_FILE), 0o600)
329
+ sock.listen(1)
330
+ sock.settimeout(1.0)
331
+
332
+ while controller_state != 'CLOSING' and controller_state != 'COMPLETE':
333
+ try:
334
+ conn, _ = sock.accept()
335
+ data = b''
336
+ while True:
337
+ chunk = conn.recv(4096)
338
+ if not chunk:
339
+ break
340
+ data += chunk
341
+ if len(data) > 65536:
342
+ break
343
+ conn.close()
344
+ if data:
345
+ msg = json.loads(data.decode())
346
+ cmd = msg.get('command', '')
347
+ cmd_data = msg.get('data', {})
348
+ cmd_data['request_id'] = msg.get('request_id', '')
349
+ result = handle_command(cmd, cmd_data)
350
+ result['request_id'] = msg.get('request_id', '')
351
+ write_response(result)
352
+ log({'step': 'cmd_handled', 'command': cmd, 'result': list(result.keys())})
353
+ except socket.timeout:
354
+ continue
355
+ except Exception as e:
356
+ log({'step': 'socket_error', 'error': str(e)[:100]})
357
+
358
+ try:
359
+ sock.close()
360
+ SOCK_FILE.unlink(missing_ok=True)
361
+ except OSError:
362
+ pass
363
+
364
+ def main():
365
+ global controller_state, task_id
366
+ if len(sys.argv) < 2:
367
+ raise SystemExit('profile_id required')
368
+ configure(sys.argv[1])
369
+ # Initial startup
370
+ task_id = f'xhs_login_{ALIAS}_{datetime.now(tz).strftime("%Y%m%d_%H%M%S")}'
371
+ if not acquire_lock():
372
+ log({'error': 'PROFILE_LOCKED'})
373
+ sys.exit(1)
374
+
375
+ browser_obj = launch_persistent_context(
376
+ user_data_dir=str(PROFILE_DIR / 'browser_data'),
377
+ headless=False, stealth_args=True,
378
+ viewport=cfg['fingerprint']['viewport'],
379
+ locale=cfg['fingerprint']['locale'],
380
+ timezone=cfg['fingerprint']['timezone'],
381
+ humanize=False, geoip=False
382
+ )
383
+ global browser, context, page
384
+ browser = browser_obj
385
+ context = browser_obj
386
+ page = context.pages[0] if context.pages else context.new_page()
387
+
388
+ result = navigate_to_qr()
389
+ if result == 'logged_in':
390
+ controller_state = 'LOGGED_IN'
391
+ startup = {'event': 'controller_started', 'state': 'LOGGED_IN', 'controller_pid': os.getpid(), 'task_id': task_id}
392
+ write_response(startup)
393
+ log(startup)
394
+ elif result in ('sms_login_found', 'qr_already'):
395
+ switched = result == 'qr_already' or switch_to_qr()
396
+ if switched:
397
+ qr_verify = verify_qr_mode()
398
+ if qr_verify.get('success'):
399
+ controller_state = 'WAIT_QR_SCAN'
400
+ qr_path = capture_qr()
401
+ startup = {'event': 'controller_started', 'state': 'WAIT_QR_SCAN', 'qr_path': qr_path, 'task_id': task_id, 'pid': os.getpid(), 'qr_verified': qr_verify}
402
+ write_response(startup)
403
+ log(startup)
404
+ else:
405
+ controller_state = 'WAIT_QR_MODE'
406
+ startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'QR_SWITCH_FAILED', 'task_id': task_id, 'qr_verify': qr_verify}
407
+ write_response(startup)
408
+ log(startup)
409
+ else:
410
+ controller_state = 'WAIT_QR_MODE'
411
+ startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'QR_ICON_NOT_FOUND', 'task_id': task_id}
412
+ write_response(startup)
413
+ log(startup)
414
+ else:
415
+ controller_state = 'WAIT_QR_MODE'
416
+ startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'UNKNOWN_FORM', 'task_id': task_id}
417
+ write_response(startup)
418
+ log(startup)
419
+
420
+ # Start IPC server in background
421
+ t = threading.Thread(target=server_thread, daemon=True)
422
+ t.start()
423
+
424
+ # Keep alive
425
+ try:
426
+ while controller_state not in ('CLOSING', 'COMPLETE'):
427
+ time.sleep(1)
428
+ except KeyboardInterrupt:
429
+ pass
430
+
431
+ # Cleanup
432
+ try:
433
+ if context:
434
+ context.close()
435
+ except:
436
+ pass
437
+ try:
438
+ if browser:
439
+ browser.close()
440
+ except:
441
+ pass
442
+ release_lock()
443
+ try:
444
+ SOCK_FILE.unlink(missing_ok=True)
445
+ except:
446
+ pass
447
+
448
+ if __name__ == '__main__':
449
+ main()
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Pure evidence and state helpers for Xiaohongshu Creator login."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Iterable
7
+
8
+
9
+ DASHBOARD_KEYWORDS = (
10
+ "创作服务平台",
11
+ "首页",
12
+ "笔记管理",
13
+ "数据看板",
14
+ "创作中心",
15
+ "发布笔记",
16
+ "我的笔记",
17
+ )
18
+
19
+ LOGIN_KEYWORDS = ("短信登录", "APP扫一扫登录", "扫码登录", "手机号")
20
+
21
+
22
+ def classify_login_evidence(
23
+ *,
24
+ url: str,
25
+ dashboard_evidence: Iterable[str],
26
+ login_form_detected: bool,
27
+ qr_detected: bool,
28
+ ) -> str:
29
+ """Classify login without trusting URL alone.
30
+
31
+ Xiaohongshu's SPA may retain /login after rendering the authenticated
32
+ dashboard, so strong dashboard DOM plus absence of login UI wins.
33
+ """
34
+
35
+ dashboard = {item for item in dashboard_evidence if item}
36
+ backend_route = "/new/" in url or "/creator" in url
37
+ strong_dashboard = len(dashboard) >= 2 or (
38
+ backend_route and len(dashboard) >= 1
39
+ )
40
+ login_ui = login_form_detected or qr_detected
41
+
42
+ if strong_dashboard and not login_ui:
43
+ return "logged_in"
44
+ if login_ui and not strong_dashboard:
45
+ return "login_required"
46
+ return "indeterminate"
47
+
48
+
49
+ def qr_mode_ready(*, qr_text: bool, qr_image: bool, phone_input: bool) -> bool:
50
+ """Return true only when the QR UI is visible and SMS inputs are gone."""
51
+
52
+ return qr_text and qr_image and not phone_input
53
+
54
+
55
+ def scan_poll_schedule() -> tuple[int, int, int]:
56
+ """Initial wait, poll interval, and maximum poll count."""
57
+
58
+ return 12, 2, 30
59
+
60
+
61
+ def post_scan_timeout_state() -> str:
62
+ """Never ask for another scan merely because redirect polling timed out."""
63
+
64
+ return "INDETERMINATE"
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Profile-scoped IPC client for the Xiaohongshu login controller."""
3
+
4
+ from __future__ import annotations
5
+ from media_agent.runtime.paths import runtime_home
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import socket
11
+ import sys
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+
16
+
17
+ BASE = runtime_home()
18
+
19
+
20
+ def response_path(profile_id: str) -> Path:
21
+ return BASE / "signals" / f"{profile_id}_response.json"
22
+
23
+
24
+ def wait_for_response(path: Path, request_id: str, timeout: float) -> dict:
25
+ deadline = time.monotonic() + timeout
26
+ while time.monotonic() < deadline:
27
+ if path.exists():
28
+ try:
29
+ payload = json.loads(path.read_text())
30
+ except (OSError, json.JSONDecodeError):
31
+ payload = {}
32
+ if payload.get("request_id") == request_id:
33
+ return payload
34
+ time.sleep(0.2)
35
+ return {
36
+ "state": "IPC_TIMEOUT",
37
+ "error": "response timeout",
38
+ "request_id": request_id,
39
+ }
40
+
41
+
42
+ def send(profile_id: str, command: str, timeout: float) -> dict:
43
+ sock_path = BASE / "controllers" / f"{profile_id}.sock"
44
+ if not sock_path.exists():
45
+ return {"state": "CONTROLLER_NOT_FOUND", "error": "no socket"}
46
+
47
+ request_id = uuid.uuid4().hex
48
+ payload = {"command": command, "request_id": request_id, "data": {}}
49
+ try:
50
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
51
+ client.settimeout(5)
52
+ client.connect(str(sock_path))
53
+ client.sendall(json.dumps(payload).encode())
54
+ try:
55
+ client.shutdown(socket.SHUT_WR)
56
+ except OSError:
57
+ # 半关闭只是 EOF 提示;请求已完整发送,对端先关闭时(如 macOS 偶发
58
+ # ENOTCONN)不得误判 CONTROLLER_DEAD,响应经信号文件交付。
59
+ pass
60
+ client.close()
61
+ except OSError as exc:
62
+ return {
63
+ "state": "CONTROLLER_DEAD",
64
+ "error": str(exc)[:120],
65
+ "request_id": request_id,
66
+ }
67
+
68
+ return wait_for_response(response_path(profile_id), request_id, timeout)
69
+
70
+
71
+ def wait_start(profile_id: str, timeout: float) -> dict:
72
+ path = response_path(profile_id)
73
+ deadline = time.monotonic() + timeout
74
+ while time.monotonic() < deadline:
75
+ if path.exists():
76
+ try:
77
+ payload = json.loads(path.read_text())
78
+ except (OSError, json.JSONDecodeError):
79
+ payload = {}
80
+ if payload.get("event") == "controller_started":
81
+ return payload
82
+ time.sleep(0.2)
83
+ return {"state": "START_TIMEOUT", "error": "controller startup timeout"}
84
+
85
+
86
+ def main() -> int:
87
+ parser = argparse.ArgumentParser()
88
+ parser.add_argument("profile_id")
89
+ parser.add_argument(
90
+ "command",
91
+ choices=("status", "scan-done", "refresh-qr", "close", "wait-start"),
92
+ )
93
+ parser.add_argument("--timeout", type=float)
94
+ args = parser.parse_args()
95
+
96
+ defaults = {
97
+ "status": 10,
98
+ "scan-done": 80,
99
+ "refresh-qr": 15,
100
+ "close": 15,
101
+ "wait-start": 45,
102
+ }
103
+ timeout = args.timeout or defaults[args.command]
104
+ if args.command == "wait-start":
105
+ result = wait_start(args.profile_id, timeout)
106
+ else:
107
+ wire_command = {
108
+ "status": "login-status",
109
+ "scan-done": "login-scan-done",
110
+ "refresh-qr": "login-refresh-qr",
111
+ "close": "login-close",
112
+ }[args.command]
113
+ result = send(args.profile_id, wire_command, timeout)
114
+
115
+ print(json.dumps(result, indent=2, ensure_ascii=False))
116
+ return 0 if "error" not in result else 1
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())