dankgrinder 5.0.0 → 5.0.2
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/lib/commands/adventure.js +25 -14
- package/lib/commands/beg.js +3 -1
- package/lib/commands/blackjack.js +30 -15
- package/lib/commands/crime.js +7 -3
- package/lib/commands/drops.js +29 -14
- package/lib/commands/fish.js +9 -4
- package/lib/commands/gamble.js +4 -1
- package/lib/commands/highlow.js +19 -11
- package/lib/commands/postmemes.js +3 -1
- package/lib/commands/profile.js +5 -4
- package/lib/commands/search.js +2 -2
- package/lib/commands/stream.js +1 -1
- package/lib/commands/trivia.js +6 -2
- package/lib/commands/utils.js +124 -65
- package/lib/commands/work.js +17 -8
- package/lib/grinder.js +169 -59
- package/lib/structures.js +131 -0
- package/package.json +1 -1
package/lib/commands/utils.js
CHANGED
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
const https = require('https');
|
|
13
13
|
const { AhoCorasick, LRUCache, StringPool, ObjectPool } = require('../structures');
|
|
14
14
|
|
|
15
|
+
// ── HTTPS Keep-Alive Agent ───────────────────────────────────
|
|
16
|
+
// At 10K accounts, every CV2 fetch without keep-alive creates a new
|
|
17
|
+
// TCP+TLS handshake (~150ms). With keep-alive, connections are reused.
|
|
18
|
+
// maxSockets=150 prevents file descriptor exhaustion; scheduling='fifo'
|
|
19
|
+
// distributes load evenly across connections.
|
|
20
|
+
const httpsAgent = new https.Agent({
|
|
21
|
+
keepAlive: true,
|
|
22
|
+
keepAliveMsecs: 30000,
|
|
23
|
+
maxSockets: 150,
|
|
24
|
+
maxFreeSockets: 30,
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
scheduling: 'fifo',
|
|
27
|
+
});
|
|
28
|
+
|
|
15
29
|
// ── String Interning ─────────────────────────────────────────
|
|
16
30
|
// Discord sends repeated IDs millions of times. Intern them to share references.
|
|
17
31
|
const strings = new StringPool();
|
|
@@ -62,6 +76,25 @@ const LOG = {
|
|
|
62
76
|
debug: (msg) => log(`${c.dim}⊙${c.reset}`, msg),
|
|
63
77
|
};
|
|
64
78
|
|
|
79
|
+
// ── Pre-compiled Regex (avoid recompilation in hot paths) ────
|
|
80
|
+
// V8 recompiles inline /regex/ on every call site. Module-level
|
|
81
|
+
// compilation runs once and shares the compiled automaton.
|
|
82
|
+
const RE_ANSI = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
83
|
+
const RE_NET_COINS = /Net:\s*\*{0,2}\s*[⏣o]\s*\*{0,2}([+-]?[\d,]+)/i;
|
|
84
|
+
const RE_WINNINGS = /Winnings:\s*\*{0,2}\s*[⏣o]\s*\*{0,2}([\d,]+)/i;
|
|
85
|
+
const RE_RECEIVED = /you\s+received\s*:\s*[\s\S]{0,80}?([\d,]+)/i;
|
|
86
|
+
const RE_COIN_EMOJI = /<a?:Coin:\d+>\s*([\d,]+)/gi;
|
|
87
|
+
const RE_WALLET_PLACED = /⏣\s*([\d,]+)\s*was placed/i;
|
|
88
|
+
const RE_COINS_PLAIN = /⏣\s*([\d,]+)/g;
|
|
89
|
+
const RE_WALLET_TEXT = /wallet[:\s]*[⏣💰]?\s*([\d,]+)/i;
|
|
90
|
+
const RE_BANK_TEXT = /bank[:\s]*[⏣💰]?\s*([\d,]+)/i;
|
|
91
|
+
const RE_BANK_EMOJI = /<a?:Bank:\d+>\s*([\d,]+)/i;
|
|
92
|
+
const RE_BANK_SLASH = /[\d,]+\s*\/\s*[\d,]+/g;
|
|
93
|
+
const RE_LARGE_NUM = /([\d,]{2,})/;
|
|
94
|
+
const RE_NET_SIGNED = /Net:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i;
|
|
95
|
+
const RE_WIN_SIGNED = /Winnings:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i;
|
|
96
|
+
const RE_HOLD_TIGHT_REASON = /Reason:\s*\/(\w+)/i;
|
|
97
|
+
|
|
65
98
|
// ── Sleep ────────────────────────────────────────────────────
|
|
66
99
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
67
100
|
|
|
@@ -72,76 +105,83 @@ function humanDelay(min = 50, max = 200) {
|
|
|
72
105
|
// ── Message Parsing ──────────────────────────────────────────
|
|
73
106
|
function getFullText(msg) {
|
|
74
107
|
if (!msg) return '';
|
|
75
|
-
// If CV2 text was pre-fetched via ensureCV2(), use it
|
|
76
108
|
if (msg._cv2text) return msg._cv2text;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
// Array-join pattern: O(n) vs O(n²) from repeated string concatenation.
|
|
110
|
+
// At 10K accounts processing messages concurrently, this avoids
|
|
111
|
+
// creating intermediate string copies on every += operation.
|
|
112
|
+
const parts = [msg.content || ''];
|
|
113
|
+
const embeds = msg.embeds;
|
|
114
|
+
if (embeds) {
|
|
115
|
+
for (let i = 0; i < embeds.length; i++) {
|
|
116
|
+
const e = embeds[i];
|
|
117
|
+
if (e.title) parts.push(e.title);
|
|
118
|
+
if (e.description) parts.push(e.description);
|
|
119
|
+
const fields = e.fields;
|
|
120
|
+
if (fields) for (let j = 0; j < fields.length; j++) {
|
|
121
|
+
parts.push(fields[j].name || '', fields[j].value || '');
|
|
122
|
+
}
|
|
123
|
+
if (e.footer?.text) parts.push(e.footer.text);
|
|
124
|
+
}
|
|
83
125
|
}
|
|
84
|
-
|
|
85
|
-
|
|
126
|
+
const _extract = (components) => {
|
|
127
|
+
if (!components) return;
|
|
128
|
+
for (let i = 0; i < components.length; i++) {
|
|
129
|
+
const comp = components[i];
|
|
86
130
|
if (!comp) continue;
|
|
87
|
-
if (comp.content)
|
|
88
|
-
if (comp.components)
|
|
131
|
+
if (comp.content) parts.push(comp.content);
|
|
132
|
+
if (comp.components) _extract(comp.components);
|
|
89
133
|
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return
|
|
134
|
+
};
|
|
135
|
+
_extract(msg.components);
|
|
136
|
+
return parts.join(' ');
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
function stripAnsi(text) {
|
|
96
140
|
if (!text) return '';
|
|
97
|
-
return String(text).replace(
|
|
141
|
+
return String(text).replace(RE_ANSI, '');
|
|
98
142
|
}
|
|
99
143
|
|
|
100
144
|
function parseCoins(text) {
|
|
101
145
|
if (!text) return 0;
|
|
102
146
|
const cleanText = stripAnsi(text);
|
|
103
|
-
|
|
104
|
-
const netMatch = cleanText.match(
|
|
147
|
+
RE_NET_COINS.lastIndex = 0;
|
|
148
|
+
const netMatch = cleanText.match(RE_NET_COINS);
|
|
105
149
|
if (netMatch) {
|
|
106
150
|
const net = parseInt(netMatch[1].replace(/,/g, ''));
|
|
107
151
|
return net > 0 ? net : 0;
|
|
108
152
|
}
|
|
109
|
-
|
|
153
|
+
RE_WINNINGS.lastIndex = 0;
|
|
154
|
+
const winMatch = cleanText.match(RE_WINNINGS);
|
|
110
155
|
if (winMatch) {
|
|
111
156
|
const w = parseInt(winMatch[1].replace(/,/g, ''));
|
|
112
157
|
if (w > 0) return w;
|
|
113
158
|
}
|
|
114
|
-
|
|
115
|
-
// Tidy/CV2 reward blocks often use "You received:" with non-⏣ symbols.
|
|
116
|
-
const receivedMatch = cleanText.match(/you\s+received\s*:\s*[\s\S]{0,80}?([\d,]+)/i);
|
|
159
|
+
const receivedMatch = cleanText.match(RE_RECEIVED);
|
|
117
160
|
if (receivedMatch) {
|
|
118
161
|
const r = parseInt(receivedMatch[1].replace(/,/g, ''), 10);
|
|
119
162
|
if (r > 0) return r;
|
|
120
163
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const coinEmojiMatches = [...cleanText.matchAll(/<a?:Coin:\d+>\s*([\d,]+)/gi)];
|
|
164
|
+
RE_COIN_EMOJI.lastIndex = 0;
|
|
165
|
+
const coinEmojiMatches = [...cleanText.matchAll(RE_COIN_EMOJI)];
|
|
124
166
|
if (coinEmojiMatches.length > 0) {
|
|
125
167
|
let best = 0;
|
|
126
|
-
for (
|
|
127
|
-
const v = parseInt((
|
|
168
|
+
for (let i = 0; i < coinEmojiMatches.length; i++) {
|
|
169
|
+
const v = parseInt((coinEmojiMatches[i][1] || '0').replace(/,/g, ''), 10) || 0;
|
|
128
170
|
if (v > best) best = v;
|
|
129
171
|
}
|
|
130
172
|
if (best > 0) return best;
|
|
131
173
|
}
|
|
132
|
-
|
|
133
|
-
// Prefer "placed in your wallet" pattern (daily, beg, etc.)
|
|
134
|
-
const walletMatch = cleanText.match(/⏣\s*([\d,]+)\s*was placed/i);
|
|
174
|
+
const walletMatch = cleanText.match(RE_WALLET_PLACED);
|
|
135
175
|
if (walletMatch) return parseInt(walletMatch[1].replace(/,/g, ''));
|
|
136
|
-
|
|
137
|
-
const matches = cleanText.match(
|
|
176
|
+
RE_COINS_PLAIN.lastIndex = 0;
|
|
177
|
+
const matches = cleanText.match(RE_COINS_PLAIN);
|
|
138
178
|
if (!matches) return 0;
|
|
139
179
|
let best = 0;
|
|
140
|
-
for (
|
|
141
|
-
const numStr =
|
|
180
|
+
for (let i = 0; i < matches.length; i++) {
|
|
181
|
+
const numStr = matches[i].replace(/[^\d]/g, '');
|
|
142
182
|
if (numStr) {
|
|
143
183
|
const val = parseInt(numStr);
|
|
144
|
-
if (val > 0 && val < 1_000_000_000) best =
|
|
184
|
+
if (val > 0 && val < 1_000_000_000 && val > best) best = val;
|
|
145
185
|
}
|
|
146
186
|
}
|
|
147
187
|
return best;
|
|
@@ -149,11 +189,9 @@ function parseCoins(text) {
|
|
|
149
189
|
|
|
150
190
|
function parseNetCoins(text) {
|
|
151
191
|
if (!text) return 0;
|
|
152
|
-
|
|
153
|
-
const netMatch = text.match(/Net:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i);
|
|
192
|
+
const netMatch = text.match(RE_NET_SIGNED);
|
|
154
193
|
if (netMatch) return parseInt(netMatch[1].replace(/,/g, ''));
|
|
155
|
-
|
|
156
|
-
const winMatch = text.match(/Winnings:\s*\*{0,2}\s*[⏣]\s*\*{0,2}([+-]?[\d,]+)/i);
|
|
194
|
+
const winMatch = text.match(RE_WIN_SIGNED);
|
|
157
195
|
if (winMatch) {
|
|
158
196
|
const v = parseInt(winMatch[1].replace(/,/g, ''));
|
|
159
197
|
if (v !== 0) return v;
|
|
@@ -169,27 +207,19 @@ function parseNetCoins(text) {
|
|
|
169
207
|
function parseBalance(msg) {
|
|
170
208
|
if (!msg) return 0;
|
|
171
209
|
const text = stripAnsi(getFullText(msg));
|
|
172
|
-
|
|
173
|
-
// Try standard embed wallet pattern
|
|
174
|
-
const walletMatch = text.match(/wallet[:\s]*[⏣💰]?\s*([\d,]+)/i);
|
|
210
|
+
const walletMatch = text.match(RE_WALLET_TEXT);
|
|
175
211
|
if (walletMatch) return parseInt(walletMatch[1].replace(/,/g, ''));
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const coinEmojiMatch = text.match(/<a?:Coin:\d+>\s*([\d,]+)/i);
|
|
212
|
+
RE_COIN_EMOJI.lastIndex = 0;
|
|
213
|
+
const coinEmojiMatch = text.match(RE_COIN_EMOJI);
|
|
179
214
|
if (coinEmojiMatch) return parseInt(coinEmojiMatch[1].replace(/,/g, ''));
|
|
180
|
-
|
|
181
|
-
const bankEmojiMatch = text.match(/<a?:Bank:\d+>\s*([\d,]+)/i);
|
|
215
|
+
const bankEmojiMatch = text.match(RE_BANK_EMOJI);
|
|
182
216
|
if (bankEmojiMatch) return parseInt(bankEmojiMatch[1].replace(/,/g, ''));
|
|
183
|
-
|
|
184
|
-
// Fallback: ⏣ prefixed
|
|
185
217
|
const coins = parseCoins(text);
|
|
186
218
|
if (coins > 0) return coins;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
const numMatch = cleaned.match(/([\d,]{2,})/);
|
|
219
|
+
RE_BANK_SLASH.lastIndex = 0;
|
|
220
|
+
const cleaned = text.replace(RE_BANK_SLASH, '');
|
|
221
|
+
const numMatch = cleaned.match(RE_LARGE_NUM);
|
|
191
222
|
if (numMatch) return parseInt(numMatch[1].replace(/,/g, ''));
|
|
192
|
-
|
|
193
223
|
return 0;
|
|
194
224
|
}
|
|
195
225
|
|
|
@@ -252,7 +282,9 @@ function findSelectMenuOption(msg, label) {
|
|
|
252
282
|
return null;
|
|
253
283
|
}
|
|
254
284
|
|
|
255
|
-
// Safe button click — tries library methods first, falls back to raw HTTP for CV2
|
|
285
|
+
// Safe button click — tries library methods first, falls back to raw HTTP for CV2.
|
|
286
|
+
// When CV2 fallback is used, waits for the message to update so callers always
|
|
287
|
+
// get the updated message back (instead of null, which broke multi-round games).
|
|
256
288
|
async function safeClickButton(msg, button) {
|
|
257
289
|
if (typeof button.click === 'function') {
|
|
258
290
|
return button.click();
|
|
@@ -265,9 +297,29 @@ async function safeClickButton(msg, button) {
|
|
|
265
297
|
// Fall through to CV2 raw interaction fallback.
|
|
266
298
|
}
|
|
267
299
|
}
|
|
268
|
-
// CV2 fallback: send interaction via raw HTTP
|
|
300
|
+
// CV2 fallback: send interaction via raw HTTP, then wait for the message
|
|
301
|
+
// to update so we can return the updated message to the caller.
|
|
269
302
|
if (id) {
|
|
270
303
|
await clickCV2Button(msg, id);
|
|
304
|
+
// Wait for Dank Memer to process the interaction and update the message
|
|
305
|
+
const updatedMsg = await new Promise((resolve) => {
|
|
306
|
+
const timeout = setTimeout(() => {
|
|
307
|
+
msg.client?.removeListener?.('messageUpdate', handler);
|
|
308
|
+
resolve(null);
|
|
309
|
+
}, 8000);
|
|
310
|
+
const handler = (_, newMsg) => {
|
|
311
|
+
if (newMsg.id === msg.id) {
|
|
312
|
+
clearTimeout(timeout);
|
|
313
|
+
msg.client?.removeListener?.('messageUpdate', handler);
|
|
314
|
+
resolve(newMsg);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
msg.client?.on?.('messageUpdate', handler);
|
|
318
|
+
});
|
|
319
|
+
if (updatedMsg) {
|
|
320
|
+
await ensureCV2(updatedMsg);
|
|
321
|
+
return updatedMsg;
|
|
322
|
+
}
|
|
271
323
|
return null;
|
|
272
324
|
}
|
|
273
325
|
throw new Error('No click method available on button');
|
|
@@ -288,7 +340,7 @@ function isHoldTight(msg) {
|
|
|
288
340
|
function getHoldTightReason(msg) {
|
|
289
341
|
if (!msg) return null;
|
|
290
342
|
const text = getFullText(msg);
|
|
291
|
-
const match = text.match(
|
|
343
|
+
const match = text.match(RE_HOLD_TIGHT_REASON);
|
|
292
344
|
return match ? match[1].toLowerCase() : null;
|
|
293
345
|
}
|
|
294
346
|
|
|
@@ -359,10 +411,13 @@ function dumpMessage(msg, label) {
|
|
|
359
411
|
|
|
360
412
|
function _httpGet(url, headers) {
|
|
361
413
|
return new Promise((resolve, reject) => {
|
|
362
|
-
https.get(url, { headers }, res => {
|
|
363
|
-
|
|
364
|
-
res.on('data', c =>
|
|
365
|
-
res.on('end', () => {
|
|
414
|
+
https.get(url, { headers, agent: httpsAgent }, res => {
|
|
415
|
+
const chunks = [];
|
|
416
|
+
res.on('data', c => chunks.push(c));
|
|
417
|
+
res.on('end', () => {
|
|
418
|
+
const d = chunks.join('');
|
|
419
|
+
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
|
420
|
+
});
|
|
366
421
|
}).on('error', reject);
|
|
367
422
|
});
|
|
368
423
|
}
|
|
@@ -371,11 +426,11 @@ function _httpPost(url, headers, body) {
|
|
|
371
426
|
return new Promise((resolve, reject) => {
|
|
372
427
|
const u = new URL(url);
|
|
373
428
|
const req = https.request({
|
|
374
|
-
hostname: u.hostname, path: u.pathname, method: 'POST', headers,
|
|
429
|
+
hostname: u.hostname, path: u.pathname, method: 'POST', headers, agent: httpsAgent,
|
|
375
430
|
}, res => {
|
|
376
|
-
|
|
377
|
-
res.on('data', c =>
|
|
378
|
-
res.on('end', () => resolve({ status: res.statusCode, body:
|
|
431
|
+
const chunks = [];
|
|
432
|
+
res.on('data', c => chunks.push(c));
|
|
433
|
+
res.on('end', () => resolve({ status: res.statusCode, body: chunks.join('') }));
|
|
379
434
|
});
|
|
380
435
|
req.on('error', reject);
|
|
381
436
|
req.write(body);
|
|
@@ -564,8 +619,12 @@ module.exports = {
|
|
|
564
619
|
isCV2,
|
|
565
620
|
ensureCV2,
|
|
566
621
|
clickCV2Button,
|
|
567
|
-
//
|
|
622
|
+
// Shared structures and optimized constants
|
|
568
623
|
strings,
|
|
569
624
|
cv2Cache,
|
|
570
625
|
itemDetector,
|
|
626
|
+
httpsAgent,
|
|
627
|
+
RE_NET_SIGNED,
|
|
628
|
+
RE_WIN_SIGNED,
|
|
629
|
+
RE_COIN_EMOJI,
|
|
571
630
|
};
|
package/lib/commands/work.js
CHANGED
|
@@ -20,14 +20,20 @@ const {
|
|
|
20
20
|
logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
|
|
21
21
|
} = require('./utils');
|
|
22
22
|
|
|
23
|
+
const RE_MEMORY_BACKTICK_CHUNK = /`([^`]+)`/g;
|
|
24
|
+
const RE_BACKTICK_STRIP = /`/g;
|
|
25
|
+
const RE_WORK_COOLDOWN_TS = /<t:(\d+):R>/;
|
|
26
|
+
const RE_WORK_COOLDOWN_MINUTES = /(\d+)\s*minute/i;
|
|
27
|
+
const RE_WORK_COOLDOWN_HOURS = /(\d+)\s*hour/i;
|
|
28
|
+
|
|
23
29
|
// Job progression list (order matters — first is easiest to get)
|
|
24
|
-
const JOBS = [
|
|
30
|
+
const JOBS = Object.freeze([
|
|
25
31
|
'babysitter', 'dog walker', 'fast food worker', 'youtuber',
|
|
26
32
|
'twitch streamer', 'professional fisherman', 'robber',
|
|
27
33
|
'veterinarian', 'musician', 'manager', 'politician',
|
|
28
34
|
'detective', 'santa claus', 'discord mod', 'professional hunter',
|
|
29
35
|
'scientist', 'pro gamer', 'ghost',
|
|
30
|
-
];
|
|
36
|
+
]);
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
39
|
* Re-fetch a message to get updated state.
|
|
@@ -41,9 +47,9 @@ async function refetchMsg(channel, msgId) {
|
|
|
41
47
|
* Looks for backtick-wrapped words: `word1` `word2` `word3`
|
|
42
48
|
*/
|
|
43
49
|
function parseMemoryOrder(text) {
|
|
44
|
-
const matches = text.match(
|
|
50
|
+
const matches = text.match(RE_MEMORY_BACKTICK_CHUNK);
|
|
45
51
|
if (matches && matches.length > 0) {
|
|
46
|
-
return matches.map(m => m.replace(
|
|
52
|
+
return matches.map(m => m.replace(RE_BACKTICK_STRIP, '').trim().toLowerCase());
|
|
47
53
|
}
|
|
48
54
|
return [];
|
|
49
55
|
}
|
|
@@ -54,7 +60,7 @@ function parseMemoryOrder(text) {
|
|
|
54
60
|
*/
|
|
55
61
|
function parseWorkCooldown(text) {
|
|
56
62
|
// Unix timestamp pattern: <t:TIMESTAMP:R>
|
|
57
|
-
const tsMatch = text.match(
|
|
63
|
+
const tsMatch = text.match(RE_WORK_COOLDOWN_TS);
|
|
58
64
|
if (tsMatch) {
|
|
59
65
|
const ts = parseInt(tsMatch[1]);
|
|
60
66
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -62,9 +68,9 @@ function parseWorkCooldown(text) {
|
|
|
62
68
|
return diff > 0 ? diff : 3600;
|
|
63
69
|
}
|
|
64
70
|
// "X minutes" / "X hours" pattern
|
|
65
|
-
const minMatch = text.match(
|
|
71
|
+
const minMatch = text.match(RE_WORK_COOLDOWN_MINUTES);
|
|
66
72
|
if (minMatch) return parseInt(minMatch[1]) * 60;
|
|
67
|
-
const hrMatch = text.match(
|
|
73
|
+
const hrMatch = text.match(RE_WORK_COOLDOWN_HOURS);
|
|
68
74
|
if (hrMatch) return parseInt(hrMatch[1]) * 3600;
|
|
69
75
|
return null;
|
|
70
76
|
}
|
|
@@ -214,7 +220,10 @@ async function handleWordMemory({ channel, current, wordOrder, waitForDankMemer
|
|
|
214
220
|
async function handleRepeatWord({ current, wordOrder }) {
|
|
215
221
|
// Find which word appears more than once
|
|
216
222
|
const counts = {};
|
|
217
|
-
for (
|
|
223
|
+
for (let wi = 0; wi < wordOrder.length; wi++) {
|
|
224
|
+
const w = wordOrder[wi];
|
|
225
|
+
counts[w] = (counts[w] || 0) + 1;
|
|
226
|
+
}
|
|
218
227
|
const repeated = Object.entries(counts).find(([, count]) => count > 1);
|
|
219
228
|
if (repeated) {
|
|
220
229
|
const [word] = repeated;
|