@roottale/analytics-runtime 0.6.0 → 0.7.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/README.md +5 -0
- package/dist/ai-referrals.d.ts +12 -1
- package/dist/ai-referrals.js +5 -1
- package/dist/bots.d.ts +32 -0
- package/dist/bots.js +101 -0
- package/dist/bots.js.map +1 -0
- package/dist/{chunk-GV4CWEWY.js → chunk-GOLIQX2X.js} +2 -2
- package/dist/{chunk-6NWJK7V7.js → chunk-KKGWQGIR.js} +18 -1
- package/dist/chunk-KKGWQGIR.js.map +1 -0
- package/dist/{chunk-EHXYDR7C.js → chunk-KS5MJEOM.js} +13 -2
- package/dist/chunk-KS5MJEOM.js.map +1 -0
- package/dist/chunk-MXOJW2UE.js +155 -0
- package/dist/chunk-MXOJW2UE.js.map +1 -0
- package/dist/{chunk-K3NVHH7K.js → chunk-YCUCU247.js} +39 -3
- package/dist/chunk-YCUCU247.js.map +1 -0
- package/dist/entry-channels.d.ts +75 -1
- package/dist/entry-channels.js +15 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.js +26 -6
- package/dist/index.js.map +1 -1
- package/dist/next.js +4 -4
- package/dist/next.js.map +1 -1
- package/dist/react.js +3 -3
- package/dist/visits.d.ts +42 -0
- package/dist/visits.js +50 -0
- package/dist/visits.js.map +1 -0
- package/package.json +11 -1
- package/dist/chunk-6NWJK7V7.js.map +0 -1
- package/dist/chunk-EHXYDR7C.js.map +0 -1
- package/dist/chunk-K3NVHH7K.js.map +0 -1
- package/dist/chunk-KIOFZXJX.js +0 -99
- package/dist/chunk-KIOFZXJX.js.map +0 -1
- /package/dist/{chunk-GV4CWEWY.js.map → chunk-GOLIQX2X.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nimport { aiReferralRulesLiteral } from \"./ai-referrals\";\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/**\n * 비콘 수집 규약 버전(`bv`) — 모든 이벤트에 싣는다. 수집 서버는 이 값으로 비콘이\n * 보내는 신호(nv·wd·vs·vu)를 추정 없이 안다.\n * 2 = 방문 시작(`vs`)·저장소 차단(`vu`)·탭 공유 방문 판정·SPA `nv:3`·`wd`.\n */\nexport const BEACON_PROTOCOL_VERSION = 2;\n\n/**\n * 내부 접속 제외 표시 localStorage 키. 사이트를 `?rt_internal=1` 로 열면 켜지고\n * `?rt_internal=0` 으로 꺼진다. 켜진 브라우저는 비콘을 전혀 보내지 않는다.\n */\nexport const INTERNAL_TRAFFIC_STORAGE_KEY = \"_rt_internal\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\n/* 내부 접속 제외: ?rt_internal=1 로 한 번 열면 이 브라우저는 수집하지 않는다.\n ?rt_internal=0 으로 해제한다. 표시만 저장하고 식별자는 만들지 않는다. */\ntry{var _ri=new URLSearchParams(location.search).get('rt_internal');\nif(_ri==='1')localStorage.setItem('${INTERNAL_TRAFFIC_STORAGE_KEY}','1');\nelse if(_ri==='0')localStorage.removeItem('${INTERNAL_TRAFFIC_STORAGE_KEY}');\nif(localStorage.getItem('${INTERNAL_TRAFFIC_STORAGE_KEY}')==='1')return;}catch(e){}\nvar AIR=${aiReferralRulesLiteral()};\n/* 사이트별 익명 방문 유형: 같은 브라우저의 모든 탭에서 30분 비활동이면 새 방문,\n 최근 30일 기록이 없으면 첫 방문. 1=첫 방문 시작, 2=재방문 시작, 0=방문 중,\n -1=저장소 차단(판정 불가). ID를 만들거나 전송하지 않는다. */\nfunction visitType(){try{\nvar now=Date.now(),sk='_rt_visit_last_'+C.sid,lk='_rt_visit_seen_'+C.sid;\nvar last=Number(localStorage.getItem(sk)||0);\nlocalStorage.setItem(sk,String(now));\nif(last>0&&now>=last&&now-last<1800000)return 0;\nvar seen=Number(localStorage.getItem(lk)||0);\nvar result=seen>0&&now>=seen&&now-seen<2592e6?2:1;\nlocalStorage.setItem(lk,String(now));\nreturn result;\n}catch(e){return -1;}}\n/* AI 챗봇 유입 판정 — 원문 referrer 는 보내지 않고 고정 목록의 라벨만 남긴다.\n 목록 밖(일반 사이트·검색엔진)은 null 이라 아무것도 전송되지 않는다. */\nfunction aiRef(){try{\nif(!document.referrer)return null;\nvar h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nif(!h||h===location.hostname.toLowerCase().replace(/^www\\\\./,''))return null;\nfor(var i=0;i<AIR.length;i++){var r=AIR[i][0];if(h===r||h.slice(-(r.length+1))==='.'+r)return AIR[i][1];}\nreturn null;\n}catch(e){return null;}}\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now(),bv:${BEACON_PROTOCOL_VERSION}};\ntry{if(navigator.webdriver)p.wd=1;}catch(e){}\nif(extra){for(var k in extra){p[k]=extra[k];}}\nif(ev==='pageview'){var _vst=visitType();delete p.vst;\nif(_vst>0){p.vst=_vst;p.vs=1;}else if(_vst<0)p.vu=1;}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\n/* 유입 신호 1회 계산 — 어트리뷰션 저장(아래 C.attr 블록)과 pageview 동봉이\n 같은 값을 쓴다. referrer 는 호스트명만(경로·쿼리 미포함, www 제거),\n gclid/fbclid 는 존재 여부(gc:1)만 — 원값은 어디에도 없다. */\nvar SIG=(function(){var o={};\ntry{\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');if(rs)o.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)o.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)o.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)o.uc=uc.slice(0,100);\nvar ut=sp.get('utm_term');if(ut)o.ut=ut.slice(0,100);\nvar un=sp.get('utm_content');if(un)o.un=un.slice(0,100);\nif(sp.get('gclid')||sp.get('fbclid'))o.gc=1;\n}catch(e){}\ntry{\nif(document.referrer){\nvar rh=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nvar oh=location.hostname.toLowerCase().replace(/^www\\\\./,'');\nif(rh&&rh!==oh)o.rh=rh.slice(0,253);\n}\n}catch(e){}\nreturn o;})();\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(SIG.rs)env.rs=SIG.rs;\nif(SIG.us)env.us=SIG.us;\nif(SIG.um)env.um=SIG.um;\nif(SIG.uc)env.uc=SIG.uc;\nif(SIG.ut)env.ut=SIG.ut;\nif(SIG.un)env.un=SIG.un;\nif(SIG.gc)env.gcl=1;\nif(SIG.rh)env.rf=SIG.rh;\n}\nvar ch=null;\nif(SIG.rs){ch='rs';}else if(SIG.us||SIG.um||SIG.uc||SIG.ut||SIG.un){ch='utm';}else if(SIG.gc){ch='gcl';}else if(SIG.rh){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(SIG.rs)lt.rs=SIG.rs;\nif(SIG.us)lt.us=SIG.us;\nif(SIG.um)lt.um=SIG.um;\nif(SIG.uc)lt.uc=SIG.uc;\nif(SIG.ut)lt.ut=SIG.ut;\nif(SIG.un)lt.un=SIG.un;\nif(SIG.gc)lt.gcl=1;\nif(SIG.rh)lt.rf=SIG.rh;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();var _air=aiRef();var _x={};\nif(_pid)_x.pid=_pid;\nif(_air)_x.air=_air;\n/* AI 챗봇 유입은 라벨(air)만 — 호스트명도 보내지 않는다(기존 불변식 유지). */\nif(SIG.rh&&!_air)_x.rh=SIG.rh;\nif(SIG.us)_x.us=SIG.us;\nif(SIG.um)_x.um=SIG.um;\nif(SIG.uc)_x.uc=SIG.uc;\nif(SIG.ut)_x.ut=SIG.ut;\nif(SIG.un)_x.un=SIG.un;\nif(SIG.rs)_x.rs=SIG.rs;\nif(SIG.gc)_x.gc=1;\n/* 브라우저 새로고침·방문 기록 이동은 새 외부 유입으로 세지 않는다.\n 최초 문서 조회에만 동봉한다. SPA pageview에는 이전 문서의 이동 유형을 재사용하지 않는다. */\ntry{var _nav=performance.getEntriesByType('navigation')[0];\nif(_nav&&_nav.type==='reload')_x.nv=1;\nelse if(_nav&&_nav.type==='back_forward')_x.nv=2;}catch(e){}\n/* 새 외부 신호(referrer·utm·광고·단축링크·AI)로 연 문서는 방문 중이어도 새 방문이다\n (GA4 세션 규칙과 같다). 새로고침·뒤로 가기로 같은 신호가 다시 보이는 경우는 제외. */\nif(!_x.nv&&(SIG.rh||SIG.us||SIG.um||SIG.uc||SIG.rs||SIG.gc||_air))_x.vs=1;\n/* 브라우저 언어 1차 서브태그만(ko·en·ja···) — 외국인 방문 비율용. 전체\n locale 문자열(ko-KR 등)은 지문성분이 늘어 보내지 않는다. */\ntry{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}\nvar _has=false;for(var _q in _x){_has=true;break;}\nsend('pageview',_has?_x:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * 유입 경로 신호(2026-08-19) — pageview 에만 동봉, 전부 있을 때만 실린다:\n * `rh` 외부 referrer **호스트명**(경로·쿼리 없음, www 제거) · `us`/`um`/`uc`\n * utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재\n * 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full\n * locale 미전송). 판정은 서버가 한다 —\n * `nv` 는 문서 새로고침(1)·방문 기록 이동(2)일 때만 보내 유입 재집계를\n * 막는다. SPA 이동에는 싣지 않는다.\n * `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널\n * 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).\n * raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).\n * `wd:1` 은 navigator.webdriver 가 켜진 자동화 브라우저일 때만(모든 이벤트) —\n * 서버가 사람 통계 대신 자동 방문(`bot_pageview`)으로 적재한다.\n * `vst` 는 브라우저 저장소 기준 방문 시작 때만 1(최근 30일 첫 확인) 또는\n * 2(재방문)로 전송한다. 방문자 ID나 원문 저장값은 전송하지 않는다.\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";;;;;AA2DO,IAAM,0BAA0B;AAOhC,IAAM,0BAA0B;AAMhC,IAAM,+BAA+B;AAGrC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,qCAKqB,4BAA4B;AAAA,6CACpB,4BAA4B;AAAA,2BAC9C,4BAA4B;AAAA,UAC7C,uBAAuB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2FA0ByD,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmI3G,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|
package/dist/entry-channels.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ type EntryChannelId = (typeof ENTRY_CHANNEL_IDS)[number];
|
|
|
23
23
|
declare function isEntryChannelId(value: string): value is EntryChannelId;
|
|
24
24
|
/** 고객 화면 라벨 — GA4 6묶음과 같은 말을 쓴다(두 화면이 다르게 부르지 않게). */
|
|
25
25
|
declare const ENTRY_CHANNEL_LABEL: Record<EntryChannelId, string>;
|
|
26
|
+
/** referrer 호스트 정규화 — 소문자·www 제거·안드로이드 앱 패키지 → 웹 호스트. */
|
|
27
|
+
declare function normalizeReferrerHost(host: string | null | undefined): string;
|
|
28
|
+
/** 검색 콘솔 등 운영 도구에서 연 조회인지 — 방문 시작으로도 세지 않는다. */
|
|
29
|
+
declare function isOwnerToolReferrer(host: string | null | undefined): boolean;
|
|
26
30
|
interface EntryChannelSignals {
|
|
27
31
|
/** 외부 referrer 호스트명(소문자, www 제거) — 같은 호스트면 비콘이 생략. */
|
|
28
32
|
readonly referrerHost?: string | null;
|
|
@@ -48,5 +52,75 @@ interface EntryChannelSignals {
|
|
|
48
52
|
* utm_medium 명시가 host 추정보다 먼저다 — 고객이 직접 붙인 라벨이 더 정확.
|
|
49
53
|
*/
|
|
50
54
|
declare function classifyEntryChannel(signals: EntryChannelSignals): EntryChannelId | null;
|
|
55
|
+
/**
|
|
56
|
+
* 앱 안 브라우저 — 카카오톡·밴드처럼 링크를 앱 안에서 열면 referrer 가 비는
|
|
57
|
+
* 경우가 많다. referrer·utm 이 전혀 없을 때만 수집 서버가 요청 UA 에서 앱 이름을
|
|
58
|
+
* 읽어 출처로 남긴다(UA 원문은 저장하지 않는다). 순서가 곧 우선순위다.
|
|
59
|
+
*
|
|
60
|
+
* `channel` 은 GA4 묶음과 같은 말: 메신저·SNS 는 social, 포털 앱은 referral.
|
|
61
|
+
* 출처 칸(blob11)에는 호스트 대신 `app:<id>` 로 적재한다.
|
|
62
|
+
*/
|
|
63
|
+
declare const IN_APP_BROWSERS: readonly [{
|
|
64
|
+
readonly id: "kakaotalk";
|
|
65
|
+
readonly label: "카카오톡";
|
|
66
|
+
readonly pattern: RegExp;
|
|
67
|
+
readonly channel: "social";
|
|
68
|
+
}, {
|
|
69
|
+
readonly id: "kakaostory";
|
|
70
|
+
readonly label: "카카오스토리";
|
|
71
|
+
readonly pattern: RegExp;
|
|
72
|
+
readonly channel: "social";
|
|
73
|
+
}, {
|
|
74
|
+
readonly id: "instagram";
|
|
75
|
+
readonly label: "인스타그램";
|
|
76
|
+
readonly pattern: RegExp;
|
|
77
|
+
readonly channel: "social";
|
|
78
|
+
}, {
|
|
79
|
+
readonly id: "facebook";
|
|
80
|
+
readonly label: "페이스북";
|
|
81
|
+
readonly pattern: RegExp;
|
|
82
|
+
readonly channel: "social";
|
|
83
|
+
}, {
|
|
84
|
+
readonly id: "threads";
|
|
85
|
+
readonly label: "스레드";
|
|
86
|
+
readonly pattern: RegExp;
|
|
87
|
+
readonly channel: "social";
|
|
88
|
+
}, {
|
|
89
|
+
readonly id: "line";
|
|
90
|
+
readonly label: "라인";
|
|
91
|
+
readonly pattern: RegExp;
|
|
92
|
+
readonly channel: "social";
|
|
93
|
+
}, {
|
|
94
|
+
readonly id: "band";
|
|
95
|
+
readonly label: "밴드";
|
|
96
|
+
readonly pattern: RegExp;
|
|
97
|
+
readonly channel: "social";
|
|
98
|
+
}, {
|
|
99
|
+
readonly id: "tiktok";
|
|
100
|
+
readonly label: "틱톡";
|
|
101
|
+
readonly pattern: RegExp;
|
|
102
|
+
readonly channel: "social";
|
|
103
|
+
}, {
|
|
104
|
+
readonly id: "everytime";
|
|
105
|
+
readonly label: "에브리타임";
|
|
106
|
+
readonly pattern: RegExp;
|
|
107
|
+
readonly channel: "social";
|
|
108
|
+
}, {
|
|
109
|
+
readonly id: "naver";
|
|
110
|
+
readonly label: "네이버 앱";
|
|
111
|
+
readonly pattern: RegExp;
|
|
112
|
+
readonly channel: "referral";
|
|
113
|
+
}, {
|
|
114
|
+
readonly id: "daum";
|
|
115
|
+
readonly label: "다음 앱";
|
|
116
|
+
readonly pattern: RegExp;
|
|
117
|
+
readonly channel: "referral";
|
|
118
|
+
}];
|
|
119
|
+
type InAppBrowserId = (typeof IN_APP_BROWSERS)[number]["id"];
|
|
120
|
+
/** 출처 칸에 적재하는 앱 표기 접두사 — 실제 호스트명과 겹치지 않는다(`:` 불가). */
|
|
121
|
+
declare const IN_APP_SOURCE_PREFIX = "app:";
|
|
122
|
+
declare function inAppBrowserFromUserAgent(userAgent: string | null | undefined): (typeof IN_APP_BROWSERS)[number] | null;
|
|
123
|
+
/** 출처 칸 값(`app:<id>`) → 앱. 앱 표기가 아니거나 사전 밖이면 null. */
|
|
124
|
+
declare function inAppBrowserFromSource(source: string | null | undefined): (typeof IN_APP_BROWSERS)[number] | null;
|
|
51
125
|
|
|
52
|
-
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, type EntryChannelId, type EntryChannelSignals, classifyEntryChannel, isEntryChannelId };
|
|
126
|
+
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, type EntryChannelId, type EntryChannelSignals, IN_APP_BROWSERS, IN_APP_SOURCE_PREFIX, type InAppBrowserId, classifyEntryChannel, inAppBrowserFromSource, inAppBrowserFromUserAgent, isEntryChannelId, isOwnerToolReferrer, normalizeReferrerHost };
|
package/dist/entry-channels.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ENTRY_CHANNEL_IDS,
|
|
3
3
|
ENTRY_CHANNEL_LABEL,
|
|
4
|
+
IN_APP_BROWSERS,
|
|
5
|
+
IN_APP_SOURCE_PREFIX,
|
|
4
6
|
classifyEntryChannel,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
inAppBrowserFromSource,
|
|
8
|
+
inAppBrowserFromUserAgent,
|
|
9
|
+
isEntryChannelId,
|
|
10
|
+
isOwnerToolReferrer,
|
|
11
|
+
normalizeReferrerHost
|
|
12
|
+
} from "./chunk-MXOJW2UE.js";
|
|
7
13
|
export {
|
|
8
14
|
ENTRY_CHANNEL_IDS,
|
|
9
15
|
ENTRY_CHANNEL_LABEL,
|
|
16
|
+
IN_APP_BROWSERS,
|
|
17
|
+
IN_APP_SOURCE_PREFIX,
|
|
10
18
|
classifyEntryChannel,
|
|
11
|
-
|
|
19
|
+
inAppBrowserFromSource,
|
|
20
|
+
inAppBrowserFromUserAgent,
|
|
21
|
+
isEntryChannelId,
|
|
22
|
+
isOwnerToolReferrer,
|
|
23
|
+
normalizeReferrerHost
|
|
12
24
|
};
|
|
13
25
|
//# sourceMappingURL=entry-channels.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { C as ConsentCategory, a as ConsentState, T as TagProvider, A as AnalyticsSiteConfig, b as AnalyticsManifest } from './types-VA3xp0bt.js';
|
|
2
2
|
export { c as ConsentValue, R as ResolvedTag, d as TagConfig } from './types-VA3xp0bt.js';
|
|
3
|
-
export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, AiReferralId, AiReferralMetric, aiReferralRulesLiteral, classifyAiReferral, isAiReferralId, summarizeAiReferrals } from './ai-referrals.js';
|
|
4
|
-
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, EntryChannelId, EntryChannelSignals, classifyEntryChannel, isEntryChannelId } from './entry-channels.js';
|
|
3
|
+
export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, AiReferralId, AiReferralMetric, aiReferralRulesLiteral, aiUtmSourceAliases, classifyAiReferral, classifyAiUtmSource, isAiReferralId, summarizeAiReferrals } from './ai-referrals.js';
|
|
4
|
+
export { ENTRY_CHANNEL_IDS, ENTRY_CHANNEL_LABEL, EntryChannelId, EntryChannelSignals, IN_APP_BROWSERS, IN_APP_SOURCE_PREFIX, InAppBrowserId, classifyEntryChannel, inAppBrowserFromSource, inAppBrowserFromUserAgent, isEntryChannelId, isOwnerToolReferrer, normalizeReferrerHost } from './entry-channels.js';
|
|
5
5
|
export { GA4_MIRROR, TRACK_EVENTS, TrackEventDef, TrackEventName, isTrackEvent } from './events.js';
|
|
6
6
|
|
|
7
7
|
declare const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[];
|
|
@@ -74,6 +74,17 @@ interface BeaconOptions {
|
|
|
74
74
|
* 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.
|
|
75
75
|
*/
|
|
76
76
|
declare const ATTRIBUTION_STORAGE_KEY = "_rt_attr";
|
|
77
|
+
/**
|
|
78
|
+
* 비콘 수집 규약 버전(`bv`) — 모든 이벤트에 싣는다. 수집 서버는 이 값으로 비콘이
|
|
79
|
+
* 보내는 신호(nv·wd·vs·vu)를 추정 없이 안다.
|
|
80
|
+
* 2 = 방문 시작(`vs`)·저장소 차단(`vu`)·탭 공유 방문 판정·SPA `nv:3`·`wd`.
|
|
81
|
+
*/
|
|
82
|
+
declare const BEACON_PROTOCOL_VERSION = 2;
|
|
83
|
+
/**
|
|
84
|
+
* 내부 접속 제외 표시 localStorage 키. 사이트를 `?rt_internal=1` 로 열면 켜지고
|
|
85
|
+
* `?rt_internal=0` 으로 꺼진다. 켜진 브라우저는 비콘을 전혀 보내지 않는다.
|
|
86
|
+
*/
|
|
87
|
+
declare const INTERNAL_TRAFFIC_STORAGE_KEY = "_rt_internal";
|
|
77
88
|
/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */
|
|
78
89
|
declare const ATTRIBUTION_TTL_MS: number;
|
|
79
90
|
/**
|
|
@@ -140,9 +151,15 @@ interface LastTouchAttribution {
|
|
|
140
151
|
* utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재
|
|
141
152
|
* 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full
|
|
142
153
|
* locale 미전송). 판정은 서버가 한다 —
|
|
154
|
+
* `nv` 는 문서 새로고침(1)·방문 기록 이동(2)일 때만 보내 유입 재집계를
|
|
155
|
+
* 막는다. SPA 이동에는 싣지 않는다.
|
|
143
156
|
* `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널
|
|
144
157
|
* 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).
|
|
145
158
|
* raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).
|
|
159
|
+
* `wd:1` 은 navigator.webdriver 가 켜진 자동화 브라우저일 때만(모든 이벤트) —
|
|
160
|
+
* 서버가 사람 통계 대신 자동 방문(`bot_pageview`)으로 적재한다.
|
|
161
|
+
* `vst` 는 브라우저 저장소 기준 방문 시작 때만 1(최근 30일 첫 확인) 또는
|
|
162
|
+
* 2(재방문)로 전송한다. 방문자 ID나 원문 저장값은 전송하지 않는다.
|
|
146
163
|
*
|
|
147
164
|
* ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name="rt:content-id">` 의
|
|
148
165
|
* content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별
|
|
@@ -232,4 +249,4 @@ declare global {
|
|
|
232
249
|
}
|
|
233
250
|
}
|
|
234
251
|
|
|
235
|
-
export { ALL_CONSENT_CATEGORIES, ATTRIBUTION_STORAGE_KEY, ATTRIBUTION_TTL_MS, AnalyticsManifest, AnalyticsSiteConfig, type BeaconOptions, CONSENT_STORAGE_KEY, type ConsentBannerOptions, ConsentCategory, ConsentState, DEFAULT_CONSENT, DENIED_CONSENT, type FirstTouchAttribution, JOURNEY_MAX_ITEMS, JOURNEY_STORAGE_KEY, type JourneyEntry, type LastTouchAttribution, type LastTouchChannel, PROVIDER_CONSENT, TagProvider, buildManifest, initObservers, isTagAllowed, readJourney, renderBeaconScript, renderConsentBannerMarkup, renderConsentBannerScript, renderConsentBootstrap, renderGa4BridgeScript, renderJourneyBridgeScript, renderLoaderScript, teardownObservers, trackCustomEvent };
|
|
252
|
+
export { ALL_CONSENT_CATEGORIES, ATTRIBUTION_STORAGE_KEY, ATTRIBUTION_TTL_MS, AnalyticsManifest, AnalyticsSiteConfig, BEACON_PROTOCOL_VERSION, type BeaconOptions, CONSENT_STORAGE_KEY, type ConsentBannerOptions, ConsentCategory, ConsentState, DEFAULT_CONSENT, DENIED_CONSENT, type FirstTouchAttribution, INTERNAL_TRAFFIC_STORAGE_KEY, JOURNEY_MAX_ITEMS, JOURNEY_STORAGE_KEY, type JourneyEntry, type LastTouchAttribution, type LastTouchChannel, PROVIDER_CONSENT, TagProvider, buildManifest, initObservers, isTagAllowed, readJourney, renderBeaconScript, renderConsentBannerMarkup, renderConsentBannerScript, renderConsentBootstrap, renderGa4BridgeScript, renderJourneyBridgeScript, renderLoaderScript, teardownObservers, trackCustomEvent };
|
package/dist/index.js
CHANGED
|
@@ -12,17 +12,19 @@ import {
|
|
|
12
12
|
renderGa4BridgeScript,
|
|
13
13
|
renderJourneyBridgeScript,
|
|
14
14
|
renderLoaderScript
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-KS5MJEOM.js";
|
|
16
16
|
import {
|
|
17
17
|
initObservers,
|
|
18
18
|
teardownObservers
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-GOLIQX2X.js";
|
|
20
20
|
import {
|
|
21
21
|
ATTRIBUTION_STORAGE_KEY,
|
|
22
22
|
ATTRIBUTION_TTL_MS,
|
|
23
|
+
BEACON_PROTOCOL_VERSION,
|
|
24
|
+
INTERNAL_TRAFFIC_STORAGE_KEY,
|
|
23
25
|
renderBeaconScript,
|
|
24
26
|
trackCustomEvent
|
|
25
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-YCUCU247.js";
|
|
26
28
|
import {
|
|
27
29
|
GA4_MIRROR,
|
|
28
30
|
TRACK_EVENTS,
|
|
@@ -32,16 +34,24 @@ import {
|
|
|
32
34
|
AI_REFERRAL_IDS,
|
|
33
35
|
AI_REFERRAL_LABEL,
|
|
34
36
|
aiReferralRulesLiteral,
|
|
37
|
+
aiUtmSourceAliases,
|
|
35
38
|
classifyAiReferral,
|
|
39
|
+
classifyAiUtmSource,
|
|
36
40
|
isAiReferralId,
|
|
37
41
|
summarizeAiReferrals
|
|
38
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-KKGWQGIR.js";
|
|
39
43
|
import {
|
|
40
44
|
ENTRY_CHANNEL_IDS,
|
|
41
45
|
ENTRY_CHANNEL_LABEL,
|
|
46
|
+
IN_APP_BROWSERS,
|
|
47
|
+
IN_APP_SOURCE_PREFIX,
|
|
42
48
|
classifyEntryChannel,
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
inAppBrowserFromSource,
|
|
50
|
+
inAppBrowserFromUserAgent,
|
|
51
|
+
isEntryChannelId,
|
|
52
|
+
isOwnerToolReferrer,
|
|
53
|
+
normalizeReferrerHost
|
|
54
|
+
} from "./chunk-MXOJW2UE.js";
|
|
45
55
|
|
|
46
56
|
// src/manifest.ts
|
|
47
57
|
function buildManifest(config) {
|
|
@@ -122,25 +132,35 @@ export {
|
|
|
122
132
|
ALL_CONSENT_CATEGORIES,
|
|
123
133
|
ATTRIBUTION_STORAGE_KEY,
|
|
124
134
|
ATTRIBUTION_TTL_MS,
|
|
135
|
+
BEACON_PROTOCOL_VERSION,
|
|
125
136
|
CONSENT_STORAGE_KEY,
|
|
126
137
|
DEFAULT_CONSENT,
|
|
127
138
|
DENIED_CONSENT,
|
|
128
139
|
ENTRY_CHANNEL_IDS,
|
|
129
140
|
ENTRY_CHANNEL_LABEL,
|
|
130
141
|
GA4_MIRROR,
|
|
142
|
+
INTERNAL_TRAFFIC_STORAGE_KEY,
|
|
143
|
+
IN_APP_BROWSERS,
|
|
144
|
+
IN_APP_SOURCE_PREFIX,
|
|
131
145
|
JOURNEY_MAX_ITEMS,
|
|
132
146
|
JOURNEY_STORAGE_KEY,
|
|
133
147
|
PROVIDER_CONSENT,
|
|
134
148
|
TRACK_EVENTS,
|
|
135
149
|
aiReferralRulesLiteral,
|
|
150
|
+
aiUtmSourceAliases,
|
|
136
151
|
buildManifest,
|
|
137
152
|
classifyAiReferral,
|
|
153
|
+
classifyAiUtmSource,
|
|
138
154
|
classifyEntryChannel,
|
|
155
|
+
inAppBrowserFromSource,
|
|
156
|
+
inAppBrowserFromUserAgent,
|
|
139
157
|
initObservers,
|
|
140
158
|
isAiReferralId,
|
|
141
159
|
isEntryChannelId,
|
|
160
|
+
isOwnerToolReferrer,
|
|
142
161
|
isTagAllowed,
|
|
143
162
|
isTrackEvent,
|
|
163
|
+
normalizeReferrerHost,
|
|
144
164
|
readJourney,
|
|
145
165
|
renderBeaconScript,
|
|
146
166
|
renderConsentBannerMarkup,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/manifest.ts","../src/consent-banner.ts"],"sourcesContent":["// ADR-0038 §1 — site 태그 구성 → manifest (순수 함수).\n\nimport { DEFAULT_CONSENT, PROVIDER_CONSENT } from \"./consent\";\nimport type { AnalyticsManifest, AnalyticsSiteConfig } from \"./types\";\n\n/**\n * site 태그 구성을 manifest 로 해석.\n * - `enabled=false` 또는 빈 id 는 제외.\n * - provider 당 1개 (중복은 첫 항목만).\n * - 각 태그에 필요한 동의 카테고리(`PROVIDER_CONSENT`)를 부착.\n * - 동의 기본값 = 통계 granted · 광고 denied (ADR-0038 Amend 4 — 광고 도구는\n * 동의 UI 없이는 발화하지 않는다).\n */\nexport function buildManifest(config: AnalyticsSiteConfig): AnalyticsManifest {\n const seen = new Set<string>();\n const tags = config.tags\n .filter(\n (t) => t.enabled && typeof t.id === \"string\" && t.id.trim() !== \"\",\n )\n .filter((t) => {\n if (seen.has(t.provider)) return false;\n seen.add(t.provider);\n return true;\n })\n .map((t) => ({\n provider: t.provider,\n id: t.id.trim(),\n requiredConsent: PROVIDER_CONSENT[t.provider],\n }));\n\n return {\n siteId: config.siteId,\n tags,\n consentDefault: { ...DEFAULT_CONSENT },\n };\n}\n","// ADR-0038 §2 — 동의 배너. **Amend 4(2026-08-19)부터 기본 미렌더** —\n// 통계는 기본 허용이라 배너가 필요 없고, 광고 도구는 기본 denied 로 잠겨\n// 있다. 광고 연동을 도입하는 날 이 모듈을 다시 렌더러에 물린다(그때까지\n// 광고 태그는 발화 불가). 과거 배너가 저장한 선택은 loader.ts 가 계속 읽어\n// 존중한다(CONSENT_STORAGE_KEY 공유).\n//\n// 주의: `set:html`/`dangerouslySetInnerHTML` 로 주입된 <script> 는 실행되지\n// 않는다. 본 모듈은 *마크업* 과 *JS 본문* 을 분리 반환하고, 렌더러가 JS 는\n// <script is:inline> 로, 마크업은 컨테이너로 주입해야 한다.\n//\n// 마크업/스크립트가 공유하는 DOM id (계약):\n// #rt-consent · #rt-consent-all · #rt-consent-analytics · #rt-consent-deny\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\n\nexport interface ConsentBannerOptions {\n /** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */\n privacyUrl?: string;\n /** 본문 카피. 기본 한국어. */\n message?: string;\n}\n\nconst BANNER_ID = \"rt-consent\";\nconst STORAGE_KEY = CONSENT_STORAGE_KEY;\n\n/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */\nexport function renderConsentBannerMarkup(\n options: ConsentBannerOptions = {},\n): string {\n const message =\n options.message ??\n \"이 사이트는 ROOT-ANALYTICS와 외부 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\n const privacy = options.privacyUrl\n ? ` <a href=\"${escapeAttr(options.privacyUrl)}\" target=\"_blank\" rel=\"noopener\">개인정보처리방침</a>`\n : \"\";\n\n return [\n `<div id=\"${BANNER_ID}\" role=\"dialog\" aria-live=\"polite\" aria-label=\"동의 안내\" hidden`,\n ` style=\"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,\n `background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,\n `display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,\n `font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)\">`,\n `<span style=\"flex:1 1 280px;min-width:240px\">${escapeHtml(message)}${privacy}</span>`,\n `<button id=\"rt-consent-all\" type=\"button\" style=\"${BTN_PRIMARY}\">모두 허용</button>`,\n `<button id=\"rt-consent-analytics\" type=\"button\" style=\"${BTN_SECONDARY}\">분석만 허용</button>`,\n `<button id=\"rt-consent-deny\" type=\"button\" style=\"${BTN_GHOST}\">거부</button>`,\n `</div>`,\n ].join(\"\");\n}\n\n/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */\nexport function renderConsentBannerScript(): string {\n return `(function(){\nvar KEY=${JSON.stringify(STORAGE_KEY)};\nvar el=document.getElementById(${JSON.stringify(BANNER_ID)});\nfunction apply(state){\nif(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){\nwindow.RootTaleAnalytics.updateConsent(state);\n}}\nfunction persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}\nfunction read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}\nvar ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};\nvar ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nvar DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nfunction choose(state){apply(state);persist(state);if(el){el.hidden=true;}}\nvar prior=read();\nif(prior){apply(prior);if(el){el.hidden=true;}}\nelse if(el){el.hidden=false;}\nfunction bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}\nbind('rt-consent-all',ALL);\nbind('rt-consent-analytics',ANALYTICS);\nbind('rt-consent-deny',DENY);\n})();`;\n}\n\nconst BTN_BASE =\n \"padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;\";\nconst BTN_PRIMARY = BTN_BASE + \"background:#111;color:#fff;border-color:#111;\";\nconst BTN_SECONDARY = BTN_BASE + \"background:#f3f4f6;color:#111;\";\nconst BTN_GHOST = BTN_BASE + \"background:transparent;color:#6b7280;\";\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \""\");\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/manifest.ts","../src/consent-banner.ts"],"sourcesContent":["// ADR-0038 §1 — site 태그 구성 → manifest (순수 함수).\n\nimport { DEFAULT_CONSENT, PROVIDER_CONSENT } from \"./consent\";\nimport type { AnalyticsManifest, AnalyticsSiteConfig } from \"./types\";\n\n/**\n * site 태그 구성을 manifest 로 해석.\n * - `enabled=false` 또는 빈 id 는 제외.\n * - provider 당 1개 (중복은 첫 항목만).\n * - 각 태그에 필요한 동의 카테고리(`PROVIDER_CONSENT`)를 부착.\n * - 동의 기본값 = 통계 granted · 광고 denied (ADR-0038 Amend 4 — 광고 도구는\n * 동의 UI 없이는 발화하지 않는다).\n */\nexport function buildManifest(config: AnalyticsSiteConfig): AnalyticsManifest {\n const seen = new Set<string>();\n const tags = config.tags\n .filter(\n (t) => t.enabled && typeof t.id === \"string\" && t.id.trim() !== \"\",\n )\n .filter((t) => {\n if (seen.has(t.provider)) return false;\n seen.add(t.provider);\n return true;\n })\n .map((t) => ({\n provider: t.provider,\n id: t.id.trim(),\n requiredConsent: PROVIDER_CONSENT[t.provider],\n }));\n\n return {\n siteId: config.siteId,\n tags,\n consentDefault: { ...DEFAULT_CONSENT },\n };\n}\n","// ADR-0038 §2 — 동의 배너. **Amend 4(2026-08-19)부터 기본 미렌더** —\n// 통계는 기본 허용이라 배너가 필요 없고, 광고 도구는 기본 denied 로 잠겨\n// 있다. 광고 연동을 도입하는 날 이 모듈을 다시 렌더러에 물린다(그때까지\n// 광고 태그는 발화 불가). 과거 배너가 저장한 선택은 loader.ts 가 계속 읽어\n// 존중한다(CONSENT_STORAGE_KEY 공유).\n//\n// 주의: `set:html`/`dangerouslySetInnerHTML` 로 주입된 <script> 는 실행되지\n// 않는다. 본 모듈은 *마크업* 과 *JS 본문* 을 분리 반환하고, 렌더러가 JS 는\n// <script is:inline> 로, 마크업은 컨테이너로 주입해야 한다.\n//\n// 마크업/스크립트가 공유하는 DOM id (계약):\n// #rt-consent · #rt-consent-all · #rt-consent-analytics · #rt-consent-deny\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\n\nexport interface ConsentBannerOptions {\n /** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */\n privacyUrl?: string;\n /** 본문 카피. 기본 한국어. */\n message?: string;\n}\n\nconst BANNER_ID = \"rt-consent\";\nconst STORAGE_KEY = CONSENT_STORAGE_KEY;\n\n/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */\nexport function renderConsentBannerMarkup(\n options: ConsentBannerOptions = {},\n): string {\n const message =\n options.message ??\n \"이 사이트는 ROOT-ANALYTICS와 외부 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\n const privacy = options.privacyUrl\n ? ` <a href=\"${escapeAttr(options.privacyUrl)}\" target=\"_blank\" rel=\"noopener\">개인정보처리방침</a>`\n : \"\";\n\n return [\n `<div id=\"${BANNER_ID}\" role=\"dialog\" aria-live=\"polite\" aria-label=\"동의 안내\" hidden`,\n ` style=\"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,\n `background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,\n `display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,\n `font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)\">`,\n `<span style=\"flex:1 1 280px;min-width:240px\">${escapeHtml(message)}${privacy}</span>`,\n `<button id=\"rt-consent-all\" type=\"button\" style=\"${BTN_PRIMARY}\">모두 허용</button>`,\n `<button id=\"rt-consent-analytics\" type=\"button\" style=\"${BTN_SECONDARY}\">분석만 허용</button>`,\n `<button id=\"rt-consent-deny\" type=\"button\" style=\"${BTN_GHOST}\">거부</button>`,\n `</div>`,\n ].join(\"\");\n}\n\n/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */\nexport function renderConsentBannerScript(): string {\n return `(function(){\nvar KEY=${JSON.stringify(STORAGE_KEY)};\nvar el=document.getElementById(${JSON.stringify(BANNER_ID)});\nfunction apply(state){\nif(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){\nwindow.RootTaleAnalytics.updateConsent(state);\n}}\nfunction persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}\nfunction read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}\nvar ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};\nvar ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nvar DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nfunction choose(state){apply(state);persist(state);if(el){el.hidden=true;}}\nvar prior=read();\nif(prior){apply(prior);if(el){el.hidden=true;}}\nelse if(el){el.hidden=false;}\nfunction bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}\nbind('rt-consent-all',ALL);\nbind('rt-consent-analytics',ANALYTICS);\nbind('rt-consent-deny',DENY);\n})();`;\n}\n\nconst BTN_BASE =\n \"padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;\";\nconst BTN_PRIMARY = BTN_BASE + \"background:#111;color:#fff;border-color:#111;\";\nconst BTN_SECONDARY = BTN_BASE + \"background:#f3f4f6;color:#111;\";\nconst BTN_GHOST = BTN_BASE + \"background:transparent;color:#6b7280;\";\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \""\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAaO,SAAS,cAAc,QAAgD;AAC5E,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,OAAO,OAAO,KACjB;AAAA,IACC,CAAC,MAAM,EAAE,WAAW,OAAO,EAAE,OAAO,YAAY,EAAE,GAAG,KAAK,MAAM;AAAA,EAClE,EACC,OAAO,CAAC,MAAM;AACb,QAAI,KAAK,IAAI,EAAE,QAAQ,EAAG,QAAO;AACjC,SAAK,IAAI,EAAE,QAAQ;AACnB,WAAO;AAAA,EACT,CAAC,EACA,IAAI,CAAC,OAAO;AAAA,IACX,UAAU,EAAE;AAAA,IACZ,IAAI,EAAE,GAAG,KAAK;AAAA,IACd,iBAAiB,iBAAiB,EAAE,QAAQ;AAAA,EAC9C,EAAE;AAEJ,SAAO;AAAA,IACL,QAAQ,OAAO;AAAA,IACf;AAAA,IACA,gBAAgB,EAAE,GAAG,gBAAgB;AAAA,EACvC;AACF;;;ACbA,IAAM,YAAY;AAClB,IAAM,cAAc;AAGb,SAAS,0BACd,UAAgC,CAAC,GACzB;AACR,QAAM,UACJ,QAAQ,WACR;AACF,QAAM,UAAU,QAAQ,aACpB,aAAa,WAAW,QAAQ,UAAU,CAAC,0FAC3C;AAEJ,SAAO;AAAA,IACL,YAAY,SAAS;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,gDAAgD,WAAW,OAAO,CAAC,GAAG,OAAO;AAAA,IAC7E,oDAAoD,WAAW;AAAA,IAC/D,0DAA0D,aAAa;AAAA,IACvE,qDAAqD,SAAS;AAAA,IAC9D;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAGO,SAAS,4BAAoC;AAClD,SAAO;AAAA,UACC,KAAK,UAAU,WAAW,CAAC;AAAA,iCACJ,KAAK,UAAU,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmB1D;AAEA,IAAM,WACJ;AACF,IAAM,cAAc,WAAW;AAC/B,IAAM,gBAAgB,WAAW;AACjC,IAAM,YAAY,WAAW;AAE7B,SAAS,WAAW,GAAmB;AACrC,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,WAAW,GAAmB;AACrC,SAAO,WAAW,CAAC,EAAE,QAAQ,MAAM,QAAQ;AAC7C;","names":[]}
|
package/dist/next.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import {
|
|
3
3
|
initObservers,
|
|
4
4
|
teardownObservers
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-GOLIQX2X.js";
|
|
6
6
|
import {
|
|
7
7
|
trackCustomEvent
|
|
8
|
-
} from "./chunk-
|
|
9
|
-
import "./chunk-
|
|
8
|
+
} from "./chunk-YCUCU247.js";
|
|
9
|
+
import "./chunk-KKGWQGIR.js";
|
|
10
10
|
|
|
11
11
|
// src/next.tsx
|
|
12
12
|
import { useEffect, useRef } from "react";
|
|
@@ -20,7 +20,7 @@ function RootTaleAnalyticsNextAdapter() {
|
|
|
20
20
|
const mounted = useRef(false);
|
|
21
21
|
useEffect(() => {
|
|
22
22
|
if (mounted.current) {
|
|
23
|
-
trackCustomEvent("pageview");
|
|
23
|
+
trackCustomEvent("pageview", { nv: 3 });
|
|
24
24
|
}
|
|
25
25
|
mounted.current = true;
|
|
26
26
|
if (observersEnabled()) initObservers();
|
package/dist/next.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/next.tsx"],"sourcesContent":["\"use client\";\n\n// 설계(measurement-design-draft.md) §5 — Next App Router SPA 어댑터.\n//\n// App Router 클라이언트 네비게이션은 풀 페이지 로드가 아니므로 beacon\n// RUNTIME 의 최초 1회 pageview(스크립트 파싱 시점)만으로는 이후 라우트\n// 전환을 놓친다. 본 어댑터가 usePathname() 변화를 감지해 pageview 재발화 +\n// observer 재바인드(teardownObservers → initObservers)를 담당한다.\n//\n// 초기 마운트에서는 pageview 를 재발화하지 않는다 — beacon RUNTIME 이 이미\n// 최초 1회 담당(중복 방지, useRef 로 첫 렌더 skip). 초기 마운트에서도\n// observer 초기화는 수행한다(첫 페이지에도 section_view 등 감지가 필요).\n//\n// `RootTaleAnalytics`(react.tsx, RSC 호환·hookless)와 함께 root layout 에\n// 나란히 배치한다:\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// <RootTaleAnalyticsNextAdapter />\n// </body>\n\nimport { useEffect, useRef } from \"react\";\nimport { usePathname } from \"next/navigation\";\n\nimport { trackCustomEvent } from \"./beacon\";\nimport { initObservers, teardownObservers } from \"./observers\";\n\n/** `window.__rtObserversEnabled` 미설정 시 기본 활성(react.tsx 의 기본값과 동일). */\nfunction observersEnabled(): boolean {\n if (typeof window === \"undefined\") return false;\n return window.__rtObserversEnabled !== false;\n}\n\n/**\n * pathname 변화를 감지해 pageview 재발화 + observer 재바인드를 수행하는\n * 드롭인 컴포넌트. 렌더 출력 없음(`null`) — 부수효과 전용.\n */\nexport function RootTaleAnalyticsNextAdapter(): null {\n const pathname = usePathname();\n const mounted = useRef(false);\n\n useEffect(() => {\n // pathname 변화(재실행)일 때만 pageview 재발화 — 최초 마운트는 beacon\n // RUNTIME 이 이미 담당(중복 방지). teardown 은 React 가 이전 실행의\n // cleanup(아래 return)으로 자동 수행하므로 여기선 init 만 하면 SPA\n // 재바인드 계약(teardown → pageview 재발화 → init)이 자연히 성립한다.\n if (mounted.current) {\n trackCustomEvent(\"pageview\");\n }\n mounted.current = true;\n if (observersEnabled()) initObservers();\n return () => teardownObservers();\n \n }, [pathname]);\n\n return null;\n}\n"],"mappings":";;;;;;;;;;;AAqBA,SAAS,WAAW,cAAc;AAClC,SAAS,mBAAmB;AAM5B,SAAS,mBAA4B;AACnC,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAO,OAAO,yBAAyB;AACzC;AAMO,SAAS,+BAAqC;AACnD,QAAM,WAAW,YAAY;AAC7B,QAAM,UAAU,OAAO,KAAK;AAE5B,YAAU,MAAM;
|
|
1
|
+
{"version":3,"sources":["../src/next.tsx"],"sourcesContent":["\"use client\";\n\n// 설계(measurement-design-draft.md) §5 — Next App Router SPA 어댑터.\n//\n// App Router 클라이언트 네비게이션은 풀 페이지 로드가 아니므로 beacon\n// RUNTIME 의 최초 1회 pageview(스크립트 파싱 시점)만으로는 이후 라우트\n// 전환을 놓친다. 본 어댑터가 usePathname() 변화를 감지해 pageview 재발화 +\n// observer 재바인드(teardownObservers → initObservers)를 담당한다.\n//\n// 초기 마운트에서는 pageview 를 재발화하지 않는다 — beacon RUNTIME 이 이미\n// 최초 1회 담당(중복 방지, useRef 로 첫 렌더 skip). 초기 마운트에서도\n// observer 초기화는 수행한다(첫 페이지에도 section_view 등 감지가 필요).\n//\n// `RootTaleAnalytics`(react.tsx, RSC 호환·hookless)와 함께 root layout 에\n// 나란히 배치한다:\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// <RootTaleAnalyticsNextAdapter />\n// </body>\n\nimport { useEffect, useRef } from \"react\";\nimport { usePathname } from \"next/navigation\";\n\nimport { trackCustomEvent } from \"./beacon\";\nimport { initObservers, teardownObservers } from \"./observers\";\n\n/** `window.__rtObserversEnabled` 미설정 시 기본 활성(react.tsx 의 기본값과 동일). */\nfunction observersEnabled(): boolean {\n if (typeof window === \"undefined\") return false;\n return window.__rtObserversEnabled !== false;\n}\n\n/**\n * pathname 변화를 감지해 pageview 재발화 + observer 재바인드를 수행하는\n * 드롭인 컴포넌트. 렌더 출력 없음(`null`) — 부수효과 전용.\n */\nexport function RootTaleAnalyticsNextAdapter(): null {\n const pathname = usePathname();\n const mounted = useRef(false);\n\n useEffect(() => {\n // pathname 변화(재실행)일 때만 pageview 재발화 — 최초 마운트는 beacon\n // RUNTIME 이 이미 담당(중복 방지). teardown 은 React 가 이전 실행의\n // cleanup(아래 return)으로 자동 수행하므로 여기선 init 만 하면 SPA\n // 재바인드 계약(teardown → pageview 재발화 → init)이 자연히 성립한다.\n // document.referrer 는 SPA 이동에도 첫 문서 값 그대로라, 표시 없이 보내면\n // 직접 들어온 방문의 다음 페이지가 전부 '출처 정보 없음' 유입으로 세진다.\n // nv:3 = 사이트 안 이동(`/v1/collect` 가 유입 채널을 비운다).\n if (mounted.current) {\n trackCustomEvent(\"pageview\", { nv: 3 });\n }\n mounted.current = true;\n if (observersEnabled()) initObservers();\n return () => teardownObservers();\n \n }, [pathname]);\n\n return null;\n}\n"],"mappings":";;;;;;;;;;;AAqBA,SAAS,WAAW,cAAc;AAClC,SAAS,mBAAmB;AAM5B,SAAS,mBAA4B;AACnC,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAO,OAAO,yBAAyB;AACzC;AAMO,SAAS,+BAAqC;AACnD,QAAM,WAAW,YAAY;AAC7B,QAAM,UAAU,OAAO,KAAK;AAE5B,YAAU,MAAM;AAQd,QAAI,QAAQ,SAAS;AACnB,uBAAiB,YAAY,EAAE,IAAI,EAAE,CAAC;AAAA,IACxC;AACA,YAAQ,UAAU;AAClB,QAAI,iBAAiB,EAAG,eAAc;AACtC,WAAO,MAAM,kBAAkB;AAAA,EAEjC,GAAG,CAAC,QAAQ,CAAC;AAEb,SAAO;AACT;","names":[]}
|
package/dist/react.js
CHANGED
|
@@ -3,12 +3,12 @@ import {
|
|
|
3
3
|
renderGa4BridgeScript,
|
|
4
4
|
renderJourneyBridgeScript,
|
|
5
5
|
renderLoaderScript
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-KS5MJEOM.js";
|
|
7
7
|
import {
|
|
8
8
|
renderBeaconScript
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-YCUCU247.js";
|
|
10
10
|
import "./chunk-GBIGQHVN.js";
|
|
11
|
-
import "./chunk-
|
|
11
|
+
import "./chunk-KKGWQGIR.js";
|
|
12
12
|
|
|
13
13
|
// src/react.tsx
|
|
14
14
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
package/dist/visits.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 방문 기록(`cms_site_visits`) 적재 계약 — ADR-0115 P1.
|
|
3
|
+
*
|
|
4
|
+
* 한 방문 = 방문을 시작한 조회 1건. 이벤트 기록(`cms_site_events`)은 행동 분석에,
|
|
5
|
+
* 방문 기록은 유입 분석에 쓴다. 칸 순서는 이 파일 하나가 정한다 — 수집 서버는
|
|
6
|
+
* {@link visitBlobs} 로 쓰고, 관리자 조회는 {@link visitColumn} 으로 읽는다.
|
|
7
|
+
* AE 는 칸 이름이 없어(blob1~20) 순서를 바꾸면 과거 기록을 잘못 읽는다. 새 칸은
|
|
8
|
+
* 끝에만 붙인다.
|
|
9
|
+
*
|
|
10
|
+
* 비식별 원칙(ADR-0026·0038 §2) 그대로 — 원문 IP·UA·referrer URL 은 없다.
|
|
11
|
+
*/
|
|
12
|
+
declare const VISIT_BLOB_FIELDS: readonly ["channel", "source", "ai", "utmSource", "utmMedium", "utmCampaign", "utmTerm", "utmContent", "rtSrc", "landingPath", "device", "language", "country", "city", "vid", "origin", "postId"];
|
|
13
|
+
type VisitBlobField = (typeof VISIT_BLOB_FIELDS)[number];
|
|
14
|
+
declare const VISIT_DOUBLE_FIELDS: readonly ["count", "visitType", "beaconVersion", "adClick"];
|
|
15
|
+
type VisitDoubleField = (typeof VISIT_DOUBLE_FIELDS)[number];
|
|
16
|
+
/**
|
|
17
|
+
* 방문 시작 판정의 근거.
|
|
18
|
+
* - `beacon`: 비콘이 방문 시작(`vs`)을 알렸다.
|
|
19
|
+
* - `inferred`: 비콘이 저장소를 못 써서(`vu`) 외부 신호로 서버가 판정했다.
|
|
20
|
+
* - `legacy`: 방문 판정 이전 비콘 — 유입 조회를 방문 시작으로 본다.
|
|
21
|
+
*/
|
|
22
|
+
declare const VISIT_ORIGINS: readonly ["beacon", "inferred", "legacy"];
|
|
23
|
+
type VisitOrigin = (typeof VISIT_ORIGINS)[number];
|
|
24
|
+
/** AE SQL 열 이름(`blobN`·`doubleN`). */
|
|
25
|
+
declare function visitColumn(field: VisitBlobField | VisitDoubleField): string;
|
|
26
|
+
declare function visitBlobs(values: Readonly<Record<VisitBlobField, string>>): string[];
|
|
27
|
+
declare function visitDoubles(values: Readonly<Record<VisitDoubleField, number>>): number[];
|
|
28
|
+
/**
|
|
29
|
+
* 방문 시작 판정. 방문 기록을 쓸 조회면 근거를, 아니면 null.
|
|
30
|
+
*
|
|
31
|
+
* - 비콘 v2+: `vs`(30분 비활동 뒤 첫 조회 또는 새 외부 신호)만 방문 시작이다.
|
|
32
|
+
* `vu`(저장소 차단)면 외부 신호가 있거나 신호 없는 첫 문서(direct)일 때만.
|
|
33
|
+
* - 이전 비콘: 유입 조회(채널 있음)를 방문 시작으로 본다.
|
|
34
|
+
*/
|
|
35
|
+
declare function visitStartOrigin(input: {
|
|
36
|
+
readonly beaconVersion: number;
|
|
37
|
+
readonly visitStart: boolean;
|
|
38
|
+
readonly storageBlocked: boolean;
|
|
39
|
+
readonly channel: string;
|
|
40
|
+
}): VisitOrigin | null;
|
|
41
|
+
|
|
42
|
+
export { VISIT_BLOB_FIELDS, VISIT_DOUBLE_FIELDS, VISIT_ORIGINS, type VisitBlobField, type VisitDoubleField, type VisitOrigin, visitBlobs, visitColumn, visitDoubles, visitStartOrigin };
|
package/dist/visits.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/visits.ts
|
|
2
|
+
var VISIT_BLOB_FIELDS = [
|
|
3
|
+
"channel",
|
|
4
|
+
"source",
|
|
5
|
+
"ai",
|
|
6
|
+
"utmSource",
|
|
7
|
+
"utmMedium",
|
|
8
|
+
"utmCampaign",
|
|
9
|
+
"utmTerm",
|
|
10
|
+
"utmContent",
|
|
11
|
+
"rtSrc",
|
|
12
|
+
"landingPath",
|
|
13
|
+
"device",
|
|
14
|
+
"language",
|
|
15
|
+
"country",
|
|
16
|
+
"city",
|
|
17
|
+
"vid",
|
|
18
|
+
"origin",
|
|
19
|
+
"postId"
|
|
20
|
+
];
|
|
21
|
+
var VISIT_DOUBLE_FIELDS = ["count", "visitType", "beaconVersion", "adClick"];
|
|
22
|
+
var VISIT_ORIGINS = ["beacon", "inferred", "legacy"];
|
|
23
|
+
function visitColumn(field) {
|
|
24
|
+
const blob = VISIT_BLOB_FIELDS.indexOf(field);
|
|
25
|
+
if (blob >= 0) return `blob${blob + 1}`;
|
|
26
|
+
return `double${VISIT_DOUBLE_FIELDS.indexOf(field) + 1}`;
|
|
27
|
+
}
|
|
28
|
+
function visitBlobs(values) {
|
|
29
|
+
return VISIT_BLOB_FIELDS.map((field) => values[field]);
|
|
30
|
+
}
|
|
31
|
+
function visitDoubles(values) {
|
|
32
|
+
return VISIT_DOUBLE_FIELDS.map((field) => values[field]);
|
|
33
|
+
}
|
|
34
|
+
function visitStartOrigin(input) {
|
|
35
|
+
if (input.beaconVersion >= 2) {
|
|
36
|
+
if (input.visitStart) return "beacon";
|
|
37
|
+
return input.storageBlocked && input.channel ? "inferred" : null;
|
|
38
|
+
}
|
|
39
|
+
return input.channel ? "legacy" : null;
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
VISIT_BLOB_FIELDS,
|
|
43
|
+
VISIT_DOUBLE_FIELDS,
|
|
44
|
+
VISIT_ORIGINS,
|
|
45
|
+
visitBlobs,
|
|
46
|
+
visitColumn,
|
|
47
|
+
visitDoubles,
|
|
48
|
+
visitStartOrigin
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=visits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/visits.ts"],"sourcesContent":["/**\n * 방문 기록(`cms_site_visits`) 적재 계약 — ADR-0115 P1.\n *\n * 한 방문 = 방문을 시작한 조회 1건. 이벤트 기록(`cms_site_events`)은 행동 분석에,\n * 방문 기록은 유입 분석에 쓴다. 칸 순서는 이 파일 하나가 정한다 — 수집 서버는\n * {@link visitBlobs} 로 쓰고, 관리자 조회는 {@link visitColumn} 으로 읽는다.\n * AE 는 칸 이름이 없어(blob1~20) 순서를 바꾸면 과거 기록을 잘못 읽는다. 새 칸은\n * 끝에만 붙인다.\n *\n * 비식별 원칙(ADR-0026·0038 §2) 그대로 — 원문 IP·UA·referrer URL 은 없다.\n */\n\nexport const VISIT_BLOB_FIELDS = [\n \"channel\",\n \"source\",\n \"ai\",\n \"utmSource\",\n \"utmMedium\",\n \"utmCampaign\",\n \"utmTerm\",\n \"utmContent\",\n \"rtSrc\",\n \"landingPath\",\n \"device\",\n \"language\",\n \"country\",\n \"city\",\n \"vid\",\n \"origin\",\n \"postId\",\n] as const;\n\nexport type VisitBlobField = (typeof VISIT_BLOB_FIELDS)[number];\n\nexport const VISIT_DOUBLE_FIELDS = [\"count\", \"visitType\", \"beaconVersion\", \"adClick\"] as const;\n\nexport type VisitDoubleField = (typeof VISIT_DOUBLE_FIELDS)[number];\n\n/**\n * 방문 시작 판정의 근거.\n * - `beacon`: 비콘이 방문 시작(`vs`)을 알렸다.\n * - `inferred`: 비콘이 저장소를 못 써서(`vu`) 외부 신호로 서버가 판정했다.\n * - `legacy`: 방문 판정 이전 비콘 — 유입 조회를 방문 시작으로 본다.\n */\nexport const VISIT_ORIGINS = [\"beacon\", \"inferred\", \"legacy\"] as const;\n\nexport type VisitOrigin = (typeof VISIT_ORIGINS)[number];\n\n/** AE SQL 열 이름(`blobN`·`doubleN`). */\nexport function visitColumn(field: VisitBlobField | VisitDoubleField): string {\n const blob = (VISIT_BLOB_FIELDS as readonly string[]).indexOf(field);\n if (blob >= 0) return `blob${blob + 1}`;\n return `double${(VISIT_DOUBLE_FIELDS as readonly string[]).indexOf(field) + 1}`;\n}\n\nexport function visitBlobs(values: Readonly<Record<VisitBlobField, string>>): string[] {\n return VISIT_BLOB_FIELDS.map((field) => values[field]);\n}\n\nexport function visitDoubles(values: Readonly<Record<VisitDoubleField, number>>): number[] {\n return VISIT_DOUBLE_FIELDS.map((field) => values[field]);\n}\n\n/**\n * 방문 시작 판정. 방문 기록을 쓸 조회면 근거를, 아니면 null.\n *\n * - 비콘 v2+: `vs`(30분 비활동 뒤 첫 조회 또는 새 외부 신호)만 방문 시작이다.\n * `vu`(저장소 차단)면 외부 신호가 있거나 신호 없는 첫 문서(direct)일 때만.\n * - 이전 비콘: 유입 조회(채널 있음)를 방문 시작으로 본다.\n */\nexport function visitStartOrigin(input: {\n readonly beaconVersion: number;\n readonly visitStart: boolean;\n readonly storageBlocked: boolean;\n readonly channel: string;\n}): VisitOrigin | null {\n if (input.beaconVersion >= 2) {\n if (input.visitStart) return \"beacon\";\n return input.storageBlocked && input.channel ? \"inferred\" : null;\n }\n return input.channel ? \"legacy\" : null;\n}\n"],"mappings":";AAYO,IAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,IAAM,sBAAsB,CAAC,SAAS,aAAa,iBAAiB,SAAS;AAU7E,IAAM,gBAAgB,CAAC,UAAU,YAAY,QAAQ;AAKrD,SAAS,YAAY,OAAkD;AAC5E,QAAM,OAAQ,kBAAwC,QAAQ,KAAK;AACnE,MAAI,QAAQ,EAAG,QAAO,OAAO,OAAO,CAAC;AACrC,SAAO,SAAU,oBAA0C,QAAQ,KAAK,IAAI,CAAC;AAC/E;AAEO,SAAS,WAAW,QAA4D;AACrF,SAAO,kBAAkB,IAAI,CAAC,UAAU,OAAO,KAAK,CAAC;AACvD;AAEO,SAAS,aAAa,QAA8D;AACzF,SAAO,oBAAoB,IAAI,CAAC,UAAU,OAAO,KAAK,CAAC;AACzD;AASO,SAAS,iBAAiB,OAKV;AACrB,MAAI,MAAM,iBAAiB,GAAG;AAC5B,QAAI,MAAM,WAAY,QAAO;AAC7B,WAAO,MAAM,kBAAkB,MAAM,UAAU,aAAa;AAAA,EAC9D;AACA,SAAO,MAAM,UAAU,WAAW;AACpC;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/analytics-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ROOT-ANALYTICS runtime — privacy-first page, click, content-view and attribution collection with Consent Mode v2 external-tag loading.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -36,6 +36,16 @@
|
|
|
36
36
|
"import": "./dist/entry-channels.js",
|
|
37
37
|
"default": "./dist/entry-channels.js"
|
|
38
38
|
},
|
|
39
|
+
"./bots": {
|
|
40
|
+
"types": "./dist/bots.d.ts",
|
|
41
|
+
"import": "./dist/bots.js",
|
|
42
|
+
"default": "./dist/bots.js"
|
|
43
|
+
},
|
|
44
|
+
"./visits": {
|
|
45
|
+
"types": "./dist/visits.d.ts",
|
|
46
|
+
"import": "./dist/visits.js",
|
|
47
|
+
"default": "./dist/visits.js"
|
|
48
|
+
},
|
|
39
49
|
"./package.json": "./package.json"
|
|
40
50
|
},
|
|
41
51
|
"files": [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/ai-referrals.ts"],"sourcesContent":["/**\n * AI 챗봇 유입 분류 — GA4 세션 소스와 ROOT-ANALYTICS 비콘이 같은 기준을 쓴다.\n *\n * 원문 주소는 저장하지 않는다. 아래 고정 목록의 호스트에 해당할 때만 챗봇\n * id 라벨로 환산하고, 목록 밖은 버린다(분류 불가를 \"기타\"로 뭉뚱그리면\n * 숫자의 의미가 사라진다).\n *\n * 이 파일은 브라우저 비콘 생성기와 서버 양쪽에서 참조하므로 런타임 의존이\n * 없어야 한다.\n */\n\nexport const AI_REFERRAL_IDS = [\n \"gemini\",\n \"chatgpt\",\n \"claude\",\n \"perplexity\",\n \"copilot\",\n] as const;\n\nexport type AiReferralId = (typeof AI_REFERRAL_IDS)[number];\n\nexport type AiReferralMetric = {\n readonly id: AiReferralId;\n readonly label: string;\n readonly sessions: number;\n};\n\n/** 수집 라우트의 입력 검증 — 사전 밖 문자열이 차원에 들어가지 못하게. */\nexport function isAiReferralId(value: string): value is AiReferralId {\n return (AI_REFERRAL_IDS as readonly string[]).includes(value);\n}\n\nexport const AI_REFERRAL_LABEL: Record<AiReferralId, string> = {\n gemini: \"제미나이\",\n chatgpt: \"GPT\",\n claude: \"클로드\",\n perplexity: \"퍼플렉시티\",\n copilot: \"코파일럿\",\n};\n\n/**\n * 호스트 판정 규칙. 정확 일치 또는 서브도메인(`.` 접미) 일치만 인정한다 —\n * 부분 문자열 매칭은 `chatgpt.evil.com` 같은 위장 도메인을 통과시킨다.\n */\nconst HOST_RULES: readonly { readonly host: string; readonly id: AiReferralId }[] = [\n { host: \"gemini.google.com\", id: \"gemini\" },\n { host: \"bard.google.com\", id: \"gemini\" },\n { host: \"chatgpt.com\", id: \"chatgpt\" },\n { host: \"chat.openai.com\", id: \"chatgpt\" },\n { host: \"openai.com\", id: \"chatgpt\" },\n { host: \"claude.ai\", id: \"claude\" },\n { host: \"perplexity.ai\", id: \"perplexity\" },\n { host: \"copilot.microsoft.com\", id: \"copilot\" },\n { host: \"m365.cloud.microsoft\", id: \"copilot\" },\n];\n\nfunction matchesHost(candidate: string, rule: string): boolean {\n return candidate === rule || candidate.endsWith(`.${rule}`);\n}\n\n/**\n * 비콘 인라인 스크립트에 심을 규칙표 리터럴. 규칙이 여기 한 곳에만 있어야\n * 브라우저 판정과 서버 판정이 갈리지 않는다.\n */\nexport function aiReferralRulesLiteral(): string {\n return JSON.stringify(HOST_RULES.map((rule) => [rule.host, rule.id]));\n}\n\n/**\n * 유입 출처 문자열(호스트 또는 GA4 세션 소스)을 챗봇 id 로 환산한다.\n * 목록 밖이면 null — 호출부가 집계에서 제외한다.\n */\nexport function classifyAiReferral(source: string | null | undefined): AiReferralId | null {\n if (!source) return null;\n const normalized = source.trim().toLowerCase().replace(/^www\\./, \"\");\n if (normalized === \"\") return null;\n const rule = HOST_RULES.find((candidate) => matchesHost(normalized, candidate.host));\n return rule?.id ?? null;\n}\n\n/**\n * (id, 세션 수) 쌍을 합산해 많은 순으로 정렬한다. 0 인 챗봇은 빼서\n * \"안 들어온 채널\"이 차트를 채우지 않게 한다.\n */\nexport function summarizeAiReferrals(\n entries: readonly { readonly id: AiReferralId; readonly sessions: number }[],\n): readonly AiReferralMetric[] {\n const totals = new Map<AiReferralId, number>();\n for (const entry of entries) {\n totals.set(entry.id, (totals.get(entry.id) ?? 0) + entry.sessions);\n }\n return [...totals.entries()]\n .filter(([, sessions]) => sessions > 0)\n .map(([id, sessions]) => ({ id, label: AI_REFERRAL_LABEL[id], sessions }))\n .sort((left, right) => right.sessions - left.sessions);\n}\n"],"mappings":";AAWO,IAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAWO,SAAS,eAAe,OAAsC;AACnE,SAAQ,gBAAsC,SAAS,KAAK;AAC9D;AAEO,IAAM,oBAAkD;AAAA,EAC7D,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,SAAS;AACX;AAMA,IAAM,aAA8E;AAAA,EAClF,EAAE,MAAM,qBAAqB,IAAI,SAAS;AAAA,EAC1C,EAAE,MAAM,mBAAmB,IAAI,SAAS;AAAA,EACxC,EAAE,MAAM,eAAe,IAAI,UAAU;AAAA,EACrC,EAAE,MAAM,mBAAmB,IAAI,UAAU;AAAA,EACzC,EAAE,MAAM,cAAc,IAAI,UAAU;AAAA,EACpC,EAAE,MAAM,aAAa,IAAI,SAAS;AAAA,EAClC,EAAE,MAAM,iBAAiB,IAAI,aAAa;AAAA,EAC1C,EAAE,MAAM,yBAAyB,IAAI,UAAU;AAAA,EAC/C,EAAE,MAAM,wBAAwB,IAAI,UAAU;AAChD;AAEA,SAAS,YAAY,WAAmB,MAAuB;AAC7D,SAAO,cAAc,QAAQ,UAAU,SAAS,IAAI,IAAI,EAAE;AAC5D;AAMO,SAAS,yBAAiC;AAC/C,SAAO,KAAK,UAAU,WAAW,IAAI,CAAC,SAAS,CAAC,KAAK,MAAM,KAAK,EAAE,CAAC,CAAC;AACtE;AAMO,SAAS,mBAAmB,QAAwD;AACzF,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,aAAa,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,UAAU,EAAE;AACnE,MAAI,eAAe,GAAI,QAAO;AAC9B,QAAM,OAAO,WAAW,KAAK,CAAC,cAAc,YAAY,YAAY,UAAU,IAAI,CAAC;AACnF,SAAO,MAAM,MAAM;AACrB;AAMO,SAAS,qBACd,SAC6B;AAC7B,QAAM,SAAS,oBAAI,IAA0B;AAC7C,aAAW,SAAS,SAAS;AAC3B,WAAO,IAAI,MAAM,KAAK,OAAO,IAAI,MAAM,EAAE,KAAK,KAAK,MAAM,QAAQ;AAAA,EACnE;AACA,SAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,EACxB,OAAO,CAAC,CAAC,EAAE,QAAQ,MAAM,WAAW,CAAC,EACrC,IAAI,CAAC,CAAC,IAAI,QAAQ,OAAO,EAAE,IAAI,OAAO,kBAAkB,EAAE,GAAG,SAAS,EAAE,EACxE,KAAK,CAAC,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ;AACzD;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/consent.ts","../src/loader.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2 + Amend 4 — 동의 정책 + provider↔consent 매핑.\n//\n// Amend 4 (2026-08-19): 통계는 기본 허용, 광고는 기본 거부.\n// 한국법엔 유럽식 쿠키 배너(사전 옵트인) 의무가 없다 — 통계 목적 분석 도구는\n// 처리방침 고지로 충분하고, 동의가 실질 요구되는 건 행태정보를 **맞춤형\n// 광고**에 쓸 때부터다(개보위 2022 구글·메타 처분 영역). 따라서 분석\n// 카테고리를 fail-closed 로 잠그면 법이 요구하지 않는 배너 손실(무시층\n// 30~50%)만 남는다. 광고 카테고리는 그대로 fail-closed — 광고 도구는 동의\n// UI 없이는 영원히 발화하지 않는다(현재 광고 연동 계획 없음이 사업 결정).\n\nimport type { ConsentCategory, ConsentState, TagProvider } from \"./types\";\n\nexport const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[] = [\n \"analytics_storage\",\n \"ad_storage\",\n \"ad_user_data\",\n \"ad_personalization\",\n] as const;\n\n/**\n * 방문자의 명시적 동의 선택 저장 키(localStorage) — 과거 배너가 저장한 값을\n * 로더가 계속 읽는다. **과거에 \"거부\"를 누른 방문자의 선택은 기본값 변경\n * 후에도 존중된다**(기본값은 선택 기록이 없는 방문자에게만 적용).\n */\nexport const CONSENT_STORAGE_KEY = \"rt_consent_v1\";\n\n/** 전부 denied — 광고 도구 게이트·테스트 기준값으로 유지. */\nexport const DENIED_CONSENT: ConsentState = {\n analytics_storage: \"denied\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/** 기본 동의(Amend 4) — 통계 granted · 광고 denied. */\nexport const DEFAULT_CONSENT: ConsentState = {\n analytics_storage: \"granted\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/**\n * provider 가 발화하기 전 granted 여야 하는 동의 카테고리.\n * - analytics 도구(GA4·Clarity·Naver) → `analytics_storage`\n * - 광고 도구(Meta Pixel) → `ad_storage` + `ad_user_data` + `ad_personalization`\n * (국외이전·광고목적이라 동의 요건 가장 강함)\n */\nexport const PROVIDER_CONSENT: Record<TagProvider, ConsentCategory[]> = {\n ga4: [\"analytics_storage\"],\n clarity: [\"analytics_storage\"],\n naver: [\"analytics_storage\"],\n meta_pixel: [\"ad_storage\", \"ad_user_data\", \"ad_personalization\"],\n};\n\n/** required 카테고리가 *모두* granted 일 때만 true. */\nexport function isTagAllowed(\n required: readonly ConsentCategory[],\n consent: ConsentState,\n): boolean {\n return required.every((c) => consent[c] === \"granted\");\n}\n","// ADR-0038 §2·§4 + Amend 4 — 공유 주입 산출물. 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default =\n// 통계 granted · 광고 denied (Amend 4). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 초기\n// applyAll() 이 동의 충족 태그(분석 3종)를 즉시 주입하고, 광고 도구는\n// 동의 update(`RootTaleAnalytics.updateConsent`) 전까지 발화하지 않는다.\n// 과거 배너가 localStorage 에 남긴 명시적 선택(CONSENT_STORAGE_KEY)이\n// 있으면 기본값보다 우선한다 — 예전에 \"거부\"한 방문자는 계속 거부 상태.\n//\n// 설계(measurement-design-draft.md) §0 게이트 계약 — `RootTaleAnalytics.\n// isTagLoaded(provider)` 로 태그 로드 완료 상태를 노출한다(내부 `loaded` 맵\n// 그대로 read-only 조회). `ga4-bridge.ts` 가 GA4 미러 발화 전 \"동의 granted\n// 그리고 GA4 로드 완료\" 를 함께 확인하는 데 쓴다.\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\nimport type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩 — 통계 granted · 광고 denied (Amend 4).\n * 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다. `wait_for_update` 는\n * 로더가 저장된 과거 선택을 적용할 짧은 창.\n */\nexport function renderConsentBootstrap(): string {\n return [\n \"window.dataLayer=window.dataLayer||[];\",\n \"function gtag(){dataLayer.push(arguments);}\",\n \"gtag('consent','default',{\",\n \"'ad_storage':'denied','ad_user_data':'denied',\",\n \"'ad_personalization':'denied','analytics_storage':'granted',\",\n \"'wait_for_update':500});\",\n ].join(\"\");\n}\n\n// 브라우저 런타임 — manifest 를 읽고 동의가 충족된 태그만 주입한다.\n// `__RT_MANIFEST__` 는 renderLoaderScript 가 JSON 으로 치환.\nconst RUNTIME = `(function(){\nvar M=__RT_MANIFEST__;\nvar consent=Object.assign({},M.consentDefault);\n/* 과거 배너가 저장한 명시적 선택이 있으면 기본값을 덮는다 — \"거부\"를 눌렀던\n 방문자는 기본값이 granted 로 바뀌어도 거부 상태를 유지한다(Amend 4). */\ntry{\nvar stored=JSON.parse(localStorage.getItem(__RT_CONSENT_KEY__)||'null');\nif(stored&&typeof stored==='object'){\nfor(var sk in stored){if(Object.prototype.hasOwnProperty.call(consent,sk))consent[sk]=stored[sk];}\nif(typeof gtag==='function'){gtag('consent','update',stored);}\n}\n}catch(e){}\nvar loaded={};\nfunction allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}\nfunction inject(tag){\nif(loaded[tag.provider]||!allowed(tag.requiredConsent))return;\nloaded[tag.provider]=true;\nif(tag.provider==='ga4'){\nvar s=document.createElement('script');s.async=true;\ns.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);\ndocument.head.appendChild(s);gtag('js',new Date());gtag('config',tag.id);\n}else if(tag.provider==='clarity'){\n(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\nvar t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;\nvar y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,'clarity','script',tag.id);\n}else if(tag.provider==='meta_pixel'){\n(function(f,b,e,v){if(f.fbq)return;var n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};\nif(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];var t=b.createElement(e);t.async=!0;\nt.src=v;var s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)})(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init',tag.id);fbq('track','PageView');\n}else if(tag.provider==='naver'){\nvar w=document.createElement('script');w.async=true;w.src='//wcs.naver.net/wcslog.js';\nw.onload=function(){if(window.wcs){window.wcs_add={wa:tag.id};if(window.wcs.inflow)window.wcs.inflow();if(window.wcs_do)window.wcs_do();}};\ndocument.head.appendChild(w);\n}}\nfunction applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}\nwindow.RootTaleAnalytics={\nmanifest:M,\ngetConsent:function(){return Object.assign({},consent);},\nisTagLoaded:function(provider){return !!loaded[provider];},\nupdateConsent:function(partial){\nfor(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}\nif(typeof gtag==='function'){gtag('consent','update',partial);}\napplyAll();\n}};\napplyAll();\n})();`;\n\n/**\n * manifest 를 embed 한 로더 스크립트. 초기 `applyAll()` 이 동의 충족 태그\n * (기본값에선 분석 도구)를 즉시 주입한다. 광고 도구는 `requiredConsent` 의\n * ad_* 카테고리가 기본 denied 라 `updateConsent` 없이는 절대 붙지 않는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return (\n \"var __RT_MANIFEST__=\" +\n JSON.stringify(manifest) +\n \";var __RT_CONSENT_KEY__=\" +\n JSON.stringify(CONSENT_STORAGE_KEY) +\n \";\" +\n RUNTIME\n );\n}\n","// 설계(measurement-design-draft.md) §0(ADR-0038 amend) — GA4 미러 브리지.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtGa4Mirror` 전역 훅이\n// 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그 훅을\n// 설치할 뿐 beacon 을 직접 건드리지 않는다 — 순서 무관하게 안전(훅이 아직\n// 없을 때 이벤트가 나면 그냥 스킵, 다음 이벤트부터 미러링).\n//\n// 게이트(설계 §0): events.ts `GA4_MIRROR` 에 등록된 이벤트 + 동의\n// `analytics_storage==='granted'` + GA4 태그 로드 완료(`isTagLoaded('ga4')`) +\n// `typeof gtag==='function'` **전부** 참일 때만 `gtag('event', ga4Name, params)`.\n// 하나라도 불충족이면 그냥 버린다 — dataLayer 에 선큐잉해 동의 이후 소급\n// 전송하지 않는다(큐·재생 금지). 확정 전환 수는 어디까지나 inquiries DB —\n// 본 미러는 광고 생태계용 근사 신호일 뿐이다(설계 §0 SSoT 계약).\n\nimport { GA4_MIRROR } from \"./events\";\n\nconst RUNTIME = `(function(){\nvar MIRROR=__RT_GA4_MIRROR__;\nwindow.__rtGa4Mirror=function(ev,extra){\nvar name=MIRROR[ev];\nif(!name)return;\nvar RA=window.RootTaleAnalytics;\nif(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;\nif(RA.getConsent().analytics_storage!=='granted')return;\nif(!RA.isTagLoaded('ga4'))return;\nif(typeof gtag!=='function')return;\ngtag('event',name,extra||{});\n};\n})();`;\n\n/**\n * GA4 미러 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와 함께\n * (순서 무관) 주입한다. `enableGa4Bridge={false}` 로 아예 생략 가능(react.tsx).\n */\nexport function renderGa4BridgeScript(): string {\n return \"var __RT_GA4_MIRROR__=\" + JSON.stringify(GA4_MIRROR) + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 GA4 미러 훅(설치돼 있을 때만). */\n __rtGa4Mirror?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n","// 설계(measurement-design-draft.md) §3 — 방문 여정(journey) 링버퍼.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtJourneyRecord` 전역\n// 훅이 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그\n// 훅을 설치할 뿐이다 — `events.ts` `TRACK_EVENTS[ev].journey` 가 true 인\n// 이벤트(pageview + 전환 사다리)만 sessionStorage `_rt_journey` 링버퍼(최대\n// 30항목)에 적재한다. scroll_depth·section_view 등 고빈도 감지 이벤트는\n// journey:false 라 기록되지 않는다.\n//\n// 서버측 계약(설계 §3, 본 모듈 범위 밖): vertical(medical/legal) 게이트는\n// 서버가 재강제하고, 저장은 `inquiries.journey` jsonb 컬럼 + zod 재구성 +\n// 180일 보존이다 — 클라이언트 캡처는 신뢰의 원천이 아니다.\n\nimport { TRACK_EVENTS, type TrackEventName } from \"./events\";\n\n/** sessionStorage 키 — cms-client/renderer 와의 공유 계약(변경 시 양쪽 동시 갱신). */\nexport const JOURNEY_STORAGE_KEY = \"_rt_journey\";\n\n/** 링버퍼 최대 항목 수(변경 시 RUNTIME 내 하드코딩 리터럴도 동시 갱신). */\nexport const JOURNEY_MAX_ITEMS = 30;\n\n/** `_rt_journey` 항목 형태. */\nexport interface JourneyEntry {\n /** 캡처 시각 (epoch ms). */\n t: number;\n /** 이벤트 이름 (events.ts 사전). */\n ev: string;\n /** 발생 경로 (pathname). vertical 게이트 대상은 서버에서 제거될 수 있음. */\n path?: string;\n /** 소형 차원 데이터 (data-track-* 로 캡처된 extra). */\n d?: Record<string, unknown>;\n}\n\n/** journey:true 이벤트 이름 목록 — events.ts SSoT 에서 파생. */\nconst JOURNEY_EVENT_NAMES: TrackEventName[] = (\n Object.keys(TRACK_EVENTS) as TrackEventName[]\n).filter((name) => TRACK_EVENTS[name].journey);\n\nconst RUNTIME = `(function(){\nvar JE=__RT_JOURNEY_EVENTS__;\nwindow.__rtJourneyRecord=function(ev,extra){\nif(!JE||JE.indexOf(ev)===-1)return;\nvar KEY='_rt_journey';\nvar arr=null;\ntry{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}\nif(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];\nvar item={t:Date.now(),ev:ev};\ntry{if(location&&location.pathname)item.path=location.pathname;}catch(e){}\nif(extra)item.d=extra;\narr.push(item);\nif(arr.length>30)arr=arr.slice(arr.length-30);\nsessionStorage.setItem(KEY,JSON.stringify(arr));\n};\n})();`;\n\n/**\n * journey 기록 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와\n * 함께(순서 무관) 항상 주입한다 — 1차 캡처 인프라라 opt-out 프롭 없음(ga4\n * 미러와 달리).\n */\nexport function renderJourneyBridgeScript(): string {\n return (\n \"var __RT_JOURNEY_EVENTS__=\" +\n JSON.stringify(JOURNEY_EVENT_NAMES) +\n \";\" +\n RUNTIME\n );\n}\n\n/**\n * sessionStorage 의 journey 링버퍼를 읽는다(브라우저 전용). 파싱 실패·미존재\n * 시 빈 배열. cms-client/renderer 가 문의 제출 시 이 값을 재직렬화해 동봉한다\n * (submit capture/`formdata` 시점 — 설계 §3 P1-3, hidden 스냅샷 아님).\n */\nexport function readJourney(): JourneyEntry[] {\n if (typeof window === \"undefined\" || typeof sessionStorage === \"undefined\") {\n return [];\n }\n try {\n const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);\n if (!raw) return [];\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? (parsed as JourneyEntry[]) : [];\n } catch {\n return [];\n }\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 journey 기록 훅(설치돼 있을 때만). */\n __rtJourneyRecord?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n"],"mappings":";;;;;;AAYO,IAAM,yBAAqD;AAAA,EAChE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,sBAAsB;AAG5B,IAAM,iBAA+B;AAAA,EAC1C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAGO,IAAM,kBAAgC;AAAA,EAC3C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAQO,IAAM,mBAA2D;AAAA,EACtE,KAAK,CAAC,mBAAmB;AAAA,EACzB,SAAS,CAAC,mBAAmB;AAAA,EAC7B,OAAO,CAAC,mBAAmB;AAAA,EAC3B,YAAY,CAAC,cAAc,gBAAgB,oBAAoB;AACjE;AAGO,SAAS,aACd,UACA,SACS;AACT,SAAO,SAAS,MAAM,CAAC,MAAM,QAAQ,CAAC,MAAM,SAAS;AACvD;;;ACrCO,SAAS,yBAAiC;AAC/C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAIA,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqDT,SAAS,mBAAmB,UAAqC;AACtE,SACE,yBACA,KAAK,UAAU,QAAQ,IACvB,6BACA,KAAK,UAAU,mBAAmB,IAClC,MACA;AAEJ;;;ACnFA,IAAMA,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBT,SAAS,wBAAgC;AAC9C,SAAO,2BAA2B,KAAK,UAAU,UAAU,IAAI,MAAMA;AACvE;;;ACpBO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAejC,IAAM,sBACJ,OAAO,KAAK,YAAY,EACxB,OAAO,CAAC,SAAS,aAAa,IAAI,EAAE,OAAO;AAE7C,IAAMC,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBT,SAAS,4BAAoC;AAClD,SACE,+BACA,KAAK,UAAU,mBAAmB,IAClC,MACAA;AAEJ;AAOO,SAAS,cAA8B;AAC5C,MAAI,OAAO,WAAW,eAAe,OAAO,mBAAmB,aAAa;AAC1E,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,mBAAmB;AACtD,QAAI,CAAC,IAAK,QAAO,CAAC;AAClB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,MAAM,QAAQ,MAAM,IAAK,SAA4B,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":["RUNTIME","RUNTIME"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nimport { aiReferralRulesLiteral } from \"./ai-referrals\";\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\nvar AIR=${aiReferralRulesLiteral()};\n/* AI 챗봇 유입 판정 — 원문 referrer 는 보내지 않고 고정 목록의 라벨만 남긴다.\n 목록 밖(일반 사이트·검색엔진)은 null 이라 아무것도 전송되지 않는다. */\nfunction aiRef(){try{\nif(!document.referrer)return null;\nvar h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nif(!h||h===location.hostname.toLowerCase().replace(/^www\\\\./,''))return null;\nfor(var i=0;i<AIR.length;i++){var r=AIR[i][0];if(h===r||h.slice(-(r.length+1))==='.'+r)return AIR[i][1];}\nreturn null;\n}catch(e){return null;}}\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};\nif(extra){for(var k in extra){p[k]=extra[k];}}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\n/* 유입 신호 1회 계산 — 어트리뷰션 저장(아래 C.attr 블록)과 pageview 동봉이\n 같은 값을 쓴다. referrer 는 호스트명만(경로·쿼리 미포함, www 제거),\n gclid/fbclid 는 존재 여부(gc:1)만 — 원값은 어디에도 없다. */\nvar SIG=(function(){var o={};\ntry{\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');if(rs)o.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)o.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)o.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)o.uc=uc.slice(0,100);\nvar ut=sp.get('utm_term');if(ut)o.ut=ut.slice(0,100);\nvar un=sp.get('utm_content');if(un)o.un=un.slice(0,100);\nif(sp.get('gclid')||sp.get('fbclid'))o.gc=1;\n}catch(e){}\ntry{\nif(document.referrer){\nvar rh=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nvar oh=location.hostname.toLowerCase().replace(/^www\\\\./,'');\nif(rh&&rh!==oh)o.rh=rh.slice(0,253);\n}\n}catch(e){}\nreturn o;})();\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(SIG.rs)env.rs=SIG.rs;\nif(SIG.us)env.us=SIG.us;\nif(SIG.um)env.um=SIG.um;\nif(SIG.uc)env.uc=SIG.uc;\nif(SIG.ut)env.ut=SIG.ut;\nif(SIG.un)env.un=SIG.un;\nif(SIG.gc)env.gcl=1;\nif(SIG.rh)env.rf=SIG.rh;\n}\nvar ch=null;\nif(SIG.rs){ch='rs';}else if(SIG.us||SIG.um||SIG.uc||SIG.ut||SIG.un){ch='utm';}else if(SIG.gc){ch='gcl';}else if(SIG.rh){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(SIG.rs)lt.rs=SIG.rs;\nif(SIG.us)lt.us=SIG.us;\nif(SIG.um)lt.um=SIG.um;\nif(SIG.uc)lt.uc=SIG.uc;\nif(SIG.ut)lt.ut=SIG.ut;\nif(SIG.un)lt.un=SIG.un;\nif(SIG.gc)lt.gcl=1;\nif(SIG.rh)lt.rf=SIG.rh;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();var _air=aiRef();var _x={};\nif(_pid)_x.pid=_pid;\nif(_air)_x.air=_air;\n/* AI 챗봇 유입은 라벨(air)만 — 호스트명도 보내지 않는다(기존 불변식 유지). */\nif(SIG.rh&&!_air)_x.rh=SIG.rh;\nif(SIG.us)_x.us=SIG.us;\nif(SIG.um)_x.um=SIG.um;\nif(SIG.uc)_x.uc=SIG.uc;\nif(SIG.rs)_x.rs=SIG.rs;\nif(SIG.gc)_x.gc=1;\n/* 브라우저 언어 1차 서브태그만(ko·en·ja···) — 외국인 방문 비율용. 전체\n locale 문자열(ko-KR 등)은 지문성분이 늘어 보내지 않는다. */\ntry{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}\nvar _has=false;for(var _q in _x){_has=true;break;}\nsend('pageview',_has?_x:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * 유입 경로 신호(2026-08-19) — pageview 에만 동봉, 전부 있을 때만 실린다:\n * `rh` 외부 referrer **호스트명**(경로·쿼리 없음, www 제거) · `us`/`um`/`uc`\n * utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재\n * 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full\n * locale 미전송). 판정은 서버가 한다 —\n * `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널\n * 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).\n * raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";;;;;AA2DO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA,UAEN,uBAAuB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA6H3B,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|