v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션) - POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커 - 관리자 대시보드 통계 카드·인기 게시물 Top 5 - 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
This commit is contained in:
211
plugins/site-analytics.client.js
Normal file
211
plugins/site-analytics.client.js
Normal file
@@ -0,0 +1,211 @@
|
||||
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<string>} 세션 내 전송 완료된 pageview 키 */
|
||||
const sentViewKeys = new Set()
|
||||
|
||||
/** @type {Set<string>} 세션 내 전송 완료된 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)
|
||||
})
|
||||
})
|
||||
@@ -8,22 +8,23 @@ import {
|
||||
const siteSettingsFetchKey = 'site-settings-public'
|
||||
|
||||
/**
|
||||
* 공개 사이트 설정에서 스플래시용 브랜드를 localStorage에 캐시한다.
|
||||
* 공개 사이트 설정에서 스플래시용 로고 이미지 URL만 localStorage에 캐시한다.
|
||||
* logoText(井 등)는 헤더 이미지 없을 때 짧은 기호용이며 스플래시·브랜드명과 무관하다.
|
||||
* @param {Object|null|undefined} settings - 사이트 설정
|
||||
* @returns {void}
|
||||
*/
|
||||
const cacheSiteBrandForSplash = (settings) => {
|
||||
if (!settings?.logoUrl && !settings?.logoText) {
|
||||
try {
|
||||
localStorage.removeItem(SITE_BRAND_LOGO_TEXT_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!settings?.logoUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.logoUrl) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_URL_KEY, settings.logoUrl)
|
||||
}
|
||||
|
||||
if (settings.logoText) {
|
||||
localStorage.setItem(SITE_BRAND_LOGO_TEXT_KEY, settings.logoText)
|
||||
}
|
||||
localStorage.setItem(SITE_BRAND_LOGO_URL_KEY, settings.logoUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user