v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션) - POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커 - 관리자 대시보드 통계 카드·인기 게시물 Top 5 - 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
This commit is contained in:
65
lib/analytics.js
Normal file
65
lib/analytics.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/** @type {RegExp} 추적 제외 경로 */
|
||||
const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/
|
||||
|
||||
/** @type {RegExp} 봇 User-Agent 패턴 */
|
||||
const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i
|
||||
|
||||
/**
|
||||
* 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다.
|
||||
* @returns {string} 날짜 문자열
|
||||
*/
|
||||
export const getAnalyticsDayKey = () => {
|
||||
const now = new Date()
|
||||
return now.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추적 대상 경로인지 확인한다.
|
||||
* @param {string} path - 요청 경로
|
||||
* @returns {boolean} 추적 가능 여부
|
||||
*/
|
||||
export const isTrackableAnalyticsPath = (path) => {
|
||||
const normalized = (path || '').trim()
|
||||
if (!normalized.startsWith('/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (EXCLUDED_PATH_PATTERN.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 User-Agent 여부
|
||||
* @param {string} userAgent - User-Agent
|
||||
* @returns {boolean} 봇 여부
|
||||
*/
|
||||
export const isBotUserAgent = (userAgent) => {
|
||||
const value = (userAgent || '').trim()
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return BOT_USER_AGENT_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다.
|
||||
* @param {{ day: string, ip: string, userAgent: string, secret: string }} input - 해시 입력
|
||||
* @returns {string} visitor hash
|
||||
*/
|
||||
export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => {
|
||||
const payload = `${day}|${ip}|${userAgent}|${secret}`
|
||||
return createHash('sha256').update(payload).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 slug 정규화
|
||||
* @param {string} slug - slug
|
||||
* @returns {string} 정규화된 slug
|
||||
*/
|
||||
export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
|
||||
@@ -10,7 +10,9 @@ export const SITE_THEME_STORAGE_KEY = 'SITE_THEME'
|
||||
/** localStorage 키: 스플래시용 로고 이미지 URL(이전 방문에서 캐시) */
|
||||
export const SITE_BRAND_LOGO_URL_KEY = 'SITE_BRAND_LOGO_URL'
|
||||
|
||||
/** localStorage 키: 스플래시용 로고 텍스트 fallback */
|
||||
/**
|
||||
* @deprecated 스플래시 문구는 사이트 제목을 사용한다. 이전 버전 잔여 키 정리용.
|
||||
*/
|
||||
export const SITE_BRAND_LOGO_TEXT_KEY = 'SITE_BRAND_LOGO_TEXT'
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export const resolveSiteTheme = (savedTheme, prefersDark) => {
|
||||
|
||||
/**
|
||||
* 첫 페인트 전에 테마·스플래시를 준비하는 head 인라인 스크립트 본문
|
||||
* @param {string} [siteTitle] - 스플래시에 쓸 사이트 제목(로고 이미지 없을 때)
|
||||
* @returns {string}
|
||||
*/
|
||||
export const buildSiteBootInlineScript = () => `(function(){try{var sk=${JSON.stringify(SITE_THEME_STORAGE_KEY)};var lk=${JSON.stringify(SITE_BRAND_LOGO_URL_KEY)};var tk=${JSON.stringify(SITE_BRAND_LOGO_TEXT_KEY)};var root=document.documentElement;var prefersDark=window.matchMedia("(prefers-color-scheme: dark)").matches;var saved=localStorage.getItem(sk);var theme=(saved==="light"||saved==="dark")?saved:(prefersDark?"dark":"light");root.dataset.theme=theme;root.style.colorScheme=theme;if(/^\\/(admin|signin|signup|forgot-password)(\\/|$)/.test(location.pathname)){root.classList.add("site-app-ready");return}var splash=document.getElementById("site-splash");if(!splash){return}var logoEl=document.getElementById("site-splash-logo");var textEl=document.getElementById("site-splash-text");var logoUrl=localStorage.getItem(lk)||"";var logoText=localStorage.getItem(tk)||"sori.studio";if(logoUrl&&logoEl){logoEl.src=logoUrl;logoEl.hidden=false;if(textEl){textEl.hidden=true}}else if(textEl){textEl.textContent=logoText;textEl.hidden=false}}catch(e){}})();`
|
||||
export const buildSiteBootInlineScript = (siteTitle = 'sori.studio') => `(function(){try{var sk=${JSON.stringify(SITE_THEME_STORAGE_KEY)};var lk=${JSON.stringify(SITE_BRAND_LOGO_URL_KEY)};var legacyTk=${JSON.stringify(SITE_BRAND_LOGO_TEXT_KEY)};var splashTitle=${JSON.stringify(siteTitle)};var root=document.documentElement;var prefersDark=window.matchMedia("(prefers-color-scheme: dark)").matches;var saved=localStorage.getItem(sk);var theme=(saved==="light"||saved==="dark")?saved:(prefersDark?"dark":"light");root.dataset.theme=theme;root.style.colorScheme=theme;try{localStorage.removeItem(legacyTk)}catch(e){}if(/^\\/(admin|signin|signup|forgot-password)(\\/|$)/.test(location.pathname)){root.classList.add("site-app-ready");return}var splash=document.getElementById("site-splash");if(!splash){return}var logoEl=document.getElementById("site-splash-logo");var textEl=document.getElementById("site-splash-text");var logoUrl=localStorage.getItem(lk)||"";if(logoUrl&&logoEl){logoEl.src=logoUrl;logoEl.hidden=false;if(textEl){textEl.hidden=true}}else if(textEl){textEl.textContent=splashTitle;textEl.hidden=false}}catch(e){}})();`
|
||||
|
||||
Reference in New Issue
Block a user