mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-17 21:43:59 +00:00
V2.1.5 (#379)
* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features. * Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience. * Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes. * Added serial protection dialog management for message forwarding, including closing by button or escape. * feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events feat: Add ConfigValidator to validate configuration files and catch common issues feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources feat: Develop RiskManager to monitor account activity and assess risk levels dynamically * Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic. * feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class. * feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell * feat: Add sample account setup * Update README.md * Update README.md * Update README.md * Update README.md * Update README.md
This commit is contained in:
@@ -35,12 +35,13 @@ class Browser {
|
||||
}
|
||||
|
||||
let browser: import('rebrowser-playwright').Browser
|
||||
// Support both legacy and new config structures (wider scope for later usage)
|
||||
const cfgAny = this.bot.config as unknown as Record<string, unknown>
|
||||
try {
|
||||
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
|
||||
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
|
||||
let headlessValue = envForceHeadless ? true : ((cfgAny['headless'] as boolean | undefined) ?? (cfgAny['browser'] && (cfgAny['browser'] as Record<string, unknown>)['headless'] as boolean | undefined) ?? false)
|
||||
// Support legacy config.headless OR nested config.browser.headless
|
||||
const legacyHeadless = (this.bot.config as { headless?: boolean }).headless
|
||||
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
|
||||
let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
|
||||
if (this.bot.isBuyModeEnabled() && !envForceHeadless) {
|
||||
if (headlessValue !== false) {
|
||||
const target = this.bot.getBuyModeTarget()
|
||||
@@ -77,8 +78,9 @@ class Browser {
|
||||
}
|
||||
|
||||
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
|
||||
const fpConfig = (cfgAny['saveFingerprint'] as unknown) || ((cfgAny['fingerprinting'] as Record<string, unknown> | undefined)?.['saveFingerprint'] as unknown)
|
||||
const saveFingerprint: { mobile: boolean; desktop: boolean } = (fpConfig as { mobile: boolean; desktop: boolean }) || { mobile: false, desktop: false }
|
||||
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint
|
||||
const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
|
||||
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
|
||||
|
||||
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
|
||||
|
||||
@@ -87,8 +89,10 @@ class Browser {
|
||||
const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
|
||||
|
||||
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
|
||||
const globalTimeout = (cfgAny['globalTimeout'] as unknown) ?? ((cfgAny['browser'] as Record<string, unknown> | undefined)?.['globalTimeout'] as unknown) ?? 30000
|
||||
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout as (number | string)))
|
||||
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
|
||||
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
|
||||
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
|
||||
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
|
||||
|
||||
// Normalize viewport and page rendering so content fits typical screens
|
||||
try {
|
||||
@@ -126,7 +130,7 @@ class Browser {
|
||||
await context.addCookies(sessionData.cookies)
|
||||
|
||||
// Persist fingerprint when feature is configured
|
||||
if (fpConfig) {
|
||||
if (saveFingerprint.mobile || saveFingerprint.desktop) {
|
||||
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,65 +4,134 @@ import { load } from 'cheerio'
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
|
||||
|
||||
type DismissButton = { selector: string; label: string; isXPath?: boolean }
|
||||
|
||||
export default class BrowserUtil {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
private static readonly DISMISS_BUTTONS: readonly DismissButton[] = [
|
||||
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
|
||||
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
|
||||
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
|
||||
{ selector: '#iShowSkip', label: 'Show Skip' },
|
||||
{ selector: '#iNext', label: 'Next' },
|
||||
{ selector: '#iLooksGood', label: 'LooksGood' },
|
||||
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
|
||||
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
|
||||
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
|
||||
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
|
||||
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
|
||||
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
|
||||
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
|
||||
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
|
||||
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
|
||||
]
|
||||
|
||||
private static readonly OVERLAY_SELECTORS = {
|
||||
container: '#bnp_overlay_wrapper',
|
||||
reject: '#bnp_btn_reject, button[aria-label*="Reject" i]',
|
||||
accept: '#bnp_btn_accept'
|
||||
} as const
|
||||
|
||||
private static readonly STREAK_DIALOG_SELECTORS = {
|
||||
container: '[role="dialog"], div[role="alert"], div.ms-Dialog',
|
||||
textFilter: /streak protection has run out/i,
|
||||
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
|
||||
} as const
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async tryDismissAllMessages(page: Page): Promise<void> {
|
||||
const attempts = 3
|
||||
const buttonGroups: { selector: string; label: string; isXPath?: boolean }[] = [
|
||||
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
|
||||
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
|
||||
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
|
||||
{ selector: '#iShowSkip', label: 'Show Skip' },
|
||||
{ selector: '#iNext', label: 'Next' },
|
||||
{ selector: '#iLooksGood', label: 'LooksGood' },
|
||||
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
|
||||
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
|
||||
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
|
||||
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
|
||||
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
|
||||
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
|
||||
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
|
||||
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
|
||||
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
|
||||
]
|
||||
for (let round = 0; round < attempts; round++) {
|
||||
let dismissedThisRound = 0
|
||||
for (const btn of buttonGroups) {
|
||||
try {
|
||||
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
|
||||
if (await loc.first().isVisible({ timeout: 200 }).catch(()=>false)) {
|
||||
await loc.first().click({ timeout: 500 }).catch(()=>{})
|
||||
dismissedThisRound++
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const maxRounds = 3
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const dismissCount = await this.dismissRound(page)
|
||||
if (dismissCount === 0) break
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissRound(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
count += await this.dismissStandardButtons(page)
|
||||
count += await this.dismissOverlayButtons(page)
|
||||
count += await this.dismissStreakDialog(page)
|
||||
return count
|
||||
}
|
||||
|
||||
private async dismissStandardButtons(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
for (const btn of BrowserUtil.DISMISS_BUTTONS) {
|
||||
const dismissed = await this.tryClickButton(page, btn)
|
||||
if (dismissed) {
|
||||
count++
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
// Special case: blocking overlay with inside buttons
|
||||
try {
|
||||
const overlay = page.locator('#bnp_overlay_wrapper')
|
||||
if (await overlay.isVisible({ timeout: 200 }).catch(()=>false)) {
|
||||
const reject = overlay.locator('#bnp_btn_reject, button[aria-label*="Reject" i]')
|
||||
const accept = overlay.locator('#bnp_btn_accept')
|
||||
if (await reject.first().isVisible().catch(()=>false)) {
|
||||
await reject.first().click({ timeout: 500 }).catch(()=>{})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
|
||||
dismissedThisRound++
|
||||
} else if (await accept.first().isVisible().catch(()=>false)) {
|
||||
await accept.first().click({ timeout: 500 }).catch(()=>{})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
|
||||
dismissedThisRound++
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (dismissedThisRound === 0) break // nothing new dismissed -> stop early
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private async tryClickButton(page: Page, btn: DismissButton): Promise<boolean> {
|
||||
try {
|
||||
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
|
||||
const visible = await loc.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return false
|
||||
|
||||
await loc.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissOverlayButtons(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, reject, accept } = BrowserUtil.OVERLAY_SELECTORS
|
||||
const overlay = page.locator(container)
|
||||
const visible = await overlay.isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const rejectBtn = overlay.locator(reject)
|
||||
if (await rejectBtn.first().isVisible().catch(() => false)) {
|
||||
await rejectBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
|
||||
return 1
|
||||
}
|
||||
|
||||
const acceptBtn = overlay.locator(accept)
|
||||
if (await acceptBtn.first().isVisible().catch(() => false)) {
|
||||
await acceptBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissStreakDialog(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, textFilter, closeButtons } = BrowserUtil.STREAK_DIALOG_SELECTORS
|
||||
const dialog = page.locator(container).filter({ hasText: textFilter })
|
||||
const visible = await dialog.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const closeBtn = dialog.locator(closeButtons).first()
|
||||
if (await closeBtn.isVisible({ timeout: 200 }).catch(() => false)) {
|
||||
await closeBtn.click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
|
||||
return 1
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape').catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
|
||||
return 1
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,5 +193,44 @@
|
||||
"docker": false,
|
||||
// Custom updater script path (relative to repo root)
|
||||
"scriptPath": "setup/update/update.mjs"
|
||||
}
|
||||
},
|
||||
|
||||
// NEW INTELLIGENT FEATURES
|
||||
"riskManagement": {
|
||||
// Risk-Aware Throttling: dynamically adjusts delays based on detected risk signals
|
||||
"enabled": true,
|
||||
// Automatically increase delays when captchas/errors are detected
|
||||
"autoAdjustDelays": true,
|
||||
// Stop execution if risk level reaches critical (score > riskThreshold)
|
||||
"stopOnCritical": false,
|
||||
// Enable ML-style ban prediction based on patterns
|
||||
"banPrediction": true,
|
||||
// Risk threshold (0-100). If exceeded, bot pauses or alerts you.
|
||||
"riskThreshold": 75
|
||||
},
|
||||
|
||||
"analytics": {
|
||||
// Performance Dashboard: track points earned, success rates, execution times
|
||||
"enabled": true,
|
||||
// How long to keep analytics data (days)
|
||||
"retentionDays": 30,
|
||||
// Generate markdown summary reports
|
||||
"exportMarkdown": true,
|
||||
// Send analytics summary via webhook
|
||||
"webhookSummary": false
|
||||
},
|
||||
|
||||
"queryDiversity": {
|
||||
// Multi-source query generation: use Reddit, News, Wikipedia instead of just Google Trends
|
||||
"enabled": true,
|
||||
// Which sources to use (google-trends, reddit, news, wikipedia, local-fallback)
|
||||
"sources": ["google-trends", "reddit", "local-fallback"],
|
||||
// Max queries to fetch per source
|
||||
"maxQueriesPerSource": 10,
|
||||
// Cache duration in minutes (avoids hammering APIs)
|
||||
"cacheMinutes": 30
|
||||
},
|
||||
|
||||
// Dry-run mode: simulate execution without actually running tasks (useful for testing config)
|
||||
"dryRun": false
|
||||
}
|
||||
|
||||
@@ -321,16 +321,8 @@ export class Login {
|
||||
}
|
||||
await input.fill('')
|
||||
await input.fill(code)
|
||||
const submitSelectors = [
|
||||
'#idSubmit_SAOTCC_Continue',
|
||||
'#idSubmit_SAOTCC_OTC',
|
||||
'button[type="submit"]:has-text("Verify")',
|
||||
'button[type="submit"]:has-text("Continuer")',
|
||||
'button:has-text("Verify")',
|
||||
'button:has-text("Continuer")',
|
||||
'button:has-text("Submit")'
|
||||
]
|
||||
const submit = await this.findFirstVisibleLocator(page, submitSelectors)
|
||||
// Use unified selector system
|
||||
const submit = await this.findFirstVisibleLocator(page, Login.TOTP_SELECTORS.submit)
|
||||
if (submit) {
|
||||
await submit.click().catch(()=>{})
|
||||
} else {
|
||||
@@ -342,19 +334,17 @@ export class Login {
|
||||
}
|
||||
}
|
||||
|
||||
private totpInputSelectors(): string[] {
|
||||
return [
|
||||
// Unified selector system - DRY principle
|
||||
private static readonly TOTP_SELECTORS = {
|
||||
input: [
|
||||
'input[name="otc"]',
|
||||
'#idTxtBx_SAOTCC_OTC',
|
||||
'#idTxtBx_SAOTCS_OTC',
|
||||
'input[data-testid="otcInput"]',
|
||||
'input[autocomplete="one-time-code"]',
|
||||
'input[type="tel"][name="otc"]'
|
||||
]
|
||||
}
|
||||
|
||||
private totpAltOptionSelectors(): string[] {
|
||||
return [
|
||||
],
|
||||
altOptions: [
|
||||
'#idA_SAOTCS_ProofPickerChange',
|
||||
'#idA_SAOTCC_AlternateLogin',
|
||||
'a:has-text("Use a different verification option")',
|
||||
@@ -362,11 +352,8 @@ export class Login {
|
||||
'a:has-text("I can\'t use my Microsoft Authenticator app right now")',
|
||||
'button:has-text("Use a different verification option")',
|
||||
'button:has-text("Sign in another way")'
|
||||
]
|
||||
}
|
||||
|
||||
private totpChallengeSelectors(): string[] {
|
||||
return [
|
||||
],
|
||||
challenge: [
|
||||
'[data-value="PhoneAppOTP"]',
|
||||
'[data-value="OneTimeCode"]',
|
||||
'button:has-text("Use a verification code")',
|
||||
@@ -380,20 +367,32 @@ export class Login {
|
||||
'button:has-text("Entrez un code")',
|
||||
'div[role="button"]:has-text("Use a verification code")',
|
||||
'div[role="button"]:has-text("Enter a code")'
|
||||
],
|
||||
submit: [
|
||||
'#idSubmit_SAOTCC_Continue',
|
||||
'#idSubmit_SAOTCC_OTC',
|
||||
'button[type="submit"]:has-text("Verify")',
|
||||
'button[type="submit"]:has-text("Continuer")',
|
||||
'button:has-text("Verify")',
|
||||
'button:has-text("Continuer")',
|
||||
'button:has-text("Submit")'
|
||||
]
|
||||
}
|
||||
} as const
|
||||
|
||||
private async findFirstVisibleSelector(page: Page, selectors: string[]): Promise<string | null> {
|
||||
private totpInputSelectors(): readonly string[] { return Login.TOTP_SELECTORS.input }
|
||||
private totpAltOptionSelectors(): readonly string[] { return Login.TOTP_SELECTORS.altOptions }
|
||||
private totpChallengeSelectors(): readonly string[] { return Login.TOTP_SELECTORS.challenge }
|
||||
|
||||
// Generic selector finder - reduces duplication from 3 functions to 1
|
||||
private async findFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<string | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
return sel
|
||||
}
|
||||
if (await loc.isVisible().catch(() => false)) return sel
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async clickFirstVisibleSelector(page: Page, selectors: string[]): Promise<boolean> {
|
||||
private async clickFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<boolean> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
@@ -404,12 +403,10 @@ export class Login {
|
||||
return false
|
||||
}
|
||||
|
||||
private async findFirstVisibleLocator(page: Page, selectors: string[]): Promise<Locator | null> {
|
||||
private async findFirstVisibleLocator(page: Page, selectors: readonly string[]): Promise<Locator | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
return loc
|
||||
}
|
||||
if (await loc.isVisible().catch(() => false)) return loc
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -147,95 +147,98 @@ export class Workers {
|
||||
|
||||
// Solve all the different types of activities
|
||||
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
const activityInitial = activityPage.url() // Homepage for Daily/More and Index for promotions
|
||||
const activityInitial = activityPage.url()
|
||||
const retry = new Retry(this.bot.config.retryPolicy)
|
||||
const throttle = new AdaptiveThrottler()
|
||||
|
||||
const retry = new Retry(this.bot.config.retryPolicy)
|
||||
const throttle = new AdaptiveThrottler()
|
||||
for (const activity of activities) {
|
||||
for (const activity of activities) {
|
||||
try {
|
||||
// Reselect the worker page
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
|
||||
await this.applyThrottle(throttle, 800, 1400)
|
||||
|
||||
const pages = activityPage.context().pages()
|
||||
if (pages.length > 3) {
|
||||
await activityPage.close()
|
||||
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
|
||||
await this.prepareActivityPage(activityPage, selector, throttle)
|
||||
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
}
|
||||
|
||||
await this.bot.browser.utils.humanizePage(activityPage)
|
||||
{
|
||||
const m = throttle.getDelayMultiplier()
|
||||
await this.bot.utils.waitRandom(Math.floor(800*m), Math.floor(1400*m))
|
||||
}
|
||||
|
||||
if (activityPage.url() !== activityInitial) {
|
||||
await activityPage.goto(activityInitial)
|
||||
}
|
||||
|
||||
|
||||
let selector = `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
|
||||
if (punchCard) {
|
||||
selector = await this.bot.browser.func.getPunchCardActivity(activityPage, activity)
|
||||
|
||||
} else if (activity.name.toLowerCase().includes('membercenter') || activity.name.toLowerCase().includes('exploreonbing')) {
|
||||
selector = `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
}
|
||||
|
||||
// Wait for the new tab to fully load, ignore error.
|
||||
/*
|
||||
Due to common false timeout on this function, we're ignoring the error regardless, if it worked then it's faster,
|
||||
if it didn't then it gave enough time for the page to load.
|
||||
*/
|
||||
await activityPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { })
|
||||
// Small human-like jitter before executing
|
||||
await this.bot.browser.utils.humanizePage(activityPage)
|
||||
{
|
||||
const m = throttle.getDelayMultiplier()
|
||||
await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
|
||||
}
|
||||
|
||||
// Log the detected type using the same heuristics as before
|
||||
const typeLabel = this.bot.activities.getTypeLabel(activity)
|
||||
if (typeLabel !== 'Unsupported') {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${typeLabel}" title: "${activity.title}"`)
|
||||
await activityPage.click(selector)
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
// Watchdog: abort if the activity hangs too long
|
||||
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
|
||||
const runWithTimeout = (p: Promise<void>) => Promise.race([
|
||||
p,
|
||||
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
|
||||
])
|
||||
await retry.run(async () => {
|
||||
try {
|
||||
await runWithTimeout(this.bot.activities.run(activityPage, activity))
|
||||
throttle.record(true)
|
||||
} catch (e) {
|
||||
await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_timeout_${activity.title || activity.offerId}`)
|
||||
throttle.record(false)
|
||||
throw e
|
||||
}
|
||||
}, () => true)
|
||||
await this.executeActivity(activityPage, activity, selector, throttle, retry)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
|
||||
}
|
||||
|
||||
// Cooldown with jitter
|
||||
await this.bot.browser.utils.humanizePage(activityPage)
|
||||
{
|
||||
const m = throttle.getDelayMultiplier()
|
||||
await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
|
||||
}
|
||||
|
||||
await this.applyThrottle(throttle, 1200, 2600)
|
||||
} catch (error) {
|
||||
await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_error_${activity.title || activity.offerId}`)
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
throttle.record(false)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private async manageTabLifecycle(page: Page, initialUrl: string): Promise<Page> {
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const pages = page.context().pages()
|
||||
if (pages.length > 3) {
|
||||
await page.close()
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
}
|
||||
|
||||
if (page.url() !== initialUrl) {
|
||||
await page.goto(initialUrl)
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
private async buildActivitySelector(page: Page, activity: PromotionalItem | MorePromotion, punchCard?: PunchCard): Promise<string> {
|
||||
if (punchCard) {
|
||||
return await this.bot.browser.func.getPunchCardActivity(page, activity)
|
||||
}
|
||||
|
||||
const name = activity.name.toLowerCase()
|
||||
if (name.includes('membercenter') || name.includes('exploreonbing')) {
|
||||
return `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
}
|
||||
|
||||
return `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
}
|
||||
|
||||
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
await this.applyThrottle(throttle, 1200, 2600)
|
||||
}
|
||||
|
||||
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`)
|
||||
|
||||
await page.click(selector)
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
|
||||
const runWithTimeout = (p: Promise<void>) => Promise.race([
|
||||
p,
|
||||
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
|
||||
])
|
||||
|
||||
await retry.run(async () => {
|
||||
try {
|
||||
await runWithTimeout(this.bot.activities.run(page, activity))
|
||||
throttle.record(true)
|
||||
} catch (e) {
|
||||
await this.bot.browser.utils.captureDiagnostics(page, `activity_timeout_${activity.title || activity.offerId}`)
|
||||
throttle.record(false)
|
||||
throw e
|
||||
}
|
||||
}, () => true)
|
||||
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
}
|
||||
|
||||
private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise<void> {
|
||||
const multiplier = throttle.getDelayMultiplier()
|
||||
await this.bot.utils.waitRandom(Math.floor(min * multiplier), Math.floor(max * multiplier))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,6 +20,14 @@ export class Quiz extends Workers {
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
let quizData = await this.bot.browser.func.getQuizData(page)
|
||||
|
||||
// Verify quiz is actually loaded before proceeding
|
||||
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: 5000 }).then(() => true).catch(() => false)
|
||||
if (!firstOptionExists) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
const questionsRemaining = quizData.maxQuestions - quizData.CorrectlyAnsweredQuestionCount // Amount of questions remaining
|
||||
|
||||
// All questions
|
||||
@@ -29,13 +37,26 @@ export class Quiz extends Workers {
|
||||
const answers: string[] = []
|
||||
|
||||
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 }).catch(() => null)
|
||||
|
||||
if (!answerSelector) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn')
|
||||
break
|
||||
}
|
||||
|
||||
const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption'))
|
||||
|
||||
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
||||
answers.push(`#rqAnswerOption${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
// If no correct answers found, skip this question
|
||||
if (answers.length === 0) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'No correct answers found for 8-option quiz. Skipping.', 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Click the answers
|
||||
for (const answer of answers) {
|
||||
@@ -56,15 +77,24 @@ export class Quiz extends Workers {
|
||||
} else if ([2, 3, 4].includes(quizData.numberOfOptions)) {
|
||||
quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data
|
||||
const correctOption = quizData.correctAnswer
|
||||
|
||||
let answerClicked = false
|
||||
|
||||
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 }).catch(() => null)
|
||||
|
||||
if (!answerSelector) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
|
||||
continue
|
||||
}
|
||||
|
||||
const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option'))
|
||||
|
||||
if (dataOption === correctOption) {
|
||||
// Click the answer on page
|
||||
await page.click(`#rqAnswerOption${i}`)
|
||||
answerClicked = true
|
||||
|
||||
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
|
||||
if (!refreshSuccess) {
|
||||
@@ -72,8 +102,16 @@ export class Quiz extends Workers {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!answerClicked) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Could not find correct answer for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
67
src/index.ts
67
src/index.ts
@@ -84,20 +84,19 @@ export class MicrosoftRewardsBot {
|
||||
this.humanizer = new Humanizer(this.utils, this.config.humanization)
|
||||
this.activeWorkers = this.config.clusters
|
||||
this.mobileRetryAttempts = 0
|
||||
// Base buy mode from config
|
||||
const cfgAny = this.config as unknown as { buyMode?: { enabled?: boolean } }
|
||||
if (cfgAny.buyMode?.enabled === true) {
|
||||
this.buyMode.enabled = true
|
||||
}
|
||||
|
||||
// CLI: detect buy mode flag and target email (overrides config)
|
||||
|
||||
// Buy mode: CLI args take precedence over config
|
||||
const idx = process.argv.indexOf('-buy')
|
||||
if (idx >= 0) {
|
||||
const target = process.argv[idx + 1]
|
||||
if (target && /@/.test(target)) {
|
||||
this.buyMode = { enabled: true, email: target }
|
||||
} else {
|
||||
this.buyMode = { enabled: true }
|
||||
this.buyMode = target && /@/.test(target)
|
||||
? { enabled: true, email: target }
|
||||
: { enabled: true }
|
||||
} else {
|
||||
// Fallback to config if no CLI flag
|
||||
const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined
|
||||
if (buyModeConfig?.enabled === true) {
|
||||
this.buyMode.enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,10 +220,8 @@ export class MicrosoftRewardsBot {
|
||||
let last = initial
|
||||
let spent = 0
|
||||
|
||||
const cfgAny = this.config as unknown as Record<string, unknown>
|
||||
const buyModeConfig = cfgAny['buyMode'] as Record<string, unknown> | undefined
|
||||
const maxMinutesRaw = buyModeConfig?.['maxMinutes'] ?? 45
|
||||
const maxMinutes = Math.max(10, Number(maxMinutesRaw))
|
||||
const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined
|
||||
const maxMinutes = Math.max(10, buyModeConfig?.maxMinutes ?? 45)
|
||||
const endAt = start + maxMinutes * 60 * 1000
|
||||
|
||||
while (Date.now() < endAt) {
|
||||
@@ -292,25 +289,33 @@ export class MicrosoftRewardsBot {
|
||||
if (this.config.clusters > 1 && !cluster.isPrimary) return
|
||||
|
||||
const banner = `
|
||||
███╗ ███╗███████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███████╗
|
||||
████╗ ████║██╔════╝ ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔════╝
|
||||
██╔████╔██║███████╗ ██████╔╝█████╗ ██║ █╗ ██║███████║██████╔╝██║ ██║███████╗
|
||||
██║╚██╔╝██║╚════██║ ██╔══██╗██╔══╝ ██║███╗██║██╔══██║██╔══██╗██║ ██║╚════██║
|
||||
██║ ╚═╝ ██║███████║ ██║ ██║███████╗╚███╔███╔╝██║ ██║██║ ██║██████╔╝███████║
|
||||
╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝
|
||||
|
||||
TypeScript • Playwright • Automated Point Collection
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ███╗ ███╗███████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ██████╗ ██████╗ ███████╗ ║
|
||||
║ ████╗ ████║██╔════╝ ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔════╝ ║
|
||||
║ ██╔████╔██║███████╗ ██████╔╝█████╗ ██║ █╗ ██║███████║██████╔╝██║ ██║███████╗ ║
|
||||
║ ██║╚██╔╝██║╚════██║ ██╔══██╗██╔══╝ ██║███╗██║██╔══██║██╔══██╗██║ ██║╚════██║ ║
|
||||
║ ██║ ╚═╝ ██║███████║ ██║ ██║███████╗╚███╔███╔╝██║ ██║██║ ██║██████╔╝███████║ ║
|
||||
║ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ║
|
||||
║ ║
|
||||
║ TypeScript • Playwright • Intelligent Automation ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
`
|
||||
|
||||
const buyModeBanner = `
|
||||
███╗ ███╗███████╗ ██████╗ ██╗ ██╗██╗ ██╗
|
||||
████╗ ████║██╔════╝ ██╔══██╗██║ ██║╚██╗ ██╔╝
|
||||
██╔████╔██║███████╗ ██████╔╝██║ ██║ ╚████╔╝
|
||||
██║╚██╔╝██║╚════██║ ██╔══██╗██║ ██║ ╚██╔╝
|
||||
██║ ╚═╝ ██║███████║ ██████╔╝╚██████╔╝ ██║
|
||||
╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
|
||||
By @Light • Manual Purchase Mode • Passive Monitoring
|
||||
╔══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ███╗ ███╗███████╗ ██████╗ ██╗ ██╗██╗ ██╗ ║
|
||||
║ ████╗ ████║██╔════╝ ██╔══██╗██║ ██║╚██╗ ██╔╝ ║
|
||||
║ ██╔████╔██║███████╗ ██████╔╝██║ ██║ ╚████╔╝ ║
|
||||
║ ██║╚██╔╝██║╚════██║ ██╔══██╗██║ ██║ ╚██╔╝ ║
|
||||
║ ██║ ╚═╝ ██║███████║ ██████╔╝╚██████╔╝ ██║ ║
|
||||
║ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ Manual Purchase Mode • Passive Monitoring ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════╝
|
||||
`
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,8 @@ export interface Config {
|
||||
baseURL: string;
|
||||
sessionPath: string;
|
||||
headless: boolean;
|
||||
browser?: ConfigBrowser; // Optional nested browser config
|
||||
fingerprinting?: ConfigFingerprinting; // Optional nested fingerprinting config
|
||||
parallel: boolean;
|
||||
runOnZeroPoints: boolean;
|
||||
clusters: number;
|
||||
@@ -27,6 +29,10 @@ export interface Config {
|
||||
buyMode?: ConfigBuyMode; // Optional manual spending mode
|
||||
vacation?: ConfigVacation; // Optional monthly contiguous off-days
|
||||
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
|
||||
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
|
||||
analytics?: ConfigAnalytics; // NEW: Performance dashboard and metrics tracking
|
||||
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
|
||||
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
|
||||
}
|
||||
|
||||
export interface ConfigSaveFingerprint {
|
||||
@@ -34,6 +40,15 @@ export interface ConfigSaveFingerprint {
|
||||
desktop: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigBrowser {
|
||||
headless?: boolean;
|
||||
globalTimeout?: number | string;
|
||||
}
|
||||
|
||||
export interface ConfigFingerprinting {
|
||||
saveFingerprint?: ConfigSaveFingerprint;
|
||||
}
|
||||
|
||||
export interface ConfigSearchSettings {
|
||||
useGeoLocaleQueries: boolean;
|
||||
scrollRandomResults: boolean;
|
||||
@@ -178,3 +193,26 @@ export interface ConfigLogging {
|
||||
|
||||
// CommunityHelp removed (privacy-first policy)
|
||||
|
||||
// NEW FEATURES: Risk Management, Analytics, Query Diversity
|
||||
export interface ConfigRiskManagement {
|
||||
enabled?: boolean; // master toggle for risk-aware throttling
|
||||
autoAdjustDelays?: boolean; // automatically increase delays when risk is high
|
||||
stopOnCritical?: boolean; // halt execution if risk reaches critical level
|
||||
banPrediction?: boolean; // enable ML-style ban prediction
|
||||
riskThreshold?: number; // 0-100, pause if risk exceeds this
|
||||
}
|
||||
|
||||
export interface ConfigAnalytics {
|
||||
enabled?: boolean; // track performance metrics
|
||||
retentionDays?: number; // how long to keep analytics data
|
||||
exportMarkdown?: boolean; // generate markdown reports
|
||||
webhookSummary?: boolean; // send analytics via webhook
|
||||
}
|
||||
|
||||
export interface ConfigQueryDiversity {
|
||||
enabled?: boolean; // use multi-source query generation
|
||||
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
|
||||
maxQueriesPerSource?: number; // limit per source
|
||||
cacheMinutes?: number; // cache duration
|
||||
}
|
||||
|
||||
|
||||
264
src/util/Analytics.ts
Normal file
264
src/util/Analytics.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface DailyMetrics {
|
||||
date: string // YYYY-MM-DD
|
||||
email: string
|
||||
pointsEarned: number
|
||||
pointsInitial: number
|
||||
pointsEnd: number
|
||||
desktopPoints: number
|
||||
mobilePoints: number
|
||||
executionTimeMs: number
|
||||
successRate: number // 0-1
|
||||
errorsCount: number
|
||||
banned: boolean
|
||||
riskScore?: number
|
||||
}
|
||||
|
||||
export interface AccountHistory {
|
||||
email: string
|
||||
totalRuns: number
|
||||
totalPointsEarned: number
|
||||
avgPointsPerDay: number
|
||||
avgExecutionTime: number
|
||||
successRate: number
|
||||
lastRunDate: string
|
||||
banHistory: Array<{ date: string; reason: string }>
|
||||
riskTrend: number[] // last N risk scores
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
period: string // e.g., 'last-7-days', 'last-30-days', 'all-time'
|
||||
accounts: AccountHistory[]
|
||||
globalStats: {
|
||||
totalPoints: number
|
||||
avgSuccessRate: number
|
||||
mostProductiveAccount: string
|
||||
mostRiskyAccount: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics tracks performance metrics, point collection trends, and account health.
|
||||
* Stores data in JSON files for lightweight persistence and easy analysis.
|
||||
*/
|
||||
export class Analytics {
|
||||
private dataDir: string
|
||||
|
||||
constructor(baseDir: string = 'analytics') {
|
||||
this.dataDir = path.join(process.cwd(), baseDir)
|
||||
if (!fs.existsSync(this.dataDir)) {
|
||||
fs.mkdirSync(this.dataDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record metrics for a completed account run
|
||||
*/
|
||||
recordRun(metrics: DailyMetrics): void {
|
||||
const date = metrics.date
|
||||
const email = this.sanitizeEmail(metrics.email)
|
||||
const fileName = `${email}_${date}.json`
|
||||
const filePath = path.join(this.dataDir, fileName)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(metrics, null, 2), 'utf-8')
|
||||
} catch (error) {
|
||||
console.error(`Failed to save metrics for ${metrics.email}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history for a specific account
|
||||
*/
|
||||
getAccountHistory(email: string, days: number = 30): AccountHistory {
|
||||
const sanitized = this.sanitizeEmail(email)
|
||||
const files = this.getAccountFiles(sanitized, days)
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
email,
|
||||
totalRuns: 0,
|
||||
totalPointsEarned: 0,
|
||||
avgPointsPerDay: 0,
|
||||
avgExecutionTime: 0,
|
||||
successRate: 1.0,
|
||||
lastRunDate: 'never',
|
||||
banHistory: [],
|
||||
riskTrend: []
|
||||
}
|
||||
}
|
||||
|
||||
let totalPoints = 0
|
||||
let totalTime = 0
|
||||
let successCount = 0
|
||||
const banHistory: Array<{ date: string; reason: string }> = []
|
||||
const riskScores: number[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(this.dataDir, file)
|
||||
try {
|
||||
const data: DailyMetrics = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
totalPoints += data.pointsEarned
|
||||
totalTime += data.executionTimeMs
|
||||
if (data.successRate > 0.5) successCount++
|
||||
if (data.banned) {
|
||||
banHistory.push({ date: data.date, reason: 'detected' })
|
||||
}
|
||||
if (typeof data.riskScore === 'number') {
|
||||
riskScores.push(data.riskScore)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const totalRuns = files.length
|
||||
const lastFile = files[files.length - 1]
|
||||
const lastRunDate = lastFile ? lastFile.split('_')[1]?.replace('.json', '') || 'unknown' : 'unknown'
|
||||
|
||||
return {
|
||||
email,
|
||||
totalRuns,
|
||||
totalPointsEarned: totalPoints,
|
||||
avgPointsPerDay: Math.round(totalPoints / Math.max(1, totalRuns)),
|
||||
avgExecutionTime: Math.round(totalTime / Math.max(1, totalRuns)),
|
||||
successRate: successCount / Math.max(1, totalRuns),
|
||||
lastRunDate,
|
||||
banHistory,
|
||||
riskTrend: riskScores.slice(-10) // last 10 risk scores
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary report for all accounts
|
||||
*/
|
||||
generateSummary(days: number = 30): AnalyticsSummary {
|
||||
const accountEmails = this.getAllAccounts()
|
||||
const accounts: AccountHistory[] = []
|
||||
|
||||
for (const email of accountEmails) {
|
||||
accounts.push(this.getAccountHistory(email, days))
|
||||
}
|
||||
|
||||
const totalPoints = accounts.reduce((sum, a) => sum + a.totalPointsEarned, 0)
|
||||
const avgSuccess = accounts.reduce((sum, a) => sum + a.successRate, 0) / Math.max(1, accounts.length)
|
||||
|
||||
let mostProductive = ''
|
||||
let maxPoints = 0
|
||||
let mostRisky = ''
|
||||
let maxRisk = 0
|
||||
|
||||
for (const acc of accounts) {
|
||||
if (acc.totalPointsEarned > maxPoints) {
|
||||
maxPoints = acc.totalPointsEarned
|
||||
mostProductive = acc.email
|
||||
}
|
||||
const avgRisk = acc.riskTrend.reduce((s, r) => s + r, 0) / Math.max(1, acc.riskTrend.length)
|
||||
if (avgRisk > maxRisk) {
|
||||
maxRisk = avgRisk
|
||||
mostRisky = acc.email
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
period: `last-${days}-days`,
|
||||
accounts,
|
||||
globalStats: {
|
||||
totalPoints,
|
||||
avgSuccessRate: Number(avgSuccess.toFixed(2)),
|
||||
mostProductiveAccount: mostProductive || 'none',
|
||||
mostRiskyAccount: mostRisky || 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export summary as markdown table (for human readability)
|
||||
*/
|
||||
exportMarkdown(days: number = 30): string {
|
||||
const summary = this.generateSummary(days)
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`# Analytics Summary (${summary.period})`)
|
||||
lines.push('')
|
||||
lines.push('## Global Stats')
|
||||
lines.push(`- Total Points: ${summary.globalStats.totalPoints}`)
|
||||
lines.push(`- Avg Success Rate: ${(summary.globalStats.avgSuccessRate * 100).toFixed(1)}%`)
|
||||
lines.push(`- Most Productive: ${summary.globalStats.mostProductiveAccount}`)
|
||||
lines.push(`- Most Risky: ${summary.globalStats.mostRiskyAccount}`)
|
||||
lines.push('')
|
||||
lines.push('## Per-Account Breakdown')
|
||||
lines.push('')
|
||||
lines.push('| Account | Runs | Total Points | Avg/Day | Success Rate | Last Run | Bans |')
|
||||
lines.push('|---------|------|--------------|---------|--------------|----------|------|')
|
||||
|
||||
for (const acc of summary.accounts) {
|
||||
const successPct = (acc.successRate * 100).toFixed(0)
|
||||
const banCount = acc.banHistory.length
|
||||
lines.push(
|
||||
`| ${acc.email} | ${acc.totalRuns} | ${acc.totalPointsEarned} | ${acc.avgPointsPerDay} | ${successPct}% | ${acc.lastRunDate} | ${banCount} |`
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old analytics files (retention policy)
|
||||
*/
|
||||
cleanup(retentionDays: number): void {
|
||||
const files = fs.readdirSync(this.dataDir)
|
||||
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue
|
||||
const filePath = path.join(this.dataDir, file)
|
||||
try {
|
||||
const stats = fs.statSync(filePath)
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeEmail(email: string): string {
|
||||
return email.replace(/[^a-zA-Z0-9@._-]/g, '_')
|
||||
}
|
||||
|
||||
private getAccountFiles(sanitizedEmail: string, days: number): string[] {
|
||||
const files = fs.readdirSync(this.dataDir)
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days)
|
||||
|
||||
return files
|
||||
.filter((f: string) => f.startsWith(sanitizedEmail) && f.endsWith('.json'))
|
||||
.filter((f: string) => {
|
||||
const datePart = f.split('_')[1]?.replace('.json', '')
|
||||
if (!datePart) return false
|
||||
const fileDate = new Date(datePart)
|
||||
return fileDate >= cutoffDate
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
private getAllAccounts(): string[] {
|
||||
const files = fs.readdirSync(this.dataDir)
|
||||
const emailSet = new Set<string>()
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue
|
||||
const parts = file.split('_')
|
||||
if (parts.length >= 2) {
|
||||
const email = parts[0]
|
||||
if (email) emailSet.add(email)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(emailSet)
|
||||
}
|
||||
}
|
||||
394
src/util/BanPredictor.ts
Normal file
394
src/util/BanPredictor.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { RiskManager, RiskEvent } from './RiskManager'
|
||||
|
||||
export interface BanPattern {
|
||||
name: string
|
||||
description: string
|
||||
weight: number // 0-10
|
||||
detected: boolean
|
||||
evidence: string[]
|
||||
}
|
||||
|
||||
export interface BanPrediction {
|
||||
riskScore: number // 0-100
|
||||
confidence: number // 0-1
|
||||
likelihood: 'very-low' | 'low' | 'medium' | 'high' | 'critical'
|
||||
patterns: BanPattern[]
|
||||
recommendation: string
|
||||
preventiveActions: string[]
|
||||
}
|
||||
|
||||
export interface HistoricalData {
|
||||
email: string
|
||||
timestamp: number
|
||||
banned: boolean
|
||||
preBanEvents: RiskEvent[]
|
||||
accountAge: number // days since first use
|
||||
totalRuns: number
|
||||
}
|
||||
|
||||
/**
|
||||
* BanPredictor uses machine-learning-style pattern analysis to predict ban risk.
|
||||
* Learns from historical data and real-time signals to calculate ban probability.
|
||||
*/
|
||||
export class BanPredictor {
|
||||
private riskManager: RiskManager
|
||||
private history: HistoricalData[] = []
|
||||
private patterns: BanPattern[] = []
|
||||
|
||||
constructor(riskManager: RiskManager) {
|
||||
this.riskManager = riskManager
|
||||
this.initializePatterns()
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze current state and predict ban risk
|
||||
*/
|
||||
predictBanRisk(accountEmail: string, accountAgeDays: number, totalRuns: number): BanPrediction {
|
||||
const riskMetrics = this.riskManager.assessRisk()
|
||||
const recentEvents = this.riskManager.getRecentEvents(60)
|
||||
|
||||
// Detect patterns
|
||||
this.detectPatterns(recentEvents, accountAgeDays, totalRuns)
|
||||
|
||||
// Calculate base risk from RiskManager
|
||||
const baseRisk = riskMetrics.score
|
||||
|
||||
// Apply ML-style feature weights
|
||||
const featureScore = this.calculateFeatureScore(recentEvents, accountAgeDays, totalRuns)
|
||||
|
||||
// Pattern detection bonus
|
||||
const detectedPatterns = this.patterns.filter(p => p.detected)
|
||||
const patternPenalty = detectedPatterns.reduce((sum, p) => sum + p.weight, 0)
|
||||
|
||||
// Historical learning adjustment
|
||||
const historicalAdjustment = this.getHistoricalAdjustment(accountEmail)
|
||||
|
||||
// Final risk score (capped at 100)
|
||||
const finalScore = Math.min(100, baseRisk + featureScore + patternPenalty + historicalAdjustment)
|
||||
|
||||
// Calculate confidence (based on data availability)
|
||||
const confidence = this.calculateConfidence(recentEvents.length, this.history.length)
|
||||
|
||||
// Determine likelihood tier
|
||||
let likelihood: BanPrediction['likelihood']
|
||||
if (finalScore < 20) likelihood = 'very-low'
|
||||
else if (finalScore < 40) likelihood = 'low'
|
||||
else if (finalScore < 60) likelihood = 'medium'
|
||||
else if (finalScore < 80) likelihood = 'high'
|
||||
else likelihood = 'critical'
|
||||
|
||||
// Generate recommendations
|
||||
const recommendation = this.generateRecommendation(finalScore)
|
||||
const preventiveActions = this.generatePreventiveActions(detectedPatterns)
|
||||
|
||||
return {
|
||||
riskScore: Math.round(finalScore),
|
||||
confidence: Number(confidence.toFixed(2)),
|
||||
likelihood,
|
||||
patterns: detectedPatterns,
|
||||
recommendation,
|
||||
preventiveActions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record ban event for learning
|
||||
*/
|
||||
recordBan(email: string, accountAgeDays: number, totalRuns: number): void {
|
||||
const preBanEvents = this.riskManager.getRecentEvents(120)
|
||||
|
||||
this.history.push({
|
||||
email,
|
||||
timestamp: Date.now(),
|
||||
banned: true,
|
||||
preBanEvents,
|
||||
accountAge: accountAgeDays,
|
||||
totalRuns
|
||||
})
|
||||
|
||||
// Keep history limited (last 100 bans)
|
||||
if (this.history.length > 100) {
|
||||
this.history.shift()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful run (no ban) for learning
|
||||
*/
|
||||
recordSuccess(email: string, accountAgeDays: number, totalRuns: number): void {
|
||||
this.history.push({
|
||||
email,
|
||||
timestamp: Date.now(),
|
||||
banned: false,
|
||||
preBanEvents: [],
|
||||
accountAge: accountAgeDays,
|
||||
totalRuns
|
||||
})
|
||||
|
||||
if (this.history.length > 100) {
|
||||
this.history.shift()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize known ban patterns
|
||||
*/
|
||||
private initializePatterns(): void {
|
||||
this.patterns = [
|
||||
{
|
||||
name: 'rapid-captcha-sequence',
|
||||
description: 'Multiple captchas in short timespan',
|
||||
weight: 8,
|
||||
detected: false,
|
||||
evidence: []
|
||||
},
|
||||
{
|
||||
name: 'high-error-rate',
|
||||
description: 'Excessive errors (>50% in last hour)',
|
||||
weight: 6,
|
||||
detected: false,
|
||||
evidence: []
|
||||
},
|
||||
{
|
||||
name: 'timeout-storm',
|
||||
description: 'Many consecutive timeouts',
|
||||
weight: 7,
|
||||
detected: false,
|
||||
evidence: []
|
||||
},
|
||||
{
|
||||
name: 'suspicious-timing',
|
||||
description: 'Activity at unusual hours or too consistent',
|
||||
weight: 5,
|
||||
detected: false,
|
||||
evidence: []
|
||||
},
|
||||
{
|
||||
name: 'new-account-aggressive',
|
||||
description: 'Aggressive activity on young account',
|
||||
weight: 9,
|
||||
detected: false,
|
||||
evidence: []
|
||||
},
|
||||
{
|
||||
name: 'proxy-flagged',
|
||||
description: 'Proxy showing signs of blacklisting',
|
||||
weight: 7,
|
||||
detected: false,
|
||||
evidence: []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect patterns in recent events
|
||||
*/
|
||||
private detectPatterns(events: RiskEvent[], accountAgeDays: number, totalRuns: number): void {
|
||||
// Reset detection
|
||||
for (const p of this.patterns) {
|
||||
p.detected = false
|
||||
p.evidence = []
|
||||
}
|
||||
|
||||
const captchaEvents = events.filter(e => e.type === 'captcha')
|
||||
const errorEvents = events.filter(e => e.type === 'error')
|
||||
const timeoutEvents = events.filter(e => e.type === 'timeout')
|
||||
|
||||
// Pattern 1: Rapid captcha sequence
|
||||
if (captchaEvents.length >= 3) {
|
||||
const timeSpan = (events[events.length - 1]?.timestamp || 0) - (events[0]?.timestamp || 0)
|
||||
if (timeSpan < 1800000) { // 30 min
|
||||
const p = this.patterns.find(pat => pat.name === 'rapid-captcha-sequence')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push(`${captchaEvents.length} captchas in ${Math.round(timeSpan / 60000)}min`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: High error rate
|
||||
const errorRate = errorEvents.length / Math.max(1, events.length)
|
||||
if (errorRate > 0.5) {
|
||||
const p = this.patterns.find(pat => pat.name === 'high-error-rate')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push(`Error rate: ${(errorRate * 100).toFixed(1)}%`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: Timeout storm
|
||||
if (timeoutEvents.length >= 5) {
|
||||
const p = this.patterns.find(pat => pat.name === 'timeout-storm')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push(`${timeoutEvents.length} timeouts detected`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Suspicious timing (all events within same hour)
|
||||
if (events.length > 5) {
|
||||
const hours = new Set(events.map(e => new Date(e.timestamp).getHours()))
|
||||
if (hours.size === 1) {
|
||||
const p = this.patterns.find(pat => pat.name === 'suspicious-timing')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push('All activity in same hour of day')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 5: New account aggressive
|
||||
if (accountAgeDays < 7 && totalRuns > 10) {
|
||||
const p = this.patterns.find(pat => pat.name === 'new-account-aggressive')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push(`Account ${accountAgeDays} days old with ${totalRuns} runs`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 6: Proxy flagged (heuristic: many ban hints)
|
||||
const banHints = events.filter(e => e.type === 'ban_hint')
|
||||
if (banHints.length >= 2) {
|
||||
const p = this.patterns.find(pat => pat.name === 'proxy-flagged')
|
||||
if (p) {
|
||||
p.detected = true
|
||||
p.evidence.push(`${banHints.length} ban hints detected`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate feature-based risk score (ML-style)
|
||||
*/
|
||||
private calculateFeatureScore(events: RiskEvent[], accountAgeDays: number, totalRuns: number): number {
|
||||
let score = 0
|
||||
|
||||
// Feature 1: Event density (events per minute)
|
||||
const eventDensity = events.length / 60
|
||||
if (eventDensity > 0.5) score += 10
|
||||
else if (eventDensity > 0.2) score += 5
|
||||
|
||||
// Feature 2: Account age risk
|
||||
if (accountAgeDays < 3) score += 15
|
||||
else if (accountAgeDays < 7) score += 10
|
||||
else if (accountAgeDays < 14) score += 5
|
||||
|
||||
// Feature 3: Run frequency risk
|
||||
const runsPerDay = totalRuns / Math.max(1, accountAgeDays)
|
||||
if (runsPerDay > 3) score += 12
|
||||
else if (runsPerDay > 2) score += 6
|
||||
|
||||
// Feature 4: Severity distribution
|
||||
const highSeverityEvents = events.filter(e => e.severity >= 7)
|
||||
if (highSeverityEvents.length > 3) score += 15
|
||||
else if (highSeverityEvents.length > 1) score += 8
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn from historical data
|
||||
*/
|
||||
private getHistoricalAdjustment(email: string): number {
|
||||
const accountHistory = this.history.filter(h => h.email === email)
|
||||
if (accountHistory.length === 0) return 0
|
||||
|
||||
const bannedCount = accountHistory.filter(h => h.banned).length
|
||||
const banRate = bannedCount / accountHistory.length
|
||||
|
||||
// If this account has high ban history, increase risk
|
||||
if (banRate > 0.3) return 20
|
||||
if (banRate > 0.1) return 10
|
||||
|
||||
// If clean history, slight bonus
|
||||
if (accountHistory.length > 5 && banRate === 0) return -5
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate prediction confidence
|
||||
*/
|
||||
private calculateConfidence(eventCount: number, historyCount: number): number {
|
||||
let confidence = 0.5
|
||||
|
||||
// More events = higher confidence
|
||||
if (eventCount > 20) confidence += 0.2
|
||||
else if (eventCount > 10) confidence += 0.1
|
||||
|
||||
// More historical data = higher confidence
|
||||
if (historyCount > 50) confidence += 0.2
|
||||
else if (historyCount > 20) confidence += 0.1
|
||||
|
||||
return Math.min(1.0, confidence)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable recommendation
|
||||
*/
|
||||
private generateRecommendation(score: number): string {
|
||||
if (score < 20) {
|
||||
return 'Safe to proceed. Risk is minimal.'
|
||||
} else if (score < 40) {
|
||||
return 'Low risk detected. Monitor for issues but safe to continue.'
|
||||
} else if (score < 60) {
|
||||
return 'Moderate risk. Consider increasing delays and reviewing patterns.'
|
||||
} else if (score < 80) {
|
||||
return 'High risk! Strongly recommend pausing automation for 24-48 hours.'
|
||||
} else {
|
||||
return 'CRITICAL RISK! Stop all automation immediately. Manual review required.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate actionable preventive steps
|
||||
*/
|
||||
private generatePreventiveActions(patterns: BanPattern[]): string[] {
|
||||
const actions: string[] = []
|
||||
|
||||
if (patterns.some(p => p.name === 'rapid-captcha-sequence')) {
|
||||
actions.push('Increase search delays to 3-5 minutes minimum')
|
||||
actions.push('Enable longer cool-down periods between activities')
|
||||
}
|
||||
|
||||
if (patterns.some(p => p.name === 'high-error-rate')) {
|
||||
actions.push('Check proxy connectivity and health')
|
||||
actions.push('Verify User-Agent and fingerprint configuration')
|
||||
}
|
||||
|
||||
if (patterns.some(p => p.name === 'new-account-aggressive')) {
|
||||
actions.push('Slow down activity on new accounts (max 1 run per day for first week)')
|
||||
actions.push('Allow account to age naturally before heavy automation')
|
||||
}
|
||||
|
||||
if (patterns.some(p => p.name === 'proxy-flagged')) {
|
||||
actions.push('Rotate to different proxy immediately')
|
||||
actions.push('Test proxy manually before resuming')
|
||||
}
|
||||
|
||||
if (patterns.some(p => p.name === 'suspicious-timing')) {
|
||||
actions.push('Randomize execution times across different hours')
|
||||
actions.push('Enable humanization.allowedWindows with varied schedules')
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
actions.push('Continue monitoring but no immediate action needed')
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Export historical data for analysis
|
||||
*/
|
||||
exportHistory(): HistoricalData[] {
|
||||
return [...this.history]
|
||||
}
|
||||
|
||||
/**
|
||||
* Import historical data (for persistence)
|
||||
*/
|
||||
importHistory(data: HistoricalData[]): void {
|
||||
this.history = data.slice(-100) // Keep last 100
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,13 @@ type WebhookContext = 'summary' | 'ban' | 'security' | 'compromised' | 'spend' |
|
||||
|
||||
function pickUsername(ctx: WebhookContext, fallbackColor?: number): string {
|
||||
switch (ctx) {
|
||||
case 'summary': return 'Summary'
|
||||
case 'ban': return 'Ban'
|
||||
case 'security': return 'Security'
|
||||
case 'compromised': return 'Pirate'
|
||||
case 'spend': return 'Spend'
|
||||
case 'error': return 'Error'
|
||||
default: return fallbackColor === 0xFF0000 ? 'Error' : 'Rewards'
|
||||
case 'summary': return '📊 MS Rewards Summary'
|
||||
case 'ban': return '🚫 Ban Alert'
|
||||
case 'security': return '🔐 Security Alert'
|
||||
case 'compromised': return '⚠️ Security Issue'
|
||||
case 'spend': return '💳 Spend Notice'
|
||||
case 'error': return '❌ Error Report'
|
||||
default: return fallbackColor === 0xFF0000 ? '❌ Error Report' : '🎯 MS Rewards'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
531
src/util/ConfigValidator.ts
Normal file
531
src/util/ConfigValidator.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import fs from 'fs'
|
||||
import { Config } from '../interface/Config'
|
||||
import { Account } from '../interface/Account'
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
field: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
issues: ValidationIssue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigValidator performs intelligent validation of config.jsonc and accounts.json
|
||||
* before execution to catch common mistakes, conflicts, and security issues.
|
||||
*/
|
||||
export class ConfigValidator {
|
||||
/**
|
||||
* Validate the main config file
|
||||
*/
|
||||
static validateConfig(config: Config): ValidationResult {
|
||||
const issues: ValidationIssue[] = []
|
||||
|
||||
// Check baseURL
|
||||
if (!config.baseURL || !config.baseURL.startsWith('https://')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'baseURL',
|
||||
message: 'baseURL must be a valid HTTPS URL',
|
||||
suggestion: 'Use https://rewards.bing.com'
|
||||
})
|
||||
}
|
||||
|
||||
// Check sessionPath
|
||||
if (!config.sessionPath || config.sessionPath.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'sessionPath',
|
||||
message: 'sessionPath cannot be empty'
|
||||
})
|
||||
}
|
||||
|
||||
// Check clusters
|
||||
if (config.clusters < 1) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'clusters',
|
||||
message: 'clusters must be at least 1'
|
||||
})
|
||||
}
|
||||
if (config.clusters > 10) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'clusters',
|
||||
message: 'High cluster count may consume excessive resources',
|
||||
suggestion: 'Consider using 2-4 clusters for optimal performance'
|
||||
})
|
||||
}
|
||||
|
||||
// Check globalTimeout
|
||||
const timeout = this.parseTimeout(config.globalTimeout)
|
||||
if (timeout < 10000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'globalTimeout',
|
||||
message: 'Very short timeout may cause frequent failures',
|
||||
suggestion: 'Use at least 15s for stability'
|
||||
})
|
||||
}
|
||||
if (timeout > 120000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'globalTimeout',
|
||||
message: 'Very long timeout may slow down execution',
|
||||
suggestion: 'Use 30-60s for optimal balance'
|
||||
})
|
||||
}
|
||||
|
||||
// Check search settings
|
||||
if (config.searchSettings) {
|
||||
const searchDelay = config.searchSettings.searchDelay
|
||||
const minDelay = this.parseTimeout(searchDelay.min)
|
||||
const maxDelay = this.parseTimeout(searchDelay.max)
|
||||
|
||||
if (minDelay >= maxDelay) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'searchSettings.searchDelay',
|
||||
message: 'min delay must be less than max delay'
|
||||
})
|
||||
}
|
||||
|
||||
if (minDelay < 10000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'searchSettings.searchDelay.min',
|
||||
message: 'Very short search delays increase ban risk',
|
||||
suggestion: 'Use at least 30s between searches'
|
||||
})
|
||||
}
|
||||
|
||||
if (config.searchSettings.retryMobileSearchAmount > 5) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'searchSettings.retryMobileSearchAmount',
|
||||
message: 'Too many retries may waste time',
|
||||
suggestion: 'Use 2-3 retries maximum'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check humanization
|
||||
if (config.humanization) {
|
||||
if (config.humanization.enabled === false && config.humanization.stopOnBan === true) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'humanization',
|
||||
message: 'stopOnBan is enabled but humanization is disabled',
|
||||
suggestion: 'Enable humanization for better ban protection'
|
||||
})
|
||||
}
|
||||
|
||||
const actionDelay = config.humanization.actionDelay
|
||||
if (actionDelay) {
|
||||
const minAction = this.parseTimeout(actionDelay.min)
|
||||
const maxAction = this.parseTimeout(actionDelay.max)
|
||||
if (minAction >= maxAction) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'humanization.actionDelay',
|
||||
message: 'min action delay must be less than max'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (config.humanization.allowedWindows && config.humanization.allowedWindows.length > 0) {
|
||||
for (const window of config.humanization.allowedWindows) {
|
||||
if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'humanization.allowedWindows',
|
||||
message: `Invalid time window format: ${window}`,
|
||||
suggestion: 'Use format HH:mm-HH:mm (e.g., 09:00-17:00)'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check proxy config
|
||||
if (config.proxy) {
|
||||
if (config.proxy.proxyGoogleTrends === false && config.proxy.proxyBingTerms === false) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'proxy',
|
||||
message: 'All proxy options disabled - outbound requests will use direct connection'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check webhooks
|
||||
if (config.webhook?.enabled && (!config.webhook.url || config.webhook.url.trim() === '')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'webhook.url',
|
||||
message: 'Webhook enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
|
||||
if (config.conclusionWebhook?.enabled && (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'conclusionWebhook.url',
|
||||
message: 'Conclusion webhook enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
|
||||
// Check ntfy
|
||||
if (config.ntfy?.enabled) {
|
||||
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'ntfy.url',
|
||||
message: 'NTFY enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'ntfy.topic',
|
||||
message: 'NTFY enabled but topic is empty'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check schedule
|
||||
if (config.schedule?.enabled) {
|
||||
if (!config.schedule.timeZone) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'schedule.timeZone',
|
||||
message: 'No timeZone specified, defaulting to UTC',
|
||||
suggestion: 'Set your local timezone (e.g., America/New_York)'
|
||||
})
|
||||
}
|
||||
|
||||
const useAmPm = config.schedule.useAmPm
|
||||
const time12 = (config.schedule as unknown as Record<string, unknown>)['time12']
|
||||
const time24 = (config.schedule as unknown as Record<string, unknown>)['time24']
|
||||
|
||||
if (useAmPm === true && (!time12 || (typeof time12 === 'string' && time12.trim() === ''))) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'schedule.time12',
|
||||
message: 'useAmPm is true but time12 is empty'
|
||||
})
|
||||
}
|
||||
if (useAmPm === false && (!time24 || (typeof time24 === 'string' && time24.trim() === ''))) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'schedule.time24',
|
||||
message: 'useAmPm is false but time24 is empty'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check workers
|
||||
if (config.workers) {
|
||||
const allDisabled = !config.workers.doDailySet &&
|
||||
!config.workers.doMorePromotions &&
|
||||
!config.workers.doPunchCards &&
|
||||
!config.workers.doDesktopSearch &&
|
||||
!config.workers.doMobileSearch &&
|
||||
!config.workers.doDailyCheckIn &&
|
||||
!config.workers.doReadToEarn
|
||||
|
||||
if (allDisabled) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'workers',
|
||||
message: 'All workers are disabled - bot will not perform any tasks',
|
||||
suggestion: 'Enable at least one worker type'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check diagnostics
|
||||
if (config.diagnostics?.enabled) {
|
||||
const maxPerRun = config.diagnostics.maxPerRun || 2
|
||||
if (maxPerRun > 20) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'diagnostics.maxPerRun',
|
||||
message: 'Very high maxPerRun may fill disk quickly'
|
||||
})
|
||||
}
|
||||
|
||||
const retention = config.diagnostics.retentionDays || 7
|
||||
if (retention > 90) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'diagnostics.retentionDays',
|
||||
message: 'Long retention period - monitor disk usage'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate accounts.json
|
||||
*/
|
||||
static validateAccounts(accounts: Account[]): ValidationResult {
|
||||
const issues: ValidationIssue[] = []
|
||||
|
||||
if (accounts.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'accounts',
|
||||
message: 'No accounts found in accounts.json'
|
||||
})
|
||||
return { valid: false, issues }
|
||||
}
|
||||
|
||||
const seenEmails = new Set<string>()
|
||||
const seenProxies = new Map<string, string[]>() // proxy -> [emails]
|
||||
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const acc = accounts[i]
|
||||
const prefix = `accounts[${i}]`
|
||||
|
||||
if (!acc) continue
|
||||
|
||||
// Check email
|
||||
if (!acc.email || acc.email.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: 'Account email is empty'
|
||||
})
|
||||
} else {
|
||||
if (seenEmails.has(acc.email)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: `Duplicate email: ${acc.email}`
|
||||
})
|
||||
}
|
||||
seenEmails.add(acc.email)
|
||||
|
||||
if (!/@/.test(acc.email)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: 'Invalid email format'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (!acc.password || acc.password.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.password`,
|
||||
message: 'Account password is empty'
|
||||
})
|
||||
} else if (acc.password.length < 8) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: `${prefix}.password`,
|
||||
message: 'Very short password - verify it\'s correct'
|
||||
})
|
||||
}
|
||||
|
||||
// Check proxy
|
||||
if (acc.proxy) {
|
||||
const proxyUrl = acc.proxy.url
|
||||
if (proxyUrl && proxyUrl.trim() !== '') {
|
||||
if (!acc.proxy.port) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.proxy.port`,
|
||||
message: 'Proxy URL specified but port is missing'
|
||||
})
|
||||
}
|
||||
|
||||
// Track proxy reuse
|
||||
const proxyKey = `${proxyUrl}:${acc.proxy.port}`
|
||||
if (!seenProxies.has(proxyKey)) {
|
||||
seenProxies.set(proxyKey, [])
|
||||
}
|
||||
seenProxies.get(proxyKey)?.push(acc.email)
|
||||
}
|
||||
}
|
||||
|
||||
// Check TOTP
|
||||
if (acc.totp && acc.totp.trim() !== '') {
|
||||
if (acc.totp.length < 16) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: `${prefix}.totp`,
|
||||
message: 'TOTP secret seems too short - verify it\'s correct'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warn about excessive proxy reuse
|
||||
for (const [proxyKey, emails] of seenProxies) {
|
||||
if (emails.length > 3) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'accounts.proxy',
|
||||
message: `Proxy ${proxyKey} used by ${emails.length} accounts - may trigger rate limits`,
|
||||
suggestion: 'Use different proxies per account for better safety'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate both config and accounts together (cross-checks)
|
||||
*/
|
||||
static validateAll(config: Config, accounts: Account[]): ValidationResult {
|
||||
const configResult = this.validateConfig(config)
|
||||
const accountsResult = this.validateAccounts(accounts)
|
||||
|
||||
const issues = [...configResult.issues, ...accountsResult.issues]
|
||||
|
||||
// Cross-validation: clusters vs accounts
|
||||
if (accounts.length > 0 && config.clusters > accounts.length) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'clusters',
|
||||
message: `${config.clusters} clusters configured but only ${accounts.length} account(s)`,
|
||||
suggestion: 'Reduce clusters to match account count for efficiency'
|
||||
})
|
||||
}
|
||||
|
||||
// Cross-validation: parallel mode with single account
|
||||
if (config.parallel && accounts.length === 1) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'parallel',
|
||||
message: 'Parallel mode enabled with single account has no effect',
|
||||
suggestion: 'Disable parallel mode or add more accounts'
|
||||
})
|
||||
}
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate from file paths
|
||||
*/
|
||||
static validateFromFiles(configPath: string, accountsPath: string): ValidationResult {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'config',
|
||||
message: `Config file not found: ${configPath}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(accountsPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'accounts',
|
||||
message: `Accounts file not found: ${accountsPath}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const configRaw = fs.readFileSync(configPath, 'utf-8')
|
||||
const accountsRaw = fs.readFileSync(accountsPath, 'utf-8')
|
||||
|
||||
// Remove JSONC comments (basic approach)
|
||||
const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
|
||||
const config: Config = JSON.parse(configJson)
|
||||
const accounts: Account[] = JSON.parse(accountsRaw)
|
||||
|
||||
return this.validateAll(config, accounts)
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'parse',
|
||||
message: `Failed to parse files: ${error instanceof Error ? error.message : String(error)}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print validation results to console with color
|
||||
*/
|
||||
static printResults(result: ValidationResult): void {
|
||||
if (result.valid) {
|
||||
console.log('✅ Configuration validation passed\n')
|
||||
} else {
|
||||
console.log('❌ Configuration validation failed\n')
|
||||
}
|
||||
|
||||
if (result.issues.length === 0) {
|
||||
console.log('No issues found.')
|
||||
return
|
||||
}
|
||||
|
||||
const errors = result.issues.filter(i => i.severity === 'error')
|
||||
const warnings = result.issues.filter(i => i.severity === 'warning')
|
||||
const infos = result.issues.filter(i => i.severity === 'info')
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`\n🚫 ERRORS (${errors.length}):`)
|
||||
for (const issue of errors) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log(`\n⚠️ WARNINGS (${warnings.length}):`)
|
||||
for (const issue of warnings) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (infos.length > 0) {
|
||||
console.log(`\nℹ️ INFO (${infos.length}):`)
|
||||
for (const issue of infos) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
}
|
||||
|
||||
private static parseTimeout(value: number | string): number {
|
||||
if (typeof value === 'number') return value
|
||||
const str = String(value).toLowerCase()
|
||||
if (str.endsWith('ms')) return parseInt(str, 10)
|
||||
if (str.endsWith('s')) return parseInt(str, 10) * 1000
|
||||
if (str.endsWith('min')) return parseInt(str, 10) * 60000
|
||||
return parseInt(str, 10) || 30000
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,10 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
|
||||
|
||||
// Access logging config with fallback for backward compatibility
|
||||
const configAny = configData as unknown as Record<string, unknown>
|
||||
const loggingConfig = configAny.logging || configData
|
||||
const loggingConfigAny = loggingConfig as unknown as Record<string, unknown>
|
||||
|
||||
const logExcludeFunc = Array.isArray(loggingConfigAny.excludeFunc) ? loggingConfigAny.excludeFunc :
|
||||
Array.isArray(loggingConfigAny.logExcludeFunc) ? loggingConfigAny.logExcludeFunc : []
|
||||
const logging = configAny.logging as { excludeFunc?: string[]; logExcludeFunc?: string[] } | undefined
|
||||
const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
|
||||
|
||||
if (Array.isArray(logExcludeFunc) && logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
|
||||
if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,18 +112,47 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Console output with better formatting
|
||||
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '●'
|
||||
// Console output with better formatting and contextual icons
|
||||
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'
|
||||
const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta
|
||||
const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green
|
||||
|
||||
// Add contextual icon based on title/message (ASCII-safe for Windows PowerShell)
|
||||
const titleLower = title.toLowerCase()
|
||||
const msgLower = message.toLowerCase()
|
||||
|
||||
// ASCII-safe icons for Windows PowerShell compatibility
|
||||
const iconMap: Array<[RegExp, string]> = [
|
||||
[/security|compromised/i, '[SECURITY]'],
|
||||
[/ban|suspend/i, '[BANNED]'],
|
||||
[/error/i, '[ERROR]'],
|
||||
[/warn/i, '[WARN]'],
|
||||
[/success|complet/i, '[OK]'],
|
||||
[/login/i, '[LOGIN]'],
|
||||
[/point/i, '[POINTS]'],
|
||||
[/search/i, '[SEARCH]'],
|
||||
[/activity|quiz|poll/i, '[ACTIVITY]'],
|
||||
[/browser/i, '[BROWSER]'],
|
||||
[/main/i, '[MAIN]']
|
||||
]
|
||||
|
||||
let icon = ''
|
||||
for (const [pattern, symbol] of iconMap) {
|
||||
if (pattern.test(titleLower) || pattern.test(msgLower)) {
|
||||
icon = chalk.dim(symbol)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const iconPart = icon ? icon + ' ' : ''
|
||||
|
||||
const formattedStr = [
|
||||
chalk.gray(`[${currentTime}]`),
|
||||
chalk.gray(`[${process.pid}]`),
|
||||
typeColor(`${typeIndicator} ${type.toUpperCase()}`),
|
||||
typeColor(`${typeIndicator}`),
|
||||
platformColor(`[${platformText}]`),
|
||||
chalk.bold(`[${title}]`),
|
||||
redact(message)
|
||||
iconPart + redact(message)
|
||||
].join(' ')
|
||||
|
||||
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
|
||||
|
||||
339
src/util/QueryDiversityEngine.ts
Normal file
339
src/util/QueryDiversityEngine.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface QuerySource {
|
||||
name: string
|
||||
weight: number // 0-1, probability of selection
|
||||
fetchQueries: () => Promise<string[]>
|
||||
}
|
||||
|
||||
export interface QueryDiversityConfig {
|
||||
sources: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>
|
||||
deduplicate: boolean
|
||||
mixStrategies: boolean // Mix different source types in same session
|
||||
maxQueriesPerSource: number
|
||||
cacheMinutes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryDiversityEngine fetches search queries from multiple sources to avoid patterns.
|
||||
* Supports Google Trends, Reddit, News APIs, Wikipedia, and local fallbacks.
|
||||
*/
|
||||
export class QueryDiversityEngine {
|
||||
private config: QueryDiversityConfig
|
||||
private cache: Map<string, { queries: string[]; expires: number }> = new Map()
|
||||
|
||||
constructor(config?: Partial<QueryDiversityConfig>) {
|
||||
this.config = {
|
||||
sources: config?.sources || ['google-trends', 'reddit', 'local-fallback'],
|
||||
deduplicate: config?.deduplicate !== false,
|
||||
mixStrategies: config?.mixStrategies !== false,
|
||||
maxQueriesPerSource: config?.maxQueriesPerSource || 10,
|
||||
cacheMinutes: config?.cacheMinutes || 30
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch diverse queries from configured sources
|
||||
*/
|
||||
async fetchQueries(count: number): Promise<string[]> {
|
||||
const allQueries: string[] = []
|
||||
|
||||
for (const sourceName of this.config.sources) {
|
||||
try {
|
||||
const queries = await this.getFromSource(sourceName)
|
||||
allQueries.push(...queries.slice(0, this.config.maxQueriesPerSource))
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch from ${sourceName}:`, error instanceof Error ? error.message : error)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
let final = this.config.deduplicate ? Array.from(new Set(allQueries)) : allQueries
|
||||
|
||||
// Mix strategies: interleave queries from different sources
|
||||
if (this.config.mixStrategies && this.config.sources.length > 1) {
|
||||
final = this.interleaveQueries(final, count)
|
||||
}
|
||||
|
||||
// Shuffle and limit to requested count
|
||||
final = this.shuffleArray(final).slice(0, count)
|
||||
|
||||
return final.length > 0 ? final : this.getLocalFallback(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from a specific source with caching
|
||||
*/
|
||||
private async getFromSource(source: string): Promise<string[]> {
|
||||
const cached = this.cache.get(source)
|
||||
if (cached && Date.now() < cached.expires) {
|
||||
return cached.queries
|
||||
}
|
||||
|
||||
let queries: string[] = []
|
||||
|
||||
switch (source) {
|
||||
case 'google-trends':
|
||||
queries = await this.fetchGoogleTrends()
|
||||
break
|
||||
case 'reddit':
|
||||
queries = await this.fetchReddit()
|
||||
break
|
||||
case 'news':
|
||||
queries = await this.fetchNews()
|
||||
break
|
||||
case 'wikipedia':
|
||||
queries = await this.fetchWikipedia()
|
||||
break
|
||||
case 'local-fallback':
|
||||
queries = this.getLocalFallback(20)
|
||||
break
|
||||
default:
|
||||
console.warn(`Unknown source: ${source}`)
|
||||
}
|
||||
|
||||
this.cache.set(source, {
|
||||
queries,
|
||||
expires: Date.now() + (this.config.cacheMinutes * 60000)
|
||||
})
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Google Trends (existing logic can be reused)
|
||||
*/
|
||||
private async fetchGoogleTrends(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://trends.google.com/trends/api/dailytrends?geo=US', {
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const data = response.data.toString().replace(')]}\',', '')
|
||||
const parsed = JSON.parse(data)
|
||||
|
||||
const queries: string[] = []
|
||||
for (const item of parsed.default.trendingSearchesDays || []) {
|
||||
for (const search of item.trendingSearches || []) {
|
||||
if (search.title?.query) {
|
||||
queries.push(search.title.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queries.slice(0, 20)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Reddit (top posts from popular subreddits)
|
||||
*/
|
||||
private async fetchReddit(): Promise<string[]> {
|
||||
try {
|
||||
const subreddits = ['news', 'worldnews', 'todayilearned', 'askreddit', 'technology']
|
||||
const randomSub = subreddits[Math.floor(Math.random() * subreddits.length)]
|
||||
|
||||
const response = await axios.get(`https://www.reddit.com/r/${randomSub}/hot.json?limit=15`, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
const posts = response.data.data.children || []
|
||||
const queries: string[] = []
|
||||
|
||||
for (const post of posts) {
|
||||
const title = post.data?.title
|
||||
if (title && title.length > 10 && title.length < 100) {
|
||||
queries.push(title)
|
||||
}
|
||||
}
|
||||
|
||||
return queries
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from News API (requires API key - fallback to headlines scraping)
|
||||
*/
|
||||
private async fetchNews(): Promise<string[]> {
|
||||
try {
|
||||
// Using NewsAPI.org free tier (limited requests)
|
||||
const apiKey = process.env.NEWS_API_KEY
|
||||
if (!apiKey) {
|
||||
return this.fetchNewsFallback()
|
||||
}
|
||||
|
||||
const response = await axios.get('https://newsapi.org/v2/top-headlines', {
|
||||
params: {
|
||||
country: 'us',
|
||||
pageSize: 15,
|
||||
apiKey
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const articles = response.data.articles || []
|
||||
return articles.map((a: { title?: string }) => a.title).filter((t: string | undefined) => t && t.length > 10)
|
||||
} catch {
|
||||
return this.fetchNewsFallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback news scraper (BBC/CNN headlines)
|
||||
*/
|
||||
private async fetchNewsFallback(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://www.bbc.com/news', {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
const html = response.data
|
||||
const regex = /<h3[^>]*>(.*?)<\/h3>/gi
|
||||
const matches: RegExpMatchArray[] = []
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
matches.push(match)
|
||||
}
|
||||
|
||||
return matches
|
||||
.map(m => m[1]?.replace(/<[^>]+>/g, '').trim())
|
||||
.filter((t: string | undefined) => t && t.length > 10 && t.length < 100)
|
||||
.slice(0, 10) as string[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Wikipedia (featured articles / trending topics)
|
||||
*/
|
||||
private async fetchWikipedia(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://en.wikipedia.org/w/api.php', {
|
||||
params: {
|
||||
action: 'query',
|
||||
list: 'random',
|
||||
rnnamespace: 0,
|
||||
rnlimit: 15,
|
||||
format: 'json'
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const pages = response.data.query?.random || []
|
||||
return pages.map((p: { title?: string }) => p.title).filter((t: string | undefined) => t && t.length > 3)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local fallback queries (curated list)
|
||||
*/
|
||||
private getLocalFallback(count: number): string[] {
|
||||
const fallback = [
|
||||
'weather forecast',
|
||||
'news today',
|
||||
'stock market',
|
||||
'sports scores',
|
||||
'movie reviews',
|
||||
'recipes',
|
||||
'travel destinations',
|
||||
'health tips',
|
||||
'technology news',
|
||||
'best restaurants near me',
|
||||
'how to cook pasta',
|
||||
'python tutorial',
|
||||
'world events',
|
||||
'climate change',
|
||||
'electric vehicles',
|
||||
'space exploration',
|
||||
'artificial intelligence',
|
||||
'cryptocurrency',
|
||||
'gaming news',
|
||||
'fashion trends',
|
||||
'fitness workout',
|
||||
'home improvement',
|
||||
'gardening tips',
|
||||
'pet care',
|
||||
'book recommendations',
|
||||
'music charts',
|
||||
'streaming shows',
|
||||
'historical events',
|
||||
'science discoveries',
|
||||
'education resources'
|
||||
]
|
||||
|
||||
return this.shuffleArray(fallback).slice(0, count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleave queries from different sources for diversity
|
||||
*/
|
||||
private interleaveQueries(queries: string[], targetCount: number): string[] {
|
||||
const result: string[] = []
|
||||
const sourceMap = new Map<string, string[]>()
|
||||
|
||||
// Group queries by estimated source (simple heuristic)
|
||||
for (const q of queries) {
|
||||
const source = this.guessSource(q)
|
||||
if (!sourceMap.has(source)) {
|
||||
sourceMap.set(source, [])
|
||||
}
|
||||
sourceMap.get(source)?.push(q)
|
||||
}
|
||||
|
||||
const sources = Array.from(sourceMap.values())
|
||||
let index = 0
|
||||
|
||||
while (result.length < targetCount && sources.some(s => s.length > 0)) {
|
||||
const source = sources[index % sources.length]
|
||||
if (source && source.length > 0) {
|
||||
const q = source.shift()
|
||||
if (q) result.push(q)
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess which source a query came from (basic heuristic)
|
||||
*/
|
||||
private guessSource(query: string): string {
|
||||
if (/^[A-Z]/.test(query) && query.includes(' ')) return 'news'
|
||||
if (query.length > 80) return 'reddit'
|
||||
if (/how to|what is|why/i.test(query)) return 'local'
|
||||
return 'trends'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array (Fisher-Yates)
|
||||
*/
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (call between runs)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
177
src/util/RiskManager.ts
Normal file
177
src/util/RiskManager.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { AdaptiveThrottler } from './AdaptiveThrottler'
|
||||
|
||||
export interface RiskEvent {
|
||||
type: 'captcha' | 'error' | 'timeout' | 'ban_hint' | 'success'
|
||||
timestamp: number
|
||||
severity: number // 0-10, higher = worse
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface RiskMetrics {
|
||||
score: number // 0-100, higher = riskier
|
||||
level: 'safe' | 'elevated' | 'high' | 'critical'
|
||||
recommendation: string
|
||||
delayMultiplier: number
|
||||
}
|
||||
|
||||
/**
|
||||
* RiskManager monitors account activity patterns and detects early ban signals.
|
||||
* Integrates with AdaptiveThrottler to dynamically adjust delays based on risk.
|
||||
*/
|
||||
export class RiskManager {
|
||||
private events: RiskEvent[] = []
|
||||
private readonly maxEvents = 100
|
||||
private readonly timeWindowMs = 3600000 // 1 hour
|
||||
private throttler: AdaptiveThrottler
|
||||
private cooldownUntil: number = 0
|
||||
|
||||
constructor(throttler?: AdaptiveThrottler) {
|
||||
this.throttler = throttler || new AdaptiveThrottler()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a risk event (captcha, error, success, etc.)
|
||||
*/
|
||||
recordEvent(type: RiskEvent['type'], severity: number, context?: string): void {
|
||||
const event: RiskEvent = {
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
severity: Math.max(0, Math.min(10, severity)),
|
||||
context
|
||||
}
|
||||
|
||||
this.events.push(event)
|
||||
if (this.events.length > this.maxEvents) {
|
||||
this.events.shift()
|
||||
}
|
||||
|
||||
// Feed success/error into adaptive throttler
|
||||
if (type === 'success') {
|
||||
this.throttler.record(true)
|
||||
} else if (['error', 'captcha', 'timeout', 'ban_hint'].includes(type)) {
|
||||
this.throttler.record(false)
|
||||
}
|
||||
|
||||
// Auto cool-down on critical events
|
||||
if (severity >= 8) {
|
||||
const coolMs = Math.min(300000, severity * 30000) // max 5min
|
||||
this.cooldownUntil = Date.now() + coolMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate current risk metrics based on recent events
|
||||
*/
|
||||
assessRisk(): RiskMetrics {
|
||||
const now = Date.now()
|
||||
const recentEvents = this.events.filter(e => now - e.timestamp < this.timeWindowMs)
|
||||
|
||||
if (recentEvents.length === 0) {
|
||||
return {
|
||||
score: 0,
|
||||
level: 'safe',
|
||||
recommendation: 'Normal operation',
|
||||
delayMultiplier: 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate base risk score (weighted by recency and severity)
|
||||
let weightedSum = 0
|
||||
let totalWeight = 0
|
||||
|
||||
for (const event of recentEvents) {
|
||||
const age = now - event.timestamp
|
||||
const recencyFactor = 1 - (age / this.timeWindowMs) // newer = higher weight
|
||||
const weight = recencyFactor * (event.severity / 10)
|
||||
|
||||
weightedSum += weight * event.severity
|
||||
totalWeight += weight
|
||||
}
|
||||
|
||||
const baseScore = totalWeight > 0 ? (weightedSum / totalWeight) * 10 : 0
|
||||
|
||||
// Penalty for rapid event frequency
|
||||
const eventRate = recentEvents.length / (this.timeWindowMs / 60000) // events per minute
|
||||
const frequencyPenalty = Math.min(30, eventRate * 5)
|
||||
|
||||
// Bonus penalty for specific patterns
|
||||
const captchaCount = recentEvents.filter(e => e.type === 'captcha').length
|
||||
const banHintCount = recentEvents.filter(e => e.type === 'ban_hint').length
|
||||
const patternPenalty = (captchaCount * 15) + (banHintCount * 25)
|
||||
|
||||
const finalScore = Math.min(100, baseScore + frequencyPenalty + patternPenalty)
|
||||
|
||||
// Determine risk level
|
||||
let level: RiskMetrics['level']
|
||||
let recommendation: string
|
||||
let delayMultiplier: number
|
||||
|
||||
if (finalScore < 20) {
|
||||
level = 'safe'
|
||||
recommendation = 'Normal operation'
|
||||
delayMultiplier = 1.0
|
||||
} else if (finalScore < 40) {
|
||||
level = 'elevated'
|
||||
recommendation = 'Minor issues detected. Increasing delays slightly.'
|
||||
delayMultiplier = 1.5
|
||||
} else if (finalScore < 70) {
|
||||
level = 'high'
|
||||
recommendation = 'Significant risk detected. Applying heavy throttling.'
|
||||
delayMultiplier = 2.5
|
||||
} else {
|
||||
level = 'critical'
|
||||
recommendation = 'CRITICAL: High ban risk. Consider stopping or manual review.'
|
||||
delayMultiplier = 4.0
|
||||
}
|
||||
|
||||
// Apply adaptive throttler multiplier on top
|
||||
const adaptiveMultiplier = this.throttler.getDelayMultiplier()
|
||||
delayMultiplier *= adaptiveMultiplier
|
||||
|
||||
return {
|
||||
score: Math.round(finalScore),
|
||||
level,
|
||||
recommendation,
|
||||
delayMultiplier: Number(delayMultiplier.toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently in forced cool-down period
|
||||
*/
|
||||
isInCooldown(): boolean {
|
||||
return Date.now() < this.cooldownUntil
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining cool-down time in milliseconds
|
||||
*/
|
||||
getCooldownRemaining(): number {
|
||||
const remaining = this.cooldownUntil - Date.now()
|
||||
return Math.max(0, remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the adaptive throttler instance for advanced usage
|
||||
*/
|
||||
getThrottler(): AdaptiveThrottler {
|
||||
return this.throttler
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events and reset state (use between accounts)
|
||||
*/
|
||||
reset(): void {
|
||||
this.events = []
|
||||
this.cooldownUntil = 0
|
||||
// Keep throttler state across resets for learning
|
||||
}
|
||||
|
||||
/**
|
||||
* Export events for analytics/logging
|
||||
*/
|
||||
getRecentEvents(limitMinutes: number = 60): RiskEvent[] {
|
||||
const cutoff = Date.now() - (limitMinutes * 60000)
|
||||
return this.events.filter(e => e.timestamp >= cutoff)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user