commit 0ea42650455add2fcf15261c93c3f730d03c0cab Author: adrapier03 Date: Mon Jun 29 22:25:58 2026 +0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..891d65f --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# MiMo Chain Bot + +Bot otomatis untuk Xiaomi MiMo Open Platform (`platform.xiaomimimo.com`). +Chain-loop: register akun → redeem invite code → create API key → apply +Ultraspeed beta → ambil ref code → ulangi dengan ref code akun sebelumnya. + +## Install + +```bash +npm install +npx playwright install chrome +``` + +## Configuration + +Edit `config.json`: + +```json +{ + "tempmail": { + "apiUrl": "https://tempmail.adrnode.com/api" + }, + "captcha": { + "provider": "2captcha", + "apiKey": "YOUR_2CAPTCHA_API_KEY" + }, + "xiaomi": { + "referralLink": "https://platform.xiaomimimo.com/?ref=XXXXXX", + "inviteCode": "XXXXXX", + "password": "MimoAuto2024!", + "betaApplication": "MiMo-V2.5-Pro-UltraSpeed" + }, + "browser": { + "headless": false, + "timeout": 60000, + "screenshots": false + } +} +``` + +Yang **wajib** diisi: +- `captcha.apiKey` — API key dari [2captcha.com](https://2captcha.com), butuh balance +- `xiaomi.inviteCode` — kode referral untuk seed iterasi pertama (6 karakter) +- `xiaomi.referralLink` — URL referral, format `?ref=XXXXXX` (sama dengan inviteCode) + +Yang opsional: +- `xiaomi.password` — password untuk semua akun baru (default OK) +- `browser.headless` — `true` tanpa UI, `false` (default) buat lihat browser + +## Run + +```bash +node chain-loop.js --count 5 +``` + +| Flag | Default | Keterangan | +|---|---|---| +| `--count N` | 1 | jumlah akun yang dibuat | +| `--seed XXXXXX` | `config.xiaomi.inviteCode` | invite code untuk akun pertama | +| `--output file.txt` | `chain-result.txt` | file output | + +```bash +# 5 akun, seed dari config +node chain-loop.js --count 5 + +# 10 akun, seed manual +node chain-loop.js --count 10 --seed P9LHUK + +# Output ke file lain +node chain-loop.js --count 3 --output batch1.txt +``` + +## Per-akun flow + +``` +[1] Bikin temp email +[2] Launch browser dengan random fingerprint (UA, viewport, locale, GPU, dst) +[3] Buka referral link (?ref=) +[4] Sign up form (email + password + confirm + terms checkbox) +[5] Solve captcha (reCAPTCHA via 2Captcha + image captcha) +[6] Verify email (kode 6 digit dari tempmail) +[7] Klik Console → Balance → "Enter invite code" → isi 6-box → Redeem + → cek restriction (kalau kena, STOP) + → reload halaman, verify balance bertambah ~$2 +[8] Create API key (atau ambil existing kalau sudah ada) +[9] Fill Ultraspeed beta application form +[10] Klik "Refer & earn" → ambil ref code akun ini → seed untuk akun berikut +``` + +## Output + +```bash +# chain-result.txt (auto-create, append) +# Format: email:password:refCode:apiKey:invitedBy +foo@adrnode.com:MimoAuto2024!:K3M2P8:sk-aaa...bbb:T9K59J +bar@adrnode.com:MimoAuto2024!:LX8N2A:sk-ccc...ddd:K3M2P8 +baz@adrnode.com:MimoAuto2024!:Q9R3FZ:sk-eee...fff:LX8N2A +``` + +Tiap baris dichain — `invitedBy` baris N+1 = `refCode` baris N. + +## Features + +**Random fingerprint per akun** — User-Agent, viewport, locale, timezone, hardwareConcurrency, +deviceMemory, WebGL vendor/renderer, canvas noise seed, navigator.plugins. Sehingga Xiaomi +tidak mengenali fingerprint yang sama antar akun. + +**Human-like typing** — Form signup, captcha code, email verification code, dan OTP +redeem diketik per-karakter dengan delay acak 60-180ms + thinking pause. +Form Ultraspeed pakai `.fill()` langsung (mode cepat, cukup untuk form internal). + +**Idempotent** — Akun yang sudah redeem invite code / sudah punya API key +akan di-skip otomatis, aman untuk re-run. + +**Verifikasi balance** — Setelah redeem invite code, halaman di-reload dan balance +dibaca ulang. Harus bertambah ~$2.00. Kalau tidak, iterasi dianggap gagal. + +**Restriction detection** — Kalau setelah redeem muncul notif "risk control restrictions", +loop berhenti total (akun kena cap, gak bisa lanjut). + +## Stop conditions + +Loop berhenti kalau: + +| Condition | Action | +|---|---| +| Akun restricted (risk control) | STOP, gak retry | +| Balance tidak bertambah ~$2 | STOP, gak retry | +| Ref code tidak tertangkap | STOP, mencegah duplikasi seed | +| Ref code = seed lama (clipboard leak) | STOP | +| Iter total fail (captcha timeout, dll) | Retry pakai seed yang sama | + +## Fingerprint + +Tiap akun randomize: + +- **User-Agent**: Chrome 122-131 di Windows NT 10.0 +- **Viewport**: 8 ukuran (1280x720 → 1920x1080) +- **Locale**: en-US, en-GB, id-ID, en-AU, en-CA, en-SG +- **Timezone**: Jakarta, Singapore, Bangkok, KL, Manila, Ho_Chi_Minh +- **Hardware**: 4-16 cores, 4-16 GB memory +- **WebGL**: pasangan vendor + renderer NVIDIA/Intel/AMD realistis +- **Canvas**: seed unik, noise 1 pixel pada toDataURL & getImageData +- **navigator.webdriver** di-undefine, plugins palsu + +## Troubleshooting + +**Captcha solve lama (>2 menit)** +- 2Captcha workers sibuk, coba lagi nanti. Balance 2Captcha cek di dashboard. + +**"Account restricted" muncul** +- IP mungkin sudah ke-flag Xiaomi. Coba jeda beberapa jam atau pakai VPN. +- Screenshot `error-restriction-*.png` ke-dump otomatis untuk referensi. + +**Balance tidak bertambah** +- Screenshot `error-balance-*.png` ke-dump otomatis. +- Kemungkinan saldo ter-delay (Xiaomi bilang "Balance updates in near-real time, ≤5 min delay"). +- Atau invite code expired / invalid. + +**Ref code tidak tertangkap** +- Screenshot `error-refcode-*.png` dan `chain-refcode-leak-*.png` ke-dump otomatis. +- Cek format modal "Refer & Earn" di Xiaomi — kalau label berubah, pattern di `extras.js` + perlu ditambah. + +**Browser zombie (chrome.exe nyangkut)** +- Ctrl+C di terminal akan auto-close browser (SIGINT handler aktif). +- Kalau masih ada: `taskkill //IM chrome.exe //F` (hati-hati: juga tutup chrome pribadi). + +## Estimasi durasi + +| Akun | Estimasi | +|---|---| +| 1 | 2-4 menit | +| 5 | 10-20 menit | +| 10 | 20-40 menit | +| 50 | 2-3 jam | + +Bottleneck utama: solving captcha (60-120s per akun via 2Captcha). + +## Files + +``` +├── chain-loop.js entry point (node chain-loop.js) +├── chain-loop-config.js CLI args + save/log helpers +├── index.js MimoRegistration core class +├── extras.js getReferralCode method +├── fingerprint.js random browser fingerprint +├── human.js human-like typing helpers +├── package.json +├── config.json ← edit ini +└── README.md +``` + +Runtime output (gak masuk git): +- `chain-result.txt` — akun sukses +- `chain-fail.log` — error log +- `*.png` — screenshot debug + +## License + +MIT diff --git a/chain-loop-config.js b/chain-loop-config.js new file mode 100644 index 0000000..0c857ab --- /dev/null +++ b/chain-loop-config.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Chain loop: register → redeem invite → ambil ref code → create API key → + * fill Ultraspeed form. Tiap iterasi kode invite-nya pake ref code akun + * sebelumnya (chained). Iterasi pertama pake --seed atau config.xiaomi.inviteCode. + * + * Cara pakai: + * node chain-loop.js --count 5 + * node chain-loop.js --count 3 --seed HWPMXZ + * node chain-loop.js --count 10 --output chain.txt + * + * Output (chain.txt) format: + * email:password:refCode:apiKey:invitedBy + */ + +import { chromium } from 'playwright'; +import { readFileSync, appendFileSync, existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +import { MimoRegistration } from './index.js'; +import './extras.js'; // attach getReferralCode +import { generateFingerprint, buildInitScript, buildExtraHeaders } from './fingerprint.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Output dir bisa di-override via env var (dipake oleh wrapper bot-mimo +// supaya output file masuk ke folder bot-mimo, bukan parent). +const outputDir = process.env.HERMES_BOT_MIMO_CWD || __dirname; + +// Config path juga bisa di-override +const configPath = process.env.HERMES_BOT_MIMO_CONFIG || join(__dirname, 'config.json'); +const config = JSON.parse(readFileSync(configPath, 'utf8')); + +// ---- CLI args --------------------------------------------------------- + +const args = process.argv.slice(2); + +const countIdx = args.indexOf('--count'); +const count = countIdx !== -1 ? parseInt(args[countIdx + 1], 10) : 1; + +const seedIdx = args.indexOf('--seed'); +const seedRef = seedIdx !== -1 ? args[seedIdx + 1] : config.xiaomi.inviteCode; + +const outputIdx = args.indexOf('--output'); +const outputFile = outputIdx !== -1 + ? join(outputDir, args[outputIdx + 1]) + : join(outputDir, 'chain-result.txt'); + +const failLog = join(outputDir, 'chain-fail.log'); + +// ---- File helpers ----------------------------------------------------- + +function saveResult(row) { + if (!existsSync(outputFile)) { + appendFileSync( + outputFile, + '# Chain loop results. Format: email:password:refCode:apiKey:invitedBy\n', + 'utf8' + ); + } + const line = [ + row.email, + row.password, + row.refCode || '', + row.apiKey || '', + row.invitedBy || '', + ].join(':') + '\n'; + appendFileSync(outputFile, line, 'utf8'); +} + +function logFail(email, error) { + const line = `[${new Date().toISOString()}] ${email || 'unknown'} | ${error}\n`; + appendFileSync(failLog, line, 'utf8'); +} + +export { config, saveResult, logFail, count, seedRef, outputFile, failLog, __dirname }; diff --git a/chain-loop.js b/chain-loop.js new file mode 100644 index 0000000..313bf6c --- /dev/null +++ b/chain-loop.js @@ -0,0 +1,283 @@ +#!/usr/bin/env node +/** + * Chain loop. Lihat chain-loop-config.js untuk dokumentasi. + * + * Per iterasi: + * 1. Bikin tempmail + * 2. Override config.xiaomi.referralLink + .inviteCode dengan ref akun sebelumnya + * 3. Launch browser dengan random fingerprint + * 4. registerOnly() — sign up + verify email + * 5. redeemInviteCode() — pakai ref code dari akun sebelumnya (sudah otomatis + * lewat config override + flow di MimoRegistration) + * 6. getReferralCode() — ambil ref code akun ini buat iterasi berikutnya + * 7. createApiKey() + * 8. fillUltraspeedForm() + */ + +import { chromium } from 'playwright'; +import { join } from 'path'; + +import { MimoRegistration } from './index.js'; +import './extras.js'; // attach getReferralCode +import { generateFingerprint, buildInitScript, buildExtraHeaders } from './fingerprint.js'; + +import { + config, saveResult, logFail, count, seedRef, outputFile, failLog, __dirname, +} from './chain-loop-config.js'; + +// ---- One-iteration runner -------------------------------------------- + +async function runIteration(idx, total, currentRef) { + console.log('\n' + '═'.repeat(70)); + console.log(` [${idx + 1}/${total}] Chain Iteration (using ref: ${currentRef})`); + console.log('═'.repeat(70)); + + // Per-iter: clone config + override invite code & referral link + const iterConfig = JSON.parse(JSON.stringify(config)); + iterConfig.xiaomi.inviteCode = currentRef; + iterConfig.xiaomi.referralLink = + `https://platform.xiaomimimo.com/?ref=${encodeURIComponent(currentRef)}`; + + const reg = new MimoRegistration(iterConfig); + + const cleanup = async () => { + if (reg.browser) { + console.log('\n Caught SIGINT, closing browser...'); + try { await reg.browser.close(); } catch (e) {} + } + process.exit(130); + }; + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + + let email = null, refCode = null, apiKey = null; + + try { + // 1. Tempmail + console.log('[1/8] Creating temporary email...'); + email = await reg.tempmail.createInbox(); + console.log(`✓ Email: ${email}`); + + // 2. Browser + console.log('[2/8] Launching browser...'); + const fp = generateFingerprint(); + reg.fingerprint = fp; + console.log(` ↳ Chrome ${fp.chromeMajor}, ${fp.viewport.width}x${fp.viewport.height}, ${fp.locale}/${fp.timezone}`); + + reg.browser = await chromium.launch({ + headless: iterConfig.browser.headless, + channel: 'chrome', + args: [ + `--window-size=${fp.viewport.width},${fp.viewport.height}`, + '--disable-blink-features=AutomationControlled', + ], + }); + const ctx = await reg.browser.newContext({ + userAgent: fp.userAgent, + viewport: fp.viewport, + deviceScaleFactor: fp.deviceScaleFactor, + locale: fp.locale, + timezoneId: fp.timezone, + screen: { width: fp.screen.width, height: fp.screen.height }, + extraHTTPHeaders: buildExtraHeaders(fp), + }); + await ctx.addInitScript({ content: buildInitScript(fp) }); + reg.page = await ctx.newPage(); + + // Skip screenshot dump + const origScreenshot = reg.page.screenshot.bind(reg.page); + reg.page.screenshot = async (opts = {}) => { + const isError = opts.path && opts.path.includes('error'); + if (isError || iterConfig.browser.screenshots === true) return origScreenshot(opts); + return Buffer.alloc(0); + }; + + // 3. Open referral link (refCode chained) + console.log('[3/8] Opening referral page...'); + await reg.page.goto(iterConfig.xiaomi.referralLink, { + waitUntil: 'networkidle', + timeout: iterConfig.browser.timeout, + }); + + // 4. Sign up form + console.log('[4/8] Filling Sign up form...'); + await reg.fillRegistrationForm(email); + await reg.submitRegistration(); + + // 5. Captcha + console.log('[5/8] Handling captcha...'); + await reg.handleXiaomiCaptcha(); + await reg.handleImageCaptcha(); + + // 6. Verify email + console.log('[6/8] Verifying email...'); + await reg.verifyEmail(email); + + // 7. Redeem invite + create API key (idempotent — gpp meskipun udah) + // flow di MimoRegistration: clickConsoleMenu → balance → handleTerms → + // cek "Enter invite code" → kalau ada redeem, kalau tidak skip. + let redeemRestricted = false; + try { + await reg.redeemInviteCode(); + } catch (e) { + if (e.code === 'ACCOUNT_RESTRICTED' || e.code === 'BALANCE_NOT_CREDITED') { + // Akun kena risk control / balance gak nambah — STOP propagate + // ke caller biar chain-loop berhenti. + const tag = e.code === 'ACCOUNT_RESTRICTED' ? 'restricted' : 'balance not credited'; + console.log(` ❌ Stopping iteration — ${tag}: ${e.message}`); + redeemRestricted = true; + throw e; + } + console.log(` ! redeem error (lanjut): ${e.message}`); + } + + // 8. Create API key (atau ambil existing) + console.log('[7/8] Creating API key...'); + try { + apiKey = await reg.createApiKey(); + console.log(`✓ API key: ${apiKey || '(none)'}`); + } catch (e) { + console.log(` ! createApiKey error: ${e.message}`); + } + + // 9. Fill Ultraspeed form (best-effort) + console.log('[Bonus] Fill Ultraspeed form...'); + try { + await reg.fillUltraspeedForm(email); + } catch (e) { + console.log(` ! fillUltraspeedForm error: ${e.message}`); + } + + // 10. Ambil ref code akun ini PALING AKHIR — biar clipboard clean dari + // side-effect step redeem/apikey/ultraspeed sebelumnya. + console.log('[8/8] Fetching referral code for next iteration...'); + try { + const captured = await reg.getReferralCode(); + if (captured && captured.toUpperCase() === currentRef.toUpperCase()) { + // Bug guard: kalau refCode == seed yg baru di-redeem, artinya + // getReferralCode tertipu (clipboard leak / modal salah / dst). + console.log(` ! refCode "${captured}" == seed "${currentRef}" — invalid, treating as null`); + try { + await reg.page.screenshot({ + path: join(__dirname, `chain-refcode-leak-${Date.now()}.png`), + fullPage: true, + }); + } catch (e) {} + refCode = null; + } else { + refCode = captured; + console.log(`✓ My ref code: ${refCode || '(not found)'}`); + } + } catch (e) { + console.log(` ! getReferralCode error: ${e.message}`); + } + + saveResult({ + email, + password: iterConfig.xiaomi.password, + refCode, + apiKey, + invitedBy: currentRef, + }); + + console.log('═'.repeat(70)); + console.log(`✅ ITERATION ${idx + 1} DONE`); + console.log(` Email : ${email}`); + console.log(` RefCode : ${refCode || '(not found)'}`); + console.log(` ApiKey : ${apiKey || '(none)'}`); + console.log('═'.repeat(70)); + + return { ok: true, email, refCode, apiKey }; + } catch (err) { + console.error(`\n❌ Iteration ${idx + 1} failed: ${err.message}`); + if (reg.page) { + try { + await reg.page.screenshot({ + path: join(__dirname, `chain-error-${Date.now()}.png`), + fullPage: true, + }); + } catch (e) {} + } + logFail(email, err.message); + return { + ok: false, + email, + error: err.message, + restricted: err.code === 'ACCOUNT_RESTRICTED' || err.code === 'BALANCE_NOT_CREDITED', + stopReason: err.code, + }; + } finally { + if (reg.browser) { + await reg.browser.close().catch(() => {}); + } + } +} + +// ---- Main loop ------------------------------------------------------- + +async function main() { + console.log(`\nChain loop config:`); + console.log(` Count : ${count}`); + console.log(` Seed ref : ${seedRef}`); + console.log(` Output file : ${outputFile}`); + console.log(` Fail log : ${failLog}\n`); + + let currentRef = seedRef; + let okCount = 0, failCount = 0; + + for (let i = 0; i < count; i++) { + const result = await runIteration(i, count, currentRef); + + if (result.ok) { + okCount++; + // Chain: kalau berhasil dapet refCode, pake itu untuk iterasi berikut + if (result.refCode) { + console.log(`\n→ Next iteration will use ref: ${result.refCode}`); + currentRef = result.refCode; + } else { + // refCode gak ke-capture — chain rusak. STOP biar gak bikin + // beberapa akun pakai invite code yang sama (bisa dianggap fraud + // sama Xiaomi). + console.error(`\n❌ Iteration ${i + 1} succeeded but didn't capture refCode.`); + console.error(` STOPPING chain to prevent reusing seed ${currentRef}.`); + console.error(` Run lagi dengan --seed ${currentRef} kalau mau lanjut manual.`); + break; + } + } else { + failCount++; + if (result.restricted) { + // Akun kena risk control / balance gak nambah — STOP loop, jangan retry + const reason = result.stopReason || 'unknown'; + console.error(`\n❌ Chain stopped: ${reason}`); + console.error(` Email: ${result.email}`); + console.error(` Detail: ${result.error}`); + console.error(` Last seed used: ${currentRef}`); + break; + } + // Iter total fail (non-restriction) — currentRef gak di-update, retry pakai seed yang sama + console.log(`\n! Iteration failed, retrying with same seed ${currentRef}`); + } + + if (i < count - 1) { + const wait = 8000 + Math.floor(Math.random() * 6000); + console.log(` Waiting ${(wait / 1000).toFixed(1)}s before next...`); + await new Promise(r => setTimeout(r, wait)); + } + } + + console.log('\n' + '═'.repeat(70)); + console.log(' CHAIN LOOP SUMMARY'); + console.log('═'.repeat(70)); + console.log(` Total : ${count}`); + console.log(` Successful : ${okCount}`); + console.log(` Failed : ${failCount}`); + console.log(` Output : ${outputFile}`); + console.log('═'.repeat(70) + '\n'); + + process.exit(okCount > 0 ? 0 : 1); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/config.json b/config.json new file mode 100644 index 0000000..b00c8db --- /dev/null +++ b/config.json @@ -0,0 +1,20 @@ +{ + "tempmail": { + "apiUrl": "https://tempmail.adrnode.com/api" + }, + "captcha": { + "provider": "2captcha", + "apiKey": "YOUR_2CAPTCHA_API_KEY" + }, + "xiaomi": { + "referralLink": "https://platform.xiaomimimo.com/?ref=XXXXXX", + "inviteCode": "XXXXXX", + "password": "MimoAuto2024!", + "betaApplication": "MiMo-V2.5-Pro-UltraSpeed" + }, + "browser": { + "headless": false, + "timeout": 60000, + "screenshots": false + } +} diff --git a/extras.js b/extras.js new file mode 100644 index 0000000..3b0f230 --- /dev/null +++ b/extras.js @@ -0,0 +1,299 @@ +/** + * Method tambahan untuk MimoRegistration yang dipakai bersama oleh + * register-only.js dan chain-loop.js. Di-attach via prototype biar gak + * perlu modifikasi index.js. + */ + +import { humanDelay } from './human.js'; +import { MimoRegistration } from './index.js'; + +// Common English words yg sering ke-match regex code uppercase. +const REF_BLACKLIST = new Set([ + 'YOUR', 'CODE', 'INVITE', 'REFERRAL', 'ENTER', 'COPY', 'SHARE', + 'EARN', 'NULL', 'NONE', 'TRUE', 'FALSE', 'EMPTY', +]); + +/** + * Validate ref code — Xiaomi MiMo invite codes are always EXACTLY 6 chars + * alphanumeric uppercase. Sample valid: T9K59J, 4G7RWD, K3M2P8, HWPMXZ, + * AXQZ3F, TQB65X, P9LHUK — semua 6 char. + * + * Pattern lebih dari 6 char (mis. "DIMASSARI59" = 11 char username email) + * harus ditolak. + */ +function isValidRefCode(s) { + if (!s) return false; + const up = String(s).toUpperCase().trim(); + if (up.length !== 6) return false; + if (REF_BLACKLIST.has(up)) return false; + return /^[A-Z0-9]{6}$/.test(up); +} + +/** + * Ambil kode referal milik akun yang sedang login. + * Strategi: + * 1. Scan semua link/href yang ada `?ref=XXXXXX` + * 2. Cari teks "Invite code: XXXXXX" di body + * 3. Klik tombol "Refer & earn" / "Invite friends" → modal → scan + clipboard + */ +MimoRegistration.prototype.getReferralCode = async function () { + // Navigate ke balance page (kalau belum di sana) + const currentUrl = this.page.url(); + if (!currentUrl.includes('/console/balance')) { + try { await this.clickConsoleMenu(); } catch (e) {} + + if (!this.page.url().includes('/console/balance')) { + await this.page.goto('https://platform.xiaomimimo.com/console/balance', { + waitUntil: 'networkidle', + timeout: this.config.browser.timeout, + }); + } + await this.handleOAuthRedirect(); + await humanDelay(1500, 2500); + await this.handleTermsModal(); + await this.waitForOverlaysGone(); + } + + // Strategi 1: scan ?ref= di link/anchor/data-clipboard + console.log(' Scanning for ?ref= links...'); + let refCode = await this.page.evaluate(() => { + const anchors = Array.from(document.querySelectorAll('a, [data-href], [data-clipboard-text]')); + for (const a of anchors) { + const href = a.href || a.getAttribute('data-href') || a.getAttribute('data-clipboard-text') || ''; + const m = href.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m) return m[1].toUpperCase(); + } + const inputs = Array.from(document.querySelectorAll('input[readonly], textarea[readonly]')); + for (const inp of inputs) { + const v = inp.value || ''; + const m = v.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m) return m[1].toUpperCase(); + } + return null; + }); + if (isValidRefCode(refCode)) return refCode; + if (refCode) console.log(` ! Strategi 1 hasil "${refCode}" ditolak (blacklist/invalid)`); + refCode = null; + + // Strategi 2: regex teks plain — 3 pattern, hasil divalidasi + console.log(' Scanning page text for ref code patterns...'); + refCode = await this.page.evaluate(() => { + const text = document.body.innerText; + // a) URL share di plain text: "...?ref=XXXXXX" + const m1 = text.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m1) return m1[1].toUpperCase(); + // b) "Invite code\nXXX" (modal Xiaomi nampilin label terus code di bawah, + // tanpa colon. Pakai [\s:\n]+ supaya match newline juga) + const m2 = text.match(/(?:invite\s+code|referral\s+code|your\s+code)[\s:\n]+([A-Z0-9]{6})\b/i); + if (m2) return m2[1].toUpperCase(); + // c) "Code: XXX" generic dengan colon + const m3 = text.match(/\bcode\s*:\s*([A-Z0-9]{6})\b/i); + if (m3) return m3[1].toUpperCase(); + return null; + }); + if (isValidRefCode(refCode)) return refCode; + if (refCode) console.log(` ! Strategi 2 hasil "${refCode}" ditolak (blacklist/invalid)`); + refCode = null; + + // Strategi 3: klik Refer & earn / Invite → modal → scan + clipboard + console.log(' Trying to click Refer & earn / Invite button...'); + const opened = await this.page.evaluate(() => { + const all = Array.from(document.querySelectorAll('button, a, [role="button"]')); + const target = all.find(el => { + const txt = (el.textContent || '').trim(); + return /^(Refer\s*&\s*earn|Refer\s*and\s*earn|Invite( friends)?|Share|Refer( friends)?)$/i.test(txt) && el.offsetHeight > 0; + }); + if (target) { + target.scrollIntoView({ block: 'center' }); + target.click(); + return true; + } + return false; + }); + + if (opened) { + console.log(' ✓ Clicked Refer & earn / Invite button, waiting for modal...'); + // Tunggu modal Refer & Earn beneran muncul (cari teks "Invite code" / + // "Invite builders" di salah satu modal yg visible) + await this.page.waitForFunction(() => { + const modals = Array.from(document.querySelectorAll('.ant-modal, .ant-modal-content, .ant-modal-wrap, [role="dialog"]')); + return modals.some(m => { + if (m.offsetHeight === 0 || m.style.display === 'none') return false; + const t = (m.innerText || '').toLowerCase(); + return t.includes('invite code') || t.includes('invite builder') || t.includes('refer & earn'); + }); + }, { timeout: 8000 }).catch(() => {}); + await humanDelay(800, 1400); + + // Clear clipboard biar gak baca residue dari step sebelumnya + try { + await this.page.evaluate(async () => { + try { await navigator.clipboard.writeText(''); } catch (e) {} + }); + } catch (e) {} + + // Re-scan modal — coba beberapa kali kalau pertama belum ke-render + for (let attempt = 1; attempt <= 3 && !refCode; attempt++) { + const candidate = await this.page.evaluate(() => { + const modal = Array.from(document.querySelectorAll('.ant-modal, .ant-modal-content, .ant-modal-wrap, [role="dialog"]')) + .find(m => m.offsetHeight > 0 && (m.style.display !== 'none')); + const scope = modal || document.body; + + // 1. Link share di anchor / data-clipboard / readonly input / value + const els = Array.from(scope.querySelectorAll('a, [data-clipboard-text], [data-href], input, textarea, span, div')); + for (const el of els) { + const sources = [ + el.href, el.value, el.getAttribute('data-clipboard-text'), + el.getAttribute('data-href'), el.getAttribute('data-link'), + el.textContent, + ]; + for (const s of sources) { + if (!s) continue; + const m = s.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m) return m[1].toUpperCase(); + } + } + // 2. Plain text di modal — coba 4 pattern (modal Xiaomi nampilin code + // sebagai standalone text dengan label "Invite code" di atasnya) + const text = scope.innerText || ''; + // a) URL ?ref=XXX (paling reliable) + const m1 = text.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m1) return m1[1].toUpperCase(); + // b) "Invite code" label + code di baris berikut (format modal aktual) + const m2 = text.match(/(?:invite\s+code|referral\s+code|your\s+code)[\s:\n]+([A-Z0-9]{6})\b/i); + if (m2) return m2[1].toUpperCase(); + // c) "Code: XXX" generic dengan colon + const m3 = text.match(/\bcode\s*:\s*([A-Z0-9]{6})\b/i); + if (m3) return m3[1].toUpperCase(); + return null; + }); + if (isValidRefCode(candidate)) { + refCode = candidate; + break; + } + if (candidate) { + console.log(` ! Modal attempt ${attempt} hasil "${candidate}" ditolak (blacklist)`); + } + if (attempt < 3) { + console.log(` Modal scan attempt ${attempt} empty, retry in 1.5s...`); + await humanDelay(1200, 1800); + } + } + + if (!refCode) { + console.log(' ! Ref code not in modal text, trying Copy button + clipboard...'); + try { + const ctx = this.page.context(); + await ctx.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {}); + + // Strategi 3a: baca attribute langsung dari button Copy di modal + // REFER & EARN (bukan modal lain). Filter modal scope dulu + // dengan teks "Invite code" / "Invite builder". + const copyBtnText = await this.page.evaluate(() => { + const modals = Array.from(document.querySelectorAll('.ant-modal, .ant-modal-content, [role="dialog"]')) + .filter(m => m.offsetHeight > 0); + // Cari modal yg teksnya mengandung "Invite code" / "Invite builder" + const referModal = modals.find(m => { + const t = (m.innerText || '').toLowerCase(); + return t.includes('invite code') || t.includes('invite builder') || t.includes('refer & earn'); + }) || modals[modals.length - 1]; // fallback ke modal terakhir + if (!referModal) return null; + + const btns = Array.from(referModal.querySelectorAll('button, a, [role="button"], [data-clipboard-text]')); + const copy = btns.find(b => /^(copy|copy link|salin)/i.test((b.textContent || '').trim())); + if (!copy) return null; + return { + clipText: copy.getAttribute('data-clipboard-text'), + link: copy.getAttribute('data-link') || copy.getAttribute('data-href'), + value: copy.value, + }; + }); + + if (copyBtnText) { + console.log(` Copy button attrs: ${JSON.stringify(copyBtnText)}`); + for (const v of Object.values(copyBtnText)) { + if (!v) continue; + const s = String(v); + // a) URL share dengan ?ref=XXX + const m = s.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m && isValidRefCode(m[1])) { refCode = m[1].toUpperCase(); break; } + // b) Bare code (TQB65X / P9LHUK) + const trimmed = s.trim().toUpperCase(); + if (isValidRefCode(trimmed)) { refCode = trimmed; break; } + } + } + + // Strategi 3b: kalau attribute kosong, klik Copy + bringToFront + readText + if (!refCode) { + await this.page.bringToFront().catch(() => {}); + await this.page.evaluate(() => { + const modal = Array.from(document.querySelectorAll('.ant-modal, .ant-modal-content, .ant-modal-wrap, [role="dialog"]')) + .find(m => m.offsetHeight > 0); + if (!modal) return false; + const btns = Array.from(modal.querySelectorAll('button, a, [role="button"]')); + const copy = btns.find(b => /^(copy|copy link|salin)/i.test((b.textContent || '').trim())); + if (copy) { copy.click(); return true; } + return false; + }); + await humanDelay(900, 1400); + + const clipboardText = await this.page.evaluate(async () => { + // Pastikan window punya focus dulu + try { window.focus(); } catch (e) {} + try { + return await navigator.clipboard.readText(); + } catch (e) { + return ''; + } + }); + if (clipboardText) { + console.log(` Clipboard: ${clipboardText.substring(0, 100)}`); + // Coba ekstrak ref code dari clipboard: + // a) URL share dengan ?ref=XXX + // b) Bare code (TQB65X / P9LHUK / dst) — modal Xiaomi cuma copy code + const m1 = clipboardText.match(/[?&]ref=([A-Z0-9]{6})\b/i); + if (m1 && isValidRefCode(m1[1])) { + refCode = m1[1].toUpperCase(); + } else { + // Bare code: trim, uppercase, validasi + const trimmed = clipboardText.trim().toUpperCase(); + if (isValidRefCode(trimmed)) { + refCode = trimmed; + } else { + // Cari token alphanumeric 4-12 char di mana pun di clipboard + const m2 = clipboardText.match(/\b([A-Z0-9]{6})\b/i); + if (m2 && isValidRefCode(m2[1])) refCode = m2[1].toUpperCase(); + } + } + } else { + console.log(' ! Clipboard empty (page may have lost focus)'); + } + } + } catch (clipErr) { + console.log(` ! Clipboard read error: ${clipErr.message}`); + } + } + + // Kalau masih null, dump screenshot + html modal untuk debug + if (!refCode) { + console.log(' ! All strategies failed — dumping debug artifacts'); + try { + const ts = Date.now(); + await this.page.screenshot({ + path: `error-refcode-${ts}.png`, + fullPage: false, + }); + const modalHtml = await this.page.evaluate(() => { + const modal = Array.from(document.querySelectorAll('.ant-modal, .ant-modal-content, [role="dialog"]')) + .find(m => m.offsetHeight > 0); + return modal ? modal.outerHTML.substring(0, 2000) : 'NO_MODAL'; + }); + console.log(` Modal HTML (preview): ${modalHtml.substring(0, 500)}`); + } catch (e) {} + } + + await this.page.keyboard.press('Escape').catch(() => {}); + } + + return refCode; +}; diff --git a/fingerprint.js b/fingerprint.js new file mode 100644 index 0000000..4a54603 --- /dev/null +++ b/fingerprint.js @@ -0,0 +1,266 @@ +/** + * Browser fingerprint randomizer for Playwright. + * + * generateFingerprint() -> profile object + * buildInitScript(fp) -> JS string yang di-inject ke setiap halaman + * via context.addInitScript() biar override jalan + * sebelum kode situs sempat baca navigator/screen/canvas. + * + * Catatan: ini ngacak permukaan-permukaan yang dibaca skrip anti-bot umum + * (FingerprintJS, Akamai BMP, dsb). Bukan stealth penuh — kalau target + * pasang deteksi level network (TLS JA3, IP, ASN), butuh proxy + tools lain. + */ + +import crypto from 'crypto'; + +// ---- Pool data ---------------------------------------------------------- + +const CHROME_VERSIONS = [ + '122.0.0.0', '123.0.0.0', '124.0.0.0', '125.0.0.0', '126.0.0.0', + '127.0.0.0', '128.0.0.0', '129.0.0.0', '130.0.0.0', '131.0.0.0', +]; + +// Windows 10 dan 11 dua-duanya report "10.0" di UA string +const WINDOWS_NT_VERSION = '10.0'; + +const VIEWPORTS = [ + { width: 1920, height: 1080 }, + { width: 1536, height: 864 }, + { width: 1440, height: 900 }, + { width: 1366, height: 768 }, + { width: 1600, height: 900 }, + { width: 1680, height: 1050 }, + { width: 1280, height: 720 }, + { width: 1280, height: 800 }, +]; + +const LOCALES = ['en-US', 'en-GB', 'id-ID', 'en-AU', 'en-CA', 'en-SG']; + +const TIMEZONES = [ + 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Bangkok', + 'Asia/Kuala_Lumpur', 'Asia/Manila', 'Asia/Ho_Chi_Minh', +]; + +const HARDWARE_CONCURRENCY = [4, 6, 8, 12, 16]; +const DEVICE_MEMORY = [4, 8, 16]; +const COLOR_DEPTHS = [24, 30]; + +// Pasangan vendor + renderer realistis (gak campur — kalau vendor NVIDIA, +// renderer-nya juga GPU NVIDIA biar konsisten saat skrip cocokkan keduanya) +const WEBGL_RENDERERS = [ + { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 Ti Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1050 Ti Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) HD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon RX 6600 XT Direct3D11 vs_5_0 ps_5_0, D3D11)' }, + { vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' }, +]; + +// ---- Helpers ------------------------------------------------------------ + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function randInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +// ---- Fingerprint generator --------------------------------------------- + +export function generateFingerprint() { + const chromeVersion = pick(CHROME_VERSIONS); + const chromeMajor = chromeVersion.split('.')[0]; + const viewport = pick(VIEWPORTS); + const locale = pick(LOCALES); + const timezone = pick(TIMEZONES); + const cores = pick(HARDWARE_CONCURRENCY); + const memory = pick(DEVICE_MEMORY); + const webgl = pick(WEBGL_RENDERERS); + const colorDepth = pick(COLOR_DEPTHS); + const deviceScaleFactor = pick([1, 1, 1, 1.25, 1.5]); + + const userAgent = + `Mozilla/5.0 (Windows NT ${WINDOWS_NT_VERSION}; Win64; x64) ` + + `AppleWebKit/537.36 (KHTML, like Gecko) ` + + `Chrome/${chromeVersion} Safari/537.36`; + + // Screen biasanya >= viewport. Kasih taskbar offset acak. + const screenWidth = viewport.width; + const screenHeight = viewport.height + randInt(40, 120); + const availHeight = screenHeight - randInt(30, 60); + + // Seed unik buat noise canvas / audio + const seed = crypto.randomBytes(16).toString('hex'); + + return { + userAgent, + chromeMajor, + chromeVersion, + viewport, + deviceScaleFactor, + screen: { + width: screenWidth, + height: screenHeight, + availWidth: screenWidth, + availHeight, + colorDepth, + }, + locale, + timezone, + platform: 'Win32', + hardwareConcurrency: cores, + deviceMemory: memory, + webgl, + seed, + pluginsCount: randInt(2, 5), + }; +} + +// HTTP headers tambahan untuk konsisten dengan UA yang diset. +// Sec-CH-UA dkk biasanya dikirim Chrome berdasar binary version aslinya; +// kita override eksplisit biar match sama UA yang kita rotasikan. +export function buildExtraHeaders(fp) { + const major = fp.chromeMajor; + const baseLang = fp.locale.split('-')[0]; + return { + 'Sec-CH-UA': `"Chromium";v="${major}", "Not_A Brand";v="24", "Google Chrome";v="${major}"`, + 'Sec-CH-UA-Mobile': '?0', + 'Sec-CH-UA-Platform': '"Windows"', + 'Accept-Language': `${fp.locale},${baseLang};q=0.9`, + }; +} + +// Init script di-inject sebelum semua script halaman jalan. +// Pakai Object.defineProperty(..., { configurable: true }) supaya pemeriksa +// fingerprint yang nge-cek descriptor gak langsung curiga. +export function buildInitScript(fp) { + const safe = (v) => JSON.stringify(v); + return ` + (() => { + try { + const seed = ${safe(fp.seed)}; + + // ---- navigator overrides ---- + Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }); + Object.defineProperty(navigator, 'platform', { get: () => ${safe(fp.platform)}, configurable: true }); + Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => ${fp.hardwareConcurrency}, configurable: true }); + Object.defineProperty(navigator, 'deviceMemory', { get: () => ${fp.deviceMemory}, configurable: true }); + Object.defineProperty(navigator, 'languages', { get: () => [${safe(fp.locale)}, ${safe(fp.locale.split('-')[0])}], configurable: true }); + + // Plugins palsu, panjangnya berbeda-beda + const fakePlugins = Array.from({ length: ${fp.pluginsCount} }, (_, i) => ({ + name: 'Plugin ' + i, + filename: 'plugin' + i + '.dll', + description: '', + length: 1, + })); + Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins, configurable: true }); + + // ---- screen overrides ---- + Object.defineProperty(screen, 'width', { get: () => ${fp.screen.width}, configurable: true }); + Object.defineProperty(screen, 'height', { get: () => ${fp.screen.height}, configurable: true }); + Object.defineProperty(screen, 'availWidth', { get: () => ${fp.screen.availWidth}, configurable: true }); + Object.defineProperty(screen, 'availHeight', { get: () => ${fp.screen.availHeight}, configurable: true }); + Object.defineProperty(screen, 'colorDepth', { get: () => ${fp.screen.colorDepth}, configurable: true }); + Object.defineProperty(screen, 'pixelDepth', { get: () => ${fp.screen.colorDepth}, configurable: true }); + + // ---- WebGL vendor/renderer ---- + const spoofWebGL = (proto) => { + if (!proto) return; + const orig = proto.getParameter; + proto.getParameter = function (parameter) { + // UNMASKED_VENDOR_WEBGL + if (parameter === 37445) return ${safe(fp.webgl.vendor)}; + // UNMASKED_RENDERER_WEBGL + if (parameter === 37446) return ${safe(fp.webgl.renderer)}; + return orig.apply(this, arguments); + }; + }; + if (typeof WebGLRenderingContext !== 'undefined') spoofWebGL(WebGLRenderingContext.prototype); + if (typeof WebGL2RenderingContext !== 'undefined') spoofWebGL(WebGL2RenderingContext.prototype); + + // ---- Canvas noise ---- + // Geser 1 pixel dengan alpha sangat rendah, posisi & warna ditentukan seed. + // Nilai akhir toDataURL/getImageData jadi unik per fingerprint. + const sx = parseInt(seed.substring(0, 4), 16); + const sy = parseInt(seed.substring(4, 8), 16); + const sr = parseInt(seed.substring(8, 10), 16); + const sg = parseInt(seed.substring(10, 12), 16); + const sb = parseInt(seed.substring(12, 14), 16); + + const _toDataURL = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function () { + try { + if (this.width > 16 && this.height > 16) { + const ctx = this.getContext('2d'); + if (ctx) { + ctx.save(); + ctx.fillStyle = 'rgba(' + sr + ',' + sg + ',' + sb + ',0.005)'; + ctx.fillRect(sx % this.width, sy % this.height, 1, 1); + ctx.restore(); + } + } + } catch (e) {} + return _toDataURL.apply(this, arguments); + }; + + const _getImageData = CanvasRenderingContext2D.prototype.getImageData; + CanvasRenderingContext2D.prototype.getImageData = function () { + const data = _getImageData.apply(this, arguments); + try { + // Geser sedikit di byte tertentu — tetap deterministik per seed. + const offset = (sx + sy) % Math.max(1, data.data.length - 1); + data.data[offset] = (data.data[offset] + (sr % 3)) & 0xff; + } catch (e) {} + return data; + }; + + // ---- AudioContext noise ---- + if (typeof AnalyserNode !== 'undefined') { + const _getFloat = AnalyserNode.prototype.getFloatFrequencyData; + AnalyserNode.prototype.getFloatFrequencyData = function (array) { + _getFloat.apply(this, arguments); + const drift = ((sr / 256) - 0.5) * 0.0001; + for (let i = 0; i < array.length; i++) array[i] += drift; + }; + } + + // ---- Permissions API tweak (notifications gak boleh "denied" + webdriver true) ---- + if (navigator.permissions && navigator.permissions.query) { + const origQuery = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = (params) => { + if (params && params.name === 'notifications') { + return Promise.resolve({ state: Notification.permission, onchange: null }); + } + return origQuery(params); + }; + } + } catch (e) { + // Jangan biarkan error fingerprint mematikan halaman — log aja. + console.error('[fp-init] error:', e && e.message); + } + })(); + `; +} + +// CLI test: `node fingerprint.js` — print 3 contoh fingerprint +if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}` || + (process.argv[1] && process.argv[1].endsWith('fingerprint.js'))) { + for (let i = 0; i < 3; i++) { + const fp = generateFingerprint(); + console.log(`\n--- Sample ${i + 1} ---`); + console.log('UA :', fp.userAgent); + console.log('Viewport :', `${fp.viewport.width}x${fp.viewport.height} (DPR ${fp.deviceScaleFactor})`); + console.log('Screen :', `${fp.screen.width}x${fp.screen.height} @ ${fp.screen.colorDepth}bpp`); + console.log('Locale / TZ :', fp.locale, '/', fp.timezone); + console.log('CPU / Mem :', fp.hardwareConcurrency, 'cores /', fp.deviceMemory, 'GB'); + console.log('GPU vendor :', fp.webgl.vendor); + console.log('GPU renderer :', fp.webgl.renderer); + console.log('Plugins count :', fp.pluginsCount); + console.log('Seed :', fp.seed.substring(0, 16) + '…'); + } +} diff --git a/human.js b/human.js new file mode 100644 index 0000000..ceb70cf --- /dev/null +++ b/human.js @@ -0,0 +1,145 @@ +/** + * Helper untuk interaksi browser ala manusia. + * + * Tujuan: menghindari pola bot yang kentara (paste instan via .fill(), + * klik tanpa hover/jeda, dsb). Bukan stealth penuh — cuma menambah + * jitter di layer event yang biasa diukur skrip antifraud + * (waktu antar keystroke, klik tanpa pointermove, fokus tanpa klik). + * + * Pakai begini: + * import { humanType, humanClick, humanDelay, humanFill } from './human.js'; + * await humanFill(page, 'input[name="email"]', email); + * await humanClick(page, 'button[type="submit"]'); + */ + +function rand(min, max) { + return Math.random() * (max - min) + min; +} + +function randInt(min, max) { + return Math.floor(rand(min, max + 1)); +} + +export function humanDelay(min = 80, max = 220) { + return new Promise((resolve) => setTimeout(resolve, randInt(min, max))); +} + +/** + * Resolve target ke ElementHandle/Locator yang punya method click/fill/type. + * - String → page.$(selector) (ElementHandle) + * - Locator → return apa adanya + * - ElementHandle → return apa adanya + */ +async function resolveTarget(pageOrFrame, target) { + if (typeof target === 'string') { + const handle = await pageOrFrame.$(target); + if (!handle) throw new Error(`humanFill: selector tidak ketemu: ${target}`); + return handle; + } + return target; +} + +/** + * Klik element dengan sedikit hover dulu. Hover memicu pointermove, + * yang sering dipakai sebagai sinyal "user benerian" oleh script antibot. + */ +export async function humanClick(pageOrFrame, target, options = {}) { + const el = await resolveTarget(pageOrFrame, target); + try { + // Move pointer ke element pelan-pelan (Playwright hover sudah + // terjemahkan jadi serangkaian pointermove) + await el.hover({ timeout: options.hoverTimeout || 4000 }).catch(() => {}); + } catch (e) {} + await humanDelay(60, 180); + await el.click({ ...options, delay: randInt(40, 120) }); +} + +/** + * Ketik teks ala manusia: per karakter dengan delay acak, + * sesekali jeda 200-500ms seolah-olah "mikir". + */ +export async function humanType(pageOrFrame, text, options = {}) { + const minDelay = options.minDelay ?? 60; + const maxDelay = options.maxDelay ?? 180; + const thinkProb = options.thinkProb ?? 0.07; // 7% chance jeda mikir per char + const thinkMin = options.thinkMin ?? 220; + const thinkMax = options.thinkMax ?? 520; + + const keyboard = pageOrFrame.keyboard; + for (const ch of text) { + await keyboard.type(ch, { delay: randInt(minDelay, maxDelay) }); + if (Math.random() < thinkProb) { + await humanDelay(thinkMin, thinkMax); + } + } +} + +/** + * Fokus ke field, bersihkan isi lama secara natural, lalu ketik nilai baru + * karakter-per-karakter. Pengganti drop-in untuk .fill() yang lebih meyakinkan. + * + * options: + * - clear: 'select-all' (default) atau 'backspace' atau 'none' + * - clickFirst: true (default) — klik field dulu sebelum ngetik + */ +export async function humanFill(pageOrFrame, target, value, options = {}) { + const el = await resolveTarget(pageOrFrame, target); + const clearMode = options.clear ?? 'select-all'; + + if (options.clickFirst !== false) { + try { await el.hover({ timeout: 3000 }); } catch (e) {} + await humanDelay(80, 200); + try { + await el.click({ delay: randInt(40, 120) }); + } catch (e) { + // beberapa input ke-overlay; coba focus saja + try { await el.focus(); } catch (e2) {} + } + } else { + try { await el.focus(); } catch (e) {} + } + + await humanDelay(80, 200); + + // Bersihkan isi lama + if (clearMode === 'select-all') { + await pageOrFrame.keyboard.press('Control+A'); + await humanDelay(40, 100); + await pageOrFrame.keyboard.press('Backspace'); + } else if (clearMode === 'backspace') { + // Hapus per karakter, max 100 (jaga-jaga) + for (let i = 0; i < 100; i++) { + const v = await el.evaluate((node) => node.value || '').catch(() => ''); + if (!v) break; + await pageOrFrame.keyboard.press('Backspace'); + await humanDelay(20, 60); + } + } + + await humanDelay(60, 160); + await humanType(pageOrFrame, String(value), options); + await humanDelay(80, 200); +} + +/** + * Versi humanFill khusus Locator API (page.locator(...)) — kadang lebih + * stabil daripada selector string karena Locator auto-wait. + */ +export async function humanFillLocator(page, locator, value, options = {}) { + await locator.scrollIntoViewIfNeeded().catch(() => {}); + await locator.hover({ timeout: 3000 }).catch(() => {}); + await humanDelay(80, 200); + await locator.click({ delay: randInt(40, 120) }).catch(async () => { + // fallback: focus aja + await locator.focus().catch(() => {}); + }); + await humanDelay(80, 200); + + await page.keyboard.press('Control+A'); + await humanDelay(40, 100); + await page.keyboard.press('Backspace'); + await humanDelay(60, 160); + + await humanType(page, String(value), options); + await humanDelay(80, 200); +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..88af09a --- /dev/null +++ b/index.js @@ -0,0 +1,2085 @@ +#!/usr/bin/env node +/** + * Xiaomi MiMo Auto-Registration Tool + * Browser automation approach - bypasses crypto complexity + */ + +import { chromium } from 'playwright'; +import fetch from 'node-fetch'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { generateFingerprint, buildInitScript, buildExtraHeaders } from './fingerprint.js'; +import { humanFill, humanFillLocator, humanClick, humanType, humanDelay } from './human.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load config +const config = JSON.parse(readFileSync(join(__dirname, 'config.json'), 'utf8')); + +// ============================================================================ +// TEMPMAIL CLIENT +// ============================================================================ + +class TempmailClient { + constructor(apiUrl) { + this.apiUrl = apiUrl; + this.sessionId = null; + } + + async initSession() { + if (this.sessionId) return this.sessionId; + + const response = await fetch(`${this.apiUrl}/session`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error(`Failed to init session: ${response.status}`); + } + + const data = await response.json(); + this.sessionId = data.sessionId || data.id || data.session_id; + return this.sessionId; + } + + async createInbox() { + await this.initSession(); + + const response = await fetch(`${this.apiUrl}/inboxes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-session-id': this.sessionId + } + }); + + if (!response.ok) { + throw new Error(`Tempmail API error: ${response.status}`); + } + + const data = await response.json(); + return data.address; + } + + async getMessages(address, maxWait = 120000, interval = 5000) { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + const headers = { 'Content-Type': 'application/json' }; + if (this.sessionId) { + headers['x-session-id'] = this.sessionId; + } + + try { + const response = await fetch(`${this.apiUrl}/inboxes/${address}/messages`, { + method: 'GET', + headers: headers + }); + + if (response.ok) { + const messages = await response.json(); + if (messages && messages.length > 0) { + return messages; + } + } else { + console.log(` [Tempmail] Poll warning: HTTP ${response.status} ${response.statusText}`); + } + } catch (err) { + console.log(` [Tempmail] Fetch error: ${err.message}`); + } + + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error('Timeout waiting for email'); + } + + extractVerificationCode(messages) { + for (const msg of messages) { + const subject = msg.subject || ''; + const body = msg.body || msg.text || ''; + const content = subject + ' ' + body; + + // Common verification code patterns + const patterns = [ + /verification code[:\s]+([0-9]{4,8})/i, + /code[:\s]+([0-9]{4,8})/i, + /([0-9]{6})/, // 6-digit code + ]; + + for (const pattern of patterns) { + const match = content.match(pattern); + if (match) { + return match[1]; + } + } + } + + throw new Error('Could not extract verification code from emails'); + } +} + +// ============================================================================ +// 2CAPTCHA CLIENT +// ============================================================================ + +class CaptchaSolver { + constructor(apiKey) { + this.apiKey = apiKey; + this.baseUrl = 'https://2captcha.com'; + } + + async solveCaptcha(sitekey, pageUrl) { + console.log('[Captcha] Creating task...'); + + const taskId = await this.createTask(sitekey, pageUrl); + console.log(`[Captcha] Task ID: ${taskId}`); + + const solution = await this.waitForSolution(taskId); + console.log('[Captcha] ✓ Solved'); + + return solution; + } + + async solveImageCaptcha(base64Image) { + console.log('[Captcha] Creating image captcha task...'); + const url = `${this.baseUrl}/in.php`; + + const params = new URLSearchParams(); + params.append('key', this.apiKey); + params.append('method', 'base64'); + params.append('body', base64Image); + params.append('json', '1'); + + const response = await fetch(url, { + method: 'POST', + body: params + }); + + const data = await response.json(); + if (data.status !== 1) { + throw new Error(`2Captcha image task failed: ${data.request || 'Unknown error'}`); + } + + const taskId = data.request; + console.log(`[Captcha] Image Task ID: ${taskId}`); + + const solution = await this.waitForSolution(taskId); + console.log('[Captcha] ✓ Solved'); + + return solution; + } + + async createTask(sitekey, pageUrl) { + const url = `${this.baseUrl}/in.php?key=${this.apiKey}&method=userrecaptcha&googlekey=${sitekey}&pageurl=${encodeURIComponent(pageUrl)}&json=1`; + + const response = await fetch(url); + const data = await response.json(); + + if (data.status !== 1) { + throw new Error(`2Captcha task creation failed: ${data.request || 'Unknown error'}`); + } + + return data.request; + } + + async waitForSolution(taskId, maxWait = 180000, interval = 5000) { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + await new Promise(resolve => setTimeout(resolve, interval)); + + const url = `${this.baseUrl}/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`; + const response = await fetch(url); + const data = await response.json(); + + if (data.status === 1) { + return data.request; + } + + if (data.request !== 'CAPCHA_NOT_READY') { + throw new Error(`2Captcha error: ${data.request}`); + } + + const elapsed = Math.floor((Date.now() - startTime) / 1000); + console.log(`[Captcha] Waiting... (${elapsed}s)`); + } + + throw new Error('Captcha solving timeout'); + } +} + +// ============================================================================ +// XIAOMI MIMO REGISTRATION +// ============================================================================ + +class MimoRegistration { + constructor(config) { + this.config = config; + this.tempmail = new TempmailClient(config.tempmail.apiUrl); + this.captcha = new CaptchaSolver(config.captcha.apiKey); + this.browser = null; + this.page = null; + } + + async run() { + try { + console.log('═'.repeat(70)); + console.log(' Xiaomi MiMo Auto-Registration'); + console.log(' Browser Automation Approach'); + console.log('═'.repeat(70)); + console.log(); + + // Step 1: Create tempmail + console.log('[Step 1/7] Creating temporary email...'); + const email = await this.tempmail.createInbox(); + console.log(`✓ Email created: ${email}`); + console.log(); + + // Step 2: Launch browser with randomized fingerprint + console.log('[Step 2/7] Launching browser...'); + const fp = generateFingerprint(); + this.fingerprint = fp; + console.log(` ↳ UA : Chrome ${fp.chromeMajor} on Win64`); + console.log(` ↳ Viewport : ${fp.viewport.width}x${fp.viewport.height} (DPR ${fp.deviceScaleFactor})`); + console.log(` ↳ Locale : ${fp.locale} TZ: ${fp.timezone}`); + console.log(` ↳ CPU/Mem : ${fp.hardwareConcurrency} cores / ${fp.deviceMemory} GB`); + console.log(` ↳ GPU : ${fp.webgl.renderer.substring(0, 70)}…`); + + this.browser = await chromium.launch({ + headless: config.browser.headless, + channel: 'chrome', + args: [ + // Hindari flag --headless yg kelihatan, dan window-size konsisten dgn viewport + `--window-size=${fp.viewport.width},${fp.viewport.height}`, + '--disable-blink-features=AutomationControlled', + ], + }); + + // Pakai context terpisah biar bisa set UA/viewport/timezone/locale per loop + const context = await this.browser.newContext({ + userAgent: fp.userAgent, + viewport: fp.viewport, + deviceScaleFactor: fp.deviceScaleFactor, + locale: fp.locale, + timezoneId: fp.timezone, + screen: { width: fp.screen.width, height: fp.screen.height }, + extraHTTPHeaders: buildExtraHeaders(fp), + }); + + // Inject fingerprint overrides sebelum kode situs sempat baca navigator/screen/canvas + await context.addInitScript({ content: buildInitScript(fp) }); + + this.page = await context.newPage(); + + // Wrap screenshot method to only capture if enabled or if it's an error screenshot + const originalScreenshot = this.page.screenshot.bind(this.page); + this.page.screenshot = async (options = {}) => { + const isErrorScreenshot = options.path && options.path.includes('error'); + if (isErrorScreenshot || this.config.browser.screenshots === true) { + return originalScreenshot(options); + } + return Buffer.alloc(0); + }; + this.page.on('console', msg => { + const txt = msg.text(); + if (txt.includes('error') || txt.includes('failed') || txt.includes('Success') || txt.includes('warn')) { + console.log(` [Browser Console] ${msg.type()}: ${txt.substring(0, 150)}`); + } + }); + this.page.on('pageerror', err => console.log(` [Browser Error] ${err.message}`)); + this.page.on('request', req => { + const url = req.url(); + if (url.includes('apply') || url.includes('form') || url.includes('platform') && req.method() === 'POST') { + console.log(` [Network Request] ${req.method()} ${url}`); + } + }); + this.page.on('response', res => { + const url = res.url(); + if (url.includes('apply') || url.includes('form') || url.includes('platform') && res.request().method() === 'POST') { + console.log(` [Network Response] ${res.status()} ${url}`); + } + }); + console.log('✓ Browser launched'); + console.log(); + + // Step 3: Navigate to registration page + console.log('[Step 3/7] Opening registration page...'); + await this.page.goto(this.config.xiaomi.referralLink, { + waitUntil: 'networkidle', + timeout: this.config.browser.timeout + }); + console.log('✓ Page loaded'); + console.log(); + + // Step 4: Fill registration form + console.log('[Step 4/7] Filling registration form...'); + await this.fillRegistrationForm(email); + console.log('✓ Form filled'); + console.log(); + + // Step 5: Submit registration (captcha appears AFTER click) + console.log('[Step 5/7] Submitting registration...'); + await this.submitRegistration(); + console.log('✓ Submit clicked'); + console.log(); + + // Step 6: Handle Xiaomi captcha modal (appears after submit) + console.log('[Step 6/7] Handling captcha...'); + await this.handleXiaomiCaptcha(); + await this.handleImageCaptcha(); + console.log('✓ Captcha handled'); + console.log(); + + // Step 7: Verify email + console.log('[Step 7/7] Verifying email...'); + await this.verifyEmail(email); + console.log('✓ Email verified'); + console.log(); + + // Get cookies for session tokens + console.log(' Extracting session cookies...'); + const cookies = await this.page.context().cookies(); + const passTokenCookie = cookies.find(c => c.name === 'passToken'); + const serviceTokenCookie = cookies.find(c => c.name === 'serviceToken'); + + const passToken = passTokenCookie ? passTokenCookie.value : null; + const serviceToken = serviceTokenCookie ? serviceTokenCookie.value : null; + + // 1. Redeem invite code (dulu sebelum apply form, biar saldo $2 langsung masuk) + try { + await this.redeemInviteCode(); + } catch (inviteErr) { + console.log(' ! Failed to complete invite code redemption:', inviteErr.message); + } + + // 2. Create API Key + let apiKey = null; + try { + apiKey = await this.createApiKey(); + } catch (keyErr) { + console.log(' ! Failed to create API Key:', keyErr.message); + } + + // 3. Fill Ultraspeed form (terakhir — tergantung saldo dulu udah masuk) + try { + await this.fillUltraspeedForm(email); + } catch (formErr) { + console.log(' ! Failed to complete Ultraspeed form submission:', formErr.message); + } + + // Success + console.log('═'.repeat(70)); + console.log('✅ REGISTRATION SUCCESSFUL'); + console.log('═'.repeat(70)); + console.log(`Email: ${email}`); + console.log(`Password: ${this.config.xiaomi.password}`); + console.log(`passToken: ${passToken || 'Not found'}`); + console.log(`serviceToken: ${serviceToken || 'Not found'}`); + console.log(`API Key: ${apiKey || 'Not created'}`); + console.log(); + + return { + email, + password: this.config.xiaomi.password, + passToken, + serviceToken, + apiKey + }; + + } catch (error) { + console.error(); + console.error('═'.repeat(70)); + console.error('❌ REGISTRATION FAILED'); + console.error('═'.repeat(70)); + console.error(error.message); + console.error(); + + // Save screenshot on error + if (this.page) { + try { + const screenshotPath = join(__dirname, 'mimo-error.png'); + await this.page.screenshot({ path: screenshotPath, fullPage: true }); + console.error(`Screenshot saved: ${screenshotPath}`); + } catch (e) { + console.error('Could not save screenshot:', e.message); + } + } + + throw error; + } finally { + if (this.browser) { + await this.browser.close(); + } + } + } + + async fillRegistrationForm(email) { + // Click "Sign up" tab to show registration form + try { + console.log(' Looking for Sign up tab...'); + await this.page.click('text=Sign up', { timeout: 5000 }); + console.log(' ✓ Clicked Sign up tab'); + await this.page.waitForTimeout(2000); + } catch (e) { + console.log(' ! Sign up tab not found, trying alternative selectors...'); + // Try CSS selectors as fallback + const tabs = await this.page.$$('button, a, [role="tab"]'); + for (const tab of tabs) { + const text = await tab.textContent(); + if (text && text.toLowerCase().includes('sign up')) { + await tab.click(); + console.log(' ✓ Clicked Sign up tab (alternative)'); + await this.page.waitForTimeout(2000); + break; + } + } + } + + // Wait for form elements to be visible + await this.page.waitForSelector('input[type="email"], input[name="email"], input[placeholder*="mail"]', { + timeout: 30000 + }); + + // Fill email — klik dulu, baru ketik per karakter + await humanFill(this.page, 'input[type="email"], input[name="email"], input[placeholder*="mail"]', email); + console.log(' ✓ Filled email'); + await humanDelay(200, 500); + + // Fill password + const passwordFields = await this.page.$$('input[type="password"]'); + if (passwordFields.length >= 1) { + await humanFill(this.page, passwordFields[0], this.config.xiaomi.password); + console.log(' ✓ Filled password'); + } + + // Fill confirm password (second password field) + if (passwordFields.length >= 2) { + await humanDelay(200, 450); + await humanFill(this.page, passwordFields[1], this.config.xiaomi.password); + console.log(' ✓ Filled confirm password'); + } + + // Check terms agreement checkbox + const termsCheckbox = await this.page.$('input[type="checkbox"], input[name*="agree"], input[name*="terms"]'); + if (termsCheckbox) { + await termsCheckbox.check(); + console.log(' ✓ Checked terms agreement'); + } + } + + async handleXiaomiCaptcha() { + // Wait for Xiaomi captcha modal to appear after submit + console.log(' Waiting for Xiaomi captcha modal...'); + + try { + // Wait for captcha modal to become visible + await this.page.waitForSelector('.miverify_wind:not([style*="display: none"])', { + timeout: 10000 + }); + console.log(' ✓ Captcha modal appeared'); + + // Check if it is the image captcha instead of reCAPTCHA + const isImageCaptcha = await this.page.$('input[name="icode"]').then(async el => el ? await el.isVisible() : false); + if (isImageCaptcha) { + console.log(' Image captcha detected directly, skipping reCAPTCHA check'); + return; + } + + // Wait for the reCAPTCHA iframe to load in the DOM + try { + await this.page.waitForSelector('iframe[src*="recaptcha"]', { timeout: 10000 }); + console.log(' ✓ reCAPTCHA iframe detected'); + } catch (e) { + console.log(' ! reCAPTCHA iframe not found by selector, scanning frames...'); + } + + let retries = 5; + let solved = false; + + while (retries > 0 && !solved) { + // Check if image captcha is visible (in case it appeared after some actions) + const isImageCaptchaInsideLoop = await this.page.$('input[name="icode"]').then(async el => el ? await el.isVisible() : false); + if (isImageCaptchaInsideLoop) { + console.log(' Image captcha detected inside loop, switching solver...'); + break; + } + + const frames = this.page.frames(); // Get fresh list of frames in each attempt + let anchorFrame = null; + + for (const frame of frames) { + const url = frame.url(); + if (url.includes('recaptcha') && url.includes('anchor')) { + anchorFrame = frame; + break; + } + } + + if (anchorFrame) { + console.log(` Found reCAPTCHA checkbox iframe (attempt ${6 - retries}/5)`); + + try { + await anchorFrame.waitForSelector('.recaptcha-checkbox-border', { timeout: 5000 }); + await anchorFrame.click('.recaptcha-checkbox-border'); + console.log(' ✓ Clicked reCAPTCHA checkbox'); + + // Wait for either checkmark (success) or image challenge + try { + await anchorFrame.waitForSelector('.recaptcha-checkbox-checked', { timeout: 3000 }); + console.log(' ✓ reCAPTCHA verified (checkmark appeared)'); + solved = true; + } catch (e) { + // Checkmark didn't appear - likely image challenge + console.log(' Image challenge detected, solving via 2Captcha...'); + + // Extract sitekey from reCAPTCHA iframe + const sitekey = await this.page.evaluate(() => { + const frames = document.querySelectorAll('iframe[src*="recaptcha"]'); + for (const frame of frames) { + const match = frame.src.match(/k=([^&]+)/); + if (match) return match[1]; + } + return null; + }); + + if (!sitekey) { + throw new Error('Could not extract reCAPTCHA sitekey'); + } + + console.log(` Sitekey: ${sitekey}`); + + // Solve via 2Captcha + const solution = await this.captcha.solveCaptcha(sitekey, this.page.url()); + + // Inject solution and trigger callback + const callbackSuccess = await this.page.evaluate((token) => { + // 1. Inject token into textarea + const textareas = document.querySelectorAll('[id^="g-recaptcha-response"], [name^="g-recaptcha-response"]'); + textareas.forEach(textarea => { + textarea.value = token; + textarea.dispatchEvent(new Event('change', { bubbles: true })); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + + // 2. Find and execute recaptcha callback in ___grecaptcha_cfg + if (typeof ___grecaptcha_cfg !== 'undefined' && ___grecaptcha_cfg.clients) { + const clients = ___grecaptcha_cfg.clients; + let called = false; + + function recursiveSearch(obj) { + if (called) return; + for (let key in obj) { + if (called) return; + let val = obj[key]; + if (val !== null && typeof val === 'object') { + if (val.callback && typeof val.callback === 'function') { + try { + val.callback(token); + called = true; + } catch (err) { + console.error('Error invoking callback:', err); + } + return; + } + recursiveSearch(val); + } + } + } + + Object.keys(clients).forEach(k => { + if (!called) { + recursiveSearch(clients[k]); + } + }); + return called; + } + return false; + }, solution); + + console.log(` ✓ Solution injected, callback executed: ${callbackSuccess}`); + solved = true; + } + + // Modal should auto-close or we proceed immediately + await this.page.waitForTimeout(2000); + + // Check if modal closed or click proceed button if exists + const proceedBtn = await this.page.$('.miverify_panel_next, button:has-text("Continue"), button:has-text("Next")'); + if (proceedBtn) { + await proceedBtn.click({ timeout: 5000 }).catch(err => { + console.log(' ! Proceed button click failed/ignored (might have already proceeded)'); + }); + } + + } catch (e) { + console.log(` ! Captcha error: ${e.message}`); + } + } else { + console.log(` reCAPTCHA iframe not ready yet (attempt ${6 - retries}/5)`); + } + + if (!solved) { + retries--; + if (retries > 0) { + console.log(` Waiting before retry... (${retries} attempts left)`); + await this.page.waitForTimeout(3000); + } + } + } + + } catch (e) { + console.log(' No captcha modal appeared (might have auto-passed)'); + } + } + + async handleImageCaptcha() { + console.log(' Checking for image verification code modal...'); + + try { + // 1. Wait up to 7 seconds for the image captcha element to appear + // If it doesn't appear, it means registration went straight to email verification + try { + await this.page.waitForSelector('img[src*="captcha"], img[src*="getCaptcha"], img[src*="code"], img[class*="captcha"]', { + timeout: 7000 + }); + console.log(' ✓ Image captcha element detected'); + } catch (e) { + console.log(' No image captcha element detected (likely proceeded to email verification)'); + return; + } + + let imageRetries = 3; + let solvedImage = false; + + while (imageRetries > 0 && !solvedImage) { + // 2. Find the input field inside the modal + const codeInput = await this.page.$('input[name="icode"]'); + if (!codeInput) { + console.log(' No image captcha input found'); + return; + } + + // 3. Locate the captcha image element + let captchaImg = await this.page.$('img[src*="captcha"], img[src*="getCaptcha"], img[src*="code"], img[class*="captcha"]'); + if (!captchaImg) { + throw new Error('Could not locate captcha image element'); + } + + // 4. Take a screenshot of the captcha image element + console.log(` Taking screenshot of captcha image (attempt ${4 - imageRetries}/3)...`); + const imgBuffer = await captchaImg.screenshot(); + const base64Image = imgBuffer.toString('base64'); + + // 5. Solve using 2Captcha + const solution = await this.captcha.solveImageCaptcha(base64Image); + console.log(` ✓ Image captcha solved: ${solution}`); + + // 6. Fill code and submit + await humanFill(this.page, codeInput, solution, { clear: 'select-all' }); + console.log(' ✓ Filled captcha code'); + + // Find submit button inside the modal/dialog container + let submitBtn = null; + try { + const parent = await codeInput.evaluateHandle(el => el.closest('div, form, [class*="modal"], [class*="dialog"]')); + if (parent) { + submitBtn = await parent.$('button, [role="button"], input[type="submit"]'); + } + } catch (err) { + console.log(' Could not find submit button via parent container:', err.message); + } + + // If not found in parent, try global fallback selectors + if (!submitBtn) { + submitBtn = await this.page.$('.miverify_wind button, .modal button, [class*="dialog"] button'); + } + + if (submitBtn) { + try { + // Use a short 4s timeout and click + await submitBtn.click({ timeout: 4000 }); + console.log(' ✓ Clicked Submit button'); + } catch (clickErr) { + console.log(' ! Button click failed or timed out, pressing Enter key instead...'); + await codeInput.press('Enter'); + console.log(' ✓ Pressed Enter on input'); + } + } else { + await codeInput.press('Enter'); + console.log(' ✓ Pressed Enter on input'); + } + + // Wait a moment for validation to process (either modal disappears or error shows) + await this.page.waitForTimeout(4000); + + // Check if error message is visible or if modal is still open + const isErrorVisible = await this.page.evaluate(() => { + const bodyText = document.body.innerText; + return bodyText.includes('incorrect') || bodyText.includes('Incorrect') || bodyText.includes('salah'); + }); + + const isStillVisible = await codeInput.isVisible().catch(() => false); + + if (isStillVisible && isErrorVisible) { + console.log(' ! Entered captcha code was incorrect. Retrying with a new captcha...'); + + // Click the captcha image to refresh it + try { + await captchaImg.click(); + await this.page.waitForTimeout(2000); // Wait for new image to load + } catch (err) { + console.log(' Could not click captcha image to refresh'); + } + + imageRetries--; + } else { + console.log(' ✓ Image captcha modal successfully handled (no error visible or modal closed)'); + solvedImage = true; + } + } + + if (!solvedImage) { + throw new Error('Failed to solve image captcha after multiple attempts'); + } + + // Wait a moment for modal transition + await this.page.waitForTimeout(3000); + } catch (e) { + console.log(` ! Failed to handle image captcha: ${e.message}`); + throw e; + } + } + + async handleCaptcha() { + // Wait for reCAPTCHA iframe to load (it appears dynamically after form is filled) + console.log(' Waiting for reCAPTCHA to load...'); + await this.page.waitForTimeout(3000); + + // Debug: Save HTML to see what's actually on page + const html = await this.page.content(); + const fs = await import('fs'); + fs.writeFileSync('/tmp/page-source.html', html); + console.log(' Saved HTML source to /tmp/page-source.html'); + + // Look for any reCAPTCHA iframe + const allIframes = await this.page.$$('iframe'); + console.log(` Found ${allIframes.length} iframes on page`); + + for (const iframe of allIframes) { + const src = await iframe.getAttribute('src'); + const title = await iframe.getAttribute('title'); + + if ((src && src.includes('recaptcha')) || (title && title.includes('reCAPTCHA'))) { + console.log(` Found reCAPTCHA iframe: title="${title}", src="${src?.substring(0, 50)}..."`); + + // Try to click inside the iframe + try { + const frame = await iframe.contentFrame(); + if (frame) { + // Wait for checkbox to be available + await frame.waitForSelector('.recaptcha-checkbox-border', { timeout: 5000 }); + await frame.click('.recaptcha-checkbox-border'); + console.log(' ✓ Clicked reCAPTCHA checkbox'); + await this.page.waitForTimeout(3000); + return; + } + } catch (e) { + console.log(` ! Failed to click checkbox: ${e.message}`); + } + } + } + + console.log(' No reCAPTCHA iframe found'); + } + + async submitRegistration() { + // Find and click submit button + const submitButton = await this.page.$('button[type="submit"], input[type="submit"], button:has-text("Sign Up"), button:has-text("Register")'); + + if (!submitButton) { + throw new Error('Submit button not found'); + } + + await submitButton.click(); + + // Wait for navigation or captcha modal to appear + console.log(' Waiting for navigation or captcha modal...'); + try { + await Promise.race([ + this.page.waitForNavigation({ timeout: 10000 }), + this.page.waitForSelector('.miverify_wind:not([style*="display: none"])', { timeout: 10000 }) + ]); + } catch (e) { + // Ignore timeout/error, proceed to captcha check + await this.page.waitForTimeout(1000); + } + } + + async verifyEmail(email) { + // Wait for verification email + console.log(' Waiting for verification email...'); + const messages = await this.tempmail.getMessages(email); + + // Extract verification code + const code = this.tempmail.extractVerificationCode(messages); + console.log(` Verification code: ${code}`); + + // Find verification input field with robust waiting and selectors + console.log(' Waiting for verification code input field to be visible...'); + let verificationInput = null; + try { + verificationInput = await this.page.waitForSelector('input[name="ticket"], input[name="code"], input[placeholder*="code" i], input[placeholder*="verification" i], input[placeholder*="Enter code" i]', { + timeout: 15000 + }); + } catch (e) { + console.log(' ! Verification input not found by selectors. Logging all page inputs:'); + const inputs = await this.page.evaluate(() => { + return Array.from(document.querySelectorAll('input')).map(el => ({ + tag: el.tagName, + type: el.type, + id: el.id, + className: el.className, + placeholder: el.placeholder, + name: el.name, + visible: el.offsetWidth > 0 && el.offsetHeight > 0 + })); + }); + console.log(JSON.stringify(inputs, null, 2)); + throw new Error('Verification code input not found'); + } + + // Fill and submit verification code — ketik per karakter biar gak instant paste + await humanFill(this.page, verificationInput, code); + console.log(' ✓ Filled verification code'); + + const verifyButton = await this.page.$('button:has-text("Submit"), button:has-text("Verify"), button:has-text("Confirm"), button[type="submit"]'); + if (verifyButton) { + try { + await verifyButton.click({ timeout: 5000 }); + console.log(' ✓ Clicked Submit/Verify button'); + } catch (clickErr) { + console.log(' ! Submit button click failed, pressing Enter key instead...'); + await verificationInput.press('Enter'); + } + } else { + await verificationInput.press('Enter'); + console.log(' ✓ Pressed Enter on input'); + } + + // Wait for verification success + await this.page.waitForTimeout(5000); + } + + async clickConsoleMenu() { + // Setelah signup, user biasanya landed di halaman referral atau dashboard. + // Daripada page.goto langsung ke /console/balance (kelihatan otomatis), + // klik link/menu "Console" beneran biar pola navigasinya mirip manusia. + console.log(' Looking for Console menu...'); + + // Coba beberapa selector — text=, role=link, atau tombol berlabel Console + const candidates = [ + 'a:has-text("Console")', + 'button:has-text("Console")', + '[role="link"]:has-text("Console")', + 'header a:has-text("Console"), nav a:has-text("Console")', + ]; + + for (const selector of candidates) { + try { + const el = this.page.locator(selector).first(); + const count = await el.count(); + if (count === 0) continue; + + await el.waitFor({ state: 'visible', timeout: 3000 }); + await el.scrollIntoViewIfNeeded().catch(() => {}); + await humanDelay(200, 400); + await el.hover({ timeout: 2500 }).catch(() => {}); + await humanDelay(150, 300); + + // Klik native — biar cookies/session-nya kebawa via SPA navigation + await el.click({ timeout: 5000 }); + console.log(` ✓ Clicked Console menu (selector: ${selector})`); + + // Tunggu URL berubah ke /console/* atau networkidle + await this.page.waitForURL(/\/console/, { timeout: 10000 }).catch(() => {}); + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await humanDelay(800, 1500); + + const url = this.page.url(); + console.log(` ✓ Now on: ${url}`); + return true; + } catch (e) { + // selector berikutnya + } + } + + // Fallback DOM eval: cari elemen apa pun yang teksnya "Console" dan clickable + const clicked = await this.page.evaluate(() => { + const all = Array.from(document.querySelectorAll('a, button, [role="link"], [role="button"]')); + const target = all.find(el => { + const txt = (el.textContent || '').trim(); + return txt === 'Console' && el.offsetHeight > 0 && el.offsetWidth > 0; + }); + if (target) { + target.scrollIntoView({ block: 'center' }); + target.click(); + return true; + } + return false; + }); + + if (clicked) { + console.log(' ✓ Clicked Console menu via DOM eval fallback'); + await this.page.waitForURL(/\/console/, { timeout: 10000 }).catch(() => {}); + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await humanDelay(800, 1500); + return true; + } + + console.log(' ! Console menu not found — caller will fallback to direct URL'); + return false; + } + + async readBalance() { + // Baca TOTAL balance dari halaman /console/balance. + // + // Layout halaman Xiaomi MiMo: + // [Balance card] [Alert card] + // $ 2.72 $ - + // Cash Balance: $0.00 Balance Alerts Off + // Bonus Balance: $2.72 + // + // [Recharge section] + // $50 $100 $200 ... ← preset buttons, bukan saldo! + // + // Yang dibutuhkan: angka di bawah label "Balance" (TOTAL = Cash + Bonus). + // Akun baru: Cash $0.00 + Bonus $0.72/$2.72. + // + // Strategi: + // 1. Cari "Balance\n$ X.XX" — total balance di card utama + // 2. Sum Cash Balance + Bonus Balance kalau keduanya ketemu + // 3. Fallback: angka $X.XX terkecil di halaman (saldo akun baru < $5) + try { + const value = await this.page.evaluate(() => { + const text = document.body.innerText || ''; + + // 1. "Balance" word-boundary, diikuti newline + "$ X.XX" + // Hindari "Cash Balance" / "Bonus Balance" / "Token Balance" / + // "Alert Threshold". Pakai negative lookbehind via leading marker. + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + // Match exactly "Balance" (kasus apa pun), bukan "Cash/Bonus/Token Balance" + if (/^balance$/i.test(line)) { + // Ambil angka $X.XX di baris berikut atau dalam 2 baris + for (let j = i + 1; j < Math.min(i + 3, lines.length); j++) { + const m = lines[j].match(/\$\s*([0-9]+\.[0-9]{2})/); + if (m) return parseFloat(m[1]); + } + } + } + + // 2. Sum Cash + Bonus + const cash = text.match(/cash\s+balance\s*[:\s]\s*\$\s*([0-9]+\.[0-9]{2})/i); + const bonus = text.match(/bonus\s+balance\s*[:\s]\s*\$\s*([0-9]+\.[0-9]{2})/i); + if (cash && bonus) { + return parseFloat(cash[1]) + parseFloat(bonus[1]); + } + if (bonus) return parseFloat(bonus[1]); + if (cash) return parseFloat(cash[1]); + + // 3. Fallback: angka $X.XX terkecil < $50 (saldo akun normal < $5, + // Recharge preset $50/$100/$200 dst pasti lebih besar) + const allWithDecimal = [...text.matchAll(/\$\s*([0-9]+\.[0-9]{2})/g)] + .map(m => parseFloat(m[1])) + .filter(n => !isNaN(n) && n > 0 && n < 50); + if (allWithDecimal.length > 0) { + return Math.min(...allWithDecimal); + } + + return null; + }); + return typeof value === 'number' && !isNaN(value) ? value : null; + } catch (e) { + return null; + } + } + + async waitForOverlaysGone(timeout = 6000) { + // Tunggu Ant Design modal-mask/wrap beneran hilang. + // Setelah Confirm, mask masih bisa ke-render ~300-500ms dan ngeblock klik. + try { + await this.page.waitForFunction(() => { + const masks = Array.from(document.querySelectorAll( + '.ant-modal-mask, .ant-modal-wrap, .ant-modal-root .ant-modal-mask' + )); + return masks.every(m => { + const style = window.getComputedStyle(m); + if (style.display === 'none' || style.visibility === 'hidden') return true; + if (m.offsetHeight === 0 || m.offsetWidth === 0) return true; + // Mask yg fade-out (opacity ~0) juga sudah aman + const op = parseFloat(style.opacity || '1'); + return op < 0.05; + }); + }, { timeout }); + console.log(' ✓ Overlays gone'); + } catch (e) { + console.log(' ! Some overlays still visible after timeout — continuing anyway'); + } + } + + async handleTermsModal() { + // Modal Terms & Agreements muncul sekali per session — bisa di balance, + // api-keys, atau ultraspeed page tergantung mana yang dibuka pertama. + // Method ini idempotent: kalau modal nggak ada, langsung return. + // + // Catatan: Ant Design ngehidden `.ant-checkbox-input` (display:none). + // Klik input langsung pakai force=true ngeset value tapi nggak nge-trigger + // React onChange — akibatnya tombol Confirm tetap disabled. + // Solusi: klik wrapper visible (`.ant-checkbox` / `.ant-checkbox-wrapper`), + // lalu tunggu Confirm benar-benar enabled. + try { + const termsModalOpen = await this.page.evaluate(() => { + const wraps = Array.from(document.querySelectorAll('.ant-modal-wrap')); + return wraps.some(wrap => { + const style = wrap.style.display; + if (style === 'none' || wrap.offsetHeight === 0) return false; + const hasCheckbox = wrap.querySelector('.ant-checkbox-input, input[type="checkbox"]'); + const text = (wrap.innerText || '').toLowerCase(); + return !!hasCheckbox && (text.includes('agree') || text.includes('terms') || text.includes('agreement')); + }); + }); + + if (!termsModalOpen) { + console.log(' No Terms & Agreements modal open'); + return false; + } + + console.log(' Terms & Agreements modal detected (open), handling...'); + + // 1) Klik wrapper checkbox yang visible — bukan input hidden + // Coba urutan: .ant-checkbox-wrapper > .ant-checkbox > label terkait + const checkboxClicked = await this.page.evaluate(() => { + const modal = Array.from(document.querySelectorAll('.ant-modal-wrap')) + .find(w => w.offsetHeight > 0 && w.style.display !== 'none'); + if (!modal) return { ok: false, reason: 'modal disappeared' }; + + // Prioritas: wrapper visible + const wrapper = + modal.querySelector('.ant-checkbox-wrapper') || + modal.querySelector('.ant-checkbox') || + modal.querySelector('label.ant-checkbox-wrapper'); + + if (!wrapper) return { ok: false, reason: 'no checkbox wrapper found' }; + + // Klik native — biar Ant Design React onChange ke-trigger + wrapper.click(); + + // Cek sekarang checkbox-nya checked atau belum + const input = modal.querySelector('.ant-checkbox-input, input[type="checkbox"]'); + return { + ok: true, + checked: input ? input.checked : null, + wrapperClass: wrapper.className, + }; + }); + + console.log(` Checkbox click result: ${JSON.stringify(checkboxClicked)}`); + + if (!checkboxClicked.ok) { + // Fallback: klik via Playwright dengan force + await this.page.click('.ant-modal-wrap .ant-checkbox-wrapper, .ant-modal-wrap .ant-checkbox', { force: true }).catch(() => {}); + console.log(' ✓ Clicked Terms checkbox (fallback Playwright)'); + } + + // Beri waktu Ant Design update state + enable tombol Confirm + await this.page.waitForTimeout(800); + + // 2) Tunggu Confirm jadi enabled (max 5 detik) + const confirmReady = await this.page.waitForFunction(() => { + const modal = Array.from(document.querySelectorAll('.ant-modal-wrap')) + .find(w => w.offsetHeight > 0 && w.style.display !== 'none'); + if (!modal) return false; + const btns = Array.from(modal.querySelectorAll('.ant-modal-footer button, button')); + const confirm = btns.find(b => /confirm|agree|continue|ok/i.test(b.textContent || '')); + if (!confirm) return false; + const disabled = confirm.disabled || confirm.classList.contains('ant-btn-disabled') || confirm.getAttribute('disabled') !== null; + return !disabled; + }, { timeout: 5000 }).catch(() => null); + + if (!confirmReady) { + console.log(' ! Confirm button stayed disabled after checkbox click'); + // Coba klik checkbox lagi (kadang butuh dua kali) + await this.page.click('.ant-modal-wrap .ant-checkbox-wrapper, .ant-modal-wrap .ant-checkbox', { force: true }).catch(() => {}); + await this.page.waitForTimeout(1500); + } + + // 3) Klik Confirm + const confirmBtn = await this.page.$('.ant-modal-wrap .ant-modal-footer .ant-btn-primary:not([disabled]):not(.ant-btn-disabled), .ant-modal-wrap button:has-text("Confirm"):not([disabled])'); + if (confirmBtn) { + await confirmBtn.click({ force: true }); + console.log(' ✓ Clicked Terms & Agreements Confirm button'); + } else { + // Fallback: cari tombol primary apa pun di modal + const fallback = await this.page.$('.ant-modal-wrap .ant-btn-primary'); + if (fallback) { + await fallback.click({ force: true }); + console.log(' ✓ Clicked Confirm (fallback ant-btn-primary)'); + } else { + console.log(' ! Confirm button not found in Terms modal'); + } + } + + // 4) Verifikasi modal beneran tertutup (max 5 detik) + const closed = await this.page.waitForFunction(() => { + const wraps = Array.from(document.querySelectorAll('.ant-modal-wrap')); + return !wraps.some(w => { + if (w.style.display === 'none' || w.offsetHeight === 0) return false; + const text = (w.innerText || '').toLowerCase(); + return text.includes('agree') || text.includes('terms'); + }); + }, { timeout: 5000 }).catch(() => null); + + if (closed) { + console.log(' ✓ Terms modal closed'); + } else { + console.log(' ! Terms modal still visible after Confirm click'); + await this.page.screenshot({ path: 'screenshot-terms-stuck.png' }).catch(() => {}); + } + + await this.page.waitForTimeout(800); + return true; + } catch (e) { + console.log(' ! Failed to handle Terms & Agreements modal:', e.message); + return false; + } + } + + async handleOAuthRedirect() { + let currentUrl = this.page.url(); + if (currentUrl.includes('account.xiaomi.com') || currentUrl.includes('login') || currentUrl.includes('auth')) { + console.log(' Redirected to authorization page, logging in...'); + + // Wait a moment for page/modals to load + await this.page.waitForTimeout(2000); + + // Check if "Attention" agreement modal is present and click Agree + const agreeBtn = await this.page.$('.miui-modal-wrap button:has-text("Agree"), button:has-text("Agree"), .miui-modal-wrap .btn-primary'); + if (agreeBtn) { + console.log(' Found Xiaomi Account Agreement modal ("Attention") during OAuth redirect, clicking Agree...'); + await agreeBtn.click({ force: true }); + await this.page.waitForTimeout(3000); + } + + // Wait for authorize button + const authBtn = await this.page.waitForSelector('button:has-text("Agree"), button:has-text("Authorize"), button:has-text("Sign in"), #accept, .btn-primary', { timeout: 10000 }).catch(() => null); + if (authBtn) { + await authBtn.click(); + console.log(' ✓ Clicked authorize button'); + await this.page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }).catch(() => {}); + await this.page.waitForTimeout(5000); + await this.page.screenshot({ path: 'screenshot-after-auth.png', fullPage: true }); + console.log(' ✓ Captured screenshot-after-auth.png'); + } + } + } + + async selectDropdownOption(labelText, searchText, exact = false) { + console.log(` Selecting dropdown option for "${labelText}" matching "${searchText}"...`); + try { + const formItem = this.page.locator('.ant-form-item').filter({ hasText: new RegExp(`^${labelText}`) }); + if (await formItem.count() === 0) { + console.log(` ! Form item for label "${labelText}" not found`); + return false; + } + + let selector = formItem.first().locator('.ant-select-selector'); + if (await selector.count() === 0) { + // Try fallback selectors for custom/nested dropdown components like FancyPhoneInput + selector = formItem.first().locator('.ant-dropdown-trigger, [class*="callingCodeTrigger"], .ant-select, .ant-select-selection, [class*="select"]'); + } + + if (await selector.count() === 0) { + console.log(` ! Selector for label "${labelText}" not found. Listing all elements in form item:`); + const elementInfo = await formItem.first().evaluate((el) => { + return Array.from(el.querySelectorAll('*')).map(child => ({ + tag: child.tagName, + class: child.className, + text: child.textContent ? child.textContent.trim().substring(0, 30) : '' + })); + }); + console.log(JSON.stringify(elementInfo, null, 2)); + return false; + } + + console.log(` Clicking select trigger for "${labelText}"...`); + await selector.first().click({ force: true }); + await this.page.waitForTimeout(1500); + + const clicked = await this.page.evaluate((args) => { + const { search, isExact } = args; + const dropdowns = Array.from(document.querySelectorAll('.ant-select-dropdown, .ant-dropdown, [class*="dropdown"], [class*="Dropdown"], [role="listbox"], [role="menu"]')).filter(el => { + const style = window.getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetHeight > 0; + }); + + if (dropdowns.length === 0) return { success: false, reason: 'No visible dropdown container found' }; + + const dropdown = dropdowns[dropdowns.length - 1]; + const options = Array.from(dropdown.querySelectorAll('.ant-select-item-option, [role="option"], [role="menuitem"], li, a, span, div')).filter(el => { + // Filter to only leaf-like or text-containing interactive elements + const style = window.getComputedStyle(el); + return style.display !== 'none' && el.offsetHeight > 0; + }); + + // Try to match specific classes first to avoid matching parent wrappers containing all text + let target = null; + + // 1. Try to find dedicated option classes first + const specificOptions = Array.from(dropdown.querySelectorAll('.ant-select-item-option, .ant-dropdown-menu-item, [role="option"], [role="menuitem"], li')); + target = specificOptions.find(opt => { + const txt = (opt.textContent || '').trim(); + const title = opt.getAttribute('title') || ''; + const dataValue = opt.getAttribute('data-value') || ''; + if (isExact) { + return txt === search || title === search || dataValue === search; + } else { + return txt.includes(search) || title.includes(search) || dataValue.includes(search); + } + }); + + // 2. Fallback to leaf text elements (spans, divs, links) if dedicated options are not found + if (!target) { + const generalEls = Array.from(dropdown.querySelectorAll('span, div, a')).filter(el => { + const style = window.getComputedStyle(el); + // Ensure element is visible and contains text + if (style.display === 'none' || el.offsetHeight === 0) return false; + + // Only look at elements that don't have block-level child elements containing text to target leaf nodes + const childTextElements = Array.from(el.children).filter(child => { + const childStyle = window.getComputedStyle(child); + return childStyle.display !== 'none' && child.offsetHeight > 0 && child.textContent.trim().length > 0; + }); + return childTextElements.length === 0; + }); + + target = generalEls.find(opt => { + const txt = (opt.textContent || '').trim(); + if (isExact) { + return txt === search; + } else { + return txt.includes(search); + } + }); + } + + if (target) { + target.click(); + return { success: true, text: target.textContent ? target.textContent.trim() : '' }; + } + + return { success: false, reason: `Option matching "${search}" not found among ${options.length} options` }; + }, { search: searchText, isExact: exact }); + + if (clicked.success) { + console.log(` ✓ Selected option: ${clicked.text}`); + await this.page.waitForTimeout(1000); + return true; + } else { + console.log(` ! Selection failed: ${clicked.reason}`); + + // Custom handling for phone calling code dropdown which uses ant-dropdown + if (labelText === 'Phone number') { + console.log(' Trying fallback click for phone number dropdown options...'); + const opt = await this.page.$(`.ant-dropdown-menu-item:has-text("${searchText}"), .ant-dropdown-menu [title*="${searchText}"], .ant-dropdown :has-text("${searchText}")`); + if (opt) { + await opt.click({ force: true }); + console.log(` ✓ Selected option via fallback class click for "${searchText}"`); + await this.page.waitForTimeout(1000); + return true; + } + } + + await this.page.keyboard.press('Escape'); + await this.page.waitForTimeout(500); + return false; + } + } catch (e) { + console.log(` ! Error selecting dropdown for "${labelText}": ${e.message}`); + return false; + } + } + + async redeemInviteCode() { + console.log('[Step 7.6] Redeeming invite code...'); + + // Read invite code from config (with fallback to parsing referral link) + const refCodeMatch = this.config.xiaomi.referralLink.match(/[?&]ref=([A-Z0-9]+)/i); + const inviteCode = this.config.xiaomi.inviteCode + || (refCodeMatch ? refCodeMatch[1] : 'HWPMXZ'); + console.log(` Invite code to redeem: ${inviteCode}`); + + // 1. Klik menu "Console" dulu — biar navigasi-nya mirip user beneran + // (bukan langsung page.goto ke /console/balance dari URL referral). + const consoleClicked = await this.clickConsoleMenu(); + + // 2. Setelah klik Console, kemungkinan landed di /console (overview) atau + // /console/balance. Cek URL — kalau bukan balance, navigasi ke sana + // via in-app link kalau ada, atau goto sebagai fallback. + let url = this.page.url(); + if (!url.includes('/console/balance')) { + console.log(` Not on balance page yet (current: ${url}), navigating...`); + + // Coba klik link "Balance" di sidebar console dulu + const balanceClicked = await this.page.evaluate(() => { + const links = Array.from(document.querySelectorAll('a, [role="link"], li, [role="menuitem"]')); + const target = links.find(el => { + const txt = (el.textContent || '').trim(); + return /^Balance$/i.test(txt) && el.offsetHeight > 0; + }); + if (target) { + target.scrollIntoView({ block: 'center' }); + target.click(); + return true; + } + return false; + }); + + if (balanceClicked) { + console.log(' ✓ Clicked Balance link in console sidebar'); + await this.page.waitForURL(/\/console\/balance/, { timeout: 8000 }).catch(() => {}); + await this.page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); + } else { + // Fallback terakhir: goto langsung + console.log(' ! Balance link not found, falling back to direct goto...'); + await this.page.goto('https://platform.xiaomimimo.com/console/balance', { + waitUntil: 'networkidle', + timeout: this.config.browser.timeout, + }); + } + } + await this.page.waitForTimeout(2500); + + await this.handleOAuthRedirect(); + await this.page.waitForTimeout(2000); + + // Close cookies modal if overlaying (do it before clicking to avoid click interception!) + console.log(' Checking for cookies banner...'); + const acceptCookiesBtn = await this.page.waitForSelector('button:has-text("Accept All"), button:has-text("Accept"), button:has-text("Allow All")', { timeout: 4000 }).catch(() => null); + if (acceptCookiesBtn) { + await acceptCookiesBtn.click({ force: true }).catch(() => {}); + console.log(' ✓ Accepted cookies banner'); + await this.page.waitForTimeout(2000); + } + + // Halaman pertama yang dibuka di console — Terms modal kemungkinan muncul di sini + await this.handleTermsModal(); + + // Tunggu sampai semua modal-mask/wrap beneran hilang dari layar. + // Ant Design naruh display:none setelah animasi, tapi mask-nya kadang + // nyangkut ~300-500ms dan nge-intercept klik berikutnya. + await this.waitForOverlaysGone(); + + // Take a screenshot to inspect the balance page + await this.page.screenshot({ path: 'screenshot-balance.png', fullPage: false }); + console.log(' ✓ Captured screenshot-balance.png'); + + // Snapshot balance SEBELUM redeem (untuk verifikasi nanti). + // Format halaman biasanya: "Balance: $0.72" atau "Cash Balance $0.72" + const balanceBefore = await this.readBalance(); + console.log(` 💰 Balance before redeem: $${balanceBefore !== null ? balanceBefore.toFixed(2) : 'unknown'}`); + + // Click "Enter invite code +$2" button/link + console.log(' Checking for "Enter invite code" button...'); + + // Cek dulu apakah link-nya emang ada — kalau akun udah pernah redeem, + // link ini hilang dan diganti dengan tampilan saldo. Treat sebagai sukses. + const linkExists = await this.page.evaluate(() => { + return document.body.innerText.includes('Enter invite code'); + }).catch(() => false); + + if (!linkExists) { + console.log(' ℹ "Enter invite code" link not found — account likely already redeemed.'); + // Cek apakah ada angka saldo > 0 sebagai konfirmasi + const balanceText = await this.page.evaluate(() => { + const matches = document.body.innerText.match(/\$\s*[\d.]+/g); + return matches ? matches.slice(0, 3).join(', ') : 'unknown'; + }).catch(() => 'unknown'); + console.log(` Balance hint: ${balanceText}`); + return; // exit method, bukan error + } + + let clicked = false; + try { + const el = this.page.locator('text=Enter invite code').first(); + await el.waitFor({ state: 'visible', timeout: 8000 }); + // Scroll ke link biar pasti dalam viewport (penting di viewport kecil 1280x720) + await el.scrollIntoViewIfNeeded().catch(() => {}); + await humanDelay(200, 400); + // Hover dulu — biar event-nya mirip user beneran, BUKAN force:true + // (force:true di selector text= sering klik elemen wrapper, bukan link click handler-nya) + try { + await el.hover({ timeout: 3000 }); + await humanDelay(150, 300); + await el.click({ timeout: 5000 }); + clicked = true; + console.log(' ✓ Clicked "Enter invite code" via hover+click'); + } catch (clickErr) { + console.log(` ! Native click failed (${clickErr.message.split('\n')[0]}), trying force click...`); + await el.click({ force: true }); + clicked = true; + console.log(' ✓ Clicked "Enter invite code" via force click'); + } + } catch (err) { + console.log(' Playwright locator click failed/timed out, trying backwards-eval leaf click...'); + clicked = await this.page.evaluate(() => { + const elements = Array.from(document.querySelectorAll('*')); + // Search backwards to get leaf elements first + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const text = (el.textContent || '').trim(); + if (text.includes('Enter invite code') && el.offsetHeight > 0) { + const tagName = el.tagName.toLowerCase(); + if (['span', 'a', 'button', 'div'].includes(tagName) && el.children.length <= 1) { + el.scrollIntoView({ block: 'center' }); + el.click(); + return true; + } + } + } + return false; + }); + if (clicked) console.log(' ✓ Clicked "Enter invite code" via DOM eval fallback'); + } + + if (clicked) { + console.log(' ✓ Entered invite code link clicked successfully'); + + // Wait for modal container (.ant-modal) to be visible to ensure it's open + console.log(' Waiting for redeem modal container (.ant-modal) to open...'); + const modal = await this.page.waitForSelector('.ant-modal, .ant-modal-wrap', { timeout: 10000 }).catch(() => null); + if (!modal) { + throw new Error('Redeem invite code modal did not open in time'); + } + await this.page.waitForTimeout(1500); + + // Capture screenshot of the modal + await this.page.screenshot({ path: 'screenshot-invite-modal.png' }); + console.log(' ✓ Captured screenshot-invite-modal.png'); + + // Fill in the invite code - find input elements specifically inside the modal + console.log(' Filling invite code...'); + + // Select input elements inside the modal, excluding checkboxes + const modalInputs = await this.page.$$('.ant-modal input:not([type="checkbox"]), .ant-modal-wrap input:not([type="checkbox"])'); + const visibleInputs = []; + for (const input of modalInputs) { + const isVisible = await input.isVisible().catch(() => false); + if (isVisible) { + visibleInputs.push(input); + } + } + + console.log(` Found ${visibleInputs.length} visible inputs in modal`); + + if (visibleInputs.length >= 6) { + console.log(` Detected ${visibleInputs.length} invite code inputs, filling...`); + + // Clear all first + for (const input of visibleInputs) { + await input.fill(''); + } + + // Focus the first box dengan jeda manusia + await humanDelay(150, 350); + await visibleInputs[0].click({ force: true }); + await visibleInputs[0].focus(); + await humanDelay(120, 280); + + for (let i = 0; i < 6; i++) { + // Get the index of the currently focused input box + const activeIndex = await this.page.evaluate((elements) => { + return elements.indexOf(document.activeElement); + }, visibleInputs); + + if (activeIndex === i) { + console.log(` [Type] Box ${i} is active, typing "${inviteCode[i]}"`); + await this.page.keyboard.type(inviteCode[i], { delay: 60 + Math.floor(Math.random() * 120) }); + } else { + console.log(` [Focus & Type] Box ${i} not active (active idx: ${activeIndex}), forcing focus`); + await visibleInputs[i].click({ force: true }); + await visibleInputs[i].focus(); + await humanDelay(80, 180); + await this.page.keyboard.press('Backspace'); + await this.page.keyboard.type(inviteCode[i], { delay: 60 + Math.floor(Math.random() * 120) }); + } + // Jeda antar box biar mirip orang ngetik kode satu-satu + await humanDelay(180, 380); + } + } else if (visibleInputs.length > 0) { + console.log(' Filling invite code in single input...'); + await humanFill(this.page, visibleInputs[0], inviteCode); + } else { + console.log(' No inputs found in modal, trying to type invite code...'); + await humanType(this.page, inviteCode); + } + + await this.page.waitForTimeout(1000); + await this.page.screenshot({ path: 'screenshot-invite-filled.png' }); + console.log(' ✓ Captured screenshot-invite-filled.png'); + + // Click the Redeem button + console.log(' Clicking Redeem button...'); + const redeemBtn = await this.page.$('.ant-modal button:has-text("Redeem"), .ant-modal button:has-text("Redeem & get"), button:has-text("Redeem & get $2 credits")'); + let clickedRedeem = false; + if (redeemBtn) { + await redeemBtn.click({ force: true }); + clickedRedeem = true; + console.log(' ✓ Clicked Redeem button'); + } else { + clickedRedeem = await this.page.evaluate(() => { + const modalEl = document.querySelector('.ant-modal'); + if (!modalEl) return false; + const btns = Array.from(modalEl.querySelectorAll('button')); + const targetBtn = btns.find(b => b.textContent.includes('Redeem') || b.textContent.includes('get $2')); + if (targetBtn) { + targetBtn.click(); + return true; + } + return false; + }); + if (clickedRedeem) { + console.log(' ✓ Clicked Redeem button (evaluate)'); + } + } + + if (clickedRedeem) { + await this.page.waitForTimeout(4000); + await this.page.screenshot({ path: 'screenshot-invite-redeemed.png' }); + console.log(' ✓ Captured screenshot-invite-redeemed.png'); + + // Cek notifikasi risk control / restriction setelah submit redeem. + // Xiaomi nampilin pesan kira-kira: + // "Your account has risk control restrictions. Please contact customer service." + // Kalau muncul, throw error khusus biar chain-loop bisa stop. + const restrictionMsg = await this.page.evaluate(() => { + const text = document.body.innerText || ''; + const patterns = [ + /risk\s*control\s*restriction/i, + /account\s+has\s+risk\s+control/i, + /contact\s+customer\s+service/i, + /account\s+(is\s+)?restricted/i, + ]; + for (const re of patterns) { + const m = text.match(new RegExp('([^\\n]{0,200}' + re.source + '[^\\n]{0,200})', re.flags)); + if (m) return m[1].trim(); + } + return null; + }).catch(() => null); + + if (restrictionMsg) { + console.log(' ❌ ACCOUNT RESTRICTED:'); + console.log(` ${restrictionMsg}`); + try { + await this.page.screenshot({ + path: `error-restriction-${Date.now()}.png`, + fullPage: true, + }); + } catch (e) {} + const err = new Error(`ACCOUNT_RESTRICTED: ${restrictionMsg}`); + err.code = 'ACCOUNT_RESTRICTED'; + err.restrictionMsg = restrictionMsg; + throw err; + } + + // Verifikasi balance bertambah ~$2 setelah redeem. + // Tutup modal redeem dulu (tombol X / Esc), lalu RELOAD halaman biar + // widget balance di header re-fetch (tanpa reload, balance widget + // sering masih nampilin nilai cached lama). + await this.page.keyboard.press('Escape').catch(() => {}); + await this.page.waitForTimeout(800); + + // Klik tombol close modal kalau Esc gak nutup + try { + await this.page.evaluate(() => { + const closes = Array.from(document.querySelectorAll('.ant-modal-close, .ant-modal button[aria-label="Close"]')); + for (const c of closes) { + if (c.offsetHeight > 0) { c.click(); return; } + } + }); + } catch (e) {} + await this.page.waitForTimeout(800); + + // Reload halaman biar fetch balance terbaru + console.log(' Reloading balance page to refresh balance widget...'); + try { + await this.page.reload({ waitUntil: 'networkidle', timeout: this.config.browser.timeout }); + } catch (e) { + console.log(` ! Reload error (lanjut): ${e.message}`); + } + await this.page.waitForTimeout(2000); + await this.handleTermsModal().catch(() => {}); + await this.waitForOverlaysGone(); + + // Re-read balance, beberapa kali kalau pertama masih cached + let balanceAfter = null; + for (let attempt = 1; attempt <= 4; attempt++) { + balanceAfter = await this.readBalance(); + if (balanceAfter !== null && balanceBefore !== null && balanceAfter > balanceBefore) { + break; + } + if (attempt < 4) { + console.log(` Balance attempt ${attempt}: $${balanceAfter !== null ? balanceAfter.toFixed(2) : '?'} — retry in 2s...`); + await this.page.waitForTimeout(2000); + } + } + console.log(` 💰 Balance after redeem : $${balanceAfter !== null ? balanceAfter.toFixed(2) : 'unknown'}`); + + if (balanceBefore !== null && balanceAfter !== null) { + const delta = balanceAfter - balanceBefore; + if (delta >= 1.5) { + console.log(` ✅ Balance verified: +$${delta.toFixed(2)} (expected ~+$2.00)`); + } else if (delta > 0) { + console.log(` ⚠ Balance increased only +$${delta.toFixed(2)} (expected ~+$2.00) — partial credit?`); + } else { + console.log(` ❌ Balance did NOT increase (before=$${balanceBefore.toFixed(2)}, after=$${balanceAfter.toFixed(2)})`); + const err = new Error(`BALANCE_NOT_CREDITED: $${balanceBefore.toFixed(2)} → $${balanceAfter.toFixed(2)}`); + err.code = 'BALANCE_NOT_CREDITED'; + err.balanceBefore = balanceBefore; + err.balanceAfter = balanceAfter; + try { + await this.page.screenshot({ + path: `error-balance-${Date.now()}.png`, + fullPage: true, + }); + } catch (e) {} + throw err; + } + } else { + console.log(' ⚠ Could not parse balance, skipping verification'); + } + } else { + console.log(' ! Redeem button not found in modal'); + } + } else { + console.log(' ! "Enter invite code" button not found on balance page (might have already been redeemed).'); + } + } + + async createApiKey() { + console.log('[Step 7.7] Creating API Key...'); + + // Navigate to API Keys page + console.log(' Navigating to API Keys page...'); + await this.page.goto('https://platform.xiaomimimo.com/console/api-keys', { + waitUntil: 'networkidle', + timeout: this.config.browser.timeout + }); + await this.page.waitForTimeout(4000); + + await this.handleOAuthRedirect(); + await this.page.waitForTimeout(2000); + + // Terms modal mungkin muncul di sini kalau halaman ini yang pertama dibuka + await this.handleTermsModal(); + await this.waitForOverlaysGone(); + + // Take screenshot of API Keys page + await this.page.screenshot({ path: 'screenshot-apikeys-page.png' }); + console.log(' ✓ Captured screenshot-apikeys-page.png'); + + // Cek dulu apakah akun sudah punya API key — kalau ada baris sk-... di tabel, + // ambil yang pertama dan return (gak perlu bikin baru). + const existingKey = await this.page.evaluate(() => { + const text = document.body.innerText; + // Format yang ditampilkan biasanya "sk-xxx...yyyy" (masked) atau full + const match = text.match(/sk-[a-zA-Z0-9_\-]{6,}(?:\.{3}[a-zA-Z0-9_\-]{3,})?/); + return match ? match[0] : null; + }).catch(() => null); + + if (existingKey) { + console.log(` ℹ Account already has API key: ${existingKey}`); + return existingKey; + } + + // Click "Create API Key" button + console.log(' Clicking "Create API Key" button...'); + const createBtn = await this.page.$('button:has-text("Create API Key"), .ant-btn:has-text("Create API Key")'); + if (createBtn) { + await createBtn.click({ force: true }); + } else { + const evalClicked = await this.page.evaluate(() => { + const btn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Create API Key')); + if (btn) { + btn.click(); + return true; + } + return false; + }); + if (!evalClicked) { + throw new Error('Create API Key button not found'); + } + } + + await this.page.waitForTimeout(2000); + await this.page.screenshot({ path: 'screenshot-create-apikey-modal.png' }); + console.log(' ✓ Captured screenshot-create-apikey-modal.png'); + + // Fill API Key Name input field + console.log(' Filling API Key Name...'); + const nameInput = await this.page.waitForSelector('.ant-modal input[placeholder="Please enter"], .ant-modal-body input', { timeout: 5000 }).catch(() => null); + if (nameInput) { + await humanFill(this.page, nameInput, 'mykey'); + } else { + await humanType(this.page, 'mykey'); + } + await humanDelay(250, 500); + + // Click Confirm button + console.log(' Clicking Confirm button...'); + const confirmBtn = await this.page.$('.ant-modal-footer button.ant-btn-primary, .ant-modal button:has-text("Confirm")'); + if (confirmBtn) { + await confirmBtn.click({ force: true }); + } else { + await this.page.evaluate(() => { + const btn = Array.from(document.querySelectorAll('.ant-modal button')).find(b => b.textContent.includes('Confirm')); + if (btn) btn.click(); + }); + } + + await this.page.waitForTimeout(4000); + await this.page.screenshot({ path: 'screenshot-apikey-created.png' }); + console.log(' ✓ Captured screenshot-apikey-created.png'); + + // Extract the API key from the modal + console.log(' Extracting API Key...'); + const apiKey = await this.page.evaluate(() => { + const modals = Array.from(document.querySelectorAll('.ant-modal-wrap, .ant-modal, .ant-notification, .ant-message')); + for (const modal of modals) { + const text = modal.innerText; + const match = text.match(/sk-[a-zA-Z0-9_\-]+/); + if (match) return match[0]; + + const input = modal.querySelector('input, textarea'); + if (input && input.value && input.value.startsWith('sk-')) { + return input.value; + } + } + + const bodyText = document.body.innerText; + const bodyMatch = bodyText.match(/sk-[a-zA-Z0-9_\-]+/); + return bodyMatch ? bodyMatch[0] : null; + }); + + if (apiKey) { + console.log(` ✓ Extracted API Key: ${apiKey}`); + } else { + console.log(' ! Failed to extract API Key from screen'); + } + + // Close success modal if there is one + const closeBtn = await this.page.$('.ant-modal-footer button, .ant-modal-wrap button:has-text("OK"), .ant-modal-wrap button:has-text("Close"), .ant-modal-wrap button:has-text("Confirm")'); + if (closeBtn) { + await closeBtn.click().catch(() => {}); + await this.page.waitForTimeout(2000); + } else { + await this.page.keyboard.press('Escape').catch(() => {}); + } + + return apiKey; + } + + async fillUltraspeedForm(email) { + console.log('[Step 7.5] Navigating to Ultraspeed form page...'); + + // Navigate to form page + await this.page.goto('https://platform.xiaomimimo.com/ultraspeed', { + waitUntil: 'networkidle', + timeout: this.config.browser.timeout + }); + console.log('✓ Form page loaded'); + await this.page.waitForTimeout(5000); + + // Debugging: take screenshot and print URL/Title + console.log(` Current page URL: ${this.page.url()}`); + console.log(` Current page Title: ${await this.page.title()}`); + await this.page.screenshot({ path: 'screenshot-1-loaded.png', fullPage: true }); + console.log(' ✓ Captured screenshot-1-loaded.png'); + + // Print any modal texts if present + const modalTexts = await this.page.evaluate(() => { + const modals = Array.from(document.querySelectorAll('.ant-modal, [class*="modal"], [class*="dialog"]')); + return modals.map(m => m.innerText).filter(Boolean); + }); + if (modalTexts.length > 0) { + console.log(' [Debug] Modals/Dialogs detected on page:'); + modalTexts.forEach((text, i) => console.log(` Modal ${i + 1}:\n${text}\n---`)); + } + + // Check if redirected to login/authorization page + await this.handleOAuthRedirect(); + + // Accept cookies if present (with waiting for selector) + const acceptCookiesBtn = await this.page.waitForSelector('button:has-text("Accept All"), button:has-text("Accept")', { timeout: 4000 }).catch(() => null); + if (acceptCookiesBtn) { + await acceptCookiesBtn.click({ force: true }).catch(() => {}); + console.log(' ✓ Accepted cookies'); + await this.page.waitForTimeout(2000); + } + + // Handle Terms & Agreements modal if present + await this.handleTermsModal(); + await this.waitForOverlaysGone(); + + // Generate random name and phone number + const firstNames = ['Adit', 'Bintang', 'Rian', 'Bayu', 'Dedi', 'Dimas', 'Eko', 'Fajar', 'Gilang', 'Heri', 'Agus', 'Budi', 'Rudi', 'Hendro']; + const lastNames = ['Nugraha', 'Wira', 'Saputra', 'Pratama', 'Hidayat', 'Kurniawan', 'Santoso', 'Wijaya', 'Susilo', 'Setiawan']; + const randomName = `${firstNames[Math.floor(Math.random() * firstNames.length)]} Susilo ${lastNames[Math.floor(Math.random() * lastNames.length)]}`; + + const randomPhone = '812' + Math.floor(10000000 + Math.random() * 90000000); // 812xxxxxxxx + + console.log(` Name to fill: ${randomName}`); + console.log(` Phone number to fill: +62${randomPhone}`); + + // Helper: fill field by label text using .ant-form-item filter (precise) + // MODE CEPAT: pakai locator.fill() langsung, gak typing per-char + const fillByLabel = async (labelText, value, inputSelector = 'input') => { + try { + const formItem = this.page.locator('.ant-form-item').filter({ hasText: new RegExp(`^${labelText}`) }); + const count = await formItem.count(); + if (count > 0) { + const input = formItem.first().locator(inputSelector); + if (await input.count() > 0) { + await input.first().fill(value); + console.log(` ✓ Filled "${labelText}" via form-item filter (fast)`); + await this.page.waitForTimeout(150); + return true; + } + } + } catch (e) {} + return false; + }; + + // Wait for form to fully load + await this.page.waitForSelector('.ant-form-item', { timeout: 10000 }); + await this.page.waitForTimeout(1000); + + // Get all visible inputs in order as fallback + const allInputs = await this.page.$$('input[placeholder="Please enter"]:visible, input[placeholder*="enter" i]'); + console.log(` Found ${allInputs.length} "Please enter" inputs on page`); + + // Fill "Your name" (input index 0) + const nameFilled = await fillByLabel('Your name', randomName); + if (!nameFilled) { + const inputs = await this.page.$$('input[placeholder="Please enter"]'); + if (inputs[0]) { await inputs[0].fill(randomName); console.log(' ✓ Filled Name (fast fallback)'); await this.page.waitForTimeout(150); } + } + + // Select Phone Prefix "+62" and fill Phone number + try { + await this.selectDropdownOption('Phone number', '+62', false); + const phoneFormItem = this.page.locator('.ant-form-item').filter({ hasText: /^Phone number/ }); + const phoneInput = phoneFormItem.first().locator('input[placeholder="Please enter"]'); + if (await phoneInput.count() > 0) { + await phoneInput.first().fill(randomPhone); + console.log(' ✓ Filled Phone (fast)'); + await this.page.waitForTimeout(150); + } + } catch (e) { + console.log(' ! Phone fill error:', e.message); + const inputs = await this.page.$$('input[placeholder="Please enter"]'); + if (inputs[1]) { await inputs[1].fill(randomPhone); console.log(' ✓ Filled Phone (fast fallback)'); await this.page.waitForTimeout(150); } + } + + // Fill "Email" (input index 2) + const emailFilled = await fillByLabel('Email', email); + if (!emailFilled) { + const inputs = await this.page.$$('input[placeholder="Please enter"]'); + if (inputs[2]) { await inputs[2].fill(email); console.log(' ✓ Filled Email (fast fallback)'); await this.page.waitForTimeout(150); } + } + + // Fill "Company name" (input index 3) + const companyFilled = await fillByLabel('Company name', 'SignalStack'); + if (!companyFilled) { + const inputs = await this.page.$$('input[placeholder="Please enter"]'); + if (inputs[3]) { await inputs[3].fill('SignalStack'); console.log(' ✓ Filled Company (fast fallback)'); await this.page.waitForTimeout(150); } + } + + // Select "Industry" dropdown → exact match "Finance" + try { + await this.selectDropdownOption('Industry', 'Finance', true); + } catch (e) { + console.log(' ! Industry dropdown error:', e.message); + } + + // Select "Your use case" dropdown → "Latency-critical tasks..." + try { + await this.selectDropdownOption('Your use case', 'Latency-critical', false); + } catch (e) { + console.log(' ! Use case dropdown error:', e.message); + } + + // Fill "Anything else you'd like to share" textarea — MODE CEPAT + const shareText = `Building automated trading systems that need to process market data and execute decisions in milliseconds. We use LLMs for risk assessment, sentiment analysis on news feeds, and generating trade rationale in real time. The challenge is that traditional models add too much latency to the decision loop. Exploring MiMo UltraSpeed to see if inference can happen fast enough to actually be part of the execution path rather than a post-hoc analysis tool. Running about 40k calls daily across different strategy pipelines.`; + try { + const textarea = this.page.locator('textarea').first(); + if (await textarea.count() > 0) { + await textarea.fill(shareText); + console.log(' ✓ Filled share textarea (fast)'); + } + } catch (e) { + console.log(' ! Textarea fill error:', e.message); + } + + // Screenshot before submit + await this.page.waitForTimeout(1000); + await this.page.screenshot({ path: 'screenshot-3-before-submit.png', fullPage: true }); + console.log(' ✓ Captured screenshot-3-before-submit.png'); + + // Submit Application + console.log(' Submitting application...'); + try { + // Find the button using both Playwright locator and page.evaluate fallback + const clickedResult = await this.page.evaluate(() => { + // Find all elements that look like buttons or are styled as buttons + const allButtons = Array.from(document.querySelectorAll('button, [role="button"], .ant-btn, input[type="submit"]')); + const submitBtn = allButtons.find(btn => { + const txt = (btn.textContent || '').trim(); + return txt.includes('Submit') || txt.includes('Submit Application') || txt.includes('Application'); + }); + + if (submitBtn) { + // Check if disabled + const isDisabled = submitBtn.disabled || submitBtn.getAttribute('disabled') !== null || submitBtn.classList.contains('ant-btn-disabled'); + + // Scroll into view + submitBtn.scrollIntoView({ block: 'center', inline: 'nearest' }); + + // Click it natively + submitBtn.click(); + return { success: true, text: submitBtn.textContent.trim(), disabled: isDisabled }; + } + return { success: false, reason: 'No submit button found in DOM' }; + }); + + console.log(` ✓ Submit button click result: ${JSON.stringify(clickedResult)}`); + + // Wait for "Before you submit" modal to appear and click "Got it" + console.log(' Waiting for "Before you submit" confirmation modal...'); + const gotItBtn = await this.page.waitForSelector('.ant-modal-wrap button:has-text("Got it"), button:has-text("Got it")', { timeout: 6000 }).catch(() => null); + if (gotItBtn) { + console.log(' ✓ Found "Got it" confirmation button, clicking...'); + await gotItBtn.click({ force: true }); + console.log(' ✓ Clicked "Got it" confirmation button'); + + // Wait a few seconds for actual form submission to process + await this.page.waitForTimeout(6000); + } else { + console.log(' ! "Got it" confirmation button not found (might have submitted directly or failed)'); + } + + // Print page text snapshot to check if it succeeded + const postSubmitText = await this.page.evaluate(() => document.body.innerText); + console.log(' [Post-Submit Text Snapshot (first 400 chars)]:\n', postSubmitText.substring(0, 400).replace(/\n/g, ' | ')); + + await this.page.screenshot({ path: 'screenshot-4-after-submit.png', fullPage: true }); + console.log(' ✓ Captured screenshot-4-after-submit.png'); + } catch (e) { + console.log(' ! Submit button error:', e.message); + } + } +} + +// ============================================================================ +// MAIN EXECUTION +// ============================================================================ + +async function main() { + // Check for command line argument --count or config.json setting (default to 1) + let count = 1; + const countArgIndex = process.argv.indexOf('--count'); + if (countArgIndex !== -1 && process.argv[countArgIndex + 1]) { + count = parseInt(process.argv[countArgIndex + 1], 10) || 1; + } else if (config.registration && config.registration.count) { + count = parseInt(config.registration.count, 10) || 1; + } + + console.log(`Starting loop to create ${count} account(s)...`); + + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < count; i++) { + console.log(`\n` + '='.repeat(70)); + console.log(` [Loop ${i + 1}/${count}] Starting Auto-Registration`); + console.log('='.repeat(70)); + + const registration = new MimoRegistration(config); + + try { + const result = await registration.run(); + successCount++; + console.log(`\n[Loop ${i + 1}/${count}] ✓ Account created successfully: ${result.email}`); + + // Save account details and tokens to accounts.json + try { + const { writeFileSync, existsSync, readFileSync } = await import('fs'); + const accountsFile = join(__dirname, 'accounts.json'); + let accounts = []; + + if (existsSync(accountsFile)) { + try { + accounts = JSON.parse(readFileSync(accountsFile, 'utf8')); + if (!Array.isArray(accounts)) { + accounts = []; + } + } catch (e) { + console.log(' ! Failed to read existing accounts.json, starting fresh'); + accounts = []; + } + } + + accounts.push({ + email: result.email, + password: result.password, + passToken: result.passToken, + serviceToken: result.serviceToken, + apiKey: result.apiKey || null, + createdAt: new Date().toISOString() + }); + + writeFileSync(accountsFile, JSON.stringify(accounts, null, 2), 'utf8'); + console.log(`✓ Saved account data to ${accountsFile}`); + } catch (saveErr) { + console.log(` ! Failed to save account to JSON file: ${saveErr.message}`); + } + + } catch (error) { + failCount++; + console.error(`\n[Loop ${i + 1}/${count}] ❌ Registration failed:`, error.message); + console.log('Continuing to next iteration...'); + } + } + + console.log('\n' + '═'.repeat(70)); + console.log(' REGISTRATION LOOP SUMMARY'); + console.log('═'.repeat(70)); + console.log(` Total requested: ${count}`); + console.log(` Successful: ${successCount}`); + console.log(` Failed: ${failCount}`); + console.log('═'.repeat(70) + '\n'); + + process.exit(successCount > 0 ? 0 : 1); +} + +// Run if called directly +if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}` || + process.argv[1].endsWith('index.js')) { + main(); +} + +export { MimoRegistration, TempmailClient, CaptchaSolver }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..38f2133 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "xiaomi-mimo-autoreg", + "version": "1.0.0", + "description": "Xiaomi MiMo Auto-Registration - Browser Automation", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "node index.js --test" + }, + "dependencies": { + "playwright": "^1.40.0", + "node-fetch": "^3.3.0" + }, + "keywords": ["xiaomi", "mimo", "automation", "registration"], + "author": "", + "license": "MIT" +}