@fudrouter/fsrouter 0.6.80 → 0.6.81

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,902 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import json
4
+ import argparse
5
+ import time
6
+ import logging
7
+ import re
8
+ import sqlite3
9
+ import socket
10
+ import os
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from urllib.parse import urlparse
14
+
15
+ # Patch Playwright's Locator.is_visible
16
+ try:
17
+ from playwright.sync_api import Locator
18
+ _orig_is_visible = Locator.is_visible
19
+ def _patched_is_visible(self, *args, **kwargs):
20
+ timeout = kwargs.pop("timeout", None)
21
+ if timeout is None and len(args) > 0: timeout = args[0]
22
+ if timeout is not None:
23
+ try:
24
+ self.wait_for(state="visible", timeout=float(timeout))
25
+ return True
26
+ except Exception: return False
27
+ return _orig_is_visible(self, *args, **kwargs)
28
+ Locator.is_visible = _patched_is_visible
29
+ except ImportError: pass
30
+
31
+ START_TIME = time.time()
32
+ def log_step(message: str, *args):
33
+ elapsed = int(time.time() - START_TIME)
34
+ duration = f"{elapsed}s" if elapsed < 60 else f"{elapsed // 60}m {elapsed % 60}s"
35
+ full_msg = message % args if args else message
36
+ print(json.dumps({"step": f"{full_msg} [{duration}]"}), flush=True)
37
+
38
+ def safe_email_to_dirname(email: str) -> str:
39
+ cleaned = (email or "").strip().lower().replace("@", "_at_")
40
+ return re.sub(r"[^a-z0-9._-]+", "_", cleaned).strip("._-")
41
+
42
+ def click_first(page, selectors, timeout_ms: int = 8000) -> bool:
43
+ deadline = time.time() + (timeout_ms / 1000.0)
44
+ while time.time() < deadline:
45
+ for sel in selectors:
46
+ try:
47
+ loc = page.locator(sel).first
48
+ if loc.count() > 0 and loc.is_visible(timeout=300):
49
+ loc.click(timeout=2000)
50
+ return True
51
+ except Exception: continue
52
+ time.sleep(0.3)
53
+ return False
54
+
55
+ def fill_first(page, selectors, value: str, timeout_ms: int = 8000) -> bool:
56
+ deadline = time.time() + (timeout_ms / 1000.0)
57
+ while time.time() < deadline:
58
+ for sel in selectors:
59
+ try:
60
+ loc = page.locator(sel).first
61
+ if loc.count() > 0 and loc.is_visible(timeout=300):
62
+ loc.fill(value, timeout=2000)
63
+ return True
64
+ except Exception: continue
65
+ time.sleep(0.3)
66
+ return False
67
+
68
+ def _react_invoke_click(page, text_marker: str) -> bool:
69
+ try:
70
+ return page.evaluate("""(marker) => {
71
+ const btn = Array.from(document.querySelectorAll('button, [role="button"], a')).find(el =>
72
+ (el.innerText || '').toLowerCase().includes(marker.toLowerCase()) && !/Cancel|Batal/i.test(el.innerText)
73
+ );
74
+ if (!btn) return false;
75
+ const propsKey = Object.keys(btn).find(k => k.startsWith('__reactProps$'));
76
+ if (propsKey && btn[propsKey] && typeof btn[propsKey].onClick === 'function') {
77
+ btn[propsKey].onClick({ preventDefault:()=>{}, stopPropagation:()=>{}, target:btn, type:'click' });
78
+ return true;
79
+ }
80
+ return false;
81
+ }""", text_marker)
82
+ except Exception: return False
83
+
84
+ def _bypass_canva_modals(page):
85
+ try:
86
+ page.evaluate("""() => {
87
+ const sels = ['[role="dialog"]', '.modal', '[aria-modal="true"]', 'div:has(> button[aria-label="Close"])'];
88
+ sels.forEach(s => document.querySelectorAll(s).forEach(e => e.remove()));
89
+ document.querySelectorAll('[class*="backdrop"], [class*="overlay"]').forEach(e => e.remove());
90
+ }""")
91
+ except Exception: pass
92
+
93
+ import urllib.request
94
+
95
+ def get_db_path() -> Path:
96
+ return Path.home() / ".9router-v2" / "db" / "data.sqlite"
97
+
98
+ def iso_to_unix(iso_str: str) -> int:
99
+ try:
100
+ clean = iso_str.replace("Z", "+00:00")
101
+ from datetime import datetime, timezone
102
+ dt = datetime.fromisoformat(clean)
103
+ return int(dt.timestamp())
104
+ except Exception:
105
+ return int(time.time())
106
+
107
+ def load_settings_db() -> dict:
108
+ db_path = get_db_path()
109
+ if not db_path.exists(): return {}
110
+ try:
111
+ conn = sqlite3.connect(str(db_path))
112
+ conn.row_factory = sqlite3.Row
113
+ cursor = conn.cursor()
114
+ cursor.execute("SELECT data FROM settings WHERE id = 1")
115
+ row = cursor.fetchone()
116
+ conn.close()
117
+ if row: return json.loads(row["data"])
118
+ except Exception: pass
119
+ return {}
120
+
121
+ def extract_otp_canva(text: str, html: str = "", subject: str = "") -> tuple:
122
+ """Extract 6-digit OTP or passwordless URL from Canva email."""
123
+ parts = [subject or "", text or "", html or ""]
124
+ haystack = "\n".join(filter(None, parts))
125
+ # Labeled code
126
+ m = re.search(r'(?:verification\s*code|code\s*(?:is|:)|one[-\s]?time)\s*[:#-]?\s*([0-9]{4,8})\b', haystack, re.IGNORECASE)
127
+ if m: return m.group(1), ""
128
+ # Canva passwordless link
129
+ m_url = re.search(r'https://www\.canva\.com/passwordless/[^\s"<>]+', haystack)
130
+ if m_url: return "", m_url.group(0).replace("&amp;", "&")
131
+ # Loose 6-digit
132
+ m_loose = re.search(r'(?<![0-9])([0-9]{6})(?![0-9])', haystack)
133
+ if m_loose: return m_loose.group(1), ""
134
+ return "", ""
135
+
136
+ def sync_ammail_messages(email: str, since_ts: int, settings: dict):
137
+ db_path = get_db_path()
138
+ if not db_path.exists(): return
139
+ api_key = settings.get("ammail_api_key")
140
+ base_url = settings.get("ammail_base_url")
141
+ fallback_url = settings.get("ammail_cf_workers_dev_url")
142
+ if not api_key or (not base_url and not fallback_url): return
143
+ alias = email.split("@")[0]
144
+ domain = email.split("@")[1] if "@" in email else ""
145
+ urls = [u.rstrip("/") for u in [base_url, fallback_url] if u]
146
+ messages = []
147
+ for base in urls:
148
+ try:
149
+ req = urllib.request.Request(f"{base}/api/inboxes/{alias}/messages",
150
+ headers={"X-API-Key": api_key, "Accept": "application/json",
151
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"})
152
+ with urllib.request.urlopen(req, timeout=10) as res:
153
+ data = json.loads(res.read().decode("utf-8"))
154
+ messages = data.get("messages", [])
155
+ if messages: break
156
+ except Exception: continue
157
+ for msg in messages:
158
+ msg_id = msg.get("id")
159
+ if not msg_id: continue
160
+ try:
161
+ conn = sqlite3.connect(str(db_path))
162
+ cursor = conn.cursor()
163
+ cursor.execute("SELECT 1 FROM ammailOtps WHERE messageShortId = ?", (msg_id,))
164
+ if cursor.fetchone(): conn.close(); continue
165
+ conn.close()
166
+ except Exception: continue
167
+ full_msg = None
168
+ for base in urls:
169
+ try:
170
+ req = urllib.request.Request(f"{base}/api/messages/{msg_id}",
171
+ headers={"X-API-Key": api_key, "Accept": "application/json",
172
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"})
173
+ with urllib.request.urlopen(req, timeout=10) as res:
174
+ data = json.loads(res.read().decode("utf-8"))
175
+ full_msg = data.get("message")
176
+ if full_msg: break
177
+ except Exception: continue
178
+ if not full_msg: continue
179
+ body_text = str(full_msg.get("text") or msg.get("snippet") or "")
180
+ body_html = str(full_msg.get("html") or "")
181
+ from_data = full_msg.get("from") or msg.get("from") or {}
182
+ sender = str(from_data.get("address") or from_data.get("name") or "")
183
+ subject = str(full_msg.get("subject") or msg.get("subject") or "")
184
+ received_at = iso_to_unix(str(full_msg.get("receivedAt") or msg.get("receivedAt") or ""))
185
+ otp_code, verify_url = extract_otp_canva(body_text, body_html, subject)
186
+ try:
187
+ conn = sqlite3.connect(str(db_path))
188
+ cursor = conn.cursor()
189
+ cursor.execute("""INSERT OR IGNORE INTO ammailOtps
190
+ (address, alias, domain, sender, subject, otpCode, verifyUrl,
191
+ bodyText, bodyHtml, messageShortId, rawEventJson, receivedAt, usedAt)
192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
193
+ (email, alias, domain, sender, subject, otp_code, verify_url,
194
+ body_text, body_html, msg_id, json.dumps(full_msg), received_at))
195
+ conn.commit(); conn.close()
196
+ except Exception: pass
197
+
198
+ def wait_for_otp_from_db(email: str, since_ts: int, settings: dict, timeout: int = 180) -> tuple:
199
+ db_path = get_db_path()
200
+ if not db_path.exists():
201
+ log_step(f"DB tidak ditemukan: {db_path}")
202
+ return "", ""
203
+ deadline = time.time() + timeout
204
+ alias = email.split('@')[0]
205
+ last_log = 0
206
+ while time.time() < deadline:
207
+ try: sync_ammail_messages(email, since_ts, settings)
208
+ except Exception: pass
209
+ try:
210
+ conn = sqlite3.connect(str(db_path))
211
+ conn.row_factory = sqlite3.Row
212
+ cursor = conn.cursor()
213
+ cursor.execute(
214
+ "SELECT id, otpCode, verifyUrl FROM ammailOtps WHERE LOWER(address) = ? AND receivedAt >= ? AND usedAt = 0 ORDER BY receivedAt DESC LIMIT 1",
215
+ (email.lower(), since_ts))
216
+ row = cursor.fetchone()
217
+ if row:
218
+ cursor.execute("UPDATE ammailOtps SET usedAt = ? WHERE id = ?", (int(time.time()), row["id"]))
219
+ conn.commit(); conn.close()
220
+ return row["otpCode"] or "", row["verifyUrl"] or ""
221
+ conn.close()
222
+ except Exception: pass
223
+ # Emit heartbeat setiap 5 detik agar timer UI terus jalan
224
+ now = time.time()
225
+ if now - last_log >= 5:
226
+ remaining = int(deadline - now)
227
+ log_step(f"Menunggu OTP Canva ({alias}@...) — sisa {remaining}s")
228
+ last_log = now
229
+ time.sleep(2)
230
+ return "", ""
231
+
232
+ def enroll_canva_via_email(page, invite_link: str, email: str, password: str, settings: dict = None) -> bool:
233
+ if settings is None: settings = {}
234
+ # Buat inbox di ammail sebelum mulai (kalau belum ada)
235
+ api_key = settings.get("ammail_api_key")
236
+ base_url = (settings.get("ammail_base_url") or settings.get("ammail_cf_workers_dev_url") or "").rstrip("/")
237
+ if api_key and base_url:
238
+ alias = email.split("@")[0]
239
+ domain = email.split("@")[1] if "@" in email else ""
240
+ try:
241
+ body_bytes = json.dumps({"alias": alias, "domain": domain}).encode("utf-8")
242
+ req = urllib.request.Request(
243
+ f"{base_url}/api/inboxes",
244
+ data=body_bytes,
245
+ method="POST"
246
+ )
247
+ req.add_header("X-API-Key", api_key)
248
+ req.add_header("Content-Type", "application/json")
249
+ req.add_header("Accept", "application/json")
250
+ req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
251
+ with urllib.request.urlopen(req, timeout=10) as res:
252
+ res_data = json.loads(res.read().decode())
253
+ addr = (res_data.get("inbox") or {}).get("address", email)
254
+ log_step(f"Inbox siap: {addr}")
255
+ except Exception as e:
256
+ log_step(f"[WARN] Inbox ammail: {e}")
257
+
258
+ log_step("Membuka Canva invite link...")
259
+ page.goto(invite_link, wait_until="domcontentloaded", timeout=60000)
260
+ time.sleep(2)
261
+ _bypass_canva_modals(page)
262
+ click_first(page, ["button:has-text('Accept all cookies')", "button:has-text('Accept')"], timeout_ms=3000)
263
+
264
+ log_step("Mengisi email...")
265
+ # Step 1: Klik tombol "Continue with email" — Canva hide field di balik button ini
266
+ email_btn_selectors = [
267
+ "button[aria-label='Continue with email']",
268
+ "button[aria-label='Sign up with email']",
269
+ "button[aria-label='Log in with email']",
270
+ "button[aria-label='Lanjutkan dengan email']",
271
+ "button:has-text('Continue with email')",
272
+ "button:has-text('Sign up with email')",
273
+ "button:has-text('Use email')",
274
+ "button:has-text('Lanjutkan dengan email')",
275
+ ]
276
+ email_btn = None
277
+ deadline_btn = time.time() + 20
278
+ while time.time() < deadline_btn and email_btn is None:
279
+ for sel in email_btn_selectors:
280
+ try:
281
+ loc = page.locator(sel).first
282
+ if loc.count() > 0 and loc.is_visible(timeout=400):
283
+ email_btn = loc; break
284
+ except Exception: continue
285
+ if email_btn is None: time.sleep(0.8)
286
+
287
+ if email_btn:
288
+ try:
289
+ email_btn.click(force=True, timeout=5000)
290
+ except Exception:
291
+ _react_invoke_click(page, "Continue with email")
292
+
293
+ # Step 2: Isi email — Canva pakai input[name='username'][inputmode='email']
294
+ email_input_selectors = [
295
+ "input[name='username'][inputmode='email']",
296
+ "input[autocomplete='username'][inputmode='email']",
297
+ "input[type='email']",
298
+ "input[name='email']",
299
+ "input[inputmode='email']",
300
+ "input[placeholder*='email' i]",
301
+ "input[autocomplete='email']",
302
+ "input[data-testid*='email' i]",
303
+ "input[aria-label*='email' i]",
304
+ "input[autocomplete='username']",
305
+ "input:not([type='password']):not([type='hidden']):not([type='checkbox'])",
306
+ ]
307
+ email_input = None
308
+ deadline_in = time.time() + 20
309
+ while time.time() < deadline_in and email_input is None:
310
+ for sel in email_input_selectors:
311
+ try:
312
+ loc = page.locator(sel).first
313
+ if loc.count() > 0 and loc.is_visible(timeout=500):
314
+ email_input = loc; break
315
+ except Exception: continue
316
+ if email_input is None:
317
+ time.sleep(0.8)
318
+ log_step(f"Menunggu email input... URL: {page.url[:50]}")
319
+
320
+ if email_input is None:
321
+ log_step("Email field tidak ditemukan!")
322
+ if "/home" in page.url: return True
323
+ else:
324
+ email_input.fill(email)
325
+
326
+ # OTP timestamp SETELAH submit (dari leoapi-main)
327
+ otp_since_ts = int(time.time())
328
+ click_first(page, ["button[type='submit']", "button:has-text('Continue')", "button:has-text('Lanjutkan')"], timeout_ms=8000)
329
+
330
+ deadline = time.time() + 150
331
+ otp_filled = False
332
+
333
+ while time.time() < deadline:
334
+ _bypass_canva_modals(page)
335
+ cur_url = page.url.lower()
336
+
337
+ # SUCCESS: Dashboard reached
338
+ if "canva.com" in cur_url and not any(x in cur_url for x in ["/signup", "/login", "/otp", "/brand/join"]):
339
+ log_step(f"Canva page tercapai: {cur_url[:60]} [DONE]")
340
+ return True
341
+
342
+ # Juga sukses jika OTP sudah diisi dan sudah di canva.com (apapun page-nya)
343
+ if otp_filled and "canva.com" in cur_url:
344
+ log_step(f"Canva OTP sukses — page: {cur_url[:60]} [DONE]")
345
+ return True
346
+
347
+ # Password (existing account)
348
+ pw_input = page.locator("input[type='password']").first
349
+ if pw_input.count() > 0 and pw_input.is_visible(timeout=300):
350
+ log_step("Mengisi password...")
351
+ pw_input.fill(password)
352
+ click_first(page, ["button[type='submit']", "button:has-text('Log in')"])
353
+ time.sleep(5); continue
354
+
355
+ # Name (new account) — Canva pakai input[autocomplete='name'], bukan input[name='firstName']
356
+ name_input = page.locator(
357
+ "input[autocomplete='name'], input[name='firstName'], input[name='name'], input[name='fullName']"
358
+ ).first
359
+ if name_input.count() > 0 and name_input.is_visible(timeout=300):
360
+ log_step("Mengisi nama akun baru...")
361
+ name_input.fill("Amstream User")
362
+ click_first(page, ["button[type='submit']", "button:has-text('Continue')", "button:has-text('Create account')"])
363
+ time.sleep(5); continue
364
+
365
+ # OTP screen — adopt leoapi-main strict selectors + text marker fallback
366
+ STRICT_OTP = [
367
+ "input[autocomplete='one-time-code']",
368
+ "input[name='code']", "input[name='otp']",
369
+ "input[name='verificationCode']", "input[name='verification_code']",
370
+ "input[id*='otp' i]:not([id*='no_otp' i])",
371
+ "input[data-testid*='otp' i]",
372
+ "input[aria-label*='code' i]:not([aria-label*='country' i])",
373
+ ]
374
+ OTP_TEXT = ["Check your email", "Enter the code", "Verification code", "We sent a code",
375
+ "We've sent", "Cek email", "Masukkan kode", "Enter the verification"]
376
+
377
+ otp_loc = None
378
+ otp_type = None
379
+ # Box-style (6 single-char inputs)
380
+ boxes = page.locator("input[maxlength='1'], input[data-testid*='code-input' i]")
381
+ if boxes.count() >= 4 and boxes.first.is_visible(timeout=300):
382
+ otp_type, otp_loc = "box", boxes
383
+ else:
384
+ for sel in STRICT_OTP:
385
+ try:
386
+ loc = page.locator(sel).first
387
+ if loc.count() > 0 and loc.is_visible(timeout=300):
388
+ otp_type, otp_loc = "single", loc; break
389
+ except Exception: continue
390
+ # Text marker fallback
391
+ if not otp_loc and not otp_filled:
392
+ try:
393
+ body = page.inner_text("body", timeout=1000)
394
+ if any(m.lower() in body.lower() for m in OTP_TEXT):
395
+ for sel in STRICT_OTP:
396
+ loc = page.locator(sel).first
397
+ if loc.count() > 0:
398
+ otp_type, otp_loc = "single", loc; break
399
+ except Exception: pass
400
+
401
+ if otp_loc and not otp_filled:
402
+ log_step(f"OTP screen terdeteksi — polling ammail ({email.split('@')[0]})...")
403
+ otp_code, verify_url = wait_for_otp_from_db(email, otp_since_ts, settings)
404
+ if otp_code:
405
+ digits = re.sub(r"\D", "", otp_code)
406
+ log_step(f"OTP didapat: {otp_code}, mengisi...")
407
+ if otp_type == "box":
408
+ for i in range(min(boxes.count(), len(digits))): boxes.nth(i).fill(digits[i])
409
+ else:
410
+ otp_loc.fill(digits)
411
+ otp_filled = True
412
+ time.sleep(1)
413
+ click_first(page, ["button[type='submit']", "button:has-text('Verify')", "button:has-text('Continue')"])
414
+ time.sleep(4)
415
+ elif verify_url:
416
+ log_step("Magic link didapat, navigasi...")
417
+ page.goto(verify_url)
418
+ otp_filled = True
419
+ time.sleep(4)
420
+ continue
421
+
422
+ # Join Team confirmation page (AFTER auth, different from /brand/join invite page)
423
+ # /brand/join?token=... = auth page, teams/accept or teams/join = post-auth confirmation
424
+ if otp_filled and ("teams/accept" in cur_url or "teams/join" in cur_url or ("/teams/" in cur_url and "join" not in cur_url)):
425
+ log_step("Halaman konfirmasi Join Team, mengklik...")
426
+ if not click_first(page, ["button:has-text('Join team')", "button:has-text('Join the team')", "button:has-text('Gabung')", "button:has-text('Accept')"], timeout_ms=3000):
427
+ _react_invoke_click(page, "Join")
428
+ time.sleep(3)
429
+
430
+ # Log current state setiap 10 detik — tampilkan fase + URL pendek
431
+ elapsed = int(time.time() - START_TIME)
432
+ if elapsed % 10 < 2:
433
+ short = page.url.replace("https://","").replace("www.","")[:55]
434
+ phase = "OTP dikirim" if otp_filled else ("Menunggu OTP" if otp_loc else "Inisialisasi")
435
+ log_step(f"[{phase}] {short}")
436
+
437
+ time.sleep(2)
438
+ return False
439
+
440
+ def _click_canva_authorize_v2(page, email: str) -> bool:
441
+ primary = ["button:has-text('Allow')", "button:has-text('Authorize')", "button:has-text('Continue')", "button:has-text('Allow access')"]
442
+ target = None
443
+ for s in primary:
444
+ loc = page.locator(s).first
445
+ if loc.count() > 0: target = loc; break
446
+
447
+ url_before = page.url
448
+ if target:
449
+ try:
450
+ target.click(timeout=3000)
451
+ time.sleep(2)
452
+ if page.url != url_before: return True
453
+ except Exception: pass
454
+ return _react_invoke_click(page, "Allow") or _react_invoke_click(page, "Continue")
455
+
456
+ def run_automation(email, password, invite_link, proxy=None, headless=True, skip_canva=False):
457
+ settings = load_settings_db()
458
+ if skip_canva: settings["skip_canva"] = True
459
+ import asyncio
460
+ from camoufox.sync_api import Camoufox
461
+
462
+ profile_dir = Path(f"profiles/{safe_email_to_dirname(email)}")
463
+ profile_dir.mkdir(parents=True, exist_ok=True)
464
+
465
+ # Fix async event loop conflicts (dari leoapi-main)
466
+ try:
467
+ asyncio.set_event_loop(None)
468
+ if hasattr(asyncio, "events") and hasattr(asyncio.events, "_set_running_loop"):
469
+ asyncio.events._set_running_loop(None)
470
+ except Exception: pass
471
+
472
+ kwargs = dict(
473
+ headless=headless,
474
+ persistent_context=True,
475
+ user_data_dir=str(profile_dir),
476
+ humanize=True,
477
+ geoip=True,
478
+ locale="en-US",
479
+ os=("windows", "macos", "linux"),
480
+ )
481
+
482
+ if proxy:
483
+ p_url = urlparse(proxy)
484
+ kwargs["proxy"] = {"server": f"{p_url.scheme}://{p_url.hostname}:{p_url.port}"}
485
+ if p_url.username: kwargs["proxy"]["username"] = p_url.username
486
+ if p_url.password: kwargs["proxy"]["password"] = p_url.password
487
+
488
+ # Graceful TypeError fallback (dari leoapi-main)
489
+ ctx = None
490
+ try:
491
+ ctx = Camoufox(**kwargs)
492
+ except TypeError:
493
+ for drop in ("os", "geoip", "humanize"):
494
+ kwargs.pop(drop, None)
495
+ try:
496
+ ctx = Camoufox(**kwargs)
497
+ break
498
+ except TypeError:
499
+ continue
500
+ if ctx is None:
501
+ kwargs.pop("locale", None)
502
+ ctx = Camoufox(**kwargs)
503
+
504
+ try:
505
+ with ctx as browser:
506
+ page = browser.new_page()
507
+
508
+ # 1. Canva Enroll
509
+ if not settings.get("skip_canva"):
510
+ if not enroll_canva_via_email(page, invite_link, email, password, settings):
511
+ sys.stdout.write(json.dumps({"status": "error", "message": "Gagal pendaftaran Canva — lihat step log"}) + "\n")
512
+ sys.stdout.flush()
513
+ return False
514
+ # Emit canva_enrolled agar Node update DB
515
+ sys.stdout.write(json.dumps({"canva_enrolled": True}) + "\n")
516
+ sys.stdout.flush()
517
+ else:
518
+ log_step("Skip Canva enrollment as requested.")
519
+
520
+ # 2. Leonardo Signup
521
+ # Intercept /api/auth/get-session untuk capture JWT langsung dari response
522
+ captured_jwt = {"value": ""}
523
+ def _find_jwt_in(obj, depth=0):
524
+ if depth > 6: return ""
525
+ if isinstance(obj, str) and obj.startswith("eyJ") and len(obj) > 100: return obj
526
+ if isinstance(obj, dict):
527
+ for v in obj.values():
528
+ r = _find_jwt_in(v, depth+1)
529
+ if r: return r
530
+ if isinstance(obj, list):
531
+ for v in obj:
532
+ r = _find_jwt_in(v, depth+1)
533
+ if r: return r
534
+ return ""
535
+ def handle_response(response):
536
+ try:
537
+ if "leonardo.ai/api/auth/get-session" not in response.url: return
538
+ if response.status != 200: return
539
+ body = response.text()
540
+ if not body or body.strip() == 'null': return
541
+ import json as _json
542
+ data = _json.loads(body)
543
+ jwt = _find_jwt_in(data)
544
+ if jwt:
545
+ captured_jwt["value"] = jwt
546
+ log_step(f"JWT captured dari get-session intercept!")
547
+ except Exception: pass
548
+ # Di Camoufox persistent_context, 'browser' IS BrowserContext
549
+ browser.on("response", handle_response)
550
+
551
+ log_step("Membuka Leonardo AI — login page...")
552
+ page.goto("https://app.leonardo.ai/auth/login", wait_until="domcontentloaded", timeout=60000)
553
+ time.sleep(3)
554
+ page.screenshot(path="leonardo_login_initial.png")
555
+
556
+ # Setup popup listener SEBELUM klik (event-driven)
557
+ canva_popup = {"page": None}
558
+ def on_popup(popup_page):
559
+ canva_popup["page"] = popup_page
560
+ log_step(f"Popup terdeteksi: {popup_page.url[:60]}")
561
+ browser.on("page", on_popup)
562
+
563
+ log_step("Fokus & Enter pada tombol 'Canva'...")
564
+ sels = [
565
+ "button:has-text('Canva')",
566
+ "a:has-text('Canva')",
567
+ "button[data-slot='button']:has-text('Canva')",
568
+ "div[role='button']:has-text('Canva')"
569
+ ]
570
+ focused = False
571
+ for sel in sels:
572
+ try:
573
+ loc = page.locator(sel).first
574
+ if loc.count() > 0 and loc.is_visible(timeout=3000):
575
+ loc.focus(timeout=3000)
576
+ page.keyboard.press("Enter")
577
+ focused = True
578
+ break
579
+ except: continue
580
+
581
+ if not focused:
582
+ log_step("GAGAL: Tombol login Canva tidak bisa difokus!")
583
+ page.screenshot(path="leonardo_login_failed_focus.png")
584
+
585
+ time.sleep(5)
586
+ page.screenshot(path="leonardo_after_click.png")
587
+ log_step(f"Interaction selesai. URL sekarang: {page.url[:60]}")
588
+ time.sleep(10)
589
+ log_step(f"Pages: {[p.url[:50] for p in browser.pages]}")
590
+
591
+ # Handle berdasarkan hasilnya
592
+ auth_page = None
593
+ if canva_popup["page"]:
594
+ auth_page = canva_popup["page"]
595
+ log_step(f"OAuth popup terdeteksi: {auth_page.url[:60]}")
596
+ # Jika about:blank, tunggu navigasi
597
+ if "about:blank" in auth_page.url:
598
+ try:
599
+ auth_page.wait_for_load_state("domcontentloaded", timeout=10000)
600
+ log_step(f"Popup url berubah: {auth_page.url[:60]}")
601
+ except: pass
602
+ _click_canva_authorize_v2(auth_page, email)
603
+ time.sleep(5)
604
+
605
+ # 2. Cek apakah main page redirect (inline OAuth)
606
+ elif "canva.com" in page.url:
607
+ auth_page = page
608
+ log_step(f"OAuth inline di main page: {page.url[:60]}")
609
+ _click_canva_authorize_v2(page, email)
610
+ time.sleep(5)
611
+
612
+ # 3. Scan semua pages (termasuk yang mungkin baru terbuka tapi telat)
613
+ else:
614
+ log_step("Scan pages untuk Canva...")
615
+ for pg in browser.pages:
616
+ if "canva.com" in pg.url and pg != page:
617
+ auth_page = pg
618
+ log_step(f"OAuth popup via scan: {pg.url[:60]}")
619
+ _click_canva_authorize_v2(pg, email)
620
+ time.sleep(5)
621
+ break
622
+ else:
623
+ # Coba klik lagi dengan JS jika belum ada popup
624
+ log_step("Belum ada redirect. Mencoba klik via JS...")
625
+ page.evaluate("Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Canva'))?.click()")
626
+ time.sleep(10)
627
+ for pg in browser.pages:
628
+ if "canva.com" in pg.url and pg != page:
629
+ auth_page = pg
630
+ log_step(f"OAuth popup via scan (setelah JS click): {pg.url[:60]}")
631
+ _click_canva_authorize_v2(pg, email)
632
+ time.sleep(5)
633
+ break
634
+ else:
635
+ log_step(f"PERINGATAN: Tidak ada redirect ke Canva! URL={page.url}")
636
+
637
+ # Tunggu Leonardo dashboard atau onboarding (max 90s)
638
+ log_step("Menunggu redirect ke Leonardo dashboard/onboarding...")
639
+ deadline_leo = time.time() + 90
640
+ last_log_leo = 0
641
+ canva_btn_retry = 0
642
+ while time.time() < deadline_leo:
643
+ cur = page.url.lower()
644
+
645
+ # Inline OAuth redirect di main page — handle
646
+ if "canva.com" in cur and page != auth_page:
647
+ log_step(f"Main page redirect ke Canva OAuth: {page.url[:60]}")
648
+ _click_canva_authorize_v2(page, email)
649
+ auth_page = page
650
+ time.sleep(5)
651
+ continue
652
+
653
+ # Sukses: sudah di luar /auth/
654
+ if "app.leonardo.ai" in cur and "/auth/" not in cur:
655
+ log_step(f"Leonardo post-auth page: {page.url[:60]}")
656
+
657
+ # Handle onboarding/survey pages — klik Continue/Skip
658
+ if any(x in cur for x in ["/onboarding", "/survey", "/welcome", "/get-started"]):
659
+ log_step("Onboarding page — cari tombol Continue/Skip...")
660
+ for sel in ["button:has-text('Continue')", "button:has-text('Skip')", "button:has-text('Get started')", "button:has-text('Next')", "button[type='submit']"]:
661
+ try:
662
+ loc = page.locator(sel).first
663
+ if loc.count() > 0 and loc.is_visible(timeout=1000):
664
+ loc.click(timeout=3000)
665
+ time.sleep(2)
666
+ break
667
+ except Exception: pass
668
+ time.sleep(2)
669
+ # Cek apakah masih onboarding
670
+ if any(x in page.url.lower() for x in ["/onboarding", "/survey", "/welcome", "/get-started"]):
671
+ time.sleep(3)
672
+ continue
673
+ break
674
+ # Cek popup auth Canva baru (deteksi broad: any canva.com page)
675
+ for pg in browser.pages:
676
+ if "canva.com" in pg.url and pg != auth_page and pg != page:
677
+ log_step(f"OAuth popup baru: {pg.url[:60]}")
678
+ _click_canva_authorize_v2(pg, email)
679
+ auth_page = pg
680
+ # Retry klik "Continue with Canva" jika stuck di /auth/login > 15s
681
+ if "/auth/login" in cur and canva_btn_retry < 2:
682
+ elapsed_stuck = time.time() - (deadline_leo - 90)
683
+ if elapsed_stuck > 15 * (canva_btn_retry + 1):
684
+ log_step(f"Stuck di /auth/login, retry klik Continue with Canva ({canva_btn_retry+1}/2)...")
685
+ try:
686
+ page.goto("https://app.leonardo.ai/auth/login", wait_until="domcontentloaded", timeout=30000)
687
+ time.sleep(2)
688
+ click_first(page, ["button:has-text('Continue with Canva')"], timeout_ms=10000)
689
+ time.sleep(5)
690
+ # Cek popup setelah retry
691
+ for pg in browser.pages:
692
+ if "canva.com" in pg.url and pg != page:
693
+ log_step(f"Popup setelah retry: {pg.url[:60]}")
694
+ _click_canva_authorize_v2(pg, email)
695
+ auth_page = pg
696
+ time.sleep(5)
697
+ break
698
+ else:
699
+ if "canva.com" in page.url:
700
+ _click_canva_authorize_v2(page, email)
701
+ auth_page = page
702
+ time.sleep(5)
703
+ except Exception as _e:
704
+ log_step(f"Retry gagal: {_e}")
705
+ canva_btn_retry += 1
706
+ # Heartbeat setiap 5 detik
707
+ now = time.time()
708
+ if now - last_log_leo >= 5:
709
+ short = page.url.replace("https://","").replace("www.","")[:50]
710
+ log_step(f"Menunggu Leonardo post-auth... [{short}]")
711
+ last_log_leo = now
712
+ time.sleep(2)
713
+
714
+ # Tunggu Leonardo load penuh (pastikan session tersimpan di storage)
715
+ log_step(f"Leonardo page: {page.url[:60]}")
716
+ log_step("Tunggu networkidle + session cookie set...")
717
+ try:
718
+ page.wait_for_load_state("networkidle", timeout=10000)
719
+ except: pass
720
+ time.sleep(5)
721
+
722
+ # Navigate ke /image-generation untuk force session cookie di-set
723
+ log_step("Navigate ke image-generation untuk force session cookie...")
724
+ try:
725
+ page.goto("https://app.leonardo.ai/ai-generations", wait_until="domcontentloaded", timeout=30000)
726
+ time.sleep(5)
727
+ page.wait_for_load_state("networkidle", timeout=10000)
728
+ except: pass
729
+ time.sleep(3)
730
+
731
+ # Extract Leonardo cookies — pakai semua cookies dari context
732
+ log_step("Mengekstrak cookies Leonardo...")
733
+ all_cookies = page.context.cookies()
734
+ log_step(f"Total cookies: {len(all_cookies)}")
735
+ leo_cookies = [c for c in all_cookies if "leonardo.ai" in (c.get("domain") or "")]
736
+
737
+ if not leo_cookies:
738
+ leo_cookies = all_cookies
739
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in leo_cookies)
740
+
741
+ # === LOGIKA DARI LEOAPI-MAIN (updated untuk better-auth) ===
742
+ # Leonardo migrasi dari next-auth ke better-auth
743
+ # Token ada di: __Secure-better-auth.session_data.* atau session_token
744
+ SESSION_TOKEN_NAMES = [
745
+ # better-auth (baru — Leonardo pakai ini sekarang)
746
+ "__Secure-better-auth.session_data.0",
747
+ "__Secure-better-auth.session_data.1",
748
+ "better-auth.session_token",
749
+ "__Secure-better-auth.session_token",
750
+ # next-auth (lama — fallback)
751
+ "__Secure-next-auth.session-token",
752
+ "next-auth.session-token",
753
+ "__Secure-authjs.session-token",
754
+ "authjs.session-token",
755
+ ]
756
+ cookie_map = {}
757
+ for item in cookie_str.split(";"):
758
+ item = item.strip()
759
+ if "=" in item:
760
+ k, v = item.split("=", 1)
761
+ cookie_map[k.strip()] = v.strip()
762
+
763
+ # 1. Cek session-token langsung dari cookie
764
+ session_token = ""
765
+ for name in SESSION_TOKEN_NAMES:
766
+ if name in cookie_map:
767
+ session_token = cookie_map[name]
768
+ log_step(f"Session token ditemukan di cookie: {name} (len={len(session_token)})")
769
+ break
770
+
771
+ # 2. Jika tidak ada, coba POST /api/auth/session dengan CSRF
772
+ if not session_token:
773
+ log_step("Session token tidak ada di cookie, coba via /api/auth/session...")
774
+ try:
775
+ CSRF_NAMES = [
776
+ "__Host-next-auth.csrf-token", "__Secure-next-auth.csrf-token",
777
+ "next-auth.csrf-token", "__Host-authjs.csrf-token",
778
+ "__Secure-authjs.csrf-token", "authjs.csrf-token"
779
+ ]
780
+ csrf_raw = ""
781
+ for n in CSRF_NAMES:
782
+ if n in cookie_map:
783
+ csrf_raw = cookie_map[n]; break
784
+ csrf = csrf_raw.split("|")[0] if "|" in csrf_raw else csrf_raw
785
+
786
+ import urllib.request as _ur, urllib.parse as _up
787
+ _headers = {
788
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
789
+ "Cookie": cookie_str, "Accept": "application/json",
790
+ "Content-Type": "application/json",
791
+ "Origin": "https://app.leonardo.ai",
792
+ "Referer": "https://app.leonardo.ai/",
793
+ }
794
+ if csrf:
795
+ body = json.dumps({"csrfToken": csrf}).encode()
796
+ _req = _ur.Request("https://app.leonardo.ai/api/auth/session", data=body, headers=_headers, method="POST")
797
+ else:
798
+ _req = _ur.Request("https://app.leonardo.ai/api/auth/session", headers=_headers)
799
+ with _ur.urlopen(_req, timeout=15) as _resp:
800
+ _data = json.loads(_resp.read().decode())
801
+ # Cari JWT dari response
802
+ def _walk_jwt(obj, depth=0):
803
+ if depth > 6: return ""
804
+ if isinstance(obj, str) and obj.startswith("eyJ") and len(obj) > 100: return obj
805
+ if isinstance(obj, dict):
806
+ for k, v in obj.items():
807
+ kl = k.lower()
808
+ if "cf_access_token" in kl: continue
809
+ if kl in {"idtoken","accesstoken","id_token","access_token","token"} or "token" in kl or isinstance(v,(dict,list)):
810
+ r = _walk_jwt(v, depth+1)
811
+ if r: return r
812
+ if isinstance(obj, list):
813
+ for v in obj:
814
+ r = _walk_jwt(v, depth+1)
815
+ if r: return r
816
+ return ""
817
+ session_token = _walk_jwt(_data)
818
+ if session_token:
819
+ log_step(f"JWT ditemukan via /api/auth/session (len={len(session_token)})")
820
+ except Exception as e:
821
+ log_step(f"/api/auth/session gagal: {e}")
822
+
823
+ # 3. Fallback: ambil dari intercept (captured dari response listener)
824
+ if not session_token:
825
+ session_token = captured_jwt.get("value", "")
826
+ if session_token:
827
+ log_step(f"Pakai JWT dari intercept (len={len(session_token)})")
828
+
829
+ if not session_token:
830
+ log_step("GAGAL: next-auth.session-token tidak ditemukan di semua metode!")
831
+ log_step(f"Cookies tersedia: {list(cookie_map.keys())[:10]}")
832
+
833
+ if not cookie_str and not session_token:
834
+ sys.stdout.write(json.dumps({"status": "error", "message": "Gagal extract session Leonardo"}) + "\n")
835
+ sys.stdout.flush()
836
+ return False
837
+
838
+ sys.stdout.write(json.dumps({
839
+ "status": "success",
840
+ "cookie": cookie_str,
841
+ "jwt": session_token,
842
+ "balance": 150,
843
+ "left_team": False,
844
+ }) + "\n")
845
+ sys.stdout.flush()
846
+ return True
847
+
848
+ finally:
849
+ try:
850
+ asyncio.set_event_loop(None)
851
+ if hasattr(asyncio, "events") and hasattr(asyncio.events, "_set_running_loop"):
852
+ asyncio.events._set_running_loop(None)
853
+ except Exception: pass
854
+
855
+ if __name__ == "__main__":
856
+ parser = argparse.ArgumentParser()
857
+ parser.add_argument("--email", required=True)
858
+ parser.add_argument("--password", required=True)
859
+ # Node kirim --invite-link (hyphen), dulu kita pakai --invite_link
860
+ parser.add_argument("--invite-link", "--invite_link", dest="invite_link", default="")
861
+ parser.add_argument("--profiles-dir", "--profiles_dir", dest="profiles_dir", default="")
862
+ parser.add_argument("--signup-method", "--signup_method", dest="signup_method", default="email")
863
+ parser.add_argument("--skip-canva", "--skip_canva", dest="skip_canva", action="store_true")
864
+ parser.add_argument("--canva-delay", "--canva_delay", dest="canva_delay", type=int, default=0)
865
+ parser.add_argument("--canva-headless", "--canva_headless", dest="canva_headless", action="store_true")
866
+ parser.add_argument("--leave-canva-team", "--leave_canva_team", dest="leave_canva_team", action="store_true")
867
+ parser.add_argument("--headless", action="store_true")
868
+ # Proxy args dari Node
869
+ parser.add_argument("--proxy", default=None)
870
+ parser.add_argument("--proxy-server", "--proxy_server", dest="proxy_server", default=None)
871
+ parser.add_argument("--proxy-user", "--proxy_user", dest="proxy_user", default=None)
872
+ parser.add_argument("--proxy-pass", "--proxy_pass", dest="proxy_pass", default=None)
873
+ args = parser.parse_args()
874
+
875
+ # Build proxy string dari proxy-server/user/pass jika ada
876
+ proxy = args.proxy
877
+ if not proxy and args.proxy_server:
878
+ if args.proxy_user and args.proxy_pass:
879
+ from urllib.parse import urlparse as _up
880
+ p = _up(args.proxy_server)
881
+ proxy = f"{p.scheme}://{args.proxy_user}:{args.proxy_pass}@{p.hostname}:{p.port}"
882
+ else:
883
+ proxy = args.proxy_server
884
+
885
+ if args.canva_delay > 0:
886
+ import random
887
+ delay = random.randint(1, args.canva_delay)
888
+ log_step(f"Pre-Canva delay {delay}s...")
889
+ time.sleep(delay)
890
+
891
+ try:
892
+ success = run_automation(args.email, args.password, args.invite_link, proxy, args.headless, skip_canva=args.skip_canva)
893
+ if not success:
894
+ # Emit status:error agar Node handler mark sebagai failed (bukan hanya exit code 0)
895
+ sys.stdout.write(json.dumps({"status": "error", "message": "Automation gagal tanpa detail — cek log"}) + "\n")
896
+ sys.stdout.flush()
897
+ except Exception as e:
898
+ log_step(f"ERROR: {str(e)}")
899
+ import traceback
900
+ sys.stderr.write(traceback.format_exc())
901
+ sys.stdout.write(json.dumps({"status": "error", "message": str(e)}) + "\n")
902
+ sys.stdout.flush()