@roottale/analytics-runtime 0.4.0 → 0.5.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/ai-referrals.d.ts +40 -0
- package/dist/ai-referrals.js +17 -0
- package/dist/ai-referrals.js.map +1 -0
- package/dist/chunk-2WQ7PVRU.js +59 -0
- package/dist/chunk-2WQ7PVRU.js.map +1 -0
- package/dist/{chunk-GW523RS4.js → chunk-ABGGPWIX.js} +2 -2
- package/dist/{chunk-DIN7LIST.js → chunk-ANB42OLW.js} +18 -2
- package/dist/chunk-ANB42OLW.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/next.js +3 -2
- package/dist/next.js.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/package.json +6 -1
- package/dist/chunk-DIN7LIST.js.map +0 -1
- /package/dist/{chunk-GW523RS4.js.map → chunk-ABGGPWIX.js.map} +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 챗봇 유입 분류 — GA4 세션 소스와 자체 비콘이 같은 기준을 쓴다.
|
|
3
|
+
*
|
|
4
|
+
* 원문 주소는 저장하지 않는다. 아래 고정 목록의 호스트에 해당할 때만 챗봇
|
|
5
|
+
* id 라벨로 환산하고, 목록 밖은 버린다(분류 불가를 "기타"로 뭉뚱그리면
|
|
6
|
+
* 숫자의 의미가 사라진다).
|
|
7
|
+
*
|
|
8
|
+
* 이 파일은 브라우저 비콘 생성기와 서버 양쪽에서 참조하므로 런타임 의존이
|
|
9
|
+
* 없어야 한다.
|
|
10
|
+
*/
|
|
11
|
+
declare const AI_REFERRAL_IDS: readonly ["gemini", "chatgpt", "claude", "perplexity", "copilot"];
|
|
12
|
+
type AiReferralId = (typeof AI_REFERRAL_IDS)[number];
|
|
13
|
+
type AiReferralMetric = {
|
|
14
|
+
readonly id: AiReferralId;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly sessions: number;
|
|
17
|
+
};
|
|
18
|
+
/** 수집 라우트의 입력 검증 — 사전 밖 문자열이 차원에 들어가지 못하게. */
|
|
19
|
+
declare function isAiReferralId(value: string): value is AiReferralId;
|
|
20
|
+
declare const AI_REFERRAL_LABEL: Record<AiReferralId, string>;
|
|
21
|
+
/**
|
|
22
|
+
* 비콘 인라인 스크립트에 심을 규칙표 리터럴. 규칙이 여기 한 곳에만 있어야
|
|
23
|
+
* 브라우저 판정과 서버 판정이 갈리지 않는다.
|
|
24
|
+
*/
|
|
25
|
+
declare function aiReferralRulesLiteral(): string;
|
|
26
|
+
/**
|
|
27
|
+
* 유입 출처 문자열(호스트 또는 GA4 세션 소스)을 챗봇 id 로 환산한다.
|
|
28
|
+
* 목록 밖이면 null — 호출부가 집계에서 제외한다.
|
|
29
|
+
*/
|
|
30
|
+
declare function classifyAiReferral(source: string | null | undefined): AiReferralId | null;
|
|
31
|
+
/**
|
|
32
|
+
* (id, 세션 수) 쌍을 합산해 많은 순으로 정렬한다. 0 인 챗봇은 빼서
|
|
33
|
+
* "안 들어온 채널"이 차트를 채우지 않게 한다.
|
|
34
|
+
*/
|
|
35
|
+
declare function summarizeAiReferrals(entries: readonly {
|
|
36
|
+
readonly id: AiReferralId;
|
|
37
|
+
readonly sessions: number;
|
|
38
|
+
}[]): readonly AiReferralMetric[];
|
|
39
|
+
|
|
40
|
+
export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, type AiReferralId, type AiReferralMetric, aiReferralRulesLiteral, classifyAiReferral, isAiReferralId, summarizeAiReferrals };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AI_REFERRAL_IDS,
|
|
3
|
+
AI_REFERRAL_LABEL,
|
|
4
|
+
aiReferralRulesLiteral,
|
|
5
|
+
classifyAiReferral,
|
|
6
|
+
isAiReferralId,
|
|
7
|
+
summarizeAiReferrals
|
|
8
|
+
} from "./chunk-2WQ7PVRU.js";
|
|
9
|
+
export {
|
|
10
|
+
AI_REFERRAL_IDS,
|
|
11
|
+
AI_REFERRAL_LABEL,
|
|
12
|
+
aiReferralRulesLiteral,
|
|
13
|
+
classifyAiReferral,
|
|
14
|
+
isAiReferralId,
|
|
15
|
+
summarizeAiReferrals
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=ai-referrals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/ai-referrals.ts
|
|
2
|
+
var AI_REFERRAL_IDS = [
|
|
3
|
+
"gemini",
|
|
4
|
+
"chatgpt",
|
|
5
|
+
"claude",
|
|
6
|
+
"perplexity",
|
|
7
|
+
"copilot"
|
|
8
|
+
];
|
|
9
|
+
function isAiReferralId(value) {
|
|
10
|
+
return AI_REFERRAL_IDS.includes(value);
|
|
11
|
+
}
|
|
12
|
+
var AI_REFERRAL_LABEL = {
|
|
13
|
+
gemini: "\uC81C\uBBF8\uB098\uC774",
|
|
14
|
+
chatgpt: "GPT",
|
|
15
|
+
claude: "\uD074\uB85C\uB4DC",
|
|
16
|
+
perplexity: "\uD37C\uD50C\uB809\uC2DC\uD2F0",
|
|
17
|
+
copilot: "\uCF54\uD30C\uC77C\uB7FF"
|
|
18
|
+
};
|
|
19
|
+
var HOST_RULES = [
|
|
20
|
+
{ host: "gemini.google.com", id: "gemini" },
|
|
21
|
+
{ host: "bard.google.com", id: "gemini" },
|
|
22
|
+
{ host: "chatgpt.com", id: "chatgpt" },
|
|
23
|
+
{ host: "chat.openai.com", id: "chatgpt" },
|
|
24
|
+
{ host: "openai.com", id: "chatgpt" },
|
|
25
|
+
{ host: "claude.ai", id: "claude" },
|
|
26
|
+
{ host: "perplexity.ai", id: "perplexity" },
|
|
27
|
+
{ host: "copilot.microsoft.com", id: "copilot" },
|
|
28
|
+
{ host: "m365.cloud.microsoft", id: "copilot" }
|
|
29
|
+
];
|
|
30
|
+
function matchesHost(candidate, rule) {
|
|
31
|
+
return candidate === rule || candidate.endsWith(`.${rule}`);
|
|
32
|
+
}
|
|
33
|
+
function aiReferralRulesLiteral() {
|
|
34
|
+
return JSON.stringify(HOST_RULES.map((rule) => [rule.host, rule.id]));
|
|
35
|
+
}
|
|
36
|
+
function classifyAiReferral(source) {
|
|
37
|
+
if (!source) return null;
|
|
38
|
+
const normalized = source.trim().toLowerCase().replace(/^www\./, "");
|
|
39
|
+
if (normalized === "") return null;
|
|
40
|
+
const rule = HOST_RULES.find((candidate) => matchesHost(normalized, candidate.host));
|
|
41
|
+
return rule?.id ?? null;
|
|
42
|
+
}
|
|
43
|
+
function summarizeAiReferrals(entries) {
|
|
44
|
+
const totals = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
totals.set(entry.id, (totals.get(entry.id) ?? 0) + entry.sessions);
|
|
47
|
+
}
|
|
48
|
+
return [...totals.entries()].filter(([, sessions]) => sessions > 0).map(([id, sessions]) => ({ id, label: AI_REFERRAL_LABEL[id], sessions })).sort((left, right) => right.sessions - left.sessions);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
AI_REFERRAL_IDS,
|
|
53
|
+
isAiReferralId,
|
|
54
|
+
AI_REFERRAL_LABEL,
|
|
55
|
+
aiReferralRulesLiteral,
|
|
56
|
+
classifyAiReferral,
|
|
57
|
+
summarizeAiReferrals
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=chunk-2WQ7PVRU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ai-referrals.ts"],"sourcesContent":["/**\n * AI 챗봇 유입 분류 — GA4 세션 소스와 자체 비콘이 같은 기준을 쓴다.\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,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
trackCustomEvent
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ANB42OLW.js";
|
|
4
4
|
|
|
5
5
|
// src/observers.ts
|
|
6
6
|
var SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
@@ -197,4 +197,4 @@ export {
|
|
|
197
197
|
initObservers,
|
|
198
198
|
teardownObservers
|
|
199
199
|
};
|
|
200
|
-
//# sourceMappingURL=chunk-
|
|
200
|
+
//# sourceMappingURL=chunk-ABGGPWIX.js.map
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aiReferralRulesLiteral
|
|
3
|
+
} from "./chunk-2WQ7PVRU.js";
|
|
4
|
+
|
|
1
5
|
// src/beacon.ts
|
|
2
6
|
var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
|
|
3
7
|
var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4
8
|
var RUNTIME = `(function(){
|
|
5
9
|
var C=__RT_BEACON__;
|
|
10
|
+
var AIR=${aiReferralRulesLiteral()};
|
|
11
|
+
/* AI \uCC57\uBD07 \uC720\uC785 \uD310\uC815 \u2014 \uC6D0\uBB38 referrer \uB294 \uBCF4\uB0B4\uC9C0 \uC54A\uACE0 \uACE0\uC815 \uBAA9\uB85D\uC758 \uB77C\uBCA8\uB9CC \uB0A8\uAE34\uB2E4.
|
|
12
|
+
\uBAA9\uB85D \uBC16(\uC77C\uBC18 \uC0AC\uC774\uD2B8\xB7\uAC80\uC0C9\uC5D4\uC9C4)\uC740 null \uC774\uB77C \uC544\uBB34\uAC83\uB3C4 \uC804\uC1A1\uB418\uC9C0 \uC54A\uB294\uB2E4. */
|
|
13
|
+
function aiRef(){try{
|
|
14
|
+
if(!document.referrer)return null;
|
|
15
|
+
var h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\./,'');
|
|
16
|
+
if(!h||h===location.hostname.toLowerCase().replace(/^www\\./,''))return null;
|
|
17
|
+
for(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];}
|
|
18
|
+
return null;
|
|
19
|
+
}catch(e){return null;}}
|
|
6
20
|
function 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;}}
|
|
7
21
|
function send(ev,extra){
|
|
8
22
|
try{
|
|
@@ -70,7 +84,9 @@ env.lt=lt;
|
|
|
70
84
|
if(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}
|
|
71
85
|
}catch(e){}
|
|
72
86
|
}
|
|
73
|
-
if(C.pv){var _pid=metaPid();
|
|
87
|
+
if(C.pv){var _pid=metaPid();var _air=aiRef();var _x=null;
|
|
88
|
+
if(_pid||_air){_x={};if(_pid)_x.pid=_pid;if(_air)_x.air=_air;}
|
|
89
|
+
send('pageview',_x);}
|
|
74
90
|
})();`;
|
|
75
91
|
function renderBeaconScript(options) {
|
|
76
92
|
const cfg = JSON.stringify({
|
|
@@ -92,4 +108,4 @@ export {
|
|
|
92
108
|
renderBeaconScript,
|
|
93
109
|
trackCustomEvent
|
|
94
110
|
};
|
|
95
|
-
//# sourceMappingURL=chunk-
|
|
111
|
+
//# sourceMappingURL=chunk-ANB42OLW.js.map
|
|
@@ -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/** 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});\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 sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');\nvar us=sp.get('utm_source');\nvar um=sp.get('utm_medium');\nvar uc=sp.get('utm_campaign');\nvar ut=sp.get('utm_term');\nvar un=sp.get('utm_content');\nvar hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));\nvar extRef=null;\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(rs)env.rs=rs.slice(0,32);\nif(us)env.us=us.slice(0,100);\nif(um)env.um=um.slice(0,100);\nif(uc)env.uc=uc.slice(0,100);\nif(ut)env.ut=ut.slice(0,100);\nif(un)env.un=un.slice(0,100);\nif(hasGcl)env.gcl=1;\nif(extRef)env.rf=extRef;\n}\nvar ch=null;\nif(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(hasGcl){ch='gcl';}else if(extRef){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(rs)lt.rs=rs.slice(0,32);\nif(us)lt.us=us.slice(0,100);\nif(um)lt.um=um.slice(0,100);\nif(uc)lt.uc=uc.slice(0,100);\nif(ut)lt.ut=ut.slice(0,100);\nif(un)lt.un=un.slice(0,100);\nif(hasGcl)lt.gcl=1;\nif(extRef)lt.rf=extRef;\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=null;\nif(_pid||_air){_x={};if(_pid)_x.pid=_pid;if(_air)_x.air=_air;}\nsend('pageview',_x);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\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;AA4F3B,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/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { C as ConsentCategory, a as ConsentState, T as TagProvider, A as AnalyticsSiteConfig, b as AnalyticsManifest } from './consent-banner-WVGsxcY2.js';
|
|
2
2
|
export { c as ConsentBannerOptions, d as ConsentValue, R as ResolvedTag, e as TagConfig, r as renderConsentBannerMarkup, f as renderConsentBannerScript } from './consent-banner-WVGsxcY2.js';
|
|
3
|
+
export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, AiReferralId, AiReferralMetric, aiReferralRulesLiteral, classifyAiReferral, isAiReferralId, summarizeAiReferrals } from './ai-referrals.js';
|
|
3
4
|
export { GA4_MIRROR, TRACK_EVENTS, TrackEventDef, TrackEventName, isTrackEvent } from './events.js';
|
|
4
5
|
|
|
5
6
|
declare const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[];
|
package/dist/index.js
CHANGED
|
@@ -12,18 +12,26 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
initObservers,
|
|
14
14
|
teardownObservers
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-ABGGPWIX.js";
|
|
16
16
|
import {
|
|
17
17
|
ATTRIBUTION_STORAGE_KEY,
|
|
18
18
|
ATTRIBUTION_TTL_MS,
|
|
19
19
|
renderBeaconScript,
|
|
20
20
|
trackCustomEvent
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-ANB42OLW.js";
|
|
22
22
|
import {
|
|
23
23
|
GA4_MIRROR,
|
|
24
24
|
TRACK_EVENTS,
|
|
25
25
|
isTrackEvent
|
|
26
26
|
} from "./chunk-GBIGQHVN.js";
|
|
27
|
+
import {
|
|
28
|
+
AI_REFERRAL_IDS,
|
|
29
|
+
AI_REFERRAL_LABEL,
|
|
30
|
+
aiReferralRulesLiteral,
|
|
31
|
+
classifyAiReferral,
|
|
32
|
+
isAiReferralId,
|
|
33
|
+
summarizeAiReferrals
|
|
34
|
+
} from "./chunk-2WQ7PVRU.js";
|
|
27
35
|
|
|
28
36
|
// src/consent.ts
|
|
29
37
|
var ALL_CONSENT_CATEGORIES = [
|
|
@@ -69,6 +77,8 @@ function buildManifest(config) {
|
|
|
69
77
|
};
|
|
70
78
|
}
|
|
71
79
|
export {
|
|
80
|
+
AI_REFERRAL_IDS,
|
|
81
|
+
AI_REFERRAL_LABEL,
|
|
72
82
|
ALL_CONSENT_CATEGORIES,
|
|
73
83
|
ATTRIBUTION_STORAGE_KEY,
|
|
74
84
|
ATTRIBUTION_TTL_MS,
|
|
@@ -78,8 +88,11 @@ export {
|
|
|
78
88
|
JOURNEY_STORAGE_KEY,
|
|
79
89
|
PROVIDER_CONSENT,
|
|
80
90
|
TRACK_EVENTS,
|
|
91
|
+
aiReferralRulesLiteral,
|
|
81
92
|
buildManifest,
|
|
93
|
+
classifyAiReferral,
|
|
82
94
|
initObservers,
|
|
95
|
+
isAiReferralId,
|
|
83
96
|
isTagAllowed,
|
|
84
97
|
isTrackEvent,
|
|
85
98
|
readJourney,
|
|
@@ -90,6 +103,7 @@ export {
|
|
|
90
103
|
renderGa4BridgeScript,
|
|
91
104
|
renderJourneyBridgeScript,
|
|
92
105
|
renderLoaderScript,
|
|
106
|
+
summarizeAiReferrals,
|
|
93
107
|
teardownObservers,
|
|
94
108
|
trackCustomEvent
|
|
95
109
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/consent.ts","../src/manifest.ts"],"sourcesContent":["// ADR-0038 §2 — 동의 fail-closed + provider↔consent 매핑.\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/** fail-closed 기본값 — 전부 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/**\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 §1 — site 태그 구성 → manifest (순수 함수).\n\nimport { DENIED_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 * - 동의 기본값은 항상 전부 denied (fail-closed).\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: { ...DENIED_CONSENT },\n };\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/consent.ts","../src/manifest.ts"],"sourcesContent":["// ADR-0038 §2 — 동의 fail-closed + provider↔consent 매핑.\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/** fail-closed 기본값 — 전부 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/**\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 §1 — site 태그 구성 → manifest (순수 함수).\n\nimport { DENIED_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 * - 동의 기본값은 항상 전부 denied (fail-closed).\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: { ...DENIED_CONSENT },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAIO,IAAM,yBAAqD;AAAA,EAChE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,IAAM,iBAA+B;AAAA,EAC1C,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;;;AC1BO,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,eAAe;AAAA,EACtC;AACF;","names":[]}
|
package/dist/next.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import {
|
|
3
3
|
initObservers,
|
|
4
4
|
teardownObservers
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-ABGGPWIX.js";
|
|
6
6
|
import {
|
|
7
7
|
trackCustomEvent
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-ANB42OLW.js";
|
|
9
|
+
import "./chunk-2WQ7PVRU.js";
|
|
9
10
|
|
|
10
11
|
// src/next.tsx
|
|
11
12
|
import { useEffect, useRef } from "react";
|
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
|
|
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;AAKd,QAAI,QAAQ,SAAS;AACnB,uBAAiB,UAAU;AAAA,IAC7B;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
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from "./chunk-G7OLBHT5.js";
|
|
9
9
|
import {
|
|
10
10
|
renderBeaconScript
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-ANB42OLW.js";
|
|
12
12
|
import "./chunk-GBIGQHVN.js";
|
|
13
|
+
import "./chunk-2WQ7PVRU.js";
|
|
13
14
|
|
|
14
15
|
// src/react.tsx
|
|
15
16
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
package/dist/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/react.tsx"],"sourcesContent":["// ADR-0038 — Next.js/React 드롭인 어댑터.\n//\n// `@roottale/analytics-runtime/react` 로 import. core 는 React-free 유지하고\n// 본 어댑터만 react(peerDependency)에 의존한다.\n//\n// 사용 (Next App Router root layout):\n// import { RootTaleAnalytics } from \"@roottale/analytics-runtime/react\";\n// ...\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// </body>\n//\n// 모든 <script> 는 dangerouslySetInnerHTML 인라인 — Next 가 실제 실행되는\n// <script> 로 렌더(set:html innerHTML 함정 회피). 순서: consent bootstrap(전부\n// denied) → loader(fail-closed) → 배너 → 비콘.\n\nimport type { AnalyticsManifest } from \"./types\";\nimport { renderConsentBootstrap, renderLoaderScript } from \"./loader\";\nimport {\n renderConsentBannerMarkup,\n renderConsentBannerScript,\n type ConsentBannerOptions,\n} from \"./consent-banner\";\nimport { renderBeaconScript } from \"./beacon\";\nimport { renderJourneyBridgeScript } from \"./journey\";\nimport { renderGa4BridgeScript } from \"./ga4-bridge\";\n\nexport interface RootTaleAnalyticsProps {\n /** buildManifest() 결과. plane ① 태그 + fail-closed 동의 기본값. */\n manifest: AnalyticsManifest;\n /** plane ② 비콘 수집 엔드포인트. 없으면 비콘 미주입. */\n collectUrl?: string;\n /** 비콘용 site 식별자. collectUrl 과 함께 제공. */\n siteId?: string;\n /** 동의 배너 개인정보처리방침 링크. */\n privacyUrl?: string;\n /** 비콘 pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** 동의 배너 카피 override. */\n bannerOptions?: ConsentBannerOptions;\n /**\n * GA4 미러 브리지 스크립트 주입 여부(설계 §0, 기본 true). beacon 이 없으면\n * (collectUrl/siteId 미설정) 어차피 미주입 — 이벤트를 흘려보낼 send() 자체가\n * 없기 때문.\n */\n enableGa4Bridge?: boolean;\n /**\n * 방문 여정(observers.ts) 감지기 활성 여부(설계 §1 2단계, 기본 true). 본\n * 컴포넌트는 RSC 호환(hook 없음)이라 IntersectionObserver 등을 직접\n * 구동하지 못한다 — `false` 로 지정하면 `window.__rtObserversEnabled=false`\n * 를 심어 `RootTaleAnalyticsNextAdapter`(next.tsx)가 initObservers() 호출\n * 전 이 값을 읽고 건너뛰게 한다. `true`(기본)는 아무 것도 심지 않음(next.tsx\n * 의 기본값이 이미 활성이므로 무동작이 곧 활성).\n */\n enableObservers?: boolean;\n}\n\n/**\n * 단일 드롭인 컴포넌트. RSC 호환(hook/상태 없음) — Server Component layout 에\n * 바로 배치 가능.\n */\nexport function RootTaleAnalytics({\n manifest,\n collectUrl,\n siteId,\n privacyUrl,\n trackPageview,\n bannerOptions,\n enableGa4Bridge = true,\n enableObservers = true,\n}: RootTaleAnalyticsProps) {\n const beacon =\n collectUrl && siteId\n ? renderBeaconScript({ collectUrl, siteId, trackPageview })\n : null;\n const bannerOpts: ConsentBannerOptions = {\n privacyUrl,\n ...bannerOptions,\n };\n\n return (\n <>\n <script\n dangerouslySetInnerHTML={{ __html: renderConsentBootstrap() }}\n />\n <script\n dangerouslySetInnerHTML={{ __html: renderLoaderScript(manifest) }}\n />\n {/*\n suppressHydrationWarning: `renderConsentBannerScript()` 는 일반\n `<script>` 로 삽입되어 브라우저가 이 div 뒤에서 그 스크립트를 만나는\n 즉시(React 하이드레이션이 시작되기 전, 최초 HTML 파싱 도중) 동의 기록이\n 없으면 `el.hidden = false` 를 실행해 `hidden` 속성을 지운다 — 배너를\n 깜빡임 없이 보여주기 위해 의도된 동작이다. 그 결과 React 가 하이드레이션\n 시점에 비교하는 실제 DOM 은 이 컴포넌트가 SSR 로 낸 `__html`(hidden 포함)\n 과 달라져 \"tree hydrated but attributes didn't match\" 경고가 난다 —\n React 문서가 안내하는 FOUC-방지 스크립트의 표준 처방(next-themes 류가\n `<html>` 에 쓰는 것과 동일 패턴)대로 이 노드에서만 경고를 억제한다.\n */}\n <div\n suppressHydrationWarning\n dangerouslySetInnerHTML={{\n __html: renderConsentBannerMarkup(bannerOpts),\n }}\n />\n <script\n dangerouslySetInnerHTML={{ __html: renderConsentBannerScript() }}\n />\n {beacon ? (\n <script dangerouslySetInnerHTML={{ __html: beacon }} />\n ) : null}\n {/*\n journey 브리지는 beacon 이 있을 때만 의미가 있다(훅 대상인\n window.__rtTrack/send() 자체가 beacon 스크립트가 심는다) — opt-out\n 프롭 없음(1차 캡처 인프라, 설계 §3).\n */}\n {beacon ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderJourneyBridgeScript() }}\n />\n ) : null}\n {beacon && enableGa4Bridge ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderGa4BridgeScript() }}\n />\n ) : null}\n {enableObservers === false ? (\n <script\n dangerouslySetInnerHTML={{\n __html: \"window.__rtObserversEnabled=false;\",\n }}\n />\n ) : null}\n </>\n );\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/react.tsx"],"sourcesContent":["// ADR-0038 — Next.js/React 드롭인 어댑터.\n//\n// `@roottale/analytics-runtime/react` 로 import. core 는 React-free 유지하고\n// 본 어댑터만 react(peerDependency)에 의존한다.\n//\n// 사용 (Next App Router root layout):\n// import { RootTaleAnalytics } from \"@roottale/analytics-runtime/react\";\n// ...\n// <body>\n// {children}\n// <RootTaleAnalytics manifest={manifest} collectUrl={url} siteId={id} />\n// </body>\n//\n// 모든 <script> 는 dangerouslySetInnerHTML 인라인 — Next 가 실제 실행되는\n// <script> 로 렌더(set:html innerHTML 함정 회피). 순서: consent bootstrap(전부\n// denied) → loader(fail-closed) → 배너 → 비콘.\n\nimport type { AnalyticsManifest } from \"./types\";\nimport { renderConsentBootstrap, renderLoaderScript } from \"./loader\";\nimport {\n renderConsentBannerMarkup,\n renderConsentBannerScript,\n type ConsentBannerOptions,\n} from \"./consent-banner\";\nimport { renderBeaconScript } from \"./beacon\";\nimport { renderJourneyBridgeScript } from \"./journey\";\nimport { renderGa4BridgeScript } from \"./ga4-bridge\";\n\nexport interface RootTaleAnalyticsProps {\n /** buildManifest() 결과. plane ① 태그 + fail-closed 동의 기본값. */\n manifest: AnalyticsManifest;\n /** plane ② 비콘 수집 엔드포인트. 없으면 비콘 미주입. */\n collectUrl?: string;\n /** 비콘용 site 식별자. collectUrl 과 함께 제공. */\n siteId?: string;\n /** 동의 배너 개인정보처리방침 링크. */\n privacyUrl?: string;\n /** 비콘 pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** 동의 배너 카피 override. */\n bannerOptions?: ConsentBannerOptions;\n /**\n * GA4 미러 브리지 스크립트 주입 여부(설계 §0, 기본 true). beacon 이 없으면\n * (collectUrl/siteId 미설정) 어차피 미주입 — 이벤트를 흘려보낼 send() 자체가\n * 없기 때문.\n */\n enableGa4Bridge?: boolean;\n /**\n * 방문 여정(observers.ts) 감지기 활성 여부(설계 §1 2단계, 기본 true). 본\n * 컴포넌트는 RSC 호환(hook 없음)이라 IntersectionObserver 등을 직접\n * 구동하지 못한다 — `false` 로 지정하면 `window.__rtObserversEnabled=false`\n * 를 심어 `RootTaleAnalyticsNextAdapter`(next.tsx)가 initObservers() 호출\n * 전 이 값을 읽고 건너뛰게 한다. `true`(기본)는 아무 것도 심지 않음(next.tsx\n * 의 기본값이 이미 활성이므로 무동작이 곧 활성).\n */\n enableObservers?: boolean;\n}\n\n/**\n * 단일 드롭인 컴포넌트. RSC 호환(hook/상태 없음) — Server Component layout 에\n * 바로 배치 가능.\n */\nexport function RootTaleAnalytics({\n manifest,\n collectUrl,\n siteId,\n privacyUrl,\n trackPageview,\n bannerOptions,\n enableGa4Bridge = true,\n enableObservers = true,\n}: RootTaleAnalyticsProps) {\n const beacon =\n collectUrl && siteId\n ? renderBeaconScript({ collectUrl, siteId, trackPageview })\n : null;\n const bannerOpts: ConsentBannerOptions = {\n privacyUrl,\n ...bannerOptions,\n };\n\n return (\n <>\n <script\n dangerouslySetInnerHTML={{ __html: renderConsentBootstrap() }}\n />\n <script\n dangerouslySetInnerHTML={{ __html: renderLoaderScript(manifest) }}\n />\n {/*\n suppressHydrationWarning: `renderConsentBannerScript()` 는 일반\n `<script>` 로 삽입되어 브라우저가 이 div 뒤에서 그 스크립트를 만나는\n 즉시(React 하이드레이션이 시작되기 전, 최초 HTML 파싱 도중) 동의 기록이\n 없으면 `el.hidden = false` 를 실행해 `hidden` 속성을 지운다 — 배너를\n 깜빡임 없이 보여주기 위해 의도된 동작이다. 그 결과 React 가 하이드레이션\n 시점에 비교하는 실제 DOM 은 이 컴포넌트가 SSR 로 낸 `__html`(hidden 포함)\n 과 달라져 \"tree hydrated but attributes didn't match\" 경고가 난다 —\n React 문서가 안내하는 FOUC-방지 스크립트의 표준 처방(next-themes 류가\n `<html>` 에 쓰는 것과 동일 패턴)대로 이 노드에서만 경고를 억제한다.\n */}\n <div\n suppressHydrationWarning\n dangerouslySetInnerHTML={{\n __html: renderConsentBannerMarkup(bannerOpts),\n }}\n />\n <script\n dangerouslySetInnerHTML={{ __html: renderConsentBannerScript() }}\n />\n {beacon ? (\n <script dangerouslySetInnerHTML={{ __html: beacon }} />\n ) : null}\n {/*\n journey 브리지는 beacon 이 있을 때만 의미가 있다(훅 대상인\n window.__rtTrack/send() 자체가 beacon 스크립트가 심는다) — opt-out\n 프롭 없음(1차 캡처 인프라, 설계 §3).\n */}\n {beacon ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderJourneyBridgeScript() }}\n />\n ) : null}\n {beacon && enableGa4Bridge ? (\n <script\n dangerouslySetInnerHTML={{ __html: renderGa4BridgeScript() }}\n />\n ) : null}\n {enableObservers === false ? (\n <script\n dangerouslySetInnerHTML={{\n __html: \"window.__rtObserversEnabled=false;\",\n }}\n />\n ) : null}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkFI,mBACE,KADF;AApBG,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,kBAAkB;AACpB,GAA2B;AACzB,QAAM,SACJ,cAAc,SACV,mBAAmB,EAAE,YAAY,QAAQ,cAAc,CAAC,IACxD;AACN,QAAM,aAAmC;AAAA,IACvC;AAAA,IACA,GAAG;AAAA,EACL;AAEA,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,uBAAuB,EAAE;AAAA;AAAA,IAC9D;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,mBAAmB,QAAQ,EAAE;AAAA;AAAA,IAClE;AAAA,IAYA;AAAA,MAAC;AAAA;AAAA,QACC,0BAAwB;AAAA,QACxB,yBAAyB;AAAA,UACvB,QAAQ,0BAA0B,UAAU;AAAA,QAC9C;AAAA;AAAA,IACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,0BAA0B,EAAE;AAAA;AAAA,IACjE;AAAA,IACC,SACC,oBAAC,YAAO,yBAAyB,EAAE,QAAQ,OAAO,GAAG,IACnD;AAAA,IAMH,SACC;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,0BAA0B,EAAE;AAAA;AAAA,IACjE,IACE;AAAA,IACH,UAAU,kBACT;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB,EAAE,QAAQ,sBAAsB,EAAE;AAAA;AAAA,IAC7D,IACE;AAAA,IACH,oBAAoB,QACnB;AAAA,MAAC;AAAA;AAAA,QACC,yBAAyB;AAAA,UACvB,QAAQ;AAAA,QACV;AAAA;AAAA,IACF,IACE;AAAA,KACN;AAEJ;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/analytics-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale analytics runtime (ADR-0038) — Consent Mode v2 fail-closed tag loader, consent banner, and first-party data-track beacon. Framework-agnostic strings + optional React drop-in (<RootTaleAnalytics>).",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"import": "./dist/events.js",
|
|
27
27
|
"default": "./dist/events.js"
|
|
28
28
|
},
|
|
29
|
+
"./ai-referrals": {
|
|
30
|
+
"types": "./dist/ai-referrals.d.ts",
|
|
31
|
+
"import": "./dist/ai-referrals.js",
|
|
32
|
+
"default": "./dist/ai-referrals.js"
|
|
33
|
+
},
|
|
29
34
|
"./package.json": "./package.json"
|
|
30
35
|
},
|
|
31
36
|
"files": [
|
|
@@ -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\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__;\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});\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 sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');\nvar us=sp.get('utm_source');\nvar um=sp.get('utm_medium');\nvar uc=sp.get('utm_campaign');\nvar ut=sp.get('utm_term');\nvar un=sp.get('utm_content');\nvar hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));\nvar extRef=null;\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(rs)env.rs=rs.slice(0,32);\nif(us)env.us=us.slice(0,100);\nif(um)env.um=um.slice(0,100);\nif(uc)env.uc=uc.slice(0,100);\nif(ut)env.ut=ut.slice(0,100);\nif(un)env.un=un.slice(0,100);\nif(hasGcl)env.gcl=1;\nif(extRef)env.rf=extRef;\n}\nvar ch=null;\nif(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(hasGcl){ch='gcl';}else if(extRef){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(rs)lt.rs=rs.slice(0,32);\nif(us)lt.us=us.slice(0,100);\nif(um)lt.um=um.slice(0,100);\nif(uc)lt.uc=uc.slice(0,100);\nif(ut)lt.ut=ut.slice(0,100);\nif(un)lt.un=un.slice(0,100);\nif(hasGcl)lt.gcl=1;\nif(extRef)lt.rf=extRef;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();send('pageview',_pid?{pid:_pid}:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\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":";AAyDO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,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;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkFT,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":[]}
|
|
File without changes
|