@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/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
|
|
package/dist/ai-referrals.d.ts
CHANGED
|
@@ -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 };
|
package/dist/ai-referrals.js
CHANGED
|
@@ -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-
|
|
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
|
package/dist/bots.js.map
ADDED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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":[]}
|
|
@@ -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-
|
|
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-
|
|
171
|
+
//# sourceMappingURL=chunk-YCUCU247.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nimport { aiReferralRulesLiteral } from \"./ai-referrals\";\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/**\n * 비콘 수집 규약 버전(`bv`) — 모든 이벤트에 싣는다. 수집 서버는 이 값으로 비콘이\n * 보내는 신호(nv·wd·vs·vu)를 추정 없이 안다.\n * 2 = 방문 시작(`vs`)·저장소 차단(`vu`)·탭 공유 방문 판정·SPA `nv:3`·`wd`.\n */\nexport const BEACON_PROTOCOL_VERSION = 2;\n\n/**\n * 내부 접속 제외 표시 localStorage 키. 사이트를 `?rt_internal=1` 로 열면 켜지고\n * `?rt_internal=0` 으로 꺼진다. 켜진 브라우저는 비콘을 전혀 보내지 않는다.\n */\nexport const INTERNAL_TRAFFIC_STORAGE_KEY = \"_rt_internal\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\n/* 내부 접속 제외: ?rt_internal=1 로 한 번 열면 이 브라우저는 수집하지 않는다.\n ?rt_internal=0 으로 해제한다. 표시만 저장하고 식별자는 만들지 않는다. */\ntry{var _ri=new URLSearchParams(location.search).get('rt_internal');\nif(_ri==='1')localStorage.setItem('${INTERNAL_TRAFFIC_STORAGE_KEY}','1');\nelse if(_ri==='0')localStorage.removeItem('${INTERNAL_TRAFFIC_STORAGE_KEY}');\nif(localStorage.getItem('${INTERNAL_TRAFFIC_STORAGE_KEY}')==='1')return;}catch(e){}\nvar AIR=${aiReferralRulesLiteral()};\n/* 사이트별 익명 방문 유형: 같은 브라우저의 모든 탭에서 30분 비활동이면 새 방문,\n 최근 30일 기록이 없으면 첫 방문. 1=첫 방문 시작, 2=재방문 시작, 0=방문 중,\n -1=저장소 차단(판정 불가). ID를 만들거나 전송하지 않는다. */\nfunction visitType(){try{\nvar now=Date.now(),sk='_rt_visit_last_'+C.sid,lk='_rt_visit_seen_'+C.sid;\nvar last=Number(localStorage.getItem(sk)||0);\nlocalStorage.setItem(sk,String(now));\nif(last>0&&now>=last&&now-last<1800000)return 0;\nvar seen=Number(localStorage.getItem(lk)||0);\nvar result=seen>0&&now>=seen&&now-seen<2592e6?2:1;\nlocalStorage.setItem(lk,String(now));\nreturn result;\n}catch(e){return -1;}}\n/* AI 챗봇 유입 판정 — 원문 referrer 는 보내지 않고 고정 목록의 라벨만 남긴다.\n 목록 밖(일반 사이트·검색엔진)은 null 이라 아무것도 전송되지 않는다. */\nfunction aiRef(){try{\nif(!document.referrer)return null;\nvar h=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nif(!h||h===location.hostname.toLowerCase().replace(/^www\\\\./,''))return null;\nfor(var i=0;i<AIR.length;i++){var r=AIR[i][0];if(h===r||h.slice(-(r.length+1))==='.'+r)return AIR[i][1];}\nreturn null;\n}catch(e){return null;}}\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now(),bv:${BEACON_PROTOCOL_VERSION}};\ntry{if(navigator.webdriver)p.wd=1;}catch(e){}\nif(extra){for(var k in extra){p[k]=extra[k];}}\nif(ev==='pageview'){var _vst=visitType();delete p.vst;\nif(_vst>0){p.vst=_vst;p.vs=1;}else if(_vst<0)p.vu=1;}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\n/* 유입 신호 1회 계산 — 어트리뷰션 저장(아래 C.attr 블록)과 pageview 동봉이\n 같은 값을 쓴다. referrer 는 호스트명만(경로·쿼리 미포함, www 제거),\n gclid/fbclid 는 존재 여부(gc:1)만 — 원값은 어디에도 없다. */\nvar SIG=(function(){var o={};\ntry{\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');if(rs)o.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)o.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)o.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)o.uc=uc.slice(0,100);\nvar ut=sp.get('utm_term');if(ut)o.ut=ut.slice(0,100);\nvar un=sp.get('utm_content');if(un)o.un=un.slice(0,100);\nif(sp.get('gclid')||sp.get('fbclid'))o.gc=1;\n}catch(e){}\ntry{\nif(document.referrer){\nvar rh=new URL(document.referrer).hostname.toLowerCase().replace(/^www\\\\./,'');\nvar oh=location.hostname.toLowerCase().replace(/^www\\\\./,'');\nif(rh&&rh!==oh)o.rh=rh.slice(0,253);\n}\n}catch(e){}\nreturn o;})();\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(SIG.rs)env.rs=SIG.rs;\nif(SIG.us)env.us=SIG.us;\nif(SIG.um)env.um=SIG.um;\nif(SIG.uc)env.uc=SIG.uc;\nif(SIG.ut)env.ut=SIG.ut;\nif(SIG.un)env.un=SIG.un;\nif(SIG.gc)env.gcl=1;\nif(SIG.rh)env.rf=SIG.rh;\n}\nvar ch=null;\nif(SIG.rs){ch='rs';}else if(SIG.us||SIG.um||SIG.uc||SIG.ut||SIG.un){ch='utm';}else if(SIG.gc){ch='gcl';}else if(SIG.rh){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(SIG.rs)lt.rs=SIG.rs;\nif(SIG.us)lt.us=SIG.us;\nif(SIG.um)lt.um=SIG.um;\nif(SIG.uc)lt.uc=SIG.uc;\nif(SIG.ut)lt.ut=SIG.ut;\nif(SIG.un)lt.un=SIG.un;\nif(SIG.gc)lt.gcl=1;\nif(SIG.rh)lt.rf=SIG.rh;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();var _air=aiRef();var _x={};\nif(_pid)_x.pid=_pid;\nif(_air)_x.air=_air;\n/* AI 챗봇 유입은 라벨(air)만 — 호스트명도 보내지 않는다(기존 불변식 유지). */\nif(SIG.rh&&!_air)_x.rh=SIG.rh;\nif(SIG.us)_x.us=SIG.us;\nif(SIG.um)_x.um=SIG.um;\nif(SIG.uc)_x.uc=SIG.uc;\nif(SIG.ut)_x.ut=SIG.ut;\nif(SIG.un)_x.un=SIG.un;\nif(SIG.rs)_x.rs=SIG.rs;\nif(SIG.gc)_x.gc=1;\n/* 브라우저 새로고침·방문 기록 이동은 새 외부 유입으로 세지 않는다.\n 최초 문서 조회에만 동봉한다. SPA pageview에는 이전 문서의 이동 유형을 재사용하지 않는다. */\ntry{var _nav=performance.getEntriesByType('navigation')[0];\nif(_nav&&_nav.type==='reload')_x.nv=1;\nelse if(_nav&&_nav.type==='back_forward')_x.nv=2;}catch(e){}\n/* 새 외부 신호(referrer·utm·광고·단축링크·AI)로 연 문서는 방문 중이어도 새 방문이다\n (GA4 세션 규칙과 같다). 새로고침·뒤로 가기로 같은 신호가 다시 보이는 경우는 제외. */\nif(!_x.nv&&(SIG.rh||SIG.us||SIG.um||SIG.uc||SIG.rs||SIG.gc||_air))_x.vs=1;\n/* 브라우저 언어 1차 서브태그만(ko·en·ja···) — 외국인 방문 비율용. 전체\n locale 문자열(ko-KR 등)은 지문성분이 늘어 보내지 않는다. */\ntry{var _lg=(navigator.language||'').split('-')[0].toLowerCase();if(/^[a-z]{2,8}$/.test(_lg))_x.lg=_lg;}catch(e){}\nvar _has=false;for(var _q in _x){_has=true;break;}\nsend('pageview',_has?_x:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * 유입 경로 신호(2026-08-19) — pageview 에만 동봉, 전부 있을 때만 실린다:\n * `rh` 외부 referrer **호스트명**(경로·쿼리 없음, www 제거) · `us`/`um`/`uc`\n * utm source/medium/campaign · `rs` rt_src · `gc:1` gclid/fbclid **존재\n * 여부**(원값 미전송) · `lg` 브라우저 언어 1차 서브태그(ko·en···, full\n * locale 미전송). 판정은 서버가 한다 —\n * `nv` 는 문서 새로고침(1)·방문 기록 이동(2)일 때만 보내 유입 재집계를\n * 막는다. SPA 이동에는 싣지 않는다.\n * `entry-channels.ts` classifyEntryChannel() 이 `/v1/collect` 에서 채널\n * 묶음으로 접어 AE blob10~ 에 적재(값 계약은 collect.ts 와 공유, 동시 갱신).\n * raw referrer URL 은 여전히 어디에도 전송·저장되지 않는다(ADR-0026 §5).\n * `wd:1` 은 navigator.webdriver 가 켜진 자동화 브라우저일 때만(모든 이벤트) —\n * 서버가 사람 통계 대신 자동 방문(`bot_pageview`)으로 적재한다.\n * `vst` 는 브라우저 저장소 기준 방문 시작 때만 1(최근 30일 첫 확인) 또는\n * 2(재방문)로 전송한다. 방문자 ID나 원문 저장값은 전송하지 않는다.\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";;;;;AA2DO,IAAM,0BAA0B;AAOhC,IAAM,0BAA0B;AAMhC,IAAM,+BAA+B;AAGrC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,qCAKqB,4BAA4B;AAAA,6CACpB,4BAA4B;AAAA,2BAC9C,4BAA4B;AAAA,UAC7C,uBAAuB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2FA0ByD,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmI3G,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|