scripter-x 1.0.31 → 1.0.33
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 +94 -7
- package/src/index.js +5 -2
- package/src/providers/bigbasket.js +66 -10
- package/src/providers/tempotp.js +27 -3
- package/src/worker.js +205 -1
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -552,13 +552,12 @@ function saveBigbasketSession(args, number, sessionKeys) {
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
export async function bigbasketCmd(io, api, args = {}) {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
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);
|
|
562
561
|
|
|
563
562
|
io.print(' ◉ BigBasket — local OTP → session JSON (Manual)');
|
|
564
563
|
io.print(' ◉ press esc to cancel', 'accent');
|
|
@@ -617,6 +616,94 @@ export async function bigbasketCmd(io, api, args = {}) {
|
|
|
617
616
|
io.print(` ✓ done — ${done} session(s) saved.`);
|
|
618
617
|
}
|
|
619
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');
|
|
702
|
+
} else {
|
|
703
|
+
io.print(' ◉ no sessions extracted — nothing to save.');
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
620
707
|
const TIER_LABEL = { rs100: '🟢 ₹100', rs75: '🟢 ₹75', rs50: '🟢 ₹50', normal: '⚪ normal', unchecked: '❔ unchecked' };
|
|
621
708
|
|
|
622
709
|
// The directory the per-tier files go in for an auto run.
|
package/src/index.js
CHANGED
|
@@ -55,8 +55,10 @@ const HELP = `${paint.bold('scripterx')} — local Flipkart session extractor
|
|
|
55
55
|
--number <10-digit> --otp <code>
|
|
56
56
|
(envelope keyed to ZeptoAuthManager cert; override with --cert or $ZAUTH_CERT_SHA)
|
|
57
57
|
scripterx bigbasket [flags] local BigBasket OTP login → session JSON (alias: bb)
|
|
58
|
-
--manual
|
|
59
|
-
|
|
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
|
|
60
62
|
scripterx recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
|
|
61
63
|
→ re-splits into corrected -minutes-free / -minutes-used files + stats
|
|
62
64
|
scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
|
|
@@ -121,6 +123,7 @@ async function main() {
|
|
|
121
123
|
mode: flags.email ? 'json_email' : flags.mode, // --email or --mode json_email
|
|
122
124
|
target: flags.target, // run: 'flipkart' | 'zepto'
|
|
123
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)
|
|
124
127
|
auto: flags.auto, manual: flags.manual,
|
|
125
128
|
checkCoupon: flags['check-coupon'] === true ? true : (flags['check-coupon'] === false ? false : null),
|
|
126
129
|
campaign: flags._[0], out: flags.out, action: flags._[0], value: flags._[1],
|
|
@@ -113,6 +113,8 @@ export function newSession(number) {
|
|
|
113
113
|
mId: null, // base64 member id (server-provided)
|
|
114
114
|
visitorId: null, // base64 visitor id (server-provided)
|
|
115
115
|
user: null,
|
|
116
|
+
isSignup: false, // true if verify created a brand-new account
|
|
117
|
+
activated: false, // true once the name/profile step succeeded (or wasn't needed)
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -146,18 +148,44 @@ export async function sendOtp(session) {
|
|
|
146
148
|
const refId = r.json && (r.json.refId || r.json.ref_id);
|
|
147
149
|
const ok = r.status === 200 && !!refId;
|
|
148
150
|
if (ok) session.refId = refId;
|
|
149
|
-
|
|
151
|
+
// Surface BigBasket's error code so callers can react:
|
|
152
|
+
// HU4001 = account does not exist / inactive (often a recycled/dead number) → rotate
|
|
153
|
+
// HU4000 = bad request shape
|
|
154
|
+
const err = r.json?.errors?.[0];
|
|
155
|
+
const code = err?.code_str || null;
|
|
156
|
+
return {
|
|
157
|
+
ok, status: r.status, code,
|
|
158
|
+
msg: ok ? (r.json.message || 'OTP sent') : (err?.msg || err?.display_msg || r.json?.message || r.text.slice(0, 160)),
|
|
159
|
+
};
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
// A default name for brand-new accounts. BigBasket requires a non-empty first_name to
|
|
163
|
+
// activate a fresh signup; override via $BB_SIGNUP_NAME.
|
|
164
|
+
const SIGNUP_NAME = (process.env.BB_SIGNUP_NAME || 'Rohan').trim();
|
|
165
|
+
|
|
152
166
|
// Step 2 — verify the OTP. On success, fills session.token / mId / visitorId / user.
|
|
167
|
+
// Handles BOTH returning members AND brand-new signups: a new account comes back with
|
|
168
|
+
// is_signup:true and an empty name, then needs a profile/set-name call to activate
|
|
169
|
+
// (otherwise the account is half-created and the session is unusable). See the BigBasket
|
|
170
|
+
// signup flow: send(unified_login) → verify → [if is_signup] profile/set-name.
|
|
171
|
+
async function _doVerify(session, otp, referrer) {
|
|
172
|
+
const body = { mobile_no: session.mobile, mobile_no_otp: String(otp).trim(), refId: session.refId, source: 'BIGBASKET' };
|
|
173
|
+
if (referrer) body.referrer = referrer; // signup variant adds referrer:"login_signup"
|
|
174
|
+
await throttle.wait();
|
|
175
|
+
return req(session, 'POST', '/member-tdl/v3/member/unified-login/', body,
|
|
176
|
+
{ 'X-Login-Origin': referrer || 'unified_login', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
export async function verifyOtp(session, otp) {
|
|
154
180
|
if (!session.refId) return { ok: false, status: 0, error: 'no refId — call sendOtp first' };
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
// First try the returning-member verify. If the number has no account yet (HU4001),
|
|
182
|
+
// retry with the signup referrer so a brand-new account is created.
|
|
183
|
+
let r = await _doVerify(session, otp, null);
|
|
184
|
+
if (r.status !== 200 && r.json?.errors?.[0]?.code_str === 'HU4001') {
|
|
185
|
+
r = await _doVerify(session, otp, 'login_signup');
|
|
186
|
+
}
|
|
159
187
|
|
|
160
|
-
// Native unified-login returns { bb_token, m_id, visitor_id }.
|
|
188
|
+
// Native unified-login returns { bb_token, m_id, visitor_id, is_signup, first_name, ... }.
|
|
161
189
|
const J = r.json || {};
|
|
162
190
|
const token = J.bb_token || J.BBAUTHTOKEN || session.jar.BBAUTHTOKEN || J.token;
|
|
163
191
|
if (r.status === 200 && token) {
|
|
@@ -165,11 +193,39 @@ export async function verifyOtp(session, otp) {
|
|
|
165
193
|
session.mId = J.m_id || session.jar._bb_mid || null;
|
|
166
194
|
session.visitorId = J.visitor_id || session.jar._bb_vid || null;
|
|
167
195
|
session.user = J.member_details || J.user || null;
|
|
168
|
-
|
|
196
|
+
session.isSignup = !!(J.is_signup || J.isSignup);
|
|
197
|
+
// A brand-new account (or one with no name) must be activated with a name, else the
|
|
198
|
+
// session is half-baked. Best-effort: failure here still returns the token we got.
|
|
199
|
+
const noName = !((J.first_name || J.firstName || '').trim());
|
|
200
|
+
if (session.isSignup || noName) {
|
|
201
|
+
session.activated = await setProfile(session, SIGNUP_NAME);
|
|
202
|
+
} else {
|
|
203
|
+
session.activated = true;
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, user: session.user, isSignup: session.isSignup, activated: session.activated };
|
|
206
|
+
}
|
|
207
|
+
// Error codes seen at verify:
|
|
208
|
+
// HU4011 = wrong/expired OTP HU4001 = account does not exist/inactive HU4000 = bad request
|
|
209
|
+
const e = J.errors?.[0];
|
|
210
|
+
const code = e?.code_str || null;
|
|
211
|
+
const error = e?.msg || e?.display_msg || J.message || r.text.slice(0, 160);
|
|
212
|
+
return { ok: false, status: r.status, code, error };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Step 3 (new users only) — set the name to activate the freshly-created account.
|
|
216
|
+
// POST /member-tdl/v3/member/profile/ with the BBAUTHTOKEN we just got. Returns true on
|
|
217
|
+
// success. Never throws — a name-set failure shouldn't discard the token.
|
|
218
|
+
export async function setProfile(session, firstName, lastName = '') {
|
|
219
|
+
try {
|
|
220
|
+
if (session.token) session.jar.BBAUTHTOKEN = session.token; // authenticate the call
|
|
221
|
+
await throttle.wait();
|
|
222
|
+
const r = await req(session, 'POST', '/member-tdl/v3/member/profile/',
|
|
223
|
+
{ first_name: firstName, last_name: lastName, name: lastName ? `${firstName} ${lastName}` : firstName },
|
|
224
|
+
{ 'X-Login-Origin': 'login_signup', 'X-tcp-client-id': 'BIGBASKET-ANDROID-APP' });
|
|
225
|
+
return r.status === 200;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
169
228
|
}
|
|
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
229
|
}
|
|
174
230
|
|
|
175
231
|
// Build the persisted-session object — the requested {bbAuthToken, mId, bbVisitorId}
|
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
|
|
|
@@ -51,8 +61,22 @@ export class TempOTPProvider {
|
|
|
51
61
|
|
|
52
62
|
startOtp(number) { return new TempStream(this.apiKey, number.txn); }
|
|
53
63
|
|
|
64
|
+
// Cancel + refund the rented number. Returns { ok, tooEarly, msg } so the worker's
|
|
65
|
+
// releaseNumber loop knows whether the cancel landed. TempOTP enforces a ~2-min
|
|
66
|
+
// no-cancel policy: a too-early attempt is reported back so the caller waits + retries
|
|
67
|
+
// (rather than treating it as a hard failure or a success).
|
|
54
68
|
async cancel(number) {
|
|
55
|
-
|
|
69
|
+
let d;
|
|
70
|
+
try { d = await getJson('/cancelNumber', { apikey: this.apiKey, id: number.txn }); }
|
|
71
|
+
catch (e) { return { ok: false, tooEarly: false, msg: e.message }; }
|
|
72
|
+
// success: API returns status 200 (sometimes with a "cancelled"/"success" message)
|
|
73
|
+
const msg = (d && (d.message || d.msg)) || '';
|
|
74
|
+
if (d && (d.status === 200 || /cancel|success|refund/i.test(msg))) {
|
|
75
|
+
return { ok: true, tooEarly: false, msg: msg || 'cancelled' };
|
|
76
|
+
}
|
|
77
|
+
// the 2-min policy / "wait before cancel" → retryable, not a hard fail
|
|
78
|
+
const tooEarly = /2\s*min|two\s*min|120\s*sec|wait|too early|cannot cancel|not allowed yet|before/i.test(msg);
|
|
79
|
+
return { ok: false, tooEarly, msg: msg || `cancel rejected (status ${d?.status})` };
|
|
56
80
|
}
|
|
57
81
|
}
|
|
58
82
|
|
package/src/worker.js
CHANGED
|
@@ -6,10 +6,15 @@ 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;
|
|
12
|
-
|
|
13
|
+
// TempOTP now enforces a 2-min no-cancel policy on rented numbers (you can't cancel/refund
|
|
14
|
+
// a number before 120s). So wait the FULL 2 min for the OTP — there's no point bailing at
|
|
15
|
+
// 90s when the number can't be released until 120s anyway. The cancel-lock below (also
|
|
16
|
+
// 120s) then fires right at the unlock point, identical to OTPCart's behaviour.
|
|
17
|
+
const TEMPOTP_DEADLINE = 120_000;
|
|
13
18
|
const WRONG_OTP_WAIT = 60_000;
|
|
14
19
|
const NUMBER_CANCEL_LOCK = 120_000;
|
|
15
20
|
const RENT_RETRY_DELAY = 3_000;
|
|
@@ -759,3 +764,202 @@ export class ZeptoWorker {
|
|
|
759
764
|
}
|
|
760
765
|
}
|
|
761
766
|
}
|
|
767
|
+
|
|
768
|
+
// ── BigBasket auto worker ─────────────────────────────────────────────────────
|
|
769
|
+
// Identical orchestration to ZeptoWorker (rent → SMS poll → verify → save), but the
|
|
770
|
+
// merchant is BigBasket: bigbasket.sendOtp() does its own 3-call bootstrap+send, we
|
|
771
|
+
// poll the OTP provider's SMS stream, then bigbasket.verifyOtp() → session JSON.
|
|
772
|
+
// Designed for TempOTP (poll, no resend) but works with any provider exposing
|
|
773
|
+
// rentOnce/startOtp(poll)/cancel. Local-first: emits res.session for incremental save;
|
|
774
|
+
// server push is layered on top by the caller (onResult) when a backend is configured.
|
|
775
|
+
export class BigBasketWorker {
|
|
776
|
+
constructor(provider, { requested, concurrency, deadlineMs, onEvent, onResult }) {
|
|
777
|
+
this.provider = provider;
|
|
778
|
+
this.requested = requested;
|
|
779
|
+
this.concurrency = Math.max(1, Math.min(concurrency, requested));
|
|
780
|
+
this.deadlineMs = deadlineMs || (provider?.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE);
|
|
781
|
+
this.onEvent = onEvent || (() => {});
|
|
782
|
+
this.onResult = onResult || null;
|
|
783
|
+
if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
|
|
784
|
+
this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
|
|
785
|
+
this.slots = {};
|
|
786
|
+
this.seq = 0;
|
|
787
|
+
this.stopped = false;
|
|
788
|
+
this._releases = [];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
stop() { this.stopped = true; }
|
|
792
|
+
_nextSeq() { return ++this.seq; }
|
|
793
|
+
|
|
794
|
+
_claim() {
|
|
795
|
+
if (this.stopped) return 0;
|
|
796
|
+
if (this.stats.succeeded >= this.requested) return 0;
|
|
797
|
+
const active = this.stats.attempts - (this.stats.succeeded + this.stats.failed + this.stats.cancelled);
|
|
798
|
+
if (this.stats.succeeded + active >= this.requested) return 0;
|
|
799
|
+
if (this.stats.attempts >= this.requested * ATTEMPT_CAP_MULT) return 0;
|
|
800
|
+
return ++this.stats.attempts;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
_emit(slot, kv) {
|
|
804
|
+
this.slots[slot] = { slot, ...(this.slots[slot] || {}), ...kv };
|
|
805
|
+
this.onEvent('slot', this.slots[slot]);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
_record(res) {
|
|
809
|
+
if (res.mobile) this.stats.generated++;
|
|
810
|
+
if (res.status === 'success') { this.stats.succeeded++; this.stats.charges += res.cost || 0; }
|
|
811
|
+
else if (res.status === 'cancelled') this.stats.cancelled++;
|
|
812
|
+
else { this.stats.failed++; this.stats.charges += res.cost || 0; }
|
|
813
|
+
this.onEvent('progress', this.stats);
|
|
814
|
+
this.onEvent('row', res);
|
|
815
|
+
if (res.status === 'success' && res.session && this.onResult) {
|
|
816
|
+
try { this.onResult(res); } catch { /* best-effort; never break the loop */ }
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async run() {
|
|
821
|
+
const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
|
|
822
|
+
await Promise.all(loops);
|
|
823
|
+
if (this._releases.length) {
|
|
824
|
+
this.onEvent('warn', `finishing up — releasing ${this._releases.length} number(s)…`);
|
|
825
|
+
await Promise.allSettled(this._releases);
|
|
826
|
+
}
|
|
827
|
+
this.onEvent('done', this.stats);
|
|
828
|
+
return this.stats;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
_release(number, rentedAt, slot) {
|
|
832
|
+
const p = releaseNumber(this.provider, number, rentedAt, {
|
|
833
|
+
log: () => {},
|
|
834
|
+
warn: (m) => this.onEvent('warn', m),
|
|
835
|
+
onWait: slot ? (sec) => this._emit(slot, { phase: 'cancelling', detail: 'releasing (cancel-lock)', wait: sec }) : undefined,
|
|
836
|
+
});
|
|
837
|
+
this._releases.push(p);
|
|
838
|
+
return p;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async _rent(slot) {
|
|
842
|
+
let attempt = 0;
|
|
843
|
+
while (!this.stopped) {
|
|
844
|
+
attempt++;
|
|
845
|
+
this._emit(slot, { phase: 'renting', detail: `requesting a number (try ${attempt})` });
|
|
846
|
+
const { number } = await this.provider.rentOnce();
|
|
847
|
+
if (number) return number;
|
|
848
|
+
if (attempt % RENT_COOLDOWN_EVERY === 0) {
|
|
849
|
+
for (let rem = RENT_COOLDOWN_SECS; rem > 0; rem--) {
|
|
850
|
+
if (this.stopped) return null;
|
|
851
|
+
this._emit(slot, { phase: 'rent_wait', detail: 'no numbers — waiting', wait: rem });
|
|
852
|
+
await sleep(1000);
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
await sleep(RENT_RETRY_DELAY);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async _loop(slot) {
|
|
862
|
+
for (;;) {
|
|
863
|
+
if (this._claim() === 0) return;
|
|
864
|
+
const res = await this._process(slot);
|
|
865
|
+
await this._record(res);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async _process(slot) {
|
|
870
|
+
const idNo = this._nextSeq();
|
|
871
|
+
const res = { id_no: idNo, status: 'failed', cost: 0, detail: '' };
|
|
872
|
+
|
|
873
|
+
if (this.stats.succeeded >= this.requested) {
|
|
874
|
+
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
875
|
+
res.status = 'cancelled'; res.detail = 'goal reached';
|
|
876
|
+
return res;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
|
|
880
|
+
const number = await this._rent(slot);
|
|
881
|
+
if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
|
|
882
|
+
const rentedAt = Date.now();
|
|
883
|
+
if (this.stats.succeeded >= this.requested) {
|
|
884
|
+
this._release(number, rentedAt, slot);
|
|
885
|
+
this._emit(slot, { phase: 'done', detail: 'goal reached' });
|
|
886
|
+
res.status = 'cancelled'; res.detail = 'goal reached';
|
|
887
|
+
return res;
|
|
888
|
+
}
|
|
889
|
+
res.mobile = number.mobile;
|
|
890
|
+
this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
|
|
891
|
+
|
|
892
|
+
let stream = null;
|
|
893
|
+
let released = false;
|
|
894
|
+
const smsArrived = { v: false };
|
|
895
|
+
|
|
896
|
+
const releaseOnce = (charged = false, doneDetail = 'done', consumed = false) => {
|
|
897
|
+
if (released) return;
|
|
898
|
+
released = true;
|
|
899
|
+
if (consumed || (charged && !this.stopped)) { this._emit(slot, { phase: 'done', detail: doneDetail }); return; }
|
|
900
|
+
this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
|
|
901
|
+
this._release(number, rentedAt, slot);
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const fail = (detail, category) => {
|
|
905
|
+
const charged = smsArrived.v;
|
|
906
|
+
res.status = charged ? 'failed' : 'cancelled';
|
|
907
|
+
res.cost = charged ? number.cost : 0;
|
|
908
|
+
res.detail = detail;
|
|
909
|
+
res.category = category || (res.status === 'failed' ? 'failed' : 'cancelled');
|
|
910
|
+
releaseOnce(charged, 'failed');
|
|
911
|
+
return res;
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const session = bigbasket.newSession(number.mobile);
|
|
915
|
+
|
|
916
|
+
try {
|
|
917
|
+
this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
|
|
918
|
+
stream = this.provider.startOtp(number);
|
|
919
|
+
if (typeof stream.ready === 'function') await stream.ready();
|
|
920
|
+
|
|
921
|
+
// bigbasket.sendOtp does register/device → header → otp (all on our IP).
|
|
922
|
+
this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
|
|
923
|
+
const sent = await bigbasket.sendOtp(session);
|
|
924
|
+
if (!sent.ok) {
|
|
925
|
+
// HU4001 = the rented number has no BigBasket account / is inactive (recycled).
|
|
926
|
+
// No SMS will ever come → don't wait; mark as a dead number and rotate to a new one.
|
|
927
|
+
const dead = sent.code === 'HU4001';
|
|
928
|
+
return fail(`send: ${sent.msg}`, dead ? 'no_account' : 'send_blocked');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Poll the rented number's SMS stream for the BigBasket OTP.
|
|
932
|
+
const end = Date.now() + this.deadlineMs;
|
|
933
|
+
let otp = null;
|
|
934
|
+
while (Date.now() < end && !this.stopped) {
|
|
935
|
+
const { otp: o, arrived } = await stream.poll();
|
|
936
|
+
if (arrived) smsArrived.v = true;
|
|
937
|
+
if (o) { otp = o; break; }
|
|
938
|
+
this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: Math.floor((end - Date.now()) / 1000) });
|
|
939
|
+
await sleep(1000);
|
|
940
|
+
}
|
|
941
|
+
if (!otp) return fail(`no OTP within ${Math.round(this.deadlineMs / 1000)}s — abandoned`, 'timeout');
|
|
942
|
+
|
|
943
|
+
this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
|
|
944
|
+
const v = await bigbasket.verifyOtp(session, otp);
|
|
945
|
+
if (!v.ok) {
|
|
946
|
+
const wrong = /valid otp|incorrect|invalid|expired/i.test(v.error || '');
|
|
947
|
+
return fail(`verify: ${v.error}`, wrong ? 'wrong_otp' : 'failed');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
res.status = 'success';
|
|
951
|
+
res.cost = number.cost;
|
|
952
|
+
res.session = bigbasket.toSessionKeys(session); // { phone, bbAuthToken, mId, bbVisitorId }
|
|
953
|
+
res.category = 'extracted';
|
|
954
|
+
|
|
955
|
+
this._emit(slot, { phase: 'saving', detail: 'saving session' });
|
|
956
|
+
releaseOnce(true, 'extracted', true); // success: OTP consumed → no cancel
|
|
957
|
+
return res;
|
|
958
|
+
} catch (e) {
|
|
959
|
+
return fail(`unexpected: ${e.message}`);
|
|
960
|
+
} finally {
|
|
961
|
+
releaseOnce(false);
|
|
962
|
+
if (stream) try { stream.close(); } catch { /* */ }
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|