@roottale/analytics-runtime 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,7 @@ return {
85
85
  | 페이지·섹션·클릭 이벤트 | Cloudflare Analytics Engine `cms_site_events` | 기간별 방향성 분석. 샘플링 가능한 이벤트 스트림 |
86
86
  | 글 누적 조회수 | 사이트별 Cloudflare Durable Object SQLite → PostgreSQL `posts.view_count` | 정확한 제품 카운터와 조회용 미러 |
87
87
  | 첫·마지막 유입 | 브라우저 `localStorage._rt_attr`, 30일 | 식별자 없이 문의 전송 시 동반 |
88
+ | 방문 유형 표식 | 브라우저 `localStorage._rt_visit_seen_<siteId>`(30일 기준)·`sessionStorage._rt_visit_session_<siteId>`(30분 비활동 기준) | 방문 시작 1회에만 첫 확인/재방문 코드 전송, ID 없음 |
88
89
  | 현재 방문 여정 | 브라우저 `sessionStorage._rt_journey`, 최대 30건 | 문의 전송 시 동반 |
89
90
  | 문의 유입·여정 | PostgreSQL `inquiries.attribution`, `inquiries.journey` | 확정 문의 레코드와 함께 저장 |
90
91
  | GA4·Search Console 원본 | 고객 Google 계정 | ROOT-ADMIN은 권한을 받아 보고서 데이터를 조회 |
@@ -92,6 +93,10 @@ return {
92
93
  first-party 비콘은 쿠키와 cross-site ID를 만들지 않으며 raw IP, User-Agent, 전체
93
94
  referrer URL을 저장하지 않습니다. 이벤트 스트림 수치는 방향성 지표이고 문의·전환과
94
95
  글 누적 조회수는 확정 수치로 구분해서 사용합니다.
96
+ 방문 유형은 브라우저 저장소 기준 **방문 횟수**입니다. 사람 수나 기간 내 고유 방문자가
97
+ 아니며 저장소 삭제·기기 변경·저장소 차단 시 정확히 구분할 수 없습니다.
98
+ 문서 새로고침과 뒤로/앞으로 이동은 브라우저 Navigation Timing의 유형을 확인해
99
+ 새 외부 유입에서 제외합니다. 유형을 확인할 수 없는 브라우저에는 기존 분류를 적용합니다.
95
100
 
96
101
  ## 프레임워크 직접 주입
97
102
 
@@ -32,9 +32,20 @@ declare function classifyAiReferral(source: string | null | undefined): AiReferr
32
32
  * (id, 세션 수) 쌍을 합산해 많은 순으로 정렬한다. 0 인 챗봇은 빼서
33
33
  * "안 들어온 채널"이 차트를 채우지 않게 한다.
34
34
  */
35
+ /**
36
+ * utm_source 로 표시된 AI 챗봇 유입. ChatGPT·제미나이 등은 답변 속 링크에
37
+ * `utm_source=chatgpt.com` 처럼 자기 이름을 붙이고 referrer 는 비우는 경우가 많다.
38
+ * 호스트 표기(chatgpt.com)와 이름 표기(chatgpt·gemini)를 모두 받는다.
39
+ */
40
+ declare function classifyAiUtmSource(source: string | null | undefined): AiReferralId | null;
41
+ /**
42
+ * AI 챗봇별 utm_source 표기 — 이름(chatgpt)과 호스트(chatgpt.com) 모두.
43
+ * 이미 적재된 기록을 조회 시점에 보정할 때 SQL 목록으로 쓴다.
44
+ */
45
+ declare function aiUtmSourceAliases(): Readonly<Record<AiReferralId, readonly string[]>>;
35
46
  declare function summarizeAiReferrals(entries: readonly {
36
47
  readonly id: AiReferralId;
37
48
  readonly sessions: number;
38
49
  }[]): readonly AiReferralMetric[];
39
50
 
40
- export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, type AiReferralId, type AiReferralMetric, aiReferralRulesLiteral, classifyAiReferral, isAiReferralId, summarizeAiReferrals };
51
+ export { AI_REFERRAL_IDS, AI_REFERRAL_LABEL, type AiReferralId, type AiReferralMetric, aiReferralRulesLiteral, aiUtmSourceAliases, classifyAiReferral, classifyAiUtmSource, isAiReferralId, summarizeAiReferrals };
@@ -2,15 +2,19 @@ import {
2
2
  AI_REFERRAL_IDS,
3
3
  AI_REFERRAL_LABEL,
4
4
  aiReferralRulesLiteral,
5
+ aiUtmSourceAliases,
5
6
  classifyAiReferral,
7
+ classifyAiUtmSource,
6
8
  isAiReferralId,
7
9
  summarizeAiReferrals
8
- } from "./chunk-6NWJK7V7.js";
10
+ } from "./chunk-KKGWQGIR.js";
9
11
  export {
10
12
  AI_REFERRAL_IDS,
11
13
  AI_REFERRAL_LABEL,
12
14
  aiReferralRulesLiteral,
15
+ aiUtmSourceAliases,
13
16
  classifyAiReferral,
17
+ classifyAiUtmSource,
14
18
  isAiReferralId,
15
19
  summarizeAiReferrals
16
20
  };
package/dist/bots.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 자동 방문(봇) 분류 — `/v1/collect` 가 요청 UA·비콘 신호로 판정하고, 사람
3
+ * 방문 통계와 섞이지 않게 `bot_pageview` 이벤트로 따로 적재한다(AE blob6 = 종류).
4
+ * 관리자 화면은 같은 사전의 라벨로 종류별 횟수를 보여 준다.
5
+ *
6
+ * UA 원문은 판정에만 쓰고 저장하지 않는다(ADR-0026 §5). 브라우저·Workers 양쪽에서
7
+ * import 하므로 런타임 의존이 없어야 한다(entry-channels.ts 와 같은 제약).
8
+ */
9
+ /** 봇 pageview 이벤트 이름(AE blob1) — 수집 서버와 관리자 자동 방문 집계의 적재 계약. */
10
+ declare const BOT_PAGEVIEW_EVENT = "bot_pageview";
11
+ declare const BOT_KINDS: readonly ["search", "ai", "seo", "preview", "automation", "datacenter"];
12
+ type BotKind = (typeof BOT_KINDS)[number];
13
+ declare const BOT_KIND_LABEL: Readonly<Record<BotKind, string>>;
14
+ /** Cloudflare 가 요청에 얹는 ASN·사업자 이름으로 데이터센터 망인지 본다. */
15
+ declare function isDatacenterNetwork(input: {
16
+ readonly asn?: number | null;
17
+ readonly asOrganization?: string | null;
18
+ }): boolean;
19
+ declare function isBotKind(value: string): value is BotKind;
20
+ /**
21
+ * 자동 방문 종류. 사람 방문이면 null.
22
+ * `webdriver` 는 비콘이 보낸 `navigator.webdriver` 신호(자동화 도구가 켠 브라우저).
23
+ * 스스로 밝힌 봇 → 자동화 신호 → 데이터센터 망 순으로 본다.
24
+ */
25
+ declare function classifyBot(input: {
26
+ readonly userAgent: string | null | undefined;
27
+ readonly webdriver?: boolean;
28
+ readonly asn?: number | null;
29
+ readonly asOrganization?: string | null;
30
+ }): BotKind | null;
31
+
32
+ export { BOT_KINDS, BOT_KIND_LABEL, BOT_PAGEVIEW_EVENT, type BotKind, classifyBot, isBotKind, isDatacenterNetwork };
package/dist/bots.js ADDED
@@ -0,0 +1,101 @@
1
+ // src/bots.ts
2
+ var BOT_PAGEVIEW_EVENT = "bot_pageview";
3
+ var BOT_KINDS = ["search", "ai", "seo", "preview", "automation", "datacenter"];
4
+ var BOT_KIND_LABEL = {
5
+ search: "\uAC80\uC0C9\uC5D4\uC9C4 \uC218\uC9D1",
6
+ ai: "AI \uC218\uC9D1",
7
+ seo: "SEO \uC810\uAC80 \uB3C4\uAD6C",
8
+ preview: "\uB9C1\uD06C \uBBF8\uB9AC\uBCF4\uAE30",
9
+ automation: "\uC790\uB3D9\uD654 \uBE0C\uB77C\uC6B0\uC800",
10
+ datacenter: "\uB370\uC774\uD130\uC13C\uD130 \uC811\uC18D"
11
+ };
12
+ var DATACENTER_ASNS = /* @__PURE__ */ new Set([
13
+ 16509,
14
+ 14618,
15
+ 8987,
16
+ // Amazon AWS
17
+ 396982,
18
+ 15169,
19
+ 19527,
20
+ // Google Cloud·Google
21
+ 8075,
22
+ 8068,
23
+ 8069,
24
+ // Microsoft Azure
25
+ 45102,
26
+ 37963,
27
+ // Alibaba Cloud
28
+ 132203,
29
+ 45090,
30
+ // Tencent Cloud
31
+ 136907,
32
+ 55990,
33
+ // Huawei Cloud
34
+ 14061,
35
+ // DigitalOcean
36
+ 16276,
37
+ // OVH
38
+ 24940,
39
+ // Hetzner
40
+ 63949,
41
+ // Linode(Akamai Connected Cloud)
42
+ 20473,
43
+ // Vultr(Choopa)
44
+ 31898,
45
+ // Oracle Cloud
46
+ 51167,
47
+ // Contabo
48
+ 12876,
49
+ // Scaleway
50
+ 60781,
51
+ 28753,
52
+ // Leaseweb
53
+ 9009,
54
+ // M247
55
+ 212238,
56
+ // Datacamp
57
+ 202053,
58
+ 199524,
59
+ // G-Core
60
+ 46844,
61
+ // Sharktech
62
+ 36352,
63
+ // ColoCrossing
64
+ 62567
65
+ // DigitalOcean(추가 대역)
66
+ ]);
67
+ var DATACENTER_ORG_RE = /amazon|\baws\b|google cloud|microsoft|azure|alibaba|aliyun|tencent|huawei cloud|digitalocean|ovh|hetzner|linode|vultr|choopa|oracle|contabo|scaleway|leaseweb|m247|datacamp|g-core|colocrossing|hostinger|ionos|kamatera|psychz|quadranet/i;
68
+ function isDatacenterNetwork(input) {
69
+ if (typeof input.asn === "number" && DATACENTER_ASNS.has(input.asn)) return true;
70
+ return Boolean(input.asOrganization && DATACENTER_ORG_RE.test(input.asOrganization));
71
+ }
72
+ var BOT_UA_RULES = [
73
+ ["ai", /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|PerplexityBot|Perplexity-User|Bytespider|Amazonbot|CCBot|Google-Extended|GoogleOther|Meta-ExternalAgent|cohere-ai|YouBot|MistralAI-User/i],
74
+ ["search", /Googlebot|Google-InspectionTool|Storebot-Google|bingbot|Yeti\/|Daum(?:oa)?\/|Daumoa|Applebot|DuckDuckBot|Baiduspider|YandexBot|Yahoo! Slurp|SeznamBot|NaverBot/i],
75
+ ["seo", /AhrefsBot|AhrefsSiteAudit|SemrushBot|SiteAuditBot|MJ12bot|DotBot|rogerbot|Screaming Frog|Sitebulb|DataForSeoBot|serpstatbot|BLEXBot|Lighthouse|Chrome-Lighthouse|PageSpeed|GTmetrix/i],
76
+ ["preview", /facebookexternalhit|Facebot|Twitterbot|Slackbot|LinkedInBot|Discordbot|TelegramBot|WhatsApp|kakaotalk-scrap|Embedly|bingpreview|quora link|Pinterestbot|redditbot|SkypeUriPreview/i],
77
+ ["automation", /headless|phantomjs|puppeteer|playwright|selenium|python-requests|python-urllib|aiohttp|httpx|curl\/|wget|axios|node-fetch|undici|go-http|okhttp|java\/|libwww|scrapy/i],
78
+ // 이름을 몰라도 스스로 수집기라 밝힌 UA.
79
+ ["automation", /(?<!cu)bot\b|bot\/|crawl|spider|slurp|scraper/i]
80
+ ];
81
+ function isBotKind(value) {
82
+ return BOT_KINDS.includes(value);
83
+ }
84
+ function classifyBot(input) {
85
+ const ua = input.userAgent?.trim() ?? "";
86
+ if (!ua) return "automation";
87
+ for (const [kind, pattern] of BOT_UA_RULES) {
88
+ if (pattern.test(ua)) return kind;
89
+ }
90
+ if (input.webdriver) return "automation";
91
+ return isDatacenterNetwork(input) ? "datacenter" : null;
92
+ }
93
+ export {
94
+ BOT_KINDS,
95
+ BOT_KIND_LABEL,
96
+ BOT_PAGEVIEW_EVENT,
97
+ classifyBot,
98
+ isBotKind,
99
+ isDatacenterNetwork
100
+ };
101
+ //# sourceMappingURL=bots.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bots.ts"],"sourcesContent":["/**\n * 자동 방문(봇) 분류 — `/v1/collect` 가 요청 UA·비콘 신호로 판정하고, 사람\n * 방문 통계와 섞이지 않게 `bot_pageview` 이벤트로 따로 적재한다(AE blob6 = 종류).\n * 관리자 화면은 같은 사전의 라벨로 종류별 횟수를 보여 준다.\n *\n * UA 원문은 판정에만 쓰고 저장하지 않는다(ADR-0026 §5). 브라우저·Workers 양쪽에서\n * import 하므로 런타임 의존이 없어야 한다(entry-channels.ts 와 같은 제약).\n */\n\n/** 봇 pageview 이벤트 이름(AE blob1) — 수집 서버와 관리자 자동 방문 집계의 적재 계약. */\nexport const BOT_PAGEVIEW_EVENT = \"bot_pageview\";\n\nexport const BOT_KINDS = [\"search\", \"ai\", \"seo\", \"preview\", \"automation\", \"datacenter\"] as const;\n\nexport type BotKind = (typeof BOT_KINDS)[number];\n\nexport const BOT_KIND_LABEL: Readonly<Record<BotKind, string>> = {\n search: \"검색엔진 수집\",\n ai: \"AI 수집\",\n seo: \"SEO 점검 도구\",\n preview: \"링크 미리보기\",\n automation: \"자동화 브라우저\",\n datacenter: \"데이터센터 접속\",\n};\n\n/**\n * 클라우드·호스팅 사업자 망(ASN). 사람은 집·회사·통신사 망에서 들어오고,\n * 이 망에서 사람 브라우저 UA 로 들어오는 조회는 위장 수집기다(2026-09 실측:\n * 싱가포르·Council Bluffs·Ashburn 발 영어 PC direct 가 방문자당 1회씩 수백 건).\n *\n * iCloud 비공개 릴레이·WARP 가 쓰는 Akamai·Cloudflare·Fastly 는 실제 사람이라\n * 넣지 않는다. 한국 통신사·KT/LG/SK 망도 넣지 않는다.\n */\nconst DATACENTER_ASNS: ReadonlySet<number> = new Set([\n 16509, 14618, 8987, // Amazon AWS\n 396982, 15169, 19527, // Google Cloud·Google\n 8075, 8068, 8069, // Microsoft Azure\n 45102, 37963, // Alibaba Cloud\n 132203, 45090, // Tencent Cloud\n 136907, 55990, // Huawei Cloud\n 14061, // DigitalOcean\n 16276, // OVH\n 24940, // Hetzner\n 63949, // Linode(Akamai Connected Cloud)\n 20473, // Vultr(Choopa)\n 31898, // Oracle Cloud\n 51167, // Contabo\n 12876, // Scaleway\n 60781, 28753, // Leaseweb\n 9009, // M247\n 212238, // Datacamp\n 202053, 199524, // G-Core\n 46844, // Sharktech\n 36352, // ColoCrossing\n 62567, // DigitalOcean(추가 대역)\n]);\n\nconst DATACENTER_ORG_RE =\n /amazon|\\baws\\b|google cloud|microsoft|azure|alibaba|aliyun|tencent|huawei cloud|digitalocean|ovh|hetzner|linode|vultr|choopa|oracle|contabo|scaleway|leaseweb|m247|datacamp|g-core|colocrossing|hostinger|ionos|kamatera|psychz|quadranet/i;\n\n/** Cloudflare 가 요청에 얹는 ASN·사업자 이름으로 데이터센터 망인지 본다. */\nexport function isDatacenterNetwork(input: {\n readonly asn?: number | null;\n readonly asOrganization?: string | null;\n}): boolean {\n if (typeof input.asn === \"number\" && DATACENTER_ASNS.has(input.asn)) return true;\n return Boolean(input.asOrganization && DATACENTER_ORG_RE.test(input.asOrganization));\n}\n\n/** 순서가 곧 우선순위 — 이름이 분명한 수집기를 일반 규칙보다 먼저 본다. */\nconst BOT_UA_RULES: readonly (readonly [BotKind, RegExp])[] = [\n [\"ai\", /GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|anthropic-ai|PerplexityBot|Perplexity-User|Bytespider|Amazonbot|CCBot|Google-Extended|GoogleOther|Meta-ExternalAgent|cohere-ai|YouBot|MistralAI-User/i],\n [\"search\", /Googlebot|Google-InspectionTool|Storebot-Google|bingbot|Yeti\\/|Daum(?:oa)?\\/|Daumoa|Applebot|DuckDuckBot|Baiduspider|YandexBot|Yahoo! Slurp|SeznamBot|NaverBot/i],\n [\"seo\", /AhrefsBot|AhrefsSiteAudit|SemrushBot|SiteAuditBot|MJ12bot|DotBot|rogerbot|Screaming Frog|Sitebulb|DataForSeoBot|serpstatbot|BLEXBot|Lighthouse|Chrome-Lighthouse|PageSpeed|GTmetrix/i],\n [\"preview\", /facebookexternalhit|Facebot|Twitterbot|Slackbot|LinkedInBot|Discordbot|TelegramBot|WhatsApp|kakaotalk-scrap|Embedly|bingpreview|quora link|Pinterestbot|redditbot|SkypeUriPreview/i],\n [\"automation\", /headless|phantomjs|puppeteer|playwright|selenium|python-requests|python-urllib|aiohttp|httpx|curl\\/|wget|axios|node-fetch|undici|go-http|okhttp|java\\/|libwww|scrapy/i],\n // 이름을 몰라도 스스로 수집기라 밝힌 UA.\n [\"automation\", /(?<!cu)bot\\b|bot\\/|crawl|spider|slurp|scraper/i],\n];\n\nexport function isBotKind(value: string): value is BotKind {\n return (BOT_KINDS as readonly string[]).includes(value);\n}\n\n/**\n * 자동 방문 종류. 사람 방문이면 null.\n * `webdriver` 는 비콘이 보낸 `navigator.webdriver` 신호(자동화 도구가 켠 브라우저).\n * 스스로 밝힌 봇 → 자동화 신호 → 데이터센터 망 순으로 본다.\n */\nexport function classifyBot(input: {\n readonly userAgent: string | null | undefined;\n readonly webdriver?: boolean;\n readonly asn?: number | null;\n readonly asOrganization?: string | null;\n}): BotKind | null {\n const ua = input.userAgent?.trim() ?? \"\";\n if (!ua) return \"automation\";\n for (const [kind, pattern] of BOT_UA_RULES) {\n if (pattern.test(ua)) return kind;\n }\n if (input.webdriver) return \"automation\";\n return isDatacenterNetwork(input) ? \"datacenter\" : null;\n}\n"],"mappings":";AAUO,IAAM,qBAAqB;AAE3B,IAAM,YAAY,CAAC,UAAU,MAAM,OAAO,WAAW,cAAc,YAAY;AAI/E,IAAM,iBAAoD;AAAA,EAC/D,QAAQ;AAAA,EACR,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,YAAY;AACd;AAUA,IAAM,kBAAuC,oBAAI,IAAI;AAAA,EACnD;AAAA,EAAO;AAAA,EAAO;AAAA;AAAA,EACd;AAAA,EAAQ;AAAA,EAAO;AAAA;AAAA,EACf;AAAA,EAAM;AAAA,EAAM;AAAA;AAAA,EACZ;AAAA,EAAO;AAAA;AAAA,EACP;AAAA,EAAQ;AAAA;AAAA,EACR;AAAA,EAAQ;AAAA;AAAA,EACR;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EAAO;AAAA;AAAA,EACP;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EAAQ;AAAA;AAAA,EACR;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,CAAC;AAED,IAAM,oBACJ;AAGK,SAAS,oBAAoB,OAGxB;AACV,MAAI,OAAO,MAAM,QAAQ,YAAY,gBAAgB,IAAI,MAAM,GAAG,EAAG,QAAO;AAC5E,SAAO,QAAQ,MAAM,kBAAkB,kBAAkB,KAAK,MAAM,cAAc,CAAC;AACrF;AAGA,IAAM,eAAwD;AAAA,EAC5D,CAAC,MAAM,gOAAgO;AAAA,EACvO,CAAC,UAAU,iKAAiK;AAAA,EAC5K,CAAC,OAAO,sLAAsL;AAAA,EAC9L,CAAC,WAAW,oLAAoL;AAAA,EAChM,CAAC,cAAc,uKAAuK;AAAA;AAAA,EAEtL,CAAC,cAAc,gDAAgD;AACjE;AAEO,SAAS,UAAU,OAAiC;AACzD,SAAQ,UAAgC,SAAS,KAAK;AACxD;AAOO,SAAS,YAAY,OAKT;AACjB,QAAM,KAAK,MAAM,WAAW,KAAK,KAAK;AACtC,MAAI,CAAC,GAAI,QAAO;AAChB,aAAW,CAAC,MAAM,OAAO,KAAK,cAAc;AAC1C,QAAI,QAAQ,KAAK,EAAE,EAAG,QAAO;AAAA,EAC/B;AACA,MAAI,MAAM,UAAW,QAAO;AAC5B,SAAO,oBAAoB,KAAK,IAAI,eAAe;AACrD;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  trackCustomEvent
3
- } from "./chunk-K3NVHH7K.js";
3
+ } from "./chunk-YCUCU247.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-GV4CWEWY.js.map
200
+ //# sourceMappingURL=chunk-GOLIQX2X.js.map
@@ -40,6 +40,21 @@ function classifyAiReferral(source) {
40
40
  const rule = HOST_RULES.find((candidate) => matchesHost(normalized, candidate.host));
41
41
  return rule?.id ?? null;
42
42
  }
43
+ function classifyAiUtmSource(source) {
44
+ const normalized = source?.trim().toLowerCase() ?? "";
45
+ if (!normalized) return null;
46
+ if (isAiReferralId(normalized)) return normalized;
47
+ if (normalized === "openai") return "chatgpt";
48
+ return classifyAiReferral(normalized);
49
+ }
50
+ function aiUtmSourceAliases() {
51
+ const aliases = Object.fromEntries(
52
+ AI_REFERRAL_IDS.map((id) => [id, [id]])
53
+ );
54
+ aliases.chatgpt.push("openai");
55
+ for (const rule of HOST_RULES) aliases[rule.id].push(rule.host);
56
+ return aliases;
57
+ }
43
58
  function summarizeAiReferrals(entries) {
44
59
  const totals = /* @__PURE__ */ new Map();
45
60
  for (const entry of entries) {
@@ -54,6 +69,8 @@ export {
54
69
  AI_REFERRAL_LABEL,
55
70
  aiReferralRulesLiteral,
56
71
  classifyAiReferral,
72
+ classifyAiUtmSource,
73
+ aiUtmSourceAliases,
57
74
  summarizeAiReferrals
58
75
  };
59
- //# sourceMappingURL=chunk-6NWJK7V7.js.map
76
+ //# sourceMappingURL=chunk-KKGWQGIR.js.map
@@ -0,0 +1 @@
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 */\n/**\n * utm_source 로 표시된 AI 챗봇 유입. ChatGPT·제미나이 등은 답변 속 링크에\n * `utm_source=chatgpt.com` 처럼 자기 이름을 붙이고 referrer 는 비우는 경우가 많다.\n * 호스트 표기(chatgpt.com)와 이름 표기(chatgpt·gemini)를 모두 받는다.\n */\nexport function classifyAiUtmSource(source: string | null | undefined): AiReferralId | null {\n const normalized = source?.trim().toLowerCase() ?? \"\";\n if (!normalized) return null;\n if (isAiReferralId(normalized)) return normalized;\n if (normalized === \"openai\") return \"chatgpt\";\n return classifyAiReferral(normalized);\n}\n\n/**\n * AI 챗봇별 utm_source 표기 — 이름(chatgpt)과 호스트(chatgpt.com) 모두.\n * 이미 적재된 기록을 조회 시점에 보정할 때 SQL 목록으로 쓴다.\n */\nexport function aiUtmSourceAliases(): Readonly<Record<AiReferralId, readonly string[]>> {\n const aliases = Object.fromEntries(\n AI_REFERRAL_IDS.map((id) => [id, [id] as string[]]),\n ) as Record<AiReferralId, string[]>;\n aliases.chatgpt.push(\"openai\");\n for (const rule of HOST_RULES) aliases[rule.id].push(rule.host);\n return aliases;\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;AAWO,SAAS,oBAAoB,QAAwD;AAC1F,QAAM,aAAa,QAAQ,KAAK,EAAE,YAAY,KAAK;AACnD,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,eAAe,UAAU,EAAG,QAAO;AACvC,MAAI,eAAe,SAAU,QAAO;AACpC,SAAO,mBAAmB,UAAU;AACtC;AAMO,SAAS,qBAAwE;AACtF,QAAM,UAAU,OAAO;AAAA,IACrB,gBAAgB,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAa,CAAC;AAAA,EACpD;AACA,UAAQ,QAAQ,KAAK,QAAQ;AAC7B,aAAW,QAAQ,WAAY,SAAQ,KAAK,EAAE,EAAE,KAAK,KAAK,IAAI;AAC9D,SAAO;AACT;AAEO,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":[]}
@@ -64,7 +64,18 @@ loaded[tag.provider]=true;
64
64
  if(tag.provider==='ga4'){
65
65
  var s=document.createElement('script');s.async=true;
66
66
  s.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);
67
- document.head.appendChild(s);gtag('js',new Date());gtag('config',tag.id);
67
+ document.head.appendChild(s);
68
+ /* GA4 \uAE30\uBCF8 \uC218\uC9D1\uC740 %\uC778\uCF54\uB529\uB41C location.href \uB97C page_location \uC73C\uB85C \uC2E4\uC5B4 \uBCF4\uACE0\uC11C\uC5D0\uC11C
69
+ \uD55C\uAE00 \uACBD\uB85C\uAC00 %EC%9A%B8... \uB85C \uAE68\uC9C4\uB2E4. decodeURI \uB85C \uD480\uC5B4(\uAD6C\uC870 \uBB38\uC790 /?#& \uC720\uC9C0,
70
+ \uC798\uBABB\uB41C \uC778\uCF54\uB529\uC774\uBA74 \uC6D0\uBB38 \uC720\uC9C0) 'set' \uC73C\uB85C \uBA85\uC2DC\uD558\uACE0 SPA \uC774\uB3D9(pushState/replaceState/
71
+ popstate)\uB9C8\uB2E4 \uAC31\uC2E0\uD55C\uB2E4. gtag.js \uBCF8\uCCB4\uB294 \uC774 \uB85C\uB354\uBCF4\uB2E4 \uB2A6\uAC8C history \uB97C \uBC14\uAE65\uC5D0\uC11C
72
+ \uAC10\uC2F8\uBBC0\uB85C, \uC5EC\uAE30 \uAC31\uC2E0\uC774 \uD5A5\uC0C1\uB41C \uCE21\uC815\uC758 \uC790\uB3D9 page_view \uBC1C\uD654\uBCF4\uB2E4 \uD56D\uC0C1 \uBA3C\uC800 \uC801\uC6A9\uB41C\uB2E4. */
73
+ var dec=function(u){try{return decodeURI(u)}catch(e){return u}};
74
+ var syncLoc=function(){gtag('set',{page_location:dec(location.href)});};
75
+ syncLoc();
76
+ ['pushState','replaceState'].forEach(function(m){var o=history[m];history[m]=function(){var r=o.apply(this,arguments);syncLoc();return r;};});
77
+ window.addEventListener('popstate',syncLoc);
78
+ gtag('js',new Date());gtag('config',tag.id);
68
79
  }else if(tag.provider==='clarity'){
69
80
  (function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
70
81
  var t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;
@@ -165,4 +176,4 @@ export {
165
176
  renderJourneyBridgeScript,
166
177
  readJourney
167
178
  };
168
- //# sourceMappingURL=chunk-EHXYDR7C.js.map
179
+ //# sourceMappingURL=chunk-KS5MJEOM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/consent.ts","../src/loader.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2 + Amend 4 — 동의 정책 + provider↔consent 매핑.\n//\n// Amend 4 (2026-08-19): 통계는 기본 허용, 광고는 기본 거부.\n// 한국법엔 유럽식 쿠키 배너(사전 옵트인) 의무가 없다 — 통계 목적 분석 도구는\n// 처리방침 고지로 충분하고, 동의가 실질 요구되는 건 행태정보를 **맞춤형\n// 광고**에 쓸 때부터다(개보위 2022 구글·메타 처분 영역). 따라서 분석\n// 카테고리를 fail-closed 로 잠그면 법이 요구하지 않는 배너 손실(무시층\n// 30~50%)만 남는다. 광고 카테고리는 그대로 fail-closed — 광고 도구는 동의\n// UI 없이는 영원히 발화하지 않는다(현재 광고 연동 계획 없음이 사업 결정).\n\nimport type { ConsentCategory, ConsentState, TagProvider } from \"./types\";\n\nexport const ALL_CONSENT_CATEGORIES: readonly ConsentCategory[] = [\n \"analytics_storage\",\n \"ad_storage\",\n \"ad_user_data\",\n \"ad_personalization\",\n] as const;\n\n/**\n * 방문자의 명시적 동의 선택 저장 키(localStorage) — 과거 배너가 저장한 값을\n * 로더가 계속 읽는다. **과거에 \"거부\"를 누른 방문자의 선택은 기본값 변경\n * 후에도 존중된다**(기본값은 선택 기록이 없는 방문자에게만 적용).\n */\nexport const CONSENT_STORAGE_KEY = \"rt_consent_v1\";\n\n/** 전부 denied — 광고 도구 게이트·테스트 기준값으로 유지. */\nexport const DENIED_CONSENT: ConsentState = {\n analytics_storage: \"denied\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/** 기본 동의(Amend 4) — 통계 granted · 광고 denied. */\nexport const DEFAULT_CONSENT: ConsentState = {\n analytics_storage: \"granted\",\n ad_storage: \"denied\",\n ad_user_data: \"denied\",\n ad_personalization: \"denied\",\n};\n\n/**\n * provider 가 발화하기 전 granted 여야 하는 동의 카테고리.\n * - analytics 도구(GA4·Clarity·Naver) → `analytics_storage`\n * - 광고 도구(Meta Pixel) → `ad_storage` + `ad_user_data` + `ad_personalization`\n * (국외이전·광고목적이라 동의 요건 가장 강함)\n */\nexport const PROVIDER_CONSENT: Record<TagProvider, ConsentCategory[]> = {\n ga4: [\"analytics_storage\"],\n clarity: [\"analytics_storage\"],\n naver: [\"analytics_storage\"],\n meta_pixel: [\"ad_storage\", \"ad_user_data\", \"ad_personalization\"],\n};\n\n/** required 카테고리가 *모두* granted 일 때만 true. */\nexport function isTagAllowed(\n required: readonly ConsentCategory[],\n consent: ConsentState,\n): boolean {\n return required.every((c) => consent[c] === \"granted\");\n}\n","// ADR-0038 §2·§4 + Amend 4 — 공유 주입 산출물. 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default =\n// 통계 granted · 광고 denied (Amend 4). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 초기\n// applyAll() 이 동의 충족 태그(분석 3종)를 즉시 주입하고, 광고 도구는\n// 동의 update(`RootTaleAnalytics.updateConsent`) 전까지 발화하지 않는다.\n// 과거 배너가 localStorage 에 남긴 명시적 선택(CONSENT_STORAGE_KEY)이\n// 있으면 기본값보다 우선한다 — 예전에 \"거부\"한 방문자는 계속 거부 상태.\n//\n// 설계(measurement-design-draft.md) §0 게이트 계약 — `RootTaleAnalytics.\n// isTagLoaded(provider)` 로 태그 로드 완료 상태를 노출한다(내부 `loaded` 맵\n// 그대로 read-only 조회). `ga4-bridge.ts` 가 GA4 미러 발화 전 \"동의 granted\n// 그리고 GA4 로드 완료\" 를 함께 확인하는 데 쓴다.\n\nimport { CONSENT_STORAGE_KEY } from \"./consent\";\nimport type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩 — 통계 granted · 광고 denied (Amend 4).\n * 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다. `wait_for_update` 는\n * 로더가 저장된 과거 선택을 적용할 짧은 창.\n */\nexport function renderConsentBootstrap(): string {\n return [\n \"window.dataLayer=window.dataLayer||[];\",\n \"function gtag(){dataLayer.push(arguments);}\",\n \"gtag('consent','default',{\",\n \"'ad_storage':'denied','ad_user_data':'denied',\",\n \"'ad_personalization':'denied','analytics_storage':'granted',\",\n \"'wait_for_update':500});\",\n ].join(\"\");\n}\n\n// 브라우저 런타임 — manifest 를 읽고 동의가 충족된 태그만 주입한다.\n// `__RT_MANIFEST__` 는 renderLoaderScript 가 JSON 으로 치환.\nconst RUNTIME = `(function(){\nvar M=__RT_MANIFEST__;\nvar consent=Object.assign({},M.consentDefault);\n/* 과거 배너가 저장한 명시적 선택이 있으면 기본값을 덮는다 — \"거부\"를 눌렀던\n 방문자는 기본값이 granted 로 바뀌어도 거부 상태를 유지한다(Amend 4). */\ntry{\nvar stored=JSON.parse(localStorage.getItem(__RT_CONSENT_KEY__)||'null');\nif(stored&&typeof stored==='object'){\nfor(var sk in stored){if(Object.prototype.hasOwnProperty.call(consent,sk))consent[sk]=stored[sk];}\nif(typeof gtag==='function'){gtag('consent','update',stored);}\n}\n}catch(e){}\nvar loaded={};\nfunction allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}\nfunction inject(tag){\nif(loaded[tag.provider]||!allowed(tag.requiredConsent))return;\nloaded[tag.provider]=true;\nif(tag.provider==='ga4'){\nvar s=document.createElement('script');s.async=true;\ns.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);\ndocument.head.appendChild(s);\n/* GA4 기본 수집은 %인코딩된 location.href 를 page_location 으로 실어 보고서에서\n 한글 경로가 %EC%9A%B8... 로 깨진다. decodeURI 로 풀어(구조 문자 /?#& 유지,\n 잘못된 인코딩이면 원문 유지) 'set' 으로 명시하고 SPA 이동(pushState/replaceState/\n popstate)마다 갱신한다. gtag.js 본체는 이 로더보다 늦게 history 를 바깥에서\n 감싸므로, 여기 갱신이 향상된 측정의 자동 page_view 발화보다 항상 먼저 적용된다. */\nvar dec=function(u){try{return decodeURI(u)}catch(e){return u}};\nvar syncLoc=function(){gtag('set',{page_location:dec(location.href)});};\nsyncLoc();\n['pushState','replaceState'].forEach(function(m){var o=history[m];history[m]=function(){var r=o.apply(this,arguments);syncLoc();return r;};});\nwindow.addEventListener('popstate',syncLoc);\ngtag('js',new Date());gtag('config',tag.id);\n}else if(tag.provider==='clarity'){\n(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\nvar t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;\nvar y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,'clarity','script',tag.id);\n}else if(tag.provider==='meta_pixel'){\n(function(f,b,e,v){if(f.fbq)return;var n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};\nif(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];var t=b.createElement(e);t.async=!0;\nt.src=v;var s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)})(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init',tag.id);fbq('track','PageView');\n}else if(tag.provider==='naver'){\nvar w=document.createElement('script');w.async=true;w.src='//wcs.naver.net/wcslog.js';\nw.onload=function(){if(window.wcs){window.wcs_add={wa:tag.id};if(window.wcs.inflow)window.wcs.inflow();if(window.wcs_do)window.wcs_do();}};\ndocument.head.appendChild(w);\n}}\nfunction applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}\nwindow.RootTaleAnalytics={\nmanifest:M,\ngetConsent:function(){return Object.assign({},consent);},\nisTagLoaded:function(provider){return !!loaded[provider];},\nupdateConsent:function(partial){\nfor(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}\nif(typeof gtag==='function'){gtag('consent','update',partial);}\napplyAll();\n}};\napplyAll();\n})();`;\n\n/**\n * manifest 를 embed 한 로더 스크립트. 초기 `applyAll()` 이 동의 충족 태그\n * (기본값에선 분석 도구)를 즉시 주입한다. 광고 도구는 `requiredConsent` 의\n * ad_* 카테고리가 기본 denied 라 `updateConsent` 없이는 절대 붙지 않는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return (\n \"var __RT_MANIFEST__=\" +\n JSON.stringify(manifest) +\n \";var __RT_CONSENT_KEY__=\" +\n JSON.stringify(CONSENT_STORAGE_KEY) +\n \";\" +\n RUNTIME\n );\n}\n","// 설계(measurement-design-draft.md) §0(ADR-0038 amend) — GA4 미러 브리지.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtGa4Mirror` 전역 훅이\n// 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그 훅을\n// 설치할 뿐 beacon 을 직접 건드리지 않는다 — 순서 무관하게 안전(훅이 아직\n// 없을 때 이벤트가 나면 그냥 스킵, 다음 이벤트부터 미러링).\n//\n// 게이트(설계 §0): events.ts `GA4_MIRROR` 에 등록된 이벤트 + 동의\n// `analytics_storage==='granted'` + GA4 태그 로드 완료(`isTagLoaded('ga4')`) +\n// `typeof gtag==='function'` **전부** 참일 때만 `gtag('event', ga4Name, params)`.\n// 하나라도 불충족이면 그냥 버린다 — dataLayer 에 선큐잉해 동의 이후 소급\n// 전송하지 않는다(큐·재생 금지). 확정 전환 수는 어디까지나 inquiries DB —\n// 본 미러는 광고 생태계용 근사 신호일 뿐이다(설계 §0 SSoT 계약).\n\nimport { GA4_MIRROR } from \"./events\";\n\nconst RUNTIME = `(function(){\nvar MIRROR=__RT_GA4_MIRROR__;\nwindow.__rtGa4Mirror=function(ev,extra){\nvar name=MIRROR[ev];\nif(!name)return;\nvar RA=window.RootTaleAnalytics;\nif(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;\nif(RA.getConsent().analytics_storage!=='granted')return;\nif(!RA.isTagLoaded('ga4'))return;\nif(typeof gtag!=='function')return;\ngtag('event',name,extra||{});\n};\n})();`;\n\n/**\n * GA4 미러 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와 함께\n * (순서 무관) 주입한다. `enableGa4Bridge={false}` 로 아예 생략 가능(react.tsx).\n */\nexport function renderGa4BridgeScript(): string {\n return \"var __RT_GA4_MIRROR__=\" + JSON.stringify(GA4_MIRROR) + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 GA4 미러 훅(설치돼 있을 때만). */\n __rtGa4Mirror?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n","// 설계(measurement-design-draft.md) §3 — 방문 여정(journey) 링버퍼.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtJourneyRecord` 전역\n// 훅이 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그\n// 훅을 설치할 뿐이다 — `events.ts` `TRACK_EVENTS[ev].journey` 가 true 인\n// 이벤트(pageview + 전환 사다리)만 sessionStorage `_rt_journey` 링버퍼(최대\n// 30항목)에 적재한다. scroll_depth·section_view 등 고빈도 감지 이벤트는\n// journey:false 라 기록되지 않는다.\n//\n// 서버측 계약(설계 §3, 본 모듈 범위 밖): vertical(medical/legal) 게이트는\n// 서버가 재강제하고, 저장은 `inquiries.journey` jsonb 컬럼 + zod 재구성 +\n// 180일 보존이다 — 클라이언트 캡처는 신뢰의 원천이 아니다.\n\nimport { TRACK_EVENTS, type TrackEventName } from \"./events\";\n\n/** sessionStorage 키 — cms-client/renderer 와의 공유 계약(변경 시 양쪽 동시 갱신). */\nexport const JOURNEY_STORAGE_KEY = \"_rt_journey\";\n\n/** 링버퍼 최대 항목 수(변경 시 RUNTIME 내 하드코딩 리터럴도 동시 갱신). */\nexport const JOURNEY_MAX_ITEMS = 30;\n\n/** `_rt_journey` 항목 형태. */\nexport interface JourneyEntry {\n /** 캡처 시각 (epoch ms). */\n t: number;\n /** 이벤트 이름 (events.ts 사전). */\n ev: string;\n /** 발생 경로 (pathname). vertical 게이트 대상은 서버에서 제거될 수 있음. */\n path?: string;\n /** 소형 차원 데이터 (data-track-* 로 캡처된 extra). */\n d?: Record<string, unknown>;\n}\n\n/** journey:true 이벤트 이름 목록 — events.ts SSoT 에서 파생. */\nconst JOURNEY_EVENT_NAMES: TrackEventName[] = (\n Object.keys(TRACK_EVENTS) as TrackEventName[]\n).filter((name) => TRACK_EVENTS[name].journey);\n\nconst RUNTIME = `(function(){\nvar JE=__RT_JOURNEY_EVENTS__;\nwindow.__rtJourneyRecord=function(ev,extra){\nif(!JE||JE.indexOf(ev)===-1)return;\nvar KEY='_rt_journey';\nvar arr=null;\ntry{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}\nif(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];\nvar item={t:Date.now(),ev:ev};\ntry{if(location&&location.pathname)item.path=location.pathname;}catch(e){}\nif(extra)item.d=extra;\narr.push(item);\nif(arr.length>30)arr=arr.slice(arr.length-30);\nsessionStorage.setItem(KEY,JSON.stringify(arr));\n};\n})();`;\n\n/**\n * journey 기록 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와\n * 함께(순서 무관) 항상 주입한다 — 1차 캡처 인프라라 opt-out 프롭 없음(ga4\n * 미러와 달리).\n */\nexport function renderJourneyBridgeScript(): string {\n return (\n \"var __RT_JOURNEY_EVENTS__=\" +\n JSON.stringify(JOURNEY_EVENT_NAMES) +\n \";\" +\n RUNTIME\n );\n}\n\n/**\n * sessionStorage 의 journey 링버퍼를 읽는다(브라우저 전용). 파싱 실패·미존재\n * 시 빈 배열. cms-client/renderer 가 문의 제출 시 이 값을 재직렬화해 동봉한다\n * (submit capture/`formdata` 시점 — 설계 §3 P1-3, hidden 스냅샷 아님).\n */\nexport function readJourney(): JourneyEntry[] {\n if (typeof window === \"undefined\" || typeof sessionStorage === \"undefined\") {\n return [];\n }\n try {\n const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);\n if (!raw) return [];\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? (parsed as JourneyEntry[]) : [];\n } catch {\n return [];\n }\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 journey 기록 훅(설치돼 있을 때만). */\n __rtJourneyRecord?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n"],"mappings":";;;;;;AAYO,IAAM,yBAAqD;AAAA,EAChE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,IAAM,sBAAsB;AAG5B,IAAM,iBAA+B;AAAA,EAC1C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAGO,IAAM,kBAAgC;AAAA,EAC3C,mBAAmB;AAAA,EACnB,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,oBAAoB;AACtB;AAQO,IAAM,mBAA2D;AAAA,EACtE,KAAK,CAAC,mBAAmB;AAAA,EACzB,SAAS,CAAC,mBAAmB;AAAA,EAC7B,OAAO,CAAC,mBAAmB;AAAA,EAC3B,YAAY,CAAC,cAAc,gBAAgB,oBAAoB;AACjE;AAGO,SAAS,aACd,UACA,SACS;AACT,SAAO,SAAS,MAAM,CAAC,MAAM,QAAQ,CAAC,MAAM,SAAS;AACvD;;;ACrCO,SAAS,yBAAiC;AAC/C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAIA,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgET,SAAS,mBAAmB,UAAqC;AACtE,SACE,yBACA,KAAK,UAAU,QAAQ,IACvB,6BACA,KAAK,UAAU,mBAAmB,IAClC,MACA;AAEJ;;;AC9FA,IAAMA,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBT,SAAS,wBAAgC;AAC9C,SAAO,2BAA2B,KAAK,UAAU,UAAU,IAAI,MAAMA;AACvE;;;ACpBO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAejC,IAAM,sBACJ,OAAO,KAAK,YAAY,EACxB,OAAO,CAAC,SAAS,aAAa,IAAI,EAAE,OAAO;AAE7C,IAAMC,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBT,SAAS,4BAAoC;AAClD,SACE,+BACA,KAAK,UAAU,mBAAmB,IAClC,MACAA;AAEJ;AAOO,SAAS,cAA8B;AAC5C,MAAI,OAAO,WAAW,eAAe,OAAO,mBAAmB,aAAa;AAC1E,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,mBAAmB;AACtD,QAAI,CAAC,IAAK,QAAO,CAAC;AAClB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,MAAM,QAAQ,MAAM,IAAK,SAA4B,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":["RUNTIME","RUNTIME"]}
@@ -0,0 +1,155 @@
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: "\uCD9C\uCC98 \uC815\uBCF4 \uC5C6\uC74C",
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: "threads.com" },
49
+ { host: "twitter.com" },
50
+ { host: "x.com" },
51
+ { host: "t.co", exact: true },
52
+ { host: "linkedin.com" },
53
+ { host: "youtube.com" },
54
+ { host: "youtu.be", exact: true },
55
+ { host: "tiktok.com" },
56
+ { host: "pinterest.com" },
57
+ { host: "band.us" },
58
+ { host: "story.kakao.com" },
59
+ { host: "pf.kakao.com" },
60
+ { host: "cafe.naver.com" },
61
+ { host: "cafe.daum.net" }
62
+ ];
63
+ var OWNER_TOOL_HOSTS = [
64
+ { host: "search.google.com", exact: true },
65
+ { host: "searchadvisor.naver.com", exact: true },
66
+ { host: "webmastertool.daum.net", exact: true }
67
+ ];
68
+ var ANDROID_APP_HOSTS = {
69
+ "com.google.android.googlequicksearchbox": "google.com",
70
+ "com.google.android.gm": "mail.google.com",
71
+ "com.google.android.youtube": "youtube.com",
72
+ "com.nhn.android.search": "m.search.naver.com",
73
+ "com.nhn.android.blog": "m.blog.naver.com",
74
+ "com.nhn.android.nmap": "m.map.naver.com",
75
+ "com.kakao.talk": "kakao.com",
76
+ "com.instagram.android": "instagram.com",
77
+ "com.facebook.katana": "facebook.com",
78
+ "net.daum.android.daum": "m.daum.net"
79
+ };
80
+ function normalizeReferrerHost(host) {
81
+ const normalized = host?.trim().toLowerCase().replace(/^www\./, "") ?? "";
82
+ return ANDROID_APP_HOSTS[normalized] ?? normalized;
83
+ }
84
+ function isOwnerToolReferrer(host) {
85
+ const normalized = normalizeReferrerHost(host);
86
+ return Boolean(normalized) && matchesRules(normalized, OWNER_TOOL_HOSTS);
87
+ }
88
+ function matchesRules(host, rules) {
89
+ for (const rule of rules) {
90
+ if (host === rule.host) return true;
91
+ if (!rule.exact && host.endsWith(`.${rule.host}`)) return true;
92
+ }
93
+ return false;
94
+ }
95
+ var PAID_MEDIUM_RE = /^(cpc|ppc|cpm|cpv|cpa|display|banner|paid[\w-]*|retargeting|remarketing)$/i;
96
+ var SOCIAL_MEDIUM_RE = /^(social|sns|social[-_]media)$/i;
97
+ function classifyEntryChannel(signals) {
98
+ const host = normalizeReferrerHost(signals.referrerHost) || null;
99
+ if (host && matchesRules(host, OWNER_TOOL_HOSTS)) return null;
100
+ const medium = signals.utmMedium?.trim() || null;
101
+ const hasUtm = Boolean(
102
+ signals.utmSource?.trim() || medium || signals.utmCampaign?.trim()
103
+ );
104
+ if (signals.aiReferralId) return "ai";
105
+ if (signals.rtSrc?.trim()) return "shortlink";
106
+ if (signals.hasAdClick || medium && PAID_MEDIUM_RE.test(medium)) {
107
+ return "paid";
108
+ }
109
+ if (medium && SOCIAL_MEDIUM_RE.test(medium)) return "social";
110
+ if (host) {
111
+ if (matchesRules(host, SEARCH_HOSTS)) return "search";
112
+ if (matchesRules(host, SOCIAL_HOSTS)) return "social";
113
+ }
114
+ if (hasUtm) return "other";
115
+ if (host) return "referral";
116
+ if (signals.hasReferrer) return null;
117
+ return "direct";
118
+ }
119
+ var IN_APP_BROWSERS = [
120
+ { id: "kakaotalk", label: "\uCE74\uCE74\uC624\uD1A1", pattern: /KAKAOTALK/i, channel: "social" },
121
+ { id: "kakaostory", label: "\uCE74\uCE74\uC624\uC2A4\uD1A0\uB9AC", pattern: /KAKAOSTORY/i, channel: "social" },
122
+ { id: "instagram", label: "\uC778\uC2A4\uD0C0\uADF8\uB7A8", pattern: /Instagram/, channel: "social" },
123
+ { id: "facebook", label: "\uD398\uC774\uC2A4\uBD81", pattern: /FBAN|FBAV|FB_IAB/, channel: "social" },
124
+ { id: "threads", label: "\uC2A4\uB808\uB4DC", pattern: /Barcelona/, channel: "social" },
125
+ { id: "line", label: "\uB77C\uC778", pattern: /\bLine\//, channel: "social" },
126
+ { id: "band", label: "\uBC34\uB4DC", pattern: /\bBAND\//, channel: "social" },
127
+ { id: "tiktok", label: "\uD2F1\uD1A1", pattern: /musical_ly|BytedanceWebview|TikTok/, channel: "social" },
128
+ { id: "everytime", label: "\uC5D0\uBE0C\uB9AC\uD0C0\uC784", pattern: /everytimeApp/i, channel: "social" },
129
+ { id: "naver", label: "\uB124\uC774\uBC84 \uC571", pattern: /NAVER\(inapp/, channel: "referral" },
130
+ { id: "daum", label: "\uB2E4\uC74C \uC571", pattern: /DaumApps/, channel: "referral" }
131
+ ];
132
+ var IN_APP_SOURCE_PREFIX = "app:";
133
+ function inAppBrowserFromUserAgent(userAgent) {
134
+ if (!userAgent) return null;
135
+ return IN_APP_BROWSERS.find((app) => app.pattern.test(userAgent)) ?? null;
136
+ }
137
+ function inAppBrowserFromSource(source) {
138
+ if (!source?.startsWith(IN_APP_SOURCE_PREFIX)) return null;
139
+ const id = source.slice(IN_APP_SOURCE_PREFIX.length);
140
+ return IN_APP_BROWSERS.find((app) => app.id === id) ?? null;
141
+ }
142
+
143
+ export {
144
+ ENTRY_CHANNEL_IDS,
145
+ isEntryChannelId,
146
+ ENTRY_CHANNEL_LABEL,
147
+ normalizeReferrerHost,
148
+ isOwnerToolReferrer,
149
+ classifyEntryChannel,
150
+ IN_APP_BROWSERS,
151
+ IN_APP_SOURCE_PREFIX,
152
+ inAppBrowserFromUserAgent,
153
+ inAppBrowserFromSource
154
+ };
155
+ //# sourceMappingURL=chunk-MXOJW2UE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/entry-channels.ts"],"sourcesContent":["/**\n * 유입 경로(entry channel) 분류 — ROOT-ANALYTICS 자체 수집의 \"어디서 왔나\".\n *\n * 비콘은 신호(외부 referrer **호스트명**·utm·광고클릭 유무·rt_src·AI 라벨)만\n * 보내고, 판정은 서버(`api-core /v1/collect`)가 이 모듈로 한다 — 판정 목록을\n * 고치면 워커 배포만으로 전 사이트에 반영된다(고객 사이트 재배포 불필요).\n *\n * Privacy(ADR-0038 §2 동의 면제 유지): 입력이 이미 비식별이다 — referrer 는\n * 경로·쿼리 없는 호스트명뿐, gclid/fbclid 는 존재 여부 불리언뿐(원값 미수신),\n * utm 은 고객이 자기 캠페인 링크에 직접 붙인 라벨이다. 출력은 고정 사전의\n * id 하나라 임의 문자열이 차원에 들어갈 수 없다.\n *\n * GA4 화면의 6묶음(search/direct/referral/social/paid/other)과 말을 맞추되,\n * 자체 수집만 아는 두 가지를 더 가른다 — `ai`(챗봇 유입, ai-referrals.ts 와\n * 동일 사전) · `shortlink`(roottale.link 단축링크/QR 의 rt_src).\n *\n * 이 파일은 브라우저 비콘 생성기·서버 양쪽에서 참조하므로 런타임 의존이\n * 없어야 한다(ai-referrals.ts 와 동일 제약).\n */\n\nexport const ENTRY_CHANNEL_IDS = [\n \"search\",\n \"direct\",\n \"referral\",\n \"social\",\n \"paid\",\n \"ai\",\n \"shortlink\",\n \"other\",\n] as const;\n\nexport type EntryChannelId = (typeof ENTRY_CHANNEL_IDS)[number];\n\n/** 수집 라우트·어드민 조회의 입력 검증 — 사전 밖 문자열 차단. */\nexport function isEntryChannelId(value: string): value is EntryChannelId {\n return (ENTRY_CHANNEL_IDS as readonly string[]).includes(value);\n}\n\n/** 고객 화면 라벨 — GA4 6묶음과 같은 말을 쓴다(두 화면이 다르게 부르지 않게). */\nexport const ENTRY_CHANNEL_LABEL: Record<EntryChannelId, string> = {\n search: \"검색\",\n direct: \"출처 정보 없음\",\n referral: \"다른 사이트 링크\",\n social: \"소셜 미디어\",\n paid: \"광고\",\n ai: \"AI 챗봇\",\n shortlink: \"단축링크·QR\",\n other: \"기타 캠페인\",\n};\n\n/**\n * 호스트 규칙 — 기본은 정확 일치 또는 서브도메인(`.` 접미) 일치.\n * `exact` 는 정확 일치만: `naver.com` 을 접미 매칭하면 `blog.naver.com`(블로그\n * = 다른 사이트 링크)까지 검색으로 잘못 묶인다.\n */\ntype HostRule = { readonly host: string; readonly exact?: true };\n\nconst SEARCH_HOSTS: readonly HostRule[] = [\n { host: \"google.com\" },\n { host: \"google.co.kr\" },\n { host: \"search.naver.com\" },\n { host: \"naver.com\", exact: true },\n { host: \"m.naver.com\", exact: true },\n { host: \"search.daum.net\" },\n { host: \"daum.net\", exact: true },\n { host: \"m.daum.net\", exact: true },\n { host: \"bing.com\" },\n { host: \"duckduckgo.com\" },\n { host: \"search.yahoo.com\" },\n { host: \"yandex.com\" },\n { host: \"baidu.com\" },\n { host: \"search.zum.com\" },\n { host: \"zum.com\", exact: true },\n { host: \"search.nate.com\" },\n { host: \"nate.com\", exact: true },\n];\n\nconst SOCIAL_HOSTS: readonly HostRule[] = [\n { host: \"facebook.com\" },\n { host: \"instagram.com\" },\n { host: \"threads.net\" },\n { host: \"threads.com\" },\n { host: \"twitter.com\" },\n { host: \"x.com\" },\n { host: \"t.co\", exact: true },\n { host: \"linkedin.com\" },\n { host: \"youtube.com\" },\n { host: \"youtu.be\", exact: true },\n { host: \"tiktok.com\" },\n { host: \"pinterest.com\" },\n { host: \"band.us\" },\n { host: \"story.kakao.com\" },\n { host: \"pf.kakao.com\" },\n { host: \"cafe.naver.com\" },\n { host: \"cafe.daum.net\" },\n];\n\n/**\n * 사이트 운영 도구 — 검색 콘솔의 \"페이지 열기\"·URL 검사처럼 운영자가 여는\n * 조회라 방문 유입이 아니다.\n */\nconst OWNER_TOOL_HOSTS: readonly HostRule[] = [\n { host: \"search.google.com\", exact: true },\n { host: \"searchadvisor.naver.com\", exact: true },\n { host: \"webmastertool.daum.net\", exact: true },\n];\n\n/**\n * 안드로이드 앱이 남기는 referrer(`android-app://<패키지>/`)의 호스트는 패키지\n * 이름이다. 알려진 앱은 같은 서비스의 웹 호스트로 바꿔 같은 출처로 센다.\n */\nconst ANDROID_APP_HOSTS: Readonly<Record<string, string>> = {\n \"com.google.android.googlequicksearchbox\": \"google.com\",\n \"com.google.android.gm\": \"mail.google.com\",\n \"com.google.android.youtube\": \"youtube.com\",\n \"com.nhn.android.search\": \"m.search.naver.com\",\n \"com.nhn.android.blog\": \"m.blog.naver.com\",\n \"com.nhn.android.nmap\": \"m.map.naver.com\",\n \"com.kakao.talk\": \"kakao.com\",\n \"com.instagram.android\": \"instagram.com\",\n \"com.facebook.katana\": \"facebook.com\",\n \"net.daum.android.daum\": \"m.daum.net\",\n};\n\n/** referrer 호스트 정규화 — 소문자·www 제거·안드로이드 앱 패키지 → 웹 호스트. */\nexport function normalizeReferrerHost(host: string | null | undefined): string {\n const normalized = host?.trim().toLowerCase().replace(/^www\\./, \"\") ?? \"\";\n return ANDROID_APP_HOSTS[normalized] ?? normalized;\n}\n\n/** 검색 콘솔 등 운영 도구에서 연 조회인지 — 방문 시작으로도 세지 않는다. */\nexport function isOwnerToolReferrer(host: string | null | undefined): boolean {\n const normalized = normalizeReferrerHost(host);\n return Boolean(normalized) && matchesRules(normalized, OWNER_TOOL_HOSTS);\n}\n\n/** 부분 문자열 매칭 금지 — `naver.com.evil.com` 같은 위장 도메인 차단. */\nfunction matchesRules(host: string, rules: readonly HostRule[]): boolean {\n for (const rule of rules) {\n if (host === rule.host) return true;\n if (!rule.exact && host.endsWith(`.${rule.host}`)) return true;\n }\n return false;\n}\n\n/** utm_medium 유료 표기 — GA4 의 paid 판정 표기와 겹치는 관용 값들. */\nconst PAID_MEDIUM_RE =\n /^(cpc|ppc|cpm|cpv|cpa|display|banner|paid[\\w-]*|retargeting|remarketing)$/i;\n\nconst SOCIAL_MEDIUM_RE = /^(social|sns|social[-_]media)$/i;\n\nexport interface EntryChannelSignals {\n /** 외부 referrer 호스트명(소문자, www 제거) — 같은 호스트면 비콘이 생략. */\n readonly referrerHost?: string | null;\n readonly utmSource?: string | null;\n readonly utmMedium?: string | null;\n readonly utmCampaign?: string | null;\n /** gclid/fbclid 존재 여부 — 원값은 어디에도 없다. */\n readonly hasAdClick?: boolean;\n /** roottale.link 단축링크/QR 토큰(rt_src). */\n readonly rtSrc?: string | null;\n /** 비콘이 판정한 AI 챗봇 라벨(ai-referrals 사전 통과분). */\n readonly aiReferralId?: string | null;\n /** referrer 존재 여부(내부 이동 포함) — 비콘 `ref` 플래그. */\n readonly hasReferrer?: boolean;\n}\n\n/**\n * 한 pageview 의 유입 경로 판정. **`null` = 유입이 아님**(내부 이동 — 같은\n * 사이트 안에서 다음 페이지로 넘어온 조회). 집계에서 내부 이동을 빼야\n * \"채널별 유입\"이 실제 들어온 횟수를 뜻한다.\n *\n * 우선순위: ai > shortlink > paid > social(utm) > search(host) >\n * social(host) > other(utm만) > referral(그 외 외부 host) > 내부/직접.\n * utm_medium 명시가 host 추정보다 먼저다 — 고객이 직접 붙인 라벨이 더 정확.\n */\nexport function classifyEntryChannel(\n signals: EntryChannelSignals,\n): EntryChannelId | null {\n const host = normalizeReferrerHost(signals.referrerHost) || null;\n // 운영자가 검색 콘솔에서 연 조회 — 유입이 아니다.\n if (host && matchesRules(host, OWNER_TOOL_HOSTS)) return null;\n const medium = signals.utmMedium?.trim() || null;\n const hasUtm = Boolean(\n signals.utmSource?.trim() || medium || signals.utmCampaign?.trim(),\n );\n\n if (signals.aiReferralId) return \"ai\";\n if (signals.rtSrc?.trim()) return \"shortlink\";\n if (signals.hasAdClick || (medium && PAID_MEDIUM_RE.test(medium))) {\n return \"paid\";\n }\n if (medium && SOCIAL_MEDIUM_RE.test(medium)) return \"social\";\n if (host) {\n if (matchesRules(host, SEARCH_HOSTS)) return \"search\";\n if (matchesRules(host, SOCIAL_HOSTS)) return \"social\";\n }\n if (hasUtm) return \"other\"; // email·뉴스레터 등 — 라벨은 utm 원문으로 확인\n if (host) return \"referral\";\n // referrer 는 있는데 외부 호스트가 아니다 = 내부 이동 → 유입 아님.\n if (signals.hasReferrer) return null;\n return \"direct\";\n}\n\n/**\n * 앱 안 브라우저 — 카카오톡·밴드처럼 링크를 앱 안에서 열면 referrer 가 비는\n * 경우가 많다. referrer·utm 이 전혀 없을 때만 수집 서버가 요청 UA 에서 앱 이름을\n * 읽어 출처로 남긴다(UA 원문은 저장하지 않는다). 순서가 곧 우선순위다.\n *\n * `channel` 은 GA4 묶음과 같은 말: 메신저·SNS 는 social, 포털 앱은 referral.\n * 출처 칸(blob11)에는 호스트 대신 `app:<id>` 로 적재한다.\n */\nexport const IN_APP_BROWSERS = [\n { id: \"kakaotalk\", label: \"카카오톡\", pattern: /KAKAOTALK/i, channel: \"social\" },\n { id: \"kakaostory\", label: \"카카오스토리\", pattern: /KAKAOSTORY/i, channel: \"social\" },\n { id: \"instagram\", label: \"인스타그램\", pattern: /Instagram/, channel: \"social\" },\n { id: \"facebook\", label: \"페이스북\", pattern: /FBAN|FBAV|FB_IAB/, channel: \"social\" },\n { id: \"threads\", label: \"스레드\", pattern: /Barcelona/, channel: \"social\" },\n { id: \"line\", label: \"라인\", pattern: /\\bLine\\//, channel: \"social\" },\n { id: \"band\", label: \"밴드\", pattern: /\\bBAND\\//, channel: \"social\" },\n { id: \"tiktok\", label: \"틱톡\", pattern: /musical_ly|BytedanceWebview|TikTok/, channel: \"social\" },\n { id: \"everytime\", label: \"에브리타임\", pattern: /everytimeApp/i, channel: \"social\" },\n { id: \"naver\", label: \"네이버 앱\", pattern: /NAVER\\(inapp/, channel: \"referral\" },\n { id: \"daum\", label: \"다음 앱\", pattern: /DaumApps/, channel: \"referral\" },\n] as const satisfies readonly {\n readonly id: string;\n readonly label: string;\n readonly pattern: RegExp;\n readonly channel: EntryChannelId;\n}[];\n\nexport type InAppBrowserId = (typeof IN_APP_BROWSERS)[number][\"id\"];\n\n/** 출처 칸에 적재하는 앱 표기 접두사 — 실제 호스트명과 겹치지 않는다(`:` 불가). */\nexport const IN_APP_SOURCE_PREFIX = \"app:\";\n\nexport function inAppBrowserFromUserAgent(\n userAgent: string | null | undefined,\n): (typeof IN_APP_BROWSERS)[number] | null {\n if (!userAgent) return null;\n return IN_APP_BROWSERS.find((app) => app.pattern.test(userAgent)) ?? null;\n}\n\n/** 출처 칸 값(`app:<id>`) → 앱. 앱 표기가 아니거나 사전 밖이면 null. */\nexport function inAppBrowserFromSource(\n source: string | null | undefined,\n): (typeof IN_APP_BROWSERS)[number] | null {\n if (!source?.startsWith(IN_APP_SOURCE_PREFIX)) return null;\n const id = source.slice(IN_APP_SOURCE_PREFIX.length);\n return IN_APP_BROWSERS.find((app) => app.id === id) ?? null;\n}\n"],"mappings":"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","names":[]}
@@ -1,13 +1,34 @@
1
1
  import {
2
2
  aiReferralRulesLiteral
3
- } from "./chunk-6NWJK7V7.js";
3
+ } from "./chunk-KKGWQGIR.js";
4
4
 
5
5
  // src/beacon.ts
6
6
  var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
7
+ var BEACON_PROTOCOL_VERSION = 2;
8
+ var INTERNAL_TRAFFIC_STORAGE_KEY = "_rt_internal";
7
9
  var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
8
10
  var RUNTIME = `(function(){
9
11
  var C=__RT_BEACON__;
12
+ /* \uB0B4\uBD80 \uC811\uC18D \uC81C\uC678: ?rt_internal=1 \uB85C \uD55C \uBC88 \uC5F4\uBA74 \uC774 \uBE0C\uB77C\uC6B0\uC800\uB294 \uC218\uC9D1\uD558\uC9C0 \uC54A\uB294\uB2E4.
13
+ ?rt_internal=0 \uC73C\uB85C \uD574\uC81C\uD55C\uB2E4. \uD45C\uC2DC\uB9CC \uC800\uC7A5\uD558\uACE0 \uC2DD\uBCC4\uC790\uB294 \uB9CC\uB4E4\uC9C0 \uC54A\uB294\uB2E4. */
14
+ try{var _ri=new URLSearchParams(location.search).get('rt_internal');
15
+ if(_ri==='1')localStorage.setItem('${INTERNAL_TRAFFIC_STORAGE_KEY}','1');
16
+ else if(_ri==='0')localStorage.removeItem('${INTERNAL_TRAFFIC_STORAGE_KEY}');
17
+ if(localStorage.getItem('${INTERNAL_TRAFFIC_STORAGE_KEY}')==='1')return;}catch(e){}
10
18
  var AIR=${aiReferralRulesLiteral()};
19
+ /* \uC0AC\uC774\uD2B8\uBCC4 \uC775\uBA85 \uBC29\uBB38 \uC720\uD615: \uAC19\uC740 \uBE0C\uB77C\uC6B0\uC800\uC758 \uBAA8\uB4E0 \uD0ED\uC5D0\uC11C 30\uBD84 \uBE44\uD65C\uB3D9\uC774\uBA74 \uC0C8 \uBC29\uBB38,
20
+ \uCD5C\uADFC 30\uC77C \uAE30\uB85D\uC774 \uC5C6\uC73C\uBA74 \uCCAB \uBC29\uBB38. 1=\uCCAB \uBC29\uBB38 \uC2DC\uC791, 2=\uC7AC\uBC29\uBB38 \uC2DC\uC791, 0=\uBC29\uBB38 \uC911,
21
+ -1=\uC800\uC7A5\uC18C \uCC28\uB2E8(\uD310\uC815 \uBD88\uAC00). ID\uB97C \uB9CC\uB4E4\uAC70\uB098 \uC804\uC1A1\uD558\uC9C0 \uC54A\uB294\uB2E4. */
22
+ function visitType(){try{
23
+ var now=Date.now(),sk='_rt_visit_last_'+C.sid,lk='_rt_visit_seen_'+C.sid;
24
+ var last=Number(localStorage.getItem(sk)||0);
25
+ localStorage.setItem(sk,String(now));
26
+ if(last>0&&now>=last&&now-last<1800000)return 0;
27
+ var seen=Number(localStorage.getItem(lk)||0);
28
+ var result=seen>0&&now>=seen&&now-seen<2592e6?2:1;
29
+ localStorage.setItem(lk,String(now));
30
+ return result;
31
+ }catch(e){return -1;}}
11
32
  /* 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
33
  \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
34
  function aiRef(){try{
@@ -20,8 +41,11 @@ return null;
20
41
  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;}}
21
42
  function send(ev,extra){
22
43
  try{
23
- var p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};
44
+ var p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now(),bv:${BEACON_PROTOCOL_VERSION}};
45
+ try{if(navigator.webdriver)p.wd=1;}catch(e){}
24
46
  if(extra){for(var k in extra){p[k]=extra[k];}}
47
+ if(ev==='pageview'){var _vst=visitType();delete p.vst;
48
+ if(_vst>0){p.vst=_vst;p.vs=1;}else if(_vst<0)p.vu=1;}
25
49
  var body=JSON.stringify(p);
26
50
  if(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}
27
51
  else{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}
@@ -104,8 +128,18 @@ if(SIG.rh&&!_air)_x.rh=SIG.rh;
104
128
  if(SIG.us)_x.us=SIG.us;
105
129
  if(SIG.um)_x.um=SIG.um;
106
130
  if(SIG.uc)_x.uc=SIG.uc;
131
+ if(SIG.ut)_x.ut=SIG.ut;
132
+ if(SIG.un)_x.un=SIG.un;
107
133
  if(SIG.rs)_x.rs=SIG.rs;
108
134
  if(SIG.gc)_x.gc=1;
135
+ /* \uBE0C\uB77C\uC6B0\uC800 \uC0C8\uB85C\uACE0\uCE68\xB7\uBC29\uBB38 \uAE30\uB85D \uC774\uB3D9\uC740 \uC0C8 \uC678\uBD80 \uC720\uC785\uC73C\uB85C \uC138\uC9C0 \uC54A\uB294\uB2E4.
136
+ \uCD5C\uCD08 \uBB38\uC11C \uC870\uD68C\uC5D0\uB9CC \uB3D9\uBD09\uD55C\uB2E4. SPA pageview\uC5D0\uB294 \uC774\uC804 \uBB38\uC11C\uC758 \uC774\uB3D9 \uC720\uD615\uC744 \uC7AC\uC0AC\uC6A9\uD558\uC9C0 \uC54A\uB294\uB2E4. */
137
+ try{var _nav=performance.getEntriesByType('navigation')[0];
138
+ if(_nav&&_nav.type==='reload')_x.nv=1;
139
+ else if(_nav&&_nav.type==='back_forward')_x.nv=2;}catch(e){}
140
+ /* \uC0C8 \uC678\uBD80 \uC2E0\uD638(referrer\xB7utm\xB7\uAD11\uACE0\xB7\uB2E8\uCD95\uB9C1\uD06C\xB7AI)\uB85C \uC5F0 \uBB38\uC11C\uB294 \uBC29\uBB38 \uC911\uC774\uC5B4\uB3C4 \uC0C8 \uBC29\uBB38\uC774\uB2E4
141
+ (GA4 \uC138\uC158 \uADDC\uCE59\uACFC \uAC19\uB2E4). \uC0C8\uB85C\uACE0\uCE68\xB7\uB4A4\uB85C \uAC00\uAE30\uB85C \uAC19\uC740 \uC2E0\uD638\uAC00 \uB2E4\uC2DC \uBCF4\uC774\uB294 \uACBD\uC6B0\uB294 \uC81C\uC678. */
142
+ if(!_x.nv&&(SIG.rh||SIG.us||SIG.um||SIG.uc||SIG.rs||SIG.gc||_air))_x.vs=1;
109
143
  /* \uBE0C\uB77C\uC6B0\uC800 \uC5B8\uC5B4 1\uCC28 \uC11C\uBE0C\uD0DC\uADF8\uB9CC(ko\xB7en\xB7ja\xB7\xB7\xB7) \u2014 \uC678\uAD6D\uC778 \uBC29\uBB38 \uBE44\uC728\uC6A9. \uC804\uCCB4
110
144
  locale \uBB38\uC790\uC5F4(ko-KR \uB4F1)\uC740 \uC9C0\uBB38\uC131\uBD84\uC774 \uB298\uC5B4 \uBCF4\uB0B4\uC9C0 \uC54A\uB294\uB2E4. */
111
145
  try{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}
@@ -128,8 +162,10 @@ function trackCustomEvent(ev, extra) {
128
162
 
129
163
  export {
130
164
  ATTRIBUTION_STORAGE_KEY,
165
+ BEACON_PROTOCOL_VERSION,
166
+ INTERNAL_TRAFFIC_STORAGE_KEY,
131
167
  ATTRIBUTION_TTL_MS,
132
168
  renderBeaconScript,
133
169
  trackCustomEvent
134
170
  };
135
- //# sourceMappingURL=chunk-K3NVHH7K.js.map
171
+ //# sourceMappingURL=chunk-YCUCU247.js.map