import { ANALYTICS_MAX_DURATION_SECONDS, isTrackableAnalyticsPath } from '../lib/analytics-shared.js' /** @type {string} 탭 단위 클라이언트 세션 storage 키 */ const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session' /** @type {string} 최초 유입 referrer storage 키 */ const INITIAL_REFERRER_STORAGE_KEY = 'sori_analytics_initial_referrer' /** @type {number} 읽음 판정 최소 체류 시간(ms) */ const READ_MIN_DURATION_MS = 15000 /** @type {number} 읽음 판정 최소 스크롤 비율 */ const READ_MIN_SCROLL_RATIO = 0.5 /** @type {number} heartbeat 전송 간격(ms) */ const HEARTBEAT_INTERVAL_MS = 20000 /** @type {Set} 세션 내 전송 완료된 pageview 키 */ const sentViewKeys = new Set() /** @type {Set} 세션 내 전송 완료된 read slug */ const sentReadSlugs = new Set() /** @type {number | null} read 폴링 타이머 */ let readPollTimer = null /** @type {number | null} heartbeat 타이머 */ let heartbeatTimer = 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 = '' /** @type {string} 현재 추적 경로 */ let currentPath = '' /** @type {string} 현재 추적 게시물 slug */ let currentPostSlug = '' /** @type {string} 현재 추적 페이지 slug */ let currentPageSlug = '' /** @type {number} 현재 페이지 체류 시작 시각 */ let pageStartedAt = 0 /** @type {number} 현재 페이지 최대 스크롤 비율 */ let maxScrollRatio = 0 /** * 탭 단위 클라이언트 세션 ID를 반환한다. * @returns {string} 클라이언트 세션 ID */ const getClientSessionId = () => { try { const existing = sessionStorage.getItem(CLIENT_SESSION_STORAGE_KEY) if (existing) { return existing } const created = crypto.randomUUID() sessionStorage.setItem(CLIENT_SESSION_STORAGE_KEY, created) return created } catch { return `fallback-${Date.now()}` } } /** * 탭 최초 유입 referrer를 반환한다. * @returns {string} referrer */ const getInitialReferrer = () => { try { const existing = sessionStorage.getItem(INITIAL_REFERRER_STORAGE_KEY) if (existing !== null) { return existing } const created = String(document.referrer || '').trim() sessionStorage.setItem(INITIAL_REFERRER_STORAGE_KEY, created) return created } catch { return String(document.referrer || '').trim() } } /** * 게시물 상세 경로에서 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() : '' } /** * 고정 페이지 경로에서 slug를 추출한다. * @param {import('vue-router').RouteLocationNormalizedLoaded} route - 현재 라우트 * @returns {string} slug 또는 빈 문자열 */ const extractPageSlugFromRoute = (route) => { const paramSlug = String(route.params?.slug || '').trim() if (paramSlug && String(route.path || '').startsWith('/pages/')) { return paramSlug } const match = String(route.path || '').match(/^\/pages\/([^/?#]+)/) 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) } /** * 현재 페이지 체류시간(초)을 반환한다. * @returns {number} 체류시간(초) */ const getCurrentDurationSeconds = () => { if (!pageStartedAt) { return 0 } const elapsedMs = Date.now() - pageStartedAt return Math.min(Math.floor(elapsedMs / 1000), ANALYTICS_MAX_DURATION_SECONDS) } /** * 스크롤 비율을 갱신한다. * @returns {void} */ const updateMaxScrollRatio = () => { maxScrollRatio = Math.max(maxScrollRatio, getDocumentScrollRatio()) } /** * read·heartbeat 리스너를 해제한다. * @returns {void} */ const clearPageTracking = () => { if (readPollTimer) { clearInterval(readPollTimer) readPollTimer = null } if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null } if (readScrollListener) { window.removeEventListener('scroll', readScrollListener) readScrollListener = null } readPostSlug = '' readPath = '' readStartedAt = 0 currentPath = '' currentPostSlug = '' currentPageSlug = '' pageStartedAt = 0 maxScrollRatio = 0 } /** * 통계 이벤트를 서버로 전송한다. * @param {string} endpoint - API 경로 * @param {Object} payload - 전송 본문 * @returns {void} */ const sendAnalyticsPayload = (endpoint, payload) => { const url = endpoint const body = JSON.stringify(payload) 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(() => { // 통계 실패는 사용자 경험에 영향을 주지 않는다 }) } /** * pageview 이벤트를 전송한다. * @param {{ path: string, postSlug?: string, pageSlug?: string, read?: boolean }} payload - 전송 본문 * @returns {void} */ const sendPageviewEvent = (payload) => { sendAnalyticsPayload('/api/analytics/pageview', { path: payload.path, postSlug: payload.postSlug || '', pageSlug: payload.pageSlug || '', referrer: getInitialReferrer(), currentUrl: window.location.href, read: Boolean(payload.read) }) } /** * heartbeat 이벤트를 전송한다. * @param {{ path: string, postSlug?: string, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문 * @returns {void} */ const sendHeartbeatEvent = (payload) => { sendAnalyticsPayload('/api/analytics/heartbeat', { path: payload.path, postSlug: payload.postSlug || '', pageSlug: payload.pageSlug || '', clientSessionId: getClientSessionId(), durationSeconds: payload.durationSeconds, maxScrollRatio: payload.maxScrollRatio }) } /** * 현재 페이지 heartbeat를 전송한다. * @returns {void} */ const sendCurrentHeartbeat = () => { if (!currentPath || !isTrackableAnalyticsPath(currentPath)) { return } updateMaxScrollRatio() sendHeartbeatEvent({ path: currentPath, postSlug: currentPostSlug, pageSlug: currentPageSlug, durationSeconds: getCurrentDurationSeconds(), maxScrollRatio }) } /** * 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다. * @returns {void} */ const trySendReadEvent = () => { if (!readPostSlug || sentReadSlugs.has(readPostSlug)) { return } const elapsed = Date.now() - readStartedAt if (elapsed < READ_MIN_DURATION_MS) { return } updateMaxScrollRatio() if (maxScrollRatio < READ_MIN_SCROLL_RATIO) { return } sentReadSlugs.add(readPostSlug) sendPageviewEvent({ path: readPath, postSlug: readPostSlug, read: true }) } /** * 게시물 상세 read 추적을 시작한다. * @param {string} path - 경로 * @param {string} postSlug - 게시물 slug * @returns {void} */ const startReadTracking = (path, postSlug) => { readPath = path readPostSlug = postSlug readStartedAt = Date.now() if (!postSlug) { return } readPollTimer = window.setInterval(() => { trySendReadEvent() }, 2000) } /** * heartbeat·스크롤 추적을 시작한다. * @returns {void} */ const startHeartbeatTracking = () => { readScrollListener = () => { updateMaxScrollRatio() trySendReadEvent() } window.addEventListener('scroll', readScrollListener, { passive: true }) heartbeatTimer = window.setInterval(() => { sendCurrentHeartbeat() }, HEARTBEAT_INTERVAL_MS) } /** * 라우트 변경 시 pageview·heartbeat 추적을 시작한다. * @param {import('vue-router').RouteLocationNormalizedLoaded} route - 대상 라우트 * @returns {void} */ const trackRouteAnalytics = (route) => { const path = String(route.path || '') if (!isTrackableAnalyticsPath(path)) { clearPageTracking() return } sendCurrentHeartbeat() const postSlug = extractPostSlugFromRoute(route) const pageSlug = postSlug ? '' : extractPageSlugFromRoute(route) const viewKey = `view:${path}:${postSlug}:${pageSlug}` if (!sentViewKeys.has(viewKey)) { sentViewKeys.add(viewKey) sendPageviewEvent({ path, postSlug, pageSlug, read: false }) } clearPageTracking() currentPath = path currentPostSlug = postSlug currentPageSlug = pageSlug pageStartedAt = Date.now() maxScrollRatio = getDocumentScrollRatio() startReadTracking(path, postSlug) startHeartbeatTracking() sendCurrentHeartbeat() } /** * 공개 페이지 방문·게시물 읽음·실시간 heartbeat 클라이언트 트래커 */ export default defineNuxtPlugin(() => { if (!import.meta.client) { return } const router = useRouter() const handlePageExit = () => { sendCurrentHeartbeat() } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { handlePageExit() } }) window.addEventListener('pagehide', handlePageExit) router.isReady().then(() => { trackRouteAnalytics(router.currentRoute.value) }) router.beforeEach(() => { sendCurrentHeartbeat() }) router.afterEach((to) => { trackRouteAnalytics(to) }) })