@fudrouter/fsrouter 0.6.79 → 0.6.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/open-sse/providers/registry/nous-research.js +1 -0
- package/dist/open-sse/shared/machineId.js +2 -1
- package/open-sse/providers/registry/nous-research.js +1 -0
- package/open-sse/shared/machineId.js +2 -1
- package/package.json +1 -1
- package/src/automation/cf_token_via_session.py +158 -0
- package/src/automation/cloudflare_signup.py +3689 -0
- package/src/automation/codebuddy_signup.py +636 -0
- package/src/automation/flashloop_signup.py +338 -0
- package/src/automation/kimi_signup.py +401 -0
- package/src/automation/leonardo_signup.py +902 -0
- package/src/automation/openvecta_signup.py +652 -0
- package/src/automation/qoder_signup.py +323 -0
- package/src/automation/test_proxy.py +86 -0
- package/src/automation/weavy_signup.py +1964 -0
|
@@ -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()
|