@gscdump/analysis 4.5.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/analyzer/all.mjs +2 -0
- package/dist/analyzers/trajectory.d.mts +121 -0
- package/dist/analyzers/trajectory.mjs +356 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Node.js 22 or newer is required for Node consumers.
|
|
|
20
20
|
| Subpath | Purpose |
|
|
21
21
|
| --- | --- |
|
|
22
22
|
| `@gscdump/analysis` | Pure Analyzers, browser dispatch, and shared Analyzer contracts |
|
|
23
|
-
| `@gscdump/analysis/registry` | All
|
|
23
|
+
| `@gscdump/analysis/registry` | All 30 registered Analyzers, including SQL implementations |
|
|
24
24
|
| `@gscdump/analysis/report` | Report registry, `runReport`, and formatting |
|
|
25
25
|
| `@gscdump/analysis/source` | Composite and in-memory Source factories |
|
|
26
26
|
| `@gscdump/analysis/errors` | Typed analysis errors and rendering helpers |
|
|
@@ -84,13 +84,13 @@ console.log(result.results)
|
|
|
84
84
|
|
|
85
85
|
Install `@gscdump/engine`, `@gscdump/engine-gsc-api`, and `gscdump` for this example.
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Thirteen Analyzers have row plans:
|
|
88
88
|
|
|
89
89
|
- `brand`, `cannibalization`, `clustering`, `concentration`
|
|
90
90
|
- `data-detail`, `data-query`, `decay`, `movers`
|
|
91
|
-
- `opportunity`, `seasonality`, `striking-distance`, `zero-click`
|
|
91
|
+
- `opportunity`, `seasonality`, `striking-distance`, `trajectory`, `zero-click`
|
|
92
92
|
|
|
93
|
-
All
|
|
93
|
+
All 30 Analyzers have SQL plans.
|
|
94
94
|
SQL-only Analyzers need a Source that supports their required capabilities.
|
|
95
95
|
|
|
96
96
|
| Factory | Import path | Input |
|
package/dist/analyzer/all.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { moversAnalyzer } from "../analyzers/movers.mjs";
|
|
|
7
7
|
import { opportunityAnalyzer } from "../analyzers/opportunity.mjs";
|
|
8
8
|
import { seasonalityAnalyzer } from "../analyzers/seasonality.mjs";
|
|
9
9
|
import { strikingDistanceAnalyzer } from "../analyzers/striking-distance.mjs";
|
|
10
|
+
import { trajectoryAnalyzer } from "../analyzers/trajectory.mjs";
|
|
10
11
|
import { zeroClickAnalyzer } from "../analyzers/zero-click.mjs";
|
|
11
12
|
import { bayesianCtrAnalyzer } from "../analyzers/bayesian-ctr.mjs";
|
|
12
13
|
import { bipartitePagerankAnalyzer } from "../analyzers/bipartite-pagerank.mjs";
|
|
@@ -55,6 +56,7 @@ const ALL_ANALYZERS = [
|
|
|
55
56
|
stlDecomposeAnalyzer,
|
|
56
57
|
strikingDistanceAnalyzer,
|
|
57
58
|
survivalAnalyzer,
|
|
59
|
+
trajectoryAnalyzer,
|
|
58
60
|
trendsAnalyzer,
|
|
59
61
|
zeroClickAnalyzer
|
|
60
62
|
];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export interface TrajectoryDay {
|
|
2
|
+
/** ISO calendar date, `YYYY-MM-DD`. */
|
|
3
|
+
date: string;
|
|
4
|
+
clicks: number;
|
|
5
|
+
impressions: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The input of `analyzeTrajectory`: which days are known, and what they held.
|
|
9
|
+
*/
|
|
10
|
+
export interface TrajectoryRecord {
|
|
11
|
+
/**
|
|
12
|
+
* Days that had traffic. A day between the first row and `knownThrough`
|
|
13
|
+
* with no row is known, and had no traffic.
|
|
14
|
+
*/
|
|
15
|
+
days: readonly TrajectoryDay[];
|
|
16
|
+
/**
|
|
17
|
+
* `YYYY-MM-DD`. The last day whose data is known: the last synced day, or
|
|
18
|
+
* the requested end when the whole range was fetched. The latest week ends
|
|
19
|
+
* here. Days after it are unknown, so they are never read as zero. A date
|
|
20
|
+
* earlier than the last row is ignored, and an invalid one falls back to
|
|
21
|
+
* the last row with a caveat.
|
|
22
|
+
*/
|
|
23
|
+
knownThrough: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TrajectoryWindow {
|
|
26
|
+
startDate: string;
|
|
27
|
+
endDate: string;
|
|
28
|
+
clicks: number;
|
|
29
|
+
impressions: number;
|
|
30
|
+
}
|
|
31
|
+
/** Which metric the peak, the ratio, and the classification read. */
|
|
32
|
+
export type TrajectoryBasis = {
|
|
33
|
+
_tag: 'impressions';
|
|
34
|
+
note: string;
|
|
35
|
+
} | {
|
|
36
|
+
_tag: 'clicks';
|
|
37
|
+
reason: 'impressions-overcount-window' | 'impressions-clicks-disagree' | 'too-few-impressions';
|
|
38
|
+
note: string;
|
|
39
|
+
};
|
|
40
|
+
export type TrajectoryCaveat = {
|
|
41
|
+
_tag: 'impressions-overcount';
|
|
42
|
+
fromDate: string;
|
|
43
|
+
throughDate: string;
|
|
44
|
+
/** True when the impressions peak window sits inside the over-count period. */
|
|
45
|
+
overlapsPeak: boolean;
|
|
46
|
+
note: string;
|
|
47
|
+
} | {
|
|
48
|
+
_tag: 'skipped-rows';
|
|
49
|
+
count: number;
|
|
50
|
+
note: string;
|
|
51
|
+
} | {
|
|
52
|
+
_tag: 'invalid-known-through';
|
|
53
|
+
value: string;
|
|
54
|
+
note: string;
|
|
55
|
+
};
|
|
56
|
+
export type TrajectoryClassification = {
|
|
57
|
+
_tag: 'insufficient-data';
|
|
58
|
+
reason: 'too-few-days' | 'no-traffic';
|
|
59
|
+
days: number;
|
|
60
|
+
} |
|
|
61
|
+
/** Fast climb from near zero, then a drop to a small fraction of peak within about 2 weeks, staying low. */
|
|
62
|
+
{
|
|
63
|
+
_tag: 'launch-honeymoon-then-cliff';
|
|
64
|
+
climbDays: number;
|
|
65
|
+
dropDays: number;
|
|
66
|
+
} |
|
|
67
|
+
/** The same fast drop on a site that did not start from near zero. */
|
|
68
|
+
{
|
|
69
|
+
_tag: 'sudden-drop';
|
|
70
|
+
dropDays: number;
|
|
71
|
+
} |
|
|
72
|
+
/** Latest week is at or near the peak and clearly above 8 weeks earlier. */
|
|
73
|
+
{
|
|
74
|
+
_tag: 'growing';
|
|
75
|
+
} |
|
|
76
|
+
/** Latest week is near the peak or moderately below it. */
|
|
77
|
+
{
|
|
78
|
+
_tag: 'steady';
|
|
79
|
+
} |
|
|
80
|
+
/** Latest week is well below the peak with no cliff. */
|
|
81
|
+
{
|
|
82
|
+
_tag: 'gradual-decline';
|
|
83
|
+
};
|
|
84
|
+
export interface TrajectoryResult {
|
|
85
|
+
/** First input date. Search Console history may begin at the backfill edge, not at launch. */
|
|
86
|
+
recordStartDate: string | null;
|
|
87
|
+
/** First day with any clicks or impressions. */
|
|
88
|
+
firstDataDate: string | null;
|
|
89
|
+
/** The end of the known range: `knownThrough`, or the last row when that is later. */
|
|
90
|
+
lastDataDate: string | null;
|
|
91
|
+
/** Days from the record start to the end of the known range, gaps included. */
|
|
92
|
+
days: number;
|
|
93
|
+
basis: TrajectoryBasis;
|
|
94
|
+
peak: TrajectoryWindow | null;
|
|
95
|
+
latest: TrajectoryWindow | null;
|
|
96
|
+
/**
|
|
97
|
+
* Latest 7-day total over peak 7-day total, on the basis metric. One
|
|
98
|
+
* outlier day is capped first (`OUTLIER_DAY_MULTIPLE`), so `peak` and
|
|
99
|
+
* `latest` hold raw totals and this ratio can differ from their quotient.
|
|
100
|
+
* Null when the record is too thin.
|
|
101
|
+
*/
|
|
102
|
+
latestToPeakRatio: number | null;
|
|
103
|
+
/** Whole weeks from the end of the peak window to the last date. */
|
|
104
|
+
weeksSincePeak: number | null;
|
|
105
|
+
classification: TrajectoryClassification;
|
|
106
|
+
caveats: TrajectoryCaveat[];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Name the shape of a Site's full daily record.
|
|
110
|
+
*
|
|
111
|
+
* Pass every preserved day and the last known day. Days between the first row
|
|
112
|
+
* and `knownThrough` with no row count as zero. Days after `knownThrough` are
|
|
113
|
+
* never read. Rows with a date that is not `YYYY-MM-DD` are skipped and counted
|
|
114
|
+
* in `caveats`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function analyzeTrajectory(record: TrajectoryRecord): TrajectoryResult;
|
|
117
|
+
/**
|
|
118
|
+
* Default read when the caller gives no start date: 16 months, the span Google
|
|
119
|
+
* keeps. Hosted callers that preserve more pass `startDate` to read all of it.
|
|
120
|
+
*/
|
|
121
|
+
export declare const TRAJECTORY_DEFAULT_DAYS = 486;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { datesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
2
|
+
import { rowNumber, rowString } from "../analyzer/row-values.mjs";
|
|
3
|
+
import { fetchBudgetOf } from "@gscdump/engine/analysis-types";
|
|
4
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
5
|
+
import { defaultEndDate } from "@gscdump/engine/period";
|
|
6
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
7
|
+
import { MS_PER_DAY, toIsoDate } from "gscdump/dates";
|
|
8
|
+
const IMPRESSIONS_OVERCOUNT_FROM = "2025-05-13";
|
|
9
|
+
const IMPRESSIONS_OVERCOUNT_THROUGH = "2026-04-27";
|
|
10
|
+
const METRIC_DISAGREEMENT = .25;
|
|
11
|
+
const CLIFF_FRACTION = .15;
|
|
12
|
+
const STAYS_LOW_FRACTION = .25;
|
|
13
|
+
const LAUNCH_START_FRACTION = .25;
|
|
14
|
+
const NEAR_PEAK_RATIO = .9;
|
|
15
|
+
const DECLINE_RATIO = .7;
|
|
16
|
+
const GROWTH_PRIOR_FRACTION = .8;
|
|
17
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
18
|
+
function parseDay(date) {
|
|
19
|
+
if (!ISO_DATE.test(date)) return null;
|
|
20
|
+
const ms = Date.parse(`${date}T00:00:00Z`);
|
|
21
|
+
return Number.isFinite(ms) ? ms : null;
|
|
22
|
+
}
|
|
23
|
+
function dateAt(startMs, index) {
|
|
24
|
+
return toIsoDate(new Date(startMs + index * MS_PER_DAY));
|
|
25
|
+
}
|
|
26
|
+
function fillSeries(days, endMs) {
|
|
27
|
+
const byMs = /* @__PURE__ */ new Map();
|
|
28
|
+
let skipped = 0;
|
|
29
|
+
for (const day of days) {
|
|
30
|
+
const ms = parseDay(day.date);
|
|
31
|
+
if (ms === null) {
|
|
32
|
+
skipped++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const held = byMs.get(ms) ?? {
|
|
36
|
+
clicks: 0,
|
|
37
|
+
impressions: 0
|
|
38
|
+
};
|
|
39
|
+
held.clicks += Number.isFinite(day.clicks) ? day.clicks : 0;
|
|
40
|
+
held.impressions += Number.isFinite(day.impressions) ? day.impressions : 0;
|
|
41
|
+
byMs.set(ms, held);
|
|
42
|
+
}
|
|
43
|
+
if (!byMs.size) return null;
|
|
44
|
+
const keys = [...byMs.keys()];
|
|
45
|
+
const first = Math.min(...keys);
|
|
46
|
+
const last = Math.max(...keys, endMs ?? Number.NEGATIVE_INFINITY);
|
|
47
|
+
const length = Math.round((last - first) / MS_PER_DAY) + 1;
|
|
48
|
+
const clicks = Array.from({ length }).fill(0);
|
|
49
|
+
const impressions = Array.from({ length }).fill(0);
|
|
50
|
+
for (const [ms, value] of byMs) {
|
|
51
|
+
const index = Math.round((ms - first) / MS_PER_DAY);
|
|
52
|
+
clicks[index] = value.clicks;
|
|
53
|
+
impressions[index] = value.impressions;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
startDate: toIsoDate(new Date(first)),
|
|
57
|
+
clicks,
|
|
58
|
+
impressions,
|
|
59
|
+
skipped
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function rollingSums(series) {
|
|
63
|
+
const out = Array.from({ length: series.length }).fill(NaN);
|
|
64
|
+
let sum = 0;
|
|
65
|
+
for (let i = 0; i < series.length; i++) {
|
|
66
|
+
sum += series[i];
|
|
67
|
+
if (i >= 7) sum -= series[i - 7];
|
|
68
|
+
if (i >= 6) out[i] = sum;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function median(values) {
|
|
73
|
+
values.sort((a, b) => a - b);
|
|
74
|
+
const mid = values.length >> 1;
|
|
75
|
+
return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
|
|
76
|
+
}
|
|
77
|
+
function capOutlierDays(series) {
|
|
78
|
+
return series.map((value, i) => {
|
|
79
|
+
const around = series.slice(Math.max(0, i - 7), i + 7 + 1);
|
|
80
|
+
return Math.min(value, Math.max(3 * median(around), 3));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function peakIndex(rolling) {
|
|
84
|
+
let best = -1;
|
|
85
|
+
for (let i = 6; i < rolling.length; i++) if (best === -1 || rolling[i] >= rolling[best]) best = i;
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
function ratioOf(rolling) {
|
|
89
|
+
const peak = rolling[peakIndex(rolling)];
|
|
90
|
+
const latest = rolling[rolling.length - 1];
|
|
91
|
+
return peak && peak > 0 && latest !== void 0 ? latest / peak : null;
|
|
92
|
+
}
|
|
93
|
+
function windowAt(filled, startMs, endIndex, rc, ri) {
|
|
94
|
+
return {
|
|
95
|
+
startDate: dateAt(startMs, endIndex - 6),
|
|
96
|
+
endDate: dateAt(startMs, endIndex),
|
|
97
|
+
clicks: rc[endIndex],
|
|
98
|
+
impressions: ri[endIndex]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function overlapsOvercount(startDate, endDate) {
|
|
102
|
+
return startDate <= "2026-04-27" && endDate >= "2025-05-13";
|
|
103
|
+
}
|
|
104
|
+
function emptyBasis(classification) {
|
|
105
|
+
return {
|
|
106
|
+
_tag: "impressions",
|
|
107
|
+
note: `Impressions are the default basis. No basis carries a read. ${classification._tag === "insufficient-data" && classification.reason === "no-traffic" ? `The peak week holds under 20 clicks and under 200 impressions.` : `The record is under 28 days.`}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function emptyResult(filled, classification, caveats) {
|
|
111
|
+
const startMs = filled ? parseDay(filled.startDate) : 0;
|
|
112
|
+
const days = filled?.clicks.length ?? 0;
|
|
113
|
+
const firstTraffic = filled ? filled.clicks.findIndex((c, i) => c > 0 || filled.impressions[i] > 0) : -1;
|
|
114
|
+
return {
|
|
115
|
+
recordStartDate: filled?.startDate ?? null,
|
|
116
|
+
firstDataDate: firstTraffic >= 0 ? dateAt(startMs, firstTraffic) : null,
|
|
117
|
+
lastDataDate: filled ? dateAt(startMs, days - 1) : null,
|
|
118
|
+
days,
|
|
119
|
+
basis: emptyBasis(classification),
|
|
120
|
+
peak: null,
|
|
121
|
+
latest: null,
|
|
122
|
+
latestToPeakRatio: null,
|
|
123
|
+
weeksSincePeak: null,
|
|
124
|
+
classification,
|
|
125
|
+
caveats
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function chooseBasis(rc, ri, startMs) {
|
|
129
|
+
const peakC = rc[peakIndex(rc)];
|
|
130
|
+
const peakIdxI = peakIndex(ri);
|
|
131
|
+
const peakI = ri[peakIdxI];
|
|
132
|
+
const enoughClicks = peakC >= 20;
|
|
133
|
+
const enoughImpressions = peakI >= 200;
|
|
134
|
+
const overlapsPeak = overlapsOvercount(dateAt(startMs, peakIdxI - 6), dateAt(startMs, peakIdxI));
|
|
135
|
+
if (!enoughClicks && !enoughImpressions) return null;
|
|
136
|
+
if (!enoughClicks) return {
|
|
137
|
+
basis: {
|
|
138
|
+
_tag: "impressions",
|
|
139
|
+
note: "Too few clicks to read. Impressions carry this read."
|
|
140
|
+
},
|
|
141
|
+
overlapsPeak
|
|
142
|
+
};
|
|
143
|
+
if (!enoughImpressions) return {
|
|
144
|
+
basis: {
|
|
145
|
+
_tag: "clicks",
|
|
146
|
+
reason: "too-few-impressions",
|
|
147
|
+
note: "Too few impressions to read. Clicks carry this read."
|
|
148
|
+
},
|
|
149
|
+
overlapsPeak
|
|
150
|
+
};
|
|
151
|
+
if (overlapsPeak) return {
|
|
152
|
+
basis: {
|
|
153
|
+
_tag: "clicks",
|
|
154
|
+
reason: "impressions-overcount-window",
|
|
155
|
+
note: `Clicks carry this read. Search Console over-counted impressions through ${IMPRESSIONS_OVERCOUNT_THROUGH}, and the impressions peak sits in that period.`
|
|
156
|
+
},
|
|
157
|
+
overlapsPeak
|
|
158
|
+
};
|
|
159
|
+
const ratioC = ratioOf(rc);
|
|
160
|
+
const ratioI = ratioOf(ri);
|
|
161
|
+
if (ratioC !== null && ratioI !== null && Math.abs(ratioC - ratioI) > .25) return {
|
|
162
|
+
basis: {
|
|
163
|
+
_tag: "clicks",
|
|
164
|
+
reason: "impressions-clicks-disagree",
|
|
165
|
+
note: "Clicks carry this read. Clicks and impressions disagree about how far the site fell from its peak."
|
|
166
|
+
},
|
|
167
|
+
overlapsPeak
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
basis: {
|
|
171
|
+
_tag: "impressions",
|
|
172
|
+
note: "Impressions and clicks agree. Impressions carry this read."
|
|
173
|
+
},
|
|
174
|
+
overlapsPeak
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function classify(rolling, peakIdx, firstTrafficIdx) {
|
|
178
|
+
const peak = rolling[peakIdx];
|
|
179
|
+
const lastIdx = rolling.length - 1;
|
|
180
|
+
const latest = rolling[lastIdx];
|
|
181
|
+
const ratio = latest / peak;
|
|
182
|
+
let dropIdx = -1;
|
|
183
|
+
for (let j = peakIdx + 1; j <= lastIdx; j++) if (rolling[j] <= .15 * peak) {
|
|
184
|
+
dropIdx = j;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
let cliffStartIdx = peakIdx;
|
|
188
|
+
for (let j = dropIdx - 1; j > peakIdx; j--) if (rolling[j] >= .7 * peak) {
|
|
189
|
+
cliffStartIdx = j;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (dropIdx !== -1 && dropIdx - cliffStartIdx <= 21) {
|
|
193
|
+
let staysLow = true;
|
|
194
|
+
for (let k = dropIdx; k <= lastIdx; k++) if (rolling[k] > .25 * peak) {
|
|
195
|
+
staysLow = false;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (staysLow) {
|
|
199
|
+
const dropDays = dropIdx - cliffStartIdx;
|
|
200
|
+
const climbDays = peakIdx - firstTrafficIdx;
|
|
201
|
+
const firstWeekIdx = firstTrafficIdx + 7 - 1;
|
|
202
|
+
const startsNearZero = firstWeekIdx < peakIdx && rolling[firstWeekIdx] <= .25 * peak;
|
|
203
|
+
return climbDays <= 56 && startsNearZero ? {
|
|
204
|
+
_tag: "launch-honeymoon-then-cliff",
|
|
205
|
+
climbDays,
|
|
206
|
+
dropDays
|
|
207
|
+
} : {
|
|
208
|
+
_tag: "sudden-drop",
|
|
209
|
+
dropDays
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (ratio <= .7) return { _tag: "gradual-decline" };
|
|
214
|
+
if (ratio >= .9) {
|
|
215
|
+
const priorIdx = lastIdx - 56;
|
|
216
|
+
if (priorIdx >= 6 && rolling[priorIdx] <= .8 * latest) return { _tag: "growing" };
|
|
217
|
+
}
|
|
218
|
+
return { _tag: "steady" };
|
|
219
|
+
}
|
|
220
|
+
function analyzeTrajectory(record) {
|
|
221
|
+
const { days } = record;
|
|
222
|
+
const knownMs = parseDay(record.knownThrough);
|
|
223
|
+
const filled = fillSeries(days, knownMs);
|
|
224
|
+
const caveats = [];
|
|
225
|
+
if (knownMs === null) caveats.push({
|
|
226
|
+
_tag: "invalid-known-through",
|
|
227
|
+
value: record.knownThrough,
|
|
228
|
+
note: `"${record.knownThrough}" is not a YYYY-MM-DD date. The latest week ends at the last row.`
|
|
229
|
+
});
|
|
230
|
+
const skippedCount = filled ? filled.skipped : days.length;
|
|
231
|
+
if (skippedCount > 0) caveats.push({
|
|
232
|
+
_tag: "skipped-rows",
|
|
233
|
+
count: skippedCount,
|
|
234
|
+
note: `${skippedCount} rows had no valid YYYY-MM-DD date and were skipped.`
|
|
235
|
+
});
|
|
236
|
+
if (!filled) return emptyResult(null, {
|
|
237
|
+
_tag: "insufficient-data",
|
|
238
|
+
reason: "too-few-days",
|
|
239
|
+
days: 0
|
|
240
|
+
}, caveats);
|
|
241
|
+
const length = filled.clicks.length;
|
|
242
|
+
if (length < 28) return emptyResult(filled, {
|
|
243
|
+
_tag: "insufficient-data",
|
|
244
|
+
reason: "too-few-days",
|
|
245
|
+
days: length
|
|
246
|
+
}, caveats);
|
|
247
|
+
const startMs = parseDay(filled.startDate);
|
|
248
|
+
const rc = rollingSums(filled.clicks);
|
|
249
|
+
const ri = rollingSums(filled.impressions);
|
|
250
|
+
const cc = rollingSums(capOutlierDays(filled.clicks));
|
|
251
|
+
const ci = rollingSums(capOutlierDays(filled.impressions));
|
|
252
|
+
const chosen = chooseBasis(cc, ci, startMs);
|
|
253
|
+
if (!chosen) return emptyResult(filled, {
|
|
254
|
+
_tag: "insufficient-data",
|
|
255
|
+
reason: "no-traffic",
|
|
256
|
+
days: length
|
|
257
|
+
}, caveats);
|
|
258
|
+
const lastDate = dateAt(startMs, length - 1);
|
|
259
|
+
if (overlapsOvercount(filled.startDate, lastDate)) caveats.push({
|
|
260
|
+
_tag: "impressions-overcount",
|
|
261
|
+
fromDate: IMPRESSIONS_OVERCOUNT_FROM,
|
|
262
|
+
throughDate: IMPRESSIONS_OVERCOUNT_THROUGH,
|
|
263
|
+
overlapsPeak: chosen.overlapsPeak,
|
|
264
|
+
note: `Search Console over-counted impressions from ${IMPRESSIONS_OVERCOUNT_FROM} through ${IMPRESSIONS_OVERCOUNT_THROUGH}. Clicks were not affected.`
|
|
265
|
+
});
|
|
266
|
+
const rolling = chosen.basis._tag === "clicks" ? cc : ci;
|
|
267
|
+
const peakIdx = peakIndex(rolling);
|
|
268
|
+
const firstTrafficIdx = filled.clicks.findIndex((c, i) => c > 0 || filled.impressions[i] > 0);
|
|
269
|
+
const lastIdx = length - 1;
|
|
270
|
+
return {
|
|
271
|
+
recordStartDate: filled.startDate,
|
|
272
|
+
firstDataDate: dateAt(startMs, firstTrafficIdx),
|
|
273
|
+
lastDataDate: lastDate,
|
|
274
|
+
days: length,
|
|
275
|
+
basis: chosen.basis,
|
|
276
|
+
peak: windowAt(filled, startMs, peakIdx, rc, ri),
|
|
277
|
+
latest: windowAt(filled, startMs, lastIdx, rc, ri),
|
|
278
|
+
latestToPeakRatio: rolling[lastIdx] / rolling[peakIdx],
|
|
279
|
+
weeksSincePeak: Math.floor((lastIdx - peakIdx) / 7),
|
|
280
|
+
classification: classify(rolling, peakIdx, firstTrafficIdx),
|
|
281
|
+
caveats
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const TRAJECTORY_DEFAULT_DAYS = 486;
|
|
285
|
+
function trajectoryWindow(params) {
|
|
286
|
+
const endDate = params.endDate || defaultEndDate();
|
|
287
|
+
return {
|
|
288
|
+
startDate: params.startDate || toIsoDate(/* @__PURE__ */ new Date(Date.parse(endDate) - 485 * MS_PER_DAY)),
|
|
289
|
+
endDate
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const trajectoryAnalyzer = defineAnalyzer({
|
|
293
|
+
id: "trajectory",
|
|
294
|
+
buildSql(params) {
|
|
295
|
+
const { startDate, endDate } = trajectoryWindow(params);
|
|
296
|
+
return {
|
|
297
|
+
sql: `
|
|
298
|
+
SELECT
|
|
299
|
+
strftime(CAST(date AS DATE), '%Y-%m-%d') AS date,
|
|
300
|
+
CAST(SUM(clicks) AS DOUBLE) AS clicks,
|
|
301
|
+
CAST(SUM(impressions) AS DOUBLE) AS impressions
|
|
302
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
303
|
+
WHERE date >= ? AND date <= ?
|
|
304
|
+
GROUP BY 1
|
|
305
|
+
ORDER BY 1
|
|
306
|
+
`,
|
|
307
|
+
params: [startDate, endDate],
|
|
308
|
+
current: {
|
|
309
|
+
table: "dates",
|
|
310
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
reduceSql(rows, params) {
|
|
315
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
316
|
+
const { startDate, endDate } = trajectoryWindow(params);
|
|
317
|
+
return {
|
|
318
|
+
results: [analyzeTrajectory({
|
|
319
|
+
days: arr.map((r) => ({
|
|
320
|
+
date: rowString(r.date),
|
|
321
|
+
clicks: rowNumber(r.clicks),
|
|
322
|
+
impressions: rowNumber(r.impressions)
|
|
323
|
+
})),
|
|
324
|
+
knownThrough: endDate
|
|
325
|
+
})],
|
|
326
|
+
meta: {
|
|
327
|
+
total: 1,
|
|
328
|
+
startDate,
|
|
329
|
+
endDate
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
buildRows(params) {
|
|
334
|
+
return { dates: datesQueryState(trajectoryWindow(params), fetchBudgetOf(params)) };
|
|
335
|
+
},
|
|
336
|
+
reduceRows(rows, params) {
|
|
337
|
+
const dates = Array.isArray(rows) ? rows : [];
|
|
338
|
+
const { startDate, endDate } = trajectoryWindow(params);
|
|
339
|
+
return {
|
|
340
|
+
results: [analyzeTrajectory({
|
|
341
|
+
days: dates.map((r) => ({
|
|
342
|
+
date: r.date,
|
|
343
|
+
clicks: r.clicks,
|
|
344
|
+
impressions: r.impressions
|
|
345
|
+
})),
|
|
346
|
+
knownThrough: endDate
|
|
347
|
+
})],
|
|
348
|
+
meta: {
|
|
349
|
+
total: 1,
|
|
350
|
+
startDate,
|
|
351
|
+
endDate
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
export { CLIFF_FRACTION, DECLINE_RATIO, GROWTH_PRIOR_FRACTION, IMPRESSIONS_OVERCOUNT_FROM, IMPRESSIONS_OVERCOUNT_THROUGH, LAUNCH_START_FRACTION, METRIC_DISAGREEMENT, NEAR_PEAK_RATIO, STAYS_LOW_FRACTION, TRAJECTORY_DEFAULT_DAYS, analyzeTrajectory, trajectoryAnalyzer };
|
package/dist/index.d.mts
CHANGED
|
@@ -8,6 +8,7 @@ import { MOVERS_SORT_METRICS, MoverData, MoversInput, MoversOptions, MoversResul
|
|
|
8
8
|
import { OpportunityFactors, OpportunityOptions, OpportunityResult, OpportunitySortMetric, OpportunityWeights, analyzeOpportunity } from "./analyzers/opportunity.mjs";
|
|
9
9
|
import { MonthlyData, SeasonalityMetric, SeasonalityOptions, SeasonalityResult, analyzeSeasonality } from "./analyzers/seasonality.mjs";
|
|
10
10
|
import { StrikingDistanceInputRow, StrikingDistanceOptions, StrikingDistanceResult, analyzeStrikingDistance } from "./analyzers/striking-distance.mjs";
|
|
11
|
+
import { TRAJECTORY_DEFAULT_DAYS, TrajectoryBasis, TrajectoryCaveat, TrajectoryClassification, TrajectoryDay, TrajectoryRecord, TrajectoryResult, TrajectoryWindow, analyzeTrajectory } from "./analyzers/trajectory.mjs";
|
|
11
12
|
import { ZeroClickOptions, ZeroClickResult, analyzeZeroClick } from "./analyzers/zero-click.mjs";
|
|
12
13
|
import { AnalyzerRunner, BrowserAnalyzeOptions, analyzeInBrowser } from "./browser.mjs";
|
|
13
14
|
import { INTENT_CLASSIFIER_VERSION, IntentClassification, SEARCH_INTENT_CODE, SearchIntent, classifyQueryIntent, encodeIntent } from "./query/intent.mjs";
|
|
@@ -18,4 +19,4 @@ import { Analyzer, AnalyzerCapabilityError, AnalyzerRegistry, AnalyzerRegistryIn
|
|
|
18
19
|
import { AnalysisPeriod, ComparisonMode, ComparisonPeriod, PadTimeseriesOptions, ResolveWindowOptions, ResolvedWindow, WindowPreset, comparisonOf, padTimeseries, periodOf, resolveWindow } from "@gscdump/engine/period";
|
|
19
20
|
import { AnalysisQuerySource, AnalysisSourceKind, ENGINE_QUERY_CAPABILITIES, EngineQuerySourceOptions, ExecuteSqlOptions, FileSet, QueryRow, SourceCapabilities, createEngineQuerySource, queryRows, runAnalyzerWithEngine } from "@gscdump/engine/source";
|
|
20
21
|
import { DefineReportOptions, DefinedReport, ReportContext, ReportFinding, ReportPlanStep, ReportResult, ReportSection } from "@gscdump/engine/report";
|
|
21
|
-
export { type AnalysisParams, type AnalysisPeriod, type AnalysisQuerySource, type AnalysisResult, type AnalysisSourceKind, type AnalysisTool, type Analyzer, AnalyzerCapabilityError, type AnalyzerRegistry, type AnalyzerRegistryInit, type AnalyzerRunner, type AnalyzerVariants, type BaseMetrics, type BrandSegmentationOptions, type BrandSegmentationResult, type BrandSegmentationRow, type BrandSummary, type BrowserAnalyzeOptions, type CannibalizationCompetitor, type CannibalizationEvent, type CannibalizationOptions, type CannibalizationPage, type CannibalizationResult, type CannibalizationSortMetric, type ClusterType, type ClusteringOptions, type ClusteringResult, type ComparisonMode, type ComparisonPeriod, type ConcentrationInput, type ConcentrationItem, type ConcentrationOptions, type ConcentrationResult, type ConcentrationRiskLevel, type CurrentSitemapScope, type DateRow, type DecayInput, type DecayOptions, type DecayResult, type DecaySeriesPoint, type DecaySortMetric, type DefineAnalyzerOptions, type DefineReportOptions, type DefinedAnalyzer, type DefinedReport, ENGINE_QUERY_CAPABILITIES, type EngineQuerySourceOptions, type ExecuteSqlOptions, type FileSet, INTENT_CLASSIFIER_VERSION, type IntentClassification, type KeywordCluster, MOVERS_SORT_METRICS, type MonthlyData, type MoverData, type MoversInput, type MoversOptions, type MoversResult, type MoversSortMetric, NORMALIZER_VERSION, type OpportunityFactors, type OpportunityOptions, type OpportunityResult, type OpportunitySortMetric, type OpportunityWeights, type PadTimeseriesOptions, type PageRow, type Plan, type QueriesRow, type QueryPageRow, type QueryRow, type ReduceContext, type ReduceCtx, type Reducer, type ReportContext, type ReportFinding, type ReportPlanStep, type ReportResult, type ReportSection, type RequiredCapability, type ResolveWindowOptions, type ResolvedWindow, type RowQueriesPlan, SEARCH_INTENT_CODE, SITEMAP_SYNC_COLLAPSE_POLICY, SITEMAP_TRUST_COLLAPSE_POLICY, type SearchIntent, type SeasonalityMetric, type SeasonalityOptions, type SeasonalityResult, type SitemapCollapsePolicy, type SitemapCollapseState, type SitemapDelta, type SitemapHealthDiff, type SitemapHealthInput, type SitemapHealthRow, type SitemapHealthTotals, type SortOrder, type SourceCapabilities, type SqlExtraQuery, type SqlPlan, type SqlPlanSpec, type StrikingDistanceInputRow, type StrikingDistanceOptions, type StrikingDistanceResult, type TypedRowQuery, type WindowPreset, type ZeroClickOptions, type ZeroClickResult, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, classifyQueryIntent, classifySitemapCollapse, compareCurrentSitemapScope, comparisonOf, createAnalyzerRegistry, createEngineQuerySource, createSorter, defineAnalyzer, diffSitemapHealth, encodeIntent, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, runAnalyzerFromSource, runAnalyzerWithEngine, sitemapHistoryHasCollapse };
|
|
22
|
+
export { type AnalysisParams, type AnalysisPeriod, type AnalysisQuerySource, type AnalysisResult, type AnalysisSourceKind, type AnalysisTool, type Analyzer, AnalyzerCapabilityError, type AnalyzerRegistry, type AnalyzerRegistryInit, type AnalyzerRunner, type AnalyzerVariants, type BaseMetrics, type BrandSegmentationOptions, type BrandSegmentationResult, type BrandSegmentationRow, type BrandSummary, type BrowserAnalyzeOptions, type CannibalizationCompetitor, type CannibalizationEvent, type CannibalizationOptions, type CannibalizationPage, type CannibalizationResult, type CannibalizationSortMetric, type ClusterType, type ClusteringOptions, type ClusteringResult, type ComparisonMode, type ComparisonPeriod, type ConcentrationInput, type ConcentrationItem, type ConcentrationOptions, type ConcentrationResult, type ConcentrationRiskLevel, type CurrentSitemapScope, type DateRow, type DecayInput, type DecayOptions, type DecayResult, type DecaySeriesPoint, type DecaySortMetric, type DefineAnalyzerOptions, type DefineReportOptions, type DefinedAnalyzer, type DefinedReport, ENGINE_QUERY_CAPABILITIES, type EngineQuerySourceOptions, type ExecuteSqlOptions, type FileSet, INTENT_CLASSIFIER_VERSION, type IntentClassification, type KeywordCluster, MOVERS_SORT_METRICS, type MonthlyData, type MoverData, type MoversInput, type MoversOptions, type MoversResult, type MoversSortMetric, NORMALIZER_VERSION, type OpportunityFactors, type OpportunityOptions, type OpportunityResult, type OpportunitySortMetric, type OpportunityWeights, type PadTimeseriesOptions, type PageRow, type Plan, type QueriesRow, type QueryPageRow, type QueryRow, type ReduceContext, type ReduceCtx, type Reducer, type ReportContext, type ReportFinding, type ReportPlanStep, type ReportResult, type ReportSection, type RequiredCapability, type ResolveWindowOptions, type ResolvedWindow, type RowQueriesPlan, SEARCH_INTENT_CODE, SITEMAP_SYNC_COLLAPSE_POLICY, SITEMAP_TRUST_COLLAPSE_POLICY, type SearchIntent, type SeasonalityMetric, type SeasonalityOptions, type SeasonalityResult, type SitemapCollapsePolicy, type SitemapCollapseState, type SitemapDelta, type SitemapHealthDiff, type SitemapHealthInput, type SitemapHealthRow, type SitemapHealthTotals, type SortOrder, type SourceCapabilities, type SqlExtraQuery, type SqlPlan, type SqlPlanSpec, type StrikingDistanceInputRow, type StrikingDistanceOptions, type StrikingDistanceResult, TRAJECTORY_DEFAULT_DAYS, type TrajectoryBasis, type TrajectoryCaveat, type TrajectoryClassification, type TrajectoryDay, type TrajectoryRecord, type TrajectoryResult, type TrajectoryWindow, type TypedRowQuery, type WindowPreset, type ZeroClickOptions, type ZeroClickResult, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeTrajectory, analyzeZeroClick, classifyQueryIntent, classifySitemapCollapse, compareCurrentSitemapScope, comparisonOf, createAnalyzerRegistry, createEngineQuerySource, createSorter, defineAnalyzer, diffSitemapHealth, encodeIntent, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, runAnalyzerFromSource, runAnalyzerWithEngine, sitemapHistoryHasCollapse };
|
package/dist/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { MOVERS_SORT_METRICS, analyzeMovers } from "./analyzers/movers.mjs";
|
|
|
8
8
|
import { analyzeOpportunity } from "./analyzers/opportunity.mjs";
|
|
9
9
|
import { analyzeSeasonality } from "./analyzers/seasonality.mjs";
|
|
10
10
|
import { analyzeStrikingDistance } from "./analyzers/striking-distance.mjs";
|
|
11
|
+
import { TRAJECTORY_DEFAULT_DAYS, analyzeTrajectory } from "./analyzers/trajectory.mjs";
|
|
11
12
|
import { analyzeZeroClick } from "./analyzers/zero-click.mjs";
|
|
12
13
|
import { analyzeInBrowser } from "./browser.mjs";
|
|
13
14
|
import { INTENT_CLASSIFIER_VERSION, SEARCH_INTENT_CODE, classifyQueryIntent, encodeIntent } from "./query/intent.mjs";
|
|
@@ -17,4 +18,4 @@ import { num } from "@gscdump/engine/analysis-types";
|
|
|
17
18
|
import { AnalyzerCapabilityError, createAnalyzerRegistry, defineAnalyzer, runAnalyzerFromSource } from "@gscdump/engine/analyzer";
|
|
18
19
|
import { comparisonOf, padTimeseries, periodOf, resolveWindow } from "@gscdump/engine/period";
|
|
19
20
|
import { ENGINE_QUERY_CAPABILITIES, createEngineQuerySource, queryRows, runAnalyzerWithEngine } from "@gscdump/engine/source";
|
|
20
|
-
export { AnalyzerCapabilityError, ENGINE_QUERY_CAPABILITIES, INTENT_CLASSIFIER_VERSION, MOVERS_SORT_METRICS, NORMALIZER_VERSION, SEARCH_INTENT_CODE, SITEMAP_SYNC_COLLAPSE_POLICY, SITEMAP_TRUST_COLLAPSE_POLICY, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeZeroClick, classifyQueryIntent, classifySitemapCollapse, compareCurrentSitemapScope, comparisonOf, createAnalyzerRegistry, createEngineQuerySource, createSorter, defineAnalyzer, diffSitemapHealth, encodeIntent, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, runAnalyzerFromSource, runAnalyzerWithEngine, sitemapHistoryHasCollapse };
|
|
21
|
+
export { AnalyzerCapabilityError, ENGINE_QUERY_CAPABILITIES, INTENT_CLASSIFIER_VERSION, MOVERS_SORT_METRICS, NORMALIZER_VERSION, SEARCH_INTENT_CODE, SITEMAP_SYNC_COLLAPSE_POLICY, SITEMAP_TRUST_COLLAPSE_POLICY, TRAJECTORY_DEFAULT_DAYS, analyzeBrandSegmentation, analyzeCannibalization, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeInBrowser, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, analyzeTrajectory, analyzeZeroClick, classifyQueryIntent, classifySitemapCollapse, compareCurrentSitemapScope, comparisonOf, createAnalyzerRegistry, createEngineQuerySource, createSorter, defineAnalyzer, diffSitemapHealth, encodeIntent, normalizeQuery, num, padTimeseries, periodOf, queryRows, resolveWindow, runAnalyzerFromSource, runAnalyzerWithEngine, sitemapHistoryHasCollapse };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/analysis",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.7.0",
|
|
5
5
|
"description": "GSC analyzers — striking-distance, opportunity, movers, decay, brand, clustering, concentration, seasonality. Pure row-based + DuckDB-native.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"node": ">=22.13.0"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@gscdump/engine": "^4.
|
|
65
|
-
"@gscdump/engine-gsc-api": "^4.
|
|
66
|
-
"gscdump": "^4.
|
|
64
|
+
"@gscdump/engine": "^4.7.0",
|
|
65
|
+
"@gscdump/engine-gsc-api": "^4.7.0",
|
|
66
|
+
"gscdump": "^4.7.0",
|
|
67
67
|
"pluralize": "^8.0.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|