관리자 유입 통계 추가 v1.5.35

This commit is contained in:
2026-06-02 14:46:56 +09:00
parent 5b78a8c92f
commit 1bcd2f6898
15 changed files with 718 additions and 12 deletions

152
lib/analytics-traffic.js Normal file
View File

@@ -0,0 +1,152 @@
/** @type {number} 통계 키워드 최대 길이 */
const MAX_KEYWORD_LENGTH = 80
/** @type {Array<{ name: string, hostPattern: RegExp, keywordParams: string[] }>} 검색 유입 규칙 */
const SEARCH_SOURCE_RULES = [
{ name: '구글', hostPattern: /(^|\.)google\./i, keywordParams: ['q'] },
{ name: '네이버', hostPattern: /(^|\.)naver\.com$/i, keywordParams: ['query', 'q'] },
{ name: '다음', hostPattern: /(^|\.)daum\.net$|(^|\.)kakao\.com$/i, keywordParams: ['q', 'query'] },
{ name: '빙', hostPattern: /(^|\.)bing\.com$/i, keywordParams: ['q'] },
{ name: '줌', hostPattern: /(^|\.)zum\.com$/i, keywordParams: ['query', 'q'] }
]
/** @type {Array<{ name: string, hostPattern: RegExp }>} SNS 유입 규칙 */
const SOCIAL_SOURCE_RULES = [
{ name: '카카오톡', hostPattern: /(^|\.)kakao\.com$|(^|\.)kakaocdn\.net$/i },
{ name: '페이스북', hostPattern: /(^|\.)facebook\.com$|(^|\.)fb\.com$|(^|\.)fb\.me$/i },
{ name: '인스타그램', hostPattern: /(^|\.)instagram\.com$/i },
{ name: '트위터', hostPattern: /(^|\.)twitter\.com$|(^|\.)x\.com$|(^|\.)t\.co$/i },
{ name: '유튜브', hostPattern: /(^|\.)youtube\.com$|(^|\.)youtu\.be$/i }
]
/**
* 문자열 값을 안전한 통계 라벨로 정리한다.
* @param {string} value - 원본 문자열
* @param {number} maxLength - 최대 길이
* @returns {string} 정리된 문자열
*/
const normalizeAnalyticsText = (value, maxLength = 120) => {
return String(value || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
}
/**
* URL 문자열을 URL 객체로 변환한다.
* @param {string} value - URL 문자열
* @returns {URL | null} URL 객체
*/
const parseAnalyticsUrl = (value) => {
const rawValue = String(value || '').trim()
if (!rawValue) {
return null
}
try {
return new URL(rawValue)
} catch {
return null
}
}
/**
* 검색 유입 키워드를 추출한다.
* @param {URL} url - referrer URL
* @param {string[]} params - 키워드 후보 파라미터
* @returns {string} 키워드
*/
const extractSearchKeyword = (url, params) => {
const keywordParams = [...params, 'keyword', 'search']
for (const param of keywordParams) {
const value = normalizeAnalyticsText(url.searchParams.get(param), MAX_KEYWORD_LENGTH)
if (value) {
return value
}
}
return ''
}
/**
* 유입 URL을 검색·SNS·직접·기타로 분류한다.
* @param {{ referrer?: string, currentUrl?: string }} input - 분류 입력
* @returns {{ sourceGroup: string, sourceName: string, keyword: string }} 유입 분류
*/
export const classifyAnalyticsTrafficSource = (input = {}) => {
const referrerUrl = parseAnalyticsUrl(input.referrer)
const currentUrl = parseAnalyticsUrl(input.currentUrl)
if (!referrerUrl) {
return {
sourceGroup: 'direct',
sourceName: '직접 유입',
keyword: ''
}
}
if (currentUrl && referrerUrl.hostname === currentUrl.hostname) {
return {
sourceGroup: 'direct',
sourceName: '직접 유입',
keyword: ''
}
}
const host = referrerUrl.hostname.replace(/^www\./i, '')
const searchRule = SEARCH_SOURCE_RULES.find((rule) => rule.hostPattern.test(host))
if (searchRule) {
return {
sourceGroup: 'search',
sourceName: searchRule.name,
keyword: extractSearchKeyword(referrerUrl, searchRule.keywordParams)
}
}
const socialRule = SOCIAL_SOURCE_RULES.find((rule) => rule.hostPattern.test(host))
if (socialRule) {
return {
sourceGroup: 'sns',
sourceName: socialRule.name,
keyword: ''
}
}
return {
sourceGroup: 'other',
sourceName: '기타 유입',
keyword: ''
}
}
/**
* User-Agent에서 디바이스와 OS를 분류한다.
* @param {string} userAgent - User-Agent
* @returns {{ deviceType: string, osName: string }} 디바이스 분류
*/
export const classifyAnalyticsDevice = (userAgent) => {
const value = String(userAgent || '')
const lowerValue = value.toLowerCase()
const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(value)
const isTablet = /ipad|tablet|android(?!.*mobile)/i.test(value)
let osName = '기타'
if (/iphone|ipad|ipod/i.test(value)) {
osName = 'iOS'
} else if (/android/i.test(value)) {
osName = 'Android'
} else if (/windows/i.test(value)) {
osName = 'Windows'
} else if (/mac os|macintosh|mac_powerpc/i.test(lowerValue)) {
osName = 'macOS'
} else if (/linux/i.test(value)) {
osName = 'Linux'
}
return {
deviceType: isMobile || isTablet ? '모바일' : 'PC',
osName
}
}