@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
|
@@ -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;
|
|
@@ -253,6 +279,11 @@ export class AdsModule implements BaseModule {
|
|
|
253
279
|
const adElement = document.createElement('div');
|
|
254
280
|
adElement.id = slotId;
|
|
255
281
|
adElement.className = `adstage-slot adstage-${type.toLowerCase()}`;
|
|
282
|
+
// 확실한 컨테이너 식별을 위한 데이터 속성 추가
|
|
283
|
+
adElement.setAttribute('data-adstage-container', 'true');
|
|
284
|
+
adElement.setAttribute('data-adstage-type', type);
|
|
285
|
+
adElement.setAttribute('data-adstage-slot', slotId);
|
|
286
|
+
|
|
256
287
|
adElement.style.width = typeof options.width === 'number' ? `${options.width}px` : (options.width || '100%');
|
|
257
288
|
adElement.style.height = typeof options.height === 'number' ? `${options.height}px` : (options.height || '250px');
|
|
258
289
|
adElement.style.border = '1px dashed #ccc';
|
|
@@ -290,6 +321,9 @@ export class AdsModule implements BaseModule {
|
|
|
290
321
|
// 광고가 1개면 일반 렌더링
|
|
291
322
|
slot.advertisement = adstageData[0];
|
|
292
323
|
await this.renderAdElement(slot, adstageData[0]);
|
|
324
|
+
|
|
325
|
+
// ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
|
|
326
|
+
this.startBasicViewabilityTracking(slot, adstageData[0]);
|
|
293
327
|
}
|
|
294
328
|
|
|
295
329
|
slot.isLoaded = true;
|
|
@@ -304,16 +338,188 @@ export class AdsModule implements BaseModule {
|
|
|
304
338
|
}
|
|
305
339
|
|
|
306
340
|
/**
|
|
307
|
-
*
|
|
341
|
+
* 기본 viewability 추적 시작
|
|
342
|
+
*/
|
|
343
|
+
private startBasicViewabilityTracking(slot: AdSlot, ad: Advertisement): void {
|
|
344
|
+
const element = document.getElementById(slot.id);
|
|
345
|
+
if (!element) return;
|
|
346
|
+
|
|
347
|
+
// 기본 fraud 검사
|
|
348
|
+
const fraudDetector = new BasicFraudDetector();
|
|
349
|
+
|
|
350
|
+
// viewability 추적
|
|
351
|
+
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
352
|
+
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// 정리를 위해 저장
|
|
356
|
+
(slot as any).viewabilityTracker = tracker;
|
|
357
|
+
(slot as any).fraudDetector = fraudDetector;
|
|
358
|
+
|
|
359
|
+
if (this._config?.debug) {
|
|
360
|
+
console.log(`🎯 Viewability tracking started for slot: ${slot.id}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Viewable 이벤트 처리
|
|
366
|
+
*/
|
|
367
|
+
private async handleViewableEvent(
|
|
368
|
+
ad: Advertisement,
|
|
369
|
+
slot: AdSlot,
|
|
370
|
+
metrics: ViewabilityMetrics,
|
|
371
|
+
fraudDetector: BasicFraudDetector
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
try {
|
|
374
|
+
const fraudScore = fraudDetector.calculateFraudScore();
|
|
375
|
+
|
|
376
|
+
// 높은 위험도면 차단
|
|
377
|
+
if (fraudScore.riskLevel === 'CRITICAL') {
|
|
378
|
+
if (this._config?.debug) {
|
|
379
|
+
console.warn(`🚫 Viewable blocked due to fraud risk: ${fraudScore.score}`, fraudScore.reasons);
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// VIEWABLE 이벤트 전송
|
|
385
|
+
if (this.advertisementEventTracker) {
|
|
386
|
+
await this.advertisementEventTracker.trackAdvertisementEvent(
|
|
387
|
+
ad._id,
|
|
388
|
+
slot.id,
|
|
389
|
+
AdEventType.VIEWABLE,
|
|
390
|
+
{
|
|
391
|
+
viewabilityMetrics: metrics,
|
|
392
|
+
fraudScore: fraudScore.score,
|
|
393
|
+
fraudReasons: fraudScore.reasons,
|
|
394
|
+
riskLevel: fraudScore.riskLevel
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
if (this._config?.debug) {
|
|
399
|
+
console.log(`✅ Viewable impression tracked for ad ${ad._id} (fraud score: ${fraudScore.score})`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error(`❌ Failed to track viewable impression:`, error);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Fallback 광고 렌더링 - AdStage 확실한 컨테이너 우선 탐지
|
|
308
410
|
*/
|
|
309
411
|
private renderFallback(slot: AdSlot): void {
|
|
310
412
|
const element = document.getElementById(slot.id);
|
|
311
413
|
if (element) {
|
|
312
|
-
|
|
313
|
-
|
|
414
|
+
// 1순위: AdStage가 생성한 확실한 컨테이너들 (데이터 속성 기반)
|
|
415
|
+
const adstageContainers = [
|
|
416
|
+
element.querySelector('[data-adstage-container="true"]'), // 내부 AdStage 컨테이너
|
|
417
|
+
element.closest('[data-adstage-container="true"]'), // 상위 AdStage 컨테이너
|
|
418
|
+
element, // 자기 자신이 AdStage 컨테이너인 경우
|
|
419
|
+
].filter(el => el && el.hasAttribute('data-adstage-container'));
|
|
420
|
+
|
|
421
|
+
// 2순위: AdStage 클래스 기반 컨테이너들
|
|
422
|
+
const classBasedContainers = [
|
|
423
|
+
element.closest('.adstage-slot'),
|
|
424
|
+
element.closest('.adstage-banner'),
|
|
425
|
+
element.closest('.adstage-text'),
|
|
426
|
+
element.closest('.adstage-video'),
|
|
427
|
+
element.closest('.adstage-native'),
|
|
428
|
+
element.closest('.adstage-interstitial'),
|
|
429
|
+
].filter(Boolean);
|
|
430
|
+
|
|
431
|
+
// 3순위: 일반적인 광고 컨테이너 패턴들 (fallback)
|
|
432
|
+
const generalContainers = [
|
|
433
|
+
element.closest('[class*="ad"]'),
|
|
434
|
+
element.closest('[class*="banner"]'),
|
|
435
|
+
element.closest('[class*="container"]'),
|
|
436
|
+
element.closest('div[style*="height"]'),
|
|
437
|
+
element.closest('div[style*="min-height"]'),
|
|
438
|
+
element.parentElement
|
|
439
|
+
].filter(Boolean);
|
|
440
|
+
|
|
441
|
+
// 우선순위에 따라 컨테이너 선택
|
|
442
|
+
const possibleContainers = [
|
|
443
|
+
...adstageContainers,
|
|
444
|
+
...classBasedContainers,
|
|
445
|
+
...generalContainers
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
// 가장 적절한 컨테이너 선택
|
|
449
|
+
const targetContainer = possibleContainers[0] as HTMLElement;
|
|
450
|
+
|
|
451
|
+
if (targetContainer) {
|
|
452
|
+
// 컨테이너 타입 로깅
|
|
453
|
+
let containerType = 'unknown';
|
|
454
|
+
if (targetContainer.hasAttribute('data-adstage-container')) {
|
|
455
|
+
containerType = 'adstage-official';
|
|
456
|
+
} else if (targetContainer.classList.contains('adstage-slot')) {
|
|
457
|
+
containerType = 'adstage-class';
|
|
458
|
+
} else {
|
|
459
|
+
containerType = 'generic';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
targetContainer.style.cssText += `
|
|
463
|
+
height: 0px !important;
|
|
464
|
+
min-height: 0px !important;
|
|
465
|
+
padding: 0px !important;
|
|
466
|
+
margin: 0px !important;
|
|
467
|
+
border: none !important;
|
|
468
|
+
overflow: hidden !important;
|
|
469
|
+
display: block !important;
|
|
470
|
+
`;
|
|
471
|
+
|
|
472
|
+
// 내부 모든 요소 제거
|
|
473
|
+
targetContainer.innerHTML = '';
|
|
474
|
+
|
|
475
|
+
// 빈 상태임을 표시하는 속성 추가
|
|
476
|
+
targetContainer.setAttribute('data-adstage-empty', 'true');
|
|
477
|
+
|
|
478
|
+
if (this._config?.debug) {
|
|
479
|
+
console.warn(`⚠️ Ad container collapsed (${containerType}): ${slot.id}`, targetContainer);
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
// 컨테이너를 찾지 못한 경우 새로운 빈 컨테이너 생성
|
|
483
|
+
this.createEmptyContainer(slot);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 슬롯 상태 업데이트 (제거하지 않고 빈 상태로 마킹)
|
|
488
|
+
slot.advertisement = undefined;
|
|
489
|
+
(slot as any).isEmpty = true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 빈 컨테이너 생성 (컨테이너를 찾지 못한 경우)
|
|
494
|
+
*/
|
|
495
|
+
private createEmptyContainer(slot: AdSlot): void {
|
|
496
|
+
const originalContainer = document.getElementById(slot.containerId);
|
|
497
|
+
if (originalContainer) {
|
|
498
|
+
// 기존 내용 제거
|
|
499
|
+
originalContainer.innerHTML = '';
|
|
500
|
+
|
|
501
|
+
// 빈 AdStage 컨테이너 생성
|
|
502
|
+
const emptyElement = document.createElement('div');
|
|
503
|
+
emptyElement.id = slot.id;
|
|
504
|
+
emptyElement.className = 'adstage-slot adstage-empty';
|
|
505
|
+
emptyElement.setAttribute('data-adstage-container', 'true');
|
|
506
|
+
emptyElement.setAttribute('data-adstage-empty', 'true');
|
|
507
|
+
emptyElement.setAttribute('data-adstage-slot', slot.id);
|
|
508
|
+
|
|
509
|
+
emptyElement.style.cssText = `
|
|
510
|
+
height: 0px !important;
|
|
511
|
+
min-height: 0px !important;
|
|
512
|
+
padding: 0px !important;
|
|
513
|
+
margin: 0px !important;
|
|
514
|
+
border: none !important;
|
|
515
|
+
overflow: hidden !important;
|
|
516
|
+
display: block !important;
|
|
517
|
+
`;
|
|
518
|
+
|
|
519
|
+
originalContainer.appendChild(emptyElement);
|
|
314
520
|
|
|
315
521
|
if (this._config?.debug) {
|
|
316
|
-
console.warn(`⚠️
|
|
522
|
+
console.warn(`⚠️ Created empty AdStage container: ${slot.id}`);
|
|
317
523
|
}
|
|
318
524
|
}
|
|
319
525
|
}
|
|
@@ -330,8 +536,18 @@ export class AdsModule implements BaseModule {
|
|
|
330
536
|
const params = new URLSearchParams();
|
|
331
537
|
params.append('adType', type);
|
|
332
538
|
|
|
333
|
-
//
|
|
334
|
-
|
|
539
|
+
// 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
|
|
540
|
+
if (options.language) {
|
|
541
|
+
params.append('language', options.language);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (options.deviceType) {
|
|
545
|
+
params.append('deviceType', options.deviceType);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (options.country) {
|
|
549
|
+
params.append('country', options.country);
|
|
550
|
+
}
|
|
335
551
|
|
|
336
552
|
const url = `${endpoints.advertisements.list()}?${params.toString()}`;
|
|
337
553
|
|
|
@@ -345,7 +561,17 @@ export class AdsModule implements BaseModule {
|
|
|
345
561
|
}
|
|
346
562
|
|
|
347
563
|
const result = await response.json();
|
|
348
|
-
|
|
564
|
+
const advertisements = result.advertisements || [];
|
|
565
|
+
|
|
566
|
+
if (this._config?.debug) {
|
|
567
|
+
console.log(`📊 Fetched ${advertisements.length} ads for type: ${type}, filters:`, {
|
|
568
|
+
language: options.language,
|
|
569
|
+
deviceType: options.deviceType,
|
|
570
|
+
country: options.country
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return advertisements;
|
|
349
575
|
}
|
|
350
576
|
|
|
351
577
|
/**
|
|
@@ -360,21 +586,21 @@ export class AdsModule implements BaseModule {
|
|
|
360
586
|
// 이벤트 추적 콜백 함수 (중복 노출 방지 포함)
|
|
361
587
|
const trackEventCallback = (adId: string, slotId: string, eventType: AdEventType) => {
|
|
362
588
|
// 노출 이벤트인 경우 중복 확인
|
|
363
|
-
if (eventType === AdEventType.
|
|
364
|
-
if (
|
|
589
|
+
if (eventType === AdEventType.VIEWABLE) {
|
|
590
|
+
if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
|
|
365
591
|
if (this._config?.debug) {
|
|
366
|
-
console.log(`🚫 Duplicate
|
|
592
|
+
console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
|
|
367
593
|
}
|
|
368
594
|
return; // 중복 노출이면 추적하지 않음
|
|
369
595
|
}
|
|
370
596
|
|
|
371
597
|
if (this._config?.debug) {
|
|
372
|
-
console.log(`✅ New
|
|
598
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
|
|
373
599
|
}
|
|
374
600
|
}
|
|
375
601
|
|
|
376
|
-
if (this.
|
|
377
|
-
console.log(`📊
|
|
602
|
+
if (this.advertisementEventTracker && this._config?.debug) {
|
|
603
|
+
console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
|
|
378
604
|
}
|
|
379
605
|
};
|
|
380
606
|
|
|
@@ -505,8 +731,8 @@ export class AdsModule implements BaseModule {
|
|
|
505
731
|
await this.renderAd(slot);
|
|
506
732
|
|
|
507
733
|
// 새로운 노출 추적
|
|
508
|
-
if (this.
|
|
509
|
-
console.log('New
|
|
734
|
+
if (this.advertisementEventTracker) {
|
|
735
|
+
console.log('New advertisement viewable tracked for slot:', slot.id);
|
|
510
736
|
}
|
|
511
737
|
}
|
|
512
738
|
} 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
|
-
}
|