@gscdump/cli 3.5.0 → 3.6.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 +217 -54
- package/bin/gscdump.mjs +6 -1
- package/dist/analysis-local.mjs +1 -1
- package/dist/auth-state.mjs +112 -0
- package/dist/auth.mjs +109 -122
- package/dist/bing-auth.mjs +200 -0
- package/dist/bing-data.mjs +160 -0
- package/dist/bing-hosted.mjs +121 -0
- package/dist/cli.d.mts +4 -4
- package/dist/cli.mjs +20 -21
- package/dist/cloud-google.mjs +92 -0
- package/dist/command-meta.mjs +13 -1
- package/dist/command-registry.mjs +5 -2
- package/dist/commands/analyze.mjs +34 -177
- package/dist/commands/auth.mjs +161 -7
- package/dist/commands/bing.mjs +337 -0
- package/dist/commands/config.mjs +36 -43
- package/dist/commands/doctor.mjs +73 -24
- package/dist/commands/dump.mjs +1 -1
- package/dist/commands/entities.mjs +4 -4
- package/dist/commands/indexing.mjs +11 -14
- package/dist/commands/init.mjs +1 -1
- package/dist/commands/inspect.mjs +3 -3
- package/dist/commands/mcp.mjs +16 -2
- package/dist/commands/papercut.mjs +76 -0
- package/dist/commands/profile-selection.mjs +2 -2
- package/dist/commands/profile.mjs +8 -3
- package/dist/commands/query.mjs +86 -40
- package/dist/commands/report.mjs +5 -3
- package/dist/commands/sitemaps.mjs +24 -18
- package/dist/commands/skill.mjs +52 -0
- package/dist/commands/stats.mjs +58 -32
- package/dist/commands/sync.mjs +58 -25
- package/dist/config.mjs +39 -4
- package/dist/context.mjs +13 -8
- package/dist/env-file.mjs +1 -1
- package/dist/local-store.mjs +2 -2
- package/dist/mcp/errors.mjs +8 -0
- package/dist/mcp/handlers/diagnostics.mjs +31 -0
- package/dist/mcp/handlers/reports.mjs +38 -11
- package/dist/mcp/server/index.mjs +9 -10
- package/dist/mcp/types.mjs +8 -3
- package/dist/package.mjs +1 -1
- package/dist/papercut.mjs +99 -0
- package/dist/render/analysis.mjs +98 -0
- package/dist/render/charts.mjs +170 -0
- package/dist/render/layout.mjs +87 -0
- package/dist/render/metrics.mjs +163 -0
- package/dist/render/query.mjs +30 -0
- package/dist/render/report.mjs +69 -0
- package/dist/render/terminal.mjs +25 -0
- package/dist/runtime.d.mts +5 -5
- package/dist/runtime.mjs +1 -1
- package/dist/skill.mjs +45 -0
- package/dist/utils.mjs +10 -38
- package/package.json +14 -12
- package/skills/gscdump/SKILL.md +287 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { renderTable, textLines } from "./layout.mjs";
|
|
2
|
+
import { finite, formatMetric, metricLabel } from "./metrics.mjs";
|
|
3
|
+
import { barColumn, calendarBuckets, renderBars, renderShare, renderSparklines } from "./charts.mjs";
|
|
4
|
+
import { parseGscSiteUrl } from "gscdump";
|
|
5
|
+
function asRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
7
|
+
}
|
|
8
|
+
function rowLabel(row) {
|
|
9
|
+
const query = row.query ?? row.keyword;
|
|
10
|
+
const page = row.page ?? row.url;
|
|
11
|
+
return query != null && page != null ? `${query} (${page})` : String(query ?? page ?? row.country ?? row.device ?? row.month ?? row.date ?? "n/a");
|
|
12
|
+
}
|
|
13
|
+
function columnsFor(rows, keys) {
|
|
14
|
+
return (keys ?? [...new Set(rows.flatMap((row) => Object.keys(row)))]).filter((key) => rows.some((row) => key in row)).map((key) => ({
|
|
15
|
+
key,
|
|
16
|
+
label: metricLabel(key),
|
|
17
|
+
numeric: rows.some((row) => typeof row[key] === "number"),
|
|
18
|
+
format: (value) => {
|
|
19
|
+
if (value == null) return "n/a";
|
|
20
|
+
if (typeof value === "number") return formatMetric(key, value);
|
|
21
|
+
if (Array.isArray(value)) return `[${value.length} items]`;
|
|
22
|
+
if (typeof value === "object") return JSON.stringify(value, (_key, nested) => typeof nested === "bigint" ? nested.toString() : nested);
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
function renderAnalysis(result, context, options) {
|
|
28
|
+
const rows = result.results;
|
|
29
|
+
const meta = result.meta;
|
|
30
|
+
const start = typeof meta.startDate === "string" ? meta.startDate : context.start;
|
|
31
|
+
const end = typeof meta.endDate === "string" ? meta.endDate : context.end;
|
|
32
|
+
const lines = [...textLines(`${parseGscSiteUrl(context.site).hostname} / ${context.id}`, options, "accent"), ...textLines(`${start} to ${end}`, options, "muted")];
|
|
33
|
+
if (context.previous) lines.push(...textLines(`vs ${context.previous.start} to ${context.previous.end}`, options, "muted"));
|
|
34
|
+
lines.push("");
|
|
35
|
+
if (!rows.length) {
|
|
36
|
+
lines.push(...textLines("No results for this period.", options));
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
if (context.id === "brand") {
|
|
40
|
+
const summary = asRecord(meta.summary);
|
|
41
|
+
lines.push(...textLines("Click share", options), ...renderShare([{
|
|
42
|
+
label: "Brand",
|
|
43
|
+
value: finite(summary.brandClicks)
|
|
44
|
+
}, {
|
|
45
|
+
label: "Non-brand",
|
|
46
|
+
value: finite(summary.nonBrandClicks)
|
|
47
|
+
}], "clicks", options), "");
|
|
48
|
+
}
|
|
49
|
+
if (context.id === "trends") {
|
|
50
|
+
const series = rows.map((row) => ({
|
|
51
|
+
...row,
|
|
52
|
+
label: rowLabel(row),
|
|
53
|
+
total: finite(row.totalClicks),
|
|
54
|
+
points: (Array.isArray(row.series) ? row.series : []).map((point) => {
|
|
55
|
+
const value = asRecord(point);
|
|
56
|
+
return {
|
|
57
|
+
date: String(value.week),
|
|
58
|
+
value: finite(value.clicks)
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
}));
|
|
62
|
+
lines.push(...renderSparklines(series, {
|
|
63
|
+
start,
|
|
64
|
+
end,
|
|
65
|
+
unit: "week"
|
|
66
|
+
}, "clicks", options, columnsFor(rows, ["avgPosition"])));
|
|
67
|
+
} else if (context.id === "movers") {
|
|
68
|
+
const displayed = rows.map((row) => ({
|
|
69
|
+
...row,
|
|
70
|
+
entity: rowLabel(row)
|
|
71
|
+
}));
|
|
72
|
+
lines.push(...renderTable(displayed, [
|
|
73
|
+
{
|
|
74
|
+
key: "entity",
|
|
75
|
+
label: ""
|
|
76
|
+
},
|
|
77
|
+
...columnsFor(rows, ["baselineClicks", "recentClicks"]),
|
|
78
|
+
barColumn(rows, "clicksChange", options, true)
|
|
79
|
+
], options));
|
|
80
|
+
} else if (context.id === "seasonality") {
|
|
81
|
+
const months = calendarBuckets(start, end, "month");
|
|
82
|
+
const values = new Map(rows.map((row) => [String(row.month), finite(row.value)]));
|
|
83
|
+
const partial = /* @__PURE__ */ new Set();
|
|
84
|
+
if (!start.endsWith("-01")) partial.add(start.slice(0, 7));
|
|
85
|
+
if ((/* @__PURE__ */ new Date(`${end}T00:00:00Z`)).getUTCDate() !== new Date(Date.UTC(Number(end.slice(0, 4)), Number(end.slice(5, 7)), 0)).getUTCDate()) partial.add(end.slice(0, 7));
|
|
86
|
+
lines.push(...textLines(`Monthly ${context.metric ?? "clicks"}`, options));
|
|
87
|
+
lines.push(...renderBars(months.map((month) => ({
|
|
88
|
+
label: `${month}${partial.has(month) ? "*" : ""}`,
|
|
89
|
+
value: values.get(month) ?? null
|
|
90
|
+
})), context.metric ?? "clicks", options));
|
|
91
|
+
if (meta.insufficientData === true || values.size < 12) lines.push(...textLines(`! ${values.size} of 12 months`, options, "warning"));
|
|
92
|
+
if (months.some((month) => partial.has(month))) lines.push(...textLines("* partial month", options, "muted"));
|
|
93
|
+
} else lines.push(...renderTable(rows, columnsFor(rows), options));
|
|
94
|
+
const total = finite(meta.total);
|
|
95
|
+
if (total !== null && total > rows.length) lines.push("", ...textLines(`${rows.length} of ${formatMetric("clicks", total)} rows`, options, "muted"));
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
export { asRecord, columnsFor, renderAnalysis, rowLabel };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { contentWidth, fitLabel, pad, paint, renderTable, textLines } from "./layout.mjs";
|
|
2
|
+
import { finite, formatChange, formatMetric, metricLabel, signed } from "./metrics.mjs";
|
|
3
|
+
function barColumn(rows, key, options, diverging = false) {
|
|
4
|
+
const max = rows.reduce((largest, row) => Math.max(largest, Math.abs(finite(row[key]) ?? 0)), 0);
|
|
5
|
+
const width = options.columns >= 110 ? 12 : options.columns >= 70 ? 8 : 4;
|
|
6
|
+
const numberWidth = rows.reduce((largest, row) => {
|
|
7
|
+
const value = finite(row[key]);
|
|
8
|
+
return Math.max(largest, (value === null ? "n/a" : diverging ? signed(value) : formatMetric(key, value)).length);
|
|
9
|
+
}, 0);
|
|
10
|
+
return {
|
|
11
|
+
key,
|
|
12
|
+
label: diverging ? "Change" : metricLabel(key),
|
|
13
|
+
numeric: true,
|
|
14
|
+
format: (value) => {
|
|
15
|
+
const n = finite(value);
|
|
16
|
+
if (n === null || !diverging && n < 0) return "n/a";
|
|
17
|
+
const size = max === 0 ? 0 : Math.round(Math.abs(n) / max * width);
|
|
18
|
+
const fill = (options.unicode ? "█" : "#").repeat(size);
|
|
19
|
+
return `${diverging ? `${(n < 0 ? fill : "").padStart(width)}${options.unicode ? "│" : "|"}${(n > 0 ? fill : "").padEnd(width)}` : fill.padEnd(width)} ${pad(diverging ? signed(n) : formatMetric(key, n), numberWidth, true)}`;
|
|
20
|
+
},
|
|
21
|
+
tone: (value) => diverging ? (finite(value) ?? 0) < 0 ? "bad" : (finite(value) ?? 0) > 0 ? "good" : "neutral" : "accent"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function renderBars(rows, metric, options, diverging = false) {
|
|
25
|
+
if (!rows.length) return textLines("No results for this period.", options);
|
|
26
|
+
const width = contentWidth(options);
|
|
27
|
+
const max = rows.reduce((largest, row) => Math.max(largest, Math.abs(finite(row.value) ?? 0)), 0);
|
|
28
|
+
const barWidth = Math.max(1, Math.min(28, width - 16));
|
|
29
|
+
const half = Math.max(1, Math.floor(barWidth / 2));
|
|
30
|
+
const fill = options.unicode ? "█" : "#";
|
|
31
|
+
const axis = options.unicode ? "│" : "|";
|
|
32
|
+
const lines = [];
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const value = finite(row.value);
|
|
35
|
+
const label = fitLabel(row.label, width >= 64 ? 24 : width, options);
|
|
36
|
+
const valid = value !== null && (diverging || value >= 0);
|
|
37
|
+
const number = valid ? diverging ? signed(value) : formatMetric(metric, value) : "n/a";
|
|
38
|
+
if (width < 30) {
|
|
39
|
+
lines.push(...textLines(`${row.label}: ${number}`, options));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const size = !valid || max === 0 ? 0 : Math.round(Math.abs(value) / max * (diverging ? half : barWidth));
|
|
43
|
+
const mark = diverging ? `${paint(value !== null && value < 0 ? fill.repeat(size).padStart(half) : " ".repeat(half), "bad", options)}${axis}${paint(value !== null && value > 0 ? fill.repeat(size).padEnd(half) : " ".repeat(half), "good", options)}` : paint(fill.repeat(size).padEnd(barWidth), "accent", options);
|
|
44
|
+
if (width >= 76 && number.length <= 16) lines.push(` ${pad(label, 24)} ${mark} ${pad(number, 16, true)}`);
|
|
45
|
+
else lines.push(...textLines(`${label} ${number}`, options), ` ${mark}`);
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
function renderShare(rows, metric, options) {
|
|
50
|
+
if (rows.some((row) => finite(row.value) === null || row.value < 0)) return textLines("Share unavailable: incomplete values.", options, "warning");
|
|
51
|
+
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
|
52
|
+
if (total === 0) return textLines(`No ${metric} in returned rows.`, options);
|
|
53
|
+
if (!Number.isFinite(total)) return textLines("Share unavailable: total exceeds display range.", options, "warning");
|
|
54
|
+
const width = Math.min(40, contentWidth(options));
|
|
55
|
+
const glyphs = options.unicode ? [
|
|
56
|
+
"█",
|
|
57
|
+
"░",
|
|
58
|
+
"▒",
|
|
59
|
+
"▓"
|
|
60
|
+
] : [
|
|
61
|
+
"#",
|
|
62
|
+
".",
|
|
63
|
+
":",
|
|
64
|
+
"+"
|
|
65
|
+
];
|
|
66
|
+
let cumulative = 0;
|
|
67
|
+
let used = 0;
|
|
68
|
+
return [
|
|
69
|
+
` ${rows.map((row, i) => {
|
|
70
|
+
cumulative += row.value;
|
|
71
|
+
const endpoint = Math.round(cumulative / total * width);
|
|
72
|
+
const mark = glyphs[i % glyphs.length].repeat(endpoint - used);
|
|
73
|
+
used = endpoint;
|
|
74
|
+
return paint(mark, i === 0 ? "accent" : "muted", options);
|
|
75
|
+
}).join("")}`,
|
|
76
|
+
"",
|
|
77
|
+
...rows.flatMap((row, i) => textLines(`${glyphs[i % glyphs.length]} ${row.label} ${formatMetric("share", row.value / total)} ${formatMetric(metric, row.value)}`, options)),
|
|
78
|
+
"",
|
|
79
|
+
...textLines(`${formatMetric(metric, total)} ${metric} in returned rows`, options, "muted")
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
function renderMetrics(values, options) {
|
|
83
|
+
return values.flatMap((value) => {
|
|
84
|
+
const display = `${value.label}: ${formatMetric(value.key, value.current)}`;
|
|
85
|
+
if (value.previous === void 0) return textLines(display, options);
|
|
86
|
+
const change = formatChange(value.key, value.current ?? NaN, value.previous);
|
|
87
|
+
return textLines(`${display} ${change.text}`, options, change.direction);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function bucketDate(value, unit) {
|
|
91
|
+
const input = unit === "month" ? `${value.slice(0, 7)}-01` : value;
|
|
92
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) return null;
|
|
93
|
+
const date = /* @__PURE__ */ new Date(`${input}T00:00:00Z`);
|
|
94
|
+
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== input) return null;
|
|
95
|
+
if (unit === "week") date.setUTCDate(date.getUTCDate() - (date.getUTCDay() + 6) % 7);
|
|
96
|
+
return date;
|
|
97
|
+
}
|
|
98
|
+
function calendarBuckets(start, end, unit) {
|
|
99
|
+
const first = bucketDate(start, unit);
|
|
100
|
+
const last = bucketDate(end, unit);
|
|
101
|
+
if (!first || !last || first > last) return [];
|
|
102
|
+
const buckets = [];
|
|
103
|
+
const cursor = new Date(first);
|
|
104
|
+
while (cursor.getTime() <= last.getTime()) {
|
|
105
|
+
if (buckets.length === 1e4) return [];
|
|
106
|
+
buckets.push(cursor.toISOString().slice(0, unit === "month" ? 7 : 10));
|
|
107
|
+
if (unit === "month") cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
108
|
+
else cursor.setUTCDate(cursor.getUTCDate() + (unit === "week" ? 7 : 1));
|
|
109
|
+
}
|
|
110
|
+
return buckets;
|
|
111
|
+
}
|
|
112
|
+
function renderSparklines(rows, range, metric, options, extraColumns = []) {
|
|
113
|
+
const buckets = calendarBuckets(range.start, range.end, range.unit);
|
|
114
|
+
if (!buckets.length) return textLines("Series unavailable for this date range.", options, "warning");
|
|
115
|
+
const width = contentWidth(options);
|
|
116
|
+
const chartWidth = Math.max(1, width >= 64 ? Math.min(extraColumns.length ? 12 : 28, width - 40) : width);
|
|
117
|
+
const group = Math.ceil(buckets.length / chartWidth);
|
|
118
|
+
const gap = options.unicode ? "·" : "?";
|
|
119
|
+
const glyphs = options.unicode ? "▁▂▃▄▅▆▇█" : "12345678";
|
|
120
|
+
const plotted = [];
|
|
121
|
+
let missing = false;
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const map = new Map(row.points.map((point) => [point.date, finite(point.value)]));
|
|
124
|
+
const values = [];
|
|
125
|
+
for (let i = 0; i < buckets.length; i += group) {
|
|
126
|
+
const cell = buckets.slice(i, i + group).map((date) => map.get(date) ?? null);
|
|
127
|
+
values.push(cell.includes(null) ? null : cell.reduce((sum, value) => sum + value, 0));
|
|
128
|
+
}
|
|
129
|
+
const known = values.filter((value) => value !== null && Number.isFinite(value));
|
|
130
|
+
const min = known.reduce((n, value) => Math.min(n, value), Infinity);
|
|
131
|
+
const max = known.reduce((n, value) => Math.max(n, value), -Infinity);
|
|
132
|
+
const spark = values.map((value) => value === null || !Number.isFinite(value) ? gap : glyphs[max === min ? max === 0 ? 0 : 3 : Math.round((value - min) / (max - min) * 7)]).join("");
|
|
133
|
+
missing ||= spark.includes(gap);
|
|
134
|
+
plotted.push({
|
|
135
|
+
...row,
|
|
136
|
+
spark
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const lines = renderTable(plotted, [
|
|
140
|
+
{
|
|
141
|
+
key: "label",
|
|
142
|
+
label: ""
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
key: "total",
|
|
146
|
+
label: metricLabel(metric),
|
|
147
|
+
numeric: true,
|
|
148
|
+
format: (value) => formatMetric(metric, value)
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
key: "spark",
|
|
152
|
+
label: {
|
|
153
|
+
day: "Daily",
|
|
154
|
+
week: "Weekly",
|
|
155
|
+
month: "Monthly"
|
|
156
|
+
}[range.unit],
|
|
157
|
+
numeric: true,
|
|
158
|
+
tone: "accent"
|
|
159
|
+
},
|
|
160
|
+
...extraColumns
|
|
161
|
+
], options);
|
|
162
|
+
const notes = [];
|
|
163
|
+
if (rows.length > 1) notes.push("row scales");
|
|
164
|
+
if (missing) notes.push(`${gap} missing`);
|
|
165
|
+
if (group > 1) notes.push(`sum / ${group} ${range.unit}s`);
|
|
166
|
+
if (!options.unicode) notes.push("1 low, 8 high");
|
|
167
|
+
if (notes.length) lines.push(...textLines(notes.join(" | "), options, "muted"));
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
export { barColumn, calendarBuckets, renderBars, renderMetrics, renderShare, renderSparklines };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
function cleanText(value) {
|
|
4
|
+
return stripVTControlCharacters(String(value)).replace(/[\x00-\x1F\x7F-\x9F\u2028\u2029]/g, " ");
|
|
5
|
+
}
|
|
6
|
+
function contentWidth(options) {
|
|
7
|
+
return Math.max(1, (Number.isFinite(options.columns) ? Math.floor(options.columns) : 80) - 2);
|
|
8
|
+
}
|
|
9
|
+
function paint(text, tone, options) {
|
|
10
|
+
const code = {
|
|
11
|
+
accent: 36,
|
|
12
|
+
muted: 90,
|
|
13
|
+
good: 32,
|
|
14
|
+
bad: 31,
|
|
15
|
+
warning: 33,
|
|
16
|
+
neutral: 0
|
|
17
|
+
}[tone];
|
|
18
|
+
return options.color && tone !== "neutral" ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
19
|
+
}
|
|
20
|
+
function fitLabel(value, width, options) {
|
|
21
|
+
const text = cleanText(value);
|
|
22
|
+
if (stringWidth(text) <= width) return text;
|
|
23
|
+
const marker = options.unicode ? "…" : "~";
|
|
24
|
+
let result = "";
|
|
25
|
+
for (const { segment } of new Intl.Segmenter("en", { granularity: "grapheme" }).segment(text)) {
|
|
26
|
+
if (stringWidth(result + segment + marker) > width) break;
|
|
27
|
+
result += segment;
|
|
28
|
+
}
|
|
29
|
+
return width > 0 ? result + marker : "";
|
|
30
|
+
}
|
|
31
|
+
function pad(value, width, right = false) {
|
|
32
|
+
const spaces = " ".repeat(Math.max(0, width - stringWidth(value)));
|
|
33
|
+
return right ? spaces + value : value + spaces;
|
|
34
|
+
}
|
|
35
|
+
function textLines(value, options, tone = "neutral") {
|
|
36
|
+
const text = cleanText(value);
|
|
37
|
+
const width = contentWidth(options);
|
|
38
|
+
const lines = [];
|
|
39
|
+
let row = "";
|
|
40
|
+
const flush = () => {
|
|
41
|
+
if (row.trim()) lines.push(` ${paint(row.trimEnd(), tone, options)}`);
|
|
42
|
+
row = "";
|
|
43
|
+
};
|
|
44
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
45
|
+
for (const word of text.split(/(\s+)/)) {
|
|
46
|
+
if (!word) continue;
|
|
47
|
+
if (stringWidth(word) <= width) {
|
|
48
|
+
if (stringWidth(row + word) > width) flush();
|
|
49
|
+
if (row || word.trim()) row += word;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
flush();
|
|
53
|
+
for (const { segment } of segmenter.segment(word)) {
|
|
54
|
+
if (row && stringWidth(row + segment) > width) flush();
|
|
55
|
+
row += segment;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push(` ${paint(row.trimEnd(), tone, options)}`);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
function renderTable(rows, columns, options) {
|
|
62
|
+
if (rows.length === 0) return textLines("No results for this period.", options);
|
|
63
|
+
const labels = columns.map((col) => cleanText(col.label));
|
|
64
|
+
const cells = rows.map((row) => columns.map((col) => cleanText(col.format ? col.format(row[col.key]) : row[col.key] ?? "n/a")));
|
|
65
|
+
if (columns.length === 0) return [];
|
|
66
|
+
const widths = columns.map((_, i) => cells.reduce((width, row) => Math.max(width, stringWidth(row[i])), stringWidth(labels[i])));
|
|
67
|
+
let totalWidth = widths.reduce((sum, width) => sum + width, 0) + (columns.length - 1) * 2;
|
|
68
|
+
if (contentWidth(options) >= 60) for (const [i, column] of columns.entries()) {
|
|
69
|
+
if (column.numeric || totalWidth <= contentWidth(options)) continue;
|
|
70
|
+
const reduction = Math.min(widths[i] - Math.max(8, stringWidth(labels[i])), totalWidth - contentWidth(options));
|
|
71
|
+
if (reduction > 0) {
|
|
72
|
+
widths[i] -= reduction;
|
|
73
|
+
totalWidth -= reduction;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (totalWidth > contentWidth(options)) return cells.flatMap((row, rowIndex) => [...columns.flatMap((col, i) => textLines(labels[i] ? `${labels[i]}: ${row[i]}` : row[i], options, typeof col.tone === "function" ? col.tone(rows[rowIndex][col.key]) : col.tone)), ""]);
|
|
77
|
+
const line = (values, row) => ` ${values.map((value, i) => {
|
|
78
|
+
const col = columns[i];
|
|
79
|
+
return paint(pad(row && !col.numeric ? fitLabel(value, widths[i], options) : value, widths[i], col.numeric), row && (typeof col.tone === "function" ? col.tone(row[col.key]) : col.tone) || "neutral", options);
|
|
80
|
+
}).join(" ")}`;
|
|
81
|
+
return [
|
|
82
|
+
line(labels),
|
|
83
|
+
` ${paint(widths.map((width) => (options.unicode ? "─" : "-").repeat(width)).join(" "), "muted", options)}`,
|
|
84
|
+
...cells.map((row, i) => line(row, rows[i]))
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
export { cleanText, contentWidth, fitLabel, pad, paint, renderTable, textLines };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const METRICS = {
|
|
2
|
+
clicks: {
|
|
3
|
+
label: "Clicks",
|
|
4
|
+
unit: "count"
|
|
5
|
+
},
|
|
6
|
+
impressions: {
|
|
7
|
+
label: "Impressions",
|
|
8
|
+
unit: "count"
|
|
9
|
+
},
|
|
10
|
+
totalClicks: {
|
|
11
|
+
label: "Clicks",
|
|
12
|
+
unit: "count"
|
|
13
|
+
},
|
|
14
|
+
totalImpressions: {
|
|
15
|
+
label: "Impressions",
|
|
16
|
+
unit: "count"
|
|
17
|
+
},
|
|
18
|
+
recentClicks: {
|
|
19
|
+
label: "Clicks",
|
|
20
|
+
unit: "count"
|
|
21
|
+
},
|
|
22
|
+
baselineClicks: {
|
|
23
|
+
label: "Previous",
|
|
24
|
+
unit: "count"
|
|
25
|
+
},
|
|
26
|
+
prevClicks: {
|
|
27
|
+
label: "Previous",
|
|
28
|
+
unit: "count"
|
|
29
|
+
},
|
|
30
|
+
ctr: {
|
|
31
|
+
label: "CTR",
|
|
32
|
+
unit: "ratio"
|
|
33
|
+
},
|
|
34
|
+
avgCtr: {
|
|
35
|
+
label: "CTR",
|
|
36
|
+
unit: "ratio"
|
|
37
|
+
},
|
|
38
|
+
position: {
|
|
39
|
+
label: "Avg position",
|
|
40
|
+
unit: "position"
|
|
41
|
+
},
|
|
42
|
+
avgPosition: {
|
|
43
|
+
label: "Avg position",
|
|
44
|
+
unit: "position"
|
|
45
|
+
},
|
|
46
|
+
recentPosition: {
|
|
47
|
+
label: "Average position",
|
|
48
|
+
unit: "position"
|
|
49
|
+
},
|
|
50
|
+
prevPosition: {
|
|
51
|
+
label: "Previous position",
|
|
52
|
+
unit: "position"
|
|
53
|
+
},
|
|
54
|
+
growthRatio: {
|
|
55
|
+
label: "Growth",
|
|
56
|
+
unit: "factor"
|
|
57
|
+
},
|
|
58
|
+
vsAverage: {
|
|
59
|
+
label: "Vs average",
|
|
60
|
+
unit: "factor"
|
|
61
|
+
},
|
|
62
|
+
brandShare: {
|
|
63
|
+
label: "Brand share",
|
|
64
|
+
unit: "share"
|
|
65
|
+
},
|
|
66
|
+
share: {
|
|
67
|
+
label: "Share",
|
|
68
|
+
unit: "share"
|
|
69
|
+
},
|
|
70
|
+
topNConcentration: {
|
|
71
|
+
label: "Top share",
|
|
72
|
+
unit: "share"
|
|
73
|
+
},
|
|
74
|
+
declinePercent: {
|
|
75
|
+
label: "Decline",
|
|
76
|
+
unit: "share"
|
|
77
|
+
},
|
|
78
|
+
clicksChangePercent: {
|
|
79
|
+
label: "Click change",
|
|
80
|
+
unit: "percentage"
|
|
81
|
+
},
|
|
82
|
+
impressionsChangePercent: {
|
|
83
|
+
label: "Impression change",
|
|
84
|
+
unit: "percentage"
|
|
85
|
+
},
|
|
86
|
+
bytes: {
|
|
87
|
+
label: "Bytes",
|
|
88
|
+
unit: "bytes"
|
|
89
|
+
},
|
|
90
|
+
liveBytes: {
|
|
91
|
+
label: "Live bytes",
|
|
92
|
+
unit: "bytes"
|
|
93
|
+
},
|
|
94
|
+
liveRows: {
|
|
95
|
+
label: "Rows",
|
|
96
|
+
unit: "count"
|
|
97
|
+
},
|
|
98
|
+
liveFiles: {
|
|
99
|
+
label: "Files",
|
|
100
|
+
unit: "count"
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
function metricLabel(key) {
|
|
104
|
+
return METRICS[key]?.label ?? (key === "keyword" ? "query" : key);
|
|
105
|
+
}
|
|
106
|
+
function finite(value) {
|
|
107
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
108
|
+
}
|
|
109
|
+
function formatMetric(key, value) {
|
|
110
|
+
const n = finite(value);
|
|
111
|
+
if (n === null) return "n/a";
|
|
112
|
+
const unit = METRICS[key]?.unit;
|
|
113
|
+
if ((unit === "ratio" || unit === "share") && (n < 0 || n > 1)) return "n/a";
|
|
114
|
+
if (unit === "factor" && !Number.isFinite((n - 1) * 100)) return "n/a";
|
|
115
|
+
switch (unit) {
|
|
116
|
+
case "count": return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
|
|
117
|
+
case "ratio": return `${(n * 100).toFixed(2)}%`;
|
|
118
|
+
case "share": return `${(n * 100).toFixed(1)}%`;
|
|
119
|
+
case "percentage": return `${signed(n, 1)}%`;
|
|
120
|
+
case "factor": return `${signed((n - 1) * 100, 1)}%`;
|
|
121
|
+
case "position": return n.toFixed(1);
|
|
122
|
+
case "bytes": {
|
|
123
|
+
const scale = n >= 1024 ** 3 ? 3 : n >= 1024 ** 2 ? 2 : n >= 1024 ? 1 : 0;
|
|
124
|
+
return `${(n / 1024 ** scale).toFixed(scale === 0 ? 0 : 2)} ${[
|
|
125
|
+
"B",
|
|
126
|
+
"KiB",
|
|
127
|
+
"MiB",
|
|
128
|
+
"GiB"
|
|
129
|
+
][scale]}`;
|
|
130
|
+
}
|
|
131
|
+
default: return String(n);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function signed(n, decimals = 0) {
|
|
135
|
+
if (!Number.isFinite(n)) return "n/a";
|
|
136
|
+
const rounded = Number(n.toFixed(decimals));
|
|
137
|
+
return `${rounded > 0 ? "+" : ""}${rounded.toLocaleString("en-US", {
|
|
138
|
+
minimumFractionDigits: decimals,
|
|
139
|
+
maximumFractionDigits: decimals
|
|
140
|
+
})}`;
|
|
141
|
+
}
|
|
142
|
+
function formatChange(key, current, previous) {
|
|
143
|
+
if (previous === null || !Number.isFinite(current) || !Number.isFinite(previous)) return {
|
|
144
|
+
text: "No baseline.",
|
|
145
|
+
direction: "neutral"
|
|
146
|
+
};
|
|
147
|
+
const difference = current - previous;
|
|
148
|
+
const unit = METRICS[key]?.unit;
|
|
149
|
+
const direction = difference === 0 || unit === "share" ? "neutral" : (unit === "position" ? difference < 0 : difference > 0) ? "good" : "bad";
|
|
150
|
+
if (unit === "position") return {
|
|
151
|
+
text: difference === 0 ? "unchanged" : `${difference < 0 ? "improved" : "worsened"} ${Math.abs(difference).toFixed(1)}`,
|
|
152
|
+
direction
|
|
153
|
+
};
|
|
154
|
+
if (unit === "ratio" || unit === "share") return {
|
|
155
|
+
text: `${signed(difference * 100, 2)} pp`,
|
|
156
|
+
direction
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
text: previous === 0 ? `${signed(difference)} (previous: 0)` : `${signed(difference)} (${signed(difference / Math.abs(previous) * 100, 1)}%)`,
|
|
160
|
+
direction
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export { finite, formatChange, formatMetric, metricLabel, signed };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { renderTable, textLines } from "./layout.mjs";
|
|
2
|
+
import { barColumn } from "./charts.mjs";
|
|
3
|
+
import { columnsFor } from "./analysis.mjs";
|
|
4
|
+
import { parseGscSiteUrl } from "gscdump";
|
|
5
|
+
function renderQuery(input, options) {
|
|
6
|
+
const columns = columnsFor(input.rows, [
|
|
7
|
+
...input.dimensions,
|
|
8
|
+
"clicks",
|
|
9
|
+
"impressions",
|
|
10
|
+
"ctr",
|
|
11
|
+
"position"
|
|
12
|
+
]);
|
|
13
|
+
if (input.dimensions.length === 1 && [
|
|
14
|
+
"page",
|
|
15
|
+
"query",
|
|
16
|
+
"country",
|
|
17
|
+
"device",
|
|
18
|
+
"date"
|
|
19
|
+
].includes(input.dimensions[0])) {
|
|
20
|
+
const clicks = columns.findIndex((column) => column.key === "clicks");
|
|
21
|
+
if (clicks !== -1) columns[clicks] = barColumn(input.rows, "clicks", options);
|
|
22
|
+
}
|
|
23
|
+
return [
|
|
24
|
+
...textLines(`${parseGscSiteUrl(input.site).hostname} / query`, options, "accent"),
|
|
25
|
+
...textLines(`${input.start} to ${input.end}`, options, "muted"),
|
|
26
|
+
"",
|
|
27
|
+
...renderTable(input.rows, columns, options)
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
export { renderQuery };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { renderTable, textLines } from "./layout.mjs";
|
|
2
|
+
import { barColumn, renderMetrics } from "./charts.mjs";
|
|
3
|
+
import { columnsFor } from "./analysis.mjs";
|
|
4
|
+
import { parseGscSiteUrl } from "gscdump";
|
|
5
|
+
function renderCliReport(report, options) {
|
|
6
|
+
const lines = [...textLines(`${parseGscSiteUrl(report.site).hostname} / ${report.id}`, options, "accent"), ...textLines(`${report.window.start} to ${report.window.end}`, options, "muted")];
|
|
7
|
+
if (report.window.comparison) lines.push(...textLines(`vs ${report.window.comparison.start} to ${report.window.comparison.end}`, options, "muted"));
|
|
8
|
+
if (report.meta.degraded) lines.push(...textLines(`! Unavailable: ${report.meta.steps.filter((step) => step.status === "error").map((step) => step.key).join(", ")}`, options, "warning"));
|
|
9
|
+
const sections = report.sections.filter((section) => section.findings.length || section.coverage === "partial");
|
|
10
|
+
for (const section of sections) {
|
|
11
|
+
const status = section.coverage === "partial" ? " (partial)" : ["info", "low"].includes(section.severity) ? "" : ` [${section.severity}]`;
|
|
12
|
+
lines.push("", ...textLines(`${section.title}${status}`, options, section.coverage === "partial" ? "warning" : "accent"));
|
|
13
|
+
if (!section.findings.length) continue;
|
|
14
|
+
const changes = section.findings.filter((finding) => finding.delta?.metric === "clicks");
|
|
15
|
+
if (changes.length) {
|
|
16
|
+
const rows = changes.map((finding) => ({
|
|
17
|
+
...finding.metrics,
|
|
18
|
+
entity: finding.why ? `${finding.entity.value} (${finding.why})` : finding.entity.value,
|
|
19
|
+
prevClicks: finding.delta.prior,
|
|
20
|
+
clicks: finding.delta.current,
|
|
21
|
+
clicksChange: finding.delta.current - finding.delta.prior,
|
|
22
|
+
clicksChangePercent: finding.delta.prior === 0 ? null : (finding.delta.current - finding.delta.prior) / Math.abs(finding.delta.prior) * 100
|
|
23
|
+
}));
|
|
24
|
+
const extra = [...new Set(changes.flatMap((finding) => Object.keys(finding.metrics)))].filter((key) => ![
|
|
25
|
+
"clicks",
|
|
26
|
+
"clicksChange",
|
|
27
|
+
"clicksChangePercent",
|
|
28
|
+
"prevClicks",
|
|
29
|
+
"lostClicks",
|
|
30
|
+
"declinePercent"
|
|
31
|
+
].includes(key));
|
|
32
|
+
lines.push(...renderTable(rows, [
|
|
33
|
+
{
|
|
34
|
+
key: "entity",
|
|
35
|
+
label: ""
|
|
36
|
+
},
|
|
37
|
+
...columnsFor(rows, ["prevClicks", "clicks"]),
|
|
38
|
+
barColumn(rows, "clicksChange", options, true),
|
|
39
|
+
...columnsFor(rows, ["clicksChangePercent"]).map((column) => ({
|
|
40
|
+
...column,
|
|
41
|
+
label: "%"
|
|
42
|
+
})),
|
|
43
|
+
...columnsFor(rows, extra)
|
|
44
|
+
], options));
|
|
45
|
+
}
|
|
46
|
+
for (const finding of section.findings.filter((finding) => finding.delta?.metric !== "clicks")) {
|
|
47
|
+
lines.push(...textLines(finding.entity.value, options));
|
|
48
|
+
const delta = finding.delta;
|
|
49
|
+
if (delta) lines.push(...renderMetrics([{
|
|
50
|
+
key: delta.metric,
|
|
51
|
+
label: delta.metric,
|
|
52
|
+
current: delta.current,
|
|
53
|
+
previous: delta.prior
|
|
54
|
+
}], options));
|
|
55
|
+
const metrics = Object.fromEntries(Object.entries(finding.metrics).filter(([key]) => !delta || ![
|
|
56
|
+
delta.metric,
|
|
57
|
+
`${delta.metric}Change`,
|
|
58
|
+
`${delta.metric}ChangePercent`
|
|
59
|
+
].includes(key)));
|
|
60
|
+
const fields = columnsFor([metrics]).map((column) => `${column.label} ${column.format(metrics[column.key])}`);
|
|
61
|
+
if (fields.length) lines.push(...textLines(fields.join(" "), options));
|
|
62
|
+
if (finding.why) lines.push(...textLines(finding.why, options, "muted"));
|
|
63
|
+
}
|
|
64
|
+
if (section.truncated && section.truncated.total > section.truncated.kept) lines.push(...textLines(`${section.truncated.kept} of ${section.truncated.total} rows`, options, "muted"));
|
|
65
|
+
}
|
|
66
|
+
if (!sections.length) lines.push("", ...textLines(report.meta.degraded ? "Data unavailable." : "No findings.", options));
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
export { renderCliReport };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCliRuntime } from "../runtime.mjs";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
function resolveOutputOptions(input) {
|
|
4
|
+
const env = input.environment;
|
|
5
|
+
const dumb = env.TERM === "dumb";
|
|
6
|
+
return {
|
|
7
|
+
columns: input.columns && input.columns > 0 ? Math.floor(input.columns) : 80,
|
|
8
|
+
color: !dumb && !input.noColor && !env.NO_COLOR && env.FORCE_COLOR !== "0" && (Boolean(input.isTTY) || Boolean(env.FORCE_COLOR)),
|
|
9
|
+
unicode: !dumb
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function terminalOutputOptions(toFile = false) {
|
|
13
|
+
const runtime = useCliRuntime();
|
|
14
|
+
const options = resolveOutputOptions({
|
|
15
|
+
columns: toFile ? 80 : process.stdout.columns,
|
|
16
|
+
isTTY: !toFile && process.stdout.isTTY,
|
|
17
|
+
environment: runtime.environment,
|
|
18
|
+
noColor: !runtime.colorEnabled || runtime.rawArgs.includes("--no-color")
|
|
19
|
+
});
|
|
20
|
+
return toFile ? {
|
|
21
|
+
...options,
|
|
22
|
+
color: false
|
|
23
|
+
} : options;
|
|
24
|
+
}
|
|
25
|
+
export { resolveOutputOptions, terminalOutputOptions };
|
package/dist/runtime.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConsolaInstance } from "consola";
|
|
2
|
-
type CliEnvironmentSource = Record<string, string | undefined>;
|
|
3
|
-
interface CliRuntime {
|
|
2
|
+
export type CliEnvironmentSource = Record<string, string | undefined>;
|
|
3
|
+
export interface CliRuntime {
|
|
4
|
+
authModeOverride?: 'cloud' | 'local';
|
|
4
5
|
activeProfileOverride: string | null;
|
|
5
6
|
colorEnabled: boolean;
|
|
6
7
|
configDir: string;
|
|
@@ -10,11 +11,10 @@ interface CliRuntime {
|
|
|
10
11
|
quiet: boolean;
|
|
11
12
|
rawArgs: string[];
|
|
12
13
|
}
|
|
13
|
-
interface CreateCliRuntimeOptions {
|
|
14
|
+
export interface CreateCliRuntimeOptions {
|
|
14
15
|
configDir?: string;
|
|
15
16
|
environment?: CliEnvironmentSource;
|
|
16
17
|
rawArgs?: readonly string[];
|
|
17
18
|
stderr?: NodeJS.WriteStream;
|
|
18
19
|
}
|
|
19
|
-
declare function createCliRuntime(opts?: CreateCliRuntimeOptions): CliRuntime;
|
|
20
|
-
export { CliEnvironmentSource, CliRuntime, CreateCliRuntimeOptions, createCliRuntime };
|
|
20
|
+
export declare function createCliRuntime(opts?: CreateCliRuntimeOptions): CliRuntime;
|
package/dist/runtime.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import os from "node:os";
|
|
5
5
|
import { createConsola } from "consola";
|
|
6
6
|
let runtimeStorage;
|
|
7
7
|
let fallbackRuntime;
|