@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.
- package/README.md +90 -0
- package/SHA256SUMS +94 -0
- package/bin/media-agent.mjs +89 -0
- package/docs/guides/capabilities.md +103 -0
- package/docs/guides/image-text-publishing.md +81 -0
- package/docs/guides/installation.md +103 -0
- package/docs/guides/runtime.md +13 -0
- package/manifest.json +382 -0
- package/package.json +42 -0
- package/pyproject.toml +20 -0
- package/resources/capabilities.json +34 -0
- package/resources/configs/accounts.yaml +21 -0
- package/resources/configs/profile.template.json +20 -0
- package/resources/configs/ranking-profiles.yaml +21 -0
- package/resources/configs/toutiao-profile.template.json +22 -0
- package/resources/configs/xiaohongshu-profile.template.json +27 -0
- package/resources/data/industry-taxonomy.yaml +326 -0
- package/skills/douyin-competitor-collect/SKILL.md +177 -0
- package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
- package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
- package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
- package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
- package/skills/douyin-creator-index/SKILL.md +32 -0
- package/skills/douyin-creator-index/agents/openai.yaml +4 -0
- package/skills/douyin-creator-login/SKILL.md +58 -0
- package/skills/douyin-creator-login/agents/openai.yaml +4 -0
- package/skills/douyin-creator-publish/SKILL.md +56 -0
- package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads/SKILL.md +26 -0
- package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
- package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
- package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
- package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
- package/skills/douyin-web-login/SKILL.md +102 -0
- package/skills/douyin-web-login/agents/openai.yaml +4 -0
- package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
- package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
- package/skills/toutiao-web-login/SKILL.md +96 -0
- package/skills/toutiao-web-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
- package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
- package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
- package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
- package/src/media_agent/__init__.py +1 -0
- package/src/media_agent/cli.py +28 -0
- package/src/media_agent/commands.sh +436 -0
- package/src/media_agent/platforms/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/check_login.py +196 -0
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
- package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
- package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
- package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
- package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
- package/src/media_agent/platforms/douyin/login_controller.py +508 -0
- package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
- package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
- package/src/media_agent/platforms/toutiao/__init__.py +1 -0
- package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
- package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
- package/src/media_agent/runtime/__init__.py +1 -0
- package/src/media_agent/runtime/account_manager.py +672 -0
- package/src/media_agent/runtime/browser.py +5 -0
- package/src/media_agent/runtime/paths.py +7 -0
- package/src/media_agent/script_map.json +23 -0
- package/src/node/config.mjs +48 -0
- package/src/node/integrity.mjs +34 -0
- package/src/node/skills.mjs +85 -0
- package/tools/archive_releases.py +83 -0
- package/tools/artifacts.py +56 -0
- package/tools/build_release.py +82 -0
- package/tools/check_catalog.py +23 -0
- package/tools/install_runtime.py +144 -0
- 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))
|