'use strict'; const INPUT_TYPES = ['mouse', 'touch', 'keyboard']; const MAX_RECENT_SESSIONS = 8; const whole = (value, max = Number.MAX_SAFE_INTEGER) => Math.min(max, Math.max(0, Math.round(Number(value) || 0))); function rangeSet(ranges, pageCount = Number.MAX_SAFE_INTEGER) { const out = new Set(); for (const pair of Array.isArray(ranges) ? ranges : []) { if (!Array.isArray(pair) || pair.length < 2) continue; const start = whole(pair[0], pageCount - 1), end = whole(pair[1], pageCount - 1); for (let i = Math.min(start, end); i <= Math.max(start, end); i++) out.add(i); } return out; } function compactRanges(values) { const sorted = [...new Set(values)].filter(Number.isInteger).sort((a, b) => a - b); const ranges = []; for (const value of sorted) { const last = ranges[ranges.length - 1]; if (last && value <= last[1] + 1) last[1] = Math.max(last[1], value); else ranges.push([value, value]); } return ranges; } function eligiblePages(version, settings) { const eligible = []; for (let i = 0; i < version.pages.length; i++) { const tags = version.pages[i].t || []; if (settings.skipExtras && tags.includes('extra')) continue; if (settings.skipCovers && tags.includes('cover')) continue; eligible.push(i); } return eligible.length ? eligible : version.pages.map((_, i) => i); } function emptyRecord() { return { schema: 1, totalActiveMs: 0, firstCompleteMs: null, lastCompleteMs: null, loadingMs: 0, sessions: 0, completedPasses: 0, inputMs: { mouse: 0, touch: 0, keyboard: 0 }, fastBursts: 0, rejectedPageViews: 0, maxObservedPps: 0, currentPass: null, recentSessions: [], updatedAt: 0, }; } function normalizeRecord(record) { const base = emptyRecord(), out = record && typeof record === 'object' ? record : {}; for (const [key, value] of Object.entries(base)) if (out[key] === undefined) out[key] = value; out.inputMs = { ...base.inputMs, ...(out.inputMs || {}) }; out.recentSessions = Array.isArray(out.recentSessions) ? out.recentSessions : []; return out; } function publicSummary(record, version, eligible) { const r = normalizeRecord(record || emptyRecord()); const pass = r.currentPass && r.currentPass.version === version ? r.currentPass : null; const allowed = new Set(eligible); const seen = pass ? [...rangeSet(pass.seenRanges)].filter(x => allowed.has(x)) : []; const predominantInput = INPUT_TYPES.reduce((best, key) => r.inputMs[key] > r.inputMs[best] ? key : best, 'mouse'); const hasInput = INPUT_TYPES.some(key => r.inputMs[key] > 0); return { schema: 1, totalActiveMs: whole(r.totalActiveMs), firstCompleteMs: r.firstCompleteMs == null ? null : whole(r.firstCompleteMs), lastCompleteMs: r.lastCompleteMs == null ? null : whole(r.lastCompleteMs), loadingMs: whole(r.loadingMs), sessions: whole(r.sessions), completedPasses: whole(r.completedPasses), inputMs: Object.fromEntries(INPUT_TYPES.map(key => [key, whole(r.inputMs[key])])), predominantInput: hasInput ? predominantInput : null, fastBursts: whole(r.fastBursts), rejectedPageViews: whole(r.rejectedPageViews), maxObservedPps: whole(r.maxObservedPps), eligiblePages: eligible.length, currentPass: { version, activeMs: whole(pass && pass.activeMs), seenRanges: compactRanges(seen), seenPages: seen.length, complete: !!(pass && pass.completedAt), startedAt: pass ? whole(pass.startedAt) : null, protocol: pass?.protocol || 1, requiredPages: Math.ceil(eligible.length * (pass?.protocol === 2 ? 0.6 : 1)), }, updatedAt: whole(r.updatedAt), }; } function chapterInsight(record) { if (!record || typeof record !== 'object') return null; const r = normalizeRecord(record); const totalActiveMs = whole(r.totalActiveMs); if (!totalActiveMs && !whole(r.completedPasses) && (!whole(r.updatedAt) || r.currentPass?.protocol === 2)) return null; return { totalActiveMs, firstCompleteMs: r.firstCompleteMs == null ? null : whole(r.firstCompleteMs), lastCompleteMs: r.lastCompleteMs == null ? null : whole(r.lastCompleteMs), completedPasses: whole(r.completedPasses), currentPassMs: whole(r.currentPass && r.currentPass.activeMs), currentPassComplete: !!(r.currentPass && r.currentPass.completedAt), updatedAt: whole(r.qualifiedUpdatedAt || r.updatedAt), }; } function median(values) { const sorted = values.filter(value => Number.isFinite(value) && value > 0).sort((a, b) => a - b); if (!sorted.length) return null; const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? whole(sorted[middle]) : whole((sorted[middle - 1] + sorted[middle]) / 2); } function mangaInsight(records, chapterIds) { const source = records && typeof records === 'object' ? records : {}; const ids = Array.isArray(chapterIds) ? chapterIds : Object.keys(source); const chapters = ids.map(id => ({ id, reading: chapterInsight(source[id]) })).filter(item => item.reading); const latest = chapters.reduce((best, item) => !best || item.reading.updatedAt > best.reading.updatedAt ? item : best, null); const completed = chapters.filter(item => item.reading.completedPasses > 0); return { activeMs: chapters.reduce((sum, item) => sum + item.reading.totalActiveMs, 0), timedChapters: chapters.filter(item => item.reading.totalActiveMs > 0).length, completedChapters: completed.length, completedPasses: completed.reduce((sum, item) => sum + item.reading.completedPasses, 0), rereads: completed.reduce((sum, item) => sum + Math.max(0, item.reading.completedPasses - 1), 0), typicalChapterMs: median(completed.map(item => item.reading.lastCompleteMs)), lastChapterId: latest ? latest.id : null, updatedAt: latest ? latest.reading.updatedAt : 0, }; } function libraryInsight(mangas) { const tracked = (Array.isArray(mangas) ? mangas : []).filter(item => item && item.reading && item.reading.activeMs > 0); const most = tracked.reduce((best, item) => !best || item.reading.activeMs > best.reading.activeMs ? item : best, null); const latest = tracked.reduce((best, item) => !best || item.reading.updatedAt > best.reading.updatedAt ? item : best, null); return { activeMs: tracked.reduce((sum, item) => sum + item.reading.activeMs, 0), timedChapters: tracked.reduce((sum, item) => sum + item.reading.timedChapters, 0), completedPasses: tracked.reduce((sum, item) => sum + item.reading.completedPasses, 0), rereads: tracked.reduce((sum, item) => sum + item.reading.rereads, 0), timedMangas: tracked.length, mostTimeMangaId: most ? most.id : null, lastMangaId: latest ? latest.id : null, lastChapterId: latest ? latest.reading.lastChapterId : null, updatedAt: latest ? latest.reading.updatedAt : 0, }; } // Cumulative visits tolerate retry and an exit beacon overtaking a regular pulse. // Coverage belongs to THIS visit, so an old read cannot qualify a picture lookup. // Legacy totals are preserved; unqualified visit time is a draft, not a statistic. function applyVisit(record, pulse, { version, pageCount, eligible, now = Date.now() } = {}) { const r = normalizeRecord(record), session = String(pulse.session || ''), seq = whole(pulse.seq, 1e9); let receipt = r.recentSessions.find(item => item.id === session); if (receipt && seq <= whole(receipt.seq)) return { duplicate: true, record: r }; if (!receipt) { if (!r.qualifiedUpdatedAt && r.totalActiveMs) r.qualifiedUpdatedAt = r.updatedAt; receipt = { id: session, seq: 0, at: now, pass: { protocol: 2, visit: session, version, pageCount, seenRanges: [], activeMs: 0, loadingMs: 0, inputMs: { mouse: 0, touch: 0, keyboard: 0 }, creditedMs: 0, startedAt: now, completedAt: null, } }; r.recentSessions.push(receipt); r.currentPass = receipt.pass; } const pass = receipt.pass; if (!pass || pass.version !== version) return { duplicate: true, record: r }; // Wall-time sanity, not a speed test or the legacy twelve-second packet cap. const budget = whole(pulse.elapsedMs, 24 * 60 * 60 * 1000) + 1500; const requestedActive = whole(pulse.activeMs, budget), requestedLoading = whole(pulse.loadingMs, budget); const scale = requestedActive + requestedLoading > budget ? budget / (requestedActive + requestedLoading) : 1; const active = Math.max(pass.activeMs, Math.round(requestedActive * scale)); const loading = Math.max(pass.loadingMs, Math.round(requestedLoading * scale)); r.loadingMs += loading - pass.loadingMs; pass.activeMs = active; pass.loadingMs = loading; const input = Object.fromEntries(INPUT_TYPES.map(key => [key, whole(pulse.inputMs?.[key], active)])); const inputTotal = INPUT_TYPES.reduce((sum, key) => sum + input[key], 0); const inputScale = inputTotal > active ? active / inputTotal : 1; for (const key of INPUT_TYPES) pass.inputMs[key] = Math.max(pass.inputMs[key], Math.round(input[key] * inputScale)); const allowed = new Set(eligible), seen = rangeSet(pass.seenRanges, pageCount); for (const page of Array.isArray(pulse.seen) ? pulse.seen.slice(0, pageCount) : []) { if (Number.isInteger(page) && allowed.has(page)) seen.add(page); } pass.seenRanges = compactRanges(seen); const covered = eligible.filter(page => seen.has(page)).length; if (!pass.completedAt && eligible.length && covered >= Math.ceil(eligible.length * 0.6) && active > 0) { pass.completedAt = now; pass.ordinal = ++r.completedPasses; r.sessions++; } if (pass.completedAt) { r.totalActiveMs += active - pass.creditedMs; pass.creditedMs = active; pass.creditedInput = pass.creditedInput || { mouse: 0, touch: 0, keyboard: 0 }; for (const key of INPUT_TYPES) { r.inputMs[key] += pass.inputMs[key] - pass.creditedInput[key]; pass.creditedInput[key] = pass.inputMs[key]; } // Duration keeps growing through the last page and departure, not just 60%. if (pass.ordinal === 1) r.firstCompleteMs = active; if (pass.ordinal === r.completedPasses) r.lastCompleteMs = active; r.qualifiedUpdatedAt = now; } pass.closed = !!pulse.final; receipt.seq = seq; receipt.at = now; if (r.currentPass?.visit === session) r.currentPass = pass; r.recentSessions.sort((a, b) => whole(b.at) - whole(a.at)); r.recentSessions.splice(MAX_RECENT_SESSIONS); r.updatedAt = now; return { duplicate: false, record: r }; } function applyPulse(record, pulse, { version, pageCount, eligible, now = Date.now(), maxDeltaMs = 12000 } = {}) { const r = normalizeRecord(record); const session = String(pulse.session || ''); const seq = whole(pulse.seq, 1e9); let receipt = r.recentSessions.find(item => item.id === session); if (receipt && seq <= whole(receipt.seq, 1e9)) return { duplicate: true, record: r }; const newSession = !receipt; if (!receipt) { receipt = { id: session, seq: 0, at: now, counted: false }; r.recentSessions.push(receipt); } const elapsed = newSession ? maxDeltaMs : Math.max(0, now - whole(receipt.at)); const allowedDelta = Math.min(maxDeltaMs, elapsed + 1500); const requestedActive = whole(pulse.activeMs, maxDeltaMs * 2); const requestedLoading = whole(pulse.loadingMs, maxDeltaMs * 2); const requestedTotal = requestedActive + requestedLoading; const scale = requestedTotal > allowedDelta && requestedTotal ? allowedDelta / requestedTotal : 1; const activeMs = Math.round(requestedActive * scale); const loadingMs = Math.round(requestedLoading * scale); receipt.seq = seq; receipt.at = now; if (!receipt.counted && activeMs > 0) { receipt.counted = true; r.sessions++; } r.recentSessions.sort((a, b) => whole(b.at) - whole(a.at)); r.recentSessions.splice(MAX_RECENT_SESSIONS); const prior = r.currentPass; if (!prior || prior.version !== version || (prior.completedAt && newSession)) { r.currentPass = { version, pageCount, seenRanges: [], activeMs: 0, startedAt: now, completedAt: null, }; } const pass = r.currentPass; pass.pageCount = pageCount; pass.activeMs = whole(pass.activeMs) + activeMs; r.totalActiveMs = whole(r.totalActiveMs) + activeMs; r.loadingMs = whole(r.loadingMs) + loadingMs; const rawInput = pulse.inputMs && typeof pulse.inputMs === 'object' ? pulse.inputMs : {}; const requestedInput = Object.fromEntries(INPUT_TYPES.map(key => [key, whole(rawInput[key], maxDeltaMs * 2)])); const inputTotal = INPUT_TYPES.reduce((sum, key) => sum + requestedInput[key], 0); const inputScale = inputTotal > activeMs && inputTotal ? activeMs / inputTotal : 1; for (const key of INPUT_TYPES) r.inputMs[key] = whole(r.inputMs[key]) + Math.round(requestedInput[key] * inputScale); const allowedPages = new Set(eligible); const seen = rangeSet(pass.seenRanges, pageCount); for (const raw of Array.isArray(pulse.seen) ? pulse.seen.slice(0, pageCount) : []) { const page = Number(raw); if (Number.isInteger(page) && allowedPages.has(page)) seen.add(page); } pass.seenRanges = compactRanges([...seen]); r.fastBursts = whole(r.fastBursts) + whole(pulse.fastBursts, 100); r.rejectedPageViews = whole(r.rejectedPageViews) + whole(pulse.rejectedPageViews, pageCount * 2); r.maxObservedPps = Math.max(whole(r.maxObservedPps), whole(pulse.maxPps, 1000)); if (!pass.completedAt && eligible.length && eligible.every(page => seen.has(page))) { pass.completedAt = now; r.completedPasses = whole(r.completedPasses) + 1; r.lastCompleteMs = pass.activeMs; if (r.firstCompleteMs == null) r.firstCompleteMs = pass.activeMs; } r.updatedAt = now; return { duplicate: false, record: r, activeMs, loadingMs }; } module.exports = { INPUT_TYPES, applyPulse, applyVisit, chapterInsight, compactRanges, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary, rangeSet, };