scripter-x 1.0.30 → 1.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands.js +213 -23
- package/src/index.js +7 -1
- package/src/providers/bigbasket.js +184 -0
- package/src/providers/tempotp.js +12 -2
- package/src/ui/RunView.js +23 -5
- package/src/ui/Shell.js +1 -0
- package/src/worker.js +202 -1
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -8,6 +8,7 @@ import * as tp from './providers/tempotp.js';
|
|
|
8
8
|
import * as oc from './providers/otpcart.js';
|
|
9
9
|
import * as kuku from './providers/kuku.js';
|
|
10
10
|
import * as zepto from './providers/zepto.js';
|
|
11
|
+
import * as bigbasket from './providers/bigbasket.js';
|
|
11
12
|
import { checkCouponTier } from './providers/zeptoCoupon.js';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
13
14
|
import { join, dirname } from 'node:path';
|
|
@@ -129,11 +130,13 @@ async function buildProvider(io, name) {
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
export async function run(io, api, args = {}) {
|
|
132
|
-
// TARGET: Flipkart (
|
|
133
|
-
const target = args.target || (
|
|
133
|
+
// TARGET: Flipkart (backend campaign), Zepto, or BigBasket (local OTP → session JSON).
|
|
134
|
+
const target = args.target || (await io.select('what to extract?', [
|
|
134
135
|
{ label: 'Flipkart', value: 'flipkart', description: 'session JSON via OTP provider (campaign)' },
|
|
135
|
-
{ label: 'Zepto', value: 'zepto', description: 'headless extraction (auto) OR local OTP login (manual)' }
|
|
136
|
+
{ label: 'Zepto', value: 'zepto', description: 'headless extraction (auto) OR local OTP login (manual)' },
|
|
137
|
+
{ label: 'BigBasket', value: 'bigbasket', description: 'local OTP login → session JSON (manual; auto soon)' }])).value;
|
|
136
138
|
if (target === 'zepto') return zeptoCmd(io, api, args);
|
|
139
|
+
if (target === 'bigbasket') return bigbasketCmd(io, api, args);
|
|
137
140
|
|
|
138
141
|
api = api || await getApi(io);
|
|
139
142
|
|
|
@@ -441,12 +444,29 @@ export async function zeptoCmd(io, api, args = {}) {
|
|
|
441
444
|
const { ZeptoWorker } = await import('./worker.js');
|
|
442
445
|
const { RunController } = await import('./controller.js');
|
|
443
446
|
const controller = new RunController({ name, provider: 'otpcart-zepto', requested: count });
|
|
447
|
+
|
|
448
|
+
// INCREMENTAL SAVE: append every extracted envelope to its tier file the instant it
|
|
449
|
+
// succeeds — so a mid-campaign stop / double-Ctrl+C / crash never loses an account.
|
|
450
|
+
const tierDir = checkCoupon ? zeptoTierDir(args, name) : null;
|
|
451
|
+
const savedTiers = {}; // tier -> count, for the final summary
|
|
452
|
+
const onResult = (res) => {
|
|
453
|
+
if (res.status !== 'success' || !res.envelope) return;
|
|
454
|
+
if (checkCoupon) {
|
|
455
|
+
const tier = res.coupon_tier || 'unchecked';
|
|
456
|
+
mkdirSync(tierDir, { recursive: true });
|
|
457
|
+
const dest = join(tierDir, `${tier}.txt`);
|
|
458
|
+
appendFileSync(dest, (existsSync(dest) ? '\n' : '') + res.envelope + '\n');
|
|
459
|
+
savedTiers[tier] = (savedTiers[tier] || 0) + 1;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
444
463
|
const worker = new ZeptoWorker(provider, {
|
|
445
464
|
requested: count,
|
|
446
465
|
concurrency,
|
|
447
466
|
certSha: cert,
|
|
448
467
|
checkCoupon,
|
|
449
468
|
onEvent: controller.handleEvent,
|
|
469
|
+
onResult,
|
|
450
470
|
});
|
|
451
471
|
controller.stop = () => worker.stop();
|
|
452
472
|
|
|
@@ -478,37 +498,207 @@ export async function zeptoCmd(io, api, args = {}) {
|
|
|
478
498
|
|
|
479
499
|
io.print(` ✓ done — ${worker.stats.succeeded} succeeded · ${worker.stats.failed} failed · ${worker.stats.cancelled} cancelled · ₹${worker.stats.charges} spent`);
|
|
480
500
|
|
|
481
|
-
|
|
501
|
+
// Note "stopped" so the summary is honest about a mid-run stop.
|
|
502
|
+
if (worker.stopped) io.print(' ⚠ campaign stopped — saving everything extracted so far', 'warn');
|
|
503
|
+
|
|
504
|
+
const succeeded = results.filter((r) => r.status === 'success' && r.envelope);
|
|
505
|
+
if (succeeded.length > 0) {
|
|
506
|
+
// combined full/zauth files (all successes, for convenience)
|
|
482
507
|
const saved = await saveZeptoSessions(results, name, args.out);
|
|
483
508
|
if (saved) {
|
|
484
509
|
io.print(` ✓ saved ${saved.count} session(s) to 2 files:`);
|
|
485
510
|
io.print(` full → ${saved.fullPath}`, 'accent');
|
|
486
511
|
io.print(` zauth → ${saved.zauthPath}`, 'accent');
|
|
487
512
|
}
|
|
488
|
-
// If coupon-tier was checked, ALSO split the envelopes into per-tier files.
|
|
489
513
|
if (checkCoupon) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (r.status !== 'success') continue;
|
|
493
|
-
const tier = r.coupon_tier || 'unchecked';
|
|
494
|
-
(tiers[tier] = tiers[tier] || []).push(r.envelope);
|
|
495
|
-
}
|
|
496
|
-
io.print(' ◉ coupon tiers:');
|
|
514
|
+
// tier files were written INCREMENTALLY during the run (survives any exit).
|
|
515
|
+
io.print(` ◉ coupon tiers (saved live in ${tierDir}):`);
|
|
497
516
|
for (const tier of ['rs100', 'rs75', 'rs50', 'normal', 'unchecked']) {
|
|
498
|
-
if (!
|
|
499
|
-
|
|
500
|
-
const dest = join(dir, `${tier}.txt`);
|
|
501
|
-
mkdirSync(dir, { recursive: true });
|
|
502
|
-
// one envelope per block, separated by a blank line — readable + paste-friendly
|
|
503
|
-
writeFileSync(dest, tiers[tier].join('\n\n') + '\n');
|
|
504
|
-
io.print(` ${TIER_LABEL[tier]} (${tiers[tier].length}) → ${dest}`, 'accent');
|
|
517
|
+
if (!savedTiers[tier]) continue;
|
|
518
|
+
io.print(` ${TIER_LABEL[tier]} (${savedTiers[tier]}) → ${join(tierDir, `${tier}.txt`)}`, 'accent');
|
|
505
519
|
}
|
|
506
520
|
} else {
|
|
507
521
|
io.print(' ◉ ZAUTH1 envelopes:');
|
|
508
|
-
for (const r of
|
|
509
|
-
|
|
522
|
+
for (const r of succeeded) io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
io.print(' ◉ no sessions extracted — nothing to save.');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── BigBasket: local OTP login → session JSON ────────────────────────────────
|
|
530
|
+
// Manual mode (built now): enter a number → SMS an OTP locally → type the code →
|
|
531
|
+
// verify → write {phone}-{timestamp}.json with { phone, bbAuthToken, mId, bbVisitorId }.
|
|
532
|
+
// LOOPS until Esc. Runs entirely on your IP. Auto mode lands once the server is wired.
|
|
533
|
+
|
|
534
|
+
// Resolve the base dir for BigBasket session output.
|
|
535
|
+
function bigbasketBaseDir(args) {
|
|
536
|
+
if (args.out) {
|
|
537
|
+
const expanded = args.out.startsWith('~') ? join(homedir(), args.out.slice(1)) : args.out;
|
|
538
|
+
return /\.(json|txt)$/i.test(expanded) ? dirname(expanded) : expanded;
|
|
539
|
+
}
|
|
540
|
+
const downloads = join(homedir(), 'Downloads');
|
|
541
|
+
return join(existsSync(downloads) ? downloads : homedir(), 'scripterx', 'bigbasket');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Save one session as a per-number JSON. Returns the path written.
|
|
545
|
+
function saveBigbasketSession(args, number, sessionKeys) {
|
|
546
|
+
const baseDir = bigbasketBaseDir(args);
|
|
547
|
+
mkdirSync(baseDir, { recursive: true });
|
|
548
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
549
|
+
const dest = join(baseDir, `${number}-${stamp}.json`);
|
|
550
|
+
writeFileSync(dest, JSON.stringify(sessionKeys, null, 2) + '\n');
|
|
551
|
+
return dest;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function bigbasketCmd(io, api, args = {}) {
|
|
555
|
+
const mode = args.manual ? 'manual' : (args.auto ? 'auto' : (args.number ? 'manual' : (await io.select('Choose BigBasket login flow', [
|
|
556
|
+
{ label: 'Auto', value: 'auto', description: 'Auto-rent numbers via TempOTP (hands-off)' },
|
|
557
|
+
{ label: 'Manual', value: 'manual', description: 'Enter phone + type OTP yourself' },
|
|
558
|
+
])).value || 'auto'));
|
|
559
|
+
|
|
560
|
+
if (mode === 'auto') return bigbasketAuto(io, args);
|
|
561
|
+
|
|
562
|
+
io.print(' ◉ BigBasket — local OTP → session JSON (Manual)');
|
|
563
|
+
io.print(' ◉ press esc to cancel', 'accent');
|
|
564
|
+
|
|
565
|
+
let done = 0;
|
|
566
|
+
let lastSig = 0, stopping = false;
|
|
567
|
+
const onSigint = () => {
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (now - lastSig < 2000) { stopping = true; process.exit(0); }
|
|
570
|
+
lastSig = now;
|
|
571
|
+
io.print(' ⚠ press Ctrl+C again to exit');
|
|
572
|
+
};
|
|
573
|
+
const interactive = !!io.startRun; // ink App owns its own Ctrl+C
|
|
574
|
+
if (!interactive) process.on('SIGINT', onSigint);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
for (;;) {
|
|
578
|
+
// number entry — Esc stops the whole loop
|
|
579
|
+
const raw = args.number || await io.ask('bigbasket phone number', { escapable: true });
|
|
580
|
+
if (raw === CANCEL) break;
|
|
581
|
+
const number = String(raw).replace(/\D/g, '').slice(-10);
|
|
582
|
+
if (number.length !== 10) { io.print(' ! enter a 10-digit number', 'danger'); if (args.number) break; continue; }
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const session = bigbasket.newSession(number);
|
|
586
|
+
io.print(` ◉ sending OTP to ${number} …`);
|
|
587
|
+
const s = await bigbasket.sendOtp(session);
|
|
588
|
+
if (!s.ok) { io.print(` ✗ could not send OTP: ${s.msg || s.status}`, 'danger'); if (args.number) break; continue; }
|
|
589
|
+
io.print(' ✓ OTP sent');
|
|
590
|
+
|
|
591
|
+
// OTP entry — Esc abandons this number and loops back
|
|
592
|
+
const otpRaw = args.otp || await io.ask('enter the OTP', { escapable: true });
|
|
593
|
+
if (otpRaw === CANCEL) break;
|
|
594
|
+
const v = await bigbasket.verifyOtp(session, String(otpRaw).trim());
|
|
595
|
+
if (!v.ok) { io.print(` ✗ OTP verify failed: ${v.error || v.status}`, 'danger'); if (args.number) break; continue; }
|
|
596
|
+
io.print(` ✓ logged in${v.user?.first_name ? ` as ${v.user.first_name}` : ''}`);
|
|
597
|
+
|
|
598
|
+
const keys = bigbasket.toSessionKeys(session);
|
|
599
|
+
const dest = saveBigbasketSession(args, number, keys);
|
|
600
|
+
done++;
|
|
601
|
+
io.print(' ✓ session saved to:');
|
|
602
|
+
io.print(` ${dest}`, 'accent');
|
|
603
|
+
io.print(` bbAuthToken=${(keys.bbAuthToken || '').slice(0, 24)}… mId=${keys.mId} bbVisitorId=${keys.bbVisitorId}`, 'accent');
|
|
604
|
+
} catch (e) {
|
|
605
|
+
io.print(` ✗ ${number}: ${e.message}`, 'danger');
|
|
606
|
+
if (args.number) break;
|
|
607
|
+
continue;
|
|
510
608
|
}
|
|
609
|
+
|
|
610
|
+
if (args.number) break; // one-shot: do exactly one and stop
|
|
611
|
+
io.print(' ◉ next number (esc to finish)', 'accent');
|
|
511
612
|
}
|
|
613
|
+
} finally {
|
|
614
|
+
if (!interactive && !stopping) process.off('SIGINT', onSigint);
|
|
615
|
+
}
|
|
616
|
+
io.print(` ✓ done — ${done} session(s) saved.`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── BigBasket auto: rent numbers via TempOTP, poll SMS, verify headlessly ─────
|
|
620
|
+
// Mirrors the Zepto auto flow. Local-first: every success is appended to a combined
|
|
621
|
+
// file the instant it lands (so a mid-run stop never loses an account).
|
|
622
|
+
async function bigbasketAuto(io, args = {}) {
|
|
623
|
+
io.print(' ◉ BigBasket — auto extraction via TempOTP');
|
|
624
|
+
const cfg = config.load();
|
|
625
|
+
|
|
626
|
+
// TempOTP key (reuse the saved-key / add-new pattern from buildProvider).
|
|
627
|
+
let key = cfg.tempotp_api_key;
|
|
628
|
+
if (key) {
|
|
629
|
+
const choice = await io.select(`saved TempOTP key: ${maskSecret(key)}`, [
|
|
630
|
+
{ label: 'use saved', value: 'use', description: maskSecret(key) },
|
|
631
|
+
{ label: 'add new', value: 'new', description: 'enter a different key' }]);
|
|
632
|
+
if ((choice.value || choice) === 'new') key = null;
|
|
633
|
+
}
|
|
634
|
+
let freshlyEntered = false;
|
|
635
|
+
if (!key) { key = await io.ask('TempOTP API key'); freshlyEntered = true; }
|
|
636
|
+
const bal = await tp.balance(key);
|
|
637
|
+
if (bal == null) { io.print(' ✗ TempOTP key invalid or unreachable', 'danger'); return; }
|
|
638
|
+
io.print(` ✓ TempOTP balance: ₹${bal}`);
|
|
639
|
+
if (freshlyEntered && await io.confirm('save this key locally for next time?', true)) config.setMany({ tempotp_api_key: key });
|
|
640
|
+
|
|
641
|
+
// BigBasket service picker (only the BB service ids).
|
|
642
|
+
const svc = args.service || (await io.select('BigBasket service', tp.BIGBASKET_SERVICES.map((id) => ({
|
|
643
|
+
label: `${id} · ${tp.SERVICE_NAMES[id]}`, value: id, description: `₹${tp.SERVICES[id]}`,
|
|
644
|
+
})))).value;
|
|
645
|
+
const provider = new tp.TempOTPProvider(key, svc);
|
|
646
|
+
|
|
647
|
+
const count = args.count || Number(await io.ask('how many sessions?', { dflt: '1' }));
|
|
648
|
+
const concurrency = args.concurrency || Number(await io.ask('concurrency (keep low — 1 recommended)', { dflt: '1' }));
|
|
649
|
+
const name = args.name || `BigBasket-${Math.floor(Date.now() / 1000)}`;
|
|
650
|
+
if (!(await io.confirm(`start ${count} BigBasket extraction(s) at concurrency ${concurrency} on service ${svc}?`, true))) return;
|
|
651
|
+
|
|
652
|
+
const { BigBasketWorker } = await import('./worker.js');
|
|
653
|
+
const { RunController } = await import('./controller.js');
|
|
654
|
+
const controller = new RunController({ name, provider: `tempotp-bigbasket`, requested: count });
|
|
655
|
+
|
|
656
|
+
// INCREMENTAL SAVE: append every extracted session to one JSONL the instant it lands.
|
|
657
|
+
const baseDir = bigbasketBaseDir(args);
|
|
658
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
659
|
+
const liveFile = join(baseDir, `${name}-${stamp}.jsonl`);
|
|
660
|
+
const onResult = (res) => {
|
|
661
|
+
if (res.status !== 'success' || !res.session) return;
|
|
662
|
+
mkdirSync(baseDir, { recursive: true });
|
|
663
|
+
appendFileSync(liveFile, JSON.stringify(res.session) + '\n');
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const worker = new BigBasketWorker(provider, { requested: count, concurrency, onEvent: controller.handleEvent, onResult });
|
|
667
|
+
controller.stop = () => worker.stop();
|
|
668
|
+
|
|
669
|
+
const results = [];
|
|
670
|
+
const origRecord = worker._record.bind(worker);
|
|
671
|
+
worker._record = (res) => { results.push(res); origRecord(res); };
|
|
672
|
+
|
|
673
|
+
const interactive = !!io.startRun;
|
|
674
|
+
if (interactive) io.startRun(controller);
|
|
675
|
+
let onSigint = null;
|
|
676
|
+
if (!interactive) {
|
|
677
|
+
let lastSig = 0;
|
|
678
|
+
onSigint = () => {
|
|
679
|
+
const now = Date.now();
|
|
680
|
+
if (now - lastSig < 2000) { worker.stop(); process.exit(0); }
|
|
681
|
+
lastSig = now; worker.stop();
|
|
682
|
+
io.print(' ⚠ stopping — press Ctrl+C again to exit');
|
|
683
|
+
};
|
|
684
|
+
process.on('SIGINT', onSigint);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await worker.run();
|
|
688
|
+
if (onSigint) process.off('SIGINT', onSigint);
|
|
689
|
+
if (io.endRun) io.endRun();
|
|
690
|
+
|
|
691
|
+
io.print(` ✓ done — ${worker.stats.succeeded} succeeded · ${worker.stats.failed} failed · ${worker.stats.cancelled} cancelled · ₹${worker.stats.charges} spent`);
|
|
692
|
+
|
|
693
|
+
// Also write a combined pretty JSON array of all successes for convenience.
|
|
694
|
+
const succeeded = results.filter((r) => r.status === 'success' && r.session).map((r) => r.session);
|
|
695
|
+
if (succeeded.length) {
|
|
696
|
+
mkdirSync(baseDir, { recursive: true });
|
|
697
|
+
const combined = join(baseDir, `${name}-${stamp}.json`);
|
|
698
|
+
writeFileSync(combined, JSON.stringify(succeeded, null, 2) + '\n');
|
|
699
|
+
io.print(` ✓ saved ${succeeded.length} session(s):`);
|
|
700
|
+
io.print(` ${combined}`, 'accent');
|
|
701
|
+
io.print(` (live JSONL: ${liveFile})`, 'accent');
|
|
512
702
|
} else {
|
|
513
703
|
io.print(' ◉ no sessions extracted — nothing to save.');
|
|
514
704
|
}
|
|
@@ -695,7 +885,7 @@ export async function configCmd(io, _api, args = {}) {
|
|
|
695
885
|
}
|
|
696
886
|
|
|
697
887
|
export function help(io) {
|
|
698
|
-
io.print(' commands: run · zepto · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
|
|
888
|
+
io.print(' commands: run · zepto · bigbasket · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
|
|
699
889
|
}
|
|
700
890
|
|
|
701
891
|
export async function update(io) {
|
|
@@ -721,6 +911,6 @@ export async function update(io) {
|
|
|
721
911
|
|
|
722
912
|
// dispatch table used by the interactive shell
|
|
723
913
|
export const REGISTRY = {
|
|
724
|
-
run, zepto: zeptoCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
|
|
914
|
+
run, zepto: zeptoCmd, bigbasket: bigbasketCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
|
|
725
915
|
whoami, login, logout, config: configCmd, update, help,
|
|
726
916
|
};
|
package/src/index.js
CHANGED
|
@@ -54,6 +54,11 @@ const HELP = `${paint.bold('scripterx')} — local Flipkart session extractor
|
|
|
54
54
|
--auto | --manual --count N --concurrency N --out <file|dir> --cert <64hex>
|
|
55
55
|
--number <10-digit> --otp <code>
|
|
56
56
|
(envelope keyed to ZeptoAuthManager cert; override with --cert or $ZAUTH_CERT_SHA)
|
|
57
|
+
scripterx bigbasket [flags] local BigBasket OTP login → session JSON (alias: bb)
|
|
58
|
+
--auto | --manual --count N --concurrency N --out <file|dir>
|
|
59
|
+
--service <1819|1874|1919|2259> (auto: TempOTP BigBasket service id)
|
|
60
|
+
--number <10-digit> --otp <code> (manual one-shot)
|
|
61
|
+
→ writes {phone, bbAuthToken, mId, bbVisitorId} per session
|
|
57
62
|
scripterx recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
|
|
58
63
|
→ re-splits into corrected -minutes-free / -minutes-used files + stats
|
|
59
64
|
scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
|
|
@@ -106,7 +111,7 @@ async function main() {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
// ── one-shot mode ──
|
|
109
|
-
const map = { run: 'run', zepto: 'zepto', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
|
|
114
|
+
const map = { run: 'run', zepto: 'zepto', bigbasket: 'bigbasket', bb: 'bigbasket', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
|
|
110
115
|
creds: 'creds', stop: 'stop', delete: 'delete', whoami: 'whoami', login: 'login',
|
|
111
116
|
logout: 'logout', config: 'config', update: 'update', help: 'help' };
|
|
112
117
|
const fnName = map[cmd];
|
|
@@ -118,6 +123,7 @@ async function main() {
|
|
|
118
123
|
mode: flags.email ? 'json_email' : flags.mode, // --email or --mode json_email
|
|
119
124
|
target: flags.target, // run: 'flipkart' | 'zepto'
|
|
120
125
|
number: flags.number, otp: flags.otp, cert: flags.cert, // zepto: local OTP login → ZAUTH1 envelope
|
|
126
|
+
service: flags.service, // bigbasket auto: TempOTP service id (1819/1874/1919/2259)
|
|
121
127
|
auto: flags.auto, manual: flags.manual,
|
|
122
128
|
checkCoupon: flags['check-coupon'] === true ? true : (flags['check-coupon'] === false ? false : null),
|
|
123
129
|
campaign: flags._[0], out: flags.out, action: flags._[0], value: flags._[1],
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// BigBasket local login provider — send an OTP to a phone number and verify it,
|
|
2
|
+
// straight against BigBasket's native API (runs on YOUR IP, no emulator, no backend).
|
|
3
|
+
//
|
|
4
|
+
// Flow (reverse-engineered from the native app; the login screen is Flutter, so its
|
|
5
|
+
// OTP calls don't show in a proxy — but they're plain native endpoints we can call):
|
|
6
|
+
// 1. sendOtp(session) -> register device + harvest csrf/csurf, then SMS an OTP.
|
|
7
|
+
// 2. verifyOtp(session, otp) -> unified-login returns { bb_token, m_id, visitor_id }.
|
|
8
|
+
//
|
|
9
|
+
// IMPORTANT: the native /member-tdl/v3/member/otp/ endpoint needs **no reCAPTCHA**.
|
|
10
|
+
// Bootstrap (register/device + ui-svc/header) is what mints the csrftoken + csurftoken
|
|
11
|
+
// the OTP/verify POSTs require. Verify body uses mobile_no + mobile_no_otp + refId +
|
|
12
|
+
// source:"BIGBASKET" (NOT identifier/referrer — that 400s with HU4000).
|
|
13
|
+
//
|
|
14
|
+
// Same shape as providers/zepto.js so the CLI command can mirror zeptoCmd exactly.
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { hostname } from 'node:os';
|
|
18
|
+
import * as throttle from '../throttle.js';
|
|
19
|
+
|
|
20
|
+
const HOST = 'www.bigbasket.com';
|
|
21
|
+
const APP_VERSION = '8.13.0';
|
|
22
|
+
const BUILD_CODE = '24105210';
|
|
23
|
+
const OS_RELEASE = '13';
|
|
24
|
+
|
|
25
|
+
const uuid = () => crypto.randomUUID();
|
|
26
|
+
// stable per-machine device id (the app keeps one forever in SharedPreferences)
|
|
27
|
+
function deviceId() {
|
|
28
|
+
const seed = hostname() + '|scripterx-bb-android';
|
|
29
|
+
return crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── cookie jar (the app quotes some identity cookies) ─────────────────────────
|
|
33
|
+
const QUOTED = new Set(['_bb_source', '_bb_vid', 'BBAUTHTOKEN', '_bb_mid']);
|
|
34
|
+
function cookieHeader(jar) {
|
|
35
|
+
jar._bb_source = 'app'; // marks the session as the Android app
|
|
36
|
+
return Object.entries(jar)
|
|
37
|
+
.filter(([, v]) => v != null && v !== '')
|
|
38
|
+
.map(([k, v]) => (QUOTED.has(k) ? `${k}="${v}"` : `${k}=${v}`))
|
|
39
|
+
.join('; ');
|
|
40
|
+
}
|
|
41
|
+
function absorb(jar, setCookie) {
|
|
42
|
+
(setCookie || []).forEach((c) => {
|
|
43
|
+
const kv = c.split(';')[0];
|
|
44
|
+
const i = kv.indexOf('=');
|
|
45
|
+
if (i > 0) {
|
|
46
|
+
const name = kv.slice(0, i).trim();
|
|
47
|
+
const val = kv.slice(i + 1).trim().replace(/^"(.*)"$/, '$1');
|
|
48
|
+
if (val && val.toLowerCase() !== 'deleted') jar[name] = val;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── native header builder (mirrors the two OkHttp interceptors) ───────────────
|
|
54
|
+
function headers(session, method, extra = {}) {
|
|
55
|
+
const h = {
|
|
56
|
+
Accept: 'application/json, text/plain, */*',
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'User-Agent': `BB Android/v${APP_VERSION}/os ${OS_RELEASE}`,
|
|
59
|
+
'X-Channel': 'BB-Android',
|
|
60
|
+
'X-Caller': 'Monster-SVC',
|
|
61
|
+
'X-Tracker': uuid(),
|
|
62
|
+
'X-Tcp-Platform': 'native',
|
|
63
|
+
'X-Tcp-Device-Version': `android_${APP_VERSION}_${BUILD_CODE}`,
|
|
64
|
+
'common-client-static-version': '101',
|
|
65
|
+
'X-Entry-Context': 'bbnow',
|
|
66
|
+
'X-Entry-Context-Id': '10',
|
|
67
|
+
'x-device-id': session.deviceId,
|
|
68
|
+
'x-device-model': 'Google sdk_gphone64_arm64',
|
|
69
|
+
'x-is-debug': 'false',
|
|
70
|
+
'X-Pharma': 'true',
|
|
71
|
+
Cookie: cookieHeader(session.jar),
|
|
72
|
+
};
|
|
73
|
+
// csurftoken/csrftoken only on mutating requests (ApiCallInterceptor.addCsrfToken)
|
|
74
|
+
const m = method.toUpperCase();
|
|
75
|
+
if (m === 'POST' || m === 'PUT' || m === 'DELETE' || m === 'PATCH') {
|
|
76
|
+
if (session.jar.csurftoken) h['x-csurftoken'] = session.jar.csurftoken;
|
|
77
|
+
if (session.jar.csrftoken) h['x-csrftoken'] = session.jar.csrftoken;
|
|
78
|
+
}
|
|
79
|
+
return { ...h, ...extra };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function req(session, method, path, body, extra = {}) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const payload = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body);
|
|
85
|
+
const h = { ...headers(session, method, extra), host: HOST };
|
|
86
|
+
if (payload) h['Content-Length'] = Buffer.byteLength(payload);
|
|
87
|
+
const r = https.request({ hostname: HOST, port: 443, method, path, headers: h, timeout: 20000 }, (res) => {
|
|
88
|
+
const ch = [];
|
|
89
|
+
res.on('data', (c) => ch.push(c));
|
|
90
|
+
res.on('end', () => {
|
|
91
|
+
absorb(session.jar, res.headers['set-cookie']);
|
|
92
|
+
const text = Buffer.concat(ch).toString('utf8');
|
|
93
|
+
let json = null;
|
|
94
|
+
try { json = JSON.parse(text); } catch { /* not json */ }
|
|
95
|
+
resolve({ status: res.statusCode, text, json });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
r.on('error', (e) => resolve({ status: 0, text: 'err:' + e.message, json: null }));
|
|
99
|
+
r.on('timeout', () => { r.destroy(); resolve({ status: 0, text: 'timeout', json: null }); });
|
|
100
|
+
if (payload) r.write(payload);
|
|
101
|
+
r.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A fresh session = a new cookie jar + a stable device identity.
|
|
106
|
+
export function newSession(number) {
|
|
107
|
+
return {
|
|
108
|
+
jar: {},
|
|
109
|
+
deviceId: deviceId(),
|
|
110
|
+
refId: null,
|
|
111
|
+
mobile: String(number).replace(/\D/g, '').slice(-10),
|
|
112
|
+
token: null, // bb_token (BBAUTHTOKEN JWT)
|
|
113
|
+
mId: null, // base64 member id (server-provided)
|
|
114
|
+
visitorId: null, // base64 visitor id (server-provided)
|
|
115
|
+
user: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Step 1 — bootstrap (register device + header) then request the OTP SMS.
|
|
120
|
+
// Returns { ok, status, msg }. Stores session.refId on success.
|
|
121
|
+
export async function sendOtp(session) {
|
|
122
|
+
// (a) register device → csrftoken + _bb_vid
|
|
123
|
+
const form =
|
|
124
|
+
'imei=02%3A00%3A00%3A00%3A00%3A00&device_id=' + session.deviceId + '&city_id=1&properties=' +
|
|
125
|
+
encodeURIComponent(JSON.stringify({
|
|
126
|
+
platform: 'java', os_name: 'android', os_version: OS_RELEASE, app_version: APP_VERSION,
|
|
127
|
+
device_make: 'Google', device_model: 'sdk_gphone64_arm64', screen_resolution: '1080X2201', screen_dpi: 420,
|
|
128
|
+
}));
|
|
129
|
+
await throttle.wait();
|
|
130
|
+
await req(session, 'POST', '/mapi/v4.2.0/register/device/', form, { 'Content-Type': 'application/x-www-form-urlencoded' });
|
|
131
|
+
|
|
132
|
+
// (b) header → csurftoken
|
|
133
|
+
await throttle.wait();
|
|
134
|
+
await req(session, 'GET', '/ui-svc/v2/header/?send_door_info=true');
|
|
135
|
+
|
|
136
|
+
if (!session.jar.csurftoken) {
|
|
137
|
+
return { ok: false, status: 0, msg: 'no csurftoken after bootstrap (blocked?)' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// (c) send OTP
|
|
141
|
+
await throttle.wait();
|
|
142
|
+
const r = await req(session, 'POST', '/member-tdl/v3/member/otp/',
|
|
143
|
+
{ identifier: session.mobile, referrer: 'unified_login' },
|
|
144
|
+
{ 'X-Login-Origin': 'unified_login' });
|
|
145
|
+
|
|
146
|
+
const refId = r.json && (r.json.refId || r.json.ref_id);
|
|
147
|
+
const ok = r.status === 200 && !!refId;
|
|
148
|
+
if (ok) session.refId = refId;
|
|
149
|
+
return { ok, status: r.status, msg: ok ? (r.json.message || 'OTP sent') : (r.json?.message || r.text.slice(0, 160)) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Step 2 — verify the OTP. On success, fills session.token / mId / visitorId / user.
|
|
153
|
+
export async function verifyOtp(session, otp) {
|
|
154
|
+
if (!session.refId) return { ok: false, status: 0, error: 'no refId — call sendOtp first' };
|
|
155
|
+
await throttle.wait();
|
|
156
|
+
const r = await req(session, 'POST', '/member-tdl/v3/member/unified-login/',
|
|
157
|
+
{ mobile_no: session.mobile, mobile_no_otp: String(otp).trim(), refId: session.refId, source: 'BIGBASKET' },
|
|
158
|
+
{ 'X-Login-Origin': 'unified_login', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
|
|
159
|
+
|
|
160
|
+
// Native unified-login returns { bb_token, m_id, visitor_id }.
|
|
161
|
+
const J = r.json || {};
|
|
162
|
+
const token = J.bb_token || J.BBAUTHTOKEN || session.jar.BBAUTHTOKEN || J.token;
|
|
163
|
+
if (r.status === 200 && token) {
|
|
164
|
+
session.token = token;
|
|
165
|
+
session.mId = J.m_id || session.jar._bb_mid || null;
|
|
166
|
+
session.visitorId = J.visitor_id || session.jar._bb_vid || null;
|
|
167
|
+
session.user = J.member_details || J.user || null;
|
|
168
|
+
return { ok: true, user: session.user };
|
|
169
|
+
}
|
|
170
|
+
// HU4000 / wrong-otp surface here
|
|
171
|
+
const err = J.errors?.[0]?.msg || J.message || r.text.slice(0, 160);
|
|
172
|
+
return { ok: false, status: r.status, error: err };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build the persisted-session object — the requested {bbAuthToken, mId, bbVisitorId}
|
|
176
|
+
// shape, plus phone for traceability.
|
|
177
|
+
export function toSessionKeys(session) {
|
|
178
|
+
return {
|
|
179
|
+
phone: session.mobile,
|
|
180
|
+
bbAuthToken: session.token || '',
|
|
181
|
+
mId: session.mId || '',
|
|
182
|
+
bbVisitorId: session.visitorId || '',
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/providers/tempotp.js
CHANGED
|
@@ -3,9 +3,19 @@ import { extractOtp } from '../flipkart.js';
|
|
|
3
3
|
|
|
4
4
|
const BASE = 'https://api.tempotp.online';
|
|
5
5
|
// serviceId -> cost (₹). Keep ids as strings (they go straight into the GET params).
|
|
6
|
-
export const SERVICES = {
|
|
6
|
+
export const SERVICES = {
|
|
7
|
+
'1040': 10.0, '940': 16.0, '2451': 12.5, '2452': 12.5, '2453': 12.0, '2484': 18.0,
|
|
8
|
+
// BigBasket (SERVER 16 [best-sv-multi])
|
|
9
|
+
'1819': 12.0, '1874': 12.0, '1919': 14.0, '2259': 14.0,
|
|
10
|
+
};
|
|
7
11
|
// optional friendly names shown in the service picker
|
|
8
|
-
export const SERVICE_NAMES = {
|
|
12
|
+
export const SERVICE_NAMES = {
|
|
13
|
+
'1040': 'Flipkart', '940': 'Flipkart', '2451': 'Shopsy/Flipkart 1 (SERVER 16)', '2452': 'Shopsy/Flipkart 2 (SERVER 16)', '2453': 'Shopsy/Flipkart 3 (SERVER 16)', '2484': 'ALL 6 DIGIT (SERVER 17 [ALL])',
|
|
14
|
+
'1819': 'BigBasket 1.0 (SERVER 16)', '1874': 'BigBasket 2.0 (SERVER 16)', '1919': 'BigBasket 3.0 (SERVER 16)', '2259': 'BigBasket 4.0 (SERVER 16)',
|
|
15
|
+
};
|
|
16
|
+
// BigBasket service ids (for the bigbasket auto picker)
|
|
17
|
+
export const BIGBASKET_SERVICES = ['1819', '1874', '1919', '2259'];
|
|
18
|
+
export const DEFAULT_BIGBASKET_SERVICE = '1819';
|
|
9
19
|
export const DEFAULT_SERVICE = '940';
|
|
10
20
|
const COUNTRY = '22';
|
|
11
21
|
|
package/src/ui/RunView.js
CHANGED
|
@@ -30,10 +30,28 @@ function SlotRow({ slot, mobile, phase, detail, wait }) {
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Per Zepto coupon tier → a colored note for the live results row.
|
|
34
|
+
function couponNote(tier, label) {
|
|
35
|
+
const C = COLORS;
|
|
36
|
+
switch (tier) {
|
|
37
|
+
case 'rs100': return { note: '₹100 coupon', color: C.success };
|
|
38
|
+
case 'rs75': return { note: '₹75 coupon', color: C.success };
|
|
39
|
+
case 'rs50': return { note: '₹50 coupon', color: C.success };
|
|
40
|
+
case 'normal': return { note: 'no coupon', color: C.muted };
|
|
41
|
+
case 'unchecked': return { note: 'coupon unchecked', color: C.warn };
|
|
42
|
+
default: return { note: label || 'extracted', color: 'gray' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
// Map a result to a clean { glyph, label, color, note } for display. Driven by the
|
|
34
47
|
// worker's `category` so every outcome reads consistently (no "successed"/"success" mix).
|
|
35
|
-
function resultDisplay({ status, category, detail }) {
|
|
48
|
+
function resultDisplay({ status, category, detail, couponTier, couponLabel }) {
|
|
36
49
|
const C = COLORS;
|
|
50
|
+
// Zepto coupon-tier success rows: show the EXACT coupon the account has.
|
|
51
|
+
if (status === 'success' && couponTier) {
|
|
52
|
+
const cn = couponNote(couponTier, couponLabel);
|
|
53
|
+
return { glyph: '✓', label: 'success', color: C.success, note: cn.note, noteColor: cn.color };
|
|
54
|
+
}
|
|
37
55
|
switch (category) {
|
|
38
56
|
case 'fresh': return { glyph: '🆕', label: 'fresh account', color: C.accent, note: 'new account created' };
|
|
39
57
|
case 'coupon': return { glyph: '✓', label: 'success', color: C.success, note: '₹100 coupon available' };
|
|
@@ -52,13 +70,13 @@ function resultDisplay({ status, category, detail }) {
|
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
function ResultRow({ status, category, mobile, detail, cost }) {
|
|
56
|
-
const d = resultDisplay({ status, category, detail });
|
|
73
|
+
function ResultRow({ status, category, mobile, detail, cost, couponTier, couponLabel }) {
|
|
74
|
+
const d = resultDisplay({ status, category, detail, couponTier, couponLabel });
|
|
57
75
|
const mob = (mobile || '—').slice(-10) || '—';
|
|
58
76
|
return h(Box, null,
|
|
59
77
|
h(Box, { width: 13 }, h(Text, null, mob)),
|
|
60
78
|
h(Box, { width: 14 }, h(Text, { color: d.color }, `${d.glyph} ${d.label}`)),
|
|
61
|
-
h(Box, { flexGrow: 1 }, h(Text, { color: 'gray' }, d.note)),
|
|
79
|
+
h(Box, { flexGrow: 1 }, h(Text, { color: d.noteColor || 'gray' }, d.note)),
|
|
62
80
|
h(Box, { width: 7, justifyContent: 'flex-end' }, h(Text, { color: 'gray' }, cost ? `₹${cost}` : '')),
|
|
63
81
|
);
|
|
64
82
|
}
|
|
@@ -97,7 +115,7 @@ export function RunView({ controller }) {
|
|
|
97
115
|
slotList.length ? slotList.map((sl) => h(SlotRow, { key: sl.slot, ...sl }))
|
|
98
116
|
: h(Text, { color: 'gray' }, 'starting…')),
|
|
99
117
|
h(Panel, { title: 'results' },
|
|
100
|
-
lastRows.length ? lastRows.map((r, i) => h(ResultRow, { key: i, status: r.status, category: r.category, mobile: r.mobile, detail: r.detail, cost: r.cost }))
|
|
118
|
+
lastRows.length ? lastRows.map((r, i) => h(ResultRow, { key: i, status: r.status, category: r.category, mobile: r.mobile, detail: r.detail, cost: r.cost, couponTier: r.coupon_tier, couponLabel: r.coupon_label }))
|
|
101
119
|
: h(Text, { color: 'gray' }, 'waiting for first result…')),
|
|
102
120
|
h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 1 },
|
|
103
121
|
h(Text, { color: COLORS.success }, `✓ ${stats.succeeded}`),
|
package/src/ui/Shell.js
CHANGED
|
@@ -13,6 +13,7 @@ const h = React.createElement;
|
|
|
13
13
|
|
|
14
14
|
export const COMMANDS = [
|
|
15
15
|
{ name: 'run', desc: 'run an extraction' },
|
|
16
|
+
{ name: 'bigbasket', desc: 'local BigBasket OTP login → session JSON' },
|
|
16
17
|
{ name: 'campaigns', desc: 'list your campaigns' },
|
|
17
18
|
{ name: 'export', desc: "download a campaign's sessions" },
|
|
18
19
|
{ name: 'balance', desc: 'check provider balance' },
|
package/src/worker.js
CHANGED
|
@@ -6,6 +6,7 @@ import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkar
|
|
|
6
6
|
import { CampaignLogger } from './logger.js';
|
|
7
7
|
import * as kuku from './providers/kuku.js';
|
|
8
8
|
import * as zepto from './providers/zepto.js';
|
|
9
|
+
import * as bigbasket from './providers/bigbasket.js';
|
|
9
10
|
|
|
10
11
|
const OTP_RESEND_AFTER = 60_000;
|
|
11
12
|
const OTPCART_DEADLINE = 120_000;
|
|
@@ -555,13 +556,14 @@ async function releaseNumber(provider, number, rentedAt, { log = () => {}, warn
|
|
|
555
556
|
export const __releaseNumberForTest = releaseNumber;
|
|
556
557
|
|
|
557
558
|
export class ZeptoWorker {
|
|
558
|
-
constructor(provider, { requested, concurrency, certSha, onEvent, checkCoupon }) {
|
|
559
|
+
constructor(provider, { requested, concurrency, certSha, onEvent, onResult, checkCoupon }) {
|
|
559
560
|
this.provider = provider;
|
|
560
561
|
this.requested = requested;
|
|
561
562
|
this.concurrency = Math.max(1, Math.min(concurrency, requested));
|
|
562
563
|
this.certSha = certSha;
|
|
563
564
|
this.checkCoupon = !!checkCoupon; // also classify the ₹50/₹75/₹100 coupon tier
|
|
564
565
|
this.onEvent = onEvent || (() => {});
|
|
566
|
+
this.onResult = onResult || null; // called per successful result for incremental save
|
|
565
567
|
// Surface OTPCart re-logins (session taken over by another login) in the UI.
|
|
566
568
|
if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
|
|
567
569
|
this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
|
|
@@ -595,6 +597,11 @@ export class ZeptoWorker {
|
|
|
595
597
|
else { this.stats.failed++; this.stats.charges += res.cost || 0; }
|
|
596
598
|
this.onEvent('progress', this.stats);
|
|
597
599
|
this.onEvent('row', res);
|
|
600
|
+
// Persist each successful envelope IMMEDIATELY (incremental save) so a mid-run
|
|
601
|
+
// stop / double-Ctrl+C / crash never loses an already-extracted account.
|
|
602
|
+
if (res.status === 'success' && res.envelope && this.onResult) {
|
|
603
|
+
try { this.onResult(res); } catch { /* best-effort; never break the loop */ }
|
|
604
|
+
}
|
|
598
605
|
}
|
|
599
606
|
|
|
600
607
|
async run() {
|
|
@@ -753,3 +760,197 @@ export class ZeptoWorker {
|
|
|
753
760
|
}
|
|
754
761
|
}
|
|
755
762
|
}
|
|
763
|
+
|
|
764
|
+
// ── BigBasket auto worker ─────────────────────────────────────────────────────
|
|
765
|
+
// Identical orchestration to ZeptoWorker (rent → SMS poll → verify → save), but the
|
|
766
|
+
// merchant is BigBasket: bigbasket.sendOtp() does its own 3-call bootstrap+send, we
|
|
767
|
+
// poll the OTP provider's SMS stream, then bigbasket.verifyOtp() → session JSON.
|
|
768
|
+
// Designed for TempOTP (poll, no resend) but works with any provider exposing
|
|
769
|
+
// rentOnce/startOtp(poll)/cancel. Local-first: emits res.session for incremental save;
|
|
770
|
+
// server push is layered on top by the caller (onResult) when a backend is configured.
|
|
771
|
+
export class BigBasketWorker {
|
|
772
|
+
constructor(provider, { requested, concurrency, deadlineMs, onEvent, onResult }) {
|
|
773
|
+
this.provider = provider;
|
|
774
|
+
this.requested = requested;
|
|
775
|
+
this.concurrency = Math.max(1, Math.min(concurrency, requested));
|
|
776
|
+
this.deadlineMs = deadlineMs || (provider?.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE);
|
|
777
|
+
this.onEvent = onEvent || (() => {});
|
|
778
|
+
this.onResult = onResult || null;
|
|
779
|
+
if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
|
|
780
|
+
this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
|
|
781
|
+
this.slots = {};
|
|
782
|
+
this.seq = 0;
|
|
783
|
+
this.stopped = false;
|
|
784
|
+
this._releases = [];
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
stop() { this.stopped = true; }
|
|
788
|
+
_nextSeq() { return ++this.seq; }
|
|
789
|
+
|
|
790
|
+
_claim() {
|
|
791
|
+
if (this.stopped) return 0;
|
|
792
|
+
if (this.stats.succeeded >= this.requested) return 0;
|
|
793
|
+
const active = this.stats.attempts - (this.stats.succeeded + this.stats.failed + this.stats.cancelled);
|
|
794
|
+
if (this.stats.succeeded + active >= this.requested) return 0;
|
|
795
|
+
if (this.stats.attempts >= this.requested * ATTEMPT_CAP_MULT) return 0;
|
|
796
|
+
return ++this.stats.attempts;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
_emit(slot, kv) {
|
|
800
|
+
this.slots[slot] = { slot, ...(this.slots[slot] || {}), ...kv };
|
|
801
|
+
this.onEvent('slot', this.slots[slot]);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
_record(res) {
|
|
805
|
+
if (res.mobile) this.stats.generated++;
|
|
806
|
+
if (res.status === 'success') { this.stats.succeeded++; this.stats.charges += res.cost || 0; }
|
|
807
|
+
else if (res.status === 'cancelled') this.stats.cancelled++;
|
|
808
|
+
else { this.stats.failed++; this.stats.charges += res.cost || 0; }
|
|
809
|
+
this.onEvent('progress', this.stats);
|
|
810
|
+
this.onEvent('row', res);
|
|
811
|
+
if (res.status === 'success' && res.session && this.onResult) {
|
|
812
|
+
try { this.onResult(res); } catch { /* best-effort; never break the loop */ }
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async run() {
|
|
817
|
+
const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
|
|
818
|
+
await Promise.all(loops);
|
|
819
|
+
if (this._releases.length) {
|
|
820
|
+
this.onEvent('warn', `finishing up — releasing ${this._releases.length} number(s)…`);
|
|
821
|
+
await Promise.allSettled(this._releases);
|
|
822
|
+
}
|
|
823
|
+
this.onEvent('done', this.stats);
|
|
824
|
+
return this.stats;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
_release(number, rentedAt, slot) {
|
|
828
|
+
const p = releaseNumber(this.provider, number, rentedAt, {
|
|
829
|
+
log: () => {},
|
|
830
|
+
warn: (m) => this.onEvent('warn', m),
|
|
831
|
+
onWait: slot ? (sec) => this._emit(slot, { phase: 'cancelling', detail: 'releasing (cancel-lock)', wait: sec }) : undefined,
|
|
832
|
+
});
|
|
833
|
+
this._releases.push(p);
|
|
834
|
+
return p;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async _rent(slot) {
|
|
838
|
+
let attempt = 0;
|
|
839
|
+
while (!this.stopped) {
|
|
840
|
+
attempt++;
|
|
841
|
+
this._emit(slot, { phase: 'renting', detail: `requesting a number (try ${attempt})` });
|
|
842
|
+
const { number } = await this.provider.rentOnce();
|
|
843
|
+
if (number) return number;
|
|
844
|
+
if (attempt % RENT_COOLDOWN_EVERY === 0) {
|
|
845
|
+
for (let rem = RENT_COOLDOWN_SECS; rem > 0; rem--) {
|
|
846
|
+
if (this.stopped) return null;
|
|
847
|
+
this._emit(slot, { phase: 'rent_wait', detail: 'no numbers — waiting', wait: rem });
|
|
848
|
+
await sleep(1000);
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
await sleep(RENT_RETRY_DELAY);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async _loop(slot) {
|
|
858
|
+
for (;;) {
|
|
859
|
+
if (this._claim() === 0) return;
|
|
860
|
+
const res = await this._process(slot);
|
|
861
|
+
await this._record(res);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async _process(slot) {
|
|
866
|
+
const idNo = this._nextSeq();
|
|
867
|
+
const res = { id_no: idNo, status: 'failed', cost: 0, detail: '' };
|
|
868
|
+
|
|
869
|
+
if (this.stats.succeeded >= this.requested) {
|
|
870
|
+
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
871
|
+
res.status = 'cancelled'; res.detail = 'goal reached';
|
|
872
|
+
return res;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
|
|
876
|
+
const number = await this._rent(slot);
|
|
877
|
+
if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
|
|
878
|
+
const rentedAt = Date.now();
|
|
879
|
+
if (this.stats.succeeded >= this.requested) {
|
|
880
|
+
this._release(number, rentedAt, slot);
|
|
881
|
+
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
882
|
+
res.status = 'cancelled'; res.detail = 'goal reached';
|
|
883
|
+
return res;
|
|
884
|
+
}
|
|
885
|
+
res.mobile = number.mobile;
|
|
886
|
+
this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
|
|
887
|
+
|
|
888
|
+
let stream = null;
|
|
889
|
+
let released = false;
|
|
890
|
+
const smsArrived = { v: false };
|
|
891
|
+
|
|
892
|
+
const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
|
|
893
|
+
if (released) return;
|
|
894
|
+
released = true;
|
|
895
|
+
if (consumed || (charged && !this.stopped)) { this._emit(slot, { phase: 'done', detail: doneDetail }); return; }
|
|
896
|
+
this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
|
|
897
|
+
this._release(number, rentedAt, slot);
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
const fail = (detail, category) => {
|
|
901
|
+
const charged = smsArrived.v;
|
|
902
|
+
res.status = charged ? 'failed' : 'cancelled';
|
|
903
|
+
res.cost = charged ? number.cost : 0;
|
|
904
|
+
res.detail = detail;
|
|
905
|
+
res.category = category || (res.status === 'failed' ? 'failed' : 'cancelled');
|
|
906
|
+
releaseOnce(charged, 'failed');
|
|
907
|
+
return res;
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const session = bigbasket.newSession(number.mobile);
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
|
|
914
|
+
stream = this.provider.startOtp(number);
|
|
915
|
+
if (typeof stream.ready === 'function') await stream.ready();
|
|
916
|
+
|
|
917
|
+
// bigbasket.sendOtp does register/device → header → otp (all on our IP).
|
|
918
|
+
this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
|
|
919
|
+
const sent = await bigbasket.sendOtp(session);
|
|
920
|
+
if (!sent.ok) return fail(`send: ${sent.msg}`, 'send_blocked');
|
|
921
|
+
|
|
922
|
+
// Poll the rented number's SMS stream for the BigBasket OTP.
|
|
923
|
+
const end = Date.now() + this.deadlineMs;
|
|
924
|
+
let otp = null;
|
|
925
|
+
while (Date.now() < end && !this.stopped) {
|
|
926
|
+
const { otp: o, arrived } = await stream.poll();
|
|
927
|
+
if (arrived) smsArrived.v = true;
|
|
928
|
+
if (o) { otp = o; break; }
|
|
929
|
+
this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: Math.floor((end - Date.now()) / 1000) });
|
|
930
|
+
await sleep(1000);
|
|
931
|
+
}
|
|
932
|
+
if (!otp) return fail(`no OTP within ${Math.round(this.deadlineMs / 1000)}s — abandoned`, 'timeout');
|
|
933
|
+
|
|
934
|
+
this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
|
|
935
|
+
const v = await bigbasket.verifyOtp(session, otp);
|
|
936
|
+
if (!v.ok) {
|
|
937
|
+
const wrong = /valid otp|incorrect|invalid|expired/i.test(v.error || '');
|
|
938
|
+
return fail(`verify: ${v.error}`, wrong ? 'wrong_otp' : 'failed');
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
res.status = 'success';
|
|
942
|
+
res.cost = number.cost;
|
|
943
|
+
res.session = bigbasket.toSessionKeys(session); // { phone, bbAuthToken, mId, bbVisitorId }
|
|
944
|
+
res.category = 'extracted';
|
|
945
|
+
|
|
946
|
+
this._emit(slot, { phase: 'saving', detail: 'saving session' });
|
|
947
|
+
releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
|
|
948
|
+
return res;
|
|
949
|
+
} catch (e) {
|
|
950
|
+
return fail(`unexpected: ${e.message}`);
|
|
951
|
+
} finally {
|
|
952
|
+
releaseOnce(false);
|
|
953
|
+
if (stream) try { stream.close(); } catch { /* */ }
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|