@adstage/web-sdk 2.0.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adstage/web-sdk",
3
- "version": "2.0.0",
3
+ "version": "2.1.2",
4
4
  "description": "AdStage Web SDK - Production-ready marketing platform SDK with namespace architecture for ads, events, and config management",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs.js",
@@ -0,0 +1,142 @@
1
+ import { AdEventType } from '../../types/advertisement';
2
+ import type { AdSlot } from '../../types/advertisement';
3
+ import { ViewableEventTracker } from './viewable-event-tracker';
4
+ import { DeviceInfoCollector } from '../device-info-collector';
5
+ import { DOMUtils } from '../../utils/dom-utils';
6
+ import { ApiHeaders } from '../../utils/api-headers';
7
+ import type { ViewabilityMetrics } from './viewability-tracker';
8
+
9
+ /**
10
+ * 광고 이벤트 추적 관리 클래스
11
+ * - 광고 전용 이벤트 추적 및 전송
12
+ * - Viewable 이벤트 중복 방지 통합
13
+ * - 광고 서버 API 통신
14
+ */
15
+ export class AdvertisementEventTracker {
16
+ private baseUrl: string;
17
+ private apiKey: string;
18
+ private debug: boolean;
19
+ private slots: Map<string, AdSlot>;
20
+
21
+ constructor(baseUrl: string, apiKey: string, debug: boolean, slots: Map<string, AdSlot>) {
22
+ this.baseUrl = baseUrl;
23
+ this.apiKey = apiKey;
24
+ this.debug = debug;
25
+ this.slots = slots;
26
+ }
27
+
28
+ /**
29
+ * 광고 이벤트 추적 - viewability 데이터 지원
30
+ */
31
+ async trackAdvertisementEvent(
32
+ adId: string,
33
+ slotId: string,
34
+ eventType: AdEventType,
35
+ additionalData?: {
36
+ viewabilityMetrics?: ViewabilityMetrics;
37
+ fraudScore?: number;
38
+ fraudReasons?: string[];
39
+ riskLevel?: string;
40
+ }
41
+ ): Promise<void> {
42
+ try {
43
+ // VIEWABLE 이벤트의 경우 중복 확인
44
+ if (eventType === AdEventType.VIEWABLE) {
45
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
46
+ return; // 중복 viewable 이벤트이므로 추적하지 않음
47
+ }
48
+ }
49
+
50
+ // 현재 슬롯 정보 가져오기
51
+ const slot = this.slots.get(slotId);
52
+
53
+ // 디바이스 정보 수집
54
+ const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
55
+
56
+ // 광고 이벤트 데이터 구성 (DTO 구조에 맞춤)
57
+ const eventData = {
58
+ // 서버에서 자동 설정: orgId, advertisementId, action
59
+ // 하지만 DTO 검증을 위해 임시값 제공
60
+ orgId: 'temp', // 서버에서 API 키로부터 덮어씀
61
+ advertisementId: adId, // 서버에서 URL 파라미터로부터 덮어씀
62
+ action: eventType, // 서버에서 URL 파라미터로부터 덮어씀
63
+
64
+ // 필수 필드들
65
+ adType: slot?.adType || 'BANNER',
66
+ platform: deviceInfo.platform,
67
+
68
+ // 디바이스 정보는 deviceInfo 객체로 래핑
69
+ deviceInfo: deviceInfo,
70
+
71
+ // 페이지 및 슬롯 정보
72
+ pageUrl: DOMUtils.getPageInfo().url,
73
+ pageTitle: DOMUtils.getPageInfo().title,
74
+ referrer: DOMUtils.getPageInfo().referrer,
75
+ slotId,
76
+ slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
77
+ slotWidth: AdvertisementEventTracker.parseNumericValue(slot?.width),
78
+ slotHeight: AdvertisementEventTracker.parseNumericValue(slot?.height),
79
+ sessionId: deviceInfo.sessionId,
80
+
81
+ // 성능 메트릭
82
+ pageLoadTime: performance.now(),
83
+
84
+ // 추가 메타데이터
85
+ metadata: {
86
+ eventType,
87
+ sdkVersion: '1.0.0',
88
+ timestamp: Date.now(),
89
+ },
90
+
91
+ // viewable 관련 추가 데이터 (DTO 필드명과 매칭)
92
+ ...(additionalData?.viewabilityMetrics && {
93
+ isViewable: additionalData.viewabilityMetrics.isViewable,
94
+ exposureTime: additionalData.viewabilityMetrics.exposureTime,
95
+ maxVisibilityRatio: additionalData.viewabilityMetrics.maxVisibilityRatio,
96
+ firstViewableTime: additionalData.viewabilityMetrics.firstViewableTime,
97
+ // IAB 표준 준수 여부
98
+ iabCompliant: additionalData.viewabilityMetrics.isViewable,
99
+ }),
100
+
101
+ // fraud 관련 데이터 (DTO 필드명과 매칭)
102
+ ...(additionalData?.fraudScore !== undefined && {
103
+ fraudScore: additionalData.fraudScore,
104
+ fraudReasons: additionalData.fraudReasons,
105
+ riskLevel: additionalData.riskLevel,
106
+ }),
107
+ };
108
+
109
+ await fetch(
110
+ `${this.baseUrl}/advertisements/events/${adId}/${eventType}`,
111
+ {
112
+ method: 'POST',
113
+ headers: ApiHeaders.createForEvents(this.apiKey, eventData),
114
+ body: JSON.stringify(eventData),
115
+ }
116
+ );
117
+
118
+ if (this.debug) {
119
+ console.log(`Tracked advertisement event: ${eventType} for ad ${adId}`, eventData);
120
+ }
121
+ } catch (error) {
122
+ console.error('Failed to track advertisement event:', error);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 크기 값을 숫자로 변환 (서버 API용)
128
+ */
129
+ private static parseNumericValue(value: number | string | undefined): number {
130
+ if (typeof value === 'number') {
131
+ return value;
132
+ }
133
+
134
+ if (typeof value === 'string') {
135
+ // px 단위 제거하고 숫자만 추출
136
+ const numericValue = parseFloat(value.replace(/px$/, ''));
137
+ return isNaN(numericValue) ? 0 : numericValue;
138
+ }
139
+
140
+ return 0; // 기본값
141
+ }
142
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * BasicFraudDetector - 현실적 구현 버전
3
+ * 기본적인 봇 탐지 및 간단한 행동 패턴 분석
4
+ */
5
+
6
+ export interface FraudScore {
7
+ score: number; // 0-100
8
+ riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
9
+ reasons: string[];
10
+ }
11
+
12
+ export interface BasicBrowserInfo {
13
+ userAgent: string;
14
+ language: string;
15
+ platform: string;
16
+ cookieEnabled: boolean;
17
+ doNotTrack: string | null;
18
+ timezone: string;
19
+ screenResolution: string;
20
+ viewportSize: string;
21
+ }
22
+
23
+ export class BasicFraudDetector {
24
+ private startTime: number;
25
+ private mouseEvents: number = 0;
26
+ private keyboardEvents: number = 0;
27
+ private scrollEvents: number = 0;
28
+
29
+ constructor() {
30
+ this.startTime = Date.now();
31
+ this.initBasicTracking();
32
+ }
33
+
34
+ private initBasicTracking(): void {
35
+ // 기본적인 사용자 상호작용 추적
36
+ document.addEventListener('mousemove', () => this.mouseEvents++, { passive: true });
37
+ document.addEventListener('keydown', () => this.keyboardEvents++, { passive: true });
38
+ document.addEventListener('scroll', () => this.scrollEvents++, { passive: true });
39
+ }
40
+
41
+ public calculateFraudScore(): FraudScore {
42
+ let score = 0;
43
+ const reasons: string[] = [];
44
+
45
+ // 1. 웹드라이버 탐지 (기본)
46
+ if (this.detectWebDriver()) {
47
+ score += 50;
48
+ reasons.push('WebDriver detected');
49
+ }
50
+
51
+ // 2. 헤드리스 브라우저 기본 탐지
52
+ if (this.detectBasicHeadless()) {
53
+ score += 40;
54
+ reasons.push('Headless browser signatures');
55
+ }
56
+
57
+ // 3. 사용자 상호작용 부족
58
+ const sessionTime = Date.now() - this.startTime;
59
+ if (sessionTime > 5000) { // 5초 이상 경과
60
+ if (this.mouseEvents === 0) {
61
+ score += 20;
62
+ reasons.push('No mouse interaction');
63
+ }
64
+
65
+ if (this.scrollEvents === 0 && sessionTime > 10000) {
66
+ score += 15;
67
+ reasons.push('No scroll activity');
68
+ }
69
+ }
70
+
71
+ // 4. 브라우저 환경 이상 징후
72
+ const browserCheck = this.checkBrowserEnvironment();
73
+ score += browserCheck.score;
74
+ reasons.push(...browserCheck.reasons);
75
+
76
+ // 5. 시간 패턴 이상 (너무 빠른 페이지 로드 후 즉시 클릭)
77
+ if (sessionTime < 1000) {
78
+ score += 25;
79
+ reasons.push('Suspiciously fast interaction');
80
+ }
81
+
82
+ const finalScore = Math.min(score, 100);
83
+
84
+ return {
85
+ score: finalScore,
86
+ riskLevel: this.getRiskLevel(finalScore),
87
+ reasons: reasons
88
+ };
89
+ }
90
+
91
+ private detectWebDriver(): boolean {
92
+ // 기본적인 웹드라이버 탐지
93
+ return !!(
94
+ (window as any).webdriver ||
95
+ (navigator as any).webdriver ||
96
+ (window as any).__webdriver_evaluate ||
97
+ (window as any).__selenium_evaluate ||
98
+ (window as any).__webdriver_script_function ||
99
+ (window as any).__webdriver_script_func ||
100
+ (window as any).__webdriver_script_fn ||
101
+ (window as any).__fxdriver_evaluate ||
102
+ (window as any).__driver_unwrapped ||
103
+ (window as any).__webdriver_unwrapped ||
104
+ (window as any).__driver_evaluate ||
105
+ (window as any).__selenium_unwrapped ||
106
+ (window as any).__fxdriver_unwrapped
107
+ );
108
+ }
109
+
110
+ private detectBasicHeadless(): boolean {
111
+ const signatures: string[] = [];
112
+
113
+ // PhantomJS 탐지
114
+ if ((window as any)._phantom || (window as any).phantom) {
115
+ signatures.push('PhantomJS');
116
+ }
117
+
118
+ // Chrome headless 기본 탐지
119
+ if (navigator.userAgent.includes('HeadlessChrome')) {
120
+ signatures.push('Chrome Headless');
121
+ }
122
+
123
+ // 플러그인 없음 (일반적이지 않음)
124
+ if (navigator.plugins.length === 0) {
125
+ signatures.push('No plugins');
126
+ }
127
+
128
+ return signatures.length > 0;
129
+ }
130
+
131
+ private checkBrowserEnvironment(): { score: number; reasons: string[] } {
132
+ let score = 0;
133
+ const reasons: string[] = [];
134
+
135
+ // 언어 설정 이상
136
+ if (!navigator.language || navigator.language === 'C') {
137
+ score += 10;
138
+ reasons.push('Unusual language setting');
139
+ }
140
+
141
+ // 쿠키 비활성화
142
+ if (!navigator.cookieEnabled) {
143
+ score += 15;
144
+ reasons.push('Cookies disabled');
145
+ }
146
+
147
+ // 화면 해상도 이상
148
+ if (screen.width === 0 || screen.height === 0) {
149
+ score += 20;
150
+ reasons.push('Invalid screen resolution');
151
+ }
152
+
153
+ // 시간대 정보 없음
154
+ try {
155
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
156
+ if (!timezone || timezone === 'UTC') {
157
+ score += 5;
158
+ reasons.push('No timezone info');
159
+ }
160
+ } catch (e) {
161
+ score += 10;
162
+ reasons.push('Timezone detection failed');
163
+ }
164
+
165
+ return { score, reasons };
166
+ }
167
+
168
+ private getRiskLevel(score: number): 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' {
169
+ if (score >= 70) return 'CRITICAL';
170
+ if (score >= 50) return 'HIGH';
171
+ if (score >= 30) return 'MEDIUM';
172
+ return 'LOW';
173
+ }
174
+
175
+ public getBrowserInfo(): BasicBrowserInfo {
176
+ return {
177
+ userAgent: navigator.userAgent,
178
+ language: navigator.language,
179
+ platform: navigator.platform,
180
+ cookieEnabled: navigator.cookieEnabled,
181
+ doNotTrack: navigator.doNotTrack,
182
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
183
+ screenResolution: `${screen.width}x${screen.height}`,
184
+ viewportSize: `${window.innerWidth}x${window.innerHeight}`
185
+ };
186
+ }
187
+
188
+ public destroy(): void {
189
+ // 이벤트 리스너 정리는 생략 (메모리 누수 방지를 위해 필요시 구현)
190
+ }
191
+ }
@@ -1,6 +1,6 @@
1
- import { AdType, AdEventType } from '../types/advertisement';
2
- import type { AdSlot, Advertisement } from '../types/advertisement';
3
- import { AdRendererFactory } from '../renderers';
1
+ import { AdType, AdEventType } from '../../types/advertisement';
2
+ import type { AdSlot, Advertisement } from '../../types/advertisement';
3
+ import { AdRendererFactory } from '../../renderers';
4
4
 
5
5
  /**
6
6
  * 캐러셀 슬라이더 관리 클래스
@@ -219,7 +219,7 @@ export class CarouselSliderManager {
219
219
 
220
220
  // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
221
221
  if (actualIndex > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
222
- trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.IMPRESSION);
222
+ trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.VIEWABLE);
223
223
  }
224
224
  };
225
225
 
@@ -1,6 +1,6 @@
1
- import { AdType, AdEventType } from '../types/advertisement';
2
- import type { AdSlot, Advertisement } from '../types/advertisement';
3
- import { AdRendererFactory } from '../renderers';
1
+ import { AdType, AdEventType } from '../../types/advertisement';
2
+ import type { AdSlot, Advertisement } from '../../types/advertisement';
3
+ import { AdRendererFactory } from '../../renderers';
4
4
 
5
5
  /**
6
6
  * 텍스트 전환 효과 관리 클래스
@@ -196,7 +196,7 @@ export class TextTransitionManager {
196
196
 
197
197
  // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
198
198
  if (currentSlide > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
199
- trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.IMPRESSION);
199
+ trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.VIEWABLE);
200
200
  }
201
201
  };
202
202
 
@@ -0,0 +1,189 @@
1
+ /**
2
+ * IAB 표준 준수 viewable impression 측정
3
+ */
4
+
5
+ export interface ViewabilityConfig {
6
+ threshold: number; // 노출 비율 (0.5 = 50%)
7
+ minDuration: number; // 최소 지속 시간 (ms)
8
+ maxMeasureTime: number; // 최대 측정 시간 (ms)
9
+ }
10
+
11
+ export interface ViewabilityMetrics {
12
+ isViewable: boolean;
13
+ exposureTime: number;
14
+ maxVisibilityRatio: number;
15
+ firstViewableTime: number | null;
16
+ measureStartTime: number;
17
+ }
18
+
19
+ // 광고 타입별 IAB 표준 설정
20
+ export const VIEWABILITY_STANDARDS: Record<string, ViewabilityConfig> = {
21
+ BANNER: {
22
+ threshold: 0.5,
23
+ minDuration: 1000,
24
+ maxMeasureTime: 30000
25
+ },
26
+ VIDEO: {
27
+ threshold: 0.5,
28
+ minDuration: 2000,
29
+ maxMeasureTime: 60000
30
+ },
31
+ NATIVE: {
32
+ threshold: 0.5,
33
+ minDuration: 1000,
34
+ maxMeasureTime: 30000
35
+ },
36
+ INTERSTITIAL: {
37
+ threshold: 0.5,
38
+ minDuration: 1000,
39
+ maxMeasureTime: 10000
40
+ },
41
+ TEXT: {
42
+ threshold: 0.5,
43
+ minDuration: 1000,
44
+ maxMeasureTime: 30000
45
+ },
46
+ POPUP: {
47
+ threshold: 0.5,
48
+ minDuration: 1000,
49
+ maxMeasureTime: 10000
50
+ }
51
+ };
52
+
53
+ export class ViewabilityTracker {
54
+ private config: ViewabilityConfig;
55
+ private element: HTMLElement;
56
+ private observer: IntersectionObserver | null = null;
57
+ private viewabilityTimer: NodeJS.Timeout | null = null;
58
+ private maxVisibilityTimer: NodeJS.Timeout | null = null;
59
+ private startTime: number = 0;
60
+ private maxVisibilityRatio: number = 0;
61
+ private firstViewableTime: number | null = null;
62
+ private isViewableAchieved: boolean = false;
63
+
64
+ private onViewableCallback?: (metrics: ViewabilityMetrics) => void;
65
+
66
+ constructor(
67
+ element: HTMLElement,
68
+ adType: string,
69
+ onViewable?: (metrics: ViewabilityMetrics) => void
70
+ ) {
71
+ this.element = element;
72
+ this.config = VIEWABILITY_STANDARDS[adType] || VIEWABILITY_STANDARDS.BANNER;
73
+ this.onViewableCallback = onViewable;
74
+
75
+ this.startTime = performance.now();
76
+ this.initIntersectionObserver();
77
+ this.initMaxMeasureTimer();
78
+ }
79
+
80
+ private initIntersectionObserver(): void {
81
+ // IntersectionObserver 지원 확인
82
+ if (!('IntersectionObserver' in window)) {
83
+ console.warn('IntersectionObserver not supported, viewability tracking disabled');
84
+ return;
85
+ }
86
+
87
+ this.observer = new IntersectionObserver(
88
+ (entries) => this.handleIntersection(entries),
89
+ {
90
+ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0],
91
+ rootMargin: '0px'
92
+ }
93
+ );
94
+
95
+ this.observer.observe(this.element);
96
+ }
97
+
98
+ private handleIntersection(entries: IntersectionObserverEntry[]): void {
99
+ entries.forEach(entry => {
100
+ const visibilityRatio = entry.intersectionRatio;
101
+ const isVisible = this.isDocumentVisible();
102
+
103
+ // 최대 가시성 비율 추적
104
+ this.maxVisibilityRatio = Math.max(this.maxVisibilityRatio, visibilityRatio);
105
+
106
+ // Viewable 조건 확인 (50% 이상 + 문서 가시성)
107
+ if (visibilityRatio >= this.config.threshold && isVisible) {
108
+ this.startViewabilityTimer();
109
+ } else {
110
+ this.stopViewabilityTimer();
111
+ }
112
+ });
113
+ }
114
+
115
+ private isDocumentVisible(): boolean {
116
+ // 단순한 문서 가시성 확인
117
+ return !document.hidden && document.visibilityState === 'visible';
118
+ }
119
+
120
+ private startViewabilityTimer(): void {
121
+ if (this.viewabilityTimer || this.isViewableAchieved) return;
122
+
123
+ if (this.firstViewableTime === null) {
124
+ this.firstViewableTime = performance.now();
125
+ }
126
+
127
+ this.viewabilityTimer = setTimeout(() => {
128
+ this.onViewabilityAchieved();
129
+ }, this.config.minDuration);
130
+ }
131
+
132
+ private stopViewabilityTimer(): void {
133
+ if (this.viewabilityTimer) {
134
+ clearTimeout(this.viewabilityTimer);
135
+ this.viewabilityTimer = null;
136
+ }
137
+ }
138
+
139
+ private initMaxMeasureTimer(): void {
140
+ // 최대 측정 시간 후 자동 종료
141
+ this.maxVisibilityTimer = setTimeout(() => {
142
+ this.destroy();
143
+ }, this.config.maxMeasureTime);
144
+ }
145
+
146
+ private onViewabilityAchieved(): void {
147
+ if (this.isViewableAchieved) return;
148
+
149
+ this.isViewableAchieved = true;
150
+ const metrics = this.calculateMetrics();
151
+
152
+ if (this.onViewableCallback) {
153
+ this.onViewableCallback(metrics);
154
+ }
155
+ }
156
+
157
+ private calculateMetrics(): ViewabilityMetrics {
158
+ const currentTime = performance.now();
159
+ const exposureTime = this.firstViewableTime
160
+ ? currentTime - this.firstViewableTime
161
+ : 0;
162
+
163
+ return {
164
+ isViewable: this.isViewableAchieved,
165
+ exposureTime,
166
+ maxVisibilityRatio: this.maxVisibilityRatio,
167
+ firstViewableTime: this.firstViewableTime,
168
+ measureStartTime: this.startTime,
169
+ };
170
+ }
171
+
172
+ public getMetrics(): ViewabilityMetrics {
173
+ return this.calculateMetrics();
174
+ }
175
+
176
+ public destroy(): void {
177
+ this.stopViewabilityTimer();
178
+
179
+ if (this.maxVisibilityTimer) {
180
+ clearTimeout(this.maxVisibilityTimer);
181
+ this.maxVisibilityTimer = null;
182
+ }
183
+
184
+ if (this.observer) {
185
+ this.observer.disconnect();
186
+ this.observer = null;
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * VIEWABLE 이벤트 추적 및 중복 방지 관리 클래스
3
+ * - 메모리 기반 중복 확인
4
+ * - 세션 스토리지 기반 영구 추적
5
+ * - 자동 정리 기능
6
+ * - ViewabilityTracker와 함께 사용하여 중복 viewable 이벤트 방지
7
+ */
8
+ export class ViewableEventTracker {
9
+ private static viewableTracker = new Map<string, number>();
10
+ private static readonly VIEWABLE_COOLDOWN = 300000; // 5분 쿨다운
11
+
12
+ /**
13
+ * 중복 viewable 이벤트 여부 확인
14
+ */
15
+ static isDuplicateViewable(adId: string, slotId: string, debug = false): boolean {
16
+ const key = `${adId}_${slotId}`;
17
+ const now = Date.now();
18
+
19
+ // 메모리 기반 중복 확인 (새로고침 시 초기화됨)
20
+ const lastViewable = ViewableEventTracker.viewableTracker.get(key);
21
+ if (lastViewable && (now - lastViewable) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
22
+ if (debug) {
23
+ console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - lastViewable)) / 1000)}s remaining`);
24
+ }
25
+ return true;
26
+ }
27
+
28
+ // 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
29
+ const sessionKey = `adstage_viewable_${key}`;
30
+ const sessionViewable = sessionStorage.getItem(sessionKey);
31
+ if (sessionViewable) {
32
+ const sessionTime = parseInt(sessionViewable, 10);
33
+ if (!isNaN(sessionTime) && (now - sessionTime) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
34
+ if (debug) {
35
+ console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
36
+ }
37
+ // 메모리에도 기록하여 이후 요청 최적화
38
+ ViewableEventTracker.viewableTracker.set(key, sessionTime);
39
+ return true;
40
+ }
41
+ }
42
+
43
+ // viewable 이벤트 시점 기록 (메모리 + 세션 스토리지)
44
+ ViewableEventTracker.viewableTracker.set(key, now);
45
+ sessionStorage.setItem(sessionKey, now.toString());
46
+
47
+ // 오래된 세션 스토리지 데이터 정리 (선택적)
48
+ ViewableEventTracker.cleanupOldViewables();
49
+
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * 오래된 viewable 추적 데이터 정리
55
+ */
56
+ private static cleanupOldViewables(): void {
57
+ const now = Date.now();
58
+ const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
59
+
60
+ // 세션 스토리지 정리
61
+ for (let i = 0; i < sessionStorage.length; i++) {
62
+ const key = sessionStorage.key(i);
63
+ if (key && key.startsWith('adstage_viewable_')) {
64
+ const timestamp = sessionStorage.getItem(key);
65
+ if (timestamp) {
66
+ const time = parseInt(timestamp, 10);
67
+ if (!isNaN(time) && (now - time) > cleanupThreshold) {
68
+ sessionStorage.removeItem(key);
69
+ i--; // 인덱스 조정
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ // 메모리 정리
76
+ for (const [key, timestamp] of ViewableEventTracker.viewableTracker.entries()) {
77
+ if ((now - timestamp) > cleanupThreshold) {
78
+ ViewableEventTracker.viewableTracker.delete(key);
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 모든 추적 데이터 정리
85
+ */
86
+ static clear(): void {
87
+ ViewableEventTracker.viewableTracker.clear();
88
+ }
89
+ }