Files
sori.studio/plugins/site-analytics.client.js

417 lines
10 KiB
JavaScript

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<string>} 세션 내 전송 완료된 pageview 키 */
const sentViewKeys = new Set()
/** @type {Set<string>} 세션 내 전송 완료된 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)
})
})