diff --git a/functions/api/manage/list.js b/functions/api/manage/list.js index 2664889e..242876c1 100644 --- a/functions/api/manage/list.js +++ b/functions/api/manage/list.js @@ -102,7 +102,13 @@ export async function onRequest(context) { // 特殊操作:获取索引信息 if (action === 'info') { - const info = await getIndexInfo(context); + const info = await getIndexInfo(context, { + timezoneOffset: url.searchParams.get('timezoneOffset'), + maxPoints: url.searchParams.get('trendMaxPoints'), + seriesLimit: url.searchParams.get('trendSeriesLimit'), + startDate: url.searchParams.get('trendStartDate'), + endDate: url.searchParams.get('trendEndDate') + }); return new Response(JSON.stringify(info), { headers: { "Content-Type": "application/json", ...corsHeaders } }); @@ -274,4 +280,4 @@ async function getAllFileRecords(env, dir) { error: error.message }; } -} \ No newline at end of file +} diff --git a/functions/utils/indexManager.js b/functions/utils/indexManager.js index 08fa9564..1e7e85b5 100644 --- a/functions/utils/indexManager.js +++ b/functions/utils/indexManager.js @@ -861,8 +861,9 @@ export async function rebuildIndex(context, progressCallback = null) { /** * 获取索引信息 * @param {Object} context - 上下文对象 + * @param {Object} options - 统计选项 */ -export async function getIndexInfo(context) { +export async function getIndexInfo(context, options = {}) { try { const index = await getIndex(context); @@ -876,29 +877,31 @@ export async function getIndexInfo(context) { } // 统计各渠道文件数量 - const channelStats = {}; - const directoryStats = {}; - const typeStats = {}; + const channelStats = Object.create(null); + const directoryStats = Object.create(null); + const typeStats = Object.create(null); + const uploadTrend = createUploadTrendAccumulator(index.files, options); index.files.forEach(file => { + const metadata = file.metadata || {}; + // 渠道统计 - let channel = file.metadata.Channel || 'Telegraph'; - if (channel === 'TelegramNew') { - channel = 'Telegram'; - } - channelStats[channel] = (channelStats[channel] || 0) + 1; + const channel = normalizeChannel(metadata.Channel); + incrementStat(channelStats, channel); // 目录统计 - const dir = file.metadata.Directory || extractDirectory(file.id) || '/'; - directoryStats[dir] = (directoryStats[dir] || 0) + 1; + const dir = metadata.Directory || extractDirectory(file.id) || '/'; + incrementStat(directoryStats, dir); // 类型统计 - let listType = file.metadata.ListType || 'None'; - const label = file.metadata.Label || 'None'; + let listType = metadata.ListType || 'None'; + const label = metadata.Label || 'None'; if (listType !== 'White' && label === 'adult') { listType = 'Block'; } - typeStats[listType] = (typeStats[listType] || 0) + 1; + incrementStat(typeStats, listType); + + addUploadTrendPoint(uploadTrend, metadata, channel); }); return { @@ -908,6 +911,7 @@ export async function getIndexInfo(context) { channelStats, directoryStats, typeStats, + uploadTrend: finalizeUploadTrend(uploadTrend), oldestFile: index.files[index.files.length - 1], newestFile: index.files[0] }; @@ -917,6 +921,331 @@ export async function getIndexInfo(context) { } } +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_TREND_MAX_POINTS = 90; +const MAX_TREND_POINTS = 366; +const DEFAULT_TREND_SERIES_LIMIT = 8; +const MAX_TREND_SERIES_LIMIT = 20; + +function incrementStat(stats, key) { + const normalizedKey = normalizeTrendKey(key); + stats[normalizedKey] = (stats[normalizedKey] || 0) + 1; +} + +function normalizeChannel(channel) { + if (channel === 'TelegramNew') { + return 'Telegram'; + } + return normalizeTrendKey(channel, 'Telegraph'); +} + +function normalizeTrendKey(value, fallback = 'Unknown') { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || fallback; + } + if (value === null || value === undefined) { + return fallback; + } + return String(value); +} + +function normalizeInteger(value, defaultValue, min, max) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return defaultValue; + } + return Math.min(max, Math.max(min, parsed)); +} + +function getLocalDayNumber(timestamp, timezoneOffset) { + return Math.floor((timestamp - timezoneOffset * 60 * 1000) / DAY_MS); +} + +function getValidTimestamp(metadata) { + const timestamp = Number(metadata?.TimeStamp); + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null; +} + +function formatTrendDay(dayNumber) { + return new Date(dayNumber * DAY_MS).toISOString().slice(0, 10); +} + +function parseTrendDate(value) { + if (typeof value !== 'string') { + return null; + } + + const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + const date = new Date(Date.UTC(year, month - 1, day)); + + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) { + return null; + } + + return Math.floor(date.getTime() / DAY_MS); +} + +function getTrendDayRange(files, timezoneOffset, options = {}) { + let newestDay = null; + let oldestDay = null; + const optionStartDay = parseTrendDate(options.startDate); + const optionEndDay = parseTrendDate(options.endDate); + + // The index is maintained in timestamp-desc order, so this avoids sorting. + for (let i = 0; i < files.length; i++) { + const timestamp = getValidTimestamp(files[i].metadata); + if (timestamp !== null) { + newestDay = getLocalDayNumber(timestamp, timezoneOffset); + break; + } + } + + for (let i = files.length - 1; i >= 0; i--) { + const timestamp = getValidTimestamp(files[i].metadata); + if (timestamp !== null) { + oldestDay = getLocalDayNumber(timestamp, timezoneOffset); + break; + } + } + + if (optionStartDay !== null || optionEndDay !== null) { + const startDay = optionStartDay !== null ? optionStartDay : (oldestDay !== null ? oldestDay : optionEndDay); + const endDay = optionEndDay !== null ? optionEndDay : (newestDay !== null ? newestDay : optionStartDay); + return startDay <= endDay + ? { startDay, endDay } + : { startDay: endDay, endDay: startDay }; + } + + if (newestDay === null || oldestDay === null) { + return null; + } + + if (oldestDay > newestDay) { + return { startDay: newestDay, endDay: oldestDay }; + } + + return { startDay: oldestDay, endDay: newestDay }; +} + +function buildTrendBucketLabels(startDay, endDay, bucketSizeDays, bucketCount) { + const labels = []; + for (let i = 0; i < bucketCount; i++) { + const bucketStartDay = startDay + i * bucketSizeDays; + const bucketEndDay = Math.min(bucketStartDay + bucketSizeDays - 1, endDay); + labels.push(bucketStartDay === bucketEndDay + ? formatTrendDay(bucketStartDay) + : `${formatTrendDay(bucketStartDay)} - ${formatTrendDay(bucketEndDay)}`); + } + return labels; +} + +function createUploadTrendAccumulator(files, options) { + const timezoneOffset = normalizeInteger(options.timezoneOffset, 0, -14 * 60, 14 * 60); + const maxPoints = normalizeInteger(options.maxPoints, DEFAULT_TREND_MAX_POINTS, 7, MAX_TREND_POINTS); + const seriesLimit = normalizeInteger(options.seriesLimit, DEFAULT_TREND_SERIES_LIMIT, 1, MAX_TREND_SERIES_LIMIT); + const range = getTrendDayRange(files, timezoneOffset, options); + + if (!range) { + return { + enabled: false, + timezoneOffset, + maxPoints, + seriesLimit + }; + } + + const spanDays = range.endDay - range.startDay + 1; + const bucketSizeDays = Math.max(1, Math.ceil(spanDays / maxPoints)); + const bucketCount = Math.ceil(spanDays / bucketSizeDays); + + return { + enabled: true, + timezoneOffset, + maxPoints, + seriesLimit, + startDay: range.startDay, + endDay: range.endDay, + bucketSizeDays, + bucketCount, + labels: buildTrendBucketLabels(range.startDay, range.endDay, bucketSizeDays, bucketCount), + total: Array(bucketCount).fill(0), + channelGroups: new Map(), + channelNameGroups: new Map() + }; +} + +function addUploadTrendPoint(accumulator, metadata, channel) { + if (!accumulator.enabled) { + return; + } + + const timestamp = getValidTimestamp(metadata); + if (timestamp === null) { + return; + } + + const dayNumber = getLocalDayNumber(timestamp, accumulator.timezoneOffset); + const bucketIndex = Math.floor((dayNumber - accumulator.startDay) / accumulator.bucketSizeDays); + if (bucketIndex < 0 || bucketIndex >= accumulator.bucketCount) { + return; + } + + const channelName = normalizeTrendKey(metadata.ChannelName, channel); + + accumulator.total[bucketIndex] += 1; + addTrendGroupPoint(accumulator.channelGroups, channel, bucketIndex); + addTrendGroupPoint(accumulator.channelNameGroups, channelName, bucketIndex); +} + +function addTrendGroupPoint(groups, key, bucketIndex) { + const normalizedKey = normalizeTrendKey(key); + let entry = groups.get(normalizedKey); + if (!entry) { + entry = { + total: 0, + buckets: new Map() + }; + groups.set(normalizedKey, entry); + } + + entry.total += 1; + entry.buckets.set(bucketIndex, (entry.buckets.get(bucketIndex) || 0) + 1); +} + +function buildTrendSeries(groups, bucketCount, seriesLimit) { + const selectedEntries = selectTopTrendEntries(groups, seriesLimit); + const selectedNames = new Set(selectedEntries.map(([name]) => name)); + const series = selectedEntries.map(([name, entry]) => ({ + name, + total: entry.total, + data: buildTrendSeriesData(entry, bucketCount) + })); + + if (groups.size > selectedEntries.length) { + const otherData = Array(bucketCount).fill(0); + let otherTotal = 0; + + for (const [name, entry] of groups.entries()) { + if (selectedNames.has(name)) { + continue; + } + otherTotal += entry.total; + entry.buckets.forEach((count, bucketIndex) => { + otherData[bucketIndex] += count; + }); + } + + series.push({ + name: '__other__', + isOther: true, + total: otherTotal, + data: otherData + }); + } + + return { + series, + totalSeries: groups.size, + limited: groups.size > selectedEntries.length + }; +} + +function selectTopTrendEntries(groups, seriesLimit) { + const topEntries = []; + + for (const entry of groups.entries()) { + insertTopTrendEntry(topEntries, entry, seriesLimit); + } + + return topEntries; +} + +function insertTopTrendEntry(topEntries, candidate, seriesLimit) { + let insertIndex = -1; + + for (let i = 0; i < topEntries.length; i++) { + if (compareTrendEntry(candidate, topEntries[i]) < 0) { + insertIndex = i; + break; + } + } + + if (insertIndex === -1) { + if (topEntries.length < seriesLimit) { + topEntries.push(candidate); + } + return; + } + + topEntries.splice(insertIndex, 0, candidate); + if (topEntries.length > seriesLimit) { + topEntries.pop(); + } +} + +function compareTrendEntry(left, right) { + if (right[1].total !== left[1].total) { + return right[1].total - left[1].total; + } + return left[0].localeCompare(right[0]); +} + +function buildTrendSeriesData(entry, bucketCount) { + const data = Array(bucketCount).fill(0); + entry.buckets.forEach((count, bucketIndex) => { + data[bucketIndex] = count; + }); + return data; +} + +function finalizeUploadTrend(accumulator) { + const emptyGroup = { + series: [], + totalSeries: 0, + limited: false + }; + + if (!accumulator.enabled) { + return { + labels: [], + total: [], + bucketSizeDays: 1, + maxPoints: accumulator.maxPoints, + seriesLimit: accumulator.seriesLimit, + range: null, + groupBy: { + channel: emptyGroup, + channelName: emptyGroup + } + }; + } + + return { + labels: accumulator.labels, + total: accumulator.total, + bucketSizeDays: accumulator.bucketSizeDays, + maxPoints: accumulator.maxPoints, + seriesLimit: accumulator.seriesLimit, + range: { + startDate: formatTrendDay(accumulator.startDay), + endDate: formatTrendDay(accumulator.endDay), + timezoneOffset: accumulator.timezoneOffset + }, + groupBy: { + channel: buildTrendSeries(accumulator.channelGroups, accumulator.bucketCount, accumulator.seriesLimit), + channelName: buildTrendSeries(accumulator.channelNameGroups, accumulator.bucketCount, accumulator.seriesLimit) + } + }; +} + /** * 获取索引元数据(轻量级,只读取 meta,不读取整个索引) * 用于容量检查等场景,避免读取整个索引 diff --git a/package.json b/package.json index 467da43c..e7053bb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cloudflare-imgbed", - "version": "2.7.2", + "version": "2.7.3", "type": "module", "scripts": { "ci-test": "concurrently --kill-others \"npm start\" \"wait-on http://localhost:8080 && mocha\"",