@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/README.md +1 -1
- package/dist/index.cjs.js +559 -91
- package/dist/index.d.ts +20 -12
- package/dist/index.esm.js +559 -91
- package/dist/index.standalone.js +559 -91
- package/package.json +1 -1
- package/src/managers/ads/advertisement-event-tracker.ts +142 -0
- package/src/managers/ads/basic-fraud-detector.ts +191 -0
- package/src/managers/{carousel-slider-manager.ts → ads/carousel-slider-manager.ts} +4 -4
- package/src/managers/{text-transition-manager.ts → ads/text-transition-manager.ts} +4 -4
- package/src/managers/ads/viewability-tracker.ts +189 -0
- package/src/managers/ads/viewable-event-tracker.ts +89 -0
- package/src/modules/ads/AdsModule.ts +252 -26
- package/src/types/advertisement.ts +4 -17
- package/src/types/api.ts +1 -5
- package/src/managers/event-tracker.ts +0 -129
- package/src/managers/impression-tracker.ts +0 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adstage/web-sdk",
|
|
3
|
-
"version": "2.
|
|
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 '
|
|
2
|
-
import type { AdSlot, Advertisement } from '
|
|
3
|
-
import { AdRendererFactory } from '
|
|
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.
|
|
222
|
+
trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.VIEWABLE);
|
|
223
223
|
}
|
|
224
224
|
};
|
|
225
225
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AdType, AdEventType } from '
|
|
2
|
-
import type { AdSlot, Advertisement } from '
|
|
3
|
-
import { AdRendererFactory } from '
|
|
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.
|
|
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
|
+
}
|