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, '>') .replace(/"/g, '"'); } 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 = `⏳ 抓取进行中\n\n` + `${bar}\n` + `${processed}/${total}\n\n` + `当前待处理队列: ${pendingCount} 个`; if (rate) { text += `\n汇率: 1 NGN ≈ ${Number(rate).toFixed(6)} CNY`; } 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 += '🎮 游戏比价及代购信息\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 += `
${escapeHTML(copyableText.trim())}`;
} else {
page1 += '📭 目前还没有成功解析的游戏。';
}
page2 += '📦 Surge 分组队列\n\n';
const groups = Array.isArray(surgeQueue?.groups) ? surgeQueue.groups : [];
if (groups.length > 0) {
page2 += `当前剩余 ${groups.length} 组待同步\n\n`;
groups.forEach((group, idx) => {
page2 += `【 第 ${group.groupIndex} 组 】共 ${group.count} 个商品\n`;
page2 += `${escapeHTML(JSON.stringify(group.products))}\n\n`;
if (idx === 0) {
firstGroupJsonObj = group.products;
} else {
hasMultipleSurgeGroups = true;
}
});
} else {
page2 += '📭 当前没有待同步的 Surge 数据。';
}
page3 += '🔗 已解析链接记录\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 =
`✅ 本批执行完成\n\n` +
`成功: ${batchSuccess} 个\n` +
`失败: ${batchFail} 个(已保留在队列中)\n\n` +
`累计解析: ${state.items.length} 个\n` +
`队列剩余: ${pendingAfter.length} 个`;
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 =
`❌ 任务终止\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 =
`✅ 本轮执行完成\n\n` +
`${bar}\n` +
`${processed}/${total}\n\n` +
`当前待处理: ${pendingQueue.length} 个\n` +
`累计成功解析: ${state.items.length} 个`;
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,
`本批处理中: ${targets.length} 个`
)
);
} 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,
`本批完成: ${targets.length} 个\n成功: ${batchSuccess} 个\n失败: ${batchFail} 个`
)
);
} 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,
`❌ 执行异常\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);
// ================= GET:syncxbox.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 =
`📦 当前待同步的 Surge 分组队列\n\n` +
`剩余组数: ${queue.groups.length}\n` +
`当前组编号: ${current.groupIndex}\n` +
`当前组商品数: ${current.count}\n\n` +
`${escapeHTML(JSON.stringify(current.products))}`;
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 = `📋 当前待处理链接(共 ${urls.length} 个):\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,
'🗑️ 已彻底清空待处理队列、成功解析记录、已解析链接记录、分页缓存与云端 Product 数据。'
);
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 =
`⏳ 准备开始抓取\n\n` +
`本次处理: ${runTotal} 个\n` +
`队列剩余总数: ${pendingQueue.length} 个\n\n` +
`${makeProgressBar(0, runTotal)}\n0/${runTotal}`;
// ★ 如果来自回调按钮,直接编辑那条消息;否则发新消息
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,
`✅ 成功记录 ${reallyNewUrls.length} 个新链接。\n\n当前待处理队列共 ${mergedQueue.length} 个。`,
inlineKeyboard
);
} else {
await sendTelegramMessage(
chatId,
`ℹ️ 这些链接已经在待处理队列中,或之前已成功解析过。\n\n当前待处理队列共 ${mergedQueue.length} 个。`,
inlineKeyboard
);
}
}
return new Response('OK', { status: 200 });
} catch (_) {
return new Response('Error', { status: 200 });
}
}
};