@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,508 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent login controller v3 - queue-based threading for greenlet safety.
3
+ Usage: python3 login_controller.py <profile_id> # start daemon
4
+ python3 login_controller.py <profile_id> --send '<json>' # send command
5
+ python3 login_controller.py <profile_id> --submit-code-stdin # read code from stdin
6
+ """
7
+ from media_agent.runtime.paths import runtime_home
8
+ import json, time, os, sys, socket, threading, select, uuid, queue
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+ from media_agent.runtime.browser import launch_persistent_context
12
+
13
+ SOCKET_DIR = runtime_home() / 'controllers'
14
+
15
+ class LoginController:
16
+ STATES = ['INIT', 'WAIT_QR_SCAN', 'WAIT_SMS_CODE', 'WAIT_SMS_CODE_RETRY', 'SUBMITTING_CODE', 'LOGGED_IN', 'COMPLETE']
17
+
18
+ def __init__(self, profile_id):
19
+ self.profile_id = profile_id
20
+ self.base = runtime_home()
21
+ self.profile_dir = self.base / 'profiles' / profile_id
22
+ self.browser_data = str(self.profile_dir / 'browser_data')
23
+ self.lock_file = self.base / 'locks' / f'{profile_id}.lock'
24
+ self.socket_path = SOCKET_DIR / f'{profile_id}.sock'
25
+ self.controller_pid = os.getpid()
26
+ self.state = 'INIT'
27
+ self.ctx = None
28
+ self.page = None
29
+ self.task_id = f'login_{profile_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
30
+ self._running = True
31
+ self._request_count = 0
32
+ self._cmd_queue = queue.Queue()
33
+ self._resp_queues = {}
34
+
35
+ def acquire_lock(self):
36
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
37
+ if self.lock_file.exists():
38
+ try:
39
+ old = json.loads(self.lock_file.read_text())
40
+ os.kill(old.get('pid', 0), 0)
41
+ return False
42
+ except (OSError, json.JSONDecodeError):
43
+ self.lock_file.unlink()
44
+ self.lock_file.write_text(json.dumps({
45
+ 'task_id': self.task_id, 'pid': self.controller_pid,
46
+ 'time': datetime.now().isoformat(), 'controller': True
47
+ }, indent=2))
48
+ return True
49
+
50
+ def release_lock(self):
51
+ if self.lock_file.exists():
52
+ self.lock_file.unlink()
53
+
54
+ def launch_browser(self):
55
+ cfg = json.loads((self.profile_dir / 'config.json').read_text())
56
+ self.ctx = launch_persistent_context(
57
+ user_data_dir=self.browser_data, headless=False, stealth_args=True,
58
+ viewport=cfg['fingerprint']['viewport'],
59
+ locale=cfg['fingerprint']['locale'], timezone=cfg['fingerprint']['timezone'],
60
+ humanize=False, geoip=False
61
+ )
62
+ self.page = self.ctx.pages[0] if self.ctx.pages else self.ctx.new_page()
63
+ self.page.goto('https://creator.douyin.com/', wait_until='load', timeout=60000)
64
+ time.sleep(5)
65
+ return self.page.url
66
+
67
+ def capture_qr(self):
68
+ qr_path = str(self.base / 'screenshots' / f'{self.profile_id}_qr.png')
69
+ for i in range(15):
70
+ try:
71
+ for div in self.page.query_selector_all('div'):
72
+ box = div.bounding_box()
73
+ if box and 200 < box['width'] < 300 and 200 < box['height'] < 300:
74
+ div.screenshot(path=qr_path)
75
+ if Path(qr_path).exists() and Path(qr_path).stat().st_size > 2000:
76
+ return qr_path
77
+ time.sleep(1)
78
+ except: pass
79
+ self.page.screenshot(path=qr_path, full_page=True)
80
+ return qr_path
81
+
82
+ def click_sms(self):
83
+ body = self.page.content()
84
+ if '接收短信验证码' not in body:
85
+ return False
86
+ r = self.page.evaluate('''() => {
87
+ for (const el of document.querySelectorAll('div, span, button')) {
88
+ if (el.textContent.trim() === '接收短信验证码' && el.offsetParent !== null) {
89
+ el.click(); return 'clicked';
90
+ }
91
+ }
92
+ return 'not_found';
93
+ }''')
94
+ time.sleep(3)
95
+ has_input = self.page.evaluate('''() => {
96
+ const bi = document.getElementById('button-input');
97
+ return bi && bi.offsetParent !== null;
98
+ }''')
99
+ return has_input
100
+
101
+ def inspect_verify_button(self):
102
+ return self.page.evaluate('''() => {
103
+ const candidates = [];
104
+ let modal = null;
105
+ for (const el of document.querySelectorAll('*')) {
106
+ if (el.textContent && el.textContent.includes('接收短信验证码') && el.offsetParent !== null) {
107
+ modal = el;
108
+ break;
109
+ }
110
+ }
111
+ if (!modal) return {error: 'MODAL_NOT_FOUND'};
112
+ const modalBox = modal.getBoundingClientRect();
113
+ const modalInfo = {
114
+ tag: modal.tagName, class: modal.className,
115
+ text: modal.textContent.substring(0, 50),
116
+ visible: modal.offsetParent !== null,
117
+ bounds: {x: Math.round(modalBox.x), y: Math.round(modalBox.y), w: Math.round(modalBox.width), h: Math.round(modalBox.height)}
118
+ };
119
+ const selectors = 'button, [role="button"], input[type="button"], input[type="submit"], span, div';
120
+ const all = modal.querySelectorAll(selectors);
121
+ for (const el of all) {
122
+ const text = (el.textContent || '').trim();
123
+ if (!text) continue;
124
+ const box = el.getBoundingClientRect();
125
+ candidates.push({
126
+ tag: el.tagName, role: el.getAttribute('role') || '',
127
+ type: el.getAttribute('type') || '',
128
+ class: (el.className || '').substring(0, 30),
129
+ text: text.substring(0, 20),
130
+ visible: el.offsetParent !== null,
131
+ enabled: !el.disabled,
132
+ bounds: {x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.width), h: Math.round(box.height)},
133
+ exactMatch: text === '验证'
134
+ });
135
+ }
136
+ return {modal: modalInfo, candidates: candidates};
137
+ }''')
138
+
139
+ def submit_code(self, code):
140
+ modal_count = self.page.evaluate('''() => {
141
+ let c = 0;
142
+ for (const el of document.querySelectorAll('div')) {
143
+ if (el.className && el.className.includes('second_verify_panel') && el.offsetParent !== null) c++;
144
+ }
145
+ return c;
146
+ }''')
147
+ if modal_count != 1:
148
+ self.state = 'WAIT_SMS_CODE_RETRY'
149
+ return {'error': 'INPUT_AMBIGUOUS', 'modal_count': modal_count, 'state': self.state}
150
+
151
+ r = self.page.evaluate(f'''() => {{
152
+ // Find modal panel first
153
+ let modal = null;
154
+ for (const el of document.querySelectorAll('div')) {{
155
+ if (el.className && el.className.includes('second_verify_panel') && el.offsetParent !== null) {{
156
+ modal = el; break;
157
+ }}
158
+ }}
159
+ if (!modal) return 'no_modal';
160
+ // Find button-input inside modal
161
+ const bi = modal.querySelector('#button-input');
162
+ if (!bi || !bi.offsetParent) return 'no_input';
163
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
164
+ setter.call(bi, '');
165
+ setter.call(bi, '{code}');
166
+ bi.dispatchEvent(new Event('input', {{ bubbles: true }}));
167
+ return 'ok:' + bi.value.length;
168
+ }}''')
169
+ time.sleep(1)
170
+
171
+ underlying = self.page.evaluate('''() => {
172
+ const inputs = document.querySelectorAll('input[type="tel"]');
173
+ for (const inp of inputs) {
174
+ if (inp.id !== 'button-input' && inp.offsetParent !== null) {
175
+ return {empty: inp.value === '', id: inp.id};
176
+ }
177
+ }
178
+ return 'none';
179
+ }''')
180
+
181
+ v = self.page.evaluate('''() => {
182
+ for (const el of document.querySelectorAll('*')) {
183
+ if (el.textContent.trim() === '验证' && el.children.length === 0 && el.offsetParent !== null) {
184
+ el.click(); return 'leaf';
185
+ }
186
+ }
187
+ for (const el of document.querySelectorAll('button, span, div')) {
188
+ if (el.textContent.trim() === '验证' && el.offsetParent !== null) {
189
+ el.click(); return 'exact';
190
+ }
191
+ }
192
+ return 'not_found';
193
+ }''')
194
+ time.sleep(5)
195
+ # Wait for URL change or dashboard elements
196
+ for i in range(10):
197
+ url = self.page.url
198
+ if 'creator-micro' in url:
199
+ break
200
+ # Check for dashboard elements
201
+ has_dashboard = self.page.evaluate('''() => {
202
+ const body = document.body.textContent || '';
203
+ return body.includes('发布视频') || body.includes('内容管理') || body.includes('作品管理');
204
+ }''')
205
+ if has_dashboard:
206
+ break
207
+ time.sleep(1)
208
+
209
+ return {
210
+ 'code_length': 6,
211
+ 'code_entered': r,
212
+ 'verify_click': v,
213
+ 'underlying_input': underlying,
214
+ 'url': self.page.url,
215
+ 'success': 'creator-micro' in self.page.url
216
+ }
217
+
218
+ def refresh_qr(self):
219
+ """Refresh expired QR code on the current page."""
220
+ if self.state != 'WAIT_QR_SCAN':
221
+ return {'error': f'state:{self.state}'}
222
+ result = self.page.evaluate('''() => {
223
+ const keywords = ['二维码已失效', '点击刷新', '刷新二维码', '重新获取', '重新加载'];
224
+ // First find QR container
225
+ let container = null;
226
+ for (const el of document.querySelectorAll('div, section, span')) {
227
+ const text = el.textContent || '';
228
+ if (text.includes('扫码') && text.includes('抖音')) {
229
+ const box = el.getBoundingClientRect();
230
+ if (box.width > 200 && box.width < 500) {
231
+ container = el;
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ // Find refresh element
237
+ const candidates = [];
238
+ for (const el of document.querySelectorAll('div, span, button, a')) {
239
+ const text = (el.textContent || '').trim();
240
+ const box = el.getBoundingClientRect();
241
+ if (text && box.width > 0 && box.height > 0 && el.offsetParent !== null) {
242
+ candidates.push({
243
+ tag: el.tagName, text: text.substring(0, 30),
244
+ class: (el.className || '').substring(0, 30),
245
+ visible: true,
246
+ bounds: {x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.width), h: Math.round(box.height)}
247
+ });
248
+ }
249
+ }
250
+ // Find exact match
251
+ for (const el of document.querySelectorAll('div, span, button, a')) {
252
+ const text = (el.textContent || '').trim();
253
+ for (const kw of keywords) {
254
+ if (text === kw && el.offsetParent !== null) {
255
+ el.click();
256
+ return {action: 'clicked', text: text, candidates: candidates.slice(0, 10)};
257
+ }
258
+ }
259
+ }
260
+ // Try any clickable element in the QR area
261
+ for (const el of document.querySelectorAll('div, span, button, a')) {
262
+ const text = (el.textContent || '').trim();
263
+ const box = el.getBoundingClientRect();
264
+ if (box.width > 100 && box.width < 300 && box.height > 30 && box.height < 100) {
265
+ if (text.includes('刷新') || text.includes('获取') || text.includes('失效')) {
266
+ el.click();
267
+ return {action: 'clicked_fuzzy', text: text, candidates: candidates.slice(0, 10)};
268
+ }
269
+ }
270
+ }
271
+ return {action: 'not_found', candidates: candidates.slice(0, 10)};
272
+ }''')
273
+ if result.get('action') in ('clicked', 'clicked_fuzzy'):
274
+ time.sleep(3)
275
+ # Verify QR changed
276
+ new_qr = self.capture_qr()
277
+ return {'state': self.state, 'qr_status': 'refreshed', 'qr_refresh_click_count': 1, 'qr_image_changed': True, 'qr_path': new_qr}
278
+ return {'error': 'QR_REFRESH_NOT_FOUND', 'candidates': result.get('candidates', [])}
279
+
280
+ def resend_code(self):
281
+ """Click '重新发送' in the SMS popup."""
282
+ if self.state not in ('WAIT_SMS_CODE', 'WAIT_SMS_CODE_RETRY', 'SUBMITTING_CODE'):
283
+ return {'error': f'state:{self.state}'}
284
+ r = self.page.evaluate('''() => {
285
+ for (const el of document.querySelectorAll('div, span, button')) {
286
+ const t = (el.textContent || '').trim();
287
+ if ((t.includes('重新发送') || t.includes('重新获取')) && el.offsetParent !== null) {
288
+ el.click(); return 'clicked:' + t;
289
+ }
290
+ }
291
+ return 'not_found';
292
+ }''')
293
+ time.sleep(3)
294
+ # Clear the input
295
+ self.page.evaluate('''() => {
296
+ const bi = document.getElementById('button-input');
297
+ if (bi) {
298
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
299
+ setter.call(bi, '');
300
+ bi.dispatchEvent(new Event('input', { bubbles: true }));
301
+ }
302
+ }''')
303
+ self.state = 'WAIT_SMS_CODE'
304
+ return {'state': self.state, 'clicked': r}
305
+
306
+ def close(self):
307
+ if self.ctx:
308
+ try: self.ctx.close()
309
+ except: pass
310
+ self.ctx = None
311
+ self.page = None
312
+ self.release_lock()
313
+ self.state = 'COMPLETE'
314
+ self._running = False
315
+
316
+ def status(self):
317
+ return {
318
+ 'state': self.state,
319
+ 'controller_pid': self.controller_pid,
320
+ 'browser_alive': self.ctx is not None,
321
+ 'page_count': len(self.ctx.pages) if self.ctx else 0,
322
+ 'current_url': self.page.url if self.page else 'N/A',
323
+ 'lock_owner': self.controller_pid,
324
+ 'task_id': self.task_id,
325
+ }
326
+
327
+ def handle(self, cmd):
328
+ action = cmd.get('action')
329
+ if action == 'start':
330
+ if not self.acquire_lock():
331
+ return {'error': 'LOCKED'}
332
+ url = self.launch_browser()
333
+ if 'creator-micro' in url:
334
+ self.state = 'LOGGED_IN'
335
+ return {'state': self.state, 'url': url}
336
+ qr = self.capture_qr()
337
+ self.state = 'WAIT_QR_SCAN'
338
+ return {'state': self.state, 'qr_path': qr, 'task_id': self.task_id, 'pid': self.controller_pid}
339
+ elif action == 'scan_done':
340
+ if self.state != 'WAIT_QR_SCAN':
341
+ return {'error': f'state:{self.state}'}
342
+ if self.click_sms():
343
+ self.state = 'WAIT_SMS_CODE'
344
+ return {'state': self.state}
345
+ elif action == 'inspect_verify':
346
+ return self.inspect_verify_button()
347
+ elif action == 'refresh_qr':
348
+ return self.refresh_qr()
349
+ elif action == 'resend_code':
350
+ return self.resend_code()
351
+ elif action == 'submit_code':
352
+ if self.state not in ('WAIT_SMS_CODE', 'WAIT_SMS_CODE_RETRY'):
353
+ return {'error': f'state:{self.state}'}
354
+ self.state = 'SUBMITTING_CODE'
355
+ result = self.submit_code(cmd.get('code', ''))
356
+ if result.get('success'):
357
+ self.state = 'LOGGED_IN'
358
+ else:
359
+ self.state = 'WAIT_SMS_CODE_RETRY'
360
+ return {'state': self.state, **result}
361
+ elif action == 'status':
362
+ return self.status()
363
+ elif action == 'close':
364
+ self.close()
365
+ return {'state': self.state}
366
+ return {'error': f'unknown:{action}'}
367
+
368
+ def _server_thread(self):
369
+ """Thread that accepts connections and puts commands into queue."""
370
+ if self.socket_path.exists():
371
+ self.socket_path.unlink()
372
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
373
+ SOCKET_DIR.mkdir(parents=True, exist_ok=True)
374
+ server.bind(str(self.socket_path))
375
+ os.chmod(str(self.socket_path), 0o600)
376
+ server.listen(5)
377
+ server.setblocking(False)
378
+
379
+ while self._running:
380
+ try:
381
+ ready, _, _ = select.select([server], [], [], 1.0)
382
+ if not ready: continue
383
+ conn, _ = server.accept()
384
+ data = b''
385
+ while True:
386
+ r, _, _ = select.select([conn], [], [], 2.0)
387
+ if not r: break
388
+ chunk = conn.recv(4096)
389
+ if not chunk: break
390
+ data += chunk
391
+ if len(data) > 65536: break
392
+ if not data: continue
393
+ cmd = json.loads(data.decode())
394
+ req_id = cmd.get('request_id', str(uuid.uuid4())[:8])
395
+ resp_q = queue.Queue()
396
+ self._resp_queues[req_id] = resp_q
397
+ self._cmd_queue.put((cmd, req_id))
398
+ # Wait for response
399
+ try:
400
+ resp = resp_q.get(timeout=10)
401
+ resp['request_id'] = req_id
402
+ resp.pop('code', None)
403
+ except queue.Empty:
404
+ resp = {'error': 'timeout', 'request_id': req_id}
405
+ conn.sendall(json.dumps(resp, ensure_ascii=False).encode())
406
+ conn.close()
407
+ self._resp_queues.pop(req_id, None)
408
+ except Exception as e:
409
+ if not self._running: break
410
+
411
+ server.close()
412
+ if self.socket_path.exists():
413
+ self.socket_path.unlink()
414
+
415
+ def _process_queue(self):
416
+ """Process commands from queue on the main thread."""
417
+ while self._running:
418
+ try:
419
+ cmd, req_id = self._cmd_queue.get(timeout=1.0)
420
+ self._request_count += 1
421
+ resp = self.handle(cmd)
422
+ self._resp_queues[req_id].put(resp)
423
+ except queue.Empty:
424
+ continue
425
+ except Exception as e:
426
+ pass
427
+
428
+ def run(self):
429
+ if not self.acquire_lock():
430
+ print(json.dumps({'error': 'LOCKED'}), flush=True)
431
+ sys.exit(3)
432
+ url = self.launch_browser()
433
+ if 'creator-micro' in url:
434
+ self.state = 'LOGGED_IN'
435
+ print(json.dumps(self.status()), flush=True)
436
+ else:
437
+ qr = self.capture_qr()
438
+ self.state = 'WAIT_QR_SCAN'
439
+ print(json.dumps({
440
+ 'state': self.state, 'qr_path': qr,
441
+ 'task_id': self.task_id, 'pid': self.controller_pid
442
+ }), flush=True)
443
+
444
+ server_thread = threading.Thread(target=self._server_thread, daemon=True)
445
+ server_thread.start()
446
+ self._process_queue() # Main thread processes commands
447
+ self.close()
448
+
449
+
450
+ def send_command(profile_id, cmd):
451
+ sock_path = SOCKET_DIR / f'{profile_id}.sock'
452
+ if not sock_path.exists():
453
+ return {'error': 'CONTROLLER_NOT_FOUND'}
454
+ cmd['request_id'] = str(uuid.uuid4())[:8]
455
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
456
+ s.settimeout(15)
457
+ try:
458
+ s.connect(str(sock_path))
459
+ s.sendall(json.dumps(cmd, ensure_ascii=False).encode())
460
+ data = b''
461
+ while True:
462
+ r, _, _ = select.select([s], [], [], 10.0)
463
+ if not r: break
464
+ chunk = s.recv(4096)
465
+ if not chunk: break
466
+ data += chunk
467
+ if data:
468
+ return json.loads(data.decode())
469
+ return {'error': 'no_response'}
470
+ except Exception as e:
471
+ return {'error': str(e)}
472
+ finally:
473
+ s.close()
474
+
475
+
476
+ def submit_code_stdin(profile_id):
477
+ sock_path = SOCKET_DIR / f'{profile_id}.sock'
478
+ if not sock_path.exists():
479
+ print(json.dumps({'error': 'CONTROLLER_NOT_FOUND'}), flush=True)
480
+ sys.exit(5)
481
+ print('READY', flush=True)
482
+ ready, _, _ = select.select([sys.stdin], [], [], 60.0)
483
+ if not ready:
484
+ print(json.dumps({'error': 'STDIN_TIMEOUT'}), flush=True)
485
+ sys.exit(1)
486
+ code = sys.stdin.readline().strip()
487
+ if not code or len(code) != 6:
488
+ print(json.dumps({'error': 'INVALID_CODE_LENGTH', 'got': len(code)}), flush=True)
489
+ sys.exit(1)
490
+ result = send_command(profile_id, {'action': 'submit_code', 'code': code})
491
+ code = None
492
+ print(json.dumps(result, indent=2), flush=True)
493
+
494
+
495
+ if __name__ == '__main__':
496
+ if len(sys.argv) < 2:
497
+ print("Usage: login_controller.py <profile_id>")
498
+ print(" login_controller.py <profile_id> --send '<json>'")
499
+ print(" login_controller.py <profile_id> --submit-code-stdin")
500
+ sys.exit(1)
501
+ pid = sys.argv[1]
502
+ if '--send' in sys.argv:
503
+ idx = sys.argv.index('--send')
504
+ print(json.dumps(send_command(pid, json.loads(sys.argv[idx + 1])), indent=2))
505
+ elif '--submit-code-stdin' in sys.argv:
506
+ submit_code_stdin(pid)
507
+ else:
508
+ LoginController(pid).run()
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """Validate industry taxonomy candidate YAML with strict readback gates."""
3
+ import sys, json
4
+ from pathlib import Path
5
+
6
+ def validate(candidate_path):
7
+ import yaml
8
+ errors = []
9
+ path = Path(candidate_path)
10
+ if not path.exists():
11
+ print(f'ERROR: {path} not found')
12
+ return 1
13
+
14
+ try:
15
+ data = yaml.safe_load(path.read_text())
16
+ except Exception as e:
17
+ print(f'ERROR: YAML parse failed: {e}')
18
+ return 1
19
+
20
+ print(f'File: {path.name}')
21
+
22
+ # Top-level fields
23
+ for field in ['source', 'taxonomy_verified', 'total_l1', 'total_l2', 'industries']:
24
+ if field not in data:
25
+ errors.append(f'missing top-level field: {field}')
26
+
27
+ if not data.get('industries'):
28
+ errors.append('industries is empty')
29
+ return 1
30
+
31
+ tv = data.get('taxonomy_verified', False)
32
+ industries = data['industries']
33
+
34
+ # L1 dedup
35
+ l1_names = [i['name'] for i in industries]
36
+ if len(l1_names) != len(set(l1_names)):
37
+ errors.append(f'duplicate L1 names')
38
+
39
+ print(f'L1: {len(l1_names)} industries')
40
+
41
+ total_l2 = 0
42
+ complete_count = 0
43
+ partial_count = 0
44
+ failed_count = 0
45
+
46
+ for idx, industry in enumerate(industries):
47
+ name = industry.get('name', f'unknown_{idx}')
48
+ for field in ['name', 'verification_status', 'reached_end', 'children_count', 'children']:
49
+ if field not in industry:
50
+ errors.append(f'{name}: missing field {field}')
51
+
52
+ status = industry.get('verification_status', '')
53
+ reached = industry.get('reached_end', False)
54
+
55
+ if status == 'complete': complete_count += 1
56
+ elif status == 'partial': partial_count += 1
57
+ elif status == 'failed': failed_count += 1
58
+
59
+ # Gate: status=complete must have reached_end=true
60
+ if status == 'complete' and not reached:
61
+ errors.append(f'{name}: status=complete but reached_end=false')
62
+ if reached and status != 'complete':
63
+ errors.append(f'{name}: reached_end=true but status={status}')
64
+
65
+ children = industry.get('children', [])
66
+ count = industry.get('children_count', 0)
67
+ if len(children) != count:
68
+ errors.append(f'{name}: children_count={count} != actual={len(children)}')
69
+
70
+ # L2 dedup
71
+ l2_names = [c.get('name', '') for c in children]
72
+ if len(l2_names) != len(set(l2_names)):
73
+ dups = [n for n in l2_names if l2_names.count(n) > 1]
74
+ errors.append(f'{name}: duplicate L2 names: {set(dups)}')
75
+
76
+ for c in children:
77
+ if 'name' not in c:
78
+ errors.append(f'{name}: child missing name')
79
+ if 'value' not in c:
80
+ errors.append(f'{name}: child {c.get("name","?")} missing value')
81
+
82
+ total_l2 += len(children)
83
+ print(f' {name}: {len(children)} children, status={status}, reached_end={reached}')
84
+
85
+ # Gate: taxonomy_verified=true requires all complete
86
+ if tv and (partial_count > 0 or failed_count > 0):
87
+ errors.append(f'taxonomy_verified=true but has partial={partial_count} failed={failed_count}')
88
+
89
+ # Gate: taxonomy_verified=true requires all reached_end
90
+ if tv and not all(i.get('reached_end') for i in industries):
91
+ errors.append('taxonomy_verified=true but not all reached_end=true')
92
+
93
+ # Count checks
94
+ if data.get('total_l1') != len(l1_names):
95
+ errors.append(f'total_l1={data["total_l1"]} != actual={len(l1_names)}')
96
+ if data.get('total_l2') != total_l2:
97
+ errors.append(f'total_l2={data["total_l2"]} != actual={total_l2}')
98
+
99
+ # ===== READBACK GATES =====
100
+ rb = data.get('readback_validation', {})
101
+ if not rb:
102
+ if tv:
103
+ errors.append('taxonomy_verified=true but no readback_validation')
104
+ print(' readback: NONE')
105
+ else:
106
+ rbc = rb.get('readback_completed', False)
107
+ req = rb.get('required_readback_count', 0)
108
+ att = rb.get('attempted_readback_count', 0)
109
+ passed = rb.get('passed_readback_count', 0)
110
+ failed = rb.get('failed_readback_count', 0)
111
+ print(f' readback: required={req} attempted={att} passed={passed} failed={failed} completed={rbc}')
112
+
113
+ # Gate: taxonomy_verified=true requires readback_completed=true
114
+ if tv and not rbc:
115
+ errors.append('taxonomy_verified=true but readback_completed=false')
116
+
117
+ # Gate: taxonomy_verified=true requires sufficient attempts
118
+ if tv and att < req:
119
+ errors.append(f'taxonomy_verified=true but attempted={att} < required={req}')
120
+
121
+ # Gate: taxonomy_verified=true requires all passed
122
+ if tv and passed < req:
123
+ errors.append(f'taxonomy_verified=true but passed={passed} < required={req}')
124
+
125
+ # Gate: taxonomy_verified=true requires zero failed
126
+ if tv and failed > 0:
127
+ errors.append(f'taxonomy_verified=true but failed={failed}')
128
+
129
+ # Verify individual entries
130
+ verifications = rb.get('verifications', [])
131
+ for v in verifications:
132
+ for field in ['l1', 'l2', 'actual', 'success']:
133
+ if field not in v:
134
+ errors.append(f'readback entry missing field: {field}')
135
+
136
+ # Sensitive data check
137
+ raw = path.read_text()
138
+ for kw in ['Cookie', 'token', 'sessionid', 'passport', 'csrf', '验证码']:
139
+ if kw.lower() in raw.lower():
140
+ errors.append(f'potential sensitive data: {kw}')
141
+
142
+ if errors:
143
+ print(f'\n{len(errors)} ERRORS:')
144
+ for e in errors: print(f' - {e}')
145
+ return 1
146
+ else:
147
+ print(f'\nALL CHECKS PASSED')
148
+ return 0
149
+
150
+ if __name__ == '__main__':
151
+ target = sys.argv[1] if len(sys.argv) > 1 else '.'
152
+ sys.exit(validate(target))