@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.
@@ -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 { ImpressionTracker } from '../../managers/impression-tracker';
12
- import { EventTracker } from '../../managers/event-tracker';
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
- private eventTracker: EventTracker | null = null;
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
- // EventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
41
- this.eventTracker = new EventTracker(
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.eventTracker && this._config?.debug) {
243
- console.log(`📊 Event tracking enabled for slot: ${slotId}`);
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
- * Fallback 광고 렌더링
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
- element.innerHTML = `<span>Ad not available</span>`;
313
- element.style.color = '#999';
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(`⚠️ Fallback rendered for slot: ${slot.id}`);
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
- // userAgent와 url은 header나 자동으로 처리되므로 query에서 제외
334
- // 기타 옵션들을 필요시 query parameter로 추가 가능
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
- return result.advertisements || [];
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.IMPRESSION) {
364
- if (ImpressionTracker.isDuplicateImpression(adId, slotId, this._config?.debug)) {
589
+ if (eventType === AdEventType.VIEWABLE) {
590
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
365
591
  if (this._config?.debug) {
366
- console.log(`🚫 Duplicate impression blocked for ad ${adId} in slot ${slotId}`);
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 impression recorded for ad ${adId} in slot ${slotId}`);
598
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
373
599
  }
374
600
  }
375
601
 
376
- if (this.eventTracker && this._config?.debug) {
377
- console.log(`📊 Event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
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.eventTracker) {
509
- console.log('New impression tracked for slot:', slot.id);
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
- VIEWABLE_IMPRESSION = 'VIEWABLE_IMPRESSION',
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
- impressions: number;
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
- impressionSent?: boolean;
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
- impressions: number;
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
- impressions: number;
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
- }