/** @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 } }