@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,115 @@
|
|
|
1
|
+
// Reactive auth primitive for the @gscdump/nuxt layer.
|
|
2
|
+
//
|
|
3
|
+
// `useState`-backed so the value is SSR-safe and shared across the page tree.
|
|
4
|
+
// Hosts call `setGscAuth(getter)` from a `'pre'`-enforced plugin so downstream
|
|
5
|
+
// `useGscFetch`, `useGscQuery`, and `useGscAnalyzer` reactively re-read auth
|
|
6
|
+
// on every change (no plugin race).
|
|
7
|
+
//
|
|
8
|
+
// `apiKey` populates `x-api-key` for cross-origin deployments.
|
|
9
|
+
// `headers` is an opaque bag for hosts that need additional/alternative
|
|
10
|
+
// auth schemes (e.g. `Authorization: Bearer …`, CSRF). Merged after `apiKey`.
|
|
11
|
+
// `browserAnalyzerEnabled` is the user-level opt-in for DuckDB-WASM.
|
|
12
|
+
//
|
|
13
|
+
// `apiBase` is *not* on this state — it's a build-time runtimeConfig value
|
|
14
|
+
// (`runtimeConfig.public.analytics.apiBase`). Per-user dynamic apiBase would
|
|
15
|
+
// reintroduce a plugin-order race where the SDK client gets a stale prefix.
|
|
16
|
+
|
|
17
|
+
import type { Ref } from '@vue/runtime-core'
|
|
18
|
+
|
|
19
|
+
export interface GscAuthState {
|
|
20
|
+
apiKey: string | null
|
|
21
|
+
browserAnalyzerEnabled: boolean
|
|
22
|
+
userId?: string | null
|
|
23
|
+
/**
|
|
24
|
+
* Extra request headers to attach to every `/api/__gsc/*` call and parquet
|
|
25
|
+
* fetch. Use for non-`x-api-key` auth schemes. `apiKey` (above) is the
|
|
26
|
+
* common case — set both when needed.
|
|
27
|
+
*/
|
|
28
|
+
headers?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface InternalAuthState extends GscAuthState {
|
|
32
|
+
/**
|
|
33
|
+
* True once the host has called `setGscAuth` at least once. Layer
|
|
34
|
+
* consumers (e.g. `useGscQuery` engine derivation) treat unconfigured
|
|
35
|
+
* state as "no host wiring" and fall back to legacy behavior, rather
|
|
36
|
+
* than honoring the default `browserAnalyzerEnabled: false`.
|
|
37
|
+
*/
|
|
38
|
+
_initialized: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATE_KEY = 'gsc:auth'
|
|
42
|
+
|
|
43
|
+
const DEFAULT_AUTH: InternalAuthState = {
|
|
44
|
+
apiKey: null,
|
|
45
|
+
browserAnalyzerEnabled: false,
|
|
46
|
+
userId: null,
|
|
47
|
+
headers: undefined,
|
|
48
|
+
_initialized: false,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function authState(): Ref<InternalAuthState> {
|
|
52
|
+
return useState<InternalAuthState>(STATE_KEY, () => ({ ...DEFAULT_AUTH }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useGscAuth(): Readonly<Ref<GscAuthState>> {
|
|
56
|
+
return readonly(authState()) as unknown as Readonly<Ref<GscAuthState>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Layer-internal: returns the underlying state including `_initialized`.
|
|
61
|
+
*/
|
|
62
|
+
export function _useGscAuthInternal(): Readonly<Ref<InternalAuthState>> {
|
|
63
|
+
return readonly(authState()) as Readonly<Ref<InternalAuthState>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set the layer's auth state. Accepts a static value, a ref, or a getter.
|
|
68
|
+
* Getter form is reactive — re-runs whenever its source updates.
|
|
69
|
+
*/
|
|
70
|
+
export function setGscAuth(
|
|
71
|
+
source: GscAuthState | Ref<GscAuthState> | (() => GscAuthState),
|
|
72
|
+
): void {
|
|
73
|
+
const state = authState()
|
|
74
|
+
const apply = (v: GscAuthState): void => {
|
|
75
|
+
state.value = { ...v, _initialized: true }
|
|
76
|
+
}
|
|
77
|
+
if (typeof source === 'function') {
|
|
78
|
+
watchEffect(() => apply((source as () => GscAuthState)()))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (isRef(source)) {
|
|
82
|
+
watch(source, v => apply(v), { immediate: true, deep: true })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
apply(source)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read auth state from any callsite — composable setup, fetch interceptor,
|
|
90
|
+
* worker fetch callback. Resolves the ambient NuxtApp via async-local-storage
|
|
91
|
+
* (server) or the global app (client) and reads the `useState` cell directly.
|
|
92
|
+
* Returns the default sentinel when no NuxtApp context is available.
|
|
93
|
+
*/
|
|
94
|
+
export function readGscAuth(): GscAuthState {
|
|
95
|
+
const nuxt = tryUseNuxtApp()
|
|
96
|
+
if (!nuxt)
|
|
97
|
+
return { ...DEFAULT_AUTH }
|
|
98
|
+
return authState().value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the auth headers to attach to a request — `x-api-key` from `apiKey`
|
|
103
|
+
* (if set) merged with the opaque `headers` bag. Returns `{}` when no auth
|
|
104
|
+
* is wired, signalling callers to fall back to cookie credentials.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveGscAuthHeaders(auth: GscAuthState = readGscAuth()): Record<string, string> {
|
|
107
|
+
const out: Record<string, string> = {}
|
|
108
|
+
if (auth.apiKey)
|
|
109
|
+
out['x-api-key'] = auth.apiKey
|
|
110
|
+
if (auth.headers) {
|
|
111
|
+
for (const [k, v] of Object.entries(auth.headers))
|
|
112
|
+
out[k] = v
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Builds `search.google.com/search-console?...` deep-links for a site +
|
|
2
|
+
// optional page / query. Portable across consumers — no layer state, pure
|
|
3
|
+
// URL string concat.
|
|
4
|
+
|
|
5
|
+
export interface GscConsoleUrlOpts {
|
|
6
|
+
/** The GSC property — either `sc-domain:example.com` or a URL-prefix. */
|
|
7
|
+
siteLabel: string
|
|
8
|
+
/** Optional page to open Performance drilldown for. */
|
|
9
|
+
page?: string
|
|
10
|
+
/** Optional query filter. */
|
|
11
|
+
query?: string
|
|
12
|
+
/** Resource: `performance`, `url-inspection`, `sitemaps`, … */
|
|
13
|
+
resource?: 'performance' | 'url-inspection' | 'sitemaps' | 'index'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function gscConsoleUrl(opts: GscConsoleUrlOpts): string {
|
|
17
|
+
const base = 'https://search.google.com/search-console'
|
|
18
|
+
const resource = opts.resource ?? 'performance'
|
|
19
|
+
const params = new URLSearchParams()
|
|
20
|
+
// Default `sc-domain:` prefix when caller passes a bare hostname.
|
|
21
|
+
const siteLabel = /^(?:https?:|sc-domain:)/.test(opts.siteLabel)
|
|
22
|
+
? opts.siteLabel
|
|
23
|
+
: `sc-domain:${opts.siteLabel}`
|
|
24
|
+
params.set('resource_id', siteLabel)
|
|
25
|
+
if (resource === 'url-inspection') {
|
|
26
|
+
// Inspect tool expects the absolute URL under `id`, not Performance's
|
|
27
|
+
// `page=*<url>` wildcard filter.
|
|
28
|
+
if (opts.page)
|
|
29
|
+
params.set('id', opts.page)
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (opts.page)
|
|
33
|
+
params.set('page', `*${opts.page}`)
|
|
34
|
+
if (opts.query)
|
|
35
|
+
params.set('query', `*${opts.query}`)
|
|
36
|
+
}
|
|
37
|
+
const path = resource === 'performance'
|
|
38
|
+
? '/performance/search-analytics'
|
|
39
|
+
: resource === 'url-inspection'
|
|
40
|
+
? '/inspect'
|
|
41
|
+
: resource === 'sitemaps'
|
|
42
|
+
? '/sitemaps'
|
|
43
|
+
: '/index'
|
|
44
|
+
return `${base}${path}?${params.toString()}`
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Fetches per-country search performance for a site over a date window.
|
|
2
|
+
// Server dispatches tier-aware: free → GSC API live, pro → engine parquet.
|
|
3
|
+
// The page sees a uniform row shape regardless of which backend served it.
|
|
4
|
+
|
|
5
|
+
import type { CountriesResponse, CountryRow, GscApiRange } 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 UseGscCountriesReturn {
|
|
12
|
+
response: Readonly<Ref<CountriesResponse | null>>
|
|
13
|
+
rows: ComputedRef<CountryRow[]>
|
|
14
|
+
range: ComputedRef<GscApiRange | null>
|
|
15
|
+
source: ComputedRef<CountriesResponse['source'] | null>
|
|
16
|
+
loading: ComputedRef<boolean>
|
|
17
|
+
status: Ref<GscResourceStatus>
|
|
18
|
+
error: Ref<Error | null>
|
|
19
|
+
refresh: () => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useGscCountries(
|
|
23
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
24
|
+
range: MaybeRefOrGetter<{ start: string, end: string } | null | undefined>,
|
|
25
|
+
): UseGscCountriesReturn {
|
|
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().getCountries(id, { start, end }),
|
|
34
|
+
isEmpty: r => r.rows.length === 0,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
response: data as Readonly<Ref<CountriesResponse | 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,15 @@
|
|
|
1
|
+
// Dashboard route convention `/sites/[id]/*` ↔ analytics layer's
|
|
2
|
+
// `useGscSite(siteId)`. Pages collapse to:
|
|
3
|
+
// const { siteId, site } = useGscCurrentSite()
|
|
4
|
+
// The analytics layer is otherwise route-agnostic — this composable is the
|
|
5
|
+
// one place the `id` param name is hard-coded. Hosts using a different param
|
|
6
|
+
// name should provide their own equivalent.
|
|
7
|
+
|
|
8
|
+
export function useGscCurrentSite(): {
|
|
9
|
+
siteId: ComputedRef<string>
|
|
10
|
+
site: ReturnType<typeof useGscSite>
|
|
11
|
+
} {
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const siteId = computed(() => String(route.params.id))
|
|
14
|
+
return { siteId, site: useGscSite(siteId) }
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// App-wide engine override for `useGscQuery`. SSR-safe via `useState`.
|
|
2
|
+
//
|
|
3
|
+
// Resolution priority lives in `useGscQueryDispatcher.pickEngine`:
|
|
4
|
+
// per-call `opts.engine` → `useGscEngine().value` → `runtimeConfig.public.
|
|
5
|
+
// analytics.defaultEngine` → `'auto'`.
|
|
6
|
+
//
|
|
7
|
+
// Set the value once on the consumer side (e.g. from your auth plugin's
|
|
8
|
+
// `onReady` hook reading a per-user feature flag) and every query inherits.
|
|
9
|
+
|
|
10
|
+
import type { GscQueryEngine } from './useGscQuery'
|
|
11
|
+
|
|
12
|
+
const STATE_KEY = 'gscdump:engine'
|
|
13
|
+
|
|
14
|
+
export function useGscEngine(): Ref<GscQueryEngine | null> {
|
|
15
|
+
return useState<GscQueryEngine | null>(STATE_KEY, () => null)
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Fetches the inspection timeline for a single URL (by hash) from the
|
|
2
|
+
// site's history shards. Oldest → newest. 404 / empty history is a normal
|
|
3
|
+
// state on the first dashboard visit before a second snapshot runs.
|
|
4
|
+
|
|
5
|
+
import type { InspectionHistoryRecord, InspectionHistoryResponse } 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 UseGscInspectionHistoryReturn {
|
|
12
|
+
response: Readonly<Ref<InspectionHistoryResponse | null>>
|
|
13
|
+
records: ComputedRef<InspectionHistoryRecord[]>
|
|
14
|
+
url: ComputedRef<string | null>
|
|
15
|
+
loading: ComputedRef<boolean>
|
|
16
|
+
status: Ref<GscResourceStatus>
|
|
17
|
+
error: Ref<Error | null>
|
|
18
|
+
refresh: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useGscInspectionHistory(
|
|
22
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
23
|
+
urlHash: MaybeRefOrGetter<string | null | undefined>,
|
|
24
|
+
): UseGscInspectionHistoryReturn {
|
|
25
|
+
const { data, status, loading, error, refresh } = useGscResource({
|
|
26
|
+
keys: [siteId, urlHash] as const,
|
|
27
|
+
fetcher: (id: string, hash: string) => useGscAnalyticsClient().getInspectionHistory(id, hash),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const records = computed(() => data.value?.records ?? [])
|
|
31
|
+
const url = computed(() => data.value?.url ?? null)
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
response: data as Readonly<Ref<InspectionHistoryResponse | null>>,
|
|
35
|
+
records,
|
|
36
|
+
url,
|
|
37
|
+
loading,
|
|
38
|
+
status,
|
|
39
|
+
error,
|
|
40
|
+
refresh,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Fetches the URL-inspection entity index for a site, with PASS/NEUTRAL/FAIL
|
|
2
|
+
// counts derived from the records. Split from the previous `useEntity(kind)`
|
|
3
|
+
// discriminated API so each entity stays its own typed hook.
|
|
4
|
+
|
|
5
|
+
import type { InspectionHistoryRecord, InspectionIndex } 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 UseGscInspectionsReturn {
|
|
12
|
+
index: Readonly<Ref<InspectionIndex | null>>
|
|
13
|
+
records: ComputedRef<InspectionHistoryRecord[]>
|
|
14
|
+
statusCounts: ComputedRef<{ PASS: number, NEUTRAL: number, FAIL: number, unknown: number }>
|
|
15
|
+
loading: ComputedRef<boolean>
|
|
16
|
+
status: Ref<GscResourceStatus>
|
|
17
|
+
error: Ref<Error | null>
|
|
18
|
+
refresh: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useGscInspections(siteId: MaybeRefOrGetter<string | null | undefined>): UseGscInspectionsReturn {
|
|
22
|
+
const { data, status, loading, error, refresh } = useGscResource({
|
|
23
|
+
keys: [siteId] as const,
|
|
24
|
+
fetcher: (id: string) => useGscAnalyticsClient().getInspections(id),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const records = computed<InspectionHistoryRecord[]>(() =>
|
|
28
|
+
data.value ? Object.values(data.value.records) : [],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const statusCounts = computed(() => {
|
|
32
|
+
const counts = { PASS: 0, NEUTRAL: 0, FAIL: 0, unknown: 0 }
|
|
33
|
+
for (const r of records.value) {
|
|
34
|
+
const key = r.indexStatus as keyof typeof counts | undefined
|
|
35
|
+
if (key && key in counts)
|
|
36
|
+
counts[key]++
|
|
37
|
+
else
|
|
38
|
+
counts.unknown++
|
|
39
|
+
}
|
|
40
|
+
return counts
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
index: data as Readonly<Ref<InspectionIndex | null>>,
|
|
45
|
+
records,
|
|
46
|
+
statusCounts,
|
|
47
|
+
loading,
|
|
48
|
+
status,
|
|
49
|
+
error,
|
|
50
|
+
refresh,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Provide/inject seam for analyzer panels. Pipeline panels (those declared
|
|
2
|
+
// with `panel.ownsLifecycle: true`) need access to the analyzer runner +
|
|
3
|
+
// long-running composable instances so they can fire `analyze`/`query` and
|
|
4
|
+
// keep their phase state alive across tab switches.
|
|
5
|
+
//
|
|
6
|
+
// `/analyze` provides these once; panels inject. Type-only — runtime values
|
|
7
|
+
// are the InjectionKey Symbols themselves.
|
|
8
|
+
|
|
9
|
+
import type { InjectionKey } from '@vue/runtime-core'
|
|
10
|
+
import type { GscAnalyzerInstance } from './useGscAnalyzer'
|
|
11
|
+
|
|
12
|
+
export interface GscPanelRunnerContext {
|
|
13
|
+
runner: GscAnalyzerInstance
|
|
14
|
+
ready: Ref<boolean>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const gscPanelRunnerKey: InjectionKey<GscPanelRunnerContext> = Symbol('gscPanelRunnerKey')
|
|
18
|
+
|
|
19
|
+
export function useGscPanelRunner(): GscPanelRunnerContext {
|
|
20
|
+
const ctx = inject(gscPanelRunnerKey, null)
|
|
21
|
+
if (!ctx)
|
|
22
|
+
throw new Error('useGscPanelRunner() called outside <GscAnalyzerPanel> context')
|
|
23
|
+
return ctx
|
|
24
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Parquet-attached DuckDB table view: fuzzy-search + sortable metrics +
|
|
2
|
+
// pagination over a single attached parquet (`main.<table>`), date-filtered.
|
|
3
|
+
//
|
|
4
|
+
// Sibling of `useGscRollupTable` — the rollup variant fetches a precomputed
|
|
5
|
+
// JSON payload; this variant fires SQL against a parquet view in the
|
|
6
|
+
// browser-attached DuckDB-WASM runtime. Two callers today (analyze.vue's
|
|
7
|
+
// `pages` and `keywords` raw tabs); third+ callers are mechanical.
|
|
8
|
+
//
|
|
9
|
+
// Caller passes the `query` fn from `useGscAnalyzer(siteId)`; the composable
|
|
10
|
+
// owns the SQL builder, the debounce, the watcher, and the count+page
|
|
11
|
+
// Promise.all. It does NOT own `period`/`sort`/`page`/`q` — those are passed
|
|
12
|
+
// in so callers can deep-link and share state (the `/analyze` page wires one
|
|
13
|
+
// `useGscTableState` across raw + analyzer tabs).
|
|
14
|
+
|
|
15
|
+
import type { MaybeRefOrGetter, Ref } from '@vue/runtime-core'
|
|
16
|
+
import type { GscSortState } from './useGscTableState'
|
|
17
|
+
|
|
18
|
+
interface QueryResult { rows: Record<string, unknown>[], queryMs: number }
|
|
19
|
+
|
|
20
|
+
export interface UseGscParquetTableOptions {
|
|
21
|
+
/** Attached view name in `main.<table>` (e.g. `pages`, `keywords`). */
|
|
22
|
+
table: MaybeRefOrGetter<string>
|
|
23
|
+
/** Dimension column rows group by + fuzzy-search against. */
|
|
24
|
+
dim: MaybeRefOrGetter<string>
|
|
25
|
+
/** `useGscAnalyzer(siteId).query`. */
|
|
26
|
+
query: (sql: string, params?: unknown[]) => Promise<QueryResult>
|
|
27
|
+
/** Date window applied as `date BETWEEN ? AND ?`. */
|
|
28
|
+
dateRange: MaybeRefOrGetter<{ start: string, end: string }>
|
|
29
|
+
/**
|
|
30
|
+
* External table state — search, sort, page, pageSize. Usually a shared
|
|
31
|
+
* `useGscTableState()` so URL deep-links survive tab switches.
|
|
32
|
+
*/
|
|
33
|
+
q: Ref<string>
|
|
34
|
+
sort: Ref<GscSortState | null>
|
|
35
|
+
page: Ref<number>
|
|
36
|
+
pageSize: Ref<number>
|
|
37
|
+
/** Gate firing the query until the underlying runtime is ready. */
|
|
38
|
+
ready: MaybeRefOrGetter<boolean>
|
|
39
|
+
/** Re-fire whenever any of these change. (Period state, stableData flag, …) */
|
|
40
|
+
triggers?: MaybeRefOrGetter<unknown>[]
|
|
41
|
+
/** Search debounce. Default 200ms. */
|
|
42
|
+
debounceMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseGscParquetTableReturn {
|
|
46
|
+
rows: Ref<Record<string, unknown>[]>
|
|
47
|
+
totalRows: Ref<number | null>
|
|
48
|
+
totalPages: Ref<number | null>
|
|
49
|
+
queryMs: Ref<number | null>
|
|
50
|
+
loading: Ref<boolean>
|
|
51
|
+
error: Ref<string | null>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SQL_SORT_COLS = new Set(['clicks', 'impressions', 'ctr', 'avg_position'])
|
|
55
|
+
const WHITESPACE_RE = /\s+/
|
|
56
|
+
|
|
57
|
+
function tokenize(s: string): string[] {
|
|
58
|
+
const seen = new Set<string>()
|
|
59
|
+
const out: string[] = []
|
|
60
|
+
for (const t of s.trim().split(WHITESPACE_RE).filter(Boolean)) {
|
|
61
|
+
const lower = t.toLowerCase()
|
|
62
|
+
if (!seen.has(lower)) {
|
|
63
|
+
seen.add(lower)
|
|
64
|
+
out.push(lower)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface SearchClauses { where: string, rank: string, params: unknown[] }
|
|
71
|
+
|
|
72
|
+
function buildSearchClauses(dim: string, tokens: string[]): SearchClauses {
|
|
73
|
+
if (tokens.length === 0)
|
|
74
|
+
return { where: '', rank: '', params: [] }
|
|
75
|
+
const patterns = tokens.map(t => `%${t}%`)
|
|
76
|
+
const ors = tokens.map(() => `${dim} ILIKE ?`).join(' OR ')
|
|
77
|
+
const cases = tokens.map(() => `(CASE WHEN ${dim} ILIKE ? THEN 1 ELSE 0 END)`).join(' + ')
|
|
78
|
+
return {
|
|
79
|
+
where: `WHERE (${ors})`,
|
|
80
|
+
rank: `${cases} DESC,`,
|
|
81
|
+
params: [...patterns, ...patterns],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withDateFilter(
|
|
86
|
+
searchWhere: string,
|
|
87
|
+
searchParams: unknown[],
|
|
88
|
+
start: string,
|
|
89
|
+
end: string,
|
|
90
|
+
): { where: string, params: unknown[] } {
|
|
91
|
+
const dateClause = 'date BETWEEN ? AND ?'
|
|
92
|
+
const where = searchWhere ? `${searchWhere} AND ${dateClause}` : `WHERE ${dateClause}`
|
|
93
|
+
return { where, params: [...searchParams, start, end] }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sortKey(dim: string, sort: GscSortState | null): string {
|
|
97
|
+
const col = sort?.column ?? 'clicks'
|
|
98
|
+
if (SQL_SORT_COLS.has(col))
|
|
99
|
+
return col
|
|
100
|
+
if (col === dim)
|
|
101
|
+
return dim
|
|
102
|
+
return 'clicks'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function useGscParquetTable(opts: UseGscParquetTableOptions): UseGscParquetTableReturn {
|
|
106
|
+
const rows = ref<Record<string, unknown>[]>([])
|
|
107
|
+
const totalRows = ref<number | null>(null)
|
|
108
|
+
const queryMs = ref<number | null>(null)
|
|
109
|
+
const loading = ref(false)
|
|
110
|
+
const error = ref<string | null>(null)
|
|
111
|
+
|
|
112
|
+
const searchDebounced = ref('')
|
|
113
|
+
let handle: ReturnType<typeof setTimeout> | null = null
|
|
114
|
+
watch(opts.q, (v: string) => {
|
|
115
|
+
if (handle)
|
|
116
|
+
clearTimeout(handle)
|
|
117
|
+
handle = setTimeout(() => {
|
|
118
|
+
searchDebounced.value = v
|
|
119
|
+
}, opts.debounceMs ?? 200)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
async function run(): Promise<void> {
|
|
123
|
+
if (!toValue(opts.ready))
|
|
124
|
+
return
|
|
125
|
+
const table = toValue(opts.table)
|
|
126
|
+
const dim = toValue(opts.dim)
|
|
127
|
+
const { start, end } = toValue(opts.dateRange)
|
|
128
|
+
const tokens = tokenize(searchDebounced.value)
|
|
129
|
+
const { where: searchWhere, rank, params: searchParams } = buildSearchClauses(dim, tokens)
|
|
130
|
+
const dir = (opts.sort.value?.direction ?? 'desc').toUpperCase()
|
|
131
|
+
const limit = opts.pageSize.value
|
|
132
|
+
const offset = (opts.page.value - 1) * limit
|
|
133
|
+
|
|
134
|
+
const wherePart = withDateFilter(searchWhere, searchParams.slice(0, tokens.length), start, end)
|
|
135
|
+
const rankParams = searchParams.slice(tokens.length)
|
|
136
|
+
|
|
137
|
+
const pageSql = `
|
|
138
|
+
SELECT ${dim},
|
|
139
|
+
SUM(clicks)::BIGINT AS clicks,
|
|
140
|
+
SUM(impressions)::BIGINT AS impressions,
|
|
141
|
+
CASE WHEN SUM(impressions) > 0
|
|
142
|
+
THEN ROUND(SUM(clicks) * 1.0 / SUM(impressions), 4)
|
|
143
|
+
ELSE 0 END AS ctr,
|
|
144
|
+
CASE WHEN SUM(impressions) > 0
|
|
145
|
+
THEN ROUND(SUM(sum_position) / SUM(impressions) + 1, 2)
|
|
146
|
+
ELSE 0 END AS avg_position
|
|
147
|
+
FROM main.${table}
|
|
148
|
+
${wherePart.where}
|
|
149
|
+
GROUP BY ${dim}
|
|
150
|
+
ORDER BY ${rank} ${sortKey(dim, opts.sort.value)} ${dir}
|
|
151
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
152
|
+
`
|
|
153
|
+
const countSql = `SELECT COUNT(DISTINCT ${dim})::BIGINT AS n FROM main.${table} ${wherePart.where}`
|
|
154
|
+
|
|
155
|
+
loading.value = true
|
|
156
|
+
error.value = null
|
|
157
|
+
rows.value = []
|
|
158
|
+
queryMs.value = null
|
|
159
|
+
try {
|
|
160
|
+
const [pageRes, countRes] = await Promise.all([
|
|
161
|
+
opts.query(pageSql, [...wherePart.params, ...rankParams]),
|
|
162
|
+
opts.query(countSql, wherePart.params),
|
|
163
|
+
])
|
|
164
|
+
rows.value = pageRes.rows
|
|
165
|
+
queryMs.value = pageRes.queryMs
|
|
166
|
+
const n = countRes.rows[0]?.n
|
|
167
|
+
totalRows.value = typeof n === 'bigint' ? Number(n) : typeof n === 'number' ? n : null
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
error.value = err instanceof Error ? err.message : String(err)
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
loading.value = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const triggerSources = [
|
|
178
|
+
() => toValue(opts.ready),
|
|
179
|
+
() => toValue(opts.table),
|
|
180
|
+
() => toValue(opts.dim),
|
|
181
|
+
() => toValue(opts.dateRange),
|
|
182
|
+
opts.sort,
|
|
183
|
+
opts.page,
|
|
184
|
+
opts.pageSize,
|
|
185
|
+
searchDebounced,
|
|
186
|
+
...(opts.triggers ?? []).map(t => () => toValue(t as MaybeRefOrGetter<unknown>)),
|
|
187
|
+
]
|
|
188
|
+
watch(triggerSources, () => {
|
|
189
|
+
void run()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const totalPages = computed(() => {
|
|
193
|
+
if (totalRows.value == null)
|
|
194
|
+
return null
|
|
195
|
+
return Math.max(1, Math.ceil(totalRows.value / opts.pageSize.value))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return { rows, totalRows, totalPages, queryMs, loading, error }
|
|
199
|
+
}
|