1276 lines
37 KiB
Plaintext
1276 lines
37 KiB
Plaintext
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, '>')
|
||
.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 =
|
||
`⏳ <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);
|
||
|
||
// ================= 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 =
|
||
`📦 <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 });
|
||
}
|
||
}
|
||
};
|