scripter-x 1.0.17 → 1.0.20
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 +63 -6
- package/src/flipkart.js +81 -14
- package/src/index.js +3 -1
- package/src/providers/otpcart.js +154 -25
- package/src/worker.js +18 -2
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as kuku from './providers/kuku.js';
|
|
|
10
10
|
import * as zepto from './providers/zepto.js';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
|
-
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { Worker } from './worker.js';
|
|
15
15
|
import { RunController } from './controller.js';
|
|
16
16
|
import { STATUS } from './theme.js';
|
|
@@ -122,14 +122,16 @@ async function buildProvider(io, name) {
|
|
|
122
122
|
io.print(' ✓ OTPCart connected');
|
|
123
123
|
if (freshlyEntered && await io.confirm('save these creds locally for next time?', true)) config.setMany({ otpcart_email: email, otpcart_password: password });
|
|
124
124
|
const deep = await io.confirm('deep number check?', false);
|
|
125
|
-
|
|
125
|
+
// Pass creds so the provider can transparently re-login if OTPCart's single active
|
|
126
|
+
// session is taken over by another login mid-run (otherwise the OTP WS goes silent).
|
|
127
|
+
return new oc.OTPCartProvider(jwt, deep, undefined, { email, password });
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
export async function run(io, api, args = {}) {
|
|
129
131
|
// TARGET: Flipkart (the backend campaign flow) or Zepto (local OTP → ZAUTH1 envelope).
|
|
130
132
|
const target = args.target || (args.number ? 'zepto' : (await io.select('what to extract?', [
|
|
131
133
|
{ label: 'Flipkart', value: 'flipkart', description: 'session JSON via OTP provider (campaign)' },
|
|
132
|
-
{ label: 'Zepto', value: 'zepto', description: '
|
|
134
|
+
{ label: 'Zepto', value: 'zepto', description: 'headless extraction (auto) OR local OTP login (manual)' }])).value);
|
|
133
135
|
if (target === 'zepto') return zeptoCmd(io, api, args);
|
|
134
136
|
|
|
135
137
|
api = api || await getApi(io);
|
|
@@ -219,6 +221,59 @@ export async function run(io, api, args = {}) {
|
|
|
219
221
|
if (worker.logPath) io.print(` ◉ log saved to: ${worker.logPath}`);
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
// ── recheck: re-validate Minutes status of an EXISTING session JSON, locally ──
|
|
225
|
+
// FK blocks server IPs, so the Minutes check must run here (residential IP). Takes
|
|
226
|
+
// a file (array of sessions, OR our {session:{...}} export shape), re-checks each
|
|
227
|
+
// account's live Minutes orders, and re-splits into correct -minutes-free /
|
|
228
|
+
// -minutes-used files with real stats + the actual order ids.
|
|
229
|
+
export async function recheck(io, _api, args = {}) {
|
|
230
|
+
// one-shot puts the positional path in action/campaign; interactive may pass file/_
|
|
231
|
+
const src = args.file || args._?.[0] || args.action || args.campaign;
|
|
232
|
+
if (!src || !existsSync(src)) throw new Error('usage: recheck <sessions.json>');
|
|
233
|
+
const raw = JSON.parse(readFileSync(src, 'utf8'));
|
|
234
|
+
const list = Array.isArray(raw) ? raw : (raw.accounts || raw.sessions || [raw]);
|
|
235
|
+
// normalize each entry to a flat FK session object
|
|
236
|
+
const sessions = list.map((e) => (e && e.session && typeof e.session === 'object' ? e.session : e)).filter(Boolean);
|
|
237
|
+
if (!sessions.length) throw new Error('no sessions found in file');
|
|
238
|
+
|
|
239
|
+
const { FlipkartLogin } = await import('./flipkart.js');
|
|
240
|
+
const fk = new FlipkartLogin();
|
|
241
|
+
|
|
242
|
+
io.print(` ◉ re-checking ${sessions.length} account(s) locally (your IP) …`);
|
|
243
|
+
const free = [], used = [], errored = [];
|
|
244
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
245
|
+
const s = sessions[i];
|
|
246
|
+
const mob = s.mobileNo || s.mobile || `#${i + 1}`;
|
|
247
|
+
try {
|
|
248
|
+
const m = await fk.checkMinutes(s);
|
|
249
|
+
if (m.eligible) { free.push(s); io.print(` 🟢 ${mob} — minutes-free (₹100 coupon)`); }
|
|
250
|
+
else {
|
|
251
|
+
used.push(s);
|
|
252
|
+
io.print(` 🔴 ${mob} — ${m.count} minutes order(s): ${m.orders.map((o) => o.orderId).join(', ')}`, 'danger');
|
|
253
|
+
}
|
|
254
|
+
} catch (e) { errored.push(s); io.print(` ! ${mob} — check failed: ${e.message}`, 'danger'); }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// write corrected files next to the source
|
|
258
|
+
const dir = dirname(src);
|
|
259
|
+
const stem = src.split(/[/\\]/).pop().replace(/\.json$/i, '').replace(/-minutes-(free|used|unchecked)$/i, '');
|
|
260
|
+
const write = (suffix, arr) => {
|
|
261
|
+
if (!arr.length) return null;
|
|
262
|
+
const dest = join(dir, `${stem}-rechecked${suffix}.json`);
|
|
263
|
+
writeFileSync(dest, JSON.stringify(arr, null, 2));
|
|
264
|
+
return dest;
|
|
265
|
+
};
|
|
266
|
+
const fFree = write('-minutes-free', free);
|
|
267
|
+
const fUsed = write('-minutes-used', used);
|
|
268
|
+
const fErr = write('-errors', errored);
|
|
269
|
+
|
|
270
|
+
io.print('');
|
|
271
|
+
io.print(` ✓ stats: 🟢 ${free.length} minutes-free · 🔴 ${used.length} minutes-used · ! ${errored.length} errors`);
|
|
272
|
+
if (fFree) io.print(` 🟢 minutes-free → ${fFree}`, 'accent');
|
|
273
|
+
if (fUsed) io.print(` 🔴 minutes-used → ${fUsed}`, 'accent');
|
|
274
|
+
if (fErr) io.print(` ! errors → ${fErr}`, 'accent');
|
|
275
|
+
}
|
|
276
|
+
|
|
222
277
|
// ── zepto: local OTP login → extract session to a ZAUTH1 envelope file ──
|
|
223
278
|
// Dead-simple, no backend: enter a number → we SMS an OTP locally → enter the
|
|
224
279
|
// code → we verify, build the ZAUTH1 envelope, and write {phone}-{timestamp}.txt.
|
|
@@ -337,7 +392,9 @@ export async function zeptoCmd(io, api, args = {}) {
|
|
|
337
392
|
if (!(await io.confirm(`start ${count} Zepto auto extractions at concurrency ${concurrency}?`, true))) return;
|
|
338
393
|
|
|
339
394
|
const ZEPTO_SERVICE_ID = '68b2cf55980e8cf480b28c96';
|
|
340
|
-
|
|
395
|
+
// Pass creds so the provider can transparently re-login if OTPCart invalidates the
|
|
396
|
+
// session (another login with the same account takes it over → WS stops pushing OTPs).
|
|
397
|
+
const provider = new oc.OTPCartProvider(jwt, false, ZEPTO_SERVICE_ID, { email, password });
|
|
341
398
|
|
|
342
399
|
const { ZeptoWorker } = await import('./worker.js');
|
|
343
400
|
const { RunController } = await import('./controller.js');
|
|
@@ -545,7 +602,7 @@ export async function configCmd(io, _api, args = {}) {
|
|
|
545
602
|
}
|
|
546
603
|
|
|
547
604
|
export function help(io) {
|
|
548
|
-
io.print(' commands: run · zepto · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
|
|
605
|
+
io.print(' commands: run · zepto · recheck · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
|
|
549
606
|
}
|
|
550
607
|
|
|
551
608
|
export async function update(io) {
|
|
@@ -558,6 +615,6 @@ export async function update(io) {
|
|
|
558
615
|
|
|
559
616
|
// dispatch table used by the interactive shell
|
|
560
617
|
export const REGISTRY = {
|
|
561
|
-
run, zepto: zeptoCmd, campaigns, export: exportCmd, balance, creds, stop, delete: del,
|
|
618
|
+
run, zepto: zeptoCmd, recheck, campaigns, export: exportCmd, balance, creds, stop, delete: del,
|
|
562
619
|
whoami, login, logout, config: configCmd, update, help,
|
|
563
620
|
};
|
package/src/flipkart.js
CHANGED
|
@@ -29,6 +29,40 @@ const nativeUa = (vid) =>
|
|
|
29
29
|
`Mozilla/5.0 (Linux; Android 13; sdk_gphone64_arm64 Build/TE1A.240213.009) ` +
|
|
30
30
|
`FKUA/Retail/3130902/Android/Mobile (Google/sdk_gphone64_arm64/${(vid || '').toLowerCase()})`;
|
|
31
31
|
|
|
32
|
+
// Parse a MY_ORDER_PAGE response and return ONLY genuine Flipkart-Minutes orders.
|
|
33
|
+
// An order is a slot whose widget is an ORDER CARD (SUPER_WIDGET +
|
|
34
|
+
// ORDER_CARD_SUPER_WIDGET_TRANSFORMER); its id is widget.params.trackingParams.orderId
|
|
35
|
+
// (exactly one per card). It's a MINUTES order iff the card's redirect carries
|
|
36
|
+
// queryParam.hyperlocal === "true" — Grocery/Kilos/memberships are NOT hyperlocal,
|
|
37
|
+
// so they're excluded even in an unfiltered response. (Verified against captured
|
|
38
|
+
// ground-truth: 0-Minutes accounts → 0, 1/2-Minutes accounts → exact match.)
|
|
39
|
+
// Returns [{ orderId, title, status }].
|
|
40
|
+
export function parseMinutesOrders(text) {
|
|
41
|
+
let env;
|
|
42
|
+
try { env = JSON.parse(text); } catch { return []; }
|
|
43
|
+
const resp = (env && env.RESPONSE) || env || {};
|
|
44
|
+
const slots = Array.isArray(resp.slots) ? resp.slots : [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const s of slots) {
|
|
48
|
+
const w = (s && s.widget) || {};
|
|
49
|
+
if (w.type !== 'SUPER_WIDGET' || w.transformerProvider !== 'ORDER_CARD_SUPER_WIDGET_TRANSFORMER') continue;
|
|
50
|
+
const tp = (w.params && w.params.trackingParams) || {};
|
|
51
|
+
const orderId = tp.orderId;
|
|
52
|
+
if (!orderId || seen.has(orderId)) continue;
|
|
53
|
+
let isMinutes = false, title = '';
|
|
54
|
+
try {
|
|
55
|
+
const rc = w.data.subWidgets[0].data.buttons.renderableComponents[0];
|
|
56
|
+
isMinutes = rc.action.nonWidgetizeRedirection.queryParam.hyperlocal === 'true';
|
|
57
|
+
title = (rc.value && rc.value.subText) || '';
|
|
58
|
+
} catch { /* malformed card → not a counted Minutes order */ }
|
|
59
|
+
if (!isMinutes) continue;
|
|
60
|
+
seen.add(orderId);
|
|
61
|
+
out.push({ orderId, title, status: tp.orderStatus || '' });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
// Parse a fetch Response's set-cookie headers into a {name:value} map.
|
|
33
67
|
function parseCookies(res, jar) {
|
|
34
68
|
// Node fetch exposes combined set-cookie via getSetCookie() (undici).
|
|
@@ -138,7 +172,9 @@ export class FlipkartLogin {
|
|
|
138
172
|
};
|
|
139
173
|
}
|
|
140
174
|
|
|
141
|
-
|
|
175
|
+
// One MY_ORDER_PAGE / HYPERLOCAL fetch on a given DC. `withRT` attaches the refresh
|
|
176
|
+
// token so a 206 "AT expired" can be recovered in-place. Returns { status, text }.
|
|
177
|
+
async _fetchOrderPage(session, dc, withRT) {
|
|
142
178
|
await throttle.wait();
|
|
143
179
|
const body = {
|
|
144
180
|
requestContext: { type: 'MY_ORDER_PAGE', pageView: '', queryTime: '', cxTenant: 'cs',
|
|
@@ -148,19 +184,50 @@ export class FlipkartLogin {
|
|
|
148
184
|
paginatedFetch: false, pageNumber: 1, fetchAllPages: false, networkSpeed: 0,
|
|
149
185
|
trackingContext: null, fetchSeoData: false },
|
|
150
186
|
locationContext: { pincode: '' } };
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
return
|
|
187
|
+
const host = `https://${dc}.rome.api.flipkart.net`;
|
|
188
|
+
const at = session.at || '';
|
|
189
|
+
const headers = {
|
|
190
|
+
'User-Agent': 'okhttp/4.9.2', 'X-User-Agent': nativeUa(session.visitId || this.vid),
|
|
191
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
192
|
+
'x-request-metaInfo': '{"actionType":"LOGIN","pageUri":"questContext"}',
|
|
193
|
+
at, sn: session.sn || '',
|
|
194
|
+
secureToken: session.secureToken || '', secureCookie: session.secureCookie || '',
|
|
195
|
+
Cookie: `ud=${session.ud || ''}`,
|
|
196
|
+
};
|
|
197
|
+
if (withRT && session.rt) headers.rt = session.rt;
|
|
198
|
+
const res = await fetch(`${host}/4/page/fetch`, { method: 'POST', body: JSON.stringify(body), headers });
|
|
199
|
+
return { status: res.status, text: await res.text() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Robust FK Minutes check. Handles 206 (AT-expired → resend with rt) and 406
|
|
203
|
+
// (DC change → retry on home DC), then parses ORDER CARDS (not a blind regex)
|
|
204
|
+
// and gates each on its per-card `hyperlocal` flag — so non-Minutes orders
|
|
205
|
+
// (Grocery/Kilos/memberships) and page chrome never count.
|
|
206
|
+
// Returns { eligible, count, orders:[{orderId,title,status}] }.
|
|
207
|
+
async checkMinutes(session) {
|
|
208
|
+
let dc = 2;
|
|
209
|
+
const triedDC = { 2: true };
|
|
210
|
+
let r = await this._fetchOrderPage(session, dc, false);
|
|
211
|
+
|
|
212
|
+
// 206 "AT expired" → resend with rt on the same DC
|
|
213
|
+
if (r.status === 206 || /AT expired/i.test(r.text)) {
|
|
214
|
+
r = await this._fetchOrderPage(session, dc, true);
|
|
215
|
+
// adopt the refreshed token for any follow-up DC retry
|
|
216
|
+
try { const e = JSON.parse(r.text); if (e?.SESSION?.at) session = { ...session, at: e.SESSION.at }; } catch { /* */ }
|
|
217
|
+
}
|
|
218
|
+
// 406 DC change → {RESPONSE:{id:"<dc>"}} → retry on the home DC
|
|
219
|
+
if (r.status === 406) {
|
|
220
|
+
let id = null;
|
|
221
|
+
try { const e = JSON.parse(r.text); id = parseInt(e?.RESPONSE?.id || e?.id, 10); } catch { /* */ }
|
|
222
|
+
if (id >= 1 && !triedDC[id]) {
|
|
223
|
+
dc = id; triedDC[id] = true;
|
|
224
|
+
r = await this._fetchOrderPage(session, dc, false);
|
|
225
|
+
if (r.status === 206 || /AT expired/i.test(r.text)) r = await this._fetchOrderPage(session, dc, true);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const orders = parseMinutesOrders(r.text);
|
|
230
|
+
return { eligible: orders.length === 0, count: orders.length, orders };
|
|
164
231
|
}
|
|
165
232
|
|
|
166
233
|
// ─── Email attach (FK-5/6/7) — on the EMAIL host (2.rome.api.flipkart.com) ──────
|
package/src/index.js
CHANGED
|
@@ -54,6 +54,8 @@ 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 recheck <file> re-validate Minutes status of a session JSON, locally (your IP)
|
|
58
|
+
→ re-splits into corrected -minutes-free / -minutes-used files + stats
|
|
57
59
|
scripterx campaigns | export [name] | balance | creds | stop | delete | whoami | config
|
|
58
60
|
scripterx --version`;
|
|
59
61
|
|
|
@@ -104,7 +106,7 @@ async function main() {
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
// ── one-shot mode ──
|
|
107
|
-
const map = { run: 'run', zepto: 'zepto', campaigns: 'campaigns', export: 'export', balance: 'balance',
|
|
109
|
+
const map = { run: 'run', zepto: 'zepto', recheck: 'recheck', campaigns: 'campaigns', export: 'export', balance: 'balance',
|
|
108
110
|
creds: 'creds', stop: 'stop', delete: 'delete', whoami: 'whoami', login: 'login',
|
|
109
111
|
logout: 'logout', config: 'config', update: 'update', help: 'help' };
|
|
110
112
|
const fnName = map[cmd];
|
package/src/providers/otpcart.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
// OTPCart provider (api.otpcart.xyz) — email/password → JWT, WebSocket OTP push.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ OTPCart allows only ONE active session per account: a fresh login (anyone,
|
|
4
|
+
// anywhere, with the same email/password) INVALIDATES the previous JWT. When that
|
|
5
|
+
// happens mid-run the symptom is a *silently dead* run — rent/cancel start returning
|
|
6
|
+
// 401 and, worst of all, the OTP WebSocket connects fine but never pushes any
|
|
7
|
+
// `otpMessage`, so OTPs simply never arrive.
|
|
8
|
+
//
|
|
9
|
+
// To survive that, the provider holds the credentials and can transparently
|
|
10
|
+
// re-login (de-duped, one at a time) to mint a fresh JWT, then everything —
|
|
11
|
+
// HTTP calls and NEW WebSocket connections — reads the *current* token. Callers
|
|
12
|
+
// that hit an auth failure trigger relogin() and retry.
|
|
2
13
|
import WebSocket from 'ws';
|
|
3
14
|
|
|
4
|
-
|
|
5
|
-
const
|
|
15
|
+
// Overridable for tests (defaults to the real hosts).
|
|
16
|
+
const BASE = process.env.OTPCART_BASE || 'https://api.otpcart.xyz';
|
|
17
|
+
const WS_URL = process.env.OTPCART_WS || 'wss://api.otpcart.xyz/check-otp';
|
|
6
18
|
const SERVICE_ID = '68b19097980e8cf480b1df04';
|
|
7
19
|
const OTP_RE = /\b(\d{6})\b/;
|
|
8
20
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
9
21
|
'(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
|
|
10
22
|
|
|
23
|
+
// Heuristic: does an OTPCart response (status + body message) look like the token
|
|
24
|
+
// was rejected / the session was taken over by another login?
|
|
25
|
+
function isAuthFailure(status, message) {
|
|
26
|
+
if (status === 401 || status === 403) return true;
|
|
27
|
+
const m = String(message || '').toLowerCase();
|
|
28
|
+
return /unauthor|jwt|token|please login|log\s?in again|session expired|invalid signature/.test(m);
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
export async function login(email, password) {
|
|
12
32
|
const res = await fetch(`${BASE}/users/login`, {
|
|
13
33
|
method: 'POST', headers: { 'Content-Type': 'application/json', Origin: 'https://www.otpcart.xyz', 'User-Agent': UA },
|
|
@@ -22,10 +42,19 @@ export async function balance() { return null; }
|
|
|
22
42
|
|
|
23
43
|
export class OTPCartProvider {
|
|
24
44
|
name = 'otpcart';
|
|
25
|
-
|
|
45
|
+
|
|
46
|
+
// Backward compatible: (jwt, deepCheck, serviceId). Pass creds via the 4th arg
|
|
47
|
+
// { email, password } to enable transparent re-login when the session is taken
|
|
48
|
+
// over by another login.
|
|
49
|
+
constructor(jwt, deepCheck = false, serviceId = SERVICE_ID, creds = null) {
|
|
26
50
|
this.jwt = jwt;
|
|
27
51
|
this.deepCheck = deepCheck;
|
|
28
52
|
this.serviceId = serviceId;
|
|
53
|
+
this.email = creds?.email || null;
|
|
54
|
+
this.password = creds?.password || null;
|
|
55
|
+
this._reloginInflight = null; // shared promise so concurrent slots relogin once
|
|
56
|
+
this._tokenEpoch = 0; // bumps on every successful relogin
|
|
57
|
+
this.onNotice = null; // optional (msg) => void for UI breadcrumbs
|
|
29
58
|
}
|
|
30
59
|
|
|
31
60
|
_hdrs() {
|
|
@@ -33,15 +62,62 @@ export class OTPCartProvider {
|
|
|
33
62
|
Origin: 'https://www.otpcart.xyz', 'User-Agent': UA };
|
|
34
63
|
}
|
|
35
64
|
|
|
65
|
+
get token() { return this.jwt; }
|
|
66
|
+
get tokenEpoch() { return this._tokenEpoch; }
|
|
67
|
+
get canRelogin() { return !!(this.email && this.password); }
|
|
68
|
+
|
|
69
|
+
// Re-login to mint a fresh JWT after the session was invalidated. De-duped:
|
|
70
|
+
// concurrent callers share ONE in-flight login so they don't invalidate each
|
|
71
|
+
// other in a relogin storm. `seenEpoch` lets a caller say "only relogin if the
|
|
72
|
+
// token I was using is still the current one" (avoids redundant relogins when
|
|
73
|
+
// another slot already refreshed it).
|
|
74
|
+
async relogin(seenEpoch = null) {
|
|
75
|
+
if (!this.canRelogin) throw new Error('OTPCart session invalidated and no saved credentials to re-login');
|
|
76
|
+
if (seenEpoch != null && seenEpoch !== this._tokenEpoch) {
|
|
77
|
+
// Someone already refreshed since this caller's token — use the new one.
|
|
78
|
+
return this.jwt;
|
|
79
|
+
}
|
|
80
|
+
if (this._reloginInflight) return this._reloginInflight;
|
|
81
|
+
this._reloginInflight = (async () => {
|
|
82
|
+
try {
|
|
83
|
+
if (this.onNotice) this.onNotice('OTPCart session was taken over — re-logging in…');
|
|
84
|
+
const tok = await login(this.email, this.password);
|
|
85
|
+
this.jwt = tok;
|
|
86
|
+
this._tokenEpoch++;
|
|
87
|
+
return tok;
|
|
88
|
+
} finally {
|
|
89
|
+
this._reloginInflight = null;
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
return this._reloginInflight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// POST a JSON body to OTPCart, transparently re-logging-in + retrying ONCE on an
|
|
96
|
+
// auth failure. Returns { status, data }.
|
|
97
|
+
async _post(path, body, timeoutMs = 15000) {
|
|
98
|
+
const epoch = this._tokenEpoch;
|
|
99
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
100
|
+
method: 'POST', headers: this._hdrs(), body: JSON.stringify(body),
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
102
|
+
});
|
|
103
|
+
const data = await res.json().catch(() => ({}));
|
|
104
|
+
if (isAuthFailure(res.status, data?.message) && this.canRelogin) {
|
|
105
|
+
await this.relogin(epoch);
|
|
106
|
+
const res2 = await fetch(`${BASE}${path}`, {
|
|
107
|
+
method: 'POST', headers: this._hdrs(), body: JSON.stringify(body),
|
|
108
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
109
|
+
});
|
|
110
|
+
const data2 = await res2.json().catch(() => ({}));
|
|
111
|
+
return { status: res2.status, data: data2 };
|
|
112
|
+
}
|
|
113
|
+
return { status: res.status, data };
|
|
114
|
+
}
|
|
115
|
+
|
|
36
116
|
async rentOnce() {
|
|
37
|
-
let d;
|
|
117
|
+
let status, d;
|
|
38
118
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
body: JSON.stringify({ serviceId: this.serviceId, isDeepCheck: this.deepCheck }),
|
|
42
|
-
signal: AbortSignal.timeout(15000),
|
|
43
|
-
});
|
|
44
|
-
d = await res.json();
|
|
119
|
+
({ status, data: d } = await this._post('/mobile/generate',
|
|
120
|
+
{ serviceId: this.serviceId, isDeepCheck: this.deepCheck }));
|
|
45
121
|
} catch (e) { return { number: null, msg: e.message, err: e }; }
|
|
46
122
|
if (d.isNumberGenerated && d.mobile?.mobileno) {
|
|
47
123
|
const m = d.mobile;
|
|
@@ -50,55 +126,103 @@ export class OTPCartProvider {
|
|
|
50
126
|
return { number: null, msg: d.message || 'no number available', err: 'err' };
|
|
51
127
|
}
|
|
52
128
|
|
|
53
|
-
startOtp(number) { return new OTPCartStream(this
|
|
129
|
+
startOtp(number) { return new OTPCartStream(this, number.txn, number.extra); }
|
|
54
130
|
|
|
55
131
|
async cancel(number) {
|
|
56
132
|
try {
|
|
57
|
-
await
|
|
58
|
-
|
|
59
|
-
body: JSON.stringify({ serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }),
|
|
60
|
-
signal: AbortSignal.timeout(12000),
|
|
61
|
-
});
|
|
133
|
+
await this._post('/otp/cancelOtp',
|
|
134
|
+
{ serialNumber: number.txn, mobileId: number.extra, serviceId: this.serviceId }, 12000);
|
|
62
135
|
} catch { /* */ }
|
|
63
136
|
}
|
|
64
137
|
}
|
|
65
138
|
|
|
139
|
+
// Grace period after the WS connects with no OTP push before we suspect the token
|
|
140
|
+
// is dead and reconnect with a freshly re-logged-in token. The real SMS usually
|
|
141
|
+
// lands well within this; a *dead-token* socket NEVER pushes, so this is what
|
|
142
|
+
// rescues an otherwise-silent run.
|
|
143
|
+
const WS_SILENT_RELOGIN_MS = 25000;
|
|
144
|
+
|
|
66
145
|
class OTPCartStream {
|
|
67
|
-
|
|
146
|
+
// Takes the PROVIDER (not a frozen jwt) so every (re)connect reads the current
|
|
147
|
+
// token and can ask the provider to re-login when the session was taken over.
|
|
148
|
+
constructor(provider, serial, mobileId) {
|
|
149
|
+
this.provider = provider;
|
|
68
150
|
this.serial = serial;
|
|
69
151
|
this.mobileId = mobileId;
|
|
70
152
|
this.otp = null;
|
|
71
153
|
this.arrived = false;
|
|
72
154
|
this.closed = false;
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
155
|
+
this._openP = null; // resolves when the WS is OPEN
|
|
156
|
+
this._reconnectTimer = null;
|
|
157
|
+
this._connectedAt = 0;
|
|
158
|
+
this._silentTimer = null; // fires if no OTP arrives → relogin + reconnect
|
|
159
|
+
this._reloggingIn = false;
|
|
160
|
+
this._silentReloginsLeft = 1; // recover a taken-over session once; don't churn a slow SMS
|
|
75
161
|
this._connect();
|
|
76
162
|
}
|
|
77
163
|
|
|
164
|
+
_currentToken() { return this.provider.token; }
|
|
165
|
+
|
|
166
|
+
_clearSilentTimer() {
|
|
167
|
+
if (this._silentTimer) { clearTimeout(this._silentTimer); this._silentTimer = null; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Schedule the "this socket has gone quiet — maybe the token is dead" check.
|
|
171
|
+
// Only while we still have a relogin budget (a genuinely slow SMS shouldn't keep
|
|
172
|
+
// rotating the shared token, which would force every other slot to reconnect).
|
|
173
|
+
_armSilentTimer() {
|
|
174
|
+
this._clearSilentTimer();
|
|
175
|
+
if (this.closed || this.otp || !this.provider.canRelogin || this._silentReloginsLeft <= 0) return;
|
|
176
|
+
this._silentTimer = setTimeout(() => this._handleSilent(), WS_SILENT_RELOGIN_MS);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _handleSilent() {
|
|
180
|
+
if (this.closed || this.otp || this._reloggingIn || this._silentReloginsLeft <= 0) return;
|
|
181
|
+
this._silentReloginsLeft--;
|
|
182
|
+
this._reloggingIn = true;
|
|
183
|
+
try {
|
|
184
|
+
// Re-login (de-duped at the provider) to get a token that the WS will accept,
|
|
185
|
+
// then reconnect this socket with it. If the token was actually fine, this is
|
|
186
|
+
// a cheap no-op reconnect that costs nothing but a fresh socket.
|
|
187
|
+
const epoch = this.provider.tokenEpoch;
|
|
188
|
+
await this.provider.relogin(epoch);
|
|
189
|
+
} catch { /* keep the old socket; nothing better to do */ }
|
|
190
|
+
finally { this._reloggingIn = false; }
|
|
191
|
+
if (this.closed || this.otp) return;
|
|
192
|
+
try { if (this.ws) this.ws.terminate(); } catch { /* */ }
|
|
193
|
+
this._connect(); // reconnect with the (possibly refreshed) current token
|
|
194
|
+
}
|
|
195
|
+
|
|
78
196
|
_connect() {
|
|
79
|
-
|
|
197
|
+
const token = this._currentToken();
|
|
198
|
+
this.ws = new WebSocket(`${WS_URL}?token=${token}`);
|
|
80
199
|
this._openP = new Promise((resolve) => {
|
|
81
200
|
this.ws.once('open', resolve);
|
|
82
201
|
this.ws.once('error', resolve); // resolve anyway so ready() never hangs
|
|
83
202
|
});
|
|
203
|
+
this.ws.on('open', () => {
|
|
204
|
+
this._connectedAt = Date.now();
|
|
205
|
+
this._armSilentTimer(); // start the dead-token watchdog
|
|
206
|
+
});
|
|
84
207
|
this.ws.on('message', (raw) => {
|
|
85
208
|
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
86
209
|
// OTP frame: {message, otpMessage}. Ack frame {serialNumber,mobileId,resend} is ignored.
|
|
87
210
|
if (msg.otpMessage) {
|
|
88
211
|
const m = String(msg.otpMessage).match(OTP_RE);
|
|
89
|
-
if (m) { this.otp = m[1]; this.arrived = true; }
|
|
212
|
+
if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); }
|
|
90
213
|
}
|
|
91
214
|
});
|
|
92
215
|
this.ws.on('error', () => { /* swallow — poll() returns nothing; we auto-reconnect */ });
|
|
93
216
|
// Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
|
|
94
217
|
// silently lose the push — this is the "purchased but never got OTP" symptom).
|
|
95
218
|
this.ws.on('close', () => {
|
|
96
|
-
|
|
97
|
-
|
|
219
|
+
this._clearSilentTimer();
|
|
220
|
+
if (this.closed || this.otp || this._reloggingIn) return;
|
|
221
|
+
this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
|
|
98
222
|
});
|
|
99
223
|
}
|
|
100
224
|
|
|
101
|
-
// Wait until the WS is OPEN (so we never send the
|
|
225
|
+
// Wait until the WS is OPEN (so we never send the OTP before we can receive the push).
|
|
102
226
|
async ready(timeoutMs = 8000) {
|
|
103
227
|
await Promise.race([this._openP, new Promise((r) => setTimeout(r, timeoutMs))]);
|
|
104
228
|
return this.ws.readyState === WebSocket.OPEN;
|
|
@@ -122,5 +246,10 @@ class OTPCartStream {
|
|
|
122
246
|
return false;
|
|
123
247
|
}
|
|
124
248
|
|
|
125
|
-
close() {
|
|
249
|
+
close() {
|
|
250
|
+
this.closed = true;
|
|
251
|
+
this._clearSilentTimer();
|
|
252
|
+
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
253
|
+
try { this.ws.close(); } catch { /* */ }
|
|
254
|
+
}
|
|
126
255
|
}
|
package/src/worker.js
CHANGED
|
@@ -28,6 +28,8 @@ export class Worker {
|
|
|
28
28
|
this.concurrency = Math.max(1, Math.min(concurrency, requested));
|
|
29
29
|
this.checkMinutes = checkMinutes;
|
|
30
30
|
this.onEvent = onEvent || (() => {});
|
|
31
|
+
// Surface OTPCart re-logins (session taken over by another login) in the UI.
|
|
32
|
+
if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
|
|
31
33
|
// onFailure({mobile, reason}) → Promise<boolean>: true = keep going, false = stop campaign.
|
|
32
34
|
this.onFailure = onFailure || null;
|
|
33
35
|
this.emailMode = !!emailMode; // also attach a newaddr.com email to each account
|
|
@@ -73,6 +75,11 @@ export class Worker {
|
|
|
73
75
|
fail_reason: res.detail || '', session: res.session,
|
|
74
76
|
minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
|
|
75
77
|
has_minutes_order: res.has_minutes_order ?? undefined,
|
|
78
|
+
// pack the local Minutes order count + ids into minutes_note (free-text the
|
|
79
|
+
// server already stores) so stats/export survive the round-trip.
|
|
80
|
+
minutes_note: res.minutes_checked
|
|
81
|
+
? (res.minutes_order_count ? `${res.minutes_order_count} minutes order(s): ${(res.minutes_orders || []).map((o) => o.orderId).join(',')}` : 'no minutes orders')
|
|
82
|
+
: undefined,
|
|
76
83
|
linked_email: res.linked_email ?? undefined,
|
|
77
84
|
email_linked: res.email_linked ?? undefined,
|
|
78
85
|
email_reason: res.email_reason ?? undefined,
|
|
@@ -214,8 +221,15 @@ export class Worker {
|
|
|
214
221
|
if (this.checkMinutes) {
|
|
215
222
|
this._emit(slot, { phase: 'minutes', detail: 'checking Minutes' });
|
|
216
223
|
try {
|
|
217
|
-
const
|
|
218
|
-
|
|
224
|
+
const m = await fk.checkMinutes(session);
|
|
225
|
+
// checkMinutes now returns { eligible, count, orders }; tolerate an old boolean too.
|
|
226
|
+
const eligible = typeof m === 'boolean' ? m : m.eligible;
|
|
227
|
+
res.minutes_checked = true;
|
|
228
|
+
res.coupon_eligible = eligible;
|
|
229
|
+
res.has_minutes_order = !eligible;
|
|
230
|
+
res.minutes_order_count = typeof m === 'boolean' ? (eligible ? 0 : 1) : m.count;
|
|
231
|
+
res.minutes_orders = typeof m === 'boolean' ? [] : (m.orders || []);
|
|
232
|
+
this._emit(slot, { phase: 'minutes', detail: eligible ? 'minutes-free (₹100 coupon)' : `has ${res.minutes_order_count} minutes order(s)` });
|
|
219
233
|
} catch { /* non-fatal */ }
|
|
220
234
|
}
|
|
221
235
|
|
|
@@ -466,6 +480,8 @@ export class ZeptoWorker {
|
|
|
466
480
|
this.concurrency = Math.max(1, Math.min(concurrency, requested));
|
|
467
481
|
this.certSha = certSha;
|
|
468
482
|
this.onEvent = onEvent || (() => {});
|
|
483
|
+
// Surface OTPCart re-logins (session taken over by another login) in the UI.
|
|
484
|
+
if (provider && 'onNotice' in provider) provider.onNotice = (msg) => this.onEvent('warn', `⚠ ${msg}`);
|
|
469
485
|
this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
|
|
470
486
|
this.slots = {};
|
|
471
487
|
this.seq = 0;
|