Files
Cloudflare-worker/ngaccountant
2026-04-02 21:17:11 +08:00

1276 lines
37 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const BOT_TOKEN = 'xxxxxxxx';
const ALLOWED_USER_ID = xxxxxxxx;
const VIEW_TOKEN = 'xbox123';
const MAX_TELEGRAM_TEXT = 4000;
const FETCH_TIMEOUT_MS = 8000;
const EXCHANGE_TIMEOUT_MS = 6000;
const MINI_CONCURRENCY = 5; // 每次 /run 只处理前 5 个
const RUN_LOCK_TTL = 1800;
const BATCH_TIMEOUT_MS = 15000;
// ========================= 工具函数 =========================
function safeTelegramText(text) {
let s = String(text ?? '');
if (s.length > MAX_TELEGRAM_TEXT) {
s = s.substring(0, MAX_TELEGRAM_TEXT) + '\n\n... (内容过长已截断)';
}
return s;
}
function escapeHTML(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function makeProgressBar(done, total, length = 10) {
if (total <= 0) return '□□□□□□□□□□';
const ratio = Math.max(0, Math.min(1, done / total));
const filled = Math.round(ratio * length);
return '■'.repeat(filled) + '□'.repeat(length - filled);
}
function getPaginationKeyboard(currentPage) {
return {
inline_keyboard: [
[
{ text: currentPage === 'page_1' ? '✅ 报价' : '🎮 报价', callback_data: 'page_1' },
{ text: currentPage === 'page_2' ? '✅ Surge' : '📦 Surge', callback_data: 'page_2' },
{ text: currentPage === 'page_3' ? '✅ 链接' : '🔗 链接', callback_data: 'page_3' }
]
]
};
}
async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...options,
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
async function answerCallbackQuery(callbackQueryId, text = null) {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/answerCallbackQuery`;
const body = { callback_query_id: callbackQueryId };
if (text) {
body.text = text;
body.show_alert = true;
}
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
try {
resp.body?.cancel();
} catch (_) {}
}
async function sendTelegramMessage(chatId, text, replyMarkup = null) {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
const payloadObj = {
chat_id: chatId,
text: safeTelegramText(text),
parse_mode: 'HTML',
disable_web_page_preview: true
};
if (replyMarkup) {
payloadObj.reply_markup = replyMarkup;
}
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadObj)
});
try {
return await resp.json();
} catch {
return null;
}
}
async function editTelegramMessage(chatId, messageId, text, replyMarkup = null) {
const url = `https://api.telegram.org/bot${BOT_TOKEN}/editMessageText`;
const payloadObj = {
chat_id: chatId,
message_id: messageId,
text: safeTelegramText(text),
parse_mode: 'HTML',
disable_web_page_preview: true
};
if (replyMarkup) {
payloadObj.reply_markup = replyMarkup;
}
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadObj)
});
try {
resp.body?.cancel();
} catch (_) {}
}
function calculateProxyPrice(cnyPrices) {
const totalCNY = cnyPrices.reduce((acc, val) => acc + parseFloat(val), 0);
let proxyPrice = 0;
let expressionStr = '';
const baseStr = cnyPrices.join('+');
const multi = cnyPrices.length > 1;
if (totalCNY < 40) {
proxyPrice = totalCNY + 12;
expressionStr = `${baseStr}+12=${proxyPrice.toFixed(2)}`;
} else if (totalCNY < 65) {
proxyPrice = totalCNY * 1.28;
expressionStr = `${multi ? '(' + baseStr + ')' : baseStr}*1.28=${proxyPrice.toFixed(2)}`;
} else {
proxyPrice = totalCNY * 1.27;
expressionStr = `${multi ? '(' + baseStr + ')' : baseStr}*1.27=${proxyPrice.toFixed(2)}`;
}
return { totalCNY, proxyPrice, expressionStr };
}
function buildProgressText(total, processed, pendingCount, rate, extra = '') {
const bar = makeProgressBar(processed, total, 10);
let text =
`⏳ <b>抓取进行中</b>\n\n` +
`${bar}\n` +
`<b>${processed}/${total}</b>\n\n` +
`当前待处理队列: <b>${pendingCount}</b> 个`;
if (rate) {
text += `\n汇率: <b>1 NGN ≈ ${Number(rate).toFixed(6)} CNY</b>`;
}
if (extra) {
text += `\n\n${extra}`;
}
return text;
}
// ================== 实时汇率获取 ==================
async function getRealTimeExchangeRate() {
const apis = [
{
name: 'Open Exchange Rates',
url: 'https://open.er-api.com/v6/latest/NGN',
parse: (data) => data?.rates?.CNY
},
{
name: 'Fawaz Ahmed API',
url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/ngn.json',
parse: (data) => data?.ngn?.cny
},
{
name: 'ExchangeRate-API',
url: 'https://api.exchangerate-api.com/v4/latest/NGN',
parse: (data) => data?.rates?.CNY
}
];
for (const api of apis) {
try {
const response = await fetchWithTimeout(api.url, {}, EXCHANGE_TIMEOUT_MS);
if (!response.ok) continue;
const data = await response.json();
const rate = api.parse(data);
if (rate && typeof rate === 'number') {
return rate;
}
} catch (_) {}
}
throw new Error('所有备用实时汇率 API 均请求失败,请稍后再试。');
}
// ========================= KV Key =========================
function getQueueKey(userId) {
return `xbox_urls_${userId}`;
}
function getRunLockKey(userId) {
return `RUNNING_${userId}`;
}
function getRunMetaKey(userId) {
return `RUN_META_${userId}`;
}
function getStateKey(userId) {
return `SUCCESS_STATE_${userId}`;
}
function getPagesKey(userId) {
return `LATEST_RESULT_${userId}`;
}
function getSurgeQueueKey(userId) {
return `SURGE_GROUP_QUEUE_${userId}`;
}
function getResolvedLinksKey(userId) {
return `RESOLVED_LINKS_${userId}`;
}
// ========================= 状态读写 =========================
async function loadPendingQueue(env, userId) {
const raw = await env.LINKS_KV.get(getQueueKey(userId));
return raw ? JSON.parse(raw) : [];
}
async function savePendingQueue(env, userId, queue) {
const uniqueQueue = [...new Set(queue)];
if (uniqueQueue.length > 0) {
await env.LINKS_KV.put(getQueueKey(userId), JSON.stringify(uniqueQueue));
} else {
await env.LINKS_KV.delete(getQueueKey(userId));
}
}
async function loadSuccessState(env, userId) {
const raw = await env.LINKS_KV.get(getStateKey(userId));
if (!raw) {
return {
items: [],
lastRate: null,
updatedAt: null
};
}
try {
const parsed = JSON.parse(raw);
return {
items: Array.isArray(parsed.items) ? parsed.items : [],
lastRate: parsed.lastRate ?? null,
updatedAt: parsed.updatedAt ?? null
};
} catch {
return {
items: [],
lastRate: null,
updatedAt: null
};
}
}
async function saveSuccessState(env, userId, state) {
await env.LINKS_KV.put(
getStateKey(userId),
JSON.stringify({
items: state.items || [],
lastRate: state.lastRate ?? null,
updatedAt: new Date().toISOString()
})
);
}
async function loadResolvedLinks(env, userId) {
const raw = await env.LINKS_KV.get(getResolvedLinksKey(userId));
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function saveResolvedLinks(env, userId, links) {
const safeLinks = Array.isArray(links) ? links : [];
await env.LINKS_KV.put(getResolvedLinksKey(userId), JSON.stringify(safeLinks));
}
async function appendResolvedLink(env, userId, linkInfo) {
const links = await loadResolvedLinks(env, userId);
const exists = links.some(x => x.sourceUrl === linkInfo.sourceUrl);
if (!exists) {
links.push({
sourceUrl: linkInfo.sourceUrl,
resolvedUrl: linkInfo.resolvedUrl,
bigId: linkInfo.bigId,
gameName: linkInfo.gameName,
createdAt: new Date().toISOString()
});
await saveResolvedLinks(env, userId, links);
}
}
async function loadRunMeta(env, userId) {
const raw = await env.LINKS_KV.get(getRunMetaKey(userId));
return raw ? JSON.parse(raw) : null;
}
async function saveRunMeta(env, userId, meta) {
await env.LINKS_KV.put(getRunMetaKey(userId), JSON.stringify(meta), {
expirationTtl: RUN_LOCK_TTL
});
}
async function clearRunMeta(env, userId) {
await env.LINKS_KV.delete(getRunMetaKey(userId));
}
async function isRunActive(env, userId, runId) {
const meta = await loadRunMeta(env, userId);
return !!(meta && meta.runId === runId);
}
async function forceStopRunInternal(env, userId, runId) {
if (!(await isRunActive(env, userId, runId))) {
return false;
}
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
return true;
}
// ========================= Surge 分组队列 =========================
async function loadSurgeQueue(env, userId) {
const raw = await env.LINKS_KV.get(getSurgeQueueKey(userId));
if (!raw) {
return {
nextGroupIndex: 1,
groups: []
};
}
try {
const parsed = JSON.parse(raw);
return {
nextGroupIndex: typeof parsed.nextGroupIndex === 'number' ? parsed.nextGroupIndex : 1,
groups: Array.isArray(parsed.groups) ? parsed.groups : []
};
} catch {
return {
nextGroupIndex: 1,
groups: []
};
}
}
async function saveSurgeQueue(env, userId, queue) {
const safeQueue = {
nextGroupIndex: typeof queue.nextGroupIndex === 'number' ? queue.nextGroupIndex : 1,
groups: Array.isArray(queue.groups) ? queue.groups : []
};
await env.LINKS_KV.put(getSurgeQueueKey(userId), JSON.stringify(safeQueue));
if (safeQueue.groups.length > 0) {
await env.LINKS_KV.put('LATEST_XBOX_LIST', JSON.stringify(safeQueue.groups[0].products));
} else {
await env.LINKS_KV.delete('LATEST_XBOX_LIST');
}
}
async function appendProductToSurgeQueue(env, userId, item) {
const queue = await loadSurgeQueue(env, userId);
const productPayload = {
ProductId: item.bigId,
SkuId: item.targetSkuId,
AvailabilityId: item.targetAvailabilityId
};
let lastGroup = queue.groups[queue.groups.length - 1];
if (!lastGroup || lastGroup.count >= 15) {
lastGroup = {
groupIndex: queue.nextGroupIndex,
count: 0,
products: {}
};
queue.groups.push(lastGroup);
queue.nextGroupIndex += 1;
}
lastGroup.count += 1;
lastGroup.products[`product${lastGroup.count}`] = productPayload;
await saveSurgeQueue(env, userId, queue);
}
async function popFirstSurgeGroup(env, userId) {
const queue = await loadSurgeQueue(env, userId);
if (!queue.groups.length) {
return {
ok: true,
cleared: false,
remainingGroups: 0,
nextGroupIndex: null,
nextGroupCount: 0
};
}
const removed = queue.groups.shift();
await saveSurgeQueue(env, userId, queue);
return {
ok: true,
cleared: true,
clearedGroupIndex: removed.groupIndex,
clearedGroupCount: removed.count,
remainingGroups: queue.groups.length,
nextGroupIndex: queue.groups[0]?.groupIndex ?? null,
nextGroupCount: queue.groups[0]?.count ?? 0
};
}
// ========================= 页面渲染 =========================
function buildPagesFromState(state, resolvedLinks, surgeQueue) {
const items = Array.isArray(state.items) ? state.items : [];
const rate = state.lastRate;
let page1 = '';
let page2 = '';
let page3 = '';
let firstGroupJsonObj = {};
let hasMultipleSurgeGroups = false;
if (items.length > 0) {
page1 += '<b>🎮 游戏比价及代购信息</b>\n\n';
if (rate) {
page1 += `💱 最近一次汇率: 1 NGN ≈ ${Number(rate).toFixed(6)} CNY\n\n`;
}
let copyableText = '';
let totalCurrentPriceNGN = 0;
items.forEach((info, idx) => {
const displayIndex = idx + 1;
copyableText += `游戏(${displayIndex}\n`;
copyableText += `名称: ${info.gameName}\n`;
copyableText += `原价: ${info.originalPriceStr}`;
if (info.originalPriceCNY !== 'N/A') copyableText += ` (¥${info.originalPriceCNY})`;
copyableText += '\n';
copyableText += `现价: ${info.currentPriceStr}`;
if (info.currentPriceCNY !== 'N/A') copyableText += ` (¥${info.currentPriceCNY})`;
copyableText += '\n';
copyableText += '----------------------------------------\n';
if (typeof info.currentPriceNum === 'number') {
totalCurrentPriceNGN += info.currentPriceNum;
}
});
const cnyPrices = items.map(info => info.currentPriceCNY).filter(p => p !== 'N/A');
if (cnyPrices.length > 0) {
const calc = calculateProxyPrice(cnyPrices);
copyableText += `📊 游戏总价: NGN ${totalCurrentPriceNGN.toFixed(2)}\n`;
copyableText += `🛍️ 代购总价: ${calc.expressionStr}`;
} else {
copyableText += `📊 游戏总价: NGN ${totalCurrentPriceNGN.toFixed(2)}\n`;
copyableText += '🛍️ 代购总价: ¥0.00';
}
page1 += `<pre>${escapeHTML(copyableText.trim())}</pre>`;
} else {
page1 += '📭 目前还没有成功解析的游戏。';
}
page2 += '<b>📦 Surge 分组队列</b>\n\n';
const groups = Array.isArray(surgeQueue?.groups) ? surgeQueue.groups : [];
if (groups.length > 0) {
page2 += `当前剩余 <b>${groups.length}</b> 组待同步\n\n`;
groups.forEach((group, idx) => {
page2 += `<b>【 第 ${group.groupIndex} 组 】</b>共 ${group.count} 个商品\n`;
page2 += `<pre>${escapeHTML(JSON.stringify(group.products))}</pre>\n\n`;
if (idx === 0) {
firstGroupJsonObj = group.products;
} else {
hasMultipleSurgeGroups = true;
}
});
} else {
page2 += '📭 当前没有待同步的 Surge 数据。';
}
page3 += '<b>🔗 已解析链接记录</b>\n\n';
if (resolvedLinks.length > 0) {
resolvedLinks.forEach((item, idx) => {
page3 += `游戏(${idx + 1}\n`;
page3 += `名称: ${item.gameName || 'Unknown Game'}\n`;
page3 += `bigId: ${item.bigId || 'N/A'}\n`;
page3 += `解析链接: ${escapeHTML(item.resolvedUrl || '')}\n`;
page3 += '----------------------------------------\n';
});
} else {
page3 += '📭 当前还没有已解析的链接记录。';
}
return {
page1,
page2,
page3,
firstGroupJsonObj,
hasMultipleSurgeGroups
};
}
async function persistRenderedPages(env, userId) {
const state = await loadSuccessState(env, userId);
const surgeQueue = await loadSurgeQueue(env, userId);
const resolvedLinks = await loadResolvedLinks(env, userId);
const pages = buildPagesFromState(state, resolvedLinks, surgeQueue);
await env.LINKS_KV.put(getPagesKey(userId), JSON.stringify({
page_1: pages.page1,
page_2: pages.page2,
page_3: pages.page3
}));
return pages;
}
async function showQuotePage(env, chatId, userId, messageId = null) {
const pages = await persistRenderedPages(env, userId);
if (messageId) {
try {
await editTelegramMessage(
chatId,
messageId,
pages.page1,
getPaginationKeyboard('page_1')
);
return;
} catch (_) {}
}
await sendTelegramMessage(
chatId,
pages.page1,
getPaginationKeyboard('page_1')
);
}
// ========================= 单链接解析 =========================
async function processSingleXboxLink(startUrl, currentRate) {
const urlObj = new URL(startUrl);
urlObj.searchParams.set('r', 'en-us');
const safeFetchUrl = urlObj.toString();
const redirectResponse = await fetchWithTimeout(
safeFetchUrl,
{ redirect: 'follow' },
FETCH_TIMEOUT_MS
);
const finalUrl = redirectResponse.url;
try {
redirectResponse.body?.cancel();
} catch (_) {}
const idMatch = finalUrl.match(/\/([a-zA-Z0-9]{12})(?:[\/?#]|$)/);
if (!idMatch) {
throw new Error('无法提取 bigId');
}
const bigId = idMatch[1];
const apiUrl = `https://displaycatalog.mp.microsoft.com/v7.0/products?bigIds=${bigId}&market=NG&languages=en-ng&MS-CV=DUMMY.1`;
const apiResponse = await fetchWithTimeout(apiUrl, {}, FETCH_TIMEOUT_MS);
if (!apiResponse.ok) {
throw new Error(`微软接口请求失败: ${apiResponse.status}`);
}
const data = await apiResponse.json();
if (!data.Products || data.Products.length === 0) {
throw new Error('Products 为空');
}
const product = data.Products[0];
const gameName = product.LocalizedProperties?.[0]?.ProductTitle || 'Unknown Game';
let targetSkuId = '';
let targetAvailabilityId = '';
let originalPriceStr = 'N/A';
let currentPriceStr = 'N/A';
let originalPriceNum = null;
let currentPriceNum = null;
for (const skuObj of product.DisplaySkuAvailabilities || []) {
if (skuObj.Sku && skuObj.Sku.SkuType === 'full') {
for (const avail of skuObj.Availabilities || []) {
if (avail.Actions && avail.Actions.includes('Purchase')) {
targetSkuId = skuObj.Sku.SkuId;
targetAvailabilityId = avail.AvailabilityId;
if (avail.OrderManagementData?.Price) {
const priceData = avail.OrderManagementData.Price;
const currency = priceData.CurrencyCode;
originalPriceNum = priceData.MSRP;
currentPriceNum = priceData.ListPrice;
originalPriceStr = `${currency} ${originalPriceNum}`;
currentPriceStr = `${currency} ${currentPriceNum}`;
}
break;
}
}
}
if (targetAvailabilityId) break;
}
if (!targetAvailabilityId) {
throw new Error('没有找到可购买的 SKU / Availability');
}
return {
sourceUrl: startUrl,
resolvedUrl: finalUrl,
bigId,
gameName,
targetSkuId,
targetAvailabilityId,
originalPriceStr,
currentPriceStr,
originalPriceNum,
currentPriceNum,
originalPriceCNY: originalPriceNum !== null ? (originalPriceNum * currentRate).toFixed(2) : 'N/A',
currentPriceCNY: currentPriceNum !== null ? (currentPriceNum * currentRate).toFixed(2) : 'N/A'
};
}
// ========================= 运行主逻辑 =========================
// ★ 新增:构建"继续"按钮键盘
function getContinueKeyboard() {
return {
inline_keyboard: [
[{ text: '▶️ 继续抓取', callback_data: 'action_run' }]
]
};
}
// ★ 修改finalizeRun 增加 batchSuccess / batchFail 参数
async function finalizeRun(
env, chatId, userId, runId, progressMessageId,
total, processed, reason = 'done',
batchSuccess = 0, batchFail = 0
) {
if (!(await isRunActive(env, userId, runId))) {
return;
}
// ★ 核心改动:'done' 分支根据剩余队列决定显示内容
if (reason === 'done') {
const pendingAfter = await loadPendingQueue(env, userId);
const state = await loadSuccessState(env, userId);
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
if (pendingAfter.length > 0) {
// 队列还有内容 → 只显示本批摘要 + 继续按钮
const summaryText =
`✅ <b>本批执行完成</b>\n\n` +
`成功: <b>${batchSuccess}</b> 个\n` +
`失败: <b>${batchFail}</b> 个(已保留在队列中)\n\n` +
`累计解析: <b>${state.items.length}</b> 个\n` +
`队列剩余: <b>${pendingAfter.length}</b> 个`;
if (progressMessageId) {
try {
await editTelegramMessage(chatId, progressMessageId, summaryText, getContinueKeyboard());
return;
} catch (_) {}
}
await sendTelegramMessage(chatId, summaryText, getContinueKeyboard());
} else {
// 队列已清空 → 显示完整报价/Surge/链接页
await showQuotePage(env, chatId, userId, progressMessageId);
}
return;
}
if (reason === 'error_rate') {
const finalText =
`❌ <b>任务终止</b>\n\n` +
`获取汇率失败,已停止执行。\n` +
`当前待处理仍保留在队列中。`;
if (progressMessageId) {
try {
await editTelegramMessage(chatId, progressMessageId, finalText, getPaginationKeyboard('page_1'));
} catch (_) {
await sendTelegramMessage(chatId, finalText, getPaginationKeyboard('page_1'));
}
} else {
await sendTelegramMessage(chatId, finalText, getPaginationKeyboard('page_1'));
}
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
return;
}
if (reason === 'force_stopped') {
await showQuotePage(env, chatId, userId, progressMessageId);
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
return;
}
// 兜底(理论上不会走到这里)
const pendingQueue = await loadPendingQueue(env, userId);
const state = await loadSuccessState(env, userId);
const bar = makeProgressBar(processed, total, 10);
const fallbackText =
`✅ <b>本轮执行完成</b>\n\n` +
`${bar}\n` +
`<b>${processed}/${total}</b>\n\n` +
`当前待处理: <b>${pendingQueue.length}</b> 个\n` +
`累计成功解析: <b>${state.items.length}</b> 个`;
if (progressMessageId) {
try {
await editTelegramMessage(chatId, progressMessageId, fallbackText, getPaginationKeyboard('page_1'));
} catch (_) {
await sendTelegramMessage(chatId, fallbackText, getPaginationKeyboard('page_1'));
}
} else {
await sendTelegramMessage(chatId, fallbackText, getPaginationKeyboard('page_1'));
}
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
}
async function runQueueTask(env, chatId, userId, runId, progressMessageId, targets) {
const total = targets.length;
let processed = 0;
try {
const rate = await getRealTimeExchangeRate();
if (!(await isRunActive(env, userId, runId))) {
return;
}
const state = await loadSuccessState(env, userId);
state.lastRate = rate;
await saveSuccessState(env, userId, state);
await persistRenderedPages(env, userId);
const pendingBefore = await loadPendingQueue(env, userId);
if (progressMessageId) {
try {
await editTelegramMessage(
chatId,
progressMessageId,
buildProgressText(
total,
0,
pendingBefore.length,
rate,
`本批处理中: <b>${targets.length}</b> 个`
)
);
} catch (_) {}
}
const batchPromise = Promise.allSettled(
targets.map(url => processSingleXboxLink(url, rate))
);
const raceResult = await Promise.race([
batchPromise,
new Promise(resolve => setTimeout(() => resolve('__BATCH_TIMEOUT__'), BATCH_TIMEOUT_MS))
]);
if (raceResult === '__BATCH_TIMEOUT__') {
const stopped = await forceStopRunInternal(env, userId, runId);
if (stopped) {
await showQuotePage(env, chatId, userId, progressMessageId);
}
return;
}
const results = raceResult;
if (!(await isRunActive(env, userId, runId))) {
return;
}
let batchSuccess = 0;
let batchFail = 0;
for (let i = 0; i < targets.length; i++) {
if (!(await isRunActive(env, userId, runId))) {
return;
}
const sourceUrl = targets[i];
const result = results[i];
if (result.status === 'fulfilled') {
const item = result.value;
const freshState = await loadSuccessState(env, userId);
const exists = freshState.items.some(x => x.sourceUrl === sourceUrl);
if (!exists) {
freshState.items.push(item);
freshState.lastRate = rate;
await saveSuccessState(env, userId, freshState);
if (!(await isRunActive(env, userId, runId))) {
return;
}
await appendProductToSurgeQueue(env, userId, item);
await appendResolvedLink(env, userId, {
sourceUrl: item.sourceUrl,
resolvedUrl: item.resolvedUrl,
bigId: item.bigId,
gameName: item.gameName
});
}
if (!(await isRunActive(env, userId, runId))) {
return;
}
// 成功:从待处理队列中移除
const latestPending = await loadPendingQueue(env, userId);
const newPending = latestPending.filter(x => x !== sourceUrl);
await savePendingQueue(env, userId, newPending);
batchSuccess++;
} else {
// ★ 失败URL 保留在待处理队列中(未移除),计入失败数
batchFail++;
}
}
if (!(await isRunActive(env, userId, runId))) {
return;
}
processed = targets.length;
const latestMeta = await loadRunMeta(env, userId);
if (latestMeta && latestMeta.runId === runId) {
latestMeta.processed = processed;
await saveRunMeta(env, userId, latestMeta);
}
await persistRenderedPages(env, userId);
const pendingAfter = await loadPendingQueue(env, userId);
if (progressMessageId) {
try {
await editTelegramMessage(
chatId,
progressMessageId,
buildProgressText(
total,
processed,
pendingAfter.length,
rate,
`本批完成: <b>${targets.length}</b> 个\n成功: <b>${batchSuccess}</b> 个\n失败: <b>${batchFail}</b> 个`
)
);
} catch (_) {}
}
// ★ 将 batchSuccess / batchFail 传入 finalizeRun
await finalizeRun(
env, chatId, userId, runId, progressMessageId,
total, processed, 'done',
batchSuccess, batchFail
);
} catch (error) {
if (String(error?.message || '').includes('汇率')) {
await finalizeRun(env, chatId, userId, runId, progressMessageId, total, processed, 'error_rate');
return;
}
if (await isRunActive(env, userId, runId)) {
await sendTelegramMessage(
chatId,
`❌ <b>执行异常</b>\n\n${escapeHTML(error?.message || '未知错误')}\n\n已完成的数据不会丢失。`
);
await env.LINKS_KV.delete(getRunLockKey(userId));
await clearRunMeta(env, userId);
}
}
}
export default {
async fetch(request, env, ctx) {
const requestUrl = new URL(request.url);
// ================= GETsyncxbox.com / Surge 前端读取当前组 =================
if (request.method === 'GET') {
if (requestUrl.searchParams.get('token') === VIEW_TOKEN) {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
};
if (requestUrl.searchParams.get('action') === 'clear') {
const result = await popFirstSurgeGroup(env, ALLOWED_USER_ID);
return new Response(JSON.stringify(result), {
status: 200,
headers
});
}
const queue = await loadSurgeQueue(env, ALLOWED_USER_ID);
if (!queue.groups.length) {
return new Response(JSON.stringify({
ok: true,
currentGroupIndex: null,
currentGroupCount: 0,
remainingGroups: 0,
remainingAfterCurrent: 0,
currentGroup: {}
}), {
status: 200,
headers
});
}
const current = queue.groups[0];
return new Response(JSON.stringify({
ok: true,
currentGroupIndex: current.groupIndex,
currentGroupCount: current.count,
remainingGroups: queue.groups.length,
remainingAfterCurrent: Math.max(queue.groups.length - 1, 0),
currentGroup: current.products
}), {
status: 200,
headers
});
}
return new Response('Xbox Bot is running.', { status: 200 });
}
// ================= Telegram webhook =================
try {
const payload = await request.json();
let chatId, userId, text;
let cbId = null;
let callbackMessageId = null;
if (payload.callback_query) {
chatId = payload.callback_query.message.chat.id;
userId = payload.callback_query.from.id;
text = payload.callback_query.data;
cbId = payload.callback_query.id;
callbackMessageId = payload.callback_query.message.message_id;
if (text.startsWith('page_')) {
const storedResult = await env.LINKS_KV.get(getPagesKey(userId));
if (storedResult) {
const pagesData = JSON.parse(storedResult);
if (pagesData[text]) {
try {
await editTelegramMessage(
chatId,
callbackMessageId,
pagesData[text],
getPaginationKeyboard(text)
);
await answerCallbackQuery(cbId);
} catch (_) {
await answerCallbackQuery(cbId);
}
} else {
await answerCallbackQuery(cbId, '⚠️ 页面不存在');
}
} else {
await answerCallbackQuery(cbId, '⚠️ 页面数据为空');
}
return new Response('OK', { status: 200 });
}
if (text === 'action_run') {
await answerCallbackQuery(cbId);
// ★ action_run 回调直接复用 /run 逻辑text 已经是 'action_run',下面统一处理
}
} else if (payload.message && payload.message.text) {
chatId = payload.message.chat.id;
userId = payload.message.from.id;
text = payload.message.text.trim();
} else {
return new Response('OK', { status: 200 });
}
if (userId !== ALLOWED_USER_ID) {
return new Response('Unauthorized', { status: 200 });
}
const queueKey = getQueueKey(userId);
const lockKey = getRunLockKey(userId);
const stateKey = getStateKey(userId);
const pagesKey = getPagesKey(userId);
const runMetaKey = getRunMetaKey(userId);
const surgeQueueKey = getSurgeQueueKey(userId);
const resolvedLinksKey = getResolvedLinksKey(userId);
if (text.toLowerCase() === '/view_product') {
const queue = await loadSurgeQueue(env, userId);
if (!queue.groups.length) {
await sendTelegramMessage(chatId, '📭 当前没有待同步的 Surge 分组数据。');
} else {
const current = queue.groups[0];
let msg =
`📦 <b>当前待同步的 Surge 分组队列</b>\n\n` +
`剩余组数: <b>${queue.groups.length}</b>\n` +
`当前组编号: <b>${current.groupIndex}</b>\n` +
`当前组商品数: <b>${current.count}</b>\n\n` +
`<pre>${escapeHTML(JSON.stringify(current.products))}</pre>`;
await sendTelegramMessage(chatId, msg);
}
return new Response('OK', { status: 200 });
}
if (text.toLowerCase().startsWith('/update_product')) {
const jsonStr = text.substring('/update_product'.length).trim();
if (!jsonStr) {
await sendTelegramMessage(chatId, '⚠️ 请在命令后附带合法的 JSON 字符串。');
return new Response('OK', { status: 200 });
}
try {
const parsedData = JSON.parse(jsonStr);
const keys = Object.keys(parsedData || {});
const queue = {
nextGroupIndex: 2,
groups: keys.length > 0 ? [{
groupIndex: 1,
count: keys.length,
products: parsedData
}] : []
};
await saveSurgeQueue(env, userId, queue);
await persistRenderedPages(env, userId);
await sendTelegramMessage(chatId, '✅ 成功覆盖更新 Surge 分组数据!');
} catch (e) {
await sendTelegramMessage(chatId, `❌ JSON 格式错误:\n${escapeHTML(e.message)}`);
}
return new Response('OK', { status: 200 });
}
if (text.toLowerCase() === '/view_list') {
const urls = await loadPendingQueue(env, userId);
if (urls.length === 0) {
await sendTelegramMessage(chatId, '📭 当前待处理链接队列为空。');
} else {
let listText = `📋 <b>当前待处理链接(共 ${urls.length} 个):</b>\n\n`;
urls.forEach((url, index) => {
listText += `${index + 1}. ${escapeHTML(url)}\n`;
});
await sendTelegramMessage(chatId, listText);
}
return new Response('OK', { status: 200 });
}
if (text.toLowerCase() === '/force_stop') {
const meta = await loadRunMeta(env, userId);
if (!meta) {
await sendTelegramMessage(chatId, ' 当前没有正在执行的任务。');
return new Response('OK', { status: 200 });
}
await env.LINKS_KV.delete(lockKey);
await clearRunMeta(env, userId);
await showQuotePage(env, chatId, userId, meta.progressMessageId);
return new Response('OK', { status: 200 });
}
if (text.toLowerCase() === '/clear') {
await env.LINKS_KV.delete(queueKey);
await env.LINKS_KV.delete(stateKey);
await env.LINKS_KV.delete(pagesKey);
await env.LINKS_KV.delete(runMetaKey);
await env.LINKS_KV.delete(surgeQueueKey);
await env.LINKS_KV.delete(resolvedLinksKey);
await env.LINKS_KV.delete('LATEST_XBOX_LIST');
await env.LINKS_KV.delete(lockKey);
await sendTelegramMessage(
chatId,
'🗑️ 已彻底清空<b>待处理队列</b>、<b>成功解析记录</b>、<b>已解析链接记录</b>、<b>分页缓存</b>与云端 <b>Product 数据</b>。'
);
return new Response('OK', { status: 200 });
}
// ★ /run 和 action_run 回调统一处理
if (text.toLowerCase() === '/run' || text === 'action_run') {
let isRunning = await env.LINKS_KV.get(lockKey);
if (isRunning) {
const meta = await loadRunMeta(env, userId);
if (!meta) {
await env.LINKS_KV.delete(lockKey);
isRunning = null;
}
}
if (isRunning) {
await sendTelegramMessage(chatId, '⏳ 当前已有任务正在执行中,请先 /force_stop。');
return new Response('OK', { status: 200 });
}
const pendingQueue = await loadPendingQueue(env, userId);
if (pendingQueue.length === 0) {
await sendTelegramMessage(chatId, '📭 链接队列为空,请先发送游戏链接。');
return new Response('OK', { status: 200 });
}
// 每次只处理前 5 个
const targets = pendingQueue.slice(0, MINI_CONCURRENCY);
const runTotal = targets.length;
const initText =
`⏳ <b>准备开始抓取</b>\n\n` +
`本次处理: <b>${runTotal}</b> 个\n` +
`队列剩余总数: <b>${pendingQueue.length}</b> 个\n\n` +
`${makeProgressBar(0, runTotal)}\n<b>0/${runTotal}</b>`;
// ★ 如果来自回调按钮,直接编辑那条消息;否则发新消息
let progressMessageId;
if (callbackMessageId) {
await editTelegramMessage(chatId, callbackMessageId, initText, null);
progressMessageId = callbackMessageId;
} else {
const startMsg = await sendTelegramMessage(chatId, initText);
progressMessageId = startMsg?.result?.message_id || null;
}
const runId = crypto.randomUUID();
await env.LINKS_KV.put(lockKey, runId, { expirationTtl: RUN_LOCK_TTL });
await saveRunMeta(env, userId, {
runId,
chatId,
progressMessageId,
total: runTotal,
processed: 0,
startedAt: new Date().toISOString()
});
ctx.waitUntil(runQueueTask(env, chatId, userId, runId, progressMessageId, targets));
return new Response('OK', { status: 200 });
}
const urlRegex = /(https?:\/\/[^\s]+)/g;
const newUrls = text.match(urlRegex);
if (newUrls && newUrls.length > 0) {
const pendingQueue = await loadPendingQueue(env, userId);
const state = await loadSuccessState(env, userId);
const resolvedLinks = await loadResolvedLinks(env, userId);
const successUrlSet = new Set((state.items || []).map(x => x.sourceUrl));
const resolvedUrlSet = new Set((resolvedLinks || []).map(x => x.sourceUrl));
const pendingSet = new Set(pendingQueue);
const reallyNewUrls = newUrls.filter(
url => !successUrlSet.has(url) && !resolvedUrlSet.has(url) && !pendingSet.has(url)
);
const mergedQueue = [...pendingQueue, ...reallyNewUrls];
await savePendingQueue(env, userId, mergedQueue);
await persistRenderedPages(env, userId);
const inlineKeyboard = {
inline_keyboard: [
[
{ text: '🚀 一键抓取执行', callback_data: 'action_run' }
]
]
};
if (reallyNewUrls.length > 0) {
await sendTelegramMessage(
chatId,
`✅ 成功记录 <b>${reallyNewUrls.length}</b> 个新链接。\n\n当前待处理队列共 <b>${mergedQueue.length}</b> 个。`,
inlineKeyboard
);
} else {
await sendTelegramMessage(
chatId,
` 这些链接已经在待处理队列中,或之前已成功解析过。\n\n当前待处理队列共 <b>${mergedQueue.length}</b> 个。`,
inlineKeyboard
);
}
}
return new Response('OK', { status: 200 });
} catch (_) {
return new Response('Error', { status: 200 });
}
}
};