mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 19:06:18 +00:00
feat: Add conclusion webhook support for final summary notifications (#355)
- Updated README.md to include new configuration options for conclusion webhook. - Enhanced BrowserFunc.ts with improved error handling during page reloads. - Implemented conclusionWebhook configuration in config.json. - Refactored Login.ts to use Playwright types and improved passkey handling. - Added safeClick method in SearchOnBing.ts to handle click timeouts and overlays. - Introduced account summary collection in index.ts for reporting. - Created ConclusionWebhook.ts to send structured summaries to a dedicated webhook. - Updated TypeScript definitions for better type safety across the project.
This commit is contained in:
@@ -82,6 +82,8 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r
|
|||||||
| proxy.proxyBingTerms | Enable or disable proxying the request via set proxy | `true` (will be proxied) |
|
| proxy.proxyBingTerms | Enable or disable proxying the request via set proxy | `true` (will be proxied) |
|
||||||
| webhook.enabled | Enable or disable your set webhook | `false` |
|
| webhook.enabled | Enable or disable your set webhook | `false` |
|
||||||
| webhook.url | Your Discord webhook URL | `null` |
|
| webhook.url | Your Discord webhook URL | `null` |
|
||||||
|
| conclusionWebhook.enabled | Enable or disable the final summary dedicated webhook | `false` |
|
||||||
|
| conclusionWebhook.url | Discord webhook URL used ONLY for the end summary | `null` |
|
||||||
|
|
||||||
## Features ##
|
## Features ##
|
||||||
- [x] Multi-Account Support
|
- [x] Multi-Account Support
|
||||||
@@ -90,6 +92,7 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r
|
|||||||
- [x] Passwordless Support
|
- [x] Passwordless Support
|
||||||
- [x] Headless Support
|
- [x] Headless Support
|
||||||
- [x] Discord Webhook Support
|
- [x] Discord Webhook Support
|
||||||
|
- [x] Final Summary Webhook (dedicated optional)
|
||||||
- [x] Desktop Searches
|
- [x] Desktop Searches
|
||||||
- [x] Configurable Tasks
|
- [x] Configurable Tasks
|
||||||
- [x] Microsoft Edge Searches
|
- [x] Microsoft Edge Searches
|
||||||
|
|||||||
@@ -92,9 +92,32 @@ export default class BrowserFunc {
|
|||||||
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||||
await this.goHome(this.bot.homePage)
|
await this.goHome(this.bot.homePage)
|
||||||
}
|
}
|
||||||
|
let lastError: any = null
|
||||||
// Reload the page to get new data
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' })
|
try {
|
||||||
|
// Reload the page to get new data
|
||||||
|
await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' })
|
||||||
|
lastError = null
|
||||||
|
break
|
||||||
|
} catch (re) {
|
||||||
|
lastError = re
|
||||||
|
const msg = (re instanceof Error ? re.message : String(re))
|
||||||
|
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
|
||||||
|
// If page/context closed => bail early after first retry
|
||||||
|
if (msg.includes('has been closed')) {
|
||||||
|
if (attempt === 1) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
|
||||||
|
try {
|
||||||
|
await this.goHome(this.bot.homePage)
|
||||||
|
} catch {/* ignore */}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attempt === 2 && lastError) throw lastError
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const scriptContent = await this.bot.homePage.evaluate(() => {
|
const scriptContent = await this.bot.homePage.evaluate(() => {
|
||||||
const scripts = Array.from(document.querySelectorAll('script'))
|
const scripts = Array.from(document.querySelectorAll('script'))
|
||||||
@@ -108,7 +131,7 @@ export default class BrowserFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract the dashboard object from the script content
|
// Extract the dashboard object from the script content
|
||||||
const dashboardData = await this.bot.homePage.evaluate(scriptContent => {
|
const dashboardData = await this.bot.homePage.evaluate((scriptContent: string) => {
|
||||||
// Extract the dashboard object using regex
|
// Extract the dashboard object using regex
|
||||||
const regex = /var dashboard = (\{.*?\});/s
|
const regex = /var dashboard = (\{.*?\});/s
|
||||||
const match = regex.exec(scriptContent)
|
const match = regex.exec(scriptContent)
|
||||||
@@ -272,7 +295,7 @@ export default class BrowserFunc {
|
|||||||
const html = await page.content()
|
const html = await page.content()
|
||||||
const $ = load(html)
|
const $ = load(html)
|
||||||
|
|
||||||
const scriptContent = $('script').filter((index, element) => {
|
const scriptContent = $('script').filter((index: number, element: any) => {
|
||||||
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
||||||
}).text()
|
}).text()
|
||||||
|
|
||||||
@@ -332,7 +355,7 @@ export default class BrowserFunc {
|
|||||||
const html = await page.content()
|
const html = await page.content()
|
||||||
const $ = load(html)
|
const $ = load(html)
|
||||||
|
|
||||||
const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId))
|
const element = $('.offer-cta').toArray().find((x: any) => x.attribs.href?.includes(activity.offerId))
|
||||||
if (element) {
|
if (element) {
|
||||||
selector = `a[href*="${element.attribs.href}"]`
|
selector = `a[href*="${element.attribs.href}"]`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ export default class BrowserUtil {
|
|||||||
// Silent fail
|
// Silent fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle blocking Bing privacy overlay intercepting clicks (#bnp_overlay_wrapper)
|
||||||
|
try {
|
||||||
|
const overlay = await page.locator('#bnp_overlay_wrapper').first()
|
||||||
|
if (await overlay.isVisible({ timeout: 500 }).catch(()=>false)) {
|
||||||
|
// Try common dismiss buttons inside overlay
|
||||||
|
const rejectBtn = await page.locator('#bnp_btn_reject, button[aria-label*="Reject" i]').first()
|
||||||
|
const acceptBtn = await page.locator('#bnp_btn_accept').first()
|
||||||
|
if (await rejectBtn.isVisible().catch(()=>false)) {
|
||||||
|
await rejectBtn.click({ timeout: 500 }).catch(()=>{})
|
||||||
|
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject')
|
||||||
|
} else if (await acceptBtn.isVisible().catch(()=>false)) {
|
||||||
|
await acceptBtn.click({ timeout: 500 }).catch(()=>{})
|
||||||
|
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Accept (fallback)')
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLatestTab(page: Page): Promise<Page> {
|
async getLatestTab(page: Page): Promise<Page> {
|
||||||
|
|||||||
@@ -43,5 +43,9 @@
|
|||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"url": ""
|
"url": ""
|
||||||
|
},
|
||||||
|
"conclusionWebhook": {
|
||||||
|
"enabled": false,
|
||||||
|
"url": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Page } from 'rebrowser-playwright'
|
import type { Page } from 'playwright'
|
||||||
import readline from 'readline'
|
import readline from 'readline'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import { AxiosRequestConfig } from 'axios'
|
import { AxiosRequestConfig } from 'axios'
|
||||||
@@ -10,8 +10,9 @@ import { OAuth } from '../interface/OAuth'
|
|||||||
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
// Use as any to avoid strict typing issues with our minimal process shim
|
||||||
output: process.stdout
|
input: (process as any).stdin,
|
||||||
|
output: (process as any).stdout
|
||||||
})
|
})
|
||||||
|
|
||||||
export class Login {
|
export class Login {
|
||||||
@@ -21,6 +22,8 @@ export class Login {
|
|||||||
private redirectUrl: string = 'https://login.live.com/oauth20_desktop.srf'
|
private redirectUrl: string = 'https://login.live.com/oauth20_desktop.srf'
|
||||||
private tokenUrl: string = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
|
private tokenUrl: string = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
|
||||||
private scope: string = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
|
private scope: string = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
|
||||||
|
// Flag to prevent spamming passkey logs after first handling
|
||||||
|
private passkeyHandled: boolean = false
|
||||||
|
|
||||||
constructor(bot: MicrosoftRewardsBot) {
|
constructor(bot: MicrosoftRewardsBot) {
|
||||||
this.bot = bot
|
this.bot = bot
|
||||||
@@ -35,7 +38,7 @@ export class Login {
|
|||||||
await page.goto('https://rewards.bing.com/signin')
|
await page.goto('https://rewards.bing.com/signin')
|
||||||
|
|
||||||
// Disable FIDO support in login request
|
// Disable FIDO support in login request
|
||||||
await page.route('**/GetCredentialType.srf*', (route) => {
|
await page.route('**/GetCredentialType.srf*', (route: any) => {
|
||||||
const body = JSON.parse(route.request().postData() || '{}')
|
const body = JSON.parse(route.request().postData() || '{}')
|
||||||
body.isFidoSupported = false
|
body.isFidoSupported = false
|
||||||
route.continue({ postData: JSON.stringify(body) })
|
route.continue({ postData: JSON.stringify(body) })
|
||||||
@@ -265,7 +268,7 @@ export class Login {
|
|||||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'SMS 2FA code required. Waiting for user input...')
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'SMS 2FA code required. Waiting for user input...')
|
||||||
|
|
||||||
const code = await new Promise<string>((resolve) => {
|
const code = await new Promise<string>((resolve) => {
|
||||||
rl.question('Enter 2FA code:\n', (input) => {
|
rl.question('Enter 2FA code:\n', (input: string) => {
|
||||||
rl.close()
|
rl.close()
|
||||||
resolve(input)
|
resolve(input)
|
||||||
})
|
})
|
||||||
@@ -287,21 +290,32 @@ export class Login {
|
|||||||
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
||||||
authorizeUrl.searchParams.append('login_hint', email)
|
authorizeUrl.searchParams.append('login_hint', email)
|
||||||
|
|
||||||
|
// Disable FIDO for OAuth flow as well (reduces passkey prompts resurfacing)
|
||||||
|
await page.route('**/GetCredentialType.srf*', (route: any) => {
|
||||||
|
const body = JSON.parse(route.request().postData() || '{}')
|
||||||
|
body.isFidoSupported = false
|
||||||
|
route.continue({ postData: JSON.stringify(body) })
|
||||||
|
}).catch(()=>{})
|
||||||
|
|
||||||
await page.goto(authorizeUrl.href)
|
await page.goto(authorizeUrl.href)
|
||||||
|
|
||||||
let currentUrl = new URL(page.url())
|
let currentUrl = new URL(page.url())
|
||||||
let code: string
|
let code: string
|
||||||
|
|
||||||
|
const authStart = Date.now()
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Waiting for authorization...')
|
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Waiting for authorization...')
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
|
// Attempt to dismiss passkey/passkey-like screens quickly (non-blocking)
|
||||||
|
await this.tryDismissPasskeyPrompt(page)
|
||||||
if (currentUrl.hostname === 'login.live.com' && currentUrl.pathname === '/oauth20_desktop.srf') {
|
if (currentUrl.hostname === 'login.live.com' && currentUrl.pathname === '/oauth20_desktop.srf') {
|
||||||
code = currentUrl.searchParams.get('code')!
|
code = currentUrl.searchParams.get('code')!
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUrl = new URL(page.url())
|
currentUrl = new URL(page.url())
|
||||||
await this.bot.utils.wait(5000)
|
// Shorter wait to react faster to passkey prompt
|
||||||
|
await this.bot.utils.wait(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams()
|
const body = new URLSearchParams()
|
||||||
@@ -322,7 +336,8 @@ export class Login {
|
|||||||
const tokenResponse = await this.bot.axios.request(tokenRequest)
|
const tokenResponse = await this.bot.axios.request(tokenRequest)
|
||||||
const tokenData: OAuth = await tokenResponse.data
|
const tokenData: OAuth = await tokenResponse.data
|
||||||
|
|
||||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Successfully authorized')
|
const authDuration = Date.now() - authStart
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Successfully authorized in ${Math.round(authDuration/1000)}s`)
|
||||||
return tokenData.access_token
|
return tokenData.access_token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,70 +361,141 @@ export class Login {
|
|||||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Successfully logged into the rewards portal')
|
this.bot.log(this.bot.isMobile, 'LOGIN', 'Successfully logged into the rewards portal')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lastNoPromptLog: number = 0
|
||||||
|
private noPromptIterations: number = 0
|
||||||
private async dismissLoginMessages(page: Page) {
|
private async dismissLoginMessages(page: Page) {
|
||||||
// Passkey / Windows Hello prompt ("Sign in faster"), click "Skip for now"
|
let didSomething = false
|
||||||
// Primary heuristics: presence of biometric video OR title mentions passkey/sign in faster
|
|
||||||
const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 2000 }).catch(() => null)
|
// PASSKEY / Windows Hello / Sign in faster
|
||||||
let handledPasskey = false
|
const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 1000 }).catch(() => null)
|
||||||
if (passkeyVideo) {
|
if (passkeyVideo) {
|
||||||
const skipButton = await page.$('[data-testid="secondaryButton"]')
|
const skipButton = await page.$('button[data-testid="secondaryButton"]')
|
||||||
if (skipButton) {
|
if (skipButton) {
|
||||||
await skipButton.click()
|
await skipButton.click().catch(()=>{})
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Use Passkey" modal via data-testid=secondaryButton')
|
if (!this.passkeyHandled) {
|
||||||
await page.waitForTimeout(500)
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog detected (video heuristic) -> clicked "Skip for now"')
|
||||||
handledPasskey = true
|
}
|
||||||
|
this.passkeyHandled = true
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
didSomething = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!didSomething) {
|
||||||
if (!handledPasskey) {
|
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 800 }).catch(() => null)
|
||||||
// Fallback heuristics: title text or presence of primary+secondary buttons typical of the passkey screen
|
|
||||||
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 1000 }).catch(() => null)
|
|
||||||
const titleText = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
|
const titleText = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
|
||||||
const looksLikePasskeyTitle = /sign in faster|passkey/i.test(titleText)
|
const looksLikePasskey = /sign in faster|passkey|fingerprint|face|pin/i.test(titleText)
|
||||||
|
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
|
||||||
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 1000 }).catch(() => null)
|
const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 500 }).catch(() => null)
|
||||||
const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 1000 }).catch(() => null)
|
if (looksLikePasskey && secondaryBtn) {
|
||||||
|
await secondaryBtn.click().catch(()=>{})
|
||||||
if (looksLikePasskeyTitle && secondaryBtn) {
|
if (!this.passkeyHandled) {
|
||||||
await secondaryBtn.click()
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey dialog detected (title: "${titleText}") -> clicked secondary`)
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by title + secondaryButton')
|
}
|
||||||
await page.waitForTimeout(500)
|
this.passkeyHandled = true
|
||||||
handledPasskey = true
|
await page.waitForTimeout(300)
|
||||||
} else if (secondaryBtn && primaryBtn) {
|
didSomething = true
|
||||||
// If both buttons are visible (Next + Skip for now), prefer the secondary (Skip for now)
|
} else if (!didSomething && secondaryBtn && primaryBtn) {
|
||||||
await secondaryBtn.click()
|
const secText = (await secondaryBtn.textContent() || '').trim()
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by button pair heuristic')
|
if (/skip for now/i.test(secText)) {
|
||||||
await page.waitForTimeout(500)
|
await secondaryBtn.click().catch(()=>{})
|
||||||
handledPasskey = true
|
if (!this.passkeyHandled) {
|
||||||
} else if (!handledPasskey) {
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (pair heuristic) -> clicked secondary (Skip for now)')
|
||||||
// Last-resort fallbacks by text and close icon
|
|
||||||
const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first()
|
|
||||||
if (await skipByText.isVisible().catch(() => false)) {
|
|
||||||
await skipByText.click()
|
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen via text fallback')
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
handledPasskey = true
|
|
||||||
} else {
|
|
||||||
const closeBtn = await page.$('#close-button')
|
|
||||||
if (closeBtn) {
|
|
||||||
await closeBtn.click().catch(() => { })
|
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Attempted to close Passkey screen via close button')
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
}
|
}
|
||||||
|
this.passkeyHandled = true
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
didSomething = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!didSomething) {
|
||||||
|
const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first()
|
||||||
|
if (await skipByText.isVisible().catch(()=>false)) {
|
||||||
|
await skipByText.click().catch(()=>{})
|
||||||
|
if (!this.passkeyHandled) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (text fallback) -> clicked "Skip for now"')
|
||||||
|
}
|
||||||
|
this.passkeyHandled = true
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
didSomething = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!didSomething) {
|
||||||
|
const closeBtn = await page.$('#close-button')
|
||||||
|
if (closeBtn) {
|
||||||
|
await closeBtn.click().catch(()=>{})
|
||||||
|
if (!this.passkeyHandled) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Attempted close button on potential passkey modal')
|
||||||
|
}
|
||||||
|
this.passkeyHandled = true
|
||||||
|
await page.waitForTimeout(300)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Keep me signed in
|
// KMSI (Keep me signed in) prompt
|
||||||
if (await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 2000 }).catch(() => null)) {
|
const kmsi = await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 800 }).catch(()=>null)
|
||||||
const yesButton = await page.$('[data-testid="primaryButton"]')
|
if (kmsi) {
|
||||||
|
const yesButton = await page.$('button[data-testid="primaryButton"]')
|
||||||
if (yesButton) {
|
if (yesButton) {
|
||||||
await yesButton.click()
|
await yesButton.click().catch(()=>{})
|
||||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Keep me signed in" modal')
|
this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'KMSI dialog detected -> accepted (Yes)')
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(300)
|
||||||
|
didSomething = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!didSomething) {
|
||||||
|
this.noPromptIterations++
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.noPromptIterations === 1 || (now - this.lastNoPromptLog) > 10000) {
|
||||||
|
this.lastNoPromptLog = now
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
|
||||||
|
// Reset counter if it grows large to keep number meaningful
|
||||||
|
if (this.noPromptIterations > 50) this.noPromptIterations = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset counters after an interaction
|
||||||
|
this.noPromptIterations = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight passkey prompt dismissal used in mobile OAuth loop */
|
||||||
|
private async tryDismissPasskeyPrompt(page: Page) {
|
||||||
|
try {
|
||||||
|
// Fast existence checks with very small timeouts to avoid slowing the loop
|
||||||
|
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 500 }).catch(() => null)
|
||||||
|
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
|
||||||
|
// Direct text locator fallback (sometimes data-testid changes)
|
||||||
|
const textSkip = secondaryBtn ? null : await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first().isVisible().catch(()=>false)
|
||||||
|
if (secondaryBtn) {
|
||||||
|
// Heuristic: if title indicates passkey or both primary/secondary exist with typical text
|
||||||
|
let shouldClick = false
|
||||||
|
let titleText = ''
|
||||||
|
if (titleEl) {
|
||||||
|
titleText = (await titleEl.textContent() || '').trim()
|
||||||
|
if (/sign in faster|passkey|fingerprint|face|pin/i.test(titleText)) {
|
||||||
|
shouldClick = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldClick && textSkip) {
|
||||||
|
shouldClick = true
|
||||||
|
}
|
||||||
|
if (!shouldClick) {
|
||||||
|
// Fallback text probe on the secondary button itself
|
||||||
|
const btnText = (await secondaryBtn.textContent() || '').trim()
|
||||||
|
if (/skip for now/i.test(btnText)) {
|
||||||
|
shouldClick = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldClick) {
|
||||||
|
await secondaryBtn.click().catch(() => { })
|
||||||
|
if (!this.passkeyHandled) {
|
||||||
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey prompt (loop) -> clicked skip${titleText ? ` (title: ${titleText})` : ''}`)
|
||||||
|
}
|
||||||
|
this.passkeyHandled = true
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore minor errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkBingLogin(page: Page): Promise<void> {
|
private async checkBingLogin(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class Quiz extends Workers {
|
|||||||
|
|
||||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
||||||
const answerAttribute = await answerSelector?.evaluate(el => el.getAttribute('iscorrectoption'))
|
const answerAttribute = await answerSelector?.evaluate((el: any) => el.getAttribute('iscorrectoption'))
|
||||||
|
|
||||||
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
||||||
answers.push(`#rqAnswerOption${i}`)
|
answers.push(`#rqAnswerOption${i}`)
|
||||||
@@ -60,7 +60,7 @@ export class Quiz extends Workers {
|
|||||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||||
|
|
||||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
||||||
const dataOption = await answerSelector?.evaluate(el => el.getAttribute('data-option'))
|
const dataOption = await answerSelector?.evaluate((el: any) => el.getAttribute('data-option'))
|
||||||
|
|
||||||
if (dataOption === correctOption) {
|
if (dataOption === correctOption) {
|
||||||
// Click the answer on page
|
// Click the answer on page
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export class Search extends Workers {
|
|||||||
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
||||||
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
||||||
|
|
||||||
await page.evaluate((scrollPos) => {
|
await page.evaluate((scrollPos: number) => {
|
||||||
window.scrollTo(0, scrollPos)
|
window.scrollTo(0, scrollPos)
|
||||||
}, randomScrollPosition)
|
}, randomScrollPosition)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Page } from 'rebrowser-playwright'
|
import type { Page } from 'playwright'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export class SearchOnBing extends Workers {
|
|||||||
|
|
||||||
const searchBar = '#sb_form_q'
|
const searchBar = '#sb_form_q'
|
||||||
await page.waitForSelector(searchBar, { state: 'visible', timeout: 10000 })
|
await page.waitForSelector(searchBar, { state: 'visible', timeout: 10000 })
|
||||||
await page.click(searchBar)
|
await this.safeClick(page, searchBar)
|
||||||
await this.bot.utils.wait(500)
|
await this.bot.utils.wait(500)
|
||||||
await page.keyboard.type(query)
|
await page.keyboard.type(query)
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
@@ -36,6 +36,22 @@ export class SearchOnBing extends Workers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async safeClick(page: Page, selector: string) {
|
||||||
|
try {
|
||||||
|
await page.click(selector, { timeout: 5000 })
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = (e?.message || '')
|
||||||
|
if (/Timeout.*click/i.test(msg) || /intercepts pointer events/i.test(msg)) {
|
||||||
|
// Try to dismiss overlays then retry once
|
||||||
|
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||||
|
await this.bot.utils.wait(500)
|
||||||
|
await page.click(selector, { timeout: 5000 })
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getSearchQuery(title: string): Promise<string> {
|
private async getSearchQuery(title: string): Promise<string> {
|
||||||
interface Queries {
|
interface Queries {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
247
src/index.ts
247
src/index.ts
@@ -1,5 +1,6 @@
|
|||||||
import cluster from 'cluster'
|
import cluster from 'cluster'
|
||||||
import { Page } from 'rebrowser-playwright'
|
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
|
||||||
|
import type { Page } from 'playwright'
|
||||||
|
|
||||||
import Browser from './browser/Browser'
|
import Browser from './browser/Browser'
|
||||||
import BrowserFunc from './browser/BrowserFunc'
|
import BrowserFunc from './browser/BrowserFunc'
|
||||||
@@ -15,6 +16,8 @@ import Activities from './functions/Activities'
|
|||||||
|
|
||||||
import { Account } from './interface/Account'
|
import { Account } from './interface/Account'
|
||||||
import Axios from './util/Axios'
|
import Axios from './util/Axios'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
|
||||||
// Main bot class
|
// Main bot class
|
||||||
@@ -41,6 +44,9 @@ export class MicrosoftRewardsBot {
|
|||||||
private login = new Login(this)
|
private login = new Login(this)
|
||||||
private accessToken: string = ''
|
private accessToken: string = ''
|
||||||
|
|
||||||
|
// Summary collection (per process)
|
||||||
|
private accountSummaries: AccountSummary[] = []
|
||||||
|
|
||||||
//@ts-expect-error Will be initialized later
|
//@ts-expect-error Will be initialized later
|
||||||
public axios: Axios
|
public axios: Axios
|
||||||
|
|
||||||
@@ -65,6 +71,7 @@ export class MicrosoftRewardsBot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
|
this.printBanner()
|
||||||
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||||
|
|
||||||
// Only cluster when there's more than 1 cluster demanded
|
// Only cluster when there's more than 1 cluster demanded
|
||||||
@@ -79,6 +86,37 @@ export class MicrosoftRewardsBot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private printBanner() {
|
||||||
|
// Only print once (primary process or single cluster execution)
|
||||||
|
if (this.config.clusters > 1 && !cluster.isPrimary) return
|
||||||
|
try {
|
||||||
|
const pkgPath = path.join(__dirname, '../', 'package.json')
|
||||||
|
let version = 'unknown'
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
const raw = fs.readFileSync(pkgPath, 'utf-8')
|
||||||
|
const pkg = JSON.parse(raw)
|
||||||
|
version = pkg.version || version
|
||||||
|
}
|
||||||
|
const banner = [
|
||||||
|
' __ __ _____ _____ _ ',
|
||||||
|
' | \/ |/ ____| | __ \\ | | ',
|
||||||
|
' | \ / | (___ ______| |__) |_____ ____ _ _ __ __| |___ ',
|
||||||
|
' | |\/| |\\___ \\______| _ // _ \\ \\ /\\ / / _` | \'__/ _` / __|',
|
||||||
|
' | | | |____) | | | \\ \\ __/ \\ V V / (_| | | | (_| \\__ \\',
|
||||||
|
' |_| |_|_____/ |_| \\_\\___| \\_/\\_/ \\__,_|_| \\__,_|___/',
|
||||||
|
'',
|
||||||
|
` Version: v${version}`,
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
console.log(banner)
|
||||||
|
} catch { /* ignore banner errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return summaries (used when clusters==1)
|
||||||
|
public getSummaries() {
|
||||||
|
return this.accountSummaries
|
||||||
|
}
|
||||||
|
|
||||||
private runMaster() {
|
private runMaster() {
|
||||||
log('main', 'MAIN-PRIMARY', 'Primary process started')
|
log('main', 'MAIN-PRIMARY', 'Primary process started')
|
||||||
|
|
||||||
@@ -87,18 +125,27 @@ export class MicrosoftRewardsBot {
|
|||||||
for (let i = 0; i < accountChunks.length; i++) {
|
for (let i = 0; i < accountChunks.length; i++) {
|
||||||
const worker = cluster.fork()
|
const worker = cluster.fork()
|
||||||
const chunk = accountChunks[i]
|
const chunk = accountChunks[i]
|
||||||
worker.send({ chunk })
|
;(worker as any).send?.({ chunk })
|
||||||
|
// Collect summaries from workers
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
if (msg && msg.type === 'summary' && Array.isArray(msg.data)) {
|
||||||
|
this.accountSummaries.push(...msg.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
cluster.on('exit', (worker, code) => {
|
cluster.on('exit', (worker: any, code: number) => {
|
||||||
this.activeWorkers -= 1
|
this.activeWorkers -= 1
|
||||||
|
|
||||||
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
||||||
|
|
||||||
// Check if all workers have exited
|
// Check if all workers have exited
|
||||||
if (this.activeWorkers === 0) {
|
if (this.activeWorkers === 0) {
|
||||||
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
// All workers done -> send conclusion (if enabled) then exit
|
||||||
process.exit(0)
|
this.sendConclusion(this.accountSummaries).finally(() => {
|
||||||
|
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -106,7 +153,7 @@ export class MicrosoftRewardsBot {
|
|||||||
private runWorker() {
|
private runWorker() {
|
||||||
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
|
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
|
||||||
// Receive the chunk of accounts from the master
|
// Receive the chunk of accounts from the master
|
||||||
process.on('message', async ({ chunk }) => {
|
;(process as any).on('message', async ({ chunk }: { chunk: Account[] }) => {
|
||||||
await this.runTasks(chunk)
|
await this.runTasks(chunk)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -115,29 +162,74 @@ export class MicrosoftRewardsBot {
|
|||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||||
|
|
||||||
|
const accountStart = Date.now()
|
||||||
|
let desktopInitial = 0
|
||||||
|
let mobileInitial = 0
|
||||||
|
let desktopCollected = 0
|
||||||
|
let mobileCollected = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
this.axios = new Axios(account.proxy)
|
this.axios = new Axios(account.proxy)
|
||||||
if (this.config.parallel) {
|
if (this.config.parallel) {
|
||||||
await Promise.all([
|
const mobileInstance = new MicrosoftRewardsBot(true)
|
||||||
this.Desktop(account),
|
mobileInstance.axios = this.axios
|
||||||
(() => {
|
// Run both and capture results
|
||||||
const mobileInstance = new MicrosoftRewardsBot(true)
|
const [desktopResult, mobileResult] = await Promise.all([
|
||||||
mobileInstance.axios = this.axios
|
this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null }),
|
||||||
|
mobileInstance.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null })
|
||||||
return mobileInstance.Mobile(account)
|
|
||||||
})()
|
|
||||||
])
|
])
|
||||||
|
if (desktopResult) {
|
||||||
|
desktopInitial = desktopResult.initialPoints
|
||||||
|
desktopCollected = desktopResult.collectedPoints
|
||||||
|
}
|
||||||
|
if (mobileResult) {
|
||||||
|
mobileInitial = mobileResult.initialPoints
|
||||||
|
mobileCollected = mobileResult.collectedPoints
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.isMobile = false
|
this.isMobile = false
|
||||||
await this.Desktop(account)
|
const desktopResult = await this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null })
|
||||||
|
if (desktopResult) {
|
||||||
|
desktopInitial = desktopResult.initialPoints
|
||||||
|
desktopCollected = desktopResult.collectedPoints
|
||||||
|
}
|
||||||
|
|
||||||
this.isMobile = true
|
this.isMobile = true
|
||||||
await this.Mobile(account)
|
const mobileResult = await this.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null })
|
||||||
|
if (mobileResult) {
|
||||||
|
mobileInitial = mobileResult.initialPoints
|
||||||
|
mobileCollected = mobileResult.collectedPoints
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountEnd = Date.now()
|
||||||
|
const durationMs = accountEnd - accountStart
|
||||||
|
const totalCollected = desktopCollected + mobileCollected
|
||||||
|
const initialTotal = (desktopInitial || 0) + (mobileInitial || 0)
|
||||||
|
this.accountSummaries.push({
|
||||||
|
email: account.email,
|
||||||
|
durationMs,
|
||||||
|
desktopCollected,
|
||||||
|
mobileCollected,
|
||||||
|
totalCollected,
|
||||||
|
initialTotal,
|
||||||
|
endTotal: initialTotal + totalCollected,
|
||||||
|
errors
|
||||||
|
})
|
||||||
|
|
||||||
log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
|
log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
|
||||||
}
|
}
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
|
log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
|
||||||
|
// If in worker mode (clusters>1) send summaries to primary
|
||||||
|
if (this.config.clusters > 1 && !cluster.isPrimary) {
|
||||||
|
if (process.send) {
|
||||||
|
process.send({ type: 'summary', data: this.accountSummaries })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single process mode -> build and send conclusion directly
|
||||||
|
await this.sendConclusion(this.accountSummaries)
|
||||||
|
}
|
||||||
process.exit()
|
process.exit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +247,8 @@ export class MicrosoftRewardsBot {
|
|||||||
|
|
||||||
const data = await this.browser.func.getDashboardData()
|
const data = await this.browser.func.getDashboardData()
|
||||||
|
|
||||||
this.pointsInitial = data.userStatus.availablePoints
|
this.pointsInitial = data.userStatus.availablePoints
|
||||||
|
const initial = this.pointsInitial
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`)
|
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`)
|
||||||
|
|
||||||
@@ -206,9 +299,14 @@ export class MicrosoftRewardsBot {
|
|||||||
// Save cookies
|
// Save cookies
|
||||||
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
|
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
|
||||||
|
|
||||||
|
// Fetch points BEFORE closing (avoid page closed reload error)
|
||||||
|
const after = await this.browser.func.getCurrentPoints().catch(()=>initial)
|
||||||
// Close desktop browser
|
// Close desktop browser
|
||||||
await this.browser.func.closeBrowser(browser, account.email)
|
await this.browser.func.closeBrowser(browser, account.email)
|
||||||
return
|
return {
|
||||||
|
initialPoints: initial,
|
||||||
|
collectedPoints: (after - initial) || 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile
|
// Mobile
|
||||||
@@ -224,7 +322,8 @@ export class MicrosoftRewardsBot {
|
|||||||
|
|
||||||
await this.browser.func.goHome(this.homePage)
|
await this.browser.func.goHome(this.homePage)
|
||||||
|
|
||||||
const data = await this.browser.func.getDashboardData()
|
const data = await this.browser.func.getDashboardData()
|
||||||
|
const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0
|
||||||
|
|
||||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
||||||
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
|
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
|
||||||
@@ -239,9 +338,11 @@ export class MicrosoftRewardsBot {
|
|||||||
|
|
||||||
// Close mobile browser
|
// Close mobile browser
|
||||||
await this.browser.func.closeBrowser(browser, account.email)
|
await this.browser.func.closeBrowser(browser, account.email)
|
||||||
return
|
return {
|
||||||
|
initialPoints: initialPoints,
|
||||||
|
collectedPoints: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do daily check in
|
// Do daily check in
|
||||||
if (this.config.workers.doDailyCheckIn) {
|
if (this.config.workers.doDailyCheckIn) {
|
||||||
await this.activities.doDailyCheckIn(this.accessToken, data)
|
await this.activities.doDailyCheckIn(this.accessToken, data)
|
||||||
@@ -292,13 +393,113 @@ export class MicrosoftRewardsBot {
|
|||||||
|
|
||||||
const afterPointAmount = await this.browser.func.getCurrentPoints()
|
const afterPointAmount = await this.browser.func.getCurrentPoints()
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - this.pointsInitial} points today`)
|
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
|
||||||
|
|
||||||
// Close mobile browser
|
// Close mobile browser
|
||||||
await this.browser.func.closeBrowser(browser, account.email)
|
await this.browser.func.closeBrowser(browser, account.email)
|
||||||
return
|
return {
|
||||||
|
initialPoints: initialPoints,
|
||||||
|
collectedPoints: (afterPointAmount - initialPoints) || 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendConclusion(summaries: AccountSummary[]) {
|
||||||
|
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||||
|
const cfg = this.config
|
||||||
|
if (!cfg.conclusionWebhook || !cfg.conclusionWebhook.enabled) return
|
||||||
|
|
||||||
|
const totalAccounts = summaries.length
|
||||||
|
if (totalAccounts === 0) return
|
||||||
|
|
||||||
|
let totalCollected = 0
|
||||||
|
let totalInitial = 0
|
||||||
|
let totalEnd = 0
|
||||||
|
let totalDuration = 0
|
||||||
|
let accountsWithErrors = 0
|
||||||
|
|
||||||
|
const accountFields: any[] = []
|
||||||
|
for (const s of summaries) {
|
||||||
|
totalCollected += s.totalCollected
|
||||||
|
totalInitial += s.initialTotal
|
||||||
|
totalEnd += s.endTotal
|
||||||
|
totalDuration += s.durationMs
|
||||||
|
if (s.errors.length) accountsWithErrors++
|
||||||
|
|
||||||
|
const statusEmoji = s.errors.length ? '⚠️' : '✅'
|
||||||
|
const diff = s.totalCollected
|
||||||
|
const duration = formatDuration(s.durationMs)
|
||||||
|
const valueLines: string[] = [
|
||||||
|
`Points: ${s.initialTotal} → ${s.endTotal} ( +${diff} )`,
|
||||||
|
`Breakdown: 🖥️ ${s.desktopCollected} | 📱 ${s.mobileCollected}`,
|
||||||
|
`Duration: ⏱️ ${duration}`
|
||||||
|
]
|
||||||
|
if (s.errors.length) {
|
||||||
|
valueLines.push(`Errors: ${s.errors.slice(0,2).join(' | ')}`)
|
||||||
|
}
|
||||||
|
accountFields.push({
|
||||||
|
name: `${statusEmoji} ${s.email}`.substring(0, 256),
|
||||||
|
value: valueLines.join('\n').substring(0, 1024),
|
||||||
|
inline: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgDuration = totalDuration / totalAccounts
|
||||||
|
const embed = {
|
||||||
|
title: '🎯 Microsoft Rewards Summary',
|
||||||
|
description: `Processed **${totalAccounts}** account(s)${accountsWithErrors ? ` • ${accountsWithErrors} with issues` : ''}`,
|
||||||
|
color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Global Totals',
|
||||||
|
value: [
|
||||||
|
`Total Points: ${totalInitial} → ${totalEnd} ( +${totalCollected} )`,
|
||||||
|
`Average Duration: ${formatDuration(avgDuration)}`,
|
||||||
|
`Cumulative Runtime: ${formatDuration(totalDuration)}`
|
||||||
|
].join('\n')
|
||||||
|
},
|
||||||
|
...accountFields
|
||||||
|
].slice(0, 25), // Discord max 25 fields
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
footer: {
|
||||||
|
text: 'Script conclusion webhook'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback plain text (rare) & embed send
|
||||||
|
const fallback = `Microsoft Rewards Summary\nAccounts: ${totalAccounts}\nTotal: ${totalInitial} -> ${totalEnd} (+${totalCollected})\nRuntime: ${formatDuration(totalDuration)}`
|
||||||
|
await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountSummary {
|
||||||
|
email: string
|
||||||
|
durationMs: number
|
||||||
|
desktopCollected: number
|
||||||
|
mobileCollected: number
|
||||||
|
totalCollected: number
|
||||||
|
initialTotal: number
|
||||||
|
endTotal: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortErr(e: any): string {
|
||||||
|
if (!e) return 'unknown'
|
||||||
|
if (e instanceof Error) return e.message.substring(0, 120)
|
||||||
|
const s = String(e)
|
||||||
|
return s.substring(0, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (!ms || ms < 1000) return `${ms}ms`
|
||||||
|
const sec = Math.floor(ms / 1000)
|
||||||
|
const h = Math.floor(sec / 3600)
|
||||||
|
const m = Math.floor((sec % 3600) / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
const parts: string[] = []
|
||||||
|
if (h) parts.push(`${h}h`)
|
||||||
|
if (m) parts.push(`${m}m`)
|
||||||
|
if (s) parts.push(`${s}s`)
|
||||||
|
return parts.join(' ') || `${ms}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Config {
|
|||||||
webhookLogExcludeFunc: string[];
|
webhookLogExcludeFunc: string[];
|
||||||
proxy: ConfigProxy;
|
proxy: ConfigProxy;
|
||||||
webhook: ConfigWebhook;
|
webhook: ConfigWebhook;
|
||||||
|
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigSaveFingerprint {
|
export interface ConfigSaveFingerprint {
|
||||||
|
|||||||
78
src/types/node-shim.d.ts
vendored
Normal file
78
src/types/node-shim.d.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Fallback in case @types/node not installed yet; ensures Process/stubs to reduce red squiggles.
|
||||||
|
// Prefer installing @types/node for full types.
|
||||||
|
|
||||||
|
interface ProcessEnv { [key: string]: string | undefined }
|
||||||
|
|
||||||
|
interface Process {
|
||||||
|
pid: number
|
||||||
|
exit(code?: number): never
|
||||||
|
send?(message: any): void
|
||||||
|
on(event: string, listener: (...args: any[]) => void): any
|
||||||
|
stdin: { on(event: string, listener: (...args: any[]) => void): any }
|
||||||
|
stdout: { write(chunk: any): boolean }
|
||||||
|
env: ProcessEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
declare var process: Process
|
||||||
|
|
||||||
|
// Minimal axios module declaration
|
||||||
|
declare module 'axios' {
|
||||||
|
export interface AxiosRequestConfig { [key: string]: any }
|
||||||
|
export interface AxiosResponse<T = any> { data: T }
|
||||||
|
export interface AxiosInstance {
|
||||||
|
defaults: any
|
||||||
|
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||||
|
}
|
||||||
|
export interface AxiosStatic {
|
||||||
|
(config: AxiosRequestConfig): Promise<AxiosResponse>
|
||||||
|
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||||
|
create(config?: AxiosRequestConfig): AxiosInstance
|
||||||
|
}
|
||||||
|
const axios: AxiosStatic
|
||||||
|
export default axios
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal readline
|
||||||
|
declare module 'readline' {
|
||||||
|
export interface Interface { question(query: string, cb: (answer: string)=>void): void; close(): void }
|
||||||
|
export function createInterface(opts: any): Interface
|
||||||
|
export default {} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal crypto
|
||||||
|
declare module 'crypto' {
|
||||||
|
export function randomBytes(size: number): { toString(encoding: string): string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal os module
|
||||||
|
declare module 'os' {
|
||||||
|
export function platform(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal cheerio subset
|
||||||
|
declare module 'cheerio' {
|
||||||
|
export interface CheerioAPI {
|
||||||
|
(selector: any): any
|
||||||
|
load(html: string): CheerioAPI
|
||||||
|
text(): string
|
||||||
|
}
|
||||||
|
export function load(html: string): CheerioAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'cluster' {
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
interface WorkerLike extends EventEmitter {
|
||||||
|
id: number
|
||||||
|
process: { pid: number }
|
||||||
|
send?(message: any): void
|
||||||
|
on(event: 'message', listener: (msg: any) => void): any
|
||||||
|
}
|
||||||
|
interface Cluster extends EventEmitter {
|
||||||
|
isPrimary: boolean
|
||||||
|
fork(env?: NodeJS.ProcessEnv): WorkerLike
|
||||||
|
workers?: Record<string, WorkerLike>
|
||||||
|
on(event: 'exit', listener: (worker: WorkerLike, code: number) => void): any
|
||||||
|
}
|
||||||
|
const cluster: Cluster
|
||||||
|
export default cluster
|
||||||
|
}
|
||||||
57
src/types/rebrowser-playwright.d.ts
vendored
Normal file
57
src/types/rebrowser-playwright.d.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Minimal module declaration to silence TS complaints if upstream types not found.
|
||||||
|
// You should replace with actual types if the package provides them.
|
||||||
|
|
||||||
|
// Basic playwright stubs (only what we currently need). Replace with real @types if available.
|
||||||
|
declare module 'playwright' {
|
||||||
|
export interface Cookie { name: string; value: string; domain?: string; path?: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: 'Lax'|'Strict'|'None' }
|
||||||
|
export interface BrowserContext {
|
||||||
|
newPage(): Promise<Page>
|
||||||
|
setDefaultTimeout(timeout: number): void
|
||||||
|
addCookies(cookies: Cookie[]): Promise<void>
|
||||||
|
cookies(): Promise<Cookie[]>
|
||||||
|
pages(): Page[]
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
export interface Browser {
|
||||||
|
newPage(): Promise<Page>
|
||||||
|
context(): BrowserContext
|
||||||
|
close(): Promise<void>
|
||||||
|
pages?(): Page[]
|
||||||
|
}
|
||||||
|
export interface Keyboard {
|
||||||
|
type(text: string): Promise<any>
|
||||||
|
press(key: string): Promise<any>
|
||||||
|
down(key: string): Promise<any>
|
||||||
|
up(key: string): Promise<any>
|
||||||
|
}
|
||||||
|
export interface Locator {
|
||||||
|
first(): Locator
|
||||||
|
click(opts?: any): Promise<any>
|
||||||
|
isVisible(opts?: any): Promise<boolean>
|
||||||
|
nth(index: number): Locator
|
||||||
|
}
|
||||||
|
export interface Page {
|
||||||
|
goto(url: string, opts?: any): Promise<any>
|
||||||
|
waitForLoadState(state?: string, opts?: any): Promise<any>
|
||||||
|
waitForSelector(selector: string, opts?: any): Promise<any>
|
||||||
|
fill(selector: string, value: string): Promise<any>
|
||||||
|
keyboard: Keyboard
|
||||||
|
click(selector: string, opts?: any): Promise<any>
|
||||||
|
close(): Promise<any>
|
||||||
|
url(): string
|
||||||
|
route(match: string, handler: any): Promise<any>
|
||||||
|
locator(selector: string): Locator
|
||||||
|
$: (selector: string) => Promise<any>
|
||||||
|
context(): BrowserContext
|
||||||
|
reload(opts?: any): Promise<any>
|
||||||
|
evaluate<R=any>(pageFunction: any, arg?: any): Promise<R>
|
||||||
|
content(): Promise<string>
|
||||||
|
waitForTimeout(timeout: number): Promise<void>
|
||||||
|
}
|
||||||
|
export interface ChromiumType { launch(opts?: any): Promise<Browser> }
|
||||||
|
export const chromium: ChromiumType
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'rebrowser-playwright' {
|
||||||
|
export * from 'playwright'
|
||||||
|
}
|
||||||
25
src/types/shims-node.d.ts
vendored
Normal file
25
src/types/shims-node.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Minimal shims to silence TypeScript errors in environments without @types/node
|
||||||
|
// If possible, install @types/node instead for full typing.
|
||||||
|
|
||||||
|
declare const __dirname: string
|
||||||
|
|
||||||
|
declare namespace NodeJS { interface Process { pid: number; send?: (msg: any) => void; exit(code?: number): void; } }
|
||||||
|
|
||||||
|
declare const process: NodeJS.Process
|
||||||
|
|
||||||
|
declare module 'cluster' {
|
||||||
|
interface Worker { process: { pid: number }; on(event: 'message', cb: (msg: any) => void): void }
|
||||||
|
const isPrimary: boolean
|
||||||
|
function fork(): Worker
|
||||||
|
function on(event: 'exit', cb: (worker: Worker, code: number) => void): void
|
||||||
|
export { isPrimary, fork, on, Worker }
|
||||||
|
export default { isPrimary, fork, on }
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fs' { const x: any; export = x }
|
||||||
|
|
||||||
|
declare module 'path' { const x: any; export = x }
|
||||||
|
|
||||||
|
// Do NOT redeclare 'Page' to avoid erasing actual Playwright types if present.
|
||||||
|
// If types are missing, install: npm i -D @types/node
|
||||||
|
|
||||||
32
src/util/ConclusionWebhook.ts
Normal file
32
src/util/ConclusionWebhook.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { Config } from '../interface/Config'
|
||||||
|
|
||||||
|
interface ConclusionPayload {
|
||||||
|
content?: string
|
||||||
|
embeds?: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a final structured summary to the dedicated conclusion webhook (if enabled),
|
||||||
|
* otherwise do nothing. Does NOT fallback to the normal logging webhook to avoid spam.
|
||||||
|
*/
|
||||||
|
export async function ConclusionWebhook(configData: Config, content: string, embed?: ConclusionPayload) {
|
||||||
|
const webhook = configData.conclusionWebhook
|
||||||
|
|
||||||
|
if (!webhook || !webhook.enabled || webhook.url.length < 10) return
|
||||||
|
|
||||||
|
const body: ConclusionPayload = embed?.embeds ? { embeds: embed.embeds } : { content }
|
||||||
|
if (content && !body.content && !body.embeds) body.content = content
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
method: 'POST',
|
||||||
|
url: webhook.url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: body
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios(request).catch(() => { })
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@
|
|||||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
||||||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||||
/* Module Resolution Options */
|
/* Module Resolution Options */
|
||||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||||
|
// "types": ["node"], // Removed explicit requirement; using local shims. Install @types/node for full typings.
|
||||||
|
"typeRoots": ["./src/types", "./node_modules/@types"],
|
||||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||||
|
|||||||
Reference in New Issue
Block a user