관리자 유입 통계 추가 v1.5.35
This commit is contained in:
152
lib/analytics-traffic.js
Normal file
152
lib/analytics-traffic.js
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user