@adstage/web-sdk 2.4.4 → 2.4.6
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/dist/index.cjs.js +88 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +88 -18
- package/dist/index.standalone.js +88 -18
- package/package.json +1 -1
- package/src/constants/endpoints.ts +2 -0
- package/src/managers/ads/advertisement-event-tracker.ts +29 -9
- package/src/managers/ads/viewable-event-tracker.ts +37 -7
- package/src/modules/ads/AdRenderer.ts +1 -3
- package/src/modules/ads/AdsModule.ts +35 -3
- package/src/renderers/text-renderer.ts +2 -2
package/dist/index.cjs.js
CHANGED
|
@@ -47,11 +47,15 @@ class ViewableEventTracker {
|
|
|
47
47
|
static isDuplicateViewable(adId, slotId, debug = false) {
|
|
48
48
|
const key = `${adId}_${slotId}`;
|
|
49
49
|
const now = Date.now();
|
|
50
|
+
// 디버그 모드에 따른 쿨다운 시간 결정
|
|
51
|
+
const cooldownTime = debug ?
|
|
52
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG :
|
|
53
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION;
|
|
50
54
|
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
51
55
|
const lastViewable = ViewableEventTracker.viewableTracker.get(key);
|
|
52
|
-
if (lastViewable && (now - lastViewable) <
|
|
56
|
+
if (lastViewable && (now - lastViewable) < cooldownTime) {
|
|
53
57
|
if (debug) {
|
|
54
|
-
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
58
|
+
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - lastViewable)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
55
59
|
}
|
|
56
60
|
return true;
|
|
57
61
|
}
|
|
@@ -60,9 +64,9 @@ class ViewableEventTracker {
|
|
|
60
64
|
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
61
65
|
if (sessionViewable) {
|
|
62
66
|
const sessionTime = parseInt(sessionViewable, 10);
|
|
63
|
-
if (!isNaN(sessionTime) && (now - sessionTime) <
|
|
67
|
+
if (!isNaN(sessionTime) && (now - sessionTime) < cooldownTime) {
|
|
64
68
|
if (debug) {
|
|
65
|
-
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
69
|
+
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - sessionTime)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
66
70
|
}
|
|
67
71
|
// 메모리에도 기록하여 이후 요청 최적화
|
|
68
72
|
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
@@ -74,6 +78,9 @@ class ViewableEventTracker {
|
|
|
74
78
|
sessionStorage.setItem(sessionKey, now.toString());
|
|
75
79
|
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
76
80
|
ViewableEventTracker.cleanupOldViewables();
|
|
81
|
+
if (debug) {
|
|
82
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId} (${debug ? 'debug' : 'production'} mode)`);
|
|
83
|
+
}
|
|
77
84
|
return false;
|
|
78
85
|
}
|
|
79
86
|
/**
|
|
@@ -81,7 +88,8 @@ class ViewableEventTracker {
|
|
|
81
88
|
*/
|
|
82
89
|
static cleanupOldViewables() {
|
|
83
90
|
const now = Date.now();
|
|
84
|
-
|
|
91
|
+
// 프로덕션 쿨다운의 2배 시간이 지난 데이터 정리
|
|
92
|
+
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION * 2;
|
|
85
93
|
// 세션 스토리지 정리
|
|
86
94
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
87
95
|
const key = sessionStorage.key(i);
|
|
@@ -104,14 +112,33 @@ class ViewableEventTracker {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
/**
|
|
107
|
-
* 모든 추적 데이터 정리
|
|
115
|
+
* 모든 추적 데이터 정리 (디버그용)
|
|
108
116
|
*/
|
|
109
117
|
static clear() {
|
|
110
118
|
ViewableEventTracker.viewableTracker.clear();
|
|
119
|
+
// 세션 스토리지에서도 adstage_viewable_ 키들 제거
|
|
120
|
+
const keysToRemove = [];
|
|
121
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
122
|
+
const key = sessionStorage.key(i);
|
|
123
|
+
if (key && key.startsWith('adstage_viewable_')) {
|
|
124
|
+
keysToRemove.push(key);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 특정 광고의 viewable 추적 초기화 (디버그용)
|
|
131
|
+
*/
|
|
132
|
+
static clearAdViewable(adId, slotId) {
|
|
133
|
+
const key = `${adId}_${slotId}`;
|
|
134
|
+
ViewableEventTracker.viewableTracker.delete(key);
|
|
135
|
+
const sessionKey = `adstage_viewable_${key}`;
|
|
136
|
+
sessionStorage.removeItem(sessionKey);
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
139
|
ViewableEventTracker.viewableTracker = new Map();
|
|
114
|
-
ViewableEventTracker.
|
|
140
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION = 300000; // 5분 쿨다운 (프로덕션)
|
|
141
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG = 30000; // 30초 쿨다운 (디버그)
|
|
115
142
|
|
|
116
143
|
/**
|
|
117
144
|
* SSR 안전한 DOM API 래퍼 클래스
|
|
@@ -607,13 +634,32 @@ class AdvertisementEventTracker {
|
|
|
607
634
|
riskLevel: additionalData.riskLevel,
|
|
608
635
|
}),
|
|
609
636
|
};
|
|
610
|
-
|
|
637
|
+
const url = `${this.baseUrl}/advertisements/events/${adId}/${eventType}`;
|
|
638
|
+
const headers = ApiHeaders.createForEvents(this.apiKey, eventData);
|
|
639
|
+
if (this.debug) {
|
|
640
|
+
console.log(`🚀 Sending advertisement event: ${eventType} for ad ${adId}`, {
|
|
641
|
+
url,
|
|
642
|
+
headers,
|
|
643
|
+
eventData
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const response = await fetch(url, {
|
|
611
647
|
method: 'POST',
|
|
612
|
-
headers
|
|
648
|
+
headers,
|
|
613
649
|
body: JSON.stringify(eventData),
|
|
614
650
|
});
|
|
615
651
|
if (this.debug) {
|
|
616
|
-
console.log(
|
|
652
|
+
console.log(`📡 API Response Status: ${response.status} ${response.statusText}`, {
|
|
653
|
+
url,
|
|
654
|
+
ok: response.ok
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (!response.ok) {
|
|
658
|
+
const errorText = await response.text();
|
|
659
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
660
|
+
}
|
|
661
|
+
if (this.debug) {
|
|
662
|
+
console.log(`✅ Successfully tracked advertisement event: ${eventType} for ad ${adId}`);
|
|
617
663
|
}
|
|
618
664
|
}
|
|
619
665
|
catch (error) {
|
|
@@ -948,6 +994,7 @@ const API_PATHS = {
|
|
|
948
994
|
/** 광고 관련 */
|
|
949
995
|
advertisements: {
|
|
950
996
|
list: '/advertisements/list',
|
|
997
|
+
detail: '/advertisements',
|
|
951
998
|
events: '/advertisements/events'
|
|
952
999
|
},
|
|
953
1000
|
/** 이벤트 관련 */
|
|
@@ -966,6 +1013,7 @@ class EndpointBuilder {
|
|
|
966
1013
|
*/
|
|
967
1014
|
this.advertisements = {
|
|
968
1015
|
list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
|
|
1016
|
+
detail: (adId) => `${this.baseUrl}${API_PATHS.advertisements.detail}/${adId}`,
|
|
969
1017
|
events: (adId, eventType) => `${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
|
|
970
1018
|
};
|
|
971
1019
|
/**
|
|
@@ -1206,7 +1254,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1206
1254
|
// 기본 컨테이너 스타일
|
|
1207
1255
|
const containerStyles = {
|
|
1208
1256
|
...this.getBaseContainerStyles(slot),
|
|
1209
|
-
padding: '
|
|
1257
|
+
padding: '4px',
|
|
1210
1258
|
background: 'transparent',
|
|
1211
1259
|
display: 'flex',
|
|
1212
1260
|
'align-items': 'center',
|
|
@@ -1234,7 +1282,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1234
1282
|
}
|
|
1235
1283
|
// 텍스트 콘텐츠 생성
|
|
1236
1284
|
const textContent = this.createTextElement(ad.textContent, 'div', {
|
|
1237
|
-
'font-size': '
|
|
1285
|
+
'font-size': '14px',
|
|
1238
1286
|
'font-weight': '500',
|
|
1239
1287
|
color: '#212529',
|
|
1240
1288
|
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|
|
@@ -2285,8 +2333,6 @@ class AdRenderer {
|
|
|
2285
2333
|
case AdType.TEXT: {
|
|
2286
2334
|
const textDiv = document.createElement('div');
|
|
2287
2335
|
textDiv.innerHTML = `
|
|
2288
|
-
<h3>${ad.title}</h3>
|
|
2289
|
-
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
2290
2336
|
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
2291
2337
|
`;
|
|
2292
2338
|
adElement.appendChild(textDiv);
|
|
@@ -2412,7 +2458,7 @@ class AdRenderer {
|
|
|
2412
2458
|
case AdType.BANNER:
|
|
2413
2459
|
return '250px'; // 일반 배너
|
|
2414
2460
|
case AdType.TEXT:
|
|
2415
|
-
return '
|
|
2461
|
+
return '60px'; // 텍스트는 좀 더 작게
|
|
2416
2462
|
case AdType.VIDEO:
|
|
2417
2463
|
return '360px'; // 비디오는 16:9 비율 고려
|
|
2418
2464
|
case AdType.NATIVE:
|
|
@@ -2682,6 +2728,8 @@ class AdsModule {
|
|
|
2682
2728
|
autoSlide: options?.autoSlide || false,
|
|
2683
2729
|
slideInterval: options?.slideInterval || 5000,
|
|
2684
2730
|
onClick: options?.onClick,
|
|
2731
|
+
// 특정 광고 ID
|
|
2732
|
+
adId: options?.adId,
|
|
2685
2733
|
// 필터링 옵션들 전달
|
|
2686
2734
|
language: options?.language,
|
|
2687
2735
|
deviceType: options?.deviceType,
|
|
@@ -2698,6 +2746,8 @@ class AdsModule {
|
|
|
2698
2746
|
maxLines: options?.maxLines || 3,
|
|
2699
2747
|
style: options?.style || 'default',
|
|
2700
2748
|
onClick: options?.onClick,
|
|
2749
|
+
// 특정 광고 ID
|
|
2750
|
+
adId: options?.adId,
|
|
2701
2751
|
// 필터링 옵션들 전달
|
|
2702
2752
|
language: options?.language,
|
|
2703
2753
|
deviceType: options?.deviceType,
|
|
@@ -2897,8 +2947,8 @@ class AdsModule {
|
|
|
2897
2947
|
// 기본 fraud 검사
|
|
2898
2948
|
const fraudDetector = new BasicFraudDetector();
|
|
2899
2949
|
// viewability 추적
|
|
2900
|
-
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
2901
|
-
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2950
|
+
const tracker = new ViewabilityTracker(element, slot.adType, async (metrics) => {
|
|
2951
|
+
await this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2902
2952
|
});
|
|
2903
2953
|
// 정리를 위해 저장
|
|
2904
2954
|
slot.viewabilityTracker = tracker;
|
|
@@ -2952,7 +3002,27 @@ class AdsModule {
|
|
|
2952
3002
|
if (!this._config?.apiKey) {
|
|
2953
3003
|
throw new Error('API key not configured');
|
|
2954
3004
|
}
|
|
2955
|
-
//
|
|
3005
|
+
// 특정 광고 ID가 지정된 경우, 해당 광고만 요청
|
|
3006
|
+
if (options.adId) {
|
|
3007
|
+
const url = `${endpoints.advertisements.detail(options.adId)}`;
|
|
3008
|
+
const response = await fetch(url, {
|
|
3009
|
+
method: 'GET',
|
|
3010
|
+
headers: ApiHeaders.create(this._config.apiKey)
|
|
3011
|
+
});
|
|
3012
|
+
if (!response.ok) {
|
|
3013
|
+
if (response.status === 404) {
|
|
3014
|
+
console.warn(`🚫 Advertisement not found with ID: ${options.adId}`);
|
|
3015
|
+
return [];
|
|
3016
|
+
}
|
|
3017
|
+
throw new Error(`Failed to fetch ad data: ${response.status}`);
|
|
3018
|
+
}
|
|
3019
|
+
const advertisement = await response.json();
|
|
3020
|
+
if (this._config?.debug) {
|
|
3021
|
+
console.log(`📊 Fetched specific ad with ID: ${options.adId}`, advertisement);
|
|
3022
|
+
}
|
|
3023
|
+
return [advertisement];
|
|
3024
|
+
}
|
|
3025
|
+
// 일반적인 광고 목록 요청
|
|
2956
3026
|
const params = new URLSearchParams();
|
|
2957
3027
|
params.append('adType', type);
|
|
2958
3028
|
// 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
|
package/dist/index.d.ts
CHANGED
package/dist/index.esm.js
CHANGED
|
@@ -45,11 +45,15 @@ class ViewableEventTracker {
|
|
|
45
45
|
static isDuplicateViewable(adId, slotId, debug = false) {
|
|
46
46
|
const key = `${adId}_${slotId}`;
|
|
47
47
|
const now = Date.now();
|
|
48
|
+
// 디버그 모드에 따른 쿨다운 시간 결정
|
|
49
|
+
const cooldownTime = debug ?
|
|
50
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG :
|
|
51
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION;
|
|
48
52
|
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
49
53
|
const lastViewable = ViewableEventTracker.viewableTracker.get(key);
|
|
50
|
-
if (lastViewable && (now - lastViewable) <
|
|
54
|
+
if (lastViewable && (now - lastViewable) < cooldownTime) {
|
|
51
55
|
if (debug) {
|
|
52
|
-
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
56
|
+
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - lastViewable)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
53
57
|
}
|
|
54
58
|
return true;
|
|
55
59
|
}
|
|
@@ -58,9 +62,9 @@ class ViewableEventTracker {
|
|
|
58
62
|
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
59
63
|
if (sessionViewable) {
|
|
60
64
|
const sessionTime = parseInt(sessionViewable, 10);
|
|
61
|
-
if (!isNaN(sessionTime) && (now - sessionTime) <
|
|
65
|
+
if (!isNaN(sessionTime) && (now - sessionTime) < cooldownTime) {
|
|
62
66
|
if (debug) {
|
|
63
|
-
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
67
|
+
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - sessionTime)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
64
68
|
}
|
|
65
69
|
// 메모리에도 기록하여 이후 요청 최적화
|
|
66
70
|
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
@@ -72,6 +76,9 @@ class ViewableEventTracker {
|
|
|
72
76
|
sessionStorage.setItem(sessionKey, now.toString());
|
|
73
77
|
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
74
78
|
ViewableEventTracker.cleanupOldViewables();
|
|
79
|
+
if (debug) {
|
|
80
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId} (${debug ? 'debug' : 'production'} mode)`);
|
|
81
|
+
}
|
|
75
82
|
return false;
|
|
76
83
|
}
|
|
77
84
|
/**
|
|
@@ -79,7 +86,8 @@ class ViewableEventTracker {
|
|
|
79
86
|
*/
|
|
80
87
|
static cleanupOldViewables() {
|
|
81
88
|
const now = Date.now();
|
|
82
|
-
|
|
89
|
+
// 프로덕션 쿨다운의 2배 시간이 지난 데이터 정리
|
|
90
|
+
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION * 2;
|
|
83
91
|
// 세션 스토리지 정리
|
|
84
92
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
85
93
|
const key = sessionStorage.key(i);
|
|
@@ -102,14 +110,33 @@ class ViewableEventTracker {
|
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
/**
|
|
105
|
-
* 모든 추적 데이터 정리
|
|
113
|
+
* 모든 추적 데이터 정리 (디버그용)
|
|
106
114
|
*/
|
|
107
115
|
static clear() {
|
|
108
116
|
ViewableEventTracker.viewableTracker.clear();
|
|
117
|
+
// 세션 스토리지에서도 adstage_viewable_ 키들 제거
|
|
118
|
+
const keysToRemove = [];
|
|
119
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
120
|
+
const key = sessionStorage.key(i);
|
|
121
|
+
if (key && key.startsWith('adstage_viewable_')) {
|
|
122
|
+
keysToRemove.push(key);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 특정 광고의 viewable 추적 초기화 (디버그용)
|
|
129
|
+
*/
|
|
130
|
+
static clearAdViewable(adId, slotId) {
|
|
131
|
+
const key = `${adId}_${slotId}`;
|
|
132
|
+
ViewableEventTracker.viewableTracker.delete(key);
|
|
133
|
+
const sessionKey = `adstage_viewable_${key}`;
|
|
134
|
+
sessionStorage.removeItem(sessionKey);
|
|
109
135
|
}
|
|
110
136
|
}
|
|
111
137
|
ViewableEventTracker.viewableTracker = new Map();
|
|
112
|
-
ViewableEventTracker.
|
|
138
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION = 300000; // 5분 쿨다운 (프로덕션)
|
|
139
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG = 30000; // 30초 쿨다운 (디버그)
|
|
113
140
|
|
|
114
141
|
/**
|
|
115
142
|
* SSR 안전한 DOM API 래퍼 클래스
|
|
@@ -605,13 +632,32 @@ class AdvertisementEventTracker {
|
|
|
605
632
|
riskLevel: additionalData.riskLevel,
|
|
606
633
|
}),
|
|
607
634
|
};
|
|
608
|
-
|
|
635
|
+
const url = `${this.baseUrl}/advertisements/events/${adId}/${eventType}`;
|
|
636
|
+
const headers = ApiHeaders.createForEvents(this.apiKey, eventData);
|
|
637
|
+
if (this.debug) {
|
|
638
|
+
console.log(`🚀 Sending advertisement event: ${eventType} for ad ${adId}`, {
|
|
639
|
+
url,
|
|
640
|
+
headers,
|
|
641
|
+
eventData
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const response = await fetch(url, {
|
|
609
645
|
method: 'POST',
|
|
610
|
-
headers
|
|
646
|
+
headers,
|
|
611
647
|
body: JSON.stringify(eventData),
|
|
612
648
|
});
|
|
613
649
|
if (this.debug) {
|
|
614
|
-
console.log(
|
|
650
|
+
console.log(`📡 API Response Status: ${response.status} ${response.statusText}`, {
|
|
651
|
+
url,
|
|
652
|
+
ok: response.ok
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
if (!response.ok) {
|
|
656
|
+
const errorText = await response.text();
|
|
657
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
658
|
+
}
|
|
659
|
+
if (this.debug) {
|
|
660
|
+
console.log(`✅ Successfully tracked advertisement event: ${eventType} for ad ${adId}`);
|
|
615
661
|
}
|
|
616
662
|
}
|
|
617
663
|
catch (error) {
|
|
@@ -946,6 +992,7 @@ const API_PATHS = {
|
|
|
946
992
|
/** 광고 관련 */
|
|
947
993
|
advertisements: {
|
|
948
994
|
list: '/advertisements/list',
|
|
995
|
+
detail: '/advertisements',
|
|
949
996
|
events: '/advertisements/events'
|
|
950
997
|
},
|
|
951
998
|
/** 이벤트 관련 */
|
|
@@ -964,6 +1011,7 @@ class EndpointBuilder {
|
|
|
964
1011
|
*/
|
|
965
1012
|
this.advertisements = {
|
|
966
1013
|
list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
|
|
1014
|
+
detail: (adId) => `${this.baseUrl}${API_PATHS.advertisements.detail}/${adId}`,
|
|
967
1015
|
events: (adId, eventType) => `${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
|
|
968
1016
|
};
|
|
969
1017
|
/**
|
|
@@ -1204,7 +1252,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1204
1252
|
// 기본 컨테이너 스타일
|
|
1205
1253
|
const containerStyles = {
|
|
1206
1254
|
...this.getBaseContainerStyles(slot),
|
|
1207
|
-
padding: '
|
|
1255
|
+
padding: '4px',
|
|
1208
1256
|
background: 'transparent',
|
|
1209
1257
|
display: 'flex',
|
|
1210
1258
|
'align-items': 'center',
|
|
@@ -1232,7 +1280,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1232
1280
|
}
|
|
1233
1281
|
// 텍스트 콘텐츠 생성
|
|
1234
1282
|
const textContent = this.createTextElement(ad.textContent, 'div', {
|
|
1235
|
-
'font-size': '
|
|
1283
|
+
'font-size': '14px',
|
|
1236
1284
|
'font-weight': '500',
|
|
1237
1285
|
color: '#212529',
|
|
1238
1286
|
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|
|
@@ -2283,8 +2331,6 @@ class AdRenderer {
|
|
|
2283
2331
|
case AdType.TEXT: {
|
|
2284
2332
|
const textDiv = document.createElement('div');
|
|
2285
2333
|
textDiv.innerHTML = `
|
|
2286
|
-
<h3>${ad.title}</h3>
|
|
2287
|
-
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
2288
2334
|
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
2289
2335
|
`;
|
|
2290
2336
|
adElement.appendChild(textDiv);
|
|
@@ -2410,7 +2456,7 @@ class AdRenderer {
|
|
|
2410
2456
|
case AdType.BANNER:
|
|
2411
2457
|
return '250px'; // 일반 배너
|
|
2412
2458
|
case AdType.TEXT:
|
|
2413
|
-
return '
|
|
2459
|
+
return '60px'; // 텍스트는 좀 더 작게
|
|
2414
2460
|
case AdType.VIDEO:
|
|
2415
2461
|
return '360px'; // 비디오는 16:9 비율 고려
|
|
2416
2462
|
case AdType.NATIVE:
|
|
@@ -2680,6 +2726,8 @@ class AdsModule {
|
|
|
2680
2726
|
autoSlide: options?.autoSlide || false,
|
|
2681
2727
|
slideInterval: options?.slideInterval || 5000,
|
|
2682
2728
|
onClick: options?.onClick,
|
|
2729
|
+
// 특정 광고 ID
|
|
2730
|
+
adId: options?.adId,
|
|
2683
2731
|
// 필터링 옵션들 전달
|
|
2684
2732
|
language: options?.language,
|
|
2685
2733
|
deviceType: options?.deviceType,
|
|
@@ -2696,6 +2744,8 @@ class AdsModule {
|
|
|
2696
2744
|
maxLines: options?.maxLines || 3,
|
|
2697
2745
|
style: options?.style || 'default',
|
|
2698
2746
|
onClick: options?.onClick,
|
|
2747
|
+
// 특정 광고 ID
|
|
2748
|
+
adId: options?.adId,
|
|
2699
2749
|
// 필터링 옵션들 전달
|
|
2700
2750
|
language: options?.language,
|
|
2701
2751
|
deviceType: options?.deviceType,
|
|
@@ -2895,8 +2945,8 @@ class AdsModule {
|
|
|
2895
2945
|
// 기본 fraud 검사
|
|
2896
2946
|
const fraudDetector = new BasicFraudDetector();
|
|
2897
2947
|
// viewability 추적
|
|
2898
|
-
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
2899
|
-
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2948
|
+
const tracker = new ViewabilityTracker(element, slot.adType, async (metrics) => {
|
|
2949
|
+
await this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2900
2950
|
});
|
|
2901
2951
|
// 정리를 위해 저장
|
|
2902
2952
|
slot.viewabilityTracker = tracker;
|
|
@@ -2950,7 +3000,27 @@ class AdsModule {
|
|
|
2950
3000
|
if (!this._config?.apiKey) {
|
|
2951
3001
|
throw new Error('API key not configured');
|
|
2952
3002
|
}
|
|
2953
|
-
//
|
|
3003
|
+
// 특정 광고 ID가 지정된 경우, 해당 광고만 요청
|
|
3004
|
+
if (options.adId) {
|
|
3005
|
+
const url = `${endpoints.advertisements.detail(options.adId)}`;
|
|
3006
|
+
const response = await fetch(url, {
|
|
3007
|
+
method: 'GET',
|
|
3008
|
+
headers: ApiHeaders.create(this._config.apiKey)
|
|
3009
|
+
});
|
|
3010
|
+
if (!response.ok) {
|
|
3011
|
+
if (response.status === 404) {
|
|
3012
|
+
console.warn(`🚫 Advertisement not found with ID: ${options.adId}`);
|
|
3013
|
+
return [];
|
|
3014
|
+
}
|
|
3015
|
+
throw new Error(`Failed to fetch ad data: ${response.status}`);
|
|
3016
|
+
}
|
|
3017
|
+
const advertisement = await response.json();
|
|
3018
|
+
if (this._config?.debug) {
|
|
3019
|
+
console.log(`📊 Fetched specific ad with ID: ${options.adId}`, advertisement);
|
|
3020
|
+
}
|
|
3021
|
+
return [advertisement];
|
|
3022
|
+
}
|
|
3023
|
+
// 일반적인 광고 목록 요청
|
|
2954
3024
|
const params = new URLSearchParams();
|
|
2955
3025
|
params.append('adType', type);
|
|
2956
3026
|
// 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
|
package/dist/index.standalone.js
CHANGED
|
@@ -42,11 +42,15 @@ class ViewableEventTracker {
|
|
|
42
42
|
static isDuplicateViewable(adId, slotId, debug = false) {
|
|
43
43
|
const key = `${adId}_${slotId}`;
|
|
44
44
|
const now = Date.now();
|
|
45
|
+
// 디버그 모드에 따른 쿨다운 시간 결정
|
|
46
|
+
const cooldownTime = debug ?
|
|
47
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG :
|
|
48
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION;
|
|
45
49
|
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
46
50
|
const lastViewable = ViewableEventTracker.viewableTracker.get(key);
|
|
47
|
-
if (lastViewable && (now - lastViewable) <
|
|
51
|
+
if (lastViewable && (now - lastViewable) < cooldownTime) {
|
|
48
52
|
if (debug) {
|
|
49
|
-
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
53
|
+
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - lastViewable)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
50
54
|
}
|
|
51
55
|
return true;
|
|
52
56
|
}
|
|
@@ -55,9 +59,9 @@ class ViewableEventTracker {
|
|
|
55
59
|
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
56
60
|
if (sessionViewable) {
|
|
57
61
|
const sessionTime = parseInt(sessionViewable, 10);
|
|
58
|
-
if (!isNaN(sessionTime) && (now - sessionTime) <
|
|
62
|
+
if (!isNaN(sessionTime) && (now - sessionTime) < cooldownTime) {
|
|
59
63
|
if (debug) {
|
|
60
|
-
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
64
|
+
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - sessionTime)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
61
65
|
}
|
|
62
66
|
// 메모리에도 기록하여 이후 요청 최적화
|
|
63
67
|
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
@@ -69,6 +73,9 @@ class ViewableEventTracker {
|
|
|
69
73
|
sessionStorage.setItem(sessionKey, now.toString());
|
|
70
74
|
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
71
75
|
ViewableEventTracker.cleanupOldViewables();
|
|
76
|
+
if (debug) {
|
|
77
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId} (${debug ? 'debug' : 'production'} mode)`);
|
|
78
|
+
}
|
|
72
79
|
return false;
|
|
73
80
|
}
|
|
74
81
|
/**
|
|
@@ -76,7 +83,8 @@ class ViewableEventTracker {
|
|
|
76
83
|
*/
|
|
77
84
|
static cleanupOldViewables() {
|
|
78
85
|
const now = Date.now();
|
|
79
|
-
|
|
86
|
+
// 프로덕션 쿨다운의 2배 시간이 지난 데이터 정리
|
|
87
|
+
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION * 2;
|
|
80
88
|
// 세션 스토리지 정리
|
|
81
89
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
82
90
|
const key = sessionStorage.key(i);
|
|
@@ -99,14 +107,33 @@ class ViewableEventTracker {
|
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
109
|
/**
|
|
102
|
-
* 모든 추적 데이터 정리
|
|
110
|
+
* 모든 추적 데이터 정리 (디버그용)
|
|
103
111
|
*/
|
|
104
112
|
static clear() {
|
|
105
113
|
ViewableEventTracker.viewableTracker.clear();
|
|
114
|
+
// 세션 스토리지에서도 adstage_viewable_ 키들 제거
|
|
115
|
+
const keysToRemove = [];
|
|
116
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
117
|
+
const key = sessionStorage.key(i);
|
|
118
|
+
if (key && key.startsWith('adstage_viewable_')) {
|
|
119
|
+
keysToRemove.push(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 특정 광고의 viewable 추적 초기화 (디버그용)
|
|
126
|
+
*/
|
|
127
|
+
static clearAdViewable(adId, slotId) {
|
|
128
|
+
const key = `${adId}_${slotId}`;
|
|
129
|
+
ViewableEventTracker.viewableTracker.delete(key);
|
|
130
|
+
const sessionKey = `adstage_viewable_${key}`;
|
|
131
|
+
sessionStorage.removeItem(sessionKey);
|
|
106
132
|
}
|
|
107
133
|
}
|
|
108
134
|
ViewableEventTracker.viewableTracker = new Map();
|
|
109
|
-
ViewableEventTracker.
|
|
135
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION = 300000; // 5분 쿨다운 (프로덕션)
|
|
136
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG = 30000; // 30초 쿨다운 (디버그)
|
|
110
137
|
|
|
111
138
|
/**
|
|
112
139
|
* SSR 안전한 DOM API 래퍼 클래스
|
|
@@ -602,13 +629,32 @@ class AdvertisementEventTracker {
|
|
|
602
629
|
riskLevel: additionalData.riskLevel,
|
|
603
630
|
}),
|
|
604
631
|
};
|
|
605
|
-
|
|
632
|
+
const url = `${this.baseUrl}/advertisements/events/${adId}/${eventType}`;
|
|
633
|
+
const headers = ApiHeaders.createForEvents(this.apiKey, eventData);
|
|
634
|
+
if (this.debug) {
|
|
635
|
+
console.log(`🚀 Sending advertisement event: ${eventType} for ad ${adId}`, {
|
|
636
|
+
url,
|
|
637
|
+
headers,
|
|
638
|
+
eventData
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const response = await fetch(url, {
|
|
606
642
|
method: 'POST',
|
|
607
|
-
headers
|
|
643
|
+
headers,
|
|
608
644
|
body: JSON.stringify(eventData),
|
|
609
645
|
});
|
|
610
646
|
if (this.debug) {
|
|
611
|
-
console.log(
|
|
647
|
+
console.log(`📡 API Response Status: ${response.status} ${response.statusText}`, {
|
|
648
|
+
url,
|
|
649
|
+
ok: response.ok
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (!response.ok) {
|
|
653
|
+
const errorText = await response.text();
|
|
654
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
655
|
+
}
|
|
656
|
+
if (this.debug) {
|
|
657
|
+
console.log(`✅ Successfully tracked advertisement event: ${eventType} for ad ${adId}`);
|
|
612
658
|
}
|
|
613
659
|
}
|
|
614
660
|
catch (error) {
|
|
@@ -943,6 +989,7 @@ const API_PATHS = {
|
|
|
943
989
|
/** 광고 관련 */
|
|
944
990
|
advertisements: {
|
|
945
991
|
list: '/advertisements/list',
|
|
992
|
+
detail: '/advertisements',
|
|
946
993
|
events: '/advertisements/events'
|
|
947
994
|
},
|
|
948
995
|
/** 이벤트 관련 */
|
|
@@ -961,6 +1008,7 @@ class EndpointBuilder {
|
|
|
961
1008
|
*/
|
|
962
1009
|
this.advertisements = {
|
|
963
1010
|
list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
|
|
1011
|
+
detail: (adId) => `${this.baseUrl}${API_PATHS.advertisements.detail}/${adId}`,
|
|
964
1012
|
events: (adId, eventType) => `${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
|
|
965
1013
|
};
|
|
966
1014
|
/**
|
|
@@ -1201,7 +1249,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1201
1249
|
// 기본 컨테이너 스타일
|
|
1202
1250
|
const containerStyles = {
|
|
1203
1251
|
...this.getBaseContainerStyles(slot),
|
|
1204
|
-
padding: '
|
|
1252
|
+
padding: '4px',
|
|
1205
1253
|
background: 'transparent',
|
|
1206
1254
|
display: 'flex',
|
|
1207
1255
|
'align-items': 'center',
|
|
@@ -1229,7 +1277,7 @@ class TextAdRenderer extends BaseAdRenderer {
|
|
|
1229
1277
|
}
|
|
1230
1278
|
// 텍스트 콘텐츠 생성
|
|
1231
1279
|
const textContent = this.createTextElement(ad.textContent, 'div', {
|
|
1232
|
-
'font-size': '
|
|
1280
|
+
'font-size': '14px',
|
|
1233
1281
|
'font-weight': '500',
|
|
1234
1282
|
color: '#212529',
|
|
1235
1283
|
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|
|
@@ -2280,8 +2328,6 @@ class AdRenderer {
|
|
|
2280
2328
|
case AdType.TEXT: {
|
|
2281
2329
|
const textDiv = document.createElement('div');
|
|
2282
2330
|
textDiv.innerHTML = `
|
|
2283
|
-
<h3>${ad.title}</h3>
|
|
2284
|
-
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
2285
2331
|
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
2286
2332
|
`;
|
|
2287
2333
|
adElement.appendChild(textDiv);
|
|
@@ -2407,7 +2453,7 @@ class AdRenderer {
|
|
|
2407
2453
|
case AdType.BANNER:
|
|
2408
2454
|
return '250px'; // 일반 배너
|
|
2409
2455
|
case AdType.TEXT:
|
|
2410
|
-
return '
|
|
2456
|
+
return '60px'; // 텍스트는 좀 더 작게
|
|
2411
2457
|
case AdType.VIDEO:
|
|
2412
2458
|
return '360px'; // 비디오는 16:9 비율 고려
|
|
2413
2459
|
case AdType.NATIVE:
|
|
@@ -2677,6 +2723,8 @@ class AdsModule {
|
|
|
2677
2723
|
autoSlide: options?.autoSlide || false,
|
|
2678
2724
|
slideInterval: options?.slideInterval || 5000,
|
|
2679
2725
|
onClick: options?.onClick,
|
|
2726
|
+
// 특정 광고 ID
|
|
2727
|
+
adId: options?.adId,
|
|
2680
2728
|
// 필터링 옵션들 전달
|
|
2681
2729
|
language: options?.language,
|
|
2682
2730
|
deviceType: options?.deviceType,
|
|
@@ -2693,6 +2741,8 @@ class AdsModule {
|
|
|
2693
2741
|
maxLines: options?.maxLines || 3,
|
|
2694
2742
|
style: options?.style || 'default',
|
|
2695
2743
|
onClick: options?.onClick,
|
|
2744
|
+
// 특정 광고 ID
|
|
2745
|
+
adId: options?.adId,
|
|
2696
2746
|
// 필터링 옵션들 전달
|
|
2697
2747
|
language: options?.language,
|
|
2698
2748
|
deviceType: options?.deviceType,
|
|
@@ -2892,8 +2942,8 @@ class AdsModule {
|
|
|
2892
2942
|
// 기본 fraud 검사
|
|
2893
2943
|
const fraudDetector = new BasicFraudDetector();
|
|
2894
2944
|
// viewability 추적
|
|
2895
|
-
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
2896
|
-
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2945
|
+
const tracker = new ViewabilityTracker(element, slot.adType, async (metrics) => {
|
|
2946
|
+
await this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
2897
2947
|
});
|
|
2898
2948
|
// 정리를 위해 저장
|
|
2899
2949
|
slot.viewabilityTracker = tracker;
|
|
@@ -2947,7 +2997,27 @@ class AdsModule {
|
|
|
2947
2997
|
if (!this._config?.apiKey) {
|
|
2948
2998
|
throw new Error('API key not configured');
|
|
2949
2999
|
}
|
|
2950
|
-
//
|
|
3000
|
+
// 특정 광고 ID가 지정된 경우, 해당 광고만 요청
|
|
3001
|
+
if (options.adId) {
|
|
3002
|
+
const url = `${endpoints.advertisements.detail(options.adId)}`;
|
|
3003
|
+
const response = await fetch(url, {
|
|
3004
|
+
method: 'GET',
|
|
3005
|
+
headers: ApiHeaders.create(this._config.apiKey)
|
|
3006
|
+
});
|
|
3007
|
+
if (!response.ok) {
|
|
3008
|
+
if (response.status === 404) {
|
|
3009
|
+
console.warn(`🚫 Advertisement not found with ID: ${options.adId}`);
|
|
3010
|
+
return [];
|
|
3011
|
+
}
|
|
3012
|
+
throw new Error(`Failed to fetch ad data: ${response.status}`);
|
|
3013
|
+
}
|
|
3014
|
+
const advertisement = await response.json();
|
|
3015
|
+
if (this._config?.debug) {
|
|
3016
|
+
console.log(`📊 Fetched specific ad with ID: ${options.adId}`, advertisement);
|
|
3017
|
+
}
|
|
3018
|
+
return [advertisement];
|
|
3019
|
+
}
|
|
3020
|
+
// 일반적인 광고 목록 요청
|
|
2951
3021
|
const params = new URLSearchParams();
|
|
2952
3022
|
params.append('adType', type);
|
|
2953
3023
|
// 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ export const API_PATHS = {
|
|
|
26
26
|
/** 광고 관련 */
|
|
27
27
|
advertisements: {
|
|
28
28
|
list: '/advertisements/list',
|
|
29
|
+
detail: '/advertisements',
|
|
29
30
|
events: '/advertisements/events'
|
|
30
31
|
},
|
|
31
32
|
|
|
@@ -67,6 +68,7 @@ export class EndpointBuilder {
|
|
|
67
68
|
*/
|
|
68
69
|
advertisements = {
|
|
69
70
|
list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
|
|
71
|
+
detail: (adId: string) => `${this.baseUrl}${API_PATHS.advertisements.detail}/${adId}`,
|
|
70
72
|
events: (adId: string, eventType: string) =>
|
|
71
73
|
`${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
|
|
72
74
|
};
|
|
@@ -107,17 +107,37 @@ export class AdvertisementEventTracker {
|
|
|
107
107
|
};
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
const url = `${this.baseUrl}/advertisements/events/${adId}/${eventType}`;
|
|
111
|
+
const headers = ApiHeaders.createForEvents(this.apiKey, eventData);
|
|
112
|
+
|
|
113
|
+
if (this.debug) {
|
|
114
|
+
console.log(`🚀 Sending advertisement event: ${eventType} for ad ${adId}`, {
|
|
115
|
+
url,
|
|
116
|
+
headers,
|
|
117
|
+
eventData
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers,
|
|
124
|
+
body: JSON.stringify(eventData),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (this.debug) {
|
|
128
|
+
console.log(`📡 API Response Status: ${response.status} ${response.statusText}`, {
|
|
129
|
+
url,
|
|
130
|
+
ok: response.ok
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const errorText = await response.text();
|
|
136
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
|
|
137
|
+
}
|
|
118
138
|
|
|
119
139
|
if (this.debug) {
|
|
120
|
-
console.log(
|
|
140
|
+
console.log(`✅ Successfully tracked advertisement event: ${eventType} for ad ${adId}`);
|
|
121
141
|
}
|
|
122
142
|
} catch (error) {
|
|
123
143
|
console.error('Failed to track advertisement event:', error);
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export class ViewableEventTracker {
|
|
9
9
|
private static viewableTracker = new Map<string, number>();
|
|
10
|
-
private static readonly
|
|
10
|
+
private static readonly VIEWABLE_COOLDOWN_PRODUCTION = 300000; // 5분 쿨다운 (프로덕션)
|
|
11
|
+
private static readonly VIEWABLE_COOLDOWN_DEBUG = 30000; // 30초 쿨다운 (디버그)
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* 중복 viewable 이벤트 여부 확인
|
|
@@ -16,11 +17,16 @@ export class ViewableEventTracker {
|
|
|
16
17
|
const key = `${adId}_${slotId}`;
|
|
17
18
|
const now = Date.now();
|
|
18
19
|
|
|
20
|
+
// 디버그 모드에 따른 쿨다운 시간 결정
|
|
21
|
+
const cooldownTime = debug ?
|
|
22
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_DEBUG :
|
|
23
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION;
|
|
24
|
+
|
|
19
25
|
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
20
26
|
const lastViewable = ViewableEventTracker.viewableTracker.get(key);
|
|
21
|
-
if (lastViewable && (now - lastViewable) <
|
|
27
|
+
if (lastViewable && (now - lastViewable) < cooldownTime) {
|
|
22
28
|
if (debug) {
|
|
23
|
-
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
29
|
+
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - lastViewable)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
24
30
|
}
|
|
25
31
|
return true;
|
|
26
32
|
}
|
|
@@ -30,9 +36,9 @@ export class ViewableEventTracker {
|
|
|
30
36
|
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
31
37
|
if (sessionViewable) {
|
|
32
38
|
const sessionTime = parseInt(sessionViewable, 10);
|
|
33
|
-
if (!isNaN(sessionTime) && (now - sessionTime) <
|
|
39
|
+
if (!isNaN(sessionTime) && (now - sessionTime) < cooldownTime) {
|
|
34
40
|
if (debug) {
|
|
35
|
-
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((
|
|
41
|
+
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((cooldownTime - (now - sessionTime)) / 1000)}s remaining (${debug ? 'debug' : 'production'} mode)`);
|
|
36
42
|
}
|
|
37
43
|
// 메모리에도 기록하여 이후 요청 최적화
|
|
38
44
|
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
@@ -47,6 +53,10 @@ export class ViewableEventTracker {
|
|
|
47
53
|
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
48
54
|
ViewableEventTracker.cleanupOldViewables();
|
|
49
55
|
|
|
56
|
+
if (debug) {
|
|
57
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId} (${debug ? 'debug' : 'production'} mode)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
return false;
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -55,7 +65,8 @@ export class ViewableEventTracker {
|
|
|
55
65
|
*/
|
|
56
66
|
private static cleanupOldViewables(): void {
|
|
57
67
|
const now = Date.now();
|
|
58
|
-
|
|
68
|
+
// 프로덕션 쿨다운의 2배 시간이 지난 데이터 정리
|
|
69
|
+
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN_PRODUCTION * 2;
|
|
59
70
|
|
|
60
71
|
// 세션 스토리지 정리
|
|
61
72
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
@@ -81,9 +92,28 @@ export class ViewableEventTracker {
|
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
/**
|
|
84
|
-
* 모든 추적 데이터 정리
|
|
95
|
+
* 모든 추적 데이터 정리 (디버그용)
|
|
85
96
|
*/
|
|
86
97
|
static clear(): void {
|
|
87
98
|
ViewableEventTracker.viewableTracker.clear();
|
|
99
|
+
// 세션 스토리지에서도 adstage_viewable_ 키들 제거
|
|
100
|
+
const keysToRemove: string[] = [];
|
|
101
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
102
|
+
const key = sessionStorage.key(i);
|
|
103
|
+
if (key && key.startsWith('adstage_viewable_')) {
|
|
104
|
+
keysToRemove.push(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
keysToRemove.forEach(key => sessionStorage.removeItem(key));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 특정 광고의 viewable 추적 초기화 (디버그용)
|
|
112
|
+
*/
|
|
113
|
+
static clearAdViewable(adId: string, slotId: string): void {
|
|
114
|
+
const key = `${adId}_${slotId}`;
|
|
115
|
+
ViewableEventTracker.viewableTracker.delete(key);
|
|
116
|
+
const sessionKey = `adstage_viewable_${key}`;
|
|
117
|
+
sessionStorage.removeItem(sessionKey);
|
|
88
118
|
}
|
|
89
119
|
}
|
|
@@ -345,8 +345,6 @@ export class AdRenderer {
|
|
|
345
345
|
case AdType.TEXT: {
|
|
346
346
|
const textDiv = document.createElement('div');
|
|
347
347
|
textDiv.innerHTML = `
|
|
348
|
-
<h3>${ad.title}</h3>
|
|
349
|
-
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
350
348
|
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
351
349
|
`;
|
|
352
350
|
adElement.appendChild(textDiv);
|
|
@@ -480,7 +478,7 @@ export class AdRenderer {
|
|
|
480
478
|
case AdType.BANNER:
|
|
481
479
|
return '250px'; // 일반 배너
|
|
482
480
|
case AdType.TEXT:
|
|
483
|
-
return '
|
|
481
|
+
return '60px'; // 텍스트는 좀 더 작게
|
|
484
482
|
case AdType.VIDEO:
|
|
485
483
|
return '360px'; // 비디오는 16:9 비율 고려
|
|
486
484
|
case AdType.NATIVE:
|
|
@@ -26,6 +26,8 @@ export interface AdOptions {
|
|
|
26
26
|
autoplay?: boolean;
|
|
27
27
|
muted?: boolean;
|
|
28
28
|
onClick?: (adData: any) => void;
|
|
29
|
+
// 특정 광고 ID 지정
|
|
30
|
+
adId?: string;
|
|
29
31
|
// 광고 필터링 옵션 (백엔드 API에서 실제 지원하는 것들만)
|
|
30
32
|
language?: 'ko' | 'en' | 'ja' | 'zh';
|
|
31
33
|
deviceType?: 'MOBILE' | 'DESKTOP';
|
|
@@ -91,6 +93,8 @@ export class AdsModule implements BaseModule {
|
|
|
91
93
|
autoSlide: options?.autoSlide || false,
|
|
92
94
|
slideInterval: options?.slideInterval || 5000,
|
|
93
95
|
onClick: options?.onClick,
|
|
96
|
+
// 특정 광고 ID
|
|
97
|
+
adId: options?.adId,
|
|
94
98
|
// 필터링 옵션들 전달
|
|
95
99
|
language: options?.language,
|
|
96
100
|
deviceType: options?.deviceType,
|
|
@@ -110,6 +114,8 @@ export class AdsModule implements BaseModule {
|
|
|
110
114
|
maxLines: options?.maxLines || 3,
|
|
111
115
|
style: options?.style || 'default',
|
|
112
116
|
onClick: options?.onClick,
|
|
117
|
+
// 특정 광고 ID
|
|
118
|
+
adId: options?.adId,
|
|
113
119
|
// 필터링 옵션들 전달
|
|
114
120
|
language: options?.language,
|
|
115
121
|
deviceType: options?.deviceType,
|
|
@@ -351,8 +357,8 @@ export class AdsModule implements BaseModule {
|
|
|
351
357
|
const fraudDetector = new BasicFraudDetector();
|
|
352
358
|
|
|
353
359
|
// viewability 추적
|
|
354
|
-
const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
|
|
355
|
-
this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
360
|
+
const tracker = new ViewabilityTracker(element, slot.adType, async (metrics) => {
|
|
361
|
+
await this.handleViewableEvent(ad, slot, metrics, fraudDetector);
|
|
356
362
|
});
|
|
357
363
|
|
|
358
364
|
// 정리를 위해 저장
|
|
@@ -426,7 +432,33 @@ export class AdsModule implements BaseModule {
|
|
|
426
432
|
throw new Error('API key not configured');
|
|
427
433
|
}
|
|
428
434
|
|
|
429
|
-
//
|
|
435
|
+
// 특정 광고 ID가 지정된 경우, 해당 광고만 요청
|
|
436
|
+
if (options.adId) {
|
|
437
|
+
const url = `${endpoints.advertisements.detail(options.adId)}`;
|
|
438
|
+
|
|
439
|
+
const response = await fetch(url, {
|
|
440
|
+
method: 'GET',
|
|
441
|
+
headers: ApiHeaders.create(this._config.apiKey)
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!response.ok) {
|
|
445
|
+
if (response.status === 404) {
|
|
446
|
+
console.warn(`🚫 Advertisement not found with ID: ${options.adId}`);
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`Failed to fetch ad data: ${response.status}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const advertisement = await response.json();
|
|
453
|
+
|
|
454
|
+
if (this._config?.debug) {
|
|
455
|
+
console.log(`📊 Fetched specific ad with ID: ${options.adId}`, advertisement);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return [advertisement];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 일반적인 광고 목록 요청
|
|
430
462
|
const params = new URLSearchParams();
|
|
431
463
|
params.append('adType', type);
|
|
432
464
|
|
|
@@ -16,7 +16,7 @@ export class TextAdRenderer extends BaseAdRenderer {
|
|
|
16
16
|
// 기본 컨테이너 스타일
|
|
17
17
|
const containerStyles: Record<string, string> = {
|
|
18
18
|
...this.getBaseContainerStyles(slot),
|
|
19
|
-
padding: '
|
|
19
|
+
padding: '4px',
|
|
20
20
|
background: 'transparent',
|
|
21
21
|
display: 'flex',
|
|
22
22
|
'align-items': 'center',
|
|
@@ -52,7 +52,7 @@ export class TextAdRenderer extends BaseAdRenderer {
|
|
|
52
52
|
ad.textContent,
|
|
53
53
|
'div',
|
|
54
54
|
{
|
|
55
|
-
'font-size': '
|
|
55
|
+
'font-size': '14px',
|
|
56
56
|
'font-weight': '500',
|
|
57
57
|
color: '#212529',
|
|
58
58
|
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|