refactor: remove legacy scheduling and analytics code

- Deleted the scheduler module and its associated functions, transitioning to OS-level scheduling.
- Removed the Analytics module and its related interfaces, retaining only a placeholder for backward compatibility.
- Updated ConfigValidator to warn about legacy schedule and analytics configurations.
- Cleaned up StartupValidator to remove diagnostics and schedule validation logic.
- Adjusted Load.ts to handle legacy flags for diagnostics and analytics.
- Removed unused diagnostics capturing functionality.
This commit is contained in:
2025-11-03 19:18:09 +01:00
parent 67006d7e93
commit 43ed6cd7f8
39 changed files with 415 additions and 1494 deletions

View File

@@ -1,12 +1,14 @@
{
// Sample accounts configuration. Copy to accounts.jsonc and fill in real values.
// Sample accounts configuration. Copy to accounts.jsonc and replace with real values.
"accounts": [
{
// Account #1 — enabled with TOTP and recovery email required
"enabled": true,
"email": "email_1@outlook.com",
"password": "password_1",
"totp": "",
"recoveryEmail": "backup_1@example.com",
"email": "primary_account@outlook.com",
"password": "strong-password-1",
"totp": "BASE32SECRETPRIMARY",
"recoveryRequired": true,
"recoveryEmail": "primary.backup@example.com",
"proxy": {
"proxyAxios": true,
"url": "",
@@ -16,11 +18,61 @@
}
},
{
// Account #2 — disabled account kept for later use (recovery optional)
"enabled": false,
"email": "email_2@outlook.com",
"password": "password_2",
"totp": "",
"email": "secondary_account@outlook.com",
"password": "strong-password-2",
"totp": "BASE32SECRETSECOND",
"recoveryRequired": false,
"recoveryEmail": "secondary.backup@example.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// Account #3 — dedicated proxy with credentials
"enabled": true,
"email": "with_proxy@outlook.com",
"password": "strong-password-3",
"totp": "BASE32SECRETTHIRD",
"recoveryRequired": true,
"recoveryEmail": "proxy.backup@example.com",
"proxy": {
"proxyAxios": true,
"url": "proxy.example.com",
"port": 3128,
"username": "proxyuser",
"password": "proxypass"
}
},
{
// Account #4 — recovery optional, no proxying through Axios layer
"enabled": true,
"email": "no_proxy@outlook.com",
"password": "strong-password-4",
"totp": "BASE32SECRETFOUR",
"recoveryRequired": false,
"recoveryEmail": "no.proxy.backup@example.com",
"proxy": {
"proxyAxios": false,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// Account #5 — enabled with TOTP omitted (will rely on recovery email)
"enabled": true,
"email": "totp_optional@outlook.com",
"password": "strong-password-5",
"totp": "",
"recoveryRequired": true,
"recoveryEmail": "totp.optional.backup@example.com",
"proxy": {
"proxyAxios": true,
"url": "",

View File

@@ -129,7 +129,6 @@ export default class BrowserFunc {
if (!scriptContent) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch(() => {})
// Force a navigation retry once before failing hard
await this.goHome(target)
@@ -148,9 +147,8 @@ export default class BrowserFunc {
const dashboardData = await this.parseDashboardFromScript(target, scriptContent)
if (!dashboardData) {
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch(() => {})
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
throw new Error('Unable to parse dashboard script - check diagnostics')
throw new Error('Unable to parse dashboard script - inspect recent logs and page markup')
}
return dashboardData

View File

@@ -2,7 +2,6 @@ import { Page } from 'rebrowser-playwright'
import { load } from 'cheerio'
import { MicrosoftRewardsBot } from '../index'
import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics'
type DismissButton = { selector: string; label: string; isXPath?: boolean }
@@ -219,12 +218,4 @@ export default class BrowserUtil {
} catch { /* swallow */ }
}
/**
* Capture minimal diagnostics for a page: screenshot + HTML content.
* Files are written under ./reports/<date>/ with a safe label.
*/
async captureDiagnostics(page: Page, label: string): Promise<void> {
await captureSharedDiagnostics(this.bot, page, label)
}
}

View File

@@ -23,14 +23,6 @@
"clusters": 1,
"passesPerRun": 1
},
"schedule": {
"enabled": false,
"useAmPm": false,
"time12": "9:00 AM",
"time24": "09:00",
"timeZone": "Europe/Paris",
"runImmediatelyOnStart": true
},
"jobState": {
"enabled": true,
"dir": ""
@@ -126,7 +118,7 @@
"authToken": ""
},
// Logging & diagnostics
// Logging
"logging": {
"excludeFunc": [
"SEARCH-CLOSE-TABS",
@@ -140,19 +132,6 @@
],
"redactEmails": true
},
"diagnostics": {
"enabled": true,
"saveScreenshot": true,
"saveHtml": true,
"maxPerRun": 2,
"retentionDays": 7
},
"analytics": {
"enabled": true,
"retentionDays": 30,
"exportMarkdown": true,
"webhookSummary": true
},
// Buy mode
"buyMode": {

View File

@@ -8,7 +8,6 @@ import { AxiosRequestConfig } from 'axios'
import { generateTOTP } from '../util/Totp'
import { saveSessionData } from '../util/Load'
import { MicrosoftRewardsBot } from '../index'
import { captureDiagnostics } from '../util/Diagnostics'
import { OAuth } from '../interface/OAuth'
import { Retry } from '../util/Retry'
@@ -202,10 +201,7 @@ export class Login {
const currentUrl = page.url()
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s (timeout: ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s). Current URL: ${currentUrl}`, 'error')
// Save diagnostics for debugging
await this.saveIncidentArtifacts(page, 'oauth-timeout').catch(() => {})
throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s - mobile token acquisition failed. Check diagnostics in reports/`)
throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s - mobile token acquisition failed. Check recent logs for details.`)
}
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`)
@@ -897,10 +893,8 @@ export class Login {
}
}).catch(() => ({ title: 'unknown', bodyLength: 0, hasRewardsText: false, visibleElements: 0 }))
this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error')
await this.bot.browser.utils.captureDiagnostics(page, 'login-portal-missing').catch(()=>{})
this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing (diagnostics saved)', 'error')
this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error')
this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing', 'error')
throw new Error(`Rewards portal not detected. URL: ${currentUrl}. Check reports/ folder`)
}
this.bot.log(this.bot.isMobile, 'LOGIN', `Portal found via fallback (${fallbackSelector})`)
@@ -1092,7 +1086,6 @@ export class Login {
this.bot.compromisedReason = 'sign-in-blocked'
this.startCompromisedInterval()
await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(()=>{})
await this.saveIncidentArtifacts(page,'sign-in-blocked').catch(()=>{})
// Open security docs for immediate guidance (best-effort)
await this.openDocsTab(page, docsUrl).catch(()=>{})
return true
@@ -1203,7 +1196,6 @@ export class Login {
this.bot.compromisedReason = 'recovery-mismatch'
this.startCompromisedInterval()
await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(()=>{})
await this.saveIncidentArtifacts(page,'recovery-mismatch').catch(()=>{})
await this.openDocsTab(page, docsUrl).catch(()=>{})
} else {
const mode = observedPrefix.length === 1 ? 'lenient' : 'strict'
@@ -1272,10 +1264,6 @@ export class Login {
}
}
private async saveIncidentArtifacts(page: Page, slug: string) {
await captureDiagnostics(this.bot, page, slug, { scope: 'security', skipSlot: true, force: true })
}
private async openDocsTab(page: Page, url: string) {
try {
const ctx = page.context()

View File

@@ -168,7 +168,6 @@ export class Workers {
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)
}
@@ -227,7 +226,6 @@ export class Workers {
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
}

View File

@@ -123,7 +123,6 @@ export class Quiz extends Workers {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')
} catch (error) {
await this.bot.browser.utils.captureDiagnostics(page, 'quiz_error')
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred:' + error, 'error')
}

View File

@@ -25,7 +25,6 @@ import Humanizer from './util/Humanizer'
import { detectBanReason } from './util/BanDetector'
import { RiskManager, RiskMetrics, RiskEvent } from './util/RiskManager'
import { BanPredictor } from './util/BanPredictor'
import { Analytics } from './util/Analytics'
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
import JobState from './util/JobState'
import { StartupValidator } from './util/StartupValidator'
@@ -67,17 +66,12 @@ export class MicrosoftRewardsBot {
// Summary collection (per process)
private accountSummaries: AccountSummary[] = []
private runId: string = Math.random().toString(36).slice(2)
private diagCount: number = 0
private bannedTriggered: { email: string; reason: string } | null = null
private globalStandby: { active: boolean; reason?: string } = { active: false }
// Scheduler heartbeat integration
private heartbeatFile?: string
private heartbeatTimer?: NodeJS.Timeout
private riskManager?: RiskManager
private lastRiskMetrics?: RiskMetrics
private riskThresholdTriggered: boolean = false
private banPredictor?: BanPredictor
private analytics?: Analytics
private accountJobState?: JobState
private accountRunCounts: Map<string, number> = new Map()
@@ -109,10 +103,6 @@ export class MicrosoftRewardsBot {
})
}
if (this.config.analytics?.enabled) {
this.analytics = new Analytics()
}
if (this.config.riskManagement?.enabled) {
this.riskManager = new RiskManager()
if (this.config.riskManagement.banPrediction) {
@@ -190,29 +180,6 @@ export class MicrosoftRewardsBot {
return this.lastRiskMetrics?.delayMultiplier ?? 1
}
private trackAnalytics(summary: AccountSummary, riskScore?: number): void {
if (!this.analytics || this.config.analytics?.enabled !== true) return
const today = new Date().toISOString().slice(0, 10)
try {
this.analytics.recordRun({
date: today,
email: summary.email,
pointsEarned: summary.totalCollected,
pointsInitial: summary.initialTotal,
pointsEnd: summary.endTotal,
desktopPoints: summary.desktopCollected,
mobilePoints: summary.mobileCollected,
executionTimeMs: summary.durationMs,
successRate: summary.errors.length ? 0 : 1,
errorsCount: summary.errors.length,
banned: !!summary.banned?.status,
riskScore
})
} catch (e) {
log('main', 'ANALYTICS', `Failed to record analytics for ${summary.email}: ${e instanceof Error ? e.message : e}`, 'warn')
}
}
private shouldSkipAccount(email: string, dayKey: string): boolean {
if (!this.accountJobState) return false
if (this.config.jobState?.skipCompletedAccounts === false) return false
@@ -245,20 +212,6 @@ export class MicrosoftRewardsBot {
this.printBanner()
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
// If scheduler provided a heartbeat file, update it periodically to signal liveness
const hbFile = process.env.SCHEDULER_HEARTBEAT_FILE
if (hbFile) {
try {
const dir = path.dirname(hbFile)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(hbFile, String(Date.now()))
this.heartbeatFile = hbFile
this.heartbeatTimer = setInterval(() => {
try { fs.writeFileSync(hbFile, String(Date.now())) } catch { /* ignore */ }
}, 60_000)
} catch { /* ignore */ }
}
// If buy mode is enabled, run single-account interactive session without automation
if (this.buyMode.enabled) {
const targetInfo = this.buyMode.email ? ` for ${this.buyMode.email}` : ''
@@ -446,32 +399,7 @@ export class MicrosoftRewardsBot {
console.log(` Auto-Update: ${updTargets.join(', ')}`)
}
const sched = this.config.schedule || {}
const schedEnabled = !!sched.enabled
if (!schedEnabled) {
console.log(' Scheduler: Disabled')
} else {
const tz = sched.timeZone || 'UTC'
let formatName = ''
let timeShown = ''
const srec: Record<string, unknown> = sched as unknown as Record<string, unknown>
const useAmPmVal = typeof srec['useAmPm'] === 'boolean' ? (srec['useAmPm'] as boolean) : undefined
const time12Val = typeof srec['time12'] === 'string' ? String(srec['time12']) : undefined
const time24Val = typeof srec['time24'] === 'string' ? String(srec['time24']) : undefined
if (useAmPmVal) {
formatName = '12h'
timeShown = time12Val || sched.time || '9:00 AM'
} else if (useAmPmVal === false) {
formatName = '24h'
timeShown = time24Val || sched.time || '09:00'
} else {
if (time24Val && time24Val.trim()) { formatName = '24h'; timeShown = time24Val }
else if (time12Val && time12Val.trim()) { formatName = '12h'; timeShown = time12Val }
else { formatName = 'auto'; timeShown = sched.time || '09:00' }
}
console.log(` Scheduler: ${timeShown} (${formatName}, ${tz})`)
}
console.log(' Scheduler: External (see docs)')
}
console.log('─'.repeat(60) + '\n')
}
@@ -585,13 +513,8 @@ export class MicrosoftRewardsBot {
try {
await this.runAutoUpdate()
} catch {/* ignore */}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
} else {
log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.')
}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
})()
}
})
@@ -681,7 +604,6 @@ export class MicrosoftRewardsBot {
riskLevel: 'safe'
}
this.accountSummaries.push(summary)
this.trackAnalytics(summary, summary.riskScore)
this.persistAccountCompletion(account.email, accountDayKey, summary)
continue
}
@@ -846,7 +768,6 @@ export class MicrosoftRewardsBot {
}
this.accountSummaries.push(summary)
this.trackAnalytics(summary, riskScore)
this.persistAccountCompletion(account.email, accountDayKey, summary)
if (banned.status) {
@@ -888,16 +809,10 @@ export class MicrosoftRewardsBot {
} else {
// Single process mode -> build and send conclusion directly
await this.sendConclusion(this.accountSummaries)
// Cleanup heartbeat timer/file at end of run
if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } }
if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } }
// After conclusion, run optional auto-update
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
process.exit()
}
process.exit()
}
/** Send immediate ban alert if configured. */
@@ -1336,83 +1251,6 @@ export class MicrosoftRewardsBot {
log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
}
// Cleanup old diagnostics
try {
const days = cfg.diagnostics?.retentionDays
if (typeof days === 'number' && days > 0) {
await this.cleanupOldDiagnostics(days)
}
} catch (e) {
log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn')
}
await this.publishAnalyticsArtifacts().catch(e => {
log('main','ANALYTICS',`Failed analytics post-processing: ${e instanceof Error ? e.message : e}`,'warn')
})
}
/** Reserve one diagnostics slot for this run (caps captures). */
public tryReserveDiagSlot(maxPerRun: number): boolean {
if (this.diagCount >= Math.max(0, maxPerRun || 0)) return false
this.diagCount += 1
return true
}
/** Delete diagnostics folders older than N days under ./reports */
private async cleanupOldDiagnostics(retentionDays: number) {
const base = path.join(process.cwd(), 'reports')
if (!fs.existsSync(base)) return
const entries = fs.readdirSync(base, { withFileTypes: true })
const now = Date.now()
const keepMs = retentionDays * 24 * 60 * 60 * 1000
for (const e of entries) {
if (!e.isDirectory()) continue
const name = e.name // expect YYYY-MM-DD
const parts = name.split('-').map((n: string) => parseInt(n, 10))
if (parts.length !== 3 || parts.some(isNaN)) continue
const [yy, mm, dd] = parts
if (yy === undefined || mm === undefined || dd === undefined) continue
const dirDate = new Date(yy, mm - 1, dd).getTime()
if (isNaN(dirDate)) continue
if (now - dirDate > keepMs) {
const dirPath = path.join(base, name)
try { fs.rmSync(dirPath, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
}
private async publishAnalyticsArtifacts(): Promise<void> {
if (!this.analytics || this.config.analytics?.enabled !== true) return
const retention = this.config.analytics.retentionDays
if (typeof retention === 'number' && retention > 0) {
this.analytics.cleanup(retention)
}
if (this.config.analytics.exportMarkdown || this.config.analytics.webhookSummary) {
const markdown = this.analytics.exportMarkdown(30)
if (this.config.analytics.exportMarkdown) {
const now = new Date()
const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
const baseDir = path.join(process.cwd(), 'reports', day)
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
const mdPath = path.join(baseDir, `analytics_${this.runId}.md`)
fs.writeFileSync(mdPath, markdown, 'utf-8')
log('main','ANALYTICS',`Saved analytics summary to ${mdPath}`)
}
if (this.config.analytics.webhookSummary) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'📈 Performance Report',
['```markdown', markdown, '```'].join('\n'),
undefined,
DISCORD.COLOR_BLUE
)
}
}
}
// Run optional auto-update script based on configuration flags.
@@ -1501,24 +1339,6 @@ function formatDuration(ms: number): string {
}
async function main() {
const initialConfig = loadConfig()
const scheduleEnabled = initialConfig?.schedule?.enabled === true
const skipScheduler = process.argv.some((arg: string) => arg === '--no-scheduler' || arg === '--single-run')
|| process.env.REWARDS_FORCE_SINGLE_RUN === '1'
const buyModeRequested = process.argv.includes('-buy')
const invokedByScheduler = !!process.env.SCHEDULER_HEARTBEAT_FILE
if (scheduleEnabled && !skipScheduler && !buyModeRequested && !invokedByScheduler) {
log('main', 'SCHEDULER', 'Schedule enabled → handing off to in-process scheduler. Use --no-scheduler for a single pass.', 'log', 'green')
try {
await import('./scheduler')
return
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Failed to start scheduler inline: ${message}. Continuing with single-run fallback.`, 'warn', 'yellow')
}
}
const rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 }
@@ -1538,7 +1358,6 @@ async function main() {
}
const gracefulExit = (code: number) => {
try { rewardsBot['heartbeatTimer'] && clearInterval(rewardsBot['heartbeatTimer']) } catch { /* ignore */ }
if (config?.crashRecovery?.autoRestart && code !== 0) {
const max = config.crashRecovery.maxRestarts ?? 2
if (crashState.restarts < max) {

View File

@@ -7,7 +7,7 @@ import type { Page } from 'playwright'
* and perform all required steps on the provided page.
*/
export interface ActivityHandler {
/** Optional identifier for diagnostics */
/** Optional identifier used in logging output */
id?: string
/**
* Return true if this handler knows how to process the given activity.

View File

@@ -22,17 +22,15 @@ export interface Config {
webhook: ConfigWebhook;
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
ntfy: ConfigNtfy;
diagnostics?: ConfigDiagnostics;
update?: ConfigUpdate;
schedule?: ConfigSchedule;
passesPerRun?: number;
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
legacy?: ConfigLegacyFlags; // Track legacy config usage for warnings
}
export interface ConfigSaveFingerprint {
@@ -81,14 +79,6 @@ export interface ConfigProxy {
proxyBingTerms: boolean;
}
export interface ConfigDiagnostics {
enabled?: boolean; // master toggle
saveScreenshot?: boolean; // capture .png
saveHtml?: boolean; // capture .html
maxPerRun?: number; // cap number of captures per run
retentionDays?: number; // delete older diagnostic folders
}
export interface ConfigUpdate {
git?: boolean; // if true, run git pull + npm ci + npm run build after completion
docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
@@ -102,18 +92,6 @@ export interface ConfigBuyMode {
maxMinutes?: number; // session duration cap
}
export interface ConfigSchedule {
enabled?: boolean;
time?: string; // Back-compat: accepts "HH:mm" or "h:mm AM/PM"
// New optional explicit times
time12?: string; // e.g., "9:00 AM"
time24?: string; // e.g., "09:00"
timeZone?: string; // IANA TZ e.g., "America/New_York"
useAmPm?: boolean; // If true, prefer time12 + AM/PM style; if false, prefer time24. If undefined, back-compat behavior.
runImmediatelyOnStart?: boolean; // if true, run once immediately when process starts
cron?: string | string[]; // Optional cron expression(s) (standard 5-field or 6-field) for advanced scheduling
}
export interface ConfigVacation {
enabled?: boolean; // default false
minDays?: number; // default 3
@@ -192,9 +170,9 @@ export interface ConfigLogging {
[key: string]: unknown; // forward compatibility
}
// CommunityHelp removed (privacy-first policy)
// CommunityHelp intentionally omitted (privacy-first policy)
// NEW FEATURES: Risk Management, Analytics, Query Diversity
// NEW FEATURES: Risk Management and Query Diversity
export interface ConfigRiskManagement {
enabled?: boolean; // master toggle for risk-aware throttling
autoAdjustDelays?: boolean; // automatically increase delays when risk is high
@@ -203,13 +181,6 @@ export interface ConfigRiskManagement {
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
@@ -217,3 +188,8 @@ export interface ConfigQueryDiversity {
cacheMinutes?: number; // cache duration
}
export interface ConfigLegacyFlags {
diagnosticsConfigured?: boolean;
analyticsConfigured?: boolean;
}

View File

@@ -1,427 +0,0 @@
import { DateTime, IANAZone } from 'luxon'
import cronParser from 'cron-parser'
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import { MicrosoftRewardsBot } from './index'
import { loadConfig } from './util/Load'
import { log } from './util/Logger'
import type { Config } from './interface/Config'
type CronExpressionInfo = { expression: string; tz: string }
type DateTimeInstance = ReturnType<typeof DateTime.fromJSDate>
/**
* Parse environment variable as number with validation
*/
function parseEnvNumber(key: string, defaultValue: number, min: number, max: number): number {
const raw = process.env[key]
if (!raw) return defaultValue
const parsed = Number(raw)
if (isNaN(parsed)) {
void log('main', 'SCHEDULER', `Invalid ${key}="${raw}". Using default ${defaultValue}`, 'warn')
return defaultValue
}
if (parsed < min || parsed > max) {
void log('main', 'SCHEDULER', `${key}=${parsed} out of range [${min}, ${max}]. Using default ${defaultValue}`, 'warn')
return defaultValue
}
return parsed
}
/**
* Parse time from schedule config (supports 12h and 24h formats)
*/
function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } {
const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
// Warn if an invalid timezone was provided
if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) {
void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn')
}
// Determine source string
let src = ''
if (typeof schedule?.useAmPm === 'boolean') {
if (schedule.useAmPm) src = (schedule.time12 || schedule.time || '').trim()
else src = (schedule.time24 || schedule.time || '').trim()
} else {
// Back-compat: prefer time if present; else time24 or time12
src = (schedule?.time || schedule?.time24 || schedule?.time12 || '').trim()
}
// Try to parse 24h first: HH:mm
const m24 = src.match(/^\s*(\d{1,2}):(\d{2})\s*$/i)
if (m24) {
const hh = Math.max(0, Math.min(23, parseInt(m24[1]!, 10)))
const mm = Math.max(0, Math.min(59, parseInt(m24[2]!, 10)))
return { tz, hour: hh, minute: mm }
}
// Parse 12h with AM/PM: h:mm AM or h AM
const m12 = src.match(/^\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*$/i)
if (m12) {
let hh = parseInt(m12[1]!, 10)
const mm = m12[2] ? parseInt(m12[2]!, 10) : 0
const ampm = m12[3]!.toUpperCase()
if (hh === 12) hh = 0
if (ampm === 'PM') hh += 12
hh = Math.max(0, Math.min(23, hh))
const m = Math.max(0, Math.min(59, mm))
return { tz, hour: hh, minute: m }
}
// Fallback: default 09:00
return { tz, hour: 9, minute: 0 }
}
function parseTargetToday(now: Date, schedule: Config['schedule'] | undefined) {
const { tz, hour, minute } = resolveTimeParts(schedule)
const dtn = DateTime.fromJSDate(now, { zone: tz })
return dtn.set({ hour, minute, second: 0, millisecond: 0 })
}
function normalizeCronExpressions(schedule: Config['schedule'] | undefined, fallbackTz: string): CronExpressionInfo[] {
if (!schedule) return []
const raw = schedule.cron
if (!raw) return []
const expressions = Array.isArray(raw) ? raw : [raw]
return expressions
.map(expr => (typeof expr === 'string' ? expr.trim() : ''))
.filter(expr => expr.length > 0)
.map(expr => ({ expression: expr, tz: (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : fallbackTz }))
}
function getNextCronOccurrence(after: DateTimeInstance, items: CronExpressionInfo[]): { next: DateTimeInstance; source: string } | null {
let soonest: { next: DateTimeInstance; source: string } | null = null
for (const item of items) {
try {
const iterator = cronParser.parseExpression(item.expression, {
currentDate: after.toJSDate(),
tz: item.tz
})
const nextDate = iterator.next().toDate()
const nextDt = DateTime.fromJSDate(nextDate, { zone: item.tz })
if (!soonest || nextDt < soonest.next) {
soonest = { next: nextDt, source: item.expression }
}
} catch (error) {
void log('main', 'SCHEDULER', `Invalid cron expression "${item.expression}": ${error instanceof Error ? error.message : String(error)}`, 'warn')
}
}
return soonest
}
function getNextDailyOccurrence(after: DateTimeInstance, schedule: Config['schedule'] | undefined): DateTimeInstance {
const todayTarget = parseTargetToday(after.toJSDate(), schedule)
const target = after >= todayTarget ? todayTarget.plus({ days: 1 }) : todayTarget
return target
}
function computeNextRun(after: DateTimeInstance, schedule: Config['schedule'] | undefined, cronItems: CronExpressionInfo[]): { next: DateTimeInstance; source: 'cron' | 'daily'; detail?: string } {
if (cronItems.length > 0) {
const cronNext = getNextCronOccurrence(after, cronItems)
if (cronNext) {
return { next: cronNext.next, source: 'cron', detail: cronNext.source }
}
void log('main', 'SCHEDULER', 'All cron expressions invalid; falling back to daily schedule', 'warn')
}
return { next: getNextDailyOccurrence(after, schedule), source: 'daily' }
}
async function runOnePass(): Promise<void> {
const bot = new MicrosoftRewardsBot(false)
await bot.initialize()
await bot.run()
}
/**
* Run a single pass either in-process or as a child process (default),
* with a watchdog timeout to kill stuck runs.
*/
async function runOnePassWithWatchdog(): Promise<void> {
// Heartbeat-aware watchdog configuration
const staleHeartbeatMin = parseEnvNumber('SCHEDULER_STALE_HEARTBEAT_MINUTES', 30, 5, 1440)
const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120)
const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440)
const checkEveryMs = 60_000 // check once per minute
// Validate: stale should be >= grace
const effectiveStale = Math.max(staleHeartbeatMin, graceMin)
// Fork per pass: safer because we can terminate a stuck child without killing the scheduler
const forkPerPass = String(process.env.SCHEDULER_FORK_PER_PASS || 'true').toLowerCase() !== 'false'
if (!forkPerPass) {
// In-process fallback (cannot forcefully stop if truly stuck)
await log('main', 'SCHEDULER', `Starting pass in-process (grace ${graceMin}m • stale ${staleHeartbeatMin}m • hardcap ${hardcapMin}m). Cannot force-kill if stuck.`)
// No true watchdog possible in-process; just run
await runOnePass()
return
}
// Child process execution
const indexJs = path.join(__dirname, 'index.js')
await log('main', 'SCHEDULER', `Spawning child for pass: ${process.execPath} ${indexJs}`)
// Prepare heartbeat file path and pass to child
const cfg = loadConfig() as Config
const baseDir = path.join(process.cwd(), cfg.sessionPath || 'sessions')
const hbFile = path.join(baseDir, `heartbeat_${Date.now()}.lock`)
try { fs.mkdirSync(baseDir, { recursive: true }) } catch { /* ignore */ }
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [indexJs], { stdio: 'inherit', env: { ...process.env, SCHEDULER_HEARTBEAT_FILE: hbFile } })
let finished = false
const startedAt = Date.now()
let killTimeout: NodeJS.Timeout | undefined
const killChild = async (signal: NodeJS.Signals) => {
try {
await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn')
child.kill(signal)
} catch { /* ignore */ }
}
const timer = setInterval(() => {
if (finished) return
const now = Date.now()
const runtimeMin = Math.floor((now - startedAt) / 60000)
// Hard cap: always terminate if exceeded
if (runtimeMin >= hardcapMin) {
log('main', 'SCHEDULER', `Pass exceeded hard cap of ${hardcapMin} minutes; terminating...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
return
}
// Before grace, don't judge
if (runtimeMin < graceMin) return
// Check heartbeat freshness
try {
const st = fs.statSync(hbFile)
const mtimeMs = st.mtimeMs
const ageMin = Math.floor((now - mtimeMs) / 60000)
if (ageMin >= effectiveStale) {
log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${effectiveStale}m). Terminating child...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
} catch (err) {
// If file missing after grace, consider stale
const msg = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn')
void killChild('SIGTERM')
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
}, checkEveryMs)
child.on('exit', async (code, signal) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
// Cleanup heartbeat file
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
if (signal) {
await log('main', 'SCHEDULER', `Child exited due to signal: ${signal}`, 'warn')
} else if (code && code !== 0) {
await log('main', 'SCHEDULER', `Child exited with non-zero code: ${code}`, 'warn')
} else {
await log('main', 'SCHEDULER', 'Child pass completed successfully')
}
resolve()
})
child.on('error', async (err) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error')
resolve()
})
})
}
async function runPasses(passes: number): Promise<void> {
const n = Math.max(1, Math.floor(passes || 1))
for (let i = 1; i <= n; i++) {
await log('main', 'SCHEDULER', `Starting pass ${i}/${n}`)
const started = Date.now()
await runOnePassWithWatchdog()
const took = Date.now() - started
const sec = Math.max(1, Math.round(took / 1000))
await log('main', 'SCHEDULER', `Completed pass ${i}/${n}`)
await log('main', 'SCHEDULER', `Pass ${i} duration: ${sec}s`)
}
}
async function main() {
const cfg = loadConfig() as Config & { schedule?: { enabled?: boolean; time?: string; timeZone?: string; runImmediatelyOnStart?: boolean } }
const schedule = cfg.schedule || { enabled: false }
const passes = typeof cfg.passesPerRun === 'number' ? cfg.passesPerRun : 1
const offPerWeek = Math.max(0, Math.min(7, Number(cfg.humanization?.randomOffDaysPerWeek ?? 1)))
let offDays: number[] = [] // 1..7 ISO weekday
let offWeek: number | null = null
type VacRange = { start: string; end: string } | null
let vacMonth: string | null = null // 'yyyy-LL'
let vacRange: VacRange = null // ISO dates 'yyyy-LL-dd'
const refreshOffDays = async (now: { weekNumber: number }) => {
if (offPerWeek <= 0) { offDays = []; offWeek = null; return }
const week = now.weekNumber
if (offWeek === week && offDays.length) return
// choose distinct weekdays [1..7]
const pool = [1,2,3,4,5,6,7]
const chosen: number[] = []
for (let i=0;i<Math.min(offPerWeek,7);i++) {
const idx = Math.floor(Math.random()*pool.length)
chosen.push(pool[idx]!)
pool.splice(idx,1)
}
offDays = chosen.sort((a,b)=>a-b)
offWeek = week
const msg = offDays.length ? offDays.join(', ') : 'none'
await log('main','SCHEDULER',`Weekly humanization off-day sample (ISO weekday): ${msg} | adjust via config.humanization.randomOffDaysPerWeek`,'warn')
}
const chooseVacationRange = async (now: typeof DateTime.prototype) => {
// Only when enabled
if (!cfg.vacation?.enabled) { vacRange = null; vacMonth = null; return }
const monthKey = now.toFormat('yyyy-LL')
if (vacMonth === monthKey && vacRange) return
// Determine month days and choose contiguous block
const monthStart = now.startOf('month')
const monthEnd = now.endOf('month')
const totalDays = monthEnd.day
const minD = Math.max(1, Math.min(28, Number(cfg.vacation.minDays ?? 3)))
const maxD = Math.max(minD, Math.min(31, Number(cfg.vacation.maxDays ?? 5)))
const span = (minD === maxD) ? minD : (minD + Math.floor(Math.random() * (maxD - minD + 1)))
const latestStart = Math.max(1, totalDays - span + 1)
const startDay = 1 + Math.floor(Math.random() * latestStart)
const start = monthStart.set({ day: startDay })
const end = start.plus({ days: span - 1 })
vacMonth = monthKey
vacRange = { start: start.toFormat('yyyy-LL-dd'), end: end.toFormat('yyyy-LL-dd') }
await log('main','SCHEDULER',`Selected vacation block this month: ${vacRange.start}${vacRange.end} (${span} day(s))`,'warn')
}
if (!schedule.enabled) {
await log('main', 'SCHEDULER', 'Schedule disabled; running once then exit')
await runPasses(passes)
process.exit(0)
}
const tz = (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
const cronExpressions = normalizeCronExpressions(schedule, tz)
let running = false
// Optional initial jitter before the first run (to vary start time)
const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => {
const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0)
const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0)
if (isNaN(minVal) || minVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn')
return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal]
}
if (isNaN(maxVal) || maxVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn')
return [minVal, 0]
}
return [minVal, maxVal]
}
const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX')
const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0)
// Check if immediate run is enabled (default to false to avoid unexpected runs)
const runImmediate = schedule.runImmediatelyOnStart === true
if (runImmediate && !running) {
running = true
if (applyInitialJitter) {
const min = Math.max(0, Math.min(initialJitterBounds[0], initialJitterBounds[1]))
const max = Math.max(min, initialJitterBounds[0], initialJitterBounds[1])
const jitterSec = (min === max) ? min * 60 : (min * 60 + Math.floor(Math.random() * ((max - min) * 60)))
if (jitterSec > 0) {
await log('main', 'SCHEDULER', `Initial jitter: delaying first run by ${Math.round(jitterSec / 60)} minute(s) (${jitterSec}s)`, 'warn')
await new Promise((r) => setTimeout(r, jitterSec * 1000))
}
}
const nowDT = DateTime.local().setZone(tz)
await chooseVacationRange(nowDT)
await refreshOffDays(nowDT)
const todayIso = nowDT.toFormat('yyyy-LL-dd')
const vr = vacRange as { start: string; end: string } | null
const isVacationToday = !!(vr && todayIso >= vr.start && todayIso <= vr.end)
if (isVacationToday) {
await log('main','SCHEDULER',`Skipping immediate run: vacation day (${todayIso})`,'warn')
} else if (offDays.includes(nowDT.weekday)) {
await log('main','SCHEDULER',`Skipping immediate run: humanization off-day (ISO weekday ${nowDT.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
} else {
await runPasses(passes)
}
running = false
}
for (;;) {
const nowDT = DateTime.local().setZone(tz)
const nextInfo = computeNextRun(nowDT, schedule, cronExpressions)
const next = nextInfo.next
let ms = Math.max(0, next.toMillis() - nowDT.toMillis())
// Optional daily jitter to further randomize the exact start time each day
let extraMs = 0
if (cronExpressions.length === 0) {
const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX')
const djMin = dailyJitterBounds[0]
const djMax = dailyJitterBounds[1]
if (djMin > 0 || djMax > 0) {
const mn = Math.max(0, Math.min(djMin, djMax))
const mx = Math.max(mn, djMin, djMax)
const jitterSec = (mn === mx) ? mn * 60 : (mn * 60 + Math.floor(Math.random() * ((mx - mn) * 60)))
extraMs = jitterSec * 1000
ms += extraMs
}
}
const human = next.toFormat('yyyy-LL-dd HH:mm ZZZZ')
const totalSec = Math.round(ms / 1000)
const jitterMsg = extraMs > 0 ? ` plus daily jitter (+${Math.round(extraMs/60000)}m)` : ''
const sourceMsg = nextInfo.source === 'cron' ? ` [cron: ${nextInfo.detail}]` : ''
await log('main', 'SCHEDULER', `Next run at ${human}${jitterMsg}${sourceMsg} (in ${totalSec}s)`)
await new Promise((resolve) => setTimeout(resolve, ms))
const nowRun = DateTime.local().setZone(tz)
await chooseVacationRange(nowRun)
await refreshOffDays(nowRun)
const todayIso2 = nowRun.toFormat('yyyy-LL-dd')
const vr2 = vacRange as { start: string; end: string } | null
const isVacation = !!(vr2 && todayIso2 >= vr2.start && todayIso2 <= vr2.end)
if (isVacation) {
await log('main','SCHEDULER',`Skipping scheduled run: vacation day (${todayIso2})`,'warn')
continue
}
if (offDays.includes(nowRun.weekday)) {
await log('main','SCHEDULER',`Skipping scheduled run: humanization off-day (ISO weekday ${nowRun.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn')
continue
}
if (!running) {
running = true
await runPasses(passes)
running = false
} else {
await log('main','SCHEDULER','Skipped scheduled trigger because a pass is already running','warn')
}
}
}
main().catch((e) => {
void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error')
process.exit(1)
})

View File

@@ -1,264 +1,3 @@
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)
}
}
// Placeholder kept for backward compatibility with older imports.
// New code should implement its own reporting or use webhooks.
export {}

View File

@@ -197,35 +197,32 @@ export class ConfigValidator {
}
}
// 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 legacySchedule = (config as unknown as { schedule?: unknown }).schedule
if (legacySchedule !== undefined) {
issues.push({
severity: 'warning',
field: 'schedule',
message: 'Legacy schedule block detected.',
suggestion: 'Remove schedule.* entries and configure OS-level scheduling (docs/schedule.md).'
})
}
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 (config.legacy?.diagnosticsConfigured) {
issues.push({
severity: 'warning',
field: 'diagnostics',
message: 'Unrecognized diagnostics.* block in config.jsonc.',
suggestion: 'Delete the diagnostics section; logs and webhooks now cover troubleshooting.'
})
}
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'
})
}
if (config.legacy?.analyticsConfigured) {
issues.push({
severity: 'warning',
field: 'analytics',
message: 'Unrecognized analytics.* block in config.jsonc.',
suggestion: 'Delete the analytics section because those values are ignored.'
})
}
// Check workers
@@ -248,27 +245,6 @@ export class ConfigValidator {
}
}
// 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 }
}

View File

@@ -1,74 +1,3 @@
import path from 'path'
import fs from 'fs'
import type { Page } from 'rebrowser-playwright'
import type { MicrosoftRewardsBot } from '../index'
export type DiagnosticsScope = 'default' | 'security'
export interface DiagnosticsOptions {
scope?: DiagnosticsScope
skipSlot?: boolean
force?: boolean
}
export async function captureDiagnostics(bot: MicrosoftRewardsBot, page: Page, rawLabel: string, options?: DiagnosticsOptions): Promise<void> {
try {
const scope: DiagnosticsScope = options?.scope ?? 'default'
const cfg = bot.config?.diagnostics ?? {}
const forceCapture = options?.force === true || scope === 'security'
if (!forceCapture && cfg.enabled === false) return
if (scope === 'default') {
const maxPerRun = typeof cfg.maxPerRun === 'number' ? cfg.maxPerRun : 8
if (!options?.skipSlot && !bot.tryReserveDiagSlot(maxPerRun)) return
}
const saveScreenshot = scope === 'security' ? true : cfg.saveScreenshot !== false
const saveHtml = scope === 'security' ? true : cfg.saveHtml !== false
if (!saveScreenshot && !saveHtml) return
const safeLabel = rawLabel.replace(/[^a-z0-9-_]/gi, '_').slice(0, 64) || 'capture'
const now = new Date()
const timestamp = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
let dir: string
if (scope === 'security') {
const base = path.join(process.cwd(), 'diagnostics', 'security-incidents')
fs.mkdirSync(base, { recursive: true })
const sub = `${now.toISOString().replace(/[:.]/g, '-')}-${safeLabel}`
dir = path.join(base, sub)
fs.mkdirSync(dir, { recursive: true })
} else {
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
dir = path.join(process.cwd(), 'reports', day)
fs.mkdirSync(dir, { recursive: true })
}
if (saveScreenshot) {
const shotName = scope === 'security' ? 'page.png' : `${timestamp}_${safeLabel}.png`
const shotPath = path.join(dir, shotName)
await page.screenshot({ path: shotPath }).catch(() => {})
if (scope === 'security') {
bot.log(bot.isMobile, 'DIAG', `Saved security screenshot to ${shotPath}`)
} else {
bot.log(bot.isMobile, 'DIAG', `Saved diagnostics screenshot to ${shotPath}`)
}
}
if (saveHtml) {
const htmlName = scope === 'security' ? 'page.html' : `${timestamp}_${safeLabel}.html`
const htmlPath = path.join(dir, htmlName)
try {
const html = await page.content()
await fs.promises.writeFile(htmlPath, html, 'utf-8')
if (scope === 'security') {
bot.log(bot.isMobile, 'DIAG', `Saved security HTML to ${htmlPath}`)
}
} catch {
/* ignore */
}
}
} catch (error) {
bot.log(bot.isMobile, 'DIAG', `Failed to capture diagnostics: ${error instanceof Error ? error.message : error}`, 'warn')
}
}
// Placeholder kept for backward compatibility with older imports.
// New code should handle troubleshooting through logging and webhooks instead.
export {}

View File

@@ -5,7 +5,7 @@ import path from 'path'
import { Account } from '../interface/Account'
import { Config, ConfigSaveFingerprint } from '../interface/Config'
import { Config, ConfigLegacyFlags, ConfigSaveFingerprint } from '../interface/Config'
let configCache: Config
let configSourcePath = ''
@@ -168,15 +168,6 @@ function normalizeConfig(raw: unknown): Config {
riskThreshold: typeof riskRaw.riskThreshold === 'number' ? riskRaw.riskThreshold : undefined
} : undefined
const analyticsRaw = (n.analytics ?? {}) as Record<string, unknown>
const hasAnalyticsCfg = Object.keys(analyticsRaw).length > 0
const analytics = hasAnalyticsCfg ? {
enabled: analyticsRaw.enabled === true,
retentionDays: typeof analyticsRaw.retentionDays === 'number' ? analyticsRaw.retentionDays : undefined,
exportMarkdown: analyticsRaw.exportMarkdown === true,
webhookSummary: analyticsRaw.webhookSummary === true
} : undefined
const queryDiversityRaw = (n.queryDiversity ?? {}) as Record<string, unknown>
const hasQueryCfg = Object.keys(queryDiversityRaw).length > 0
const queryDiversity = hasQueryCfg ? {
@@ -197,6 +188,15 @@ function normalizeConfig(raw: unknown): Config {
skipCompletedAccounts: jobStateRaw.skipCompletedAccounts !== false
}
const legacy: ConfigLegacyFlags = {}
if (typeof n.diagnostics !== 'undefined') {
legacy.diagnosticsConfigured = true
}
if (typeof n.analytics !== 'undefined') {
legacy.analyticsConfigured = true
}
const hasLegacyFlags = legacy.diagnosticsConfigured === true || legacy.analyticsConfigured === true
const cfg: Config = {
baseURL: n.baseURL ?? 'https://rewards.bing.com',
sessionPath: n.sessionPath ?? 'sessions',
@@ -219,17 +219,15 @@ function normalizeConfig(raw: unknown): Config {
webhook,
conclusionWebhook,
ntfy,
diagnostics: n.diagnostics,
update: n.update,
schedule: n.schedule,
passesPerRun: passesPerRun,
vacation: n.vacation,
buyMode: { enabled: buyModeEnabled, maxMinutes: buyModeMax },
crashRecovery: n.crashRecovery || {},
riskManagement,
analytics,
dryRun,
queryDiversity
queryDiversity,
legacy: hasLegacyFlags ? legacy : undefined
}
return cfg

View File

@@ -32,7 +32,6 @@ export class StartupValidator {
this.validateEnvironment()
this.validateFileSystem(config)
this.validateBrowserSettings(config)
this.validateScheduleSettings(config)
this.validateNetworkSettings(config)
this.validateWorkerSettings(config)
this.validateSearchSettings(config)
@@ -173,6 +172,16 @@ export class StartupValidator {
}
private validateConfig(config: Config): void {
const maybeSchedule = (config as unknown as { schedule?: unknown }).schedule
if (maybeSchedule !== undefined) {
this.addWarning(
'config',
'Legacy schedule settings detected in config.jsonc.',
'Remove schedule.* entries and use your operating system scheduler.',
'docs/schedule.md'
)
}
// Headless mode in Docker
if (process.env.FORCE_HEADLESS === '1' && config.headless === false) {
this.addWarning(
@@ -330,20 +339,13 @@ export class StartupValidator {
}
}
// Check diagnostics directory if enabled
if (config.diagnostics?.enabled === true) {
const diagPath = path.join(process.cwd(), 'diagnostics')
if (!fs.existsSync(diagPath)) {
try {
fs.mkdirSync(diagPath, { recursive: true })
} catch (error) {
this.addWarning(
'filesystem',
'Cannot create diagnostics directory',
'Screenshots and HTML snapshots will not be saved'
)
}
}
if (config.legacy?.diagnosticsConfigured || config.legacy?.analyticsConfigured) {
this.addWarning(
'filesystem',
'Unrecognized diagnostics/analytics block detected in config.jsonc',
'Remove those sections to keep the file aligned with the current schema.',
'docs/diagnostics.md'
)
}
}
@@ -368,60 +370,6 @@ export class StartupValidator {
}
}
private validateScheduleSettings(config: Config): void {
if (config.schedule?.enabled === true) {
// Time format validation
const schedRec = config.schedule as Record<string, unknown>
const useAmPm = schedRec.useAmPm
const time12 = typeof schedRec.time12 === 'string' ? schedRec.time12 : ''
const time24 = typeof schedRec.time24 === 'string' ? schedRec.time24 : ''
if (useAmPm === true && (!time12 || time12.trim() === '')) {
this.addError(
'schedule',
'Schedule enabled with useAmPm=true but time12 is missing',
'Add time12 field (e.g., "9:00 AM") or set useAmPm=false',
'docs/schedule.md'
)
}
if (useAmPm === false && (!time24 || time24.trim() === '')) {
this.addError(
'schedule',
'Schedule enabled with useAmPm=false but time24 is missing',
'Add time24 field (e.g., "09:00") or set useAmPm=true',
'docs/schedule.md'
)
}
// Timezone validation
const tz = config.schedule.timeZone || 'UTC'
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
} catch {
this.addError(
'schedule',
`Invalid timezone: ${tz}`,
'Use a valid IANA timezone (e.g., "America/New_York", "Europe/Paris")',
'docs/schedule.md'
)
}
// Vacation mode check
if (config.vacation?.enabled === true) {
if (config.vacation.minDays && config.vacation.maxDays) {
if (config.vacation.minDays > config.vacation.maxDays) {
this.addError(
'schedule',
`Vacation minDays (${config.vacation.minDays}) > maxDays (${config.vacation.maxDays})`,
'Set minDays <= maxDays (e.g., minDays: 2, maxDays: 4)'
)
}
}
}
}
}
private validateNetworkSettings(config: Config): void {
// Webhook validation
if (config.webhook?.enabled === true) {
@@ -651,8 +599,6 @@ export class StartupValidator {
)
}
// Removed diagnostics warning - reports/ folder with masked emails is safe for debugging
// Proxy exposure check
if (config.proxy?.proxyGoogleTrends === false && config.proxy?.proxyBingTerms === false) {
this.addWarning(