@gscdump/sdk 6.0.0 → 7.1.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/dist/indexing-issues.mjs +2 -2
- package/dist/package.mjs +1 -1
- package/dist/site-triage.d.mts +22 -13
- package/dist/site-triage.mjs +152 -115
- package/package.json +3 -3
package/dist/indexing-issues.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const issueDetails = {
|
|
2
2
|
crawled_not_indexed: {
|
|
3
|
-
description: "
|
|
3
|
+
description: "Saved URL Inspection results report these URLs as crawled but not indexed at their check times. This state alone does not establish why.",
|
|
4
4
|
fix: "Improve content quality and uniqueness. Add internal links pointing to these pages. Ensure they have clear, distinct value compared to other pages on your site."
|
|
5
5
|
},
|
|
6
6
|
discovered_not_indexed: {
|
|
@@ -68,7 +68,7 @@ const issueDetails = {
|
|
|
68
68
|
fix: "If the content moved, add 301 redirects to the new URLs. If intentionally removed, ensure no internal links still point to them. The 404s will clear over time."
|
|
69
69
|
},
|
|
70
70
|
soft_404: {
|
|
71
|
-
description: "
|
|
71
|
+
description: "Saved URL Inspection results report these URLs as Soft 404 at their check times. Review the affected pages before choosing work.",
|
|
72
72
|
fix: "Return a proper 404 status code for missing pages. If the pages should exist, add meaningful content. Avoid thin placeholder pages."
|
|
73
73
|
},
|
|
74
74
|
blocked_robots: {
|
package/dist/package.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var version = "
|
|
1
|
+
var version = "7.1.0";
|
|
2
2
|
export { version };
|
package/dist/site-triage.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SearchConsoleStageIssue } from "./search-console-stage.mjs";
|
|
2
|
-
export type ReachStage = 'emerging' | 'growing' | 'plateaued' | 'declining' | 'faded' | 'decayed';
|
|
2
|
+
export type ReachStage = 'emerging' | 'growing' | 'plateaued' | 'mixed' | 'insufficient_data' | 'declining' | 'faded' | 'decayed';
|
|
3
3
|
export type HealthStage = 'healthy' | 'crawl_faults' | 'quality_rejection' | 'not_indexed';
|
|
4
4
|
export interface TriageEvidence {
|
|
5
5
|
label: string;
|
|
@@ -14,7 +14,7 @@ export interface TriageEvidence {
|
|
|
14
14
|
* (crawl_faults/quality_rejection). `pct` = inverse distance: closer to passing ⇒ higher.
|
|
15
15
|
* - `sustain` — already good (growing/healthy). `nextStage` null, `pct` 1, framed as momentum.
|
|
16
16
|
*
|
|
17
|
-
* `gapLabel`
|
|
17
|
+
* `gapLabel` gives a count toward the target or explains why progression is unavailable.
|
|
18
18
|
*/
|
|
19
19
|
export interface StageProgression {
|
|
20
20
|
nextStage: ReachStage | HealthStage | null;
|
|
@@ -30,7 +30,8 @@ export interface ReachVerdict {
|
|
|
30
30
|
summary: string;
|
|
31
31
|
primaryAction: string;
|
|
32
32
|
evidence: TriageEvidence[];
|
|
33
|
-
|
|
33
|
+
/** Null when mixed observations provide no meaningful next-stage target. */
|
|
34
|
+
progression: StageProgression | null;
|
|
34
35
|
}
|
|
35
36
|
export interface HealthVerdict {
|
|
36
37
|
stage: HealthStage;
|
|
@@ -40,25 +41,35 @@ export interface HealthVerdict {
|
|
|
40
41
|
progression: StageProgression;
|
|
41
42
|
}
|
|
42
43
|
export interface SiteTriage {
|
|
44
|
+
/** Absent on stored verdicts produced before complete-period classification. */
|
|
45
|
+
algorithmVersion?: 4;
|
|
43
46
|
reach: ReachVerdict;
|
|
44
47
|
health: HealthVerdict;
|
|
45
48
|
/** Which axis leads the dashboard headline. */
|
|
46
49
|
headline: 'reach' | 'health';
|
|
47
50
|
}
|
|
48
51
|
export interface SiteTriageInput {
|
|
52
|
+
/** Last complete reporting date of the current 28-day period. */
|
|
53
|
+
periodEnd?: string | null;
|
|
54
|
+
/** Date-ascending, complete daily observations ending at the stable reporting date. */
|
|
55
|
+
daily?: Array<{
|
|
56
|
+
date: string;
|
|
57
|
+
clicks: number;
|
|
58
|
+
impressions: number;
|
|
59
|
+
}> | null;
|
|
49
60
|
/** Impressions over the trailing 28 days — the maturity tier. */
|
|
50
61
|
impressions28d?: number | null;
|
|
51
|
-
/**
|
|
62
|
+
/** Legacy aggregate retained in stored caller inputs. Daily observations determine reach. */
|
|
52
63
|
impressions12m?: number | null;
|
|
53
|
-
/** Absolute clicks over the trailing 28 days
|
|
64
|
+
/** Absolute clicks over the trailing 28 days, checked against daily observations when supplied. */
|
|
54
65
|
clicks28d?: number | null;
|
|
55
66
|
clicksPct90d?: number | null;
|
|
56
67
|
clicksPct28d?: number | null;
|
|
57
|
-
/**
|
|
68
|
+
/** Legacy prior aggregate. Daily observations supply the comparison baseline. */
|
|
58
69
|
clicksPrior28d?: number | null;
|
|
59
70
|
impressionsPct90d?: number | null;
|
|
60
71
|
positionDelta90d?: number | null;
|
|
61
|
-
/**
|
|
72
|
+
/** Recent three-week maximum divided by the median of four prior weeks. */
|
|
62
73
|
livenessRatio?: number | null;
|
|
63
74
|
totalUrls?: number | null;
|
|
64
75
|
indexed?: number | null;
|
|
@@ -69,14 +80,12 @@ export interface SiteTriageInput {
|
|
|
69
80
|
siteType?: string | null;
|
|
70
81
|
}
|
|
71
82
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* signal a period-vs-prior delta cannot see. Trailing zero-impression days
|
|
76
|
-
* (GSC reporting lag) are trimmed before the latest-week sum. Returns null when
|
|
77
|
-
* the series is too short to judge.
|
|
83
|
+
* Compare three complete recent weeks with the median of four prior weeks.
|
|
84
|
+
* The caller excludes reporting lag by date before supplying the daily series.
|
|
85
|
+
* Known zero-impression days remain observations. Sparse baselines return null.
|
|
78
86
|
*/
|
|
79
87
|
export declare function reachLivenessRatio(daily: Array<{
|
|
88
|
+
date?: string;
|
|
80
89
|
impressions: number;
|
|
81
90
|
}> | null | undefined): number | null;
|
|
82
91
|
export declare function classifyHealthStage(input: SiteTriageInput): HealthVerdict;
|
package/dist/site-triage.mjs
CHANGED
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
import { countSearchConsoleIssues, formatSearchConsoleCount } from "./search-console-signals.mjs";
|
|
2
2
|
import { normalizeSiteType } from "./site-baseline.mjs";
|
|
3
3
|
const MIN_GROWTH_IMPRESSIONS_28D = 1e3;
|
|
4
|
-
const ESTABLISHED_IMPRESSIONS_28D = 2e4;
|
|
5
|
-
const MIN_HISTORY_IMPRESSIONS = 500;
|
|
6
|
-
const GROWTH_CLICKS_PCT = 10;
|
|
7
|
-
const GROWTH_IMPRESSIONS_PCT = 20;
|
|
8
|
-
const DECLINE_CLICKS_PCT = -10;
|
|
9
|
-
const MIN_DECLINE_PRIOR_CLICKS = 50;
|
|
10
|
-
const FADED_LIVENESS = .2;
|
|
11
|
-
const DECAY_CTR = .005;
|
|
12
4
|
const HARD_BLOCK_FLOOR = 10;
|
|
13
5
|
const HARD_BLOCK_SHARE = .03;
|
|
14
6
|
const REJECT_SHARE = .3;
|
|
15
7
|
const REJECT_MIN = 50;
|
|
16
8
|
function reachLivenessRatio(daily) {
|
|
17
|
-
if (!daily || daily.length <
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const latestWeek = rolling7(series.length - 1);
|
|
27
|
-
let peakWeek = 0;
|
|
28
|
-
for (let i = 6; i < series.length; i++) peakWeek = Math.max(peakWeek, rolling7(i));
|
|
29
|
-
return peakWeek > 0 ? latestWeek / peakWeek : null;
|
|
9
|
+
if (!daily || daily.length < 49) return null;
|
|
10
|
+
const observations = daily.slice(-49);
|
|
11
|
+
if (observations.some((row) => row.date != null) && !completeDates(observations.map((row) => ({ date: row.date ?? "" })))) return null;
|
|
12
|
+
const series = observations.map((d) => d.impressions);
|
|
13
|
+
if (series.some((value) => !Number.isFinite(value) || value < 0)) return null;
|
|
14
|
+
const weeks = Array.from({ length: 7 }, (_, index) => series.slice(index * 7, (index + 1) * 7).reduce((sum, value) => sum + value, 0));
|
|
15
|
+
const baselineWeeks = weeks.slice(0, 4).sort((a, b) => a - b);
|
|
16
|
+
const baseline = (baselineWeeks[1] + baselineWeeks[2]) / 2;
|
|
17
|
+
return baseline >= 250 ? Math.max(...weeks.slice(4)) / baseline : null;
|
|
30
18
|
}
|
|
31
19
|
function clamp01(n) {
|
|
32
20
|
if (!Number.isFinite(n)) return 0;
|
|
@@ -46,24 +34,24 @@ function advance(nextStage, metric, value, target, gapLabel) {
|
|
|
46
34
|
direction: "advance"
|
|
47
35
|
};
|
|
48
36
|
}
|
|
49
|
-
function
|
|
37
|
+
function escapeReduce(nextStage, metric, value, target, gapLabel) {
|
|
50
38
|
return {
|
|
51
39
|
nextStage,
|
|
52
40
|
metric,
|
|
53
41
|
value,
|
|
54
42
|
target,
|
|
55
|
-
pct: clamp01(value / target),
|
|
43
|
+
pct: target <= 0 ? value <= 0 ? 1 : 0 : clamp01(2 - value / target),
|
|
56
44
|
gapLabel,
|
|
57
45
|
direction: "escape"
|
|
58
46
|
};
|
|
59
47
|
}
|
|
60
|
-
function
|
|
48
|
+
function escapeToward(nextStage, metric, value, target, gapLabel) {
|
|
61
49
|
return {
|
|
62
50
|
nextStage,
|
|
63
51
|
metric,
|
|
64
52
|
value,
|
|
65
53
|
target,
|
|
66
|
-
pct:
|
|
54
|
+
pct: clamp01(value / target),
|
|
67
55
|
gapLabel,
|
|
68
56
|
direction: "escape"
|
|
69
57
|
};
|
|
@@ -81,20 +69,20 @@ function sustain(metric, value, gapLabel) {
|
|
|
81
69
|
}
|
|
82
70
|
const HEALTH_COPY = {
|
|
83
71
|
healthy: {
|
|
84
|
-
summary: "
|
|
85
|
-
primaryAction: "
|
|
72
|
+
summary: "Available observations do not exceed the indexing warning thresholds.",
|
|
73
|
+
primaryAction: "Review inspection coverage and freshness before deciding whether indexing needs attention."
|
|
86
74
|
},
|
|
87
75
|
crawl_faults: {
|
|
88
|
-
summary: "
|
|
76
|
+
summary: "Available observations show access faults or broken links above the warning threshold.",
|
|
89
77
|
primaryAction: "Fix the 5xx and broken URLs; return 410/404 for pages you retire on purpose."
|
|
90
78
|
},
|
|
91
79
|
not_indexed: {
|
|
92
|
-
summary: "
|
|
93
|
-
primaryAction: "
|
|
80
|
+
summary: "Saved inspections show a substantial share of known URLs are discovered or crawled but not indexed.",
|
|
81
|
+
primaryAction: "Inspect affected URLs and check discovery, responses, and canonical URLs before choosing changes."
|
|
94
82
|
},
|
|
95
83
|
quality_rejection: {
|
|
96
|
-
summary: "
|
|
97
|
-
primaryAction: "
|
|
84
|
+
summary: "Saved inspections show many crawled or soft-404 URLs are not indexed. These states do not establish a quality cause.",
|
|
85
|
+
primaryAction: "Inspect affected URLs and check their content, responses, and canonical URLs before choosing changes."
|
|
98
86
|
}
|
|
99
87
|
};
|
|
100
88
|
function isIntentionalRetirementSite(input, notFound, serverError) {
|
|
@@ -125,30 +113,28 @@ function classifyHealthStage(input) {
|
|
|
125
113
|
label: "Access faults (5xx / broken)",
|
|
126
114
|
value: formatSearchConsoleCount(hardBlocks)
|
|
127
115
|
}],
|
|
128
|
-
progression: escapeReduce("healthy", "access faults", hardBlocks, faultTarget, `${formatSearchConsoleCount(hardBlocks)} faults
|
|
116
|
+
progression: escapeReduce("healthy", "access faults", hardBlocks, faultTarget, `${formatSearchConsoleCount(hardBlocks)} faults. About ${formatSearchConsoleCount(toFix)} fewer would meet the warning threshold.`)
|
|
129
117
|
};
|
|
130
118
|
}
|
|
131
119
|
const rejectPool = crawledNotIndexed + softFound;
|
|
132
120
|
if (totalUrls > 0 && rejectPool > REJECT_MIN && rejectPool / totalUrls >= REJECT_SHARE) {
|
|
133
121
|
const share = rejectPool / totalUrls;
|
|
134
|
-
const toClear = Math.max(0, Math.ceil(rejectPool - REJECT_SHARE * totalUrls));
|
|
135
122
|
return {
|
|
136
123
|
stage: "quality_rejection",
|
|
137
124
|
...HEALTH_COPY.quality_rejection,
|
|
138
125
|
evidence: [{
|
|
139
|
-
label: "Crawled
|
|
126
|
+
label: "Crawled not indexed or soft 404",
|
|
140
127
|
value: formatSearchConsoleCount(rejectPool)
|
|
141
128
|
}, {
|
|
142
129
|
label: "Share of known URLs",
|
|
143
130
|
value: `${(share * 100).toFixed(0)}%`
|
|
144
131
|
}],
|
|
145
|
-
progression: escapeReduce("healthy", "crawled-
|
|
132
|
+
progression: escapeReduce("healthy", "crawled-not-indexed or soft-404 share", share, REJECT_SHARE, `${(share * 100).toFixed(0)}% of known URLs. This classification applies at 30% or more, with over 50 affected URLs.`)
|
|
146
133
|
};
|
|
147
134
|
}
|
|
148
135
|
const notIndexedPool = rejectPool + discoveredNotIndexed;
|
|
149
136
|
if (totalUrls > 0 && notIndexedPool > REJECT_MIN && notIndexedPool / totalUrls >= REJECT_SHARE) {
|
|
150
137
|
const share = notIndexedPool / totalUrls;
|
|
151
|
-
const toClear = Math.max(0, Math.ceil(notIndexedPool - REJECT_SHARE * totalUrls));
|
|
152
138
|
return {
|
|
153
139
|
stage: "not_indexed",
|
|
154
140
|
...HEALTH_COPY.not_indexed,
|
|
@@ -158,7 +144,7 @@ function classifyHealthStage(input) {
|
|
|
158
144
|
value: formatSearchConsoleCount(discoveredNotIndexed)
|
|
159
145
|
},
|
|
160
146
|
{
|
|
161
|
-
label: "Crawled
|
|
147
|
+
label: "Crawled not indexed or soft 404",
|
|
162
148
|
value: formatSearchConsoleCount(rejectPool)
|
|
163
149
|
},
|
|
164
150
|
{
|
|
@@ -166,40 +152,52 @@ function classifyHealthStage(input) {
|
|
|
166
152
|
value: `${(share * 100).toFixed(0)}%`
|
|
167
153
|
}
|
|
168
154
|
],
|
|
169
|
-
progression: escapeReduce("healthy", "not-indexed share", share, REJECT_SHARE, `${(share * 100).toFixed(0)}%
|
|
155
|
+
progression: escapeReduce("healthy", "not-indexed share", share, REJECT_SHARE, `${(share * 100).toFixed(0)}% of known URLs. This classification applies at 30% or more, with over 50 affected URLs.`)
|
|
170
156
|
};
|
|
171
157
|
}
|
|
172
158
|
return {
|
|
173
159
|
stage: "healthy",
|
|
174
160
|
...HEALTH_COPY.healthy,
|
|
161
|
+
...totalUrls <= 0 ? {
|
|
162
|
+
summary: "No known URL count is available to assess indexing.",
|
|
163
|
+
primaryAction: "Collect URL Inspection results before assessing indexing."
|
|
164
|
+
} : {},
|
|
175
165
|
evidence: [],
|
|
176
|
-
progression: sustain("indexing health", 1, "
|
|
166
|
+
progression: sustain("indexing health", 1, totalUrls <= 0 ? "Indexing assessment unavailable without a known URL count." : "No warning threshold exceeded in available observations. This does not confirm every URL is indexed.")
|
|
177
167
|
};
|
|
178
168
|
}
|
|
179
169
|
const REACH_COPY = {
|
|
180
170
|
emerging: {
|
|
181
|
-
summary: "Search
|
|
182
|
-
primaryAction: "
|
|
171
|
+
summary: "Search observations are too sparse to establish a click trend.",
|
|
172
|
+
primaryAction: "Review pages and queries with recorded impressions before choosing further work."
|
|
183
173
|
},
|
|
184
174
|
growing: {
|
|
185
|
-
summary: "
|
|
186
|
-
primaryAction: "
|
|
175
|
+
summary: "Search clicks or impressions rose across two complete 28-day periods and at least three paired weeks.",
|
|
176
|
+
primaryAction: "Review growing pages and relevant search queries for further opportunities."
|
|
187
177
|
},
|
|
188
178
|
plateaued: {
|
|
189
|
-
summary: "
|
|
190
|
-
primaryAction: "
|
|
179
|
+
summary: "Clicks and impressions changed by no more than 10% across two complete 28-day periods.",
|
|
180
|
+
primaryAction: "Review page and query changes before choosing further work."
|
|
181
|
+
},
|
|
182
|
+
mixed: {
|
|
183
|
+
summary: "Recent search observations do not show a consistent direction.",
|
|
184
|
+
primaryAction: "Compare clicks, impressions, and weekly changes before choosing further work."
|
|
185
|
+
},
|
|
186
|
+
insufficient_data: {
|
|
187
|
+
summary: "Complete daily observations are unavailable for two consecutive 28-day periods.",
|
|
188
|
+
primaryAction: "Collect complete search data before assessing the trend."
|
|
191
189
|
},
|
|
192
190
|
declining: {
|
|
193
|
-
summary: "
|
|
191
|
+
summary: "Search traffic fell across the recent complete reporting periods.",
|
|
194
192
|
primaryAction: "Investigate the losing pages, recent releases, and competitor moves before expanding."
|
|
195
193
|
},
|
|
196
194
|
faded: {
|
|
197
|
-
summary: "
|
|
198
|
-
primaryAction: "
|
|
195
|
+
summary: "Recent weekly impressions are below 20% of the peak week in the supplied history.",
|
|
196
|
+
primaryAction: "Compare the recent week with the peak. Investigate affected queries, pages, and inspection results."
|
|
199
197
|
},
|
|
200
198
|
decayed: {
|
|
201
|
-
summary: "
|
|
202
|
-
primaryAction: "
|
|
199
|
+
summary: "The Site still earns impressions, but its observed 28-day CTR is below 0.5%.",
|
|
200
|
+
primaryAction: "Review queries, positions, and search appearance before deciding whether pages need changes."
|
|
203
201
|
}
|
|
204
202
|
};
|
|
205
203
|
function reach(stage, evidence, progression) {
|
|
@@ -210,77 +208,116 @@ function reach(stage, evidence, progression) {
|
|
|
210
208
|
progression
|
|
211
209
|
};
|
|
212
210
|
}
|
|
211
|
+
function compareWeeks(values, weeklyMinimumChange) {
|
|
212
|
+
const weeks = Array.from({ length: 8 }, (_, index) => values.slice(index * 7, (index + 1) * 7).reduce((sum, value) => sum + value, 0));
|
|
213
|
+
const prior = weeks.slice(0, 4).reduce((sum, value) => sum + value, 0);
|
|
214
|
+
const current = weeks.slice(4).reduce((sum, value) => sum + value, 0);
|
|
215
|
+
const materiallyChanged = (value, baseline, sign) => sign * (value - baseline) >= Math.max(baseline * .2, weeklyMinimumChange);
|
|
216
|
+
return {
|
|
217
|
+
current,
|
|
218
|
+
prior,
|
|
219
|
+
pct: prior > 0 ? (current / prior - 1) * 100 : null,
|
|
220
|
+
upWeeks: weeks.slice(4).filter((value, index) => materiallyChanged(value, weeks[index], 1)).length,
|
|
221
|
+
downWeeks: weeks.slice(4).filter((value, index) => materiallyChanged(value, weeks[index], -1)).length,
|
|
222
|
+
flatWeeks: weeks.slice(4).filter((value, index) => Math.abs(value - weeks[index]) <= weeks[index] * .1).length
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function measuredDirection(comparison, minimumChange) {
|
|
226
|
+
const { pct, current, prior, upWeeks, downWeeks } = comparison;
|
|
227
|
+
if (pct == null) return current === 0 && prior === 0 ? "flat" : "unclear";
|
|
228
|
+
const delta = current - prior;
|
|
229
|
+
if (delta >= prior * .2 && delta >= minimumChange && upWeeks >= 3) return "up";
|
|
230
|
+
if (delta <= -prior * .2 && delta <= -minimumChange && downWeeks >= 3) return "down";
|
|
231
|
+
if (Math.abs(delta) <= prior * .1) return "flat";
|
|
232
|
+
return "unclear";
|
|
233
|
+
}
|
|
234
|
+
function completeDates(daily) {
|
|
235
|
+
return daily.every((row, index) => {
|
|
236
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.date)) return false;
|
|
237
|
+
const timestamp = Date.parse(row.date);
|
|
238
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === row.date && (index === 0 || timestamp - Date.parse(daily[index - 1].date) === 864e5);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
213
241
|
function classifyReachStage(input) {
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
value: `${(liveness * 100).toFixed(0)}%`
|
|
228
|
-
}], escapeToward("growing", "recent week vs peak (liveness)", liveness, .5, `recent week is ${(liveness * 100).toFixed(0)}% of peak — revive toward ~50%`));
|
|
229
|
-
if (clicks90dPct != null && clicks90dPct < DECLINE_CLICKS_PCT && clicks28dPct != null && clicks28dPct < DECLINE_CLICKS_PCT && priorClicks != null && priorClicks >= MIN_DECLINE_PRIOR_CLICKS) return reach("declining", [{
|
|
230
|
-
label: "Clicks 90d",
|
|
231
|
-
value: pctStr(clicks90dPct)
|
|
232
|
-
}, {
|
|
233
|
-
label: "Clicks 28d",
|
|
234
|
-
value: pctStr(clicks28dPct)
|
|
235
|
-
}], {
|
|
236
|
-
nextStage: "plateaued",
|
|
237
|
-
metric: "90d clicks change",
|
|
238
|
-
value: clicks90dPct,
|
|
239
|
-
target: DECLINE_CLICKS_PCT,
|
|
240
|
-
pct: clamp01(2 - Math.abs(clicks90dPct) / Math.abs(DECLINE_CLICKS_PCT)),
|
|
241
|
-
gapLabel: `down ${pctStr(clicks90dPct)} (90d) — recover clicks above ${pctStr(DECLINE_CLICKS_PCT)} to exit`,
|
|
242
|
-
direction: "escape"
|
|
242
|
+
const impressions = input.impressions28d;
|
|
243
|
+
const evidence = impressions != null && Number.isFinite(impressions) && impressions >= 0 ? [{
|
|
244
|
+
label: "Impressions (28d)",
|
|
245
|
+
value: formatSearchConsoleCount(impressions)
|
|
246
|
+
}] : [];
|
|
247
|
+
const unavailable = () => reach("insufficient_data", evidence, {
|
|
248
|
+
nextStage: null,
|
|
249
|
+
metric: "search data",
|
|
250
|
+
value: 0,
|
|
251
|
+
target: 56,
|
|
252
|
+
pct: 0,
|
|
253
|
+
gapLabel: "Two complete 28-day periods are required.",
|
|
254
|
+
direction: "advance"
|
|
243
255
|
});
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return
|
|
250
|
-
label: "Clicks 90d",
|
|
251
|
-
value: pctStr(clicks90dPct)
|
|
252
|
-
}] : [], ...imp90dPct != null ? [{
|
|
253
|
-
label: "Impressions 90d",
|
|
254
|
-
value: pctStr(imp90dPct)
|
|
255
|
-
}] : []], sustain(growthMetric, growthPct, growthLabel));
|
|
256
|
+
if (impressions == null || !Number.isFinite(impressions) || impressions < 0) return unavailable();
|
|
257
|
+
const daily = input.daily?.slice(-56);
|
|
258
|
+
if (daily && (!completeDates(daily) || daily.some((row) => !Number.isFinite(row.clicks) || row.clicks < 0 || !Number.isFinite(row.impressions) || row.impressions < 0) || input.periodEnd != null && daily.at(-1)?.date !== input.periodEnd)) return unavailable();
|
|
259
|
+
if (!daily || daily.length < 56) {
|
|
260
|
+
if (impressions < MIN_GROWTH_IMPRESSIONS_28D) return reach("emerging", evidence, advance("growing", "28d impressions", impressions, MIN_GROWTH_IMPRESSIONS_28D, "Build enough search visibility to measure a trend."));
|
|
261
|
+
return unavailable();
|
|
256
262
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
263
|
+
const clicks = compareWeeks(daily.map((row) => row.clicks), 5);
|
|
264
|
+
const visibility = compareWeeks(daily.map((row) => row.impressions), 50);
|
|
265
|
+
if (visibility.current !== impressions || input.clicks28d != null && clicks.current !== input.clicks28d) return unavailable();
|
|
266
|
+
const clicksDirection = measuredDirection(clicks, 20);
|
|
267
|
+
const impressionsDirection = measuredDirection(visibility, 200);
|
|
268
|
+
const comparisonEvidence = [
|
|
269
|
+
...evidence,
|
|
270
|
+
{
|
|
271
|
+
label: "Clicks (28d)",
|
|
272
|
+
value: formatSearchConsoleCount(clicks.current)
|
|
273
|
+
},
|
|
274
|
+
...clicks.pct != null ? [{
|
|
275
|
+
label: "Clicks 28d",
|
|
276
|
+
value: pctStr(clicks.pct)
|
|
277
|
+
}] : [],
|
|
278
|
+
...visibility.pct != null ? [{
|
|
279
|
+
label: "Impressions 28d",
|
|
280
|
+
value: pctStr(visibility.pct)
|
|
281
|
+
}] : []
|
|
282
|
+
];
|
|
283
|
+
const growthTarget = Math.ceil(Math.max(clicks.prior * 1.2, clicks.prior + 20, 50));
|
|
284
|
+
const growthProgression = advance("growing", "28d clicks", clicks.current, growthTarget, `About ${formatSearchConsoleCount(Math.max(0, growthTarget - clicks.current))} more clicks would meet the growth count threshold. Growth also requires three materially increased paired weeks.`);
|
|
285
|
+
const opposingDirections = clicksDirection === "up" && impressionsDirection === "down" || clicksDirection === "down" && impressionsDirection === "up";
|
|
286
|
+
if (clicks.prior >= 50 && visibility.prior >= 1e3 && opposingDirections) return {
|
|
287
|
+
...reach("mixed", comparisonEvidence, null),
|
|
288
|
+
summary: `Clicks ${pctStr(clicks.pct)}; impressions ${pctStr(visibility.pct)}. These measured directions oppose each other.`
|
|
289
|
+
};
|
|
290
|
+
if (clicks.prior >= 50 && clicksDirection === "down") {
|
|
291
|
+
const recoveryTarget = Math.ceil(clicks.prior * .9);
|
|
292
|
+
return reach("declining", comparisonEvidence, escapeToward("plateaued", "28d clicks", clicks.current, recoveryTarget, `About ${formatSearchConsoleCount(Math.max(0, recoveryTarget - clicks.current))} more clicks would bring this period within 10% of the previous period.`));
|
|
293
|
+
}
|
|
294
|
+
if (clicks.prior >= 50 && clicks.current >= 50 && clicksDirection === "up") return reach("growing", comparisonEvidence, sustain("28d clicks growth", clicks.pct, "Clicks rose by at least 20% and 20 clicks in three or more paired weeks."));
|
|
295
|
+
const liveness = reachLivenessRatio(daily);
|
|
296
|
+
if (liveness != null && liveness < .2) return {
|
|
297
|
+
...reach("declining", comparisonEvidence, escapeToward("plateaued", "recent weekly impressions", liveness, .2, "Raise recent weekly impressions to at least 20% of the prior baseline.")),
|
|
298
|
+
summary: "Impressions stayed below 20% of the prior weekly baseline for three consecutive weeks."
|
|
299
|
+
};
|
|
300
|
+
if (clicksDirection === "flat" && impressionsDirection === "flat" && clicks.flatWeeks >= 3 && visibility.flatWeeks >= 3 && (clicks.prior >= 50 && clicks.current >= 50 || visibility.prior >= 1e3 && visibility.current >= 1e3)) {
|
|
301
|
+
if (clicks.prior < 50 || clicks.current < 50) {
|
|
302
|
+
const target = Math.ceil(Math.max(visibility.prior * 1.2, visibility.prior + 200));
|
|
303
|
+
return reach("plateaued", comparisonEvidence, advance("growing", "28d impressions", visibility.current, target, `About ${formatSearchConsoleCount(Math.max(0, target - visibility.current))} more impressions would meet the growth count threshold. Growth also requires three materially increased paired weeks.`));
|
|
304
|
+
}
|
|
305
|
+
return reach("plateaued", comparisonEvidence, growthProgression);
|
|
306
|
+
}
|
|
307
|
+
if (clicks.prior < 50 || clicks.current < 50) {
|
|
308
|
+
if (visibility.prior >= 1e3 && impressionsDirection === "down") {
|
|
309
|
+
const recoveryTarget = Math.ceil(visibility.prior * .9);
|
|
310
|
+
return reach("declining", comparisonEvidence, escapeToward("plateaued", "28d impressions", visibility.current, recoveryTarget, `About ${formatSearchConsoleCount(Math.max(0, recoveryTarget - visibility.current))} more impressions would bring this period within 10% of the previous period.`));
|
|
311
|
+
}
|
|
312
|
+
if (visibility.prior >= 1e3 && visibility.current >= 1e3 && impressionsDirection === "up") return reach("growing", comparisonEvidence, sustain("28d impressions growth", visibility.pct, "Impressions rose by at least 20% and 200 impressions in three or more paired weeks."));
|
|
313
|
+
return reach("emerging", comparisonEvidence, advance("growing", "28d clicks", Math.min(clicks.prior, clicks.current), 50, "At least 50 clicks in each period are required to compare click trends."));
|
|
314
|
+
}
|
|
315
|
+
return reach("mixed", comparisonEvidence, null);
|
|
280
316
|
}
|
|
281
317
|
function classifySiteTriage(input) {
|
|
282
318
|
const health = classifyHealthStage(input);
|
|
283
319
|
return {
|
|
320
|
+
algorithmVersion: 4,
|
|
284
321
|
reach: classifyReachStage(input),
|
|
285
322
|
health,
|
|
286
323
|
headline: health.stage === "healthy" ? "reach" : "health"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/sdk",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "7.1.0",
|
|
5
5
|
"description": "Consumer SDK for hosted gscdump.com integrations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -144,8 +144,8 @@
|
|
|
144
144
|
"node": ">=22.13.0"
|
|
145
145
|
},
|
|
146
146
|
"dependencies": {
|
|
147
|
-
"@gscdump/contracts": "^
|
|
148
|
-
"gscdump": "^
|
|
147
|
+
"@gscdump/contracts": "^7.1.0",
|
|
148
|
+
"gscdump": "^7.1.0",
|
|
149
149
|
"ofetch": "^1.5.1",
|
|
150
150
|
"zod": "^4.6.5"
|
|
151
151
|
},
|