@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,3689 @@
1
+ #!/usr/bin/env python3
2
+ """Cloudflare account auto-signup via Camoufox (anti-fingerprint) + Fsmail email verification.
3
+
4
+ Outputs JSON lines to stdout:
5
+ {"step": "..."} — progress update
6
+ {"status": "success", "api_key": "...", "account_id": "...", "email": "..."} — final result
7
+ {"status": "error", "error": "..."} — failure
8
+ """
9
+
10
+ import sys
11
+ import json
12
+ import argparse
13
+ import time
14
+ import random
15
+ import string
16
+ import re
17
+ import urllib.request
18
+ import urllib.parse
19
+ import urllib.error
20
+ from pathlib import Path
21
+
22
+ # ── Stdout JSON helpers ────────────────────────────────────────────────────────
23
+ def emit(obj):
24
+ print(json.dumps(obj), flush=True)
25
+
26
+ def log_step(msg):
27
+ emit({"step": msg})
28
+
29
+ def success(api_key, account_id, email):
30
+ # Clean api_key — extract Bearer token if it's a curl command
31
+ import re as _re_clean
32
+ bearer_match = _re_clean.search(r'Bearer\s+([A-Za-z0-9_\-]{20,})', api_key)
33
+ if bearer_match:
34
+ api_key = bearer_match.group(1)
35
+ # Also match cfut_ token pattern directly
36
+ cfut_match = _re_clean.search(r'\b(cfut_[A-Za-z0-9_\-]{30,})\b', api_key)
37
+ if cfut_match:
38
+ api_key = cfut_match.group(1)
39
+ emit({"status": "success", "api_key": api_key, "account_id": account_id, "email": email})
40
+
41
+ def die(msg):
42
+ emit({"status": "error", "error": msg})
43
+ sys.exit(1)
44
+
45
+ # ── Fsmail helpers ─────────────────────────────────────────────────────────────
46
+ def fsmail_request(base_url, api_key, path, method="GET", data=None, host_header=None):
47
+ url = base_url.rstrip("/") + "/api" + path
48
+ req = urllib.request.Request(url, method=method)
49
+ req.add_header("Authorization", f"Bearer {api_key}")
50
+ req.add_header("X-API-Key", api_key)
51
+ req.add_header("Content-Type", "application/json")
52
+ req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
53
+ req.add_header("Accept", "application/json, */*")
54
+ # Nginx vhost routing: tambah Host header jika base_url adalah localhost
55
+ if host_header:
56
+ req.add_header("Host", host_header)
57
+ elif "localhost" in base_url or "127.0.0.1" in base_url:
58
+ req.add_header("Host", "fsmail.klipers.site")
59
+ if data:
60
+ req.data = json.dumps(data).encode()
61
+ with urllib.request.urlopen(req, timeout=15) as resp:
62
+ return json.loads(resp.read())
63
+
64
+ def create_fsmail_inbox(base_url, api_key, email):
65
+ """Create inbox by splitting email into alias + domain."""
66
+ try:
67
+ alias, domain = email.split("@", 1)
68
+ fsmail_request(base_url, api_key, "/inboxes", method="POST",
69
+ data={"alias": alias, "domain": domain})
70
+ except Exception:
71
+ pass # might already exist
72
+
73
+ def wait_for_cf_verify_email(base_url, api_key, email, timeout=600):
74
+ log_step(f"Menunggu email verifikasi Cloudflare ({email})...")
75
+ alias = email.split("@")[0]
76
+ deadline = time.time() + timeout
77
+ seen_ids = set()
78
+ while time.time() < deadline:
79
+ try:
80
+ data = fsmail_request(base_url, api_key, f"/inboxes/{urllib.parse.quote(alias)}/messages")
81
+ messages = data.get("messages", [])
82
+ for msg in messages:
83
+ msg_id = msg.get("id", "")
84
+ subject = msg.get("subject", "")
85
+ if msg_id in seen_ids:
86
+ continue
87
+ seen_ids.add(msg_id)
88
+ # Broader subject matching — CF sometimes uses different subject lines
89
+ subj_lower = subject.lower()
90
+ is_cf_email = (
91
+ "cloudflare" in subj_lower or
92
+ "verify" in subj_lower or
93
+ "confirm" in subj_lower or
94
+ "email" in subj_lower or
95
+ "activate" in subj_lower or
96
+ "validate" in subj_lower
97
+ )
98
+ if is_cf_email:
99
+ # Fetch full message body
100
+ try:
101
+ full = fsmail_request(base_url, api_key, f"/messages/{urllib.parse.quote(msg_id)}")
102
+ msg_body = full.get("message", full)
103
+ body = msg_body.get("body", msg_body.get("html", msg_body.get("text", "")))
104
+ except Exception:
105
+ body = msg.get("snippet", "")
106
+ patterns = [
107
+ r'https://dash\.cloudflare\.com/email-verification[^\s\'"<>]+',
108
+ r'https://[^\s\'"<>]*confirm[^\s\'"<>]*',
109
+ r'https://[^\s\'"<>]*verify[^\s\'"<>]*',
110
+ r'https://dash\.cloudflare\.com/[^\s\'"<>]+',
111
+ ]
112
+ for pat in patterns:
113
+ links = re.findall(pat, body)
114
+ if links:
115
+ link = links[0].rstrip(".")
116
+ log_step(f"Link verifikasi ditemukan!")
117
+ return link
118
+ except Exception as e:
119
+ log_step(f"Fsmail poll error: {e}")
120
+ time.sleep(5)
121
+ return None
122
+
123
+ # ── 2Captcha Turnstile solver ───────────────────────────────────────────────────
124
+ # Hardcoded sitekey as fallback — scraping from page is preferred (see get_turnstile_sitekey)
125
+ CF_SIGNUP_TURNSTILE_SITEKEY = "0x4AAAAAAAJel0iaAR3mgkjp"
126
+ CF_SIGNUP_PAGE_URL = "https://dash.cloudflare.com/sign-up"
127
+
128
+ def get_turnstile_sitekey(page, fallback=CF_SIGNUP_TURNSTILE_SITEKEY):
129
+ """Scrape the actual Turnstile sitekey from page — avoids hardcode becoming stale."""
130
+ try:
131
+ sitekey = page.evaluate(
132
+ r"""
133
+ () => {
134
+ // Method 1: data-sitekey attribute
135
+ const el = document.querySelector('[data-sitekey]');
136
+ if (el) return el.getAttribute('data-sitekey');
137
+ // Method 2: inside Turnstile iframe src
138
+ for (const iframe of document.querySelectorAll('iframe')) {
139
+ const src = iframe.src || '';
140
+ const m = src.match(/[?&]sitekey=([^&]+)/);
141
+ if (m) return decodeURIComponent(m[1]);
142
+ }
143
+ // Method 3: window.__CF$cv$params
144
+ try {
145
+ const raw = JSON.stringify(window.__CF$cv$params || {});
146
+ const m2 = raw.match(/sitekey["']?\s*:\s*["']([^"']+)["']/);
147
+ if (m2) return m2[1];
148
+ } catch(e) {}
149
+ return null;
150
+ }
151
+ """
152
+ )
153
+ if sitekey and len(sitekey.strip()) > 10:
154
+ log_step(f"Sitekey dari halaman: {sitekey}")
155
+ return sitekey.strip()
156
+ except Exception as e:
157
+ log_step(f"get_turnstile_sitekey error: {e}")
158
+ log_step(f"Pakai sitekey hardcode: {fallback}")
159
+ return fallback
160
+
161
+
162
+ def get_turnstile_action(page, default=None):
163
+ """Extract data-action from Turnstile widget on page."""
164
+ try:
165
+ action = page.evaluate(r"""
166
+ () => {
167
+ // Method 1: data-action on cf-turnstile div
168
+ const el = document.querySelector('[data-action], .cf-turnstile, [data-cf-turnstile-response]');
169
+ if (el && el.getAttribute('data-action')) return el.getAttribute('data-action');
170
+ // Method 2: scan iframe src for action param
171
+ for (const iframe of document.querySelectorAll('iframe')) {
172
+ const src = iframe.src || '';
173
+ const m = src.match(/[?&]action=([^&]+)/);
174
+ if (m) return decodeURIComponent(m[1]);
175
+ }
176
+ return null;
177
+ }
178
+ """)
179
+ if action:
180
+ return action.strip()
181
+ except Exception:
182
+ pass
183
+ return default
184
+
185
+
186
+ def solve_turnstile_2captcha(api_key, page_url, sitekey, timeout=120, action=None, data=None):
187
+ """Submit Turnstile to 2Captcha and wait for solution token."""
188
+ log_step("Mengirim Turnstile ke 2Captcha untuk diselesaikan...")
189
+ try:
190
+ # Submit task
191
+ submit_data = {
192
+ "key": api_key,
193
+ "method": "turnstile",
194
+ "sitekey": sitekey,
195
+ "pageurl": page_url,
196
+ "json": 1,
197
+ }
198
+ if action:
199
+ submit_data["action"] = action
200
+ log_step(f"2Captcha Turnstile action: {action}")
201
+ if data:
202
+ submit_data["data"] = data
203
+ encoded = urllib.parse.urlencode(submit_data).encode()
204
+ req = urllib.request.Request("https://2captcha.com/in.php", data=encoded)
205
+ with urllib.request.urlopen(req, timeout=15) as r:
206
+ resp = json.loads(r.read())
207
+ if not resp.get("status") == 1:
208
+ log_step(f"2Captcha submit error: {resp}")
209
+ return None
210
+ task_id = resp.get("request")
211
+ log_step(f"2Captcha task submitted: {task_id}")
212
+
213
+ # Poll for result
214
+ deadline = time.time() + timeout
215
+ time.sleep(15) # initial wait
216
+ while time.time() < deadline:
217
+ res_url = f"https://2captcha.com/res.php?key={api_key}&action=get&id={task_id}&json=1"
218
+ req2 = urllib.request.Request(res_url)
219
+ with urllib.request.urlopen(req2, timeout=15) as r2:
220
+ res = json.loads(r2.read())
221
+ if res.get("status") == 1:
222
+ token = res.get("request")
223
+ log_step(f"2Captcha Turnstile solved!")
224
+ return token
225
+ if res.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
226
+ log_step("2Captcha: captcha unsolvable")
227
+ return None
228
+ time.sleep(5)
229
+ log_step("2Captcha Turnstile timeout")
230
+ return None
231
+ except Exception as e:
232
+ log_step(f"2Captcha error: {e}")
233
+ return None
234
+
235
+ def inject_turnstile_token(page, token):
236
+ """Inject solved Turnstile token into the page."""
237
+ try:
238
+ page.evaluate(f"""
239
+ (function() {{
240
+ // Set cf-turnstile-response hidden input
241
+ var inputs = document.querySelectorAll('input[name="cf-turnstile-response"], input[name="cf_challenge_response"]');
242
+ inputs.forEach(function(el) {{ el.value = '{token}'; }});
243
+ // Also try window.turnstile callback
244
+ if (window.turnstile && window.turnstile.getResponse) {{
245
+ try {{ window.turnstile.execute(); }} catch(e) {{}}
246
+ }}
247
+ }})();
248
+ """)
249
+ return True
250
+ except Exception as e:
251
+ log_step(f"inject_turnstile_token error: {e}")
252
+ return False
253
+
254
+ # ── Turnstile bypass (ported from weavy_signup.py) ─────────────────────────────
255
+ def is_on_turnstile_page(page) -> bool:
256
+ try:
257
+ title = page.title() or ""
258
+ if "just a moment" in title.lower() or "security verification" in title.lower():
259
+ return True
260
+ except Exception:
261
+ pass
262
+ try:
263
+ token = page.evaluate("() => { const el = document.getElementsByName('cf-turnstile-response')[0] || document.getElementById('cf-turnstile-response'); return el ? el.value : null; }")
264
+ if token is not None:
265
+ return len(token.strip()) == 0
266
+ except Exception:
267
+ pass
268
+ for sel in ["text=Just a moment", "text=Verifying you are human", "#challenge-form", "#cf-challenge-running"]:
269
+ try:
270
+ loc = page.locator(sel).first
271
+ if loc.count() > 0 and loc.is_visible(timeout=300):
272
+ return True
273
+ except Exception:
274
+ continue
275
+ try:
276
+ for f in page.frames:
277
+ url = f.url or ""
278
+ if ("challenges.cloudflare.com" in url or "turnstile" in url) and "challenge-platform" in url:
279
+ token = page.evaluate("() => { const el = document.getElementsByName('cf-turnstile-response')[0]; return el ? el.value : ''; }")
280
+ if token and len(token.strip()) > 0:
281
+ return False
282
+ return True
283
+ except Exception:
284
+ pass
285
+ return False
286
+
287
+ def try_click_turnstile_checkbox(page) -> bool:
288
+ target_frame = None
289
+ try:
290
+ for f in page.frames:
291
+ url = f.url or ""
292
+ if "challenges.cloudflare.com" in url or "turnstile" in url:
293
+ target_frame = f
294
+ break
295
+ except Exception:
296
+ pass
297
+
298
+ if target_frame:
299
+ try:
300
+ frame_element = page.locator("iframe[src*='challenges.cloudflare.com'], iframe[src*='turnstile']").first
301
+ if frame_element.count() > 0 and not frame_element.is_visible(timeout=500):
302
+ return False
303
+ except Exception:
304
+ pass
305
+ for cb_sel in ["input[type='checkbox']", "[role='checkbox']", "div.ctp-checkbox-label"]:
306
+ try:
307
+ box = target_frame.locator(cb_sel).first
308
+ if box.count() > 0:
309
+ box.click(timeout=3000)
310
+ return True
311
+ except Exception:
312
+ continue
313
+ try:
314
+ handle = target_frame.frame_element()
315
+ bbox = handle.bounding_box() if handle else None
316
+ if bbox:
317
+ x = bbox["x"] + 28
318
+ y = bbox["y"] + 32
319
+ page.mouse.move(x, y, steps=10)
320
+ time.sleep(0.3)
321
+ page.mouse.click(x, y)
322
+ return True
323
+ except Exception:
324
+ pass
325
+ for iframe_sel in ["iframe[src*='challenges.cloudflare.com']", "iframe[src*='turnstile']"]:
326
+ for cb_sel in ["input[type='checkbox']", "[role='checkbox']"]:
327
+ try:
328
+ box = page.frame_locator(iframe_sel).locator(cb_sel).first
329
+ if box.count() > 0:
330
+ box.click(timeout=3000)
331
+ return True
332
+ except Exception:
333
+ continue
334
+ return False
335
+
336
+ def wait_for_cf_clearance(page, timeout=45.0):
337
+ if not is_on_turnstile_page(page):
338
+ return True
339
+ log_step("Cloudflare Turnstile terdeteksi, menunggu resolve...")
340
+ deadline = time.time() + timeout
341
+ click_attempts = 0
342
+ next_click_at = time.time() + 4.0
343
+ while time.time() < deadline:
344
+ time.sleep(2.0)
345
+ if not is_on_turnstile_page(page):
346
+ log_step("Turnstile selesai!")
347
+ try:
348
+ page.wait_for_load_state("networkidle", timeout=5000)
349
+ except Exception:
350
+ pass
351
+ return True
352
+ now = time.time()
353
+ if click_attempts < 5 and now >= next_click_at:
354
+ click_attempts += 1
355
+ log_step(f"Klik Turnstile checkbox (attempt {click_attempts}/5)...")
356
+ try_click_turnstile_checkbox(page)
357
+ next_click_at = now + 8.0
358
+ time.sleep(2.0)
359
+ return False
360
+
361
+ # ── Cloudflare API ─────────────────────────────────────────────────────────────
362
+ CF_API = "https://api.cloudflare.com/client/v4"
363
+
364
+ def cf_api_call(path, global_key, email, method="GET", body=None):
365
+ url = CF_API + path
366
+ req = urllib.request.Request(url, method=method)
367
+ req.add_header("X-Auth-Key", global_key)
368
+ req.add_header("X-Auth-Email", email)
369
+ req.add_header("Content-Type", "application/json")
370
+ if body:
371
+ req.data = json.dumps(body).encode()
372
+ try:
373
+ with urllib.request.urlopen(req, timeout=15) as resp:
374
+ return json.loads(resp.read())
375
+ except urllib.error.HTTPError as e:
376
+ raise Exception(f"CF API {path} {e.code}: {e.read().decode()}")
377
+
378
+ def get_account_id_via_api(global_key, email):
379
+ try:
380
+ r = cf_api_call("/accounts?per_page=1", global_key, email)
381
+ if r.get("success") and r.get("result"):
382
+ return r["result"][0]["id"], r["result"][0]["name"]
383
+ except Exception as e:
384
+ log_step(f"get_account_id error: {e}")
385
+ return None, None
386
+
387
+ def create_workers_ai_token(global_key, email, account_id, token_name="9router Workers AI"):
388
+ """Create Workers AI Read+Edit token via CF API using Global API Key."""
389
+ try:
390
+ # Get permission groups
391
+ r = cf_api_call(f"/accounts/{account_id}/tokens/permission_groups", global_key, email)
392
+ groups = r.get("result", [])
393
+ # Exact match first: "Workers AI Read" not "Workers AI Metadata Read"
394
+ def _match_wa(groups, keyword):
395
+ # 1. exact match: name == "Workers AI <keyword>"
396
+ exact = next((g for g in groups if g["name"].lower() == f"workers ai {keyword}"), None)
397
+ if exact: return exact
398
+ # 2. starts with "Workers AI " and ends with keyword (avoid Metadata)
399
+ ends = next((g for g in groups if g["name"].lower().startswith("workers ai ") and
400
+ g["name"].lower().endswith(keyword) and "metadata" not in g["name"].lower()), None)
401
+ if ends: return ends
402
+ # 3. fallback: contains both keywords, exclude metadata
403
+ return next((g for g in groups if "workers ai" in g["name"].lower() and
404
+ keyword in g["name"].lower() and "metadata" not in g["name"].lower()), None)
405
+ read_g = _match_wa(groups, "read")
406
+ edit_g = _match_wa(groups, "write") or _match_wa(groups, "edit")
407
+ if not read_g or not edit_g:
408
+ # fallback: use Write as both
409
+ wa = [g for g in groups if "workers ai" in g["name"].lower() and "metadata" not in g["name"].lower()]
410
+ if len(wa) >= 2:
411
+ read_g, edit_g = wa[0], wa[1]
412
+ elif len(wa) == 1:
413
+ read_g = edit_g = wa[0]
414
+ else:
415
+ return None
416
+ payload = {
417
+ "name": token_name,
418
+ "policies": [{
419
+ "effect": "allow",
420
+ "permission_groups": [{"id": read_g["id"]}, {"id": edit_g["id"]}],
421
+ "resources": {f"com.cloudflare.api.account.{account_id}": "*"},
422
+ }],
423
+ }
424
+ r2 = cf_api_call("/user/tokens", global_key, email, method="POST", body=payload)
425
+ if r2.get("success") and r2.get("result", {}).get("value"):
426
+ return r2["result"]["value"]
427
+ except Exception as e:
428
+ log_step(f"create_workers_ai_token error: {e}")
429
+ return None
430
+
431
+ # ── Handle "Verify Your Identity" popup ────────────────────────────────────────
432
+ def handle_identity_verification(page, fsmail_base_url, fsmail_api_key, email):
433
+ """Detect CF identity verification popup, send OTP, fetch from Fsmail, submit."""
434
+ try:
435
+ # Use multiple selectors to detect the popup
436
+ popup_visible = False
437
+ for sel in [
438
+ "h2:has-text('Verify Your Identity')",
439
+ "h1:has-text('Verify Your Identity')",
440
+ "div:has-text('Verify Your Identity')",
441
+ "button:has-text('Send Verification Code')",
442
+ ]:
443
+ try:
444
+ el = page.locator(sel).first
445
+ if el.is_visible(timeout=2000):
446
+ popup_visible = True
447
+ break
448
+ except Exception:
449
+ continue
450
+
451
+ if not popup_visible:
452
+ return True # No popup, all good
453
+
454
+ log_step("Popup 'Verify Your Identity' terdeteksi!")
455
+
456
+ # Click "Send Verification Code"
457
+ send_btn = page.locator("button:has-text('Send Verification Code')").first
458
+ if send_btn.is_visible(timeout=2000):
459
+ send_btn.click()
460
+ log_step("Klik Send Verification Code...")
461
+ time.sleep(3)
462
+ else:
463
+ # Try clicking Cancel and skip
464
+ cancel = page.locator("button:has-text('Cancel')").first
465
+ if cancel.is_visible(timeout=1000):
466
+ cancel.click()
467
+ return False
468
+
469
+ # Fetch OTP from Fsmail
470
+ if not fsmail_base_url or not fsmail_api_key:
471
+ log_step("Fsmail tidak dikonfigurasi, tidak bisa ambil OTP")
472
+ return False
473
+
474
+ log_step("Menunggu OTP di Fsmail...")
475
+ otp_code = None
476
+ for attempt in range(20): # 60 seconds
477
+ time.sleep(3)
478
+ try:
479
+ msgs = fsmail_request(fsmail_base_url, fsmail_api_key, f"/inboxes/{email.split('@')[0]}/messages")
480
+ for msg in msgs.get("messages", []):
481
+ # Get full body
482
+ msg_detail = fsmail_request(fsmail_base_url, fsmail_api_key, f"/messages/{msg['id']}")
483
+ body = msg_detail.get("body", "") or msg_detail.get("html", "") or msg.get("snippet", "")
484
+ # CF OTP is typically 6 digits
485
+ import re as _re
486
+ otp_match = _re.search(r'\b(\d{6})\b', body)
487
+ if otp_match:
488
+ otp_code = otp_match.group(1)
489
+ log_step(f"OTP ditemukan: {otp_code}")
490
+ break
491
+ except Exception as e:
492
+ log_step(f"Fsmail OTP fetch error: {e}")
493
+ if otp_code:
494
+ break
495
+
496
+ if not otp_code:
497
+ log_step("OTP tidak diterima dalam 60 detik")
498
+ return False
499
+
500
+ # Enter OTP
501
+ otp_input = page.locator("input[type='text'][maxlength='6'], input[placeholder*='code'], input[name*='code'], input[type='number']").first
502
+ if otp_input.is_visible(timeout=5000):
503
+ otp_input.fill(otp_code)
504
+ time.sleep(0.5)
505
+ log_step("OTP diisi!")
506
+
507
+ # Submit
508
+ for sel in ["button:has-text('Verify')", "button:has-text('Submit')", "button:has-text('Confirm')", "button[type='submit']"]:
509
+ try:
510
+ btn = page.locator(sel).first
511
+ if btn.is_visible(timeout=1000):
512
+ btn.click()
513
+ time.sleep(2)
514
+ log_step("OTP submitted!")
515
+ return True
516
+ except Exception:
517
+ continue
518
+ else:
519
+ log_step("OTP input field tidak ditemukan")
520
+ except Exception as e:
521
+ log_step(f"handle_identity_verification error: {e}")
522
+ return False
523
+
524
+ # ── Extract Global API Key from dashboard page ─────────────────────────────────
525
+ def extract_global_api_key(page, password, fsmail_base_url="", fsmail_api_key="", email=""):
526
+ """Navigate to API tokens page and extract Global API Key."""
527
+ log_step("Membuka halaman API Tokens...")
528
+ try:
529
+ page.goto("https://dash.cloudflare.com/profile/api-tokens", wait_until="domcontentloaded", timeout=30000)
530
+ wait_for_cf_clearance(page, timeout=20)
531
+ time.sleep(3)
532
+
533
+ # ── Handle "Verify Your Identity" popup ─────────────────────────────
534
+ handle_identity_verification(page, fsmail_base_url, fsmail_api_key, email)
535
+ time.sleep(1)
536
+
537
+ # Find "View" button for Global API Key
538
+ view_selectors = [
539
+ "button:has-text('View')",
540
+ "button:has-text('Reveal')",
541
+ "span:has-text('View'):visible",
542
+ ]
543
+ for sel in view_selectors:
544
+ try:
545
+ btn = page.locator(sel).first
546
+ if btn.is_visible(timeout=2000):
547
+ btn.click()
548
+ time.sleep(1)
549
+ break
550
+ except Exception:
551
+ continue
552
+
553
+ # Password confirmation modal
554
+ pw_input = page.locator("input[type='password']").first
555
+ if pw_input.is_visible(timeout=3000):
556
+ log_step("Mengisi password konfirmasi...")
557
+ pw_input.evaluate("""
558
+ (el, pw) => {
559
+ const nativeSetter = Object.getOwnPropertyDescriptor(
560
+ window.HTMLInputElement.prototype, 'value'
561
+ ).set;
562
+ nativeSetter.call(el, pw);
563
+ el.dispatchEvent(new Event('input', { bubbles: true }));
564
+ el.dispatchEvent(new Event('change', { bubbles: true }));
565
+ }
566
+ """, password)
567
+ time.sleep(0.5)
568
+ # Click confirm button
569
+ for sel in ["button:has-text('View')", "button[type='submit']", "button:has-text('Confirm')"]:
570
+ try:
571
+ btn = page.locator(sel).last
572
+ if btn.is_visible(timeout=1000):
573
+ btn.click()
574
+ break
575
+ except Exception:
576
+ continue
577
+ time.sleep(2)
578
+
579
+ # Extract the key value
580
+ for sel in [
581
+ "input[data-testid='global-api-key']",
582
+ "input[readonly][type='text']",
583
+ "code",
584
+ ".cf-input-code",
585
+ "input[class*='code']",
586
+ "input[class*='api']",
587
+ ]:
588
+ try:
589
+ el = page.locator(sel).first
590
+ if el.is_visible(timeout=2000):
591
+ val = el.input_value() if sel.startswith("input") else el.text_content()
592
+ if val and len(val) > 20:
593
+ return val.strip()
594
+ except Exception:
595
+ continue
596
+
597
+ # Take screenshot to debug
598
+ try:
599
+ page.screenshot(path="/tmp/cf_api_key_page.png")
600
+ log_step("Screenshot saved: /tmp/cf_api_key_page.png")
601
+ except Exception:
602
+ pass
603
+
604
+ except Exception as e:
605
+ log_step(f"extract_global_api_key error: {e}")
606
+ return None
607
+
608
+ # ── Main ───────────────────────────────────────────────────────────────────────
609
+ def main():
610
+ parser = argparse.ArgumentParser()
611
+ parser.add_argument("--email", required=True)
612
+ parser.add_argument("--password", required=True)
613
+ parser.add_argument("--fsmail-base-url", default="")
614
+ parser.add_argument("--fsmail-api-key", default="")
615
+ parser.add_argument("--fsmail-domain", default="")
616
+ parser.add_argument("--profiles-dir", default="profiles/cloudflare")
617
+ parser.add_argument("--headless", action="store_true")
618
+ parser.add_argument("--proxy-server")
619
+ parser.add_argument("--proxy-user")
620
+ parser.add_argument("--proxy-pass")
621
+ parser.add_argument("--2captcha-key", default="", dest="captcha_key")
622
+ # ── Manual override: skip automation, paste token directly ────────────────
623
+ parser.add_argument("--token", default="",
624
+ help="Paste CF API token manual — skip seluruh automation")
625
+ parser.add_argument("--account-id", default="", dest="account_id_arg",
626
+ help="Cloudflare Account ID (wajib jika pakai --token)")
627
+ parser.add_argument("--stagger-delay", type=int, default=0, dest="stagger_delay",
628
+ help="Delay (detik) sebelum launch browser, untuk stagger concurrent instances")
629
+ args = parser.parse_args()
630
+
631
+ # ── Shortcut: jika user paste token manual, langsung simpan ──────────────
632
+ if args.token:
633
+ if not args.account_id_arg:
634
+ die("--token butuh --account-id juga")
635
+ log_step(f"Mode manual token: {args.token[:12]}...")
636
+ success(args.token.strip(), args.account_id_arg.strip(), args.email)
637
+ return
638
+
639
+ # Import Camoufox (same as weavy_signup.py)
640
+ try:
641
+ from camoufox.sync_api import Camoufox
642
+ except ImportError:
643
+ die("Camoufox tidak terinstall. Jalankan: pip install camoufox && python -m camoufox fetch")
644
+
645
+ profiles_dir = Path(args.profiles_dir)
646
+ profiles_dir.mkdir(parents=True, exist_ok=True)
647
+
648
+ # Pre-create Fsmail inbox if we have credentials
649
+ fsmail_ok = bool(args.fsmail_base_url and args.fsmail_api_key and args.fsmail_domain)
650
+ if fsmail_ok:
651
+ log_step(f"Membuat inbox Fsmail untuk {args.email}...")
652
+ try:
653
+ create_fsmail_inbox(args.fsmail_base_url, args.fsmail_api_key, args.email)
654
+ except Exception as e:
655
+ log_step(f"Fsmail inbox warning: {e}")
656
+
657
+ log_step("Meluncurkan browser Camoufox (anti-fingerprint)...")
658
+
659
+ # Stagger delay — when running concurrent instances, delay launch to avoid
660
+ # resource contention and Cloudflare rate-limit detection
661
+ if args.stagger_delay > 0:
662
+ log_step(f"Stagger delay {args.stagger_delay}s...")
663
+ time.sleep(args.stagger_delay)
664
+
665
+ proxy_dict = None
666
+ if args.proxy_server:
667
+ proxy_dict = {"server": args.proxy_server}
668
+ if args.proxy_user:
669
+ proxy_dict["username"] = args.proxy_user
670
+ if args.proxy_pass:
671
+ proxy_dict["password"] = args.proxy_pass
672
+
673
+ launch_kwargs = dict(
674
+ headless=args.headless,
675
+ os="windows",
676
+ locale="en-US",
677
+ )
678
+ if proxy_dict:
679
+ launch_kwargs["proxy"] = proxy_dict
680
+ launch_kwargs["geoip"] = True # match geolocation to proxy IP (suppresses LeakWarning)
681
+
682
+ def _make_camoufox(kw):
683
+ """Launch Camoufox, stripping unsupported kwargs one by one."""
684
+ try:
685
+ return Camoufox(**kw)
686
+ except TypeError:
687
+ kw.pop("os", None)
688
+ try:
689
+ return Camoufox(**kw)
690
+ except TypeError:
691
+ kw.pop("locale", None)
692
+ return Camoufox(**kw)
693
+
694
+ try:
695
+ browser_ctx = _make_camoufox(dict(launch_kwargs))
696
+ except Exception as _pe:
697
+ _ps = str(_pe)
698
+ if proxy_dict and any(k in _ps for k in ("InvalidProxy","Tunnel connection","Failed to connect to proxy","ProxyError")):
699
+ log_step(f"Proxy dead ({proxy_dict.get('server','?')}) — fallback tanpa proxy")
700
+ launch_kwargs.pop("proxy", None)
701
+ launch_kwargs.pop("geoip", None)
702
+ browser_ctx = _make_camoufox(dict(launch_kwargs))
703
+ else:
704
+ raise
705
+
706
+ with browser_ctx as browser:
707
+ page = browser.new_page()
708
+ page.set_viewport_size({"width": 1920, "height": 1080})
709
+
710
+ # ── Step 1: Open Cloudflare signup ────────────────────────────────────
711
+ log_step("Membuka halaman registrasi Cloudflare...")
712
+ try:
713
+ page.goto("https://dash.cloudflare.com/sign-up", wait_until="domcontentloaded", timeout=30000)
714
+ except Exception:
715
+ page.goto("https://dash.cloudflare.com/sign-up", wait_until="load", timeout=30000)
716
+
717
+ wait_for_cf_clearance(page, timeout=30)
718
+ time.sleep(random.uniform(1.5, 2.5))
719
+
720
+ # ── Step 2: Fill email ────────────────────────────────────────────────
721
+ log_step("Menunggu form signup muncul...")
722
+ form_found = False
723
+ for attempt in range(3):
724
+ try:
725
+ page.wait_for_selector("input[name='email'], input[autocomplete='email']", timeout=20000)
726
+ form_found = True
727
+ break
728
+ except Exception:
729
+ log_step(f"Form belum muncul (attempt {attempt+1}), reload...")
730
+ try:
731
+ page.reload(wait_until="load", timeout=20000)
732
+ wait_for_cf_clearance(page, timeout=15)
733
+ time.sleep(3)
734
+ except Exception:
735
+ pass
736
+ if not form_found:
737
+ die("Form signup tidak muncul setelah 3 percobaan")
738
+
739
+ log_step("Mengisi email...")
740
+ email_sel = [
741
+ "input[name='email']",
742
+ "input[autocomplete='email']",
743
+ "input[type='email']",
744
+ ]
745
+ email_filled = False
746
+ for sel in email_sel:
747
+ try:
748
+ el = page.locator(sel).first
749
+ if el.is_visible(timeout=2000):
750
+ el.evaluate("""
751
+ (el, email) => {
752
+ const nativeSetter = Object.getOwnPropertyDescriptor(
753
+ window.HTMLInputElement.prototype, 'value'
754
+ ).set;
755
+ nativeSetter.call(el, email);
756
+ el.dispatchEvent(new Event('input', { bubbles: true }));
757
+ el.dispatchEvent(new Event('change', { bubbles: true }));
758
+ }
759
+ """, args.email)
760
+ email_filled = True
761
+ break
762
+ except Exception:
763
+ continue
764
+ if not email_filled:
765
+ die("Tidak bisa menemukan input email di halaman signup Cloudflare")
766
+
767
+ # ── Step 3: Fill password ─────────────────────────────────────────────
768
+ log_step("Mengisi password...")
769
+ pw_inputs = page.locator("input[name='password'], input[type='password']")
770
+ pw_count = pw_inputs.count()
771
+
772
+ def fill_password_field(el, password, retries=3):
773
+ """Fill password field with React-compatible injection + verification."""
774
+ for attempt in range(retries):
775
+ try:
776
+ # Method 1: React-compatible JS injection (most reliable for React forms)
777
+ el.evaluate("""
778
+ (el, pw) => {
779
+ const nativeSetter = Object.getOwnPropertyDescriptor(
780
+ window.HTMLInputElement.prototype, 'value'
781
+ ).set;
782
+ nativeSetter.call(el, pw);
783
+ el.dispatchEvent(new Event('input', { bubbles: true }));
784
+ el.dispatchEvent(new Event('change', { bubbles: true }));
785
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
786
+ }
787
+ """, password)
788
+ time.sleep(0.3)
789
+
790
+ # Verify
791
+ actual = el.evaluate("el => el.value")
792
+ if actual == password:
793
+ log_step(f"Password set via React injection OK (attempt {attempt+1})")
794
+ return True
795
+
796
+ log_step(f"React injection mismatch (attempt {attempt+1}): expected {len(password)}, got {len(actual)}")
797
+
798
+ # Method 2: type() as fallback
799
+ el.click()
800
+ el.press("Control+a")
801
+ el.press("Backspace")
802
+ time.sleep(0.1)
803
+ el.type(password, delay=50)
804
+ time.sleep(0.5)
805
+ actual2 = el.evaluate("el => el.value")
806
+ if actual2 == password:
807
+ log_step(f"Password set via type() OK (attempt {attempt+1})")
808
+ return True
809
+
810
+ log_step(f"type() mismatch (attempt {attempt+1}): expected {len(password)}, got {len(actual2)}")
811
+ except Exception as e:
812
+ log_step(f"Fill password attempt {attempt+1} error: {e}")
813
+ return False
814
+
815
+ if pw_count >= 1:
816
+ ok1 = fill_password_field(pw_inputs.nth(0), args.password)
817
+ if not ok1:
818
+ log_step("WARNING: Password field 0 could not be verified")
819
+ if pw_count >= 2:
820
+ ok2 = fill_password_field(pw_inputs.nth(1), args.password)
821
+ if not ok2:
822
+ log_step("WARNING: Password field 1 (confirm) could not be verified")
823
+
824
+ # ── Step 4: Handle Turnstile ──────────────────────────────────────────
825
+ log_step("Menangani Turnstile captcha...")
826
+ time.sleep(3)
827
+
828
+ # First try auto-solve with retry — checks both cf_challenge_response and cf-turnstile-response
829
+ turnstile_solved = False
830
+ for _ts_attempt in range(3):
831
+ wait_for_cf_clearance(page, timeout=10)
832
+ try:
833
+ token_val = page.evaluate("""
834
+ () => {
835
+ const names = ['cf-turnstile-response', 'cf_challenge_response', 'cf-turnstile-response-0'];
836
+ for (const n of names) {
837
+ const el = document.querySelector(`input[name="${n}"]`) || document.getElementById(n);
838
+ if (el && el.value && el.value.length > 10) return el.value;
839
+ }
840
+ return '';
841
+ }
842
+ """)
843
+ if token_val and len(token_val.strip()) > 10:
844
+ turnstile_solved = True
845
+ log_step(f"Turnstile auto-solved! (attempt {_ts_attempt+1})")
846
+ break
847
+ except Exception:
848
+ pass
849
+ if _ts_attempt < 2:
850
+ time.sleep(3)
851
+
852
+ # Scrape actual sitekey from page (not hardcode)
853
+ actual_sitekey = get_turnstile_sitekey(page)
854
+
855
+ # Fallback: 2Captcha
856
+ if not turnstile_solved and args.captcha_key:
857
+ log_step("Turnstile belum solved, pakai 2Captcha...")
858
+ token_2c = solve_turnstile_2captcha(
859
+ args.captcha_key,
860
+ CF_SIGNUP_PAGE_URL,
861
+ actual_sitekey,
862
+ timeout=150,
863
+ )
864
+ if token_2c:
865
+ inject_turnstile_token(page, token_2c)
866
+ turnstile_solved = True
867
+ time.sleep(1)
868
+ else:
869
+ log_step("2Captcha gagal, tetap coba submit...")
870
+ elif not turnstile_solved:
871
+ log_step("Tidak ada 2Captcha key, lanjut submit tanpa solve...")
872
+
873
+ # ── Step 5: Submit form ───────────────────────────────────────────────
874
+ log_step("Submit form registrasi...")
875
+ submit_selectors = [
876
+ "button[type='submit']",
877
+ "button:has-text('Create Account')",
878
+ "button:has-text('Sign up')",
879
+ "button:has-text('Get started')",
880
+ ]
881
+ submitted = False
882
+ for sel in submit_selectors:
883
+ try:
884
+ btn = page.locator(sel).first
885
+ if btn.is_visible(timeout=2000):
886
+ # React-compatible click: dispatch mouse events
887
+ btn.evaluate("""
888
+ (el) => {
889
+ // Focus first
890
+ el.focus();
891
+ el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
892
+ el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
893
+ el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
894
+ }
895
+ """)
896
+ submitted = True
897
+ log_step(f"Submit button clicked via: {sel}")
898
+ break
899
+ except Exception:
900
+ continue
901
+ if not submitted:
902
+ # Try form.submit() as fallback
903
+ try:
904
+ page.evaluate("""
905
+ () => {
906
+ const form = document.querySelector('form');
907
+ if (form) {
908
+ form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true}));
909
+ return 'form dispatched';
910
+ }
911
+ // Try clicking ANY visible button
912
+ const btns = Array.from(document.querySelectorAll('button'));
913
+ for (const b of btns) {
914
+ const txt = b.textContent.trim().toLowerCase();
915
+ if (txt.includes('create') || txt.includes('sign up') || txt.includes('get started') || txt.includes('register')) {
916
+ b.click();
917
+ return 'clicked: ' + txt;
918
+ }
919
+ }
920
+ return 'no button found';
921
+ }
922
+ """)
923
+ submitted = True
924
+ log_step("Submit via form.submit() or JS fallback")
925
+ except Exception as e:
926
+ die(f"Tidak bisa menemukan tombol submit registrasi: {e}")
927
+
928
+ # Wait longer and check if URL changed
929
+ time.sleep(5)
930
+ post_submit_url = page.url
931
+ log_step(f"Post-submit URL: {post_submit_url}")
932
+
933
+ # If still on signup page, try pressing Enter on password field
934
+ if "/sign-up" in post_submit_url or "/register" in post_submit_url:
935
+ log_step("Masih di signup page, coba Enter di password field...")
936
+ try:
937
+ pw_el = page.locator("input[type='password']").first
938
+ if pw_el.is_visible(timeout=2000):
939
+ pw_el.press("Enter")
940
+ time.sleep(5)
941
+ log_step(f"After Enter URL: {page.url}")
942
+ except Exception:
943
+ pass
944
+
945
+ # If STILL on signup page, try dispatching form submit event directly
946
+ if "/sign-up" in page.url or "/register" in page.url:
947
+ log_step("Masih di signup page setelah Enter, coba force submit...")
948
+ try:
949
+ page.evaluate("""
950
+ () => {
951
+ const forms = document.querySelectorAll('form');
952
+ for (const form of forms) {
953
+ // Try native submit
954
+ try { HTMLFormElement.prototype.submit.call(form); } catch(e) {}
955
+ }
956
+ // Try clicking submit button via native click
957
+ const btn = document.querySelector('button[type="submit"]');
958
+ if (btn) {
959
+ HTMLButtonElement.prototype.click.call(btn);
960
+ }
961
+ }
962
+ """)
963
+ time.sleep(5)
964
+ log_step(f"After force submit URL: {page.url}")
965
+ except Exception:
966
+ pass
967
+
968
+ time.sleep(3)
969
+
970
+ # Check for errors (email already registered, etc.)
971
+ # Use JS to get all visible text — catch any wording CF uses
972
+ email_already_registered = False
973
+ try:
974
+ page_text_lower = page.evaluate("document.body.innerText").lower()
975
+ log_step(f"Post-signup page snippet: {page_text_lower[:200]}")
976
+ # Only treat as already-registered if CF explicitly says so
977
+ already_kw = [
978
+ "already registered", "already exists", "already in use",
979
+ "already taken", "account exists", "email exists",
980
+ "sudah terdaftar",
981
+ "email address is already",
982
+ # NOTE: "already have an account?" is NOT here — it's just the
983
+ # normal sign-in link on CF's signup page, not an error message
984
+ ]
985
+ for kw in already_kw:
986
+ if kw in page_text_lower:
987
+ log_step(f"Email sudah terdaftar ({args.email}) — detected: '{kw}'")
988
+ email_already_registered = True
989
+ break
990
+ except Exception as e:
991
+ log_step(f"Post-signup check error: {e}")
992
+
993
+ # Detect success: "check your email" / verify message
994
+ signup_success_verify = False
995
+ try:
996
+ success_kw = ["check your email", "verify your email", "verification email", "link has been sent", "email sent", "confirmation link"]
997
+ if any(kw in page_text_lower for kw in success_kw):
998
+ signup_success_verify = True
999
+ log_step("Signup sukses: CF meminta verifikasi email")
1000
+ except Exception:
1001
+ pass
1002
+
1003
+ # If signup not detected as success, wait longer — CF may still be processing
1004
+ if not signup_success_verify and not email_already_registered:
1005
+ log_step("Signup status unclear — waiting 10s for CF to redirect...")
1006
+ for _sw in range(5):
1007
+ time.sleep(2)
1008
+ _cur_url = page.url
1009
+ # Any URL change away from signup/login = success
1010
+ if 'dash.cloudflare.com/login' not in _cur_url and 'dash.cloudflare.com/sign-up' not in _cur_url and 'dash.cloudflare.com' in _cur_url:
1011
+ log_step(f"CF redirect detected after signup: {_cur_url[:60]}")
1012
+ signup_success_verify = True
1013
+ break
1014
+ # Also re-check body text
1015
+ try:
1016
+ _recheck = page.evaluate("document.body.innerText").lower()
1017
+ if any(kw in _recheck for kw in ["check your email", "verify your email", "verification email"]):
1018
+ signup_success_verify = True
1019
+ log_step("Signup sukses terdeteksi (delayed)")
1020
+ break
1021
+ if any(kw in _recheck for kw in ["already registered", "already exists", "email exists"]):
1022
+ email_already_registered = True
1023
+ log_step("Email sudah terdaftar (delayed detect)")
1024
+ break
1025
+ except Exception:
1026
+ pass
1027
+ if not signup_success_verify and not email_already_registered:
1028
+ log_step(f"Signup state masih unclear setelah wait. URL: {page.url[:80]}")
1029
+
1030
+ if email_already_registered:
1031
+ # Navigate FRESH to /login (don't carry stale security_token from verify link)
1032
+ # Use try/except — CF SPA can abort domcontentloaded with NS_BINDING_ABORTED
1033
+ for _goto_attempt in range(3):
1034
+ try:
1035
+ page.goto("https://dash.cloudflare.com/login",
1036
+ wait_until="domcontentloaded", timeout=30000)
1037
+ break
1038
+ except Exception as _ge:
1039
+ if "NS_BINDING_ABORTED" in str(_ge) or "net::ERR_ABORTED" in str(_ge):
1040
+ log_step(f"Login goto aborted (attempt {_goto_attempt+1}), retry with commit...")
1041
+ try:
1042
+ page.wait_for_load_state("domcontentloaded", timeout=10000)
1043
+ break
1044
+ except Exception:
1045
+ time.sleep(2)
1046
+ else:
1047
+ log_step(f"Login goto error: {_ge}")
1048
+ break
1049
+ time.sleep(3)
1050
+
1051
+
1052
+
1053
+ # ── Step 6: Email verification ────────────────────────────────────────
1054
+ if fsmail_ok and not email_already_registered:
1055
+ verify_link = wait_for_cf_verify_email(
1056
+ args.fsmail_base_url,
1057
+ args.fsmail_api_key,
1058
+ args.email,
1059
+ timeout=240,
1060
+ )
1061
+ if verify_link:
1062
+ log_step(f"Membuka link verifikasi...")
1063
+ try:
1064
+ page.goto(verify_link, wait_until="domcontentloaded", timeout=30000)
1065
+ wait_for_cf_clearance(page, timeout=20)
1066
+ # Wait for CF SPA to execute email verification API call
1067
+ try:
1068
+ page.wait_for_load_state("networkidle", timeout=15000)
1069
+ except Exception:
1070
+ pass
1071
+ time.sleep(5) # extra wait for React verification to complete
1072
+ _vurl = page.url
1073
+ _vbody = ""
1074
+ try:
1075
+ _vbody = page.evaluate("document.body.innerText").lower()[:200]
1076
+ except Exception:
1077
+ pass
1078
+ log_step(f"After verify link — URL: {_vurl[:80]}, body: {_vbody[:150]}")
1079
+ except Exception as e:
1080
+ log_step(f"Warning navigasi verify link: {e}")
1081
+
1082
+ else:
1083
+ log_step("Email verifikasi tidak diterima dalam 2 menit, lanjut coba login...")
1084
+ elif email_already_registered:
1085
+ log_step("Email sudah terdaftar — skip verifikasi, langsung ke login form")
1086
+ else:
1087
+ log_step("Fsmail tidak dikonfigurasi — skip email verification, lanjut login manual...")
1088
+ time.sleep(5)
1089
+
1090
+
1091
+ # ── Step 7: Login if needed ───────────────────────────────────────────
1092
+ # After verify link, CF might already redirect to dashboard
1093
+ _early_account_id = ""
1094
+ _post_verify_url = page.url
1095
+ _m_verify = re.search(r"/(?:home/)?([a-f0-9]{32})(?:/|$)", _post_verify_url)
1096
+ if _m_verify:
1097
+ _early_account_id = _m_verify.group(1)
1098
+ log_step(f"Sudah di dashboard setelah verify! Account ID: {_early_account_id[:8]}...")
1099
+ else:
1100
+ log_step("Login ke Cloudflare Dashboard...")
1101
+ try:
1102
+ for _goto_attempt2 in range(3):
1103
+ try:
1104
+ page.goto("https://dash.cloudflare.com/login",
1105
+ wait_until="domcontentloaded", timeout=20000)
1106
+ break
1107
+ except Exception as _ge2:
1108
+ if "NS_BINDING_ABORTED" in str(_ge2) or "net::ERR_ABORTED" in str(_ge2):
1109
+ log_step(f"Login goto aborted (attempt {_goto_attempt2+1}), wait for load...")
1110
+ try:
1111
+ page.wait_for_load_state("domcontentloaded", timeout=10000)
1112
+ break
1113
+ except Exception:
1114
+ time.sleep(2)
1115
+ else:
1116
+ raise
1117
+ time.sleep(2)
1118
+
1119
+ # Check if already redirected to dashboard
1120
+ _m_redir = re.search(r"/(?:home/)?([a-f0-9]{32})(?:/|$)", page.url)
1121
+ if _m_redir:
1122
+ _early_account_id = _m_redir.group(1)
1123
+ log_step(f"Redirect otomatis ke dashboard: {_early_account_id[:8]}...")
1124
+ else:
1125
+ # Wait for login form
1126
+ try:
1127
+ page.wait_for_selector("input[name='email'], input[autocomplete='email']", timeout=8000)
1128
+ except Exception:
1129
+ log_step("Login form tidak muncul, cek URL...")
1130
+ _m2 = re.search(r"/(?:home/)?([a-f0-9]{32})(?:/|$)", page.url)
1131
+ if _m2:
1132
+ _early_account_id = _m2.group(1)
1133
+
1134
+ if not _early_account_id:
1135
+ # Take screenshot to see login page state
1136
+ page.screenshot(path="/tmp/cf_login_page.png")
1137
+
1138
+ # Screenshot login page state
1139
+ page.screenshot(path="/tmp/cf_login_state.png")
1140
+
1141
+ # Diagnostic: log all inputs on login page
1142
+ try:
1143
+ login_inputs = page.evaluate("""
1144
+ () => Array.from(document.querySelectorAll('input')).map(i => ({
1145
+ type: i.type, name: i.name, id: i.id,
1146
+ autocomplete: i.autocomplete, placeholder: i.placeholder,
1147
+ visible: i.offsetParent !== null
1148
+ }))
1149
+ """)
1150
+ log_step(f"Login page inputs: {login_inputs}")
1151
+ except Exception as e:
1152
+ log_step(f"Login inputs diagnostic: {e}")
1153
+
1154
+ # CF login — 2-step flow (email → Continue → password → Sign in)
1155
+ # Step A: Fill email
1156
+ email_filled = False
1157
+ for sel in [
1158
+ "input[name='email']",
1159
+ "input[type='email']",
1160
+ "input[autocomplete='email']",
1161
+ "input[autocomplete='username']",
1162
+ "input[id*='email' i]",
1163
+ "input[placeholder*='email' i]",
1164
+ "form input:not([type='password']):not([type='hidden']):not([type='checkbox'])",
1165
+ ]:
1166
+ try:
1167
+ el = page.locator(sel).first
1168
+ if el.count() > 0 and el.is_visible(timeout=2000):
1169
+ el.evaluate("""
1170
+ (el, email) => {
1171
+ const nativeSetter = Object.getOwnPropertyDescriptor(
1172
+ window.HTMLInputElement.prototype, 'value'
1173
+ ).set;
1174
+ nativeSetter.call(el, email);
1175
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1176
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1177
+ }
1178
+ """, args.email)
1179
+ email_filled = True
1180
+ log_step(f"Login email filled via: {sel}")
1181
+ break
1182
+ except Exception as ex:
1183
+ log_step(f"Login email try {sel}: {type(ex).__name__}")
1184
+ continue
1185
+
1186
+ if not email_filled:
1187
+ log_step("Email field not found on login page")
1188
+ page.screenshot(path="/tmp/cf_login_noemail.png")
1189
+
1190
+ # Step B: Click Continue/Next (2-step flow) or fill password directly (1-step)
1191
+ # First, try to find a Continue/Next button
1192
+ continue_clicked = False
1193
+ for cont_sel in [
1194
+ "button:has-text('Continue')",
1195
+ "button:has-text('Next')",
1196
+ "button[type='submit']:not(:has-text('Sign')):not(:has-text('Log'))",
1197
+ "input[type='submit']",
1198
+ ]:
1199
+ try:
1200
+ btn = page.locator(cont_sel).first
1201
+ if btn.count() > 0 and btn.is_visible(timeout=1500):
1202
+ # Check if password field is NOT visible (2-step)
1203
+ pw_check = page.locator("input[type='password']")
1204
+ if pw_check.count() == 0 or not pw_check.first.is_visible(timeout=500):
1205
+ btn.click()
1206
+ continue_clicked = True
1207
+ log_step(f"Clicked Continue via: {cont_sel}")
1208
+ time.sleep(3)
1209
+ break
1210
+ except Exception:
1211
+ continue
1212
+
1213
+ # Step C: Fill password (after Continue in 2-step, or directly in 1-step)
1214
+ pw_filled = False
1215
+ def fill_login_pw(el, password, retries=3):
1216
+ """Fill login password with React-compatible injection + verification."""
1217
+ for attempt in range(retries):
1218
+ try:
1219
+ # Method 1: React-compatible JS injection
1220
+ el.evaluate("""
1221
+ (el, pw) => {
1222
+ const nativeSetter = Object.getOwnPropertyDescriptor(
1223
+ window.HTMLInputElement.prototype, 'value'
1224
+ ).set;
1225
+ nativeSetter.call(el, pw);
1226
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1227
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1228
+ el.dispatchEvent(new Event('blur', { bubbles: true }));
1229
+ }
1230
+ """, password)
1231
+ time.sleep(0.3)
1232
+ actual = el.evaluate("el => el.value")
1233
+ if actual == password:
1234
+ log_step(f"Login password set via React injection OK (attempt {attempt+1})")
1235
+ return True
1236
+ log_step(f"React injection mismatch (attempt {attempt+1}): expected {len(password)}, got {len(actual)}")
1237
+
1238
+ # Method 2: type() as fallback
1239
+ el.click()
1240
+ el.press("Control+a")
1241
+ el.press("Backspace")
1242
+ time.sleep(0.1)
1243
+ el.type(password, delay=50)
1244
+ time.sleep(0.5)
1245
+ actual2 = el.evaluate("el => el.value")
1246
+ if actual2 == password:
1247
+ log_step(f"Login password set via type() OK (attempt {attempt+1})")
1248
+ return True
1249
+ log_step(f"type() mismatch (attempt {attempt+1}): expected {len(password)}, got {len(actual2)}")
1250
+ except Exception as e:
1251
+ log_step(f"Fill login pw attempt {attempt+1} error: {e}")
1252
+ return False
1253
+
1254
+ for pw_sel in [
1255
+ "input[type='password']",
1256
+ "input[name='password']",
1257
+ "input[autocomplete='current-password']",
1258
+ "input[autocomplete='new-password']",
1259
+ ]:
1260
+ try:
1261
+ pw_el = page.locator(pw_sel).first
1262
+ if pw_el.count() > 0 and pw_el.is_visible(timeout=5000):
1263
+ if fill_login_pw(pw_el, args.password):
1264
+ pw_filled = True
1265
+ break
1266
+ except Exception:
1267
+ continue
1268
+
1269
+ if not pw_filled:
1270
+ # Maybe email page had no Continue — try submit first then fill password
1271
+ log_step("Password field not visible, trying submit first...")
1272
+ for sel in ["button[type='submit']", "button:has-text('Sign in')", "button:has-text('Log in')", "button:has-text('Continue')", "button:has-text('Next')"]:
1273
+ try:
1274
+ btn = page.locator(sel).first
1275
+ if btn.count() > 0 and btn.is_visible(timeout=1000):
1276
+ btn.click()
1277
+ break
1278
+ except Exception:
1279
+ continue
1280
+ time.sleep(3)
1281
+ # Try password field again
1282
+ for pw_sel in [
1283
+ "input[type='password']",
1284
+ "input[name='password']",
1285
+ ]:
1286
+ try:
1287
+ pw_el = page.locator(pw_sel).first
1288
+ if pw_el.count() > 0 and pw_el.is_visible(timeout=5000):
1289
+ if fill_login_pw(pw_el, args.password):
1290
+ pw_filled = True
1291
+ break
1292
+ except Exception:
1293
+ continue
1294
+ if not pw_filled:
1295
+ log_step("Password field not found")
1296
+ page.screenshot(path="/tmp/cf_login_nopw.png")
1297
+
1298
+ # Solve Turnstile (if any appeared after filling form)
1299
+ wait_for_cf_clearance(page, timeout=3)
1300
+ try_click_turnstile_checkbox(page)
1301
+ time.sleep(1)
1302
+
1303
+ # DEBUG: screenshot after password fill, before submit
1304
+ page.screenshot(path="/tmp/cf_before_submit.png")
1305
+ try:
1306
+ pw_val = page.evaluate("""
1307
+ () => {
1308
+ const pw = document.querySelector('input[type="password"]');
1309
+ return pw ? { len: pw.value.length, type: pw.type, name: pw.name } : 'no pw field';
1310
+ }
1311
+ """)
1312
+ log_step(f"Before submit - PW field: {pw_val}")
1313
+ except Exception:
1314
+ pass
1315
+
1316
+
1317
+ # Check if auto-solved, else try 2Captcha
1318
+ login_turnstile_solved = False
1319
+ try:
1320
+ token_val = page.evaluate("() => { const el = document.getElementsByName('cf_challenge_response')[0]; return el ? el.value : ''; }")
1321
+ if token_val and len(token_val.strip()) > 10:
1322
+ login_turnstile_solved = True
1323
+ log_step("Turnstile login auto-solved!")
1324
+ except Exception:
1325
+ pass
1326
+
1327
+ if not login_turnstile_solved and args.captcha_key:
1328
+ log_step("Solve Turnstile login via 2Captcha...")
1329
+ login_sitekey = get_turnstile_sitekey(page)
1330
+ login_token = solve_turnstile_2captcha(
1331
+ args.captcha_key,
1332
+ "https://dash.cloudflare.com/login",
1333
+ login_sitekey,
1334
+ timeout=150,
1335
+ )
1336
+ if login_token:
1337
+ inject_turnstile_token(page, login_token)
1338
+ login_turnstile_solved = True
1339
+ time.sleep(1)
1340
+ log_step("Turnstile login injected via 2Captcha!")
1341
+
1342
+ # Submit
1343
+ for sel in ["button[type='submit']", "button:has-text('Sign in')", "button:has-text('Log in')"]:
1344
+ try:
1345
+ btn = page.locator(sel).first
1346
+ if btn.is_visible(timeout=1000):
1347
+ btn.click()
1348
+ break
1349
+ except Exception:
1350
+ continue
1351
+
1352
+ log_step("Menunggu redirect ke dashboard...")
1353
+ time.sleep(10)
1354
+ page.screenshot(path="/tmp/cf_after_login_submit.png")
1355
+
1356
+ current_url = page.url
1357
+ log_step(f"After login URL: {current_url}")
1358
+
1359
+ # Debug: dump password field value before checking
1360
+ try:
1361
+ pw_debug = page.evaluate("""
1362
+ () => {
1363
+ const pw = document.querySelector('input[type="password"]');
1364
+ return pw ? { exists: true, value_len: pw.value.length, type: pw.type } : { exists: false };
1365
+ }
1366
+ """)
1367
+ log_step(f"PW field after submit: {pw_debug}")
1368
+ except Exception:
1369
+ pass
1370
+
1371
+ # Check page content for ANY error (not just "login" in URL)
1372
+ try:
1373
+ err_txt = page.evaluate("document.body.innerText")
1374
+ log_step(f"Page text (first 500): {err_txt[:500]}")
1375
+
1376
+ # Detect wrong password
1377
+ login_fail_kw = [
1378
+ "incorrect email or password",
1379
+ "invalid email or password",
1380
+ "wrong password",
1381
+ "email or password is incorrect",
1382
+ "authentication failed",
1383
+ ]
1384
+ if any(kw in err_txt.lower() for kw in login_fail_kw):
1385
+ page.screenshot(path="/tmp/cf_login_wrong_pw.png")
1386
+ die(f"Login gagal: password salah untuk {args.email}. Akun CF ini mungkin sudah ada dengan password berbeda.")
1387
+
1388
+ # Detect other errors (rate limit, captcha, etc)
1389
+ other_errors = [
1390
+ "too many attempts",
1391
+ "try again later",
1392
+ "blocked",
1393
+ "suspended",
1394
+ "captcha",
1395
+ "challenge",
1396
+ ]
1397
+ if any(kw in err_txt.lower() for kw in other_errors):
1398
+ log_step(f"Login blocked by other error: {[kw for kw in other_errors if kw in err_txt.lower()]}")
1399
+ except SystemExit:
1400
+ raise
1401
+ except Exception:
1402
+ pass
1403
+
1404
+ # If still on login page but no explicit error, it might be a captcha/challenge issue
1405
+ if "/login" in current_url or "/challenge" in current_url:
1406
+ page.screenshot(path="/tmp/cf_login_stuck.png")
1407
+ log_step(f"Still on login/challenge page: {current_url}")
1408
+
1409
+ _m_after = re.search(r"(?:home/)?([a-f0-9]{32})(?:/|$)", current_url)
1410
+ if _m_after:
1411
+ _early_account_id = _m_after.group(1)
1412
+ log_step(f"Account ID from login URL: {_early_account_id[:8]}...")
1413
+
1414
+ except SystemExit:
1415
+ raise
1416
+ except Exception as e:
1417
+ log_step(f"Login error: {e}")
1418
+
1419
+ # ── Step 8: Get to dashboard and extract account ID ───────────────────
1420
+ account_id = ""
1421
+
1422
+ # Method 0: from login URL (already captured above)
1423
+ if _early_account_id:
1424
+ account_id = _early_account_id
1425
+ log_step(f"Account ID (from login): {account_id[:8]}...")
1426
+
1427
+ # Method 1: from current page URL — ANY 32-hex in the path (not just /home/)
1428
+ if not account_id:
1429
+ try:
1430
+ for _ in range(5):
1431
+ url_match = re.search(r"/([a-f0-9]{32})(?:/|$)", page.url)
1432
+ if url_match:
1433
+ account_id = url_match.group(1)
1434
+ log_step(f"Account ID from URL: {account_id[:8]}...")
1435
+ break
1436
+ time.sleep(1)
1437
+ except Exception as e:
1438
+ log_step(f"account_id from URL error: {e}")
1439
+
1440
+ # Method 2: CF API /accounts via page.request.fetch
1441
+ if not account_id:
1442
+ try:
1443
+ log_step("Method 2: CF /accounts via page.request.fetch...")
1444
+ api_resp = page.request.fetch(
1445
+ "https://api.cloudflare.com/client/v4/accounts?per_page=50",
1446
+ method="GET",
1447
+ headers={"Accept": "application/json"}
1448
+ )
1449
+ log_step(f"CF /accounts status: {api_resp.status}")
1450
+ if api_resp.status == 200:
1451
+ data = api_resp.json()
1452
+ if data.get("success") and data.get("result"):
1453
+ for acct in data["result"]:
1454
+ if acct.get("id") and len(acct["id"]) == 32:
1455
+ account_id = acct["id"]
1456
+ log_step(f"Account ID via API: {account_id[:8]}...")
1457
+ break
1458
+ else:
1459
+ log_step(f"CF /accounts response: {api_resp.text()[:200]}")
1460
+ except Exception as e:
1461
+ log_step(f"Method 2 error: {e}")
1462
+
1463
+ # Method 3: Extract browser cookies → use Python requests to call CF API
1464
+ if not account_id:
1465
+ try:
1466
+ log_step("Method 3: Extract cookies → Python requests to CF API...")
1467
+ cookies = page.context.cookies()
1468
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
1469
+ if cookie_str:
1470
+ import requests as _req
1471
+ for _retry in range(3):
1472
+ try:
1473
+ r = _req.get(
1474
+ "https://api.cloudflare.com/client/v4/accounts?per_page=50",
1475
+ headers={
1476
+ "Cookie": cookie_str,
1477
+ "Accept": "application/json",
1478
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
1479
+ },
1480
+ timeout=15
1481
+ )
1482
+ log_step(f"Python CF /accounts status: {r.status_code}")
1483
+ if r.status_code == 200:
1484
+ data = r.json()
1485
+ if data.get("success") and data.get("result"):
1486
+ for acct in data["result"]:
1487
+ if acct.get("id") and len(acct["id"]) == 32:
1488
+ account_id = acct["id"]
1489
+ log_step(f"Account ID via Python API: {account_id[:8]}...")
1490
+ break
1491
+ if account_id:
1492
+ break
1493
+ except Exception as e:
1494
+ log_step(f"Python API retry {_retry+1}: {e}")
1495
+ time.sleep(2)
1496
+ else:
1497
+ log_step("Method 3: No cookies found")
1498
+ except Exception as e:
1499
+ log_step(f"Method 3 error: {e}")
1500
+
1501
+ # Method 4: Navigate to /profile/api-tokens → has account_id in URL
1502
+ if not account_id:
1503
+ try:
1504
+ log_step("Method 4: Navigate to /profile/api-tokens...")
1505
+ page.goto("https://dash.cloudflare.com/profile/api-tokens", wait_until="domcontentloaded", timeout=30000)
1506
+ for _ in range(10):
1507
+ time.sleep(1)
1508
+ m = re.search(r"/([a-f0-9]{32})(?:/|$)", page.url)
1509
+ if m:
1510
+ account_id = m.group(1)
1511
+ log_step(f"Account ID from api-tokens URL: {account_id[:8]}...")
1512
+ break
1513
+ if not account_id:
1514
+ log_step(f"api-tokens final URL: {page.url}")
1515
+ except Exception as e:
1516
+ log_step(f"Method 4 error: {e}")
1517
+
1518
+ # Method 5: Navigate to home → wait for redirect with account_id
1519
+ if not account_id:
1520
+ try:
1521
+ log_step("Method 5: Navigate to / → wait for account_id redirect...")
1522
+ page.goto("https://dash.cloudflare.com/", wait_until="domcontentloaded", timeout=30000)
1523
+ for _ in range(15):
1524
+ time.sleep(1)
1525
+ m = re.search(r"/([a-f0-9]{32})(?:/|$)", page.url)
1526
+ if m:
1527
+ account_id = m.group(1)
1528
+ log_step(f"Account ID from / redirect: {account_id[:8]}...")
1529
+ break
1530
+ if not account_id:
1531
+ log_step(f"/ redirect final URL: {page.url}")
1532
+ page.screenshot(path="/tmp/cf_no_account_id.png")
1533
+ except Exception as e:
1534
+ log_step(f"Method 5 error: {e}")
1535
+
1536
+ # Method 6: Extract from JS page state (deep search)
1537
+ if not account_id:
1538
+ try:
1539
+ log_step("Method 6: Deep JS search for account_id...")
1540
+ page.goto("https://dash.cloudflare.com/", wait_until="domcontentloaded", timeout=20000)
1541
+ time.sleep(5)
1542
+ account_id = page.evaluate("""
1543
+ () => {
1544
+ // Deep search window objects
1545
+ const candidates = [
1546
+ window.__INITIAL_STATE__, window.__cf_data__,
1547
+ window.__BOOTSTRAP_DATA__, window.__NEXT_DATA__,
1548
+ window.__APP_STATE__,
1549
+ ];
1550
+ for (const obj of candidates) {
1551
+ if (!obj) continue;
1552
+ const search = (o, depth) => {
1553
+ if (depth > 5 || !o || typeof o !== 'object') return null;
1554
+ for (const [k, v] of Object.entries(o)) {
1555
+ if (k === 'account_id' || k === 'accountId') {
1556
+ if (typeof v === 'string' && /^[a-f0-9]{32}$/.test(v)) return v;
1557
+ }
1558
+ if (typeof v === 'object' && v !== null) {
1559
+ const found = search(v, depth + 1);
1560
+ if (found) return found;
1561
+ }
1562
+ }
1563
+ return null;
1564
+ };
1565
+ const found = search(obj, 0);
1566
+ if (found) return found;
1567
+ }
1568
+ // Check cookies
1569
+ const cookies = document.cookie.split(';');
1570
+ for (const c of cookies) {
1571
+ const m = c.match(/account_id=([a-f0-9]{32})/);
1572
+ if (m) return m[1];
1573
+ }
1574
+ // Check localStorage
1575
+ try {
1576
+ for (let i = 0; i < localStorage.length; i++) {
1577
+ const key = localStorage.key(i);
1578
+ const val = localStorage.getItem(key);
1579
+ if (val) {
1580
+ const m = val.match(/"account_id"\\s*:\\s*"([a-f0-9]{32})"/);
1581
+ if (m) return m[1];
1582
+ }
1583
+ }
1584
+ } catch(e) {}
1585
+ return '';
1586
+ }
1587
+ """)
1588
+ if account_id and len(account_id) == 32:
1589
+ log_step(f"Account ID from JS: {account_id[:8]}...")
1590
+ else:
1591
+ account_id = ""
1592
+ log_step("Method 6: account_id not found")
1593
+ except Exception as e:
1594
+ log_step(f"Method 6 error: {e}")
1595
+
1596
+ # Method 7: CF API /memberships endpoint (different from /accounts — may work when /accounts fails)
1597
+ if not account_id:
1598
+ try:
1599
+ log_step("Method 7: CF /memberships via page.request.fetch...")
1600
+ mem_resp = page.request.fetch(
1601
+ "https://api.cloudflare.com/client/v4/user/memberships?per_page=50",
1602
+ method="GET",
1603
+ headers={"Accept": "application/json"}
1604
+ )
1605
+ log_step(f"CF /memberships status: {mem_resp.status}")
1606
+ if mem_resp.status == 200:
1607
+ mem_data = mem_resp.json()
1608
+ if mem_data.get("success") and mem_data.get("result"):
1609
+ for membership in mem_data["result"]:
1610
+ org = membership.get("organization", {}) or {}
1611
+ org_id = org.get("id", "")
1612
+ # Also check membership.account.id
1613
+ acct = membership.get("account", {}) or {}
1614
+ acct_id = acct.get("id", "")
1615
+ for candidate in [org_id, acct_id]:
1616
+ if candidate and len(candidate) == 32 and re.match(r'^[a-f0-9]{32}$', candidate):
1617
+ account_id = candidate
1618
+ log_step(f"Account ID via memberships: {account_id[:8]}...")
1619
+ break
1620
+ if account_id:
1621
+ break
1622
+ except Exception as e:
1623
+ log_step(f"Method 7 error: {e}")
1624
+
1625
+ # Method 8: CF API /memberships via Python requests with browser cookies
1626
+ if not account_id:
1627
+ try:
1628
+ log_step("Method 8: /memberships via Python requests + cookies...")
1629
+ cookies = page.context.cookies()
1630
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
1631
+ if cookie_str:
1632
+ import requests as _req2
1633
+ for _retry in range(2):
1634
+ try:
1635
+ r = _req2.get(
1636
+ "https://api.cloudflare.com/client/v4/user/memberships?per_page=50",
1637
+ headers={
1638
+ "Cookie": cookie_str,
1639
+ "Accept": "application/json",
1640
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
1641
+ },
1642
+ timeout=15
1643
+ )
1644
+ log_step(f"Python /memberships status: {r.status_code}")
1645
+ if r.status_code == 200:
1646
+ data = r.json()
1647
+ if data.get("success") and data.get("result"):
1648
+ for membership in data["result"]:
1649
+ acct = membership.get("account", {}) or {}
1650
+ acct_id = acct.get("id", "")
1651
+ if acct_id and len(acct_id) == 32 and re.match(r'^[a-f0-9]{32}$', acct_id):
1652
+ account_id = acct_id
1653
+ log_step(f"Account ID via Python memberships: {account_id[:8]}...")
1654
+ break
1655
+ if account_id:
1656
+ break
1657
+ except Exception as e:
1658
+ log_step(f"Python memberships retry {_retry+1}: {e}")
1659
+ time.sleep(2)
1660
+ except Exception as e:
1661
+ log_step(f"Method 8 error: {e}")
1662
+
1663
+ # Method 9: Deep JS scan — search ALL window properties and global state for account_id
1664
+ if not account_id:
1665
+ try:
1666
+ log_step("Method 9: Deep JS scan ALL global objects...")
1667
+ page.goto("https://dash.cloudflare.com/", wait_until="domcontentloaded", timeout=20000)
1668
+ time.sleep(6)
1669
+ account_id = page.evaluate("""
1670
+ () => {
1671
+ // Broader search: look in ALL window properties
1672
+ const hex32 = /^[a-f0-9]{32}$/;
1673
+ // Check common CF state containers
1674
+ const stateNames = [
1675
+ '__INITIAL_STATE__', '__cf_data__', '__BOOTSTRAP_DATA__',
1676
+ '__NEXT_DATA__', '__APP_STATE__', '__cf_context__',
1677
+ 'cfData', 'CFData', '__CF$', 'analytics',
1678
+ ];
1679
+ const deepSearch = (obj, depth, path) => {
1680
+ if (depth > 8 || !obj) return null;
1681
+ if (typeof obj === 'string' && hex32.test(obj) && path.toLowerCase().includes('account')) return obj;
1682
+ if (typeof obj === 'string' && hex32.test(obj)) return obj; // any 32-hex in deep state
1683
+ if (Array.isArray(obj)) {
1684
+ for (let i = 0; i < Math.min(obj.length, 10); i++) {
1685
+ const r = deepSearch(obj[i], depth+1, path+'['+i+']');
1686
+ if (r) return r;
1687
+ }
1688
+ }
1689
+ if (typeof obj === 'object' && obj !== null) {
1690
+ for (const [k, v] of Object.entries(obj)) {
1691
+ if (k === 'account_id' || k === 'accountId' || k === 'account') {
1692
+ if (typeof v === 'string' && hex32.test(v)) return v;
1693
+ if (typeof v === 'object' && v !== null) {
1694
+ if (v.id && hex32.test(v.id)) return v.id;
1695
+ const r = deepSearch(v, depth+1, path+'.'+k);
1696
+ if (r) return r;
1697
+ }
1698
+ }
1699
+ if (typeof v === 'object' && v !== null) {
1700
+ const r = deepSearch(v, depth+1, path+'.'+k);
1701
+ if (r) return r;
1702
+ }
1703
+ }
1704
+ }
1705
+ return null;
1706
+ };
1707
+ for (const name of stateNames) {
1708
+ try {
1709
+ const obj = window[name];
1710
+ if (obj) {
1711
+ const r = deepSearch(obj, 0, name);
1712
+ if (r) return r;
1713
+ }
1714
+ } catch(e) {}
1715
+ }
1716
+ // Also scan all script tags for account_id patterns
1717
+ try {
1718
+ const scripts = Array.from(document.querySelectorAll('script'));
1719
+ for (const s of scripts) {
1720
+ const txt = s.textContent || '';
1721
+ const m = txt.match(/"account_id"\\s*:\\s*"([a-f0-9]{32})"/);
1722
+ if (m) return m[1];
1723
+ const m2 = txt.match(/"accountId"\\s*:\\s*"([a-f0-9]{32})"/);
1724
+ if (m2) return m2[1];
1725
+ }
1726
+ } catch(e) {}
1727
+ return '';
1728
+ }
1729
+ """)
1730
+ if account_id and len(account_id) == 32:
1731
+ log_step(f"Account ID from deep JS scan: {account_id[:8]}...")
1732
+ else:
1733
+ account_id = ""
1734
+ log_step("Method 9: account_id not found")
1735
+ except Exception as e:
1736
+ log_step(f"Method 9 error: {e}")
1737
+
1738
+ # ── Step 9/10: Buat Workers AI Token via Session API ─────────────────
1739
+ global_key = None
1740
+ workers_ai_token = None
1741
+
1742
+ if not account_id:
1743
+ # Last-chance: try /accounts one more time via page.evaluate (sends cookies natively)
1744
+ try:
1745
+ log_step("Last-chance account_id: page.evaluate fetch /accounts...")
1746
+ _lc_result = page.evaluate("""
1747
+ async () => {
1748
+ try {
1749
+ const r = await fetch('https://api.cloudflare.com/client/v4/accounts?per_page=50', {
1750
+ credentials: 'include',
1751
+ headers: {'Accept': 'application/json'}
1752
+ });
1753
+ const d = await r.json();
1754
+ if (d.success && d.result && d.result.length > 0) {
1755
+ return d.result[0].id;
1756
+ }
1757
+ // Also try /memberships
1758
+ const r2 = await fetch('https://api.cloudflare.com/client/v4/user/memberships?per_page=50', {
1759
+ credentials: 'include',
1760
+ headers: {'Accept': 'application/json'}
1761
+ });
1762
+ const d2 = await r2.json();
1763
+ if (d2.success && d2.result) {
1764
+ for (const m of d2.result) {
1765
+ const id = (m.account && m.account.id) || (m.organization && m.organization.id) || '';
1766
+ if (id && /^[a-f0-9]{32}$/.test(id)) return id;
1767
+ }
1768
+ }
1769
+ } catch(e) { return ''; }
1770
+ return '';
1771
+ }
1772
+ """)
1773
+ if _lc_result and len(_lc_result) == 32 and re.match(r'^[a-f0-9]{32}$', _lc_result):
1774
+ account_id = _lc_result
1775
+ log_step(f"Account ID from last-chance fetch: {account_id[:8]}...")
1776
+ except Exception as e:
1777
+ log_step(f"Last-chance account_id error: {e}")
1778
+
1779
+ if not account_id:
1780
+ die("Tidak bisa membuat API Token: account_id tidak ditemukan")
1781
+
1782
+ log_step("Membuat Workers AI API Token...")
1783
+
1784
+ # ── Strategy A: Get Global API Key → create token via CF API ────────────
1785
+ # Capture fsmail vars into local scope for nested function closure
1786
+ _fsmail_base_url = args.fsmail_base_url or ""
1787
+ _fsmail_api_key = args.fsmail_api_key or ""
1788
+
1789
+ def create_token_via_global_key(page):
1790
+ """Navigate to API Keys page, get Global API Key, use CF API to create token."""
1791
+ import requests as _req
1792
+ log_step("Mencoba ambil Global API Key dari dashboard...")
1793
+ try:
1794
+ # Navigate to API keys page — CF React SPA takes time to mount.
1795
+ # Retry navigate + wait up to 3x if still showing loading spinner.
1796
+ for _nav_attempt in range(3):
1797
+ page.goto("https://dash.cloudflare.com/profile/api-tokens", wait_until="domcontentloaded", timeout=25000)
1798
+ # Wait for actual content (not just loading spinner)
1799
+ _page_ready = False
1800
+ for _wait_sel in [
1801
+ "text=Global API Key",
1802
+ "button:has-text('View')",
1803
+ "h1, h2, h3, [role='heading']",
1804
+ ]:
1805
+ try:
1806
+ page.wait_for_selector(_wait_sel, timeout=12000)
1807
+ log_step(f"API tokens page ready via: {_wait_sel}")
1808
+ _page_ready = True
1809
+ break
1810
+ except Exception:
1811
+ continue
1812
+ if _page_ready:
1813
+ break
1814
+ log_step(f"API tokens page not ready (attempt {_nav_attempt+1}), retry...")
1815
+ time.sleep(3)
1816
+
1817
+ time.sleep(2)
1818
+ page.screenshot(path="/tmp/cf_gak_page.png")
1819
+ _pg_txt = page.inner_text("body")
1820
+ _gidx = _pg_txt.find("Global")
1821
+ log_step(f"GAK page: {_pg_txt[_gidx:_gidx+200] if _gidx >= 0 else _pg_txt[:200]}")
1822
+
1823
+ # Scroll to bottom to reveal Global API Key section
1824
+ page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
1825
+ time.sleep(2)
1826
+
1827
+ # Intercept CF API response to capture Global API Key from network
1828
+ _intercepted_key = []
1829
+ _captcha_challenge = {} # captures context=apikey challenge response
1830
+ def _on_gak_response(resp):
1831
+ try:
1832
+ url = resp.url
1833
+ if 'cloudflare.com/api' in url or '/api/v4/' in url:
1834
+ log_step(f"CF API call: {resp.status} {url[-80:]}")
1835
+ # Capture challenge token issued by CF (needed for GAK POST)
1836
+ if 'captcha/challenge' in url and 'context=apikey' in url and resp.status == 200:
1837
+ try:
1838
+ body = resp.json()
1839
+ log_step(f"GAK captcha/challenge body: {str(body)[:400]}")
1840
+ _captcha_challenge.update(body.get('result', {}) or {})
1841
+ except Exception as _ce:
1842
+ log_step(f"captcha/challenge parse error: {_ce}")
1843
+ # Log full body for user/api_key
1844
+ if 'user/api_key' in url:
1845
+ try:
1846
+ body = resp.json()
1847
+ log_step(f"GAK api_key {resp.status} body: {str(body)[:400]}")
1848
+ if resp.status == 200:
1849
+ result = body.get('result', {}) or {}
1850
+ key = (result.get('api_key') or result.get('key') or
1851
+ result.get('value') or result.get('global_key') or '')
1852
+ if key and len(key) > 20:
1853
+ log_step(f"GAK key from api_key 200: {key[:12]}...")
1854
+ _intercepted_key.append(key)
1855
+ except Exception as _re:
1856
+ log_step(f"GAK api_key body error: {_re}")
1857
+ return
1858
+ if resp.status == 200 and ('api_key' in url or 'global_key' in url or
1859
+ 'user/api' in url or 'verify' in url):
1860
+ try:
1861
+ body = resp.json()
1862
+ result = body.get('result', {}) or {}
1863
+ key = (result.get('api_key') or result.get('key') or
1864
+ result.get('value') or result.get('global_key') or '')
1865
+ if not key:
1866
+ for v in (result.values() if isinstance(result, dict) else []):
1867
+ if isinstance(v, str) and len(v) > 30:
1868
+ key = v; break
1869
+ if key and len(key) > 20:
1870
+ log_step(f"GAK intercepted ({url[-40:]}): {key[:12]}...")
1871
+ _intercepted_key.append(key)
1872
+ except Exception:
1873
+ pass
1874
+ except Exception:
1875
+ pass
1876
+ page.on("response", _on_gak_response)
1877
+
1878
+ # Click on "Global API Key" > "View" button
1879
+ view_clicked = False
1880
+ for sel in ["button:has-text('View')", "a:has-text('View')"]:
1881
+ try:
1882
+ b = page.locator(sel).first
1883
+ if b.count() > 0 and b.is_visible(timeout=3000):
1884
+ b.click()
1885
+ time.sleep(2)
1886
+ log_step(f"Clicked View Global API Key via: {sel}")
1887
+ view_clicked = True
1888
+ break
1889
+ except Exception:
1890
+ continue
1891
+ if not view_clicked:
1892
+ _btns = page.evaluate("Array.from(document.querySelectorAll('button')).filter(b=>b.offsetParent).map(b=>b.innerText.trim()).filter(t=>t).slice(0,30)")
1893
+ log_step(f"View not found. Visible buttons: {_btns}")
1894
+
1895
+ # CF shows "Verify Your Identity" modal — click Send Verification Code → enter OTP from email
1896
+ try:
1897
+ send_btn = page.locator("button:has-text('Send Verification Code')").first
1898
+ if send_btn.count() > 0 and send_btn.is_visible(timeout=3000):
1899
+ send_btn.click()
1900
+ time.sleep(2)
1901
+ log_step("Sent verification code for Global API Key")
1902
+
1903
+ # Record existing message IDs before sending to skip stale OTPs
1904
+ seen_msg_ids = set()
1905
+ try:
1906
+ pre_msgs = fsmail_request(_fsmail_base_url, _fsmail_api_key,
1907
+ f"/inboxes/{urllib.parse.quote(args.email.split('@')[0])}/messages")
1908
+ pre_list = pre_msgs.get("messages", []) if isinstance(pre_msgs, dict) else (pre_msgs if isinstance(pre_msgs, list) else [])
1909
+ seen_msg_ids = {str(m.get('id', '')) for m in pre_list}
1910
+ log_step(f"Pre-existing msgs: {len(seen_msg_ids)}")
1911
+ except Exception: pass
1912
+
1913
+ # Poll fsmail for the OTP (36 × 5s = 3 minutes)
1914
+ otp_code = None
1915
+ for _poll_i in range(36):
1916
+ time.sleep(5)
1917
+ log_step(f"OTP poll {_poll_i+1}/36...")
1918
+
1919
+ try:
1920
+ msgs_resp = fsmail_request(_fsmail_base_url, _fsmail_api_key,
1921
+ f"/inboxes/{urllib.parse.quote(args.email.split('@')[0])}/messages")
1922
+ # fsmail_request returns dict {"messages": [...]} not a list directly
1923
+ msgs_list = msgs_resp.get("messages", []) if isinstance(msgs_resp, dict) else (msgs_resp if isinstance(msgs_resp, list) else [])
1924
+ for msg in msgs_list:
1925
+ mid = str(msg.get('id', ''))
1926
+ # Skip pre-existing messages (stale OTPs)
1927
+ if mid in seen_msg_ids:
1928
+ continue
1929
+ if 'cloudflare' in str(msg.get('from', '')).lower() or 'cloudflare' in str(msg.get('subject', '')).lower():
1930
+ import re as _re_otp
1931
+ # Strategy 1: extract code from subject (CF sends "Your Cloudflare login token: NNNNNNN")
1932
+ subj = str(msg.get('subject', ''))
1933
+ subj_m = _re_otp.search(r'token[:\s]+(\d{5,9})', subj, _re_otp.I)
1934
+ if subj_m:
1935
+ otp_code = subj_m.group(1)
1936
+ log_step(f"OTP dari subject: {otp_code}")
1937
+ else:
1938
+ # Strategy 2: fetch body
1939
+ try:
1940
+ full = fsmail_request(_fsmail_base_url, _fsmail_api_key, f"/messages/{urllib.parse.quote(mid)}")
1941
+ msg_body = full.get("message", full) if isinstance(full, dict) else {}
1942
+ body = str(msg_body.get('body','') or msg_body.get('html','') or msg_body.get('text','') or full.get('body','') or msg.get('snippet',''))
1943
+ ctx_m = _re_otp.search(r'(?:token|verify|code)[^\d]{0,30}(\d{5,9})', body, _re_otp.I)
1944
+ if not ctx_m:
1945
+ for bm in _re_otp.finditer(r'(?m)^\s*(\d{5,9})\s*$', body):
1946
+ ctx_m = bm; break
1947
+ if ctx_m:
1948
+ try: otp_code = ctx_m.group(1)
1949
+ except: otp_code = ctx_m.group(0)
1950
+ if otp_code and len(set(otp_code)) > 1:
1951
+ log_step(f"OTP dari body: {otp_code}")
1952
+ except Exception as _be:
1953
+ log_step(f"OTP body error: {_be}")
1954
+ if otp_code:
1955
+ break
1956
+ except Exception as _otp_e:
1957
+ log_step(f"OTP poll error: {_otp_e}")
1958
+ if otp_code:
1959
+ break
1960
+
1961
+
1962
+ if otp_code:
1963
+ # Dismiss consent overlay before looking for OTP input
1964
+ try:
1965
+ page.evaluate("""
1966
+ () => {
1967
+ const ot = document.querySelector('#onetrust-banner-sdk, #onetrust-consent-sdk');
1968
+ if (ot) ot.style.display = 'none';
1969
+ }
1970
+ """)
1971
+ except Exception: pass
1972
+ time.sleep(1)
1973
+ page.screenshot(path="/tmp/cf_otp_modal.png")
1974
+
1975
+ # Try dialog-specific selectors first, then broader
1976
+ otp_input = None
1977
+ for otp_sel in [
1978
+ "[role='dialog'] input[type='text']",
1979
+ "[aria-modal='true'] input[type='text']",
1980
+ "input[autocomplete='one-time-code']",
1981
+ "input[maxlength='6']",
1982
+ "input[placeholder*='code' i]",
1983
+ "input[placeholder*='verification' i]",
1984
+ "input[id*='code' i]",
1985
+ "input[name*='code' i]",
1986
+ "input[name*='otp' i]",
1987
+ # Broader: any visible text input except known cookie ones
1988
+ "input[type='text']:not([name='vendor-search-handler']):not([id='vendor-search-handler'])",
1989
+ ]:
1990
+ try:
1991
+ el = page.locator(otp_sel).first
1992
+ if el.count() > 0 and el.is_visible(timeout=1500):
1993
+ # Sanity check: not the cookie search input
1994
+ el_id = el.get_attribute("id") or ""
1995
+ el_name = el.get_attribute("name") or ""
1996
+ if "vendor" in el_id or "vendor" in el_name:
1997
+ continue
1998
+ otp_input = el
1999
+ log_step(f"OTP input found: {otp_sel} (id={el_id})")
2000
+ break
2001
+ except Exception:
2002
+ continue
2003
+
2004
+ # Last resort: JS — find first visible input in any modal/overlay
2005
+ if not otp_input:
2006
+ js_sel = page.evaluate("""
2007
+ () => {
2008
+ const inputs = Array.from(document.querySelectorAll('input[type="text"]'));
2009
+ const v = inputs.find(i => {
2010
+ if (!i.offsetParent) return false;
2011
+ const id = (i.id||'') + (i.name||'');
2012
+ return !id.includes('vendor') && !id.includes('search');
2013
+ });
2014
+ return v ? (v.id || v.name || v.placeholder || 'found') : null;
2015
+ }
2016
+ """)
2017
+ log_step(f"JS OTP input scan: {js_sel}")
2018
+ if js_sel:
2019
+ try:
2020
+ page.evaluate(f"""
2021
+ () => {{
2022
+ const inputs = Array.from(document.querySelectorAll('input[type="text"]'));
2023
+ const v = inputs.find(i => {{
2024
+ if (!i.offsetParent) return false;
2025
+ const id = (i.id||'') + (i.name||'');
2026
+ return !id.includes('vendor') && !id.includes('search');
2027
+ }});
2028
+ if (v) {{
2029
+ v.focus();
2030
+ v.value = '{otp_code}';
2031
+ v.dispatchEvent(new Event('input', {{bubbles: true}}));
2032
+ v.dispatchEvent(new Event('change', {{bubbles: true}}));
2033
+ }}
2034
+ }}
2035
+ """)
2036
+ log_step(f"OTP filled via JS inject")
2037
+ except Exception as jse:
2038
+ log_step(f"JS OTP fill error: {jse}")
2039
+
2040
+ if otp_input:
2041
+ otp_input.fill(otp_code)
2042
+ time.sleep(0.5)
2043
+ page.screenshot(path="/tmp/cf_otp_filled.png")
2044
+
2045
+ # Solve Turnstile inside the Global API Key modal (if present)
2046
+ # Log ALL frames to diagnose
2047
+ all_frame_urls = [f.url[:80] for f in page.frames if f.url and f.url != 'about:blank']
2048
+ log_step(f"GAK modal frames: {all_frame_urls}")
2049
+
2050
+ # Solve Turnstile in GAK modal
2051
+ time.sleep(4) # Let Turnstile iframe load after OTP filled
2052
+ page.screenshot(path="/tmp/cf_gak_before_ts.png")
2053
+ _ts_clicked = False
2054
+
2055
+ # Solve Turnstile in GAK modal
2056
+ # Strategy: try auto-click first (works in non-headless Camoufox),
2057
+ # then ALWAYS try 2Captcha inject as insurance (token injected
2058
+ # before View is clicked, so CF accepts it either way).
2059
+ _gak_ts_frame = next((f for f in page.frames if 'challenges.cloudflare.com' in (f.url or '')), None)
2060
+ if _gak_ts_frame:
2061
+ log_step("GAK TS: Turnstile frame found")
2062
+ # Step 1: auto-click (non-headless Camoufox auto-solves)
2063
+ _ts_clicked = try_click_turnstile_checkbox(page)
2064
+ log_step(f"GAK TS auto-click result: {_ts_clicked}")
2065
+ time.sleep(5) # wait for potential auto-solve
2066
+ # Step 2: always inject 2Captcha token as well (headless fallback)
2067
+ if args.captcha_key:
2068
+ log_step("GAK TS: injecting 2Captcha token...")
2069
+ try:
2070
+ _gak_sitekey = get_turnstile_sitekey(page)
2071
+ _gak_action = get_turnstile_action(page, default="managed")
2072
+ log_step(f"GAK TS action: {_gak_action}")
2073
+ _gak_ts_tok = solve_turnstile_2captcha(
2074
+ args.captcha_key,
2075
+ "https://dash.cloudflare.com/profile/api-tokens",
2076
+ _gak_sitekey,
2077
+ timeout=120,
2078
+ action=_gak_action,
2079
+ )
2080
+ if _gak_ts_tok:
2081
+ page.evaluate(f"""
2082
+ () => {{
2083
+ const tok = '{_gak_ts_tok}';
2084
+ // Use React native setter trick — plain assignment bypasses React controlled inputs
2085
+ const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
2086
+ for (const name of ['cf-turnstile-response', 'cf_challenge_response']) {{
2087
+ document.getElementsByName(name).forEach(el => {{
2088
+ nativeSetter.call(el, tok);
2089
+ el.dispatchEvent(new Event('input', {{bubbles: true}}));
2090
+ el.dispatchEvent(new Event('change', {{bubbles: true}}));
2091
+ }});
2092
+ }}
2093
+ // Also call Turnstile success callback if registered on widget
2094
+ try {{
2095
+ const widget = document.querySelector('[data-callback]');
2096
+ const cbName = widget && widget.getAttribute('data-callback');
2097
+ if (cbName && window[cbName]) window[cbName](tok);
2098
+ }} catch(e) {{}}
2099
+ // Try direct turnstile API
2100
+ try {{
2101
+ if (window.__cf_chl_opt && window.__cf_chl_opt.cFRq) {{
2102
+ window.__cf_chl_opt.cFRq(tok);
2103
+ }}
2104
+ }} catch(e) {{}}
2105
+ }}
2106
+ """)
2107
+ time.sleep(3)
2108
+ log_step(f"GAK TS: 2Captcha token injected ({_gak_ts_tok[:12]}...)")
2109
+ _ts_clicked = True
2110
+ except Exception as _2ce:
2111
+ log_step(f"GAK TS 2Captcha error: {_2ce}")
2112
+ else:
2113
+ log_step("GAK TS: no Turnstile frame in modal (skip)")
2114
+ _ts_clicked = True # no Turnstile needed
2115
+
2116
+ page.screenshot(path="/tmp/cf_gak_before_submit.png")
2117
+
2118
+ # Click View button INSIDE the modal (not the background View)
2119
+ # Background page has [View] → modal has [View] — use .last to get modal's View
2120
+ # Enumerate all visible View buttons for debugging
2121
+ _view_cnt = page.locator("button:has-text('View')").count()
2122
+ log_step(f"View buttons on page: {_view_cnt}")
2123
+ _clicked_view = False
2124
+ for btn_sel in [
2125
+ "[role='dialog'] button:has-text('View')",
2126
+ "[role='dialog'] button[type='submit']",
2127
+ ]:
2128
+ try:
2129
+ b = page.locator(btn_sel)
2130
+ if b.count() > 0 and b.first.is_visible(timeout=1000):
2131
+ b.first.click()
2132
+ _clicked_view = True
2133
+ log_step(f"OTP submitted via dialog: {btn_sel}")
2134
+ break
2135
+ except Exception:
2136
+ pass
2137
+
2138
+ if not _clicked_view:
2139
+ # Use LAST View button (modal's View comes after background's View in DOM)
2140
+ try:
2141
+ _all_views = page.locator("button:has-text('View')")
2142
+ _cnt = _all_views.count()
2143
+ log_step(f"Trying last View ({_cnt} total)...")
2144
+ if _cnt > 0:
2145
+ _all_views.last.click()
2146
+ _clicked_view = True
2147
+ log_step("OTP submitted via: button.last View")
2148
+ except Exception as _le:
2149
+ log_step(f"Last View err: {_le}")
2150
+
2151
+ if not _clicked_view:
2152
+ try:
2153
+ page.locator("button[type='submit']").last.click()
2154
+ _clicked_view = True
2155
+ log_step("OTP submitted via: button[type=submit].last")
2156
+ except Exception:
2157
+ pass
2158
+
2159
+ if _clicked_view:
2160
+ time.sleep(5)
2161
+ page.screenshot(path="/tmp/cf_gak_after_submit.png")
2162
+ # Log modal state after submit — detect error or success
2163
+ try:
2164
+ _modal_txt = page.locator("[role='dialog']").inner_text(timeout=3000)
2165
+ log_step(f"Modal after View click: {_modal_txt[:300]}")
2166
+ # Check if error shown (invalid code, expired, etc.)
2167
+ _modal_lower = _modal_txt.lower()
2168
+ if any(w in _modal_lower for w in ["invalid", "incorrect", "expired", "error", "failed", "wrong"]):
2169
+ log_step("GAK modal shows error — OTP rejected by CF")
2170
+ except Exception:
2171
+ log_step("Modal closed after View click (good sign)")
2172
+ else:
2173
+ log_step("OTP input not found via any selector")
2174
+
2175
+ except Exception as e:
2176
+ log_step(f"OTP verify step: {e}")
2177
+
2178
+ # CF shows a password confirmation dialog after OTP
2179
+ try:
2180
+ pwd_input = page.locator("input[type='password']").first
2181
+ if pwd_input.is_visible(timeout=3000):
2182
+ pwd_input.evaluate("""
2183
+ (el, pw) => {
2184
+ const nativeSetter = Object.getOwnPropertyDescriptor(
2185
+ window.HTMLInputElement.prototype, 'value'
2186
+ ).set;
2187
+ nativeSetter.call(el, pw);
2188
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2189
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2190
+ }
2191
+ """, args.password)
2192
+ time.sleep(0.5)
2193
+ for btn_sel in ["button:has-text('Continue')", "button[type='submit']", "button:has-text('View')"]:
2194
+ try:
2195
+ b = page.locator(btn_sel).first
2196
+ if b.count() > 0:
2197
+ b.click()
2198
+ time.sleep(2)
2199
+ log_step("Submitted password for Global API Key")
2200
+ break
2201
+ except Exception:
2202
+ continue
2203
+ except Exception:
2204
+ pass
2205
+
2206
+ # Extract Global API Key — poll every 2s for up to 60s
2207
+ # Key appears in input value after modal closes/changes
2208
+ global_key = None
2209
+ import re as _re2
2210
+ _key_regex = r'\b([a-f0-9]{36,45})\b'
2211
+
2212
+ for _gk_poll in range(30):
2213
+ if _gk_poll == 0:
2214
+ # First poll — dump full page content to diagnose where key appears
2215
+ try:
2216
+ _body_dump = page.inner_text("body")
2217
+ log_step(f"GAK body after modal close (first 500): {_body_dump[:500]}")
2218
+ # Also dump all visible text values
2219
+ _all_dom = page.evaluate("""
2220
+ () => {
2221
+ const vals = [];
2222
+ document.querySelectorAll('*').forEach(el => {
2223
+ if (el.children.length === 0) {
2224
+ const t = (el.textContent || el.value || '').trim();
2225
+ if (t.length >= 30 && t.length <= 60 && /^[a-f0-9]+$/.test(t)) {
2226
+ vals.push(el.tagName + ': ' + t);
2227
+ }
2228
+ }
2229
+ });
2230
+ return vals.join(' | ');
2231
+ }
2232
+ """)
2233
+ log_step(f"GAK hex strings in DOM: {_all_dom or 'none'}")
2234
+ page.screenshot(path="/tmp/cf_gak_poll0.png")
2235
+ except Exception as _dump_e:
2236
+ log_step(f"GAK body dump error: {_dump_e}")
2237
+ # Also log all CF API responses captured so far
2238
+ if _intercepted_key:
2239
+ log_step(f"GAK intercepted key at poll 0: {_intercepted_key[0][:12]}...")
2240
+ global_key = _intercepted_key[0]
2241
+ break
2242
+ page.screenshot(path="/tmp/cf_globalkey_page.png")
2243
+ # Check intercepted key each poll
2244
+ if _intercepted_key:
2245
+ global_key = _intercepted_key[0]
2246
+ log_step(f"GAK from network intercept (poll {_gk_poll}): {global_key[:12]}...")
2247
+ break
2248
+ try:
2249
+ # 1. Check ALL input + TEXTAREA values via evaluate
2250
+ _all_vals = page.evaluate("""
2251
+ () => {
2252
+ const vals = [];
2253
+ document.querySelectorAll('input, textarea').forEach(el => {
2254
+ if (el.value && el.value.length > 20) vals.push(el.value);
2255
+ });
2256
+ // Also check text content of specific elements
2257
+ document.querySelectorAll('code, pre, [class*="key"], [class*="token"]').forEach(el => {
2258
+ const t = el.textContent || '';
2259
+ if (t.length > 20) vals.push(t.trim());
2260
+ });
2261
+ return vals.join('|||');
2262
+ }
2263
+ """)
2264
+ if _all_vals:
2265
+ # CF key formats: cfk_XXX (User API Token) OR 40-char hex (Global API Key)
2266
+ _gk_m = _re2.search(r'(cfk_[a-zA-Z0-9]{30,}|[a-f0-9]{36,45})', _all_vals)
2267
+ if _gk_m:
2268
+ global_key = _gk_m.group(1)
2269
+ log_step(f"GAK from input/textarea (poll {_gk_poll}): {global_key[:12]}...")
2270
+ break
2271
+
2272
+ # 2. Check inner text
2273
+ body_text = page.inner_text("body")
2274
+ _gk_m2 = _re2.search(r'(cfk_[a-zA-Z0-9]{30,}|[a-f0-9]{36,45})', body_text)
2275
+ if _gk_m2:
2276
+ global_key = _gk_m2.group(1)
2277
+ log_step(f"GAK from body text (poll {_gk_poll}): {global_key[:12]}...")
2278
+ break
2279
+
2280
+ # 3. Textarea specifically
2281
+ for _sel in ["textarea", "input[readonly]", "code"]:
2282
+ try:
2283
+ _el = page.locator(_sel).first
2284
+ if _el.count() > 0:
2285
+ _v = (_el.input_value() if "input" in _sel or _sel=="textarea" else _el.text_content()) or ""
2286
+ _v = _v.strip()
2287
+ if len(_v) > 20 and ' ' not in _v:
2288
+ _gk_m3 = _re2.search(r'(cfk_[a-zA-Z0-9]{30,}|[a-f0-9]{36,45})', _v)
2289
+ if _gk_m3:
2290
+ global_key = _gk_m3.group(1)
2291
+ log_step(f"GAK from {_sel} (poll {_gk_poll}): {global_key[:12]}...")
2292
+ break
2293
+ except Exception:
2294
+ pass
2295
+ if global_key:
2296
+ break
2297
+
2298
+ log_step(f"GAK poll {_gk_poll}/30 — no key yet")
2299
+ except Exception as _pe:
2300
+ log_step(f"GAK poll error: {_pe}")
2301
+
2302
+ time.sleep(2)
2303
+
2304
+ if not global_key:
2305
+ log_step("Global API Key tidak ditemukan")
2306
+ return None
2307
+
2308
+ # Use Global API Key to create Workers AI token via CF API
2309
+ api_email_header = args.email # email dari outer scope
2310
+ headers = {
2311
+ "X-Auth-Email": api_email_header,
2312
+ "X-Auth-Key": global_key,
2313
+ "Content-Type": "application/json",
2314
+ }
2315
+ base_api = "https://api.cloudflare.com/client/v4"
2316
+
2317
+ # Get Workers AI permission group ID
2318
+ r = _req.get(f"{base_api}/user/tokens/permission_groups", headers=headers, timeout=15)
2319
+ pg_data = r.json()
2320
+ workers_ai_id = None
2321
+ _wa_groups = pg_data.get('result', [])
2322
+ # Prefer exact "Workers AI Read" over "Workers AI Metadata Read"
2323
+ for pg in _wa_groups:
2324
+ nm = pg.get('name', '')
2325
+ if nm in ('Workers AI Read', 'Workers AI Write'):
2326
+ workers_ai_id = pg['id']
2327
+ log_step(f"Workers AI permission group id (exact): {nm} = {workers_ai_id}")
2328
+ break
2329
+ if not workers_ai_id:
2330
+ # fallback: any Workers AI group that is not Metadata
2331
+ for pg in _wa_groups:
2332
+ nm = pg.get('name', '')
2333
+ if 'Workers AI' in nm and 'Metadata' not in nm:
2334
+ workers_ai_id = pg['id']
2335
+ log_step(f"Workers AI permission group id (fallback): {nm} = {workers_ai_id}")
2336
+ break
2337
+
2338
+ if not workers_ai_id:
2339
+ log_step(f"Workers AI group not found. Available: {[p['name'] for p in pg_data.get('result', [])[:10]]}")
2340
+ return None
2341
+
2342
+ # Create the scoped token
2343
+ payload = {
2344
+ "name": "9router-workers-ai",
2345
+ "policies": [{
2346
+ "effect": "allow",
2347
+ "resources": {f"com.cloudflare.api.account.{account_id}": "*"},
2348
+ "permission_groups": [{"id": workers_ai_id}]
2349
+ }]
2350
+ }
2351
+ r2 = _req.post(f"{base_api}/user/tokens", json=payload, headers=headers, timeout=15)
2352
+ resp2 = r2.json()
2353
+ log_step(f"Token create via Global Key: {str(resp2)[:200]}")
2354
+ if resp2.get('success'):
2355
+ return resp2['result'].get('value')
2356
+ except Exception as e:
2357
+ log_step(f"Global API Key approach failed: {e}")
2358
+ return None
2359
+
2360
+ def create_token_via_session(page):
2361
+ """Use same-origin dashboard API proxy (most reliable method).
2362
+ CF dashboard at dash.cloudflare.com proxies /api/v4/... to the CF API
2363
+ with session cookies — no CORS issues, no OTP needed.
2364
+ """
2365
+ log_step("Mencoba buat token via same-origin API proxy...")
2366
+ try:
2367
+ # Verify session auth works first
2368
+ verify = page.evaluate("""
2369
+ async () => {
2370
+ try {
2371
+ const r = await fetch('/api/v4/accounts?per_page=50', {
2372
+ credentials: 'include',
2373
+ headers: {'Accept': 'application/json'}
2374
+ });
2375
+ const d = await r.json();
2376
+ return {status: r.status, ok: d.success, count: (d.result||[]).length};
2377
+ } catch(e) { return {error: e.message}; }
2378
+ }
2379
+ """)
2380
+ log_step(f"Same-origin auth check: {verify}")
2381
+ if not isinstance(verify, dict) or not verify.get('ok'):
2382
+ log_step("Same-origin auth failed — not logged in")
2383
+ return None
2384
+
2385
+ # Step 1: get Workers AI permission group id
2386
+ pg_result = page.evaluate("""
2387
+ async () => {
2388
+ try {
2389
+ const r = await fetch('/api/v4/user/tokens/permission_groups', {
2390
+ credentials: 'include',
2391
+ headers: {'Accept': 'application/json'}
2392
+ });
2393
+ const d = await r.json();
2394
+ return {status: r.status, ok: d.success, groups: d.result || []};
2395
+ } catch(e) { return {error: e.message}; }
2396
+ }
2397
+ """)
2398
+ if not isinstance(pg_result, dict) or not pg_result.get('ok'):
2399
+ log_step(f"permission_groups failed: {pg_result}")
2400
+ return None
2401
+
2402
+ groups = pg_result.get('groups', [])
2403
+ workers_ai_id = next(
2404
+ (g["id"] for g in groups if g.get("name") in ("Workers AI Read", "Workers AI Write")), None
2405
+ )
2406
+ if not workers_ai_id:
2407
+ workers_ai_id = next(
2408
+ (g["id"] for g in groups if "Workers AI" in g.get("name", "") and "Metadata" not in g.get("name", "")), None
2409
+ )
2410
+ if not workers_ai_id:
2411
+ # Hardcoded fallback
2412
+ workers_ai_id = "a92d2450e05d4e7bb7d0a64968f83d11"
2413
+ log_step(f"Using hardcoded Workers AI perm group ID")
2414
+ log_step(f"Workers AI permission group id: {workers_ai_id}")
2415
+
2416
+ # Step 2: create scoped API token via same-origin proxy
2417
+ token_result = page.evaluate("""
2418
+ async (args) => {
2419
+ try {
2420
+ const payload = {
2421
+ name: 'amrouter-workers-ai',
2422
+ policies: [{
2423
+ effect: 'allow',
2424
+ resources: {[`com.cloudflare.api.account.${args.account_id}`]: '*'},
2425
+ permission_groups: [{id: args.perm_id}]
2426
+ }]
2427
+ };
2428
+ const r = await fetch('/api/v4/user/tokens', {
2429
+ method: 'POST',
2430
+ credentials: 'include',
2431
+ headers: {
2432
+ 'Accept': 'application/json',
2433
+ 'Content-Type': 'application/json'
2434
+ },
2435
+ body: JSON.stringify(payload)
2436
+ });
2437
+ const d = await r.json();
2438
+ return {status: r.status, ok: d.success, value: d.result?.value || null, errors: d.errors || []};
2439
+ } catch(e) { return {error: e.message}; }
2440
+ }
2441
+ """, {"account_id": account_id, "perm_id": workers_ai_id})
2442
+ log_step(f"Token create result: {str(token_result)[:300]}")
2443
+ if isinstance(token_result, dict) and token_result.get('ok') and token_result.get('value'):
2444
+ return token_result['value']
2445
+ log_step(f"Token create failed: {token_result.get('errors', token_result.get('error', 'unknown'))}")
2446
+ except Exception as e:
2447
+ log_step(f"Same-origin token exception: {e}")
2448
+ return None
2449
+
2450
+ # Try session API first (fast, no OTP needed)
2451
+ try:
2452
+ workers_ai_token = create_token_via_session(page)
2453
+ if workers_ai_token:
2454
+ log_step(f"Token via session fetch: {workers_ai_token[:12]}...")
2455
+ except Exception as e:
2456
+ log_step(f"Session API token failed: {e}")
2457
+
2458
+ # ── EARLY GAK ATTEMPT: Try Global API Key first if fsmail available ─────
2459
+ # NOTE: Do NOT reset workers_ai_token if create_token_via_session already succeeded
2460
+ if not workers_ai_token:
2461
+ workers_ai_token = None # only reset if still empty
2462
+ token_from_route = [] # always init — used later regardless of GAK success
2463
+ if fsmail_ok:
2464
+ log_step("Mencoba GAK dulu (skip UI form yang sering gagal)...")
2465
+ try:
2466
+ workers_ai_token = create_token_via_global_key(page)
2467
+ if workers_ai_token:
2468
+ log_step(f"Workers AI token via GAK (early): {workers_ai_token[:10]}...")
2469
+ except Exception as _egak_e:
2470
+ log_step(f"Early GAK error: {_egak_e}")
2471
+
2472
+ # ── Strategy B: Browser UI — /profile/api-tokens/create (dropdown form)
2473
+ if not workers_ai_token:
2474
+ log_step("Trying browser UI token creation")
2475
+
2476
+ # Setup route interception BEFORE navigating — capture CF's own token API call
2477
+ def _token_route_handler(route):
2478
+ req = route.request
2479
+ try:
2480
+ resp = route.fetch()
2481
+ if req.method == "POST" and "tokens" in req.url:
2482
+ log_step(f"Route: POST {req.url} → {resp.status}")
2483
+ if resp.status in (200, 201):
2484
+ try:
2485
+ d = resp.json()
2486
+ if d.get("result", {}).get("value"):
2487
+ token_from_route.append(d["result"]["value"])
2488
+ log_step(f"TOKEN via route: {d['result']['value'][:10]}...")
2489
+ except Exception:
2490
+ pass
2491
+ route.fulfill(response=resp)
2492
+ except Exception as route_err:
2493
+ try: route.continue_()
2494
+ except Exception: pass
2495
+ try:
2496
+ page.route("**/user/tokens**", _token_route_handler)
2497
+ except Exception as route_err:
2498
+ log_step(f"Route setup: {re}")
2499
+
2500
+ for create_url in [
2501
+ "https://dash.cloudflare.com/profile/api-tokens/create",
2502
+ f"https://dash.cloudflare.com/{account_id}/api-tokens/create",
2503
+ ]:
2504
+ try:
2505
+ page.goto(create_url, wait_until="domcontentloaded", timeout=25000)
2506
+ wait_for_cf_clearance(page, timeout=15)
2507
+ time.sleep(4)
2508
+ current = page.url
2509
+ log_step(f"Create token URL: {current}")
2510
+ if "api-tokens/create" not in current:
2511
+ log_step("Redirected away, try next...")
2512
+ continue
2513
+ break
2514
+ except Exception as e:
2515
+ log_step(f"Nav error: {e}")
2516
+ continue
2517
+
2518
+ # Method 4: navigate to /accounts and parse
2519
+ if not account_id:
2520
+ try:
2521
+ page.goto("https://dash.cloudflare.com/?to=/:account/home", wait_until="domcontentloaded", timeout=15000)
2522
+ time.sleep(3)
2523
+ url_match = re.search(r"/(?:home/)?([a-f0-9]{32})(?:/|$)", page.url)
2524
+ if url_match:
2525
+ account_id = url_match.group(1)
2526
+ log_step(f"Account ID via redirect: {account_id[:8]}...")
2527
+ except Exception as e:
2528
+ log_step(f"account_id method4 error: {e}")
2529
+
2530
+ # Method 4b: /memberships API (different endpoint, may work when /accounts fails)
2531
+ if not account_id:
2532
+ try:
2533
+ log_step("Method 4b: /memberships via page.request.fetch...")
2534
+ _mem_resp = page.request.fetch(
2535
+ "https://api.cloudflare.com/client/v4/user/memberships?per_page=50",
2536
+ method="GET",
2537
+ headers={"Accept": "application/json"}
2538
+ )
2539
+ if _mem_resp.status == 200:
2540
+ _mem_data = _mem_resp.json()
2541
+ if _mem_data.get("success") and _mem_data.get("result"):
2542
+ for _m in _mem_data["result"]:
2543
+ _acct = _m.get("account", {}) or {}
2544
+ _acct_id = _acct.get("id", "")
2545
+ if _acct_id and len(_acct_id) == 32 and re.match(r'^[a-f0-9]{32}$', _acct_id):
2546
+ account_id = _acct_id
2547
+ log_step(f"Account ID via memberships (4b): {account_id[:8]}...")
2548
+ break
2549
+ except Exception as e:
2550
+ log_step(f"Method 4b error: {e}")
2551
+
2552
+ # Method 4c: page.evaluate fetch (native cookies, no CORS)
2553
+ if not account_id:
2554
+ try:
2555
+ log_step("Method 4c: page.evaluate fetch /accounts + /memberships...")
2556
+ _ev_id = page.evaluate("""
2557
+ async () => {
2558
+ const hex32 = /^[a-f0-9]{32}$/;
2559
+ try {
2560
+ const r = await fetch('https://api.cloudflare.com/client/v4/accounts?per_page=50', {
2561
+ credentials: 'include',
2562
+ headers: {'Accept': 'application/json'}
2563
+ });
2564
+ const d = await r.json();
2565
+ if (d.success && d.result) {
2566
+ for (const a of d.result) {
2567
+ if (a.id && hex32.test(a.id)) return a.id;
2568
+ }
2569
+ }
2570
+ } catch(e) {}
2571
+ try {
2572
+ const r2 = await fetch('https://api.cloudflare.com/client/v4/user/memberships?per_page=50', {
2573
+ credentials: 'include',
2574
+ headers: {'Accept': 'application/json'}
2575
+ });
2576
+ const d2 = await r2.json();
2577
+ if (d2.success && d2.result) {
2578
+ for (const m of d2.result) {
2579
+ const id = (m.account && m.account.id) || '';
2580
+ if (id && hex32.test(id)) return id;
2581
+ }
2582
+ }
2583
+ } catch(e) {}
2584
+ return '';
2585
+ }
2586
+ """)
2587
+ if _ev_id and len(_ev_id) == 32 and re.match(r'^[a-f0-9]{32}$', _ev_id):
2588
+ account_id = _ev_id
2589
+ log_step(f"Account ID via evaluate (4c): {account_id[:8]}...")
2590
+ except Exception as e:
2591
+ log_step(f"Method 4c error: {e}")
2592
+
2593
+ if account_id:
2594
+ log_step(f"Account ID confirmed: {account_id[:8]}...")
2595
+ else:
2596
+ log_step("WARN: Account ID tidak ditemukan, lanjut tanpa account_id")
2597
+
2598
+ # ── Step 9: Skip Global API Key (needs OTP) — buat Account API Token langsung ──
2599
+ global_key = None
2600
+ if not workers_ai_token: # don't reset if GAK already succeeded!
2601
+ workers_ai_token = None
2602
+
2603
+ if not account_id:
2604
+ die("Tidak bisa membuat API Token: account_id tidak ditemukan")
2605
+
2606
+ # ── Step 10: Create Workers AI Token — proper CF UI flow ──────────────
2607
+ log_step("Membuat Workers AI API Token via browser...")
2608
+ try:
2609
+ # Helper: dismiss any OneTrust / GDPR cookie consent dialogs
2610
+ def dismiss_consent_dialogs(page):
2611
+ """Dismiss OneTrust, cookie consent, GDPR popups that block the page."""
2612
+ dismissed = False
2613
+ for sel in [
2614
+ "button#onetrust-accept-btn-handler",
2615
+ "button#accept-recommended-btn-handler",
2616
+ "#onetrust-accept-btn-handler",
2617
+ "button:has-text('Accept all')",
2618
+ "button:has-text('Accept All')",
2619
+ "button:has-text('Accept All Cookies')",
2620
+ "button:has-text('I Accept')",
2621
+ "button:has-text('Accept')",
2622
+ "button:has-text('Agree')",
2623
+ "button:has-text('Confirm')",
2624
+ "button:has-text('Save Preferences')",
2625
+ "[id*='accept'][id*='cookie']",
2626
+ ".ot-sdk-btn-floating",
2627
+ "[class*='onetrust'] button[class*='accept']",
2628
+ "[class*='onetrust'] button[class*='confirm']",
2629
+ ]:
2630
+ try:
2631
+ el = page.locator(sel).first
2632
+ if el.count() > 0 and el.is_visible(timeout=800):
2633
+ el.click()
2634
+ log_step(f"Dismissed consent via: {sel}")
2635
+ time.sleep(0.5)
2636
+ dismissed = True
2637
+ break
2638
+ except Exception:
2639
+ continue
2640
+ # Also try JS dismiss as backup (covers any modal/overlay)
2641
+ if not dismissed:
2642
+ try:
2643
+ result = page.evaluate("""
2644
+ () => {
2645
+ // Try standard OneTrust dismiss
2646
+ const btns = Array.from(document.querySelectorAll('button'));
2647
+ for (const btn of btns) {
2648
+ const txt = btn.textContent.trim().toLowerCase();
2649
+ if (txt === 'accept all' || txt === 'accept all cookies' ||
2650
+ txt === 'i accept' || txt === 'save preferences' ||
2651
+ btn.id === 'onetrust-accept-btn-handler') {
2652
+ btn.click();
2653
+ return 'JS dismissed: ' + btn.textContent.trim();
2654
+ }
2655
+ }
2656
+ // Hide OneTrust overlay if present
2657
+ const ot = document.querySelector('#onetrust-consent-sdk, .onetrust-pc-dark-filter');
2658
+ if (ot) { ot.style.display = 'none'; return 'hidden onetrust overlay'; }
2659
+ return 'no consent dialog found';
2660
+ }
2661
+ """)
2662
+ if "dismissed" in result or "hidden" in result:
2663
+ log_step(f"Consent JS: {result}")
2664
+ except Exception:
2665
+ pass
2666
+
2667
+ # 1. Navigate to profile/api-tokens (not account-specific)
2668
+ page.goto("https://dash.cloudflare.com/profile/api-tokens", wait_until="domcontentloaded", timeout=25000)
2669
+ wait_for_cf_clearance(page, timeout=15)
2670
+ time.sleep(3)
2671
+ dismiss_consent_dialogs(page)
2672
+ log_step(f"API Tokens page: {page.url}")
2673
+ page.screenshot(path="/tmp/cf_tokens_page.png")
2674
+
2675
+ # 2. Click "Create Token" button → wait for template page to render
2676
+ for btn_sel in ["button:has-text('Create Token')", "a:has-text('Create Token')"]:
2677
+ try:
2678
+ b = page.locator(btn_sel).first
2679
+ if b.count() > 0 and b.is_visible(timeout=3000):
2680
+ b.click()
2681
+ log_step(f"Clicked Create Token via: {btn_sel}")
2682
+ break
2683
+ except Exception:
2684
+ continue
2685
+
2686
+ # Wait for template page content (React routing — URL stays same)
2687
+ # OneTrust GDPR consent dialog can appear AFTER navigating to template page
2688
+ # — retry dismiss up to 3x while waiting for template buttons
2689
+ workers_ai_template_used = False
2690
+ template_page_ready = False
2691
+ for _wait_attempt in range(3):
2692
+ try:
2693
+ page.wait_for_selector("button:has-text('Use template')", timeout=5000)
2694
+ template_page_ready = True
2695
+ log_step(f"Template page ready (attempt {_wait_attempt+1})")
2696
+ break
2697
+ except Exception:
2698
+ log_step(f"Template wait timeout attempt {_wait_attempt+1} — dismissing consent")
2699
+ dismiss_consent_dialogs(page)
2700
+ time.sleep(2)
2701
+
2702
+ dismiss_consent_dialogs(page)
2703
+ page.screenshot(path="/tmp/cf_create_token_page.png")
2704
+ log_step(f"After Create Token click: {page.url}")
2705
+
2706
+ try:
2707
+ # Find the "Workers AI" template row and click its "Use template" button
2708
+ # Structure: <tr> or <div> containing "Workers AI" text + "Use template" button
2709
+ wa_row = page.locator("tr:has-text('Workers AI'), li:has-text('Workers AI'), [class*='row']:has-text('Workers AI')").first
2710
+ if wa_row.count() > 0 and wa_row.is_visible(timeout=3000):
2711
+ use_btn = wa_row.locator("button:has-text('Use template'), a:has-text('Use template')")
2712
+ if use_btn.count() > 0 and use_btn.is_visible(timeout=2000):
2713
+ use_btn.click()
2714
+ time.sleep(3)
2715
+ log_step("Workers AI template clicked via row")
2716
+ workers_ai_template_used = True
2717
+ except Exception as e:
2718
+ log_step(f"Template row approach: {e}")
2719
+
2720
+ # Fallback: find "Use template" button next to "Workers AI" text using JS
2721
+ if not workers_ai_template_used:
2722
+ try:
2723
+ # Get all "Use template" buttons and find the one near "Workers AI" text
2724
+ use_btns = page.locator("button:has-text('Use template')").all()
2725
+ log_step(f"Found {len(use_btns)} Use template buttons")
2726
+ # Workers AI is typically the 5th template (index 4)
2727
+ # Find by evaluating each button's nearby text
2728
+ result = page.evaluate("""
2729
+ () => {
2730
+ const btns = Array.from(document.querySelectorAll('button'));
2731
+ const useTemplateBtns = btns.filter(b => b.textContent.trim() === 'Use template');
2732
+ for (const btn of useTemplateBtns) {
2733
+ // Check if the parent row/section contains "Workers AI"
2734
+ let el = btn.parentElement;
2735
+ for (let i = 0; i < 5; i++) {
2736
+ if (el && el.textContent.includes('Workers AI') && !el.textContent.includes('Cloudflare Workers')) {
2737
+ btn.click();
2738
+ return 'clicked Workers AI template: ' + el.textContent.substring(0, 50);
2739
+ }
2740
+ el = el ? el.parentElement : null;
2741
+ }
2742
+ }
2743
+ return 'Workers AI template button not found';
2744
+ }
2745
+ """)
2746
+ log_step(f"JS template click: {result}")
2747
+ if "clicked" in result:
2748
+ workers_ai_template_used = True
2749
+ time.sleep(3)
2750
+ except Exception as e:
2751
+ log_step(f"JS template fallback: {e}")
2752
+
2753
+ if workers_ai_template_used:
2754
+ # Workers AI template pre-fills the form — just rename the token and submit
2755
+ log_step(f"Template form URL: {page.url}")
2756
+ page.screenshot(path="/tmp/cf_template_form.png")
2757
+
2758
+ # Rename token from default to "9router-workers-ai"
2759
+ try:
2760
+ for name_sel in ["input[name*='name' i]", "input[placeholder*='name' i]", "input[type='text']:first-of-type"]:
2761
+ try:
2762
+ el = page.locator(name_sel).first
2763
+ if el.count() > 0 and el.is_visible(timeout=2000):
2764
+ el.click(click_count=3)
2765
+ el.fill("9router-workers-ai")
2766
+ log_step("Token name renamed: 9router-workers-ai")
2767
+ break
2768
+ except Exception:
2769
+ continue
2770
+ except Exception as e:
2771
+ log_step(f"Rename token: {e}")
2772
+
2773
+ workers_ai_permission_set = True # template already has Workers AI + Read
2774
+ else:
2775
+ # Fallback to custom token form
2776
+ log_step("Template not found, trying custom token form")
2777
+ # 3. Click "Get started" for Custom Token
2778
+ for sel in ["button:has-text('Get started')", "a:has-text('Get started')"]:
2779
+ try:
2780
+ b = page.locator(sel).first
2781
+ if b.count() > 0 and b.is_visible(timeout=3000):
2782
+ b.click()
2783
+ time.sleep(2)
2784
+ log_step(f"Clicked Get started via: {sel}")
2785
+ break
2786
+ except Exception:
2787
+ continue
2788
+
2789
+ time.sleep(2)
2790
+ page.screenshot(path="/tmp/cf_custom_token_form.png")
2791
+
2792
+ # 4. Fill Token name
2793
+ for name_sel in ["input[placeholder*='name' i]", "input[name*='name' i]", "input[aria-label*='name' i]", "input:first-of-type"]:
2794
+ try:
2795
+ el = page.locator(name_sel).first
2796
+ if el.count() > 0 and el.is_visible(timeout=2000):
2797
+ el.click()
2798
+ el.fill("9router-workers-ai")
2799
+ time.sleep(0.5)
2800
+ log_step("Token name filled: 9router-workers-ai")
2801
+ break
2802
+ except Exception:
2803
+ continue
2804
+
2805
+ # 5. Select Workers AI permission
2806
+ try:
2807
+ page.wait_for_selector("input[aria-autocomplete]", timeout=8000)
2808
+ time.sleep(1)
2809
+ log_step("React form loaded, searching dropdowns")
2810
+ except Exception as e:
2811
+ log_step(f"Wait for form timeout: {e}")
2812
+ time.sleep(2)
2813
+
2814
+ workers_ai_permission_set = False
2815
+
2816
+ # Find all select-like elements
2817
+ try:
2818
+ perm_dropdowns = page.locator("select, [role='combobox'], [role='listbox']").all()
2819
+ log_step(f"Found {len(perm_dropdowns)} dropdowns")
2820
+ for sel in ["input[aria-autocomplete]", "[class*='select'] input", "[placeholder*='Select' i]"]:
2821
+ try:
2822
+ els = page.locator(sel).all()
2823
+ for el in els:
2824
+ if el.is_visible():
2825
+ el.click()
2826
+ time.sleep(0.5)
2827
+ el.fill("Workers AI")
2828
+ time.sleep(1)
2829
+ wa_opt = page.locator("text=Workers AI").first
2830
+ if wa_opt.count() > 0 and wa_opt.is_visible(timeout=2000):
2831
+ wa_opt.click()
2832
+ time.sleep(0.5)
2833
+ log_step(f"Workers AI selected via: {sel}")
2834
+ workers_ai_permission_set = True
2835
+ break
2836
+ if workers_ai_permission_set:
2837
+ break
2838
+ except Exception:
2839
+ continue
2840
+ except Exception as e:
2841
+ log_step(f"Workers AI dropdown: {e}")
2842
+
2843
+ # Strategy B: use keyboard Tab to navigate to permission select, type Workers AI
2844
+ if not workers_ai_permission_set:
2845
+ try:
2846
+ # Find native <select> elements
2847
+ selects = page.locator("select").all()
2848
+ log_step(f"Native selects: {len(selects)}")
2849
+ for i, sel_el in enumerate(selects):
2850
+ try:
2851
+ opts = sel_el.evaluate("el => Array.from(el.options).map(o => o.text)")
2852
+ log_step(f"Select {i} options: {opts[:5]}")
2853
+ if any('Workers AI' in o for o in opts):
2854
+ sel_el.select_option(label="Workers AI")
2855
+ time.sleep(0.5)
2856
+ log_step(f"Workers AI selected via native select {i}")
2857
+ workers_ai_permission_set = True
2858
+ break
2859
+ except Exception:
2860
+ continue
2861
+ except Exception as e:
2862
+ log_step(f"Strategy B selects: {e}")
2863
+
2864
+ # Strategy C: JS evaluate — find the select with Workers AI and set it
2865
+ if not workers_ai_permission_set:
2866
+ try:
2867
+ result = page.evaluate("""
2868
+ () => {
2869
+ // Find all select elements
2870
+ const selects = Array.from(document.querySelectorAll('select'));
2871
+ for (const sel of selects) {
2872
+ const opts = Array.from(sel.options);
2873
+ const waOpt = opts.find(o => o.text.trim() === 'Workers AI');
2874
+ if (waOpt) {
2875
+ sel.value = waOpt.value;
2876
+ sel.dispatchEvent(new Event('change', {bubbles: true}));
2877
+ return 'Workers AI set on select: ' + (sel.name || sel.id || 'unnamed');
2878
+ }
2879
+ }
2880
+ return 'Workers AI option not found in any select';
2881
+ }
2882
+ """)
2883
+ log_step(f"JS select: {result}")
2884
+ if 'Workers AI set' in str(result):
2885
+ workers_ai_permission_set = True
2886
+ time.sleep(0.5)
2887
+ except Exception as e:
2888
+ log_step(f"Strategy C JS: {e}")
2889
+
2890
+ page.screenshot(path="/tmp/cf_after_perm_select.png")
2891
+ log_step(f"After permission selection (set={workers_ai_permission_set})")
2892
+ time.sleep(1)
2893
+
2894
+ # 5b. Select "Edit" — ONLY for custom form, NOT template
2895
+ # Template already has Workers AI:Read; adding again = duplicate permission = validation fail
2896
+ read_set = workers_ai_template_used # template = already done
2897
+ if workers_ai_template_used:
2898
+ log_step("Template used — skip Read/Edit dropdown (Workers AI:Read already set)")
2899
+ else:
2900
+ time.sleep(0.5)
2901
+
2902
+ # Strategy A: JS — find all React-Select containers, click the one showing "Select..."
2903
+ try:
2904
+ result = page.evaluate("""
2905
+ () => {
2906
+ // Find all elements with placeholder "Select..."
2907
+ const all = Array.from(document.querySelectorAll('*'));
2908
+ for (const el of all) {
2909
+ if (el.children.length === 0 && el.textContent.trim() === 'Select...') {
2910
+ el.click();
2911
+ return 'clicked placeholder: ' + el.tagName + ' ' + el.className;
2912
+ }
2913
+ }
2914
+ return 'placeholder not found';
2915
+ }
2916
+ """)
2917
+ log_step(f"JS click Select...: {result}")
2918
+ time.sleep(1)
2919
+ # Now look for Edit or Read option in dropdown
2920
+ for perm_label in ["Edit", "Read"]:
2921
+ for read_sel in [f"text='{perm_label}'", f"[role='option']:has-text('{perm_label}')", f"li:has-text('{perm_label}')"]:
2922
+ try:
2923
+ r = page.locator(read_sel).first
2924
+ if r.count() > 0 and r.is_visible(timeout=1500):
2925
+ r.click()
2926
+ time.sleep(0.5)
2927
+ log_step(f"{perm_label} selected via: {read_sel}")
2928
+ read_set = True
2929
+ break
2930
+ except Exception:
2931
+ continue
2932
+ if read_set:
2933
+ break
2934
+ except Exception as e:
2935
+ log_step(f"Strategy A JS click: {e}")
2936
+
2937
+ # Strategy B: bounding box — the Select... is to the right of Workers AI row
2938
+ if not read_set:
2939
+ try:
2940
+ # Find the Workers AI input in the permissions row
2941
+ wa_inputs = page.locator("input[aria-autocomplete]").all()
2942
+ for wa_inp in wa_inputs:
2943
+ try:
2944
+ if "Workers AI" in (wa_inp.input_value() or ""):
2945
+ wa_box = wa_inp.bounding_box()
2946
+ if wa_box:
2947
+ # The Select... dropdown is to the right
2948
+ select_x = wa_box["x"] + wa_box["width"] + 200
2949
+ select_y = wa_box["y"] + wa_box["height"] / 2
2950
+ page.mouse.click(select_x, select_y)
2951
+ time.sleep(1)
2952
+ log_step(f"Positional click Select... at ({select_x:.0f},{select_y:.0f})")
2953
+ page.screenshot(path="/tmp/cf_after_select_click.png")
2954
+ for perm_label in ["Edit", "Read"]:
2955
+ for read_sel in [f"text='{perm_label}'", f"[role='option']:has-text('{perm_label}')"]:
2956
+ try:
2957
+ r = page.locator(read_sel).first
2958
+ if r.count() > 0 and r.is_visible(timeout=1500):
2959
+ r.click()
2960
+ time.sleep(0.5)
2961
+ log_step(f"{perm_label} selected (positional)")
2962
+ read_set = True
2963
+ break
2964
+ except Exception:
2965
+ continue
2966
+ if read_set:
2967
+ break
2968
+ break
2969
+ except Exception:
2970
+ continue
2971
+ except Exception as e:
2972
+ log_step(f"Strategy B positional: {e}")
2973
+
2974
+ # Strategy C: keyboard Tab navigation
2975
+ if not read_set:
2976
+ try:
2977
+ page.keyboard.press("Tab")
2978
+ time.sleep(0.5)
2979
+ page.keyboard.press("Tab")
2980
+ time.sleep(0.5)
2981
+ page.keyboard.press("Enter")
2982
+ time.sleep(0.8)
2983
+ # Try arrow down to navigate options
2984
+ page.keyboard.press("ArrowDown")
2985
+ time.sleep(0.3)
2986
+ page.keyboard.press("Enter")
2987
+ time.sleep(0.5)
2988
+ log_step("Read/Edit via Tab+Enter keyboard")
2989
+ read_set = True
2990
+ except Exception as e:
2991
+ log_step(f"Strategy C keyboard: {e}")
2992
+
2993
+ log_step(f"Read access level set: {read_set}")
2994
+ page.screenshot(path="/tmp/cf_after_read_select.png")
2995
+
2996
+ # Log all form inputs/selects for debugging
2997
+ try:
2998
+ form_state = page.evaluate("""
2999
+ () => {
3000
+ const inputs = Array.from(document.querySelectorAll('input, select, textarea'));
3001
+ return inputs.map(el => ({
3002
+ tag: el.tagName, type: el.type, name: el.name,
3003
+ value: el.value, placeholder: el.placeholder
3004
+ })).filter(el => el.value || el.placeholder);
3005
+ }
3006
+ """)
3007
+ log_step(f"Form state: {str(form_state)[:500]}")
3008
+ except Exception:
3009
+ pass
3010
+
3011
+ # 6. Fill Account Resources then click "Continue to summary"
3012
+ time.sleep(1)
3013
+ page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
3014
+ time.sleep(1)
3015
+
3016
+ # Account Resources — [Include ▼][Select... ▼] — REQUIRED (red error if empty)
3017
+ # "Select..." is a React Select — click .react-select__control or .react-select__dropdown-indicator
3018
+ try:
3019
+ ar_opened = page.evaluate("""
3020
+ () => {
3021
+ // Find react-select__control that has "Select..." placeholder (= Account Resources)
3022
+ const ctrls = Array.from(document.querySelectorAll('[class*="react-select__control"]'));
3023
+ for (const ctrl of ctrls) {
3024
+ const ph = ctrl.querySelector('[class*="react-select__placeholder"]');
3025
+ if (ph && ph.textContent.trim() === 'Select...') {
3026
+ // Click the dropdown indicator (the ▼ arrow)
3027
+ const ind = ctrl.querySelector('[class*="react-select__dropdown-indicator"]');
3028
+ if (ind) { ind.click(); return 'indicator clicked'; }
3029
+ ctrl.click();
3030
+ return 'control clicked';
3031
+ }
3032
+ }
3033
+ return 'no Select... control found';
3034
+ }
3035
+ """)
3036
+ log_step(f"Account Resources React Select: {ar_opened}")
3037
+
3038
+ if "clicked" in ar_opened:
3039
+ # Take screenshot to see dropdown state
3040
+ page.screenshot(path="/tmp/cf_ar_dropdown.png")
3041
+ time.sleep(2) # Wait for async option load
3042
+
3043
+ # Step 1: Check options WITHOUT typing (initial load)
3044
+ opts_initial = page.evaluate("""
3045
+ () => {
3046
+ const opts = Array.from(document.querySelectorAll('[class*="react-select__option"], [class*="option"]'));
3047
+ const menu = document.querySelector('[class*="react-select__menu"]');
3048
+ const menuHtml = menu ? menu.innerHTML.substring(0, 300) : 'NO MENU';
3049
+ return {
3050
+ opts: opts.filter(o => o.offsetParent !== null).map(o => o.textContent.trim()),
3051
+ menuHtml: menuHtml
3052
+ };
3053
+ }
3054
+ """)
3055
+ log_step(f"AR initial options: {opts_initial.get('opts', [])} | menu: {opts_initial.get('menuHtml','')[:100]}")
3056
+
3057
+ # Take screenshot to see what menu looks like
3058
+ page.screenshot(path="/tmp/cf_ar_menu.png")
3059
+
3060
+ ar_selected = False
3061
+ if opts_initial.get('opts'):
3062
+ first = page.locator("[class*='react-select__option']").first
3063
+ if first.count() > 0 and first.is_visible(timeout=1000):
3064
+ txt = first.text_content() or "?"
3065
+ first.click()
3066
+ time.sleep(0.5)
3067
+ log_step(f"Account Resources selected (initial): {txt[:60]}")
3068
+ ar_selected = True
3069
+
3070
+ # Step 2: If still empty, try typing "all"
3071
+ if not ar_selected:
3072
+ page.keyboard.type("all", delay=80)
3073
+ time.sleep(2)
3074
+ opts_all = page.evaluate("""() => {
3075
+ const opts = Array.from(document.querySelectorAll('[class*="react-select__option"]'));
3076
+ return opts.filter(o => o.offsetParent !== null).map(o => o.textContent.trim());
3077
+ }""")
3078
+ log_step(f"AR after 'all': {opts_all}")
3079
+ if opts_all:
3080
+ page.locator("[class*='react-select__option']").first.click()
3081
+ ar_selected = True
3082
+ log_step(f"AR selected after 'all': {opts_all[0][:50]}")
3083
+
3084
+ # Step 3: Try account_id prefix
3085
+ if not ar_selected:
3086
+ page.keyboard.press("Control+a")
3087
+ page.keyboard.type(account_id[:8], delay=80)
3088
+ time.sleep(2)
3089
+ opts_id = page.evaluate("""() => {
3090
+ const opts = Array.from(document.querySelectorAll('[class*="react-select__option"]'));
3091
+ return opts.filter(o => o.offsetParent !== null).map(o => o.textContent.trim());
3092
+ }""")
3093
+ log_step(f"AR after acct_id: {opts_id}")
3094
+ if opts_id:
3095
+ page.locator("[class*='react-select__option']").first.click()
3096
+ ar_selected = True
3097
+ log_step(f"AR selected after acct_id: {opts_id[0][:50]}")
3098
+
3099
+ if not ar_selected:
3100
+ page.keyboard.press("Escape")
3101
+ log_step(f"Account Resources: no options found — trying React inject")
3102
+
3103
+ # React fiber inject: walk up fiber tree to find onChange handler
3104
+ inject_result = page.evaluate(f"""
3105
+ () => {{
3106
+ const ctrls = Array.from(document.querySelectorAll('[class*="react-select__control"]'));
3107
+ const arCtrl = ctrls.find(c => {{
3108
+ const ph = c.querySelector('[class*="react-select__placeholder"]');
3109
+ return ph && ph.textContent.trim() === 'Select...';
3110
+ }});
3111
+ if (!arCtrl) return 'No Select... control';
3112
+ const input = arCtrl.querySelector('input');
3113
+ if (!input) return 'No input';
3114
+ const fk = Object.keys(input).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance') || k.startsWith('__reactProps'));
3115
+ if (!fk) {{
3116
+ const allKeys = Object.keys(input).filter(k=>k.startsWith('__')).join(',');
3117
+ return 'No fiber. React keys: ' + allKeys;
3118
+ }}
3119
+ let fiber = input[fk];
3120
+ for (let i = 0; i < 25; i++) {{
3121
+ if (!fiber || !fiber.return) break;
3122
+ fiber = fiber.return;
3123
+ const p = fiber.memoizedProps;
3124
+ if (p && typeof p.onChange === 'function') {{
3125
+ try {{
3126
+ p.onChange(
3127
+ {{value: '{account_id}', label: 'My Account'}},
3128
+ {{action: 'select-option'}}
3129
+ );
3130
+ return 'React onChange called OK';
3131
+ }} catch(e) {{ return 'onChange error: ' + e.message; }}
3132
+ }}
3133
+ }}
3134
+ return 'onChange not found';
3135
+ }}
3136
+ """)
3137
+ log_step(f"Account Resources React inject: {inject_result}")
3138
+ time.sleep(1)
3139
+
3140
+ except Exception as e:
3141
+ log_step(f"Account Resources error: {e}")
3142
+
3143
+ page.screenshot(path="/tmp/cf_before_continue.png")
3144
+
3145
+ def _is_summary_page():
3146
+ """CF uses React SPA — URL never changes. Detect summary by content.
3147
+ IMPORTANT: use 'token will affect' only — NOT 'summary' which matches
3148
+ the 'Continue to summary' BUTTON TEXT on the form page (false positive)."""
3149
+ try:
3150
+ txt = page.inner_text("body")
3151
+ # "token will affect" only appears on the actual summary page
3152
+ # "Workers AI API token summary" also works
3153
+ return ("token will affect" in txt or "API token summary" in txt)
3154
+ except Exception:
3155
+ return False
3156
+
3157
+ continue_clicked = False # always try clicking Continue first
3158
+ if not continue_clicked:
3159
+ for sel in [
3160
+ "button:has-text('Continue to summary')",
3161
+ "input[value*='Continue']",
3162
+ "button:has-text('Continue')",
3163
+ "button:has-text('Review')",
3164
+ "button[type='submit']",
3165
+ ]:
3166
+ try:
3167
+ loc = page.locator(sel).first
3168
+ if loc.count() > 0 and loc.is_visible(timeout=3000):
3169
+ loc.scroll_into_view_if_needed()
3170
+ time.sleep(0.3)
3171
+ bbox = loc.bounding_box()
3172
+ if bbox:
3173
+ page.mouse.move(bbox['x'] + bbox['width']/2, bbox['y'] + bbox['height']/2)
3174
+ time.sleep(0.2)
3175
+ page.mouse.click(bbox['x'] + bbox['width']/2, bbox['y'] + bbox['height']/2)
3176
+ log_step(f"Mouse.click Continue via: {sel}")
3177
+ else:
3178
+ loc.click()
3179
+ time.sleep(3)
3180
+ page.screenshot(path="/tmp/cf_after_continue.png")
3181
+ if _is_summary_page():
3182
+ log_step("Summary page detected (React routing)")
3183
+ continue_clicked = True
3184
+ break
3185
+ log_step(f"'{sel}' clicked, not on summary yet")
3186
+ try:
3187
+ err = page.evaluate("Array.from(document.querySelectorAll('[class*=error],[class*=alert],[role=alert]')).map(e=>e.innerText).join(' ')")
3188
+ if err:
3189
+ log_step(f"Form error: {err[:200]}")
3190
+ except Exception:
3191
+ pass
3192
+ except Exception as e:
3193
+ log_step(f"Continue '{sel}' failed: {e}")
3194
+ continue
3195
+
3196
+ log_step(f"Continue to summary: {continue_clicked}")
3197
+
3198
+ # 7a. If "Continue to summary" failed, try CF API via browser session cookies
3199
+ # This bypasses ALL form UI issues — uses browser session (cf_clearance + cookies)
3200
+ if not continue_clicked:
3201
+ log_step("Continue failed — trying CF API via browser session (page.evaluate fetch)")
3202
+ try:
3203
+ # The permission group IDs are hardcoded from CF's workers-ai template:
3204
+ # a92d2450e05d4e7bb7d0a64968f83d11 = Workers AI Read
3205
+ # bacc64e0f6c34fc0883a1223f938a104 = Workers AI Edit
3206
+ # account_id is available from earlier login step
3207
+ # page.request.fetch uses browser cookies (avoids CORS — runs outside browser JS)
3208
+ import json as _json
3209
+ api_payload = _json.dumps({
3210
+ "name": "Workers AI",
3211
+ "policies": [{
3212
+ "effect": "allow",
3213
+ "resources": {
3214
+ f"com.cloudflare.api.account.{account_id}": "*"
3215
+ },
3216
+ "permission_groups": [
3217
+ {"id": "a92d2450e05d4e7bb7d0a64968f83d11"},
3218
+ {"id": "bacc64e0f6c34fc0883a1223f938a104"}
3219
+ ]
3220
+ }]
3221
+ })
3222
+ api_resp = page.request.fetch(
3223
+ "https://api.cloudflare.com/client/v4/user/tokens",
3224
+ method="POST",
3225
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
3226
+ data=api_payload
3227
+ )
3228
+ log_step(f"CF API /user/tokens status: {api_resp.status}")
3229
+ if api_resp.status in (200, 201):
3230
+ api_data = api_resp.json()
3231
+ if api_data.get("success") and api_data.get("result", {}).get("value"):
3232
+ api_token = api_data["result"]["value"]
3233
+ log_step(f"CF API token created: {api_token[:10]}...")
3234
+ output_result({"status": "ok", "email": args.email, "api_key": api_token, "account_id": account_id})
3235
+ sys.exit(0)
3236
+ else:
3237
+ log_step(f"CF API token create failed: {api_data.get('errors', 'unknown')}")
3238
+ else:
3239
+ body_text = api_resp.text()[:300]
3240
+ log_step(f"CF API HTTP {api_resp.status}: {body_text}")
3241
+ except Exception as e:
3242
+ log_step(f"CF API fallback error: {e}")
3243
+
3244
+ # 7. On summary page, click "Create Token"
3245
+ time.sleep(2)
3246
+ page.screenshot(path="/tmp/cf_summary_page.png")
3247
+ for sel in ["button:has-text('Create Token')", "input[value*='Create Token']", "button[type='submit']"]:
3248
+ try:
3249
+ b = page.locator(sel).first
3250
+ if b.count() > 0 and b.is_visible(timeout=5000):
3251
+ b.scroll_into_view_if_needed()
3252
+ time.sleep(0.3)
3253
+ b.click()
3254
+ time.sleep(5)
3255
+ log_step(f"Create Token clicked via: {sel}")
3256
+ break
3257
+ except Exception:
3258
+ continue
3259
+
3260
+ # 8. Extract token from result page
3261
+ page.screenshot(path="/tmp/cf_token_result.png")
3262
+ log_step("Screenshot token result saved")
3263
+
3264
+ # CF token result page shows token in a dashed-border div as plain text
3265
+ # Also check <code>, <input readonly>, etc.
3266
+ # Try cfut_ pattern directly first from page body (most reliable)
3267
+ try:
3268
+ body_text = page.inner_text("body")
3269
+ import re as _re_tok
3270
+ cfut_m = _re_tok.search(r'\b(cfut_[A-Za-z0-9_\-]{30,})\b', body_text)
3271
+ if cfut_m and not workers_ai_token:
3272
+ workers_ai_token = cfut_m.group(1)
3273
+ log_step(f"Token dari body regex: {workers_ai_token[:12]}...")
3274
+ except Exception as _e:
3275
+ log_step(f"Body token regex: {_e}")
3276
+
3277
+ # Fallback: try specific selectors
3278
+ if not workers_ai_token:
3279
+ for sel in ["code", "input[readonly]", "input[type='text'][readonly]",
3280
+ "[data-testid='token-value']", ".cf-input-code",
3281
+ "input[class*='token']", "input[class*='code']", "input[class*='api']"]:
3282
+ try:
3283
+ el = page.locator(sel).first
3284
+ if el.is_visible(timeout=2000):
3285
+ val = el.input_value() if "input" in sel else el.text_content()
3286
+ val = (val or "").strip()
3287
+ if val and len(val) > 10 and ' ' not in val:
3288
+ workers_ai_token = val
3289
+ log_step(f"Token dari selector {sel}: {val[:12]}...")
3290
+ break
3291
+ except Exception:
3292
+ continue
3293
+
3294
+ # Fallback: extract token-like string from body (cfp_ or similar)
3295
+ if not workers_ai_token:
3296
+ try:
3297
+ body = page.inner_text("body")
3298
+ import re as _re
3299
+ # CF tokens start with cfut_ or similar
3300
+ for pattern in [r'\b(cfut_[A-Za-z0-9_\-]{30,})\b', r'\b([A-Za-z0-9_\-]{40,})\b']:
3301
+ tok_match = _re.search(pattern, body)
3302
+ if tok_match:
3303
+ workers_ai_token = tok_match.group(1)
3304
+ log_step(f"Token dari body: {workers_ai_token[:12]}...")
3305
+ break
3306
+ except Exception:
3307
+ pass
3308
+
3309
+ except Exception as e:
3310
+ log_step(f"Token creation error: {e}")
3311
+ try:
3312
+ page.screenshot(path="/tmp/cf_create_token_err.png")
3313
+ except Exception:
3314
+ pass
3315
+
3316
+
3317
+ # Final API key to save
3318
+ if not workers_ai_token and token_from_route:
3319
+ workers_ai_token = token_from_route[0]
3320
+ log_step(f"Token from route: {workers_ai_token[:10]}...")
3321
+
3322
+ # Strategy A: Global API Key from dashboard UI (last resort — needs OTP from email)
3323
+ if not workers_ai_token and fsmail_ok:
3324
+ log_step("Fallback: mencoba Global API Key dari dashboard UI...")
3325
+ try:
3326
+ global_key_token = create_token_via_global_key(page)
3327
+ if global_key_token:
3328
+ workers_ai_token = global_key_token
3329
+ log_step(f"Workers AI token via Global Key: {workers_ai_token[:10]}...")
3330
+ except Exception as gke:
3331
+ log_step(f"Global API Key fallback error: {gke}")
3332
+
3333
+ # ── Strategy C: Dashboard same-origin API proxy ──────────────────────
3334
+ # CF dashboard proxies /api/v4/... to api.cloudflare.com with session cookies
3335
+ # This is SAME-ORIGIN so no CORS issues — the most reliable method
3336
+ if not workers_ai_token and account_id:
3337
+ log_step("Strategy C: Dashboard same-origin API proxy...")
3338
+ try:
3339
+ # First verify session auth works
3340
+ verify_result = page.evaluate("""
3341
+ async () => {
3342
+ try {
3343
+ const r = await fetch('/api/v4/accounts?per_page=50', {
3344
+ credentials: 'include',
3345
+ headers: {'Accept': 'application/json'}
3346
+ });
3347
+ const d = await r.json();
3348
+ return {status: r.status, ok: d.success, count: (d.result||[]).length,
3349
+ first_id: (d.result||[])[0]?.id || ''};
3350
+ } catch(e) { return {error: e.message}; }
3351
+ }
3352
+ """)
3353
+ log_step(f"Strategy C verify: {verify_result}")
3354
+
3355
+ if isinstance(verify_result, dict) and verify_result.get('ok'):
3356
+ # Get permission groups
3357
+ pg_result = page.evaluate("""
3358
+ async () => {
3359
+ try {
3360
+ const r = await fetch('/api/v4/user/tokens/permission_groups', {
3361
+ credentials: 'include',
3362
+ headers: {'Accept': 'application/json'}
3363
+ });
3364
+ const d = await r.json();
3365
+ return {status: r.status, ok: d.success, groups: d.result || []};
3366
+ } catch(e) { return {error: e.message}; }
3367
+ }
3368
+ """)
3369
+ log_step(f"Strategy C perm groups: status={pg_result.get('status') if isinstance(pg_result, dict) else 'err'}")
3370
+
3371
+ wa_read_id = None
3372
+ wa_write_id = None
3373
+ if isinstance(pg_result, dict) and pg_result.get('ok'):
3374
+ for g in pg_result.get('groups', []):
3375
+ gname = g.get('name', '')
3376
+ if gname == 'Workers AI Read':
3377
+ wa_read_id = g['id']
3378
+ elif gname == 'Workers AI Write':
3379
+ wa_write_id = g['id']
3380
+ if not wa_read_id:
3381
+ wa_read_id = next(
3382
+ (g['id'] for g in pg_result.get('groups', [])
3383
+ if 'Workers AI' in g.get('name', '') and 'Metadata' not in g.get('name', '')),
3384
+ None
3385
+ )
3386
+
3387
+ if not wa_read_id:
3388
+ wa_read_id = "a92d2450e05d4e7bb7d0a64968f83d11"
3389
+ wa_write_id = "bacc64e0f6c34fc0883a1223f938a104"
3390
+ log_step(f"Strategy C: Using hardcoded perm group IDs")
3391
+
3392
+ if wa_read_id:
3393
+ log_step(f"Strategy C: Workers AI perm group: {wa_read_id}")
3394
+ # Create token via same-origin proxy
3395
+ perm_groups = [{"id": wa_read_id}]
3396
+ if wa_write_id:
3397
+ perm_groups.append({"id": wa_write_id})
3398
+
3399
+ token_result = page.evaluate("""
3400
+ async (args) => {
3401
+ try {
3402
+ const payload = {
3403
+ name: 'amrouter-workers-ai',
3404
+ policies: [{
3405
+ effect: 'allow',
3406
+ resources: {[`com.cloudflare.api.account.${args.account_id}`]: '*'},
3407
+ permission_groups: args.perm_groups
3408
+ }]
3409
+ };
3410
+ const r = await fetch('/api/v4/user/tokens', {
3411
+ method: 'POST',
3412
+ credentials: 'include',
3413
+ headers: {
3414
+ 'Accept': 'application/json',
3415
+ 'Content-Type': 'application/json'
3416
+ },
3417
+ body: JSON.stringify(payload)
3418
+ });
3419
+ const d = await r.json();
3420
+ return {
3421
+ status: r.status,
3422
+ ok: d.success,
3423
+ value: d.result?.value || null,
3424
+ errors: d.errors || []
3425
+ };
3426
+ } catch(e) { return {error: e.message}; }
3427
+ }
3428
+ """, {"account_id": account_id, "perm_groups": perm_groups})
3429
+ log_step(f"Strategy C token result: status={token_result.get('status') if isinstance(token_result, dict) else 'err'}")
3430
+
3431
+ if isinstance(token_result, dict) and token_result.get('ok') and token_result.get('value'):
3432
+ workers_ai_token = token_result['value']
3433
+ log_step(f"Strategy C token created: {workers_ai_token[:15]}...")
3434
+ elif isinstance(token_result, dict):
3435
+ log_step(f"Strategy C token failed: {token_result.get('errors', token_result.get('error', 'unknown'))}")
3436
+ else:
3437
+ log_step(f"Strategy C: Session auth failed: {verify_result}")
3438
+ except Exception as sce:
3439
+ log_step(f"Strategy C error: {sce}")
3440
+
3441
+ # ── Strategy D: CF API direct with session cookies (Python requests) ──────
3442
+ # Bypasses browser UI entirely — uses browser session cookies to auth with CF API
3443
+ if not workers_ai_token and account_id:
3444
+ log_step("Strategy D: CF API direct via session cookies (Python requests)...")
3445
+ try:
3446
+ import requests as _req_d
3447
+ cookies = page.context.cookies()
3448
+ cookie_str = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
3449
+ if cookie_str:
3450
+ # Step 1: Get Workers AI permission group IDs
3451
+ pg_resp = _req_d.get(
3452
+ "https://api.cloudflare.com/client/v4/user/tokens/permission_groups",
3453
+ headers={
3454
+ "Cookie": cookie_str,
3455
+ "Accept": "application/json",
3456
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
3457
+ "Referer": "https://dash.cloudflare.com/",
3458
+ },
3459
+ timeout=20
3460
+ )
3461
+ log_step(f"Strategy D permission_groups status: {pg_resp.status_code}")
3462
+ wa_read_id = None
3463
+ wa_write_id = None
3464
+ if pg_resp.status_code == 200:
3465
+ pg_data = pg_resp.json()
3466
+ groups = pg_data.get("result") or []
3467
+ for g in groups:
3468
+ gname = g.get("name", "")
3469
+ if gname == "Workers AI Read":
3470
+ wa_read_id = g["id"]
3471
+ elif gname == "Workers AI Write":
3472
+ wa_write_id = g["id"]
3473
+ if not wa_read_id:
3474
+ wa_read_id = next(
3475
+ (g["id"] for g in groups
3476
+ if "Workers AI" in g.get("name", "") and "Metadata" not in g.get("name", "")),
3477
+ None
3478
+ )
3479
+ if wa_read_id:
3480
+ log_step(f"Strategy D: Workers AI perm group: {wa_read_id}")
3481
+ else:
3482
+ # Fallback: use known hardcoded permission group IDs
3483
+ wa_read_id = "a92d2450e05d4e7bb7d0a64968f83d11"
3484
+ wa_write_id = "bacc64e0f6c34fc0883a1223f938a104"
3485
+ log_step(f"Strategy D: Using hardcoded perm group IDs")
3486
+
3487
+ # Step 2: Create token via CF API
3488
+ perm_groups = [{"id": wa_read_id}]
3489
+ if wa_write_id:
3490
+ perm_groups.append({"id": wa_write_id})
3491
+ token_payload = {
3492
+ "name": "9router-workers-ai",
3493
+ "policies": [{
3494
+ "effect": "allow",
3495
+ "resources": {f"com.cloudflare.api.account.{account_id}": "*"},
3496
+ "permission_groups": perm_groups,
3497
+ }],
3498
+ }
3499
+ tok_resp = _req_d.post(
3500
+ "https://api.cloudflare.com/client/v4/user/tokens",
3501
+ json=token_payload,
3502
+ headers={
3503
+ "Cookie": cookie_str,
3504
+ "Content-Type": "application/json",
3505
+ "Accept": "application/json",
3506
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
3507
+ "Referer": "https://dash.cloudflare.com/profile/api-tokens",
3508
+ "Origin": "https://dash.cloudflare.com",
3509
+ },
3510
+ timeout=20
3511
+ )
3512
+ log_step(f"Strategy D token create status: {tok_resp.status_code}")
3513
+ if tok_resp.status_code in (200, 201):
3514
+ tok_data = tok_resp.json()
3515
+ if tok_data.get("success") and tok_data.get("result", {}).get("value"):
3516
+ workers_ai_token = tok_data["result"]["value"]
3517
+ log_step(f"Strategy D token created: {workers_ai_token[:10]}...")
3518
+ else:
3519
+ log_step(f"Strategy D token failed: {tok_data.get('errors', 'unknown')}")
3520
+ else:
3521
+ log_step(f"Strategy D HTTP error: {tok_resp.text[:300]}")
3522
+ else:
3523
+ log_step("Strategy D: No cookies available")
3524
+ except Exception as sde:
3525
+ log_step(f"Strategy D error: {sde}")
3526
+
3527
+ # ── Strategy E: UI form final fallback — navigate to create-token page directly ──
3528
+ if not workers_ai_token:
3529
+ log_step("Strategy E: UI form final fallback — /profile/api-tokens/create-token...")
3530
+ try:
3531
+ # Navigate directly to the token creation page
3532
+ for _e_url in [
3533
+ "https://dash.cloudflare.com/profile/api-tokens/create-token",
3534
+ "https://dash.cloudflare.com/profile/api-tokens/create",
3535
+ f"https://dash.cloudflare.com/{account_id}/api-tokens/create-token" if account_id else "",
3536
+ f"https://dash.cloudflare.com/{account_id}/api-tokens/create" if account_id else "",
3537
+ ]:
3538
+ if not _e_url:
3539
+ continue
3540
+ try:
3541
+ page.goto(_e_url, wait_until="domcontentloaded", timeout=20000)
3542
+ time.sleep(3)
3543
+ _cur = page.url
3544
+ log_step(f"Strategy E nav: {_cur}")
3545
+ if "api-tokens" in _cur and "create" in _cur:
3546
+ break
3547
+ except Exception:
3548
+ continue
3549
+
3550
+ # Dismiss any consent dialogs
3551
+ try:
3552
+ dismiss_consent_dialogs(page)
3553
+ except Exception:
3554
+ pass
3555
+ time.sleep(2)
3556
+ page.screenshot(path="/tmp/cf_strategy_e_page.png")
3557
+
3558
+ # Try "Edit Cloudflare Workers" template first (simpler than Workers AI)
3559
+ _e_template_clicked = False
3560
+ for _tmpl_name in ["Workers AI", "Edit Cloudflare Workers", "Read Cloudflare Workers"]:
3561
+ if _e_template_clicked:
3562
+ break
3563
+ try:
3564
+ _tmpl_result = page.evaluate(f"""
3565
+ () => {{
3566
+ const btns = Array.from(document.querySelectorAll('button, a'));
3567
+ for (const btn of btns) {{
3568
+ if (btn.textContent.trim() === 'Use template') {{
3569
+ let el = btn.parentElement;
3570
+ for (let i = 0; i < 6; i++) {{
3571
+ if (el && el.textContent.includes('{_tmpl_name}')) {{
3572
+ btn.click();
3573
+ return 'clicked ' + '{_tmpl_name}';
3574
+ }}
3575
+ el = el ? el.parentElement : null;
3576
+ }}
3577
+ }}
3578
+ }}
3579
+ return 'template not found: ' + '{_tmpl_name}';
3580
+ }}
3581
+ """)
3582
+ log_step(f"Strategy E template ({_tmpl_name}): {_tmpl_result}")
3583
+ if "clicked" in str(_tmpl_result):
3584
+ _e_template_clicked = True
3585
+ time.sleep(3)
3586
+ except Exception:
3587
+ continue
3588
+
3589
+ if _e_template_clicked:
3590
+ # Template pre-fills the form — rename and continue
3591
+ try:
3592
+ for name_sel in ["input[name*='name' i]", "input[placeholder*='name' i]", "input[type='text']:first-of-type"]:
3593
+ try:
3594
+ el = page.locator(name_sel).first
3595
+ if el.count() > 0 and el.is_visible(timeout=2000):
3596
+ el.click(click_count=3)
3597
+ el.fill("9router-workers-ai")
3598
+ break
3599
+ except Exception:
3600
+ continue
3601
+ except Exception:
3602
+ pass
3603
+
3604
+ # Click Continue to summary
3605
+ time.sleep(1)
3606
+ page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
3607
+ time.sleep(1)
3608
+ for cont_sel in ["button:has-text('Continue to summary')", "button:has-text('Continue')", "button:has-text('Review')"]:
3609
+ try:
3610
+ cb = page.locator(cont_sel).first
3611
+ if cb.count() > 0 and cb.is_visible(timeout=3000):
3612
+ cb.click()
3613
+ time.sleep(3)
3614
+ log_step(f"Strategy E continue: {cont_sel}")
3615
+ break
3616
+ except Exception:
3617
+ continue
3618
+
3619
+ # Click Create Token on summary page
3620
+ time.sleep(2)
3621
+ page.screenshot(path="/tmp/cf_strategy_e_summary.png")
3622
+ for ct_sel in ["button:has-text('Create Token')", "input[value*='Create Token']", "button[type='submit']"]:
3623
+ try:
3624
+ ct = page.locator(ct_sel).first
3625
+ if ct.count() > 0 and ct.is_visible(timeout=5000):
3626
+ ct.click()
3627
+ time.sleep(5)
3628
+ log_step(f"Strategy E create token: {ct_sel}")
3629
+ break
3630
+ except Exception:
3631
+ continue
3632
+
3633
+ # Extract token from result page
3634
+ try:
3635
+ _e_body = page.inner_text("body")
3636
+ import re as _re_e
3637
+ _e_cfut = _re_e.search(r'\b(cfut_[A-Za-z0-9_\-]{30,})\b', _e_body)
3638
+ if _e_cfut:
3639
+ workers_ai_token = _e_cfut.group(1)
3640
+ log_step(f"Strategy E token from body: {workers_ai_token[:12]}...")
3641
+ else:
3642
+ # Try broader pattern
3643
+ _e_tok = _re_e.search(r'\b([a-zA-Z0-9_\-]{40,})\b', _e_body)
3644
+ if _e_tok and len(_e_tok.group(1)) > 30:
3645
+ workers_ai_token = _e_tok.group(1)
3646
+ log_step(f"Strategy E token (broad): {workers_ai_token[:12]}...")
3647
+ except Exception:
3648
+ pass
3649
+
3650
+ # Also try selector-based extraction
3651
+ if not workers_ai_token:
3652
+ for tok_sel in ["code", "input[readonly]", "[data-testid='token-value']", ".cf-input-code"]:
3653
+ try:
3654
+ tel = page.locator(tok_sel).first
3655
+ if tel.is_visible(timeout=2000):
3656
+ tval = (tel.input_value() if "input" in tok_sel else tel.text_content()) or ""
3657
+ tval = tval.strip()
3658
+ if tval and len(tval) > 10 and ' ' not in tval:
3659
+ workers_ai_token = tval
3660
+ log_step(f"Strategy E token from selector: {tval[:12]}...")
3661
+ break
3662
+ except Exception:
3663
+ continue
3664
+
3665
+ # Intercept via route as well
3666
+ if not workers_ai_token:
3667
+ try:
3668
+ page.screenshot(path="/tmp/cf_strategy_e_result.png")
3669
+ log_step("Strategy E: no token found from page content")
3670
+ except Exception:
3671
+ pass
3672
+
3673
+ except Exception as see:
3674
+ log_step(f"Strategy E error: {see}")
3675
+ try:
3676
+ page.screenshot(path="/tmp/cf_strategy_e_err.png")
3677
+ except Exception:
3678
+ pass
3679
+
3680
+ if not workers_ai_token:
3681
+ die("Tidak ada API key yang bisa digunakan")
3682
+
3683
+
3684
+ log_step("Selesai! Menyimpan kredensial ke 9router...")
3685
+ success(workers_ai_token, account_id, args.email)
3686
+
3687
+
3688
+ if __name__ == "__main__":
3689
+ main()