@gscdump/devframe 0.0.0-stage → 5.7.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/README.md +89 -2
- package/dist/account.mjs +75 -0
- package/dist/client/assets/browser-agent-rpc-BXhoSh1z-CQwaKeCZ.js +1 -0
- package/dist/client/assets/index-B8m88k2l.js +4 -0
- package/dist/client/assets/index-Bv6TqI10.css +1 -0
- package/dist/client/index.html +13 -0
- package/dist/client-script/index.mjs +1518 -0
- package/dist/credential.mjs +59 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +52 -0
- package/dist/package.mjs +5 -0
- package/dist/page-stats.mjs +114 -0
- package/dist/reader.d.mts +9 -0
- package/dist/reader.mjs +238 -0
- package/dist/rpc.mjs +31 -0
- package/dist/shared/protocol.d.mts +96 -0
- package/dist/shared/protocol.mjs +19 -0
- package/dist/vite.d.mts +17 -0
- package/dist/vite.mjs +6 -0
- package/package.json +81 -4
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const DEFAULT_API_ROOT = "https://gscdump.com/api";
|
|
3
|
+
const cliAuthenticationSchema = z.union([
|
|
4
|
+
z.object({
|
|
5
|
+
_tag: z.literal("Hosted"),
|
|
6
|
+
apiRoot: z.string().url(),
|
|
7
|
+
sessionId: z.string().min(1)
|
|
8
|
+
}),
|
|
9
|
+
z.object({
|
|
10
|
+
_tag: z.literal("Hosted"),
|
|
11
|
+
apiRoot: z.string().url(),
|
|
12
|
+
apiKey: z.string().min(1)
|
|
13
|
+
}),
|
|
14
|
+
z.object({ _tag: z.literal("Local") })
|
|
15
|
+
]);
|
|
16
|
+
function trimRoot(root) {
|
|
17
|
+
return root.replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
async function resolveCredential(input) {
|
|
20
|
+
const apiRoot = trimRoot(input.apiRoot ?? input.env.GSCDUMP_API_ROOT ?? DEFAULT_API_ROOT);
|
|
21
|
+
if (input.apiKey) return {
|
|
22
|
+
_tag: "ApiKey",
|
|
23
|
+
apiKey: input.apiKey,
|
|
24
|
+
apiRoot,
|
|
25
|
+
source: "option"
|
|
26
|
+
};
|
|
27
|
+
if (input.env.GSCDUMP_API_KEY) return {
|
|
28
|
+
_tag: "ApiKey",
|
|
29
|
+
apiKey: input.env.GSCDUMP_API_KEY,
|
|
30
|
+
apiRoot,
|
|
31
|
+
source: "env"
|
|
32
|
+
};
|
|
33
|
+
const saved = await input.readCliAuthentication();
|
|
34
|
+
if (saved == null) return null;
|
|
35
|
+
const parsed = cliAuthenticationSchema.safeParse(saved);
|
|
36
|
+
if (!parsed.success || parsed.data._tag === "Local") return null;
|
|
37
|
+
const root = trimRoot(parsed.data.apiRoot);
|
|
38
|
+
return "sessionId" in parsed.data ? {
|
|
39
|
+
_tag: "CliSession",
|
|
40
|
+
sessionId: parsed.data.sessionId,
|
|
41
|
+
apiRoot: root,
|
|
42
|
+
source: "cli-session"
|
|
43
|
+
} : {
|
|
44
|
+
_tag: "ApiKey",
|
|
45
|
+
apiKey: parsed.data.apiKey,
|
|
46
|
+
apiRoot: root,
|
|
47
|
+
source: "cli-session"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function bearerOf(credential) {
|
|
51
|
+
return credential._tag === "CliSession" ? credential.sessionId : credential.apiKey;
|
|
52
|
+
}
|
|
53
|
+
function cliHeadersOf(credential) {
|
|
54
|
+
return credential._tag === "CliSession" ? { "x-cli-session": credential.sessionId } : { "x-api-key": credential.apiKey };
|
|
55
|
+
}
|
|
56
|
+
function dashboardOrigin(apiRoot) {
|
|
57
|
+
return URL.parse(apiRoot)?.origin ?? "https://gscdump.com";
|
|
58
|
+
}
|
|
59
|
+
export { bearerOf, cliHeadersOf, dashboardOrigin, resolveCredential };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DailyPoint, DateWindow, GscdumpContext, MetricTotals, PageStats, PageStatsInput, Period, QueryRow, SiteSummary } from "./shared/protocol.mjs";
|
|
2
|
+
import { GscdumpReaderOptions } from "./reader.mjs";
|
|
3
|
+
import { DevframeDefinition } from "devframe";
|
|
4
|
+
export interface GscdumpDevframeOptions extends GscdumpReaderOptions {
|
|
5
|
+
/** Override the request implementation, for proxies and tests. */
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The gscdump devframe: Search Console clicks, impressions, CTR, position, and
|
|
10
|
+
* top queries for the page in view, read from the gscdump Hosted record.
|
|
11
|
+
*
|
|
12
|
+
* Mount it in Vite DevTools with `@gscdump/devframe/vite`, or pass it to any
|
|
13
|
+
* devframe hub.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createGscdumpDevframe(options?: GscdumpDevframeOptions): DevframeDefinition;
|
|
16
|
+
export { type DailyPoint, type DateWindow, type GscdumpContext, type GscdumpReaderOptions, type MetricTotals, type PageStats, type PageStatsInput, type Period, type QueryRow, type SiteSummary, createGscdumpDevframe as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { description, homepage, name, version } from "./package.mjs";
|
|
2
|
+
import { DEVFRAME_ID } from "./shared/protocol.mjs";
|
|
3
|
+
import { createGscdumpReader } from "./reader.mjs";
|
|
4
|
+
import { createRpcFunctions } from "./rpc.mjs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { defineDevframe } from "devframe";
|
|
11
|
+
function packageDir() {
|
|
12
|
+
return dirname(createRequire(import.meta.url).resolve(`${name}/package.json`));
|
|
13
|
+
}
|
|
14
|
+
async function readCliAuthentication(env) {
|
|
15
|
+
const dir = env.GSCDUMP_CONFIG_DIR ?? join(homedir(), ".config", "gscdump");
|
|
16
|
+
const body = await readFile(join(dir, "authentication.json"), "utf8").catch((error) => {
|
|
17
|
+
if (error.code === "ENOENT") return null;
|
|
18
|
+
throw error;
|
|
19
|
+
});
|
|
20
|
+
return body === null ? null : JSON.parse(body);
|
|
21
|
+
}
|
|
22
|
+
function createGscdumpDevframe(options = {}) {
|
|
23
|
+
const dir = packageDir();
|
|
24
|
+
return defineDevframe({
|
|
25
|
+
id: DEVFRAME_ID,
|
|
26
|
+
name: "Search Console",
|
|
27
|
+
version,
|
|
28
|
+
packageName: name,
|
|
29
|
+
importMetaUrl: import.meta.url,
|
|
30
|
+
homepage,
|
|
31
|
+
description,
|
|
32
|
+
icon: "ph:chart-line-up-duotone",
|
|
33
|
+
clientAssets: join(dir, "dist/client"),
|
|
34
|
+
dock: {
|
|
35
|
+
category: "web",
|
|
36
|
+
clientScript: { importFrom: join(dir, "dist/client-script/index.mjs") }
|
|
37
|
+
},
|
|
38
|
+
capabilities: { build: false },
|
|
39
|
+
setup(ctx) {
|
|
40
|
+
const env = process.env;
|
|
41
|
+
const reader = createGscdumpReader(options, {
|
|
42
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
43
|
+
env,
|
|
44
|
+
readCliAuthentication: () => readCliAuthentication(env),
|
|
45
|
+
now: Date.now
|
|
46
|
+
});
|
|
47
|
+
const scoped = ctx.scope(DEVFRAME_ID);
|
|
48
|
+
for (const fn of createRpcFunctions(reader)) scoped.rpc.register(fn);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export { createGscdumpDevframe, createGscdumpDevframe as default };
|
package/dist/package.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
var name = "@gscdump/devframe";
|
|
2
|
+
var version = "5.7.0";
|
|
3
|
+
var description = "Search Console clicks, impressions, and queries for the page you are developing, in Vite DevTools and any devframe hub.";
|
|
4
|
+
var homepage = "https://github.com/harlan-zw/gscdump/tree/main/packages/devframe#readme";
|
|
5
|
+
export { description, homepage, name, version };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const DAY_MS = 864e5;
|
|
2
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
3
|
+
function addDays(date, days) {
|
|
4
|
+
return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
function pagePathOf(input) {
|
|
7
|
+
const text = input.trim();
|
|
8
|
+
if (!text) return null;
|
|
9
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) {
|
|
10
|
+
const url = URL.parse(text);
|
|
11
|
+
return url ? `${url.pathname}${url.search}` : null;
|
|
12
|
+
}
|
|
13
|
+
const path = text.split("#")[0];
|
|
14
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
15
|
+
}
|
|
16
|
+
function periodWindows(days, site) {
|
|
17
|
+
const { oldestDate, newestDate } = site;
|
|
18
|
+
if (!newestDate || !ISO_DATE.test(newestDate)) return null;
|
|
19
|
+
const oldest = oldestDate && ISO_DATE.test(oldestDate) ? oldestDate : null;
|
|
20
|
+
const start = addDays(newestDate, -(days - 1));
|
|
21
|
+
const current = {
|
|
22
|
+
start: oldest && start < oldest ? oldest : start,
|
|
23
|
+
end: newestDate
|
|
24
|
+
};
|
|
25
|
+
const previousEnd = addDays(start, -1);
|
|
26
|
+
const previousStart = addDays(previousEnd, -(days - 1));
|
|
27
|
+
return {
|
|
28
|
+
current,
|
|
29
|
+
previous: oldest && previousStart < oldest ? null : {
|
|
30
|
+
start: previousStart,
|
|
31
|
+
end: previousEnd
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function num(value) {
|
|
36
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
37
|
+
return Number.isFinite(n) ? n : 0;
|
|
38
|
+
}
|
|
39
|
+
function finish(sum) {
|
|
40
|
+
return {
|
|
41
|
+
clicks: sum.clicks,
|
|
42
|
+
impressions: sum.impressions,
|
|
43
|
+
ctr: sum.impressions > 0 ? sum.clicks / sum.impressions : 0,
|
|
44
|
+
position: sum.impressions > 0 ? sum.positionWeight / sum.impressions : null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function accumulate(sum, row) {
|
|
48
|
+
const impressions = num(row.impressions);
|
|
49
|
+
sum.clicks += num(row.clicks);
|
|
50
|
+
sum.impressions += impressions;
|
|
51
|
+
sum.positionWeight += num(row.position) * impressions;
|
|
52
|
+
}
|
|
53
|
+
function inWindow(date, window) {
|
|
54
|
+
return date >= window.start && date <= window.end;
|
|
55
|
+
}
|
|
56
|
+
function fillDays(window, byDate) {
|
|
57
|
+
const days = [];
|
|
58
|
+
for (let date = window.start; date <= window.end; date = addDays(date, 1)) days.push(byDate.get(date) ?? {
|
|
59
|
+
date,
|
|
60
|
+
clicks: 0,
|
|
61
|
+
impressions: 0,
|
|
62
|
+
ctr: 0,
|
|
63
|
+
position: null
|
|
64
|
+
});
|
|
65
|
+
return days;
|
|
66
|
+
}
|
|
67
|
+
function summariseDaily(rows, windows) {
|
|
68
|
+
const current = {
|
|
69
|
+
clicks: 0,
|
|
70
|
+
impressions: 0,
|
|
71
|
+
positionWeight: 0
|
|
72
|
+
};
|
|
73
|
+
const previous = {
|
|
74
|
+
clicks: 0,
|
|
75
|
+
impressions: 0,
|
|
76
|
+
positionWeight: 0
|
|
77
|
+
};
|
|
78
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const date = String(row.date ?? "");
|
|
81
|
+
const target = inWindow(date, windows.current) ? current : windows.previous && inWindow(date, windows.previous) ? previous : null;
|
|
82
|
+
if (!target) continue;
|
|
83
|
+
accumulate(target, row);
|
|
84
|
+
const day = {
|
|
85
|
+
clicks: 0,
|
|
86
|
+
impressions: 0,
|
|
87
|
+
positionWeight: 0
|
|
88
|
+
};
|
|
89
|
+
accumulate(day, row);
|
|
90
|
+
byDate.set(date, {
|
|
91
|
+
date,
|
|
92
|
+
...finish(day)
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
totals: finish(current),
|
|
97
|
+
previousTotals: windows.previous ? finish(previous) : null,
|
|
98
|
+
daily: fillDays(windows.current, byDate),
|
|
99
|
+
previousDaily: windows.previous ? fillDays(windows.previous, byDate) : null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function parseQueryRows(rows) {
|
|
103
|
+
return rows.filter((row) => typeof row.query === "string" && row.query.length > 0).map((row) => ({
|
|
104
|
+
query: String(row.query),
|
|
105
|
+
clicks: num(row.clicks),
|
|
106
|
+
impressions: num(row.impressions),
|
|
107
|
+
ctr: num(row.ctr),
|
|
108
|
+
position: row.position == null ? null : num(row.position)
|
|
109
|
+
})).sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions);
|
|
110
|
+
}
|
|
111
|
+
function dashboardPageUrl(origin, site, path) {
|
|
112
|
+
return `${origin}/app/sites/${encodeURIComponent(site.siteUrl)}/search-console/pages/${encodeURIComponent(path)}?siteId=${encodeURIComponent(site.siteId)}`;
|
|
113
|
+
}
|
|
114
|
+
export { addDays, dashboardPageUrl, pagePathOf, parseQueryRows, periodWindows, summariseDaily };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "./shared/protocol.mjs";
|
|
2
|
+
export interface GscdumpReaderOptions {
|
|
3
|
+
/** The Site to read: a Site ID, a Site URL, or its host. Optional when the credential holds one Site. */
|
|
4
|
+
site?: string;
|
|
5
|
+
/** A gscdump user API key. Defaults to `GSCDUMP_API_KEY`, then the CLI's Hosted login. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Defaults to `GSCDUMP_API_ROOT`, then `https://gscdump.com/api`. */
|
|
8
|
+
apiRoot?: string;
|
|
9
|
+
}
|
package/dist/reader.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { bearerOf, dashboardOrigin, resolveCredential } from "./credential.mjs";
|
|
2
|
+
import { matchSite, readAccount } from "./account.mjs";
|
|
3
|
+
import { addDays, dashboardPageUrl, pagePathOf, parseQueryRows, periodWindows, summariseDaily } from "./page-stats.mjs";
|
|
4
|
+
import { PERIODS } from "./shared/protocol.mjs";
|
|
5
|
+
import { parseRecordReadRefusal } from "@gscdump/contracts";
|
|
6
|
+
import { periodToDateRange } from "@gscdump/sdk/period";
|
|
7
|
+
import { createGscdumpV1Client, isGscdumpV1Error } from "@gscdump/sdk/v1";
|
|
8
|
+
const ACCOUNT_TTL_MS = 3e5;
|
|
9
|
+
const STATS_TTL_MS = 3e5;
|
|
10
|
+
const TOP_QUERIES = 25;
|
|
11
|
+
function rowsBody(dimension, path, window) {
|
|
12
|
+
return {
|
|
13
|
+
dimensions: [dimension],
|
|
14
|
+
filter: { _filters: [{
|
|
15
|
+
dimension: "date",
|
|
16
|
+
operator: "between",
|
|
17
|
+
expression: window.start,
|
|
18
|
+
expression2: window.end
|
|
19
|
+
}, {
|
|
20
|
+
dimension: "page",
|
|
21
|
+
operator: "equals",
|
|
22
|
+
expression: path
|
|
23
|
+
}] },
|
|
24
|
+
orderBy: dimension === "date" ? {
|
|
25
|
+
column: "date",
|
|
26
|
+
dir: "asc"
|
|
27
|
+
} : {
|
|
28
|
+
column: "clicks",
|
|
29
|
+
dir: "desc"
|
|
30
|
+
},
|
|
31
|
+
rowLimit: dimension === "date" ? 400 : TOP_QUERIES
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function periodDays(period) {
|
|
35
|
+
return PERIODS.find((option) => option.value === period)?.days ?? 28;
|
|
36
|
+
}
|
|
37
|
+
function createGscdumpReader(options, deps) {
|
|
38
|
+
let session = null;
|
|
39
|
+
const stats = /* @__PURE__ */ new Map();
|
|
40
|
+
async function loadSession() {
|
|
41
|
+
const credential = await resolveCredential({
|
|
42
|
+
apiKey: options.apiKey,
|
|
43
|
+
apiRoot: options.apiRoot,
|
|
44
|
+
env: deps.env,
|
|
45
|
+
readCliAuthentication: deps.readCliAuthentication
|
|
46
|
+
});
|
|
47
|
+
if (!credential) return {
|
|
48
|
+
_tag: "Blocked",
|
|
49
|
+
context: { _tag: "CredentialMissing" }
|
|
50
|
+
};
|
|
51
|
+
const account = await readAccount(credential, deps.fetch);
|
|
52
|
+
if (account._tag === "Rejected") return {
|
|
53
|
+
_tag: "Blocked",
|
|
54
|
+
context: {
|
|
55
|
+
_tag: "CredentialRejected",
|
|
56
|
+
source: credential.source
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
if (account._tag === "Unavailable") return {
|
|
60
|
+
_tag: "Blocked",
|
|
61
|
+
context: {
|
|
62
|
+
_tag: "Unavailable",
|
|
63
|
+
message: account.message
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
if (account.sites.length === 0) return {
|
|
67
|
+
_tag: "Blocked",
|
|
68
|
+
context: { _tag: "NoSites" }
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
_tag: "Ready",
|
|
72
|
+
credential,
|
|
73
|
+
client: createGscdumpV1Client({
|
|
74
|
+
apiRoot: credential.apiRoot,
|
|
75
|
+
credential: bearerOf(credential),
|
|
76
|
+
fetch: deps.fetch
|
|
77
|
+
}),
|
|
78
|
+
sites: account.sites
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function currentSession() {
|
|
82
|
+
if (session && deps.now() - session.at < ACCOUNT_TTL_MS) return session.value;
|
|
83
|
+
const value = loadSession();
|
|
84
|
+
session = {
|
|
85
|
+
at: deps.now(),
|
|
86
|
+
value
|
|
87
|
+
};
|
|
88
|
+
value.then((result) => {
|
|
89
|
+
if (result._tag === "Blocked" && session?.value === value) session = null;
|
|
90
|
+
}, () => {
|
|
91
|
+
if (session?.value === value) session = null;
|
|
92
|
+
});
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
async function context(preferredSiteId) {
|
|
96
|
+
const current = await currentSession();
|
|
97
|
+
if (current._tag === "Blocked") return current.context;
|
|
98
|
+
if (options.site) {
|
|
99
|
+
const match = matchSite(current.sites, options.site);
|
|
100
|
+
return match._tag === "Found" ? {
|
|
101
|
+
_tag: "Ready",
|
|
102
|
+
site: match.site,
|
|
103
|
+
sites: current.sites,
|
|
104
|
+
configured: true
|
|
105
|
+
} : {
|
|
106
|
+
_tag: "SiteRequired",
|
|
107
|
+
sites: current.sites,
|
|
108
|
+
target: options.site
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const preferred = preferredSiteId ? current.sites.find((site) => site.siteId === preferredSiteId) : void 0;
|
|
112
|
+
if (preferred) return {
|
|
113
|
+
_tag: "Ready",
|
|
114
|
+
site: preferred,
|
|
115
|
+
sites: current.sites,
|
|
116
|
+
configured: false
|
|
117
|
+
};
|
|
118
|
+
const match = matchSite(current.sites, null);
|
|
119
|
+
return match._tag === "Found" ? {
|
|
120
|
+
_tag: "Ready",
|
|
121
|
+
site: match.site,
|
|
122
|
+
sites: current.sites,
|
|
123
|
+
configured: false
|
|
124
|
+
} : {
|
|
125
|
+
_tag: "SiteRequired",
|
|
126
|
+
sites: current.sites,
|
|
127
|
+
target: null
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function readRows(client, siteId, body) {
|
|
131
|
+
return (await client.queryAnalyticsRows({
|
|
132
|
+
params: { siteId },
|
|
133
|
+
body
|
|
134
|
+
})).data.rows;
|
|
135
|
+
}
|
|
136
|
+
async function readPage(current, site, path, period, windows, retried) {
|
|
137
|
+
const span = {
|
|
138
|
+
start: windows.previous?.start ?? windows.current.start,
|
|
139
|
+
end: windows.current.end
|
|
140
|
+
};
|
|
141
|
+
const result = await Promise.all([readRows(current.client, site.siteId, rowsBody("date", path, span)), readRows(current.client, site.siteId, rowsBody("query", path, windows.current))]).catch((error) => error);
|
|
142
|
+
if (Array.isArray(result)) {
|
|
143
|
+
const [daily, queries] = result;
|
|
144
|
+
return {
|
|
145
|
+
_tag: "Ok",
|
|
146
|
+
siteId: site.siteId,
|
|
147
|
+
path,
|
|
148
|
+
period,
|
|
149
|
+
window: windows.current,
|
|
150
|
+
previousWindow: windows.previous,
|
|
151
|
+
...summariseDaily(daily, windows),
|
|
152
|
+
queries: parseQueryRows(queries),
|
|
153
|
+
dashboardUrl: dashboardPageUrl(dashboardOrigin(current.credential.apiRoot), site, path)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!isGscdumpV1Error(result)) throw result;
|
|
157
|
+
const refusal = parseRecordReadRefusal(result.details);
|
|
158
|
+
if (refusal?.reason === "range_not_synced" && !retried && refusal.missingStart > windows.current.start) {
|
|
159
|
+
const shifted = periodWindows(periodDays(period), {
|
|
160
|
+
oldestDate: site.oldestDate,
|
|
161
|
+
newestDate: addDays(refusal.missingStart, -1)
|
|
162
|
+
});
|
|
163
|
+
if (shifted) return readPage(current, site, path, period, shifted, true);
|
|
164
|
+
}
|
|
165
|
+
if (refusal?.reason === "record_not_ready") return {
|
|
166
|
+
_tag: "NoData",
|
|
167
|
+
siteId: site.siteId,
|
|
168
|
+
message: "gscdump has not prepared this Site's record for reads yet. Try again in a few minutes."
|
|
169
|
+
};
|
|
170
|
+
if (refusal?.reason === "range_not_synced") return {
|
|
171
|
+
_tag: "NoData",
|
|
172
|
+
siteId: site.siteId,
|
|
173
|
+
message: `The Site's record does not hold ${refusal.missingStart} to ${refusal.missingEnd} yet.`
|
|
174
|
+
};
|
|
175
|
+
if (result.code === "rate_limited") return { _tag: "RateLimited" };
|
|
176
|
+
if (result.status === 401 || result.status === 403) return {
|
|
177
|
+
_tag: "SiteUnavailable",
|
|
178
|
+
context: {
|
|
179
|
+
_tag: "CredentialRejected",
|
|
180
|
+
source: current.credential.source
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
_tag: "Failed",
|
|
185
|
+
message: result.message,
|
|
186
|
+
requestId: result.requestId ?? null
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function pageStats(input) {
|
|
190
|
+
const path = pagePathOf(input.page);
|
|
191
|
+
if (!path) return {
|
|
192
|
+
_tag: "InvalidPage",
|
|
193
|
+
page: input.page
|
|
194
|
+
};
|
|
195
|
+
const period = input.period ?? "28d";
|
|
196
|
+
const resolved = await context(input.siteId);
|
|
197
|
+
if (resolved._tag !== "Ready") return {
|
|
198
|
+
_tag: "SiteUnavailable",
|
|
199
|
+
context: resolved
|
|
200
|
+
};
|
|
201
|
+
const site = resolved.site;
|
|
202
|
+
const key = `${site.siteId}|${period}|${path}`;
|
|
203
|
+
const hit = stats.get(key);
|
|
204
|
+
if (hit && deps.now() - hit.at < STATS_TTL_MS) return hit.value;
|
|
205
|
+
const current = await currentSession();
|
|
206
|
+
if (current._tag !== "Ready") return {
|
|
207
|
+
_tag: "SiteUnavailable",
|
|
208
|
+
context: current.context
|
|
209
|
+
};
|
|
210
|
+
const stableEnd = periodToDateRange(period, { now: new Date(deps.now()) }).end;
|
|
211
|
+
const newestDate = site.newestDate && site.newestDate > stableEnd ? stableEnd : site.newestDate;
|
|
212
|
+
const windows = periodWindows(periodDays(period), {
|
|
213
|
+
oldestDate: site.oldestDate,
|
|
214
|
+
newestDate
|
|
215
|
+
});
|
|
216
|
+
if (!windows) return {
|
|
217
|
+
_tag: "NoData",
|
|
218
|
+
siteId: site.siteId,
|
|
219
|
+
message: "The Site's record holds no days yet. gscdump is still syncing it."
|
|
220
|
+
};
|
|
221
|
+
const value = readPage(current, site, path, period, windows, false);
|
|
222
|
+
stats.set(key, {
|
|
223
|
+
at: deps.now(),
|
|
224
|
+
value
|
|
225
|
+
});
|
|
226
|
+
value.then((result) => {
|
|
227
|
+
if (result._tag !== "Ok" && stats.get(key)?.value === value) stats.delete(key);
|
|
228
|
+
}, () => {
|
|
229
|
+
if (stats.get(key)?.value === value) stats.delete(key);
|
|
230
|
+
});
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
context,
|
|
235
|
+
pageStats
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export { createGscdumpReader };
|
package/dist/rpc.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineRpcFunction } from "devframe";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const pageStatsArgs = z.object({
|
|
4
|
+
page: z.string().min(1).describe("A path such as `/blog/post`, or a full URL of a page on the Site."),
|
|
5
|
+
period: z.enum([
|
|
6
|
+
"7d",
|
|
7
|
+
"28d",
|
|
8
|
+
"3m"
|
|
9
|
+
]).optional().describe("The window to read. Defaults to `28d`."),
|
|
10
|
+
siteId: z.string().optional().describe("The Site ID. Defaults to the Site the devtool reads.")
|
|
11
|
+
});
|
|
12
|
+
const pageStatsResult = z.custom();
|
|
13
|
+
function createRpcFunctions(reader) {
|
|
14
|
+
return [defineRpcFunction({
|
|
15
|
+
name: "get-context",
|
|
16
|
+
type: "query",
|
|
17
|
+
jsonSerializable: true,
|
|
18
|
+
handler: (preferredSiteId) => reader.context(preferredSiteId ?? null)
|
|
19
|
+
}), defineRpcFunction({
|
|
20
|
+
name: "get-page-stats",
|
|
21
|
+
type: "query",
|
|
22
|
+
args: [pageStatsArgs],
|
|
23
|
+
returns: pageStatsResult,
|
|
24
|
+
agent: {
|
|
25
|
+
title: "Search Console page stats",
|
|
26
|
+
description: "Read Google Search Console clicks, impressions, CTR, average position, the daily series, and the top queries for one page of the Site, from the gscdump Hosted record. Call it to check how a page performs in Google search before or after you change it."
|
|
27
|
+
},
|
|
28
|
+
handler: (input) => reader.pageStats(input)
|
|
29
|
+
})];
|
|
30
|
+
}
|
|
31
|
+
export { createRpcFunctions };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type Period = '7d' | '28d' | '3m';
|
|
2
|
+
export interface SiteSummary {
|
|
3
|
+
siteId: string;
|
|
4
|
+
/** The Site as gscdump registered it, for example `example.com`. */
|
|
5
|
+
siteUrl: string;
|
|
6
|
+
/** The first day the Hosted record holds, `YYYY-MM-DD`. */
|
|
7
|
+
oldestDate: string | null;
|
|
8
|
+
/** The last day the Hosted record holds, `YYYY-MM-DD`. */
|
|
9
|
+
newestDate: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What the node side can read, decided once per panel boot. Each tag names
|
|
13
|
+
* the next step the panel shows.
|
|
14
|
+
*/
|
|
15
|
+
export type GscdumpContext = {
|
|
16
|
+
_tag: 'Ready';
|
|
17
|
+
site: SiteSummary;
|
|
18
|
+
sites: SiteSummary[];
|
|
19
|
+
configured: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
_tag: 'SiteRequired';
|
|
22
|
+
sites: SiteSummary[];
|
|
23
|
+
target: string | null;
|
|
24
|
+
} | {
|
|
25
|
+
_tag: 'NoSites';
|
|
26
|
+
} | {
|
|
27
|
+
_tag: 'CredentialMissing';
|
|
28
|
+
} | {
|
|
29
|
+
_tag: 'CredentialRejected';
|
|
30
|
+
source: CredentialSource;
|
|
31
|
+
} | {
|
|
32
|
+
_tag: 'Unavailable';
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
export type CredentialSource = 'option' | 'env' | 'cli-session';
|
|
36
|
+
export interface MetricTotals {
|
|
37
|
+
clicks: number;
|
|
38
|
+
impressions: number;
|
|
39
|
+
ctr: number;
|
|
40
|
+
/** Impression-weighted average position. `null` when the page had no impressions. */
|
|
41
|
+
position: number | null;
|
|
42
|
+
}
|
|
43
|
+
export interface DailyPoint extends MetricTotals {
|
|
44
|
+
date: string;
|
|
45
|
+
}
|
|
46
|
+
export interface QueryRow extends MetricTotals {
|
|
47
|
+
query: string;
|
|
48
|
+
}
|
|
49
|
+
export interface DateWindow {
|
|
50
|
+
start: string;
|
|
51
|
+
end: string;
|
|
52
|
+
}
|
|
53
|
+
export interface PageStatsInput {
|
|
54
|
+
/** A path such as `/blog/post`, or a full URL. Search Console stores pages by path. */
|
|
55
|
+
page: string;
|
|
56
|
+
period?: Period;
|
|
57
|
+
/** Defaults to the Site the context resolved. */
|
|
58
|
+
siteId?: string;
|
|
59
|
+
}
|
|
60
|
+
export type PageStats = {
|
|
61
|
+
_tag: 'Ok';
|
|
62
|
+
siteId: string;
|
|
63
|
+
path: string;
|
|
64
|
+
period: Period;
|
|
65
|
+
window: DateWindow;
|
|
66
|
+
/** `null` when the Hosted record does not hold the preceding window. */
|
|
67
|
+
previousWindow: DateWindow | null;
|
|
68
|
+
totals: MetricTotals;
|
|
69
|
+
previousTotals: MetricTotals | null;
|
|
70
|
+
/** One point per day of `window`, ascending. Days without data are zero. */
|
|
71
|
+
daily: DailyPoint[];
|
|
72
|
+
/** One point per day of `previousWindow`, ascending. */
|
|
73
|
+
previousDaily: DailyPoint[] | null;
|
|
74
|
+
/** The queries that earned the page impressions, most clicks first. */
|
|
75
|
+
queries: QueryRow[];
|
|
76
|
+
/** The page in the gscdump.com dashboard. */
|
|
77
|
+
dashboardUrl: string;
|
|
78
|
+
} | {
|
|
79
|
+
_tag: 'InvalidPage';
|
|
80
|
+
page: string;
|
|
81
|
+
} | {
|
|
82
|
+
_tag: 'SiteUnavailable';
|
|
83
|
+
context: Exclude<GscdumpContext, {
|
|
84
|
+
_tag: 'Ready';
|
|
85
|
+
}>;
|
|
86
|
+
} | {
|
|
87
|
+
_tag: 'NoData';
|
|
88
|
+
siteId: string;
|
|
89
|
+
message: string;
|
|
90
|
+
} | {
|
|
91
|
+
_tag: 'RateLimited';
|
|
92
|
+
} | {
|
|
93
|
+
_tag: 'Failed';
|
|
94
|
+
message: string;
|
|
95
|
+
requestId: string | null;
|
|
96
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const DEVFRAME_ID = "gscdump";
|
|
2
|
+
const PERIODS = [
|
|
3
|
+
{
|
|
4
|
+
value: "7d",
|
|
5
|
+
label: "Last 7 days",
|
|
6
|
+
days: 7
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
value: "28d",
|
|
10
|
+
label: "Last 28 days",
|
|
11
|
+
days: 28
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
value: "3m",
|
|
15
|
+
label: "Last 3 months",
|
|
16
|
+
days: 90
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
export { DEVFRAME_ID, PERIODS };
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GscdumpDevframeOptions } from "./index.mjs";
|
|
2
|
+
import { createPluginFromDevframe } from "@vitejs/devtools-kit/node";
|
|
3
|
+
/**
|
|
4
|
+
* The gscdump devframe as a Vite plugin. Vite DevTools mounts it as a dock.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // vite.config.ts
|
|
8
|
+
* import { gscdump } from '@gscdump/devframe/vite'
|
|
9
|
+
*
|
|
10
|
+
* export default defineConfig({
|
|
11
|
+
* devtools: true,
|
|
12
|
+
* plugins: [gscdump({ site: 'example.com' })],
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function gscdump(options?: GscdumpDevframeOptions): ReturnType<typeof createPluginFromDevframe>;
|
|
17
|
+
export { gscdump as default };
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createGscdumpDevframe } from "./index.mjs";
|
|
2
|
+
import { createPluginFromDevframe } from "@vitejs/devtools-kit/node";
|
|
3
|
+
function gscdump(options = {}) {
|
|
4
|
+
return createPluginFromDevframe(createGscdumpDevframe(options));
|
|
5
|
+
}
|
|
6
|
+
export { gscdump as default, gscdump };
|