416 lines
17 KiB
JavaScript
416 lines
17 KiB
JavaScript
/**
|
||
* Xbox Cart Web Runner
|
||
* 远程路径: https://raw.githubusercontent.com/dragonisheep/Surge/refs/heads/master/Scripts/NewAddToCart_Web.js
|
||
*
|
||
* 流程:
|
||
* 1. GET 读取远程当前组(服务端加锁)
|
||
* 2. 执行加购
|
||
* 3. POST /surge/commit 提交结果:
|
||
* - remaining 为空 → 服务端弹出当前组(全部成功)
|
||
* - remaining 非空 → 服务端用失败的 product 更新当前组,释放锁(部分失败,下次只重试失败部分)
|
||
* 4. 若远程无数据,回退到本地 XboxProductList
|
||
*/
|
||
|
||
const MARKET = "NG";
|
||
const LOCALE = "en-ng";
|
||
const FRIENDLY_NAME = `cart-${MARKET}`;
|
||
const CLIENT_CONTEXT = { client: "UniversalWebStore.Cart", deviceType: "Pc" };
|
||
|
||
const REMOTE_READ_URL = 'https://xbox-bot.biubiubiu-lalala.workers.dev/surge?token=xbox123';
|
||
const REMOTE_COMMIT_URL = 'https://xbox-bot.biubiubiu-lalala.workers.dev/surge/commit?token=xbox123';
|
||
const LOCAL_KEY = 'XboxProductList';
|
||
|
||
const MUID = $persistentStore.read("cart-x-authorization-muid");
|
||
const MS_CV = $persistentStore.read("cart-ms-cv");
|
||
|
||
let logBuffer = [];
|
||
const results = { success: [], failure: [] };
|
||
const successKeys = [];
|
||
let currentIndex = 0;
|
||
let productList = [];
|
||
let sourceLabel = "";
|
||
let useRemote = false;
|
||
let _remoteGroupRaw = null; // 保留原始远程数据,用于构建 failedProducts
|
||
|
||
function log(type, message, detail = "") {
|
||
const icon = type === "success" ? "✅" : (type === "error" ? "❌" : "ℹ️");
|
||
const color = type === "success" ? "green" : (type === "error" ? "red" : "#666");
|
||
console.log(`${icon} ${message} ${detail}`);
|
||
logBuffer.push(`<div style="color:${color}; border-bottom:1px solid #eee; padding:5px;">${icon} ${message} <small>${detail}</small></div>`);
|
||
}
|
||
|
||
const generateRiskSessionId = () =>
|
||
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c =>
|
||
(c === "x" ? (Math.random() * 16 | 0) : ((Math.random() * 4 | 8) | 0)).toString(16));
|
||
|
||
const toNum = k => { const m = /^product(\d+)$/.exec(k) || /^\((\d+)\)/.exec(k); return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER; };
|
||
const normEntry = v => {
|
||
if (!v || typeof v !== "object") return null;
|
||
const productId = String(v.ProductId ?? v.productId ?? "").trim();
|
||
const skuId = String(v.SkuId ?? v.skuId ?? "").trim();
|
||
const availabilityId = String(v.AvailabilityId ?? v.availabilityId ?? "").trim();
|
||
if (!productId || !skuId || !availabilityId) return null;
|
||
return { productId, skuId, availabilityId };
|
||
};
|
||
|
||
function parseProductList(raw) {
|
||
let parsed; try { parsed = JSON.parse(raw || "{}"); } catch { parsed = {}; }
|
||
return Object.keys(parsed)
|
||
.filter(k => normEntry(parsed[k]) !== null) // 接受任意 key,只要 value 有效
|
||
.sort((a, b) => toNum(a) - toNum(b))
|
||
.map(k => { const norm = normEntry(parsed[k]); return norm ? { key: k, ...norm } : null; })
|
||
.filter(Boolean);
|
||
}
|
||
|
||
const API_URL = "https://cart.production.store-web.dynamics.com/cart/v1.0/cart/loadCart?cartType=consumer&appId=StoreWeb";
|
||
const HEADERS = {
|
||
"content-type": "application/json",
|
||
"accept": "*/*",
|
||
"x-authorization-muid": MUID,
|
||
"ms-cv": MS_CV,
|
||
"origin": "https://www.microsoft.com",
|
||
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
|
||
};
|
||
|
||
const normId = v => String(v ?? "").trim().toUpperCase();
|
||
const arr = v => Array.isArray(v) ? v : [];
|
||
|
||
function parseJsonBody(raw) {
|
||
if (raw && typeof raw === "object") return { ok: true, value: raw };
|
||
try {
|
||
return { ok: true, value: JSON.parse(String(raw || "{}")) };
|
||
} catch (e) {
|
||
return { ok: false, error: String(e) };
|
||
}
|
||
}
|
||
|
||
function getStatusCode(response) {
|
||
return Number(response && (response.status || response.statusCode)) || 0;
|
||
}
|
||
|
||
function collectLineItems(cart) {
|
||
const out = [];
|
||
const addItems = items => {
|
||
for (const item of arr(items)) {
|
||
if (item && typeof item === "object") out.push(item);
|
||
}
|
||
};
|
||
|
||
addItems(cart && cart.lineItems);
|
||
addItems(cart && cart.bundleLineItems);
|
||
|
||
for (const bundle of arr(cart && cart.bundleLineItems)) {
|
||
addItems(bundle && bundle.lineItems);
|
||
addItems(bundle && bundle.items);
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function collectBusinessErrors(payload) {
|
||
const events = payload && payload.events;
|
||
if (!events || typeof events !== "object") return [];
|
||
|
||
const found = [];
|
||
const visit = (section, value) => {
|
||
if (Array.isArray(value)) {
|
||
for (const event of value) visit(section, event);
|
||
return;
|
||
}
|
||
if (!value || typeof value !== "object") return;
|
||
|
||
const data = value.data || {};
|
||
const status = Number(data.httpStatusCode || value.httpStatusCode || 0);
|
||
const type = String(value.type || value.severity || "").toLowerCase();
|
||
if (type === "error" || status >= 400) {
|
||
found.push({ section, event: value });
|
||
return;
|
||
}
|
||
|
||
for (const key of Object.keys(value)) {
|
||
if (Array.isArray(value[key])) visit(`${section}.${key}`, value[key]);
|
||
}
|
||
};
|
||
|
||
for (const key of Object.keys(events)) visit(key, events[key]);
|
||
return found;
|
||
}
|
||
|
||
function formatBusinessError(item) {
|
||
const event = item.event || {};
|
||
const data = event.data || {};
|
||
const parts = [];
|
||
if (item.section) parts.push(item.section);
|
||
if (event.provider) parts.push(`provider=${event.provider}`);
|
||
if (event.code) parts.push(`code=${event.code}`);
|
||
if (data.reason) parts.push(`reason=${data.reason}`);
|
||
if (Array.isArray(data.subReasons) && data.subReasons.length) {
|
||
parts.push(`sub=${data.subReasons.filter(Boolean).join(" / ")}`);
|
||
}
|
||
if (data.httpStatusCode) parts.push(`http=${data.httpStatusCode}`);
|
||
return parts.join(", ") || "unknown business error";
|
||
}
|
||
|
||
function validateAddResult(rawBody, target) {
|
||
const parsed = parseJsonBody(rawBody);
|
||
if (!parsed.ok) {
|
||
return { ok: false, reason: `响应不是合法 JSON: ${parsed.error}` };
|
||
}
|
||
|
||
const payload = parsed.value || {};
|
||
const cart = payload.cart || {};
|
||
const market = cart.market ? String(cart.market).toUpperCase() : "";
|
||
const language = cart.language ? String(cart.language).toLowerCase() : "";
|
||
const contextProblems = [];
|
||
|
||
if (market && market !== MARKET) contextProblems.push(`market=${cart.market}`);
|
||
if (language && language !== LOCALE.toLowerCase()) contextProblems.push(`language=${cart.language}`);
|
||
|
||
const errors = collectBusinessErrors(payload);
|
||
const lineItems = collectLineItems(cart);
|
||
const matchedLine = lineItems.find(item =>
|
||
normId(item.productId) === normId(target.productId) &&
|
||
normId(item.skuId) === normId(target.skuId) &&
|
||
normId(item.availabilityId) === normId(target.availabilityId)
|
||
);
|
||
|
||
if (errors.length > 0) {
|
||
const detail = errors.map(formatBusinessError).join(" | ");
|
||
const context = contextProblems.length ? ` | context: ${contextProblems.join(", ")}` : "";
|
||
return { ok: false, reason: `${detail}${context} | lineItems=${lineItems.length}` };
|
||
}
|
||
|
||
if (contextProblems.length > 0) {
|
||
return { ok: false, reason: `购物车上下文不匹配: ${contextProblems.join(", ")} | lineItems=${lineItems.length}` };
|
||
}
|
||
|
||
if (!matchedLine) {
|
||
return {
|
||
ok: false,
|
||
reason: `HTTP 200 但未在 cart.lineItems 找到目标商品 | lineItems=${lineItems.length} | cartId=${cart.id || ""}`
|
||
};
|
||
}
|
||
|
||
const title = matchedLine.title ? ` | ${matchedLine.title}` : "";
|
||
const qty = matchedLine.quantity ? ` | qty=${matchedLine.quantity}` : "";
|
||
const amount = matchedLine.totalAmount ? ` | amount=${matchedLine.totalAmount}` : "";
|
||
return { ok: true, detail: `${cart.language || ""}/${cart.market || ""}${title}${qty}${amount}` };
|
||
}
|
||
|
||
function finalizeAndClean() {
|
||
const successCount = results.success.length;
|
||
const failureCount = results.failure.length;
|
||
|
||
const showResult = (ngnStr = '', failedNames = []) => {
|
||
const priceNote = ngnStr ? ` | ${ngnStr}` : '';
|
||
const notifSubtitle = failureCount === 0
|
||
? `成功: ${successCount}${priceNote}`
|
||
: `成功: ${successCount} / 失败: ${failureCount}${priceNote}`;
|
||
|
||
const failedHtml = failedNames.length > 0
|
||
? `<div style="color:red;margin-top:10px;"><b>加购失败的游戏:</b><ul>${failedNames.map(n => `<li>${n}</li>`).join('')}</ul></div>`
|
||
: '';
|
||
const priceHtml = ngnStr
|
||
? `<div style="margin-top:10px;font-weight:bold;">游戏总价: ${ngnStr}</div>`
|
||
: '';
|
||
|
||
$notification.post(
|
||
"🛒 Xbox 加购完成",
|
||
notifSubtitle,
|
||
`来源: ${sourceLabel}`
|
||
);
|
||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Xbox Cart</title></head><body style="font-family:sans-serif;padding:20px;"><h3>执行结果: 成功 ${successCount} / 失败 ${failureCount} | 来源: ${sourceLabel}</h3>${priceHtml}${failedHtml}<div style="background:#f9f9f9;padding:10px;margin-top:10px;">${logBuffer.join("")}</div></body></html>`;
|
||
$done({ response: { status: 200, headers: { "Content-Type": "text/html;charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache" }, body: html } });
|
||
};
|
||
|
||
if (useRemote) {
|
||
// 构建失败的 product 对象:优先从原始远程数据取(保留 PriceNGN 等完整字段)
|
||
const failedProducts = {};
|
||
for (const item of productList) {
|
||
if (results.failure.includes(item.productId)) {
|
||
// 从原始数据里找到对应的完整 product(含 PriceNGN)
|
||
const originalProduct = _remoteGroupRaw ? _remoteGroupRaw[item.key] : null;
|
||
failedProducts[item.key] = originalProduct || {
|
||
ProductId: item.productId,
|
||
SkuId: item.skuId,
|
||
AvailabilityId: item.availabilityId
|
||
};
|
||
}
|
||
}
|
||
|
||
const logMsg = failureCount === 0
|
||
? "全部成功,提交 commit(弹出当前组)"
|
||
: `${failureCount} 个失败,提交 commit(只保留失败的 product,其余组不变)`;
|
||
log("info", logMsg);
|
||
|
||
// 提交结果到服务端,响应包含价格信息
|
||
$httpClient.post({
|
||
url: REMOTE_COMMIT_URL,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ remaining: failedProducts })
|
||
}, (commitErr, commitResp, commitData) => {
|
||
// 解析服务端返回的价格信息
|
||
let priceInfo = {};
|
||
try { priceInfo = JSON.parse(commitData || '{}'); } catch (_) {}
|
||
|
||
// 全部成功:groupNGN;部分失败:successNGN + failedNames
|
||
const totalNGN = priceInfo.groupNGN ?? priceInfo.successNGN ?? 0;
|
||
const ngnStr = totalNGN > 0 ? `${totalNGN.toFixed(2)} NGN` : '';
|
||
const failedNames = priceInfo.failedNames || [];
|
||
|
||
showResult(ngnStr, failedNames);
|
||
});
|
||
|
||
} else {
|
||
// 本地模式:清理已成功的 key
|
||
try {
|
||
let storeObj; try { storeObj = JSON.parse($persistentStore.read(LOCAL_KEY) || "{}"); } catch { storeObj = {}; }
|
||
for (const k of successKeys) {
|
||
if (k && Object.prototype.hasOwnProperty.call(storeObj, k)) delete storeObj[k];
|
||
}
|
||
const remainingCount = Object.keys(storeObj).filter(k => /^product\d+$/.test(k)).length;
|
||
$persistentStore.write(JSON.stringify(storeObj), LOCAL_KEY);
|
||
log("info", "本地清理完成", `剩余: ${remainingCount}`);
|
||
} catch (e) { log("error", "清理异常", String(e)); }
|
||
showResult();
|
||
}
|
||
}
|
||
|
||
function doneWithPage(title, message, type = "warn") {
|
||
const color = type === "error" ? "#e05050" : type === "warn" ? "#e8a838" : "#52b043";
|
||
const icon = type === "error" ? "❌" : type === "warn" ? "⚠️" : "✅";
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||
<title>Xbox Cart · 尼区</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@600;700&family=Noto+Sans+SC:wght@400;500&display=swap');
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:#0b0b0b;color:#ddd;font-family:'Noto Sans SC',sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 18px;-webkit-font-smoothing:antialiased}
|
||
.card{width:100%;max-width:420px;background:#141414;border:1px solid #222;border-top:3px solid ${color};border-radius:4px;padding:24px 20px 20px}
|
||
.icon{font-size:32px;margin-bottom:12px}
|
||
.title{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:700;color:#fff;letter-spacing:1px;margin-bottom:8px}
|
||
.msg{font-size:13px;color:#888;line-height:1.7}
|
||
.sub{margin-top:20px;font-size:11px;color:#2a2a2a;letter-spacing:1.5px;text-transform:uppercase}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="icon">${icon}</div>
|
||
<div class="title">${title}</div>
|
||
<div class="msg">${message}</div>
|
||
<div class="sub">Xbox Cart · 🇳🇬 尼区</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
$done({ response: { status: 200, headers: { "Content-Type": "text/html;charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache" }, body: html } });
|
||
}
|
||
|
||
function startTask() {
|
||
if (!MUID || !MS_CV) {
|
||
log("error", "缺少 MUID/CV");
|
||
$notification.post("❌ Xbox 脚本错误", "缺少必要参数", "请检查 MUID 或 MS_CV");
|
||
doneWithPage("缺少必要参数", "未找到 MUID 或 MS-CV,请确认已正确写入:<br><br><code>cart-x-authorization-muid</code><br><code>cart-ms-cv</code>", "error");
|
||
return;
|
||
}
|
||
if (productList.length === 0) {
|
||
log("info", "列表为空");
|
||
$notification.post("⚠️ Xbox 脚本", "无需执行", `来源: ${sourceLabel} | 列表为空`);
|
||
if (useRemote) {
|
||
// 远程为空也要提交 commit 释放锁
|
||
$httpClient.post({
|
||
url: REMOTE_COMMIT_URL,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ remaining: {} })
|
||
}, () => doneWithPage("暂无商品", `远程队列与本地列表均为空,无需加购。<br><br>来源: ${sourceLabel}`, "warn"));
|
||
} else {
|
||
doneWithPage("暂无商品", `远程队列与本地列表均为空,无需加购。<br><br>来源: ${sourceLabel}`, "warn");
|
||
}
|
||
return;
|
||
}
|
||
log("info", `开始任务 [${sourceLabel}]`, `数量: ${productList.length}`);
|
||
sendRequest();
|
||
}
|
||
|
||
function sendRequest() {
|
||
if (currentIndex >= productList.length) return finalizeAndClean();
|
||
const { key, productId, skuId, availabilityId } = productList[currentIndex];
|
||
const bodyObj = {
|
||
locale: LOCALE, market: MARKET,
|
||
catalogClientType: "storeWeb",
|
||
friendlyName: FRIENDLY_NAME,
|
||
riskSessionId: generateRiskSessionId(),
|
||
clientContext: CLIENT_CONTEXT,
|
||
itemsToAdd: { items: [{ productId, skuId, availabilityId, campaignId: "xboxcomct", quantity: 1 }] }
|
||
};
|
||
|
||
$httpClient.put({ url: API_URL, headers: HEADERS, body: JSON.stringify(bodyObj) }, (error, response, data) => {
|
||
const idStr = `${productId}`;
|
||
// 显示标签(序号+游戏名)和现价,取不到时回退到 bigId
|
||
const priceNGN = (_remoteGroupRaw && key && _remoteGroupRaw[key]?.PriceNGN)
|
||
? ` | NGN ${Number(_remoteGroupRaw[key].PriceNGN).toFixed(2)}`
|
||
: '';
|
||
const displayLabel = key ? `${key}${priceNGN}` : idStr;
|
||
const statusCode = getStatusCode(response);
|
||
if (error || statusCode !== 200) {
|
||
results.failure.push(idStr);
|
||
log("error", "失败", `${displayLabel} | HTTP ${statusCode || "ERR"} ${error ? String(error) : ""}`);
|
||
} else {
|
||
const verdict = validateAddResult(data != null ? data : (response && response.body), { productId, skuId, availabilityId });
|
||
if (verdict.ok) {
|
||
results.success.push(idStr);
|
||
if (key) successKeys.push(key);
|
||
log("success", "成功", `${displayLabel}${verdict.detail ? " | " + verdict.detail : ""}`);
|
||
} else {
|
||
results.failure.push(idStr);
|
||
log("error", "失败", `${displayLabel} | ${verdict.reason}`);
|
||
}
|
||
}
|
||
currentIndex++;
|
||
setTimeout(sendRequest, 50);
|
||
});
|
||
}
|
||
|
||
// ========================= 主流程 =========================
|
||
// 服务端有锁机制,客户端无需时间锁
|
||
$httpClient.get(REMOTE_READ_URL, (error, response, data) => {
|
||
let remoteGroup = null;
|
||
let groupIndex = null;
|
||
|
||
if (!error && data) {
|
||
try {
|
||
const payload = JSON.parse((data || '').trim() || '{}');
|
||
if (payload.ok && payload.currentGroup) {
|
||
const keys = Object.keys(payload.currentGroup);
|
||
if (keys.length > 0) {
|
||
remoteGroup = payload.currentGroup;
|
||
groupIndex = payload.currentGroupIndex;
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
if (remoteGroup) {
|
||
useRemote = true;
|
||
sourceLabel = `远程第 ${groupIndex} 组`;
|
||
log("info", "使用远程待同步 Product", `第 ${groupIndex} 组,共 ${Object.keys(remoteGroup).length} 个`);
|
||
productList = parseProductList(JSON.stringify(remoteGroup));
|
||
// ★ 保留原始数据,供构建 failedProducts 时还原完整字段(含 PriceNGN)
|
||
_remoteGroupRaw = remoteGroup;
|
||
startTask();
|
||
} else {
|
||
useRemote = false;
|
||
sourceLabel = "本地";
|
||
const localRaw = $persistentStore.read(LOCAL_KEY) || "{}";
|
||
productList = parseProductList(localRaw);
|
||
if (!error) {
|
||
log("info", "远程队列为空,使用本地 Product");
|
||
} else {
|
||
log("info", "远程连接失败,使用本地 Product", String(error));
|
||
}
|
||
startTask();
|
||
}
|
||
});
|