@gscdump/nuxt 0.19.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/app/assets/css/main.css +2 -0
  4. package/app/components/GscAnalyzerPanel.vue +94 -0
  5. package/app/components/GscAnonymizationBanner.vue +46 -0
  6. package/app/components/GscBootProgress.vue +297 -0
  7. package/app/components/GscCommandPalette.vue +77 -0
  8. package/app/components/GscDashboardPage.vue +26 -0
  9. package/app/components/GscEngineBadge.vue +28 -0
  10. package/app/components/GscHero.vue +215 -0
  11. package/app/components/GscLazyTopResult.vue +45 -0
  12. package/app/components/GscPerformanceChart.vue +532 -0
  13. package/app/components/GscPositionDistributionChart.vue +63 -0
  14. package/app/components/GscQueryLabel.vue +63 -0
  15. package/app/components/GscSitePageHeader.vue +40 -0
  16. package/app/components/GscSourceDebugPanel.vue +195 -0
  17. package/app/components/GscSyncStatusCell.vue +54 -0
  18. package/app/components/GscTopRollupCard.vue +90 -0
  19. package/app/composables/_useGscBackfill.ts +111 -0
  20. package/app/composables/_useGscQueryDispatcher.ts +122 -0
  21. package/app/composables/_useGscResource.ts +114 -0
  22. package/app/composables/_useGscSharedSiteResource.ts +136 -0
  23. package/app/composables/useGscAnalytics.ts +197 -0
  24. package/app/composables/useGscAnalyticsClient.ts +9 -0
  25. package/app/composables/useGscAnalyticsConfig.ts +8 -0
  26. package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
  27. package/app/composables/useGscAnalyzer.ts +374 -0
  28. package/app/composables/useGscAnalyzerBatch.ts +106 -0
  29. package/app/composables/useGscAnalyzerDefs.ts +118 -0
  30. package/app/composables/useGscAuth.ts +115 -0
  31. package/app/composables/useGscConsoleUrl.ts +45 -0
  32. package/app/composables/useGscCountries.ts +47 -0
  33. package/app/composables/useGscCurrentSite.ts +15 -0
  34. package/app/composables/useGscEngine.ts +16 -0
  35. package/app/composables/useGscInspectionHistory.ts +42 -0
  36. package/app/composables/useGscInspections.ts +52 -0
  37. package/app/composables/useGscPanelContext.ts +24 -0
  38. package/app/composables/useGscParquetTable.ts +199 -0
  39. package/app/composables/useGscPeriod.ts +243 -0
  40. package/app/composables/useGscQuery.ts +290 -0
  41. package/app/composables/useGscQueryDispatcher.ts +122 -0
  42. package/app/composables/useGscRequestIndexingInspect.ts +20 -0
  43. package/app/composables/useGscResource.ts +114 -0
  44. package/app/composables/useGscRollup.ts +252 -0
  45. package/app/composables/useGscRollupTable.ts +78 -0
  46. package/app/composables/useGscRowQuery.ts +56 -0
  47. package/app/composables/useGscSearchAppearance.ts +47 -0
  48. package/app/composables/useGscSitemapHistory.ts +41 -0
  49. package/app/composables/useGscSitemaps.ts +45 -0
  50. package/app/composables/useGscTableState.ts +119 -0
  51. package/app/plugins/analytics.ts +57 -0
  52. package/app/utils/anonymization.ts +24 -0
  53. package/app/utils/country-names.ts +56 -0
  54. package/app/utils/gsc-constants.ts +10 -0
  55. package/app/utils/gsc-error.ts +58 -0
  56. package/app/utils/gsc-fetch.ts +94 -0
  57. package/app/utils/gsc-filters.ts +32 -0
  58. package/app/utils/gsc-rows.ts +72 -0
  59. package/app/utils/position.ts +7 -0
  60. package/app/utils/setup-gsc-fetch-auth.ts +62 -0
  61. package/module.ts +81 -0
  62. package/nuxt.config.ts +36 -0
  63. package/package.json +75 -0
  64. package/types.ts +114 -0
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ // Thin page container: sets the `flex flex-col min-h-screen` chrome every
3
+ // site-scoped page has been repeating and provides a main-region slot with
4
+ // the shared max-width + padding. Use together with PageHeader.
5
+
6
+ const { gap = 'md' } = defineProps<{
7
+ /** Spacing between direct children of the main region. Maps to tailwind gap-3/4/5. */
8
+ gap?: 'sm' | 'md' | 'lg'
9
+ }>()
10
+
11
+ const gapClass = computed(() => (
12
+ gap === 'sm' ? 'gap-3' : gap === 'lg' ? 'gap-5' : 'gap-4'
13
+ ))
14
+ </script>
15
+
16
+ <template>
17
+ <div class="flex flex-col min-h-screen">
18
+ <slot name="header" />
19
+ <div
20
+ class="max-w-[1128px] px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
21
+ :class="gapClass"
22
+ >
23
+ <slot />
24
+ </div>
25
+ </div>
26
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ // Small observability pill: shows which backend served the query
3
+ // (browser/DuckDB-WASM vs server/D1), how long it took, and the fallback
4
+ // reason if the browser path tried and failed. Silent until data settles.
5
+
6
+ const { engine, elapsedMs = null, fallbackReason = null } = defineProps<{
7
+ engine: 'browser' | 'server' | null
8
+ elapsedMs?: number | null
9
+ fallbackReason?: string | null
10
+ }>()
11
+ </script>
12
+
13
+ <template>
14
+ <div v-if="engine" class="flex items-center gap-2 text-[10px] font-mono uppercase text-neutral-500">
15
+ <span
16
+ class="px-1.5 py-0.5 border"
17
+ :class="engine === 'browser'
18
+ ? 'border-cyan-500/40 text-cyan-400 bg-cyan-500/5'
19
+ : 'border-white/15 text-neutral-400 bg-white/5'"
20
+ >
21
+ engine: {{ engine }}
22
+ </span>
23
+ <span v-if="elapsedMs != null">{{ Math.round(elapsedMs) }} ms</span>
24
+ <span v-if="fallbackReason" class="text-amber-500" :title="fallbackReason">
25
+ fallback
26
+ </span>
27
+ </div>
28
+ </template>
@@ -0,0 +1,215 @@
1
+ <script setup lang="ts">
2
+ // Hero block: four stat cards (clicks / impressions / CTR / avg position) with
3
+ // growth badges vs. the comparison window, plus a PerformanceChart over the
4
+ // selected period. Fed directly by the `daily_totals` rollup payload so it
5
+ // works before DuckDB has finished booting.
6
+ //
7
+ // Consumes `DateRange` from useGscPeriod — the caller owns period/compare state
8
+ // and passes the resolved window in. Keeping the compute here means the stat
9
+ // grid and chart read from the same windowed slice.
10
+
11
+ import type { CompareMode, DateRange } from '../composables/useGscPeriod'
12
+ import { computeGrowth } from '../composables/useGscPeriod'
13
+
14
+ interface DailyTotal {
15
+ /** Rollup writer stamps this as Unix ms, not an ISO date. */
16
+ date: number
17
+ clicks: number
18
+ impressions: number
19
+ sum_position: number
20
+ anonymizedImpressionsPct: number
21
+ }
22
+
23
+ const { payload, range, compareMode } = defineProps<{
24
+ payload: readonly DailyTotal[] | null | undefined
25
+ range: DateRange
26
+ compareMode: CompareMode
27
+ }>()
28
+
29
+ function toIso(ms: number): string {
30
+ return new Date(ms).toISOString().slice(0, 10)
31
+ }
32
+
33
+ function daysBetween(a: string, b: string): number {
34
+ return Math.round((Date.parse(b) - Date.parse(a)) / 86400000)
35
+ }
36
+
37
+ function shiftIso(iso: string, days: number): string {
38
+ return new Date(Date.parse(iso) + days * 86400000).toISOString().slice(0, 10)
39
+ }
40
+
41
+ // Snap the requested window's `end` to the site's actual most-recent data
42
+ // point. Without this, a syncing site whose last day is earlier than the
43
+ // stable-latency cutoff compares a short-current window to a full-length
44
+ // previous window and shows a false decline.
45
+ function snapWindow(payload: readonly DailyTotal[], start: string, end: string): { start: string, end: string } {
46
+ let maxIso: string | null = null
47
+ for (const d of payload) {
48
+ const iso = toIso(d.date)
49
+ if (!maxIso || iso > maxIso)
50
+ maxIso = iso
51
+ }
52
+ if (!maxIso || maxIso >= end)
53
+ return { start, end }
54
+ const shift = daysBetween(end, maxIso)
55
+ return { start: shiftIso(start, shift), end: maxIso }
56
+ }
57
+
58
+ interface WindowTotals {
59
+ clicks: number
60
+ impressions: number
61
+ ctr: number
62
+ position: number
63
+ series: Array<{ date: string, clicks: number, impressions: number }>
64
+ }
65
+
66
+ function summarize(days: readonly DailyTotal[], start: string, end: string): WindowTotals {
67
+ let clicks = 0
68
+ let impressions = 0
69
+ let weightedPosition = 0
70
+ const series: WindowTotals['series'] = []
71
+ for (const d of days) {
72
+ const iso = toIso(d.date)
73
+ if (iso < start || iso > end)
74
+ continue
75
+ clicks += d.clicks
76
+ impressions += d.impressions
77
+ weightedPosition += d.sum_position
78
+ series.push({ date: iso, clicks: d.clicks, impressions: d.impressions })
79
+ }
80
+ return {
81
+ clicks,
82
+ impressions,
83
+ ctr: impressions > 0 ? clicks / impressions : 0,
84
+ position: impressions > 0 ? weightedPosition / impressions + 1 : 0,
85
+ series,
86
+ }
87
+ }
88
+
89
+ const stats = computed(() => {
90
+ if (!payload?.length)
91
+ return null
92
+ const snapped = snapWindow(payload, range.start, range.end)
93
+ const shift = daysBetween(range.end, snapped.end)
94
+ const cmpRawStart = compareMode === 'year' ? range.yearStart : range.prevStart
95
+ const cmpRawEnd = compareMode === 'year' ? range.yearEnd : range.prevEnd
96
+ const cmpStart = shift ? shiftIso(cmpRawStart, shift) : cmpRawStart
97
+ const cmpEnd = shift ? shiftIso(cmpRawEnd, shift) : cmpRawEnd
98
+ return {
99
+ current: summarize(payload, snapped.start, snapped.end),
100
+ previous: compareMode === 'none' ? null : summarize(payload, cmpStart, cmpEnd),
101
+ snapped,
102
+ }
103
+ })
104
+
105
+ function fmtInt(n: number): string {
106
+ return new Intl.NumberFormat().format(Math.round(n))
107
+ }
108
+ function fmtPct(n: number): string {
109
+ return `${(n * 100).toFixed(2)}%`
110
+ }
111
+ function fmtPos(n: number): string {
112
+ return n > 0 ? n.toFixed(1) : '–'
113
+ }
114
+ function fmtGrowth(g: number | null, invert = false): string {
115
+ if (g == null)
116
+ return ''
117
+ const v = invert ? -g : g
118
+ const sign = v > 0 ? '+' : ''
119
+ return `${sign}${(v * 100).toFixed(1)}%`
120
+ }
121
+ function growthColor(g: number | null, invert = false): 'success' | 'error' | 'neutral' {
122
+ if (g == null || Math.abs(g) < 0.005)
123
+ return 'neutral'
124
+ const v = invert ? -g : g
125
+ return v > 0 ? 'success' : 'error'
126
+ }
127
+
128
+ const statCards = computed(() => {
129
+ if (!stats.value)
130
+ return []
131
+ const { current, previous } = stats.value
132
+ return [
133
+ {
134
+ label: 'Clicks',
135
+ value: fmtInt(current.clicks),
136
+ icon: 'i-lucide-mouse-pointer-click',
137
+ growth: computeGrowth(current.clicks, previous?.clicks),
138
+ invert: false,
139
+ },
140
+ {
141
+ label: 'Impressions',
142
+ value: fmtInt(current.impressions),
143
+ icon: 'i-lucide-eye',
144
+ growth: computeGrowth(current.impressions, previous?.impressions),
145
+ invert: false,
146
+ },
147
+ {
148
+ label: 'CTR',
149
+ value: fmtPct(current.ctr),
150
+ icon: 'i-lucide-percent',
151
+ growth: computeGrowth(current.ctr, previous?.ctr),
152
+ invert: false,
153
+ },
154
+ {
155
+ label: 'Avg. position',
156
+ value: fmtPos(current.position),
157
+ icon: 'i-lucide-hash',
158
+ growth: computeGrowth(current.position, previous?.position),
159
+ invert: true,
160
+ },
161
+ ]
162
+ })
163
+
164
+ const chartData = computed(() => stats.value?.current.series ?? [])
165
+ const chartPrevData = computed(() => stats.value?.previous?.series ?? [])
166
+ </script>
167
+
168
+ <template>
169
+ <div class="flex flex-col gap-4">
170
+ <div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
171
+ <div
172
+ v-for="stat in statCards"
173
+ :key="stat.label"
174
+ class="rounded-lg border border-default bg-default p-4"
175
+ >
176
+ <div class="flex items-center gap-1.5 text-[11px] font-semibold text-dimmed uppercase tracking-widest">
177
+ <UIcon :name="stat.icon" class="size-3" />
178
+ {{ stat.label }}
179
+ </div>
180
+ <div class="flex items-baseline gap-2 mt-1.5">
181
+ <div class="text-2xl font-semibold text-default tabular-nums tracking-tight">
182
+ {{ stat.value }}
183
+ </div>
184
+ <UBadge
185
+ v-if="stats?.previous"
186
+ :color="growthColor(stat.growth, stat.invert)"
187
+ variant="soft"
188
+ size="xs"
189
+ class="tabular-nums"
190
+ >
191
+ {{ fmtGrowth(stat.growth, stat.invert) }}
192
+ </UBadge>
193
+ </div>
194
+ </div>
195
+ <div
196
+ v-if="!stats"
197
+ class="col-span-full rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted"
198
+ >
199
+ No data yet. Run <UKbd>gscdump sync</UKbd> to populate the daily_totals rollup.
200
+ </div>
201
+ </div>
202
+
203
+ <div
204
+ v-if="stats && chartData.length"
205
+ class="rounded-lg border border-default bg-default p-4"
206
+ >
207
+ <PerformanceChart
208
+ :data="chartData"
209
+ :previous-data="chartPrevData"
210
+ :show-comparison="compareMode !== 'none' && chartPrevData.length > 0"
211
+ :height="220"
212
+ />
213
+ </div>
214
+ </div>
215
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { useIntersectionObserver } from '@vueuse/core'
3
+ import { useGscFetch } from '../utils/gsc-fetch'
4
+
5
+ const props = defineProps<{
6
+ siteUrl: string
7
+ type: 'topPage' | 'topKeyword'
8
+ identifier: string
9
+ startDate: string
10
+ endDate: string
11
+ }>()
12
+
13
+ const container = useTemplateRef<HTMLElement>('container')
14
+ const value = ref<string | null>(null)
15
+ const loaded = ref(false)
16
+
17
+ const { stop } = useIntersectionObserver(
18
+ container,
19
+ ([entry]) => {
20
+ if (!entry?.isIntersecting || loaded.value)
21
+ return
22
+ loaded.value = true
23
+ stop()
24
+ useGscFetch()<{ value: string | null }>(`/api/__gsc/sites/${encodeURIComponent(props.siteUrl)}/data/top-association`, {
25
+ query: {
26
+ type: props.type,
27
+ identifier: props.identifier,
28
+ startDate: props.startDate,
29
+ endDate: props.endDate,
30
+ },
31
+ })
32
+ .then(r => value.value = r.value)
33
+ .catch(() => {})
34
+ },
35
+ { rootMargin: '100px' },
36
+ )
37
+ </script>
38
+
39
+ <template>
40
+ <div ref="container">
41
+ <div v-if="value" class="text-xs text-muted truncate max-w-md">
42
+ {{ value }}
43
+ </div>
44
+ </div>
45
+ </template>