@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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/app/assets/css/main.css +2 -0
- package/app/components/GscAnalyzerPanel.vue +94 -0
- package/app/components/GscAnonymizationBanner.vue +46 -0
- package/app/components/GscBootProgress.vue +297 -0
- package/app/components/GscCommandPalette.vue +77 -0
- package/app/components/GscDashboardPage.vue +26 -0
- package/app/components/GscEngineBadge.vue +28 -0
- package/app/components/GscHero.vue +215 -0
- package/app/components/GscLazyTopResult.vue +45 -0
- package/app/components/GscPerformanceChart.vue +532 -0
- package/app/components/GscPositionDistributionChart.vue +63 -0
- package/app/components/GscQueryLabel.vue +63 -0
- package/app/components/GscSitePageHeader.vue +40 -0
- package/app/components/GscSourceDebugPanel.vue +195 -0
- package/app/components/GscSyncStatusCell.vue +54 -0
- package/app/components/GscTopRollupCard.vue +90 -0
- package/app/composables/_useGscBackfill.ts +111 -0
- package/app/composables/_useGscQueryDispatcher.ts +122 -0
- package/app/composables/_useGscResource.ts +114 -0
- package/app/composables/_useGscSharedSiteResource.ts +136 -0
- package/app/composables/useGscAnalytics.ts +197 -0
- package/app/composables/useGscAnalyticsClient.ts +9 -0
- package/app/composables/useGscAnalyticsConfig.ts +8 -0
- package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
- package/app/composables/useGscAnalyzer.ts +374 -0
- package/app/composables/useGscAnalyzerBatch.ts +106 -0
- package/app/composables/useGscAnalyzerDefs.ts +118 -0
- package/app/composables/useGscAuth.ts +115 -0
- package/app/composables/useGscConsoleUrl.ts +45 -0
- package/app/composables/useGscCountries.ts +47 -0
- package/app/composables/useGscCurrentSite.ts +15 -0
- package/app/composables/useGscEngine.ts +16 -0
- package/app/composables/useGscInspectionHistory.ts +42 -0
- package/app/composables/useGscInspections.ts +52 -0
- package/app/composables/useGscPanelContext.ts +24 -0
- package/app/composables/useGscParquetTable.ts +199 -0
- package/app/composables/useGscPeriod.ts +243 -0
- package/app/composables/useGscQuery.ts +290 -0
- package/app/composables/useGscQueryDispatcher.ts +122 -0
- package/app/composables/useGscRequestIndexingInspect.ts +20 -0
- package/app/composables/useGscResource.ts +114 -0
- package/app/composables/useGscRollup.ts +252 -0
- package/app/composables/useGscRollupTable.ts +78 -0
- package/app/composables/useGscRowQuery.ts +56 -0
- package/app/composables/useGscSearchAppearance.ts +47 -0
- package/app/composables/useGscSitemapHistory.ts +41 -0
- package/app/composables/useGscSitemaps.ts +45 -0
- package/app/composables/useGscTableState.ts +119 -0
- package/app/plugins/analytics.ts +57 -0
- package/app/utils/anonymization.ts +24 -0
- package/app/utils/country-names.ts +56 -0
- package/app/utils/gsc-constants.ts +10 -0
- package/app/utils/gsc-error.ts +58 -0
- package/app/utils/gsc-fetch.ts +94 -0
- package/app/utils/gsc-filters.ts +32 -0
- package/app/utils/gsc-rows.ts +72 -0
- package/app/utils/position.ts +7 -0
- package/app/utils/setup-gsc-fetch-auth.ts +62 -0
- package/module.ts +81 -0
- package/nuxt.config.ts +36 -0
- package/package.json +75 -0
- 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>
|