@stacksjs/ts-analytics 0.1.6
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/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response utilities for the analytics API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ApiResponse {
|
|
6
|
+
statusCode: number
|
|
7
|
+
headers: Record<string, string>
|
|
8
|
+
body: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a standardized JSON response
|
|
13
|
+
*/
|
|
14
|
+
export function jsonResponse(body: unknown, statusCode = 200, headers: Record<string, string> = {}): Response {
|
|
15
|
+
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
16
|
+
status: statusCode,
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'Access-Control-Allow-Origin': '*',
|
|
20
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
21
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
22
|
+
...headers,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 204 No Content with CORS — for the public collect / tracking endpoints, which
|
|
29
|
+
* every host site POSTs to cross-origin. Without Access-Control-Allow-Origin the
|
|
30
|
+
* browser blocks the beacon (and logs a CORS error) even though the write
|
|
31
|
+
* succeeded, so the bare `new Response(null, { status: 204 })` must not be used.
|
|
32
|
+
*/
|
|
33
|
+
export function noContentResponse(request?: Request, headers: Record<string, string> = {}): Response {
|
|
34
|
+
return new Response(null, {
|
|
35
|
+
status: 204,
|
|
36
|
+
headers: {
|
|
37
|
+
...corsOriginHeaders(request),
|
|
38
|
+
...headers,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create an HTML response
|
|
45
|
+
*/
|
|
46
|
+
export function htmlResponse(body: string, statusCode = 200, headers: Record<string, string> = {}): Response {
|
|
47
|
+
return new Response(body, {
|
|
48
|
+
status: statusCode,
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
51
|
+
'Access-Control-Allow-Origin': '*',
|
|
52
|
+
...headers,
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a JavaScript response.
|
|
59
|
+
*
|
|
60
|
+
* Carries an ETag (#179) so tracker updates roll out on the next
|
|
61
|
+
* revalidation instead of a hard 1-hour cache-expiry wait: pass the request
|
|
62
|
+
* and a matching If-None-Match short-circuits to 304.
|
|
63
|
+
* stale-while-revalidate keeps page loads fast while the check happens in
|
|
64
|
+
* the background. Cache-bust hard with a ?v= query when needed.
|
|
65
|
+
*/
|
|
66
|
+
export function jsResponse(body: string, headers: Record<string, string> = {}, request?: Request): Response {
|
|
67
|
+
const etag = `W/"${Bun.hash(body).toString(36)}"`
|
|
68
|
+
const baseHeaders = {
|
|
69
|
+
'Content-Type': 'application/javascript',
|
|
70
|
+
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
|
71
|
+
'Access-Control-Allow-Origin': '*',
|
|
72
|
+
'ETag': etag,
|
|
73
|
+
...headers,
|
|
74
|
+
}
|
|
75
|
+
if (request?.headers.get('if-none-match') === etag) {
|
|
76
|
+
return new Response(null, { status: 304, headers: baseHeaders })
|
|
77
|
+
}
|
|
78
|
+
return new Response(body, { status: 200, headers: baseHeaders })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* CORS origin headers for the public collect endpoints. sendBeacon always
|
|
83
|
+
* sends with credentials mode 'include' (per spec), and credentialed CORS
|
|
84
|
+
* rejects the wildcard — so when the request carries an Origin, echo it and
|
|
85
|
+
* allow credentials; fall back to '*' otherwise.
|
|
86
|
+
*/
|
|
87
|
+
export function corsOriginHeaders(request?: Request): Record<string, string> {
|
|
88
|
+
const origin = request?.headers?.get?.('origin')
|
|
89
|
+
if (!origin)
|
|
90
|
+
return { 'Access-Control-Allow-Origin': '*' }
|
|
91
|
+
return {
|
|
92
|
+
'Access-Control-Allow-Origin': origin,
|
|
93
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
94
|
+
'Vary': 'Origin',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* CORS preflight response for the public collect endpoints (#86).
|
|
100
|
+
*
|
|
101
|
+
* Cross-domain installs preflight every beacon: the tracker posts JSON
|
|
102
|
+
* (non-simple content type) and the error SDK adds X-Analytics-Token, so the
|
|
103
|
+
* browser sends OPTIONS first. Without this, preflights 404 and the browser
|
|
104
|
+
* silently drops the POST. The origin is echoed (not wildcarded) because
|
|
105
|
+
* sendBeacon requests are always credentialed.
|
|
106
|
+
*/
|
|
107
|
+
export function preflightResponse(request?: Request): Response {
|
|
108
|
+
return new Response(null, {
|
|
109
|
+
status: 204,
|
|
110
|
+
headers: {
|
|
111
|
+
...corsOriginHeaders(request),
|
|
112
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
113
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Analytics-Token',
|
|
114
|
+
'Access-Control-Max-Age': '86400',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create an error response
|
|
121
|
+
*/
|
|
122
|
+
export function errorResponse(message: string, statusCode = 500): Response {
|
|
123
|
+
return jsonResponse({ error: message }, statusCode)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a not found response
|
|
128
|
+
*/
|
|
129
|
+
export function notFoundResponse(message = 'Not found'): Response {
|
|
130
|
+
return jsonResponse({ error: message }, 404)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Legacy response helper for backwards compatibility during migration
|
|
135
|
+
* Returns the old format expected by the Lambda handler
|
|
136
|
+
*/
|
|
137
|
+
export function legacyResponse(body: unknown, statusCode = 200, headers: Record<string, string> = {}): ApiResponse {
|
|
138
|
+
return {
|
|
139
|
+
statusCode,
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'Access-Control-Allow-Origin': '*',
|
|
143
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
144
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
145
|
+
...headers,
|
|
146
|
+
},
|
|
147
|
+
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Split-domain credentialed CORS (#118).
|
|
153
|
+
*
|
|
154
|
+
* When the dashboard runs on a DIFFERENT origin than the API
|
|
155
|
+
* (STX_PUBLIC_API_ENDPOINT), its credentialed fetches need the API to echo the
|
|
156
|
+
* request Origin with Access-Control-Allow-Credentials — browsers forbid the
|
|
157
|
+
* wildcard with credentials. The allowlist comes from CORS_ORIGINS
|
|
158
|
+
* (comma-separated origins). Unset = same-origin installs (the dev proxy,
|
|
159
|
+
* #116) — no echo, wildcard responses stay as-is.
|
|
160
|
+
*/
|
|
161
|
+
export function allowedCorsOrigins(): string[] {
|
|
162
|
+
return (process.env.CORS_ORIGINS || '')
|
|
163
|
+
.split(',')
|
|
164
|
+
.map(o => o.trim().replace(/\/$/, ''))
|
|
165
|
+
.filter(o => o.length > 0 && o !== '*')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The echoed CORS headers for an allowlisted Origin, or null. */
|
|
169
|
+
export function credentialedCorsHeaders(request: Request): Record<string, string> | null {
|
|
170
|
+
const origin = request.headers.get('origin')
|
|
171
|
+
if (!origin)
|
|
172
|
+
return null
|
|
173
|
+
if (!allowedCorsOrigins().includes(origin.replace(/\/$/, '')))
|
|
174
|
+
return null
|
|
175
|
+
return {
|
|
176
|
+
'Access-Control-Allow-Origin': origin,
|
|
177
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
178
|
+
'Vary': 'Origin',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country from the browser's IANA timezone (Fathom-privacy geolocation).
|
|
3
|
+
*
|
|
4
|
+
* The tracker sends `Intl.DateTimeFormat().resolvedOptions().timeZone` (e.g.
|
|
5
|
+
* "Asia/Manila") and we map it to a country server-side. Nothing about the
|
|
6
|
+
* visitor leaves their machine except a coarse timezone string, no IP is used
|
|
7
|
+
* or shipped to a third party, and only the derived country is stored — the
|
|
8
|
+
* raw timezone is discarded. Works in any deployment (no CDN geo headers
|
|
9
|
+
* needed), so it's the default fallback after CloudFront/Cloudflare headers.
|
|
10
|
+
*
|
|
11
|
+
* Mapping data follows the IANA tz database's zone-to-country table
|
|
12
|
+
* (zone1970.tab) plus the common legacy aliases browsers still emit
|
|
13
|
+
* (Asia/Calcutta, Europe/Kiev, US/Eastern, …). Zones with no single country
|
|
14
|
+
* (UTC, Etc/*) intentionally resolve to nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { COUNTRY_NAMES } from './geolocation'
|
|
17
|
+
|
|
18
|
+
const TIMEZONE_COUNTRY: Record<string, string> = {
|
|
19
|
+
// Africa
|
|
20
|
+
'Africa/Abidjan': 'CI', 'Africa/Accra': 'GH', 'Africa/Addis_Ababa': 'ET',
|
|
21
|
+
'Africa/Algiers': 'DZ', 'Africa/Asmara': 'ER', 'Africa/Asmera': 'ER',
|
|
22
|
+
'Africa/Bamako': 'ML', 'Africa/Bangui': 'CF', 'Africa/Banjul': 'GM',
|
|
23
|
+
'Africa/Bissau': 'GW', 'Africa/Blantyre': 'MW', 'Africa/Brazzaville': 'CG',
|
|
24
|
+
'Africa/Bujumbura': 'BI', 'Africa/Cairo': 'EG', 'Africa/Casablanca': 'MA',
|
|
25
|
+
'Africa/Ceuta': 'ES', 'Africa/Conakry': 'GN', 'Africa/Dakar': 'SN',
|
|
26
|
+
'Africa/Dar_es_Salaam': 'TZ', 'Africa/Djibouti': 'DJ', 'Africa/Douala': 'CM',
|
|
27
|
+
'Africa/El_Aaiun': 'EH', 'Africa/Freetown': 'SL', 'Africa/Gaborone': 'BW',
|
|
28
|
+
'Africa/Harare': 'ZW', 'Africa/Johannesburg': 'ZA', 'Africa/Juba': 'SS',
|
|
29
|
+
'Africa/Kampala': 'UG', 'Africa/Khartoum': 'SD', 'Africa/Kigali': 'RW',
|
|
30
|
+
'Africa/Kinshasa': 'CD', 'Africa/Lagos': 'NG', 'Africa/Libreville': 'GA',
|
|
31
|
+
'Africa/Lome': 'TG', 'Africa/Luanda': 'AO', 'Africa/Lubumbashi': 'CD',
|
|
32
|
+
'Africa/Lusaka': 'ZM', 'Africa/Malabo': 'GQ', 'Africa/Maputo': 'MZ',
|
|
33
|
+
'Africa/Maseru': 'LS', 'Africa/Mbabane': 'SZ', 'Africa/Mogadishu': 'SO',
|
|
34
|
+
'Africa/Monrovia': 'LR', 'Africa/Nairobi': 'KE', 'Africa/Ndjamena': 'TD',
|
|
35
|
+
'Africa/Niamey': 'NE', 'Africa/Nouakchott': 'MR', 'Africa/Ouagadougou': 'BF',
|
|
36
|
+
'Africa/Porto-Novo': 'BJ', 'Africa/Sao_Tome': 'ST', 'Africa/Timbuktu': 'ML',
|
|
37
|
+
'Africa/Tripoli': 'LY', 'Africa/Tunis': 'TN', 'Africa/Windhoek': 'NA',
|
|
38
|
+
|
|
39
|
+
// America — US
|
|
40
|
+
'America/Adak': 'US', 'America/Anchorage': 'US', 'America/Boise': 'US',
|
|
41
|
+
'America/Chicago': 'US', 'America/Denver': 'US', 'America/Detroit': 'US',
|
|
42
|
+
'America/Indiana/Indianapolis': 'US', 'America/Indiana/Knox': 'US',
|
|
43
|
+
'America/Indiana/Marengo': 'US', 'America/Indiana/Petersburg': 'US',
|
|
44
|
+
'America/Indiana/Tell_City': 'US', 'America/Indiana/Vevay': 'US',
|
|
45
|
+
'America/Indiana/Vincennes': 'US', 'America/Indiana/Winamac': 'US',
|
|
46
|
+
'America/Indianapolis': 'US', 'America/Fort_Wayne': 'US', 'America/Knox_IN': 'US',
|
|
47
|
+
'America/Juneau': 'US', 'America/Kentucky/Louisville': 'US',
|
|
48
|
+
'America/Kentucky/Monticello': 'US', 'America/Louisville': 'US',
|
|
49
|
+
'America/Los_Angeles': 'US', 'America/Menominee': 'US', 'America/Metlakatla': 'US',
|
|
50
|
+
'America/New_York': 'US', 'America/Nome': 'US',
|
|
51
|
+
'America/North_Dakota/Beulah': 'US', 'America/North_Dakota/Center': 'US',
|
|
52
|
+
'America/North_Dakota/New_Salem': 'US', 'America/Phoenix': 'US',
|
|
53
|
+
'America/Shiprock': 'US', 'America/Sitka': 'US', 'America/Yakutat': 'US',
|
|
54
|
+
'America/Atka': 'US',
|
|
55
|
+
'US/Alaska': 'US', 'US/Aleutian': 'US', 'US/Arizona': 'US', 'US/Central': 'US',
|
|
56
|
+
'US/East-Indiana': 'US', 'US/Eastern': 'US', 'US/Hawaii': 'US',
|
|
57
|
+
'US/Indiana-Starke': 'US', 'US/Michigan': 'US', 'US/Mountain': 'US', 'US/Pacific': 'US',
|
|
58
|
+
'Pacific/Honolulu': 'US',
|
|
59
|
+
|
|
60
|
+
// America — Canada
|
|
61
|
+
'America/Atikokan': 'CA', 'America/Blanc-Sablon': 'CA', 'America/Cambridge_Bay': 'CA',
|
|
62
|
+
'America/Coral_Harbour': 'CA', 'America/Creston': 'CA', 'America/Dawson': 'CA',
|
|
63
|
+
'America/Dawson_Creek': 'CA', 'America/Edmonton': 'CA', 'America/Fort_Nelson': 'CA',
|
|
64
|
+
'America/Glace_Bay': 'CA', 'America/Goose_Bay': 'CA', 'America/Halifax': 'CA',
|
|
65
|
+
'America/Inuvik': 'CA', 'America/Iqaluit': 'CA', 'America/Moncton': 'CA',
|
|
66
|
+
'America/Montreal': 'CA', 'America/Nipigon': 'CA', 'America/Pangnirtung': 'CA',
|
|
67
|
+
'America/Rainy_River': 'CA', 'America/Rankin_Inlet': 'CA', 'America/Regina': 'CA',
|
|
68
|
+
'America/Resolute': 'CA', 'America/St_Johns': 'CA', 'America/Swift_Current': 'CA',
|
|
69
|
+
'America/Thunder_Bay': 'CA', 'America/Toronto': 'CA', 'America/Vancouver': 'CA',
|
|
70
|
+
'America/Whitehorse': 'CA', 'America/Winnipeg': 'CA', 'America/Yellowknife': 'CA',
|
|
71
|
+
'Canada/Atlantic': 'CA', 'Canada/Central': 'CA', 'Canada/Eastern': 'CA',
|
|
72
|
+
'Canada/Mountain': 'CA', 'Canada/Newfoundland': 'CA', 'Canada/Pacific': 'CA',
|
|
73
|
+
'Canada/Saskatchewan': 'CA', 'Canada/Yukon': 'CA',
|
|
74
|
+
|
|
75
|
+
// America — Mexico
|
|
76
|
+
'America/Bahia_Banderas': 'MX', 'America/Cancun': 'MX', 'America/Chihuahua': 'MX',
|
|
77
|
+
'America/Ciudad_Juarez': 'MX', 'America/Ensenada': 'MX', 'America/Hermosillo': 'MX',
|
|
78
|
+
'America/Matamoros': 'MX', 'America/Mazatlan': 'MX', 'America/Merida': 'MX',
|
|
79
|
+
'America/Mexico_City': 'MX', 'America/Monterrey': 'MX', 'America/Ojinaga': 'MX',
|
|
80
|
+
'America/Santa_Isabel': 'MX', 'America/Tijuana': 'MX',
|
|
81
|
+
'Mexico/BajaNorte': 'MX', 'Mexico/BajaSur': 'MX', 'Mexico/General': 'MX',
|
|
82
|
+
|
|
83
|
+
// America — Brazil
|
|
84
|
+
'America/Araguaina': 'BR', 'America/Bahia': 'BR', 'America/Belem': 'BR',
|
|
85
|
+
'America/Boa_Vista': 'BR', 'America/Campo_Grande': 'BR', 'America/Cuiaba': 'BR',
|
|
86
|
+
'America/Eirunepe': 'BR', 'America/Fortaleza': 'BR', 'America/Maceio': 'BR',
|
|
87
|
+
'America/Manaus': 'BR', 'America/Noronha': 'BR', 'America/Porto_Acre': 'BR',
|
|
88
|
+
'America/Porto_Velho': 'BR', 'America/Recife': 'BR', 'America/Rio_Branco': 'BR',
|
|
89
|
+
'America/Santarem': 'BR', 'America/Sao_Paulo': 'BR',
|
|
90
|
+
'Brazil/Acre': 'BR', 'Brazil/DeNoronha': 'BR', 'Brazil/East': 'BR', 'Brazil/West': 'BR',
|
|
91
|
+
|
|
92
|
+
// America — Argentina
|
|
93
|
+
'America/Argentina/Buenos_Aires': 'AR', 'America/Argentina/Catamarca': 'AR',
|
|
94
|
+
'America/Argentina/ComodRivadavia': 'AR', 'America/Argentina/Cordoba': 'AR',
|
|
95
|
+
'America/Argentina/Jujuy': 'AR', 'America/Argentina/La_Rioja': 'AR',
|
|
96
|
+
'America/Argentina/Mendoza': 'AR', 'America/Argentina/Rio_Gallegos': 'AR',
|
|
97
|
+
'America/Argentina/Salta': 'AR', 'America/Argentina/San_Juan': 'AR',
|
|
98
|
+
'America/Argentina/San_Luis': 'AR', 'America/Argentina/Tucuman': 'AR',
|
|
99
|
+
'America/Argentina/Ushuaia': 'AR', 'America/Buenos_Aires': 'AR',
|
|
100
|
+
'America/Catamarca': 'AR', 'America/Cordoba': 'AR', 'America/Jujuy': 'AR',
|
|
101
|
+
'America/Mendoza': 'AR', 'America/Rosario': 'AR',
|
|
102
|
+
|
|
103
|
+
// America — rest
|
|
104
|
+
'America/Anguilla': 'AI', 'America/Antigua': 'AG', 'America/Aruba': 'AW',
|
|
105
|
+
'America/Asuncion': 'PY', 'America/Barbados': 'BB', 'America/Belize': 'BZ',
|
|
106
|
+
'America/Bogota': 'CO', 'America/Caracas': 'VE', 'America/Cayenne': 'GF',
|
|
107
|
+
'America/Cayman': 'KY', 'America/Costa_Rica': 'CR', 'America/Curacao': 'CW',
|
|
108
|
+
'America/Danmarkshavn': 'GL', 'America/Dominica': 'DM', 'America/El_Salvador': 'SV',
|
|
109
|
+
'America/Godthab': 'GL', 'America/Grand_Turk': 'TC', 'America/Grenada': 'GD',
|
|
110
|
+
'America/Guadeloupe': 'GP', 'America/Guatemala': 'GT', 'America/Guayaquil': 'EC',
|
|
111
|
+
'America/Guyana': 'GY', 'America/Havana': 'CU', 'America/Jamaica': 'JM',
|
|
112
|
+
'America/Kralendijk': 'BQ', 'America/La_Paz': 'BO', 'America/Lima': 'PE',
|
|
113
|
+
'America/Lower_Princes': 'SX', 'America/Managua': 'NI', 'America/Marigot': 'MF',
|
|
114
|
+
'America/Martinique': 'MQ', 'America/Miquelon': 'PM', 'America/Montevideo': 'UY',
|
|
115
|
+
'America/Montserrat': 'MS', 'America/Nassau': 'BS', 'America/Nuuk': 'GL',
|
|
116
|
+
'America/Panama': 'PA', 'America/Paramaribo': 'SR', 'America/Port-au-Prince': 'HT',
|
|
117
|
+
'America/Port_of_Spain': 'TT', 'America/Puerto_Rico': 'PR', 'America/Punta_Arenas': 'CL',
|
|
118
|
+
'America/Santiago': 'CL', 'America/Santo_Domingo': 'DO', 'America/Scoresbysund': 'GL',
|
|
119
|
+
'America/St_Barthelemy': 'BL', 'America/St_Kitts': 'KN', 'America/St_Lucia': 'LC',
|
|
120
|
+
'America/St_Thomas': 'VI', 'America/St_Vincent': 'VC', 'America/Tegucigalpa': 'HN',
|
|
121
|
+
'America/Thule': 'GL', 'America/Tortola': 'VG', 'America/Virgin': 'VI',
|
|
122
|
+
'America/Coyhaique': 'CL',
|
|
123
|
+
'Chile/Continental': 'CL', 'Chile/EasterIsland': 'CL', 'Cuba': 'CU', 'Jamaica': 'JM',
|
|
124
|
+
|
|
125
|
+
// Antarctica (research stations — no meaningful country; map to AQ)
|
|
126
|
+
'Antarctica/Casey': 'AQ', 'Antarctica/Davis': 'AQ', 'Antarctica/DumontDUrville': 'AQ',
|
|
127
|
+
'Antarctica/Macquarie': 'AU', 'Antarctica/Mawson': 'AQ', 'Antarctica/McMurdo': 'AQ',
|
|
128
|
+
'Antarctica/Palmer': 'AQ', 'Antarctica/Rothera': 'AQ', 'Antarctica/South_Pole': 'AQ',
|
|
129
|
+
'Antarctica/Syowa': 'AQ', 'Antarctica/Troll': 'AQ', 'Antarctica/Vostok': 'AQ',
|
|
130
|
+
|
|
131
|
+
// Asia
|
|
132
|
+
'Asia/Aden': 'YE', 'Asia/Almaty': 'KZ', 'Asia/Amman': 'JO', 'Asia/Anadyr': 'RU',
|
|
133
|
+
'Asia/Aqtau': 'KZ', 'Asia/Aqtobe': 'KZ', 'Asia/Ashgabat': 'TM', 'Asia/Ashkhabad': 'TM',
|
|
134
|
+
'Asia/Atyrau': 'KZ', 'Asia/Baghdad': 'IQ', 'Asia/Bahrain': 'BH', 'Asia/Baku': 'AZ',
|
|
135
|
+
'Asia/Bangkok': 'TH', 'Asia/Barnaul': 'RU', 'Asia/Beirut': 'LB', 'Asia/Bishkek': 'KG',
|
|
136
|
+
'Asia/Brunei': 'BN', 'Asia/Calcutta': 'IN', 'Asia/Chita': 'RU', 'Asia/Choibalsan': 'MN',
|
|
137
|
+
'Asia/Chongqing': 'CN', 'Asia/Chungking': 'CN', 'Asia/Colombo': 'LK', 'Asia/Dacca': 'BD',
|
|
138
|
+
'Asia/Damascus': 'SY', 'Asia/Dhaka': 'BD', 'Asia/Dili': 'TL', 'Asia/Dubai': 'AE',
|
|
139
|
+
'Asia/Dushanbe': 'TJ', 'Asia/Famagusta': 'CY', 'Asia/Gaza': 'PS', 'Asia/Harbin': 'CN',
|
|
140
|
+
'Asia/Hebron': 'PS', 'Asia/Ho_Chi_Minh': 'VN', 'Asia/Hong_Kong': 'HK', 'Asia/Hovd': 'MN',
|
|
141
|
+
'Asia/Irkutsk': 'RU', 'Asia/Istanbul': 'TR', 'Asia/Jakarta': 'ID', 'Asia/Jayapura': 'ID',
|
|
142
|
+
'Asia/Jerusalem': 'IL', 'Asia/Kabul': 'AF', 'Asia/Kamchatka': 'RU', 'Asia/Karachi': 'PK',
|
|
143
|
+
'Asia/Kashgar': 'CN', 'Asia/Kathmandu': 'NP', 'Asia/Katmandu': 'NP', 'Asia/Khandyga': 'RU',
|
|
144
|
+
'Asia/Kolkata': 'IN', 'Asia/Krasnoyarsk': 'RU', 'Asia/Kuala_Lumpur': 'MY',
|
|
145
|
+
'Asia/Kuching': 'MY', 'Asia/Kuwait': 'KW', 'Asia/Macao': 'MO', 'Asia/Macau': 'MO',
|
|
146
|
+
'Asia/Magadan': 'RU', 'Asia/Makassar': 'ID', 'Asia/Manila': 'PH', 'Asia/Muscat': 'OM',
|
|
147
|
+
'Asia/Nicosia': 'CY', 'Asia/Novokuznetsk': 'RU', 'Asia/Novosibirsk': 'RU',
|
|
148
|
+
'Asia/Omsk': 'RU', 'Asia/Oral': 'KZ', 'Asia/Phnom_Penh': 'KH', 'Asia/Pontianak': 'ID',
|
|
149
|
+
'Asia/Pyongyang': 'KP', 'Asia/Qatar': 'QA', 'Asia/Qostanay': 'KZ', 'Asia/Qyzylorda': 'KZ',
|
|
150
|
+
'Asia/Rangoon': 'MM', 'Asia/Riyadh': 'SA', 'Asia/Saigon': 'VN', 'Asia/Sakhalin': 'RU',
|
|
151
|
+
'Asia/Samarkand': 'UZ', 'Asia/Seoul': 'KR', 'Asia/Shanghai': 'CN', 'Asia/Singapore': 'SG',
|
|
152
|
+
'Asia/Srednekolymsk': 'RU', 'Asia/Taipei': 'TW', 'Asia/Tashkent': 'UZ',
|
|
153
|
+
'Asia/Tbilisi': 'GE', 'Asia/Tehran': 'IR', 'Asia/Tel_Aviv': 'IL', 'Asia/Thimbu': 'BT',
|
|
154
|
+
'Asia/Thimphu': 'BT', 'Asia/Tokyo': 'JP', 'Asia/Tomsk': 'RU', 'Asia/Ujung_Pandang': 'ID',
|
|
155
|
+
'Asia/Ulaanbaatar': 'MN', 'Asia/Ulan_Bator': 'MN', 'Asia/Urumqi': 'CN',
|
|
156
|
+
'Asia/Ust-Nera': 'RU', 'Asia/Vientiane': 'LA', 'Asia/Vladivostok': 'RU',
|
|
157
|
+
'Asia/Yakutsk': 'RU', 'Asia/Yangon': 'MM', 'Asia/Yekaterinburg': 'RU', 'Asia/Yerevan': 'AM',
|
|
158
|
+
'Hongkong': 'HK', 'Israel': 'IL', 'Iran': 'IR', 'Japan': 'JP', 'Singapore': 'SG',
|
|
159
|
+
'ROC': 'TW', 'ROK': 'KR', 'PRC': 'CN', 'Turkey': 'TR', 'Egypt': 'EG', 'Libya': 'LY',
|
|
160
|
+
|
|
161
|
+
// Atlantic
|
|
162
|
+
'Atlantic/Azores': 'PT', 'Atlantic/Bermuda': 'BM', 'Atlantic/Canary': 'ES',
|
|
163
|
+
'Atlantic/Cape_Verde': 'CV', 'Atlantic/Faeroe': 'FO', 'Atlantic/Faroe': 'FO',
|
|
164
|
+
'Atlantic/Jan_Mayen': 'SJ', 'Atlantic/Madeira': 'PT', 'Atlantic/Reykjavik': 'IS',
|
|
165
|
+
'Atlantic/South_Georgia': 'GS', 'Atlantic/St_Helena': 'SH', 'Atlantic/Stanley': 'FK',
|
|
166
|
+
'Iceland': 'IS',
|
|
167
|
+
|
|
168
|
+
// Australia
|
|
169
|
+
'Australia/ACT': 'AU', 'Australia/Adelaide': 'AU', 'Australia/Brisbane': 'AU',
|
|
170
|
+
'Australia/Broken_Hill': 'AU', 'Australia/Canberra': 'AU', 'Australia/Currie': 'AU',
|
|
171
|
+
'Australia/Darwin': 'AU', 'Australia/Eucla': 'AU', 'Australia/Hobart': 'AU',
|
|
172
|
+
'Australia/LHI': 'AU', 'Australia/Lindeman': 'AU', 'Australia/Lord_Howe': 'AU',
|
|
173
|
+
'Australia/Melbourne': 'AU', 'Australia/NSW': 'AU', 'Australia/North': 'AU',
|
|
174
|
+
'Australia/Perth': 'AU', 'Australia/Queensland': 'AU', 'Australia/South': 'AU',
|
|
175
|
+
'Australia/Sydney': 'AU', 'Australia/Tasmania': 'AU', 'Australia/Victoria': 'AU',
|
|
176
|
+
'Australia/West': 'AU', 'Australia/Yancowinna': 'AU',
|
|
177
|
+
|
|
178
|
+
// Europe
|
|
179
|
+
'Europe/Amsterdam': 'NL', 'Europe/Andorra': 'AD', 'Europe/Astrakhan': 'RU',
|
|
180
|
+
'Europe/Athens': 'GR', 'Europe/Belfast': 'GB', 'Europe/Belgrade': 'RS',
|
|
181
|
+
'Europe/Berlin': 'DE', 'Europe/Bratislava': 'SK', 'Europe/Brussels': 'BE',
|
|
182
|
+
'Europe/Bucharest': 'RO', 'Europe/Budapest': 'HU', 'Europe/Busingen': 'DE',
|
|
183
|
+
'Europe/Chisinau': 'MD', 'Europe/Copenhagen': 'DK', 'Europe/Dublin': 'IE',
|
|
184
|
+
'Europe/Gibraltar': 'GI', 'Europe/Guernsey': 'GG', 'Europe/Helsinki': 'FI',
|
|
185
|
+
'Europe/Isle_of_Man': 'IM', 'Europe/Istanbul': 'TR', 'Europe/Jersey': 'JE',
|
|
186
|
+
'Europe/Kaliningrad': 'RU', 'Europe/Kiev': 'UA', 'Europe/Kirov': 'RU',
|
|
187
|
+
'Europe/Kyiv': 'UA', 'Europe/Lisbon': 'PT', 'Europe/Ljubljana': 'SI',
|
|
188
|
+
'Europe/London': 'GB', 'Europe/Luxembourg': 'LU', 'Europe/Madrid': 'ES',
|
|
189
|
+
'Europe/Malta': 'MT', 'Europe/Mariehamn': 'AX', 'Europe/Minsk': 'BY',
|
|
190
|
+
'Europe/Monaco': 'MC', 'Europe/Moscow': 'RU', 'Europe/Nicosia': 'CY',
|
|
191
|
+
'Europe/Oslo': 'NO', 'Europe/Paris': 'FR', 'Europe/Podgorica': 'ME',
|
|
192
|
+
'Europe/Prague': 'CZ', 'Europe/Riga': 'LV', 'Europe/Rome': 'IT',
|
|
193
|
+
'Europe/Samara': 'RU', 'Europe/San_Marino': 'SM', 'Europe/Sarajevo': 'BA',
|
|
194
|
+
'Europe/Saratov': 'RU', 'Europe/Simferopol': 'UA', 'Europe/Skopje': 'MK',
|
|
195
|
+
'Europe/Sofia': 'BG', 'Europe/Stockholm': 'SE', 'Europe/Tallinn': 'EE',
|
|
196
|
+
'Europe/Tirane': 'AL', 'Europe/Tiraspol': 'MD', 'Europe/Ulyanovsk': 'RU',
|
|
197
|
+
'Europe/Uzhgorod': 'UA', 'Europe/Vaduz': 'LI', 'Europe/Vatican': 'VA',
|
|
198
|
+
'Europe/Vienna': 'AT', 'Europe/Vilnius': 'LT', 'Europe/Volgograd': 'RU',
|
|
199
|
+
'Europe/Warsaw': 'PL', 'Europe/Zagreb': 'HR', 'Europe/Zaporozhye': 'UA',
|
|
200
|
+
'Europe/Zurich': 'CH',
|
|
201
|
+
'Arctic/Longyearbyen': 'SJ',
|
|
202
|
+
'Eire': 'IE', 'GB': 'GB', 'GB-Eire': 'GB', 'Poland': 'PL', 'Portugal': 'PT',
|
|
203
|
+
'W-SU': 'RU',
|
|
204
|
+
|
|
205
|
+
// Indian Ocean
|
|
206
|
+
'Indian/Antananarivo': 'MG', 'Indian/Chagos': 'IO', 'Indian/Christmas': 'CX',
|
|
207
|
+
'Indian/Cocos': 'CC', 'Indian/Comoro': 'KM', 'Indian/Kerguelen': 'TF',
|
|
208
|
+
'Indian/Mahe': 'SC', 'Indian/Maldives': 'MV', 'Indian/Mauritius': 'MU',
|
|
209
|
+
'Indian/Mayotte': 'YT', 'Indian/Reunion': 'RE',
|
|
210
|
+
|
|
211
|
+
// Pacific
|
|
212
|
+
'Pacific/Apia': 'WS', 'Pacific/Auckland': 'NZ', 'Pacific/Bougainville': 'PG',
|
|
213
|
+
'Pacific/Chatham': 'NZ', 'Pacific/Chuuk': 'FM', 'Pacific/Easter': 'CL',
|
|
214
|
+
'Pacific/Efate': 'VU', 'Pacific/Enderbury': 'KI', 'Pacific/Fakaofo': 'TK',
|
|
215
|
+
'Pacific/Fiji': 'FJ', 'Pacific/Funafuti': 'TV', 'Pacific/Galapagos': 'EC',
|
|
216
|
+
'Pacific/Gambier': 'PF', 'Pacific/Guadalcanal': 'SB', 'Pacific/Guam': 'GU',
|
|
217
|
+
'Pacific/Johnston': 'UM', 'Pacific/Kanton': 'KI', 'Pacific/Kiritimati': 'KI',
|
|
218
|
+
'Pacific/Kosrae': 'FM', 'Pacific/Kwajalein': 'MH', 'Pacific/Majuro': 'MH',
|
|
219
|
+
'Pacific/Marquesas': 'PF', 'Pacific/Midway': 'UM', 'Pacific/Nauru': 'NR',
|
|
220
|
+
'Pacific/Niue': 'NU', 'Pacific/Norfolk': 'NF', 'Pacific/Noumea': 'NC',
|
|
221
|
+
'Pacific/Pago_Pago': 'AS', 'Pacific/Palau': 'PW', 'Pacific/Pitcairn': 'PN',
|
|
222
|
+
'Pacific/Pohnpei': 'FM', 'Pacific/Ponape': 'FM', 'Pacific/Port_Moresby': 'PG',
|
|
223
|
+
'Pacific/Rarotonga': 'CK', 'Pacific/Saipan': 'MP', 'Pacific/Samoa': 'AS',
|
|
224
|
+
'Pacific/Tahiti': 'PF', 'Pacific/Tarawa': 'KI', 'Pacific/Tongatapu': 'TO',
|
|
225
|
+
'Pacific/Truk': 'FM', 'Pacific/Wake': 'UM', 'Pacific/Wallis': 'WF',
|
|
226
|
+
'Pacific/Yap': 'FM',
|
|
227
|
+
'Kwajalein': 'MH', 'NZ': 'NZ', 'NZ-CHAT': 'NZ', 'Navajo': 'US',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve a browser IANA timezone to the stored country value (display name
|
|
232
|
+
* when known, ISO code otherwise — same convention as the header-based path).
|
|
233
|
+
* Unknown / global zones (UTC, Etc/*) resolve to undefined.
|
|
234
|
+
*/
|
|
235
|
+
export function getCountryFromTimezone(tz?: string): string | undefined {
|
|
236
|
+
if (!tz || typeof tz !== 'string' || tz.length > 64)
|
|
237
|
+
return undefined
|
|
238
|
+
const code = TIMEZONE_COUNTRY[tz.trim()]
|
|
239
|
+
if (!code)
|
|
240
|
+
return undefined
|
|
241
|
+
return COUNTRY_NAMES[code] || code
|
|
242
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User agent parsing utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ParsedUserAgent {
|
|
6
|
+
deviceType: 'desktop' | 'mobile' | 'tablet'
|
|
7
|
+
browser: string
|
|
8
|
+
os: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse user agent string to extract device, browser, and OS info
|
|
13
|
+
*/
|
|
14
|
+
export function parseUserAgent(ua: string): ParsedUserAgent {
|
|
15
|
+
if (!ua || ua === 'unknown') {
|
|
16
|
+
return { deviceType: 'desktop', browser: 'Unknown', os: 'Unknown' }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Detect device type
|
|
20
|
+
let deviceType: 'desktop' | 'mobile' | 'tablet' = 'desktop'
|
|
21
|
+
if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i.test(ua)) {
|
|
22
|
+
deviceType = 'mobile'
|
|
23
|
+
}
|
|
24
|
+
else if (/ipad|tablet|playbook|silk/i.test(ua)) {
|
|
25
|
+
deviceType = 'tablet'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Detect browser - order matters (more specific first)
|
|
29
|
+
// Chromium-based browsers should be detected before Chrome
|
|
30
|
+
let browser = 'Unknown'
|
|
31
|
+
// Dia browser - check various possible formats
|
|
32
|
+
if (/\bdia\b|diahq|diabrowser/i.test(ua)) browser = 'Dia'
|
|
33
|
+
else if (/arc\//i.test(ua)) browser = 'Arc'
|
|
34
|
+
else if (/edg/i.test(ua)) browser = 'Edge'
|
|
35
|
+
else if (/opr\b|opera/i.test(ua)) browser = 'Opera'
|
|
36
|
+
else if (/brave/i.test(ua)) browser = 'Brave'
|
|
37
|
+
else if (/vivaldi/i.test(ua)) browser = 'Vivaldi'
|
|
38
|
+
else if (/yabrowser/i.test(ua)) browser = 'Yandex'
|
|
39
|
+
else if (/whale/i.test(ua)) browser = 'Whale'
|
|
40
|
+
else if (/puffin/i.test(ua)) browser = 'Puffin'
|
|
41
|
+
else if (/qqbrowser/i.test(ua)) browser = 'QQ Browser'
|
|
42
|
+
else if (/ucbrowser/i.test(ua)) browser = 'UC Browser'
|
|
43
|
+
else if (/samsungbrowser/i.test(ua)) browser = 'Samsung Internet'
|
|
44
|
+
else if (/silk/i.test(ua)) browser = 'Amazon Silk'
|
|
45
|
+
else if (/duckduckgo/i.test(ua)) browser = 'DuckDuckGo'
|
|
46
|
+
else if (/firefox|fxios/i.test(ua)) browser = 'Firefox'
|
|
47
|
+
else if (/chrome|chromium|crios/i.test(ua)) browser = 'Chrome'
|
|
48
|
+
else if (/safari/i.test(ua) && !/chrome|chromium/i.test(ua)) browser = 'Safari'
|
|
49
|
+
else if (/trident|msie/i.test(ua)) browser = 'IE'
|
|
50
|
+
else if (/bot|crawl|spider|slurp|bingpreview/i.test(ua)) browser = 'Bot'
|
|
51
|
+
|
|
52
|
+
// Detect OS
|
|
53
|
+
let os = 'Unknown'
|
|
54
|
+
if (/windows nt 10/i.test(ua)) os = 'Windows 10'
|
|
55
|
+
else if (/windows nt 11/i.test(ua)) os = 'Windows 11'
|
|
56
|
+
else if (/windows/i.test(ua)) os = 'Windows'
|
|
57
|
+
else if (/mac os x|macintosh/i.test(ua)) os = 'macOS'
|
|
58
|
+
else if (/iphone|ipad|ipod/i.test(ua)) os = 'iOS'
|
|
59
|
+
else if (/android/i.test(ua)) os = 'Android'
|
|
60
|
+
else if (/linux/i.test(ua)) os = 'Linux'
|
|
61
|
+
else if (/cros/i.test(ua)) os = 'Chrome OS'
|
|
62
|
+
|
|
63
|
+
return { deviceType, browser, os }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a user agent is a bot / non-human client (#166).
|
|
68
|
+
*
|
|
69
|
+
* Three signals, applied at ingestion so bots never pollute the numbers
|
|
70
|
+
* (Fathom-style):
|
|
71
|
+
* 1. A MISSING or unrecognizably short UA — real browsers always send one;
|
|
72
|
+
* UA-less curl/scripts previously counted as human visitors.
|
|
73
|
+
* 2. Generic bot markers (bot/crawl/spider/preview...).
|
|
74
|
+
* 3. Known non-human client families: headless browsers, HTTP libraries,
|
|
75
|
+
* monitoring agents, and AI scrapers.
|
|
76
|
+
*/
|
|
77
|
+
const BOT_UA_PATTERN = new RegExp([
|
|
78
|
+
// generic markers
|
|
79
|
+
'bot|crawl|spider|slurp|preview|scan|probe|monitor(?:ing)?|checker|validator',
|
|
80
|
+
// headless / automation
|
|
81
|
+
'headless|phantomjs|puppeteer|playwright|selenium|webdriver|electron',
|
|
82
|
+
// http libraries & CLI clients
|
|
83
|
+
'curl|wget|python-requests|python-urllib|aiohttp|httpx|go-http-client|okhttp',
|
|
84
|
+
'java/|libwww|lwp::|php/|guzzle|axios|node-fetch|undici|bun/',
|
|
85
|
+
// feed/link expanders, previews, misc known agents
|
|
86
|
+
'facebookexternalhit|whatsapp|telegrambot|discordbot|slackbot|twitterbot',
|
|
87
|
+
'pingdom|uptimerobot|statuscake|newrelic|datadog|site24x7',
|
|
88
|
+
// AI scrapers
|
|
89
|
+
'gptbot|claudebot|ccbot|bytespider|amazonbot|anthropic|perplexity',
|
|
90
|
+
].join('|'), 'i')
|
|
91
|
+
|
|
92
|
+
export function isBot(ua: string): boolean {
|
|
93
|
+
// No/garbage UA: every real browser sends a UA string. Treat absent or
|
|
94
|
+
// implausibly short values as non-human instead of counting them as
|
|
95
|
+
// visitors (previously `if (!ua) return false` let them ALL through).
|
|
96
|
+
if (!ua || ua === 'unknown' || ua.length < 12)
|
|
97
|
+
return true
|
|
98
|
+
return BOT_UA_PATTERN.test(ua)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get browser family (simplifies browser name to family)
|
|
103
|
+
*/
|
|
104
|
+
export function getBrowserFamily(browser: string): string {
|
|
105
|
+
const chromiumBased = ['Chrome', 'Edge', 'Opera', 'Brave', 'Vivaldi', 'Arc', 'Dia', 'Whale', 'Samsung Internet']
|
|
106
|
+
if (chromiumBased.includes(browser)) return 'Chromium'
|
|
107
|
+
if (browser === 'Firefox') return 'Firefox'
|
|
108
|
+
if (browser === 'Safari') return 'Safari'
|
|
109
|
+
return 'Other'
|
|
110
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracker/library version (#179) — embedded in the generated script, sent on
|
|
3
|
+
* every beacon (`v`), and aggregated into the hourly ingest counters, so
|
|
4
|
+
* support can tell exactly which tracker build a site is running instead of
|
|
5
|
+
* guessing at cache states. Kept in sync with package.json by a test.
|
|
6
|
+
*/
|
|
7
|
+
export const TRACKER_VERSION: string = '0.1.0'
|