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