@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.
@@ -1,6 +1,6 @@
1
1
  const issueDetails = {
2
2
  crawled_not_indexed: {
3
- description: "Google crawled these pages but decided not to add them to the index. This often means the content was deemed low-quality, duplicate, or not useful enough.",
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: "These pages return a 200 status but Google detects them as effectively empty or error pages — \"soft\" 404s.",
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 = "6.0.0";
1
+ var version = "7.1.0";
2
2
  export { version };
@@ -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` is always a concrete count (pages/clicks/points), never a bare %.
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
- progression: StageProgression;
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
- /** Lifetime-ish impressions (trailing 12 months) — separates new from decayed/faded. */
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 — for the clicks≪impressions decay tell. */
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
- /** Absolute prior-28d clicks — gates decline so a % crash on trivial traffic isn't a false decline. */
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
- /** Latest complete week ÷ 90d peak week (lag-trimmed). <0.2 = faded (spike→died). */
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
- * Reach liveness: latest complete week ÷ peak rolling-7d week over a daily
73
- * impressions series (typically the trailing 90 days). `<0.2` means recent
74
- * impressions have collapsed versus the site's own peak (spike→died) — the
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;
@@ -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 < 14) return null;
18
- const series = daily.map((d) => d.impressions);
19
- while (series.length && series[series.length - 1] === 0) series.pop();
20
- if (series.length < 14) return null;
21
- const rolling7 = (end) => {
22
- let sum = 0;
23
- for (let i = Math.max(0, end - 6); i <= end; i++) sum += series[i];
24
- return sum;
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 escapeToward(nextStage, metric, value, target, gapLabel) {
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 escapeReduce(nextStage, metric, value, target, gapLabel) {
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: target <= 0 ? value <= 0 ? 1 : 0 : clamp01(2 - value / target),
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: "Google can crawl and index the pages that should be indexed.",
85
- primaryAction: "No indexing cleanup needed — focus on reach."
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: "Real access faults (server errors / broken links) are capping otherwise-indexable pages.",
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: "Google has not indexed most known URLs. It queued many without a crawl, and it crawled others and left them out.",
93
- primaryAction: "Link to the queued URLs from pages Google already indexed, and consolidate or improve the crawled ones."
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: "Google is crawling pages and refusing to index them — a soft quality signal it never reports explicitly.",
97
- primaryAction: "Consolidate or improve the rejected pages, or noindex the thin/low-value set."
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 — fix ~${formatSearchConsoleCount(toFix)} to clear the gate`)
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, then refused",
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-rejected share", share, REJECT_SHARE, `${(share * 100).toFixed(0)}% rejected — improve/consolidate ~${formatSearchConsoleCount(toClear)} pages to clear`)
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, currently not indexed",
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)}% not indexed. Get ~${formatSearchConsoleCount(toClear)} URLs indexed to clear the gate`)
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, "Indexable pages are getting indexed — no gate blocking reach.")
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 Console is ready. Search visibility is still limited.",
182
- primaryAction: "Improve discovery and indexing, then build on pages that earn impressions."
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: "Discoverable, indexed-enough, and trending up. The next work is expansion, not cleanup.",
186
- primaryAction: "Expand: striking-distance pages, content gaps, authority."
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: "Established but flat — visibility is steady, not compounding.",
190
- primaryAction: "Refresh top pages and open a new content cluster to restart growth."
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: "Established visibility is genuinely falling versus the previous period.",
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: "The site had real reach that has collapsed — it spiked and is now near-invisible in search.",
198
- primaryAction: "Diagnose the drop (quality, deindexing, lost rankings) — the 90-day total hides it; look at the recent week."
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: "Still shows for old terms but earns almost no clicks — the content has aged out of relevance.",
202
- primaryAction: "Refresh the decayed top pages, or mark the site low-priority if it is no longer maintained."
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 imp28d = input.impressions28d ?? 0;
215
- const imp12m = input.impressions12m ?? null;
216
- const clicks28d = input.clicks28d ?? null;
217
- const clicks90dPct = input.clicksPct90d ?? null;
218
- const clicks28dPct = input.clicksPct28d ?? null;
219
- const priorClicks = input.clicksPrior28d ?? null;
220
- const imp90dPct = input.impressionsPct90d ?? null;
221
- const posDelta = input.positionDelta90d ?? null;
222
- const liveness = input.livenessRatio ?? null;
223
- const hadRealReach = (imp12m ?? imp28d) > MIN_HISTORY_IMPRESSIONS;
224
- const isGrowing = imp28d >= MIN_GROWTH_IMPRESSIONS_28D && (clicks90dPct != null && clicks90dPct > GROWTH_CLICKS_PCT || imp90dPct != null && imp90dPct > GROWTH_IMPRESSIONS_PCT && posDelta != null && posDelta < 0);
225
- if (hadRealReach && liveness != null && liveness < FADED_LIVENESS && !isGrowing) return reach("faded", [{
226
- label: "Recent week vs peak",
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 (isGrowing) {
245
- const clickGrowth = clicks90dPct != null && clicks90dPct > GROWTH_CLICKS_PCT;
246
- const growthPct = clickGrowth ? clicks90dPct : imp90dPct ?? clicks90dPct ?? 0;
247
- const growthMetric = clickGrowth ? "90d clicks growth" : "90d impressions growth";
248
- const growthLabel = clickGrowth ? `${pctStr(clicks90dPct)} 90d clicks — comfortably growing; defend & expand` : `${pctStr(growthPct)} 90d impressions — comfortably growing; defend & expand`;
249
- return reach("growing", [...clicks90dPct != null ? [{
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 ctr = clicks28d != null && imp28d > 0 ? clicks28d / imp28d : null;
258
- if (hadRealReach && imp28d >= MIN_GROWTH_IMPRESSIONS_28D && ctr != null && ctr < DECAY_CTR) return reach("decayed", [{
259
- label: "Impressions (28d)",
260
- value: formatSearchConsoleCount(imp28d)
261
- }, {
262
- label: "Clicks (28d)",
263
- value: formatSearchConsoleCount(clicks28d ?? 0)
264
- }], escapeToward("growing", "CTR (clicks/impressions)", ctr, DECAY_CTR, `${(ctr * 100).toFixed(2)}% CTR on ${formatSearchConsoleCount(imp28d)} impressions — refresh content toward ~${(DECAY_CTR * 100).toFixed(1)}%`));
265
- const growthVal = clicks90dPct ?? 0;
266
- const growthGap = Math.max(0, GROWTH_CLICKS_PCT - growthVal);
267
- const growthProgression = (stage) => advance("growing", "90d clicks growth", growthVal, GROWTH_CLICKS_PCT, clicks90dPct != null ? `${pctStr(growthVal)} 90d clicks — need ${pctStr(growthGap)} more to clear the +${GROWTH_CLICKS_PCT}% growth bar` : `${stage === "plateaued" ? "flat" : "rising"} — reach +${GROWTH_CLICKS_PCT}% 90d clicks growth to break into growing`);
268
- if (imp28d >= ESTABLISHED_IMPRESSIONS_28D) return reach("plateaued", [{
269
- label: "Impressions (28d)",
270
- value: formatSearchConsoleCount(imp28d)
271
- }], growthProgression("plateaued"));
272
- if (imp28d < MIN_GROWTH_IMPRESSIONS_28D) return reach("emerging", [{
273
- label: "Impressions (28d)",
274
- value: formatSearchConsoleCount(imp28d)
275
- }], advance("growing", "28d impressions", imp28d, MIN_GROWTH_IMPRESSIONS_28D, `${formatSearchConsoleCount(imp28d)} impressions in 28 days. Build enough visibility to measure growth reliably.`));
276
- return reach("plateaued", [{
277
- label: "Impressions (28d)",
278
- value: formatSearchConsoleCount(imp28d)
279
- }], growthProgression("plateaued"));
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": "6.0.0",
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": "^6.0.0",
148
- "gscdump": "^6.0.0",
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
  },