@roottale/analytics-runtime 0.6.1 → 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-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 +25 -5
- package/dist/index.js.map +1 -1
- package/dist/next.js +4 -4
- package/dist/next.js.map +1 -1
- package/dist/react.js +2 -2
- 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-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
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
|
@@ -16,13 +16,15 @@ import {
|
|
|
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
|
@@ -6,9 +6,9 @@ import {
|
|
|
6
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/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":[]}
|
package/dist/chunk-KIOFZXJX.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// src/entry-channels.ts
|
|
2
|
-
var ENTRY_CHANNEL_IDS = [
|
|
3
|
-
"search",
|
|
4
|
-
"direct",
|
|
5
|
-
"referral",
|
|
6
|
-
"social",
|
|
7
|
-
"paid",
|
|
8
|
-
"ai",
|
|
9
|
-
"shortlink",
|
|
10
|
-
"other"
|
|
11
|
-
];
|
|
12
|
-
function isEntryChannelId(value) {
|
|
13
|
-
return ENTRY_CHANNEL_IDS.includes(value);
|
|
14
|
-
}
|
|
15
|
-
var ENTRY_CHANNEL_LABEL = {
|
|
16
|
-
search: "\uAC80\uC0C9",
|
|
17
|
-
direct: "\uC8FC\uC18C \uC785\uB825\xB7\uC990\uACA8\uCC3E\uAE30",
|
|
18
|
-
referral: "\uB2E4\uB978 \uC0AC\uC774\uD2B8 \uB9C1\uD06C",
|
|
19
|
-
social: "\uC18C\uC15C \uBBF8\uB514\uC5B4",
|
|
20
|
-
paid: "\uAD11\uACE0",
|
|
21
|
-
ai: "AI \uCC57\uBD07",
|
|
22
|
-
shortlink: "\uB2E8\uCD95\uB9C1\uD06C\xB7QR",
|
|
23
|
-
other: "\uAE30\uD0C0 \uCEA0\uD398\uC778"
|
|
24
|
-
};
|
|
25
|
-
var SEARCH_HOSTS = [
|
|
26
|
-
{ host: "google.com" },
|
|
27
|
-
{ host: "google.co.kr" },
|
|
28
|
-
{ host: "search.naver.com" },
|
|
29
|
-
{ host: "naver.com", exact: true },
|
|
30
|
-
{ host: "m.naver.com", exact: true },
|
|
31
|
-
{ host: "search.daum.net" },
|
|
32
|
-
{ host: "daum.net", exact: true },
|
|
33
|
-
{ host: "m.daum.net", exact: true },
|
|
34
|
-
{ host: "bing.com" },
|
|
35
|
-
{ host: "duckduckgo.com" },
|
|
36
|
-
{ host: "search.yahoo.com" },
|
|
37
|
-
{ host: "yandex.com" },
|
|
38
|
-
{ host: "baidu.com" },
|
|
39
|
-
{ host: "search.zum.com" },
|
|
40
|
-
{ host: "zum.com", exact: true },
|
|
41
|
-
{ host: "search.nate.com" },
|
|
42
|
-
{ host: "nate.com", exact: true }
|
|
43
|
-
];
|
|
44
|
-
var SOCIAL_HOSTS = [
|
|
45
|
-
{ host: "facebook.com" },
|
|
46
|
-
{ host: "instagram.com" },
|
|
47
|
-
{ host: "threads.net" },
|
|
48
|
-
{ host: "twitter.com" },
|
|
49
|
-
{ host: "x.com" },
|
|
50
|
-
{ host: "t.co", exact: true },
|
|
51
|
-
{ host: "linkedin.com" },
|
|
52
|
-
{ host: "youtube.com" },
|
|
53
|
-
{ host: "youtu.be", exact: true },
|
|
54
|
-
{ host: "tiktok.com" },
|
|
55
|
-
{ host: "pinterest.com" },
|
|
56
|
-
{ host: "band.us" },
|
|
57
|
-
{ host: "story.kakao.com" },
|
|
58
|
-
{ host: "pf.kakao.com" },
|
|
59
|
-
{ host: "cafe.naver.com" },
|
|
60
|
-
{ host: "cafe.daum.net" }
|
|
61
|
-
];
|
|
62
|
-
function matchesRules(host, rules) {
|
|
63
|
-
for (const rule of rules) {
|
|
64
|
-
if (host === rule.host) return true;
|
|
65
|
-
if (!rule.exact && host.endsWith(`.${rule.host}`)) return true;
|
|
66
|
-
}
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
var PAID_MEDIUM_RE = /^(cpc|ppc|cpm|cpv|cpa|display|banner|paid[\w-]*|retargeting|remarketing)$/i;
|
|
70
|
-
var SOCIAL_MEDIUM_RE = /^(social|sns|social[-_]media)$/i;
|
|
71
|
-
function classifyEntryChannel(signals) {
|
|
72
|
-
const host = signals.referrerHost?.trim().toLowerCase() || null;
|
|
73
|
-
const medium = signals.utmMedium?.trim() || null;
|
|
74
|
-
const hasUtm = Boolean(
|
|
75
|
-
signals.utmSource?.trim() || medium || signals.utmCampaign?.trim()
|
|
76
|
-
);
|
|
77
|
-
if (signals.aiReferralId) return "ai";
|
|
78
|
-
if (signals.rtSrc?.trim()) return "shortlink";
|
|
79
|
-
if (signals.hasAdClick || medium && PAID_MEDIUM_RE.test(medium)) {
|
|
80
|
-
return "paid";
|
|
81
|
-
}
|
|
82
|
-
if (medium && SOCIAL_MEDIUM_RE.test(medium)) return "social";
|
|
83
|
-
if (host) {
|
|
84
|
-
if (matchesRules(host, SEARCH_HOSTS)) return "search";
|
|
85
|
-
if (matchesRules(host, SOCIAL_HOSTS)) return "social";
|
|
86
|
-
}
|
|
87
|
-
if (hasUtm) return "other";
|
|
88
|
-
if (host) return "referral";
|
|
89
|
-
if (signals.hasReferrer) return null;
|
|
90
|
-
return "direct";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export {
|
|
94
|
-
ENTRY_CHANNEL_IDS,
|
|
95
|
-
isEntryChannelId,
|
|
96
|
-
ENTRY_CHANNEL_LABEL,
|
|
97
|
-
classifyEntryChannel
|
|
98
|
-
};
|
|
99
|
-
//# sourceMappingURL=chunk-KIOFZXJX.js.map
|