@gscdump/analysis 1.4.0 → 1.4.1
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/action-priority.d.mts +34 -0
- package/dist/action-priority.mjs +201 -0
- package/dist/analyzer/adapt-rows.mjs +12 -0
- package/dist/analyzer/all.mjs +61 -0
- package/dist/analyzer/paginate.mjs +85 -0
- package/dist/analyzer/row-analyzers.d.mts +3 -0
- package/dist/analyzer/row-analyzers.mjs +3 -0
- package/dist/analyzer/row-values.mjs +22 -0
- package/dist/analyzers/bayesian-ctr.mjs +177 -0
- package/dist/analyzers/bipartite-pagerank.mjs +243 -0
- package/dist/analyzers/brand.d.mts +33 -0
- package/dist/analyzers/brand.mjs +154 -0
- package/dist/analyzers/cannibalization.d.mts +58 -0
- package/dist/analyzers/cannibalization.mjs +267 -0
- package/dist/analyzers/change-point.mjs +197 -0
- package/dist/analyzers/clustering.d.mts +29 -0
- package/dist/analyzers/clustering.mjs +217 -0
- package/dist/analyzers/concentration.d.mts +42 -0
- package/dist/analyzers/concentration.mjs +180 -0
- package/dist/analyzers/content-velocity.mjs +87 -0
- package/dist/analyzers/ctr-anomaly.mjs +189 -0
- package/dist/analyzers/ctr-curve.mjs +128 -0
- package/dist/analyzers/dark-traffic.mjs +130 -0
- package/dist/analyzers/data-detail.mjs +44 -0
- package/dist/analyzers/data-query.mjs +44 -0
- package/dist/analyzers/decay.d.mts +38 -0
- package/dist/analyzers/decay.mjs +187 -0
- package/dist/analyzers/device-gap.mjs +135 -0
- package/dist/analyzers/intent-atlas.mjs +175 -0
- package/dist/analyzers/keyword-breadth.mjs +115 -0
- package/dist/analyzers/long-tail.mjs +150 -0
- package/dist/analyzers/movers.d.mts +43 -0
- package/dist/analyzers/movers.mjs +283 -0
- package/dist/analyzers/opportunity.d.mts +32 -0
- package/dist/analyzers/opportunity.mjs +212 -0
- package/dist/analyzers/position-distribution.mjs +57 -0
- package/dist/analyzers/position-volatility.mjs +143 -0
- package/dist/analyzers/query-migration.mjs +178 -0
- package/dist/analyzers/seasonality.d.mts +28 -0
- package/dist/analyzers/seasonality.mjs +136 -0
- package/dist/analyzers/stl-decompose.mjs +187 -0
- package/dist/analyzers/striking-distance.d.mts +54 -0
- package/dist/analyzers/striking-distance.mjs +82 -0
- package/dist/analyzers/survival.mjs +200 -0
- package/dist/analyzers/trends.mjs +156 -0
- package/dist/analyzers/zero-click.d.mts +17 -0
- package/dist/analyzers/zero-click.mjs +122 -0
- package/dist/browser.d.mts +5 -0
- package/dist/browser.mjs +11 -0
- package/dist/default-registry.d.mts +3 -1
- package/dist/default-registry.mjs +5 -1
- package/dist/errors.d.mts +51 -1
- package/dist/index.d.mts +30 -494
- package/dist/index.mjs +28 -49
- package/dist/query/analyzers.mjs +301 -0
- package/dist/query/index.mjs +4 -0
- package/dist/query/intent.d.mts +20 -0
- package/dist/query/intent.mjs +139 -0
- package/dist/query/normalize.d.mts +15 -0
- package/dist/query/normalize.mjs +204 -0
- package/dist/report/format.d.mts +7 -0
- package/dist/report/format.mjs +45 -0
- package/dist/report/index.d.mts +4 -1
- package/dist/report/index.mjs +4 -1
- package/dist/report/registry.d.mts +4 -0
- package/dist/report/registry.mjs +26 -0
- package/dist/report/reports/brand.mjs +104 -0
- package/dist/report/reports/growth.mjs +144 -0
- package/dist/report/reports/health.mjs +141 -0
- package/dist/report/reports/movers.mjs +203 -0
- package/dist/report/reports/opportunities.mjs +184 -0
- package/dist/report/reports/pre-publish.mjs +111 -0
- package/dist/report/reports/priority.mjs +97 -0
- package/dist/report/reports/risks.mjs +201 -0
- package/dist/report/reports/triage.mjs +155 -0
- package/dist/report/require.mjs +15 -0
- package/dist/report/resolve-target.d.mts +29 -0
- package/dist/report/resolve-target.mjs +32 -0
- package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
- package/dist/report/runtime.mjs +80 -0
- package/dist/report/sections.mjs +22 -0
- package/dist/sitemap-health.d.mts +34 -0
- package/dist/sitemap-health.mjs +37 -0
- package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
- package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
- package/dist/source/in-memory.d.mts +14 -0
- package/dist/source/in-memory.mjs +17 -0
- package/dist/source/index.d.mts +2 -1
- package/dist/source/index.mjs +2 -1
- package/dist/sql-analyzers.d.mts +3 -0
- package/dist/sql-analyzers.mjs +3 -0
- package/dist/types.d.mts +34 -0
- package/dist/types.mjs +21 -0
- package/package.json +4 -4
- package/dist/_chunks/default-registry.d.mts +0 -5
- package/dist/_chunks/default-registry.mjs +0 -5183
- package/dist/_chunks/errors.d.mts +0 -52
- package/dist/_chunks/report.mjs +0 -1710
- /package/dist/{_chunks/scoring.mjs → scoring.mjs} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified `striking-distance` analyzer. Spike for the `defineAnalyzer`
|
|
3
|
+
* pattern: one file owns the SQL plan, the row plan, the typed `InputRow`
|
|
4
|
+
* contract, and the shared reducer.
|
|
5
|
+
*
|
|
6
|
+
* SQL and row plans both emit rows matching `StrikingDistanceInputRow`.
|
|
7
|
+
* Filtering (`minPosition`/`maxPosition`/`minImpressions`/`maxCtr`),
|
|
8
|
+
* derivation (`potentialClicks`), and sorting all live in the reducer, so
|
|
9
|
+
* drift between plans cannot silently change results. A SQL-side `LIMIT`
|
|
10
|
+
* push-down can be added later as an optional optimization, but the
|
|
11
|
+
* correctness contract stays with the reducer.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Row shape both plans produce. Field names match GSC's native columns so
|
|
15
|
+
* the row-source path needs no rename step; the SQL plan aliases `url → page`.
|
|
16
|
+
*/
|
|
17
|
+
interface StrikingDistanceInputRow {
|
|
18
|
+
query: string;
|
|
19
|
+
page: string | null;
|
|
20
|
+
clicks: number;
|
|
21
|
+
impressions: number;
|
|
22
|
+
ctr: number;
|
|
23
|
+
position: number;
|
|
24
|
+
}
|
|
25
|
+
interface StrikingDistanceResult {
|
|
26
|
+
keyword: string;
|
|
27
|
+
page: string | null;
|
|
28
|
+
clicks: number;
|
|
29
|
+
impressions: number;
|
|
30
|
+
ctr: number;
|
|
31
|
+
position: number;
|
|
32
|
+
/** Estimated clicks at ~15% CTR (the average for positions 1–3). */
|
|
33
|
+
potentialClicks: number;
|
|
34
|
+
}
|
|
35
|
+
interface StrikingDistanceFilterOptions {
|
|
36
|
+
minPosition?: number;
|
|
37
|
+
maxPosition?: number;
|
|
38
|
+
minImpressions?: number;
|
|
39
|
+
maxCtr?: number;
|
|
40
|
+
}
|
|
41
|
+
interface StrikingDistanceOptions extends StrikingDistanceFilterOptions {
|
|
42
|
+
limit?: number;
|
|
43
|
+
offset?: number;
|
|
44
|
+
}
|
|
45
|
+
/** Pure striking-distance analysis for consumers that already own the rows. */
|
|
46
|
+
declare function analyzeStrikingDistance(rows: readonly {
|
|
47
|
+
query: unknown;
|
|
48
|
+
page?: unknown;
|
|
49
|
+
clicks: unknown;
|
|
50
|
+
impressions: unknown;
|
|
51
|
+
ctr: unknown;
|
|
52
|
+
position: unknown;
|
|
53
|
+
}[], options?: StrikingDistanceOptions): StrikingDistanceResult[];
|
|
54
|
+
export { StrikingDistanceFilterOptions, StrikingDistanceInputRow, StrikingDistanceOptions, StrikingDistanceResult, analyzeStrikingDistance };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
2
|
+
import { paginateSortedInMemory } from "../analyzer/paginate.mjs";
|
|
3
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
4
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
5
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
const DEFAULT_ROW_LIMIT = 25e3;
|
|
8
|
+
function paginateStrikingDistance(results, options) {
|
|
9
|
+
return paginateSortedInMemory(results, {
|
|
10
|
+
limit: options.limit ?? 1e3,
|
|
11
|
+
offset: options.offset
|
|
12
|
+
}, (left, right) => right.potentialClicks - left.potentialClicks);
|
|
13
|
+
}
|
|
14
|
+
function filterStrikingDistance(rows, options = {}) {
|
|
15
|
+
const minPosition = options.minPosition ?? 4;
|
|
16
|
+
const maxPosition = options.maxPosition ?? 20;
|
|
17
|
+
const minImpressions = options.minImpressions ?? 100;
|
|
18
|
+
const maxCtr = options.maxCtr ?? .05;
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
const position = num(row.position);
|
|
22
|
+
const impressions = num(row.impressions);
|
|
23
|
+
const ctr = num(row.ctr);
|
|
24
|
+
const clicks = num(row.clicks);
|
|
25
|
+
if (position < minPosition || position > maxPosition) continue;
|
|
26
|
+
if (impressions < minImpressions) continue;
|
|
27
|
+
if (ctr > maxCtr) continue;
|
|
28
|
+
results.push({
|
|
29
|
+
keyword: String(row.query ?? ""),
|
|
30
|
+
page: row.page == null ? null : String(row.page),
|
|
31
|
+
clicks,
|
|
32
|
+
impressions,
|
|
33
|
+
ctr,
|
|
34
|
+
position,
|
|
35
|
+
potentialClicks: Math.round(impressions * .15)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
function analyzeStrikingDistance(rows, options = {}) {
|
|
41
|
+
return paginateStrikingDistance(filterStrikingDistance(rows, options), options);
|
|
42
|
+
}
|
|
43
|
+
const strikingDistanceAnalyzer = defineAnalyzer({
|
|
44
|
+
id: "striking-distance",
|
|
45
|
+
reduce(rows, params) {
|
|
46
|
+
const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
|
|
47
|
+
const paged = paginateStrikingDistance(results, params);
|
|
48
|
+
return {
|
|
49
|
+
results: paged,
|
|
50
|
+
meta: {
|
|
51
|
+
total: results.length,
|
|
52
|
+
returned: paged.length
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
buildSql(params) {
|
|
57
|
+
const { startDate, endDate } = periodOf(params);
|
|
58
|
+
return {
|
|
59
|
+
sql: `
|
|
60
|
+
SELECT
|
|
61
|
+
query,
|
|
62
|
+
url AS page,
|
|
63
|
+
CAST(SUM(clicks) AS DOUBLE) AS clicks,
|
|
64
|
+
CAST(SUM(impressions) AS DOUBLE) AS impressions,
|
|
65
|
+
CAST(SUM(clicks) AS DOUBLE) / NULLIF(SUM(impressions), 0) AS ctr,
|
|
66
|
+
SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS position
|
|
67
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
68
|
+
WHERE date >= ? AND date <= ?
|
|
69
|
+
GROUP BY query, url
|
|
70
|
+
`,
|
|
71
|
+
params: [startDate, endDate],
|
|
72
|
+
current: {
|
|
73
|
+
table: "page_queries",
|
|
74
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
buildRows(params) {
|
|
79
|
+
return { queries: queriesQueryState(periodOf(params), params.limit ?? DEFAULT_ROW_LIMIT) };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
export { analyzeStrikingDistance, filterStrikingDistance, strikingDistanceAnalyzer };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { defaultEndDate } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
import { MS_PER_DAY, daysAgoUtc } from "gscdump/dates";
|
|
8
|
+
const survivalAnalyzer = defineAnalyzer({
|
|
9
|
+
id: "survival",
|
|
10
|
+
buildSql(params) {
|
|
11
|
+
const endDate = params.endDate ?? defaultEndDate();
|
|
12
|
+
const startDate = params.startDate ?? daysAgoUtc(183);
|
|
13
|
+
const minImpressions = params.minImpressions ?? 5;
|
|
14
|
+
return {
|
|
15
|
+
sql: `
|
|
16
|
+
WITH daily AS (
|
|
17
|
+
SELECT
|
|
18
|
+
query,
|
|
19
|
+
url,
|
|
20
|
+
date,
|
|
21
|
+
${METRIC_EXPR.clicks} AS day_clicks,
|
|
22
|
+
${METRIC_EXPR.impressions} AS day_impressions,
|
|
23
|
+
${METRIC_EXPR.position} AS day_position
|
|
24
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
25
|
+
WHERE date >= ? AND date <= ?
|
|
26
|
+
AND query IS NOT NULL AND query <> ''
|
|
27
|
+
AND url IS NOT NULL AND url <> ''
|
|
28
|
+
GROUP BY query, url, date
|
|
29
|
+
HAVING SUM(impressions) >= ?
|
|
30
|
+
),
|
|
31
|
+
classified AS (
|
|
32
|
+
SELECT *,
|
|
33
|
+
(day_position <= 10) AS in_top10
|
|
34
|
+
FROM daily
|
|
35
|
+
),
|
|
36
|
+
transitions AS (
|
|
37
|
+
SELECT *,
|
|
38
|
+
CASE
|
|
39
|
+
WHEN in_top10 AND (LAG(in_top10) OVER w IS NULL OR NOT LAG(in_top10) OVER w)
|
|
40
|
+
THEN 1 ELSE 0
|
|
41
|
+
END AS is_entry
|
|
42
|
+
FROM classified
|
|
43
|
+
WINDOW w AS (PARTITION BY query, url ORDER BY date)
|
|
44
|
+
),
|
|
45
|
+
run_ids AS (
|
|
46
|
+
SELECT *,
|
|
47
|
+
SUM(is_entry) OVER (PARTITION BY query, url ORDER BY date) AS run_id
|
|
48
|
+
FROM transitions
|
|
49
|
+
WHERE in_top10
|
|
50
|
+
),
|
|
51
|
+
window_bounds AS (
|
|
52
|
+
SELECT MIN(date) AS window_start, MAX(date) AS window_end FROM daily
|
|
53
|
+
),
|
|
54
|
+
episodes_raw AS (
|
|
55
|
+
SELECT
|
|
56
|
+
query, url, run_id,
|
|
57
|
+
MIN(date) AS entry_date,
|
|
58
|
+
MAX(date) AS exit_date,
|
|
59
|
+
DATEDIFF('day', MIN(date), MAX(date)) + 1 AS tenure
|
|
60
|
+
FROM run_ids
|
|
61
|
+
GROUP BY query, url, run_id
|
|
62
|
+
),
|
|
63
|
+
episodes AS (
|
|
64
|
+
SELECT
|
|
65
|
+
e.query, e.url, e.run_id, e.entry_date, e.exit_date, e.tenure,
|
|
66
|
+
(e.exit_date >= wb.window_end - INTERVAL 2 DAY) AS censored,
|
|
67
|
+
CASE
|
|
68
|
+
WHEN regexp_extract(e.url, '^(?:https?://[^/]+)?(/[^/?#]*)', 1) = '/' OR e.url = '/'
|
|
69
|
+
THEN 'home'
|
|
70
|
+
WHEN regexp_extract(e.url, '^(?:https?://[^/]+)?/([^/?#]+)', 1) = ''
|
|
71
|
+
THEN 'home'
|
|
72
|
+
ELSE regexp_extract(e.url, '^(?:https?://[^/]+)?/([^/?#]+)', 1)
|
|
73
|
+
END AS cohort
|
|
74
|
+
FROM episodes_raw e
|
|
75
|
+
CROSS JOIN window_bounds wb
|
|
76
|
+
),
|
|
77
|
+
episodes_all AS (
|
|
78
|
+
SELECT query, url, tenure, censored, cohort FROM episodes
|
|
79
|
+
UNION ALL
|
|
80
|
+
SELECT query, url, tenure, censored, '__all__' AS cohort FROM episodes
|
|
81
|
+
),
|
|
82
|
+
cohort_totals AS (
|
|
83
|
+
SELECT cohort, COUNT(*) AS n_total
|
|
84
|
+
FROM episodes_all
|
|
85
|
+
GROUP BY cohort
|
|
86
|
+
),
|
|
87
|
+
events AS (
|
|
88
|
+
SELECT
|
|
89
|
+
cohort,
|
|
90
|
+
tenure,
|
|
91
|
+
COUNT(*) FILTER (WHERE NOT censored) AS d_t,
|
|
92
|
+
COUNT(*) AS n_ending_at_t
|
|
93
|
+
FROM episodes_all
|
|
94
|
+
GROUP BY cohort, tenure
|
|
95
|
+
),
|
|
96
|
+
km AS (
|
|
97
|
+
SELECT
|
|
98
|
+
e.cohort,
|
|
99
|
+
e.tenure,
|
|
100
|
+
e.d_t,
|
|
101
|
+
e.n_ending_at_t,
|
|
102
|
+
SUM(e.n_ending_at_t) OVER (PARTITION BY e.cohort ORDER BY e.tenure DESC
|
|
103
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS at_risk
|
|
104
|
+
FROM events e
|
|
105
|
+
),
|
|
106
|
+
km_surv AS (
|
|
107
|
+
SELECT
|
|
108
|
+
cohort, tenure, d_t, at_risk,
|
|
109
|
+
EXP(SUM(LN(GREATEST(1.0 - CAST(d_t AS DOUBLE) / NULLIF(at_risk, 0), 1e-9)))
|
|
110
|
+
OVER (PARTITION BY cohort ORDER BY tenure
|
|
111
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS survival
|
|
112
|
+
FROM km
|
|
113
|
+
),
|
|
114
|
+
curve_agg AS (
|
|
115
|
+
SELECT
|
|
116
|
+
cohort,
|
|
117
|
+
to_json(list({
|
|
118
|
+
'tenure': tenure,
|
|
119
|
+
'survival': survival,
|
|
120
|
+
'atRisk': at_risk,
|
|
121
|
+
'events': d_t
|
|
122
|
+
} ORDER BY tenure)) AS curveJson
|
|
123
|
+
FROM km_surv
|
|
124
|
+
GROUP BY cohort
|
|
125
|
+
),
|
|
126
|
+
cohort_stats AS (
|
|
127
|
+
SELECT
|
|
128
|
+
ea.cohort,
|
|
129
|
+
COUNT(*) AS episode_count,
|
|
130
|
+
AVG(CASE WHEN ea.censored THEN 1.0 ELSE 0.0 END) AS censoring_rate
|
|
131
|
+
FROM episodes_all ea
|
|
132
|
+
GROUP BY ea.cohort
|
|
133
|
+
)
|
|
134
|
+
SELECT
|
|
135
|
+
cs.cohort,
|
|
136
|
+
cs.episode_count AS episodeCount,
|
|
137
|
+
cs.censoring_rate AS censoringRate,
|
|
138
|
+
ca.curveJson
|
|
139
|
+
FROM cohort_stats cs
|
|
140
|
+
LEFT JOIN curve_agg ca USING (cohort)
|
|
141
|
+
ORDER BY cs.cohort
|
|
142
|
+
`,
|
|
143
|
+
params: [
|
|
144
|
+
startDate,
|
|
145
|
+
endDate,
|
|
146
|
+
minImpressions
|
|
147
|
+
],
|
|
148
|
+
current: {
|
|
149
|
+
table: "page_queries",
|
|
150
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
reduceSql(rows, params) {
|
|
155
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
156
|
+
const endDate = params.endDate ?? defaultEndDate();
|
|
157
|
+
const startDate = params.startDate ?? daysAgoUtc(183);
|
|
158
|
+
const windowDays = Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / MS_PER_DAY) + 1;
|
|
159
|
+
const results = arr.map((r) => {
|
|
160
|
+
const curve = parseJsonRows(r.curveJson).map((p) => ({
|
|
161
|
+
tenure: num(p.tenure),
|
|
162
|
+
survival: num(p.survival),
|
|
163
|
+
atRisk: num(p.atRisk),
|
|
164
|
+
events: num(p.events)
|
|
165
|
+
}));
|
|
166
|
+
let medianTenure = 0;
|
|
167
|
+
for (let i = 0; i < curve.length; i++) {
|
|
168
|
+
const cur = curve[i];
|
|
169
|
+
if (cur.survival <= .5) {
|
|
170
|
+
if (i === 0) medianTenure = cur.tenure;
|
|
171
|
+
else {
|
|
172
|
+
const prev = curve[i - 1];
|
|
173
|
+
const span = prev.survival - cur.survival;
|
|
174
|
+
const frac = span > 0 ? (prev.survival - .5) / span : 0;
|
|
175
|
+
medianTenure = prev.tenure + frac * (cur.tenure - prev.tenure);
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const last = curve[curve.length - 1];
|
|
181
|
+
if (medianTenure === 0 && last && last.survival > .5) medianTenure = last.tenure;
|
|
182
|
+
return {
|
|
183
|
+
cohort: rowString(r.cohort),
|
|
184
|
+
episodeCount: num(r.episodeCount),
|
|
185
|
+
censoringRate: num(r.censoringRate),
|
|
186
|
+
medianTenure,
|
|
187
|
+
curve
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
results,
|
|
192
|
+
meta: {
|
|
193
|
+
totalEpisodes: results.find((r) => r.cohort === "__all__")?.episodeCount ?? 0,
|
|
194
|
+
cohortCount: results.filter((r) => r.cohort !== "__all__").length,
|
|
195
|
+
windowDays
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
export { survivalAnalyzer };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { defaultEndDate } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
import { MS_PER_DAY, toIsoDate } from "gscdump/dates";
|
|
8
|
+
const trendsAnalyzer = defineAnalyzer({
|
|
9
|
+
id: "trends",
|
|
10
|
+
buildSql(params) {
|
|
11
|
+
const weeks = params.weeks ?? 28;
|
|
12
|
+
const endDate = params.endDate || defaultEndDate();
|
|
13
|
+
const startDate = params.startDate || toIsoDate(/* @__PURE__ */ new Date(Date.parse(endDate) - (weeks * 7 - 1) * MS_PER_DAY));
|
|
14
|
+
const minImpressions = params.minImpressions ?? 100;
|
|
15
|
+
const minWeeksWithData = params.minWeeksWithData ?? Math.max(2, Math.floor(weeks / 4));
|
|
16
|
+
const limit = params.limit ?? 500;
|
|
17
|
+
const dim = params.dimension === "keywords" ? "keywords" : "pages";
|
|
18
|
+
const table = dim === "keywords" ? "queries" : "pages";
|
|
19
|
+
return {
|
|
20
|
+
sql: `
|
|
21
|
+
WITH bucketed AS (
|
|
22
|
+
SELECT
|
|
23
|
+
${dim === "keywords" ? "query" : "url"} AS entity,
|
|
24
|
+
date_trunc('week', CAST(date AS DATE)) AS week,
|
|
25
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
26
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
27
|
+
SUM(sum_position) AS sum_position_sum
|
|
28
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
29
|
+
WHERE date >= ? AND date <= ?
|
|
30
|
+
GROUP BY entity, week
|
|
31
|
+
),
|
|
32
|
+
with_meta AS (
|
|
33
|
+
SELECT
|
|
34
|
+
entity, week, clicks, impressions, sum_position_sum,
|
|
35
|
+
ROW_NUMBER() OVER (PARTITION BY entity ORDER BY week) - 1 AS week_idx,
|
|
36
|
+
COUNT(*) OVER (PARTITION BY entity) AS n_weeks,
|
|
37
|
+
(ROW_NUMBER() OVER (PARTITION BY entity ORDER BY week) - 1)
|
|
38
|
+
< (COUNT(*) OVER (PARTITION BY entity) / 2) AS is_first_half
|
|
39
|
+
FROM bucketed
|
|
40
|
+
),
|
|
41
|
+
agg AS (
|
|
42
|
+
SELECT
|
|
43
|
+
entity,
|
|
44
|
+
SUM(clicks) AS totalClicks,
|
|
45
|
+
SUM(impressions) AS totalImpressions,
|
|
46
|
+
any_value(n_weeks) AS weeksWithData,
|
|
47
|
+
COALESCE(regr_slope(clicks, CAST(week_idx AS DOUBLE)), 0.0) AS slope,
|
|
48
|
+
SUM(CASE WHEN is_first_half THEN clicks ELSE 0 END) AS firstHalfClicks,
|
|
49
|
+
SUM(CASE WHEN NOT is_first_half THEN clicks ELSE 0 END) AS secondHalfClicks,
|
|
50
|
+
SUM(sum_position_sum) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
|
|
51
|
+
to_json(list({
|
|
52
|
+
'week': strftime(week, '%Y-%m-%d'),
|
|
53
|
+
'clicks': clicks,
|
|
54
|
+
'impressions': impressions
|
|
55
|
+
} ORDER BY week)) AS seriesJson
|
|
56
|
+
FROM with_meta
|
|
57
|
+
GROUP BY entity
|
|
58
|
+
HAVING SUM(impressions) >= ? AND any_value(n_weeks) >= ?
|
|
59
|
+
),
|
|
60
|
+
classified AS (
|
|
61
|
+
SELECT
|
|
62
|
+
*,
|
|
63
|
+
CASE
|
|
64
|
+
WHEN firstHalfClicks = 0 AND secondHalfClicks > 0 THEN 10.0
|
|
65
|
+
WHEN firstHalfClicks = 0 THEN 1.0
|
|
66
|
+
ELSE secondHalfClicks / firstHalfClicks
|
|
67
|
+
END AS growthRatio
|
|
68
|
+
FROM agg
|
|
69
|
+
)
|
|
70
|
+
SELECT
|
|
71
|
+
entity,
|
|
72
|
+
totalClicks,
|
|
73
|
+
totalImpressions,
|
|
74
|
+
weeksWithData,
|
|
75
|
+
slope,
|
|
76
|
+
growthRatio,
|
|
77
|
+
avgPosition,
|
|
78
|
+
CASE
|
|
79
|
+
WHEN growthRatio >= 1.5 AND slope > 0 THEN 'accelerating'
|
|
80
|
+
WHEN growthRatio >= 1.1 AND slope >= 0 THEN 'growing'
|
|
81
|
+
WHEN growthRatio < 0.5 THEN 'cratering'
|
|
82
|
+
WHEN growthRatio < 0.9 AND slope < 0 THEN 'declining'
|
|
83
|
+
ELSE 'steady'
|
|
84
|
+
END AS trend,
|
|
85
|
+
seriesJson
|
|
86
|
+
FROM classified
|
|
87
|
+
ORDER BY
|
|
88
|
+
CASE
|
|
89
|
+
WHEN growthRatio >= 1.5 AND slope > 0 THEN 0
|
|
90
|
+
WHEN growthRatio < 0.5 THEN 1
|
|
91
|
+
WHEN growthRatio >= 1.1 AND slope >= 0 THEN 2
|
|
92
|
+
WHEN growthRatio < 0.9 AND slope < 0 THEN 3
|
|
93
|
+
ELSE 4
|
|
94
|
+
END,
|
|
95
|
+
ABS(growthRatio - 1) DESC,
|
|
96
|
+
totalClicks DESC
|
|
97
|
+
LIMIT ${Number(limit)}
|
|
98
|
+
`,
|
|
99
|
+
params: [
|
|
100
|
+
startDate,
|
|
101
|
+
endDate,
|
|
102
|
+
minImpressions,
|
|
103
|
+
minWeeksWithData
|
|
104
|
+
],
|
|
105
|
+
current: {
|
|
106
|
+
table,
|
|
107
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
reduceSql(rows, params) {
|
|
112
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
113
|
+
const weeks = params.weeks ?? 28;
|
|
114
|
+
const endDate = params.endDate || defaultEndDate();
|
|
115
|
+
const startDate = params.startDate || toIsoDate(/* @__PURE__ */ new Date(Date.parse(endDate) - (weeks * 7 - 1) * MS_PER_DAY));
|
|
116
|
+
const dim = params.dimension === "keywords" ? "keywords" : "pages";
|
|
117
|
+
const results = arr.map((r) => {
|
|
118
|
+
const series = parseJsonRows(r.seriesJson).map((s) => ({
|
|
119
|
+
week: rowString(s.week),
|
|
120
|
+
clicks: num(s.clicks),
|
|
121
|
+
impressions: num(s.impressions)
|
|
122
|
+
}));
|
|
123
|
+
return {
|
|
124
|
+
[dim === "keywords" ? "query" : "page"]: rowString(r.entity),
|
|
125
|
+
totalClicks: num(r.totalClicks),
|
|
126
|
+
totalImpressions: num(r.totalImpressions),
|
|
127
|
+
weeksWithData: num(r.weeksWithData),
|
|
128
|
+
slope: num(r.slope),
|
|
129
|
+
growthRatio: num(r.growthRatio),
|
|
130
|
+
avgPosition: num(r.avgPosition),
|
|
131
|
+
trend: rowString(r.trend),
|
|
132
|
+
series
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
const counts = {
|
|
136
|
+
accelerating: 0,
|
|
137
|
+
growing: 0,
|
|
138
|
+
steady: 0,
|
|
139
|
+
declining: 0,
|
|
140
|
+
cratering: 0
|
|
141
|
+
};
|
|
142
|
+
for (const r of results) counts[r.trend] = (counts[r.trend] ?? 0) + 1;
|
|
143
|
+
return {
|
|
144
|
+
results,
|
|
145
|
+
meta: {
|
|
146
|
+
total: results.length,
|
|
147
|
+
dimension: dim,
|
|
148
|
+
weeks: Number(weeks),
|
|
149
|
+
startDate,
|
|
150
|
+
endDate,
|
|
151
|
+
counts
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
export { trendsAnalyzer };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { QueryPageRow } from "../types.mjs";
|
|
2
|
+
interface ZeroClickResult {
|
|
3
|
+
query: string;
|
|
4
|
+
page: string;
|
|
5
|
+
clicks: number;
|
|
6
|
+
impressions: number;
|
|
7
|
+
ctr: number;
|
|
8
|
+
position: number;
|
|
9
|
+
}
|
|
10
|
+
interface ZeroClickOptions {
|
|
11
|
+
minImpressions?: number;
|
|
12
|
+
maxCtr?: number;
|
|
13
|
+
maxPosition?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Pure zero-click detector shared by hosted and package analyzer callers. */
|
|
16
|
+
declare function analyzeZeroClick(rows: QueryPageRow[], options?: ZeroClickOptions): ZeroClickResult[];
|
|
17
|
+
export { ZeroClickOptions, ZeroClickResult, analyzeZeroClick };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { paginateClause, paginateSortedInMemory } from "../analyzer/paginate.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
6
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
7
|
+
import { between, date, gsc, page, query } from "gscdump/query";
|
|
8
|
+
const DEFAULT_ROW_LIMIT = 25e3;
|
|
9
|
+
function analyzeZeroClick(rows, options = {}) {
|
|
10
|
+
const minImpressions = options.minImpressions ?? 1e3;
|
|
11
|
+
const maxCtr = options.maxCtr ?? .03;
|
|
12
|
+
const maxPosition = options.maxPosition ?? 10;
|
|
13
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
|
|
16
|
+
const existing = queryMap.get(row.query);
|
|
17
|
+
if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
|
|
18
|
+
}
|
|
19
|
+
return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
|
|
20
|
+
}
|
|
21
|
+
const zeroClickAnalyzer = defineAnalyzer({
|
|
22
|
+
id: "zero-click",
|
|
23
|
+
buildSql(params) {
|
|
24
|
+
const { startDate, endDate } = periodOf(params);
|
|
25
|
+
const minImpressions = params.minImpressions ?? 1e3;
|
|
26
|
+
const maxCtr = params.maxCtr ?? .03;
|
|
27
|
+
const maxPosition = params.maxPosition ?? 10;
|
|
28
|
+
const limit = params.limit ?? 1e3;
|
|
29
|
+
return {
|
|
30
|
+
sql: `
|
|
31
|
+
WITH agg AS (
|
|
32
|
+
SELECT
|
|
33
|
+
query,
|
|
34
|
+
url AS page,
|
|
35
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
36
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
37
|
+
${METRIC_EXPR.ctr} AS ctr,
|
|
38
|
+
${METRIC_EXPR.position} AS position
|
|
39
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
40
|
+
WHERE date >= ? AND date <= ?
|
|
41
|
+
GROUP BY query, url
|
|
42
|
+
HAVING SUM(impressions) >= ?
|
|
43
|
+
)
|
|
44
|
+
SELECT
|
|
45
|
+
query, page, clicks, impressions, ctr, position,
|
|
46
|
+
CAST(GREATEST(0, ROUND(impressions * (
|
|
47
|
+
CASE
|
|
48
|
+
WHEN position <= 1 THEN 0.30
|
|
49
|
+
WHEN position <= 3 THEN 0.15
|
|
50
|
+
WHEN position <= 5 THEN 0.08
|
|
51
|
+
ELSE 0.04
|
|
52
|
+
END
|
|
53
|
+
)) - clicks) AS DOUBLE) AS missedClicks
|
|
54
|
+
FROM agg
|
|
55
|
+
WHERE position <= ? AND ctr < ?
|
|
56
|
+
ORDER BY impressions DESC
|
|
57
|
+
${paginateClause({
|
|
58
|
+
limit,
|
|
59
|
+
offset: params.offset
|
|
60
|
+
})}
|
|
61
|
+
`,
|
|
62
|
+
params: [
|
|
63
|
+
startDate,
|
|
64
|
+
endDate,
|
|
65
|
+
minImpressions,
|
|
66
|
+
maxPosition,
|
|
67
|
+
maxCtr
|
|
68
|
+
],
|
|
69
|
+
current: {
|
|
70
|
+
table: "page_queries",
|
|
71
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
reduceSql(rows, params) {
|
|
76
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
77
|
+
const minImpressions = params.minImpressions ?? 1e3;
|
|
78
|
+
const maxCtr = params.maxCtr ?? .03;
|
|
79
|
+
const maxPosition = params.maxPosition ?? 10;
|
|
80
|
+
return {
|
|
81
|
+
results: arr.map((r) => ({
|
|
82
|
+
query: r.query == null ? "" : String(r.query),
|
|
83
|
+
page: r.page == null ? "" : String(r.page),
|
|
84
|
+
clicks: num(r.clicks),
|
|
85
|
+
impressions: num(r.impressions),
|
|
86
|
+
ctr: num(r.ctr),
|
|
87
|
+
position: num(r.position),
|
|
88
|
+
missedClicks: num(r.missedClicks)
|
|
89
|
+
})),
|
|
90
|
+
meta: {
|
|
91
|
+
total: arr.length,
|
|
92
|
+
minImpressions,
|
|
93
|
+
maxCtr,
|
|
94
|
+
maxPosition
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
buildRows(params) {
|
|
99
|
+
const period = periodOf(params);
|
|
100
|
+
const limit = params.limit ?? DEFAULT_ROW_LIMIT;
|
|
101
|
+
return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
|
|
102
|
+
},
|
|
103
|
+
reduceRows(rows, params) {
|
|
104
|
+
const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
|
|
105
|
+
minImpressions: params.minImpressions ?? 1e3,
|
|
106
|
+
maxCtr: params.maxCtr ?? .03,
|
|
107
|
+
maxPosition: params.maxPosition ?? 10
|
|
108
|
+
});
|
|
109
|
+
const paged = paginateSortedInMemory(results, {
|
|
110
|
+
limit: params.limit,
|
|
111
|
+
offset: params.offset
|
|
112
|
+
}, (left, right) => right.impressions - left.impressions);
|
|
113
|
+
return {
|
|
114
|
+
results: paged,
|
|
115
|
+
meta: {
|
|
116
|
+
total: results.length,
|
|
117
|
+
returned: paged.length
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
export { analyzeZeroClick, zeroClickAnalyzer };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AnalyzerRegistry } from "@gscdump/engine/analyzer";
|
|
2
|
+
import { AnalysisParams, AnalysisResult } from "@gscdump/engine/analysis-types";
|
|
3
|
+
import { AttachedTableRunner, AttachedTableRunner as AnalyzerRunner, AttachedTableSourceOptions, AttachedTableSourceOptions as BrowserAnalyzeOptions } from "@gscdump/engine/source";
|
|
4
|
+
declare function analyzeInBrowser(runner: AttachedTableRunner, opts: AttachedTableSourceOptions, params: AnalysisParams, registry: AnalyzerRegistry): Promise<AnalysisResult>;
|
|
5
|
+
export { type AnalyzerRunner, type BrowserAnalyzeOptions, analyzeInBrowser };
|
package/dist/browser.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runAnalyzerFromSource } from "@gscdump/engine/analyzer";
|
|
2
|
+
import { pgResolverAdapter } from "@gscdump/engine/resolver";
|
|
3
|
+
import { createAttachedTableSource } from "@gscdump/engine/source";
|
|
4
|
+
async function analyzeInBrowser(runner, opts, params, registry) {
|
|
5
|
+
opts.signal?.throwIfAborted();
|
|
6
|
+
return runAnalyzerFromSource(createAttachedTableSource(runner, {
|
|
7
|
+
adapter: pgResolverAdapter,
|
|
8
|
+
...opts
|
|
9
|
+
}), params, registry);
|
|
10
|
+
}
|
|
11
|
+
export { analyzeInBrowser };
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
import { ROW_ANALYZERS
|
|
1
|
+
import { ROW_ANALYZERS } from "./analyzer/row-analyzers.mjs";
|
|
2
|
+
import { SQL_ANALYZERS } from "./sql-analyzers.mjs";
|
|
3
|
+
declare const defaultAnalyzerRegistry: import("@gscdump/engine/analyzer").AnalyzerRegistry;
|
|
2
4
|
export { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry };
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ALL_ANALYZERS } from "./analyzer/all.mjs";
|
|
2
|
+
import { ROW_ANALYZERS } from "./analyzer/row-analyzers.mjs";
|
|
3
|
+
import { SQL_ANALYZERS } from "./sql-analyzers.mjs";
|
|
4
|
+
import { createAnalyzerRegistry } from "@gscdump/engine/analyzer";
|
|
5
|
+
const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: ALL_ANALYZERS });
|
|
2
6
|
export { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry };
|