@gscdump/sdk 1.0.1 → 1.0.3
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 +7 -1
- package/dist/_chunks/errors.d.mts +22 -0
- package/dist/_chunks/request.d.mts +25 -0
- package/dist/_chunks/request.mjs +84 -0
- package/dist/_chunks/search-console-signals.mjs +9 -0
- package/dist/analytics-client.d.mts +53 -0
- package/dist/analytics-client.mjs +128 -0
- package/dist/analyzer-defs.d.mts +73 -0
- package/dist/analyzer-defs.mjs +4 -0
- package/dist/anonymization.d.mts +6 -0
- package/dist/anonymization.mjs +12 -0
- package/dist/archetype-compile.d.mts +48 -0
- package/dist/archetype-compile.mjs +124 -0
- package/dist/client.d.mts +122 -0
- package/dist/client.mjs +365 -0
- package/dist/country-names.d.mts +2 -0
- package/dist/country-names.mjs +56 -0
- package/dist/cwv-thresholds.d.mts +13 -0
- package/dist/cwv-thresholds.mjs +23 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +47 -0
- package/dist/gsc-console-url.d.mts +12 -0
- package/dist/gsc-console-url.mjs +15 -0
- package/dist/gsc-constants.d.mts +17 -0
- package/dist/gsc-constants.mjs +2 -0
- package/dist/gsc-error.d.mts +12 -0
- package/dist/gsc-error.mjs +42 -0
- package/dist/gsc-period-presets.d.mts +31 -0
- package/dist/gsc-period-presets.mjs +115 -0
- package/dist/gsc-rows.d.mts +54 -0
- package/dist/gsc-rows.mjs +48 -0
- package/dist/hosted-query.d.mts +31 -0
- package/dist/hosted-query.mjs +105 -0
- package/dist/index.d.mts +21 -852
- package/dist/index.mjs +21 -2275
- package/dist/indexing-issues.d.mts +29 -0
- package/dist/indexing-issues.mjs +168 -0
- package/dist/lifecycle.d.mts +14 -0
- package/dist/lifecycle.mjs +84 -0
- package/dist/period.d.mts +49 -0
- package/dist/period.mjs +138 -0
- package/dist/search-console-stage.d.mts +102 -0
- package/dist/search-console-stage.mjs +292 -0
- package/dist/site-baseline.d.mts +69 -0
- package/dist/site-baseline.mjs +112 -0
- package/dist/site-triage.d.mts +86 -0
- package/dist/site-triage.mjs +268 -0
- package/dist/v1/index.d.mts +1 -1
- package/dist/v1/index.mjs +2 -2
- package/dist/webhook.d.mts +29 -0
- package/dist/webhook.mjs +118 -0
- package/package.json +110 -5
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { countSearchConsoleIssues, formatSearchConsoleCount } from "./_chunks/search-console-signals.mjs";
|
|
2
|
+
import { siteTypeBaseline } from "./site-baseline.mjs";
|
|
3
|
+
function totalSitemapErrors(sitemaps) {
|
|
4
|
+
return (sitemaps ?? []).reduce((sum, sitemap) => {
|
|
5
|
+
return sum + (sitemap.errors ?? 0) + (sitemap.lastError ? 1 : 0);
|
|
6
|
+
}, 0);
|
|
7
|
+
}
|
|
8
|
+
function signedPct1(value) {
|
|
9
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
|
10
|
+
}
|
|
11
|
+
const SEARCH_CONSOLE_STAGES = {
|
|
12
|
+
not_connected: {
|
|
13
|
+
label: "Not connected",
|
|
14
|
+
severity: "neutral",
|
|
15
|
+
summary: "Search Console is not connected for this site yet.",
|
|
16
|
+
primaryAction: "Connect Google Search Console so we can read discovery, indexing, and performance data.",
|
|
17
|
+
nextStage: "waiting_for_data",
|
|
18
|
+
sprintFindingTypes: []
|
|
19
|
+
},
|
|
20
|
+
waiting_for_data: {
|
|
21
|
+
label: "Waiting for data",
|
|
22
|
+
severity: "info",
|
|
23
|
+
summary: "Search Console is connected, but there is not enough indexing data to diagnose the site yet.",
|
|
24
|
+
primaryAction: "Let the first sync finish, then make sure a sitemap is submitted.",
|
|
25
|
+
nextStage: "weak_discovery",
|
|
26
|
+
sprintFindingTypes: []
|
|
27
|
+
},
|
|
28
|
+
weak_discovery: {
|
|
29
|
+
label: "Weak discovery",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
summary: "Google does not have a clean map of the pages you want indexed.",
|
|
32
|
+
primaryAction: "Submit a clean sitemap that contains only canonical, indexable 200 URLs.",
|
|
33
|
+
nextStage: "discovery_backlog",
|
|
34
|
+
sprintFindingTypes: ["search-console-stage", "sitemap-missing"]
|
|
35
|
+
},
|
|
36
|
+
discovery_backlog: {
|
|
37
|
+
label: "Discovery backlog",
|
|
38
|
+
severity: "warning",
|
|
39
|
+
summary: "Google knows these pages exist, but is not crawling them fast enough.",
|
|
40
|
+
primaryAction: "Add internal links from indexed pages and remove low-value URLs from the sitemap.",
|
|
41
|
+
nextStage: "crawl_blocked",
|
|
42
|
+
sprintFindingTypes: ["search-console-stage"]
|
|
43
|
+
},
|
|
44
|
+
crawl_blocked: {
|
|
45
|
+
label: "Crawl blocked",
|
|
46
|
+
severity: "error",
|
|
47
|
+
summary: "Google is trying to access pages, but technical access problems are blocking progress.",
|
|
48
|
+
primaryAction: "Fix robots.txt blocks, server errors, broken URLs, and access failures before content work.",
|
|
49
|
+
nextStage: "indexability_blocked",
|
|
50
|
+
sprintFindingTypes: ["search-console-stage", "noindex-block"]
|
|
51
|
+
},
|
|
52
|
+
indexability_blocked: {
|
|
53
|
+
label: "Indexability blocked",
|
|
54
|
+
severity: "error",
|
|
55
|
+
summary: "Google can reach pages, but index directives or canonical signals are preventing clean indexing.",
|
|
56
|
+
primaryAction: "Remove accidental noindex directives and make canonical signals agree.",
|
|
57
|
+
nextStage: "index_rejection",
|
|
58
|
+
sprintFindingTypes: [
|
|
59
|
+
"search-console-stage",
|
|
60
|
+
"noindex-block",
|
|
61
|
+
"canonicalisation"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
index_rejection: {
|
|
65
|
+
label: "Index rejection",
|
|
66
|
+
severity: "warning",
|
|
67
|
+
summary: "Google is crawling pages but skipping too many of them from the index.",
|
|
68
|
+
primaryAction: "Improve or consolidate crawled-but-not-indexed pages before publishing more.",
|
|
69
|
+
nextStage: "partially_indexed",
|
|
70
|
+
sprintFindingTypes: ["search-console-stage", "pages-not-indexed"]
|
|
71
|
+
},
|
|
72
|
+
partially_indexed: {
|
|
73
|
+
label: "Partially indexed",
|
|
74
|
+
severity: "warning",
|
|
75
|
+
summary: "A meaningful share of the site is indexed, but coverage is still below a healthy level.",
|
|
76
|
+
primaryAction: "Work through the largest remaining indexing blocker first.",
|
|
77
|
+
nextStage: "indexed_invisible",
|
|
78
|
+
sprintFindingTypes: ["search-console-stage", "pages-not-indexed"]
|
|
79
|
+
},
|
|
80
|
+
indexed_invisible: {
|
|
81
|
+
label: "Indexed but invisible",
|
|
82
|
+
severity: "warning",
|
|
83
|
+
summary: "Pages are indexed, but too many are not earning impressions in Search.",
|
|
84
|
+
primaryAction: "Improve query targeting, titles, headings, internal links, and page depth.",
|
|
85
|
+
nextStage: "visible_not_clicked",
|
|
86
|
+
sprintFindingTypes: ["search-console-stage"]
|
|
87
|
+
},
|
|
88
|
+
visible_not_clicked: {
|
|
89
|
+
label: "Visible but not clicked",
|
|
90
|
+
severity: "warning",
|
|
91
|
+
summary: "Google is showing your pages, but searchers are not clicking often enough.",
|
|
92
|
+
primaryAction: "Rewrite titles and descriptions for the queries already producing impressions.",
|
|
93
|
+
nextStage: "ranking_stalled",
|
|
94
|
+
sprintFindingTypes: ["search-console-stage", "ctr-outliers"]
|
|
95
|
+
},
|
|
96
|
+
ranking_stalled: {
|
|
97
|
+
label: "Ranking but stalled",
|
|
98
|
+
severity: "info",
|
|
99
|
+
summary: "The site has search visibility, but many pages are not yet ranking in useful positions.",
|
|
100
|
+
primaryAction: "Prioritise striking-distance pages, refresh content, and add internal links.",
|
|
101
|
+
nextStage: "healthy_growth_ready",
|
|
102
|
+
sprintFindingTypes: ["striking-distance", "internal-linking"]
|
|
103
|
+
},
|
|
104
|
+
declining_visibility: {
|
|
105
|
+
label: "Declining visibility",
|
|
106
|
+
severity: "error",
|
|
107
|
+
summary: "Search visibility is dropping compared with the previous period.",
|
|
108
|
+
primaryAction: "Review affected pages, recent releases, competitors, and SERP changes before expanding.",
|
|
109
|
+
nextStage: "healthy_growth_ready",
|
|
110
|
+
sprintFindingTypes: ["search-console-stage", "negative-movers"]
|
|
111
|
+
},
|
|
112
|
+
healthy_growth_ready: {
|
|
113
|
+
label: "Healthy",
|
|
114
|
+
severity: "success",
|
|
115
|
+
summary: "Google can discover, index, and show your pages.",
|
|
116
|
+
primaryAction: "Push striking-distance pages (positions 11 to 20) and close content gaps to grow impressions.",
|
|
117
|
+
nextStage: null,
|
|
118
|
+
sprintFindingTypes: ["striking-distance", "competitor-content-gap"]
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
function stage(key, evidence) {
|
|
122
|
+
const definition = SEARCH_CONSOLE_STAGES[key];
|
|
123
|
+
return {
|
|
124
|
+
key,
|
|
125
|
+
evidence,
|
|
126
|
+
...definition,
|
|
127
|
+
sprintFindingTypes: [...definition.sprintFindingTypes]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const ESTABLISHED_IMPRESSIONS_28D = 2e4;
|
|
131
|
+
const NASCENT_IMPRESSIONS_28D = 1e3;
|
|
132
|
+
const GROWTH_CLICKS_PCT_90D = 10;
|
|
133
|
+
const GROWTH_IMPRESSIONS_PCT_90D = 20;
|
|
134
|
+
const DECLINE_CLICKS_PCT = -10;
|
|
135
|
+
const MIN_DECLINE_PRIOR_CLICKS = 50;
|
|
136
|
+
function classifySearchConsoleStage(input) {
|
|
137
|
+
const issues = input.issues ?? [];
|
|
138
|
+
const summary = input.summary ?? null;
|
|
139
|
+
const sitemaps = input.sitemaps ?? [];
|
|
140
|
+
const totalUrls = summary?.totalUrls ?? 0;
|
|
141
|
+
const indexed = summary?.indexed ?? 0;
|
|
142
|
+
const indexedPercent = summary?.indexedPercent ?? 0;
|
|
143
|
+
const notIndexed = Math.max(0, totalUrls - indexed);
|
|
144
|
+
if (!input.connected) return stage("not_connected", [{
|
|
145
|
+
label: "Connection",
|
|
146
|
+
value: "Not connected",
|
|
147
|
+
source: "connection"
|
|
148
|
+
}]);
|
|
149
|
+
if (input.indexingStatus === "pending" || !summary || totalUrls === 0) return stage("waiting_for_data", [{
|
|
150
|
+
label: "Indexing sync",
|
|
151
|
+
value: input.indexingStatus === "pending" ? "Pending" : "No URLs yet",
|
|
152
|
+
source: "indexing"
|
|
153
|
+
}]);
|
|
154
|
+
const sitemapErrors = totalSitemapErrors(sitemaps);
|
|
155
|
+
const unknown = countSearchConsoleIssues(issues, "unknown_to_google");
|
|
156
|
+
const discovered = countSearchConsoleIssues(issues, "discovered_not_indexed");
|
|
157
|
+
const crawled = countSearchConsoleIssues(issues, "crawled_not_indexed");
|
|
158
|
+
const hardBlocks = countSearchConsoleIssues(issues, "blocked_robots", "server_error", "access_denied", "forbidden") + (input.crawlAuditBlockerCount ?? 0);
|
|
159
|
+
const canonicalMismatches = Math.min(notIndexed, input.canonicalMismatchCount ?? countSearchConsoleIssues(issues, "canonical_mismatch"));
|
|
160
|
+
let visibleNoClickPages = 0;
|
|
161
|
+
let poorPositionPages = 0;
|
|
162
|
+
for (const page of input.pageInventory ?? []) {
|
|
163
|
+
if (page.impressions < 50) continue;
|
|
164
|
+
if (page.clicks === 0) visibleNoClickPages++;
|
|
165
|
+
if ((page.position ?? 0) > 20) poorPositionPages++;
|
|
166
|
+
}
|
|
167
|
+
const ctrOutlierCount = input.ctrOutlierCount ?? 0;
|
|
168
|
+
const traj = input.trajectory ?? null;
|
|
169
|
+
const impressions28d = input.impressions28d ?? null;
|
|
170
|
+
const clicks90d = traj?.clicksPct90d ?? null;
|
|
171
|
+
const imp90d = traj?.impressionsPct90d ?? null;
|
|
172
|
+
const posDelta90d = traj?.positionDelta90d ?? null;
|
|
173
|
+
const clicks28d = traj?.clicksPct28d ?? null;
|
|
174
|
+
const clicksPrior28d = traj?.clicksPrior28d ?? null;
|
|
175
|
+
const isGrowing = clicks90d != null && clicks90d > GROWTH_CLICKS_PCT_90D || imp90d != null && imp90d > GROWTH_IMPRESSIONS_PCT_90D && posDelta90d != null && posDelta90d < 0;
|
|
176
|
+
const isDeclining = clicks90d != null && clicks90d < DECLINE_CLICKS_PCT && clicks28d != null && clicks28d < DECLINE_CLICKS_PCT && clicksPrior28d != null && clicksPrior28d >= MIN_DECLINE_PRIOR_CLICKS;
|
|
177
|
+
const isNascent = impressions28d != null && impressions28d < NASCENT_IMPRESSIONS_28D;
|
|
178
|
+
const isEstablished = impressions28d != null && impressions28d >= ESTABLISHED_IMPRESSIONS_28D;
|
|
179
|
+
if (hardBlocks > Math.max(10, totalUrls * .05)) return stage("crawl_blocked", [{
|
|
180
|
+
label: "Crawl / on-page faults",
|
|
181
|
+
value: formatSearchConsoleCount(hardBlocks),
|
|
182
|
+
source: "indexing"
|
|
183
|
+
}, {
|
|
184
|
+
label: "Indexed pages",
|
|
185
|
+
value: `${formatSearchConsoleCount(indexed)} of ${formatSearchConsoleCount(totalUrls)}`,
|
|
186
|
+
source: "indexing"
|
|
187
|
+
}]);
|
|
188
|
+
if (isDeclining) return stage("declining_visibility", [{
|
|
189
|
+
label: "Clicks 90d",
|
|
190
|
+
value: `${clicks90d.toFixed(1)}%`,
|
|
191
|
+
source: "performance"
|
|
192
|
+
}, {
|
|
193
|
+
label: "Clicks 28d",
|
|
194
|
+
value: `${clicks28d.toFixed(1)}%`,
|
|
195
|
+
source: "performance"
|
|
196
|
+
}]);
|
|
197
|
+
if (isGrowing) return stage("healthy_growth_ready", [
|
|
198
|
+
...hardBlocks > 0 ? [{
|
|
199
|
+
label: "Critical blockers",
|
|
200
|
+
value: formatSearchConsoleCount(hardBlocks),
|
|
201
|
+
source: "indexing"
|
|
202
|
+
}] : [],
|
|
203
|
+
...clicks90d != null ? [{
|
|
204
|
+
label: "Clicks 90d",
|
|
205
|
+
value: signedPct1(clicks90d),
|
|
206
|
+
source: "performance"
|
|
207
|
+
}] : [],
|
|
208
|
+
...imp90d != null ? [{
|
|
209
|
+
label: "Impressions 90d",
|
|
210
|
+
value: signedPct1(imp90d),
|
|
211
|
+
source: "performance"
|
|
212
|
+
}] : [],
|
|
213
|
+
...(input.recoverableBacklinkCount ?? 0) > 0 ? [{
|
|
214
|
+
label: "Recoverable backlinks",
|
|
215
|
+
value: formatSearchConsoleCount(input.recoverableBacklinkCount),
|
|
216
|
+
source: "performance"
|
|
217
|
+
}] : [],
|
|
218
|
+
...(input.competitorGapCount ?? 0) > 0 ? [{
|
|
219
|
+
label: "Competitor content gaps",
|
|
220
|
+
value: formatSearchConsoleCount(input.competitorGapCount),
|
|
221
|
+
source: "performance"
|
|
222
|
+
}] : []
|
|
223
|
+
]);
|
|
224
|
+
if (crawled > Math.max(10, totalUrls * .3) && totalUrls > 500) return stage("index_rejection", [{
|
|
225
|
+
label: "Crawled, not indexed",
|
|
226
|
+
value: formatSearchConsoleCount(crawled),
|
|
227
|
+
source: "indexing"
|
|
228
|
+
}, {
|
|
229
|
+
label: "Not indexed",
|
|
230
|
+
value: formatSearchConsoleCount(notIndexed),
|
|
231
|
+
source: "indexing"
|
|
232
|
+
}]);
|
|
233
|
+
if (isNascent) return stage("waiting_for_data", [{
|
|
234
|
+
label: "Impressions (28d)",
|
|
235
|
+
value: formatSearchConsoleCount(impressions28d ?? 0),
|
|
236
|
+
source: "performance"
|
|
237
|
+
}, {
|
|
238
|
+
label: "Indexed pages",
|
|
239
|
+
value: `${formatSearchConsoleCount(indexed)} of ${formatSearchConsoleCount(totalUrls)}`,
|
|
240
|
+
source: "indexing"
|
|
241
|
+
}]);
|
|
242
|
+
if (!isEstablished && (sitemaps.length === 0 || sitemapErrors > 0 || unknown > Math.max(5, totalUrls * .1))) return stage("weak_discovery", [{
|
|
243
|
+
label: "Sitemaps",
|
|
244
|
+
value: sitemaps.length === 0 ? "None registered" : `${formatSearchConsoleCount(sitemapErrors)} errors`,
|
|
245
|
+
source: "sitemap"
|
|
246
|
+
}, ...unknown > 0 ? [{
|
|
247
|
+
label: "Unknown URLs",
|
|
248
|
+
value: formatSearchConsoleCount(unknown),
|
|
249
|
+
source: "indexing"
|
|
250
|
+
}] : []]);
|
|
251
|
+
if (discovered > Math.max(10, totalUrls * .15)) return stage("discovery_backlog", [{
|
|
252
|
+
label: "Discovered, not crawled",
|
|
253
|
+
value: formatSearchConsoleCount(discovered),
|
|
254
|
+
source: "indexing"
|
|
255
|
+
}, {
|
|
256
|
+
label: "Indexed pages",
|
|
257
|
+
value: `${indexedPercent.toFixed(1)}%`,
|
|
258
|
+
source: "indexing"
|
|
259
|
+
}]);
|
|
260
|
+
if (canonicalMismatches > Math.max(5, totalUrls * .05)) return stage("indexability_blocked", [{
|
|
261
|
+
label: "Canonical mismatches",
|
|
262
|
+
value: formatSearchConsoleCount(canonicalMismatches),
|
|
263
|
+
source: "canonical"
|
|
264
|
+
}]);
|
|
265
|
+
const lowCtrType = siteTypeBaseline(input.siteType).ctrExpectation === "low";
|
|
266
|
+
const noClickShare = lowCtrType ? .5 : .25;
|
|
267
|
+
const noClickTrigger = visibleNoClickPages >= Math.max(1, (input.pageInventory?.length ?? 0) * noClickShare);
|
|
268
|
+
if (ctrOutlierCount > 0 && !lowCtrType || noClickTrigger) return stage("visible_not_clicked", [...ctrOutlierCount > 0 ? [{
|
|
269
|
+
label: "CTR outliers",
|
|
270
|
+
value: formatSearchConsoleCount(ctrOutlierCount),
|
|
271
|
+
source: "performance"
|
|
272
|
+
}] : [], ...visibleNoClickPages > 0 ? [{
|
|
273
|
+
label: "Visible, no clicks",
|
|
274
|
+
value: formatSearchConsoleCount(visibleNoClickPages),
|
|
275
|
+
source: "performance"
|
|
276
|
+
}] : []]);
|
|
277
|
+
if (poorPositionPages >= Math.max(1, (input.pageInventory?.length ?? 0) * .25)) return stage("ranking_stalled", [{
|
|
278
|
+
label: "Low-ranking visible pages",
|
|
279
|
+
value: formatSearchConsoleCount(poorPositionPages),
|
|
280
|
+
source: "performance"
|
|
281
|
+
}]);
|
|
282
|
+
return stage("healthy_growth_ready", [{
|
|
283
|
+
label: "Indexed",
|
|
284
|
+
value: `${indexedPercent.toFixed(1)}%`,
|
|
285
|
+
source: "indexing"
|
|
286
|
+
}, {
|
|
287
|
+
label: "Critical blockers",
|
|
288
|
+
value: formatSearchConsoleCount(hardBlocks),
|
|
289
|
+
source: "indexing"
|
|
290
|
+
}]);
|
|
291
|
+
}
|
|
292
|
+
export { classifySearchConsoleStage };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
type SiteType = 'saas' | 'ecommerce' | 'docs' | 'blog' | 'agency' | 'portfolio' | 'other';
|
|
2
|
+
/**
|
|
3
|
+
* Normalise the AI profile `type` to the closed enum. The categoriser mostly
|
|
4
|
+
* emits the 7 values but occasionally leaks free-text (e.g. "event") or null
|
|
5
|
+
* (~40% of live sites are unprofiled) — everything unknown collapses to `other`
|
|
6
|
+
* so downstream logic always has a defined bucket.
|
|
7
|
+
*/
|
|
8
|
+
declare function normalizeSiteType(raw: string | null | undefined): SiteType;
|
|
9
|
+
interface SiteTypeBaseline {
|
|
10
|
+
label: string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether indexed-coverage% is a meaningful health signal for this type.
|
|
13
|
+
* docs/blog/portfolio accumulate intentional low-value pages (tags, versions,
|
|
14
|
+
* pagination, archives) so coverage% is noise; ecommerce/saas/agency care.
|
|
15
|
+
*/
|
|
16
|
+
coverageMatters: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Typical click-through at good positions. Informational types (docs/blog)
|
|
19
|
+
* earn structurally lower CTR (answer shown in SERP, multi-page research), so
|
|
20
|
+
* a low CTR is NOT a defect — the classifier raises the visible-not-clicked
|
|
21
|
+
* bar for these.
|
|
22
|
+
*/
|
|
23
|
+
ctrExpectation: 'low' | 'medium' | 'high';
|
|
24
|
+
/** The default growth lever when the site is healthy and leading. */
|
|
25
|
+
primaryGoal: 'content' | 'authority' | 'conversion' | 'coverage';
|
|
26
|
+
}
|
|
27
|
+
declare const SITE_TYPE_BASELINE: Record<SiteType, SiteTypeBaseline>;
|
|
28
|
+
declare function siteTypeBaseline(raw: string | null | undefined): SiteTypeBaseline;
|
|
29
|
+
type PeerStanding = 'leader' | 'on_par' | 'behind' | 'unknown';
|
|
30
|
+
/** How much to trust the standing: a median over 1–2 peers is noisy. */
|
|
31
|
+
type PeerConfidence = 'high' | 'low' | 'none';
|
|
32
|
+
/**
|
|
33
|
+
* Peer metrics from tracked competitors (`siteCompetitors`). Domain rank is the
|
|
34
|
+
* DataForSEO-derived 0–100 score (`min(100, log10(keywordCount)*20)`), NOT a
|
|
35
|
+
* true authority metric — good enough for relative standing within a peer set.
|
|
36
|
+
* The site's OWN metrics are not stored alongside competitors, so callers must
|
|
37
|
+
* supply them (one cheap cached `estimateDomainTraffic` call).
|
|
38
|
+
*/
|
|
39
|
+
interface PeerBaselineInput {
|
|
40
|
+
siteDomainRank?: number | null;
|
|
41
|
+
siteOrganicTraffic?: number | null;
|
|
42
|
+
competitorDomainRanks?: number[] | null;
|
|
43
|
+
competitorOrganicTraffic?: number[] | null;
|
|
44
|
+
}
|
|
45
|
+
interface SiteBaseline {
|
|
46
|
+
siteType: SiteType;
|
|
47
|
+
baseline: SiteTypeBaseline;
|
|
48
|
+
peerStanding: PeerStanding;
|
|
49
|
+
peerConfidence: PeerConfidence;
|
|
50
|
+
peerMedianDomainRank: number | null;
|
|
51
|
+
peerMedianOrganicTraffic: number | null;
|
|
52
|
+
/** Human-facing goal headline combining type + standing. */
|
|
53
|
+
recommendedGoal: string;
|
|
54
|
+
goalKind: SiteTypeBaseline['primaryGoal'];
|
|
55
|
+
}
|
|
56
|
+
/** Standing is decided on domain rank first; organic traffic breaks the tie. */
|
|
57
|
+
declare function derivePeerStanding(input: PeerBaselineInput): {
|
|
58
|
+
standing: PeerStanding;
|
|
59
|
+
confidence: PeerConfidence;
|
|
60
|
+
peerMedianDomainRank: number | null;
|
|
61
|
+
peerMedianOrganicTraffic: number | null;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Combine the site type with its peer standing into a relative goal. A leader
|
|
65
|
+
* defends and expands on its type's lever; a site that's behind closes the gap
|
|
66
|
+
* on the same lever; unknown standing falls back to the type default.
|
|
67
|
+
*/
|
|
68
|
+
declare function deriveSiteBaseline(rawType: string | null | undefined, peer?: PeerBaselineInput): SiteBaseline;
|
|
69
|
+
export { PeerBaselineInput, PeerConfidence, PeerStanding, SITE_TYPE_BASELINE, SiteBaseline, SiteType, SiteTypeBaseline, derivePeerStanding, deriveSiteBaseline, normalizeSiteType, siteTypeBaseline };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const KNOWN_SITE_TYPES = /* @__PURE__ */ new Set([
|
|
2
|
+
"saas",
|
|
3
|
+
"ecommerce",
|
|
4
|
+
"docs",
|
|
5
|
+
"blog",
|
|
6
|
+
"agency",
|
|
7
|
+
"portfolio",
|
|
8
|
+
"other"
|
|
9
|
+
]);
|
|
10
|
+
function normalizeSiteType(raw) {
|
|
11
|
+
if (!raw) return "other";
|
|
12
|
+
const t = raw.toLowerCase().trim();
|
|
13
|
+
return KNOWN_SITE_TYPES.has(t) ? t : "other";
|
|
14
|
+
}
|
|
15
|
+
const SITE_TYPE_BASELINE = {
|
|
16
|
+
docs: {
|
|
17
|
+
label: "Documentation",
|
|
18
|
+
coverageMatters: false,
|
|
19
|
+
ctrExpectation: "low",
|
|
20
|
+
primaryGoal: "content"
|
|
21
|
+
},
|
|
22
|
+
blog: {
|
|
23
|
+
label: "Blog / content",
|
|
24
|
+
coverageMatters: false,
|
|
25
|
+
ctrExpectation: "low",
|
|
26
|
+
primaryGoal: "content"
|
|
27
|
+
},
|
|
28
|
+
portfolio: {
|
|
29
|
+
label: "Portfolio",
|
|
30
|
+
coverageMatters: false,
|
|
31
|
+
ctrExpectation: "low",
|
|
32
|
+
primaryGoal: "content"
|
|
33
|
+
},
|
|
34
|
+
saas: {
|
|
35
|
+
label: "SaaS",
|
|
36
|
+
coverageMatters: true,
|
|
37
|
+
ctrExpectation: "medium",
|
|
38
|
+
primaryGoal: "authority"
|
|
39
|
+
},
|
|
40
|
+
agency: {
|
|
41
|
+
label: "Agency",
|
|
42
|
+
coverageMatters: true,
|
|
43
|
+
ctrExpectation: "medium",
|
|
44
|
+
primaryGoal: "authority"
|
|
45
|
+
},
|
|
46
|
+
ecommerce: {
|
|
47
|
+
label: "Ecommerce",
|
|
48
|
+
coverageMatters: true,
|
|
49
|
+
ctrExpectation: "high",
|
|
50
|
+
primaryGoal: "coverage"
|
|
51
|
+
},
|
|
52
|
+
other: {
|
|
53
|
+
label: "General",
|
|
54
|
+
coverageMatters: true,
|
|
55
|
+
ctrExpectation: "medium",
|
|
56
|
+
primaryGoal: "content"
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
function siteTypeBaseline(raw) {
|
|
60
|
+
return SITE_TYPE_BASELINE[normalizeSiteType(raw)];
|
|
61
|
+
}
|
|
62
|
+
const MIN_CONFIDENT_PEERS = 3;
|
|
63
|
+
const LEADER_RATIO = 1.15;
|
|
64
|
+
const BEHIND_RATIO = .85;
|
|
65
|
+
function median(values) {
|
|
66
|
+
if (!values || values.length === 0) return null;
|
|
67
|
+
const xs = [...values].sort((a, b) => a - b);
|
|
68
|
+
const mid = Math.floor(xs.length / 2);
|
|
69
|
+
return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
|
|
70
|
+
}
|
|
71
|
+
function derivePeerStanding(input) {
|
|
72
|
+
const peerMedianDomainRank = median(input.competitorDomainRanks);
|
|
73
|
+
const peerMedianOrganicTraffic = median(input.competitorOrganicTraffic);
|
|
74
|
+
const peerCount = Math.max(input.competitorDomainRanks?.length ?? 0, input.competitorOrganicTraffic?.length ?? 0);
|
|
75
|
+
const confidence = peerCount === 0 ? "none" : peerCount >= MIN_CONFIDENT_PEERS ? "high" : "low";
|
|
76
|
+
const compare = (site, peer) => {
|
|
77
|
+
if (site == null || peer == null || peer === 0) return null;
|
|
78
|
+
const ratio = site / peer;
|
|
79
|
+
if (ratio >= LEADER_RATIO) return "leader";
|
|
80
|
+
if (ratio <= BEHIND_RATIO) return "behind";
|
|
81
|
+
return "on_par";
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
standing: compare(input.siteDomainRank, peerMedianDomainRank) ?? compare(input.siteOrganicTraffic, peerMedianOrganicTraffic) ?? "unknown",
|
|
85
|
+
confidence,
|
|
86
|
+
peerMedianDomainRank,
|
|
87
|
+
peerMedianOrganicTraffic
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const GOAL_HEADLINE = {
|
|
91
|
+
content: "publish & expand content",
|
|
92
|
+
authority: "build authority & backlinks",
|
|
93
|
+
conversion: "lift conversion & CTR",
|
|
94
|
+
coverage: "expand indexable catalogue"
|
|
95
|
+
};
|
|
96
|
+
function deriveSiteBaseline(rawType, peer = {}) {
|
|
97
|
+
const siteType = normalizeSiteType(rawType);
|
|
98
|
+
const baseline = SITE_TYPE_BASELINE[siteType];
|
|
99
|
+
const { standing, confidence, peerMedianDomainRank, peerMedianOrganicTraffic } = derivePeerStanding(peer);
|
|
100
|
+
const lever = GOAL_HEADLINE[baseline.primaryGoal];
|
|
101
|
+
return {
|
|
102
|
+
siteType,
|
|
103
|
+
baseline,
|
|
104
|
+
peerStanding: standing,
|
|
105
|
+
peerConfidence: confidence,
|
|
106
|
+
peerMedianDomainRank,
|
|
107
|
+
peerMedianOrganicTraffic,
|
|
108
|
+
recommendedGoal: standing === "behind" ? `Close the gap on peers — ${lever}` : standing === "leader" ? `Defend the lead — ${lever}` : standing === "on_par" ? `Pull ahead of peers — ${lever}` : `${baseline.label}: ${lever}`,
|
|
109
|
+
goalKind: baseline.primaryGoal
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export { SITE_TYPE_BASELINE, derivePeerStanding, deriveSiteBaseline, normalizeSiteType, siteTypeBaseline };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { SearchConsoleStageIssue } from "./search-console-stage.mjs";
|
|
2
|
+
type ReachStage = 'waiting_for_data' | 'emerging' | 'growing' | 'plateaued' | 'declining' | 'faded' | 'decayed';
|
|
3
|
+
type HealthStage = 'healthy' | 'crawl_faults' | 'quality_rejection';
|
|
4
|
+
interface TriageEvidence {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Distance-to-next-stage for one axis, so the UI never re-derives thresholds.
|
|
10
|
+
*
|
|
11
|
+
* `direction`:
|
|
12
|
+
* - `advance` — up-path rung (waiting → emerging → growing). `pct` = value/target.
|
|
13
|
+
* - `escape` — off-ramp recovery (declining/faded/decayed) or a held health gate
|
|
14
|
+
* (crawl_faults/quality_rejection). `pct` = inverse distance: closer to passing ⇒ higher.
|
|
15
|
+
* - `sustain` — already good (growing/healthy). `nextStage` null, `pct` 1, framed as momentum.
|
|
16
|
+
*
|
|
17
|
+
* `gapLabel` is always a concrete count (pages/clicks/points), never a bare %.
|
|
18
|
+
*/
|
|
19
|
+
interface StageProgression {
|
|
20
|
+
nextStage: ReachStage | HealthStage | null;
|
|
21
|
+
metric: string;
|
|
22
|
+
value: number;
|
|
23
|
+
target: number;
|
|
24
|
+
pct: number;
|
|
25
|
+
gapLabel: string;
|
|
26
|
+
direction: 'advance' | 'escape' | 'sustain';
|
|
27
|
+
}
|
|
28
|
+
interface ReachVerdict {
|
|
29
|
+
stage: ReachStage;
|
|
30
|
+
summary: string;
|
|
31
|
+
primaryAction: string;
|
|
32
|
+
evidence: TriageEvidence[];
|
|
33
|
+
progression: StageProgression;
|
|
34
|
+
}
|
|
35
|
+
interface HealthVerdict {
|
|
36
|
+
stage: HealthStage;
|
|
37
|
+
summary: string;
|
|
38
|
+
primaryAction: string;
|
|
39
|
+
evidence: TriageEvidence[];
|
|
40
|
+
progression: StageProgression;
|
|
41
|
+
}
|
|
42
|
+
interface SiteTriage {
|
|
43
|
+
reach: ReachVerdict;
|
|
44
|
+
health: HealthVerdict;
|
|
45
|
+
/** Which axis leads the dashboard headline. */
|
|
46
|
+
headline: 'reach' | 'health';
|
|
47
|
+
}
|
|
48
|
+
interface SiteTriageInput {
|
|
49
|
+
connected: boolean;
|
|
50
|
+
/** Impressions over the trailing 28 days — the maturity tier. */
|
|
51
|
+
impressions28d?: number | null;
|
|
52
|
+
/** Lifetime-ish impressions (trailing 12 months) — separates new from decayed/faded. */
|
|
53
|
+
impressions12m?: number | null;
|
|
54
|
+
/** Absolute clicks over the trailing 28 days — for the clicks≪impressions decay tell. */
|
|
55
|
+
clicks28d?: number | null;
|
|
56
|
+
clicksPct90d?: number | null;
|
|
57
|
+
clicksPct28d?: number | null;
|
|
58
|
+
/** Absolute prior-28d clicks — gates decline so a % crash on trivial traffic isn't a false decline. */
|
|
59
|
+
clicksPrior28d?: number | null;
|
|
60
|
+
impressionsPct90d?: number | null;
|
|
61
|
+
positionDelta90d?: number | null;
|
|
62
|
+
/** Latest complete week ÷ 90d peak week (lag-trimmed). <0.2 = faded (spike→died). */
|
|
63
|
+
livenessRatio?: number | null;
|
|
64
|
+
totalUrls?: number | null;
|
|
65
|
+
indexed?: number | null;
|
|
66
|
+
issues?: SearchConsoleStageIssue[] | null;
|
|
67
|
+
/** Real on-page faults from the crawl audit: 5xx, broken internal links/images. Excludes intentional noindex/404. */
|
|
68
|
+
crawlAuditBlockerCount?: number | null;
|
|
69
|
+
/** AI profile type — drives purpose-expected subtraction. */
|
|
70
|
+
siteType?: string | null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Reach liveness: latest complete week ÷ peak rolling-7d week over a daily
|
|
74
|
+
* impressions series (typically the trailing 90 days). `<0.2` means recent
|
|
75
|
+
* impressions have collapsed versus the site's own peak (spike→died) — the
|
|
76
|
+
* signal a period-vs-prior delta cannot see. Trailing zero-impression days
|
|
77
|
+
* (GSC reporting lag) are trimmed before the latest-week sum. Returns null when
|
|
78
|
+
* the series is too short to judge.
|
|
79
|
+
*/
|
|
80
|
+
declare function reachLivenessRatio(daily: Array<{
|
|
81
|
+
impressions: number;
|
|
82
|
+
}> | null | undefined): number | null;
|
|
83
|
+
declare function classifyHealthStage(input: SiteTriageInput): HealthVerdict;
|
|
84
|
+
declare function classifyReachStage(input: SiteTriageInput): ReachVerdict;
|
|
85
|
+
declare function classifySiteTriage(input: SiteTriageInput): SiteTriage;
|
|
86
|
+
export { HealthStage, HealthVerdict, ReachStage, ReachVerdict, SiteTriage, SiteTriageInput, StageProgression, TriageEvidence, classifyHealthStage, classifyReachStage, classifySiteTriage, reachLivenessRatio };
|