448 lines
12 KiB
JavaScript
448 lines
12 KiB
JavaScript
import { isTrackableAnalyticsPath } from '../lib/analytics-shared.js'
|
|
import { createAnalyticsDurationTracker } from '../lib/analytics-duration.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 {string} 마지막 pageview 키 */
|
|
let lastPageviewKey = ''
|
|
|
|
/** @type {number} 마지막 pageview 전송 시각 */
|
|
let lastPageviewSentAt = 0
|
|
|
|
/** @type {string} 마지막 pageview 콘텐츠 세션 ID */
|
|
let lastPageviewContentSessionId = ''
|
|
|
|
/** @type {Set<string>} 전송 완료된 read 콘텐츠 세션 ID */
|
|
const sentReadContentSessionIds = new Set()
|
|
|
|
/** @type {number | null} read 폴링 타이머 */
|
|
let readPollTimer = null
|
|
|
|
/** @type {number | null} heartbeat 타이머 */
|
|
let heartbeatTimer = null
|
|
|
|
/** @type {(() => void) | null} scroll 리스너 */
|
|
let readScrollListener = null
|
|
|
|
/** @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 {string} 현재 콘텐츠 단위 세션 ID */
|
|
let currentContentSessionId = ''
|
|
|
|
/** @type {ReturnType<typeof createAnalyticsDurationTracker> | null} 현재 페이지 접속 시간 측정기 */
|
|
let pageDurationTracker = null
|
|
|
|
/** @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()}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 콘텐츠 단위 세션 ID를 생성한다.
|
|
* @returns {string} 콘텐츠 세션 ID
|
|
*/
|
|
const createContentSessionId = () => {
|
|
try {
|
|
return crypto.randomUUID()
|
|
} catch {
|
|
return `content-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 탭 최초 유입 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 {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 = ''
|
|
currentPath = ''
|
|
currentPostSlug = ''
|
|
currentPageSlug = ''
|
|
currentContentSessionId = ''
|
|
pageDurationTracker = null
|
|
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, contentSessionId?: string, read?: boolean }} payload - 전송 본문
|
|
* @returns {void}
|
|
*/
|
|
const sendPageviewEvent = (payload) => {
|
|
sendAnalyticsPayload('/api/analytics/pageview', {
|
|
path: payload.path,
|
|
postSlug: payload.postSlug || '',
|
|
pageSlug: payload.pageSlug || '',
|
|
clientSessionId: getClientSessionId(),
|
|
contentSessionId: payload.contentSessionId || '',
|
|
referrer: getInitialReferrer(),
|
|
currentUrl: window.location.href,
|
|
read: Boolean(payload.read)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* heartbeat 이벤트를 전송한다.
|
|
* @param {{ path: string, postSlug?: string, pageSlug?: string, contentSessionId?: 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(),
|
|
contentSessionId: payload.contentSessionId || '',
|
|
durationSeconds: payload.durationSeconds,
|
|
maxScrollRatio: payload.maxScrollRatio
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 방문 시간을 측정하고 긴 실행 중단 후 새 방문으로 전환한다.
|
|
* @returns {number} 현재 방문의 접속 시간(초)
|
|
*/
|
|
const updateCurrentVisit = () => {
|
|
if (!pageDurationTracker) return 0
|
|
const { durationSeconds, restarted } = pageDurationTracker()
|
|
if (restarted) {
|
|
currentContentSessionId = createContentSessionId()
|
|
lastPageviewContentSessionId = currentContentSessionId
|
|
lastPageviewKey = `view:${currentPath}:${currentPostSlug}:${currentPageSlug}`
|
|
lastPageviewSentAt = Date.now()
|
|
maxScrollRatio = getDocumentScrollRatio()
|
|
sendPageviewEvent({ path: currentPath, postSlug: currentPostSlug, pageSlug: currentPageSlug, contentSessionId: currentContentSessionId, read: false })
|
|
}
|
|
return durationSeconds
|
|
}
|
|
|
|
/**
|
|
* 현재 페이지 heartbeat를 전송한다.
|
|
* @returns {void}
|
|
*/
|
|
const sendCurrentHeartbeat = () => {
|
|
if (!currentPath || !isTrackableAnalyticsPath(currentPath) || !pageDurationTracker) return
|
|
const durationSeconds = updateCurrentVisit()
|
|
updateMaxScrollRatio()
|
|
|
|
sendHeartbeatEvent({
|
|
path: currentPath,
|
|
postSlug: currentPostSlug,
|
|
pageSlug: currentPageSlug,
|
|
contentSessionId: currentContentSessionId,
|
|
durationSeconds,
|
|
maxScrollRatio
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 읽음 조건을 만족하면 read 이벤트를 한 번 전송한다.
|
|
* @returns {void}
|
|
*/
|
|
const trySendReadEvent = () => {
|
|
if (!readPostSlug || !currentContentSessionId) return
|
|
const durationSeconds = updateCurrentVisit()
|
|
if (sentReadContentSessionIds.has(currentContentSessionId)) return
|
|
|
|
if (durationSeconds * 1000 < READ_MIN_DURATION_MS) {
|
|
return
|
|
}
|
|
|
|
updateMaxScrollRatio()
|
|
|
|
if (maxScrollRatio < READ_MIN_SCROLL_RATIO) {
|
|
return
|
|
}
|
|
|
|
sentReadContentSessionIds.add(currentContentSessionId)
|
|
sendPageviewEvent({
|
|
path: readPath,
|
|
postSlug: readPostSlug,
|
|
contentSessionId: currentContentSessionId,
|
|
read: true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 게시물 상세 read 추적을 시작한다.
|
|
* @param {string} path - 경로
|
|
* @param {string} postSlug - 게시물 slug
|
|
* @returns {void}
|
|
*/
|
|
const startReadTracking = (path, postSlug) => {
|
|
readPath = path
|
|
readPostSlug = postSlug
|
|
|
|
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}`
|
|
const now = Date.now()
|
|
const shouldSendPageview = viewKey !== lastPageviewKey || now - lastPageviewSentAt > 1000
|
|
const contentSessionId = shouldSendPageview
|
|
? createContentSessionId()
|
|
: lastPageviewContentSessionId || createContentSessionId()
|
|
|
|
if (shouldSendPageview) {
|
|
lastPageviewKey = viewKey
|
|
lastPageviewSentAt = now
|
|
lastPageviewContentSessionId = contentSessionId
|
|
sendPageviewEvent({
|
|
path,
|
|
postSlug,
|
|
pageSlug,
|
|
contentSessionId,
|
|
read: false
|
|
})
|
|
}
|
|
|
|
clearPageTracking()
|
|
|
|
currentPath = path
|
|
currentPostSlug = postSlug
|
|
currentPageSlug = pageSlug
|
|
currentContentSessionId = contentSessionId
|
|
pageDurationTracker = createAnalyticsDurationTracker()
|
|
maxScrollRatio = getDocumentScrollRatio()
|
|
|
|
startReadTracking(path, postSlug)
|
|
startHeartbeatTracking()
|
|
sendCurrentHeartbeat()
|
|
}
|
|
|
|
/**
|
|
* 공개 페이지 방문·게시물 읽음·실시간 heartbeat 클라이언트 트래커
|
|
*/
|
|
export default defineNuxtPlugin(() => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
const router = useRouter()
|
|
|
|
/**
|
|
* 탭 숨김·복귀·페이지 복원 시 접속 신호를 갱신한다.
|
|
* @returns {void}
|
|
*/
|
|
const handlePageActivity = () => {
|
|
sendCurrentHeartbeat()
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', handlePageActivity)
|
|
window.addEventListener('pagehide', handlePageActivity)
|
|
window.addEventListener('pageshow', handlePageActivity)
|
|
|
|
router.isReady().then(() => {
|
|
trackRouteAnalytics(router.currentRoute.value)
|
|
})
|
|
|
|
router.beforeEach(() => {
|
|
sendCurrentHeartbeat()
|
|
})
|
|
|
|
router.afterEach((to) => {
|
|
trackRouteAnalytics(to)
|
|
})
|
|
})
|