scripter-x 1.0.0

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/src/worker.js ADDED
@@ -0,0 +1,261 @@
1
+ // Goal-driven extraction worker: keep going until N successful JSONs.
2
+ // Async concurrency (no threads needed). Mirrors the server worker's semantics:
3
+ // goal = N JSONs, 4× attempt cap; refund rule (charge only if SMS arrived);
4
+ // OTP timing + wrong-OTP resend; every result pushed to the server (ingest).
5
+ import { FlipkartLogin, WrongOTP, BlockedError, TransientError } from './flipkart.js';
6
+
7
+ const OTP_RESEND_AFTER = 60_000;
8
+ const OTPCART_DEADLINE = 120_000;
9
+ const TEMPOTP_DEADLINE = 90_000; // TempOTP: give up after 90s, cancel + buy a new number
10
+ const WRONG_OTP_WAIT = 60_000;
11
+ const NUMBER_CANCEL_LOCK = 120_000;
12
+ const RENT_RETRY_DELAY = 3_000;
13
+ const RENT_COOLDOWN_EVERY = 9;
14
+ const RENT_COOLDOWN_SECS = 30;
15
+ const ATTEMPT_CAP_MULT = 4;
16
+
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ export class Worker {
20
+ constructor(api, provider, { campaignId, requested, concurrency, checkMinutes, onEvent, onFailure }) {
21
+ this.api = api;
22
+ this.provider = provider;
23
+ this.cid = campaignId;
24
+ this.requested = requested;
25
+ this.concurrency = Math.max(1, Math.min(concurrency, requested));
26
+ this.checkMinutes = checkMinutes;
27
+ this.onEvent = onEvent || (() => {});
28
+ // onFailure({mobile, reason}) → Promise<boolean>: true = keep going, false = stop campaign.
29
+ this.onFailure = onFailure || null;
30
+ this.stats = { total: requested, succeeded: 0, failed: 0, cancelled: 0, generated: 0, attempts: 0, charges: 0 };
31
+ this.slots = {};
32
+ this.seq = 0;
33
+ this.stopped = false;
34
+ }
35
+
36
+ stop() { this.stopped = true; }
37
+ _nextSeq() { return ++this.seq; }
38
+
39
+ _claim() {
40
+ if (this.stopped) return 0;
41
+ if (this.stats.succeeded >= this.requested) return 0;
42
+ if (this.stats.attempts >= this.requested * ATTEMPT_CAP_MULT) return 0;
43
+ return ++this.stats.attempts;
44
+ }
45
+
46
+ _emit(slot, kv) {
47
+ this.slots[slot] = { slot, ...(this.slots[slot] || {}), ...kv };
48
+ this.onEvent('slot', this.slots[slot]);
49
+ }
50
+
51
+ async _record(res) {
52
+ if (res.mobile) this.stats.generated++;
53
+ if (res.status === 'success') { this.stats.succeeded++; this.stats.charges += res.cost || 0; }
54
+ else if (res.status === 'cancelled') this.stats.cancelled++;
55
+ else { this.stats.failed++; this.stats.charges += res.cost || 0; } // failed w/ SMS = charged by provider
56
+ this.onEvent('progress', this.stats);
57
+ this.onEvent('row', res);
58
+ try {
59
+ await this.api.ingestAccount(this.cid, {
60
+ id_no: res.id_no, mobile: res.mobile || '', status: res.status, cost: res.cost || 0,
61
+ fail_reason: res.detail || '', session: res.session,
62
+ minutes_checked: !!res.minutes_checked, coupon_eligible: res.coupon_eligible ?? undefined,
63
+ has_minutes_order: res.has_minutes_order ?? undefined,
64
+ });
65
+ } catch (e) { this.onEvent('warn', `ingest failed: ${e.message}`); }
66
+ }
67
+
68
+ async run() {
69
+ await this.api.markRunning(this.cid);
70
+ const loops = Array.from({ length: this.concurrency }, (_, i) => this._loop(i + 1));
71
+ await Promise.all(loops);
72
+ const status = this.stopped ? 'cancelled' : 'completed';
73
+ const s = this.stats;
74
+ try {
75
+ await this.api.finishCampaign(this.cid, {
76
+ succeeded: s.succeeded, failed: s.failed, cancelled: s.cancelled,
77
+ numbers_generated: s.generated, attempts: s.attempts, charges: s.charges, status,
78
+ });
79
+ } catch (e) { this.onEvent('warn', `finish failed: ${e.message}`); }
80
+ this.onEvent('done', s);
81
+ return s;
82
+ }
83
+
84
+ async _loop(slot) {
85
+ for (;;) {
86
+ if (this._claim() === 0) return;
87
+ const res = await this._process(slot);
88
+ await this._record(res);
89
+ }
90
+ }
91
+
92
+ async _process(slot) {
93
+ const idNo = this._nextSeq();
94
+ const res = { id_no: idNo, status: 'failed', cost: 0, detail: '' };
95
+
96
+ this._emit(slot, { mobile: '', phase: 'renting', detail: 'requesting a number', wait: 0 });
97
+ const number = await this._rent(slot);
98
+ if (!number) { res.status = 'cancelled'; res.detail = 'stopped while renting'; return res; }
99
+ const rentedAt = Date.now();
100
+ res.mobile = number.mobile;
101
+ this._emit(slot, { mobile: number.mobile, phase: 'rented', detail: 'got a number' });
102
+
103
+ const smsArrived = { v: false };
104
+ const fk = new FlipkartLogin();
105
+ let stream = null;
106
+
107
+ const fail = (detail) => {
108
+ const charged = smsArrived.v;
109
+ res.status = charged ? 'failed' : 'cancelled';
110
+ res.cost = charged ? number.cost : 0;
111
+ res.detail = detail;
112
+ this._emit(slot, { phase: 'cancelling', detail: 'releasing number' });
113
+ this._release(number, rentedAt);
114
+ return res;
115
+ };
116
+
117
+ try {
118
+ this._emit(slot, { phase: 'ws', detail: 'opening OTP channel' });
119
+ stream = this.provider.startOtp(number);
120
+
121
+ this._emit(slot, { phase: 'send_otp', detail: 'sending OTP' });
122
+ try { await fk.sendOtp(number.mobile); }
123
+ catch (e) { return fail(`send: ${e.message}`); }
124
+
125
+ const deadline = this.provider.name === 'tempotp' ? TEMPOTP_DEADLINE : OTPCART_DEADLINE;
126
+ const doResend = this.provider.name === 'otpcart';
127
+ const otp = await this._awaitOtp(slot, stream, fk, number, deadline, doResend, smsArrived);
128
+ if (!otp) return fail(`no OTP within ${deadline / 1000}s — abandoned`);
129
+
130
+ this._emit(slot, { phase: 'verify', detail: 'verifying OTP' });
131
+ const { session, error: verifyErr } = await this._verify(slot, fk, stream, number, otp, smsArrived);
132
+ if (!session) {
133
+ // surface the REAL Flipkart reason, and (since the OTP SMS was delivered) charge it.
134
+ const failed = fail(verifyErr || 'verify failed');
135
+ // ask the user whether to keep going after a failure (one prompt at a time)
136
+ if (this.onFailure && !this.stopped && !this._asking) {
137
+ this._asking = true;
138
+ try {
139
+ const cont = await this.onFailure({ mobile: number.mobile, reason: verifyErr || 'verify failed' });
140
+ if (!cont) this.stopped = true; // stop the whole campaign
141
+ } finally { this._asking = false; }
142
+ }
143
+ return failed;
144
+ }
145
+
146
+ if (this.checkMinutes) {
147
+ this._emit(slot, { phase: 'minutes', detail: 'checking Minutes' });
148
+ try {
149
+ const avail = await fk.checkMinutes(session);
150
+ res.minutes_checked = true; res.coupon_eligible = avail; res.has_minutes_order = !avail;
151
+ } catch { /* non-fatal */ }
152
+ }
153
+
154
+ this._emit(slot, { phase: 'saving', detail: 'saving session' });
155
+ res.status = 'success'; res.cost = number.cost; res.session = session;
156
+ this._emit(slot, { phase: 'done', detail: 'extracted' });
157
+ this._release(number, rentedAt);
158
+ return res;
159
+ } finally {
160
+ if (stream) try { stream.close(); } catch { /* */ }
161
+ }
162
+ }
163
+
164
+ async _rent(slot) {
165
+ let attempt = 0;
166
+ while (!this.stopped) {
167
+ attempt++;
168
+ this._emit(slot, { phase: 'renting', detail: `requesting a number (try ${attempt})` });
169
+ const { number } = await this.provider.rentOnce();
170
+ if (number) return number;
171
+ if (attempt % RENT_COOLDOWN_EVERY === 0) {
172
+ for (let rem = RENT_COOLDOWN_SECS; rem > 0; rem--) {
173
+ if (this.stopped) return null;
174
+ this._emit(slot, { phase: 'rent_wait', detail: 'no numbers — waiting', wait: rem });
175
+ await sleep(1000);
176
+ }
177
+ } else {
178
+ await sleep(RENT_RETRY_DELAY);
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ async _awaitOtp(slot, stream, fk, number, deadline, doResend, smsArrived) {
185
+ const end = Date.now() + deadline;
186
+ const resendAt = Date.now() + OTP_RESEND_AFTER;
187
+ let resent = false;
188
+ while (Date.now() < end) {
189
+ if (this.stopped) return null;
190
+ const { otp, arrived } = await stream.poll();
191
+ if (arrived) smsArrived.v = true;
192
+ if (otp) return otp;
193
+ const rem = Math.floor((end - Date.now()) / 1000);
194
+ if (doResend && !resent && Date.now() >= resendAt) {
195
+ try { await fk.sendOtp(number.mobile); } catch { /* */ }
196
+ stream.resend(); resent = true;
197
+ this._emit(slot, { phase: 'otp_resend', detail: 'OTP resent', wait: rem });
198
+ } else {
199
+ this._emit(slot, { phase: 'await_otp', detail: 'waiting for OTP', wait: rem });
200
+ }
201
+ await sleep(1000);
202
+ }
203
+ return null;
204
+ }
205
+
206
+ // Verify the OTP. Returns { session, error } — error is a human reason on failure.
207
+ // Transient (529/5xx/IP) failures are RETRIED a few times before giving up, so they're
208
+ // not mislabeled as "OTP rejected".
209
+ async _verify(slot, fk, stream, number, otp, smsArrived) {
210
+ let lastErr = 'verify failed';
211
+ for (let attempt = 1; attempt <= 3; attempt++) {
212
+ try {
213
+ const session = await fk.verifyOtp(number.mobile, otp);
214
+ return { session, error: null };
215
+ } catch (e) {
216
+ if (e instanceof TransientError) {
217
+ lastErr = e.message;
218
+ this._emit(slot, { phase: 'verify', detail: `Flipkart busy — retry ${attempt}/3` });
219
+ await sleep(3000);
220
+ continue; // retry the SAME otp (it's still valid; the server just hiccupped)
221
+ }
222
+ if (e instanceof WrongOTP) {
223
+ // genuine wrong/expired code. OTPCart can ask for a fresh one; TempOTP can't (same txn).
224
+ if (this.provider.name === 'tempotp') return { session: null, error: e.message };
225
+ return this._resendAndVerify(slot, fk, stream, number, smsArrived, e.message);
226
+ }
227
+ return { session: null, error: e.message };
228
+ }
229
+ }
230
+ return { session: null, error: lastErr };
231
+ }
232
+
233
+ async _resendAndVerify(slot, fk, stream, number, smsArrived, why) {
234
+ this._emit(slot, { phase: 'otp_resend', detail: `${why} — resending`, wait: WRONG_OTP_WAIT / 1000 });
235
+ try { await fk.sendOtp(number.mobile); } catch (e) { return { session: null, error: `resend failed: ${e.message}` }; }
236
+ stream.resend();
237
+ const end = Date.now() + WRONG_OTP_WAIT;
238
+ let otp2 = null;
239
+ while (Date.now() < end && !this.stopped) {
240
+ const { otp: o, arrived } = await stream.poll();
241
+ if (arrived) smsArrived.v = true;
242
+ if (o) { otp2 = o; break; }
243
+ this._emit(slot, { phase: 'await_otp', detail: 'waiting for new OTP', wait: Math.floor((end - Date.now()) / 1000) });
244
+ await sleep(1000);
245
+ }
246
+ if (!otp2) return { session: null, error: 'no new OTP after resend' };
247
+ this._emit(slot, { phase: 'verify', detail: 'verifying OTP (retry)' });
248
+ try { return { session: await fk.verifyOtp(number.mobile, otp2), error: null }; }
249
+ catch (e) { return { session: null, error: e.message }; }
250
+ }
251
+
252
+ _release(number, rentedAt) {
253
+ (async () => {
254
+ if (this.provider.name === 'tempotp') {
255
+ const wait = NUMBER_CANCEL_LOCK - (Date.now() - rentedAt);
256
+ if (wait > 0) await sleep(wait);
257
+ }
258
+ try { await this.provider.cancel(number); } catch { /* */ }
259
+ })();
260
+ }
261
+ }