instauto 9.2.0 → 9.4.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/dist/index.js ADDED
@@ -0,0 +1,1108 @@
1
+ import assert from 'node:assert';
2
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import UserAgent from 'user-agents';
5
+ import {} from "./db.js"; // eslint-disable-line import/extensions
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null;
8
+ }
9
+ function isInstagramUser(value) {
10
+ if (!isRecord(value))
11
+ return false;
12
+ const { id, edge_followed_by: edgeFollowedBy, edge_follow: edgeFollow, is_private: isPrivate, is_verified: isVerified } = value;
13
+ return typeof id === 'string'
14
+ && isRecord(edgeFollowedBy)
15
+ && isRecord(edgeFollow)
16
+ && typeof isPrivate === 'boolean'
17
+ && typeof isVerified === 'boolean';
18
+ }
19
+ // NOTE duplicated inside puppeteer page
20
+ function shuffleArray(arrayIn) {
21
+ const array = [...arrayIn];
22
+ for (let i = array.length - 1; i > 0; i -= 1) {
23
+ const j = Math.floor(Math.random() * (i + 1));
24
+ const temp = array[i];
25
+ if (temp === undefined || array[j] === undefined) {
26
+ throw new Error('Invalid shuffle index');
27
+ }
28
+ array[i] = array[j]; // eslint-disable-line no-param-reassign
29
+ array[j] = temp; // eslint-disable-line no-param-reassign
30
+ }
31
+ return array;
32
+ }
33
+ // https://stackoverflow.com/questions/14822153/escape-single-quote-in-xpath-with-nokogiri
34
+ // example str: "That's mine", he said.
35
+ function escapeXpathStr(str) {
36
+ const parts = str.split("'").map((token) => `'${token}'`);
37
+ if (parts.length === 1)
38
+ return `${parts[0]}`;
39
+ const str2 = parts.join(', "\'", ');
40
+ return `concat(${str2})`;
41
+ }
42
+ const botWorkShiftHours = 16;
43
+ const dayMs = 24 * 60 * 60 * 1000;
44
+ const hourMs = 60 * 60 * 1000;
45
+ const Instauto = async (db, browser, options) => {
46
+ const { instagramBaseUrl = 'https://www.instagram.com', cookiesPath, username: myUsernameIn, password, enableCookies = true, randomizeUserAgent = true, userAgent, maxFollowsPerHour = 20, maxFollowsPerDay = 150, maxLikesPerDay = 50, followUserRatioMin = 0.2, followUserRatioMax = 4, followUserMaxFollowers = null, followUserMaxFollowing = null, followUserMinFollowers = null, followUserMinFollowing = null, shouldFollowUser = null, shouldLikeMedia = null, dontUnfollowUntilTimeElapsed = 3 * 24 * 60 * 60 * 1000, excludeUsers = [], dryRun = true, screenshotOnError = false, screenshotsPath = '.', logger = console, } = options;
47
+ let myUsername = myUsernameIn;
48
+ const userDataCache = {};
49
+ assert(cookiesPath);
50
+ assert(db);
51
+ assert(maxFollowsPerHour * botWorkShiftHours >= maxFollowsPerDay, 'Max follows per hour too low compared to max follows per day');
52
+ const { addPrevFollowedUser, getPrevFollowedUser, addPrevUnfollowedUser, getLikedPhotosLastTimeUnit, getPrevUnfollowedUsers, getPrevFollowedUsers, addLikedPhoto, } = db;
53
+ const getNumLikesThisTimeUnit = (time) => getLikedPhotosLastTimeUnit(time).length;
54
+ // State
55
+ let page;
56
+ async function takeScreenshot() {
57
+ if (!screenshotOnError)
58
+ return;
59
+ try {
60
+ const fileName = `${Date.now()}.jpg`;
61
+ logger.log('Taking screenshot', fileName);
62
+ await page.screenshot({ path: join(screenshotsPath, fileName), type: 'jpeg', quality: 30 });
63
+ }
64
+ catch (err) {
65
+ logger.error('Failed to take screenshot', err);
66
+ }
67
+ }
68
+ async function tryLoadCookies() {
69
+ try {
70
+ const cookies = JSON.parse(await readFile(cookiesPath, 'utf8'));
71
+ for (const cookie of cookies) {
72
+ if (cookie.name !== 'ig_lang')
73
+ await page.setCookie(cookie);
74
+ }
75
+ }
76
+ catch {
77
+ logger.error('No cookies found');
78
+ }
79
+ }
80
+ async function trySaveCookies() {
81
+ try {
82
+ logger.log('Saving cookies');
83
+ const cookies = await page.cookies();
84
+ await writeFile(cookiesPath, JSON.stringify(cookies, null, 2));
85
+ }
86
+ catch {
87
+ logger.error('Failed to save cookies');
88
+ }
89
+ }
90
+ async function tryDeleteCookies() {
91
+ try {
92
+ logger.log('Deleting cookies');
93
+ await unlink(cookiesPath);
94
+ }
95
+ catch {
96
+ logger.error('No cookies to delete');
97
+ }
98
+ }
99
+ const sleepFixed = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+ const sleep = (ms, deviation = 1) => {
101
+ let msWithDev = ((Math.random() * deviation) + 1) * ms;
102
+ if (dryRun)
103
+ msWithDev = Math.min(3000, msWithDev); // for dryRun, no need to wait so long
104
+ logger.log('Waiting', (msWithDev / 1000).toFixed(2), 'sec');
105
+ return sleepFixed(msWithDev);
106
+ };
107
+ async function onImageLiked({ username, href }) {
108
+ await addLikedPhoto({ username, href, time: Date.now() });
109
+ }
110
+ function getNumFollowedUsersThisTimeUnit(timeUnit) {
111
+ const now = Date.now();
112
+ return getPrevFollowedUsers().filter((u) => now - u.time < timeUnit).length
113
+ + getPrevUnfollowedUsers().filter((u) => !u.noActionTaken && now - u.time < timeUnit).length;
114
+ }
115
+ async function checkReachedFollowedUserDayLimit() {
116
+ if (getNumFollowedUsersThisTimeUnit(dayMs) >= maxFollowsPerDay) {
117
+ logger.log('Have reached daily follow/unfollow limit, waiting 10 min');
118
+ await sleep(10 * 60 * 1000);
119
+ }
120
+ }
121
+ async function checkReachedFollowedUserHourLimit() {
122
+ if (getNumFollowedUsersThisTimeUnit(hourMs) >= maxFollowsPerHour) {
123
+ logger.log('Have reached hourly follow rate limit, pausing 10 min');
124
+ await sleep(10 * 60 * 1000);
125
+ }
126
+ }
127
+ async function checkReachedLikedUserDayLimit() {
128
+ if (getNumLikesThisTimeUnit(dayMs) >= maxLikesPerDay) {
129
+ logger.log('Have reached daily like rate limit, pausing 10 min');
130
+ await sleep(10 * 60 * 1000);
131
+ }
132
+ }
133
+ async function throttle() {
134
+ await checkReachedFollowedUserDayLimit();
135
+ await checkReachedFollowedUserHourLimit();
136
+ await checkReachedLikedUserDayLimit();
137
+ }
138
+ function haveRecentlyFollowedUser(username) {
139
+ const followedUserEntry = getPrevFollowedUser(username);
140
+ if (!followedUserEntry)
141
+ return false; // We did not previously follow this user, so don't know
142
+ return Date.now() - followedUserEntry.time < dontUnfollowUntilTimeElapsed;
143
+ }
144
+ // See https://github.com/mifi/SimpleInstaBot/issues/140#issuecomment-1149105387
145
+ const gotoUrl = async (url) => page.goto(url, { waitUntil: ['load', 'domcontentloaded', 'networkidle0'] });
146
+ async function gotoWithRetry(url) {
147
+ const maxAttempts = 3;
148
+ for (let attempt = 0;; attempt += 1) {
149
+ logger.log(`Goto ${url}`);
150
+ const response = await gotoUrl(url);
151
+ if (!response)
152
+ throw new Error('Navigation did not return a response');
153
+ const status = response.status();
154
+ logger.log('Page loaded');
155
+ await sleep(2000);
156
+ // https://www.reddit.com/r/Instagram/comments/kwrt0s/error_560/
157
+ // https://github.com/mifi/instauto/issues/60
158
+ if (![560, 429].includes(status))
159
+ return status;
160
+ if (attempt > maxAttempts) {
161
+ throw new Error(`Navigate to user failed after ${maxAttempts} attempts, last status: ${status}`);
162
+ }
163
+ logger.info(`Got ${status} - Retrying request later...`);
164
+ if (status === 429)
165
+ logger.warn('429 Too Many Requests could mean that Instagram suspects you\'re using a bot. You could try to use the Instagram Mobile app from the same IP for a few days first');
166
+ await sleep((attempt + 1) * 30 * 60 * 1000);
167
+ }
168
+ }
169
+ const getUserPageUrl = (username) => `${instagramBaseUrl}/${encodeURIComponent(username)}`;
170
+ function isAlreadyOnUserPage(username) {
171
+ const url = getUserPageUrl(username);
172
+ // optimization: already on URL? (ignore trailing slash)
173
+ return (page.url().replace(/\/$/, '') === url.replace(/\/$/, ''));
174
+ }
175
+ // How to test xpaths in the browser:
176
+ // document.evaluate("your xpath", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null ).singleNodeValue
177
+ async function getXpathElement(xpath, opts) {
178
+ try {
179
+ return await page.waitForSelector(`::-p-xpath(${xpath})`, opts);
180
+ }
181
+ catch {
182
+ logger.debug(`Element not found for xpath: ${xpath}`);
183
+ return null;
184
+ }
185
+ }
186
+ async function navigateToUser(username) {
187
+ if (isAlreadyOnUserPage(username))
188
+ return true;
189
+ // logger.log('navigating from', page.url(), 'to', url);
190
+ logger.log(`Navigating to user ${username}`);
191
+ const url = getUserPageUrl(username);
192
+ const status = await gotoWithRetry(url);
193
+ if (status === 404) {
194
+ logger.warn('User page returned 404');
195
+ return false;
196
+ }
197
+ if (status === 200) {
198
+ // logger.log('Page returned 200 ☑️');
199
+ // some pages return 200 but nothing there (I think deleted accounts)
200
+ // https://github.com/mifi/SimpleInstaBot/issues/48
201
+ // example: https://www.instagram.com/victorialarson__/
202
+ // so we check if the page has the user's name on it
203
+ const elementHandle = await getXpathElement(`//body//main//*[contains(text(),${escapeXpathStr(username)})]`, { timeout: 1000 });
204
+ const foundUsernameOnPage = elementHandle != null;
205
+ if (!foundUsernameOnPage)
206
+ logger.warn(`Cannot find text "${username}" on page`);
207
+ return foundUsernameOnPage;
208
+ }
209
+ throw new Error(`Navigate to user failed with status ${status}`);
210
+ }
211
+ async function navigateToUserWithCheck(username) {
212
+ if (!(await navigateToUser(username)))
213
+ throw new Error('User not found');
214
+ }
215
+ async function navigateToUserAndGetData(username) {
216
+ const cachedUserData = userDataCache[username];
217
+ if (isAlreadyOnUserPage(username) && cachedUserData) {
218
+ // assume we have data
219
+ return cachedUserData;
220
+ }
221
+ if (cachedUserData != null) {
222
+ // if we already have userData, just navigate
223
+ await navigateToUserWithCheck(username);
224
+ return cachedUserData;
225
+ }
226
+ async function getUserDataFromPage() {
227
+ // https://github.com/mifi/instauto/issues/115#issuecomment-1199335650
228
+ // to test in browser: document.getElementsByTagName('html')[0].innerHTML.split('\n');
229
+ try {
230
+ const body = await page.content();
231
+ for (let q of body.split(/\r?\n/)) {
232
+ if (q.includes('edge_followed_by')) {
233
+ // eslint-disable-next-line prefer-destructuring
234
+ q = q.split(',[],[')[1] ?? '';
235
+ // eslint-disable-next-line prefer-destructuring
236
+ q = q.split(']]]')[0] ?? '';
237
+ if (!q)
238
+ return undefined;
239
+ const outerParsed = JSON.parse(q);
240
+ // eslint-disable-next-line no-underscore-dangle
241
+ if (!isRecord(outerParsed))
242
+ return undefined;
243
+ const { data } = outerParsed;
244
+ if (!isRecord(data))
245
+ return undefined;
246
+ const bbox = data['__bbox'];
247
+ if (!isRecord(bbox))
248
+ return undefined;
249
+ const { result } = bbox;
250
+ if (!isRecord(result))
251
+ return undefined;
252
+ const { response } = result;
253
+ if (typeof response !== 'string')
254
+ return undefined;
255
+ q = response;
256
+ q = q.replaceAll('\\', '');
257
+ const innerParsed = JSON.parse(q);
258
+ if (!isRecord(innerParsed))
259
+ return undefined;
260
+ const innerData = innerParsed['data'];
261
+ if (!isRecord(innerData))
262
+ return undefined;
263
+ const innerUser = innerData['user'];
264
+ if (!isInstagramUser(innerUser))
265
+ return undefined;
266
+ return innerUser;
267
+ }
268
+ }
269
+ }
270
+ catch (err) {
271
+ const message = err instanceof Error ? err.message : 'Unknown error';
272
+ logger.warn(`Unable to get user data from page (${message}) - This is normal`);
273
+ }
274
+ return undefined;
275
+ }
276
+ // intercept special XHR network request that fetches user's data and store it in a cache
277
+ // TODO fallback to DOM to get user ID if this request fails?
278
+ // https://github.com/mifi/SimpleInstaBot/issues/125#issuecomment-1145354294
279
+ async function getUserDataFromInterceptedRequest() {
280
+ const t = setTimeout(async () => {
281
+ logger.log('Unable to intercept request, will send manually');
282
+ try {
283
+ await page.evaluate(async (username2) => {
284
+ const response = await window.fetch(`https://i.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(username2.toLowerCase())}`, { mode: 'cors', credentials: 'include', headers: { 'x-ig-app-id': '936619743392459' } });
285
+ await response.json(); // else it will not finish the request
286
+ }, username);
287
+ // todo `https://i.instagram.com/api/v1/users/${userId}/info/`
288
+ // https://www.javafixing.com/2022/07/fixed-can-get-instagram-profile-picture.html?m=1
289
+ }
290
+ catch (err) {
291
+ logger.error('Failed to manually send request', err);
292
+ }
293
+ }, 5000);
294
+ try {
295
+ const [foundResponse] = await Promise.all([
296
+ page.waitForResponse((response) => {
297
+ const request = response.request();
298
+ return request.method() === 'GET' && new RegExp(`https:\\/\\/i\\.instagram\\.com\\/api\\/v1\\/users\\/web_profile_info\\/\\?username=${encodeURIComponent(username.toLowerCase())}`).test(request.url());
299
+ }, { timeout: 30000 }),
300
+ navigateToUserWithCheck(username),
301
+ // page.waitForNavigation({ waitUntil: 'networkidle0' }),
302
+ ]);
303
+ const jsonText = await foundResponse.text();
304
+ const jsonParsed = JSON.parse(jsonText);
305
+ if (!isRecord(jsonParsed))
306
+ return undefined;
307
+ const { data } = jsonParsed;
308
+ if (!isRecord(data))
309
+ return undefined;
310
+ const { user } = data;
311
+ if (!isInstagramUser(user))
312
+ return undefined;
313
+ return user;
314
+ }
315
+ finally {
316
+ clearTimeout(t);
317
+ }
318
+ }
319
+ logger.log('Trying to get user data from HTML');
320
+ await navigateToUserWithCheck(username);
321
+ let userData = await getUserDataFromPage();
322
+ if (userData) {
323
+ userDataCache[username] = userData;
324
+ return userData;
325
+ }
326
+ logger.log('Need to intercept network request to get user data');
327
+ // works for old accounts only:
328
+ userData = await getUserDataFromInterceptedRequest();
329
+ if (userData) {
330
+ userDataCache[username] = userData;
331
+ return userData;
332
+ }
333
+ return undefined;
334
+ }
335
+ async function getPageJson() {
336
+ const pre = await page.$('pre');
337
+ assert(pre);
338
+ const textContentHandle = await pre.getProperty('textContent');
339
+ const textContentValue = await textContentHandle.jsonValue();
340
+ assert(typeof textContentValue === 'string');
341
+ return JSON.parse(textContentValue);
342
+ }
343
+ async function isActionBlocked() {
344
+ if (!(await getXpathElement('//*[contains(text(), "Action Blocked")]', { timeout: 1000 })))
345
+ return true;
346
+ if (!(await getXpathElement('//*[contains(text(), "Try Again Later")]', { timeout: 1000 })))
347
+ return true;
348
+ return false;
349
+ }
350
+ async function checkActionBlocked() {
351
+ if (await isActionBlocked()) {
352
+ const hours = 3;
353
+ logger.error(`Action Blocked, waiting ${hours} hours...`);
354
+ await tryDeleteCookies();
355
+ await sleep(hours * 60 * 60 * 1000);
356
+ throw new Error('Aborted operation due to action blocked');
357
+ }
358
+ }
359
+ async function findButtonWithText(text) {
360
+ // todo escape text?
361
+ // button seems to look like this now:
362
+ // <button class="..."><div class="...">Follow</div></button>
363
+ // https://sqa.stackexchange.com/questions/36918/xpath-text-buy-now-is-working-but-not-containstext-buy-now
364
+ // https://github.com/mifi/SimpleInstaBot/issues/106
365
+ let elementHandle = await getXpathElement(`//header//button[contains(.,'${text}')]`, { timeout: 1000 });
366
+ if (elementHandle != null)
367
+ return elementHandle;
368
+ // old button:
369
+ elementHandle = await getXpathElement(`//header//button[text()='${text}']`, { timeout: 1000 });
370
+ if (elementHandle != null)
371
+ return elementHandle;
372
+ return undefined;
373
+ }
374
+ async function findFollowButton() {
375
+ let button = await findButtonWithText('Follow');
376
+ if (button)
377
+ return button;
378
+ button = await findButtonWithText('Follow Back');
379
+ if (button)
380
+ return button;
381
+ return undefined;
382
+ }
383
+ async function findUnfollowButton() {
384
+ let button = await findButtonWithText('Following');
385
+ if (button)
386
+ return button;
387
+ button = await findButtonWithText('Requested');
388
+ if (button)
389
+ return button;
390
+ let elementHandle = await getXpathElement("//header//button[*//span[@aria-label='Following']]", { timeout: 1000 });
391
+ if (elementHandle != null)
392
+ return elementHandle;
393
+ elementHandle = await getXpathElement("//header//button[*//span[@aria-label='Requested']]", { timeout: 1000 });
394
+ if (elementHandle != null)
395
+ return elementHandle;
396
+ elementHandle = await getXpathElement("//header//button[*//*[name()='svg'][@aria-label='Following']]", { timeout: 1000 });
397
+ if (elementHandle != null)
398
+ return elementHandle;
399
+ elementHandle = await getXpathElement("//header//button[*//*[name()='svg'][@aria-label='Requested']]", { timeout: 1000 });
400
+ if (elementHandle != null)
401
+ return elementHandle;
402
+ return undefined;
403
+ }
404
+ async function findUnfollowConfirmButton() {
405
+ let elementHandle = await getXpathElement("//button[text()='Unfollow']", { timeout: 1000 });
406
+ if (elementHandle != null)
407
+ return elementHandle;
408
+ // https://github.com/mifi/SimpleInstaBot/issues/191
409
+ elementHandle = await getXpathElement("//*[@role='button'][contains(.,'Unfollow')]", { timeout: 1000 });
410
+ return elementHandle;
411
+ }
412
+ async function followUser(username) {
413
+ await navigateToUserAndGetData(username);
414
+ const elementHandle = await findFollowButton();
415
+ if (!elementHandle) {
416
+ if (await findUnfollowButton()) {
417
+ logger.log('We are already following this user');
418
+ await sleep(5000);
419
+ return;
420
+ }
421
+ throw new Error('Follow button not found');
422
+ }
423
+ logger.log(`Following user ${username}`);
424
+ if (!dryRun) {
425
+ await elementHandle.click();
426
+ await sleep(5000);
427
+ await checkActionBlocked();
428
+ const elementHandle2 = await findUnfollowButton();
429
+ // Don't want to retry this user over and over in case there is an issue https://github.com/mifi/instauto/issues/33#issuecomment-723217177
430
+ const entry = { username, time: Date.now(), ...(elementHandle2 ? {} : { failed: true }) };
431
+ await addPrevFollowedUser(entry);
432
+ if (!elementHandle2) {
433
+ logger.log('Button did not change state - Sleeping 1 min');
434
+ await sleep(60000);
435
+ throw new Error('Button did not change state');
436
+ }
437
+ }
438
+ await sleep(1000);
439
+ }
440
+ // See https://github.com/timgrossmann/InstaPy/pull/2345
441
+ // https://github.com/timgrossmann/InstaPy/issues/2355
442
+ async function unfollowUser(username) {
443
+ await navigateToUserAndGetData(username);
444
+ logger.log(`Unfollowing user ${username}`);
445
+ const res = { username, time: Date.now() };
446
+ const elementHandle = await findUnfollowButton();
447
+ if (!elementHandle) {
448
+ const elementHandle2 = await findFollowButton();
449
+ if (elementHandle2) {
450
+ logger.log('User has been unfollowed already');
451
+ res.noActionTaken = true;
452
+ }
453
+ else {
454
+ logger.log('Failed to find unfollow button');
455
+ res.noActionTaken = true;
456
+ }
457
+ }
458
+ if (!dryRun) {
459
+ if (elementHandle) {
460
+ await elementHandle.click();
461
+ await sleep(1000);
462
+ const confirmHandle = await findUnfollowConfirmButton();
463
+ if (confirmHandle)
464
+ await confirmHandle.click();
465
+ await sleep(5000);
466
+ await checkActionBlocked();
467
+ const elementHandle2 = await findFollowButton();
468
+ if (!elementHandle2)
469
+ throw new Error('Unfollow button did not change state');
470
+ }
471
+ await addPrevUnfollowedUser(res);
472
+ }
473
+ await sleep(1000);
474
+ return res;
475
+ }
476
+ const isLoggedIn = async () => await getXpathElement('//*[@aria-label="Home"]', { timeout: 1000 }) != null;
477
+ async function* graphqlQueryUsers({ queryHash, getResponseProp, graphqlVariables: graphqlVariablesIn }) {
478
+ const graphqlUrl = `${instagramBaseUrl}/graphql/query/?query_hash=${queryHash}`;
479
+ const graphqlVariables = {
480
+ ...graphqlVariablesIn,
481
+ first: graphqlVariablesIn.first ?? 50,
482
+ };
483
+ const outUsers = [];
484
+ let hasNextPage = true;
485
+ let i = 0;
486
+ while (hasNextPage) {
487
+ const url = `${graphqlUrl}&variables=${JSON.stringify(graphqlVariables)}`;
488
+ // logger.log(url);
489
+ await page.goto(url);
490
+ const json = await getPageJson();
491
+ const subProp = getResponseProp(json);
492
+ assert(subProp);
493
+ const pageInfo = subProp.page_info;
494
+ const { edges } = subProp;
495
+ const ret = [];
496
+ edges.forEach((e) => ret.push(e.node.username));
497
+ graphqlVariables.after = pageInfo.end_cursor;
498
+ hasNextPage = pageInfo.has_next_page;
499
+ i += 1;
500
+ if (hasNextPage) {
501
+ logger.log(`Has more pages (current ${i})`);
502
+ // await sleep(300);
503
+ }
504
+ yield ret;
505
+ }
506
+ return outUsers;
507
+ }
508
+ function getFollowersOrFollowingGenerator({ userId, getFollowers = false }) {
509
+ return graphqlQueryUsers({
510
+ getResponseProp: (json) => json.data.user?.[getFollowers ? 'edge_followed_by' : 'edge_follow'],
511
+ graphqlVariables: { id: userId },
512
+ queryHash: getFollowers ? '37479f2b8209594dde7facb0d904896a' : '58712303d941c6855d4e888c5f0cd22f',
513
+ });
514
+ }
515
+ async function getFollowersOrFollowing({ userId, getFollowers = false }) {
516
+ let users = [];
517
+ for await (const usersBatch of getFollowersOrFollowingGenerator({ userId, getFollowers })) {
518
+ users = [...users, ...usersBatch];
519
+ }
520
+ return users;
521
+ }
522
+ function getUsersWhoLikedContent({ contentId }) {
523
+ return graphqlQueryUsers({
524
+ getResponseProp: (json) => json.data.shortcode_media?.edge_liked_by,
525
+ graphqlVariables: {
526
+ shortcode: contentId,
527
+ include_reel: true,
528
+ },
529
+ queryHash: 'd5d763b1e2acf209d62d22d184488e57',
530
+ });
531
+ }
532
+ /* eslint-disable no-undef */
533
+ async function likeCurrentUserImagesPageCode({ dryRun: dryRunIn, likeImagesMin, likeImagesMax, shouldLikeMedia: shouldLikeMediaIn }) {
534
+ const allImages = [...document.getElementsByTagName('a')].filter((el) => typeof el.href === 'string' && /instagram.com\/p\//.test(el.href));
535
+ // eslint-disable-next-line no-shadow
536
+ function shuffleArray(arrayIn) {
537
+ const array = [...arrayIn];
538
+ for (let i = array.length - 1; i > 0; i -= 1) {
539
+ const j = Math.floor(Math.random() * (i + 1));
540
+ const temp = array[i];
541
+ if (temp === undefined || array[j] === undefined) {
542
+ throw new Error('Invalid shuffle index');
543
+ }
544
+ array[i] = array[j]; // eslint-disable-line no-param-reassign
545
+ array[j] = temp; // eslint-disable-line no-param-reassign
546
+ }
547
+ return array;
548
+ }
549
+ const imagesShuffled = shuffleArray(allImages);
550
+ const numImagesToLike = Math.floor((Math.random() * ((likeImagesMax + 1) - likeImagesMin)) + likeImagesMin);
551
+ window.instautoLog(`Liking ${numImagesToLike} image(s)`);
552
+ const images = imagesShuffled.slice(0, numImagesToLike);
553
+ if (images.length === 0) {
554
+ window.instautoLog('No images to like');
555
+ return;
556
+ }
557
+ for (const image of images) {
558
+ image.click?.();
559
+ await window.instautoSleep(3000);
560
+ const dialog = document.querySelector('*[role=dialog]');
561
+ if (!dialog)
562
+ throw new Error('Dialog not found');
563
+ const section = [...dialog.querySelectorAll('section')].find((s) => s.querySelectorAll('*[aria-label="Like"]')[0] && s.querySelectorAll('*[aria-label="Comment"]')[0]);
564
+ if (!section)
565
+ throw new Error('Like button section not found');
566
+ const likeButtonChild = section.querySelectorAll('*[aria-label="Like"]')[0];
567
+ if (!likeButtonChild)
568
+ throw new Error('Like button not found (aria-label)');
569
+ // eslint-disable-next-line no-inner-declarations
570
+ function findClickableParent(el) {
571
+ let elAt = el ?? undefined;
572
+ while (elAt) {
573
+ if ('click' in elAt && typeof elAt.click === 'function') {
574
+ return elAt;
575
+ }
576
+ elAt = elAt.parentElement ?? undefined;
577
+ }
578
+ return undefined;
579
+ }
580
+ const foundClickable = findClickableParent(likeButtonChild);
581
+ if (!foundClickable)
582
+ throw new Error('Like button not found');
583
+ const instautoLog2 = window.instautoLog;
584
+ // eslint-disable-next-line no-inner-declarations
585
+ function likeImage() {
586
+ const dialogResolved = dialog;
587
+ if (!dialogResolved)
588
+ throw new Error('Dialog not found');
589
+ if (shouldLikeMediaIn !== null && (typeof shouldLikeMediaIn === 'function')) {
590
+ const presentation = dialogResolved.querySelector('article[role=presentation]');
591
+ if (!presentation) {
592
+ instautoLog2('Presentation element not found');
593
+ return;
594
+ }
595
+ const img = presentation.querySelector('img[alt^="Photo by "]');
596
+ const video = presentation.querySelector('video[type="video/mp4"]');
597
+ const menuItem = presentation.querySelector('[role=menuitem] h2 ~ div');
598
+ const mediaDesc = menuItem?.textContent ?? '';
599
+ let mediaType = 'unknown';
600
+ let src;
601
+ let alt;
602
+ let poster;
603
+ if (img) {
604
+ mediaType = 'image';
605
+ src = img.src;
606
+ alt = img.alt;
607
+ }
608
+ else if (video) {
609
+ mediaType = 'video';
610
+ poster = video.poster;
611
+ src = video.src;
612
+ }
613
+ else {
614
+ instautoLog2('Could not determin mediaType');
615
+ }
616
+ if (!shouldLikeMediaIn({ mediaType, mediaDesc, src, alt, poster })) {
617
+ instautoLog2(`shouldLikeMedia returned false for ${image.href}, skipping`);
618
+ return;
619
+ }
620
+ }
621
+ foundClickable?.click?.();
622
+ if (image.href)
623
+ window.instautoOnImageLiked(image.href);
624
+ }
625
+ if (!dryRunIn) {
626
+ likeImage();
627
+ }
628
+ await window.instautoSleep(3000);
629
+ const closeButtonChild = document.querySelector('svg[aria-label="Close"]');
630
+ if (!closeButtonChild)
631
+ throw new Error('Close button not found (aria-label)');
632
+ const closeButton = findClickableParent(closeButtonChild);
633
+ if (!closeButton)
634
+ throw new Error('Close button not found');
635
+ closeButton?.click?.();
636
+ await window.instautoSleep(5000);
637
+ }
638
+ window.instautoLog('Done liking images');
639
+ }
640
+ /* eslint-enable no-undef */
641
+ async function likeUserImages({ username, likeImagesMin, likeImagesMax } = {}) {
642
+ if (!username)
643
+ throw new Error('Username is required');
644
+ if (likeImagesMin == null || likeImagesMax == null || likeImagesMax < likeImagesMin || likeImagesMin < 1)
645
+ throw new Error('Invalid arguments');
646
+ await navigateToUserAndGetData(username);
647
+ logger.log(`Liking ${likeImagesMin}-${likeImagesMax} user images`);
648
+ try {
649
+ await page.exposeFunction('instautoSleep', (...args) => sleep(...args));
650
+ await page.exposeFunction('instautoLog', (...args) => console.log(...args));
651
+ await page.exposeFunction('instautoOnImageLiked', (href) => onImageLiked({ username, href }));
652
+ }
653
+ catch {
654
+ // Ignore already exists error
655
+ }
656
+ await page.evaluate(likeCurrentUserImagesPageCode, { dryRun, likeImagesMin, likeImagesMax, shouldLikeMedia });
657
+ }
658
+ async function followUserRespectingRestrictions({ username, skipPrivate = false }) {
659
+ if (getPrevFollowedUser(username)) {
660
+ logger.log('Skipping already followed user', username);
661
+ return false;
662
+ }
663
+ const graphqlUser = await navigateToUserAndGetData(username);
664
+ if (!graphqlUser)
665
+ return false;
666
+ const { edge_followed_by: { count: followedByCount }, edge_follow: { count: followsCount }, is_private: isPrivate, is_verified: isVerified, is_business_account: isBusinessAccount, is_professional_account: isProfessionalAccount, full_name: fullName, biography, profile_pic_url_hd: profilePicUrlHd, external_url: externalUrl, business_category_name: businessCategoryName, category_name: categoryName } = graphqlUser;
667
+ // logger.log('followedByCount:', followedByCount, 'followsCount:', followsCount);
668
+ const ratio = followedByCount / (followsCount || 1);
669
+ if (isPrivate && skipPrivate) {
670
+ logger.log('User is private, skipping');
671
+ return false;
672
+ }
673
+ if ((followUserMaxFollowers != null && followedByCount > followUserMaxFollowers)
674
+ || (followUserMaxFollowing != null && followsCount > followUserMaxFollowing)
675
+ || (followUserMinFollowers != null && followedByCount < followUserMinFollowers)
676
+ || (followUserMinFollowing != null && followsCount < followUserMinFollowing)) {
677
+ logger.log('User has too many or too few followers or following, skipping.', 'followedByCount:', followedByCount, 'followsCount:', followsCount);
678
+ return false;
679
+ }
680
+ if ((followUserRatioMax != null && ratio > followUserRatioMax)
681
+ || (followUserRatioMin != null && ratio < followUserRatioMin)) {
682
+ logger.log('User has too many followers compared to follows or opposite, skipping');
683
+ return false;
684
+ }
685
+ if (shouldFollowUser !== null && (typeof shouldFollowUser === 'function' && shouldFollowUser({ username, isVerified, isBusinessAccount, isProfessionalAccount, fullName, biography, profilePicUrlHd, externalUrl, businessCategoryName, categoryName }) !== true)) {
686
+ logger.log(`Custom follow logic returned false for ${username}, skipping`);
687
+ return false;
688
+ }
689
+ await followUser(username);
690
+ await sleep(30000);
691
+ await throttle();
692
+ return true;
693
+ }
694
+ async function processUserFollowers(username, { maxFollowsPerUser = 5, skipPrivate = false, enableLikeImages, likeImagesMin, likeImagesMax, } = {}) {
695
+ const enableFollow = maxFollowsPerUser > 0;
696
+ if (enableFollow)
697
+ logger.log(`Following up to ${maxFollowsPerUser} followers of ${username}`);
698
+ if (enableLikeImages)
699
+ logger.log(`Liking images of up to ${likeImagesMax} followers of ${username}`);
700
+ await throttle();
701
+ let numFollowedForThisUser = 0;
702
+ const userData = await navigateToUserAndGetData(username);
703
+ if (!userData)
704
+ return;
705
+ const { id: userId } = userData;
706
+ for await (const followersBatch of getFollowersOrFollowingGenerator({ userId, getFollowers: true })) {
707
+ logger.log('User followers batch', followersBatch);
708
+ for (const follower of followersBatch) {
709
+ await throttle();
710
+ try {
711
+ if (enableFollow && numFollowedForThisUser >= maxFollowsPerUser) {
712
+ logger.log('Have reached followed limit for this user, stopping');
713
+ return;
714
+ }
715
+ let didActuallyFollow = false;
716
+ if (enableFollow)
717
+ didActuallyFollow = await followUserRespectingRestrictions({ username: follower, skipPrivate });
718
+ if (didActuallyFollow)
719
+ numFollowedForThisUser += 1;
720
+ const didFailToFollow = enableFollow && !didActuallyFollow;
721
+ if (enableLikeImages && !didFailToFollow) {
722
+ // Note: throws error if user isPrivate
723
+ await likeUserImages({ username: follower, likeImagesMin, likeImagesMax });
724
+ }
725
+ }
726
+ catch (err) {
727
+ logger.error(`Failed to process follower ${follower}`, err);
728
+ await takeScreenshot();
729
+ await sleep(20000);
730
+ }
731
+ }
732
+ }
733
+ }
734
+ async function processUsersFollowers({ usersToFollowFollowersOf, maxFollowsTotal = 150, skipPrivate, enableFollow = true, enableLikeImages = false, likeImagesMin = 1, likeImagesMax = 2 }) {
735
+ // If maxFollowsTotal turns out to be lower than the user list size, slice off the user list
736
+ const usersToFollowFollowersOfSliced = shuffleArray(usersToFollowFollowersOf).slice(0, maxFollowsTotal);
737
+ const maxFollowsPerUser = enableFollow && usersToFollowFollowersOfSliced.length > 0 ? Math.floor(maxFollowsTotal / usersToFollowFollowersOfSliced.length) : 0;
738
+ if (maxFollowsPerUser === 0 && (!enableLikeImages || likeImagesMin < 1 || likeImagesMax < 1)) {
739
+ logger.warn('Nothing to follow or like');
740
+ return;
741
+ }
742
+ for (const username of usersToFollowFollowersOfSliced) {
743
+ try {
744
+ await processUserFollowers(username, { maxFollowsPerUser, skipPrivate, enableLikeImages, likeImagesMin, likeImagesMax });
745
+ await sleep(10 * 60 * 1000);
746
+ await throttle();
747
+ }
748
+ catch (err) {
749
+ logger.error('Failed to process user followers, continuing', username, err);
750
+ await takeScreenshot();
751
+ await sleep(60 * 1000);
752
+ }
753
+ }
754
+ }
755
+ async function safelyUnfollowUserList(usersToUnfollow, limit, condition = () => true) {
756
+ logger.log('Unfollowing users, up to limit', limit);
757
+ let i = 0; // Number of people processed
758
+ let j = 0; // Number of people actually unfollowed (button pressed)
759
+ for await (const listOrUsername of usersToUnfollow) {
760
+ // backward compatible:
761
+ const list = Array.isArray(listOrUsername) ? listOrUsername : [listOrUsername];
762
+ for (const username of list) {
763
+ if (await condition(username)) {
764
+ try {
765
+ const userFound = await navigateToUser(username);
766
+ if (!userFound) {
767
+ // to avoid repeatedly unfollowing failed users, flag them as already unfollowed
768
+ logger.log('User not found for unfollow');
769
+ await addPrevUnfollowedUser({ username, time: Date.now(), noActionTaken: true });
770
+ await sleep(3000);
771
+ }
772
+ else {
773
+ const { noActionTaken } = await unfollowUser(username);
774
+ if (noActionTaken) {
775
+ await sleep(3000);
776
+ }
777
+ else {
778
+ await sleep(15000);
779
+ j += 1;
780
+ if (j % 10 === 0) {
781
+ logger.log('Have unfollowed 10 users since last break. Taking a break');
782
+ await sleep(10 * 60 * 1000, 0.1);
783
+ }
784
+ }
785
+ }
786
+ i += 1;
787
+ logger.log(`Have now unfollowed (or tried to unfollow) ${i} users`);
788
+ if (limit && j >= limit) {
789
+ logger.log(`Have unfollowed limit of ${limit}, stopping`);
790
+ return j;
791
+ }
792
+ await throttle();
793
+ }
794
+ catch (err) {
795
+ logger.error('Failed to unfollow, continuing with next', err);
796
+ }
797
+ }
798
+ }
799
+ }
800
+ logger.log('Done with unfollowing', i, j);
801
+ return j;
802
+ }
803
+ async function safelyFollowUserList({ users, skipPrivate, limit }) {
804
+ logger.log('Following users, up to limit', limit);
805
+ for (const username of users) {
806
+ await throttle();
807
+ try {
808
+ await followUserRespectingRestrictions({ username, skipPrivate });
809
+ }
810
+ catch (err) {
811
+ logger.error(`Failed to follow user ${username}, continuing`, err);
812
+ await takeScreenshot();
813
+ await sleep(20000);
814
+ }
815
+ }
816
+ }
817
+ function getPage() {
818
+ return page;
819
+ }
820
+ page = await browser.newPage();
821
+ // https://github.com/mifi/SimpleInstaBot/issues/118#issuecomment-1067883091
822
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'en' });
823
+ if (randomizeUserAgent) {
824
+ const userAgentGenerated = new UserAgent({ deviceCategory: 'desktop' });
825
+ await page.setUserAgent({ userAgent: userAgentGenerated.toString() });
826
+ }
827
+ if (userAgent)
828
+ await page.setUserAgent({ userAgent });
829
+ if (enableCookies)
830
+ await tryLoadCookies();
831
+ const goHome = async () => gotoUrl(`${instagramBaseUrl}/?hl=en`);
832
+ // https://github.com/mifi/SimpleInstaBot/issues/28
833
+ async function setLang(short, long, assumeLoggedIn = false) {
834
+ logger.log(`Setting language to ${long} (${short})`);
835
+ try {
836
+ await sleep(1000);
837
+ // when logged in, we need to go to account in order to be able to check/set language
838
+ // (need to see the footer)
839
+ await (assumeLoggedIn ? gotoUrl(`${instagramBaseUrl}/accounts/edit/`) : goHome());
840
+ await sleep(3000);
841
+ const selectElement = await getXpathElement(`//select[//option[@value='${short}' and text()='${long}']]`, { timeout: 1000 });
842
+ if (!selectElement)
843
+ throw new Error('Language selector not found');
844
+ logger.log('Found language selector');
845
+ // https://stackoverflow.com/questions/45864516/how-to-select-an-option-from-dropdown-select
846
+ const alreadyEnglish = await page.evaluate((selectElem, short2) => {
847
+ const optionElem = selectElem.querySelector?.(`option[value='${short2}']`);
848
+ if (!optionElem)
849
+ return false;
850
+ if (optionElem.selected)
851
+ return true; // already selected?
852
+ optionElem.selected = true;
853
+ // eslint-disable-next-line no-undef
854
+ const event = new Event('change', { bubbles: true });
855
+ selectElem.dispatchEvent?.(event);
856
+ return false;
857
+ }, selectElement, short);
858
+ if (alreadyEnglish) {
859
+ logger.log('Already English language');
860
+ if (!assumeLoggedIn) {
861
+ await goHome(); // because we were on the settings page
862
+ await sleep(1000);
863
+ }
864
+ return;
865
+ }
866
+ logger.log('Selected language');
867
+ await sleep(3000);
868
+ await goHome();
869
+ await sleep(1000);
870
+ }
871
+ catch (err) {
872
+ logger.error('Failed to set language, trying fallback (cookie)', err);
873
+ // This doesn't seem to always work, hence why it's just a fallback now
874
+ await goHome();
875
+ await sleep(1000);
876
+ await page.setCookie({
877
+ name: 'ig_lang',
878
+ value: short,
879
+ path: '/',
880
+ });
881
+ await sleep(1000);
882
+ await goHome();
883
+ await sleep(3000);
884
+ }
885
+ }
886
+ const setEnglishLang = async (assumeLoggedIn) => setLang('en', 'English', assumeLoggedIn);
887
+ // const setEnglishLang = async (assumeLoggedIn) => setLang('de', 'Deutsch', assumeLoggedIn);
888
+ async function tryPressButton(elementHandle, name, sleepMs = 3000) {
889
+ try {
890
+ if (elementHandle != null) {
891
+ logger.log(`Pressing button: ${name}`);
892
+ elementHandle.click();
893
+ await sleep(sleepMs);
894
+ }
895
+ }
896
+ catch {
897
+ logger.warn(`Failed to press button: ${name}`);
898
+ }
899
+ }
900
+ async function tryClickLogin() {
901
+ async function tryClickButton(xpath) {
902
+ const btn = await getXpathElement(xpath, { timeout: 1000 });
903
+ if (btn == null)
904
+ return false;
905
+ await btn.click();
906
+ return true;
907
+ }
908
+ if (await tryClickButton("//button[.//text() = 'Log In']"))
909
+ return true;
910
+ if (await tryClickButton("//button[.//text() = 'Log in']"))
911
+ return true; // https://github.com/mifi/instauto/pull/110 https://github.com/mifi/instauto/issues/109
912
+ return false;
913
+ }
914
+ await setEnglishLang(false);
915
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Accept")]', { timeout: 1000 }), 'Accept cookies dialog');
916
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Only allow essential cookies")]', { timeout: 1000 }), 'Accept cookies dialog 2 button 1', 10000);
917
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Allow essential and optional cookies")]', { timeout: 1000 }), 'Accept cookies dialog 2 button 2', 10000);
918
+ if (!(await isLoggedIn())) {
919
+ if (!myUsername || !password) {
920
+ await tryDeleteCookies();
921
+ throw new Error('No longer logged in. Deleting cookies and aborting. Need to provide username/password');
922
+ }
923
+ try {
924
+ await page.click('a[href="/accounts/login/?source=auth_switcher"]');
925
+ await sleep(1000);
926
+ }
927
+ catch {
928
+ logger.info('No login page button, assuming we are on login form');
929
+ }
930
+ // Mobile version https://github.com/mifi/SimpleInstaBot/issues/7
931
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Log In")]', { timeout: 1000 }), 'Login form button');
932
+ await page.type('input[name="username"]', myUsername, { delay: 50 });
933
+ await sleep(1000);
934
+ await page.type('input[name="password"]', password, { delay: 50 });
935
+ await sleep(1000);
936
+ for (;;) {
937
+ const didClickLogin = await tryClickLogin();
938
+ if (didClickLogin)
939
+ break;
940
+ logger.warn('Login button not found. Maybe you can help me click it? And also report an issue on github with a screenshot of what you\'re seeing :)');
941
+ await sleep(6000);
942
+ }
943
+ await sleepFixed(10000);
944
+ // Sometimes login button gets stuck with a spinner
945
+ // https://github.com/mifi/SimpleInstaBot/issues/25
946
+ if (!(await isLoggedIn())) {
947
+ logger.log('Still not logged in, trying to reload loading page');
948
+ await page.reload();
949
+ await sleep(5000);
950
+ }
951
+ let warnedAboutLoginFail = false;
952
+ while (!(await isLoggedIn())) {
953
+ if (!warnedAboutLoginFail)
954
+ logger.warn('WARNING: Login has not succeeded. This could be because of an incorrect username/password, or a "suspicious login attempt"-message. You need to manually complete the process, or if really logged in, click the Instagram logo in the top left to go to the Home page.');
955
+ warnedAboutLoginFail = true;
956
+ await sleep(5000);
957
+ }
958
+ // In case language gets reset after logging in
959
+ // https://github.com/mifi/SimpleInstaBot/issues/118
960
+ await setEnglishLang(true);
961
+ // Mobile version https://github.com/mifi/SimpleInstaBot/issues/7
962
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Save Info")]', { timeout: 1000 }), 'Login info dialog: Save Info');
963
+ // May sometimes be "Save info" too? https://github.com/mifi/instauto/pull/70
964
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Save info")]', { timeout: 1000 }), 'Login info dialog: Save info');
965
+ }
966
+ await tryPressButton(await getXpathElement('//button[contains(text(), "Not Now")]', { timeout: 1000 }), 'Turn on Notifications dialog');
967
+ await trySaveCookies();
968
+ logger.log(`Have followed/unfollowed ${getNumFollowedUsersThisTimeUnit(hourMs)} in the last hour`);
969
+ logger.log(`Have followed/unfollowed ${getNumFollowedUsersThisTimeUnit(dayMs)} in the last 24 hours`);
970
+ logger.log(`Have liked ${getNumLikesThisTimeUnit(dayMs)} images in the last 24 hours`);
971
+ try {
972
+ // eslint-disable-next-line no-underscore-dangle
973
+ const detectedUsername = await page.evaluate(() => window._sharedData?.config?.viewer?.username);
974
+ if (detectedUsername)
975
+ myUsername = detectedUsername;
976
+ }
977
+ catch (err) {
978
+ logger.error('Failed to detect username', err);
979
+ }
980
+ if (!myUsername) {
981
+ throw new Error('Don\'t know what\'s my username');
982
+ }
983
+ const me = await navigateToUserAndGetData(myUsername);
984
+ if (!me)
985
+ throw new Error('Failed to load my user data');
986
+ const { id: myUserId } = me;
987
+ // --- END OF INITIALIZATION
988
+ async function doesUserFollowMe(username) {
989
+ try {
990
+ logger.info('Checking if user', username, 'follows us');
991
+ const userData = await navigateToUserAndGetData(username);
992
+ if (!userData)
993
+ throw new Error('Unable to resolve user id');
994
+ const { id: userId } = userData;
995
+ const elementHandle = await getXpathElement("//a[contains(.,' following')][contains(@href,'/following')]", { timeout: 1000 });
996
+ if (elementHandle == null)
997
+ throw new Error('Following button not found');
998
+ if (!userId)
999
+ throw new Error('Unable to resolve user id');
1000
+ const [foundResponse] = await Promise.all([
1001
+ page.waitForResponse((response) => {
1002
+ const request = response.request();
1003
+ return request.method() === 'GET' && new RegExp(`instagram.com/api/v1/friendships/${userId}/following/`).test(request.url());
1004
+ }),
1005
+ elementHandle.click(),
1006
+ // page.waitForNavigation({ waitUntil: 'networkidle0' }),
1007
+ ]);
1008
+ const responseText = await foundResponse.text();
1009
+ const parsed = JSON.parse(responseText);
1010
+ if (!isRecord(parsed) || !Array.isArray(parsed['users']))
1011
+ throw new Error('Invalid follow response');
1012
+ const { users } = parsed;
1013
+ if (users.length < 2)
1014
+ throw new Error('Unable to find user follows list');
1015
+ // console.log(users, myUserId);
1016
+ return users.some((user) => isRecord(user) && (String(user['pk']) === String(myUserId) || user['username'] === myUsername)); // If they follow us, we will show at the top of the list
1017
+ }
1018
+ catch (err) {
1019
+ logger.error('Failed to check if user follows us', err);
1020
+ return undefined;
1021
+ }
1022
+ }
1023
+ async function unfollowNonMutualFollowers({ limit } = {}) {
1024
+ logger.log(`Unfollowing non-mutual followers (limit ${limit})...`);
1025
+ /* const allFollowers = await getFollowersOrFollowing({
1026
+ userId: myUserId,
1027
+ getFollowers: true,
1028
+ }); */
1029
+ const allFollowingGenerator = getFollowersOrFollowingGenerator({
1030
+ userId: myUserId,
1031
+ getFollowers: false,
1032
+ });
1033
+ async function condition(username) {
1034
+ // if (allFollowers.includes(u)) return false; // Follows us
1035
+ if (excludeUsers.includes(username))
1036
+ return false; // User is excluded by exclude list
1037
+ if (haveRecentlyFollowedUser(username)) {
1038
+ logger.log(`Have recently followed user ${username}, skipping`);
1039
+ return false;
1040
+ }
1041
+ const followsMe = await doesUserFollowMe(username);
1042
+ logger.info('User follows us?', followsMe);
1043
+ return followsMe === false;
1044
+ }
1045
+ return safelyUnfollowUserList(allFollowingGenerator, limit, condition);
1046
+ }
1047
+ async function unfollowAllUnknown({ limit } = {}) {
1048
+ logger.log('Unfollowing all except excludes and auto followed');
1049
+ const unfollowUsersGenerator = getFollowersOrFollowingGenerator({
1050
+ userId: myUserId,
1051
+ getFollowers: false,
1052
+ });
1053
+ function condition(username) {
1054
+ if (getPrevFollowedUser(username))
1055
+ return false; // we followed this user, so it's not unknown
1056
+ if (excludeUsers.includes(username))
1057
+ return false; // User is excluded by exclude list
1058
+ return true;
1059
+ }
1060
+ return safelyUnfollowUserList(unfollowUsersGenerator, limit, condition);
1061
+ }
1062
+ async function unfollowOldFollowed({ ageInDays, limit } = {}) {
1063
+ assert(ageInDays != null, 'Age in days is required');
1064
+ const ageInDaysResolved = ageInDays;
1065
+ logger.log(`Unfollowing currently followed users who were auto-followed more than ${ageInDaysResolved} days ago (limit ${limit})...`);
1066
+ const followingUsersGenerator = getFollowersOrFollowingGenerator({
1067
+ userId: myUserId,
1068
+ getFollowers: false,
1069
+ });
1070
+ function condition(username) {
1071
+ const previous = getPrevFollowedUser(username);
1072
+ if (!previous)
1073
+ return false;
1074
+ if (excludeUsers.includes(username))
1075
+ return false;
1076
+ return (Date.now() - previous.time) / (1000 * 60 * 60 * 24) > ageInDaysResolved;
1077
+ }
1078
+ return safelyUnfollowUserList(followingUsersGenerator, limit, condition);
1079
+ }
1080
+ async function listManuallyFollowedUsers() {
1081
+ const allFollowing = await getFollowersOrFollowing({
1082
+ userId: myUserId,
1083
+ getFollowers: false,
1084
+ });
1085
+ return allFollowing.filter((u) => !getPrevFollowedUser(u) && !excludeUsers.includes(u));
1086
+ }
1087
+ return {
1088
+ followUserFollowers: processUserFollowers,
1089
+ unfollowNonMutualFollowers,
1090
+ unfollowAllUnknown,
1091
+ unfollowOldFollowed,
1092
+ followUser,
1093
+ unfollowUser,
1094
+ likeUserImages,
1095
+ sleep,
1096
+ listManuallyFollowedUsers,
1097
+ getFollowersOrFollowing,
1098
+ getUsersWhoLikedContent,
1099
+ safelyUnfollowUserList,
1100
+ safelyFollowUserList,
1101
+ getPage,
1102
+ followUsersFollowers: processUsersFollowers,
1103
+ doesUserFollowMe,
1104
+ navigateToUserAndGetData,
1105
+ };
1106
+ };
1107
+ export default Instauto;
1108
+ export { default as JSONDB } from "./db.js";