diff --git a/README.md b/README.md index 1b86b79..4bfbb55 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,14 @@ Under development, however mainly for personal use! - [x] Multi-Account Support - [x] Session Storing - [x] 2FA Support +- [x] Headless Support - [x] Discord Webhook Support - [x] Desktop Searches +- [x] Configurable Tasks - [x] Microsoft Edge Searches - [x] Mobile Searches -- [x] Emulate scrolling and link clicking (Optional) +- [x] Emulated Scrolling Support +- [x] Emulated Link Clicking Support - [x] Completing Daily Set - [x] Completing More Promotions - [x] Solving Quiz (10 point variant) @@ -30,7 +33,9 @@ Under development, however mainly for personal use! - [ ] Solving This Or That Quiz - [x] Clicking Promotional Items - [x] Solving ABC Quiz -- [ ] Completing Shop And Earn +- [ ] Completing Shopping Game +- [ ] Completing Gaming Tab +- [x] Clustering Support - [ ] Proxy Support ## Disclaimer ## diff --git a/package.json b/package.json index 45abd91..c11bda4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microsoft-rewards-script", - "version": "1.1.1", + "version": "1.2.0", "description": "Automatically do tasks for Microsoft Rewards but in TS!", "main": "index.js", "engines": { @@ -9,14 +9,17 @@ "scripts": { "build": "tsc", "start": "node ./dist/index.js", + "ts-start": "ts-node ./src/index.ts", "dev": "ts-node ./src/index.ts -dev" }, "keywords": [ "Bing Rewards", "Microsoft Rewards", "Bot", + "Script", "TypeScript", - "Puppeteer" + "Puppeteer", + "Cheerio" ], "author": "Netsky", "license": "ISC", diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 3ffbbfa..d76270a 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -18,6 +18,7 @@ class Browser { userDataDir: await loadSesion(email), args: [ '--no-sandbox', + '--mute-audio', '--disable-setuid-sandbox', `--user-agent=${userAgent.userAgent}`, isMobile ? '--window-size=568,1024' : '' diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 2c0affb..32e17df 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -28,7 +28,7 @@ export async function goHome(page: Page): Promise { // Check if account is suspended const isSuspended = await page.waitForSelector('#suspendedAccountHeader', { visible: true, timeout: 3000 }).then(() => true).catch(() => false) if (isSuspended) { - log('GO-HOME', 'This account is suspended!') + log('GO-HOME', 'This account is suspended!', 'error') throw new Error('Account has been suspended!') } diff --git a/src/config.json b/src/config.json index cf2ce9e..624cf1c 100644 --- a/src/config.json +++ b/src/config.json @@ -3,6 +3,7 @@ "sessionPath": "sessions", "headless": false, "runOnZeroPoints": false, + "clusters": 1, "workers": { "doDailySet": true, "doMorePromotions": true, diff --git a/src/functions/Login.ts b/src/functions/Login.ts index ace22c6..7aeba4d 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -15,10 +15,16 @@ export async function login(page: Page, email: string, password: string) { try { // Navigate to the Bing login page await page.goto('https://login.live.com/') - + const isLoggedIn = await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 5000 }).then(() => true).catch(() => false) if (!isLoggedIn) { + const isLocked = await page.waitForSelector('.serviceAbusePageContainer', { visible: true, timeout: 5000 }).then(() => true).catch(() => false) + if (isLocked) { + log('LOGIN', 'This account is suspended!', 'error') + throw new Error('Account has been locked!') + } + await page.waitForSelector('#loginHeader', { visible: true, timeout: 10_000 }) await execLogin(page, email, password) @@ -34,13 +40,15 @@ export async function login(page: Page, email: string, password: string) { log('LOGIN', 'Logged in successfully') } catch (error) { - log('LOGIN', 'An error occurred:' + error, 'error') + // Throw and don't continue + throw log('LOGIN', 'An error occurred:' + error, 'error') } } async function execLogin(page: Page, email: string, password: string) { await page.type('#i0116', email) await page.click('#idSIButton9') + log('LOGIN', 'Email entered successfully') try { diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 5e0733b..dbb9183 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -14,6 +14,7 @@ import { log } from '../util/Logger' import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData' +import { baseURL } from '../config.json' // Daily Set export async function doDailySet(page: Page, data: DashboardData) { @@ -55,7 +56,7 @@ export async function doPunchCard(page: Page, data: DashboardData) { page = await browser.newPage() // Got to punch card index page in a new tab - await page.goto(punchCard.parentPromotion.destinationUrl, { referer: 'https://rewards.bing.com/' }) + await page.goto(punchCard.parentPromotion.destinationUrl, { referer: baseURL }) await solveActivities(page, activitiesUncompleted, punchCard) @@ -94,24 +95,29 @@ export async function doMorePromotions(page: Page, data: DashboardData) { // Solve all the different types of activities async function solveActivities(page: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) { - try { - for (const activity of activities) { + for (const activity of activities) { + try { + + let selector = `[data-bi-id="${activity.offerId}"]` if (punchCard) { - const selector = await getPunchCardActivity(page, activity) + selector = await getPunchCardActivity(page, activity) - // Wait for page to load and click to load the activity in a new tab - await page.waitForSelector(selector, { timeout: 5000 }) - await page.click(selector) + } else if (activity.name.toLowerCase().includes('membercenter')) { - } else { - const selector = `[data-bi-id="${activity.offerId}"]` - - // Wait for page to load and click to load the activity in a new tab - await page.waitForSelector(selector, { timeout: 5000 }) - await page.click(selector) + // Promotion + if (activity.priority === 1) { + selector = '#promo-item' + } else { + selector = `[data-bi-id="${activity.name}"]` + } } + // Wait for element to load + await page.waitForSelector(selector, { timeout: 5000 }) + // Click element, it will be opened in a new tab + await page.click(selector) + // Select the new activity page const activityPage = await getLatestTab(page) @@ -134,7 +140,7 @@ async function solveActivities(page: Page, activities: PromotionalItem[] | MoreP // This Or That Quiz (Usually 50 points) case 50: log('ACTIVITY', `Found activity type: "ThisOrThat" title: "${activity.title}"`) - await doThisOrThat(activityPage, activity) + await doThisOrThat(activityPage) break // Quizzes are usually 30-40 points @@ -151,13 +157,16 @@ async function solveActivities(page: Page, activities: PromotionalItem[] | MoreP await doUrlReward(activityPage) break + // Misc default: + log('ACTIVITY', `Found activity type: "Misc" title: "${activity.title}"`) + await doUrlReward(activityPage) break } - await wait(1500) - } - } catch (error) { - log('ACTIVITY', 'An error occurred:' + error, 'error') + await wait(1500) + } catch (error) { + log('ACTIVITY', 'An error occurred:' + error, 'error') + } } } \ No newline at end of file diff --git a/src/functions/activities/Search.ts b/src/functions/activities/Search.ts index 9660016..37b4b2b 100644 --- a/src/functions/activities/Search.ts +++ b/src/functions/activities/Search.ts @@ -46,7 +46,7 @@ export async function doSearch(page: Page, data: DashboardData, mobile: boolean) // Go to bing await searchPage.goto('https://bing.com') - let maxLoop = 0 // If the loop hits 20 this when not gaining any points, we're assuming it's stuck. + let maxLoop = 0 // If the loop hits 10 this when not gaining any points, we're assuming it's stuck. If it ddoesn't continue after 5 more searches with alternative queries, abort search const queries: string[] = [] googleSearchQueries.forEach(x => queries.push(x.topic, ...x.related)) @@ -80,8 +80,9 @@ export async function doSearch(page: Page, data: DashboardData, mobile: boolean) break } - if (maxLoop > 20) { - log('SEARCH-BING', 'Search didn\'t gain point for 20 iterations aborting searches', 'warn') + // If we didn't gain points for 10 iterations, assume it's stuck + if (maxLoop > 10) { + log('SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn') maxLoop = 0 // Reset to 0 so we can retry with related searches below break } @@ -125,10 +126,10 @@ export async function doSearch(page: Page, data: DashboardData, mobile: boolean) break } - // Try 5 more times + // Try 5 more times, then we tried a total of 15 times, fair to say it's stuck if (maxLoop > 5) { log('SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn') - break + return } } } @@ -143,7 +144,7 @@ async function bingSearch(page: Page, searchPage: Page, query: string) { for (let i = 0; i < 5; i++) { try { const searchBar = '#sb_form_q' - await searchPage.waitForSelector(searchBar, { visible: true, timeout: 3000 }) + await searchPage.waitForSelector(searchBar, { visible: true, timeout: 10_000 }) await searchPage.click(searchBar) // Focus on the textarea await wait(500) await searchPage.keyboard.down('Control') @@ -252,7 +253,7 @@ function formatDate(date: Date): string { async function randomScroll(page: Page) { try { // Press the arrow down key to scroll - for (let i = 0; i < randomNumber(5, 50); i++) { + for (let i = 0; i < randomNumber(5, 100); i++) { await page.keyboard.press('ArrowDown') } } catch (error) { @@ -262,7 +263,7 @@ async function randomScroll(page: Page) { async function clickRandomLink(page: Page) { try { - const searchListingURL = new URL(page.url()) // Get page info before clicking + const searchListingURL = new URL(page.url()) // Get searchPage info before clicking await page.click('#b_results .b_algo h2').catch(() => { }) // Since we don't really care if it did it or not @@ -272,15 +273,16 @@ async function clickRandomLink(page: Page) { // Will get current tab if no new one is created let lastTab = await getLatestTab(page) - // Wait for website to finish loading, don't break loop however - await lastTab.waitForNetworkIdle({ idleTime: 1000, timeout: 5000 }).catch(() => { }) + // Wait for the body of the new page to be loaded + await lastTab.waitForSelector('body', { timeout: 10_000 }).catch(() => { }) // Check if the tab is closed or not if (!lastTab.isClosed()) { let lastTabURL = new URL(lastTab.url()) // Get new tab info - // Check if the URL is different from the original one - while (lastTabURL.href !== searchListingURL.href) { + // Check if the URL is different from the original one, don't loop more than 5 times. + let i = 0 + while (lastTabURL.href !== searchListingURL.href && i < 5) { // If hostname is still bing, (Bing images/news etc) if (lastTabURL.hostname == searchListingURL.hostname) { @@ -294,7 +296,6 @@ async function clickRandomLink(page: Page) { await lastTab.goto(searchListingURL.href) } - break } else { // No longer on bing, likely opened a new tab, close this tab lastTab = await getLatestTab(page) // Get last opened tab lastTabURL = new URL(lastTab.url()) @@ -303,8 +304,13 @@ async function clickRandomLink(page: Page) { // If the browser has more than 3 tabs open, it has opened a new one, we need to close this one. if (tabs.length > 3) { - await lastTab.close() - } else { + // Make sure the page is still open! + if (!lastTab.isClosed()) { + await lastTab.close() + } + + } else if (lastTabURL.href !== searchListingURL.href) { + await lastTab.goBack() lastTab = await getLatestTab(page) // Get last opened tab @@ -315,10 +321,11 @@ async function clickRandomLink(page: Page) { await lastTab.goto(searchListingURL.href) } } - - break } } + + lastTab = await getLatestTab(page) // Finally update the lastTab var again + i++ } } catch (error) { log('SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error') diff --git a/src/functions/activities/ThisOrThat.ts b/src/functions/activities/ThisOrThat.ts index 8cde77b..36b0644 100644 --- a/src/functions/activities/ThisOrThat.ts +++ b/src/functions/activities/ThisOrThat.ts @@ -1,30 +1,20 @@ import { Page } from 'puppeteer' -import { getLatestTab } from '../../browser/BrowserUtil' import { wait } from '../../util/Utils' import { log } from '../../util/Logger' -import { PromotionalItem, MorePromotion } from '../../interface/DashboardData' - -export async function doThisOrThat(page: Page, data: PromotionalItem | MorePromotion) { +export async function doThisOrThat(page: Page) { return // Todo log('THIS-OR-THAT', 'Trying to complete ThisOrThat') try { - const selector = `[data-bi-id="${data.offerId}"]` - - // Wait for page to load and click to load the this or that quiz in a new tab - await page.waitForSelector(selector, { timeout: 5000 }) - await page.click(selector) - - const thisorthatPage = await getLatestTab(page) - await thisorthatPage.waitForNetworkIdle({ timeout: 5000 }) + await page.waitForNetworkIdle({ timeout: 5000 }) await wait(2000) // Check if the quiz has been started or not - const quizNotStarted = await thisorthatPage.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false) + const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false) if (quizNotStarted) { - await thisorthatPage.click('#rqStartQuiz') + await page.click('#rqStartQuiz') } else { log('THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it') } diff --git a/src/index.ts b/src/index.ts index 09fb72c..b164fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ +import cluster from 'cluster' + import Browser from './browser/Browser' import { getDashboardData, getEarnablePoints, goHome } from './browser/BrowserFunc' import { log } from './util/Logger' import { loadAccounts } from './util/Account' +import { chunkArray } from './util/Utils' import { login } from './functions/Login' import { doDailySet, doMorePromotions, doPunchCard } from './functions/Workers' @@ -9,20 +12,74 @@ import { doSearch } from './functions/activities/Search' import { Account } from './interface/Account' -import { runOnZeroPoints, workers } from './config.json' +import { runOnZeroPoints, workers, clusters } from './config.json' // Main bot class class MicrosoftRewardsBot { + private activeWorkers: number = clusters private collectedPoints: number = 0 private browserFactory: Browser = new Browser() + private accounts: Account[] + + constructor() { + this.accounts = [] + } + + async initialize() { + this.accounts = await loadAccounts() + } async run() { - log('MAIN', 'Bot started') + log('MAIN', `Bot started with ${clusters} clusters`) - const accounts = await loadAccounts() + // Only cluster when there's more than 1 cluster demanded + if (clusters > 1) { + if (cluster.isPrimary) { + this.runMaster() + } else { + this.runWorker() + } + } else { + this.runTasks(this.accounts) + } + } + private runMaster() { + log('MAIN-PRIMARY', 'Primary process started') + + const accountChunks = chunkArray(this.accounts, clusters) + + for (let i = 0; i < accountChunks.length; i++) { + const worker = cluster.fork() + const chunk = accountChunks[i] + worker.send({ chunk }) + } + + cluster.on('exit', (worker, code) => { + this.activeWorkers -= 1 + + log('MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') + + // Check if all workers have exited + if (this.activeWorkers === 0) { + log('MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') + process.exit(0) + } + }) + } + + private runWorker() { + log('MAIN-WORKER', `Worker ${process.pid} spawned`) + + // Receive the chunk of accounts from the master + process.on('message', async ({ chunk }) => { + await this.runTasks(chunk) + }) + } + + private async runTasks(accounts: Account[]) { for (const account of accounts) { - log('MAIN', `Started tasks for account ${account.email}`) + log('MAIN-WORKER', `Started tasks for account ${account.email}`) // Desktop Searches, DailySet and More Promotions await this.Desktop(account) @@ -35,13 +92,11 @@ class MicrosoftRewardsBot { // Mobile Searches await this.Mobile(account) - log('MAIN', `Completed tasks for account ${account.email}`) + log('MAIN-WORKER', `Completed tasks for account ${account.email}`) } - - // Clean exit - log('MAIN', 'Completed tasks for ALL accounts') - log('MAIN', 'Bot exited') + log('MAIN-PRIMARY', 'Completed tasks for ALL accounts') + log('MAIN-PRIMARY', 'All workers destroyed!') process.exit(0) } @@ -99,6 +154,7 @@ class MicrosoftRewardsBot { await browser.close() } + // Mobile async Mobile(account: Account) { const browser = await this.browserFactory.createBrowser(account.email, true) const page = await browser.newPage() @@ -137,4 +193,9 @@ class MicrosoftRewardsBot { } } -new MicrosoftRewardsBot().run() \ No newline at end of file +const bot = new MicrosoftRewardsBot() + +// Initialize accounts first and then start the bot +bot.initialize().then(() => { + bot.run() +}) \ No newline at end of file diff --git a/src/util/Logger.ts b/src/util/Logger.ts index 3d518e2..c01af21 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -7,17 +7,17 @@ export function log(title: string, message: string, type?: 'log' | 'warn' | 'err switch (type) { case 'warn': - str = `[${currentTime}] [WARN] [${title}] ${message}` + str = `[${currentTime}] [PID: ${process.pid}] [WARN] [${title}] ${message}` console.warn(str) break case 'error': - str = `[${currentTime}] [ERROR] [${title}] ${message}` + str = `[${currentTime}] [PID: ${process.pid}] [ERROR] [${title}] ${message}` console.error(str) break default: - str = `[${currentTime}] [LOG] [${title}] ${message}` + str = `[${currentTime}] [PID: ${process.pid}] [LOG] [${title}] ${message}` console.log(str) break } diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 2842ca1..bea4776 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -13,8 +13,7 @@ export function getFormattedDate(ms = Date.now()) { return `${month}/${day}/${year}` } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function shuffleArray(array: any[]): any[] { +export function shuffleArray(array: T[]): T[] { const shuffledArray = array.slice() shuffledArray.sort(() => Math.random() - 0.5) @@ -24,4 +23,16 @@ export function shuffleArray(array: any[]): any[] { export function randomNumber(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min +} + +export function chunkArray(arr: T[], numChunks: number): T[][] { + const chunkSize = Math.ceil(arr.length / numChunks) + const chunks: T[][] = [] + + for (let i = 0; i < arr.length; i += chunkSize) { + const chunk = arr.slice(i, i + chunkSize) + chunks.push(chunk) + } + + return chunks } \ No newline at end of file