import { isTrackableAnalyticsPath } from '../lib/analytics.js' /** @type {number} 읽음 판정 최소 체류 시간(ms) */ const READ_MIN_DURATION_MS = 15000 /** @type {number} 읽음 판정 최소 스크롤 비율 */ const READ_MIN_SCROLL_RATIO = 0.5 /** @type {Set} 세션 내 전송 완료된 pageview 키 */ const sentViewKeys = new Set() /** @type {Set} 세션 내 전송 완료된 read slug */ const sentReadSlugs = new Set() /** @type {number | null} read 폴링 타이머 */ let readPollTimer = null /** @type {(() => void) | null} scroll 리스너 */ let readScrollListener = null /** @type {number} read 추적 시작 시각 */ let readStartedAt = 0 /** @type {string} read 추적 중인 게시물 slug */ let readPostSlug = '' /** @type {string} read 추적 중인 경로 */ let readPath = '' /** * 게시물 상세 경로에서 slug를 추출한다. * @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트 * @returns {string} slug 또는 빈 문자열 */ const extractPostSlugFromRoute = (route) => { const paramSlug = String(route.params?.slug || '').trim() if (paramSlug && String(route.path || '').startsWith('/post/')) { return paramSlug } const match = String(route.path || '').match(/^\/post\/([^/?#]+)/) return match?.[1] ? decodeURIComponent(match[1]).trim() : '' } /** * 문서 스크롤 진행 비율(0~1)을 반환한다. * @returns {number} 스크롤 비율 */ const getDocumentScrollRatio = () => { const scrollTop = window.scrollY || document.documentElement.scrollTop || 0 const viewport = window.innerHeight || document.documentElement.clientHeight || 0 const scrollHeight = document.documentElement.scrollHeight || 0 const maxScroll = Math.max(scrollHeight - viewport, 0) if (maxScroll <= 0) { return 1 } return Math.min(scrollTop / maxScroll, 1) } /** * read 추적 리스너를 해제한다. * @returns {void} */ const clearReadTracking = () => { if (readPollTimer) { clearInterval(readPollTimer) readPollTimer = null } if (readScrollListener) { window.removeEventListener('scroll', readScrollListener) readScrollListener = null } readPostSlug = '' readPath = '' readStartedAt = 0 } /** * 통계 이벤트를 서버로 전송한다. * @param {{ path: string, postSlug?: string, read?: boolean }} payload - 전송 본문 * @returns {void} */ const sendAnalyticsEvent = (payload) => { const url = '/api/analytics/pageview' const body = JSON.stringify({ path: payload.path, postSlug: payload.postSlug || '', read: Boolean(payload.read) }) try { if (typeof navigator !== 'undefined' && navigator.sendBeacon) { navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })) return } } catch { // sendBeacon 실패 시 fetch로 대체 } $fetch(url, { method: 'POST', body: JSON.parse(body), keepalive: true }).catch(() => { // 통계 실패는 사용자 경험에 영향을 주지 않는다 }) } /** * 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다. * @returns {void} */ const trySendReadEvent = () => { if (!readPostSlug || sentReadSlugs.has(readPostSlug)) { return } const elapsed = Date.now() - readStartedAt if (elapsed < READ_MIN_DURATION_MS) { return } if (getDocumentScrollRatio() < READ_MIN_SCROLL_RATIO) { return } sentReadSlugs.add(readPostSlug) sendAnalyticsEvent({ path: readPath, postSlug: readPostSlug, read: true }) } /** * 게시물 상세에서 read 추적을 시작한다. * @param {string} path - 경로 * @param {string} postSlug - 게시물 slug * @returns {void} */ const startReadTracking = (path, postSlug) => { clearReadTracking() if (!postSlug) { return } readPath = path readPostSlug = postSlug readStartedAt = Date.now() readScrollListener = () => { trySendReadEvent() } window.addEventListener('scroll', readScrollListener, { passive: true }) readPollTimer = window.setInterval(() => { trySendReadEvent() }, 2000) } /** * 라우트 변경 시 pageview를 기록한다. * @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트 * @returns {void} */ const trackRouteAnalytics = (route) => { const path = String(route.path || '') if (!isTrackableAnalyticsPath(path)) { clearReadTracking() return } const postSlug = extractPostSlugFromRoute(route) const viewKey = `view:${path}:${postSlug}` if (!sentViewKeys.has(viewKey)) { sentViewKeys.add(viewKey) sendAnalyticsEvent({ path, postSlug, read: false }) } startReadTracking(path, postSlug) } /** * 공개 페이지 방문·게시물 읽음 통계 클라이언트 트래커 */ export default defineNuxtPlugin(() => { if (!import.meta.client) { return } const router = useRouter() router.isReady().then(() => { trackRouteAnalytics(router.currentRoute.value) }) router.afterEach((to) => { trackRouteAnalytics(to) }) })