// ==UserScript== // @name 微软积分(优化版) // @namespace https://rewards.bing.com/ // @version 1.1.2 // @description 每天在后台自动完成 Microsoft Rewards 任务获取积分奖励,✅签入(PC+App静默)、✅阅读、✅活动、✅搜索、✅Quiz、✅拼图、✅热搜API、✅二次扫描、✅积分通知、✅连签任务检测、✅每日活动自动上报、✅桌面应用每周专属任务、✅Edge 浏览连签、✅出口检测(非大陆 IP 直接停手)(全程后台运行,不打开多余标签页) // @icon https://bing.com/th?id=OMR.icon-96.png&pid=Rewards // @license MIT // @background // @connect bing.com // @connect login.live.com // @connect rewards.bing.com // @connect prod.rewardsplatform.microsoft.com // @connect hotapi.nntool.cc // @connect hot.baiwumm.com // @connect cnxiaobai.com // @connect disp-qryapi.3g.qq.com // @connect www.cloudflare.com // @connect ipinfo.io // @connect api.ip.sb // @connect qyapi.weixin.qq.com // @connect oapi.dingtalk.com // @connect open.feishu.cn // @connect push.i-i.me // @connect api.day.app // @match https://login.live.com/oauth20_desktop.srf* // @match https://rewards.bing.com/* // @match https://www.bing.com/* // @match https://cn.bing.com/* // @run-at document-start // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM_cookie // @grant GM_info // @grant GM_log // @grant GM_registerMenuCommand // @storageName BingRewardsAuto_Shared // @tips 此脚本为开源免费使用,请勿购买 // ==/UserScript== /* global GM_cookie, GM_getValue, GM_setValue, GM_xmlhttpRequest, GM_log, GM_info, GM_notification, GM_openInTab */ /* ==UserConfig== Config: keep: title: 持续检测(全部完成后是否继续) type: checkbox default: true lock: title: 锁定国区(非大陆IP自动停止) type: checkbox default: true span: title: 动作最小间隔(秒,搜索/阅读/卡片共用,脚本只会更慢不会更快) type: number default: 30 min: 30 max: 180 unit: 秒 api: title: 搜索词接口(offline为随机搜索词) type: select default: offline values: [offline, hot.nntool.cc, hot.baiwumm.com, hot.cnxiaobai.com] code: title: 授权码链接 type: textarea description: 粘贴 login.live.com 跳转后的完整URL autoAuth: title: 自动检测授权码(手动运行一次,token状态无则手动授权) type: checkbox default: true Tasks: sign: title: 每日签入 type: checkbox default: true read: title: 新闻阅读 type: checkbox default: true promos: title: 活动卡片(含打卡) type: checkbox default: true quiz: title: Quiz 自动答题 type: checkbox default: true search: title: PC搜索 type: checkbox default: true edge: title: Edge 浏览连签(后台分次上报,累计 30 分钟) type: checkbox default: true Notice: bro: title: 浏览器通知(当前脚本) type: checkbox default: true wework: title: 企业微信消息推送(群机器人) type: text password: true description: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx dingding: title: 钉钉群机器人(不加签,关键词:#) type: text password: true description: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx feishu: title: 飞书群机器人(不加签,关键词:#) type: text password: true description: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx pushme: title: PushMe(push.i-i.me) type: text password: true description: xxxxxxxxxxxxxxxxxxxx bark: title: Bark(bark.day.app) type: text password: true description: xxxxxxxxxxxxxxxxxxxx ==/UserConfig== */ const __scBgKeepAlive = (function() { 'use strict'; // 授权回调页:此处只做标记,真正的「捕获授权码 → 换 Token」在脚本末尾的 Auth.handleCallback() const IS_OAUTH_CALLBACK = location.hostname === "login.live.com" && location.pathname === "/oauth20_desktop.srf"; /* ===== 授权码「早鸟快照」===== * 微软现在的授权回调分成两跳: * ① GET /oauth20_desktop.srf?code=M.C5xx…&lc=2052 → 302(不是文档,脚本不会在这里执行) * ② GET /oauth20_desktop.srf?removed=true ← code 只留在这一跳的 Referer 请求头里 * 能加载脚本的只有第 ② 跳,而它的地址栏已经被换成 removed=true —— 这就是「自动捕获老是 * 捕不到」的根因:code 不在 location 里,只在 document.referrer(同主机所以 Referer 带完整 * query)。于是在 document-start 的最早时刻,把所有可能残留 code 的位置抄一份;再给 * history.pushState/replaceState 挂钩,页面若用 JS 洗地址也能留下痕迹。 */ const OAUTH_WITNESS = (() => { const snapshot = { at: Date.now(), href: "", referrer: "", name: "", urls: [] }; const seen = new Set(); const maybeRecord = url => { if (!url) return; const s = String(url); if (!/code=/i.test(s) || seen.has(s)) return; seen.add(s); snapshot.urls.push(s); if (snapshot.urls.length > 12) snapshot.urls.shift(); // 只留最近 12 条,别把存储撑大 }; try { snapshot.href = String(location.href || ""); maybeRecord(snapshot.href); } catch (_) {} try { snapshot.referrer = String((typeof document !== "undefined" && document && document.referrer) || ""); maybeRecord(snapshot.referrer); } catch (_) {} try { snapshot.name = String((typeof window !== "undefined" && window && window.name) || ""); maybeRecord(snapshot.name); } catch (_) {} // 挂钩子只在 login.live.com 上做,rewards/bing 页面一点多余动作都没有 try { const host = (typeof unsafeWindow !== "undefined" && unsafeWindow) ? unsafeWindow : (typeof window !== "undefined" ? window : null); if (host && host.location && host.location.hostname === "login.live.com" && host.history) { ["pushState", "replaceState"].forEach(fnName => { const orig = host.history[fnName]; if (typeof orig !== "function" || orig.__mpWitness) return; const wrapped = function (state, title, url) { try { maybeRecord(url ? new URL(String(url), host.location.href).href : host.location.href); } catch (_) { maybeRecord(url); } return orig.apply(this, arguments); }; wrapped.__mpWitness = true; try { host.history[fnName] = wrapped; } catch (_) {} }); } } catch (_) {} return snapshot; })(); const RewardsAuto = { // UA 不再写死版本:旧版固定 "Chrome/131 ... Edg/131.0.0.0",用户把 Edge 升到 15x // 之后,脚本发出的请求特征反而变成「一年前的浏览器」,Edge 通道的请求也和真机对不上。 // 现在每次访问都按当前浏览器动态生成,见下面的 UA 模块。 get ua() { return UA.all(); }, desktopAppHeaders: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) MSRewards/Desktop/1.4.56", "X-MSRewards-App": "1", "X-Rewards-Source": "msrewards-desktop" }, appConfig: { rewardsAppId: "SAAndroid/32.6.2110003560", channel: "SAAndroid", dapiMeUrl: "https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613", offerIds: { dailyCheckIn: "Gamification_Sapphire_DailyCheckIn", readArticle: "ENUS_readarticle3_30points", dailySetPrefix: "Gamification_DailySet_", streakCounter: "Gamification_Streak_Counter_Promotion", } }, /* 请求头里该带哪个地区:锁定国区时恒为 cn,未锁定时用出口检测出来的地区。 * 以前这行判断在 5 个请求里各写一遍,口径一改就得改 5 处。 */ region(fallback = "us") { if (GM_getValue("Config.lock", true)) return "cn"; const r = String(this.state.region || "").toLowerCase().trim(); return r || fallback; }, searchPool: [ "what is the weather forecast tomorrow", "how do I make sourdough bread at home", "where can I find cheap flights to tokyo", "why is the sky blue scientific explanation", "how to learn rust programming in 2026", "what time does the world cup final start", "how to fix a leaky kitchen faucet step by step", "what are the best vr games of 2026", "how to start a vegetable garden in spring", "where to watch new movies this week", "how to take care of a bonsai tree", "what is the difference between python async and threading", "how to meditate properly for beginners", "what is the origin of halloween traditions", "how do solar panels actually work", "what is the best mechanical keyboard for typing", "how to tie a windsor knot tie", "what causes northern lights aurora borealis", "how to brew the perfect espresso at home", "what are the symptoms of vitamin d deficiency", "how to sleep better naturally tonight", "why do cats purr when they are happy", "best coffee shops in san francisco downtown", "italian restaurants near times square", "tokyo cherry blossom season 2026 forecast", "rtx 5070 ti benchmark vs rtx 4080 super", "iphone 17 release date and features", "tesla stock price today nasdaq", "best noise cancelling headphones under 300", "fastest electric cars 0 to 60 mph", "vintage camera brands collectors guide", "budget gaming laptop with rtx 4070 2026", "easy chocolate chip cookies recipe from scratch", "30 minute home workout routine no equipment", "stretching exercises for lower back pain relief", "easy origami crane folding instructions", "git rebase vs merge which one to use", "markdown cheat sheet with examples", "japanese hiragana chart pronunciation", "ancient rome history quick overview", "pomodoro technique for focus and productivity", "healthy breakfast ideas under 10 minutes", "天气预报", "今日新闻热点", "美食食谱家常菜", "旅游攻略", "健康养生知识", "科技资讯", "电影推荐", "股票行情", "体育赛事", "历史上的今天" ], apiConfig: { mode: GM_getValue("Config.api", "offline"), arr: [ ["hot.baiwumm.com", { url: "https://hot.baiwumm.com/api/", hot: ["weibo", "douyin", "baidu", "toutiao", "thepaper", "qq", "netease", "zhihu"], }], ["hot.cnxiaobai.com", { url: "https://cnxiaobai.com/DailyHotApi/", hot: ["weibo", "douyin", "baidu", "toutiao", "thepaper", "qq-news", "netease-news", "zhihu"], }], ["hot.nntool.cc", { url: "https://hotapi.nntool.cc/", hot: ["weibo", "douyin", "baidu", "toutiao", "thepaper", "qq-news", "netease-news", "zhihu"], }], ], url: "", hot: [], wordList: [], wordIndex: 0, }, skipPatterns: [ "referral", "refer and earn", "sweepstake", "entries", "install the", "set bing as your default", "bing wallpaper", "punch card", "ancient coin", "sea of thieves", "rewards extension", "redemption goal", "order history", "claim your gift", "shop to earn", "set goal", // 锁态文案("Available tomorrow" / "Offer is Locked" / "Earn -1 points")不再用字符串粗筛: // 已解锁的 WW_Rewards_locked_level2_* 每日优惠卡也会带这类文案,粗筛会把它们一起误杀。 // 是否锁卡改由 CardLock 依据 isLocked / isUnlocked / unlockCriteria 判断(见下方 CardLock)。 // 拼图 / quiz 不跳过 ], skipHrefs: [ "sweepstakes/", "referandearn", "aka.ms/win", "workinprogress", "punchcard", "microsoft-store", "goal/all", "orderhistory", "/redeem", "/redeemgoal", "xbox.com/rewards" ], state: { token: false, region: "CN", host: "www.bing.com", dateNowNum: 0, dateNowStr: "", pcProgress: 0, pcMax: 90, readProgress: 0, readMax: 30, sendMSG: "", lastSearchProgress: -1, restrictedTimes: 0, ip: "", ipInfo: "", startTime: 0, // 本轮扫描到的真锁卡数量/分数(需在必应 App 完成),只用于汇总日志 lockedCount: 0, lockedPoints: 0, // 网页通道确认「未登录/无 Cookie」后置真:当轮剩余卡片直接走 DAPI,避免白发请求 webNoAuth: false, // 网页通道连续失败计数:连续失败到阈值后本轮不再试网页,避免一串超时把整轮拖住 webFailStreak: 0, } }; /* ===== 防「卡住」预算(网页优先改造后新增) ===== * 网页通道每一步都是网络请求:Server Action POST、404 后探测 action id、目的地访问。 * 没有上限的话,某个接口一直超时就会让整轮长时间没有日志,看起来像卡死。 */ const CLAIM_ONCE_TIMEOUT = 150 * 1000; // 单张卡单次尝试的硬上限:超过就记日志、放弃这次尝试 const NEXT_ACTION_SCAN_MAX = 8; // 探测 Server Action ID 时最多扫几个 chunk const NEXT_ACTION_SCAN_BUDGET = 45 * 1000; // 探测 Server Action ID 的总耗时上限 const WEB_FAIL_LIMIT = 3; // 网页通道连续失败几次后,本轮改为只用 DAPI /* ===== 卡片锁态语义(从「微软积分商城签到(改进加强版).user.js」迁移) ===== * 网页端下发的活动卡片带 isLocked / isUnlocked / unlockCriteria 三个字段, * 只有它们确实表示「锁住」时才是真锁卡;只按 "Offer is Locked" / "Available tomorrow" * 这类文案做字符串过滤,会把「已解锁、可以在网页完成」的卡一并误杀(原有漏卡原因之一)。 * 判定与 user.js 完全一致: * - isLocked === false 且 isUnlocked === true → 已解锁,正常执行; * - isLocked === true / isUnlocked === false / unlockCriteria 有值(且非 $undefined)→ 真锁卡,跳过; * - offerId 含 Moreactivities_RewardsApp 或 unlockCriteria === "rewardsApp" → App 专属卡: * 仍然可以尝试(本脚本的 DAPI 通道就是 App 通道),只是日志里单独标注,不计入锁卡。 * 没有锁态字段的来源(DOM 兜底)才退回文案判断,保证旧页面也不漏判。 */ const CardLock = { fields(item) { if (!item || typeof item !== "object") return {}; const pick = (...keys) => { for (const k of keys) { if (item[k] !== undefined && item[k] !== null) return item[k]; } return undefined; }; const rawUnlock = pick("unlockCriteria", "unlockcriteria"); return { isLocked: pick("isLocked", "islocked"), isUnlocked: pick("isUnlocked", "isunlocked"), unlockCriteria: rawUnlock === "$undefined" ? undefined : rawUnlock, state: String(pick("state", "State") || ""), offerId: String(pick("offerId", "offerid", "name") || ""), title: String(pick("title", "name", "description") || "") }; }, hasExplicitState(item) { const f = this.fields(item); return f.isLocked !== undefined || f.isUnlocked !== undefined || f.unlockCriteria !== undefined; }, // 只给「没有任何锁态字段」的来源兜底:这类文案出现时基本就是锁住/未开放 textLocked(text) { return /offer is locked|available tomorrow|earn -1 points/i.test(String(text || "")); }, // → { locked, appExclusive, reason };completed 由调用方按自己的字段判断 probe(item) { const f = this.fields(item); const appExclusive = /Moreactivities_RewardsApp/i.test(f.offerId) || String(f.unlockCriteria || "").toLowerCase() === "rewardsapp"; if (!this.hasExplicitState(item)) { if (this.textLocked(`${f.title} ${f.offerId} ${f.state}`)) { return { locked: true, appExclusive, reason: "仅有锁态文案" }; } return { locked: false, appExclusive, reason: "" }; } // isLocked:false 是运行时权威信号(网页上锁图标已经消失);unlockCriteria 只是「解锁条件的名字」 // 这类元数据,卡解锁之后它仍会留在负载里 —— 不能再据此判锁,否则刚解锁的卡会被永久挡住。 if (f.isLocked === false) { return { locked: false, appExclusive, reason: appExclusive ? "rewardsApp" : "" }; } if (f.isLocked === true) return { locked: true, appExclusive, reason: "isLocked=true" }; if (f.isUnlocked === false) return { locked: true, appExclusive, reason: "isUnlocked=false" }; // App 专属卡(unlockCriteria=rewardsApp / Moreactivities_RewardsApp)在网页端始终显示为锁态, // 但本脚本的 DAPI 通道就是 Rewards App 通道,能直接完成它 —— 所以只有 isLocked/isUnlocked // 明确表示「App 里也还没解锁」时才跳过,其余情况放行交给 DAPI 领取。 if (appExclusive) return { locked: false, appExclusive: true, reason: "rewardsApp" }; if (f.unlockCriteria !== undefined && f.unlockCriteria !== "") { return { locked: true, appExclusive, reason: `unlockCriteria=${f.unlockCriteria}` }; } if (f.state && this.textLocked(f.state)) { return { locked: true, appExclusive, reason: `state=${f.state}` }; } return { locked: false, appExclusive, reason: "" }; } }; /* ===== UA 自适应(适配新版 Edge) ===== * 旧版把 "Chrome/131 ... Edg/131.0.0.0" 写死在代码里,浏览器升级后脚本仍在冒充 * 一年前的 Edge —— 既是明显的「非真实客户端」特征,也让 Edge 通道的请求和真机对不上。 * 取值顺序: * 1) 当前上下文的 navigator.userAgent 本来就有 Edg/:直接用它(版本永远跟浏览器同步, * 以后 Edge 再升级也不用改脚本); * 2) 页面上下文拿到过的真实 Edge UA 存在 Config.uaEdge 里,给 UA 被裁剪成纯 Chrome 的 * 后台 service worker 复用; * 3) 只有 Chrome:按 Chrome 的完整版本号合成同期 Edge UA(Edge 大版本与 Chromium 同步); * 4) 什么都没有(纯沙箱):回退到内置的近期版本。 */ const UA = { /* 内置兜底版本:只有拿不到 navigator 的环境才会用到(2026-10 的 Edge 稳定版是 * 156.0.4314.50)。它不影响正常浏览器里的请求,那里一律用真实 UA。 */ FALLBACK_CHROME: "156.0.0.0", FALLBACK_EDGE: "156.0.4314.50", DESKTOP: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)", ANDROID: "Mozilla/5.0 (Linux; Android 16; Redmi K20 Pro Build/BP4A.251205.006; ) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Mobile Safari/537.36", _cache: null, _cacheAt: 0, // navigator 在 service worker 里也存在;万一取不到就当空串 navigatorUA() { try { return (typeof navigator !== "undefined" && navigator && navigator.userAgent) ? String(navigator.userAgent) : ""; } catch (_) { return ""; } }, // 只有真的看到 Edg/ 才回写,避免把沙盒里被裁剪的 UA 覆盖掉页面上拿到的真值 remember(ua) { if (!/Edg\/\d/.test(ua)) return; try { if (GM_getValue("Config.uaEdge", "") !== ua) GM_setValue("Config.uaEdge", ua); } catch (_) { /* GM 存储不可用时忽略 */ } }, all() { const now = Date.now(); if (this._cache && now - this._cacheAt < 60000) return this._cache; const real = this.navigatorUA(); this.remember(real); const saved = String(GM_getValue("Config.uaEdge", "") || ""); const edgeUA = [real, saved].find(s => /Edg\/\d/.test(s)) || (() => { // 纯 Chrome/其它浏览器:用它的完整版本号(如 157.0.5123.9)合成同期 Edge UA //(Edge 主版本跟随 Chromium,这样版本串看起来就是「这个月装的」,不会被判成过期客户端) const ver = [real, saved] .map(s => (String(s).match(/Chrome\/([\d.]+)/) || [])[1]) .find(Boolean); if (ver) return `${this.DESKTOP} Chrome/${ver} Safari/537.36 Edg/${ver}`; return `${this.DESKTOP} Chrome/${this.FALLBACK_CHROME} Safari/537.36 Edg/${this.FALLBACK_EDGE}`; })(); this._cache = { // PC 通道:真 Edge 用真 UA(与 TLS 指纹一致);其它浏览器沿用内置 Edge UA, // 与旧版行为保持一致,只是把版本从 131 提到了当前稳定版 pc: /Edg\//.test(real) ? real : edgeUA, // Edge 专属通道(EdgeDesktop/EdgeHub)必须自称 Edge edge: edgeUA, mobile: `${this.ANDROID} EdgA/144.0.0.0`, app: `${this.ANDROID} BingSapphire/32.6.2110003560`, }; this._cacheAt = now; return this._cache; } }; /* ===== Edge 浏览连签常量 ===== */ const EDGE = { PROMO_NAME: "edge_browsing_streak_flight", // 老版本的活动名(仍可用) PROMO_PREFIX: /^edge_browsing_streak/i, // 新版可能改名/加后缀,按前缀兜底匹配 ACTIVATION_OFFER: "edge_flight_1_ww_treatment_eligible", TARGET_MINUTES: 30, // 累计满 30 分钟才记账 MAX_EXTRA_ROUNDS: 3, // 次数报够但服务端没确认时,整体重来的轮数上限 }; /* 5 个推送平台的差异只有:名字、Webhook 地址、开关键名、请求体结构。 * 以前是 5 段几乎逐字相同的对象(各带 getter),加一个平台要抄 15 行; * 这里只留「平台定义 + body 模板」,msg 改成取值时才拼的 getter, * 语义与原来的 getter 完全一致(每次序列化都读最新的时间与结算文本)。 */ const WEBHOOK_PLATFORMS = [ { name: "企业微信", url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=", config: "Notice.wework", body: (time, text) => ({ "msgtype": "text", "text": { "content": `> ${time}\n\n ## ${GM_info.script.name}\n ${text}` } }) }, { name: "钉钉", url: "https://oapi.dingtalk.com/robot/send?access_token=", config: "Notice.dingding", body: (time, text) => ({ "msgtype": "markdown", "markdown": { "title": GM_info.script.name, "text": `> ${time}\n ### ${GM_info.script.name}\n ${text}` } }) }, { name: "飞书", url: "https://open.feishu.cn/open-apis/bot/v2/hook/", config: "Notice.feishu", body: (time, text) => ({ "msg_type": "interactive", "card": { "schema": "2.0", "header": { "title": { "tag": "plain_text", "content": GM_info.script.name }, "template": "orange" }, "body": { "elements": [{ "tag": "markdown", "text_align": "center", "content": `#### ${time}\n ${text}` }] } } }) }, { name: "PushMe", url: "https://push.i-i.me/?push_key=", config: "Notice.pushme", body: (time, text) => ({ "type": "markdown", "title": `${GM_info.script.name}[#rewards!https://rewards.bing.com/rewards.png]`, "content": `\n ${text}` }) }, { name: "Bark", url: "https://api.day.app/", config: "Notice.bark", body: (time, text) => ({ "group": "rewards", "icon": "https://rewards.bing.com/rewards.png", "title": GM_info.script.name, "markdown": `\n ${text}` }) }, ]; const Webhooks = WEBHOOK_PLATFORMS.map(p => ({ name: p.name, url: p.url, key: GM_getValue(p.config, false), get msg() { return p.body(new Date().toLocaleString(), RewardsAuto.state.sendMSG); } })); const Utils = { log(icon, msg, push = false) { GM_log(`${icon} ${msg}`); if (push && GM_getValue("Notice.bro", true)) { try { GM_notification({ title: GM_info.script.name + ` ${icon}`, text: msg, onclick: () => GM_openInTab("https://rewards.bing.com/dashboard", { active: true }) }); } catch(_) {} } if (push) { RewardsAuto.state.sendMSG = `${icon} ${msg}`; this.sendWebhook(); } }, async sendWebhook() { await Promise.all(Webhooks.map(async (i) => { if (!i.key) return; const safeKey = String(i.key).trim(); const targetUrl = safeKey.startsWith("http") ? safeKey : i.url + safeKey; try { const result = await this.xhr({ method: "POST", url: targetUrl, headers: { "content-type": "application/json; charset=UTF-8" }, data: JSON.stringify(i.msg), }); if (result) GM_log(`🔵 「${i.name}」消息推送完成`); } catch (e) { GM_log(`🔴 「${i.name}」消息推送出错: ${e.message}`); } })); }, /* 交互兼容层:@background 的 service_worker 里没有 prompt/alert/confirm, * 有 DOM 用原生对话框,没有 DOM 就转成页内输入框或通知。 * @run-at document-start 时 body 还是 null,故只判断 document,domPrompt 内部再等 body。 */ hasDOM() { return typeof document !== "undefined" && !!document && !!document.documentElement; }, waitForBody(timeoutMs = 8000) { return new Promise(resolve => { const t0 = Date.now(); const tick = () => { if (document.body) return resolve(true); if (Date.now() - t0 > timeoutMs) return resolve(false); setTimeout(tick, 100); }; tick(); }); }, uiAlert(msg) { const text = String(msg); if (this.hasDOM() && typeof alert === "function") { try { alert(text); return; } catch (_) {} } GM_log(text); try { GM_notification({ title: GM_info.script.name, text: text.slice(0, 300), timeout: 10000 }); } catch (_) {} }, uiConfirm(msg) { if (this.hasDOM() && typeof confirm === "function") { try { return confirm(msg); } catch (_) {} } this.uiAlert(`${msg}\n\n(当前是后台上下文,无法弹出确认框,已按「取消」处理)`); return false; }, async uiPrompt(message, defaultValue = "") { if (this.hasDOM() && typeof prompt === "function") { try { return prompt(message, defaultValue); } catch (_) {} } if (this.hasDOM()) return this.domPrompt(message, defaultValue); // 后台上下文:没有输入框,打开页面由 init() 在页面里弹出 GM_setValue("Config.pendingPaste", true); Utils.log("🟡", "当前为后台上下文(无输入框),已打开 rewards.bing.com 以便粘贴授权码"); try { GM_notification({ title: "📋 请粘贴授权码", text: "已为你打开 rewards.bing.com,页面加载后会自动弹出输入框", timeout: 8000 }); } catch (_) {} try { GM_openInTab("https://rewards.bing.com/", { active: true }); } catch (_) {} return null; }, async domPrompt(message, defaultValue = "") { await this.waitForBody(); return new Promise(resolve => { const wrap = document.createElement("div"); wrap.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;"; const box = document.createElement("div"); box.style.cssText = "background:#fff;color:#222;padding:18px;border-radius:10px;width:min(680px,92vw);font:14px/1.6 system-ui,-apple-system,'Microsoft YaHei',sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.4);"; const label = document.createElement("div"); label.textContent = message; label.style.cssText = "margin-bottom:10px;white-space:pre-wrap;"; const ta = document.createElement("textarea"); ta.value = defaultValue; ta.spellcheck = false; ta.style.cssText = "width:100%;height:130px;box-sizing:border-box;padding:8px;border:1px solid #c8c8c8;border-radius:6px;font:12px/1.5 Consolas,monospace;resize:vertical;"; const row = document.createElement("div"); row.style.cssText = "display:flex;gap:10px;justify-content:flex-end;margin-top:12px;"; const mkBtn = (text, primary) => { const b = document.createElement("button"); b.textContent = text; b.style.cssText = `padding:7px 18px;border-radius:6px;cursor:pointer;font:14px system-ui;border:1px solid ${primary ? "#0b6cff" : "#c8c8c8"};background:${primary ? "#0b6cff" : "#fff"};color:${primary ? "#fff" : "#222"};`; return b; }; const okBtn = mkBtn("确定", true); const cancelBtn = mkBtn("取消", false); const done = v => { try { wrap.remove(); } catch (_) {} resolve(v); }; okBtn.onclick = () => done(ta.value); cancelBtn.onclick = () => done(null); ta.addEventListener("keydown", e => { if (e.key === "Escape") done(null); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) done(ta.value); }); row.appendChild(cancelBtn); row.appendChild(okBtn); box.appendChild(label); box.appendChild(ta); box.appendChild(row); wrap.appendChild(box); (document.body || document.documentElement).appendChild(wrap); setTimeout(() => { try { ta.focus(); ta.select(); } catch (_) {} }, 50); }); }, xhr(options) { return new Promise((resolve, reject) => { const start = Date.now(); GM_xmlhttpRequest({ anonymous: false, ...options, // 允许调用方自带超时(出口探测要短超时),没传就还是 15 秒 timeout: Number(options && options.timeout) || 15000, onload: (res) => { const cost = ((Date.now() - start) / 1000).toFixed(2); if (res.status >= 200 && res.status < 300) { resolve(res.responseText); } else if ([301, 302, 307, 308].includes(res.status)) { const match = res.responseHeaders?.match(/Location:\s*(.*?)\s*\r?\n/i); resolve(match ? match[1] : false); } else { reject(new Error(`HTTP ${res.status},用时 ${cost} 秒`)); } }, onerror: (err) => { const cost = ((Date.now() - start) / 1000).toFixed(2); reject(new Error(`${err?.error || "网络错误"},用时 ${cost} 秒`)); }, ontimeout: () => { const cost = ((Date.now() - start) / 1000).toFixed(2); reject(new Error(`请求超时,用时 ${cost} 秒`)); } }); }); }, randomRange(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); }, getTimestamp() { return Date.now(); }, getTodayNum() { const d = new Date(); return Number(`${d.getFullYear()}${String(d.getMonth()+1).padStart(2,"0")}${String(d.getDate()).padStart(2,"0")}`); }, getTodayStr() { const d = new Date(); return `${d.getMonth()+1}/${d.getDate()}/${d.getFullYear()}`; }, /* Next.js Server Action 需要的 next-router-state-tree 头(从 user.js 迁移)。 * 缺这个头时,新版 earn/dashboard 的 action 端点会直接 500 —— 这也是「已解锁卡交卷失败」的原因之一。 */ routerStateTree(segment = "earn") { const refreshFlag = 4096; const tree = ["", { children: ["(nav)", { children: [segment, { children: ["PAGE", {}, null, null, refreshFlag] }, null, null, refreshFlag] }, null, null, refreshFlag] }, null, null, refreshFlag + 16]; return encodeURIComponent(JSON.stringify(tree)); }, getRandomUUID() { return crypto.randomUUID().replace(/-/g, "").toUpperCase(); }, isJSON(s) { try { const j = JSON.parse(s); return Array.isArray(j) || (typeof j === "object" && j !== null); } catch { return false; } }, /* 解析 JSON 字符串,非 JSON / null / 标量一律 null。 * 以前全脚本 15 处都是「先 isJSON 再 JSON.parse」,同一个判断写两遍。 */ parseJSON(s) { if (typeof s !== "string" || !s) return null; try { const j = JSON.parse(s); return (typeof j === "object" && j !== null) ? j : null; } catch { return null; } }, /* DAPI 请求头(App 通道 / Edge 通道)。 * 以前这套头在 7 个请求里逐字复制,字段一改要改 7 遍才不出错。 * kind="app":BingSapphire App 通道;kind="edge":Edge 桌面通道(appid/partnerid 与语言都不同)。 * extra 用来追加/覆盖个别请求的额外头(signApp 的 partnerid、flights)。 */ appHeaders(token, { kind = "app", country, extra } = {}) { const cc = country || RewardsAuto.region(); const base = kind === "edge" ? { "accept": "*/*", "content-type": "application/json; charset=UTF-8", "user-agent": RewardsAuto.ua.edge, "authorization": `Bearer ${token}`, "x-rewards-appid": "EdgeDesktop", "x-rewards-partnerid": "EdgeHub", "x-rewards-country": cc, "x-rewards-language": cc === "cn" ? "zh-CN" : "en-US", } : { "content-type": "application/json; charset=UTF-8", "user-agent": RewardsAuto.ua.app, "authorization": `Bearer ${token}`, "x-rewards-appid": RewardsAuto.appConfig.rewardsAppId, "x-rewards-ismobile": "true", "x-rewards-country": cc, "x-rewards-language": "zh", }; return extra ? Object.assign(base, extra) : base; }, delay(ms) { return new Promise(r => setTimeout(r, ms)); }, randomDelay(min = 3000, max = 8000) { return this.delay(this.randomRange(min, max)); }, waitForElement(selector, timeout = 30000) { return new Promise((resolve, reject) => { const element = document.querySelector(selector); if (element) return resolve(element); const observer = new MutationObserver((_, obs) => { const el = document.querySelector(selector); if (el) { obs.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const el = document.querySelector(selector); el ? resolve(el) : reject(new Error(`等待元素超时: ${selector}`)); }, timeout); }); }, waitForElementsByText(containerSelector, textPatterns, timeout = 30000) { return new Promise((resolve, reject) => { const findElements = () => { const containers = document.querySelectorAll(containerSelector); const results = []; for (const container of containers) { const text = container.textContent || ""; for (const pattern of textPatterns) { if (text.includes(pattern)) { results.push({ element: container, pattern }); break; } } } return results; }; const found = findElements(); if (found.length > 0) return resolve(found); const observer = new MutationObserver((_, obs) => { const found = findElements(); if (found.length > 0) { obs.disconnect(); resolve(found); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); const found = findElements(); found.length > 0 ? resolve(found) : reject(new Error("等待文本元素超时")); }, timeout); }); }, /* 跨上下文互斥锁:后台实例与已打开的 rewards 标签页会同时跑脚本, * 并发会互相覆盖 Token 并重复上报,这里用 GM 存储做带 TTL 的锁,只允许一个上下文执行任务。 */ lockId() { if (!RewardsAuto.state.lockId) RewardsAuto.state.lockId = Utils.getRandomUUID(); return RewardsAuto.state.lockId; }, acquireLock(key, ttlMs = 20 * 60 * 1000) { const now = Date.now(); const holder = GM_getValue(key, null); const mine = Utils.lockId(); if (holder && typeof holder === "object" && holder.id && holder.id !== mine && now - (holder.ts || 0) < ttlMs) { return false; } GM_setValue(key, { id: mine, ts: now }); return true; }, refreshLock(key) { GM_setValue(key, { id: Utils.lockId(), ts: Date.now() }); }, releaseLock(key) { const holder = GM_getValue(key, null); if (!holder || holder.id === Utils.lockId()) GM_setValue(key, null); } }; /* ===== 网络出口守卫(非大陆 / 机房 / 代理,均由「锁定国区」一个开关控制) ===== * 旧实现只读 bing.com 首页的 Region/RevIpCC,于是「开着梯子也永远是中国」,原因有三: * 1) Utils.xhr 默认 anonymous:false,会带上 cc=Cn、SRCHHPGUSR 之类的大陆 Cookie, * Bing 按 Cookie 优先回 CN,跟真实出口 IP 无关; * 2) 大陆出口访问 www.bing.com 会被 302 到 cn.bing.com,那一页的 Region 恒为 CN; * 3) 真正花积分的是 prod.rewardsplatform.microsoft.com / rewards.bing.com / login.live.com, * 分流规则把 bing.com 走直连、把这几个微软接口走代理时,只看 bing 首页根本发现不了。 * 现在改成「多路独立探测 + 结论缓存 + 全流程闸门」: * - bing.com:不带 Cookie + 时间戳,Bing 只能按出口 IP 回 RevIpCC; * - Cloudflare cdn-cgi/trace、ipinfo.io、api.ip.sb:第三方视角的出口国家/ASN; * - 腾讯 disp-qryapi:国内服务看到的出口(境内「中国 …」,境外「美国 …」)。 * 任一路看到非 CN、出口是机房/云厂商 ASN、或走了 WARP,一律判为「不能继续」。 */ class RegionBlockedError extends Error { constructor(message) { super(message); this.name = "RegionBlockedError"; this.regionBlocked = true; } } const RegionGuard = { _cache: null, // 上一次结论(含 ok/reasons/signals/ip),全流程闸门复用 _inflight: null, // 并发合并:搜索循环里可能同时有多处调用 _lastState: "", // "ok" / "block",只在状态翻转时打日志 _lastNotifyAt: 0, // 推送节流 TTL_OK: 120 * 1000, // 通过后 2 分钟内复用(中途开梯子最多 2 分钟被发现) TTL_BLOCK: 45 * 1000, // 拦截后 45 秒重新确认(关掉梯子能尽快恢复) PROBE_TIMEOUT: 8000, NOTIFY_GAP: 10 * 60 * 1000, enabled() { return !!GM_getValue("Config.lock", true); }, // 机房 / VPS / 公共代理的 ASN 关键词:家宽运营商(电信/联通/移动/Comcast…)不会命中 HOSTING_RE: /cloudflare|digitalocean|vultr|linode|akamai|fastly|amazon|aws|google\s*cloud|azure|alibaba|aliyun|alicloud|tencent\s*cloud|qcloud|huawei\s*cloud|hetzner|ovh|leaseweb|contabo|hostwinds|hostinger|racknerd|colocrossing|sharktech|psychz|choopa|m247|datacamp|zenlayer|layerstack|gcore|oracle\s*cloud|\bvpn\b|\bproxy\b|tor\s*exit|ipvanish|nordvpn|expressvpn|surfshark|mullvad/i, // 只在 cn.bing.com 首页出现的特征:看到它就能断定「微软把我当大陆」。 // 注意:这里刻意用整个 HTML 而非去空白版,中文标记不能被 \s 吃掉。 CN_PAGE_RE: /cn\.bing\.com|京ICP备|beian\.miit\.gov\.cn|国内版<\/a>|国内版/, /* 5 路探测:微软侧 + 第三方 IP 库。差异只有「地址、请求头、怎么解析」, * 骨架统一在 _probeAll(匿名取数、超时、失败兜底),再加一路只要往这里加一条。 */ PROBES: [ { // 微软侧:不带 Cookie,问 Bing「你看到的我是哪国」。大陆出口会被 302 到 cn.bing.com, // 那一页没有 RevIpCC,但有只在 cn.bing.com 出现的标记(版本切换、京ICP备号) src: "bing", url: () => `https://www.bing.com/?_=${Date.now()}`, headers: { "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache" }, parse: (txt, g) => { const text = String(txt || ""); const flat = text.replace(/\s/g, ""); const ipcc = flat.match(/RevIpCC:"([A-Za-z]{2})"/); const region = flat.match(/Region:"([A-Za-z]{2})"/); let cc = ipcc ? ipcc[1] : (region ? region[1] : ""); if (!cc && g.CN_PAGE_RE.test(text)) cc = "CN"; if (!cc) throw new Error("首页未返回地区标记"); return { cc: cc.toUpperCase(), ip: "", org: "" }; } }, { src: "cloudflare", url: () => `https://www.cloudflare.com/cdn-cgi/trace?_=${Date.now()}`, parse: txt => { const kv = {}; String(txt || "").split("\n").forEach(line => { const i = line.indexOf("="); if (i > 0) kv[line.slice(0, i).trim()] = line.slice(i + 1).trim(); }); if (!kv.loc) throw new Error("trace 格式异常"); return { cc: String(kv.loc).toUpperCase(), ip: kv.ip || "", org: "", warp: kv.warp === "on" }; } }, { src: "ipinfo", url: () => `https://ipinfo.io/json?_=${Date.now()}`, parse: txt => { const d = JSON.parse(txt); if (!d || !d.country) throw new Error(String((d && (d.error || d.message)) || "无 country 字段")); return { cc: String(d.country).toUpperCase(), ip: d.ip || "", org: d.org || "" }; } }, { src: "ip.sb", url: () => `https://api.ip.sb/geoip?_=${Date.now()}`, parse: txt => { const d = JSON.parse(txt); if (!d || !d.country_code) throw new Error("无 country_code 字段"); return { cc: String(d.country_code).toUpperCase(), ip: d.ip || "", org: [d.isp, d.organization, d.asn_organization].filter(Boolean).join(" ") }; } }, { // 腾讯 IP 库:境内出口一定以「中国」开头,境外是「美国 加利福尼亚 …」这种。 // 这一路历来不匿名(带 Cookie),与上面几路不同,保持原样 src: "tencent", url: () => "https://disp-qryapi.3g.qq.com/v1/dispatch", headers: { referer: "https://3g.qq.com/" }, anonymous: false, parse: txt => { const d = JSON.parse(txt); if (!d || d.code != 0) throw new Error("dispatch 返回异常"); return { cc: "", ip: (d.extra && d.extra.ip) || "", org: "", cnText: String(d.ipInfo || "").replace(/[#*]+/g, " ").trim() }; } }, ], async _probeAll() { return await Promise.all(this.PROBES.map(async p => { try { const txt = await Utils.xhr({ url: p.url(), headers: p.headers, // 关键:默认不带 Cookie,否则 cc=Cn 会被 Bing 沿用 anonymous: p.anonymous !== false, timeout: this.PROBE_TIMEOUT }); return Object.assign({ src: p.src, cc: "", ip: "", org: "" }, p.parse(txt, this)); } catch (e) { return { src: p.src, error: e instanceof Error ? e : new Error(String(e)), cc: "", ip: "", org: "" }; } })); }, // 同一路探测重试后可能有多条结果:成功的一票为准 _merge(list) { const map = new Map(); for (const r of list) { if (!r) continue; const score = (r.error ? 0 : 2) + (r.cc ? 1 : 0); const cur = map.get(r.src); const curScore = cur ? (cur.error ? 0 : 2) + (cur.cc ? 1 : 0) : -1; if (score > curScore) map.set(r.src, r); } return [...map.values()]; }, _judge(raw) { const probes = this._merge(raw); const ok = probes.filter(p => !p.error); const coded = ok.filter(p => p.cc); const foreign = coded.filter(p => p.cc !== "CN"); const tencent = ok.find(p => p.src === "tencent"); const cnText = (tencent && tencent.cnText) || ""; const tencentCN = /中国/.test(cnText); const tencentOverseas = !!(cnText && !tencentCN && !/局域网|内网|本地|保留|未知/.test(cnText)); const warp = ok.find(p => p.warp); const hosting = ok.find(p => p.org && this.HOSTING_RE.test(p.org)); const reasons = []; if (foreign.length > 0) { reasons.push(`出口非大陆:${foreign.map(p => `${p.src}=${p.cc}${p.ip ? `(${p.ip})` : ""}`).join("、")}`); } if (tencentOverseas) { reasons.push(`国内服务看到的出口在境外:${cnText}`); } if (warp) reasons.push("Cloudflare WARP 正在接管流量(代理特征)"); if (hosting) reasons.push(`出口是机房/代理 IP:${hosting.org}`); const signals = probes.map(p => { if (p.error) return `${p.src}=✗`; if (p.src === "tencent") { return `tencent=${tencentCN ? "CN" : tencentOverseas ? "海外" : "?"}${p.ip ? `(${p.ip})` : ""}`; } return `${p.src}=${p.cc}${p.ip ? `(${p.ip})` : ""}`; }); const ipHolder = (tencent && tencent.ip) ? tencent : ok.find(p => p.ip); return { ok: reasons.length === 0, reasons, signals, ip: (ipHolder && ipHolder.ip) || "", ipInfo: cnText ? `🌏所在地区:${cnText}` : "", coded: coded.length, known: coded.length + (cnText ? 1 : 0), // 拿到结论的路数(国家票 + 腾讯文本) probes: probes.length, failed: probes.filter(p => p.error).map(p => `${p.src}: ${p.error.message}`) }; }, /* 主动探测一次(有缓存)。返回 { ok, reasons, signals, ip, ipInfo, skipped } */ async check({ fresh = false, quiet = false } = {}) { if (!this.enabled()) { return { ok: true, skipped: true, reasons: [], signals: [], ip: "", ipInfo: "", coded: 0, known: 0, probes: 0, failed: [] }; } const now = Date.now(); if (!fresh && this._cache && (now - this._cache.at) < (this._cache.ok ? this.TTL_OK : this.TTL_BLOCK)) { return this._cache; } if (this._inflight) return this._inflight; this._inflight = (async () => { let raw = await this._probeAll(); let verdict = this._judge(raw); // 一路都没结论:可能刚好在切换网络/梯子启停/刚从睡眠唤醒,退避后再确认两次, // 连续三次全都探测失败才按「出口不明」处理(fail-safe,宁停不放) for (const wait of [2000, 5000]) { if (verdict.known > 0) break; if (!quiet) Utils.log("🟡", `出口检测没拿到任何结果,${wait / 1000} 秒后再试一次`); await Utils.delay(wait); raw = raw.concat(await this._probeAll()); verdict = this._judge(raw); } // 全都失败时不能放行:宁可停一轮,也别在出口不明的情况下上报 if (verdict.known === 0) { verdict.ok = false; verdict.reasons.unshift("无法确认出口地区(所有探测都失败)"); } verdict.at = Date.now(); this._cache = verdict; if (verdict.ok) { RewardsAuto.state.region = "CN"; if (verdict.ip) RewardsAuto.state.ip = verdict.ip; if (this._lastState !== "ok" || fresh) { Utils.log("🟢", `出口检测通过(CN)|${verdict.signals.join(" ")}`); } this._lastState = "ok"; } else { if (verdict.ip) RewardsAuto.state.ip = verdict.ip; if (verdict.ipInfo) RewardsAuto.state.ipInfo = verdict.ipInfo; const stateChanged = this._lastState !== "block"; const push = stateChanged || (Date.now() - this._lastNotifyAt > this.NOTIFY_GAP); if (stateChanged || fresh) { Utils.log(push ? "🔴" : "🟡", `检测到代理/VPN 或非大陆出口,已暂停上报\n${verdict.reasons.join("\n")}\n出口 IP:${verdict.ip || "未知"}\n${verdict.ipInfo || ""}`, push); } if (push) this._lastNotifyAt = Date.now(); this._lastState = "block"; } return verdict; })(); try { return await this._inflight; } finally { this._inflight = null; } }, /* 闸门(抛异常版):拦截时让这一轮立刻收手,一个微软请求都不再发 */ async guard(where = "任务") { const v = await this.check(); if (!v.ok) throw new RegionBlockedError(`出口异常(${where}):${v.reasons[0] || "未知原因"}`); return v; }, /* 闸门(布尔版):心跳这种「跳过一次就够」的地方用,安静返回 */ async allow() { const v = await this.check(); return v.ok; } }; /* 授权码 / Token 策略:解析任意形态的输入 → 保存 → 立刻兑换 → 明确报错; * 自动获取先静默请求,失败再打开授权页,由 @match 的 handleCallback() 捕获。 */ const CLIENT_ID = "0000000040170455"; const REDIRECT_URI = "https://login.live.com/oauth20_desktop.srf"; const TOKEN_SCOPE = "service::prod.rewardsplatform.microsoft.com::MBI_SSL"; const Auth = { authUrl: `https://login.live.com/oauth20_authorize.srf?client_id=${CLIENT_ID}&response_type=code&scope=${TOKEN_SCOPE}&redirect_uri=${REDIRECT_URI}`, // 只解 %XX,不把 + 当空格(授权码里可能出现 *!$._- ,但不会出现 +) decode(s) { return String(s).replace(/%([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))); }, normalize(code) { if (!code) return null; const c = String(code) .trim() .replace(/^[^A-Za-z0-9]+/, "") .replace(/[^A-Za-z0-9._*!$%~-]+$/, ""); return c.length >= 20 ? c : null; }, /** 从任意粘贴内容里取出 code:支持完整跳转地址、HTML/JSON 转义过的地址、 * 只粘 code= 后那一段、以及前后带空格/引号/换行的粘贴。 */ parseCode(input) { if (input == null) return null; let s = String(input) .replace(/[\u00a0\u3000]/g, " ") .replace(/\\u0026/gi, "&") .replace(/\\\//g, "/") .replace(/&/gi, "&") .replace(/[\r\n\t]+/g, " ") .trim(); if (!s) return null; // 1) URL / query 形态:取 code= 之后、& 之前 const m = s.match(/[?\s]code=([^&\s"'<>\\]+)/i) || s.match(/^code=([^&\s"'<>\\]+)/i); if (m) { const c = this.normalize(this.decode(m[1])); if (c) return c; } // 2) 裸码(MS 授权码形如 M.C561_BAY.2.U.MsaArtifacts.xxxxx) const bare = s.match(/M\.[A-Za-z0-9._*!$%~-]{20,}/); if (bare) return this.normalize(this.decode(bare[0])); // 3) 兜底:整串就是一个长码(可能被加了类型前缀等杂字符) const t = s.replace(/^[\s"'`<\[(]+/, "").replace(/[\s"'`>\])]+$/, ""); if (/^[A-Za-z0-9._*!$%~-]{40,}$/.test(t)) return this.normalize(this.decode(t)); return null; }, preview(code) { if (!code) return "(空)"; return `${code.slice(0, 12)}…${code.slice(-6)}(${code.length} 字符)`; }, /** 从一串候选字符串里找 code,返回 { code, source, raw };找不到返回 null */ pick(list) { for (const item of list) { if (!item || !item.value) continue; const code = this.parseCode(item.value); if (code) return { code, source: item.source, raw: String(item.value) }; } return null; }, /** 列出「可能残留 code」的所有位置,顺序即优先级:地址栏 > document.referrer > 启动快照。 * deep=true 时才翻页面内容和 sessionStorage/localStorage(开销大,只在没辙时用)。 * —— 之所以要这么多处:微软把 code 从地址栏抹成 ?removed=true 后,它只剩在 * document.referrer(302 那一跳的 Referer)里,极少数情况下还会被内联进页面/存储。 */ candidates(deep) { const list = []; const add = (source, value) => { if (value) list.push({ source, value: String(value) }); }; try { add("地址栏", location.href); } catch (_) {} try { add("地址栏参数", `${location.search || ""}${location.hash || ""}`); } catch (_) {} /* 关键通道:302 到 ?removed=true 之后,原始跳转地址只剩在 document.referrer 里 */ try { add("document.referrer", typeof document !== "undefined" && document ? document.referrer : ""); } catch (_) {} try { const nav = (typeof performance !== "undefined" && performance.getEntriesByType) ? performance.getEntriesByType("navigation") : null; if (nav && nav[0] && nav[0].name) add("navigation", nav[0].name); } catch (_) {} try { add("window.name", typeof window !== "undefined" ? window.name : ""); } catch (_) {} if (OAUTH_WITNESS) { add("启动快照(地址栏)", OAUTH_WITNESS.href); add("启动快照(referrer)", OAUTH_WITNESS.referrer); add("启动快照(window.name)", OAUTH_WITNESS.name); (OAUTH_WITNESS.urls || []).forEach((u, i) => add(`历史 URL#${i + 1}`, u)); } if (!deep) return list; // 有些回调页会把跳转地址内联在 HTML / 存储里回显 try { const html = (typeof document !== "undefined" && document && document.documentElement) ? String(document.documentElement.outerHTML || "") : ""; const m = html.match(/[^\s"'<>\\]{0,160}code=[A-Za-z0-9._~%*!$-]{20,120}[^\s"'<>\\]{0,32}/i); if (m) add("页面内容", m[0]); } catch (_) {} for (const storeName of ["sessionStorage", "localStorage"]) { try { const store = (typeof window !== "undefined" && window) ? window[storeName] : null; if (!store) continue; for (let i = 0; i < store.length && i < 40; i++) { const key = store.key(i); if (key == null) continue; const val = store.getItem(key); if (val && val.length < 4000 && /code=/i.test(val)) add(`${storeName}.${key}`, val); } } catch (_) {} } return list; }, /** 立刻在所有位置找一次授权码 */ collectCode(deep = false) { return this.pick(this.candidates(deep)); }, /** 短轮询兜底:回调页有时先跳 ?removed=true,稍后内容/存储才写出来。 * 第一拍与每 4 拍做深度检查(HTML、存储),其余只查轻量位置,别把 CPU 烧了。 */ async waitForAnyCode(timeoutMs = 12000, stepMs = 500) { const t0 = Date.now(); for (let tick = 0; Date.now() - t0 < timeoutMs; tick++) { const hit = this.collectCode(tick === 0 || tick % 4 === 0); if (hit) return hit; await Utils.delay(stepMs); } return this.collectCode(true); }, /** 该授权码是否已经成功兑换过(授权码只保留展示,不再重复拿去换 Token) */ used(code) { if (!GM_getValue("Config.codeUsed", false)) return false; const fp = String(GM_getValue("Config.codeFingerprint", "")); return !fp || fp === String(code || "").slice(-32); }, /** 读取已保存的授权码;无法解析时只告警一次,绝不删除用户粘贴的内容 */ stored() { const raw = GM_getValue("Config.code", ""); if (!raw) return null; const code = this.parseCode(raw); if (!code) { if (!this._warned) { this._warned = true; Utils.log("🟡", `已保存的授权码无法解析(长度 ${String(raw).length}),请重新「📋 粘贴授权码」`); } return null; } // 已经用它换到 Token 了:保留在存储里(状态菜单可见),但不再重复兑换 if (this.used(code)) return null; return code; }, /** 保存原始输入(保留完整 URL,方便状态菜单回显)+ 时间戳 */ save(raw) { const code = this.parseCode(raw); GM_setValue("Config.code", String(raw).trim()); GM_setValue("Config.codeTime", Utils.getTimestamp()); // 换了新的授权码 → 旧的「已兑换」标记作废 GM_setValue("Config.codeUsed", false); GM_setValue("Config.codeFingerprint", code ? code.slice(-32) : ""); // 换了授权码 → 之前缓存的 access_token 属于旧凭据,立即作废 GM_setValue("Config.accessToken", ""); GM_setValue("Config.accessTokenTime", 0); RewardsAuto.state.token = null; return code; }, clear(reason) { GM_setValue("Config.code", ""); GM_setValue("Config.codeTime", 0); GM_setValue("Config.codeUsed", false); GM_setValue("Config.codeFingerprint", ""); if (reason) Utils.log("🟡", `已清除授权码:${reason}`); }, /** 用授权码换 Token(成功即写入 refresh_token / access_token 缓存)。 * keep=true 时保留授权码,只标记已兑换,便于状态菜单回显。 */ async exchange(code, keep = true) { Utils.log("🟡", `正在用授权码换取 Token(${this.preview(code)})...`); const r = await API.tokenRequest({ client_id: CLIENT_ID, code, redirect_uri: REDIRECT_URI, grant_type: "authorization_code", }); if (r.ok) { API.applyToken(r.json); if (keep) { GM_setValue("Config.codeUsed", true); GM_setValue("Config.codeFingerprint", String(code).slice(-32)); Utils.log("🟢", "授权码换取 Token 成功(授权码已保留)", true); } else { Utils.log("🟢", "授权码换取 Token 成功,已保存", true); this.clear(); } return true; } const fatal = ["invalid_grant", "invalid_request", "unauthorized_client", "invalid_client"].includes(r.error); Utils.log("🔴", `授权码换取 Token 失败:${r.error}${r.description ? " - " + r.description : ""}`); if (fatal) this.clear("授权码已过期或已被使用过"); return false; }, /** 静默通道:后台直接请求 authorize 端点,看能不能直接拿到跳转里的 code */ async fetchViaXhr() { let res; try { res = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: this.authUrl, anonymous: false, headers: typeof navigator !== "undefined" ? { "User-Agent": navigator.userAgent } : {}, timeout: 20000, onload: r => resolve(r), onerror: e => reject(new Error((e && e.error) || "网络错误")), ontimeout: () => reject(new Error("请求超时")), }); }); } catch (e) { Utils.log("🟡", `静默获取授权码失败:${e.message}`); return null; } const candidates = []; if (res.finalUrl) candidates.push(res.finalUrl); if (res.responseURL) candidates.push(res.responseURL); if (res.status >= 300 && res.status < 400) { const loc = (res.responseHeaders || "").match(/location:\s*(\S+)/i); if (loc) candidates.push(loc[1]); } if (res.responseText) { const m = res.responseText.match(/oauth20_desktop\.srf\?[^"'\s<>]*code=[^"'\s<>]+/i) || res.responseText.match(/[?&]code=M\.[^"'\s<>&]+/i); if (m) candidates.push(m[0]); } for (const c of candidates) { const code = this.parseCode(c); if (code) return code; } const finalUrl = res.finalUrl || res.responseURL || ""; if (/removed=true/i.test(finalUrl)) { // 微软现在把 code 直接 302 掉:静默请求只会停在这一跳,拿不到 code, // 必须靠页面通道从 Referer 里取(见 Auth.handleCallback)。 Utils.log("🟡", `静默通道被微软跳到了 ?removed=true(code 已被抹掉),改走页面 Referer 通道`); return null; } Utils.log("🟡", `静默通道没拿到授权码(HTTP ${res.status},最终地址 ${finalUrl || "未知"})`); return null; }, /** 等待页面捕获授权码(或其它上下文直接换好了 Token)。 * 原「自动打开授权页 → 在这里等 3 分钟」的逻辑已移除(脚本不再自动开标签页), * 这个等待函数保留备查,当前无调用方。 */ async waitForCode(since, timeoutMs = 180000) { const step = 3000; for (let waited = 0; waited < timeoutMs; waited += step) { await Utils.delay(step); const raw = GM_getValue("Config.code", ""); const t = Number(GM_getValue("Config.codeTime", 0)) || 0; const code = raw ? this.parseCode(raw) : null; const freshToken = () => GM_getValue("Config.token", false) && (Number(GM_getValue("Config.tokenTime", 0)) || 0) >= since; if (code && t >= since) { // 回调页可能正在同时兑换同一个 code,先等 2.5 秒, // 避免第二次必然 invalid_grant(看起来像「授权失败」)。 await Utils.delay(2500); if (freshToken()) return { tokenReady: true }; return { code }; } if (freshToken()) return { tokenReady: true }; } return {}; }, /** 后台静默尝试捕获授权码。 * 无论「自动检测授权码」开关是开是关,都不会自动打开授权页: * 开关只决定是否在后台静默发一次 authorize 请求碰碰运气,失败就只记日志(不推送通知), * 交给用户点菜单「🔑 手动授权」/「📋 粘贴授权码」处理。 */ async autoFetch(reason) { if (!GM_getValue("Config.autoAuth", true)) { Utils.log("🔴", `${reason},且配置里已关闭「自动检测授权码」:请点菜单「🔑 手动授权」,登录后把跳转地址粘贴到「📋 粘贴授权码」。`); return {}; } Utils.log("🟡", `${reason},后台静默尝试捕获授权码(不打开标签页)...`); const viaXhr = await this.fetchViaXhr(); if (viaXhr) { Utils.log("🟢", "自动获取授权码成功(静默通道)"); return { code: viaXhr }; } Utils.log("🔴", "静默捕获授权码失败,不自动打开授权页。请点菜单「🔑 手动授权」登录:页面跳到 oauth20_desktop.srf 时脚本会自动从 Referer 捕获授权码并换 Token;也可以 F12 → 网络 → oauth20_desktop.srf?removed=true → 标头 → 请求标头 → 复制 Referer,再用「📋 粘贴授权码」粘贴。"); return {}; }, /** 授权回调页:捕获 code → 立刻兑换 → 通知并关闭页面。 * 注意别只看 location:微软会把 code 从地址栏抹成 ?removed=true, * 真正的授权码只剩在 document.referrer(上一跳 302 的 Referer)里。 */ async handleCallback() { const params = new URLSearchParams(location.search); const err = params.get("error"); if (err) { const desc = params.get("error_description") || ""; Utils.log("🔴", `授权页返回错误:${err} - ${desc}`, true); try { GM_notification({ title: "🔴 授权失败", text: `${err}:${desc}`, timeout: 0 }); } catch (_) {} return; } if (!params.get("code")) { Utils.log("🟡", `回调地址里没有 code(${location.search || "无参数"}),改从 Referer / 启动快照里找...`); } // 1) 先同步抓一次(document-start 时最快的通道就是 location 与 document.referrer) // 2) 没抓到再短轮询 12 秒,给「先跳 removed=true,稍后才写出内容/存储」留出余量 const hit = this.collectCode(false) || await this.waitForAnyCode(12000); if (!hit) { Utils.log("🔴", "自动捕获授权码失败:地址栏、document.referrer、启动快照里都没有 code", true); try { GM_notification({ title: "🔴 自动捕获授权码失败", text: "F12 → 网络 → 点 oauth20_desktop.srf?removed=true → 标头 → 请求标头 → 复制 Referer 右侧完整链接,再用「📋 粘贴授权码」", timeout: 0 }); } catch (_) {} return; } const code = hit.code; Utils.log("🟢", `已捕获授权码(来源:${hit.source},${this.preview(code)}),正在换取 Token...`); this.save(hit.raw || code); const ok = await this.exchange(code); if (ok) { try { GM_notification({ title: "🟢 授权成功", text: "Token 已保存,可以关闭此页了", timeout: 6000 }); } catch (_) {} setTimeout(() => { try { window.close(); } catch (_) {} }, 2000); } else { try { GM_notification({ title: "🔴 授权失败", text: "授权码换取 Token 失败,请查看脚本日志后重新授权", timeout: 0 }); } catch (_) {} } }, /** 手动救急:在授权回调页(或任何 login.live.com 页面)当场把 Referer 里的 code * 抓出来兑换。原菜单「🕵️ 捕获 Referer 里的授权码」已移除,逻辑保留备查。 */ async recoverFromPage() { const hit = this.collectCode(true) || await this.waitForAnyCode(6000); if (!hit) { Utils.uiAlert( "🕵️ 没有在 Referer / 地址栏 / 页面内容里找到授权码。\n\n" + "请确认当前页就是授权跳转后的那一个(地址形如 /oauth20_desktop.srf?removed=true)。\n" + "若仍失败,按 F12 → 网络 → 点 oauth20_desktop.srf?removed=true → 标头 → 请求标头 →\n" + "复制 Referer 右侧的完整链接,再用「📋 粘贴授权码」粘贴。" ); return false; } Utils.log("🟢", `从页面捕获授权码(来源:${hit.source},${this.preview(hit.code)}),正在换取 Token...`); this.save(hit.raw || hit.code); const ok = await this.exchange(hit.code); Utils.uiAlert(ok ? "✅ 已用捕获到的授权码换取 Token 并保存。" : "⚠️ 捕获到授权码,但换取 Token 失败(多半已过期或已被用过)。请重新「🔑 手动授权」。"); return ok; }, /** 粘贴流程:解析 → 保存 → 立刻兑换 → 明确告知结果 */ async pasteFlow(input) { let raw = input; if (raw == null) { raw = await Utils.uiPrompt( "粘贴授权页跳转后的完整地址:\n" + "形如 https://login.live.com/oauth20_desktop.srf?code=M.C561_BAY...&lc=2052\n\n" + "如果手速不够快,地址栏发生了变化(没有code=M...=2052):\n" + "方案1:再次点击手动授权(看准时机复制地址栏内容)\n"+ "方案2:手动授权 → F12 → 网络(Network) → oauth20_desktop.srf?removed=true → 标头(Headers) → 请求标头(Request headers) → Referer → 复制右侧完整链接\n" ); if (raw == null) return false; } const code = this.save(raw); if (!code) { Utils.uiAlert("❌ 没能从粘贴内容里解析出授权码。\n\n请确认复制的是授权页跳转后的完整地址(里面要带 code=),然后重新粘贴。形如:\nhttps://login.live.com/oauth20_desktop.srf?code=M.C561_BAY...&lc=2052"); Utils.log("🔴", `粘贴的授权码无法解析(长度 ${String(raw).trim().length})`); return false; } Utils.log("🟢", `授权码已保存(${this.preview(code)}),立即换取 Token...`); const ok = await this.exchange(code); Utils.uiAlert(ok ? "✅ 授权成功,Token 已保存。\n\n授权码也已保留(可在「📊 Token状态」里查看)。授权码本身只能用一次,\n后续使用的是token,无需你再操作。" : "⚠️ 授权码已保存,但换取 Token 失败。\n\n最常见原因:授权码已过期或已被使用过。\n请重新点「🔑 手动授权」,登录后立刻复制跳转地址再粘贴。"); return ok; }, }; const API = { /* 统一走 POST 表单请求 token 端点,并解析 400 响应里的 JSON 错误体, * 否则 invalid_grant 会被当成网络错误盲重试。 */ async tokenRequest(params) { const body = Object.entries(params) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .join("&"); let res; try { res = await 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: body, timeout: 20000, onload: r => resolve(r), onerror: e => reject(new Error((e && e.error) || "网络错误")), ontimeout: () => reject(new Error("请求超时")), }); }); } catch (e) { return { ok: false, status: 0, error: "network", description: e.message }; } let json = null; try { json = JSON.parse(res.responseText || ""); } catch (_) {} if (json && json.access_token && json.refresh_token) { return { ok: true, status: res.status, json }; } const error = (json && json.error) || `HTTP ${res.status}`; const description = (json && (json.error_description || json.error_codes)) || String(res.responseText || "").slice(0, 200); return { ok: false, status: res.status, error: String(error), description: String(description) }; }, applyToken(data) { GM_setValue("Config.token", data.refresh_token); GM_setValue("Config.tokenTime", Utils.getTimestamp()); // 缓存 access_token:同一账号的多个上下文可复用,避免并发刷新互相踢 token GM_setValue("Config.accessToken", data.access_token); GM_setValue("Config.accessTokenTime", Date.now()); RewardsAuto.state.token = data.access_token; }, async refreshWithToken(refreshToken) { return await this.tokenRequest({ client_id: CLIENT_ID, refresh_token: refreshToken, scope: TOKEN_SCOPE, grant_type: "REFRESH_TOKEN", }); }, async withTokenRetry(requestFn) { let token = RewardsAuto.state.token; if (!token) return null; try { return await requestFn(token); } catch (e) { if (e.message && e.message.includes("401")) { // 401 只代表 access_token 过期,绝不能清 refresh_token, // 否则一次偶发 401 会让当天后续任务全部退化成「需要手动授权」。 Utils.log("🟡", "access_token 过期,使用 refresh_token 续期..."); RewardsAuto.state.token = null; GM_setValue("Config.accessToken", ""); GM_setValue("Config.accessTokenTime", 0); const refreshed = await this.renewToken(); if (!refreshed) return null; return await requestFn(RewardsAuto.state.token); } throw e; } }, /** 统一 Token 入口(缓存复用 + 跨上下文单飞)。 * MS 的 refresh_token 每次刷新都轮换,并发刷新会让其中一个拿着作废的 token 换来 invalid_grant。 */ async renewToken() { if (!GM_getValue("Tasks.sign", true) && !GM_getValue("Tasks.read", true)) return true; const cachedAt = GM_getValue("Config.accessTokenTime", 0); const cachedAT = GM_getValue("Config.accessToken", ""); if (cachedAT && cachedAt && Date.now() - cachedAt < 45 * 60 * 1000) { RewardsAuto.state.token = cachedAT; return true; } if (!Utils.acquireLock("Config.tokenLock", 90 * 1000)) { Utils.log("⏳", "其它上下文正在刷新 Token,等待复用..."); for (let i = 0; i < 10; i++) { await Utils.delay(2000); const at = GM_getValue("Config.accessToken", ""); const ts = GM_getValue("Config.accessTokenTime", 0); if (at && ts > cachedAt) { RewardsAuto.state.token = at; Utils.log("🟢", "已复用其它上下文刷新的 Token"); return true; } } Utils.log("🟡", "等待 Token 超时,本上下文自行刷新"); } try { return await this.renewTokenInner(); } finally { Utils.releaseLock("Config.tokenLock"); } }, async renewTokenInner() { // ---------- 1) 已有 refresh_token:直接刷新 ---------- let refreshToken = GM_getValue("Config.token", false); const tokenTime = Number(GM_getValue("Config.tokenTime", 0)) || 0; if (refreshToken && tokenTime > 0) { const days = (Utils.getTimestamp() - tokenTime) / (1000 * 60 * 60 * 24); if (days > 7) { Utils.log("🟡", `Token 已 ${Math.floor(days)} 天,提前续期`); refreshToken = false; } } if (refreshToken) { const r = await this.refreshWithToken(refreshToken); if (r.ok) { this.applyToken(r.json); return true; } Utils.log("🔴", `刷新 Token 失败:${r.error}${r.description ? " - " + r.description : ""}`); // 并发轮换:别的上下文可能已经换出了新 refresh_token const latest = GM_getValue("Config.token", false); if (latest && latest !== refreshToken) { Utils.log("🟡", "检测到其它上下文已更新 refresh_token,改用新值重试"); const r2 = await this.refreshWithToken(latest); if (r2.ok) { this.applyToken(r2.json); return true; } } if (r.error !== "invalid_grant") { // 网络 / 服务端临时故障:不要清 Token,下次再试 return false; } Utils.log("🟡", "refresh_token 已失效,需要重新授权"); GM_setValue("Config.token", false); GM_setValue("Config.tokenTime", 0); } // ---------- 2) 优先使用「📋 粘贴授权码」保存的码 ---------- const storedCode = Auth.stored(); if (storedCode) { Utils.log("🟡", "发现已保存的授权码,尝试用它换取 Token..."); if (await Auth.exchange(storedCode)) return true; } // ---------- 3) 自动获取授权码 ---------- const got = await Auth.autoFetch(refreshToken ? "Token 刷新失败" : "检测到尚未授权"); if (got.tokenReady) return true; if (got.code) { if (await Auth.exchange(got.code)) return true; Utils.log("🔴", "授权码换取 Token 失败,请重新「🔑 手动授权」", true); } return false; }, async getRewardsInfo(maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const html = await Utils.xhr({ url: "https://rewards.bing.com/earn" }); const clean = html.replace(/\\"/g, '"'); let balance = 0; let pcMax = 60, pcCur = 0, mobMax = 0, mobCur = 0; let dailyOffer = 0; let searchQuotaFound = false; const pcIdx = clean.indexOf('"pointsCounters":{'); if (pcIdx !== -1) { let start = pcIdx + 17, depth = 0, end = start; for (let i = start; i < clean.length && i < start + 500; i++) { if (clean[i] === '{') depth++; if (clean[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } } } try { const pts = JSON.parse(clean.substring(start, end)); pcMax = pts.pc?.max ?? 60; pcCur = pts.pc?.progress ?? 0; mobMax = pts.mobile?.max ?? 0; mobCur = pts.mobile?.progress ?? 0; dailyOffer = pts.dailyOffer ?? 0; if (pts.totalPoints != null) balance = Number(pts.totalPoints); } catch {} } if (balance === 0) { const balMatch = clean.match(/"balance":(\d+)/) || clean.match(/"availablePoints":(\d+)/); if (balMatch) balance = parseInt(balMatch[1]); } const todayDetails = []; const acIdx = clean.indexOf('"activityCards":['); if (acIdx !== -1) { let acStart = acIdx + 16, acDepth = 0, acEnd = acStart; for (let i = acStart; i < clean.length && i < acStart + 5000; i++) { if (clean[i] === '[') acDepth++; if (clean[i] === ']') { acDepth--; if (acDepth === 0) { acEnd = i + 1; break; } } } try { const cardsStr = clean.substring(acStart, acEnd); const cardRegex = /"title":"([^"]+)".*?"points":(\d+).*?"isCompleted":(true|false)/g; let cardMatch; while ((cardMatch = cardRegex.exec(cardsStr)) !== null) { const title = cardMatch[1]; const points = parseInt(cardMatch[2]); const isCompleted = cardMatch[3] === "true"; if (points > 0 && isCompleted) todayDetails.push({ title, points }); } } catch {} } const toNum = value => parseInt(String(value).replace(/,/g, ''), 10) || 0; const searchRowMatch = clean.match(/
\s*必应搜索\s*<\/p>\s*<\/div>\s*
([^<]+)<\/p><\/div>