@gscdump/engine-gsc-api 0.7.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Harlan Wilton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @gscdump/engine-gsc-api
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@gscdump/engine-gsc-api?color=yellow)](https://npmjs.com/package/@gscdump/engine-gsc-api)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@gscdump/engine-gsc-api?color=yellow)](https://npm.chart.dev/@gscdump/engine-gsc-api)
5
+ [![license](https://img.shields.io/github/license/harlan-zw/gscdump?color=yellow)](https://github.com/harlan-zw/gscdump/blob/main/LICENSE)
6
+
7
+ > GSC live-API engine adapter — wraps the Search Analytics REST API as an `AnalysisQuerySource` for typed analyzer dispatch.
8
+
9
+ Wraps the Google Search Console live REST API as a `RowQuerySource` so row-based analyzers (`striking-distance`, `opportunity`, `movers`, `decay`, `brand`, `clustering`, `concentration`, `seasonality`) dispatch through the same `runAnalyzerFromSource` pipeline as engine-backed sources.
10
+
11
+ Use this when you have a GSC OAuth token but no synced parquet data — free-tier flows, demo pages, queries whose date range falls outside the synced window. Pair with `createCompositeSource` to fall back to GSC for out-of-range queries.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @gscdump/engine-gsc-api @gscdump/engine gscdump
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { analyzeMoversFromSource } from '@gscdump/analysis'
23
+ import { createGscApiQuerySource } from '@gscdump/engine-gsc-api'
24
+ import { googleSearchConsole } from 'gscdump'
25
+
26
+ const client = googleSearchConsole(auth)
27
+ const source = createGscApiQuerySource({ client, siteUrl: 'sc-domain:example.com' })
28
+
29
+ const movers = await analyzeMoversFromSource(source, {
30
+ current: { startDate: '2026-04-01', endDate: '2026-04-28' },
31
+ previous: { startDate: '2026-03-01', endDate: '2026-03-31' },
32
+ })
33
+ ```
34
+
35
+ For host apps that mint short-lived access tokens per request:
36
+
37
+ ```ts
38
+ import { createLiveGscSource } from '@gscdump/engine-gsc-api'
39
+
40
+ const source = createLiveGscSource({
41
+ siteUrl,
42
+ getAccessToken: () => refreshAccessTokenForUser(userId),
43
+ })
44
+ ```
45
+
46
+ ## Exports
47
+
48
+ - `createGscApiQuerySource({ client, siteUrl })` — `RowQuerySource` over a `GoogleSearchConsoleClient`.
49
+ - `createLiveGscSource({ siteUrl, getAccessToken })` — token-refresh wrapper on top of `createGscApiQuerySource`.
50
+ - `canProxyToGsc(state)` — guard for `createCompositeSource`: returns `true` if a `BuilderState` can be answered by GSC's native API (no metric filters, no engine-derived dimensions).
51
+ - `fetchGscTopN({ client, siteUrl, dimension, range, limit })` — typed top-N rollup helper.
52
+ - `fetchGscDaily({ client, siteUrl, range })` — typed daily timeseries helper.
53
+ - `collectGscRows(asyncIterable)` — drain `client.query()` into an array.
54
+ - `applyBuilderStatePostProcessing(rows, state)` — post-process row collections for predicates GSC can't push down (metric filters, special operators).
55
+ - `GSC_API_CAPABILITIES` — `PlannerCapabilities` for the GSC API surface.
56
+
57
+ ## Capabilities
58
+
59
+ GSC supports regex pushdown via `INCLUDING_REGEX` / `EXCLUDING_REGEX` filters but has no SQL surface, no comparison joins, no cross-dataset queries, and no engine-derived dimensions (`queryCanonical`, `page_keywords`). Pair with `createCompositeSource({ engine, gsc })` from `@gscdump/analysis/source` to route SQL-shaped queries to the engine and date-out-of-range queries to GSC.
60
+
61
+ ## Related
62
+
63
+ - [`@gscdump/engine`](../engine) — Source contracts (`RowQuerySource`, `AnalysisQuerySource`).
64
+ - [`@gscdump/analysis`](../analysis) — Analyzer instances + `createCompositeSource`.
65
+ - [`gscdump`](../gscdump) — REST client + query builder.
66
+
67
+ ## License
68
+
69
+ [MIT](../../LICENSE)
@@ -0,0 +1,69 @@
1
+ import { GoogleSearchConsoleClient } from "gscdump";
2
+ import { BuilderState, Column, Dimension } from "gscdump/query";
3
+ import { AnalysisQuerySource, RowQuerySource } from "@gscdump/engine/resolver";
4
+ import { PlannerCapabilities } from "gscdump/query/plan";
5
+ declare function canProxyToGsc(state: BuilderState): boolean;
6
+ interface CreateLiveGscSourceOptions {
7
+ /** GSC property URL (e.g. `sc-domain:example.com` or `https://example.com/`). */
8
+ siteUrl: string;
9
+ /**
10
+ * Returns a valid GSC access token. Called lazily on first query so refresh
11
+ * cost is paid only when the source actually runs. Host owns refresh logic.
12
+ */
13
+ getAccessToken: () => Promise<string>;
14
+ }
15
+ declare function createLiveGscSource(opts: CreateLiveGscSourceOptions): AnalysisQuerySource;
16
+ declare function applyBuilderStatePostProcessing(rows: Record<string, unknown>[], state: BuilderState): Record<string, unknown>[];
17
+ declare function collectRows<T>(gen: AsyncGenerator<T[]>): Promise<T[]>;
18
+ interface GscRange {
19
+ start: string;
20
+ end: string;
21
+ }
22
+ interface GscTopNRow {
23
+ key: string;
24
+ clicks: number;
25
+ impressions: number;
26
+ sum_position: number;
27
+ }
28
+ interface FetchTopNOptions<D extends Dimension> {
29
+ client: GoogleSearchConsoleClient;
30
+ siteUrl: string;
31
+ dimension: Column<D>;
32
+ range: GscRange;
33
+ /**
34
+ * Ask the GSC API to order by clicks desc. Skip for dimensions where GSC
35
+ * already returns sensibly ranked rows (e.g. country).
36
+ */
37
+ orderByClicksDesc?: boolean;
38
+ /** Forwarded to the GSC builder. */
39
+ limit?: number;
40
+ /** Trim after the fact (e.g. country has no server-side limit). */
41
+ sliceTop?: number;
42
+ }
43
+ declare function fetchGscTopN<D extends Dimension>(opts: FetchTopNOptions<D>): Promise<GscTopNRow[]>;
44
+ interface GscDailyRow {
45
+ date: number;
46
+ clicks: number;
47
+ impressions: number;
48
+ sum_position: number;
49
+ anonymizedImpressionsPct: number;
50
+ }
51
+ declare function fetchGscDaily(opts: {
52
+ client: GoogleSearchConsoleClient;
53
+ siteUrl: string;
54
+ range: GscRange;
55
+ }): Promise<GscDailyRow[]>;
56
+ /**
57
+ * Capabilities the live GSC API can satisfy. Regex pushes down via the
58
+ * `INCLUDING_REGEX` / `EXCLUDING_REGEX` filter types; comparison joins and
59
+ * cross-dataset queries do not exist on the wire, and the API does not
60
+ * expose window aggregations. Metric filters and ordering are honored by
61
+ * the source-layer post-process pass after row collection.
62
+ */
63
+ declare const GSC_API_CAPABILITIES: PlannerCapabilities;
64
+ interface GscApiQuerySourceOptions {
65
+ client: GoogleSearchConsoleClient;
66
+ siteUrl: string;
67
+ }
68
+ declare function createGscApiQuerySource(options: GscApiQuerySourceOptions): RowQuerySource;
69
+ export { type CreateLiveGscSourceOptions, type FetchTopNOptions, GSC_API_CAPABILITIES, type GscApiQuerySourceOptions, type GscDailyRow, type GscRange, type GscTopNRow, applyBuilderStatePostProcessing, canProxyToGsc, collectRows as collectGscRows, createGscApiQuerySource, createLiveGscSource, fetchGscDaily, fetchGscTopN };
package/dist/index.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import { googleSearchConsole } from "gscdump";
2
+ import { between, clicks, date, extractMetricFilters, extractSpecialOperatorFilters, gsc } from "gscdump/query";
3
+ import { assertDimensionsSupported, getDimensionFilters, getFilterDimensions, matchesDimensionFilter, matchesMetricFilter, matchesTopLevelPage, metricValue } from "@gscdump/engine/resolver";
4
+ import { buildLogicalPlan } from "gscdump/query/plan";
5
+ const METRIC_NAMES = [
6
+ "clicks",
7
+ "impressions",
8
+ "ctr",
9
+ "position"
10
+ ];
11
+ function isMetricDimension$1(dim) {
12
+ return METRIC_NAMES.includes(dim);
13
+ }
14
+ function applyBuilderStatePostProcessing(rows, state) {
15
+ const dimensionFilters = getDimensionFilters(state.filter, isMetricDimension$1);
16
+ const metricFilters = extractMetricFilters(state.filter);
17
+ const specialFilters = extractSpecialOperatorFilters(state.filter);
18
+ const ordered = [...rows.filter((row) => {
19
+ if (!dimensionFilters.every((filter) => matchesDimensionFilter(row, filter))) return false;
20
+ if (!metricFilters.every((filter) => matchesMetricFilter(row, filter))) return false;
21
+ if (specialFilters.some((filter) => filter.operator === "topLevel") && !matchesTopLevelPage(row)) return false;
22
+ return true;
23
+ })].sort((a, b) => {
24
+ const column = state.orderBy?.column ?? "clicks";
25
+ const dir = state.orderBy?.dir ?? "desc";
26
+ const left = column === "date" ? String(a.date ?? "") : metricValue(a, column);
27
+ const right = column === "date" ? String(b.date ?? "") : metricValue(b, column);
28
+ if (left === right) return 0;
29
+ if (dir === "asc") return left < right ? -1 : 1;
30
+ return left > right ? -1 : 1;
31
+ });
32
+ const offset = Math.max(0, Number(state.startRow ?? 0));
33
+ const limit = Math.max(0, Number((state.rowLimit ?? ordered.length) || 0));
34
+ return ordered.slice(offset, offset + limit);
35
+ }
36
+ async function collectRows(gen) {
37
+ const out = [];
38
+ for await (const batch of gen) out.push(...batch);
39
+ return out;
40
+ }
41
+ async function fetchGscTopN(opts) {
42
+ const { client, siteUrl, dimension, range, orderByClicksDesc, limit, sliceTop } = opts;
43
+ let builder = gsc.select(dimension).where(between(date, range.start, range.end));
44
+ if (orderByClicksDesc) builder = builder.orderBy(clicks, "desc");
45
+ if (typeof limit === "number") builder = builder.limit(limit);
46
+ const mapped = (await collectRows(client.query(siteUrl, builder))).map((r) => {
47
+ const row = r;
48
+ const key = row[dimension.dimension];
49
+ if (typeof key !== "string" || !key) return null;
50
+ const impressions = Number(row.impressions ?? 0);
51
+ const position = Number(row.position ?? 0);
52
+ return {
53
+ key,
54
+ clicks: Number(row.clicks ?? 0),
55
+ impressions,
56
+ sum_position: position * impressions
57
+ };
58
+ }).filter((x) => x != null);
59
+ if (!orderByClicksDesc) mapped.sort((a, b) => b.clicks - a.clicks);
60
+ return typeof sliceTop === "number" ? mapped.slice(0, sliceTop) : mapped;
61
+ }
62
+ async function fetchGscDaily(opts) {
63
+ const { client, siteUrl, range } = opts;
64
+ const builder = gsc.select(date).where(between(date, range.start, range.end));
65
+ return (await collectRows(client.query(siteUrl, builder))).map((r) => {
66
+ const row = r;
67
+ if (!row.date) return null;
68
+ const impressions = row.impressions ?? 0;
69
+ return {
70
+ date: Date.parse(`${row.date}T00:00:00Z`),
71
+ clicks: row.clicks ?? 0,
72
+ impressions,
73
+ sum_position: (row.position ?? 0) * impressions,
74
+ anonymizedImpressionsPct: 0
75
+ };
76
+ }).filter((x) => x != null).sort((a, b) => a.date - b.date);
77
+ }
78
+ const GSC_API_CAPABILITIES = {
79
+ regex: true,
80
+ multiDataset: false,
81
+ comparisonJoin: false,
82
+ windowTotals: false
83
+ };
84
+ function isMetricDimension(dim) {
85
+ return [
86
+ "clicks",
87
+ "impressions",
88
+ "ctr",
89
+ "position"
90
+ ].includes(dim);
91
+ }
92
+ function builderFromState(state) {
93
+ return { getState: () => state };
94
+ }
95
+ function createGscApiQuerySource(options) {
96
+ const { client, siteUrl } = options;
97
+ return {
98
+ name: "gsc-api",
99
+ capabilities: GSC_API_CAPABILITIES,
100
+ async queryRows(state) {
101
+ buildLogicalPlan(state, GSC_API_CAPABILITIES);
102
+ const filterDims = getFilterDimensions(state.filter, isMetricDimension);
103
+ assertDimensionsSupported([...state.dimensions, ...filterDims], "api", "gsc-api query source");
104
+ return applyBuilderStatePostProcessing(await collectRows(client.query(siteUrl, builderFromState(state))), state);
105
+ }
106
+ };
107
+ }
108
+ const PRO_ONLY_DIMENSIONS = new Set(["queryCanonical", "page_keywords"]);
109
+ function canProxyToGsc(state) {
110
+ if (state.dimensions.some((d) => PRO_ONLY_DIMENSIONS.has(d))) return false;
111
+ if (extractMetricFilters(state.filter).length > 0) return false;
112
+ if (extractSpecialOperatorFilters(state.filter).length > 0) return false;
113
+ return true;
114
+ }
115
+ function createLiveGscSource(opts) {
116
+ let clientPromise = null;
117
+ function getClient() {
118
+ if (!clientPromise) clientPromise = opts.getAccessToken().then((accessToken) => googleSearchConsole({ accessToken }));
119
+ return clientPromise;
120
+ }
121
+ return {
122
+ name: "gsc-api",
123
+ capabilities: {
124
+ regex: true,
125
+ multiDataset: false,
126
+ comparisonJoin: false,
127
+ windowTotals: false
128
+ },
129
+ async queryRows(state) {
130
+ return createGscApiQuerySource({
131
+ client: await getClient(),
132
+ siteUrl: opts.siteUrl
133
+ }).queryRows(state);
134
+ }
135
+ };
136
+ }
137
+ export { GSC_API_CAPABILITIES, applyBuilderStatePostProcessing, canProxyToGsc, collectRows as collectGscRows, createGscApiQuerySource, createLiveGscSource, fetchGscDaily, fetchGscTopN };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@gscdump/engine-gsc-api",
3
+ "type": "module",
4
+ "version": "0.7.1",
5
+ "description": "GSC live-API engine adapter — wraps the Search Analytics REST API as an AnalysisQuerySource for typed analyzer dispatch.",
6
+ "author": {
7
+ "name": "Harlan Wilton",
8
+ "email": "harlan@harlanzw.com",
9
+ "url": "https://harlanzw.com/"
10
+ },
11
+ "license": "MIT",
12
+ "funding": "https://github.com/sponsors/harlan-zw",
13
+ "homepage": "https://github.com/harlan-zw/gscdump/tree/main/packages/engine-gsc-api#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/harlan-zw/gscdump.git",
17
+ "directory": "packages/engine-gsc-api"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/harlan-zw/gscdump/issues"
21
+ },
22
+ "sideEffects": false,
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.mts",
26
+ "import": "./dist/index.mjs",
27
+ "default": "./dist/index.mjs"
28
+ }
29
+ },
30
+ "main": "./dist/index.mjs",
31
+ "types": "./dist/index.d.mts",
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@gscdump/engine": "0.7.1",
40
+ "gscdump": "0.7.1"
41
+ },
42
+ "devDependencies": {
43
+ "vitest": "^4.1.5"
44
+ },
45
+ "scripts": {
46
+ "build": "obuild",
47
+ "dev": "obuild --stub",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest --passWithNoTests"
50
+ }
51
+ }