diff --git a/.gitignore b/.gitignore index be83784..7d5140d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -sessions/ dist/ .dev/ +.idea node_modules/ /config.json # ClawBot 微信推送的扫码凭证(含 bot token),严禁提交 @@ -20,3 +20,6 @@ note # 本地私密配置与编辑器工作目录,严禁提交 /.env /.zcode/ +# 浏览器会话数据库(含 Cookie/账号信息),严禁提交 +/sessions/ +/src/sessions/ diff --git a/config.example.json b/config.example.json index 8257b51..f79fabb 100644 --- a/config.example.json +++ b/config.example.json @@ -1,6 +1,6 @@ { "sessionPath": "sessions", - "headless": false, + "headless": true, "clusters": 1, "errorDiagnostics": false, "ensureStreakProtection": true, @@ -51,7 +51,7 @@ "max": "11min" }, "chinaApi": { - "appkey": "" + "appkey": "52930078ecae0a24e7f922740f59d613" } }, "experimental": { diff --git a/config.json b/config.json new file mode 100644 index 0000000..1f8199a --- /dev/null +++ b/config.json @@ -0,0 +1,113 @@ +{ + "sessionPath": "sessions", + "headless": true, + "clusters": 1, + "errorDiagnostics": false, + "ensureStreakProtection": true, + "autoClaimPunchcardRewards": false, + "skipNonPointTasks": true, + "accountDelay": { + "min": "1min", + "max": "3min" + }, + "workers": { + "doDailySet": true, + "doClaimBonusPoints": true, + "doMorePromotions": true, + "doPunchCards": true, + "doAppPromotions": true, + "doDesktopSearch": true, + "doMobileSearch": true, + "doBonusSearches": false, + "doDailyCheckIn": true, + "doReadToEarn": true, + "doActivateSearchPerk": true, + "doVisualSearch": false + }, + "activities": { + "urlReward": true, + "searchOnBing": true + }, + "searchOnBingLocalQueries": false, + "globalTimeout": "50sec", + "searchSettings": { + "scrollRandomResults": true, + "clickRandomResults": true, + "runOnZeroPoints": false, + "maxBonusSearches": 110, + "parallelSearching": false, + "clusterSearch": true, + "queryEngines": [ + "china", + "local" + ], + "searchResultVisitTime": "20sec", + "searchDelay": { + "min": "6min", + "max": "12min" + }, + "readDelay": { + "min": "6min", + "max": "11min" + }, + "chinaApi": { + "appkey": "" + } + }, + "experimental": { + "apiSearch": false, + "apiSearchOnBing": false, + "blockMedia": false, + "edgeBrowsing": false + }, + "debugLogs": true, + "consoleLogFilter": { + "enabled": false, + "mode": "whitelist", + "levels": ["error", "warn"], + "keywords": ["starting account"], + "regexPatterns": [] + }, + "proxy": { + "queryEngine": false, + "ignoreCertificateErrors": false + }, + "webhook": { + "discord": { + "enabled": false, + "url": "" + }, + "telegram": { + "enabled": false, + "botToken": "", + "chatId": "" + }, + "pushplus": { + "enabled": false, + "token": "", + "title": "Microsoft-Rewards-Script", + "template": "txt", + "channel": "" + }, + "clawbot": { + "enabled": false, + "authFile": "" + }, + "ntfy": { + "enabled": false, + "url": "", + "topic": "", + "token": "", + "title": "Microsoft-Rewards-Script", + "tags": ["bot", "notify"], + "priority": 3 + }, + "webhookLogFilter": { + "enabled": false, + "mode": "whitelist", + "levels": ["error"], + "keywords": ["starting account", "select number", "collected"], + "regexPatterns": [] + } + } +} diff --git a/env.env b/env.env new file mode 100644 index 0000000..9b9a226 --- /dev/null +++ b/env.env @@ -0,0 +1,46 @@ +# Account Credentials +# Copy this file to .env and fill in your details. +# .env should be in your .gitignore, never commit your credentials! +# +# Add one numbered block per account. Slots may be skipped; for example, +# ACCOUNT_2 can be used even when ACCOUNT_1 is not configured. +# +# LANG_CODE accepts a BCP 47 language tag such as nl, it, or pt-BR. +# GEO_LOCALE accepts a two-letter country code. With auto, the Microsoft +# profile country is cached after a successful dashboard request and used for +# later browser fingerprints and request headers. + +# Account 1 +ACCOUNT_1_EMAIL=tmor2026 +ACCOUNT_1_PASSWORD=Wang930513 +#ACCOUNT_1_TOTP_SECRET= +#ACCOUNT_1_RECOVERY_EMAIL= +# 国内账号推荐:GEO_LOCALE=CN、LANG_CODE=zh-CN(配合 china 热搜源) +ACCOUNT_1_LANG_CODE=zh-CN +ACCOUNT_1_GEO_LOCALE=CN +# Browser proxy: HTTP/HTTPS/SOCKS4/SOCKS5. SOCKS proxies must not require authentication. +#ACCOUNT_1_PROXY_HTTP=false +#ACCOUNT_1_PROXY_URL= +#ACCOUNT_1_PROXY_PORT=0 +#ACCOUNT_1_PROXY_USERNAME= +#ACCOUNT_1_PROXY_PASSWORD= +ACCOUNT_1_SAVE_FINGERPRINT_MOBILE=true +ACCOUNT_1_SAVE_FINGERPRINT_DESKTOP=true + +# Account 2 (duplicate and use any positive slot number for more accounts) +#ACCOUNT_2_EMAIL=email_2@example.com +#ACCOUNT_2_PASSWORD=your_password +#ACCOUNT_2_TOTP_SECRET= +#ACCOUNT_2_RECOVERY_EMAIL= +#ACCOUNT_2_GEO_LOCALE=auto +#ACCOUNT_2_LANG_CODE=en +#ACCOUNT_2_PROXY_HTTP=false +#ACCOUNT_2_PROXY_URL= +#ACCOUNT_2_PROXY_PORT=0 +#ACCOUNT_2_PROXY_USERNAME= +#ACCOUNT_2_PROXY_PASSWORD= + +# Control API (used when API_MODE=true in compose.yaml) +# See scripts/api/README.md for full documentation. +# Generate a token: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +#API_TOKEN= diff --git a/env.example b/env.example index 3a776ea..9b9a226 100644 --- a/env.example +++ b/env.example @@ -11,8 +11,8 @@ # later browser fingerprints and request headers. # Account 1 -ACCOUNT_1_EMAIL=email@example.com -ACCOUNT_1_PASSWORD=your_password +ACCOUNT_1_EMAIL=tmor2026 +ACCOUNT_1_PASSWORD=Wang930513 #ACCOUNT_1_TOTP_SECRET= #ACCOUNT_1_RECOVERY_EMAIL= # 国内账号推荐:GEO_LOCALE=CN、LANG_CODE=zh-CN(配合 china 热搜源) diff --git a/package-lock.json b/package-lock.json index acab4d4..6d26509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/node": "^24.10.1", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", + "cross-env": "^10.1.0", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", @@ -54,6 +55,13 @@ "node": ">=12" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -880,6 +888,24 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 73e0c3f..59fcfbd 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "pre-build": "npm i && rimraf dist && npx patchright install chromium", "install:browser": "npx patchright install chromium", "build": "rimraf dist && tsc && node ./scripts/main/copyAssets.js", - "start": "node ./dist/index.js", - "ts-start": "ts-node ./src/index.ts", - "dev": "ts-node ./src/index.ts -dev", + "start": "cross-env HTTPS_PROXY= HTTP_PROXY= https_proxy= http_proxy= node ./dist/index.js", + "ts-start": "cross-env HTTPS_PROXY= HTTP_PROXY= https_proxy= http_proxy= ts-node ./src/index.ts", + "dev": "cross-env HTTPS_PROXY= HTTP_PROXY= https_proxy= http_proxy= ts-node ./src/index.ts -dev", "api": "node ./scripts/api/server.js", "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", "create-docker": "docker build -t microsoft-rewards-script-docker .", @@ -42,6 +42,7 @@ "@types/node": "^24.10.1", "@types/qrcode-terminal": "^0.12.2", "@types/semver": "^7.7.1", + "cross-env": "^10.1.0", "eslint": "^10.4.0", "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 64920e8..f1db5ed 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -44,11 +44,12 @@ export default class BrowserFunc { if (response.data) return response.data throw new Error('Dashboard data missing from API response') } catch (error) { - throw this.bot.logger.error( + this.bot.logger.error( this.bot.isMobile, 'GET-DASHBOARD-DATA', `获取仪表板数据失败: ${error instanceof Error ? error.message : String(error)}` ) + throw error } } @@ -208,8 +209,22 @@ export default class BrowserFunc { async bootstrap(page: Page): Promise { try { - // /earn is the offers page - await page.goto(URLs.rewards.earn, { waitUntil: 'domcontentloaded' }) + // /earn is the offers page(代理连接不稳定时重试 3 次) + let gotoOk = false + for (let i = 1; i <= 3 && !gotoOk; i++) { + try { + await page.goto(URLs.rewards.earn, { waitUntil: 'domcontentloaded', timeout: 30000 }) + gotoOk = true + } catch (e) { + if (i === 3) throw e + this.bot.logger.warn( + this.bot.isMobile, + 'BOOTSTRAP', + `访问 /earn 失败(尝试 ${i}/3): ${e instanceof Error ? e.message : String(e)},重试中...` + ) + await this.bot.utils.wait(2000) + } + } const earnDom = await page.content() const earnRaw = await this.fetchBootstrapHtml(page, URLs.rewards.earn, '/earn') diff --git a/src/browser/BrowserUtils.ts b/src/browser/BrowserUtils.ts index 6c92c14..d71672c 100644 --- a/src/browser/BrowserUtils.ts +++ b/src/browser/BrowserUtils.ts @@ -96,7 +96,8 @@ export default class BrowserUtils { const newTab = pages[pages.length - 1] if (!newTab) { - throw this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', '找不到任何标签页!') + this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', '找不到任何标签页!') + throw new Error('找不到任何标签页!') } return newTab @@ -213,14 +214,27 @@ export default class BrowserUtils { `尝试点击选择器: ${selector},选项: ${JSON.stringify(options)}` ) - // Wait for selector to exist before clicking - await page.waitForSelector(selector, { timeout: 1000 }).catch(() => {}) + // Wait for selector to be visible before clicking + await page.waitForSelector(selector, { state: 'visible', timeout: 2000 }).catch(() => {}) // ghost-cursor expects its own Playwright Page type from a different // eslint-disable-next-line @typescript-eslint/no-explicit-any const cursor = createCursor(page as any) await cursor.click(selector, options) + return true + } catch (error) { + this.bot.logger.warn( + this.bot.isMobile, + 'GHOST-CLICK', + `ghost-cursor点击失败,尝试原生点击: ${error instanceof Error ? error.message : String(error)}` + ) + } + + // Fallback: use native Playwright click + try { + await page.locator(selector).first().click({ timeout: 2000 }) + this.bot.logger.debug(this.bot.isMobile, 'GHOST-CLICK', `原生点击成功: ${selector}`) return true } catch (error) { this.bot.logger.warn( diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index 4635726..bf3be16 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -422,18 +422,6 @@ export class Login { }) } - private async tryClick(page: Page, selector: string, label: string, timeout = 2000): Promise { - const found = await page.waitForSelector(selector, { state: 'visible', timeout }).catch(() => null) - if (!found) return false - - const clicked = await this.bot.browser.utils.ghostClick(page, selector) - if (!clicked) return false - - await this.waitForIdle(page, `after ${label}`) - this.bot.logger.info(this.bot.isMobile, 'LOGIN', `${label} 已点击`) - return true - } - private async handleState(state: LoginState, page: Page, account: Account): Promise { this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `处理状态: ${state}`) @@ -725,14 +713,12 @@ export class Login { this.bot.logger.info( this.bot.isMobile, 'LOGIN', - '检测到OTP代码输入页面;返回登录方式选择' + '检测到OTP代码输入页面;启动代码登录处理器' ) - if (!(await this.tryClick(page, this.selectors.backButton, 'Back button'))) { - this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'OTP页面上未找到返回按钮') - return false - } - + await this.waitForIdle(page, 'on OTP code entry page') + await this.codeLogin.handle(page) + this.bot.logger.info(this.bot.isMobile, 'LOGIN', '代码登录处理器完成') return true } @@ -900,11 +886,12 @@ export class Login { `上下文就绪 | actions=${actionsCount} | reportable=${reportableCount} | available=${availablePoints}` ) } catch (error) { - throw this.bot.logger.error( + this.bot.logger.error( this.bot.isMobile, 'GET-REWARD-SESSION', `获取奖励上下文失败: ${error instanceof Error ? error.message : String(error)}` ) + throw error } } diff --git a/src/browser/auth/methods/GetACodeLogin.ts b/src/browser/auth/methods/GetACodeLogin.ts index a71b68c..0bf4b2c 100644 --- a/src/browser/auth/methods/GetACodeLogin.ts +++ b/src/browser/auth/methods/GetACodeLogin.ts @@ -9,8 +9,9 @@ export class CodeLogin { 'input#proof-confirmation-email-input', '[data-testid="proof-confirmation"]' ] as const - private readonly maxManualSeconds = 60 - private readonly maxManualAttempts = 5 + private readonly maxEmailSeconds = 60 + private readonly maxCodeSeconds = 300 + private readonly maxManualAttempts = 3 constructor(private bot: MicrosoftRewardsBot) {} @@ -131,9 +132,9 @@ export class CodeLogin { for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { const email = await promptInput({ question: maskedHint - ? `Enter the full email address for "${maskedHint}" to send the code to (waiting ${this.maxManualSeconds}s): ` - : `Enter the email address to send the code to (waiting ${this.maxManualSeconds}s): `, - timeoutSeconds: this.maxManualSeconds, + ? `Enter the full email address for "${maskedHint}" to send the code to (waiting ${this.maxEmailSeconds}s): ` + : `Enter the email address to send the code to (waiting ${this.maxEmailSeconds}s): `, + timeoutSeconds: this.maxEmailSeconds, validate: value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) }) @@ -202,8 +203,8 @@ export class CodeLogin { for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) { const code = await promptInput({ - question: `Enter the 6-digit code (waiting ${this.maxManualSeconds}s): `, - timeoutSeconds: this.maxManualSeconds, + question: `Enter the 6-digit code (waiting ${this.maxCodeSeconds}s): `, + timeoutSeconds: this.maxCodeSeconds, validate: code => /^\d{6}$/.test(code) })