@fudrouter/fsrouter 0.6.80 → 0.6.82

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.
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone script to automate Kimi Coding auto-login via Google/GSuite OAuth.
3
+ Outputs step logs and final results to stdout as JSON lines.
4
+ """
5
+
6
+ import sys
7
+ import json
8
+ import argparse
9
+ import time
10
+ import logging
11
+ import re
12
+ import urllib.request
13
+ import urllib.parse
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional
16
+
17
+ # Setup logger
18
+ logging.basicConfig(level=logging.WARNING)
19
+ logger = logging.getLogger("kimi_signup")
20
+
21
+ class KimiAutomationError(RuntimeError):
22
+ pass
23
+
24
+ def log_step(msg, *args):
25
+ txt = msg % args if args else msg
26
+ sys.stdout.write(json.dumps({"step": txt}, ensure_ascii=False) + "\n")
27
+ sys.stdout.flush()
28
+
29
+ def safe_email_to_dirname(email: str) -> str:
30
+ cleaned = (email or "").strip().lower()
31
+ cleaned = cleaned.replace("@", "_at_")
32
+ cleaned = re.sub(r"[^a-z0-9._-]+", "_", cleaned)
33
+ cleaned = cleaned.strip("._-")
34
+ return cleaned or "account"
35
+
36
+ def request_device_code() -> dict:
37
+ url = "https://auth.kimi.com/api/oauth/device_authorization"
38
+ headers = {
39
+ "Content-Type": "application/x-www-form-urlencoded",
40
+ "Accept": "application/json"
41
+ }
42
+ data = urllib.parse.urlencode({"client_id": "17e5f671-d194-4dfb-9706-5516cb48c098"}).encode("utf-8")
43
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
44
+ try:
45
+ with urllib.request.urlopen(req, timeout=15) as resp:
46
+ return json.loads(resp.read().decode("utf-8"))
47
+ except Exception as e:
48
+ raise KimiAutomationError(f"Gagal mengambil device code Kimi: {e}")
49
+
50
+ def click_first(page, selectors, timeout_ms: int = 8000) -> bool:
51
+ deadline = time.time() + (timeout_ms / 1000.0)
52
+ while time.time() < deadline:
53
+ for sel in selectors:
54
+ try:
55
+ loc = page.locator(sel).first
56
+ if loc.count() > 0 and loc.is_visible(timeout=300):
57
+ try:
58
+ loc.scroll_into_view_if_needed(timeout=600)
59
+ loc.click(timeout=2000, force=True)
60
+ return True
61
+ except Exception:
62
+ handle = loc.element_handle(timeout=300)
63
+ if handle:
64
+ page.evaluate("(el) => el.click()", handle)
65
+ return True
66
+ except Exception:
67
+ continue
68
+ time.sleep(0.3)
69
+ return False
70
+
71
+ def fill_first(page, selectors, value: str, timeout_ms: int = 8000) -> bool:
72
+ deadline = time.time() + (timeout_ms / 1000.0)
73
+ while time.time() < deadline:
74
+ for sel in selectors:
75
+ try:
76
+ loc = page.locator(sel).first
77
+ if loc.count() > 0 and loc.is_visible(timeout=300):
78
+ try:
79
+ loc.fill("", timeout=500)
80
+ except Exception:
81
+ pass
82
+ loc.fill(value, timeout=2000)
83
+ return True
84
+ except Exception:
85
+ continue
86
+ time.sleep(0.3)
87
+ return False
88
+
89
+ def poll_token(device_code: str, timeout: int = 120, interval: int = 5) -> dict:
90
+ url = "https://auth.kimi.com/api/oauth/token"
91
+ headers = {
92
+ "Content-Type": "application/x-www-form-urlencoded",
93
+ "Accept": "application/json"
94
+ }
95
+ payload = {
96
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
97
+ "client_id": "17e5f671-d194-4dfb-9706-5516cb48c098",
98
+ "device_code": device_code
99
+ }
100
+
101
+ deadline = time.time() + timeout
102
+ while time.time() < deadline:
103
+ data = urllib.parse.urlencode(payload).encode("utf-8")
104
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
105
+ try:
106
+ with urllib.request.urlopen(req, timeout=10) as resp:
107
+ res_data = json.loads(resp.read().decode("utf-8"))
108
+ if "access_token" in res_data:
109
+ return res_data
110
+ except urllib.error.HTTPError as he:
111
+ try:
112
+ err_data = json.loads(he.read().decode("utf-8"))
113
+ err = err_data.get("error")
114
+ if err == "authorization_pending":
115
+ pass
116
+ else:
117
+ log_step(f"Polling error Kimi: {err_data.get('error_description') or err}")
118
+ if err in ("expired_token", "access_denied"):
119
+ break
120
+ except Exception:
121
+ pass
122
+ except Exception as e:
123
+ log_step(f"Polling network error Kimi: {e}")
124
+
125
+ time.sleep(interval)
126
+ return {}
127
+
128
+ def main():
129
+ parser = argparse.ArgumentParser(description="Kimi Coding auto-login tool")
130
+ parser.add_argument("--email", required=True)
131
+ parser.add_argument("--password", required=True)
132
+ parser.add_argument("--proxy-server")
133
+ parser.add_argument("--proxy-user")
134
+ parser.add_argument("--proxy-pass")
135
+ parser.add_argument("--profiles-dir", required=True)
136
+ parser.add_argument("--headless", action="store_true", default=False)
137
+ args = parser.parse_args()
138
+
139
+ # Step 1: Request Device Authorization Code
140
+ log_step("Mengambil device code dari Kimi...")
141
+ try:
142
+ device_data = request_device_code()
143
+ except Exception as e:
144
+ sys.stdout.write(json.dumps({"status": "error", "message": str(e)}) + "\n")
145
+ sys.exit(1)
146
+
147
+ device_code = device_data.get("device_code")
148
+ user_code = device_data.get("user_code")
149
+ verification_uri = device_data.get("verification_uri") or "https://www.kimi.com/code/authorize_device"
150
+ complete_url = f"{verification_uri}?user_code={user_code}"
151
+
152
+ log_step(f"Device code: {user_code}. Menghubungkan URL verifikasi...")
153
+
154
+ # Step 2: Set up profile dir and launch Camoufox
155
+ profiles_root = Path(args.profiles_dir)
156
+ profiles_root.mkdir(parents=True, exist_ok=True)
157
+ profile_dir = profiles_root / safe_email_to_dirname(args.email)
158
+ profile_dir.mkdir(parents=True, exist_ok=True)
159
+
160
+ proxy_dict = None
161
+ if args.proxy_server:
162
+ proxy_dict = {"server": args.proxy_server}
163
+ if args.proxy_user:
164
+ proxy_dict["username"] = args.proxy_user
165
+ if args.proxy_pass:
166
+ proxy_dict["password"] = args.proxy_pass
167
+
168
+ try:
169
+ from camoufox.sync_api import Camoufox
170
+ except ImportError:
171
+ sys.stdout.write(json.dumps({"status": "error", "message": "Camoufox tidak terinstall di environment python."}) + "\n")
172
+ sys.exit(1)
173
+
174
+ kwargs = dict(
175
+ headless=args.headless,
176
+ persistent_context=True, no_viewport=True,
177
+ user_data_dir=str(profile_dir),
178
+ humanize=True,
179
+ geoip=True,
180
+ locale="en-US",
181
+ os=("windows", "macos", "linux"),
182
+ window=(1280, 800),
183
+ firefox_user_prefs={
184
+ "network.trr.mode": 5,
185
+ }
186
+ )
187
+ if proxy_dict:
188
+ kwargs["proxy"] = {k: v for k, v in proxy_dict.items() if v}
189
+
190
+ log_step("Meluncurkan browser...")
191
+ try:
192
+ with Camoufox(**kwargs) as browser:
193
+ context = getattr(browser, "context", None) or browser
194
+ page = context.new_page()
195
+
196
+ log_step("Membuka halaman verifikasi Kimi...")
197
+ page.goto(complete_url, wait_until="domcontentloaded", timeout=45000)
198
+ time.sleep(3.0)
199
+
200
+ # Wait for either the login button or the authorize button to appear
201
+ log_step("Menunggu halaman verifikasi termuat...")
202
+ try:
203
+ page.wait_for_selector(".google-login-btn, button:has-text('Confirm'), button:has-text('Authorize'), button:has-text('Allow'), button:has-text('确认授权'), button:has-text('确认'), button:has-text('授权'), button.kimi-button", timeout=15000)
204
+ except Exception:
205
+ pass
206
+
207
+ google_btn = page.locator(".google-login-btn").first
208
+ is_login_required = False
209
+ if google_btn.count() > 0 and google_btn.is_visible(timeout=500):
210
+ is_login_required = True
211
+
212
+ if is_login_required:
213
+ log_step("Menemukan tombol login Google. Mengklik tombol...")
214
+
215
+ with page.expect_popup() as popup_info:
216
+ google_btn.click(timeout=5000)
217
+ popup = popup_info.value
218
+ popup.wait_for_load_state()
219
+ time.sleep(2.0)
220
+
221
+ log_step("Menangani Google OAuth popup...")
222
+ google_deadline = time.time() + 60.0
223
+ filled_email = False
224
+ filled_pass = False
225
+ clicked_account = False
226
+
227
+ while time.time() < google_deadline:
228
+ if popup.is_closed():
229
+ log_step("Popup Google ditutup.")
230
+ break
231
+ try:
232
+ popup_url = popup.url or ""
233
+ popup_title = popup.title() or ""
234
+ log_step(f"Popup URL: {popup_url}, Title: {popup_title}")
235
+
236
+ # Check if Google account is disabled / speedbumped
237
+ if "speedbump/disabled" in popup_url or "disabled" in popup_url or "disabled" in popup_title.lower():
238
+ raise KimiAutomationError(f"Akun Google ({args.email}) dinonaktifkan/disabled oleh Google.")
239
+
240
+ # Account chooser
241
+ if not clicked_account and (
242
+ "accountchooser" in popup_url or
243
+ "Choose an account" in (popup.title() or "")
244
+ ):
245
+ clicked = popup.evaluate(f"""
246
+ () => {{
247
+ const email = '{args.email}';
248
+ const lis = document.querySelectorAll('li[data-identifier], li.JDAKTe');
249
+ for (const li of lis) {{
250
+ const id = li.getAttribute('data-identifier') || '';
251
+ if (id === email || li.textContent.includes(email)) {{
252
+ li.click(); return true;
253
+ }}
254
+ }}
255
+ const all = document.querySelectorAll('[data-email], [data-identifier]');
256
+ for (const el of all) {{
257
+ const v = el.getAttribute('data-email') || el.getAttribute('data-identifier') || '';
258
+ if (v === args.email) {{ el.click(); return true; }}
259
+ }}
260
+ const rows = document.querySelectorAll('ul li');
261
+ if (rows.length > 0) {{ rows[0].click(); return true; }}
262
+ return false;
263
+ }}
264
+ """)
265
+ if clicked:
266
+ log_step(f"Memilih akun '{args.email}' di Google chooser...")
267
+ clicked_account = True
268
+ time.sleep(3.0)
269
+ continue
270
+
271
+ # Email input
272
+ if not filled_email:
273
+ email_in = popup.locator("input[type='email'], input[name='identifier']").first
274
+ if email_in.count() > 0 and email_in.is_visible(timeout=500):
275
+ log_step("Mengisi email GSuite di popup Google...")
276
+ email_in.fill(args.email)
277
+ popup.keyboard.press("Enter")
278
+ filled_email = True
279
+ time.sleep(3.0)
280
+ continue
281
+
282
+ # Password input
283
+ if filled_email and not filled_pass:
284
+ pass_in = popup.locator("input[type='password'], input[name='Passwd']").first
285
+ if pass_in.count() > 0 and pass_in.is_visible(timeout=500):
286
+ log_step("Mengisi password GSuite di popup Google...")
287
+ pass_in.fill(args.password)
288
+ popup.keyboard.press("Enter")
289
+ filled_pass = True
290
+ time.sleep(3.0)
291
+ continue
292
+
293
+ # Workspace terms confirmation
294
+ understand_btn = popup.locator(
295
+ "button:has-text('I understand'), "
296
+ "button:has-text('I Understand')"
297
+ ).first
298
+ if understand_btn.count() > 0 and understand_btn.is_visible(timeout=500):
299
+ log_step("Klik 'I understand' di Google terms...")
300
+ understand_btn.click(timeout=3000)
301
+ time.sleep(2.0)
302
+ continue
303
+
304
+ # General continue button in Google Popup
305
+ for btn_text in ["Continue", "Allow", "Next", "Accept", "Confirm"]:
306
+ try:
307
+ b = popup.locator(f"button:has-text('{btn_text}')").first
308
+ if b.count() > 0 and b.is_visible(timeout=300):
309
+ log_step(f"Klik '{btn_text}' di popup Google...")
310
+ b.click(timeout=3000)
311
+ time.sleep(2.0)
312
+ break
313
+ except Exception:
314
+ pass
315
+
316
+ except KimiAutomationError:
317
+ raise
318
+ except Exception as e_popup:
319
+ logger.debug(f"Google popup handler error: {e_popup}")
320
+ time.sleep(1.0)
321
+
322
+ # Google popup final state screenshot
323
+ try:
324
+ popup_screenshot = str(profile_dir / "google_popup_final.png")
325
+ popup.screenshot(path=popup_screenshot)
326
+ log_step(f"Google popup final screenshot saved: {popup_screenshot}")
327
+ except Exception:
328
+ pass
329
+
330
+ # Wait for main page to reload/render after login
331
+ time.sleep(5.0)
332
+
333
+ # Accept terms check box if present on Kimi
334
+ try:
335
+ page.evaluate("""() => {
336
+ const checkboxes = document.querySelectorAll("input[type='checkbox']");
337
+ for (const cb of checkboxes) {
338
+ cb.checked = true;
339
+ cb.dispatchEvent(new Event('change', { bubbles: true }));
340
+ cb.dispatchEvent(new Event('click', { bubbles: true }));
341
+ }
342
+ }""")
343
+ except Exception:
344
+ pass
345
+
346
+ # Click Authorize/Confirm button on Kimi page
347
+ log_step("Mengklik tombol konfirmasi/otorisasi Kimi...")
348
+ auth_selectors = [
349
+ "button:has-text('Confirm')",
350
+ "button:has-text('Authorize')",
351
+ "button:has-text('Allow')",
352
+ "button:has-text('确认授权')",
353
+ "button:has-text('确认')",
354
+ "button:has-text('授权')",
355
+ "button.kimi-button"
356
+ ]
357
+ clicked_auth = click_first(page, auth_selectors, timeout_ms=10000)
358
+ if clicked_auth:
359
+ log_step("Tombol otorisasi diklik, menunggu proses...")
360
+ else:
361
+ log_step("Tombol otorisasi tidak ditemukan, kemungkinan sudah terotorisasi otomatis.")
362
+
363
+ # Kimi main page final state screenshot
364
+ try:
365
+ screenshot_path = str(profile_dir / "final_state.png")
366
+ page.screenshot(path=screenshot_path)
367
+ log_step(f"Screenshot disimpan ke: {screenshot_path}")
368
+ except Exception:
369
+ pass
370
+
371
+ time.sleep(5.0)
372
+
373
+ except Exception as e:
374
+ try:
375
+ err_screenshot = str(profile_dir / "error_state.png")
376
+ page.screenshot(path=err_screenshot)
377
+ log_step(f"Error screenshot disimpan ke: {err_screenshot}")
378
+ except Exception:
379
+ pass
380
+ sys.stdout.write(json.dumps({"status": "error", "message": f"Browser Error: {e}"}) + "\n")
381
+ sys.exit(1)
382
+
383
+ # Step 3: Poll for token
384
+ log_step("Menunggu Kimi menerbitkan token...")
385
+ token_data = poll_token(device_code, timeout=60, interval=5)
386
+
387
+ if not token_data or "access_token" not in token_data:
388
+ sys.stdout.write(json.dumps({"status": "error", "message": "Timeout menunggu otorisasi token Kimi."}) + "\n")
389
+ sys.exit(1)
390
+
391
+ # Successful output
392
+ sys.stdout.write(json.dumps({
393
+ "status": "success",
394
+ "api_key": token_data.get("access_token"),
395
+ "refresh_token": token_data.get("refresh_token"),
396
+ "expires_in": token_data.get("expires_in") or 86400
397
+ }) + "\n")
398
+ sys.stdout.flush()
399
+
400
+ if __name__ == "__main__":
401
+ main()