@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,243 @@
|
|
|
1
|
+
// Period + comparison primitives. Pure utilities (date math, growth calc)
|
|
2
|
+
// plus the reactive `useGscPeriod()` composable that wires them into refs.
|
|
3
|
+
//
|
|
4
|
+
// Supports rolling presets (7d/28d/3m/6m/12m), calendar presets (this-week,
|
|
5
|
+
// this-month, last-month, this-quarter, this-year), and custom ranges
|
|
6
|
+
// (`custom:start:end` or `custom:start:end:prevStart:prevEnd`).
|
|
7
|
+
//
|
|
8
|
+
// Timezone via `runtimeConfig.public.analytics.timezone` (IANA name) or
|
|
9
|
+
// the `timezone` option. Default: UTC.
|
|
10
|
+
|
|
11
|
+
import { GSC_STABLE_LATENCY_DAYS } from '../utils/gsc-constants'
|
|
12
|
+
|
|
13
|
+
export type RollingPeriod = '7d' | '28d' | '3m' | '6m' | '12m'
|
|
14
|
+
export type CalendarPeriod = 'this-week' | 'this-month' | 'last-month' | 'this-quarter' | 'this-year'
|
|
15
|
+
export type CustomPeriod = `custom:${string}:${string}` | `custom:${string}:${string}:${string}:${string}`
|
|
16
|
+
export type Period = RollingPeriod | CalendarPeriod | CustomPeriod
|
|
17
|
+
export type CompareMode = 'previous' | 'year' | 'none'
|
|
18
|
+
|
|
19
|
+
export interface PeriodPreset {
|
|
20
|
+
value: RollingPeriod | CalendarPeriod
|
|
21
|
+
label: string
|
|
22
|
+
shortLabel: string
|
|
23
|
+
days: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PERIOD_PRESETS = [
|
|
27
|
+
{ value: '7d', label: 'Last 7 days', shortLabel: '7d', days: 7 },
|
|
28
|
+
{ value: '28d', label: 'Last 28 days', shortLabel: '28d', days: 28 },
|
|
29
|
+
{ value: '3m', label: 'Last 3 months', shortLabel: '3m', days: 90 },
|
|
30
|
+
{ value: '6m', label: 'Last 6 months', shortLabel: '6m', days: 180 },
|
|
31
|
+
{ value: '12m', label: 'Last 12 months', shortLabel: '12m', days: 365 },
|
|
32
|
+
{ value: 'this-week', label: 'This week', shortLabel: 'WTD', days: 7 },
|
|
33
|
+
{ value: 'this-month', label: 'This month', shortLabel: 'MTD', days: 31 },
|
|
34
|
+
{ value: 'last-month', label: 'Last month', shortLabel: 'LM', days: 31 },
|
|
35
|
+
{ value: 'this-quarter', label: 'This quarter', shortLabel: 'QTD', days: 92 },
|
|
36
|
+
{ value: 'this-year', label: 'This year', shortLabel: 'YTD', days: 365 },
|
|
37
|
+
] as const satisfies readonly PeriodPreset[]
|
|
38
|
+
|
|
39
|
+
export const COMPARE_OPTIONS: readonly { value: CompareMode, label: string }[] = [
|
|
40
|
+
{ value: 'previous', label: 'Previous period' },
|
|
41
|
+
{ value: 'year', label: 'Year over year' },
|
|
42
|
+
{ value: 'none', label: 'No comparison' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export interface DateRange {
|
|
46
|
+
start: string
|
|
47
|
+
end: string
|
|
48
|
+
days: number
|
|
49
|
+
prevStart: string
|
|
50
|
+
prevEnd: string
|
|
51
|
+
yearStart: string
|
|
52
|
+
yearEnd: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isCustomPeriod(p: Period | string): p is CustomPeriod {
|
|
56
|
+
return typeof p === 'string' && p.startsWith('custom:')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseCustomPeriod(p: Period | string): { start: string, end: string, prevStart?: string, prevEnd?: string } | null {
|
|
60
|
+
if (!isCustomPeriod(p))
|
|
61
|
+
return null
|
|
62
|
+
const [, start, end, prevStart, prevEnd] = p.split(':')
|
|
63
|
+
if (!start || !end)
|
|
64
|
+
return null
|
|
65
|
+
return prevStart && prevEnd ? { start, end, prevStart, prevEnd } : { start, end }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PeriodOptions {
|
|
69
|
+
/** Subtract GSC's stable-data latency from `end`. Default `true`. */
|
|
70
|
+
stableData?: boolean
|
|
71
|
+
/** IANA timezone (e.g. 'America/Los_Angeles'). Default UTC. */
|
|
72
|
+
timezone?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function periodToDateRange(period: Period | string, opts: PeriodOptions = {}): DateRange {
|
|
76
|
+
const stableData = opts.stableData ?? true
|
|
77
|
+
const custom = parseCustomPeriod(period)
|
|
78
|
+
if (custom) {
|
|
79
|
+
const range = buildRange(parseIso(custom.start), parseIso(custom.end))
|
|
80
|
+
if (custom.prevStart && custom.prevEnd) {
|
|
81
|
+
return {
|
|
82
|
+
...range,
|
|
83
|
+
prevStart: custom.prevStart,
|
|
84
|
+
prevEnd: custom.prevEnd,
|
|
85
|
+
yearStart: custom.prevStart,
|
|
86
|
+
yearEnd: custom.prevEnd,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return range
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const today = todayInTz(opts.timezone)
|
|
93
|
+
const end = stableData ? addDays(today, -GSC_STABLE_LATENCY_DAYS) : addDays(today, -1)
|
|
94
|
+
|
|
95
|
+
switch (period) {
|
|
96
|
+
case '7d': return buildRange(addDays(end, -6), end)
|
|
97
|
+
case '28d': return buildRange(addDays(end, -27), end)
|
|
98
|
+
case '3m': return buildRange(addDays(end, -89), end)
|
|
99
|
+
case '6m': return buildRange(addDays(end, -179), end)
|
|
100
|
+
case '12m': return buildRange(addDays(end, -364), end)
|
|
101
|
+
case 'this-week': return buildRange(startOfWeek(end), end)
|
|
102
|
+
case 'this-month': return buildRange(startOfMonth(end), end)
|
|
103
|
+
case 'last-month': {
|
|
104
|
+
const prev = addDays(startOfMonth(end), -1)
|
|
105
|
+
return buildRange(startOfMonth(prev), endOfMonth(prev))
|
|
106
|
+
}
|
|
107
|
+
case 'this-quarter': return buildRange(startOfQuarter(end), end)
|
|
108
|
+
case 'this-year': return buildRange(startOfYear(end), end)
|
|
109
|
+
default: return buildRange(addDays(end, -27), end)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function periodToDays(period: Period | string, opts?: PeriodOptions): number {
|
|
114
|
+
return periodToDateRange(period, opts).days
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function compareRange(range: DateRange, mode: CompareMode): { start: string, end: string } | null {
|
|
118
|
+
if (mode === 'none')
|
|
119
|
+
return null
|
|
120
|
+
if (mode === 'year')
|
|
121
|
+
return { start: range.yearStart, end: range.yearEnd }
|
|
122
|
+
return { start: range.prevStart, end: range.prevEnd }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getPeriodLabel(period: Period | string): string {
|
|
126
|
+
if (isCustomPeriod(period)) {
|
|
127
|
+
const c = parseCustomPeriod(period)
|
|
128
|
+
return c ? `${c.start} → ${c.end}` : 'Custom'
|
|
129
|
+
}
|
|
130
|
+
return PERIOD_PRESETS.find(p => p.value === period)?.label ?? String(period)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Relative growth as a fraction (0.12 = +12%). `null` if prev is zero or
|
|
135
|
+
* either side missing — caller renders "—" rather than divide-by-zero.
|
|
136
|
+
*/
|
|
137
|
+
export function computeGrowth(current: number, previous: number | null | undefined): number | null {
|
|
138
|
+
if (previous == null || previous === 0)
|
|
139
|
+
return null
|
|
140
|
+
return (current - previous) / previous
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface UseGscPeriodOptions {
|
|
144
|
+
defaultPeriod?: Period
|
|
145
|
+
defaultCompareMode?: CompareMode
|
|
146
|
+
defaultStableData?: boolean
|
|
147
|
+
/** Override `runtimeConfig.public.analytics.timezone`. */
|
|
148
|
+
timezone?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface UseGscPeriodReturn {
|
|
152
|
+
period: Ref<Period>
|
|
153
|
+
compareMode: Ref<CompareMode>
|
|
154
|
+
stableData: Ref<boolean>
|
|
155
|
+
range: ComputedRef<DateRange>
|
|
156
|
+
comparison: ComputedRef<{ start: string, end: string } | null>
|
|
157
|
+
label: ComputedRef<string>
|
|
158
|
+
presets: typeof PERIOD_PRESETS
|
|
159
|
+
compareOptions: typeof COMPARE_OPTIONS
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function useGscPeriod(opts: UseGscPeriodOptions = {}): UseGscPeriodReturn {
|
|
163
|
+
const period = useState<Period>('gsc:period', () => opts.defaultPeriod ?? '28d')
|
|
164
|
+
const compareMode = useState<CompareMode>('gsc:compareMode', () => opts.defaultCompareMode ?? 'previous')
|
|
165
|
+
const stableData = useState<boolean>('gsc:stableData', () => opts.defaultStableData ?? true)
|
|
166
|
+
|
|
167
|
+
const cfg = useRuntimeConfig().public.analytics as { timezone?: string } | undefined
|
|
168
|
+
const timezone = opts.timezone ?? cfg?.timezone ?? undefined
|
|
169
|
+
|
|
170
|
+
const range = computed(() => periodToDateRange(period.value, { stableData: stableData.value, timezone }))
|
|
171
|
+
const comparison = computed(() => compareRange(range.value, compareMode.value))
|
|
172
|
+
const label = computed(() => getPeriodLabel(period.value))
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
period,
|
|
176
|
+
compareMode,
|
|
177
|
+
stableData,
|
|
178
|
+
range,
|
|
179
|
+
comparison,
|
|
180
|
+
label,
|
|
181
|
+
presets: PERIOD_PRESETS,
|
|
182
|
+
compareOptions: COMPARE_OPTIONS,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function todayInTz(tz?: string): Date {
|
|
187
|
+
if (!tz) {
|
|
188
|
+
const now = new Date()
|
|
189
|
+
return new Date(`${now.toISOString().slice(0, 10)}T00:00:00Z`)
|
|
190
|
+
}
|
|
191
|
+
const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
192
|
+
return new Date(`${fmt.format(new Date())}T00:00:00Z`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseIso(s: string): Date {
|
|
196
|
+
return new Date(`${s}T00:00:00Z`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addDays(d: Date, n: number): Date {
|
|
200
|
+
const out = new Date(d)
|
|
201
|
+
out.setUTCDate(out.getUTCDate() + n)
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function iso(d: Date): string {
|
|
206
|
+
return d.toISOString().slice(0, 10)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function startOfWeek(d: Date): Date {
|
|
210
|
+
const day = d.getUTCDay()
|
|
211
|
+
return addDays(d, day === 0 ? -6 : 1 - day)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function startOfMonth(d: Date): Date {
|
|
215
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function endOfMonth(d: Date): Date {
|
|
219
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function startOfQuarter(d: Date): Date {
|
|
223
|
+
return new Date(Date.UTC(d.getUTCFullYear(), Math.floor(d.getUTCMonth() / 3) * 3, 1))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function startOfYear(d: Date): Date {
|
|
227
|
+
return new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildRange(start: Date, end: Date): DateRange {
|
|
231
|
+
const days = Math.round((end.getTime() - start.getTime()) / 86400000) + 1
|
|
232
|
+
const prevEnd = addDays(start, -1)
|
|
233
|
+
const prevStart = addDays(prevEnd, -(days - 1))
|
|
234
|
+
return {
|
|
235
|
+
start: iso(start),
|
|
236
|
+
end: iso(end),
|
|
237
|
+
days,
|
|
238
|
+
prevStart: iso(prevStart),
|
|
239
|
+
prevEnd: iso(prevEnd),
|
|
240
|
+
yearStart: iso(addDays(start, -365)),
|
|
241
|
+
yearEnd: iso(addDays(end, -365)),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Unified analytics query hook. Dispatches browser DuckDB-WASM vs server
|
|
2
|
+
// fallback, abort-safe, with typed status enum and opt-in backfill-on-demand.
|
|
3
|
+
//
|
|
4
|
+
// `site` is a parameter (not global state) so multi-site pages can issue
|
|
5
|
+
// independent queries. `serverFallback` is optional — the default POSTs
|
|
6
|
+
// AnalysisParams to `/api/__gsc/sites/[siteId]/analyze`. Override if your server
|
|
7
|
+
// uses a different contract.
|
|
8
|
+
|
|
9
|
+
import type { AnalysisParams, AnalysisResult } from '@gscdump/analysis'
|
|
10
|
+
import type { ComputedRef, Ref, WatchSource } from '@vue/runtime-core'
|
|
11
|
+
import { classifyGscError } from '../utils/gsc-error'
|
|
12
|
+
import { useGscBackfill } from './_useGscBackfill'
|
|
13
|
+
import { useGscQueryDispatcher } from './_useGscQueryDispatcher'
|
|
14
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
15
|
+
import { useGscAnalyzer } from './useGscAnalyzer'
|
|
16
|
+
import { _useGscAuthInternal } from './useGscAuth'
|
|
17
|
+
|
|
18
|
+
export type GscQueryEngine = 'auto' | 'browser' | 'server'
|
|
19
|
+
|
|
20
|
+
export type GscQueryStatus
|
|
21
|
+
= | 'idle'
|
|
22
|
+
| 'pending'
|
|
23
|
+
| 'success'
|
|
24
|
+
| 'empty'
|
|
25
|
+
| 'error'
|
|
26
|
+
| 'auth-missing'
|
|
27
|
+
| 'rate-limited'
|
|
28
|
+
| 'network'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Why a given query ended up on `browser` or `server`. Surfaced for
|
|
32
|
+
* dev tooling so 0% R2 utilisation can be diagnosed without guessing
|
|
33
|
+
* (e.g. opt-in off vs. site not eligible vs. attach failure).
|
|
34
|
+
*/
|
|
35
|
+
export type GscQueryDecisionReason
|
|
36
|
+
= | 'idle'
|
|
37
|
+
| 'ssr'
|
|
38
|
+
| 'disabled'
|
|
39
|
+
| 'forced:server'
|
|
40
|
+
| 'forced:browser'
|
|
41
|
+
| 'optin:off'
|
|
42
|
+
| 'auto:browser'
|
|
43
|
+
| 'auto:fallback'
|
|
44
|
+
|
|
45
|
+
export interface GscQueryDecision {
|
|
46
|
+
mode: 'browser' | 'server' | null
|
|
47
|
+
reason: GscQueryDecisionReason
|
|
48
|
+
/** Free-text detail for `auto:fallback` (the underlying error message). */
|
|
49
|
+
detail?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GscQueryMeta {
|
|
53
|
+
raw: Record<string, unknown> | null
|
|
54
|
+
backfillRequired?: { startDate: string, endDate: string }
|
|
55
|
+
retryAfter?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type GscBackfillRunner = ReturnType<typeof useGscBackfill>
|
|
59
|
+
|
|
60
|
+
export interface UseGscQueryOptions<T> {
|
|
61
|
+
/** Site id (reactive). Required — queries don't dispatch without a site. */
|
|
62
|
+
site: MaybeRefOrGetter<string | null | undefined>
|
|
63
|
+
/** Reactive analysis params (discriminated union by `.type`). */
|
|
64
|
+
params: MaybeRefOrGetter<AnalysisParams>
|
|
65
|
+
/**
|
|
66
|
+
* Transform raw `AnalysisResult` from the browser path into the consumer type.
|
|
67
|
+
* Omit to pass through as-is (return type becomes `AnalysisResult`).
|
|
68
|
+
*/
|
|
69
|
+
reshape?: (raw: AnalysisResult) => T
|
|
70
|
+
/**
|
|
71
|
+
* Server fallback override. Defaults to POSTing the params to
|
|
72
|
+
* `/api/__gsc/sites/[siteId]/analyze` and returning the response body.
|
|
73
|
+
*/
|
|
74
|
+
serverFallback?: (siteId: string, params: AnalysisParams) => Promise<T>
|
|
75
|
+
/**
|
|
76
|
+
* Force a specific engine. Default `'auto'`. Accepts a getter so callers
|
|
77
|
+
* can swing engine reactively (e.g. flip to `'server'` when the requested
|
|
78
|
+
* range overlaps a known coverage gap). Re-read on every `runQuery` and
|
|
79
|
+
* also included in the watcher graph so changes trigger a refetch.
|
|
80
|
+
*/
|
|
81
|
+
engine?: MaybeRefOrGetter<GscQueryEngine>
|
|
82
|
+
/** Extra reactive sources that should trigger refetch. */
|
|
83
|
+
watchSources?: WatchSource[]
|
|
84
|
+
/** Extract meta from the consumer payload. Defaults to `(out as any).meta`. */
|
|
85
|
+
extractMeta?: (out: T) => Record<string, unknown> | null | undefined
|
|
86
|
+
/**
|
|
87
|
+
* Backfill handling. `false` (default) = off. `true` = spin up a dedicated
|
|
88
|
+
* `useGscBackfill()`. Pass an existing runner to share across queries.
|
|
89
|
+
*/
|
|
90
|
+
backfill?: boolean | GscBackfillRunner
|
|
91
|
+
/** Gate the query — when `false` it stays idle. */
|
|
92
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface UseGscQueryReturn<T> {
|
|
96
|
+
data: Ref<T | null>
|
|
97
|
+
status: Ref<GscQueryStatus>
|
|
98
|
+
pending: ComputedRef<boolean>
|
|
99
|
+
error: Ref<Error | null>
|
|
100
|
+
engine: Ref<'browser' | 'server' | null>
|
|
101
|
+
elapsedMs: Ref<number | null>
|
|
102
|
+
fallbackReason: Ref<string | null>
|
|
103
|
+
/** Why this query ran where it ran. Updated on every dispatch. */
|
|
104
|
+
lastDecision: Ref<GscQueryDecision>
|
|
105
|
+
meta: Ref<GscQueryMeta>
|
|
106
|
+
backfill: GscBackfillRunner | null
|
|
107
|
+
refresh: () => Promise<void>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifyError(e: unknown): { status: GscQueryStatus, retryAfter?: number } {
|
|
111
|
+
const c = classifyGscError(e)
|
|
112
|
+
return { status: c.status as GscQueryStatus, retryAfter: c.retryAfter }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isEmpty(v: unknown): boolean {
|
|
116
|
+
if (v == null)
|
|
117
|
+
return true
|
|
118
|
+
if (Array.isArray(v))
|
|
119
|
+
return v.length === 0
|
|
120
|
+
if (typeof v === 'object') {
|
|
121
|
+
const rec = v as Record<string, unknown>
|
|
122
|
+
for (const key of ['rows', 'results', 'daily', 'items']) {
|
|
123
|
+
const arr = rec[key]
|
|
124
|
+
if (Array.isArray(arr))
|
|
125
|
+
return arr.length === 0
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function defaultServerFallback<T>(siteId: string, params: AnalysisParams): Promise<T> {
|
|
132
|
+
return await useGscAnalyticsClient().analyze<T>(siteId, params)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function useGscQuery<T = AnalysisResult>(opts: UseGscQueryOptions<T>): UseGscQueryReturn<T> {
|
|
136
|
+
const analyzer = useGscAnalyzer(opts.site)
|
|
137
|
+
const dispatcher = useGscQueryDispatcher()
|
|
138
|
+
|
|
139
|
+
const data = shallowRef<T | null>(null)
|
|
140
|
+
const status = ref<GscQueryStatus>('idle')
|
|
141
|
+
const error = ref<Error | null>(null)
|
|
142
|
+
const engine = ref<'browser' | 'server' | null>(null)
|
|
143
|
+
const elapsedMs = ref<number | null>(null)
|
|
144
|
+
const fallbackReason = ref<string | null>(null)
|
|
145
|
+
const lastDecision = ref<GscQueryDecision>({ mode: null, reason: 'idle' })
|
|
146
|
+
const meta = ref<GscQueryMeta>({ raw: null })
|
|
147
|
+
const pending = computed(() => status.value === 'pending')
|
|
148
|
+
|
|
149
|
+
function extractMeta(out: T): Record<string, unknown> | null {
|
|
150
|
+
if (opts.extractMeta) {
|
|
151
|
+
const m = opts.extractMeta(out)
|
|
152
|
+
return m ?? null
|
|
153
|
+
}
|
|
154
|
+
const m = (out as unknown as { meta?: Record<string, unknown> })?.meta
|
|
155
|
+
return m ?? null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function captureMeta(out: T): void {
|
|
159
|
+
const raw = extractMeta(out)
|
|
160
|
+
const backfillRequired = (raw as { backfillRequired?: { startDate: string, endDate: string } } | null)?.backfillRequired
|
|
161
|
+
meta.value = backfillRequired ? { raw, backfillRequired } : { raw }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runServer(siteId: string): Promise<void> {
|
|
165
|
+
const t0 = performance.now()
|
|
166
|
+
const fn = opts.serverFallback ?? defaultServerFallback
|
|
167
|
+
const out = await fn(siteId, toValue(opts.params)) as T
|
|
168
|
+
data.value = out
|
|
169
|
+
engine.value = 'server'
|
|
170
|
+
elapsedMs.value = performance.now() - t0
|
|
171
|
+
captureMeta(out)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function runBrowser(signal: AbortSignal): Promise<void> {
|
|
175
|
+
const out = await analyzer.analyze(toValue(opts.params), { signal })
|
|
176
|
+
signal.throwIfAborted()
|
|
177
|
+
const shaped = opts.reshape ? opts.reshape(out) : (out as unknown as T)
|
|
178
|
+
data.value = shaped
|
|
179
|
+
engine.value = 'browser'
|
|
180
|
+
elapsedMs.value = (out as unknown as { queryMs?: number }).queryMs ?? null
|
|
181
|
+
captureMeta(shaped)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let activeController: AbortController | null = null
|
|
185
|
+
|
|
186
|
+
async function runQuery(): Promise<void> {
|
|
187
|
+
if (!import.meta.client) {
|
|
188
|
+
status.value = 'idle'
|
|
189
|
+
lastDecision.value = { mode: null, reason: 'ssr' }
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (opts.enabled && !toValue(opts.enabled)) {
|
|
193
|
+
status.value = 'idle'
|
|
194
|
+
data.value = null
|
|
195
|
+
engine.value = null
|
|
196
|
+
elapsedMs.value = null
|
|
197
|
+
lastDecision.value = { mode: null, reason: 'disabled' }
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const siteId = toValue(opts.site)
|
|
201
|
+
if (!siteId) {
|
|
202
|
+
status.value = 'idle'
|
|
203
|
+
lastDecision.value = { mode: null, reason: 'idle' }
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
activeController?.abort()
|
|
207
|
+
const controller = new AbortController()
|
|
208
|
+
activeController = controller
|
|
209
|
+
status.value = 'pending'
|
|
210
|
+
error.value = null
|
|
211
|
+
fallbackReason.value = null
|
|
212
|
+
|
|
213
|
+
const decision = dispatcher.pickEngine(_useGscAuthInternal().value, { perCall: toValue(opts.engine) })
|
|
214
|
+
lastDecision.value = decision
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
if (decision.mode === 'server') {
|
|
218
|
+
await runServer(siteId)
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// Browser-mode: when the caller asked for `auto` we fall back to server
|
|
222
|
+
// on failure; explicit `browser` propagates the error.
|
|
223
|
+
await runBrowser(controller.signal).catch(async (e) => {
|
|
224
|
+
if (e?.name === 'AbortError')
|
|
225
|
+
throw e
|
|
226
|
+
if (decision.requested !== 'auto')
|
|
227
|
+
throw e
|
|
228
|
+
fallbackReason.value = e instanceof Error ? e.message : String(e)
|
|
229
|
+
console.warn('[useGscQuery] browser failed, falling back to server:', fallbackReason.value)
|
|
230
|
+
dispatcher.reportFallback({
|
|
231
|
+
reason: fallbackReason.value,
|
|
232
|
+
at: Date.now(),
|
|
233
|
+
url: typeof location !== 'undefined' ? location.pathname : '',
|
|
234
|
+
})
|
|
235
|
+
lastDecision.value = { mode: 'server', reason: 'auto:fallback', detail: fallbackReason.value }
|
|
236
|
+
await runServer(siteId)
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
if (!controller.signal.aborted)
|
|
240
|
+
status.value = isEmpty(data.value) ? 'empty' : 'success'
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
if ((e as { name?: string })?.name === 'AbortError')
|
|
244
|
+
return
|
|
245
|
+
error.value = e instanceof Error ? e : new Error(String(e))
|
|
246
|
+
const classified = classifyError(e)
|
|
247
|
+
status.value = classified.status
|
|
248
|
+
if (classified.retryAfter != null)
|
|
249
|
+
meta.value = { ...meta.value, retryAfter: classified.retryAfter }
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (activeController === controller)
|
|
253
|
+
activeController = null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (import.meta.client) {
|
|
258
|
+
onScopeDispose(() => {
|
|
259
|
+
activeController?.abort()
|
|
260
|
+
activeController = null
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const sources: WatchSource[] = [
|
|
265
|
+
() => toValue(opts.site),
|
|
266
|
+
() => toValue(opts.params),
|
|
267
|
+
...(opts.watchSources ?? []),
|
|
268
|
+
]
|
|
269
|
+
if (opts.enabled)
|
|
270
|
+
sources.push(() => toValue(opts.enabled))
|
|
271
|
+
if (opts.engine !== undefined)
|
|
272
|
+
sources.push(() => toValue(opts.engine))
|
|
273
|
+
watch(sources, runQuery, { deep: true, immediate: true })
|
|
274
|
+
|
|
275
|
+
let backfill: GscBackfillRunner | null = null
|
|
276
|
+
const b = opts.backfill ?? false
|
|
277
|
+
if (b !== false) {
|
|
278
|
+
backfill = b === true ? useGscBackfill() : b
|
|
279
|
+
watch(() => meta.value.backfillRequired, (req: { startDate: string, endDate: string } | undefined) => {
|
|
280
|
+
if (!req)
|
|
281
|
+
return
|
|
282
|
+
const siteId = toValue(opts.site)
|
|
283
|
+
if (!siteId)
|
|
284
|
+
return
|
|
285
|
+
backfill!.maybeTrigger({ backfillRequired: req }, siteId, runQuery)
|
|
286
|
+
}, { immediate: true })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { data, status, pending, error, engine, elapsedMs, fallbackReason, lastDecision, meta, backfill, refresh: runQuery }
|
|
290
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Per-query dispatch policy + fallback telemetry behind one swappable seam.
|
|
2
|
+
//
|
|
3
|
+
// `useGscQuery` owns *intent* (the caller's `engine: 'auto' | 'browser' | 'server'`);
|
|
4
|
+
// the dispatcher owns *policy* (how `auto` resolves against current auth, what
|
|
5
|
+
// to do with auto-fallback events). Default impl is no-op telemetry — hosts
|
|
6
|
+
// that want fallback beacons override the provide with a configured factory.
|
|
7
|
+
|
|
8
|
+
import type { _useGscAuthInternal } from './useGscAuth'
|
|
9
|
+
import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
|
|
10
|
+
import { useGscEngine } from './useGscEngine'
|
|
11
|
+
|
|
12
|
+
type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
|
|
13
|
+
|
|
14
|
+
export interface GscEngineDecision {
|
|
15
|
+
mode: 'browser' | 'server'
|
|
16
|
+
reason: GscQueryDecisionReason
|
|
17
|
+
/** The resolved intent before the auth/optin mapping — useful for callers that branch on "did the user ask for auto?". */
|
|
18
|
+
requested: GscQueryEngine
|
|
19
|
+
detail?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GscFallbackEvent {
|
|
23
|
+
reason: string
|
|
24
|
+
at: number
|
|
25
|
+
url: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PickEngineOpts {
|
|
29
|
+
/** Per-call override. Wins over `useGscEngine()` + `runtimeConfig.public.analytics.defaultEngine`. */
|
|
30
|
+
perCall?: GscQueryEngine
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GscQueryDispatcher {
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the active engine into a concrete mode + reason. Consults the
|
|
36
|
+
* resolution chain: `opts.perCall` → `useGscEngine()` → runtimeConfig →
|
|
37
|
+
* `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
|
|
38
|
+
*/
|
|
39
|
+
pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
|
|
40
|
+
/** Called once per auto-fallback. Default impl is a no-op. */
|
|
41
|
+
reportFallback: (event: GscFallbackEvent) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreateDefaultDispatcherOpts {
|
|
45
|
+
/** When set, fallbacks beacon to this URL with batched payloads. */
|
|
46
|
+
telemetryEndpoint?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FLUSH_DELAY_MS = 5000
|
|
50
|
+
|
|
51
|
+
function createReporter(endpoint: string): (event: GscFallbackEvent) => void {
|
|
52
|
+
const buffer: GscFallbackEvent[] = []
|
|
53
|
+
let flushScheduled = false
|
|
54
|
+
|
|
55
|
+
function flush(): void {
|
|
56
|
+
flushScheduled = false
|
|
57
|
+
if (buffer.length === 0)
|
|
58
|
+
return
|
|
59
|
+
const events = buffer.splice(0)
|
|
60
|
+
const body = JSON.stringify({ events })
|
|
61
|
+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
62
|
+
navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
fetch(endpoint, { method: 'POST', body, headers: { 'content-type': 'application/json' } }).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scheduleFlush(): void {
|
|
69
|
+
if (flushScheduled || typeof window === 'undefined')
|
|
70
|
+
return
|
|
71
|
+
flushScheduled = true
|
|
72
|
+
setTimeout(flush, FLUSH_DELAY_MS)
|
|
73
|
+
if (typeof document !== 'undefined') {
|
|
74
|
+
const handler = (): void => {
|
|
75
|
+
if (document.visibilityState === 'hidden')
|
|
76
|
+
flush()
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener('visibilitychange', handler, { once: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (event: GscFallbackEvent): void => {
|
|
83
|
+
buffer.push(event)
|
|
84
|
+
scheduleFlush()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createDefaultGscQueryDispatcher(
|
|
89
|
+
opts: CreateDefaultDispatcherOpts = {},
|
|
90
|
+
): GscQueryDispatcher {
|
|
91
|
+
const reportFallback = opts.telemetryEndpoint
|
|
92
|
+
? createReporter(opts.telemetryEndpoint)
|
|
93
|
+
: (): void => {}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
pickEngine(auth, opts = {}): GscEngineDecision {
|
|
97
|
+
const requested = opts.perCall ?? resolveDefaultEngine()
|
|
98
|
+
if (requested === 'server')
|
|
99
|
+
return { mode: 'server', reason: 'forced:server', requested }
|
|
100
|
+
if (requested === 'browser')
|
|
101
|
+
return { mode: 'browser', reason: 'forced:browser', requested }
|
|
102
|
+
// 'auto': when the host has wired setGscAuth, derive from the per-user
|
|
103
|
+
// browserAnalyzerEnabled flag. When unwired, preserve legacy 'auto' = browser.
|
|
104
|
+
if (auth._initialized && !auth.browserAnalyzerEnabled)
|
|
105
|
+
return { mode: 'server', reason: 'optin:off', requested }
|
|
106
|
+
return { mode: 'browser', reason: 'auto:browser', requested }
|
|
107
|
+
},
|
|
108
|
+
reportFallback,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveDefaultEngine(): GscQueryEngine {
|
|
113
|
+
const override = useGscEngine().value
|
|
114
|
+
if (override)
|
|
115
|
+
return override
|
|
116
|
+
const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
|
|
117
|
+
return cfg?.defaultEngine ?? 'auto'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useGscQueryDispatcher(): GscQueryDispatcher {
|
|
121
|
+
return useNuxtApp().$gscQueryDispatcher
|
|
122
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IndexingInspectRateLimited, IndexingInspectResponse } from '@gscdump/contracts'
|
|
2
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
3
|
+
|
|
4
|
+
export function useGscRequestIndexingInspect(): (
|
|
5
|
+
siteId: string,
|
|
6
|
+
urls: string[],
|
|
7
|
+
) => Promise<IndexingInspectResponse | IndexingInspectRateLimited> {
|
|
8
|
+
const client = useGscAnalyticsClient()
|
|
9
|
+
return async (siteId, urls) => {
|
|
10
|
+
return client.requestIndexingInspect(siteId, { urls }).catch((err: unknown) => {
|
|
11
|
+
const status = (err as { status?: number, statusCode?: number, response?: { status?: number } })?.status
|
|
12
|
+
?? (err as { statusCode?: number })?.statusCode
|
|
13
|
+
?? (err as { response?: { status?: number } })?.response?.status
|
|
14
|
+
const data = (err as { data?: unknown })?.data as IndexingInspectRateLimited | undefined
|
|
15
|
+
if (status === 429 && data && data.error === 'rate_limited')
|
|
16
|
+
return data
|
|
17
|
+
throw err
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
}
|