@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,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
+ }