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,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(