@sprid/scout 0.1.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/LICENSE +14 -0
- package/README.md +115 -0
- package/package.json +32 -0
- package/src/ads/cli.mjs +82 -0
- package/src/ads/extract.mjs +108 -0
- package/src/ads/harvest.mjs +90 -0
- package/src/ads/push.mjs +56 -0
- package/src/ads/run.js +67 -0
- package/src/analyze.js +155 -0
- package/src/chrome.js +396 -0
- package/src/config.js +83 -0
- package/src/discover.js +463 -0
- package/src/download.js +324 -0
- package/src/frames.js +85 -0
- package/src/jobs.js +370 -0
- package/src/login-cli.js +30 -0
- package/src/push-to-sprid.js +194 -0
- package/src/quick.js +318 -0
- package/src/register.js +27 -0
- package/src/server.js +881 -0
- package/src/sheet.js +106 -0
- package/src/store.js +130 -0
- package/src/tiktok.js +391 -0
package/src/discover.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { acquire, handleChallenge, invoke } = require("./chrome");
|
|
4
|
+
const {
|
|
5
|
+
TOP,
|
|
6
|
+
PHOTO,
|
|
7
|
+
parseCount,
|
|
8
|
+
loadAccountDetails,
|
|
9
|
+
measurePosts,
|
|
10
|
+
decideVerdict,
|
|
11
|
+
flagOutliers,
|
|
12
|
+
} = require("./tiktok");
|
|
13
|
+
const jobsLib = require("./jobs");
|
|
14
|
+
|
|
15
|
+
const MAX_ROUNDS = 5;
|
|
16
|
+
const FIRST_ROUND_CANDIDATES_PER_ACCOUNT = 4;
|
|
17
|
+
const MIN_CANDIDATE_POOL = 10;
|
|
18
|
+
const MAX_CANDIDATE_POOL = 500;
|
|
19
|
+
const BOT_THRESHOLD = 3;
|
|
20
|
+
const LIKE_EVERY = 6;
|
|
21
|
+
|
|
22
|
+
function clamp(value, lo, hi) {
|
|
23
|
+
return Math.max(lo, Math.min(hi, value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How big a pool to gather this round. Sized from the pass rate actually
|
|
28
|
+
* observed, so a strict filter asks for more ground rather than coming back short.
|
|
29
|
+
*/
|
|
30
|
+
function nextPool(missing, passRate) {
|
|
31
|
+
const needed =
|
|
32
|
+
passRate > 0 ? Math.ceil(missing / passRate) : missing * FIRST_ROUND_CANDIDATES_PER_ACCOUNT;
|
|
33
|
+
return clamp(needed, MIN_CANDIDATE_POOL, MAX_CANDIDATE_POOL);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function likePost(context, username, postId, log) {
|
|
37
|
+
log(" Liking a post to train the algorithm...");
|
|
38
|
+
let page = null;
|
|
39
|
+
try {
|
|
40
|
+
page = await context.newPage();
|
|
41
|
+
await page.goto(`https://www.tiktok.com/@${username}/photo/${postId}`, {
|
|
42
|
+
waitUntil: "domcontentloaded",
|
|
43
|
+
timeout: 15_000,
|
|
44
|
+
});
|
|
45
|
+
await page.waitForTimeout(2000);
|
|
46
|
+
for (const selector of [
|
|
47
|
+
'[data-e2e="like-icon"]',
|
|
48
|
+
'[data-e2e="browse-like-icon"]',
|
|
49
|
+
'button[aria-label*="ike"]',
|
|
50
|
+
]) {
|
|
51
|
+
try {
|
|
52
|
+
const button = page.locator(selector).first();
|
|
53
|
+
if (await button.isVisible({ timeout: 2000 })) {
|
|
54
|
+
await button.click();
|
|
55
|
+
await page.waitForTimeout(1000);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
} finally {
|
|
62
|
+
await page?.close().catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Phase 1. Types each keyword into TikTok's own search and reads the results
|
|
68
|
+
* the page fetches for itself, Top tab first and Photo behind it when Top runs
|
|
69
|
+
* thin. Keywords are walked in order and spent only as far as needed.
|
|
70
|
+
*/
|
|
71
|
+
async function searchViaFeed({ keywords, cursor, maxPool, alreadyProcessed, session, log, gate, challenge }) {
|
|
72
|
+
const { context, newPage } = session;
|
|
73
|
+
const searchPage = await newPage();
|
|
74
|
+
const candidates = {};
|
|
75
|
+
const hashtagFreq = {};
|
|
76
|
+
const liked = new Set();
|
|
77
|
+
let consumed = 0;
|
|
78
|
+
|
|
79
|
+
async function sweep(keyword, tab) {
|
|
80
|
+
const before = Object.keys(candidates).length;
|
|
81
|
+
const slideshowPosts = [];
|
|
82
|
+
const seenPostIds = new Set();
|
|
83
|
+
const seenAllIds = new Set();
|
|
84
|
+
|
|
85
|
+
const onResponse = async (response) => {
|
|
86
|
+
if (!response.url().includes("/api/search/")) return;
|
|
87
|
+
try {
|
|
88
|
+
const payload = await response.json();
|
|
89
|
+
for (const item of tab.items(payload)) {
|
|
90
|
+
const postId = item.id ?? "";
|
|
91
|
+
if (postId && !seenAllIds.has(postId)) seenAllIds.add(postId);
|
|
92
|
+
if (!item.imagePost) continue;
|
|
93
|
+
const author = item.author ?? {};
|
|
94
|
+
const uid = author.uniqueId ?? "";
|
|
95
|
+
if (!uid || !postId || seenPostIds.has(postId)) continue;
|
|
96
|
+
seenPostIds.add(postId);
|
|
97
|
+
for (const tag of item.textExtra ?? []) {
|
|
98
|
+
if (tag.hashtagName) hashtagFreq[tag.hashtagName] = (hashtagFreq[tag.hashtagName] ?? 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
if (alreadyProcessed.has(uid) || candidates[uid]) continue;
|
|
101
|
+
slideshowPosts.push({
|
|
102
|
+
post_id: postId,
|
|
103
|
+
username: uid,
|
|
104
|
+
display_name: author.nickname ?? "",
|
|
105
|
+
bio: author.signature ?? "",
|
|
106
|
+
followers: tab.followers(item),
|
|
107
|
+
verified: Boolean(author.verified),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
searchPage.on("response", onResponse);
|
|
114
|
+
try {
|
|
115
|
+
await searchPage.goto(tab.url(keyword.trim()), {
|
|
116
|
+
waitUntil: "domcontentloaded",
|
|
117
|
+
timeout: 20_000,
|
|
118
|
+
});
|
|
119
|
+
await searchPage.waitForTimeout(3000);
|
|
120
|
+
await handleChallenge(searchPage, log, challenge);
|
|
121
|
+
} catch {
|
|
122
|
+
searchPage.off("response", onResponse);
|
|
123
|
+
return { added: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (tab.confirm) {
|
|
127
|
+
for (const selector of tab.confirm) {
|
|
128
|
+
try {
|
|
129
|
+
const control = searchPage.locator(selector).first();
|
|
130
|
+
if (await control.isVisible({ timeout: 1500 })) {
|
|
131
|
+
await control.click();
|
|
132
|
+
await searchPage.waitForTimeout(1500);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let silentScrolls = 0;
|
|
140
|
+
for (let scroll = 0; scroll < 15; scroll++) {
|
|
141
|
+
if (!(await gate())) break;
|
|
142
|
+
const prev = Object.keys(candidates).length;
|
|
143
|
+
await searchPage.evaluate("window.scrollTo(0, document.body.scrollHeight)");
|
|
144
|
+
await searchPage.waitForTimeout(2000);
|
|
145
|
+
await handleChallenge(searchPage, log, challenge);
|
|
146
|
+
|
|
147
|
+
for (const post of slideshowPosts) {
|
|
148
|
+
if (!candidates[post.username] && !alreadyProcessed.has(post.username)) {
|
|
149
|
+
candidates[post.username] = {
|
|
150
|
+
username: post.username,
|
|
151
|
+
display_name: post.display_name,
|
|
152
|
+
bio: post.bio,
|
|
153
|
+
followers: post.followers,
|
|
154
|
+
verified: post.verified,
|
|
155
|
+
_post_id: post.post_id,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Tell the algorithm what this session is into, occasionally.
|
|
161
|
+
const fresh = slideshowPosts.filter((p) => !liked.has(p.post_id));
|
|
162
|
+
if (fresh.length && liked.size < Math.ceil(scroll / LIKE_EVERY) + 1) {
|
|
163
|
+
const pick = fresh[0];
|
|
164
|
+
liked.add(pick.post_id);
|
|
165
|
+
await likePost(context, pick.username, pick.post_id, log);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Object.keys(candidates).length >= maxPool) break;
|
|
169
|
+
silentScrolls = Object.keys(candidates).length === prev ? silentScrolls + 1 : 0;
|
|
170
|
+
if (silentScrolls >= 3) break;
|
|
171
|
+
}
|
|
172
|
+
searchPage.off("response", onResponse);
|
|
173
|
+
return { added: Object.keys(candidates).length - before };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
while (cursor.index < keywords.length && Object.keys(candidates).length < maxPool) {
|
|
177
|
+
if (!(await gate())) break;
|
|
178
|
+
const keyword = keywords[cursor.index];
|
|
179
|
+
log(`\nSearching '${keyword}'...`);
|
|
180
|
+
const top = await sweep(keyword, TOP);
|
|
181
|
+
if (top.added < 3) {
|
|
182
|
+
log(` Top tab ran thin (${top.added}); trying the Photo tab.`);
|
|
183
|
+
await sweep(keyword, PHOTO);
|
|
184
|
+
}
|
|
185
|
+
cursor.index += 1;
|
|
186
|
+
consumed += 1;
|
|
187
|
+
log(` '${keyword}' done -> ${Object.keys(candidates).length} candidates so far`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await searchPage.close().catch(() => {});
|
|
191
|
+
return { candidates, hashtagFreq, consumed };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function unmeasuredRecord(username, account, followers) {
|
|
195
|
+
const { _post_id, ...rest } = account;
|
|
196
|
+
return {
|
|
197
|
+
...rest,
|
|
198
|
+
username,
|
|
199
|
+
followers,
|
|
200
|
+
metrics: { scraped_posts: 0 },
|
|
201
|
+
verdict: {
|
|
202
|
+
passed: false,
|
|
203
|
+
failed: [],
|
|
204
|
+
noPostsInWindow: false,
|
|
205
|
+
unmeasured: true,
|
|
206
|
+
viewsPerFollower: null,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Phase 2. Visits each candidate profile and measures it. Every profile opened
|
|
213
|
+
* is kept, whatever its verdict: the filters sort accounts, they never hide them.
|
|
214
|
+
*/
|
|
215
|
+
async function collectMetrics({ candidates, filters, session, log, gate, onMeasured, onBot }) {
|
|
216
|
+
const profilePage = session.page;
|
|
217
|
+
const entries = Object.entries(candidates);
|
|
218
|
+
let consecutiveEmpty = 0;
|
|
219
|
+
const challenge = { hold: gate, signal: session.challengeSignal };
|
|
220
|
+
|
|
221
|
+
for (const [index, [username, account]] of entries.entries()) {
|
|
222
|
+
if (!(await gate())) break;
|
|
223
|
+
log(`\nAnalyzing @${username} (${index + 1}/${entries.length})...`);
|
|
224
|
+
|
|
225
|
+
const posts = [];
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
let user = {};
|
|
228
|
+
const onResponse = async (response) => {
|
|
229
|
+
const url = response.url();
|
|
230
|
+
if (url.includes("/api/post/item_list/") || url.includes("/api/user/post/")) {
|
|
231
|
+
try {
|
|
232
|
+
const data = await response.json();
|
|
233
|
+
for (const item of data.itemList ?? data.items ?? []) {
|
|
234
|
+
if (item.id && !seen.has(item.id)) {
|
|
235
|
+
seen.add(item.id);
|
|
236
|
+
posts.push(item);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
if (url.includes("/api/user/detail/")) {
|
|
242
|
+
try {
|
|
243
|
+
const data = await response.json();
|
|
244
|
+
if (data.userInfo?.user) user = { ...user, ...data.userInfo.user };
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
profilePage.on("response", onResponse);
|
|
249
|
+
|
|
250
|
+
let domFollowers = 0;
|
|
251
|
+
try {
|
|
252
|
+
await profilePage.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
|
253
|
+
await profilePage.goto(`https://www.tiktok.com/@${username}`, {
|
|
254
|
+
waitUntil: "domcontentloaded",
|
|
255
|
+
timeout: 20_000,
|
|
256
|
+
});
|
|
257
|
+
await profilePage.waitForTimeout(3000);
|
|
258
|
+
await handleChallenge(profilePage, log, challenge);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await profilePage.waitForSelector('[data-e2e="followers-count"]', { timeout: 3000 });
|
|
262
|
+
const raw = await profilePage.evaluate(
|
|
263
|
+
invoke(`() => {
|
|
264
|
+
const el = document.querySelector('[data-e2e="followers-count"]');
|
|
265
|
+
return el ? (el.textContent || el.innerText || '').trim() : '';
|
|
266
|
+
}`)
|
|
267
|
+
);
|
|
268
|
+
domFollowers = parseCount(raw || "");
|
|
269
|
+
} catch {
|
|
270
|
+
try {
|
|
271
|
+
const raw = await profilePage.evaluate(
|
|
272
|
+
invoke(`() => {
|
|
273
|
+
for (const el of document.querySelectorAll('strong[title]')) {
|
|
274
|
+
const t = (el.title || el.textContent || '').trim();
|
|
275
|
+
if (t) return t;
|
|
276
|
+
}
|
|
277
|
+
return '';
|
|
278
|
+
}`)
|
|
279
|
+
);
|
|
280
|
+
domFollowers = parseCount(raw || "");
|
|
281
|
+
} catch {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (let scroll = 0; scroll < 5; scroll++) {
|
|
285
|
+
if (!(await gate())) break;
|
|
286
|
+
const before = posts.length;
|
|
287
|
+
await profilePage.evaluate("window.scrollTo(0, document.body.scrollHeight)");
|
|
288
|
+
await profilePage.waitForTimeout(2500);
|
|
289
|
+
await handleChallenge(profilePage, log, challenge);
|
|
290
|
+
if (posts.length === before && scroll >= 1) break;
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
profilePage.off("response", onResponse);
|
|
294
|
+
if (!(await gate())) break;
|
|
295
|
+
log(" Profile did not load: recording it as unmeasured.");
|
|
296
|
+
onMeasured(unmeasuredRecord(username, account, domFollowers || account.followers || 0));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
profilePage.off("response", onResponse);
|
|
300
|
+
|
|
301
|
+
if (posts.length === 0) {
|
|
302
|
+
consecutiveEmpty += 1;
|
|
303
|
+
log(` No posts loaded: unmeasured (${consecutiveEmpty}/${BOT_THRESHOLD} consecutive)`);
|
|
304
|
+
onMeasured(unmeasuredRecord(username, account, domFollowers || account.followers || 0));
|
|
305
|
+
if (consecutiveEmpty >= BOT_THRESHOLD) {
|
|
306
|
+
onBot(
|
|
307
|
+
`TikTok stopped serving profile content after ${BOT_THRESHOLD} consecutive empty profiles. Check the Chrome window, then resume.`
|
|
308
|
+
);
|
|
309
|
+
consecutiveEmpty = 0;
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
consecutiveEmpty = 0;
|
|
314
|
+
|
|
315
|
+
const details = await loadAccountDetails(username);
|
|
316
|
+
const followers =
|
|
317
|
+
details.followerCount ||
|
|
318
|
+
domFollowers ||
|
|
319
|
+
user.followerCount ||
|
|
320
|
+
posts[0]?.author?.followerCount ||
|
|
321
|
+
account.followers ||
|
|
322
|
+
0;
|
|
323
|
+
|
|
324
|
+
const measured = measurePosts({
|
|
325
|
+
posts,
|
|
326
|
+
followers,
|
|
327
|
+
avgViewsDays: filters.avgViewsDays,
|
|
328
|
+
details,
|
|
329
|
+
});
|
|
330
|
+
if (!measured) {
|
|
331
|
+
log(" Post timestamps could not be read: recording it as unmeasured.");
|
|
332
|
+
onMeasured(unmeasuredRecord(username, account, followers));
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const verdict = decideVerdict({
|
|
337
|
+
followers,
|
|
338
|
+
ratio: measured.ratio,
|
|
339
|
+
ratioPct: measured.ratioPct,
|
|
340
|
+
avgViews: measured.avgViews,
|
|
341
|
+
totalViewsWindow: measured.totalViewsWindow,
|
|
342
|
+
noPostsInWindow: measured.noPostsInWindow,
|
|
343
|
+
filters,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
log(
|
|
347
|
+
measured.noPostsInWindow
|
|
348
|
+
? ` ${posts.length} posts | ${measured.ratioPct}% slideshows | no posts in the last ${filters.avgViewsDays}d`
|
|
349
|
+
: ` ${posts.length} posts | ${measured.ratioPct}% slideshows | avg ${Math.trunc(
|
|
350
|
+
measured.avgViews ?? 0
|
|
351
|
+
).toLocaleString("en-US")} views`
|
|
352
|
+
);
|
|
353
|
+
log(verdict.passed ? " -> passed" : ` -> rejected: ${verdict.failed.map((f) => f.filter).join(", ") || "no posts in window"}`);
|
|
354
|
+
|
|
355
|
+
const { _post_id, ...cleaned } = account;
|
|
356
|
+
onMeasured({
|
|
357
|
+
...cleaned,
|
|
358
|
+
username,
|
|
359
|
+
followers,
|
|
360
|
+
// Kept so a retune can re-window this account without re-visiting it.
|
|
361
|
+
samples: measured.samples,
|
|
362
|
+
measuredAt: Date.now() / 1000,
|
|
363
|
+
display_name: details.nickname || user.nickname || account.display_name || "",
|
|
364
|
+
bio: details.signature ?? (user.signature || account.bio || ""),
|
|
365
|
+
verified: details.verified ?? Boolean(user.verified ?? account.verified),
|
|
366
|
+
metrics: measured.metrics,
|
|
367
|
+
sample_captions: measured.captions,
|
|
368
|
+
verdict,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function runDiscovery(job) {
|
|
374
|
+
const { keywords, filters, maxAccounts, hashtagPivot } = job.meta;
|
|
375
|
+
const log = (text, alert) => jobsLib.log(job, text, alert);
|
|
376
|
+
const gate = jobsLib.gate(job);
|
|
377
|
+
const alreadyProcessed = new Set();
|
|
378
|
+
const cursor = { index: 0 };
|
|
379
|
+
const allKeywords = [...keywords];
|
|
380
|
+
job.state.keywordsTotal = allKeywords.length;
|
|
381
|
+
job.state.target = maxAccounts;
|
|
382
|
+
|
|
383
|
+
const session = await acquire();
|
|
384
|
+
const challenge = {
|
|
385
|
+
hold: gate,
|
|
386
|
+
signal: session.challengeSignal,
|
|
387
|
+
onDetected: (prompt) => jobsLib.setAlert(job, "captcha", prompt),
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
let pivoted = false;
|
|
392
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
393
|
+
const passed = job.state.measured.filter((a) => a.verdict?.passed).length;
|
|
394
|
+
const missing = maxAccounts - passed;
|
|
395
|
+
if (missing <= 0) break;
|
|
396
|
+
if (cursor.index >= allKeywords.length) {
|
|
397
|
+
if (!hashtagPivot || pivoted) break;
|
|
398
|
+
// A second pass on the vocabulary the first pass turned up, which
|
|
399
|
+
// reaches accounts the original wording never would.
|
|
400
|
+
const tags = Object.entries(job.state.hashtagFreq ?? {})
|
|
401
|
+
.sort((a, b) => b[1] - a[1])
|
|
402
|
+
.slice(0, 8)
|
|
403
|
+
.map(([tag]) => tag);
|
|
404
|
+
if (tags.length === 0) break;
|
|
405
|
+
allKeywords.push(...tags);
|
|
406
|
+
job.state.keywordsTotal = allKeywords.length;
|
|
407
|
+
pivoted = true;
|
|
408
|
+
log(`\nHashtag pivot: searching ${tags.length} hashtags the first pass turned up.`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const measuredSoFar = job.state.measured.length;
|
|
412
|
+
const passRate = measuredSoFar > 0 ? passed / measuredSoFar : 0;
|
|
413
|
+
const pool = nextPool(missing, passRate);
|
|
414
|
+
log(`\nRound ${round + 1}: gathering up to ${pool} candidates.`);
|
|
415
|
+
|
|
416
|
+
const search = await searchViaFeed({
|
|
417
|
+
keywords: allKeywords,
|
|
418
|
+
cursor,
|
|
419
|
+
maxPool: pool,
|
|
420
|
+
alreadyProcessed,
|
|
421
|
+
session,
|
|
422
|
+
log,
|
|
423
|
+
gate,
|
|
424
|
+
challenge,
|
|
425
|
+
});
|
|
426
|
+
job.state.keywordsConsumed = cursor.index;
|
|
427
|
+
job.state.hashtagFreq = { ...(job.state.hashtagFreq ?? {}), ...search.hashtagFreq };
|
|
428
|
+
jobsLib.emit("progress", job);
|
|
429
|
+
|
|
430
|
+
const found = Object.keys(search.candidates).length;
|
|
431
|
+
if (found === 0) {
|
|
432
|
+
log(" No new candidates from that pass.");
|
|
433
|
+
if (cursor.index >= allKeywords.length) break;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
log(`\nMeasuring ${found} candidate profiles.`);
|
|
437
|
+
|
|
438
|
+
await collectMetrics({
|
|
439
|
+
candidates: search.candidates,
|
|
440
|
+
filters: job.meta.filters,
|
|
441
|
+
session,
|
|
442
|
+
log,
|
|
443
|
+
gate,
|
|
444
|
+
onMeasured: (account) => {
|
|
445
|
+
alreadyProcessed.add(account.username);
|
|
446
|
+
job.state.measured.push(account);
|
|
447
|
+
flagOutliers(job.state.measured);
|
|
448
|
+
jobsLib.emit("progress", job);
|
|
449
|
+
},
|
|
450
|
+
onBot: (message) => jobsLib.setAlert(job, "bot", message),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (!(await gate())) break;
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
await session.release();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
flagOutliers(job.state.measured);
|
|
460
|
+
return job.state.measured;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = { runDiscovery, nextPool };
|