@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,119 @@
|
|
|
1
|
+
// Reactive table state — search, pagination, sort, filter — with optional
|
|
2
|
+
// URL sync. One source of truth across a page so deep-links and
|
|
3
|
+
// back/forward navigation restore exactly what the user was looking at.
|
|
4
|
+
|
|
5
|
+
export interface GscSortState {
|
|
6
|
+
column: string
|
|
7
|
+
direction: 'asc' | 'desc'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseGscTableStateOptions<TFilter = Record<string, string>> {
|
|
11
|
+
/** Sync state into the URL query string. Default `true`. */
|
|
12
|
+
syncUrl?: boolean
|
|
13
|
+
/** Prefix for query keys (avoids collisions when multiple tables share a page). */
|
|
14
|
+
prefix?: string
|
|
15
|
+
defaultPage?: number
|
|
16
|
+
defaultPageSize?: number
|
|
17
|
+
defaultQ?: string
|
|
18
|
+
defaultSort?: GscSortState | null
|
|
19
|
+
defaultFilter?: TFilter
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseGscTableStateReturn<TFilter = Record<string, string>> {
|
|
23
|
+
q: Ref<string>
|
|
24
|
+
page: Ref<number>
|
|
25
|
+
pageSize: Ref<number>
|
|
26
|
+
sort: Ref<GscSortState | null>
|
|
27
|
+
filter: Ref<TFilter>
|
|
28
|
+
reset: () => void
|
|
29
|
+
/** Toggle a column's sort: desc → asc → off. */
|
|
30
|
+
toggleSort: (column: string) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useGscTableState<TFilter extends Record<string, any> = Record<string, string>>(
|
|
34
|
+
opts: UseGscTableStateOptions<TFilter> = {},
|
|
35
|
+
): UseGscTableStateReturn<TFilter> {
|
|
36
|
+
const syncUrl = opts.syncUrl ?? true
|
|
37
|
+
const prefix = opts.prefix ?? ''
|
|
38
|
+
const k = (name: string): string => (prefix ? `${prefix}_${name}` : name)
|
|
39
|
+
|
|
40
|
+
const defaultPage = opts.defaultPage ?? 1
|
|
41
|
+
const defaultPageSize = opts.defaultPageSize ?? 25
|
|
42
|
+
const defaultQ = opts.defaultQ ?? ''
|
|
43
|
+
const defaultSort = opts.defaultSort ?? null
|
|
44
|
+
const defaultFilter = (opts.defaultFilter ?? {}) as TFilter
|
|
45
|
+
|
|
46
|
+
const route = syncUrl ? useRoute() : null
|
|
47
|
+
const router = syncUrl ? useRouter() : null
|
|
48
|
+
|
|
49
|
+
function readQuery<T>(key: string, fallback: T, parse: (raw: string) => T): T {
|
|
50
|
+
if (!route)
|
|
51
|
+
return fallback
|
|
52
|
+
const raw = route.query[k(key)]
|
|
53
|
+
if (raw == null)
|
|
54
|
+
return fallback
|
|
55
|
+
return parse(Array.isArray(raw) ? (raw[0] ?? '') : String(raw))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeQuery(key: string, value: string | null): void {
|
|
59
|
+
if (!route || !router)
|
|
60
|
+
return
|
|
61
|
+
const query = { ...route.query }
|
|
62
|
+
const fullKey = k(key)
|
|
63
|
+
if (value == null || value === '')
|
|
64
|
+
delete query[fullKey]
|
|
65
|
+
else
|
|
66
|
+
query[fullKey] = value
|
|
67
|
+
void router.replace({ query })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const q = ref(readQuery('q', defaultQ, s => s))
|
|
71
|
+
const page = ref(readQuery('page', defaultPage, s => Number(s) || defaultPage))
|
|
72
|
+
const pageSize = ref(readQuery('pageSize', defaultPageSize, s => Number(s) || defaultPageSize))
|
|
73
|
+
const sort = ref<GscSortState | null>(readQuery('sort', defaultSort, parseSort))
|
|
74
|
+
const filter = ref(defaultFilter) as Ref<TFilter>
|
|
75
|
+
|
|
76
|
+
if (syncUrl) {
|
|
77
|
+
watch(q, v => writeQuery('q', v || null))
|
|
78
|
+
watch(page, v => writeQuery('page', v === defaultPage ? null : String(v)))
|
|
79
|
+
watch(pageSize, v => writeQuery('pageSize', v === defaultPageSize ? null : String(v)))
|
|
80
|
+
watch(sort, v => writeQuery('sort', serializeSort(v)))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reset page when q/filter changes (standard table UX).
|
|
84
|
+
watch([q, filter], () => {
|
|
85
|
+
page.value = defaultPage
|
|
86
|
+
}, { deep: true })
|
|
87
|
+
|
|
88
|
+
function reset(): void {
|
|
89
|
+
q.value = defaultQ
|
|
90
|
+
page.value = defaultPage
|
|
91
|
+
pageSize.value = defaultPageSize
|
|
92
|
+
sort.value = defaultSort
|
|
93
|
+
filter.value = defaultFilter
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toggleSort(column: string): void {
|
|
97
|
+
const cur = sort.value
|
|
98
|
+
if (!cur || cur.column !== column) {
|
|
99
|
+
sort.value = { column, direction: 'desc' }
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
sort.value = cur.direction === 'desc' ? { column, direction: 'asc' } : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { q, page, pageSize, sort, filter, reset, toggleSort }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function serializeSort(s: GscSortState | null): string | null {
|
|
109
|
+
return s ? `${s.column}:${s.direction}` : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseSort(raw: string): GscSortState | null {
|
|
113
|
+
if (!raw)
|
|
114
|
+
return null
|
|
115
|
+
const [column, direction] = raw.split(':')
|
|
116
|
+
if (!column || (direction !== 'asc' && direction !== 'desc'))
|
|
117
|
+
return null
|
|
118
|
+
return { column, direction }
|
|
119
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Layer-wide DI: analytics context, query dispatcher, fetch instance, and
|
|
2
|
+
// analytics client. Each value is defined as a lazy getter on the NuxtApp so
|
|
3
|
+
// non-GSC routes pay zero cost and host plugins (e.g. route-gated setGscAuth)
|
|
4
|
+
// can run before any of these are materialized. Hosts override by reassigning
|
|
5
|
+
// the same `$xxx` key from a later plugin (Nuxt's provide uses configurable
|
|
6
|
+
// defineProperty, so the last writer wins).
|
|
7
|
+
|
|
8
|
+
import type { AnalyticsClient, AnalyticsFetch } from '@gscdump/sdk'
|
|
9
|
+
import type { $Fetch } from 'ofetch'
|
|
10
|
+
import type { GscQueryDispatcher } from '../composables/_useGscQueryDispatcher'
|
|
11
|
+
import type { GscAnalyticsContext } from '../composables/useGscAnalytics'
|
|
12
|
+
import { createAnalyticsClient } from '@gscdump/sdk'
|
|
13
|
+
import { createDefaultGscQueryDispatcher } from '../composables/_useGscQueryDispatcher'
|
|
14
|
+
import { createGscAnalyticsContext } from '../composables/useGscAnalytics'
|
|
15
|
+
import { useGscAnalyticsConfig } from '../composables/useGscAnalyticsConfig'
|
|
16
|
+
import { resolveGscAuthHeaders } from '../composables/useGscAuth'
|
|
17
|
+
import { createGscFetch } from '../utils/gsc-fetch'
|
|
18
|
+
|
|
19
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
20
|
+
let _ctx: GscAnalyticsContext | undefined
|
|
21
|
+
let _dispatcher: GscQueryDispatcher | undefined
|
|
22
|
+
let _fetch: $Fetch | undefined
|
|
23
|
+
let _client: AnalyticsClient | undefined
|
|
24
|
+
|
|
25
|
+
function getFetch(): $Fetch {
|
|
26
|
+
if (!_fetch) {
|
|
27
|
+
const cfg = useGscAnalyticsConfig()
|
|
28
|
+
_fetch = createGscFetch(cfg.apiBase, cfg.toastErrors)
|
|
29
|
+
}
|
|
30
|
+
return _fetch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lazy: Record<string, () => unknown> = {
|
|
34
|
+
$gscAnalytics: () => _ctx ??= createGscAnalyticsContext(),
|
|
35
|
+
$gscQueryDispatcher: () => _dispatcher ??= createDefaultGscQueryDispatcher(),
|
|
36
|
+
$gscFetch: () => getFetch(),
|
|
37
|
+
$gscAnalyticsClient: () => {
|
|
38
|
+
if (!_client) {
|
|
39
|
+
const cfg = useGscAnalyticsConfig()
|
|
40
|
+
_client = createAnalyticsClient({
|
|
41
|
+
apiBase: cfg.apiBase || '',
|
|
42
|
+
fetch: getFetch() as unknown as AnalyticsFetch,
|
|
43
|
+
headers: () => new Headers(resolveGscAuthHeaders()),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return _client
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [key, factory] of Object.entries(lazy)) {
|
|
51
|
+
Object.defineProperty(nuxtApp, key, {
|
|
52
|
+
get: factory,
|
|
53
|
+
configurable: true,
|
|
54
|
+
enumerable: true,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// GSC anonymises queries on a per-day basis; summing query-grained rows
|
|
2
|
+
// undercounts page impressions by this amount. Callers render a banner when
|
|
3
|
+
// the trailing-28d weighted average crosses a threshold.
|
|
4
|
+
//
|
|
5
|
+
// Impression-weighted average matches what the user experiences when they
|
|
6
|
+
// open the dashboard — daily % alone is noisy on low-traffic days.
|
|
7
|
+
|
|
8
|
+
export interface DailyAnonInput {
|
|
9
|
+
impressions: number
|
|
10
|
+
anonymizedImpressionsPct: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function weightedAnonPct(days: readonly DailyAnonInput[] | null | undefined, window = 28): number | null {
|
|
14
|
+
if (!days?.length)
|
|
15
|
+
return null
|
|
16
|
+
const trailing = days.slice(-window)
|
|
17
|
+
let totalImpressions = 0
|
|
18
|
+
let weighted = 0
|
|
19
|
+
for (const d of trailing) {
|
|
20
|
+
totalImpressions += d.impressions
|
|
21
|
+
weighted += d.impressions * d.anonymizedImpressionsPct
|
|
22
|
+
}
|
|
23
|
+
return totalImpressions > 0 ? weighted / totalImpressions : null
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const COUNTRY_NAMES: Record<string, string> = {
|
|
2
|
+
US: 'United States',
|
|
3
|
+
GB: 'United Kingdom',
|
|
4
|
+
DE: 'Germany',
|
|
5
|
+
FR: 'France',
|
|
6
|
+
CA: 'Canada',
|
|
7
|
+
AU: 'Australia',
|
|
8
|
+
IN: 'India',
|
|
9
|
+
BR: 'Brazil',
|
|
10
|
+
JP: 'Japan',
|
|
11
|
+
IT: 'Italy',
|
|
12
|
+
ES: 'Spain',
|
|
13
|
+
NL: 'Netherlands',
|
|
14
|
+
SE: 'Sweden',
|
|
15
|
+
CH: 'Switzerland',
|
|
16
|
+
MX: 'Mexico',
|
|
17
|
+
KR: 'South Korea',
|
|
18
|
+
RU: 'Russia',
|
|
19
|
+
PL: 'Poland',
|
|
20
|
+
BE: 'Belgium',
|
|
21
|
+
AT: 'Austria',
|
|
22
|
+
NO: 'Norway',
|
|
23
|
+
DK: 'Denmark',
|
|
24
|
+
FI: 'Finland',
|
|
25
|
+
PT: 'Portugal',
|
|
26
|
+
IE: 'Ireland',
|
|
27
|
+
NZ: 'New Zealand',
|
|
28
|
+
SG: 'Singapore',
|
|
29
|
+
HK: 'Hong Kong',
|
|
30
|
+
TW: 'Taiwan',
|
|
31
|
+
IL: 'Israel',
|
|
32
|
+
ZA: 'South Africa',
|
|
33
|
+
AR: 'Argentina',
|
|
34
|
+
CL: 'Chile',
|
|
35
|
+
CO: 'Colombia',
|
|
36
|
+
TH: 'Thailand',
|
|
37
|
+
PH: 'Philippines',
|
|
38
|
+
MY: 'Malaysia',
|
|
39
|
+
ID: 'Indonesia',
|
|
40
|
+
VN: 'Vietnam',
|
|
41
|
+
TR: 'Turkey',
|
|
42
|
+
CZ: 'Czech Republic',
|
|
43
|
+
RO: 'Romania',
|
|
44
|
+
HU: 'Hungary',
|
|
45
|
+
GR: 'Greece',
|
|
46
|
+
UA: 'Ukraine',
|
|
47
|
+
EG: 'Egypt',
|
|
48
|
+
NG: 'Nigeria',
|
|
49
|
+
KE: 'Kenya',
|
|
50
|
+
PK: 'Pakistan',
|
|
51
|
+
BD: 'Bangladesh',
|
|
52
|
+
AE: 'United Arab Emirates',
|
|
53
|
+
SA: 'Saudi Arabia',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const countryName = (code: string): string => COUNTRY_NAMES[code] || code
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Constants shared across GSC-shaped components. Kept as layer exports so
|
|
2
|
+
// consumers import from `#imports` like everything else.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google Search Console data is considered "unstable" for this many days from
|
|
6
|
+
* today (PST). Rows within the window may still shift as GSC finalizes clicks/
|
|
7
|
+
* impressions; charts render those points under a dimmed / striped overlay so
|
|
8
|
+
* users don't misread last-day dips as trends.
|
|
9
|
+
*/
|
|
10
|
+
export const GSC_STABLE_LATENCY_DAYS = 3
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Error classification shared by the layer's fetch wrappers and query hooks.
|
|
2
|
+
// Returns a structured status the caller maps to UI (toast, banner, retry, …).
|
|
3
|
+
|
|
4
|
+
export type GscErrorStatus
|
|
5
|
+
= | 'auth-missing'
|
|
6
|
+
| 'rate-limited'
|
|
7
|
+
| 'network'
|
|
8
|
+
| 'error'
|
|
9
|
+
|
|
10
|
+
export interface GscClassifiedError {
|
|
11
|
+
status: GscErrorStatus
|
|
12
|
+
/** HTTP status code if the error came from a response, otherwise undefined. */
|
|
13
|
+
code?: number
|
|
14
|
+
/** Best-effort human message: server-supplied `message`, then `data.message`, then the error's own message. */
|
|
15
|
+
message?: string
|
|
16
|
+
/** Seconds the server suggested waiting (429 with `retryAfter` payload). */
|
|
17
|
+
retryAfter?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function classifyGscError(e: unknown): GscClassifiedError {
|
|
21
|
+
const code = (e as { statusCode?: number, status?: number })?.statusCode
|
|
22
|
+
?? (e as { status?: number })?.status
|
|
23
|
+
const message = extractMessage(e)
|
|
24
|
+
|
|
25
|
+
if (code === 401 || code === 403)
|
|
26
|
+
return { status: 'auth-missing', code, message }
|
|
27
|
+
if (code === 429) {
|
|
28
|
+
const retry = (e as { data?: { retryAfter?: number } })?.data?.retryAfter
|
|
29
|
+
return {
|
|
30
|
+
status: 'rate-limited',
|
|
31
|
+
code,
|
|
32
|
+
message,
|
|
33
|
+
retryAfter: typeof retry === 'number' ? retry : undefined,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// No code => didn't make it to the server (DNS, CORS, offline, abort).
|
|
37
|
+
if (code == null && !isAbort(e))
|
|
38
|
+
return { status: 'network', message }
|
|
39
|
+
|
|
40
|
+
return { status: 'error', code, message }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractMessage(e: unknown): string | undefined {
|
|
44
|
+
if (!e || typeof e !== 'object')
|
|
45
|
+
return undefined
|
|
46
|
+
const data = (e as { data?: unknown }).data
|
|
47
|
+
if (data && typeof data === 'object') {
|
|
48
|
+
const m = (data as { message?: unknown, error?: unknown }).message ?? (data as { error?: unknown }).error
|
|
49
|
+
if (typeof m === 'string')
|
|
50
|
+
return m
|
|
51
|
+
}
|
|
52
|
+
const m = (e as { message?: unknown }).message
|
|
53
|
+
return typeof m === 'string' ? m : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAbort(e: unknown): boolean {
|
|
57
|
+
return (e as { name?: string })?.name === 'AbortError'
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Configured $fetch instance for the layer's `/api/__gsc/*` calls.
|
|
2
|
+
//
|
|
3
|
+
// When `runtimeConfig.public.analytics.apiBase` is set, requests are routed
|
|
4
|
+
// to that origin (e.g. `https://gscdump.com`). Empty = same-origin, the
|
|
5
|
+
// default for self-hosted deployments (gscdump.com itself).
|
|
6
|
+
//
|
|
7
|
+
// Auth: resolved via `useGscAuth` (host calls `setGscAuth` from a `'pre'`
|
|
8
|
+
// plugin). `apiKey` populates `x-api-key`; `headers` carries any additional
|
|
9
|
+
// auth headers. When no auth is wired, the layer falls back to cookies for
|
|
10
|
+
// same-site / cookie-credentials deployments.
|
|
11
|
+
//
|
|
12
|
+
// Built once per NuxtApp by the layer plugin and provided as `$gscFetch`.
|
|
13
|
+
// `useGscFetch()` is a thin reader; hosts override by providing their own
|
|
14
|
+
// `$gscFetch` from a later plugin.
|
|
15
|
+
|
|
16
|
+
import type { $Fetch } from 'ofetch'
|
|
17
|
+
import { readGscAuth, resolveGscAuthHeaders } from '../composables/useGscAuth'
|
|
18
|
+
import { classifyGscError } from './gsc-error'
|
|
19
|
+
|
|
20
|
+
const TOAST_DEDUP_MS = 5000
|
|
21
|
+
|
|
22
|
+
interface ToastDedup {
|
|
23
|
+
recent: Map<string, number>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shouldEmitToast(dedup: ToastDedup, key: string): boolean {
|
|
27
|
+
const now = Date.now()
|
|
28
|
+
const last = dedup.recent.get(key) ?? 0
|
|
29
|
+
if (now - last < TOAST_DEDUP_MS)
|
|
30
|
+
return false
|
|
31
|
+
dedup.recent.set(key, now)
|
|
32
|
+
if (dedup.recent.size > 32) {
|
|
33
|
+
const oldest = [...dedup.recent.entries()].sort((a, b) => a[1] - b[1])[0]
|
|
34
|
+
if (oldest)
|
|
35
|
+
dedup.recent.delete(oldest[0])
|
|
36
|
+
}
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultToastTitle(status: string): string {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 'auth-missing': return 'Sign in required'
|
|
43
|
+
case 'rate-limited': return 'Rate limit exceeded'
|
|
44
|
+
case 'network': return 'Network error'
|
|
45
|
+
default: return 'Request failed'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createGscFetch(cfgApiBase: string, toastErrors: boolean): $Fetch {
|
|
50
|
+
const dedup: ToastDedup = { recent: new Map() }
|
|
51
|
+
return $fetch.create({
|
|
52
|
+
onRequest: ({ options }) => {
|
|
53
|
+
const auth = readGscAuth()
|
|
54
|
+
const apiBase = cfgApiBase ?? ''
|
|
55
|
+
const authHeaders = resolveGscAuthHeaders(auth)
|
|
56
|
+
|
|
57
|
+
const merged = new Headers(options.headers as HeadersInit | undefined)
|
|
58
|
+
for (const [k, v] of Object.entries(authHeaders))
|
|
59
|
+
merged.set(k, v)
|
|
60
|
+
options.headers = merged
|
|
61
|
+
|
|
62
|
+
if (apiBase && typeof options.baseURL !== 'string')
|
|
63
|
+
options.baseURL = apiBase
|
|
64
|
+
|
|
65
|
+
const hasAuth = Object.keys(authHeaders).length > 0
|
|
66
|
+
if (hasAuth) {
|
|
67
|
+
if (!options.credentials)
|
|
68
|
+
options.credentials = 'omit'
|
|
69
|
+
}
|
|
70
|
+
else if (apiBase && !options.credentials) {
|
|
71
|
+
// No explicit auth — fall back to cookies for cross-origin sessions.
|
|
72
|
+
options.credentials = 'include'
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onResponseError: (ctx) => {
|
|
76
|
+
if (!toastErrors || !import.meta.client)
|
|
77
|
+
return
|
|
78
|
+
const c = classifyGscError(ctx.error ?? ctx.response)
|
|
79
|
+
const key = `${c.status}:${c.code ?? '-'}:${c.message ?? ''}`
|
|
80
|
+
if (!shouldEmitToast(dedup, key))
|
|
81
|
+
return
|
|
82
|
+
const toast = useToast()
|
|
83
|
+
toast.add({
|
|
84
|
+
title: defaultToastTitle(c.status),
|
|
85
|
+
description: c.message,
|
|
86
|
+
color: c.status === 'rate-limited' ? 'warning' : 'error',
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
}) as unknown as $Fetch
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useGscFetch(): $Fetch {
|
|
93
|
+
return useNuxtApp().$gscFetch as $Fetch
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Re-exports the typed query operators from `gscdump/query` so layer
|
|
2
|
+
// consumers can compose filters without an extra import line. Auto-imported
|
|
3
|
+
// like the layer's other utils.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// const filter = and(
|
|
7
|
+
// between(date, range.start, range.end),
|
|
8
|
+
// eq(country, 'usa'),
|
|
9
|
+
// )
|
|
10
|
+
//
|
|
11
|
+
// Replaces nuxtseo's wire-format `dateFilter` / `andFilter` helpers — both
|
|
12
|
+
// formats coerce to the same shape server-side, so the typed primitives
|
|
13
|
+
// are strictly better.
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
and,
|
|
17
|
+
between,
|
|
18
|
+
contains,
|
|
19
|
+
eq,
|
|
20
|
+
gt,
|
|
21
|
+
gte,
|
|
22
|
+
inArray,
|
|
23
|
+
like,
|
|
24
|
+
lt,
|
|
25
|
+
lte,
|
|
26
|
+
ne,
|
|
27
|
+
not,
|
|
28
|
+
notRegex,
|
|
29
|
+
or,
|
|
30
|
+
regex,
|
|
31
|
+
topLevel,
|
|
32
|
+
} from 'gscdump/query'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shape-canonicalising helpers for GSC row payloads.
|
|
2
|
+
//
|
|
3
|
+
// Free-tier rows (GSC API) ship `position`; engine rows ship `sum_position`.
|
|
4
|
+
// Both formats coerce to the same denominator: `sum_position = position * impressions`,
|
|
5
|
+
// so downstream `weightedPosition / impressions` math works without branching.
|
|
6
|
+
//
|
|
7
|
+
// Lifted from the per-page detail pages where the coalesce + totals reducer
|
|
8
|
+
// was duplicated. The `+1` in `position` is GSC's 1-indexed convention
|
|
9
|
+
// (position 1 == top result), applied once after weighting.
|
|
10
|
+
|
|
11
|
+
export interface RawDailyRow {
|
|
12
|
+
date: string
|
|
13
|
+
clicks: number
|
|
14
|
+
impressions: number
|
|
15
|
+
sum_position?: number
|
|
16
|
+
position?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CanonicalDailyRow {
|
|
20
|
+
date: string
|
|
21
|
+
clicks: number
|
|
22
|
+
impressions: number
|
|
23
|
+
sum_position: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GscRowTotals {
|
|
27
|
+
clicks: number
|
|
28
|
+
impressions: number
|
|
29
|
+
ctr: number
|
|
30
|
+
position: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GscDailySummary {
|
|
34
|
+
daily: CanonicalDailyRow[]
|
|
35
|
+
totals: GscRowTotals
|
|
36
|
+
chartData: Array<{ date: string, clicks: number, impressions: number }>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Fill `sum_position` from `position * impressions` when the row only carries `position`. */
|
|
40
|
+
export function coerceRowMetrics<
|
|
41
|
+
T extends { impressions: number, sum_position?: number, position?: number },
|
|
42
|
+
>(row: T): T & { sum_position: number } {
|
|
43
|
+
return {
|
|
44
|
+
...row,
|
|
45
|
+
sum_position: row.sum_position ?? (row.position ?? 0) * row.impressions,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sort daily rows by date asc, coerce `sum_position`, reduce totals, derive chartData. */
|
|
50
|
+
export function summarizeDailyRows(raw: readonly RawDailyRow[]): GscDailySummary {
|
|
51
|
+
const daily: CanonicalDailyRow[] = raw
|
|
52
|
+
.map(coerceRowMetrics)
|
|
53
|
+
.map(r => ({ date: r.date, clicks: r.clicks, impressions: r.impressions, sum_position: r.sum_position }))
|
|
54
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
55
|
+
|
|
56
|
+
let clicks = 0
|
|
57
|
+
let impressions = 0
|
|
58
|
+
let weightedPosition = 0
|
|
59
|
+
for (const d of daily) {
|
|
60
|
+
clicks += d.clicks
|
|
61
|
+
impressions += d.impressions
|
|
62
|
+
weightedPosition += d.sum_position
|
|
63
|
+
}
|
|
64
|
+
const totals: GscRowTotals = {
|
|
65
|
+
clicks,
|
|
66
|
+
impressions,
|
|
67
|
+
ctr: impressions > 0 ? clicks / impressions : 0,
|
|
68
|
+
position: impressions > 0 ? weightedPosition / impressions + 1 : 0,
|
|
69
|
+
}
|
|
70
|
+
const chartData = daily.map(d => ({ date: d.date, clicks: d.clicks, impressions: d.impressions }))
|
|
71
|
+
return { daily, totals, chartData }
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Rollup-row position helper. `sum_position` is GSC's average-position sum
|
|
2
|
+
// across impressions; dividing back out (+1 because GSC is 1-indexed) gives
|
|
3
|
+
// the impression-weighted average position. Returns 0 when there were no
|
|
4
|
+
// impressions so the column renders blank rather than NaN.
|
|
5
|
+
export function positionFor(r: { impressions: number, sum_position: number }): number {
|
|
6
|
+
return r.impressions > 0 ? r.sum_position / r.impressions + 1 : 0
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Plugin factory for consumer-mode auth wiring.
|
|
2
|
+
//
|
|
3
|
+
// In consumer mode, the host app authenticates the viewer against its own
|
|
4
|
+
// origin and exchanges that for a gscdump.com api key. `setupGscFetchAuth`
|
|
5
|
+
// collapses the boilerplate of "fetch credentials, call setGscAuth, dedupe
|
|
6
|
+
// via inflight promise, enforce: 'pre'" into a single call. The returned
|
|
7
|
+
// value is a plugin definition; consumers re-export it from a client plugin
|
|
8
|
+
// file (`plugins/00.gscdump-auth.client.ts`).
|
|
9
|
+
//
|
|
10
|
+
// `enforce: 'pre'` + the returned promise make Nuxt block subsequent plugins
|
|
11
|
+
// until headers land, which avoids the auth-header race where a query mounts
|
|
12
|
+
// before credentials resolve.
|
|
13
|
+
|
|
14
|
+
import { setGscAuth } from '../composables/useGscAuth'
|
|
15
|
+
|
|
16
|
+
interface SetupGscFetchAuthOptions<TCreds extends Record<string, any> = { apiKey: string }> {
|
|
17
|
+
/** Host endpoint returning credentials. Called once per page load. */
|
|
18
|
+
credentialsEndpoint: string
|
|
19
|
+
/** Field on the response holding the api key. Default: `apiKey`. */
|
|
20
|
+
tokenField?: keyof TCreds & string
|
|
21
|
+
/** Header name to send to gscdump.com. Default: `x-api-key`. Set this only when the host uses a custom header name; the layer maps `apiKey` → `x-api-key` by default. */
|
|
22
|
+
headerName?: string
|
|
23
|
+
/**
|
|
24
|
+
* Optional hook fired after auth is set. Use for host-specific state
|
|
25
|
+
* preloading (e.g. user settings flags). Runs inside `nuxtApp.runWithContext`.
|
|
26
|
+
*/
|
|
27
|
+
onReady?: (ctx: { credentials: TCreds }) => void | Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setupGscFetchAuth<TCreds extends Record<string, any> = { apiKey: string }>(
|
|
31
|
+
options: SetupGscFetchAuthOptions<TCreds>,
|
|
32
|
+
): ReturnType<typeof defineNuxtPlugin> {
|
|
33
|
+
const { credentialsEndpoint, tokenField = 'apiKey' as keyof TCreds & string, headerName = 'x-api-key', onReady } = options
|
|
34
|
+
let inflight: Promise<void> | null = null
|
|
35
|
+
|
|
36
|
+
return defineNuxtPlugin({
|
|
37
|
+
name: 'gscdump-analytics-auth',
|
|
38
|
+
enforce: 'pre',
|
|
39
|
+
async setup(nuxtApp) {
|
|
40
|
+
if (inflight)
|
|
41
|
+
return inflight
|
|
42
|
+
inflight = (async () => {
|
|
43
|
+
const credentials = await $fetch<TCreds>(credentialsEndpoint).catch(() => null)
|
|
44
|
+
const apiKey = credentials?.[tokenField] as string | undefined
|
|
45
|
+
if (!apiKey)
|
|
46
|
+
return
|
|
47
|
+
// Default header name = `x-api-key` is already handled by the layer
|
|
48
|
+
// when `apiKey` is set on auth state. A custom `headerName` is routed
|
|
49
|
+
// through the opaque `headers` bag.
|
|
50
|
+
setGscAuth({
|
|
51
|
+
apiKey: headerName === 'x-api-key' ? apiKey : null,
|
|
52
|
+
browserAnalyzerEnabled: false,
|
|
53
|
+
headers: headerName === 'x-api-key' ? undefined : { [headerName]: apiKey },
|
|
54
|
+
})
|
|
55
|
+
if (onReady) {
|
|
56
|
+
await nuxtApp.runWithContext(() => onReady({ credentials: credentials! }))
|
|
57
|
+
}
|
|
58
|
+
})()
|
|
59
|
+
return inflight
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
package/module.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Nuxt module for @gscdump/nuxt.
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities:
|
|
4
|
+
// - Merge the layer's public runtime-config defaults (apiBase, duckdbBundleBase)
|
|
5
|
+
// with env-var fallbacks. The layer is a portable client; hosts pick where
|
|
6
|
+
// `/api/__gsc/*` calls go via `GSCDUMP_ANALYTICS_API_BASE`.
|
|
7
|
+
// - Generate the `$gscAnalyzers` plugin from `options.analyzers` so hosts
|
|
8
|
+
// only declare their analyzer registry path in `nuxt.config.ts` — no
|
|
9
|
+
// plugin boilerplate.
|
|
10
|
+
//
|
|
11
|
+
// Auth-provider wiring is NOT a Nuxt module hook; the host wires its own
|
|
12
|
+
// Nitro server handlers using primitives from @gscdump/cloudflare,
|
|
13
|
+
// @gscdump/engine-sqlite, and @gscdump/analysis. Using a build-time hook
|
|
14
|
+
// here would fire before the host's plugin has a chance to register.
|
|
15
|
+
|
|
16
|
+
import type { GscAnalyticsRuntimeConfig } from './types'
|
|
17
|
+
import process from 'node:process'
|
|
18
|
+
import { addPluginTemplate, defineNuxtModule } from '@nuxt/kit'
|
|
19
|
+
import { defu } from 'defu'
|
|
20
|
+
|
|
21
|
+
export interface ModuleOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Path to a TS/JS file exporting `ANALYZERS` (a `GscAnalyzerDefinition[]`).
|
|
24
|
+
* Accepts Nuxt aliases (`~/gscAnalyzers`) or absolute paths. When set, the
|
|
25
|
+
* module generates a plugin that provides `$gscAnalyzers` so hosts do not
|
|
26
|
+
* need to hand-roll their own plugin file.
|
|
27
|
+
*/
|
|
28
|
+
analyzers?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare module 'nuxt/schema' {
|
|
32
|
+
interface NuxtConfig { gscdumpAnalytics?: ModuleOptions }
|
|
33
|
+
interface NuxtOptions { gscdumpAnalytics: ModuleOptions }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default defineNuxtModule<ModuleOptions>({
|
|
37
|
+
meta: {
|
|
38
|
+
name: '@gscdump/nuxt',
|
|
39
|
+
configKey: 'gscdumpAnalytics',
|
|
40
|
+
compatibility: { nuxt: '>=4.0.0' },
|
|
41
|
+
},
|
|
42
|
+
setup(options, nuxt) {
|
|
43
|
+
const defaults: GscAnalyticsRuntimeConfig = {
|
|
44
|
+
apiBase: process.env.GSCDUMP_ANALYTICS_API_BASE ?? '',
|
|
45
|
+
duckdbBundleBase: process.env.GSCDUMP_DUCKDB_BUNDLE_BASE ?? '',
|
|
46
|
+
timezone: process.env.GSCDUMP_ANALYTICS_TIMEZONE ?? '',
|
|
47
|
+
toastErrors: process.env.GSCDUMP_ANALYTICS_TOAST_ERRORS === 'true',
|
|
48
|
+
defaultEngine: (process.env.GSCDUMP_ANALYTICS_DEFAULT_ENGINE as GscAnalyticsRuntimeConfig['defaultEngine'] | undefined) ?? 'auto',
|
|
49
|
+
}
|
|
50
|
+
nuxt.options.runtimeConfig.public.analytics = defu(
|
|
51
|
+
nuxt.options.runtimeConfig.public.analytics as Partial<GscAnalyticsRuntimeConfig> | undefined,
|
|
52
|
+
defaults,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if (options.analyzers) {
|
|
56
|
+
// Generated plugin imports the host's analyzer file and provides
|
|
57
|
+
// `$gscAnalyzers`. Runs after the layer's `analytics.ts` plugin (which
|
|
58
|
+
// doesn't provide `$gscAnalyzers`), so consumers see this array.
|
|
59
|
+
addPluginTemplate({
|
|
60
|
+
filename: 'gscdump-analyzers.plugin.mjs',
|
|
61
|
+
getContents: () => [
|
|
62
|
+
`import { defineNuxtPlugin } from '#app'`,
|
|
63
|
+
`import { ANALYZERS } from ${JSON.stringify(options.analyzers)}`,
|
|
64
|
+
`export default defineNuxtPlugin(() => ({ provide: { gscAnalyzers: ANALYZERS } }))`,
|
|
65
|
+
``,
|
|
66
|
+
].join('\n'),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Strip underscore-prefixed composables from the consumer auto-import surface.
|
|
71
|
+
// `app/composables/_useFoo.ts` stays usable inside the layer via explicit
|
|
72
|
+
// relative imports, but never appears as a global auto-import in host apps.
|
|
73
|
+
nuxt.hook('imports:extend', (imports) => {
|
|
74
|
+
for (let i = imports.length - 1; i >= 0; i--) {
|
|
75
|
+
const from = imports[i]?.from ?? ''
|
|
76
|
+
if (/\/_use[A-Z]/.test(from))
|
|
77
|
+
imports.splice(i, 1)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
})
|