@adstage/web-sdk 1.4.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -17,15 +17,8 @@ var Platform;
17
17
  // 광고 이벤트 타입
18
18
  var AdEventType;
19
19
  (function (AdEventType) {
20
- AdEventType["IMPRESSION"] = "IMPRESSION";
21
- AdEventType["CLICK"] = "CLICK";
22
- AdEventType["HOVER"] = "HOVER";
23
20
  AdEventType["VIEWABLE"] = "VIEWABLE";
24
- AdEventType["VIEWABLE_IMPRESSION"] = "VIEWABLE_IMPRESSION";
25
- AdEventType["COMPLETED"] = "COMPLETED";
26
- AdEventType["VIDEO_START"] = "VIDEO_START";
27
- AdEventType["VIDEO_COMPLETE"] = "VIDEO_COMPLETE";
28
- AdEventType["ERROR"] = "ERROR";
21
+ AdEventType["CLICK"] = "CLICK";
29
22
  })(AdEventType || (AdEventType = {}));
30
23
  // 디바이스 타입
31
24
  var DeviceType;
@@ -947,7 +940,7 @@ class CarouselSliderManager {
947
940
  }
948
941
  // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
949
942
  if (actualIndex > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
950
- trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.IMPRESSION);
943
+ trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.VIEWABLE);
951
944
  }
952
945
  };
953
946
  // 무한 루프 처리 함수
@@ -1267,7 +1260,7 @@ class TextTransitionManager {
1267
1260
  currentSlide = index;
1268
1261
  // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
1269
1262
  if (currentSlide > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
1270
- trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.IMPRESSION);
1263
+ trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.VIEWABLE);
1271
1264
  }
1272
1265
  };
1273
1266
  // 자동 슬라이드
@@ -1333,57 +1326,58 @@ class TextTransitionManager {
1333
1326
  }
1334
1327
 
1335
1328
  /**
1336
- * 노출 추적 및 중복 방지 관리 클래스
1329
+ * VIEWABLE 이벤트 추적 및 중복 방지 관리 클래스
1337
1330
  * - 메모리 기반 중복 확인
1338
1331
  * - 세션 스토리지 기반 영구 추적
1339
1332
  * - 자동 정리 기능
1333
+ * - ViewabilityTracker와 함께 사용하여 중복 viewable 이벤트 방지
1340
1334
  */
1341
- class ImpressionTracker {
1335
+ class ViewableEventTracker {
1342
1336
  /**
1343
- * 중복 노출 여부 확인
1337
+ * 중복 viewable 이벤트 여부 확인
1344
1338
  */
1345
- static isDuplicateImpression(adId, slotId, debug = false) {
1339
+ static isDuplicateViewable(adId, slotId, debug = false) {
1346
1340
  const key = `${adId}_${slotId}`;
1347
1341
  const now = Date.now();
1348
1342
  // 메모리 기반 중복 확인 (새로고침 시 초기화됨)
1349
- const lastImpression = ImpressionTracker.impressionTracker.get(key);
1350
- if (lastImpression && (now - lastImpression) < ImpressionTracker.IMPRESSION_COOLDOWN) {
1343
+ const lastViewable = ViewableEventTracker.viewableTracker.get(key);
1344
+ if (lastViewable && (now - lastViewable) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
1351
1345
  if (debug) {
1352
- console.log(`Duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - lastImpression)) / 1000)}s remaining`);
1346
+ console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - lastViewable)) / 1000)}s remaining`);
1353
1347
  }
1354
1348
  return true;
1355
1349
  }
1356
1350
  // 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
1357
- const sessionKey = `adstage_impression_${key}`;
1358
- const sessionImpression = sessionStorage.getItem(sessionKey);
1359
- if (sessionImpression) {
1360
- const sessionTime = parseInt(sessionImpression, 10);
1361
- if (!isNaN(sessionTime) && (now - sessionTime) < ImpressionTracker.IMPRESSION_COOLDOWN) {
1351
+ const sessionKey = `adstage_viewable_${key}`;
1352
+ const sessionViewable = sessionStorage.getItem(sessionKey);
1353
+ if (sessionViewable) {
1354
+ const sessionTime = parseInt(sessionViewable, 10);
1355
+ if (!isNaN(sessionTime) && (now - sessionTime) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
1362
1356
  if (debug) {
1363
- console.log(`Session-based duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
1357
+ console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
1364
1358
  }
1365
1359
  // 메모리에도 기록하여 이후 요청 최적화
1366
- ImpressionTracker.impressionTracker.set(key, sessionTime);
1360
+ ViewableEventTracker.viewableTracker.set(key, sessionTime);
1367
1361
  return true;
1368
1362
  }
1369
1363
  }
1370
- // 노출 시점 기록 (메모리 + 세션 스토리지)
1371
- ImpressionTracker.impressionTracker.set(key, now);
1364
+ // viewable 이벤트 시점 기록 (메모리 + 세션 스토리지)
1365
+ ViewableEventTracker.viewableTracker.set(key, now);
1372
1366
  sessionStorage.setItem(sessionKey, now.toString());
1373
1367
  // 오래된 세션 스토리지 데이터 정리 (선택적)
1374
- ImpressionTracker.cleanupOldImpressions();
1368
+ ViewableEventTracker.cleanupOldViewables();
1375
1369
  return false;
1376
1370
  }
1377
1371
  /**
1378
- * 오래된 노출 추적 데이터 정리
1372
+ * 오래된 viewable 추적 데이터 정리
1379
1373
  */
1380
- static cleanupOldImpressions() {
1374
+ static cleanupOldViewables() {
1381
1375
  const now = Date.now();
1382
- const cleanupThreshold = ImpressionTracker.IMPRESSION_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
1376
+ const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
1383
1377
  // 세션 스토리지 정리
1384
1378
  for (let i = 0; i < sessionStorage.length; i++) {
1385
1379
  const key = sessionStorage.key(i);
1386
- if (key && key.startsWith('adstage_impression_')) {
1380
+ if (key && key.startsWith('adstage_viewable_')) {
1387
1381
  const timestamp = sessionStorage.getItem(key);
1388
1382
  if (timestamp) {
1389
1383
  const time = parseInt(timestamp, 10);
@@ -1395,9 +1389,9 @@ class ImpressionTracker {
1395
1389
  }
1396
1390
  }
1397
1391
  // 메모리 정리
1398
- for (const [key, timestamp] of ImpressionTracker.impressionTracker.entries()) {
1392
+ for (const [key, timestamp] of ViewableEventTracker.viewableTracker.entries()) {
1399
1393
  if ((now - timestamp) > cleanupThreshold) {
1400
- ImpressionTracker.impressionTracker.delete(key);
1394
+ ViewableEventTracker.viewableTracker.delete(key);
1401
1395
  }
1402
1396
  }
1403
1397
  }
@@ -1405,11 +1399,11 @@ class ImpressionTracker {
1405
1399
  * 모든 추적 데이터 정리
1406
1400
  */
1407
1401
  static clear() {
1408
- ImpressionTracker.impressionTracker.clear();
1402
+ ViewableEventTracker.viewableTracker.clear();
1409
1403
  }
1410
1404
  }
1411
- ImpressionTracker.impressionTracker = new Map();
1412
- ImpressionTracker.IMPRESSION_COOLDOWN = 300000; // 5분 쿨다운
1405
+ ViewableEventTracker.viewableTracker = new Map();
1406
+ ViewableEventTracker.VIEWABLE_COOLDOWN = 300000; // 5분 쿨다운
1413
1407
 
1414
1408
  /**
1415
1409
  * 디바이스 정보 수집 클래스
@@ -1547,12 +1541,12 @@ class ApiHeaders {
1547
1541
  }
1548
1542
 
1549
1543
  /**
1550
- * 이벤트 추적 관리 클래스
1551
- * - 광고 이벤트 추적 및 전송
1552
- * - 중복 노출 방지 통합
1553
- * - 서버 API 통신
1544
+ * 광고 이벤트 추적 관리 클래스
1545
+ * - 광고 전용 이벤트 추적 및 전송
1546
+ * - Viewable 이벤트 중복 방지 통합
1547
+ * - 광고 서버 API 통신
1554
1548
  */
1555
- class EventTracker {
1549
+ class AdvertisementEventTracker {
1556
1550
  constructor(baseUrl, apiKey, debug, slots) {
1557
1551
  this.baseUrl = baseUrl;
1558
1552
  this.apiKey = apiKey;
@@ -1560,21 +1554,21 @@ class EventTracker {
1560
1554
  this.slots = slots;
1561
1555
  }
1562
1556
  /**
1563
- * 이벤트 추적
1557
+ * 광고 이벤트 추적 - viewability 데이터 지원
1564
1558
  */
1565
- async trackEvent(adId, slotId, eventType) {
1559
+ async trackAdvertisementEvent(adId, slotId, eventType, additionalData) {
1566
1560
  try {
1567
- // 노출 이벤트의 경우 중복 확인
1568
- if (eventType === AdEventType.IMPRESSION) {
1569
- if (ImpressionTracker.isDuplicateImpression(adId, slotId, this.debug)) {
1570
- return; // 중복 노출이므로 추적하지 않음
1561
+ // VIEWABLE 이벤트의 경우 중복 확인
1562
+ if (eventType === AdEventType.VIEWABLE) {
1563
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
1564
+ return; // 중복 viewable 이벤트이므로 추적하지 않음
1571
1565
  }
1572
1566
  }
1573
1567
  // 현재 슬롯 정보 가져오기
1574
1568
  const slot = this.slots.get(slotId);
1575
1569
  // 디바이스 정보 수집
1576
1570
  const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
1577
- // 이벤트 데이터 구성 (MongoDB 스키마에 맞춤)
1571
+ // 광고 이벤트 데이터 구성 (DTO 구조에 맞춤)
1578
1572
  const eventData = {
1579
1573
  // 서버에서 자동 설정: orgId, advertisementId, action
1580
1574
  // 하지만 DTO 검증을 위해 임시값 제공
@@ -1584,40 +1578,40 @@ class EventTracker {
1584
1578
  // 필수 필드들
1585
1579
  adType: slot?.adType || 'BANNER',
1586
1580
  platform: deviceInfo.platform,
1587
- // 디바이스 정보를 최상위로 플래튼
1588
- deviceId: deviceInfo.deviceId,
1589
- osVersion: deviceInfo.osVersion,
1590
- deviceModel: deviceInfo.deviceModel,
1591
- appVersion: deviceInfo.appVersion,
1592
- sdkVersion: deviceInfo.sdkVersion,
1593
- language: deviceInfo.language,
1594
- country: deviceInfo.country,
1595
- ipAddress: deviceInfo.ipAddress,
1596
- userAgent: deviceInfo.userAgent,
1597
- timezone: deviceInfo.timezone,
1598
- viewportWidth: deviceInfo.viewportWidth,
1599
- viewportHeight: deviceInfo.viewportHeight,
1600
- screenWidth: deviceInfo.screenWidth,
1601
- screenHeight: deviceInfo.screenHeight,
1602
- connectionType: deviceInfo.connectionType,
1581
+ // 디바이스 정보는 deviceInfo 객체로 래핑
1582
+ deviceInfo: deviceInfo,
1603
1583
  // 페이지 및 슬롯 정보
1604
1584
  pageUrl: DOMUtils.getPageInfo().url,
1605
1585
  pageTitle: DOMUtils.getPageInfo().title,
1606
1586
  referrer: DOMUtils.getPageInfo().referrer,
1607
1587
  slotId,
1608
1588
  slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
1609
- slotWidth: EventTracker.parseNumericValue(slot?.width),
1610
- slotHeight: EventTracker.parseNumericValue(slot?.height),
1589
+ slotWidth: AdvertisementEventTracker.parseNumericValue(slot?.width),
1590
+ slotHeight: AdvertisementEventTracker.parseNumericValue(slot?.height),
1611
1591
  sessionId: deviceInfo.sessionId,
1612
1592
  // 성능 메트릭
1613
1593
  pageLoadTime: performance.now(),
1614
- timestamp: new Date().toISOString(),
1615
1594
  // 추가 메타데이터
1616
1595
  metadata: {
1617
1596
  eventType,
1618
1597
  sdkVersion: '1.0.0',
1619
1598
  timestamp: Date.now(),
1620
1599
  },
1600
+ // viewable 관련 추가 데이터 (DTO 필드명과 매칭)
1601
+ ...(additionalData?.viewabilityMetrics && {
1602
+ isViewable: additionalData.viewabilityMetrics.isViewable,
1603
+ exposureTime: additionalData.viewabilityMetrics.exposureTime,
1604
+ maxVisibilityRatio: additionalData.viewabilityMetrics.maxVisibilityRatio,
1605
+ firstViewableTime: additionalData.viewabilityMetrics.firstViewableTime,
1606
+ // IAB 표준 준수 여부
1607
+ iabCompliant: additionalData.viewabilityMetrics.isViewable,
1608
+ }),
1609
+ // fraud 관련 데이터 (DTO 필드명과 매칭)
1610
+ ...(additionalData?.fraudScore !== undefined && {
1611
+ fraudScore: additionalData.fraudScore,
1612
+ fraudReasons: additionalData.fraudReasons,
1613
+ riskLevel: additionalData.riskLevel,
1614
+ }),
1621
1615
  };
1622
1616
  await fetch(`${this.baseUrl}/advertisements/events/${adId}/${eventType}`, {
1623
1617
  method: 'POST',
@@ -1625,11 +1619,11 @@ class EventTracker {
1625
1619
  body: JSON.stringify(eventData),
1626
1620
  });
1627
1621
  if (this.debug) {
1628
- console.log(`Tracked event: ${eventType} for ad ${adId}`, eventData);
1622
+ console.log(`Tracked advertisement event: ${eventType} for ad ${adId}`, eventData);
1629
1623
  }
1630
1624
  }
1631
1625
  catch (error) {
1632
- console.error('Failed to track event:', error);
1626
+ console.error('Failed to track advertisement event:', error);
1633
1627
  }
1634
1628
  }
1635
1629
  /**
@@ -1648,6 +1642,298 @@ class EventTracker {
1648
1642
  }
1649
1643
  }
1650
1644
 
1645
+ /**
1646
+ * IAB 표준 준수 viewable impression 측정
1647
+ */
1648
+ // 광고 타입별 IAB 표준 설정
1649
+ const VIEWABILITY_STANDARDS = {
1650
+ BANNER: {
1651
+ threshold: 0.5,
1652
+ minDuration: 1000,
1653
+ maxMeasureTime: 30000
1654
+ },
1655
+ VIDEO: {
1656
+ threshold: 0.5,
1657
+ minDuration: 2000,
1658
+ maxMeasureTime: 60000
1659
+ },
1660
+ NATIVE: {
1661
+ threshold: 0.5,
1662
+ minDuration: 1000,
1663
+ maxMeasureTime: 30000
1664
+ },
1665
+ INTERSTITIAL: {
1666
+ threshold: 0.5,
1667
+ minDuration: 1000,
1668
+ maxMeasureTime: 10000
1669
+ },
1670
+ TEXT: {
1671
+ threshold: 0.5,
1672
+ minDuration: 1000,
1673
+ maxMeasureTime: 30000
1674
+ },
1675
+ POPUP: {
1676
+ threshold: 0.5,
1677
+ minDuration: 1000,
1678
+ maxMeasureTime: 10000
1679
+ }
1680
+ };
1681
+ class ViewabilityTracker {
1682
+ constructor(element, adType, onViewable) {
1683
+ this.observer = null;
1684
+ this.viewabilityTimer = null;
1685
+ this.maxVisibilityTimer = null;
1686
+ this.startTime = 0;
1687
+ this.maxVisibilityRatio = 0;
1688
+ this.firstViewableTime = null;
1689
+ this.isViewableAchieved = false;
1690
+ this.element = element;
1691
+ this.config = VIEWABILITY_STANDARDS[adType] || VIEWABILITY_STANDARDS.BANNER;
1692
+ this.onViewableCallback = onViewable;
1693
+ this.startTime = performance.now();
1694
+ this.initIntersectionObserver();
1695
+ this.initMaxMeasureTimer();
1696
+ }
1697
+ initIntersectionObserver() {
1698
+ // IntersectionObserver 지원 확인
1699
+ if (!('IntersectionObserver' in window)) {
1700
+ console.warn('IntersectionObserver not supported, viewability tracking disabled');
1701
+ return;
1702
+ }
1703
+ this.observer = new IntersectionObserver((entries) => this.handleIntersection(entries), {
1704
+ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0],
1705
+ rootMargin: '0px'
1706
+ });
1707
+ this.observer.observe(this.element);
1708
+ }
1709
+ handleIntersection(entries) {
1710
+ entries.forEach(entry => {
1711
+ const visibilityRatio = entry.intersectionRatio;
1712
+ const isVisible = this.isDocumentVisible();
1713
+ // 최대 가시성 비율 추적
1714
+ this.maxVisibilityRatio = Math.max(this.maxVisibilityRatio, visibilityRatio);
1715
+ // Viewable 조건 확인 (50% 이상 + 문서 가시성)
1716
+ if (visibilityRatio >= this.config.threshold && isVisible) {
1717
+ this.startViewabilityTimer();
1718
+ }
1719
+ else {
1720
+ this.stopViewabilityTimer();
1721
+ }
1722
+ });
1723
+ }
1724
+ isDocumentVisible() {
1725
+ // 단순한 문서 가시성 확인
1726
+ return !document.hidden && document.visibilityState === 'visible';
1727
+ }
1728
+ startViewabilityTimer() {
1729
+ if (this.viewabilityTimer || this.isViewableAchieved)
1730
+ return;
1731
+ if (this.firstViewableTime === null) {
1732
+ this.firstViewableTime = performance.now();
1733
+ }
1734
+ this.viewabilityTimer = setTimeout(() => {
1735
+ this.onViewabilityAchieved();
1736
+ }, this.config.minDuration);
1737
+ }
1738
+ stopViewabilityTimer() {
1739
+ if (this.viewabilityTimer) {
1740
+ clearTimeout(this.viewabilityTimer);
1741
+ this.viewabilityTimer = null;
1742
+ }
1743
+ }
1744
+ initMaxMeasureTimer() {
1745
+ // 최대 측정 시간 후 자동 종료
1746
+ this.maxVisibilityTimer = setTimeout(() => {
1747
+ this.destroy();
1748
+ }, this.config.maxMeasureTime);
1749
+ }
1750
+ onViewabilityAchieved() {
1751
+ if (this.isViewableAchieved)
1752
+ return;
1753
+ this.isViewableAchieved = true;
1754
+ const metrics = this.calculateMetrics();
1755
+ if (this.onViewableCallback) {
1756
+ this.onViewableCallback(metrics);
1757
+ }
1758
+ }
1759
+ calculateMetrics() {
1760
+ const currentTime = performance.now();
1761
+ const exposureTime = this.firstViewableTime
1762
+ ? currentTime - this.firstViewableTime
1763
+ : 0;
1764
+ return {
1765
+ isViewable: this.isViewableAchieved,
1766
+ exposureTime,
1767
+ maxVisibilityRatio: this.maxVisibilityRatio,
1768
+ firstViewableTime: this.firstViewableTime,
1769
+ measureStartTime: this.startTime,
1770
+ };
1771
+ }
1772
+ getMetrics() {
1773
+ return this.calculateMetrics();
1774
+ }
1775
+ destroy() {
1776
+ this.stopViewabilityTimer();
1777
+ if (this.maxVisibilityTimer) {
1778
+ clearTimeout(this.maxVisibilityTimer);
1779
+ this.maxVisibilityTimer = null;
1780
+ }
1781
+ if (this.observer) {
1782
+ this.observer.disconnect();
1783
+ this.observer = null;
1784
+ }
1785
+ }
1786
+ }
1787
+
1788
+ /**
1789
+ * BasicFraudDetector - 현실적 구현 버전
1790
+ * 기본적인 봇 탐지 및 간단한 행동 패턴 분석
1791
+ */
1792
+ class BasicFraudDetector {
1793
+ constructor() {
1794
+ this.mouseEvents = 0;
1795
+ this.keyboardEvents = 0;
1796
+ this.scrollEvents = 0;
1797
+ this.startTime = Date.now();
1798
+ this.initBasicTracking();
1799
+ }
1800
+ initBasicTracking() {
1801
+ // 기본적인 사용자 상호작용 추적
1802
+ document.addEventListener('mousemove', () => this.mouseEvents++, { passive: true });
1803
+ document.addEventListener('keydown', () => this.keyboardEvents++, { passive: true });
1804
+ document.addEventListener('scroll', () => this.scrollEvents++, { passive: true });
1805
+ }
1806
+ calculateFraudScore() {
1807
+ let score = 0;
1808
+ const reasons = [];
1809
+ // 1. 웹드라이버 탐지 (기본)
1810
+ if (this.detectWebDriver()) {
1811
+ score += 50;
1812
+ reasons.push('WebDriver detected');
1813
+ }
1814
+ // 2. 헤드리스 브라우저 기본 탐지
1815
+ if (this.detectBasicHeadless()) {
1816
+ score += 40;
1817
+ reasons.push('Headless browser signatures');
1818
+ }
1819
+ // 3. 사용자 상호작용 부족
1820
+ const sessionTime = Date.now() - this.startTime;
1821
+ if (sessionTime > 5000) { // 5초 이상 경과
1822
+ if (this.mouseEvents === 0) {
1823
+ score += 20;
1824
+ reasons.push('No mouse interaction');
1825
+ }
1826
+ if (this.scrollEvents === 0 && sessionTime > 10000) {
1827
+ score += 15;
1828
+ reasons.push('No scroll activity');
1829
+ }
1830
+ }
1831
+ // 4. 브라우저 환경 이상 징후
1832
+ const browserCheck = this.checkBrowserEnvironment();
1833
+ score += browserCheck.score;
1834
+ reasons.push(...browserCheck.reasons);
1835
+ // 5. 시간 패턴 이상 (너무 빠른 페이지 로드 후 즉시 클릭)
1836
+ if (sessionTime < 1000) {
1837
+ score += 25;
1838
+ reasons.push('Suspiciously fast interaction');
1839
+ }
1840
+ const finalScore = Math.min(score, 100);
1841
+ return {
1842
+ score: finalScore,
1843
+ riskLevel: this.getRiskLevel(finalScore),
1844
+ reasons: reasons
1845
+ };
1846
+ }
1847
+ detectWebDriver() {
1848
+ // 기본적인 웹드라이버 탐지
1849
+ return !!(window.webdriver ||
1850
+ navigator.webdriver ||
1851
+ window.__webdriver_evaluate ||
1852
+ window.__selenium_evaluate ||
1853
+ window.__webdriver_script_function ||
1854
+ window.__webdriver_script_func ||
1855
+ window.__webdriver_script_fn ||
1856
+ window.__fxdriver_evaluate ||
1857
+ window.__driver_unwrapped ||
1858
+ window.__webdriver_unwrapped ||
1859
+ window.__driver_evaluate ||
1860
+ window.__selenium_unwrapped ||
1861
+ window.__fxdriver_unwrapped);
1862
+ }
1863
+ detectBasicHeadless() {
1864
+ const signatures = [];
1865
+ // PhantomJS 탐지
1866
+ if (window._phantom || window.phantom) {
1867
+ signatures.push('PhantomJS');
1868
+ }
1869
+ // Chrome headless 기본 탐지
1870
+ if (navigator.userAgent.includes('HeadlessChrome')) {
1871
+ signatures.push('Chrome Headless');
1872
+ }
1873
+ // 플러그인 없음 (일반적이지 않음)
1874
+ if (navigator.plugins.length === 0) {
1875
+ signatures.push('No plugins');
1876
+ }
1877
+ return signatures.length > 0;
1878
+ }
1879
+ checkBrowserEnvironment() {
1880
+ let score = 0;
1881
+ const reasons = [];
1882
+ // 언어 설정 이상
1883
+ if (!navigator.language || navigator.language === 'C') {
1884
+ score += 10;
1885
+ reasons.push('Unusual language setting');
1886
+ }
1887
+ // 쿠키 비활성화
1888
+ if (!navigator.cookieEnabled) {
1889
+ score += 15;
1890
+ reasons.push('Cookies disabled');
1891
+ }
1892
+ // 화면 해상도 이상
1893
+ if (screen.width === 0 || screen.height === 0) {
1894
+ score += 20;
1895
+ reasons.push('Invalid screen resolution');
1896
+ }
1897
+ // 시간대 정보 없음
1898
+ try {
1899
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1900
+ if (!timezone || timezone === 'UTC') {
1901
+ score += 5;
1902
+ reasons.push('No timezone info');
1903
+ }
1904
+ }
1905
+ catch (e) {
1906
+ score += 10;
1907
+ reasons.push('Timezone detection failed');
1908
+ }
1909
+ return { score, reasons };
1910
+ }
1911
+ getRiskLevel(score) {
1912
+ if (score >= 70)
1913
+ return 'CRITICAL';
1914
+ if (score >= 50)
1915
+ return 'HIGH';
1916
+ if (score >= 30)
1917
+ return 'MEDIUM';
1918
+ return 'LOW';
1919
+ }
1920
+ getBrowserInfo() {
1921
+ return {
1922
+ userAgent: navigator.userAgent,
1923
+ language: navigator.language,
1924
+ platform: navigator.platform,
1925
+ cookieEnabled: navigator.cookieEnabled,
1926
+ doNotTrack: navigator.doNotTrack,
1927
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1928
+ screenResolution: `${screen.width}x${screen.height}`,
1929
+ viewportSize: `${window.innerWidth}x${window.innerHeight}`
1930
+ };
1931
+ }
1932
+ destroy() {
1933
+ // 이벤트 리스너 정리는 생략 (메모리 누수 방지를 위해 필요시 구현)
1934
+ }
1935
+ }
1936
+
1651
1937
  /**
1652
1938
  * AdStage SDK 엔드포인트 상수 관리
1653
1939
  * 모든 API URL을 중앙에서 관리
@@ -1732,15 +2018,16 @@ class AdsModule {
1732
2018
  this._isReady = false;
1733
2019
  this._config = null;
1734
2020
  this.slots = new Map();
1735
- this.eventTracker = null;
2021
+ // Advertisement 이벤트 추적 관련
2022
+ this.advertisementEventTracker = null;
1736
2023
  }
1737
2024
  /**
1738
2025
  * Ads 모듈 초기화 (동기)
1739
2026
  */
1740
2027
  init(config) {
1741
2028
  this._config = config;
1742
- // EventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
1743
- this.eventTracker = new EventTracker(endpoints.getBaseUrl(), config.apiKey, config.debug || false, this.slots);
2029
+ // AdvertisementEventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
2030
+ this.advertisementEventTracker = new AdvertisementEventTracker(endpoints.getBaseUrl(), config.apiKey, config.debug || false, this.slots);
1744
2031
  this._isReady = true;
1745
2032
  if (config.debug) {
1746
2033
  console.log('🎯 Ads module initialized (sync mode)');
@@ -1768,7 +2055,11 @@ class AdsModule {
1768
2055
  height: options?.height || 250,
1769
2056
  autoSlide: options?.autoSlide || false,
1770
2057
  slideInterval: options?.slideInterval || 5000,
1771
- onClick: options?.onClick
2058
+ onClick: options?.onClick,
2059
+ // 필터링 옵션들 전달
2060
+ language: options?.language,
2061
+ deviceType: options?.deviceType,
2062
+ country: options?.country
1772
2063
  };
1773
2064
  return this.createAd(containerId, AdType.BANNER, adstageOptions);
1774
2065
  }
@@ -1780,7 +2071,11 @@ class AdsModule {
1780
2071
  const adstageOptions = {
1781
2072
  maxLines: options?.maxLines || 3,
1782
2073
  style: options?.style || 'default',
1783
- onClick: options?.onClick
2074
+ onClick: options?.onClick,
2075
+ // 필터링 옵션들 전달
2076
+ language: options?.language,
2077
+ deviceType: options?.deviceType,
2078
+ country: options?.country
1784
2079
  };
1785
2080
  return this.createAd(containerId, AdType.TEXT, adstageOptions);
1786
2081
  }
@@ -1836,6 +2131,14 @@ class AdsModule {
1836
2131
  if (!slot) {
1837
2132
  throw new Error(`Ad slot not found: ${slotId}`);
1838
2133
  }
2134
+ // ViewabilityTracker 정리
2135
+ if (slot.viewabilityTracker) {
2136
+ slot.viewabilityTracker.destroy();
2137
+ }
2138
+ // BasicFraudDetector 정리
2139
+ if (slot.fraudDetector) {
2140
+ slot.fraudDetector.destroy();
2141
+ }
1839
2142
  // DOM에서 제거
1840
2143
  const container = document.getElementById(slot.containerId);
1841
2144
  if (container) {
@@ -1900,8 +2203,8 @@ class AdsModule {
1900
2203
  // 백그라운드에서 광고 로드
1901
2204
  this.loadAdContentInBackground(slot);
1902
2205
  // 이벤트 추적 준비
1903
- if (this.eventTracker && this._config?.debug) {
1904
- console.log(`📊 Event tracking enabled for slot: ${slotId}`);
2206
+ if (this.advertisementEventTracker && this._config?.debug) {
2207
+ console.log(`📊 Advertisement event tracking enabled for slot: ${slotId}`);
1905
2208
  }
1906
2209
  return slotId;
1907
2210
  }
@@ -1945,6 +2248,8 @@ class AdsModule {
1945
2248
  // 광고가 1개면 일반 렌더링
1946
2249
  slot.advertisement = adstageData[0];
1947
2250
  await this.renderAdElement(slot, adstageData[0]);
2251
+ // ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
2252
+ this.startBasicViewabilityTracking(slot, adstageData[0]);
1948
2253
  }
1949
2254
  slot.isLoaded = true;
1950
2255
  if (this._config?.debug) {
@@ -1957,17 +2262,72 @@ class AdsModule {
1957
2262
  }
1958
2263
  }
1959
2264
  /**
1960
- * Fallback 광고 렌더링
2265
+ * 기본 viewability 추적 시작
2266
+ */
2267
+ startBasicViewabilityTracking(slot, ad) {
2268
+ const element = document.getElementById(slot.id);
2269
+ if (!element)
2270
+ return;
2271
+ // 기본 fraud 검사
2272
+ const fraudDetector = new BasicFraudDetector();
2273
+ // viewability 추적
2274
+ const tracker = new ViewabilityTracker(element, slot.adType, (metrics) => {
2275
+ this.handleViewableEvent(ad, slot, metrics, fraudDetector);
2276
+ });
2277
+ // 정리를 위해 저장
2278
+ slot.viewabilityTracker = tracker;
2279
+ slot.fraudDetector = fraudDetector;
2280
+ if (this._config?.debug) {
2281
+ console.log(`🎯 Viewability tracking started for slot: ${slot.id}`);
2282
+ }
2283
+ }
2284
+ /**
2285
+ * Viewable 이벤트 처리
2286
+ */
2287
+ async handleViewableEvent(ad, slot, metrics, fraudDetector) {
2288
+ try {
2289
+ const fraudScore = fraudDetector.calculateFraudScore();
2290
+ // 높은 위험도면 차단
2291
+ if (fraudScore.riskLevel === 'CRITICAL') {
2292
+ if (this._config?.debug) {
2293
+ console.warn(`🚫 Viewable blocked due to fraud risk: ${fraudScore.score}`, fraudScore.reasons);
2294
+ }
2295
+ return;
2296
+ }
2297
+ // VIEWABLE 이벤트 전송
2298
+ if (this.advertisementEventTracker) {
2299
+ await this.advertisementEventTracker.trackAdvertisementEvent(ad._id, slot.id, AdEventType.VIEWABLE, {
2300
+ viewabilityMetrics: metrics,
2301
+ fraudScore: fraudScore.score,
2302
+ fraudReasons: fraudScore.reasons,
2303
+ riskLevel: fraudScore.riskLevel
2304
+ });
2305
+ if (this._config?.debug) {
2306
+ console.log(`✅ Viewable impression tracked for ad ${ad._id} (fraud score: ${fraudScore.score})`);
2307
+ }
2308
+ }
2309
+ }
2310
+ catch (error) {
2311
+ console.error(`❌ Failed to track viewable impression:`, error);
2312
+ }
2313
+ }
2314
+ /**
2315
+ * Fallback 광고 렌더링 - DOM에서 완전 제거
1961
2316
  */
1962
2317
  renderFallback(slot) {
1963
2318
  const element = document.getElementById(slot.id);
1964
2319
  if (element) {
1965
- element.innerHTML = `<span>Ad not available</span>`;
1966
- element.style.color = '#999';
1967
- if (this._config?.debug) {
1968
- console.warn(`⚠️ Fallback rendered for slot: ${slot.id}`);
2320
+ // 부모 컨테이너에서 광고 슬롯을 완전히 제거
2321
+ const parentContainer = element.parentNode;
2322
+ if (parentContainer) {
2323
+ parentContainer.removeChild(element);
2324
+ if (this._config?.debug) {
2325
+ console.warn(`⚠️ Ad slot completely removed from DOM: ${slot.id}`);
2326
+ }
1969
2327
  }
1970
2328
  }
2329
+ // 슬롯 맵에서도 제거
2330
+ this.slots.delete(slot.id);
1971
2331
  }
1972
2332
  /**
1973
2333
  * 광고 데이터 가져오기
@@ -1979,8 +2339,16 @@ class AdsModule {
1979
2339
  // GET 요청용 query parameters 구성
1980
2340
  const params = new URLSearchParams();
1981
2341
  params.append('adType', type);
1982
- // userAgent와 url은 header나 자동으로 처리되므로 query에서 제외
1983
- // 기타 옵션들을 필요시 query parameter로 추가 가능
2342
+ // 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
2343
+ if (options.language) {
2344
+ params.append('language', options.language);
2345
+ }
2346
+ if (options.deviceType) {
2347
+ params.append('deviceType', options.deviceType);
2348
+ }
2349
+ if (options.country) {
2350
+ params.append('country', options.country);
2351
+ }
1984
2352
  const url = `${endpoints.advertisements.list()}?${params.toString()}`;
1985
2353
  const response = await fetch(url, {
1986
2354
  method: 'GET',
@@ -1990,7 +2358,15 @@ class AdsModule {
1990
2358
  throw new Error(`Failed to fetch ad data: ${response.status}`);
1991
2359
  }
1992
2360
  const result = await response.json();
1993
- return result.advertisements || [];
2361
+ const advertisements = result.advertisements || [];
2362
+ if (this._config?.debug) {
2363
+ console.log(`📊 Fetched ${advertisements.length} ads for type: ${type}, filters:`, {
2364
+ language: options.language,
2365
+ deviceType: options.deviceType,
2366
+ country: options.country
2367
+ });
2368
+ }
2369
+ return advertisements;
1994
2370
  }
1995
2371
  /**
1996
2372
  * 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
@@ -2003,19 +2379,19 @@ class AdsModule {
2003
2379
  // 이벤트 추적 콜백 함수 (중복 노출 방지 포함)
2004
2380
  const trackEventCallback = (adId, slotId, eventType) => {
2005
2381
  // 노출 이벤트인 경우 중복 확인
2006
- if (eventType === AdEventType.IMPRESSION) {
2007
- if (ImpressionTracker.isDuplicateImpression(adId, slotId, this._config?.debug)) {
2382
+ if (eventType === AdEventType.VIEWABLE) {
2383
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
2008
2384
  if (this._config?.debug) {
2009
- console.log(`🚫 Duplicate impression blocked for ad ${adId} in slot ${slotId}`);
2385
+ console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
2010
2386
  }
2011
2387
  return; // 중복 노출이면 추적하지 않음
2012
2388
  }
2013
2389
  if (this._config?.debug) {
2014
- console.log(`✅ New impression recorded for ad ${adId} in slot ${slotId}`);
2390
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
2015
2391
  }
2016
2392
  }
2017
- if (this.eventTracker && this._config?.debug) {
2018
- console.log(`📊 Event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
2393
+ if (this.advertisementEventTracker && this._config?.debug) {
2394
+ console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
2019
2395
  }
2020
2396
  };
2021
2397
  let sliderElement;
@@ -2120,8 +2496,8 @@ class AdsModule {
2120
2496
  slot.advertisement = newAdData[0]; // 첫 번째 광고로 업데이트
2121
2497
  await this.renderAd(slot);
2122
2498
  // 새로운 노출 추적
2123
- if (this.eventTracker) {
2124
- console.log('New impression tracked for slot:', slot.id);
2499
+ if (this.advertisementEventTracker) {
2500
+ console.log('New advertisement viewable tracked for slot:', slot.id);
2125
2501
  }
2126
2502
  }
2127
2503
  }