@adstage/web-sdk 1.4.0 → 2.1.0
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 +460 -98
- package/dist/index.cjs.js +468 -92
- package/dist/index.d.ts +16 -12
- package/dist/index.esm.js +468 -92
- package/dist/index.standalone.js +468 -92
- package/package.json +12 -13
- 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 +151 -28
- 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/src/modules/deeplinks/DeeplinksModule.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
import { AdStageConfig, BaseModule } from '../../types/config';
|
|
7
7
|
import { AdType, AdEventType } from '../../types/advertisement';
|
|
8
8
|
import type { AdSlot, Advertisement } from '../../types/advertisement';
|
|
9
|
-
import { CarouselSliderManager } from '../../managers/carousel-slider-manager';
|
|
10
|
-
import { TextTransitionManager } from '../../managers/text-transition-manager';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
9
|
+
import { CarouselSliderManager } from '../../managers/ads/carousel-slider-manager';
|
|
10
|
+
import { TextTransitionManager } from '../../managers/ads/text-transition-manager';
|
|
11
|
+
import { ViewableEventTracker } from '../../managers/ads/viewable-event-tracker';
|
|
12
|
+
import { AdvertisementEventTracker } from '../../managers/ads/advertisement-event-tracker';
|
|
13
|
+
import { ViewabilityTracker, VIEWABILITY_STANDARDS } from '../../managers/ads/viewability-tracker';
|
|
14
|
+
import { BasicFraudDetector } from '../../managers/ads/basic-fraud-detector';
|
|
15
|
+
import type { ViewabilityMetrics } from '../../managers/ads/viewability-tracker';
|
|
13
16
|
import { endpoints } from '../../constants/endpoints';
|
|
14
17
|
import { ApiHeaders } from '../../utils/api-headers';
|
|
15
18
|
|
|
@@ -23,13 +26,18 @@ export interface AdOptions {
|
|
|
23
26
|
autoplay?: boolean;
|
|
24
27
|
muted?: boolean;
|
|
25
28
|
onClick?: (adData: any) => void;
|
|
29
|
+
// 광고 필터링 옵션 (백엔드 API에서 실제 지원하는 것들만)
|
|
30
|
+
language?: 'ko' | 'en' | 'ja' | 'zh';
|
|
31
|
+
deviceType?: 'MOBILE' | 'DESKTOP';
|
|
32
|
+
country?: 'KR' | 'US' | 'JP' | 'CN' | 'DE';
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export class AdsModule implements BaseModule {
|
|
29
36
|
private _isReady = false;
|
|
30
37
|
private _config: AdStageConfig | null = null;
|
|
31
38
|
private slots = new Map<string, AdSlot>();
|
|
32
|
-
|
|
39
|
+
// Advertisement 이벤트 추적 관련
|
|
40
|
+
private advertisementEventTracker: AdvertisementEventTracker | null = null;
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* Ads 모듈 초기화 (동기)
|
|
@@ -37,8 +45,8 @@ export class AdsModule implements BaseModule {
|
|
|
37
45
|
init(config: AdStageConfig): void {
|
|
38
46
|
this._config = config;
|
|
39
47
|
|
|
40
|
-
//
|
|
41
|
-
this.
|
|
48
|
+
// AdvertisementEventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
|
|
49
|
+
this.advertisementEventTracker = new AdvertisementEventTracker(
|
|
42
50
|
endpoints.getBaseUrl(),
|
|
43
51
|
config.apiKey,
|
|
44
52
|
config.debug || false,
|
|
@@ -77,7 +85,11 @@ export class AdsModule implements BaseModule {
|
|
|
77
85
|
height: options?.height || 250,
|
|
78
86
|
autoSlide: options?.autoSlide || false,
|
|
79
87
|
slideInterval: options?.slideInterval || 5000,
|
|
80
|
-
onClick: options?.onClick
|
|
88
|
+
onClick: options?.onClick,
|
|
89
|
+
// 필터링 옵션들 전달
|
|
90
|
+
language: options?.language,
|
|
91
|
+
deviceType: options?.deviceType,
|
|
92
|
+
country: options?.country
|
|
81
93
|
};
|
|
82
94
|
|
|
83
95
|
return this.createAd(containerId, AdType.BANNER, adstageOptions);
|
|
@@ -92,7 +104,11 @@ export class AdsModule implements BaseModule {
|
|
|
92
104
|
const adstageOptions = {
|
|
93
105
|
maxLines: options?.maxLines || 3,
|
|
94
106
|
style: options?.style || 'default',
|
|
95
|
-
onClick: options?.onClick
|
|
107
|
+
onClick: options?.onClick,
|
|
108
|
+
// 필터링 옵션들 전달
|
|
109
|
+
language: options?.language,
|
|
110
|
+
deviceType: options?.deviceType,
|
|
111
|
+
country: options?.country
|
|
96
112
|
};
|
|
97
113
|
|
|
98
114
|
return this.createAd(containerId, AdType.TEXT, adstageOptions);
|
|
@@ -163,6 +179,16 @@ export class AdsModule implements BaseModule {
|
|
|
163
179
|
throw new Error(`Ad slot not found: ${slotId}`);
|
|
164
180
|
}
|
|
165
181
|
|
|
182
|
+
// ViewabilityTracker 정리
|
|
183
|
+
if ((slot as any).viewabilityTracker) {
|
|
184
|
+
(slot as any).viewabilityTracker.destroy();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// BasicFraudDetector 정리
|
|
188
|
+
if ((slot as any).fraudDetector) {
|
|
189
|
+
(slot as any).fraudDetector.destroy();
|
|
190
|
+
}
|
|
191
|
+
|
|
166
192
|
// DOM에서 제거
|
|
167
193
|
const container = document.getElementById(slot.containerId);
|
|
168
194
|
if (container) {
|
|
@@ -239,8 +265,8 @@ export class AdsModule implements BaseModule {
|
|
|
239
265
|
this.loadAdContentInBackground(slot);
|
|
240
266
|
|
|
241
267
|
// 이벤트 추적 준비
|
|
242
|
-
if (this.
|
|
243
|
-
console.log(`📊
|
|
268
|
+
if (this.advertisementEventTracker && this._config?.debug) {
|
|
269
|
+
console.log(`📊 Advertisement event tracking enabled for slot: ${slotId}`);
|
|
244
270
|
}
|
|
245
271
|
|
|
246
272
|
return slotId;
|
|
@@ -290,6 +316,9 @@ export class AdsModule implements BaseModule {
|
|
|
290
316
|
// 광고가 1개면 일반 렌더링
|
|
291
317
|
slot.advertisement = adstageData[0];
|
|
292
318
|
await this.renderAdElement(slot, adstageData[0]);
|
|
319
|
+
|
|
320
|
+
// ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
|
|
321
|
+
this.startBasicViewabilityTracking(slot, adstageData[0]);
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
slot.isLoaded = true;
|
|
@@ -304,18 +333,92 @@ export class AdsModule implements BaseModule {
|
|
|
304
333
|
}
|
|
305
334
|
|
|
306
335
|
/**
|
|
307
|
-
*
|
|
336
|
+
* 기본 viewability 추적 시작
|
|
337
|
+
*/
|
|
338
|
+
private startBasicViewabilityTracking(slot: AdSlot, ad: Advertisement): void {
|
|
339
|
+
const element = document.getElementById(slot.id);
|
|
340
|
+
if (!element) return;
|
|
341
|
+
|
|
342
|
+
// 기본 fraud 검사
|
|
343
|
+
const fraudDetector = new BasicFraudDetector();
|
|
344
|
+
|
|
345
|
+
// viewability 추적
|
|
346
|
+
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
347
|
+
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// 정리를 위해 저장
|
|
351
|
+
(slot as any).viewabilityTracker = tracker;
|
|
352
|
+
(slot as any).fraudDetector = fraudDetector;
|
|
353
|
+
|
|
354
|
+
if (this._config?.debug) {
|
|
355
|
+
console.log(`🎯 Viewability tracking started for slot: ${slot.id}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Viewable 이벤트 처리
|
|
361
|
+
*/
|
|
362
|
+
private async handleViewableEvent(
|
|
363
|
+
ad: Advertisement,
|
|
364
|
+
slot: AdSlot,
|
|
365
|
+
metrics: ViewabilityMetrics,
|
|
366
|
+
fraudDetector: BasicFraudDetector
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
try {
|
|
369
|
+
const fraudScore = fraudDetector.calculateFraudScore();
|
|
370
|
+
|
|
371
|
+
// 높은 위험도면 차단
|
|
372
|
+
if (fraudScore.riskLevel === 'CRITICAL') {
|
|
373
|
+
if (this._config?.debug) {
|
|
374
|
+
console.warn(`🚫 Viewable blocked due to fraud risk: ${fraudScore.score}`, fraudScore.reasons);
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// VIEWABLE 이벤트 전송
|
|
380
|
+
if (this.advertisementEventTracker) {
|
|
381
|
+
await this.advertisementEventTracker.trackAdvertisementEvent(
|
|
382
|
+
ad._id,
|
|
383
|
+
slot.id,
|
|
384
|
+
AdEventType.VIEWABLE,
|
|
385
|
+
{
|
|
386
|
+
viewabilityMetrics: metrics,
|
|
387
|
+
fraudScore: fraudScore.score,
|
|
388
|
+
fraudReasons: fraudScore.reasons,
|
|
389
|
+
riskLevel: fraudScore.riskLevel
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
if (this._config?.debug) {
|
|
394
|
+
console.log(`✅ Viewable impression tracked for ad ${ad._id} (fraud score: ${fraudScore.score})`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error(`❌ Failed to track viewable impression:`, error);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Fallback 광고 렌더링 - DOM에서 완전 제거
|
|
308
405
|
*/
|
|
309
406
|
private renderFallback(slot: AdSlot): void {
|
|
310
407
|
const element = document.getElementById(slot.id);
|
|
311
408
|
if (element) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
409
|
+
// 부모 컨테이너에서 광고 슬롯을 완전히 제거
|
|
410
|
+
const parentContainer = element.parentNode;
|
|
411
|
+
if (parentContainer) {
|
|
412
|
+
parentContainer.removeChild(element);
|
|
413
|
+
|
|
414
|
+
if (this._config?.debug) {
|
|
415
|
+
console.warn(`⚠️ Ad slot completely removed from DOM: ${slot.id}`);
|
|
416
|
+
}
|
|
317
417
|
}
|
|
318
418
|
}
|
|
419
|
+
|
|
420
|
+
// 슬롯 맵에서도 제거
|
|
421
|
+
this.slots.delete(slot.id);
|
|
319
422
|
}
|
|
320
423
|
|
|
321
424
|
/**
|
|
@@ -330,8 +433,18 @@ export class AdsModule implements BaseModule {
|
|
|
330
433
|
const params = new URLSearchParams();
|
|
331
434
|
params.append('adType', type);
|
|
332
435
|
|
|
333
|
-
//
|
|
334
|
-
|
|
436
|
+
// 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
|
|
437
|
+
if (options.language) {
|
|
438
|
+
params.append('language', options.language);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (options.deviceType) {
|
|
442
|
+
params.append('deviceType', options.deviceType);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (options.country) {
|
|
446
|
+
params.append('country', options.country);
|
|
447
|
+
}
|
|
335
448
|
|
|
336
449
|
const url = `${endpoints.advertisements.list()}?${params.toString()}`;
|
|
337
450
|
|
|
@@ -345,7 +458,17 @@ export class AdsModule implements BaseModule {
|
|
|
345
458
|
}
|
|
346
459
|
|
|
347
460
|
const result = await response.json();
|
|
348
|
-
|
|
461
|
+
const advertisements = result.advertisements || [];
|
|
462
|
+
|
|
463
|
+
if (this._config?.debug) {
|
|
464
|
+
console.log(`📊 Fetched ${advertisements.length} ads for type: ${type}, filters:`, {
|
|
465
|
+
language: options.language,
|
|
466
|
+
deviceType: options.deviceType,
|
|
467
|
+
country: options.country
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return advertisements;
|
|
349
472
|
}
|
|
350
473
|
|
|
351
474
|
/**
|
|
@@ -360,21 +483,21 @@ export class AdsModule implements BaseModule {
|
|
|
360
483
|
// 이벤트 추적 콜백 함수 (중복 노출 방지 포함)
|
|
361
484
|
const trackEventCallback = (adId: string, slotId: string, eventType: AdEventType) => {
|
|
362
485
|
// 노출 이벤트인 경우 중복 확인
|
|
363
|
-
if (eventType === AdEventType.
|
|
364
|
-
if (
|
|
486
|
+
if (eventType === AdEventType.VIEWABLE) {
|
|
487
|
+
if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
|
|
365
488
|
if (this._config?.debug) {
|
|
366
|
-
console.log(`🚫 Duplicate
|
|
489
|
+
console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
|
|
367
490
|
}
|
|
368
491
|
return; // 중복 노출이면 추적하지 않음
|
|
369
492
|
}
|
|
370
493
|
|
|
371
494
|
if (this._config?.debug) {
|
|
372
|
-
console.log(`✅ New
|
|
495
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
|
|
373
496
|
}
|
|
374
497
|
}
|
|
375
498
|
|
|
376
|
-
if (this.
|
|
377
|
-
console.log(`📊
|
|
499
|
+
if (this.advertisementEventTracker && this._config?.debug) {
|
|
500
|
+
console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
|
|
378
501
|
}
|
|
379
502
|
};
|
|
380
503
|
|
|
@@ -505,8 +628,8 @@ export class AdsModule implements BaseModule {
|
|
|
505
628
|
await this.renderAd(slot);
|
|
506
629
|
|
|
507
630
|
// 새로운 노출 추적
|
|
508
|
-
if (this.
|
|
509
|
-
console.log('New
|
|
631
|
+
if (this.advertisementEventTracker) {
|
|
632
|
+
console.log('New advertisement viewable tracked for slot:', slot.id);
|
|
510
633
|
}
|
|
511
634
|
}
|
|
512
635
|
} catch (error) {
|
|
@@ -16,15 +16,8 @@ export enum Platform {
|
|
|
16
16
|
|
|
17
17
|
// 광고 이벤트 타입
|
|
18
18
|
export enum AdEventType {
|
|
19
|
-
IMPRESSION = 'IMPRESSION',
|
|
20
|
-
CLICK = 'CLICK',
|
|
21
|
-
HOVER = 'HOVER',
|
|
22
19
|
VIEWABLE = 'VIEWABLE',
|
|
23
|
-
|
|
24
|
-
COMPLETED = 'COMPLETED',
|
|
25
|
-
VIDEO_START = 'VIDEO_START',
|
|
26
|
-
VIDEO_COMPLETE = 'VIDEO_COMPLETE',
|
|
27
|
-
ERROR = 'ERROR',
|
|
20
|
+
CLICK = 'CLICK',
|
|
28
21
|
}
|
|
29
22
|
|
|
30
23
|
// 디바이스 타입
|
|
@@ -79,7 +72,7 @@ export interface ViewabilityMetrics {
|
|
|
79
72
|
isViewable: boolean;
|
|
80
73
|
visibilityRatio: number;
|
|
81
74
|
duration: number;
|
|
82
|
-
|
|
75
|
+
viewables: number;
|
|
83
76
|
attentionTime: number;
|
|
84
77
|
scrollDepth: number;
|
|
85
78
|
completionRate: number;
|
|
@@ -171,7 +164,7 @@ export interface AdSlot {
|
|
|
171
164
|
config?: AdSlotConfig;
|
|
172
165
|
advertisement?: Advertisement;
|
|
173
166
|
isViewable?: boolean;
|
|
174
|
-
|
|
167
|
+
viewableSent?: boolean;
|
|
175
168
|
loadTime?: number;
|
|
176
169
|
renderTime?: number;
|
|
177
170
|
events?: AdEvent[];
|
|
@@ -185,13 +178,7 @@ export interface AdSlot {
|
|
|
185
178
|
|
|
186
179
|
// 광고 성과 지표
|
|
187
180
|
export interface AdAnalytics {
|
|
188
|
-
|
|
181
|
+
viewables: number;
|
|
189
182
|
clicks: number;
|
|
190
|
-
hovers: number;
|
|
191
|
-
viewableImpressions: number;
|
|
192
|
-
errors: number;
|
|
193
183
|
ctr: number; // Click Through Rate
|
|
194
|
-
viewabilityRate: number;
|
|
195
|
-
averageViewTime: number;
|
|
196
|
-
totalViewTime: number;
|
|
197
184
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -76,13 +76,9 @@ export interface AnalyticsRequest {
|
|
|
76
76
|
|
|
77
77
|
// 광고 분석 응답
|
|
78
78
|
export interface AnalyticsResponse {
|
|
79
|
-
|
|
79
|
+
viewables: number;
|
|
80
80
|
clicks: number;
|
|
81
|
-
hovers: number;
|
|
82
|
-
viewableImpressions: number;
|
|
83
|
-
errors: number;
|
|
84
81
|
ctr: number;
|
|
85
|
-
viewabilityRate: number;
|
|
86
82
|
breakdown?: Record<string, AdAnalytics>;
|
|
87
83
|
}
|
|
88
84
|
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { AdEventType } from '../types/advertisement';
|
|
2
|
-
import type { AdSlot } from '../types/advertisement';
|
|
3
|
-
import { ImpressionTracker } from './impression-tracker';
|
|
4
|
-
import { DeviceInfoCollector } from './device-info-collector';
|
|
5
|
-
import { DOMUtils } from '../utils/dom-utils';
|
|
6
|
-
import { ApiHeaders } from '../utils/api-headers';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 이벤트 추적 관리 클래스
|
|
10
|
-
* - 광고 이벤트 추적 및 전송
|
|
11
|
-
* - 중복 노출 방지 통합
|
|
12
|
-
* - 서버 API 통신
|
|
13
|
-
*/
|
|
14
|
-
export class EventTracker {
|
|
15
|
-
private baseUrl: string;
|
|
16
|
-
private apiKey: string;
|
|
17
|
-
private debug: boolean;
|
|
18
|
-
private slots: Map<string, AdSlot>;
|
|
19
|
-
|
|
20
|
-
constructor(baseUrl: string, apiKey: string, debug: boolean, slots: Map<string, AdSlot>) {
|
|
21
|
-
this.baseUrl = baseUrl;
|
|
22
|
-
this.apiKey = apiKey;
|
|
23
|
-
this.debug = debug;
|
|
24
|
-
this.slots = slots;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* 이벤트 추적
|
|
29
|
-
*/
|
|
30
|
-
async trackEvent(adId: string, slotId: string, eventType: AdEventType): Promise<void> {
|
|
31
|
-
try {
|
|
32
|
-
// 노출 이벤트의 경우 중복 확인
|
|
33
|
-
if (eventType === AdEventType.IMPRESSION) {
|
|
34
|
-
if (ImpressionTracker.isDuplicateImpression(adId, slotId, this.debug)) {
|
|
35
|
-
return; // 중복 노출이므로 추적하지 않음
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 현재 슬롯 정보 가져오기
|
|
40
|
-
const slot = this.slots.get(slotId);
|
|
41
|
-
|
|
42
|
-
// 디바이스 정보 수집
|
|
43
|
-
const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
|
|
44
|
-
|
|
45
|
-
// 이벤트 데이터 구성 (MongoDB 스키마에 맞춤)
|
|
46
|
-
const eventData = {
|
|
47
|
-
// 서버에서 자동 설정: orgId, advertisementId, action
|
|
48
|
-
// 하지만 DTO 검증을 위해 임시값 제공
|
|
49
|
-
orgId: 'temp', // 서버에서 API 키로부터 덮어씀
|
|
50
|
-
advertisementId: adId, // 서버에서 URL 파라미터로부터 덮어씀
|
|
51
|
-
action: eventType, // 서버에서 URL 파라미터로부터 덮어씀
|
|
52
|
-
|
|
53
|
-
// 필수 필드들
|
|
54
|
-
adType: slot?.adType || 'BANNER',
|
|
55
|
-
platform: deviceInfo.platform,
|
|
56
|
-
|
|
57
|
-
// 디바이스 정보를 최상위로 플래튼
|
|
58
|
-
deviceId: deviceInfo.deviceId,
|
|
59
|
-
osVersion: deviceInfo.osVersion,
|
|
60
|
-
deviceModel: deviceInfo.deviceModel,
|
|
61
|
-
appVersion: deviceInfo.appVersion,
|
|
62
|
-
sdkVersion: deviceInfo.sdkVersion,
|
|
63
|
-
language: deviceInfo.language,
|
|
64
|
-
country: deviceInfo.country,
|
|
65
|
-
ipAddress: deviceInfo.ipAddress,
|
|
66
|
-
userAgent: deviceInfo.userAgent,
|
|
67
|
-
timezone: deviceInfo.timezone,
|
|
68
|
-
viewportWidth: deviceInfo.viewportWidth,
|
|
69
|
-
viewportHeight: deviceInfo.viewportHeight,
|
|
70
|
-
screenWidth: deviceInfo.screenWidth,
|
|
71
|
-
screenHeight: deviceInfo.screenHeight,
|
|
72
|
-
connectionType: deviceInfo.connectionType,
|
|
73
|
-
|
|
74
|
-
// 페이지 및 슬롯 정보
|
|
75
|
-
pageUrl: DOMUtils.getPageInfo().url,
|
|
76
|
-
pageTitle: DOMUtils.getPageInfo().title,
|
|
77
|
-
referrer: DOMUtils.getPageInfo().referrer,
|
|
78
|
-
slotId,
|
|
79
|
-
slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
|
|
80
|
-
slotWidth: EventTracker.parseNumericValue(slot?.width),
|
|
81
|
-
slotHeight: EventTracker.parseNumericValue(slot?.height),
|
|
82
|
-
sessionId: deviceInfo.sessionId,
|
|
83
|
-
|
|
84
|
-
// 성능 메트릭
|
|
85
|
-
pageLoadTime: performance.now(),
|
|
86
|
-
timestamp: new Date().toISOString(),
|
|
87
|
-
|
|
88
|
-
// 추가 메타데이터
|
|
89
|
-
metadata: {
|
|
90
|
-
eventType,
|
|
91
|
-
sdkVersion: '1.0.0',
|
|
92
|
-
timestamp: Date.now(),
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
await fetch(
|
|
97
|
-
`${this.baseUrl}/advertisements/events/${adId}/${eventType}`,
|
|
98
|
-
{
|
|
99
|
-
method: 'POST',
|
|
100
|
-
headers: ApiHeaders.createForEvents(this.apiKey, eventData),
|
|
101
|
-
body: JSON.stringify(eventData),
|
|
102
|
-
}
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
if (this.debug) {
|
|
106
|
-
console.log(`Tracked event: ${eventType} for ad ${adId}`, eventData);
|
|
107
|
-
}
|
|
108
|
-
} catch (error) {
|
|
109
|
-
console.error('Failed to track event:', error);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* 크기 값을 숫자로 변환 (서버 API용)
|
|
115
|
-
*/
|
|
116
|
-
private static parseNumericValue(value: number | string | undefined): number {
|
|
117
|
-
if (typeof value === 'number') {
|
|
118
|
-
return value;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (typeof value === 'string') {
|
|
122
|
-
// px 단위 제거하고 숫자만 추출
|
|
123
|
-
const numericValue = parseFloat(value.replace(/px$/, ''));
|
|
124
|
-
return isNaN(numericValue) ? 0 : numericValue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return 0; // 기본값
|
|
128
|
-
}
|
|
129
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 노출 추적 및 중복 방지 관리 클래스
|
|
3
|
-
* - 메모리 기반 중복 확인
|
|
4
|
-
* - 세션 스토리지 기반 영구 추적
|
|
5
|
-
* - 자동 정리 기능
|
|
6
|
-
*/
|
|
7
|
-
export class ImpressionTracker {
|
|
8
|
-
private static impressionTracker = new Map<string, number>();
|
|
9
|
-
private static readonly IMPRESSION_COOLDOWN = 300000; // 5분 쿨다운
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 중복 노출 여부 확인
|
|
13
|
-
*/
|
|
14
|
-
static isDuplicateImpression(adId: string, slotId: string, debug = false): boolean {
|
|
15
|
-
const key = `${adId}_${slotId}`;
|
|
16
|
-
const now = Date.now();
|
|
17
|
-
|
|
18
|
-
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
19
|
-
const lastImpression = ImpressionTracker.impressionTracker.get(key);
|
|
20
|
-
if (lastImpression && (now - lastImpression) < ImpressionTracker.IMPRESSION_COOLDOWN) {
|
|
21
|
-
if (debug) {
|
|
22
|
-
console.log(`Duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - lastImpression)) / 1000)}s remaining`);
|
|
23
|
-
}
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
|
|
28
|
-
const sessionKey = `adstage_impression_${key}`;
|
|
29
|
-
const sessionImpression = sessionStorage.getItem(sessionKey);
|
|
30
|
-
if (sessionImpression) {
|
|
31
|
-
const sessionTime = parseInt(sessionImpression, 10);
|
|
32
|
-
if (!isNaN(sessionTime) && (now - sessionTime) < ImpressionTracker.IMPRESSION_COOLDOWN) {
|
|
33
|
-
if (debug) {
|
|
34
|
-
console.log(`Session-based duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
|
|
35
|
-
}
|
|
36
|
-
// 메모리에도 기록하여 이후 요청 최적화
|
|
37
|
-
ImpressionTracker.impressionTracker.set(key, sessionTime);
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 노출 시점 기록 (메모리 + 세션 스토리지)
|
|
43
|
-
ImpressionTracker.impressionTracker.set(key, now);
|
|
44
|
-
sessionStorage.setItem(sessionKey, now.toString());
|
|
45
|
-
|
|
46
|
-
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
47
|
-
ImpressionTracker.cleanupOldImpressions();
|
|
48
|
-
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 오래된 노출 추적 데이터 정리
|
|
54
|
-
*/
|
|
55
|
-
private static cleanupOldImpressions(): void {
|
|
56
|
-
const now = Date.now();
|
|
57
|
-
const cleanupThreshold = ImpressionTracker.IMPRESSION_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
|
|
58
|
-
|
|
59
|
-
// 세션 스토리지 정리
|
|
60
|
-
for (let i = 0; i < sessionStorage.length; i++) {
|
|
61
|
-
const key = sessionStorage.key(i);
|
|
62
|
-
if (key && key.startsWith('adstage_impression_')) {
|
|
63
|
-
const timestamp = sessionStorage.getItem(key);
|
|
64
|
-
if (timestamp) {
|
|
65
|
-
const time = parseInt(timestamp, 10);
|
|
66
|
-
if (!isNaN(time) && (now - time) > cleanupThreshold) {
|
|
67
|
-
sessionStorage.removeItem(key);
|
|
68
|
-
i--; // 인덱스 조정
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 메모리 정리
|
|
75
|
-
for (const [key, timestamp] of ImpressionTracker.impressionTracker.entries()) {
|
|
76
|
-
if ((now - timestamp) > cleanupThreshold) {
|
|
77
|
-
ImpressionTracker.impressionTracker.delete(key);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* 모든 추적 데이터 정리
|
|
84
|
-
*/
|
|
85
|
-
static clear(): void {
|
|
86
|
-
ImpressionTracker.impressionTracker.clear();
|
|
87
|
-
}
|
|
88
|
-
}
|
|
File without changes
|