scripter-x 1.0.30 → 1.0.31

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripter-x",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "ScripterX — local Flipkart session extractor (runs on your residential IP, syncs to marthunt)",
5
5
  "type": "module",
6
6
  "bin": {
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 (the backend campaign flow) or Zepto (local OTP → ZAUTH1 envelope).
133
- const target = args.target || (args.number ? 'zepto' : (await io.select('what to extract?', [
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)' }])).value);
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,42 +498,125 @@ 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
- if (worker.stats.succeeded > 0) {
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
- const tiers = {};
491
- for (const r of results) {
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 (!tiers[tier]?.length) continue;
499
- const dir = zeptoTierDir(args, name);
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 results) {
509
- if (r.status === 'success') io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
510
- }
522
+ for (const r of succeeded) io.print(` +91${r.mobile} → ${r.envelope}`, 'accent');
511
523
  }
512
524
  } else {
513
525
  io.print(' ◉ no sessions extracted — nothing to save.');
514
526
  }
515
527
  }
516
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
+ // Only manual exists today; auto is wired after the server contract is known.
556
+ const mode = args.auto ? 'auto' : 'manual';
557
+ if (mode === 'auto') {
558
+ io.print(' ! BigBasket auto mode is not wired yet — coming once the server is set.', 'danger');
559
+ io.print(' ◉ use manual for now: scripterx bigbasket --manual', 'accent');
560
+ return;
561
+ }
562
+
563
+ io.print(' ◉ BigBasket — local OTP → session JSON (Manual)');
564
+ io.print(' ◉ press esc to cancel', 'accent');
565
+
566
+ let done = 0;
567
+ let lastSig = 0, stopping = false;
568
+ const onSigint = () => {
569
+ const now = Date.now();
570
+ if (now - lastSig < 2000) { stopping = true; process.exit(0); }
571
+ lastSig = now;
572
+ io.print(' ⚠ press Ctrl+C again to exit');
573
+ };
574
+ const interactive = !!io.startRun; // ink App owns its own Ctrl+C
575
+ if (!interactive) process.on('SIGINT', onSigint);
576
+
577
+ try {
578
+ for (;;) {
579
+ // number entry — Esc stops the whole loop
580
+ const raw = args.number || await io.ask('bigbasket phone number', { escapable: true });
581
+ if (raw === CANCEL) break;
582
+ const number = String(raw).replace(/\D/g, '').slice(-10);
583
+ if (number.length !== 10) { io.print(' ! enter a 10-digit number', 'danger'); if (args.number) break; continue; }
584
+
585
+ try {
586
+ const session = bigbasket.newSession(number);
587
+ io.print(` ◉ sending OTP to ${number} …`);
588
+ const s = await bigbasket.sendOtp(session);
589
+ if (!s.ok) { io.print(` ✗ could not send OTP: ${s.msg || s.status}`, 'danger'); if (args.number) break; continue; }
590
+ io.print(' ✓ OTP sent');
591
+
592
+ // OTP entry — Esc abandons this number and loops back
593
+ const otpRaw = args.otp || await io.ask('enter the OTP', { escapable: true });
594
+ if (otpRaw === CANCEL) break;
595
+ const v = await bigbasket.verifyOtp(session, String(otpRaw).trim());
596
+ if (!v.ok) { io.print(` ✗ OTP verify failed: ${v.error || v.status}`, 'danger'); if (args.number) break; continue; }
597
+ io.print(` ✓ logged in${v.user?.first_name ? ` as ${v.user.first_name}` : ''}`);
598
+
599
+ const keys = bigbasket.toSessionKeys(session);
600
+ const dest = saveBigbasketSession(args, number, keys);
601
+ done++;
602
+ io.print(' ✓ session saved to:');
603
+ io.print(` ${dest}`, 'accent');
604
+ io.print(` bbAuthToken=${(keys.bbAuthToken || '').slice(0, 24)}… mId=${keys.mId} bbVisitorId=${keys.bbVisitorId}`, 'accent');
605
+ } catch (e) {
606
+ io.print(` ✗ ${number}: ${e.message}`, 'danger');
607
+ if (args.number) break;
608
+ continue;
609
+ }
610
+
611
+ if (args.number) break; // one-shot: do exactly one and stop
612
+ io.print(' ◉ next number (esc to finish)', 'accent');
613
+ }
614
+ } finally {
615
+ if (!interactive && !stopping) process.off('SIGINT', onSigint);
616
+ }
617
+ io.print(` ✓ done — ${done} session(s) saved.`);
618
+ }
619
+
517
620
  const TIER_LABEL = { rs100: '🟢 ₹100', rs75: '🟢 ₹75', rs50: '🟢 ₹50', normal: '⚪ normal', unchecked: '❔ unchecked' };
518
621
 
519
622
  // The directory the per-tier files go in for an auto run.
@@ -695,7 +798,7 @@ export async function configCmd(io, _api, args = {}) {
695
798
  }
696
799
 
697
800
  export function help(io) {
698
- io.print(' commands: run · zepto · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
801
+ io.print(' commands: run · zepto · bigbasket · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
699
802
  }
700
803
 
701
804
  export async function update(io) {
@@ -721,6 +824,6 @@ export async function update(io) {
721
824
 
722
825
  // dispatch table used by the interactive shell
723
826
  export const REGISTRY = {
724
- run, zepto: zeptoCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
827
+ run, zepto: zeptoCmd, bigbasket: bigbasketCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
725
828
  whoami, login, logout, config: configCmd, update, help,
726
829
  };
package/src/index.js CHANGED
@@ -54,6 +54,9 @@ 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
+ --manual --number <10-digit> --otp <code> --out <file|dir>
59
+ → writes {phone, bbAuthToken, mId, bbVisitorId} per number (auto mode soon)
57
60
  scripterx recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
58
61
  → re-splits into corrected -minutes-free / -minutes-used files + stats
59
62
  scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
@@ -106,7 +109,7 @@ async function main() {
106
109
  }
107
110
 
108
111
  // ── one-shot mode ──
109
- const map = { run: 'run', zepto: 'zepto', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
112
+ const map = { run: 'run', zepto: 'zepto', bigbasket: 'bigbasket', bb: 'bigbasket', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
110
113
  creds: 'creds', stop: 'stop', delete: 'delete', whoami: 'whoami', login: 'login',
111
114
  logout: 'logout', config: 'config', update: 'update', help: 'help' };
112
115
  const fnName = map[cmd];
@@ -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/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
@@ -555,13 +555,14 @@ async function releaseNumber(provider, number, rentedAt, { log = () => {}, warn
555
555
  export const __releaseNumberForTest = releaseNumber;
556
556
 
557
557
  export class ZeptoWorker {
558
- constructor(provider, { requested, concurrency, certSha, onEvent, checkCoupon }) {
558
+ constructor(provider, { requested, concurrency, certSha, onEvent, onResult, checkCoupon }) {
559
559
  this.provider = provider;
560
560
  this.requested = requested;
561
561
  this.concurrency = Math.max(1, Math.min(concurrency, requested));
562
562
  this.certSha = certSha;
563
563
  this.checkCoupon = !!checkCoupon; // also classify the ₹50/₹75/₹100 coupon tier
564
564
  this.onEvent = onEvent || (() => {});
565
+ this.onResult = onResult || null; // called per successful result for incremental save
565
566
  // Surface OTPCart re-logins (session taken over by another login) in the UI.
566
567
  if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
567
568
  this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
@@ -595,6 +596,11 @@ export class ZeptoWorker {
595
596
  else { this.stats.failed++; this.stats.charges += res.cost || 0; }
596
597
  this.onEvent('progress', this.stats);
597
598
  this.onEvent('row', res);
599
+ // Persist each successful envelope IMMEDIATELY (incremental save) so a mid-run
600
+ // stop / double-Ctrl+C / crash never loses an already-extracted account.
601
+ if (res.status === 'success' && res.envelope && this.onResult) {
602
+ try { this.onResult(res); } catch { /* best-effort; never break the loop */ }
603
+ }
598
604
  }
599
605
 
600
606
  async run() {