@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,114 @@
|
|
|
1
|
+
// Site-keyed async resource composable. Owns key-watch, stale-token discard,
|
|
2
|
+
// status classification, refresh, and dispose for every read-only `/api/__gsc/*`
|
|
3
|
+
// fetcher in the layer. Resource composables (useGscSitemaps,
|
|
4
|
+
// useGscIndexingDiagnostics, …) are thin adapters that wire reactive keys to
|
|
5
|
+
// `useGscAnalyticsClient().getX(...)` plus optional derived computeds.
|
|
6
|
+
//
|
|
7
|
+
// Stale-token (not AbortController) because `@gscdump/sdk`'s AnalyticsClient
|
|
8
|
+
// doesn't accept a signal. Late-arriving promises from a superseded run are
|
|
9
|
+
// dropped on `data`/`status` writes; the in-flight fetch still runs to
|
|
10
|
+
// completion, which is acceptable for read-only GETs.
|
|
11
|
+
|
|
12
|
+
import type { ComputedRef, Ref, WatchSource } from 'vue'
|
|
13
|
+
import type { GscErrorStatus } from '../utils/gsc-error'
|
|
14
|
+
import { classifyGscError } from '../utils/gsc-error'
|
|
15
|
+
|
|
16
|
+
export type GscResourceStatus = 'idle' | 'pending' | 'success' | 'empty' | GscErrorStatus
|
|
17
|
+
|
|
18
|
+
export interface UseGscResourceOptions<TArgs extends readonly unknown[], TData> {
|
|
19
|
+
/** Reactive args passed to the fetcher. Resource stays idle while any required key is null/undefined. */
|
|
20
|
+
keys: { [I in keyof TArgs]: MaybeRefOrGetter<TArgs[I] | null | undefined> }
|
|
21
|
+
/** Async fetcher invoked with the resolved keys. */
|
|
22
|
+
fetcher: (...args: TArgs) => Promise<TData | null>
|
|
23
|
+
/** Predicate for the `empty` status — defaults to checking `null`/`[]`/typical container fields. */
|
|
24
|
+
isEmpty?: (data: TData) => boolean
|
|
25
|
+
/** Extra reactive sources that should retrigger a fetch. */
|
|
26
|
+
watchSources?: WatchSource[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseGscResourceReturn<TData> {
|
|
30
|
+
data: Ref<TData | null>
|
|
31
|
+
status: Ref<GscResourceStatus>
|
|
32
|
+
loading: ComputedRef<boolean>
|
|
33
|
+
error: Ref<Error | null>
|
|
34
|
+
refresh: () => Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultIsEmpty(v: unknown): boolean {
|
|
38
|
+
if (v == null)
|
|
39
|
+
return true
|
|
40
|
+
if (Array.isArray(v))
|
|
41
|
+
return v.length === 0
|
|
42
|
+
if (typeof v === 'object') {
|
|
43
|
+
const rec = v as Record<string, unknown>
|
|
44
|
+
for (const key of ['rows', 'records', 'snapshots', 'items', 'results']) {
|
|
45
|
+
const arr = rec[key]
|
|
46
|
+
if (Array.isArray(arr))
|
|
47
|
+
return arr.length === 0
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useGscResource<TArgs extends readonly unknown[], TData>(
|
|
54
|
+
opts: UseGscResourceOptions<TArgs, TData>,
|
|
55
|
+
): UseGscResourceReturn<TData> {
|
|
56
|
+
const data = shallowRef<TData | null>(null)
|
|
57
|
+
const status = ref<GscResourceStatus>('idle')
|
|
58
|
+
const error = ref<Error | null>(null)
|
|
59
|
+
const loading = computed(() => status.value === 'pending')
|
|
60
|
+
|
|
61
|
+
let runToken = 0
|
|
62
|
+
|
|
63
|
+
function resolveKeys(): TArgs | null {
|
|
64
|
+
const out: unknown[] = []
|
|
65
|
+
for (const k of opts.keys) {
|
|
66
|
+
const v = toValue(k)
|
|
67
|
+
if (v == null || v === '')
|
|
68
|
+
return null
|
|
69
|
+
out.push(v)
|
|
70
|
+
}
|
|
71
|
+
return out as unknown as TArgs
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function refresh(): Promise<void> {
|
|
75
|
+
const args = resolveKeys()
|
|
76
|
+
const token = ++runToken
|
|
77
|
+
if (!args) {
|
|
78
|
+
data.value = null
|
|
79
|
+
status.value = 'idle'
|
|
80
|
+
error.value = null
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
status.value = 'pending'
|
|
84
|
+
error.value = null
|
|
85
|
+
try {
|
|
86
|
+
const out = await opts.fetcher(...args)
|
|
87
|
+
if (token !== runToken)
|
|
88
|
+
return
|
|
89
|
+
data.value = out
|
|
90
|
+
const empty = out == null || (opts.isEmpty ?? defaultIsEmpty)(out)
|
|
91
|
+
status.value = empty ? 'empty' : 'success'
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (token !== runToken)
|
|
95
|
+
return
|
|
96
|
+
const classified = classifyGscError(e)
|
|
97
|
+
error.value = e instanceof Error ? e : new Error(String(e))
|
|
98
|
+
status.value = classified.status
|
|
99
|
+
data.value = null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
watch(
|
|
104
|
+
[...opts.keys.map(k => () => toValue(k)), ...(opts.watchSources ?? [])],
|
|
105
|
+
refresh,
|
|
106
|
+
{ immediate: true },
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
onScopeDispose(() => {
|
|
110
|
+
runToken++
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return { data, status, loading, error, refresh }
|
|
114
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Rollup fetchers.
|
|
2
|
+
//
|
|
3
|
+
// useGscRollup(site, id) → { data, envelope, loading, refresh }
|
|
4
|
+
// useGscRollups(site, [id, ...]) → { get, payload, envelopes, loading, refresh }
|
|
5
|
+
// useGscRollupFanout(sites, id) → { envelopes, loading, refresh }
|
|
6
|
+
//
|
|
7
|
+
// The single/multi-site overload on `useGscRollups` from the previous API was
|
|
8
|
+
// split into three focused hooks so each has one return shape.
|
|
9
|
+
//
|
|
10
|
+
// Progress flows to the shared analytics map so <GscBootProgress> lights up.
|
|
11
|
+
|
|
12
|
+
import type { RollupEnvelope } from '@gscdump/contracts'
|
|
13
|
+
import type { SiteListItem } from './useGscAnalytics'
|
|
14
|
+
import { useGscResource } from './_useGscResource'
|
|
15
|
+
import { useGscAnalyticsContext } from './useGscAnalytics'
|
|
16
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
17
|
+
|
|
18
|
+
type RollupsInput = MaybeRefOrGetter<string | readonly string[]>
|
|
19
|
+
type SiteLike = string | { id: string } | SiteListItem
|
|
20
|
+
|
|
21
|
+
export interface UseGscRollupReturn<T> {
|
|
22
|
+
data: Ref<T | null>
|
|
23
|
+
envelope: Ref<RollupEnvelope<T> | null>
|
|
24
|
+
loading: Readonly<Ref<boolean>>
|
|
25
|
+
refresh: () => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseGscRollupsReturn<T> {
|
|
29
|
+
get: (rollupId: string) => RollupEnvelope<T> | null
|
|
30
|
+
payload: (rollupId: string) => T | null
|
|
31
|
+
envelopes: Ref<Record<string, RollupEnvelope<T> | null>>
|
|
32
|
+
loading: Readonly<Ref<boolean>>
|
|
33
|
+
refresh: () => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UseGscRollupFanoutReturn<T> {
|
|
37
|
+
/** `{ [siteId]: envelope | null }` — null for sites missing the rollup. */
|
|
38
|
+
envelopes: Ref<Record<string, RollupEnvelope<T> | null>>
|
|
39
|
+
loading: Readonly<Ref<boolean>>
|
|
40
|
+
/**
|
|
41
|
+
* How many fan-out fetches have resolved so far (incl. nulls). Useful for
|
|
42
|
+
* showing "loaded X/Y" hints in tiers where each site is a live API call.
|
|
43
|
+
*/
|
|
44
|
+
progress: Readonly<Ref<{ completed: number, total: number }>>
|
|
45
|
+
refresh: () => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseGscRollupOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Optional reactive `{ start, end }` ISO-date window. When provided, the
|
|
51
|
+
* endpoint is free to synthesize the rollup for that window (how it does
|
|
52
|
+
* so is host-specific). Rollups that are range-agnostic (e.g. the
|
|
53
|
+
* client-sliced `daily_totals`) safely ignore these params.
|
|
54
|
+
*/
|
|
55
|
+
range?: MaybeRefOrGetter<{ start: string, end: string } | null | undefined>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fetch one rollup for one site. */
|
|
59
|
+
export function useGscRollup<T = unknown>(
|
|
60
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
61
|
+
rollupId: MaybeRefOrGetter<string>,
|
|
62
|
+
opts: UseGscRollupOptions = {},
|
|
63
|
+
): UseGscRollupReturn<T> {
|
|
64
|
+
const ctx = useGscAnalyticsContext()
|
|
65
|
+
const resource = useGscResource<[string, string], RollupEnvelope<T> | null>({
|
|
66
|
+
keys: [siteId, rollupId],
|
|
67
|
+
fetcher: (id, rid) => fetchOne<T>(id, rid, ctx, toValue(opts.range) ?? null),
|
|
68
|
+
watchSources: [() => toValue(opts.range)?.start, () => toValue(opts.range)?.end],
|
|
69
|
+
isEmpty: env => env == null,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
data: computed<T | null>(() => resource.data.value?.payload ?? null),
|
|
74
|
+
envelope: resource.data as unknown as Ref<RollupEnvelope<T> | null>,
|
|
75
|
+
loading: resource.loading as unknown as Readonly<Ref<boolean>>,
|
|
76
|
+
refresh: resource.refresh,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Fetch N rollups for one site. */
|
|
81
|
+
export function useGscRollups<T = unknown>(
|
|
82
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
83
|
+
rollupIds: RollupsInput,
|
|
84
|
+
opts: UseGscRollupOptions = {},
|
|
85
|
+
): UseGscRollupsReturn<T> {
|
|
86
|
+
const ctx = useGscAnalyticsContext()
|
|
87
|
+
|
|
88
|
+
const envelopes = ref<Record<string, RollupEnvelope<T> | null>>({})
|
|
89
|
+
const loading = ref(false)
|
|
90
|
+
|
|
91
|
+
async function refresh(): Promise<void> {
|
|
92
|
+
const sid = toValue(siteId)
|
|
93
|
+
const rids = normaliseRollups(toValue(rollupIds))
|
|
94
|
+
if (!sid || rids.length === 0) {
|
|
95
|
+
envelopes.value = {}
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
loading.value = true
|
|
99
|
+
const range = toValue(opts.range) ?? null
|
|
100
|
+
const next: Record<string, RollupEnvelope<T> | null> = {}
|
|
101
|
+
try {
|
|
102
|
+
await Promise.all(rids.map(async (rid) => {
|
|
103
|
+
next[rid] = await fetchOne<T>(sid, rid, ctx, range)
|
|
104
|
+
}))
|
|
105
|
+
envelopes.value = next
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
loading.value = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
watch(
|
|
113
|
+
() => [toValue(siteId), normaliseRollups(toValue(rollupIds)).join(','), toValue(opts.range)?.start, toValue(opts.range)?.end],
|
|
114
|
+
refresh,
|
|
115
|
+
{ immediate: true },
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
get: (rollupId: string) => envelopes.value[rollupId] ?? null,
|
|
120
|
+
payload: (rollupId: string) => envelopes.value[rollupId]?.payload ?? null,
|
|
121
|
+
envelopes,
|
|
122
|
+
loading: loading as Readonly<Ref<boolean>>,
|
|
123
|
+
refresh,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fan one rollup id across N sites — returns `{ [siteId]: envelope | null }`.
|
|
129
|
+
* Used by the "all sites" overview page; composes without an overload.
|
|
130
|
+
*/
|
|
131
|
+
export function useGscRollupFanout<T = unknown>(
|
|
132
|
+
sites: MaybeRefOrGetter<readonly SiteLike[] | null | undefined>,
|
|
133
|
+
rollupId: MaybeRefOrGetter<string>,
|
|
134
|
+
opts: UseGscRollupOptions = {},
|
|
135
|
+
): UseGscRollupFanoutReturn<T> {
|
|
136
|
+
const ctx = useGscAnalyticsContext()
|
|
137
|
+
const envelopes = ref<Record<string, RollupEnvelope<T> | null>>({})
|
|
138
|
+
const loading = ref(false)
|
|
139
|
+
const progress = ref<{ completed: number, total: number }>({ completed: 0, total: 0 })
|
|
140
|
+
// Ensures late-arriving promises from a superseded run don't mutate state
|
|
141
|
+
// for the current run (e.g. when the range changes rapidly).
|
|
142
|
+
let runToken = 0
|
|
143
|
+
|
|
144
|
+
async function refresh(): Promise<void> {
|
|
145
|
+
const token = ++runToken
|
|
146
|
+
const siteIds = normaliseSites(toValue(sites))
|
|
147
|
+
const rid = toValue(rollupId)
|
|
148
|
+
if (siteIds.length === 0 || !rid) {
|
|
149
|
+
envelopes.value = {}
|
|
150
|
+
progress.value = { completed: 0, total: 0 }
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
loading.value = true
|
|
154
|
+
// Seed envelopes with nulls so the UI can render skeleton rows before any
|
|
155
|
+
// fetch resolves. Writes land incrementally as each site returns.
|
|
156
|
+
const seeded: Record<string, RollupEnvelope<T> | null> = {}
|
|
157
|
+
for (const sid of siteIds) seeded[sid] = null
|
|
158
|
+
envelopes.value = seeded
|
|
159
|
+
progress.value = { completed: 0, total: siteIds.length }
|
|
160
|
+
const range = toValue(opts.range) ?? null
|
|
161
|
+
try {
|
|
162
|
+
await Promise.all(siteIds.map(async (sid) => {
|
|
163
|
+
const env = await fetchOne<T>(sid, rid, ctx, range)
|
|
164
|
+
if (token !== runToken)
|
|
165
|
+
return
|
|
166
|
+
envelopes.value = { ...envelopes.value, [sid]: env }
|
|
167
|
+
progress.value = { completed: progress.value.completed + 1, total: siteIds.length }
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
if (token === runToken)
|
|
172
|
+
loading.value = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
watch(
|
|
177
|
+
() => [normaliseSites(toValue(sites)).join(','), toValue(rollupId), toValue(opts.range)?.start, toValue(opts.range)?.end],
|
|
178
|
+
refresh,
|
|
179
|
+
{ immediate: true },
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
envelopes,
|
|
184
|
+
loading: loading as Readonly<Ref<boolean>>,
|
|
185
|
+
progress: progress as Readonly<Ref<{ completed: number, total: number }>>,
|
|
186
|
+
refresh,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normaliseSites(input: unknown): string[] {
|
|
191
|
+
if (!input)
|
|
192
|
+
return []
|
|
193
|
+
const list = Array.isArray(input) ? input : [input]
|
|
194
|
+
const out: string[] = []
|
|
195
|
+
for (const s of list) {
|
|
196
|
+
if (typeof s === 'string') {
|
|
197
|
+
if (s)
|
|
198
|
+
out.push(s)
|
|
199
|
+
}
|
|
200
|
+
else if (s && typeof s === 'object' && 'id' in s && typeof s.id === 'string' && s.id) {
|
|
201
|
+
out.push(s.id)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function fetchOne<T>(
|
|
208
|
+
siteId: string,
|
|
209
|
+
rollupId: string,
|
|
210
|
+
ctx: ReturnType<typeof useGscAnalyticsContext>,
|
|
211
|
+
range: { start: string, end: string } | null = null,
|
|
212
|
+
): Promise<RollupEnvelope<T> | null> {
|
|
213
|
+
ctx.patchProgress(siteId, {
|
|
214
|
+
source: 'rollup',
|
|
215
|
+
stage: 'manifest',
|
|
216
|
+
filesTotal: 1,
|
|
217
|
+
filesAttached: 0,
|
|
218
|
+
startedAt: Date.now(),
|
|
219
|
+
error: undefined,
|
|
220
|
+
endedAt: undefined,
|
|
221
|
+
})
|
|
222
|
+
try {
|
|
223
|
+
const env = await useGscAnalyticsClient().getRollup<T>(
|
|
224
|
+
siteId,
|
|
225
|
+
rollupId,
|
|
226
|
+
range ? { start: range.start, end: range.end } : undefined,
|
|
227
|
+
)
|
|
228
|
+
ctx.patchProgress(siteId, { stage: 'ready', filesAttached: 1, endedAt: Date.now() })
|
|
229
|
+
return env
|
|
230
|
+
}
|
|
231
|
+
catch (err: unknown) {
|
|
232
|
+
const status = (err as { statusCode?: number, status?: number })?.statusCode
|
|
233
|
+
?? (err as { status?: number })?.status
|
|
234
|
+
if (status === 404) {
|
|
235
|
+
ctx.patchProgress(siteId, { stage: 'ready', filesAttached: 1, endedAt: Date.now() })
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
239
|
+
ctx.patchProgress(siteId, { stage: 'error', error: msg, endedAt: Date.now() })
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normaliseRollups(input: unknown): string[] {
|
|
245
|
+
if (!input)
|
|
246
|
+
return []
|
|
247
|
+
if (typeof input === 'string')
|
|
248
|
+
return input ? [input] : []
|
|
249
|
+
if (Array.isArray(input))
|
|
250
|
+
return input.filter((v): v is string => typeof v === 'string' && v.length > 0)
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Top-N rollup table state for list pages.
|
|
2
|
+
//
|
|
3
|
+
// Owns the canonical wiring previously duplicated across consumer apps:
|
|
4
|
+
// - shared `useGscPeriod` + windowed range
|
|
5
|
+
// - `useGscRollup` fetch
|
|
6
|
+
// - URL-synced search/sort via `useGscTableState`
|
|
7
|
+
// - debounced fuzzy filter on one row field
|
|
8
|
+
// - position-aware sort (sort key 'position' invokes `positionFor`)
|
|
9
|
+
// - top-N slice (default 100)
|
|
10
|
+
//
|
|
11
|
+
// Row shape contract: `impressions` + `sum_position` so `positionFor` works
|
|
12
|
+
// without a caller-supplied accessor.
|
|
13
|
+
|
|
14
|
+
import type { MaybeRefOrGetter, Ref } from '@vue/runtime-core'
|
|
15
|
+
|
|
16
|
+
interface SortState { column: string, direction: 'asc' | 'desc' }
|
|
17
|
+
|
|
18
|
+
export interface UseGscRollupTableOptions<TRow> {
|
|
19
|
+
siteId: MaybeRefOrGetter<string>
|
|
20
|
+
rollupKey: string
|
|
21
|
+
filterField: keyof TRow & string
|
|
22
|
+
defaultSort?: SortState
|
|
23
|
+
limit?: number
|
|
24
|
+
debounceMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line ts/explicit-function-return-type -- complex generic inference; explicit type would diverge as composables evolve
|
|
28
|
+
export function useGscRollupTable<
|
|
29
|
+
TRow extends { impressions: number, sum_position: number },
|
|
30
|
+
>(opts: UseGscRollupTableOptions<TRow>) {
|
|
31
|
+
const { period, compareMode, stableData, range } = useGscPeriod()
|
|
32
|
+
const windowRange = computed(() => ({ start: range.value.start, end: range.value.end }))
|
|
33
|
+
|
|
34
|
+
const { data: payload, loading } = useGscRollup<TRow[]>(
|
|
35
|
+
opts.siteId,
|
|
36
|
+
opts.rollupKey,
|
|
37
|
+
{ range: windowRange },
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const { q, sort, toggleSort } = useGscTableState({ defaultSort: opts.defaultSort })
|
|
41
|
+
|
|
42
|
+
const searchDebounced = refDebounced<string>(q, opts.debounceMs ?? 150)
|
|
43
|
+
|
|
44
|
+
const limit = opts.limit ?? 100
|
|
45
|
+
const rows = computed<TRow[]>(() => {
|
|
46
|
+
const needle = searchDebounced.value.trim().toLowerCase()
|
|
47
|
+
const source = (payload.value ?? []).slice()
|
|
48
|
+
const filtered = needle
|
|
49
|
+
? source.filter((r: TRow) => {
|
|
50
|
+
const v = (r as Record<string, unknown>)[opts.filterField]
|
|
51
|
+
return typeof v === 'string' && v.toLowerCase().includes(needle)
|
|
52
|
+
})
|
|
53
|
+
: source
|
|
54
|
+
const s = sort.value
|
|
55
|
+
if (s) {
|
|
56
|
+
const dir = s.direction === 'desc' ? -1 : 1
|
|
57
|
+
filtered.sort((a: TRow, b: TRow) => {
|
|
58
|
+
const av = s.column === 'position' ? positionFor(a) : ((a as Record<string, unknown>)[s.column] as number)
|
|
59
|
+
const bv = s.column === 'position' ? positionFor(b) : ((b as Record<string, unknown>)[s.column] as number)
|
|
60
|
+
return av < bv ? -1 * dir : av > bv ? 1 * dir : 0
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return filtered.slice(0, limit)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
period,
|
|
68
|
+
compareMode,
|
|
69
|
+
stableData,
|
|
70
|
+
range,
|
|
71
|
+
q,
|
|
72
|
+
sort,
|
|
73
|
+
toggleSort,
|
|
74
|
+
payload: payload as Ref<TRow[] | null>,
|
|
75
|
+
loading,
|
|
76
|
+
rows,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Thin client wrapper over POST /api/__gsc/sites/[siteId]/rows. Takes a
|
|
2
|
+
// BuilderState (or a typed query builder) and returns reactive rows with
|
|
3
|
+
// auto-refetch when the state / site changes.
|
|
4
|
+
//
|
|
5
|
+
// Use for row-level page needs (country breakdowns, top-N with custom
|
|
6
|
+
// filters, per-page drilldowns, …). For analyzer-style transforms, use
|
|
7
|
+
// `useGscQuery` which dispatches via /analyze.
|
|
8
|
+
|
|
9
|
+
import type { QueryRow } from '@gscdump/analysis'
|
|
10
|
+
import type { GscRowQueryMeta, GscRowQueryResponse } from '@gscdump/contracts'
|
|
11
|
+
import type { ComputedRef, Ref } from '@vue/runtime-core'
|
|
12
|
+
import type { BuilderState } from 'gscdump/query'
|
|
13
|
+
import { useGscResource } from './_useGscResource'
|
|
14
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
15
|
+
|
|
16
|
+
export type { GscRowQueryMeta, GscRowQueryResponse } from '@gscdump/contracts'
|
|
17
|
+
|
|
18
|
+
export interface UseGscRowQueryOptions {
|
|
19
|
+
site: MaybeRefOrGetter<string | null | undefined>
|
|
20
|
+
state: MaybeRefOrGetter<BuilderState | null | undefined>
|
|
21
|
+
/** Gate the query; when `false` stays idle (useful for lazy tabs). */
|
|
22
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseGscRowQueryReturn<T> {
|
|
26
|
+
rows: ComputedRef<T[]>
|
|
27
|
+
meta: ComputedRef<GscRowQueryMeta | null>
|
|
28
|
+
loading: Ref<boolean>
|
|
29
|
+
error: Ref<Error | null>
|
|
30
|
+
refresh: () => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useGscRowQuery<T = QueryRow>(
|
|
34
|
+
opts: UseGscRowQueryOptions,
|
|
35
|
+
): UseGscRowQueryReturn<T> {
|
|
36
|
+
const resource = useGscResource<[string, BuilderState], GscRowQueryResponse<T>>({
|
|
37
|
+
keys: [
|
|
38
|
+
() => (opts.enabled === undefined || toValue(opts.enabled) ? toValue(opts.site) : null),
|
|
39
|
+
// BuilderState is structural; stringify so keys[]-watch picks up shape changes.
|
|
40
|
+
() => {
|
|
41
|
+
const s = toValue(opts.state)
|
|
42
|
+
return s ? JSON.stringify(s) as unknown as BuilderState : null
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
fetcher: site => useGscAnalyticsClient().queryRows<T>(site, toValue(opts.state) as BuilderState),
|
|
46
|
+
isEmpty: r => r.rows.length === 0,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
rows: computed(() => resource.data.value?.rows ?? []),
|
|
51
|
+
meta: computed(() => resource.data.value?.meta ?? null),
|
|
52
|
+
loading: resource.loading as unknown as Ref<boolean>,
|
|
53
|
+
error: resource.error,
|
|
54
|
+
refresh: resource.refresh,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Fetches per-search-appearance facet performance (AMP, rich results, videos, …)
|
|
2
|
+
// for a site over a date window. Server dispatches tier-aware: free → GSC API
|
|
3
|
+
// live, pro → engine parquet. Uniform row shape regardless of backend.
|
|
4
|
+
|
|
5
|
+
import type { GscApiRange, SearchAppearanceResponse, SearchAppearanceRow } from '@gscdump/contracts'
|
|
6
|
+
import type { ComputedRef, Ref } from '@vue/runtime-core'
|
|
7
|
+
import type { GscResourceStatus } from './_useGscResource'
|
|
8
|
+
import { useGscResource } from './_useGscResource'
|
|
9
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
10
|
+
|
|
11
|
+
export interface UseGscSearchAppearanceReturn {
|
|
12
|
+
response: Readonly<Ref<SearchAppearanceResponse | null>>
|
|
13
|
+
rows: ComputedRef<SearchAppearanceRow[]>
|
|
14
|
+
range: ComputedRef<GscApiRange | null>
|
|
15
|
+
source: ComputedRef<SearchAppearanceResponse['source'] | null>
|
|
16
|
+
loading: ComputedRef<boolean>
|
|
17
|
+
status: Ref<GscResourceStatus>
|
|
18
|
+
error: Ref<Error | null>
|
|
19
|
+
refresh: () => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useGscSearchAppearance(
|
|
23
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
24
|
+
range: MaybeRefOrGetter<{ start: string, end: string } | null | undefined>,
|
|
25
|
+
): UseGscSearchAppearanceReturn {
|
|
26
|
+
const { data, status, loading, error, refresh } = useGscResource({
|
|
27
|
+
keys: [
|
|
28
|
+
siteId,
|
|
29
|
+
() => toValue(range)?.start,
|
|
30
|
+
() => toValue(range)?.end,
|
|
31
|
+
] as const,
|
|
32
|
+
fetcher: (id: string, start: string, end: string) =>
|
|
33
|
+
useGscAnalyticsClient().getSearchAppearance(id, { start, end }),
|
|
34
|
+
isEmpty: r => r.rows.length === 0,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
response: data as Readonly<Ref<SearchAppearanceResponse | null>>,
|
|
39
|
+
rows: computed(() => data.value?.rows ?? []),
|
|
40
|
+
range: computed(() => data.value?.range ?? null),
|
|
41
|
+
source: computed(() => data.value?.source ?? null),
|
|
42
|
+
loading,
|
|
43
|
+
status,
|
|
44
|
+
error,
|
|
45
|
+
refresh,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Fetches the snapshot timeline for a single sitemap (by feedpath hash)
|
|
2
|
+
// from the site's history store. Oldest → newest.
|
|
3
|
+
|
|
4
|
+
import type { SitemapHistoryRecord, SitemapHistoryResponse } from '@gscdump/contracts'
|
|
5
|
+
import type { ComputedRef, Ref } from '@vue/runtime-core'
|
|
6
|
+
import type { GscResourceStatus } from './_useGscResource'
|
|
7
|
+
import { useGscResource } from './_useGscResource'
|
|
8
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
9
|
+
|
|
10
|
+
export interface UseGscSitemapHistoryReturn {
|
|
11
|
+
response: Readonly<Ref<SitemapHistoryResponse | null>>
|
|
12
|
+
snapshots: ComputedRef<SitemapHistoryRecord[]>
|
|
13
|
+
path: ComputedRef<string | null>
|
|
14
|
+
loading: ComputedRef<boolean>
|
|
15
|
+
status: Ref<GscResourceStatus>
|
|
16
|
+
error: Ref<Error | null>
|
|
17
|
+
refresh: () => Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useGscSitemapHistory(
|
|
21
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
22
|
+
feedpathHash: MaybeRefOrGetter<string | null | undefined>,
|
|
23
|
+
): UseGscSitemapHistoryReturn {
|
|
24
|
+
const { data, status, loading, error, refresh } = useGscResource({
|
|
25
|
+
keys: [siteId, feedpathHash] as const,
|
|
26
|
+
fetcher: (id: string, hash: string) => useGscAnalyticsClient().getSitemapHistory(id, hash),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const snapshots = computed(() => data.value?.snapshots ?? [])
|
|
30
|
+
const path = computed(() => data.value?.path ?? null)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
response: data as Readonly<Ref<SitemapHistoryResponse | null>>,
|
|
34
|
+
snapshots,
|
|
35
|
+
path,
|
|
36
|
+
loading,
|
|
37
|
+
status,
|
|
38
|
+
error,
|
|
39
|
+
refresh,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Fetches the sitemap entity index for a site. Records are sorted by
|
|
2
|
+
// `lastDownloaded` desc. Split from the previous `useEntity(kind)` discriminated
|
|
3
|
+
// API so each entity stays its own typed hook.
|
|
4
|
+
|
|
5
|
+
import type { SitemapHistoryRecord, SitemapIndex } from '@gscdump/contracts'
|
|
6
|
+
import type { ComputedRef, Ref } from '@vue/runtime-core'
|
|
7
|
+
import type { GscResourceStatus } from './_useGscResource'
|
|
8
|
+
import { useGscResource } from './_useGscResource'
|
|
9
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
10
|
+
|
|
11
|
+
export interface UseGscSitemapsReturn {
|
|
12
|
+
index: Readonly<Ref<SitemapIndex | null>>
|
|
13
|
+
records: ComputedRef<SitemapHistoryRecord[]>
|
|
14
|
+
loading: ComputedRef<boolean>
|
|
15
|
+
status: Ref<GscResourceStatus>
|
|
16
|
+
error: Ref<Error | null>
|
|
17
|
+
refresh: () => Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useGscSitemaps(siteId: MaybeRefOrGetter<string | null | undefined>): UseGscSitemapsReturn {
|
|
21
|
+
const { data, status, loading, error, refresh } = useGscResource({
|
|
22
|
+
keys: [siteId] as const,
|
|
23
|
+
fetcher: (id: string) => useGscAnalyticsClient().getSitemaps(id),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const records = computed<SitemapHistoryRecord[]>(() => {
|
|
27
|
+
if (!data.value)
|
|
28
|
+
return []
|
|
29
|
+
const list = Object.values(data.value.records) as SitemapHistoryRecord[]
|
|
30
|
+
return list.sort((a, b) => {
|
|
31
|
+
const ad = a.lastDownloaded ?? ''
|
|
32
|
+
const bd = b.lastDownloaded ?? ''
|
|
33
|
+
return bd.localeCompare(ad)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
index: data as Readonly<Ref<SitemapIndex | null>>,
|
|
39
|
+
records,
|
|
40
|
+
loading,
|
|
41
|
+
status,
|
|
42
|
+
error,
|
|
43
|
+
refresh,
|
|
44
|
+
}
|
|
45
|
+
}
|