// ==UserScript== // @name Microsoft Rewards 全能自动化助手 // @namespace https://scriptcat.org/zh-CN/ // @version 1.5.0 // @description 自动完成 Microsoft Rewards 每日任务:通过官方 DAPI(channel=SAAndroid)完成每日活动、必应 App 连签(type:103)、阅读赚取、签到与更多活动,PC 搜索由真实浏览器逐字模拟真人输入。所有任务均以服务端返回的积分/余额作为唯一成功依据,不再虚报完成。 // @author YourName // @license MIT // @match https://cn.bing.com/* // @match https://www.bing.com/* // @match https://rewards.bing.com/* // @match https://login.live.com/oauth20_desktop.srf* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_notification // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @connect prod.rewardsplatform.microsoft.com // @connect rewards.bing.com // @connect login.live.com // @connect cn.bing.com // @connect www.bing.com // @connect top.baidu.com // @connect www.zhihu.com // @connect * // @run-at document-end // ==/UserScript== (function () { 'use strict'; // ==================== 常量 ==================== const DAPI_BASE = 'https://prod.rewardsplatform.microsoft.com/dapi'; const CLIENT_ID = '0000000040170455'; const REDIRECT_URI = 'https://login.live.com/oauth20_desktop.srf'; const SCOPE = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'; const PAUSE_EVERY_N_SEARCHES = 5; const PAUSE_DURATION_MS = 10 * 60 * 1000; // 待搜索关键词的跨标签页传递键 const PENDING_SEARCH_KEY = 'mr_pending_search'; const PENDING_VALID_MS = 5 * 60 * 1000; // 5 分钟内有效 // DAPI 里搜索任务的分类标记(attributes['Classification.Tag']) const SEARCH_TAGS = { pc: 'PCSearch', mobile: 'MobileSearch' }; // 复用同一个命名标签页做全部搜索,避免异步 window.open 被浏览器弹窗拦截 const SEARCH_TAB_NAME = 'MR_REWARDS_SEARCH_TAB'; // 网页端签到是固定 offerid;手机必应 App 的连签则是独立的 type:103 请求(见 appCheckIn) const WEB_CHECKIN_OFFERID = 'Gamification_Sapphire_DailyCheckIn'; // X-Rewards-Language 需要和账号市场匹配 const DEFAULT_COUNTRY = 'cn'; const LANG_BY_COUNTRY = { cn: 'zh-Hans', sg: 'zh-Hans', tw: 'zh-Hant', hk: 'zh-Hant', mo: 'zh-Hant', jp: 'ja', kr: 'ko', us: 'en', gb: 'en', au: 'en', ca: 'en', in: 'en', de: 'de', fr: 'fr', es: 'es', mx: 'es', br: 'pt', it: 'it', ru: 'ru', }; const STORAGE_KEYS = { ACCESS_TOKEN: 'mr_access_token', REFRESH_TOKEN: 'mr_refresh_token', EXPIRES_AT: 'mr_expires_at', SETTINGS: 'mr_settings', TASK_ORDER: 'mr_task_order', PROGRESS: 'mr_progress', COUNTRY: 'mr_country', // 从 /me 回包里学到的账号市场(cn / us …) }; const DEFAULT_SETTINGS = { searchStayMin: 2, readStayMin: 2, maxSearches: 30, maxMobileSearches: 20, readArticleMax: 10, // 阅读赚取每天最多可领的篇数 searchSafetyCap: 40, // 单轮搜索次数硬上限,防止无限刷 randomOrder: false, authorizeAppCheckin: true, authorizeAppRead: true, authorizeMobileSearch: true, enableDailySet: true, enableAppCheckin: true, enableAppRead: true, enableMoreActivities: true, enablePcSearch: true, enableMobileSearch: true, }; const DEFAULT_TASK_ORDER = [ 'dailySet', 'appCheckin', 'appRead', 'moreActivities', 'pcSearch', 'mobileSearch', ]; const TASK_LABELS = { dailySet: '每日任务', appCheckin: '每日签到', appRead: '每日阅读', moreActivities: '更多活动', pcSearch: 'PC搜索', mobileSearch: '移动端搜索', }; const TASKS_REQUIRING_AUTH = { appCheckin: 'authorizeAppCheckin', appRead: 'authorizeAppRead', mobileSearch: 'authorizeMobileSearch', }; const THEME = { bg: 'rgba(255,255,255,0.72)', textColor: '#1d1d1f', borderColor: 'rgba(0,0,0,0.08)', cardBg: 'rgba(255,255,255,0.5)', cardHover: 'rgba(0,0,0,0.04)', headerBg: 'rgba(255,255,255,0.4)', }; // ==================== 工具 ==================== const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; // DAPI 的 attributes 里 complete / hidden 是字符串 "true"/"false"("false" 也是真值!), // 必须显式转换,否则会把「未完成」误判成「已完成」。 function toBool(v) { return v === true || String(v).toLowerCase() === 'true'; } // 依次尝试多个字段,返回第一个能解析成整数的值 function firstInt() { for (let i = 0; i < arguments.length; i++) { if (arguments[i] === undefined || arguments[i] === null) continue; const n = parseInt(arguments[i], 10); if (Number.isFinite(n)) return n; } return 0; } function shuffleArray(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function todayStr() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function formatDuration(ms) { if (ms <= 0) return '0秒'; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const sec = s % 60; if (m >= 60) { const h = Math.floor(m / 60); return `${h}小时${m % 60}分`; } return m > 0 ? `${m}分${sec}秒` : `${sec}秒`; } function humanDelay(baseMs, jitterRatio = 0.4) { const jitter = baseMs * jitterRatio; return Math.max(500, baseMs + (Math.random() * 2 - 1) * jitter); } function log(msg) { console.log(`[Rewards助手] ${msg}`); const logEl = document.getElementById('mr-log-area'); if (logEl) { const line = document.createElement('div'); line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; } } function waitForElement(selector, timeoutMs = 10000, root = document) { return new Promise((resolve) => { const start = Date.now(); const check = () => { const el = root.querySelector(selector); if (el) return resolve(el); if (Date.now() - start > timeoutMs) return resolve(null); setTimeout(check, 100); }; check(); }); } // ==================== 存储 ==================== // 任务开关的存储键统一由 enableKey() 生成:pcSearch → enablePcSearch。 // ★ 旧版这里不一致:默认值写的是 enablePCSearch,而运行时拼出来的是 enablePcSearch, // 结果 PC 搜索在设置面板里显示「已开启」,runAll 却把它当成未启用直接过滤掉, // 从来不会真正执行——这正是「点了开始、任务显示完成、实际什么都没做」的原因之一。 function enableKey(taskId) { return 'enable' + taskId.charAt(0).toUpperCase() + taskId.slice(1); } function getSettings() { const stored = GM_getValue(STORAGE_KEYS.SETTINGS, {}) || {}; const s = { ...DEFAULT_SETTINGS, ...stored }; // 迁移旧版写法的开关值 if (stored.enablePCSearch !== undefined && stored.enablePcSearch === undefined) { s.enablePcSearch = stored.enablePCSearch; } // 补齐缺失项(旧存档里没有的新设置) Object.keys(DEFAULT_SETTINGS).forEach((k) => { if (s[k] === undefined) s[k] = DEFAULT_SETTINGS[k]; }); return s; } function saveSettings(s) { GM_setValue(STORAGE_KEYS.SETTINGS, s); } function getTaskOrder() { const stored = GM_getValue(STORAGE_KEYS.TASK_ORDER, [...DEFAULT_TASK_ORDER]); const filtered = stored.filter((id) => DEFAULT_TASK_ORDER.includes(id)); DEFAULT_TASK_ORDER.forEach((id) => { if (!filtered.includes(id)) filtered.push(id); }); return filtered; } function saveTaskOrder(o) { GM_setValue(STORAGE_KEYS.TASK_ORDER, o); } function getProgress() { const p = GM_getValue(STORAGE_KEYS.PROGRESS, null); if (!p || p.date !== todayStr()) { return { date: todayStr(), completedTasks: [], searchCount: 0, mobileSearchCount: 0, lastPauseAt: 0, pausedUntil: 0, todayPoints: 0, lastBalance: 0, }; } return p; } function saveProgress(p) { GM_setValue(STORAGE_KEYS.PROGRESS, p); } function clearProgress() { GM_setValue(STORAGE_KEYS.PROGRESS, null); } // ==================== OAuth ==================== function buildAuthUrl() { return ( `https://login.live.com/oauth20_authorize.srf?client_id=${CLIENT_ID}` + `&scope=${encodeURIComponent(SCOPE)}` + `&response_type=code` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&display=touch` ); } // 授权回调页注入的顶部提示条(回调页没有悬浮窗,必须可见) function showRedirectStatus(msg, ok) { const id = 'mr-redirect-status'; let el = document.getElementById(id); if (!el) { el = document.createElement('div'); el.id = id; el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999999;' + 'padding:12px 16px;font:13px/1.5 -apple-system,Segoe UI,sans-serif;' + 'color:#fff;text-align:center;'; document.documentElement.appendChild(el); } el.style.background = ok ? '#28a745' : '#e74c3c'; el.textContent = msg; console.log('[Rewards助手] ' + msg); } async function captureAuthCode() { const url = new URL(location.href); const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); if (error) { showRedirectStatus(`❌ 授权被拒绝:${error} ${url.searchParams.get('error_description') || ''}`, false); return; } if (!code) return; GM_setValue('mr_auth_code', code); showRedirectStatus('✅ 已捕获授权码,正在交换 Token...', true); try { await exchangeCodeForToken(code); showRedirectStatus('🎉 授权成功!2 秒后跳转回 Rewards...', true); setTimeout(() => { location.href = 'https://rewards.bing.com/'; }, 2000); } catch (e) { showRedirectStatus(`❌ Token 交换失败:${e.message}`, false); } } function exchangeCodeForToken(code) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://login.live.com/oauth20_token.srf', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // ★ 关键:authorization_code 阶段不要再传 scope,否则微软返回 invalid_request data: `client_id=${CLIENT_ID}` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&code=${encodeURIComponent(code)}` + `&grant_type=authorization_code`, onload(resp) { try { const data = JSON.parse(resp.responseText); if (resp.status >= 200 && resp.status < 300 && data.access_token) { GM_setValue(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); if (data.refresh_token) GM_setValue(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); GM_setValue(STORAGE_KEYS.EXPIRES_AT, Date.now() + (data.expires_in || 3600) * 1000); log('Token 获取成功'); GM_notification({ text: 'Rewards 授权成功!', title: 'Rewards 助手' }); resolve(data.access_token); } else { const err = data.error_description || data.error || ('HTTP ' + resp.status); reject(new Error(err)); } } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); } function refreshAccessToken() { const refreshToken = GM_getValue(STORAGE_KEYS.REFRESH_TOKEN, ''); if (!refreshToken) return Promise.reject(new Error('无 refresh_token')); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://login.live.com/oauth20_token.srf', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `client_id=${CLIENT_ID}` + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&refresh_token=${encodeURIComponent(refreshToken)}` + `&grant_type=refresh_token`, onload(resp) { try { const data = JSON.parse(resp.responseText); if (resp.status >= 200 && resp.status < 300 && data.access_token) { GM_setValue(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); if (data.refresh_token) GM_setValue(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); GM_setValue(STORAGE_KEYS.EXPIRES_AT, Date.now() + (data.expires_in || 3600) * 1000); resolve(data.access_token); } else { const err = data.error_description || data.error || ('HTTP ' + resp.status); reject(new Error(err)); } } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function getValidToken() { let token = GM_getValue(STORAGE_KEYS.ACCESS_TOKEN, ''); const expiresAt = GM_getValue(STORAGE_KEYS.EXPIRES_AT, 0); if (!token || Date.now() > expiresAt - 60000) { try { token = await refreshAccessToken(); } catch (e) { log('Token 刷新失败,需要重新授权'); return null; } } return token; } // ==================== DAPI(prod.rewardsplatform.microsoft.com) ==================== // 微软 Rewards 官方客户端(含手机必应 App)与新版 rewards.bing.com 都只走这套 dapi: // GET /dapi/me?channel=SAAndroid&options=511 → 余额 + 全部任务(promotions)+ 计数器 // POST /dapi/me/activities {type:101, attributes:{offerid}} → 完成/领取 urlreward / checkin / msnreadearn // POST /dapi/me/activities {type:103, channel:'SAAndroid', attributes:{}} → 手机必应 App 的每日连签 // 搜索积分例外:接口不发放搜索积分,必须由真实浏览器完成搜索(见 TaskRunner.doSearch)。 // 关键细节:attributes.complete / hidden 是字符串 "true"/"false";分类标记的键名是 // 'Classification.Tag'(不是 classificationTag);offerid 全小写。 function getCountry() { const c = String(GM_getValue(STORAGE_KEYS.COUNTRY, DEFAULT_COUNTRY) || DEFAULT_COUNTRY); return c.toLowerCase() || DEFAULT_COUNTRY; } function setCountry(c) { if (c && typeof c === 'string' && c.length >= 2) GM_setValue(STORAGE_KEYS.COUNTRY, c.toLowerCase()); } function langForCountry(c) { return LANG_BY_COUNTRY[String(c || '').toLowerCase()] || 'en'; } // 官方客户端每次请求都带一个唯一 id;缺失或重复会被后端当成重复提交 function randomHex(bytes = 32) { try { const buf = new Uint8Array(bytes); crypto.getRandomValues(buf); return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); } catch (e) { let s = ''; for (let i = 0; i < bytes * 2; i++) s += Math.floor(Math.random() * 16).toString(16); return s; } } function dapiHeaders(token, json, extra) { const country = getCountry(); const h = { Authorization: `Bearer ${token}`, 'X-Rewards-Country': country, 'X-Rewards-Language': langForCountry(country), }; if (json) h['Content-Type'] = 'application/json'; return Object.assign(h, extra || {}); } // 统一 dapi 请求:非 2xx 抛错,错误信息尽量带上后端原始描述 function dapiRequest(method, path, token, body = null, extraHeaders = null) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url: `${DAPI_BASE}${path}`, headers: dapiHeaders(token, !!body, extraHeaders), data: body ? JSON.stringify(body) : undefined, timeout: 20000, onload(resp) { let data = {}; try { data = JSON.parse(resp.responseText); } catch (e) {} if (resp.status >= 200 && resp.status < 300) { resolve(data); } else { const msg = (data && (data.error_description || data.error || data.message)) || `HTTP ${resp.status}`; reject(new Error(msg)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); } // 读取余额 + 全部任务。channel=SAAndroid 才能拿到手机 App 通道的任务与搜索计数器。 async function fetchRewardsData(token, channel = 'SAAndroid') { const qs = channel ? `?channel=${channel}&options=511` : '?options=511'; const resp = await dapiRequest('GET', `/me${qs}`, token); const r = resp.response || {}; const c = r.profile && r.profile.attributes && r.profile.attributes.country; if (c) setCountry(c); // 学到账号真实市场,后续请求头跟着走 return r; } // 把原始 promotion 规整成固定形状,避免各处重复踩字段名的坑 function promo(p) { const a = (p && p.attributes) || {}; return { raw: p, offerId: a.offerid || a.offerId || '', name: p && p.name ? p.name : '', title: a.title || (p && p.name) || '', type: String(a.type || '').toLowerCase(), complete: toBool(a.complete), hidden: toBool(a.hidden), progress: firstInt(a.progress, a.activityprogress), max: firstInt(a.max, a.activitymax), destination: a.destination || (p && p.destination) || '', tag: a['Classification.Tag'] || a['AnswerScenario.Tag'] || '', }; } // 通用领取:urlreward(每日活动/更多活动)、checkin(签到)、msnreadearn(阅读)都走这个 async function claimActivity(token, offerId) { const body = { amount: 1, country: getCountry(), id: randomHex(32), type: 101, attributes: { offerid: offerId }, }; const resp = await dapiRequest('POST', '/me/activities', token, body); const r = resp.response || {}; return { points: firstInt(r.activity && r.activity.p), balance: firstInt(r.balance), duplicate: !!r.isDuplicate, }; } // 手机必应 App 的每日连签(「必应应用连续打卡」):type:103、无 offerid、 // 必须声明 channel:SAAndroid 并带上 App 身份头,否则不计分。 async function appCheckIn(token) { const body = { amount: 1, country: getCountry(), id: randomHex(32), type: 103, channel: 'SAAndroid', attributes: {}, risk_context: {}, }; const resp = await dapiRequest('POST', '/me/activities', token, body, { 'X-Rewards-AppId': 'SAAndroid', 'X-Rewards-PartnerId': 'startapp', }); const r = resp.response || {}; return { points: firstInt(r.activity && r.activity.p), balance: firstInt(r.balance), duplicate: !!r.isDuplicate, }; } // 网页端签到(与 App 连签是两条独立流水,同一天可以各领一次) function webCheckIn(token) { return claimActivity(token, WEB_CHECKIN_OFFERID); } async function fetchPoints(token) { const data = await fetchRewardsData(token); const total = firstInt(data.balance); let today = null; if (data.dailyPoints !== undefined && data.dailyPoints !== null) today = firstInt(data.dailyPoints); return { total, todayEarned: today }; } // 官方判定口径:已完成的跳过;签到虽然常被标 hidden,但它确实可领;其余只认 urlreward / msnreadearn function isClaimable(p) { if (p.complete) return false; if (p.type === 'checkin') return true; if (p.hidden) return false; return p.type === 'urlreward' || p.type === 'msnreadearn'; } // ==================== 热搜源(百度 + 知乎) ==================== async function getSearchQueries(count) { const queries = []; try { const hotwords = await fetchHotwords(); queries.push(...hotwords); log(`🔥 热搜源获取成功,共 ${hotwords.length} 条`); } catch (e) { log('⚠ 热搜获取失败,使用内置词库'); } const builtin = [ '今日新闻', '天气预报', '科技动态', '健康养生', '电影推荐', '旅游攻略', '美食菜谱', '股票行情', '人工智能', '编程教程', '历史故事', '音乐推荐', '体育赛事', '游戏攻略', '学习方法', '职场技能', '理财知识', '育儿经验', '汽车评测', '数码产品', '动漫推荐', '摄影技巧', '宠物养护', '健身计划', '读书笔记', ]; const shuffled = shuffleArray(builtin); while (queries.length < count) { queries.push(shuffled[queries.length % shuffled.length]); } return [...new Set(queries)].slice(0, count); } async function fetchHotwords() { const sources = [ { name: '百度热搜', url: 'https://top.baidu.com/api/board?platform=wise&tab=realtime', parse: parseBaidu }, { name: '知乎热榜', url: 'https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', parse: parseZhihu }, ]; for (const src of sources) { try { const words = await fetchFromSource(src.url, src.parse); if (words && words.length > 0) { console.log(`[Rewards助手] ${src.name} 获取 ${words.length} 条`); return words; } } catch (e) { console.warn(`[Rewards助手] ${src.name} 失败:`, e.message); } } throw new Error('所有热搜源均失败'); } function fetchFromSource(url, parser) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', }, timeout: 8000, onload(resp) { try { const data = JSON.parse(resp.responseText); resolve(parser(data) || []); } catch (e) { reject(e); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('timeout')), }); }); } function parseBaidu(data) { const words = []; const cards = data?.data?.cards || []; cards.forEach((card) => { (card.content || []).forEach((item) => { const w = item.word || item.query || item.title; if (w && typeof w === 'string') words.push(w); }); }); return words; } function parseZhihu(data) { const words = []; const list = data?.data || []; list.forEach((item) => { const title = item.target?.title || item.target?.titleArea?.text; if (title && typeof title === 'string') words.push(title); }); return words; } // ==================== 逐字输入模拟 ==================== function setPendingSearch(query) { GM_setValue(PENDING_SEARCH_KEY, JSON.stringify({ query, timestamp: Date.now(), })); } async function handlePendingSearch(pendingRaw) { let pending; try { pending = JSON.parse(pendingRaw); } catch (e) { GM_setValue(PENDING_SEARCH_KEY, ''); return; } if (!pending || Date.now() - pending.timestamp > PENDING_VALID_MS) { GM_setValue(PENDING_SEARCH_KEY, ''); return; } const query = pending.query; GM_setValue(PENDING_SEARCH_KEY, ''); console.log('[Rewards助手] 开始逐字输入:', query); const searchBox = await waitForElement('#sb_form_q', 10000); if (!searchBox) { console.warn('[Rewards助手] 未找到搜索框,降级为直接跳转'); location.href = `https://cn.bing.com/search?q=${encodeURIComponent(query)}`; return; } searchBox.focus(); searchBox.value = ''; searchBox.dispatchEvent(new Event('input', { bubbles: true })); for (let i = 0; i < query.length; i++) { const char = query[i]; searchBox.value += char; searchBox.dispatchEvent(new Event('input', { bubbles: true })); searchBox.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true })); searchBox.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true })); searchBox.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true })); await sleep(rand(60, 220)); } await sleep(rand(300, 800)); const form = document.getElementById('sb_form'); if (form) { form.submit(); } else { searchBox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); searchBox.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); searchBox.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); } console.log('[Rewards助手] 逐字输入并提交完成'); } // ==================== 搜索入口 ==================== // 搜索积分只能靠真实浏览器(见 TaskRunner.doSearch)。用 GM_xmlhttpRequest 伪造 iPhone UA // 请求 Bing 页面并不会让 Rewards 记分——服务端按会话/设备上下文判定,这就是旧版「移动搜索 // 显示完成但 App 进度仍是 0」的原因。按用户确认的方案:移动端搜索配额统一用真实浏览器搜索覆盖。 function getSearchHome() { return getCountry() === 'cn' ? 'https://cn.bing.com/' : 'https://www.bing.com/'; } // ==================== TaskRunner ==================== class TaskRunner { constructor(preOpenedSearchWindow = null) { this.running = false; this.token = null; this.settings = getSettings(); this.baseOrder = getTaskOrder(); this.taskOrder = this.settings.randomOrder ? shuffleArray(this.baseOrder) : [...this.baseOrder]; this.stats = { completed: 0, failed: 0, points: 0, claimed: 0 }; this.openedWindows = []; // 搜索标签页:由「开始执行」按钮在同步点击上下文里预先开好(见 createFloatWindow), // 这里直接接管;否则后面几十秒才发起的 window.open 会被浏览器弹窗拦截。 this.searchWindow = preOpenedSearchWindow || null; this.claimedOfferIds = new Set(); // 本轮已成功领取过的 offerid,避免收尾复查重复请求 this.allDone = false; this.progress = getProgress(); } stop() { this.running = false; this.closeAllWindows(); } closeAllWindows() { this.openedWindows.forEach((w) => { try { if (w && !w.closed) w.close(); } catch (e) {} }); this.openedWindows = []; this.searchWindow = null; } async wait(ms) { const end = Date.now() + ms; while (Date.now() < end) { if (!this.running) return false; const remaining = end - Date.now(); if (remaining > 3000) updateCountdown(remaining); await sleep(Math.min(500, remaining)); } updateCountdown(0); return this.running; } async waitUntil(timestamp) { while (Date.now() < timestamp) { if (!this.running) return false; const remaining = timestamp - Date.now(); updateCountdown(remaining); await sleep(Math.min(1000, remaining)); } updateCountdown(0); return this.running; } openTracked(url) { const w = window.open(url, '_blank'); if (w) this.openedWindows.push(w); return w; } // 搜索标签页复用同一个具名窗口:只有第一次是真正的 window.open(来自点击,不会被拦截), // 之后改成跨域写入 location.href 导航,避免几十次异步 window.open 触发弹窗拦截。 openOrReuseSearchTab(url) { let w = this.searchWindow; if (w && !w.closed) { try { w.location.href = url; return w; } catch (e) { /* 跨域写入失败则退回重开 */ } } w = window.open(url, SEARCH_TAB_NAME); if (w) { this.searchWindow = w; this.openedWindows.push(w); } return w; } async init() { this.token = await getValidToken(); if (!this.token) { log('❌ 无有效 Token,正在打开授权页...'); window.open(buildAuthUrl(), '_blank'); return false; } log('✅ Token 有效,准备执行任务'); await this.refreshPoints(); return true; } async refreshPoints() { try { const pts = await fetchPoints(this.token); uiState.totalPoints = pts.total; if (pts.todayEarned !== null) uiState.todayPoints = pts.todayEarned; updateUI(); } catch (e) { console.warn('[Rewards 助手] 积分刷新失败:', e.message); } } async runAll() { this.running = true; const ok = await this.init(); if (!ok) { this.running = false; this.closeAllWindows(); return; } const enabledTasks = this.taskOrder.filter((t) => { const k = enableKey(t); return this.settings[k]; }); uiState.totalTasks = enabledTasks.length; log(`📋 本次执行顺序:${enabledTasks.map((t) => TASK_LABELS[t]).join(' → ')}` + (this.settings.randomOrder ? '(随机)' : '')); const pendingTasks = this.taskOrder.filter((t) => { if (this.progress.completedTasks.includes(t)) return false; const k = enableKey(t); if (!this.settings[k]) return false; const authKey = TASKS_REQUIRING_AUTH[t]; if (authKey && !this.settings[authKey]) return false; return true; }); if (this.progress.completedTasks.length > 0) { log(`🔄 检测到历史进度,已完成 ${this.progress.completedTasks.length} 个任务,续接执行`); } if (this.progress.pausedUntil > Date.now()) { const remain = this.progress.pausedUntil - Date.now(); log(`⏸ 检测到防封暂停,剩余 ${formatDuration(remain)}`); if (!await this.waitUntil(this.progress.pausedUntil)) { this.running = false; this.closeAllWindows(); return; } log('▶ 暂停结束,继续执行'); } for (const taskId of pendingTasks) { if (!this.running) break; log(`▶ 开始执行:${TASK_LABELS[taskId]}`); let res = { ok: false, points: 0 }; // 每个任务方法都返回 { ok, points }:ok 的唯一依据是服务端回包/回读结果 try { res = (await this.executeTask(taskId)) || res; } catch (e) { log(`⚠ ${TASK_LABELS[taskId]} 异常: ${e.message}`); } if (res.ok) { if (!this.progress.completedTasks.includes(taskId)) { this.progress.completedTasks.push(taskId); saveProgress(this.progress); } } else { this.stats.failed++; } uiState.completedTasks = this.progress.completedTasks.length; uiState.stats = this.stats; updateUI(); if (!this.running) break; await this.wait(humanDelay(2000)); } if (this.running) { log('💎 开始自动领取积分...'); try { await this.claimAllRewards(); } catch (e) { log(` ⚠ 领取异常: ${e.message}`); } } if (this.running) { await this.refreshPoints(); await this.checkAllCompleted(); const msg = this.allDone ? `🎉 所有任务已完成!成功 ${this.stats.completed},失败 ${this.stats.failed},领取 ${this.stats.claimed} 项,今日积分 +${uiState.todayPoints}` : `🎉 本轮执行完毕(仅统计已回读确认的项),成功 ${this.stats.completed},失败 ${this.stats.failed},领取 ${this.stats.claimed} 项`; log(msg); GM_notification({ text: msg, title: 'Rewards 助手 - 完成通知', timeout: 10000, }); } else { log('⏹ 任务已被用户停止'); } this.running = false; this.closeAllWindows(); } // —— 通用领取引擎 —— // 适用于 urlreward(每日活动/更多活动)、checkin(签到)、msnreadearn(阅读赚取)。 // 唯一的成功依据是服务端回包里的 activity.p(本次积分)与 balance(余额): // >0 积分 = 真领取成功;0/duplicate = 今天已经领过、未解锁、或需要先真实访问页面。 // single=true 时不做「阅读按篇连领」,用于收尾复查,避免重复消耗时间。 async claimPromotions(list, label, single = false) { let claimed = 0; let points = 0; for (const p of list) { if (!this.running) break; if (!p.offerId) continue; if (this.claimedOfferIds.has(p.offerId)) continue; // 本轮已领过,收尾复查时跳过 try { let res = await claimActivity(this.token, p.offerId); // 阅读赚取是按「篇」计分的:同一个 offerid 连续领,直到积分不再增长 if (!single && p.type === 'msnreadearn' && res.points > 0) { const maxArticles = this.settings.readArticleMax || 10; let gained = res.points; let lastBalance = res.balance; for (let i = 1; i < maxArticles; i++) { if (!this.running) break; if (!await this.wait(humanDelay(rand(4000, 9000)))) break; const r2 = await claimActivity(this.token, p.offerId); if (r2.points <= 0 || r2.duplicate || r2.balance === lastBalance) break; gained += r2.points; lastBalance = r2.balance; log(` 📖 阅读第 ${i + 1} 篇 +${r2.points}`); } res = { points: gained, balance: lastBalance, duplicate: false }; } if (res.points > 0) { claimed++; points += res.points; this.stats.claimed++; this.stats.points += res.points; this.claimedOfferIds.add(p.offerId); log(` ✓ ${p.title || p.name || p.offerId} +${res.points}(余额 ${res.balance})`); } else { log(` • ${p.title || p.name || p.offerId}:本次 +0(已领过 / 未解锁 / 需先访问页面)`); } } catch (e) { log(` ⚠ ${p.title || p.offerId} 领取失败:${e.message}`); } // 像真人一样间隔,避免一秒钟领完十几项 if (!await this.wait(humanDelay(rand(2500, 6000)))) break; } if (points > 0) log(` ${label}:本轮 +${points} 积分(${claimed} 项)`); return { claimed, points }; } // 收尾复查:把本轮新出现的可领取项再扫一遍(只单次领取,不做阅读连领) async claimAllRewards() { const data = await fetchRewardsData(this.token); const list = data.promotions.map(promo).filter((p) => isClaimable(p) && p.type !== 'search'); if (!list.length) { log(' - 收尾复查:已无待领取项'); return; } log(` - 收尾复查发现 ${list.length} 项,尝试领取`); await this.claimPromotions(list, '收尾领取', true); } async checkAllCompleted() { try { const data = await fetchRewardsData(this.token); const types = ['urlreward', 'checkin', 'msnreadearn', 'search']; const pending = data.promotions.map(promo) .filter((p) => !p.complete && !p.hidden && types.includes(p.type)); this.allDone = pending.length === 0; if (this.allDone) { log(' ✓ 服务端已无未完成任务'); } else { const names = pending.slice(0, 6).map((p) => p.title || p.type).join('、'); log(` ℹ 服务端仍有 ${pending.length} 个未完成任务:${names}${pending.length > 6 ? ' …' : ''}`); } } catch (e) { log(' ⚠ 完成状态检查失败: ' + e.message); this.allDone = false; } } async executeTask(taskId) { switch (taskId) { case 'dailySet': return this.doDailySet(); case 'appCheckin': return this.doAppCheckin(); case 'appRead': return this.doAppRead(); case 'moreActivities': return this.doMoreActivities(); case 'pcSearch': return this.doSearch('pc'); case 'mobileSearch': return this.doSearch('mobile'); default: return { ok: true, points: 0 }; } } // ============================================================ // 每日任务 / 每日活动(urlreward)—— 直接走 DAPI 领取 // 手机必应 App 里那 3 张 +10 的卡片、网页端的每日任务、升级活动都属于这一类。 // ============================================================ async doDailySet() { const data = await fetchRewardsData(this.token); const list = data.promotions.map(promo).filter((p) => isClaimable(p) && p.type === 'urlreward'); if (!list.length) { log(' - 每日任务/每日活动已完成或不存在'); return { ok: true, points: 0 }; } log(` - 发现 ${list.length} 个每日任务/每日活动(urlreward)`); const r = await this.claimPromotions(list, '每日任务'); if (r.points > 0) this.stats.completed++; return { ok: r.claimed > 0, points: r.points }; } // ============================================================ // 每日签到 —— 两条独立流水: // 1) 网页端签到:offerid = Gamification_Sapphire_DailyCheckIn(type 101) // 2) 手机必应 App 连签(「必应应用连续打卡 · 签到 0/1」):type 103 + channel SAAndroid // 这两条都无需真的打开 App,直接调官方接口即可计分。 // ============================================================ async doAppCheckin() { let points = 0; try { const r = await webCheckIn(this.token); if (r.points > 0) { points += r.points; log(` ✓ 网页签到 +${r.points}(余额 ${r.balance})`); } else log(' • 网页签到:今日已完成(返回 0 积分)'); } catch (e) { log(' ⚠ 网页签到失败:' + e.message); } if (!await this.wait(humanDelay(rand(2000, 4000)))) return { ok: false, points }; try { const r = await appCheckIn(this.token); if (r.points > 0) { points += r.points; log(` ✓ 必应 App 连签 +${r.points}(余额 ${r.balance})`); } else log(' • 必应 App 连签:今日已完成(返回 0 积分)'); } catch (e) { log(' ⚠ 必应 App 连签失败:' + e.message); } if (!await this.wait(humanDelay(rand(2000, 4000)))) return { ok: false, points }; // 部分市场还有独立的 type=checkin 任务项,一并领掉 try { const data = await fetchRewardsData(this.token); const list = data.promotions.map(promo) .filter((p) => !p.complete && p.type === 'checkin' && p.offerId); if (list.length) { const r = await this.claimPromotions(list, '签到任务'); points += r.points; } } catch (e) { log(' ⚠ 签到任务扫描失败:' + e.message); } if (points > 0) this.stats.completed++; return { ok: points > 0, points }; } // ============================================================ // 每日阅读赚取(msnreadearn)—— DAPI 按篇计分,不需要真的打开文章 // 对应手机 App 里的「阅读以赚取 0/30」,同一 offerid 可连领最多 readArticleMax 篇。 // ============================================================ async doAppRead() { const data = await fetchRewardsData(this.token); const list = data.promotions.map(promo).filter((p) => isClaimable(p) && p.type === 'msnreadearn'); if (!list.length) { log(' - 阅读赚取已完成或本账号无此任务'); return { ok: true, points: 0 }; } log(` - 发现 ${list.length} 个阅读任务,每个最多连领 ${this.settings.readArticleMax} 篇`); const r = await this.claimPromotions(list, '每日阅读'); if (r.points > 0) this.stats.completed++; return { ok: r.claimed > 0, points: r.points }; } // ============================================================ // 更多活动 —— urlreward / 阅读之外的可领取项兜底 // ============================================================ async doMoreActivities() { const data = await fetchRewardsData(this.token); const list = data.promotions.map(promo).filter((p) => isClaimable(p) && p.type !== 'urlreward' && p.type !== 'msnreadearn' && p.type !== 'checkin'); if (!list.length) { log(' - 更多活动已完成或不存在'); return { ok: true, points: 0 }; } log(` - 发现 ${list.length} 个更多活动`); const r = await this.claimPromotions(list, '更多活动'); if (r.points > 0) this.stats.completed++; return { ok: r.claimed > 0, points: r.points }; } // ============================================================ // 搜索(pc / mobile)—— 唯一必须用真实浏览器完成的任务 // 搜索积分不能通过 /me/activities 领取,服务端只认真实搜索会话。 // 进度直接读服务端 promotions 里的 progress/max(例如 App 上的「搜索以赚取 3/60」)。 // 移动端搜索按用户确认的方案,同样用真实浏览器搜索覆盖。 // ============================================================ async doSearch(kind) { const isPc = kind === 'pc'; const label = isPc ? 'PC搜索' : '移动端搜索'; const tag = SEARCH_TAGS[kind]; const before = await fetchRewardsData(this.token); const searchPromos = before.promotions.map(promo).filter((p) => p.type === 'search'); let target = searchPromos.find((p) => (p.tag || '').toLowerCase() === tag.toLowerCase()); // 部分市场(如国区统一搜索配额)没有分类标记,PC 任务回退到第一个搜索任务 if (!target && isPc) target = searchPromos[0]; if (!target) { log(isPc ? ' - 未找到搜索任务(账号过新或搜索配额未开放)' : ' - 本账号没有独立的移动端搜索任务(搜索配额是统一的,已由 PC 搜索覆盖)'); return { ok: true, points: 0 }; } const startProgress = target.progress; let cur = target; log(` - ${label}服务端进度 ${startProgress}/${target.max}` + (target.tag ? `(tag=${target.tag})` : '(无分类标记,按统一搜索配额处理)')); if (target.max > 0 && startProgress >= target.max) { log(' - 搜索积分今日已满,跳过'); return { ok: true, points: 0 }; } const homeUrl = getSearchHome(); const safetyCap = this.settings.searchSafetyCap || 40; const stayMs = Math.max(10, this.settings.searchStayMin * 60) * 1000; const queries = await getSearchQueries(safetyCap + 5); let done = 0; let stalls = 0; let nextCheck = rand(2, 4); let blocked = false; for (const q of queries) { if (!this.running) break; if (cur.max > 0 && cur.progress >= cur.max) break; if (done >= safetyCap) { log(` - 已达单轮搜索上限 ${safetyCap} 次,停止`); break; } // 每 5 次搜索自动暂停 10 分钟防封(沿用旧版策略) if (done > 0 && done % PAUSE_EVERY_N_SEARCHES === 0 && this.progress.lastPauseAt !== done) { const pauseUntil = Date.now() + PAUSE_DURATION_MS; this.progress.pausedUntil = pauseUntil; this.progress.lastPauseAt = done; saveProgress(this.progress); log(` ⏸ 已搜索 ${done} 次,自动暂停 ${PAUSE_DURATION_MS / 60000} 分钟防封...`); GM_notification({ text: `已搜索 ${done} 次,暂停 ${PAUSE_DURATION_MS / 60000} 分钟`, title: 'Rewards 助手 - 防封暂停', }); if (!await this.waitUntil(pauseUntil)) break; this.progress.pausedUntil = 0; saveProgress(this.progress); log(' ▶ 暂停结束,继续搜索'); } log(` 🔍 ${label} (${done + 1}): "${q}"`); setPendingSearch(q); const win = this.openOrReuseSearchTab(homeUrl); if (!win) { log(' ⚠ 搜索标签页被浏览器拦截:请在地址栏右侧允许本站弹出窗口后重试'); blocked = true; break; } done++; this.progress.searchCount++; if (!isPc) this.progress.mobileSearchCount++; saveProgress(this.progress); // 停留一段时间再搜下一个(拟人化;可在设置里调整) if (!await this.wait(stayMs)) break; // 每 2-4 次回读服务端进度,进度不动就停下来,而不是盲目刷满 if (done >= nextCheck) { if (!await this.wait(humanDelay(2000))) break; try { const fresh = await fetchRewardsData(this.token); const freshSearch = fresh.promotions.map(promo).filter((p) => p.type === 'search'); const upd = freshSearch.find((p) => (p.tag || '').toLowerCase() === tag.toLowerCase()) || freshSearch[0]; if (upd) { log(` ↻ 服务端进度 ${upd.progress}/${upd.max}(已搜索 ${done} 次)`); if (upd.progress <= cur.progress) stalls++; else stalls = 0; cur = upd; if (stalls >= 2) { log(' ⚠ 搜索进度连续 2 次无变化,停止(可能被风控,或该配额不由桌面搜索计数)'); break; } } } catch (e) { log(' ⚠ 进度回读失败:' + e.message); } nextCheck = done + rand(2, 4); } } const gained = Math.max(0, cur.progress - startProgress); if (gained > 0) { this.stats.completed += gained; log(` ✓ ${label}已确认计分 +${gained}(${cur.progress}/${cur.max})`); return { ok: true, points: gained }; } log(` ⚠ ${label}未计分(${cur.progress}/${cur.max}):` + (blocked ? '弹窗被拦截' : '请确认已登录 Bing(cn.bing.com / www.bing.com)且未被风控')); return { ok: false, points: 0 }; } } // ==================== UI ==================== let uiState = { running: false, stats: { completed: 0, failed: 0, points: 0, claimed: 0 }, totalTasks: 0, completedTasks: 0, totalPoints: 0, todayPoints: 0, countdown: 0, }; let currentRunner = null; let floatVisible = true; let menuCommandId = null; function updateUI() { const btn = document.getElementById('mr-btn-start'); if (btn) { btn.textContent = uiState.running ? '⏹ 停止执行' : '▶ 开始执行'; btn.style.background = uiState.running ? '#e74c3c' : '#0078d4'; } const progressBar = document.getElementById('mr-progress-bar'); const progressText = document.getElementById('mr-progress-text'); if (progressBar && progressText) { const total = uiState.totalTasks || 1; const done = Math.min(uiState.completedTasks, total); const pct = Math.round((done / total) * 100); progressBar.style.width = pct + '%'; progressText.textContent = `${done} / ${total} 任务完成 (${pct}%)`; } const statsEl = document.getElementById('mr-stats'); if (statsEl) { statsEl.innerHTML = `✅ ${uiState.stats.completed} | ❌ ${uiState.stats.failed} | ` + `💎 领取 ${uiState.stats.claimed} | 💰 今日 +${uiState.todayPoints} | 🏆 总 ${uiState.totalPoints}`; } } function updateCountdown(ms) { uiState.countdown = ms; const el = document.getElementById('mr-countdown'); if (el) { if (ms <= 0) el.textContent = ''; else el.textContent = `⏳ 等待中:${formatDuration(ms)}`; } } function toggleFloatPanel() { const el = document.getElementById('mr-float-container'); if (!el) { log('⚠ 悬浮面板不存在,请刷新页面'); return; } floatVisible = !floatVisible; el.style.display = floatVisible ? '' : 'none'; try { if (menuCommandId !== null && typeof GM_unregisterMenuCommand === 'function') { GM_unregisterMenuCommand(menuCommandId); } } catch (e) {} try { const newText = floatVisible ? '👁 隐藏悬浮面板' : '👁 显示悬浮面板'; menuCommandId = GM_registerMenuCommand(newText, toggleFloatPanel); } catch (e) {} } function registerMenuCommands() { try { menuCommandId = GM_registerMenuCommand( floatVisible ? '👁 隐藏悬浮面板' : '👁 显示悬浮面板', toggleFloatPanel ); GM_registerMenuCommand('🔬 抓取 Rewards 数据(诊断)', () =>