]*>\s*\/\s*([\d,]+)\s*<\/span>/i)
|| searchRowMatch[1].match(/([\d,]+)\s*\/\s*([\d,]+)/))
: null;
if (searchHtmlMatch) {
pcCur = toNum(searchHtmlMatch[1]);
pcMax = toNum(searchHtmlMatch[2]);
searchQuotaFound = pcMax > 0;
todayDetails.push({ title: '必应搜索', points: pcCur, max: pcMax });
Utils.log("🔍", `页面表格配额: PC ${pcCur}/${pcMax}`);
}
const searchRscMatch = clean.match(/"combinedSearch":\{[^}]*"progress":(\d+)[^}]*"max":(\d+)/);
if (searchRscMatch && !todayDetails.some(d => d.title === '必应搜索')) {
pcCur = toNum(searchRscMatch[1]);
pcMax = toNum(searchRscMatch[2]);
searchQuotaFound = pcMax > 0;
todayDetails.push({ title: '必应搜索', points: pcCur, max: pcMax });
}
if (dailyOffer > 0) todayDetails.push({ title: '优惠', points: dailyOffer });
const otherActivityRegex = /([^<]+)<\/p><\/div>
]*>(\d+)<\/div>/g;
let otherMatch;
while ((otherMatch = otherActivityRegex.exec(clean)) !== null) {
const title = otherMatch[1];
const points = parseInt(otherMatch[2]);
if (points > 0 && !todayDetails.some(d => d.title === title)) {
todayDetails.push({ title, points });
}
}
const history = { month: 0, year: 0, lifetime: 0 };
const historyRscMatch = clean.match(/"pointsHistory":\{[^}]*"thisMonth":\{"earn":(\d+)[^}]*"thisYear":\{"earn":(\d+)[^}]*"lifetime":\{"earn":(\d+)/);
if (historyRscMatch) {
history.month = parseInt(historyRscMatch[1]);
history.year = parseInt(historyRscMatch[2]);
history.lifetime = parseInt(historyRscMatch[3]);
} else {
const monthHtmlMatch = clean.match(/本月.*?(\d[\d,]*)<\/div>/);
const yearHtmlMatch = clean.match(/今年.*?(\d[\d,]*)<\/div>/);
const lifetimeHtmlMatch = clean.match(/生存期.*?(\d[\d,]*)<\/div>/);
const monthJsonMatch = clean.match(/"monthlyPoints":(\d+)/);
const yearJsonMatch = clean.match(/"yearlyPoints":(\d+)/);
const lifetimeJsonMatch = clean.match(/"lifetimePoints":(\d+)/);
if (monthHtmlMatch) history.month = parseInt(monthHtmlMatch[1].replace(/,/g, ''));
else if (monthJsonMatch) history.month = parseInt(monthJsonMatch[1]);
if (yearHtmlMatch) history.year = parseInt(yearHtmlMatch[1].replace(/,/g, ''));
else if (yearJsonMatch) history.year = parseInt(yearJsonMatch[1]);
if (lifetimeHtmlMatch) history.lifetime = parseInt(lifetimeHtmlMatch[1].replace(/,/g, ''));
else if (lifetimeJsonMatch) history.lifetime = parseInt(lifetimeJsonMatch[1]);
}
if (!searchQuotaFound) {
const userInfoResult = await this.getSearchQuotaFromUserInfo();
if (userInfoResult) {
Utils.log("🟢", "页面未命中搜索配额,使用 getuserinfo 兜底");
return userInfoResult;
}
}
if (!searchQuotaFound && RewardsAuto.state.token) {
const apiResult = await this.getSearchQuotaFromAPI();
if (apiResult) {
Utils.log("🟢", "页面未命中搜索配额,使用 DAPI 兜底");
return apiResult;
}
}
// 补一次 DAPI 查询,保证汇总里的阅读进度是真实值(否则永远显示 0/30)
let readProgress = RewardsAuto.state.readProgress ?? 0;
let readMax = RewardsAuto.state.readMax ?? 30;
const readInfo = await this.getReadProgress();
if (readInfo) {
readProgress = readInfo.progress;
readMax = readInfo.max;
RewardsAuto.state.readProgress = readProgress;
RewardsAuto.state.readMax = readMax;
} else {
Utils.log("🟡", "阅读进度查询失败,汇总里的阅读进度可能不准");
}
return { balance, pc: { progress: pcCur, max: pcMax }, mobile: { progress: mobCur, max: mobMax }, readProgress, readMax, dailyOffer, todayDetails, history };
} catch (e) {
if (attempt < maxRetries) { await Utils.delay(3210); continue; }
Utils.log("🔴", `仪表盘获取失败: ${e.message}`);
return false;
}
}
return false;
},
async signApp() {
const region = GM_getValue("Config.lock", true) ? "cn" : RewardsAuto.state.region.toLowerCase();
try {
const res = await this.withTokenRetry(token => Utils.xhr({
method: "POST",
url: "https://prod.rewardsplatform.microsoft.com/dapi/me/activities",
headers: {
"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": region,
"x-rewards-language": "zh",
"x-rewards-partnerid": "startapp",
"x-rewards-flights": "rwgobig"
},
data: JSON.stringify({
amount: 1, id: Utils.getRandomUUID().replace(/-/g, '') + Utils.getRandomUUID().replace(/-/g, '').slice(0, 24),
type: 103, country: region, channel: RewardsAuto.appConfig.channel
})
}));
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
const response = data.response || {};
if (response.activity) return Number(response.activity.p || response.activity.points || 0);
if (response.isDuplicate || response.activity === null) return 0;
Utils.log("🟡", `App签入响应未确认: ${String(res).slice(0, 120)}`);
}
} catch (e) {
Utils.log("🔴", `App签入失败: ${e.message}`);
}
return -1;
},
async getRequestVerificationToken(pageUrl = "https://rewards.bing.com/") {
try {
const html = await Utils.xhr({
url: pageUrl,
headers: { "user-agent": RewardsAuto.ua.pc, "referer": "https://rewards.bing.com/" },
anonymous: false
});
const tokenMatch = html.match(/name=["']__RequestVerificationToken["'][^>]*value=["']([^"']+)["']/i)
|| html.match(/RequestVerificationToken.*?value=["']([^"']+)["']/i)
|| html.match(/"verificationToken"\s*:\s*"([^"]+)"/i)
|| html.match(/"__RequestVerificationToken"\s*:\s*"([^"]+)"/i);
return tokenMatch ? tokenMatch[1].replace(/&/g, "&") : "";
} catch (e) {
Utils.log("🟡", `活动Token获取失败: ${e.message}`);
return "";
}
},
async reportActivity(offerId, hash, referer = "https://rewards.bing.com/") {
const token = await this.getRequestVerificationToken(referer);
const params = new URLSearchParams({ id: offerId, hash: hash || "1", activityAmount: "1" });
if (token) params.set("__RequestVerificationToken", token);
const headers = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"user-agent": RewardsAuto.ua.pc,
"referer": referer,
"origin": "https://rewards.bing.com",
"x-requested-with": "XMLHttpRequest"
};
if (token) headers["RequestVerificationToken"] = token;
return await Utils.xhr({
method: "POST",
url: "https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest",
headers, data: params.toString(), anonymous: false
});
},
async reportDailyActivity(searchUrl) {
try {
const fullUrl = searchUrl.startsWith("http") ? searchUrl : `https://cn.bing.com${searchUrl}`;
const ig = Utils.getRandomUUID().replace(/-/g, '').substring(0, 32).toUpperCase();
const cnUrl = fullUrl.replace(/^https?:\/\/www\.bing\.com/, "https://cn.bing.com")
.replace(/^https:\/\/bing\.com/, "https://cn.bing.com");
const cnUrlObj = new URL(cnUrl);
const cnSp = cnUrlObj.searchParams;
const reportParams = new URLSearchParams();
reportParams.set("IG", ig);
reportParams.set("IID", "commerce.5067");
if (cnSp.get("form")) reportParams.set("form", cnSp.get("form"));
if (cnSp.get("ocid") || cnSp.get("OCID")) reportParams.set("ocid", cnSp.get("ocid") || cnSp.get("OCID"));
if (cnSp.get("rnoreward")) reportParams.set("rnoreward", cnSp.get("rnoreward"));
try {
await Utils.xhr({
method: "GET", url: cnUrl,
headers: {
"user-agent": RewardsAuto.ua.pc,
"referer": "https://rewards.bing.com/",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8"
}
});
} catch (_) {}
const ncheaderParams = new URLSearchParams();
ncheaderParams.set("ver", String(Date.now()).substring(0, 8));
ncheaderParams.set("IID", "commerce.5057");
ncheaderParams.set("IG", ig);
try {
await Utils.xhr({
method: "POST",
url: `https://cn.bing.com/rewardsapp/ncheader?${ncheaderParams.toString()}`,
headers: {
"content-type": "application/x-www-form-urlencoded",
"user-agent": RewardsAuto.ua.pc,
"referer": cnUrl,
"origin": "https://cn.bing.com",
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8"
},
data: "wb=1;i=1;v=1"
});
} catch (_) {}
const bodyParams = new URLSearchParams();
bodyParams.set("url", cnUrl);
bodyParams.set("V", "web");
await Utils.xhr({
method: "POST",
url: `https://cn.bing.com/rewardsapp/reportActivity?${reportParams.toString()}`,
headers: {
"content-type": "application/x-www-form-urlencoded",
"user-agent": RewardsAuto.ua.pc,
"referer": cnUrl,
"origin": "https://cn.bing.com",
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8"
},
data: bodyParams.toString()
});
return true;
} catch (e) {
Utils.log("🟡", `每日活动上报失败: ${e.message}`);
return false;
}
},
async signPC() {
try {
const res = await this.reportActivity("Gamification_DailyCheckIn", "1", "https://rewards.bing.com/");
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
return Number(data.points || data.response?.activity?.p || 0);
}
} catch (e) {
if (e.message?.includes("401")) RewardsAuto.state.pc401 = true;
Utils.log("🟡", `PC签入失败: ${e.message}`);
}
return -1;
},
async appActivity(type, offerid) {
const region = GM_getValue("Config.lock", true) ? "cn" : RewardsAuto.state.region.toLowerCase();
const body = {
amount: 1, country: region,
id: Utils.getRandomUUID().replace(/-/g, '') + Utils.getRandomUUID().replace(/-/g, '').slice(0, 24),
type: type, channel: RewardsAuto.appConfig.channel
};
if (offerid) body.attributes = { offerid: offerid };
try {
const res = await this.withTokenRetry(token => Utils.xhr({
method: "POST",
url: "https://prod.rewardsplatform.microsoft.com/dapi/me/activities",
headers: {
"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": region,
"x-rewards-language": "zh"
},
data: JSON.stringify(body)
}));
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
return {
points: data.response?.activity?.p || 0,
isDuplicate: data.response?.isDuplicate || false,
balance: data.response?.balance || 0
};
}
} catch (e) {
Utils.log("🔴", `App活动失败(${offerid}): ${e.message}`);
}
return null;
},
async getReadProgress() {
try {
const res = await this.withTokenRetry(token => Utils.xhr({
url: "https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613",
headers: {
"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": "cn",
"x-rewards-language": "zh"
}
}));
if (Utils.isJSON(res)) {
const promos = JSON.parse(res).response?.promotions || [];
const readOfferId = RewardsAuto.appConfig.offerIds.readArticle;
const task = promos.find(x => x.attributes?.offerid === readOfferId);
if (task && task.attributes) {
const progress = parseInt(task.attributes.progress) || 0;
const max = parseInt(task.attributes.max) || 30;
Utils.log("📊", `阅读进度查询: ${progress}/${max} (offerid: ${readOfferId})`);
return { progress, max };
} else {
Utils.log("🟡", `阅读任务未找到 (offerid: ${readOfferId})`);
}
} else {
Utils.log("🟡", `DAPI 响应不是 JSON: ${String(res).substring(0, 100)}`);
}
} catch (e) {
Utils.log("🔴", `阅读进度获取失败: ${e.message}`);
}
return false;
},
/* ===== Edge 通道(浏览连签 / Edge browsing streak) =====
* 微软给 Edge 用户的每日任务:在 Edge 里累计浏览满 30 分钟即记账,满 30 分钟后
* promotion.attributes.complete 翻成 true。这一项只存在于 Edge 通道下
* (channel=edge、appid=EdgeDesktop、partnerid=EdgeHub),SAAndroid/App 通道
* 看不到它,所以必须单独一套请求头,不能复用 appActivity。
* 计时由服务端按「每次上报覆盖 report_per_minutes 分钟」累加:分次上报才有效,
* 两次间隔小于 report_per_minutes 会被判重(response.isDuplicate=true,不计次)。
*/
edgeRegion() {
if (GM_getValue("Config.lock", true)) return "cn";
const region = String(RewardsAuto.state.region || "us").toLowerCase();
return region || "us";
},
_edgeHeaders(region, token) {
const country = region || this.edgeRegion();
return {
"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": country,
"x-rewards-language": country === "cn" ? "zh-CN" : "en-US",
};
},
// Profile,Promotions:一次拿到账号档案 + 全部 promotion(含 Edge 连签那一项)
async getEdgeProfile(region) {
try {
const res = await this.withTokenRetry(token => Utils.xhr({
url: "https://prod.rewardsplatform.microsoft.com/dapi/me?channel=edge&options=Profile,Promotions",
headers: this._edgeHeaders(region, token)
}));
if (!res || !Utils.isJSON(res)) {
Utils.log("🟡", `Edge 通道返回非 JSON:${String(res).substring(0, 80)}`);
return null;
}
const data = JSON.parse(res);
if (data.code !== 0 || !data.response) {
Utils.log("🟡", `Edge 通道查询失败:code=${data.code}`);
return null;
}
return data.response;
} catch (e) {
Utils.log("🟡", `Edge 通道查询异常: ${e.message}`);
return null;
}
},
// 上报一次浏览活动:服务端按 report_offer_type 记账,amount 固定 1
async reportEdgeActivity({ offerid, type, region }) {
try {
const res = await this.withTokenRetry(token => Utils.xhr({
method: "POST",
url: "https://prod.rewardsplatform.microsoft.com/dapi/me/activities",
headers: this._edgeHeaders(region, token),
data: JSON.stringify({
amount: 1,
attributes: { offerid },
request_user_info: true,
type
})
}));
return (res && Utils.isJSON(res)) ? JSON.parse(res) : { code: -1, response: null };
} catch (e) {
Utils.log("🟡", `Edge 连签上报异常: ${e.message}`);
return { code: -1, response: null };
}
},
// 激活 Edge 连签活动:新账号/新周期要先在 earn 页「开启」一次,否则 promotion 不会出现
async activateEdgeBrowsing() {
const earnUrl = "https://rewards.bing.com/earn";
try {
let html = "";
try {
html = await Utils.xhr({
url: `${earnUrl}?section=streaks`,
headers: { "user-agent": RewardsAuto.ua.pc, "referer": earnUrl }
});
} catch (_) { /* 换 earn 首页再试 */ }
if (!html || !String(html).includes("edge_flight")) {
try {
html = await Utils.xhr({
url: earnUrl,
headers: { "user-agent": RewardsAuto.ua.pc, "referer": earnUrl }
});
} catch (_) { /* 下面统一判空 */ }
}
if (!html) return "failed";
const clean = String(html).replace(/\\"/g, '"');
// 新版 Edge 把活动 id 从 edge_flight_1_... 升到了 edge_flight_2_...,
// 所以按前缀取,已知的 1 号优先
const ids = [...clean.matchAll(/"activationOfferId":"(edge_flight[^"]*)"/g)].map(m => m[1]);
if (ids.length === 0) {
// 页面上连 edge 的活动位都没有:账号/地区不支持,今天不用再试
return /"partner":"edge"/.test(clean) ? "failed" : "absent";
}
const offerId = ids.includes(EDGE.ACTIVATION_OFFER) ? EDGE.ACTIVATION_OFFER : ids[0];
const idx = clean.indexOf(`"activationOfferId":"${offerId}"`);
const win = clean.substring(Math.max(0, idx - 600), idx + 500);
if (/"isEnabled":true/.test(win)) return "already";
const hashMatch = win.match(/"activationHash":"([^"]+)"/);
const typeMatch = win.match(/"activationActivityType":(\d+)/);
const hash = hashMatch ? hashMatch[1] : "";
const actType = typeMatch ? Number(typeMatch[1]) : 714;
if (!hash || hash === "$undefined") return "failed";
const nextAction = await this.getNextActionHash(html);
await Utils.xhr({
method: "POST",
url: earnUrl,
headers: {
"content-type": "text/plain;charset=UTF-8",
"next-action": nextAction,
"origin": "https://rewards.bing.com",
"referer": earnUrl,
"user-agent": RewardsAuto.ua.pc,
},
data: JSON.stringify([hash, actType, { offerid: offerId, isPromotional: "$undefined", timezoneOffset: "-480" }])
});
return "activated";
} catch (e) {
Utils.log("🟡", `Edge 连签激活失败: ${e.message}`);
return "failed";
}
},
/* DAPI 权威任务状态:网页通道依赖 Cookie 与 UA 指纹,Edge 与 Chrome/Firefox 不一致且无法复核;
* 这里直接读 BingSapphire App 的 DAPI 接口(Bearer Token),可精确读到每张卡的 progress/max。 */
_promoCache: null,
async getAppPromotions(force = false) {
const now = Date.now();
if (!force && this._promoCache && now - this._promoCache.ts < 15000) return this._promoCache.list;
try {
const res = await this.withTokenRetry(token => Utils.xhr({
url: RewardsAuto.appConfig.dapiMeUrl,
headers: {
"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": GM_getValue("Config.lock", true) ? "cn" : "us",
"x-rewards-language": "zh"
}
}));
if (Utils.isJSON(res)) {
const list = JSON.parse(res).response?.promotions || [];
this._promoCache = { list, ts: Date.now() };
return list;
}
Utils.log("🟡", `DAPI 任务列表非 JSON: ${String(res).slice(0, 100)}`);
} catch (e) {
Utils.log("🟡", `DAPI 任务列表获取失败: ${e.message}`);
}
return null;
},
async getPromotionMap(force = false) {
const list = await this.getAppPromotions(force);
if (!list) return null;
const map = new Map();
for (const p of list) {
const a = p.attributes || {};
// 注意:部分 promotion(如 Gamification_Streak_Counter_Promotion)没有 offerid,
// 其身份就是 promotion 的 name 字段,这类条目必须用 name 做键,否则会丢字段。
const offerId = a.offerid || a.offerId || p.name;
if (!offerId) continue;
const max = parseInt(a.max ?? a.activitymax ?? 0, 10) || 0;
// 注意字段名:日常卡片用 progress,BingSapphire 系(含 streak 计数)用 activity_progress
const progress = parseInt(a.progress ?? a.activity_progress ?? a.activityprogress ?? 0, 10) || 0;
map.set(offerId, {
offerId,
name: p.name || "",
max,
progress,
complete: String(a.complete).toLowerCase() === "true" || (max > 0 && progress >= max),
title: a.title || p.name || "",
destination: a.destination || "",
type: a.type || "",
state: a.State || "",
dailySetDate: a.daily_set_date || "",
hidden: String(a.hidden).toLowerCase() === "true"
});
}
return map;
},
async isOfferComplete(offerId, force = false) {
if (!offerId) return null;
const map = await this.getPromotionMap(force);
if (!map) return null;
const item = map.get(offerId);
return item ? item.complete : null;
},
// 通过 DAPI 发现「今日日常活动卡片」,不依赖页面 HTML / Cookie;preMap 可复用已获取的 map
async discoverDailySetCards(preMap = null) {
const todayStr = Utils.getTodayStr();
const todayNum = String(Utils.getTodayNum());
const map = preMap || await this.getPromotionMap(true);
if (!map) return [];
const cards = [];
for (const item of map.values()) {
const isDailySet = item.offerId.includes(RewardsAuto.appConfig.offerIds.dailySetPrefix)
|| (item.type === "urlreward" && item.dailySetDate === todayStr);
if (!isDailySet) continue;
const scheduledToday = item.dailySetDate === todayStr || item.offerId.includes(todayNum);
if (!scheduledToday) continue;
if (item.complete) continue;
cards.push({
title: item.title || item.offerId,
points: item.max || 10,
offerId: item.offerId,
hash: "",
kind: "daily",
url: "https://rewards.bing.com/earn",
destination: item.destination || "",
source: "dapi"
});
}
return cards;
},
async getRewardsToken() {
try {
const html = await Utils.xhr({
url: "https://rewards.bing.com/",
headers: { "user-agent": RewardsAuto.ua.pc, "referer": "https://rewards.bing.com/" }
});
if (html) {
const match = html.replace(/\s/g, "").match(/RequestVerificationToken(.*?)value="(.*?)"/);
if (match) return match[2];
}
} catch (e) {
Utils.log("🟡", `RequestVerificationToken 获取失败: ${e.message}`);
}
return false;
},
async getSearchQuotaFromUserInfo(light = false) {
try {
const res = await Utils.xhr({
url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`,
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"user-agent": RewardsAuto.ua.pc,
"referer": "https://rewards.bing.com/",
"x-requested-with": "XMLHttpRequest"
},
anonymous: false
});
if (!Utils.isJSON(res)) return false;
const data = JSON.parse(res);
const dashboard = data.dashboard || data;
const userStatus = dashboard.userStatus || {};
const counters = userStatus.counters || {};
const sumCounter = items => {
if (!Array.isArray(items)) return { progress: 0, max: 0 };
return items.reduce((acc, item) => {
acc.progress += Number(item.pointProgress || 0);
acc.max += Number(item.pointProgressMax || item.pointMax || 0);
return acc;
}, { progress: 0, max: 0 });
};
const pc = sumCounter(counters.pcSearch);
if (pc.max === 0) return false;
const balance = Number(userStatus.availablePoints || dashboard.availablePoints || 0);
let readProgress = RewardsAuto.state.readProgress ?? 0;
let readMax = RewardsAuto.state.readMax ?? 30;
/* light=true:只取搜索配额。搜索节拍器每次搜索后都要核对一次记账,
* 这里省掉额外的阅读进度请求和日志,免得 20 次搜索刷 20 行 + 20 个请求。 */
if (!light) {
try {
const readInfo = await API.getReadProgress();
if (readInfo) { readProgress = readInfo.progress; readMax = readInfo.max; }
} catch (e) {}
Utils.log("📊", `getuserinfo查询: PC ${pc.progress}/${pc.max}, 阅读 ${readProgress}/${readMax}, 积分 ${balance}`);
}
return {
balance, pc, readProgress, readMax, dailyOffer: 0,
todayDetails: pc.max > 0 ? [{ title: "必应搜索", points: pc.progress, max: pc.max }] : [],
history: null
};
} catch (e) {
if (GM_getValue("Config.debugDAPI", false)) {
Utils.log("🟡", `getuserinfo 查询失败: ${e.message}`);
}
return false;
}
},
async getSearchQuotaFromAPI(light = false) {
try {
const res = await this.withTokenRetry(token => Utils.xhr({
url: "https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613",
headers: {
"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": "cn",
"x-rewards-language": "zh"
}
}));
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
const response = data.response || {};
const promos = response.promotions || [];
const counters = response.counters || response.userStatus?.counters;
if (!counters) return false;
let pcCur = 0, pcMax = 0;
if (counters?.pcSearch && counters.pcSearch.length > 0) {
pcCur = counters.pcSearch[0].pointProgress || 0;
pcMax = counters.pcSearch[0].pointProgressMax || 0;
}
if (pcMax === 0) return false;
const balance = response.balance || response.userStatus?.availablePoints || 0;
let readProgress = RewardsAuto.state.readProgress ?? 0;
let readMax = RewardsAuto.state.readMax ?? 30;
if (!light) {
try {
const readInfo = await API.getReadProgress();
if (readInfo) { readProgress = readInfo.progress; readMax = readInfo.max; }
} catch (e) {}
Utils.log("📊", `DAPI查询: PC ${pcCur}/${pcMax}, 阅读 ${readProgress}/${readMax}, 积分 ${balance}`);
}
return { balance, pc: { progress: pcCur, max: pcMax }, readProgress, readMax, dailyOffer: 0, todayDetails: [], history: null };
}
} catch (e) {
if (!light) Utils.log("🟡", `DAPI查询失败: ${e.message}`);
}
return false;
},
/* ===== 搜索记账核对 =====
* PC 搜索的积分是「服务端记账」,本地 +3 只是猜测:被限流时搜索照发但一分不给。
* 所以每搜一次就核对一次真实进度,节拍器才知道该加速还是该退让。
* 优先用 getuserinfo(轻量 GET,带 PC 会话 Cookie),拿不到再走 App 通道的 DAPI。 */
async getPcQuota() {
try {
const ui = await this.getSearchQuotaFromUserInfo(true);
if (ui && ui.pc && ui.pc.max > 0) return { progress: ui.pc.progress, max: ui.pc.max };
} catch (_) {}
try {
if (RewardsAuto.state.token) {
const dapi = await this.getSearchQuotaFromAPI(true);
if (dapi && dapi.pc && dapi.pc.max > 0) return { progress: dapi.pc.progress, max: dapi.pc.max };
}
} catch (_) {}
return null;
},
async getBalance() {
try {
const res = await this.withTokenRetry(token => Utils.xhr({
url: "https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=105",
headers: {
"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": "cn",
"x-rewards-language": "zh"
}
}));
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
return data.response?.balance || 0;
}
} catch (e) {}
try {
const res2 = await Utils.xhr({
url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`,
headers: {
"user-agent": RewardsAuto.ua.pc,
"referer": "https://rewards.bing.com/",
"x-requested-with": "XMLHttpRequest"
},
});
if (Utils.isJSON(res2)) {
const data2 = JSON.parse(res2);
return data2.dashboard?.availablePoints || data2.balance || 0;
}
} catch (e) {}
return 0;
},
async doRead() {
const region = GM_getValue("Config.lock", true) ? "cn" : RewardsAuto.state.region.toLowerCase();
try {
const id = Utils.getRandomUUID().replace(/-/g, '') + Utils.getRandomUUID().replace(/-/g, '').slice(0, 24);
const res = await this.withTokenRetry(token => Utils.xhr({
method: "POST",
url: "https://prod.rewardsplatform.microsoft.com/dapi/me/activities",
headers: {
"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": region,
"x-rewards-language": "zh"
},
data: JSON.stringify({
amount: 1, country: region, id: id, type: 101,
attributes: { offerid: RewardsAuto.appConfig.offerIds.readArticle }
})
}));
if (Utils.isJSON(res)) {
const data = JSON.parse(res);
return { points: data.response?.activity?.p || 0, isDuplicate: data.response?.isDuplicate || false };
}
return null;
} catch (e) {
Utils.log("🔴", `阅读请求失败: ${e.message}`);
return false;
}
},
async discoverCards() {
const cards = [];
const seenCardKeys = new Set();
let earnHtml = ""; // 提升到 try 外:桌面奖励任务解析块需要复用 earn 页 HTML
try {
const html = await Utils.xhr({ url: "https://rewards.bing.com/earn" });
if (!html) { Utils.log("🔴", "earn 页面返回空"); return cards; }
earnHtml = html;
const clean = html.replace(/\\"/g, '"');
const pushCard = (card) => {
if (!card || !card.offerId || !card.hash || card.points <= 0) return;
const key = `${card.offerId}:${card.hash}`;
if (seenCardKeys.has(key)) return;
seenCardKeys.add(key);
cards.push(card);
};
const inferKind = (offerId, title = "") => {
const text = `${offerId} ${title}`.toLowerCase();
if (/quiz|trivia|question|测验|答案/.test(text)) return "quiz";
if (/puzzle|拼图/.test(text)) return "puzzle";
if (/image|图片|设计/.test(text)) return "image_creator";
if (/explore|search|浏览|探索/.test(text)) return "explore_search";
if (/dailyset|daily|每日/.test(text)) return "daily";
if (/streak|打卡|连续/.test(text)) return "streak";
return "open_only";
};
const parseCard = (obj) => {
const offerIdMatch = obj.match(/"offerId":"([^"]+)"/i) || obj.match(/"offerid":"([^"]+)"/i);
const hashMatch = obj.match(/"hash":"([^"]+)"/) || obj.match(/"activityId":"([^"]+)"/) || obj.match(/"id":"([^"]+)"/);
if (!offerIdMatch || !hashMatch) return null;
const offerId = offerIdMatch[1];
const hash = hashMatch[1];
const pointsMatch = obj.match(/"points":(\d+)/);
const isCompletedMatch = obj.match(/"isCompleted":(true|false)/i) || obj.match(/"state":"(completed|CLAIMED)"/i);
const titleMatch = obj.match(/"title":"([^"]+)"/) || obj.match(/"name":"([^"]+)"/);
const points = pointsMatch ? parseInt(pointsMatch[1]) : 0;
const isCompleted = isCompletedMatch ? (isCompletedMatch[1] === "true" || isCompletedMatch[1] === "completed") : false;
if (isCompleted) return null;
const title = titleMatch ? titleMatch[1] : "";
const skip = RewardsAuto.skipPatterns.some(p => title.toLowerCase().includes(p.toLowerCase()) || offerId.toLowerCase().includes(p.toLowerCase()));
if (skip) return null;
return { title, points, offerId, hash, kind: inferKind(offerId, title) };
};
try {
const userInfo = await Utils.xhr({
url: `https://rewards.bing.com/api/getuserinfo?type=1&X-Requested-With=XMLHttpRequest&_=${Date.now()}`,
headers: { "user-agent": RewardsAuto.ua.pc, "referer": "https://rewards.bing.com/", "x-requested-with": "XMLHttpRequest" },
anonymous: false
});
if (Utils.isJSON(userInfo)) {
const data = JSON.parse(userInfo);
const dashboard = data.dashboard || data;
const now = new Date();
const todayKeys = new Set([
`${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}/${now.getFullYear()}`,
`${now.getMonth() + 1}/${now.getDate()}/${now.getFullYear()}`
]);
const normalizeDashboardCard = (item, kind) => {
if (!item) return null;
const offerId = item.offerId || item.offerid || item.id || item.name;
const hash = item.hash || item.activityId;
const title = item.title || item.name || item.description || "";
const points = Number(item.points ?? item.pointProgressMax ?? item.max ?? 0);
const doneMax = Number(item.pointProgressMax || 0);
const doneCur = Number(item.pointProgress || 0);
const isCompleted = item.isCompleted || item.complete || (doneMax > 0 && doneCur >= doneMax);
if (!offerId || !hash || points <= 0 || isCompleted) return null;
const skip = RewardsAuto.skipPatterns.some(p => title.toLowerCase().includes(p.toLowerCase()) || offerId.toLowerCase().includes(p.toLowerCase()));
if (skip) return null;
return { title, points, offerId, hash, kind: kind || inferKind(offerId, title), source: "getuserinfo", url: item.destinationUrl || "https://rewards.bing.com/" };
};
const dailySetPromotions = dashboard.dailySetPromotions || {};
for (const dateKey of todayKeys) {
const dailyItems = dailySetPromotions[dateKey];
if (Array.isArray(dailyItems)) {
for (const item of dailyItems) pushCard(normalizeDashboardCard(item, "daily"));
}
}
const morePromotions = dashboard.morePromotions || dashboard.promotions || [];
if (Array.isArray(morePromotions)) {
for (const item of morePromotions) pushCard(normalizeDashboardCard(item));
}
}
} catch (e) {
Utils.log("🟡", `getuserinfo 活动解析跳过: ${e.message}`);
}
// --- [Next.js Flight Chunks & DAPI parsing] ---
try {
const chunks = [];
const pushRegex = /self\.__next_f\.push\(\[\d+,\s*"(.*?)"\]\)/gs;
let mFlight;
while ((mFlight = pushRegex.exec(html)) !== null) {
try {
const unescaped = JSON.parse(`"${mFlight[1]}"`);
chunks.push(unescaped);
} catch (_) {}
}
const fullFlightText = chunks.length > 0 ? chunks.join("") : html.replace(/\\"/g, '"');
const offerObjRegex = /\{[^{}]*?"offerId"\s*:\s*"([^"]+)"[^{}]*?\}/g;
let om;
while ((om = offerObjRegex.exec(fullFlightText)) !== null) {
try {
const jsonStr = om[0].replace(/:\$undefined/g, ":null").replace(/:\$D[^\",}]+/g, ":null");
const item = JSON.parse(jsonStr);
if (!item.offerId || !item.hash) continue;
if (item.isCompleted === true || item.isLocked === true) continue;
const points = Number(item.points || 0);
if (points <= 0 && !item.offerId.includes("DailySet")) continue;
pushCard({
title: item.title || item.name || item.offerId,
points: points || 10,
offerId: item.offerId,
hash: item.hash,
kind: inferKind(item.offerId, item.title || ""),
destination: item.destination || item.destinationUrl || "",
url: "https://rewards.bing.com/earn"
});
} catch (_) {}
}
} catch (e) {
Utils.log("🟡", `Flight 数据解析异常: ${e.message}`);
}
if (cards.length === 0) {
const dataRegex = /data-offer-id="([^"]+)"[^>]*data-hash="([^"]+)"/gi;
let m;
while ((m = dataRegex.exec(html)) !== null) {
const offerId = m[1];
const hash = m[2];
const skip = RewardsAuto.skipPatterns.some(p => offerId.toLowerCase().includes(p.toLowerCase()));
if (skip) continue;
pushCard({ title: "日常任务", points: 10, offerId, hash, kind: "daily" });
}
}
if (cards.length > 0) {
Utils.log("🧩", `成功获取 ${cards.length} 个可执行卡片`);
} else {
Utils.log("🟡", "未发现可执行的活动卡片(可能均已手动完成或被过滤)");
}
} catch (e) {
Utils.log("🔴", `卡片解析失败: ${e.message}`);
}
// --- [桌面奖励应用专属任务检测与解析] ---
// 注意:此块必须留在 try 内,一旦引用 try 外的 html 会抛 ReferenceError 并被 catch 静默吞掉。
try {
if (!earnHtml) return cards;
const questMatch = earnHtml.match(/href="(\/earn\/quest\/(WW_pcparent_RewardsApp_weekly_Exclusive[^"]+))"/i)
|| earnHtml.match(/"(\/earn\/quest\/(WW_pcparent_RewardsApp_weekly_Exclusive[^"]+))"/i);
if (questMatch) {
const questPath = questMatch[1];
const questUrl = "https://rewards.bing.com" + questPath;
const questHtml = await Utils.xhr({
url: questUrl,
headers: RewardsAuto.desktopAppHeaders
});
if (questHtml) {
const qChunks = [];
const qPushRegex = /self\.__next_f\.push\(\[\d+,\s*"(.*?)"\]\)/gs;
let qm;
while ((qm = qPushRegex.exec(questHtml)) !== null) {
try { qChunks.push(JSON.parse(`"${qm[1]}"`)); } catch (_) {}
}
const qText = qChunks.length > 0 ? qChunks.join("") : questHtml.replace(/\\"/g, '"');
const qOfferRegex = /\{[^{}]*?"offerId"\s*:\s*"(WW_pcchild\d+[^"]+)"[^{}]*?\}/g;
let qo;
while ((qo = qOfferRegex.exec(qText)) !== null) {
try {
const qItem = JSON.parse(qo[0].replace(/:\$undefined/g, ":null").replace(/:\$D[^\",}]+/g, ":null"));
if (qItem.offerId && qItem.hash && qItem.isLocked === false && qItem.isCompleted === false) {
Utils.log("🖥️", `发现已解锁桌面奖励任务: ${qItem.title || qItem.offerId}`);
pushCard({
title: `[桌面专享] ${qItem.title || qItem.name || qItem.offerId}`,
points: 10,
offerId: qItem.offerId,
hash: qItem.hash,
kind: "desktop_app",
destination: qItem.destination || qItem.href || "",
url: "https://rewards.bing.com/earn"
});
}
} catch (_) {}
}
}
}
} catch (eQuest) {
Utils.log("🟡", `桌面应用专属任务解析跳过: ${eQuest.message}`);
}
return cards;
},
async getNextActionHash(html) {
let cached = RewardsAuto._nextAction || GM_getValue("Config.nextAction", "707e6eb15bdfdd5fba193f0a77e934f7018faf87ce");
if (html && html.includes("_next/static/chunks/")) {
const scriptSrcs = [...new Set(html.match(/\/_next\/static\/chunks\/[a-z0-9_\-]+\.js\?dpl=[^"]+/g) || [])];
for (const src of scriptSrcs) {
try {
const chunkText = await Utils.xhr({ url: "https://rewards.bing.com" + src });
const match = chunkText.match(/createServerReference\)\("([a-f0-9]{40,44})"[^)]*"reportActivity"\)/);
if (match && match[1]) {
cached = match[1];
RewardsAuto._nextAction = cached;
GM_setValue("Config.nextAction", cached);
break;
}
} catch (_) {}
}
}
return cached;
},
/** 领取单张卡片(统一入口:先 DAPI → 再网页通道 → 一律用 DAPI 复核是否真的加分)。
* 服务端可能要 1~3 分钟才把上报记入 progress,单次失败不等于真失败,故做多轮重试。
*/
async claimCard(card, opts = {}) {
const offerId = card.offerId;
const name = card.title || offerId;
const attempts = opts.attempts || 3;
const backoffs = opts.backoffs || [[8000, 12000], [20000, 30000], [45000, 60000]];
const diag = [];
// 0) 服务端已完成:直接认账,避免重复上报触发风控
if (await this.isOfferComplete(offerId, true) === true) {
Utils.log("✅", `服务端已确认完成:${name}`);
return true;
}
for (let i = 0; i < attempts; i++) {
if (i > 0) {
const [lo, hi] = backoffs[Math.min(i - 1, backoffs.length - 1)];
Utils.log("⏳", `等待 ${Math.round(lo / 1000)}~${Math.round(hi / 1000)} 秒后重试(第 ${i + 1}/${attempts} 次):${name}`);
await Utils.randomDelay(lo, hi);
}
const ok = await this._claimCardOnce(card, { skipWeb: i === 0, diag });
if (ok) return true;
}
Utils.log("🟡", `卡片领取未确认(已重试 ${attempts} 次):${name} [${diag.join(" ")}]`);
return false;
},
// 单次领取尝试:访问目的地(urlreward 产生进度的真实机制)→ DAPI 上报 → 服务端复核
async _claimCardOnce(card, { skipWeb = false, diag = [] } = {}) {
const offerId = card.offerId;
const name = card.title || offerId;
if (await this.isOfferComplete(offerId, false) === true) {
Utils.log("✅", `服务端已确认完成:${name}`);
return true;
}
if (card.destination && /^https?:/i.test(card.destination)) {
try {
await Utils.xhr({
url: card.destination,
headers: { "user-agent": RewardsAuto.ua.pc, "referer": "https://rewards.bing.com/earn" }
});
diag.push("visit(ok)");
} catch (e) {
diag.push(`visit(${e.message})`);
}
await Utils.randomDelay(1500, 3000);
}
// 首选 DAPI 通道(Bearer Token 鉴权,不使用浏览器 Cookie,跨浏览器一致)
const appRes = await this.appActivity(101, offerId);
if (appRes) {
diag.push(`dapi(p=${appRes.points}${appRes.isDuplicate ? ",dup" : ""})`);
if (appRes.points > 0) {
Utils.log("🟢", `DAPI 领取成功 +${appRes.points} 分:${name}`);
return true;
}
} else {
diag.push("dapi(请求失败)");
}
await Utils.randomDelay(1500, 3000);
if (await this.isOfferComplete(offerId, true) === true) {
Utils.log("🟢", `DAPI 上报已生效:${name}`);
return true;
}
if (skipWeb) return false;
// 网页通道(Server Action / api/reportactivity / 目的地访问)
let webAccepted = false;
try {
webAccepted = await this.claimCardWeb(card);
} catch (e) {
diag.push(`web(${e.message})`);
}
if (webAccepted) {
await Utils.randomDelay(2500, 5000);
if (await this.isOfferComplete(offerId, true) === true) {
Utils.log("🟢", `网页通道领取成功:${name}`);
return true;
}
diag.push("web(已发出但服务端未生效)");
} else {
diag.push("web(请求未被接受)");
}
return false;
},
// 网页通道(Edge 与 Chrome/Firefox 差异所在):返回 true 仅表示请求被接受,真实加分由 claimCard() 用 DAPI 复核
async claimCardWeb(card) {
const url = card.url || "https://rewards.bing.com/earn";
const referer = card.url || "https://rewards.bing.com/earn";
let nextAction = RewardsAuto._nextAction || GM_getValue("Config.nextAction", "707e6eb15bdfdd5fba193f0a77e934f7018faf87ce");
// 策略 1: Next.js Server Action (最稳妥、官方核心)
try {
const res = await Utils.xhr({
method: "POST", url,
headers: {
"content-type": "text/plain;charset=UTF-8",
"next-action": nextAction,
"referer": referer,
...(card.kind === "desktop_app" ? RewardsAuto.desktopAppHeaders : {})
},
data: JSON.stringify([card.hash, 11, { offerid: card.offerId, isPromotional: "$undefined", timezoneOffset: "-480" }])
});
if (res && (res.includes("true") || res.includes('"i":false') || res.includes('"a":'))) {
return true;
}
} catch (e2) {
if (e2.message.includes("404")) {
Utils.log("🟡", "Server Action ID 可能已更新,尝试动态探测...");
try {
const earnHtml = await Utils.xhr({ url: "https://rewards.bing.com/earn" });
nextAction = await this.getNextActionHash(earnHtml);
const retryRes = await Utils.xhr({
method: "POST", url,
headers: { "content-type": "text/plain;charset=UTF-8", "next-action": nextAction, referer },
data: JSON.stringify([card.hash, 11, { offerid: card.offerId, isPromotional: "$undefined", timezoneOffset: "-480" }])
});
if (retryRes) return true;
} catch (_) {}
}
}
// 策略 2: 目标链接如果是搜索页,模拟执行每日活动上报
if (card.destination && card.destination.startsWith("http")) {
try {
await this.reportDailyActivity(card.destination);
return true;
} catch (_) {}
}
// 策略 3: 老版 reportactivity
try {
const token = await this.getRewardsToken();
if (token) {
await Utils.xhr({
method: "POST",
url: "https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"user-agent": RewardsAuto.ua.pc,
"referer": referer,
"origin": "https://rewards.bing.com",
"x-requested-with": "XMLHttpRequest"
},
data: new URLSearchParams({
id: card.offerId, hash: card.hash, timeZone: 480,
activityAmount: 1, dbs: 0, form: "", type: "",
__RequestVerificationToken: token
}).toString()
});
return true;
}
} catch (_) {}
Utils.log("🟡", `卡片领取未确认(${card.offerId})`);
return false;
},
/* 从搜索结果页里取服务器给这次搜索分配的 IG(交互 ID)。
* 真实浏览器是拿着 SERP 里的 IG 去上报的;以前一直用本地随机 UUID,
* 服务端认不出这是哪一次搜索,记账就时有时无(这是「第一轮给得多、
* 之后每轮只给一点」的疑点之一)。取不到才退回随机值。 */
extractIG(html) {
if (!html) return "";
const patterns = [
/IG:"([0-9A-F]{16,40})"/i,
/"IG"\s*:\s*"([0-9A-F]{16,40})"/i,
/[?&]IG=([0-9A-F]{16,40})/i,
];
for (const re of patterns) {
const m = html.match(re);
if (m && m[1]) return m[1].toUpperCase();
}
return "";
},
async getSearchPage(query, isMobile = false) {
const mkt = GM_getValue("Config.lock", true) ? "&mkt=zh-CN" : "";
const deviceType = isMobile ? "m" : "d";
return Utils.xhr({
url: `https://${RewardsAuto.state.host}/search?q=${encodeURIComponent(query)}&form=QBLH${mkt}`,
headers: {
"user-agent": isMobile ? RewardsAuto.ua.mobile : RewardsAuto.ua.pc,
"cookie": `_Rwho=u=${deviceType}&ts=${RewardsAuto.state.dateNowStr}`,
"referer": `https://${RewardsAuto.state.host}/?form=QBLH`,
// 一次搜索必须真的打到服务器:命中缓存的话服务端根本看不到这次搜索
"cache-control": "no-cache",
"pragma": "no-cache"
}
});
},
async reportSearch(html, query, isMobile = false) {
try {
const ig = this.extractIG(html) || Utils.getRandomUUID();
const mkt = GM_getValue("Config.lock", true) ? "&mkt=zh-CN" : "";
const params = `q=${encodeURIComponent(query)}&form=QBLH${mkt}`;
const deviceType = isMobile ? "m" : "d";
const headers = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"user-agent": isMobile ? RewardsAuto.ua.mobile : RewardsAuto.ua.pc,
"referer": `https://${RewardsAuto.state.host}/?form=QBLH`,
"cookie": `_Rwho=u=${deviceType}&ts=${RewardsAuto.state.dateNowStr}`
};
await Utils.xhr({
method: "POST",
url: `https://${RewardsAuto.state.host}/rewardsapp/ncheader?ver=88888888&IID=SERP.5047&IG=${ig}&ajaxreq=1`,
headers, data: "wb=1%3bi%3d1%3bv%3d1"
});
await Utils.xhr({
method: "POST",
url: `https://${RewardsAuto.state.host}/rewardsapp/reportActivity?IG=${ig}&IID=SERP.5047&${params}&ajaxreq=1`,
headers,
data: `url=${encodeURIComponent(`https://${RewardsAuto.state.host}/search?${params}`)}&V=web`
});
return true;
} catch (e) {
Utils.log("🟡", `搜索上报失败: ${e.message}`);
return false;
}
},
/* 地区闸门:真正的判断在 RegionGuard(多路探测 + 缓存),这里只做「能不能继续」的收敛。
* 保留旧名字,runAll / 诊断脚本都不用改调用方式。 */
async checkRegion() {
if (!RegionGuard.enabled()) return true;
const v = await RegionGuard.check({ fresh: true });
if (v.ok) return true;
// 拦截时补一份国内视角的 IP 归属,日志/通知里能直接看出「出口在哪」
if (!v.ipInfo) await this.getIPInfo();
return false;
},
async getIPInfo() {
try {
const qryResult = await Utils.xhr({
url: "https://disp-qryapi.3g.qq.com/v1/dispatch",
headers: { "referer": "https://3g.qq.com/" }
});
if (qryResult && Utils.isJSON(qryResult)) {
const resJSON = JSON.parse(qryResult);
RewardsAuto.state.ip = (resJSON.code == 0 && resJSON.extra && resJSON.extra.ip) ? resJSON.extra.ip : "";
let rawInfo = (resJSON.code == 0 && resJSON.ipInfo) ? String(resJSON.ipInfo) : "";
rawInfo = rawInfo.replace(/[#*]+/g, " ").trim();
RewardsAuto.state.ipInfo = rawInfo ? `🌏所在地区:${rawInfo}` : "";
}
} catch {
console.debug("获取附加 IP 信息失败");
}
},
async getHotSearchWord() {
const keywords = ["天气预报", "今日新闻", "体育赛事", "股票行情", "电影推荐", "科技资讯", "美食食谱", "旅游攻略", "历史上的今天", "健康常识"];
const baseWord = keywords[Utils.randomRange(0, keywords.length - 1)];
const randomSuffix = Math.random().toString(36).slice(2, 6);
let sentence = `${baseWord} ${randomSuffix}`;
if (RewardsAuto.apiConfig.mode !== "offline") {
if (RewardsAuto.apiConfig.wordIndex < 1 || RewardsAuto.apiConfig.wordList.length < 1) {
const apiArr = RewardsAuto.apiConfig.arr;
const lastApiIndex = parseInt(GM_getValue("Config.apiIndex", -1));
const filteredArr = apiArr.filter((_, index) => index !== lastApiIndex);
const randomIndex = Utils.randomRange(0, filteredArr.length - 1);
GM_setValue("Config.apiIndex", randomIndex);
const [apiName, apiConfig] = filteredArr[randomIndex];
RewardsAuto.apiConfig.url = apiConfig.url;
RewardsAuto.apiConfig.hot = apiConfig.hot;
try {
const hotSource = RewardsAuto.apiConfig.hot[Utils.randomRange(0, RewardsAuto.apiConfig.hot.length - 1)];
const result = await Utils.xhr({ url: RewardsAuto.apiConfig.url + hotSource });
if (result && Utils.isJSON(result)) {
const res = JSON.parse(result);
if (res.code == 200) {
RewardsAuto.apiConfig.wordIndex = 1;
RewardsAuto.apiConfig.wordList = [];
for (let i = 0; i < res.data.length; i++) {
RewardsAuto.apiConfig.wordList.push(res.data[i].title);
}
RewardsAuto.apiConfig.wordList.sort(() => Math.random() - 0.5);
sentence = RewardsAuto.apiConfig.wordList[RewardsAuto.apiConfig.wordIndex];
sentence = sentence.substring(0, Utils.randomRange(20, 32));
return sentence;
}
}
} catch (e) {
Utils.log("🟡", `热搜词获取失败: ${e.message}`);
}
} else {
RewardsAuto.apiConfig.wordIndex++;
if (RewardsAuto.apiConfig.wordIndex > RewardsAuto.apiConfig.wordList.length - 1) {
RewardsAuto.apiConfig.wordIndex = 0;
}
sentence = RewardsAuto.apiConfig.wordList[RewardsAuto.apiConfig.wordIndex];
sentence = sentence.substring(0, Utils.randomRange(20, 32));
return sentence;
}
Utils.log("🟡", "热搜词接口异常,已使用随机搜索词");
}
return sentence;
},
async checkSearchRestricted() {
const info = await this.getRewardsInfo();
const currentTotal = info ? info.pc.progress : RewardsAuto.state.pcProgress;
const lastTotal = RewardsAuto.state.lastSearchProgress;
if (lastTotal !== -1) {
if (currentTotal === lastTotal && currentTotal < RewardsAuto.state.pcMax) {
RewardsAuto.state.restrictedTimes++;
} else {
RewardsAuto.state.restrictedTimes = 0;
}
}
RewardsAuto.state.lastSearchProgress = currentTotal;
GM_setValue("Config.lastSearchProgress", currentTotal);
GM_setValue("Config.restrictedTimes", RewardsAuto.state.restrictedTimes);
if (RewardsAuto.state.restrictedTimes >= 3) {
Utils.log("🔴", "搜索受限或账号异常,已中断今日搜索!", true);
return true;
}
return false;
}
};
/* ===== Edge 浏览连签(状态机 + 节拍器) =====
* 迁移自老脚本的 taskEdgeBrowsing(),但按 main.js 的常驻调度重写过:
* - 老脚本的 service worker 活不过 30 分钟,只能「每次 cron 报 1 次」,20 分钟一轮要凑 2 小时;
* - main.js 的 Sched 每 20 秒做一次 GM 读写给 MV3 续命,所以这里做成节拍器:
* 状态全量持久化到 Config.edge,只要还有一个上下文活着,就按 report_per_minutes
* 的节奏接着报,30~40 分钟记满;上下文被打断也能从持久化状态续上。
* - 判重靠服务端:isDuplicate=true 时不计次,但仍要记 last(否则会连发、被一路判重)。
* - 完成与否以服务端 promotion.attributes.complete 为准,本地只做缓存和去重通知。
*/
const EdgeStreak = {
_running: false,
state() {
const raw = GM_getValue("Config.edge", null);
const st = (raw && typeof raw === "object") ? raw : {};
const today = Utils.getTodayNum();
const base = {
date: today, count: 0, last: 0, complete: false, activated: false,
notified: false, extra: 0, total: 0, intervalMin: 5, offerid: "", type: "714", sync: 0,
giveUp: false
};
if (st.date !== today) return base; // 跨日重置(连签是每天的任务)
return {
...base,
count: Number(st.count) || 0,
last: Number(st.last) || 0,
complete: !!st.complete,
activated: !!st.activated,
notified: !!st.notified,
extra: Number(st.extra) || 0,
total: Number(st.total) || 0,
intervalMin: Number(st.intervalMin) || 5,
offerid: String(st.offerid || ""),
type: String(st.type || "714"),
sync: Number(st.sync) || 0,
giveUp: !!st.giveUp,
};
},
save(st) {
GM_setValue("Config.edge", st);
},
// 上报间隔(分钟):服务端给的 report_per_minutes,异常值一律按 5 分钟
intervalOf(attr) {
const n = Number(attr && attr.report_per_minutes);
return (Number.isFinite(n) && n >= 1 && n <= 30) ? n : 5;
},
// 上报类型:服务端给的 report_offer_type,非纯数字一律按 714(浏览活动)
typeOf(attr) {
const t = String((attr && attr.report_offer_type) || "").trim();
return /^\d+$/.test(t) ? t : "714";
},
// 找 Edge 连签 promotion:老名字之外还要认新版变体,否则 Edge 一升级就永远找不到
findPromotion(profile) {
const list = (profile && Array.isArray(profile.promotions)) ? profile.promotions : [];
if (list.length === 0) return null;
const exact = list.find(p => p && p.name === EDGE.PROMO_NAME);
if (exact) return exact;
// 同名前缀的新变体(edge_browsing_streak_flight_v2 之类):名字认得出就是它,
// 不要求 attributes,缺字段交给后面统一提示
const variant = list.find(p => p && EDGE.PROMO_PREFIX.test(String(p.name || "")));
if (variant) return variant;
// 名字完全改了:退回按 Edge 活动特征认(带 offerid + report_offer_type 的 edge 项)
return list.find(p => p && p.attributes
&& /edge/i.test(String(p.name || ""))
&& !!(p.attributes.offerid && p.attributes.report_offer_type)) || null;
},
notifyDone(st) {
if (st.notified) return;
st.notified = true;
this.save(st);
Utils.log("🌐", `Edge 浏览连签任务已完成!(今日 ${EDGE.TARGET_MINUTES} 分钟浏览已记账)`, true);
},
/* 推进一步:可能什么都不做(间隔没到 / 已完成)。
* 返回 { done, count, total, waitMs } 给节拍器排下一次。 */
async step() {
const st = this.state();
const totalOf = () => st.total || Math.ceil(EDGE.TARGET_MINUTES / Math.max(1, st.intervalMin));
// 今日已判定做不了(账号不支持 / 数据缺失 / 重来轮次用尽):不再打扰服务端,
// 也不让常驻调度为了它空转一整天
if (st.giveUp) {
return { done: false, skipped: true, count: st.count, total: totalOf(), waitMs: 6 * 60 * 60 * 1000 };
}
// 已完成:每 2 小时跟服务端对一次账(防换账号继承了上一个账号的完成标记),平时不打扰
if (st.complete && Date.now() - st.sync < 2 * 60 * 60 * 1000) {
return { done: true, count: st.count || totalOf(), total: totalOf(), waitMs: 30 * 60 * 1000 };
}
// 还没到一个计费窗口:直接等,连查询都不发(别让 20 秒心跳把接口打成刷子)
if (!st.complete && st.count > 0 && st.last && Date.now() - st.last < st.intervalMin * 60 * 1000) {
return {
done: false, count: st.count, total: totalOf(),
waitMs: st.last + st.intervalMin * 60 * 1000 - Date.now()
};
}
if (!RewardsAuto.state.token) {
const ok = await API.renewToken();
if (!ok || !RewardsAuto.state.token) {
Utils.log("🟡", "Edge 浏览连签:暂无可用 Token,跳过(先完成授权)");
return { done: false, count: st.count, total: totalOf(), waitMs: 5 * 60 * 1000 };
}
}
const region = API.edgeRegion();
let profile = await API.getEdgeProfile(region);
if (!profile) return { done: false, count: st.count, total: totalOf(), waitMs: 5 * 60 * 1000 };
let promo = this.findPromotion(profile);
// 没找到活动项:多半是新账号/新周期还没激活,一天只尝试一次
if (!promo && !st.activated) {
st.activated = true;
this.save(st);
const res = await API.activateEdgeBrowsing();
if (res === "absent") {
st.giveUp = true;
this.save(st);
Utils.log("🟡", "Edge 浏览连签:当前账号没有这个活动(地区/账号不支持),今日跳过");
return { done: false, skipped: true, count: st.count, total: totalOf(), waitMs: 6 * 60 * 60 * 1000 };
}
Utils.log(res === "already" ? "🟢" : "🟡",
`Edge 浏览连签:活动${res === "activated" ? "已激活" : res === "already" ? "此前已激活" : "激活失败"}(${res})`);
await Utils.delay(Utils.randomRange(3000, 6000));
profile = await API.getEdgeProfile(region);
promo = profile ? this.findPromotion(profile) : null;
}
if (!promo) {
Utils.log("🟡", "Edge 浏览连签:暂未找到活动项,可先在 Edge 里打开 rewards.bing.com/earn?section=streaks 手动开启");
return { done: false, count: st.count, total: totalOf(), waitMs: 60 * 60 * 1000 };
}
const attr = promo.attributes || {};
st.intervalMin = this.intervalOf(attr);
st.total = Math.ceil(EDGE.TARGET_MINUTES / st.intervalMin);
st.offerid = String(attr.offerid || "").trim();
st.type = this.typeOf(attr);
const total = st.total;
if (String(attr.complete).toLowerCase() === "true") {
st.complete = true;
st.sync = Date.now();
st.count = total;
this.save(st);
this.notifyDone(st);
return { done: true, count: total, total, waitMs: 30 * 60 * 1000 };
}
// 本地记着完成、服务端却不认(换账号 / 服务端回滚):清标记重新累计
if (st.complete) {
Utils.log("🟡", "Edge 浏览连签:本地标记已完成但服务端未确认,重新累计");
st.complete = false; st.count = 0; st.last = 0; st.extra = 0; st.notified = false;
this.save(st);
}
if (!st.offerid) {
st.giveUp = true;
this.save(st);
Utils.log("🟡", "Edge 浏览连签:活动项缺少 offerid,无法上报,今日跳过");
return { done: false, skipped: true, count: st.count, total, waitMs: 6 * 60 * 60 * 1000 };
}
if (st.count >= total) {
// 次数报够了但服务端还没确认:先给它落库时间,仍然不认就整体重来
if (Date.now() - st.last < 10 * 60 * 1000) {
return { done: false, count: st.count, total, waitMs: 10 * 60 * 1000 };
}
if (st.extra >= EDGE.MAX_EXTRA_ROUNDS) {
st.giveUp = true;
this.save(st);
Utils.log("🟡", `Edge 浏览连签:已上报 ${st.count}/${total} 次仍未确认完成,今日暂停(明日自动重试)`);
return { done: false, skipped: true, count: st.count, total, waitMs: 6 * 60 * 60 * 1000 };
}
st.extra++;
st.count = 0;
st.last = 0;
this.save(st);
Utils.log("🟡", `Edge 浏览连签:服务端未确认完成,第 ${st.extra}/${EDGE.MAX_EXTRA_ROUNDS} 轮重新累计`);
return { done: false, count: 0, total, waitMs: 0 };
}
const res = await API.reportEdgeActivity({ offerid: st.offerid, type: st.type, region });
const code = (res && typeof res.code === "number") ? res.code : -1;
if (code !== 0) {
Utils.log("🟡", `Edge 浏览连签:第 ${Math.min(st.count + 1, total)}/${total} 次上报失败(code=${code}),稍后重试`);
return { done: false, count: st.count, total, waitMs: 5 * 60 * 1000 };
}
const dup = !!(res.response && res.response.isDuplicate === true);
st.last = Date.now();
st.sync = Date.now();
if (!dup) st.count = Math.min(st.count + 1, total);
this.save(st);
Utils.log(dup ? "🟡" : "🟢",
`Edge 浏览连签:第 ${st.count}/${total} 次上报${dup ? "(被判重,未记账)" : "已记账"}`);
// 上报后稍等再复核:累计满 30 分钟这一刻服务端才把 complete 翻成 true
await Utils.delay(Utils.randomRange(2000, 4000));
const after = await API.getEdgeProfile(region);
const afterPromo = after ? this.findPromotion(after) : null;
if (afterPromo && String((afterPromo.attributes || {}).complete).toLowerCase() === "true") {
st.complete = true;
st.sync = Date.now();
st.count = total;
this.save(st);
this.notifyDone(st);
return { done: true, count: total, total, waitMs: 30 * 60 * 1000 };
}
return {
done: false, count: st.count, total,
waitMs: dup ? Math.max(60 * 1000, st.intervalMin * 60 * 1000) : st.intervalMin * 60 * 1000
};
},
// 对外一步:加锁防多上下文重复上报(页面与后台会同时被调度/心跳唤醒)
async run() {
if (!GM_getValue("Tasks.edge", true)) return null;
if (!GM_getValue("Config.token", false)) return null; // 没授权就不用试
if (this._running) return null;
if (!Utils.acquireLock("Config.edgeLock", 3 * 60 * 1000)) return null;
this._running = true;
try {
return await this.step();
} catch (e) {
Utils.log("🟡", `Edge 浏览连签异常: ${e.message}`);
return null;
} finally {
this._running = false;
Utils.releaseLock("Config.edgeLock");
}
},
// 汇总行数据:给 runAll 的日报用
summary() {
if (!GM_getValue("Tasks.edge", true)) return null;
const st = this.state();
const total = st.total || Math.ceil(EDGE.TARGET_MINUTES / Math.max(1, st.intervalMin));
const count = Math.min(st.count, total);
return { done: st.complete, skipped: st.giveUp && !st.complete, count, total, text: `${count}/${total}` };
}
};
/* Edge 连签节拍器:挂在常驻调度的心跳里(Sched.tick 每 ≤20 秒一次),
* 但只有「距上次上报满一个计费窗口」时才真的发请求,所以心跳不会把接口打爆。
* 这样 30 分钟的累计在 30~40 分钟内记完,而不是被 20 分钟一轮的 cron 拖成 2 小时。 */
const EDGE_PACE_TICK = 30 * 1000; // 两次「是否需要上报」判断之间的最小间隔
const EdgePacer = {
busy: false,
nextAt: 0,
async maybeReport() {
if (this.busy) return null;
if (Date.now() < this.nextAt) return null;
// 出口闸门:心跳每 20 秒跑一次,这里是唯一会在「一轮任务之外」发微软请求的入口
if (!(await RegionGuard.allow())) {
this.nextAt = Date.now() + 5 * 60 * 1000; // 出口异常:退避 5 分钟再试
return null;
}
this.busy = true;
try {
const r = await EdgeStreak.run();
if (!r) {
this.nextAt = Date.now() + 5 * 60 * 1000; // 未启用/失败:退避 5 分钟
} else {
const wait = r.done ? 30 * 60 * 1000 : (r.waitMs || 0);
this.nextAt = Date.now() + Math.max(EDGE_PACE_TICK, wait);
}
return r;
} catch (_) {
this.nextAt = Date.now() + 5 * 60 * 1000;
return null;
} finally {
this.busy = false;
}
}
};
/* ===== PC 搜索节拍器 =====
* 现象:第一轮还能拿几分,之后每轮只拿一点,几十轮才凑满。
* 两层原因:
* 1) 服务端记账是「窗口式」的:短时间连着搜,只有前几次真给分,剩下的要等下一个窗口。
* 这是服务端的策略,脚本改不了,只能顺着它攒;
* 2) 脚本把「没记账」一律当成「该更慢了」,节拍一路放大到 5 分钟,还持久化给下一轮,
* 于是一轮 20 分钟只搜得到四五次 —— 后面每一轮自然就只拿一点。这一层是可以修的。
* 现在:
* 1) 每轮都从用户设的最小间隔(Config.span)起步,不继承上一轮被放大的节拍;
* 2) 记账成功立刻回到最小间隔;没记账才按倍数放慢,软上限 3 分钟
* (再慢不如结束本轮交给调度,别在一轮里干等);
* 3) 不再「连续 6 次没记账就收手」:改成「连续 10 分钟一次都没记账」才收手,
* 这样能把「服务端记账延迟几十秒」和「真的被限流」分开;
* 4) 一轮内一直搜到配额满或预算用完,尝试次数按剩余配额动态算,不再固定 40。
*/
const SEARCH_PACER = {
MIN_MS: 30 * 1000, // 绝对下限:再顺也不会比 Config.span 更快(默认 30 秒)
MAX_MS: 3 * 60 * 1000, // 没记账时最多退到这个节拍,再慢就交给下一轮
OBSERVE_MIN: 8 * 1000, // 搜索后留给服务端记账的观察窗(随机,避免固定秒数)
OBSERVE_MAX: 16 * 1000,
MAX_ATTEMPTS: 60, // 一轮内的尝试上限(兜底)
NO_CREDIT_LIMIT: 10 * 60 * 1000, // 连续这么久一次都没记账 → 本轮收手
RECENT_CREDIT: 5 * 60 * 1000, // 预算用完后,最近这么久还有记账就继续把这轮补满
MIN_ATTEMPTS_BEFORE_GIVEUP: 8, // 但至少先试够这么多次,别被两次记账延迟骗了
GROW: 1.5, // 没记账:节拍放大倍数
RETRY_GAP: 5 * 60 * 1000, // 搜索没满时,下一轮提前多少毫秒续做
// 下限 = 用户设的最小间隔(Config.span);脚本只会比它慢,绝不会比它快
minMs() {
const span = Number(GM_getValue("Config.span", 30)) || 30;
return Math.min(this.MAX_MS, Math.max(this.MIN_MS, Math.max(30, span) * 1000));
},
/* 每轮的起始节拍:永远是用户设的最小间隔。
* 上一轮被限流学到的大节拍只留作日志,不再拖住下一轮 —— 限流窗口是分钟级的,
* 拿一整天的节奏去还债,正是「后面每轮只拿一点」的由来。 */
startGap() { return this.minMs(); },
/* 只在 Config.span 之上加,绝不往下减:用户设 30 秒,就一定不会快于 30 秒。
* 额外加 0~35% 随机量,间隔不会总落在整秒整分上,看起来不像定时器。 */
jitter(ms) {
const v = ms * (1 + Math.random() * 0.35);
return Math.max(5 * 1000, Math.round(v / 1000) * 1000);
},
// 只存「上一轮最高节拍」用于诊断;跨天自动失效
saveGap(ms) {
GM_setValue("Config.searchGap", { date: Utils.getTodayNum(), ms: Math.round(ms) });
},
loadGapPeak() {
const saved = GM_getValue("Config.searchGap", null);
if (!saved || typeof saved !== "object" || saved.date !== Utils.getTodayNum()) return 0;
const ms = Number(saved.ms) || 0;
return (ms <= 0 || ms > this.MAX_MS) ? 0 : ms;
},
// 上一轮的统计(尝试几次/记账几次/最高节拍);落盘保存,刷新页面后诊断菜单还看得到
setLast(stat) {
GM_setValue("Config.searchLast", stat ? { date: Utils.getTodayNum(), ...stat } : null);
},
getLast() {
const l = GM_getValue("Config.searchLast", null);
if (!l || typeof l !== "object" || l.date !== Utils.getTodayNum()) return null;
return { attempts: Number(l.attempts) || 0, credits: Number(l.credits) || 0, peak: Number(l.peak) || 0, at: Number(l.at) || 0 };
},
// 「本轮没搜满」标记:带日期,跨天自动失效,不会让昨天的残留把今天的轮次一直拉快
setPending(v) {
GM_setValue("Config.searchPending", v ? { date: Utils.getTodayNum() } : null);
},
isPending() {
const p = GM_getValue("Config.searchPending", null);
return !!(p && typeof p === "object" && p.date === Utils.getTodayNum());
},
summary() {
const min = Math.round(this.minMs() / 1000);
return {
gap: min, min,
peak: Math.round(this.loadGapPeak() / 1000),
pending: this.isPending(),
last: this.getLast()
};
}
};
const TaskManager = {
signDate: 0, readDate: 0, promosDate: 0, searchDate: 0, streakDays: 0,
signPoint: -1, signTimes: 0, readTimes: 0, promosTimes: 0,
// running 只是本上下文的防重入标记;后台沙盒被冻结时 finally 可能跑不到,
// 所以配一个 runningSince,超过 RUN_STALE 就当作残留标记自动清理
running: false, runningSince: 0,
init() {
RewardsAuto.state.dateNowNum = Utils.getTodayNum();
RewardsAuto.state.dateNowStr = Utils.getTodayStr();
const tasks = GM_getValue("Config.tasks", {});
this.signDate = tasks.sign || 0;
this.readDate = tasks.read || 0;
this.promosDate = tasks.promos || 0;
this.searchDate = tasks.search || 0;
this.streakDays = tasks.streakDays || 0;
this.signPoint = GM_getValue("Config.signPoint", -1);
},
save() {
GM_setValue("Config.tasks", {
sign: this.signDate, read: this.readDate,
promos: this.promosDate, search: this.searchDate,
streakDays: this.streakDays
});
},
async doSign() {
if (!GM_getValue("Tasks.sign", true) || this.signTimes > 2) return;
if (this.signPoint >= 0 && this.signDate === RewardsAuto.state.dateNowNum) {
Utils.log("✅", `签入已完成(${this.signPoint}积分)`);
return;
}
await RegionGuard.guard("签入");
await Utils.randomDelay();
let totalPoint = 0;
let signOk = false;
const appPoint = await API.signApp();
if (appPoint >= 0) {
signOk = true;
if (appPoint > 0) {
GM_log(`📱 App签入静默成功 +${appPoint}积分`);
totalPoint += appPoint;
} else {
GM_log("📱 App签入已确认,无新增积分");
}
}
await Utils.randomDelay(3000, 8000);
const pcPoint = await API.signPC();
if (pcPoint >= 0) {
signOk = true;
Utils.log("💻", `PC签入成功!+${pcPoint}积分`);
totalPoint += pcPoint;
}
if (signOk) {
this.signPoint = totalPoint;
this.signDate = RewardsAuto.state.dateNowNum;
GM_setValue("Config.signPoint", totalPoint);
this.save();
Utils.log("🔵", `签入任务完成!总积分 +${totalPoint}`, true);
} else {
this.signTimes++;
Utils.log("🟡", `签入失败,稍后重试`);
}
},
async doRead() {
if (!GM_getValue("Tasks.read", true) || this.readTimes > 2) return;
if (this.readDate === RewardsAuto.state.dateNowNum) {
const verifyProgress = await API.getReadProgress();
if (verifyProgress && verifyProgress.progress >= verifyProgress.max) {
Utils.log("✅", `阅读任务已完成(已验证 ${verifyProgress.progress}/${verifyProgress.max})`);
return;
} else if (verifyProgress) {
Utils.log("🟡", `阅读标记有误(${verifyProgress.progress}/${verifyProgress.max}),重置并继续`);
this.readDate = 0;
this.save();
} else {
Utils.log("🟡", "无法验证阅读进度,跳过");
return;
}
}
const progress = await this.readProgressWithRetry();
if (!progress) {
this.readTimes++;
Utils.log("🟡", "无法获取阅读进度,稍后重试");
return;
}
let cur = progress.progress;
let max = progress.max;
Utils.log("📖", `阅读进度: ${cur}/${max}`);
if (cur >= max) {
this.readDate = RewardsAuto.state.dateNowNum;
this.save();
Utils.log("✅", "阅读任务已完成");
return;
}
// 该活动:读 10 篇文章,每篇约 3 分,共 30 分;
// progress/max 的单位是「积分」,activityprogress/activitymax 才是「篇数」。
const pointsPerArticle = 3;
const maxPerDay = 10;
const target = Math.min(Math.max(Math.ceil((max - cur) / pointsPerArticle), 1), maxPerDay);
Utils.log("📖", `今日还可阅读 ${target} 篇(上限 ${maxPerDay} 篇/天,每篇约 ${pointsPerArticle} 分)`);
let successCount = 0;
let stagnant = 0;
for (let i = 0; i < target; i++) {
await RegionGuard.guard("阅读");
const result = await API.doRead();
await Utils.randomDelay(3000, 8000);
// 任务已满时该接口依然返回 200 + p:0 + isDuplicate:false,
// 所以不能只看返回值,必须用服务端进度增量判断这一篇是否真的计入。
const after = await API.getReadProgress();
if (after) {
const gained = after.progress - cur;
max = after.max;
if (gained > 0) {
successCount++;
stagnant = 0;
cur = after.progress;
Utils.log("📖", `阅读文章 ${i + 1}/${target} +${gained}积分(${cur}/${max})`);
} else {
stagnant++;
Utils.log("🟡", `阅读文章 ${i + 1}/${target} 未计入进度(${after.progress}/${after.max})`);
}
} else if (result && result.points > 0) {
successCount++;
stagnant = 0;
cur += result.points;
Utils.log("📖", `阅读文章 ${i + 1}/${target} +${result.points}积分(进度查询失败,按返回值统计)`);
} else {
stagnant++;
if (!result) {
Utils.log("🟡", `阅读第 ${i + 1} 篇请求失败`);
} else {
Utils.log("🟡", `阅读第 ${i + 1} 篇无新增积分(返回 p:${result.points},isDuplicate:${result.isDuplicate})`);
}
}
if (cur >= max) {
Utils.log("📖", `阅读进度已满(${cur}/${max}),提前结束`);
break;
}
if (stagnant >= 3) {
Utils.log("🟡", "连续 3 篇未计入进度,停止本轮阅读上报");
break;
}
}
if (successCount === 0) {
this.readTimes++;
Utils.log("🟡", "本轮阅读未产生积分,稍后重试");
return;
}
const verify = await API.getReadProgress();
if (verify && verify.progress >= verify.max) {
this.readDate = RewardsAuto.state.dateNowNum;
this.save();
Utils.log("🔵", `阅读任务完成!共 ${successCount} 篇`, true);
} else {
this.readTimes++;
Utils.log("🟡", `阅读已执行但未完成(${verify ? `${verify.progress}/${verify.max}` : "进度未知"}),下次继续`);
}
},
/** 读取阅读任务进度并带重试:后台 DAPI 偶发失败,一次失败不应放弃整个阅读任务 */
async readProgressWithRetry(attempts = 3) {
for (let i = 1; i <= attempts; i++) {
const p = await API.getReadProgress();
if (p) return p;
if (i < attempts) {
Utils.log("🟡", `阅读进度读取失败,${i}/${attempts} 次重试`);
await Utils.delay(3210);
}
}
return false;
},
async doPromos() {
if (!GM_getValue("Tasks.promos", true) || this.promosTimes > 2) return true;
if (this.promosDate === RewardsAuto.state.dateNowNum) {
Utils.log("✅", "活动卡片已完成");
return true;
}
Utils.log("🧩", "扫描活动卡片...");
const cards = await API.discoverCards();
if (cards.length === 0) {
// 今天压根没有待办卡片(常见的全领完/当天没放卡):这就是「已完成」,必须落日期。
// 否则 promosDate 一直停在昨天,keep=false 时 isAllDone 永远为假,会无限空转。
Utils.log("✅", "未发现待处理活动卡片");
this.promosDate = RewardsAuto.state.dateNowNum;
this.save();
return true;
}
Utils.log("🧩", `发现 ${cards.length} 个卡片`);
// 用户关掉 Quiz 时,Quiz 卡片本轮本来就不处理,判定「是否还有未完成」时也不该算它,
// 否则这类卡片会让当天永远无法判定完成。
const shouldSkip = (card) => card.kind === "quiz" && !GM_getValue("Tasks.quiz", true);
let ok = 0, fail = 0;
for (const card of cards) {
await RegionGuard.guard("活动卡片");
Utils.log(" ", `[${card.kind}] ${card.title} +${card.points}p`);
if (shouldSkip(card)) continue;
await Utils.randomDelay(3000, 8000);
const result = await API.claimCard(card, { attempts: 2 });
result ? ok++ : fail++;
}
// 以服务端状态为准:仍有未完成卡片时不标记当天完成,下次运行会继续重试
let pending = 0;
try {
const map = await API.getPromotionMap(true);
if (map) pending = cards.filter(c => !shouldSkip(c) && map.has(c.offerId) && !map.get(c.offerId).complete).length;
} catch (_) {}
if (pending === 0) {
this.promosDate = RewardsAuto.state.dateNowNum;
this.save();
}
Utils.log(pending === 0 ? "🔵" : "🟡",
`活动完成: ${ok}成功/${fail}失败${pending > 0 ? `,仍剩 ${pending} 张未完成` : ""}(服务端已确认)`,
pending === 0 && ok > 0);
return pending === 0;
},
async doSearch() {
if (!GM_getValue("Tasks.search", true)) return;
const info = await API.getRewardsInfo();
if (!info) { Utils.log("🔴", "无法获取积分信息"); return; }
let progress = info.pc.progress;
let max = info.pc.max;
RewardsAuto.state.pcProgress = progress;
RewardsAuto.state.pcMax = max;
Utils.log("🔍", `搜索配额: PC ${progress}/${max}`);
const markDone = () => {
this.searchDate = RewardsAuto.state.dateNowNum;
this.save();
SEARCH_PACER.setPending(false);
RewardsAuto.state.restrictedTimes = 0;
GM_setValue("Config.restrictedTimes", 0);
GM_setValue("Config.lastSearchProgress", -1);
};
if (progress >= max) {
markDone();
Utils.log("✅", `搜索配额已满 PC: ${progress}/${max}`);
return;
}
if (this.searchDate === RewardsAuto.state.dateNowNum) {
Utils.log("🟡", "搜索配额未满,继续执行搜索任务");
this.searchDate = 0;
}
if (await API.checkSearchRestricted()) {
// 被判定受限:只打上今天已处理,别清受限计数,否则下一轮又从 0 开始数
this.searchDate = RewardsAuto.state.dateNowNum;
this.save();
SEARCH_PACER.setPending(false);
return;
}
// 单轮搜索预算固定 20 分钟:原来的「单轮搜索最长时间(分钟)」调整框已从设置里移除,
// 值不再可调,但预算、软/硬截止时间的计算与之前保持一致。
const budgetMin = 20;
const startedAt = Date.now();
// 预算内正常搜;预算用完时只要还在持续记账就继续把这轮补满,最多熬到 hardDeadline。
// 目的是「一轮尽量做满」,而不是到点就交班。
const softDeadline = startedAt + budgetMin * 60 * 1000;
const hardMin = Math.max(budgetMin, Math.min(45, budgetMin * 2 + 10));
const hardDeadline = startedAt + hardMin * 60 * 1000;
// 每轮都从最小间隔起步,绝不继承上一轮被放大的节拍
let gap = SEARCH_PACER.startGap();
let peakGap = gap;
// 还差多少分、按每次 3 分估算还要搜几次,留一倍余量(记账会漏,得补搜)
const needed = Math.max(1, Math.ceil((max - progress) / 3));
const attemptCap = Math.min(SEARCH_PACER.MAX_ATTEMPTS, Math.max(12, needed * 2));
let attempts = 0, credits = 0, sinceCredit = 0, verifyFails = 0;
// 最近一次「真正记账」的时刻:判限流用它,而不是用连续未记账的次数
let lastCreditAt = startedAt;
// 本轮用过的搜索词,避免重复词被服务端忽略
const usedQueries = new Set();
const pickPoolWord = () => RewardsAuto.searchPool[Utils.randomRange(0, RewardsAuto.searchPool.length - 1)];
Utils.log("🔍", `单轮预算 ${budgetMin} 分钟(还在记账就最多延到 ${Math.round(hardMin)} 分钟),起始节拍 ${Math.round(gap / 1000)} 秒,还差 ${Math.max(0, max - progress)} 分(约 ${needed} 次搜索)`);
while (attempts < attemptCap && Date.now() < hardDeadline) {
// 单轮可能跑十几分钟,顺手给运行锁和运行阶段续约,避免被别的上下文判成「已经挂了」
Utils.refreshLock("Config.runLock");
try { Sched.state("running"); } catch (_) {}
// 出口闸门:单轮可能跑十几分钟,中途开梯子要能被拦下(有缓存,不会每次都真探测)
await RegionGuard.guard("PC 搜索");
attempts++;
const iterStart = Date.now();
let query;
try {
query = RewardsAuto.apiConfig.mode !== "offline"
? await API.getHotSearchWord()
: "";
} catch (_) {
query = "";
}
if (!query) query = pickPoolWord();
// 同一轮里尽量不复用同一个搜索词:重复词服务端大概率不记账,白白拉长节拍
for (let i = 0; i < 5 && query && usedQueries.has(query); i++) query = pickPoolWord();
if (query) usedQueries.add(query);
Utils.log("🔍", `[PC] 第 ${attempts} 次搜索(记账 ${progress}/${max},节拍 ${Math.round(gap / 1000)}s):${query}`);
try {
const html = await API.getSearchPage(query, false);
if (html) await API.reportSearch(html, query, false);
} catch (e) {
if (e && e.regionBlocked) throw e;
Utils.log("🟡", `搜索失败: ${e.message}`);
}
// 观察窗:先给服务端记账的时间,再用轻量接口核对真实进度
// 观察窗长度也随机,别每次都精确 12 秒
await Utils.delay(Utils.randomRange(SEARCH_PACER.OBSERVE_MIN, SEARCH_PACER.OBSERVE_MAX));
const before = progress;
let quota = await API.getPcQuota();
// 记账常常有几秒延迟:第一次没变化再看一眼,别把「延迟」误判成「没记账」白退让
if (quota && quota.progress <= before) {
await Utils.delay(Utils.randomRange(8000, 14000));
const again = await API.getPcQuota();
if (again) quota = again;
}
if (quota) {
verifyFails = 0;
if (quota.max > 0) max = quota.max;
if (quota.progress > before) {
progress = quota.progress;
credits++;
sinceCredit = 0;
lastCreditAt = Date.now();
// 记账顺利就直接回到用户设的最小间隔,不再一级一级衰减
gap = SEARCH_PACER.minMs();
Utils.log("✅", `记账成功 +${progress - before}(${progress}/${max}),节拍回到 ${Math.round(gap / 1000)} 秒`);
} else {
progress = Math.max(progress, quota.progress);
sinceCredit++;
gap = Math.min(SEARCH_PACER.MAX_MS, Math.round(gap * SEARCH_PACER.GROW));
peakGap = Math.max(peakGap, gap);
Utils.log("🟡", `本次未记账(${progress}/${max},连续 ${sinceCredit} 次),节拍放慢到 ${Math.round(gap / 1000)} 秒`);
}
} else {
// 核对接口不可用:按每搜 3 分乐观推进,避免原地死循环;后续核对成功会自动校正回来
verifyFails++;
sinceCredit++;
progress = Math.min(max, progress + 3);
gap = Math.min(SEARCH_PACER.MAX_MS, Math.round(gap * SEARCH_PACER.GROW));
peakGap = Math.max(peakGap, gap);
}
RewardsAuto.state.pcProgress = progress;
if (progress >= max) break;
/* 判定限流只看「多久没有真正记账」,不看连续几次没记账:
* 服务端记账延迟几十秒很常见,按次数判会把每轮都判成限流。 */
const noCreditMs = Date.now() - lastCreditAt;
if (noCreditMs >= SEARCH_PACER.NO_CREDIT_LIMIT && attempts >= SEARCH_PACER.MIN_ATTEMPTS_BEFORE_GIVEUP) {
Utils.log("🟡", `连续 ${Math.round(noCreditMs / 60000)} 分钟没有任何记账(本轮 ${attempts} 次搜索),判定被限流,本轮收手(已记 ${progress}/${max})`);
break;
}
if (verifyFails >= 6) {
Utils.log("🟡", `记账核对连续失败 ${verifyFails} 次,本轮按乐观估计结束(已记 ${progress}/${max}),下一轮用真实进度校正`);
break;
}
if (Date.now() + gap > hardDeadline) {
Utils.log("🟡", `已到本轮硬上限(${Math.round(hardMin)} 分钟),剩下的 ${Math.max(0, max - progress)} 分交给下一轮`);
break;
}
// 预算用完之后,只有「最近还在持续记账」才继续熬,否则交班,别白耗
if (Date.now() + gap > softDeadline && Date.now() - lastCreditAt > SEARCH_PACER.RECENT_CREDIT) {
Utils.log("🟡", `本轮预算(${budgetMin} 分钟)已用完且最近 ${Math.round(SEARCH_PACER.RECENT_CREDIT / 60000)} 分钟没有新记账,剩下的 ${Math.max(0, max - progress)} 分交给下一轮`);
break;
}
// 节拍按「这一次搜索 → 下一次搜索」算:搜索本身、观察窗、二次核对花掉的时间
// 都已经算在 gap 里了,所以日志里的「等待 N 秒」可能很短(7 秒也正常),
// 真正两次搜索之间仍然隔着 gap 那么久,不会更密
const cycle = SEARCH_PACER.jitter(gap);
let wait = Math.max(5000, cycle - (Date.now() - iterStart));
if (attempts % 5 === 0) wait += Utils.randomRange(20000, 60000);
// 偶尔停下来「看一会儿结果」,整段时间里别只有等间隔的搜索
else if (Math.random() < 0.08) wait += Utils.randomRange(60000, 150000);
const aparts = Math.round((Date.now() - iterStart + wait) / 1000);
Utils.log("⏳", `等待 ${(wait / 1000).toFixed(0)} 秒(两次搜索相隔约 ${aparts} 秒,含观察与核对)`);
await Utils.delay(wait);
}
SEARCH_PACER.setLast({ attempts, credits, peak: Math.round(peakGap / 1000), at: Date.now() });
SEARCH_PACER.saveGap(peakGap);
const elapsed = Math.round((Date.now() - startedAt) / 1000);
const finalInfo = await API.getRewardsInfo();
if (finalInfo) {
progress = finalInfo.pc.progress;
max = finalInfo.pc.max || max;
RewardsAuto.state.pcProgress = progress;
RewardsAuto.state.pcMax = max;
}
const hitRate = attempts > 0 ? Math.round((credits / attempts) * 100) : 0;
// 只有拿到服务端最终确认才算完成;核对接口全挂时按乐观估计算出的「满」不算数,
// 留给下一轮用真实进度校正,免得把没记上的分当成已完成再也不搜
if (finalInfo && progress >= max) {
markDone();
Utils.log("🔵",
`🔍 搜索任务完成!PC: ${progress}/${max}(本轮 ${attempts} 次搜索、记账 ${credits} 次,用时 ${elapsed}s,最高节拍 ${Math.round(peakGap / 1000)}s)`, true);
} else {
// 没满就把「提前续做」的标记立起来,调度下一轮 5 分钟后就来,不再干等 20 分钟
SEARCH_PACER.setPending(true);
Utils.log("🟡",
`搜索未满 PC: ${progress}/${max},本轮 ${attempts} 次搜索、记账 ${credits} 次(${hitRate}%),用时 ${elapsed}s,最高节拍 ${Math.round(peakGap / 1000)}s,已安排提前续做`);
this.save();
}
},
async doDailySet() {
const today = Utils.getTodayNum();
const todayStr = Utils.getTodayStr();
const processedKey = "Config.dailySetProcessed";
let processed = GM_getValue(processedKey, []);
if (!Array.isArray(processed) || processed[0]?.date !== today) processed = [];
// 只信任「已被服务端确认完成」的卡片,避免假成功把当天卡片永久标记为已完成
const doneIds = new Set(processed.filter(p => p && p.ok).map(p => p.offerId));
const maxRounds = 3;
let totalOk = 0, totalFail = 0, remain = -1;
// 服务端记账有延迟且偶发限流,单轮容易「一张没完成」,故多轮:发现 → 领取 → 复核
for (let round = 1; round <= maxRounds; round++) {
await RegionGuard.guard("每日活动");
Utils.log("📅", `第 ${round}/${maxRounds} 轮每日活动(服务端已确认 ${doneIds.size} 张)...`);
await Utils.randomDelay(2000, 5000);
let cards = [];
let dapiOk = false;
try {
// 优先用 DAPI 权威发现:不依赖页面 HTML,也不依赖浏览器 Cookie
const map = await API.getPromotionMap(true);
dapiOk = !!map;
cards = await API.discoverDailySetCards(map);
Utils.log("📅", `DAPI 发现 ${cards.length} 张未完成的日常活动卡片`);
} catch (e) {
Utils.log("🟡", `DAPI 发现失败,回退网页解析: ${e.message}`);
}
// 只有 DAPI 不可用时才回退网页解析(DAPI 说「没有待办」就是真的没有)
if (cards.length === 0 && !dapiOk) {
try {
const allCards = await API.discoverCards();
cards = allCards.filter(c => (c.kind === "daily" || c.offerId.includes("DailySet")) && !doneIds.has(c.offerId));
} catch (e) {
Utils.log("🟡", `网页卡片解析失败: ${e.message}`);
}
}
for (const card of cards) {
if (doneIds.has(card.offerId)) continue;
await RegionGuard.guard("每日活动领取");
Utils.log("📅", `领取每日活动: ${card.title} +${card.points}p`);
await Utils.randomDelay(3000, 6000);
const res = await API.claimCard(card, round === 1 ? { attempts: 2 } : {});
if (res) { totalOk++; doneIds.add(card.offerId); } else { totalFail++; }
}
// 每轮结束都以 DAPI 复核,只有真正完成的卡片才留在记录里
remain = -1;
try {
const map = await API.getPromotionMap(true);
if (map) {
const todays = [...map.values()].filter(i =>
(i.offerId.includes(RewardsAuto.appConfig.offerIds.dailySetPrefix) || i.dailySetDate === todayStr)
&& (i.dailySetDate === todayStr || i.offerId.includes(String(today))));
remain = todays.filter(i => !i.complete).length;
for (const i of todays) {
if (i.complete) doneIds.add(i.offerId);
else doneIds.delete(i.offerId);
}
}
} catch (_) {}
GM_setValue(processedKey, [...doneIds].map(id => ({ date: today, offerId: id, ok: true })));
if (remain === 0) break;
if (cards.length === 0) break; // 本轮无卡可领:交给下次运行,不空转等待
if (round < maxRounds) {
const wait = Utils.randomRange(20000, 35000);
Utils.log("⏳", `仍有 ${remain < 0 ? "未知数量" : remain} 张未完成,${Math.round(wait / 1000)} 秒后进入下一轮`);
await Utils.randomDelay(wait, wait);
}
}
// 服务端确认今天没有剩余卡片时落一个「今日已完成」标记:
// 卡片可能早就被别的设备领完了,此时 processed 是空的,只认 processed 会让 keep=false 永远停不下来。
if (remain === 0) GM_setValue("Config.dailySetDoneDate", today);
else if (remain > 0) GM_setValue("Config.dailySetDoneDate", 0);
if (totalOk > 0 || totalFail > 0 || remain > 0) {
Utils.log(remain === 0 ? "🔵" : "🟡",
`每日活动完成: ${totalOk}成功/${totalFail}失败${remain > 0 ? `,仍剩 ${remain} 张未完成` : ""}(服务端已确认)`,
remain === 0);
} else if (remain === 0) {
Utils.log("✅", "每日活动已全部完成(服务端已确认)");
}
// 返回状态供 runAll 的 withRetry 判断:未完成则 60 秒后整体重试
this.dailySetSummary = remain >= 0
? `${doneIds.size} 张已完成${remain > 0 ? `,剩 ${remain} 张` : ""}`
: `${doneIds.size} 张已完成(服务端状态未知)`;
if (remain < 0) return true; // DAPI 不可用,重试也没有意义
return remain === 0;
},
/** 只是读一下 dashboard 上「可领取」的积分并提示手动领取:
* 纯读操作,不写任何任务日期,也不参与 isAllDone 的完成判定 ——
* 这些积分无论领没领,都不影响「今日任务已全部完成、停止检测」的判断。 */
async doClaimPoints() {
await RegionGuard.guard("领取积分");
try {
const dashboardHtml = await Utils.xhr({ url: "https://rewards.bing.com/dashboard" });
if (!dashboardHtml) {
Utils.log("✅", "无法获取 dashboard 页面");
return;
}
const claimableMatch = dashboardHtml.match(/alt="可领取"[^>]*>[\s\S]*?(\d[\d,]*)/i);
if (!claimableMatch) {
Utils.log("✅", "无可领取积分");
return;
}
const amount = parseInt(claimableMatch[1].replace(/,/g, '')) || 0;
if (amount > 0) {
Utils.log("🎁", `发现 ${amount} 积分待领取(请手动前往 dashboard 领取)`, true);
} else {
Utils.log("✅", "可领取积分为 0");
}
} catch (e) {
Utils.log("🟡", `检测可领取积分失败: ${e.message}`);
}
},
async doStreak() {
await RegionGuard.guard("连签检测");
Utils.log("📅", "开始检测连签任务...");
// 先用 DAPI 读取权威状态(不依赖页面 DOM / 浏览器 Cookie)
try {
const map = await API.getPromotionMap(true);
if (map) {
const streak = map.get(RewardsAuto.appConfig.offerIds.streakCounter);
if (streak && streak.progress > 0) {
this.streakDays = streak.progress;
Utils.log("📅", `每日连续打卡:${this.streakDays} 天(DAPI 权威值)`);
}
const todayStr = Utils.getTodayStr();
const children = [...map.values()].filter(i =>
i.offerId.includes(RewardsAuto.appConfig.offerIds.dailySetPrefix) && i.dailySetDate === todayStr);
if (children.length > 0) {
const done = children.filter(i => i.complete).length;
Utils.log(done >= children.length ? "✅" : "📅", `每日连续打卡活动:${done}/${children.length} 张已完成`);
}
}
} catch (_) {}
try {
const earnHtml = await Utils.xhr({ url: "https://rewards.bing.com/earn" });
if (!earnHtml) { Utils.log("🟡", "无法获取 earn 页面"); return; }
const text = earnHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
const streakDaysMatch = text.match(/每日连续打卡\s*(\d+)/);
if (streakDaysMatch) {
this.streakDays = parseInt(streakDaysMatch[1]) || 0;
Utils.log("📅", `每日连续打卡:${this.streakDays} 天`);
}
const taskPatterns = [
{ name: "必应搜索连续打卡", pattern: /必应搜索连续打卡[\s\S]*?搜索:\s*(\d+)\/(\d+)/ },
{ name: "每日连续打卡活动", pattern: /每日连续打卡活动[\s\S]*?活动:\s*(\d+)\/(\d+)/ },
{ name: "必应应用连续打卡", pattern: /必应应用连续打卡[\s\S]*?签到:\s*(\d+)\/(\d+)/ },
{ name: "视觉搜索连续打卡", pattern: /视觉搜索连续打卡[\s\S]*?活动:\s*(\d+)\/(\d+)/ },
];
let visualSearchDone = true;
for (const { name, pattern } of taskPatterns) {
const m = text.match(pattern);
if (m) {
const cur = parseInt(m[1]), max = parseInt(m[2]);
const done = cur >= max;
Utils.log(done ? "✅" : "📅", `${name}: ${cur}/${max}${done ? " 已完成" : ""}`);
if (name === "视觉搜索连续打卡" && !done) visualSearchDone = false;
}
}
if (!visualSearchDone) {
Utils.log("📅", "视觉搜索未完成,尝试后台打卡...");
try {
await Utils.xhr({
url: "https://www.bing.com/?features=vsstreak,vstooltip&form=ML2XES",
headers: {
"user-agent": RewardsAuto.ua.pc,
"referer": "https://www.bing.com/"
}
});
Utils.log("✅", "视觉搜索打卡请求已发送(后台)");
await Utils.randomDelay(3000, 5000);
} catch (e) {
Utils.log("🟡", `视觉搜索打卡失败: ${e.message}`);
}
}
const stampMatch = text.match(/(\d+)\s*个印章/) || text.match(/印章\s*(\d+)/);
if (stampMatch) {
Utils.log("📅", `连签奖励印章进度: ${stampMatch[1]}/12`);
}
Utils.log("📅", "连签任务检测完成,未完成任务将由活动卡片模块处理");
} catch (e) {
Utils.log("🔴", `连签任务异常: ${e.message}`);
}
},
async runAll() {
if (this.running && Date.now() - (this.runningSince || 0) < RUN_STALE) {
Utils.log("🟡", "任务正在运行中,请勿重复触发");
return;
}
if (this.running) {
// 上一轮在后台沙盒里被冻结,finally 没跑到,标记残留在内存里;
// 不清理的话之后每一轮都会在这里被挡掉,只能靠手动「立即运行」。
Utils.log("🟡", "上一轮的运行标记是残留的(上下文被冻结过),清理后继续");
this.running = false;
}
Utils.log("▶️", `本轮进入执行流程(${Utils.hasDOM() ? "页面" : "后台"}上下文)`);
// 跨上下文互斥:后台实例与 rewards 标签页会同时跑,并发会重复上报并互相踢 Token
if (!Utils.acquireLock("Config.runLock", 45 * 60 * 1000)) {
Utils.log("⏳", "另一个上下文正在运行任务,本次跳过(避免并发重复上报)");
return;
}
this.running = true;
this.runningSince = Date.now();
Sched.state("running");
try {
// 后台常驻实例会跨轮次复用,这几个「本轮重试次数」必须每轮归零,
// 否则累计到 3 次失败后就再也不重试(旧版 @crontab 每轮新实例,天然为 0)。
this.signTimes = 0;
this.readTimes = 0;
this.promosTimes = 0;
RewardsAuto.state.startTime = Utils.getTimestamp();
Utils.log("🚀", "启动全能自动化任务...");
this.init();
// 先验出口、再碰任何微软接口:原来先 getBalance 再 checkRegion,
// 等于「出口还没确认就先给 rewards 打了一枪」,正好是最该避免的那一下。
if (!(await API.checkRegion())) {
Utils.log("🛑", "出口检测未通过:本轮不发任何微软请求,关掉 VPN/梯子(或恢复直连)后会自动恢复");
return;
}
const startBalance = await API.getBalance();
Utils.log("📊", `初始积分: ${startBalance}`);
let isTokenOK = await API.renewToken();
if (!isTokenOK) Utils.log("🟡", "Token失败,跳过签入/阅读", true);
const retryDelay = 60000;
const maxRetries = 2;
const withRetry = async (taskFn, taskName, retries = 0) => {
Utils.refreshLock("Config.runLock"); // 长任务期间持续续约,避免锁过期被别人抢走
try {
const result = await taskFn();
if (result === false && retries < maxRetries) {
Utils.log("🟡", `${taskName} 失败,${retryDelay/1000}秒后重试 (${retries + 1}/${maxRetries})`);
await Utils.delay(retryDelay);
return withRetry(taskFn, taskName, retries + 1);
}
return result;
} catch (e) {
// 出口被拦截:整轮立刻收手,重试只会多发几次请求
if (e && e.regionBlocked) throw e;
if (retries < maxRetries) {
Utils.log("🟡", `${taskName} 异常: ${e.message},${retryDelay/1000}秒后重试`);
await Utils.delay(retryDelay);
return withRetry(taskFn, taskName, retries + 1);
}
Utils.log("🔴", `${taskName} 失败: ${e.message}`);
return false;
}
};
if (isTokenOK) {
await withRetry(() => this.doSign(), "签到");
await Utils.randomDelay();
// 阅读走 App/DAPI 通道(Bearer Token),与 PC 会话 Cookie 无关:
// 后台上下文里 PC 通道必然 401,不能用它来把关阅读任务。
if (RewardsAuto.state.pc401) {
Utils.log("🟡", "PC 会话不可用(只影响 PC 通道任务),阅读改用 App 通道继续执行");
}
await withRetry(() => this.doRead(), "阅读");
await Utils.randomDelay();
} else {
await withRetry(() => this.doSign(), "签到");
await Utils.randomDelay();
}
await withRetry(() => this.doPromos(), "活动卡片");
await Utils.randomDelay();
await withRetry(() => this.doSearch(), "搜索");
await withRetry(() => this.doStreak(), "连签检测");
await Utils.randomDelay();
Utils.log("📅", "开始执行每日活动任务...");
await Utils.randomDelay();
const dailySetRes = await withRetry(() => this.doDailySet(), "每日活动");
try {
await this.doClaimPoints();
} catch (e) {
if (e && e.regionBlocked) throw e;
Utils.log("🟡", `领取积分执行异常: ${e.message}`);
}
Utils.log("🔄", "二次扫描:检查是否有新解锁的卡片...");
await Utils.randomDelay(3000, 8000);
const newCards = await API.discoverCards();
if (newCards.length > 0) {
Utils.log("🧩", `二次扫描发现 ${newCards.length} 个新卡片`);
let ok = 0, fail = 0;
for (const card of newCards) {
await RegionGuard.guard("二次扫描");
Utils.log(" ", `[${card.kind}] ${card.title} +${card.points}p`);
if (card.kind === "quiz" && !GM_getValue("Tasks.quiz", true)) continue;
await Utils.randomDelay(3000, 8000);
const result = await API.claimCard(card);
result ? ok++ : fail++;
}
Utils.log("🔵", `二次扫描完成: ${ok}成功/${fail}失败`);
} else {
Utils.log("✅", "二次扫描:无新卡片");
}
// Edge 浏览连签:后台累计 30 分钟才记账,这里每轮至少推进一次
//(常驻调度的心跳里还有节拍器按 report_per_minutes 的节奏续报)
Utils.log("🌐", "检查 Edge 浏览连签进度...");
await EdgePacer.maybeReport();
const endTime = Utils.getTimestamp();
const totalTime = ((endTime - RewardsAuto.state.startTime) / 1000).toFixed(1);
const endBalance = await API.getBalance();
const earned = (startBalance > 0 && endBalance > 0) ? (endBalance - startBalance) : 0;
const info = await API.getRewardsInfo();
if (info) {
const signOk = this.signDate === RewardsAuto.state.dateNowNum;
const readOk = this.readDate === RewardsAuto.state.dateNowNum;
const searchOk = info.pc.progress >= info.pc.max;
const promosOk = this.promosDate === RewardsAuto.state.dateNowNum;
let logMsg = `签到\t\t${signOk ? '✅' : '❌'}\n`;
const readProg = info.readProgress ?? RewardsAuto.state.readProgress ?? 0;
const readMaxProg = info.readMax ?? RewardsAuto.state.readMax ?? 30;
logMsg += `阅读\t\t${readOk ? '✅' : '❌'} ${readProg}/${readMaxProg}\n`;
// 搜索没跑满时把「当前节拍」一起报出来,方便判断是被限流还是单纯没搜完
const pacer = SEARCH_PACER.summary();
logMsg += `PC 搜索\t${searchOk ? '✅' : '⏳'} ${info.pc.progress}/${info.pc.max}${searchOk ? '' : `(每轮从 ${pacer.min}s 起步${pacer.last ? `,上轮 ${pacer.last.attempts} 搜/${pacer.last.credits} 记` : ''})`}\n`;
logMsg += `活动卡片\t${promosOk ? '✅' : '❌'}\n`;
logMsg += `每日活动\t${dailySetRes ? '✅' : '❌'} ${this.dailySetSummary || ''}\n`;
logMsg += `连签\t\t${this.streakDays || 0} 天\n`;
const edge = EdgeStreak.summary();
if (edge) logMsg += `Edge连签\t${edge.done ? '✅' : edge.skipped ? '⏭️' : '⏳'} ${edge.text}\n`;
logMsg += `今日获取\t+${earned}\n`;
logMsg += `总积分\t\t${info.balance || endBalance}`;
RewardsAuto.state.sendMSG = logMsg;
Utils.log("📊", logMsg, true);
} else {
Utils.log("🎉", `任务执行完成!用时 ${totalTime} 秒`, true);
}
// 关掉「持续检测」时:这一轮结束如果已经全部完成就当场收工,
// 不必等 20 分钟后下一轮再来判定(否则会白跑一轮空任务)。
if (!GM_getValue("Config.keep", true) && isDayComplete()) markDayComplete();
} catch (e) {
if (e && e.regionBlocked) {
// 出口被拦截:本轮已发的请求到此为止,20 分钟后再看一次出口
Utils.log("🛑", `本轮已中止:${e.message}`);
} else {
Utils.log("🔴", `本轮任务异常终止: ${e.message}`);
}
} finally {
Utils.releaseLock("Config.runLock");
this.running = false;
this.runningSince = 0;
Sched.state("done"); // 拿到锁的那一轮才走到这里,可以放心把阶段标成结束
}
}
};
GM_registerMenuCommand("🔑 手动授权", () => {
try {
GM_openInTab(Auth.authUrl, { active: true });
Utils.log("🟡", "已打开微软授权页:登录后会自动捕获授权码并换取 Token(无需手动复制)");
} catch (e) {
Utils.uiAlert(`❌ 无法打开授权页:${e.message}\n\n请手动访问:\n${Auth.authUrl}`);
}
});
// 后台 service_worker 没有 prompt,统一走 Utils.uiPrompt(无 DOM 时打开页面弹输入框)
GM_registerMenuCommand("📋 粘贴授权码", () => { Auth.pasteFlow().catch(e => Utils.log("🔴", `粘贴授权码失败:${e.message}`)); });
GM_registerMenuCommand("📊 Token状态", () => {
const token = GM_getValue("Config.token", false);
const time = Number(GM_getValue("Config.tokenTime", 0)) || 0;
let ageStr = "未知";
if (time > 0) {
const diff = Utils.getTimestamp() - time;
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
const parts = [];
if (days > 0) parts.push(`${days}天`);
if (hours > 0) parts.push(`${hours}小时`);
parts.push(`${minutes}分钟`);
ageStr = parts.join("");
}
const tokenDate = time > 0 ? new Date(time).toLocaleString("zh-CN") : "未知";
const rawCode = GM_getValue("Config.code", "");
const parsed = rawCode ? Auth.parseCode(rawCode) : null;
const codeUsed = !!(parsed && Auth.used(parsed));
const codeStr = !rawCode
? "无"
: (!parsed
? `有但无法解析(长度 ${String(rawCode).length})`
: `有(${Auth.preview(parsed)}${codeUsed ? ",已兑换为 Token,保留中" : ",尚未兑换"})`);
Utils.uiAlert(
`Token: ${token ? "已保存" : "无"}\n` +
`获取时间: ${tokenDate}\n` +
`已过: ${ageStr}\n` +
`授权码: ${codeStr}`
);
});
// 原「🌐 Edge 连签状态」菜单已移除:不再注册菜单按钮、不再弹窗,逻辑照旧(仍会立刻推进一次上报),
// 需要看这段状态文本时在控制台调用 __MPDiagnostics.edgeStreak()。
const diagEdgeStreak = () => {
const enabled = GM_getValue("Tasks.edge", true);
const st = EdgeStreak.state();
const total = st.total || Math.ceil(EDGE.TARGET_MINUTES / Math.max(1, st.intervalMin));
const waitMs = (!st.complete && st.count > 0 && st.last)
? Math.max(0, st.last + st.intervalMin * 60000 - Date.now()) : 0;
let text = "🌐 Edge 浏览连签\n\n";
text += `任务开关:${enabled ? "开启" : "关闭"}\n`;
text += `上报进度:${Math.min(st.count, total)}/${total} 次(每次覆盖 ${st.intervalMin} 分钟,满 ${EDGE.TARGET_MINUTES} 分钟记账)\n`;
text += `服务端状态:${st.complete ? "✅ 已完成" : (st.giveUp ? "⏭️ 今日已跳过" : "⏳ 未完成")}\n`;
text += `上次上报:${st.last ? new Date(st.last).toLocaleString("zh-CN") : "无"}\n`;
if (waitMs > 0) text += `下次上报:约 ${Math.ceil(waitMs / 60000)} 分钟后\n`;
text += `活动激活:${st.activated ? "已尝试" : "未尝试"}${st.extra ? `(已重来 ${st.extra}/${EDGE.MAX_EXTRA_ROUNDS} 轮)` : ""}\n`;
text += `客户端:${RewardsAuto.ua.edge}\n\n`;
text += "现在立刻推进一次(不足一个计费窗口会自动跳过,不刷接口)。";
EdgePacer.nextAt = 0;
EdgePacer.maybeReport().catch(e => Utils.log("🟡", `Edge 连签手动推进失败:${e.message}`));
return text;
};
// 原「🔍 PC 搜索诊断」菜单已移除:不再注册菜单按钮、不再弹窗,逻辑照旧(仍会立刻单独跑一次 PC 搜索),
// 需要看这段状态文本时在控制台调用 __MPDiagnostics.pcSearch()。
const diagPcSearch = async () => {
const pacer = SEARCH_PACER.summary();
const restricted = Number(GM_getValue("Config.restrictedTimes", 0)) || 0;
let text = "🔍 PC 搜索诊断\n\n";
text += `任务开关:${GM_getValue("Tasks.search", true) ? "开启" : "关闭"}\n`;
text += `最小间隔:${pacer.min} 秒(Config.span,两次搜索之间不会快于它,只会更慢)\n`;
text += `每轮起始节拍:${pacer.min} 秒(每轮重置,不继承上一轮被放大的节拍)\n`;
text += `单轮内退让上限:${SEARCH_PACER.MAX_MS / 1000} 秒;连续 ${SEARCH_PACER.NO_CREDIT_LIMIT / 60000} 分钟没记账则本轮收手\n`;
if (pacer.last) {
text += `上一轮:${pacer.last.attempts} 次搜索 / 记账 ${pacer.last.credits} 次(最高节拍 ${pacer.last.peak} 秒)\n`;
} else {
text += `上一轮:还没有记录\n`;
}
text += `单轮预算:20 分钟(固定,不可调)\n`;
text += `提前续做:${pacer.pending ? "已排程(本轮没搜满)" : "无"}\n`;
text += `限流计数:${restricted}/3\n`;
text += `任务标记:${TaskManager.searchDate === RewardsAuto.state.dateNowNum ? "今日已完成" : "今日未完成"}\n\n`;
const info = await API.getRewardsInfo();
if (info) text += `服务端配额:PC ${info.pc.progress}/${info.pc.max}\n`;
else text += "服务端配额:读取失败\n";
text += "\n调用后会立刻单独跑一次 PC 搜索(日志里能看到每次是否真的记账)。";
// 和常驻调度共用一把锁,避免诊断和正式那一轮同时搜
if (!Utils.acquireLock("Config.runLock", 45 * 60 * 1000)) {
Utils.log("🟡", "已有一轮任务在跑,搜索诊断跳过");
return;
}
Promise.resolve()
.then(() => TaskManager.doSearch())
.catch(e => Utils.log("🔴", `搜索异常:${e && e.message ? e.message : e}`))
.finally(() => Utils.releaseLock("Config.runLock"));
return text;
};
GM_registerMenuCommand("🚀 立即运行", () => {
Utils.log("🚀", "手动触发:立即运行一轮");
Promise.resolve()
.then(() => TaskManager.runAll())
.catch(e => Utils.log("🔴", `本轮任务异常:${e && e.message ? e.message : e}`))
.finally(() => Sched.finish(true)); // 手动跑过的这一轮也算数,顺延到下一次计划
});
GM_registerMenuCommand("🔔 配置通知接口", async () => {
const configNames = [
{ key: "Notice.wework", name: "企业微信 Webhook", hint: "群机器人webhook key" },
{ key: "Notice.dingding", name: "钉钉机器人 Access Token", hint: "不加签,关键词需包含 #" },
{ key: "Notice.feishu", name: "飞书机器人 Webhook", hint: "不加签,关键词需包含 #" },
{ key: "Notice.pushme", name: "PushMe Key", hint: "push.i-i.me 推送key" },
{ key: "Notice.bark", name: "Bark Key", hint: "bark.day.app 推送key" }
];
let configStr = "🔔 通知接口配置\n==================\n\n";
configNames.forEach((item, index) => {
const saved = GM_getValue(item.key, "");
configStr += `${index + 1}. ${item.name}\n 状态: ${saved ? "✅ 已配置" : "❌ 未配置"}\n 说明: ${item.hint}\n\n`;
});
configStr += "请输入要配置的编号 (1-5),或输入 0 清除所有配置:";
const choice = await Utils.uiPrompt(configStr);
if (!choice) return;
const num = parseInt(choice);
if (num === 0) {
if (Utils.uiConfirm("确定要清除所有通知接口配置吗?")) {
configNames.forEach(item => GM_setValue(item.key, ""));
Utils.uiAlert("所有通知接口配置已清除!");
}
return;
}
if (num >= 1 && num <= 5) {
const selected = configNames[num - 1];
const current = GM_getValue(selected.key, "");
const newValue = await Utils.uiPrompt(`配置 ${selected.name}\n\n当前值: ${current || "(空)"}\n\n请输入新的值:`, current);
if (newValue !== null) {
GM_setValue(selected.key, newValue.trim());
Utils.uiAlert(`${selected.name} 已${newValue.trim() ? "配置" : "清除"}!`);
}
} else {
Utils.uiAlert("无效的编号!");
}
});
GM_registerMenuCommand("📢 测试通知", () => {
RewardsAuto.state.sendMSG = "🧪 这是一条测试消息\n如果你看到这条消息,说明通知接口配置成功!";
Utils.log("📢", "测试通知已发送", true);
Utils.uiAlert("测试消息已发送,请检查各通知渠道!");
});
const updateBroMenu = () => {
const enabled = GM_getValue("Notice.bro", true);
return enabled ? "🔕 关闭浏览器通知" : "🔔 开启浏览器通知";
};
GM_registerMenuCommand(updateBroMenu(), () => {
const current = GM_getValue("Notice.bro", true);
GM_setValue("Notice.bro", !current);
Utils.uiAlert(`浏览器通知已${!current ? "开启" : "关闭"}`);
try { if (Utils.hasDOM()) location.reload(); } catch (_) {}
});
GM_registerMenuCommand("📋 查看通知状态", () => {
const wework = GM_getValue("Notice.wework", "");
const dingding = GM_getValue("Notice.dingding", "");
const feishu = GM_getValue("Notice.feishu", "");
const pushme = GM_getValue("Notice.pushme", "");
const bark = GM_getValue("Notice.bark", "");
let status = "📊 通知接口配置状态:\n\n";
status += `企业微信: ${wework ? "✅ 已配置" : "❌ 未配置"}\n`;
status += `钉钉: ${dingding ? "✅ 已配置" : "❌ 未配置"}\n`;
status += `飞书: ${feishu ? "✅ 已配置" : "❌ 未配置"}\n`;
status += `PushMe: ${pushme ? "✅ 已配置" : "❌ 未配置"}\n`;
status += `Bark: ${bark ? "✅ 已配置" : "❌ 未配置"}\n`;
Utils.uiAlert(status);
});
// 「🌍 网络出口检测」菜单已移除:不再注册菜单按钮、不再弹窗,检测逻辑照旧(每个任务/心跳都会走闸门)。
// 需要看这段状态文本时在控制台调用 __MPDiagnostics.region()。
const diagRegion = async () => {
const v = await RegionGuard.check({ fresh: true });
let text = "🌍 网络出口检测\n\n";
if (v.skipped) {
text += "「锁定国区」已关闭:不做出口限制,也不拦代理。\n\n";
} else {
text += `结论:${v.ok ? "✅ 出口在大陆,可以继续" : "⛔ 已拦截,任务不会上报"}\n`;
if (v.reasons.length) text += `原因:${v.reasons.join(";")}\n`;
}
text += `出口 IP:${v.ip || "未知"}\n`;
if (v.ipInfo) text += `${v.ipInfo}\n`;
// 分组显示:真正要紧的是「微软看到的出口」,第三方只是旁证
const msSignals = v.signals.filter(s => /^bing=/.test(s));
const otherSignals = v.signals.filter(s => !/^bing=/.test(s));
text += `\n各路信号:\n`;
text += ` 微软侧(bing.com):${msSignals.length ? msSignals.join(" ") : "(无)"}\n`;
text += ` 第三方/国内视角:${otherSignals.length ? otherSignals.join(" ") : "(无)"}\n`;
if (v.failed && v.failed.length) text += `\n探测失败:\n${v.failed.map(s => ` ${s}`).join("\n")}\n`;
text += "\n探测源:bing.com(不带 Cookie)/ cloudflare / ipinfo.io / api.ip.sb / 腾讯 IP 库\n";
text += `结论缓存:通过 ${RegionGuard.TTL_OK / 1000} 秒、拦截 ${RegionGuard.TTL_BLOCK / 1000} 秒\n`;
text += `「锁定国区」:${RegionGuard.enabled() ? "开启(非大陆出口、机房/代理 IP、WARP 都会停)" : "关闭"}`;
if (!v.skipped && v.ok) {
text += "\n\n规则模式提示:只有「微软侧」才是风控依据。bing.com 判定为 CN 就代表微软把你看成大陆;"
+ "若第三方也全 CN,说明梯子的规则没接管微软域名 —— 这正是最安全的用法。"
+ "想确认业务域名有没有漏走代理,去客户端「连接/日志」里搜 "
+ "rewards.bing.com、prod.rewardsplatform.microsoft.com、login.live.com,应显示 DIRECT。";
}
return text;
};
/* ===== 常驻调度 =====
* 之前用 setInterval 每 60 秒检查一次,但 @background 的脚本跑在 ScriptCat 的沙盒里:
* MV3 浏览器空闲约 30 秒就会回收 service worker / 冻结沙盒,里面的定时器跟着一起丢失,
* 于是「跑完第一轮就再也没有日志」。现在五件事一起做:
* 1) 心跳:每 20 秒做一次 GM 存储读写,持续给扩展续命,避免空闲回收;
* 2) 自校正:每次都按「计划时间」重算下一次触发,不用 setInterval,不漂移也不堆积;
* 3) 计划持久化:下次该跑的时间写进 Config.cronNextRun,任何新上下文(沙盒重建、
* 页面加载、脚本重载)读到后——过期就立刻补一轮,没过期就接着等,节奏仍是 20 分钟;
* 4) 认领 + 看门狗:触发时先记一个「认领」,真正跑起来(或整轮结束)才推进计划;
* 认领后 CRON_STALL 内没跑起来,说明那个上下文被打断了,立刻补跑,不会白等一轮;
* 5) 普通页面也参与调度,页面一刷新就能把停摆的调度救回来;真正的执行由
* TaskManager 的 Config.runLock 互斥,多个上下文同时到点也只会跑一轮。
*/
const CRON_PERIOD = 20 * 60 * 1000; // 一轮的间隔:保持原来的 20 分钟
const CRON_HEARTBEAT = 20 * 1000; // 心跳粒度,同时也是自校正的最大等待步长
const CRON_FIRST_DELAY = 5 * 1000; // 没有任何历史计划时,启动后多久跑第一轮
const CRON_HEARTBEAT_LOG = 10 * 60 * 1000; // 心跳日志的最小间隔,避免刷屏
const CRON_HEARTBEAT_LOG_IDLE = 60 * 60 * 1000; // 今日已完成、检测已停止时的心跳日志间隔(只用来续命,1 小时一条足够)
const CRON_STALL = 3 * 60 * 1000; // 触发之后迟迟没真正开始执行,超过这个时间就判定「这次触发丢了」
const RUN_STALE = 40 * 60 * 1000; // 运行标记超过这个时间还没释放,判定为残留(上下文被冻结过)
const Sched = {
timer: null,
started: false,
startedAt: 0,
lastHbLog: 0,
task: null,
fmt(ts) {
if (!ts) return "未知";
const d = new Date(ts);
const p = n => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
},
lastRun() { return Number(GM_getValue("Config.cronLastRun", 0)) || 0; },
// 下次该跑的时间:持久化的计划 > 上次运行时间 + 周期 > 启动后几秒
plan(now = Date.now()) {
const next = Number(GM_getValue("Config.cronNextRun", 0)) || 0;
if (next) return next;
const last = this.lastRun();
return last ? last + CRON_PERIOD : now + CRON_FIRST_DELAY;
},
saveNext(ts) { GM_setValue("Config.cronNextRun", ts); },
ctxName() { return Utils.hasDOM() ? "页面" : "后台"; },
/* 触发先「认领」再执行,真正跑起来之前不推进计划。
* 以前是触发瞬间就把 cronNextRun 推到 20 分钟后,那个上下文一旦被打断
* (沙盒冻结、标签页被关),这一轮就白扔了,只能手动点「立即运行」。 */
getClaim() {
const c = GM_getValue("Config.cronClaim", null);
return c && typeof c === "object" ? c : null;
},
claim() { GM_setValue("Config.cronClaim", { at: Date.now(), ctx: this.ctxName(), id: Utils.lockId() }); },
clearClaim() { GM_setValue("Config.cronClaim", null); },
/* 运行阶段也持久化:claimed → waiting → running → done。
* 出问题时看日志或菜单就能分清是「没触发」「触发了但没跑起来」「正在跑」还是「跑完了」。 */
state(phase, extra) {
GM_setValue("Config.runState", Object.assign({ phase, at: Date.now(), ctx: this.ctxName() }, extra || {}));
},
getState() {
const s = GM_getValue("Config.runState", null);
return s && typeof s === "object" ? s : null;
},
// 是否真的有一轮在跑:以持久化的 running 阶段 + 超时为准,不用内存布尔值
isBusy(now = Date.now()) {
const s = this.getState();
return !!(s && s.phase === "running" && now - (s.at || 0) < RUN_STALE);
},
// 一轮结束:认领作废 + 推进计划(推进计划只在这里做)
finish(ok) {
const now = Date.now();
const claim = this.getClaim();
// 这次触发是别的上下文认领的(自己只是被锁挡下来的那一方):
// 交给它那一轮结束时统一推进,别把节奏改乱
if (claim && claim.id && claim.id !== Utils.lockId()) return;
// 下一次按「这一轮被触发的时刻」往后推,保持原来 20 分钟一轮的节奏,
// 不会因为每轮跑多久就把节奏越拖越长;跑超时了就 1 分钟后重来
const base = (claim && claim.at) || now;
this.clearClaim();
GM_setValue("Config.cronLastRun", now);
const next = base + CRON_PERIOD;
let plan = next > now + 60 * 1000 ? next : now + 60 * 1000;
// PC 搜索是唯一「一次只能记一点」的任务:没跑满就别干等 20 分钟,
// 提前 5 分钟回来续做(搜索跑满后 doSearch 会把这个标记清掉)
if (GM_getValue("Tasks.search", true) && SEARCH_PACER.isPending()) {
const sooner = now + SEARCH_PACER.RETRY_GAP;
if (sooner < plan) {
plan = sooner;
Utils.log("🔍", `PC 搜索未满,${Math.round(SEARCH_PACER.RETRY_GAP / 60000)} 分钟后提前续做`);
}
}
this.saveNext(plan);
this.state(ok ? "done" : "idle");
if (ok) Utils.log("🕒", `本轮结束,下一轮 ${this.fmt(this.plan(now))}`);
},
start(task) {
if (this.started) return;
this.started = true;
this.startedAt = Date.now();
this.task = task;
// 上一次留在(可能已经消失的)上下文里的认领,启动时直接作废并补跑;
// 新鲜的认领说明还有个活着的上下文正在处理,别去抢
const claim = this.getClaim();
if (claim && claim.at) {
const age = this.startedAt - claim.at;
if (age > CRON_STALL) {
Utils.log("♻️", `上次触发(${claim.ctx || "未知"})已过去 ${Math.round(age / 60000)} 分钟仍没跑完,重新补跑`);
this.clearClaim();
}
}
const next = this.plan(this.startedAt);
if (next > this.startedAt) this.saveNext(next);
Utils.log("🕒", `常驻调度已启动(${this.ctxName()}上下文):每 ${CRON_PERIOD / 60000} 分钟一轮,下一轮 ${this.fmt(next)}`);
this.arm(true); // 计划已经过期的话,这一次直接补跑
},
/* 重排下一次检查:正常情况下按「离计划还有多久」算,最长 CRON_HEARTBEAT(顺带续命)。
* 计划已经过期时不能给 0 延时死等,否则 tick 会原地空转把 CPU 打满:
* 只有刚启动、确实该立刻补跑的那一次才用 0,其余按心跳步长轮询。 */
arm(immediate) {
if (this.timer) clearTimeout(this.timer);
const now = Date.now();
const next = this.plan(now);
let wait;
if (next > now) wait = Math.min(next - now, CRON_HEARTBEAT);
else if (immediate) wait = 0;
else wait = CRON_HEARTBEAT;
this.timer = setTimeout(() => this.tick(), wait);
},
/* 已经触发、却迟迟没真正开始执行(认领它的页面被关、沙盒被冻结、延时器丢了):
* 把这次认领作废并立刻补跑,不让一轮 20 分钟白白浪费。 */
watchdog(now) {
const claim = this.getClaim();
if (!claim || !claim.at) return;
if (this.isBusy(now)) return; // 真的在跑,别打扰
if (now - claim.at <= CRON_STALL) return; // 还在容忍期内,再等等
Utils.log("♻️", `上次触发(${claim.ctx || "未知"})${Math.round((now - claim.at) / 60000)} 分钟内没有真正开始执行,现在补跑`);
this.clearClaim();
this.fire(now);
},
tick() {
this.timer = null;
try {
const now = Date.now();
// 这一行 GM 读本身就是一次扩展调用,能重置 MV3 的空闲计时,沙盒就不会被回收
const next = this.plan(now);
this.watchdog(now);
// Edge 连签节拍器:借这次心跳顺带推进(内部有「计费窗口未到就不发请求」的判断)
EdgePacer.maybeReport().catch(() => {});
// 关掉持续检测(keep=false)且今日已全部完成:不再触发新一轮,
// 只保留心跳(心跳本身是 MV3 续命用的 GM 读,跨到明天会自动恢复跑任务)。
const stopped = !GM_getValue("Config.keep", true)
&& (Number(GM_getValue("Config.doneDate", 0)) || 0) === Utils.getTodayNum();
if (!stopped && now >= next && !this.getClaim()) {
this.fire(now);
} else if (now - this.lastHbLog >= (stopped ? CRON_HEARTBEAT_LOG_IDLE : CRON_HEARTBEAT_LOG)) {
// 平时每 10 分钟留一条心跳日志,方便确认「还在持续检测」;已停止检测时改成 1 小时一条,只用来续命
this.lastHbLog = now;
GM_setValue("Config.heartbeatAt", now);
const tip = stopped
? ",今日任务已全部完成、检测已停止,明天自动恢复"
: this.getClaim() ? ",本轮已触发,正在等它跑起来" : `,约 ${Math.round((next - now) / 60000)} 分钟后进入下一轮`;
Utils.log("💓", `常驻调度心跳:本次已运行 ${Math.round((now - this.startedAt) / 60000)} 分钟${tip}`);
}
} catch (e) {
Utils.log("🔴", `常驻调度异常:${e && e.message ? e.message : e}`);
}
this.arm(false);
},
fire(now) {
if (this.isBusy(now)) {
// 真有一轮在跑:先排到 5 分钟后复查(别每 20 秒刷一条跳过日志),
// 等那一轮结束时 finish() 会把计划覆盖成正常节奏
this.saveNext(now + 5 * 60 * 1000);
Utils.log("🟡", "上一轮任务还在执行中,本次触发跳过");
return;
}
this.claim();
this.state("claimed");
Utils.log("⏰", `常驻调度:开始本轮任务(${this.ctxName()}上下文)`);
try {
this.task();
} catch (e) {
Utils.log("🔴", `本轮任务启动失败:${e && e.message ? e.message : e}`);
this.clearClaim();
this.saveNext(now + CRON_PERIOD); // 别在失败里空转,等下一轮
}
}
};
// 上下文启动动作(读偏好、处理待粘贴的授权码),只做一次;与「跑任务」解耦,
// 页面加载不再无条件跑一轮,什么时候跑交给下面的常驻调度决定。
let bootstrapped = false;
const bootstrap = () => {
if (bootstrapped) return;
bootstrapped = true;
TaskManager.init();
// 后台点「📋 粘贴授权码」时会置 Config.pendingPaste 并打开页面,
// 页面侧在加载 / 获得焦点时把输入框补弹出来。
const consumePendingPaste = () => {
if (!GM_getValue("Config.pendingPaste", false) || !Utils.hasDOM()) return false;
GM_setValue("Config.pendingPaste", false);
// document-start 时页面还没渲染完,等 body 就绪再弹,否则原生 prompt 会被浏览器吞掉
Utils.waitForBody().then(ok => {
if (!ok) return;
setTimeout(() => {
Auth.pasteFlow().catch(e => Utils.log("🔴", `粘贴授权码失败:${e.message}`));
}, 500);
});
return true;
};
if (!consumePendingPaste() && Utils.hasDOM()) {
window.addEventListener("focus", () => {
setTimeout(() => consumePendingPaste(), 300);
}, { once: false });
}
};
/** 「今日任务是否全部完成」的唯一判定:keep=false 时用它决定是否彻底停止检测。
* - 只看各任务自己落下的完成日期,以及服务端确认过的标记;
* - 「🎁 可领取积分」只是读 dashboard 的提示,不参与判定(领不领都不影响停检测);
* - 每次都重新读一遍今天的日期和任务日期:后台上下文会跨零点常驻,不刷新就会拿昨天的日期比较。
*/
const isDayComplete = () => {
TaskManager.init();
const today = RewardsAuto.state.dateNowNum;
const checkDone = (enabled, date) => !enabled || date === today;
// 每日活动两个来源都算完成:(1) 今日确实领过卡片(processed 非空);
// (2) DAPI 复核确认「今天没有剩余卡片」时记下的 dailySetDoneDate ——
// 卡片早就在别的设备领完时 processed 是空的,只认 (1) 永远停不下来。
const processed = GM_getValue("Config.dailySetProcessed", []);
const dailySetDone = (Array.isArray(processed) && processed.length > 0 && processed[0]?.date === today)
|| (Number(GM_getValue("Config.dailySetDoneDate", 0)) || 0) === today;
// Edge 浏览连签要靠「分次上报」累计 30 分钟,没确认完成前不能算今日已完成,
// 否则 keep=false 时其它任务一跑完调度就停,它永远凑不满
const edgeDone = !GM_getValue("Tasks.edge", true)
|| !GM_getValue("Config.token", false) // 没授权时它本来也跑不了,别让调度空转
|| (() => { const st = EdgeStreak.state(); return st.complete || st.giveUp; })();
return checkDone(GM_getValue("Tasks.sign"), TaskManager.signDate) &&
checkDone(GM_getValue("Tasks.read"), TaskManager.readDate) &&
checkDone(GM_getValue("Tasks.promos"), TaskManager.promosDate) &&
checkDone(GM_getValue("Tasks.search"), TaskManager.searchDate) &&
dailySetDone &&
edgeDone;
};
/** 落「今日已完成」标记(Sched.tick 看到它就不再触发新一轮),只打一次日志,不刷屏 */
const markDayComplete = () => {
const today = RewardsAuto.state.dateNowNum;
const prev = Number(GM_getValue("Config.doneDate", 0)) || 0;
GM_setValue("Config.doneDate", today);
if (prev !== today) Utils.log("💤", "今日任务已全部完成,已停止检测(明天自动恢复)", true);
};
const init = () => {
bootstrap();
const isKeep = GM_getValue("Config.keep", true);
if (!isKeep && isDayComplete()) {
// 关掉持续检测后:今日任务全部完成就彻底停止跑任务,只留调度心跳跨到明天自动恢复。
markDayComplete();
Sched.finish(false); // 这一轮什么都没做也要推进计划,否则会每 20 秒重试一次
return;
}
// 延时只用来错开多个上下文、别和人工操作撞车:越短越不容易被冻结/关页打断
const delay = Utils.randomRange(3000, 12000);
Utils.log("⏳", `${(delay / 1000).toFixed(1)} 秒后启动...`);
Sched.state("waiting", { runAt: Date.now() + delay });
setTimeout(() => {
// runAll 内部抛错会变成 unhandled rejection 直接逃出定时器回调,
// 之后就整轮没日志了,这里兜一下;结束时(成功/跳过/异常)统一推进到下一轮
Promise.resolve()
.then(() => TaskManager.runAll())
.catch(e => Utils.log("🔴", `本轮任务异常:${e && e.message ? e.message : e}`))
.finally(() => Sched.finish(true));
}, delay);
};
// 已移除删除 _EDGE_S Cookie 的逻辑:那是 Edge 专属积分标识,且缺少 GM_cookie 时会抛错中断初始化
const isBingSearchPage = typeof location !== "undefined"
&& location.hostname
&& (location.hostname === "www.bing.com" || location.hostname === "cn.bing.com");
if (IS_OAUTH_CALLBACK) {
// 授权回调页:捕获 code → 立刻换 Token → 提示并关闭页面
Auth.handleCallback().catch(e => Utils.log("🔴", `授权回调处理失败:${e.message}`));
return;
}
// Bing 搜索页是脚本自己打开的:既不初始化,也不参与调度,免得开出一堆标签页
if (isBingSearchPage) {
GM_log("⏭️ 当前为 Bing 搜索页,跳过脚本初始化(防止重复执行/打开多余页面)");
return;
}
// 先做一次启动动作(读偏好、处理待粘贴授权码,幂等),再交给常驻调度决定何时跑任务
bootstrap();
Sched.start(init);
// 原「🕒 查看调度状态」菜单已移除:不再注册菜单按钮、不再弹窗,
// 需要看这段状态文本时在控制台调用 __MPDiagnostics.schedule()。
const diagSchedStatus = () => {
const now = Date.now();
const next = Sched.plan(now);
const last = Sched.lastRun();
const st = Sched.getState();
const claim = Sched.getClaim();
const phaseText = { claimed: "已触发,等待执行", waiting: "等待延时结束", running: "正在执行", done: "上一轮已完成", idle: "空闲" };
let text = "🕒 常驻调度状态\n\n";
text += `调度间隔:${CRON_PERIOD / 60000} 分钟\n`;
text += `最近结束:${Sched.fmt(last)}\n`;
text += `下次触发:${Sched.fmt(next)}(约 ${Math.max(0, Math.ceil((next - now) / 60000))} 分钟后)\n`;
text += `运行阶段:${st ? (phaseText[st.phase] || st.phase) : "未知"}${st && st.ctx ? `(${st.ctx},${Sched.fmt(st.at)})` : ""}\n`;
if (claim) text += `当前认领:${claim.ctx || "未知"},${Sched.fmt(claim.at)}\n`;
text += `本次上下文:${Sched.ctxName()}(已运行 ${Math.round((now - Sched.startedAt) / 60000)} 分钟)\n\n`;
text += "「运行阶段」一直停在「已触发」或「等待延时结束」,说明触发它的上下文被打断了,\n调度会自动补跑;也可以打开任意 rewards.bing.com 页面立刻救一轮。";
return text;
};
// 原「🧹 重置调度计划」菜单已移除:不再注册菜单按钮,
// 需要重置时在控制台调用 __MPDiagnostics.resetSchedule()。
const diagResetSchedule = () => {
Sched.clearClaim();
GM_setValue("Config.runState", null);
GM_setValue("Config.cronNextRun", null);
Utils.log("🧹", "调度计划已重置,下一轮将立刻开始");
Sched.arm(true);
};
// 原五个诊断/操作菜单(Edge 连签状态、PC 搜索诊断、查看调度状态、重置调度计划、网络出口检测)
// 已从右键菜单移除。逻辑原样保留在这里,不再弹出任何提示框:需要时在控制台(脚本管理器或
// rewards.bing.com 页面)调用 __MPDiagnostics.edgeStreak() / pcSearch() / region() / schedule() /
// resetSchedule(),前四个的返回值就是原来弹窗里的那段文本。
try {
const host = (typeof unsafeWindow !== "undefined" && unsafeWindow) ? unsafeWindow : globalThis;
host.__MPDiagnostics = {
edgeStreak: diagEdgeStreak,
pcSearch: diagPcSearch,
region: diagRegion,
schedule: diagSchedStatus,
resetSchedule: diagResetSchedule
};
} catch (_) {}
if (!Utils.hasDOM()) {
// 判定后台脚本是否还在跑,否则调度器还在转、管理页却显示「停止」。
return new Promise(() => {});
}
})();
// 把后台分支的 pending Promise 原样返回给 ScriptCat,使 runStatus 保持 running。
// 页面上下文与授权回调返回的不是 Promise,不会触发下面的 return。
if (__scBgKeepAlive && typeof __scBgKeepAlive.then === "function") {
return __scBgKeepAlive;
}