@gscdump/analysis 1.4.0 → 1.4.2
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,243 @@
|
|
|
1
|
+
import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
3
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
4
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
5
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
6
|
+
const BIPARTITE_PAGERANK_ITERATIONS = 25;
|
|
7
|
+
const BIPARTITE_PAGERANK_DAMPING = .85;
|
|
8
|
+
const bipartitePagerankAnalyzer = defineAnalyzer({
|
|
9
|
+
id: "bipartite-pagerank",
|
|
10
|
+
buildSql(params) {
|
|
11
|
+
const { startDate, endDate } = periodOf(params);
|
|
12
|
+
const minImpressions = params.minImpressions ?? 50;
|
|
13
|
+
const topQueries = 1e3;
|
|
14
|
+
const topUrls = 500;
|
|
15
|
+
const limit = params.limit ?? 50;
|
|
16
|
+
const bridgingEdgeThreshold = .05;
|
|
17
|
+
const anchoringEdgeThreshold = .05;
|
|
18
|
+
const iterations = BIPARTITE_PAGERANK_ITERATIONS;
|
|
19
|
+
const d = BIPARTITE_PAGERANK_DAMPING;
|
|
20
|
+
const iterCtes = [];
|
|
21
|
+
for (let i = 1; i <= iterations; i++) iterCtes.push(`
|
|
22
|
+
ranks_${i} AS (
|
|
23
|
+
SELECT
|
|
24
|
+
'q' AS kind,
|
|
25
|
+
e.qid AS id,
|
|
26
|
+
(1.0 - ${d}) / (SELECT n FROM query_count)
|
|
27
|
+
+ ${d} * SUM(e.w_u_to_q * r.rank) AS rank
|
|
28
|
+
FROM u_to_q_weights e
|
|
29
|
+
JOIN ranks_${i - 1} r ON r.kind = 'u' AND r.id = e.uid
|
|
30
|
+
GROUP BY e.qid
|
|
31
|
+
UNION ALL
|
|
32
|
+
SELECT
|
|
33
|
+
'u' AS kind,
|
|
34
|
+
e.uid AS id,
|
|
35
|
+
(1.0 - ${d}) / (SELECT n FROM url_count)
|
|
36
|
+
+ ${d} * SUM(e.w_q_to_u * r.rank) AS rank
|
|
37
|
+
FROM q_to_u_weights e
|
|
38
|
+
JOIN ranks_${i - 1} r ON r.kind = 'q' AND r.id = e.qid
|
|
39
|
+
GROUP BY e.uid
|
|
40
|
+
)`);
|
|
41
|
+
const deltaParts = [];
|
|
42
|
+
for (let i = 1; i <= iterations; i++) deltaParts.push(`
|
|
43
|
+
SELECT ${i} AS step,
|
|
44
|
+
(SELECT COALESCE(SUM(ABS(a.rank - b.rank)), 0.0)
|
|
45
|
+
FROM ranks_${i} a
|
|
46
|
+
JOIN ranks_${i - 1} b USING (kind, id)) AS l1`);
|
|
47
|
+
return {
|
|
48
|
+
sql: `
|
|
49
|
+
WITH edges0 AS (
|
|
50
|
+
SELECT
|
|
51
|
+
query AS qid,
|
|
52
|
+
url AS uid,
|
|
53
|
+
CAST(SUM(impressions) AS DOUBLE) AS impressions
|
|
54
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
55
|
+
WHERE date >= ? AND date <= ?
|
|
56
|
+
AND query IS NOT NULL AND query <> ''
|
|
57
|
+
AND url IS NOT NULL AND url <> ''
|
|
58
|
+
GROUP BY query, url
|
|
59
|
+
HAVING SUM(impressions) >= ?
|
|
60
|
+
),
|
|
61
|
+
-- Top-N caps per side keep the iteration tractable.
|
|
62
|
+
query_totals AS (
|
|
63
|
+
SELECT qid, SUM(impressions) AS tot
|
|
64
|
+
FROM edges0 GROUP BY qid
|
|
65
|
+
),
|
|
66
|
+
url_totals AS (
|
|
67
|
+
SELECT uid, SUM(impressions) AS tot
|
|
68
|
+
FROM edges0 GROUP BY uid
|
|
69
|
+
),
|
|
70
|
+
top_queries AS (
|
|
71
|
+
SELECT qid FROM query_totals
|
|
72
|
+
ORDER BY tot DESC, qid ASC LIMIT ${Number(topQueries)}
|
|
73
|
+
),
|
|
74
|
+
top_urls AS (
|
|
75
|
+
SELECT uid FROM url_totals
|
|
76
|
+
ORDER BY tot DESC, uid ASC LIMIT ${Number(topUrls)}
|
|
77
|
+
),
|
|
78
|
+
edges AS (
|
|
79
|
+
SELECT e.qid, e.uid, e.impressions
|
|
80
|
+
FROM edges0 e
|
|
81
|
+
JOIN top_queries tq USING (qid)
|
|
82
|
+
JOIN top_urls tu USING (uid)
|
|
83
|
+
),
|
|
84
|
+
query_nodes AS (SELECT DISTINCT qid FROM edges),
|
|
85
|
+
url_nodes AS (SELECT DISTINCT uid FROM edges),
|
|
86
|
+
query_count AS (SELECT GREATEST(COUNT(*), 1) AS n FROM query_nodes),
|
|
87
|
+
url_count AS (SELECT GREATEST(COUNT(*), 1) AS n FROM url_nodes),
|
|
88
|
+
-- Row-stochastic transition weights in each direction. For q->u the
|
|
89
|
+
-- weights out of a query sum to 1; symmetric for u->q.
|
|
90
|
+
q_out AS (SELECT qid, SUM(impressions) AS s FROM edges GROUP BY qid),
|
|
91
|
+
u_out AS (SELECT uid, SUM(impressions) AS s FROM edges GROUP BY uid),
|
|
92
|
+
q_to_u_weights AS (
|
|
93
|
+
SELECT e.qid, e.uid,
|
|
94
|
+
e.impressions / NULLIF(q.s, 0) AS w_q_to_u
|
|
95
|
+
FROM edges e JOIN q_out q USING (qid)
|
|
96
|
+
),
|
|
97
|
+
u_to_q_weights AS (
|
|
98
|
+
SELECT e.qid, e.uid,
|
|
99
|
+
e.impressions / NULLIF(u.s, 0) AS w_u_to_q
|
|
100
|
+
FROM edges e JOIN u_out u USING (uid)
|
|
101
|
+
),
|
|
102
|
+
-- Seed: uniform distribution per side. Total mass = 2 (one unit per side).
|
|
103
|
+
ranks_0 AS (
|
|
104
|
+
SELECT 'q' AS kind, q.qid AS id, 1.0 / (SELECT n FROM query_count) AS rank
|
|
105
|
+
FROM query_nodes q
|
|
106
|
+
UNION ALL
|
|
107
|
+
SELECT 'u' AS kind, u.uid AS id, 1.0 / (SELECT n FROM url_count) AS rank
|
|
108
|
+
FROM url_nodes u
|
|
109
|
+
),
|
|
110
|
+
${iterCtes.join(",\n")},
|
|
111
|
+
final_ranks AS (SELECT * FROM ranks_${iterations}),
|
|
112
|
+
-- Hub/anchor diagnostics computed from raw edge mass (not rank). A
|
|
113
|
+
-- query "bridges" URLs it sends >= ${bridgingEdgeThreshold} of its mass
|
|
114
|
+
-- to; a URL "anchors" queries that contribute >= ${anchoringEdgeThreshold}
|
|
115
|
+
-- of its incoming mass.
|
|
116
|
+
q_bridging AS (
|
|
117
|
+
SELECT qid, COUNT(*) AS bridging
|
|
118
|
+
FROM q_to_u_weights
|
|
119
|
+
WHERE w_q_to_u >= ${bridgingEdgeThreshold}
|
|
120
|
+
GROUP BY qid
|
|
121
|
+
),
|
|
122
|
+
u_anchoring AS (
|
|
123
|
+
SELECT uid, COUNT(*) AS anchoring
|
|
124
|
+
FROM u_to_q_weights
|
|
125
|
+
WHERE w_u_to_q >= ${anchoringEdgeThreshold}
|
|
126
|
+
GROUP BY uid
|
|
127
|
+
),
|
|
128
|
+
q_degree AS (
|
|
129
|
+
SELECT qid, COUNT(*) AS degree, SUM(impressions) AS impressions
|
|
130
|
+
FROM edges GROUP BY qid
|
|
131
|
+
),
|
|
132
|
+
u_degree AS (
|
|
133
|
+
SELECT uid, COUNT(*) AS degree, SUM(impressions) AS impressions
|
|
134
|
+
FROM edges GROUP BY uid
|
|
135
|
+
),
|
|
136
|
+
deltas AS (
|
|
137
|
+
${deltaParts.join("\n UNION ALL\n")}
|
|
138
|
+
),
|
|
139
|
+
query_rows AS (
|
|
140
|
+
SELECT
|
|
141
|
+
'query' AS kind, f.id, f.rank,
|
|
142
|
+
COALESCE(b.bridging, 0) AS bridging,
|
|
143
|
+
0 AS anchoring,
|
|
144
|
+
COALESCE(qd.degree, 0) AS degree,
|
|
145
|
+
COALESCE(qd.impressions, 0) AS impressions
|
|
146
|
+
FROM final_ranks f
|
|
147
|
+
LEFT JOIN q_bridging b ON b.qid = f.id
|
|
148
|
+
LEFT JOIN q_degree qd ON qd.qid = f.id
|
|
149
|
+
WHERE f.kind = 'q'
|
|
150
|
+
ORDER BY f.rank DESC
|
|
151
|
+
LIMIT ${Number(limit)}
|
|
152
|
+
),
|
|
153
|
+
url_rows AS (
|
|
154
|
+
SELECT
|
|
155
|
+
'url' AS kind, f.id, f.rank,
|
|
156
|
+
0 AS bridging,
|
|
157
|
+
COALESCE(a.anchoring, 0) AS anchoring,
|
|
158
|
+
COALESCE(ud.degree, 0) AS degree,
|
|
159
|
+
COALESCE(ud.impressions, 0) AS impressions
|
|
160
|
+
FROM final_ranks f
|
|
161
|
+
LEFT JOIN u_anchoring a ON a.uid = f.id
|
|
162
|
+
LEFT JOIN u_degree ud ON ud.uid = f.id
|
|
163
|
+
WHERE f.kind = 'u'
|
|
164
|
+
ORDER BY f.rank DESC
|
|
165
|
+
LIMIT ${Number(limit)}
|
|
166
|
+
),
|
|
167
|
+
nodes AS (
|
|
168
|
+
SELECT * FROM query_rows
|
|
169
|
+
UNION ALL
|
|
170
|
+
SELECT * FROM url_rows
|
|
171
|
+
),
|
|
172
|
+
counts AS (
|
|
173
|
+
SELECT
|
|
174
|
+
(SELECT n FROM query_count) AS q_count,
|
|
175
|
+
(SELECT n FROM url_count) AS u_count
|
|
176
|
+
),
|
|
177
|
+
deltas_json AS (
|
|
178
|
+
SELECT to_json(list({ 'step': step, 'l1': l1 } ORDER BY step)) AS dj
|
|
179
|
+
FROM deltas
|
|
180
|
+
)
|
|
181
|
+
SELECT
|
|
182
|
+
n.kind,
|
|
183
|
+
n.id,
|
|
184
|
+
n.rank,
|
|
185
|
+
n.bridging,
|
|
186
|
+
n.anchoring,
|
|
187
|
+
n.degree,
|
|
188
|
+
n.impressions,
|
|
189
|
+
c.q_count AS queryCount,
|
|
190
|
+
c.u_count AS urlCount,
|
|
191
|
+
dj.dj AS deltasJson
|
|
192
|
+
FROM nodes n
|
|
193
|
+
CROSS JOIN counts c
|
|
194
|
+
CROSS JOIN deltas_json dj
|
|
195
|
+
ORDER BY n.kind, n.rank DESC
|
|
196
|
+
`,
|
|
197
|
+
params: [
|
|
198
|
+
startDate,
|
|
199
|
+
endDate,
|
|
200
|
+
minImpressions
|
|
201
|
+
],
|
|
202
|
+
current: {
|
|
203
|
+
table: "page_queries",
|
|
204
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
reduceSql(rows) {
|
|
209
|
+
const arr = Array.isArray(rows) ? rows : [];
|
|
210
|
+
const iterations = BIPARTITE_PAGERANK_ITERATIONS;
|
|
211
|
+
const d = BIPARTITE_PAGERANK_DAMPING;
|
|
212
|
+
const results = arr.map((r) => ({
|
|
213
|
+
kind: rowString(r.kind),
|
|
214
|
+
id: rowString(r.id),
|
|
215
|
+
rank: num(r.rank),
|
|
216
|
+
bridging: num(r.bridging),
|
|
217
|
+
anchoring: num(r.anchoring),
|
|
218
|
+
degree: num(r.degree),
|
|
219
|
+
impressions: num(r.impressions)
|
|
220
|
+
}));
|
|
221
|
+
const first = arr[0] ?? {};
|
|
222
|
+
const queryCount = num(first.queryCount);
|
|
223
|
+
const urlCount = num(first.urlCount);
|
|
224
|
+
const deltas = parseJsonRows(first.deltasJson).map((e) => ({
|
|
225
|
+
step: num(e.step),
|
|
226
|
+
l1: num(e.l1)
|
|
227
|
+
}));
|
|
228
|
+
const convergenceDelta = deltas.length > 0 ? deltas[deltas.length - 1].l1 : 0;
|
|
229
|
+
return {
|
|
230
|
+
results,
|
|
231
|
+
meta: {
|
|
232
|
+
total: results.length,
|
|
233
|
+
convergenceDelta,
|
|
234
|
+
iterations,
|
|
235
|
+
damping: d,
|
|
236
|
+
queryCount,
|
|
237
|
+
urlCount,
|
|
238
|
+
deltas
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
export { bipartitePagerankAnalyzer };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { QueriesRow } from "../types.mjs";
|
|
2
|
+
interface BrandSegmentationOptions {
|
|
3
|
+
/** Brand terms to match against keywords (case-insensitive) */
|
|
4
|
+
brandTerms: string[];
|
|
5
|
+
/** Minimum impressions for a keyword to be included. Default: 10 */
|
|
6
|
+
minImpressions?: number;
|
|
7
|
+
}
|
|
8
|
+
interface BrandSegmentationRow {
|
|
9
|
+
query?: string;
|
|
10
|
+
keyword?: string;
|
|
11
|
+
variants?: readonly string[];
|
|
12
|
+
clicks?: number | null;
|
|
13
|
+
impressions?: number | null;
|
|
14
|
+
}
|
|
15
|
+
interface BrandSummary {
|
|
16
|
+
brandClicks: number;
|
|
17
|
+
nonBrandClicks: number;
|
|
18
|
+
brandShare: number;
|
|
19
|
+
brandImpressions: number;
|
|
20
|
+
nonBrandImpressions: number;
|
|
21
|
+
}
|
|
22
|
+
interface BrandSegmentationResult<T extends BrandSegmentationRow = QueriesRow> {
|
|
23
|
+
brand: T[];
|
|
24
|
+
nonBrand: T[];
|
|
25
|
+
summary: BrandSummary;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure helper: segment keywords into brand and non-brand based on provided
|
|
29
|
+
* brand terms. Re-exported from `@gscdump/analysis` for portable callers.
|
|
30
|
+
*/
|
|
31
|
+
declare function analyzeBrandSegmentation(keywords: QueriesRow[], options: BrandSegmentationOptions): BrandSegmentationResult<QueriesRow>;
|
|
32
|
+
declare function analyzeBrandSegmentation<T extends BrandSegmentationRow>(keywords: T[], options: BrandSegmentationOptions): BrandSegmentationResult<T>;
|
|
33
|
+
export { BrandSegmentationOptions, BrandSegmentationResult, BrandSegmentationRow, BrandSummary, analyzeBrandSegmentation };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { rowString } from "../analyzer/row-values.mjs";
|
|
2
|
+
import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
|
|
3
|
+
import { analysisErrorToException, analysisErrors } from "../errors.mjs";
|
|
4
|
+
import { defineAnalyzer } from "@gscdump/engine/analyzer";
|
|
5
|
+
import { periodOf } from "@gscdump/engine/period";
|
|
6
|
+
import { enumeratePartitions } from "@gscdump/engine/planner";
|
|
7
|
+
import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
|
|
8
|
+
import { num } from "@gscdump/engine/analysis-types";
|
|
9
|
+
import { err, ok, unwrapResult } from "gscdump/result";
|
|
10
|
+
function escapeRegexAlt(s) {
|
|
11
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
+
}
|
|
13
|
+
function requireBrandTermsResult(brandTerms) {
|
|
14
|
+
return brandTerms?.length ? ok(brandTerms) : err(analysisErrors.missingBrandTerms());
|
|
15
|
+
}
|
|
16
|
+
function requireBrandTerms(brandTerms) {
|
|
17
|
+
return unwrapResult(requireBrandTermsResult(brandTerms), analysisErrorToException);
|
|
18
|
+
}
|
|
19
|
+
function analyzeBrandSegmentation(keywords, options) {
|
|
20
|
+
const { brandTerms, minImpressions = 10 } = options;
|
|
21
|
+
const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
|
|
22
|
+
const brand = [];
|
|
23
|
+
const nonBrand = [];
|
|
24
|
+
let brandClicks = 0;
|
|
25
|
+
let nonBrandClicks = 0;
|
|
26
|
+
let brandImpressions = 0;
|
|
27
|
+
let nonBrandImpressions = 0;
|
|
28
|
+
for (const row of keywords) {
|
|
29
|
+
const impressions = num(row.impressions);
|
|
30
|
+
if (impressions < minImpressions) continue;
|
|
31
|
+
const clicks = num(row.clicks);
|
|
32
|
+
if ((row.variants?.length ? row.variants : [row.query ?? row.keyword ?? ""]).some((query) => lowerBrandTerms.some((term) => query.toLowerCase().includes(term)))) {
|
|
33
|
+
brand.push(row);
|
|
34
|
+
brandClicks += clicks;
|
|
35
|
+
brandImpressions += impressions;
|
|
36
|
+
} else {
|
|
37
|
+
nonBrand.push(row);
|
|
38
|
+
nonBrandClicks += clicks;
|
|
39
|
+
nonBrandImpressions += impressions;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const totalClicks = brandClicks + nonBrandClicks;
|
|
43
|
+
return {
|
|
44
|
+
brand,
|
|
45
|
+
nonBrand,
|
|
46
|
+
summary: {
|
|
47
|
+
brandClicks,
|
|
48
|
+
nonBrandClicks,
|
|
49
|
+
brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
|
|
50
|
+
brandImpressions,
|
|
51
|
+
nonBrandImpressions
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const brandAnalyzer = defineAnalyzer({
|
|
56
|
+
id: "brand",
|
|
57
|
+
buildSql(params) {
|
|
58
|
+
const brandTerms = requireBrandTerms(params.brandTerms);
|
|
59
|
+
const { startDate, endDate } = periodOf(params);
|
|
60
|
+
const minImpressions = params.minImpressions ?? 10;
|
|
61
|
+
const limit = params.limit ?? 1e4;
|
|
62
|
+
const regex = `(${brandTerms.map((t) => escapeRegexAlt(t.toLowerCase())).join("|")})`;
|
|
63
|
+
return {
|
|
64
|
+
sql: `
|
|
65
|
+
WITH agg AS (
|
|
66
|
+
SELECT
|
|
67
|
+
query,
|
|
68
|
+
url AS page,
|
|
69
|
+
${METRIC_EXPR.clicks} AS clicks,
|
|
70
|
+
${METRIC_EXPR.impressions} AS impressions,
|
|
71
|
+
${METRIC_EXPR.ctr} AS ctr,
|
|
72
|
+
${METRIC_EXPR.position} AS position
|
|
73
|
+
FROM read_parquet({{FILES}}, union_by_name = true)
|
|
74
|
+
WHERE date >= ? AND date <= ?
|
|
75
|
+
GROUP BY query, url
|
|
76
|
+
HAVING SUM(impressions) >= ?
|
|
77
|
+
)
|
|
78
|
+
SELECT
|
|
79
|
+
query, page, clicks, impressions, ctr, position,
|
|
80
|
+
CASE WHEN regexp_matches(LOWER(query), ?) THEN 'brand' ELSE 'non-brand' END AS segment
|
|
81
|
+
FROM agg
|
|
82
|
+
ORDER BY clicks DESC
|
|
83
|
+
LIMIT ${Number(limit)}
|
|
84
|
+
`,
|
|
85
|
+
params: [
|
|
86
|
+
startDate,
|
|
87
|
+
endDate,
|
|
88
|
+
minImpressions,
|
|
89
|
+
regex
|
|
90
|
+
],
|
|
91
|
+
current: {
|
|
92
|
+
table: "page_queries",
|
|
93
|
+
partitions: enumeratePartitions(startDate, endDate)
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
reduceSql(rows) {
|
|
98
|
+
const normalized = (Array.isArray(rows) ? rows : []).map((r) => ({
|
|
99
|
+
query: rowString(r.query),
|
|
100
|
+
page: r.page == null ? void 0 : rowString(r.page),
|
|
101
|
+
clicks: num(r.clicks),
|
|
102
|
+
impressions: num(r.impressions),
|
|
103
|
+
ctr: num(r.ctr),
|
|
104
|
+
position: num(r.position),
|
|
105
|
+
segment: rowString(r.segment)
|
|
106
|
+
}));
|
|
107
|
+
let brandClicks = 0;
|
|
108
|
+
let nonBrandClicks = 0;
|
|
109
|
+
let brandImpressions = 0;
|
|
110
|
+
let nonBrandImpressions = 0;
|
|
111
|
+
for (const r of normalized) if (r.segment === "brand") {
|
|
112
|
+
brandClicks += r.clicks;
|
|
113
|
+
brandImpressions += r.impressions;
|
|
114
|
+
} else {
|
|
115
|
+
nonBrandClicks += r.clicks;
|
|
116
|
+
nonBrandImpressions += r.impressions;
|
|
117
|
+
}
|
|
118
|
+
const totalClicks = brandClicks + nonBrandClicks;
|
|
119
|
+
return {
|
|
120
|
+
results: normalized,
|
|
121
|
+
meta: {
|
|
122
|
+
total: normalized.length,
|
|
123
|
+
summary: {
|
|
124
|
+
brandClicks,
|
|
125
|
+
nonBrandClicks,
|
|
126
|
+
brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
|
|
127
|
+
brandImpressions,
|
|
128
|
+
nonBrandImpressions
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
buildRows(params) {
|
|
134
|
+
return { queries: queriesQueryState(periodOf(params), params.limit) };
|
|
135
|
+
},
|
|
136
|
+
reduceRows(rows, params) {
|
|
137
|
+
const brandTerms = requireBrandTerms(params.brandTerms);
|
|
138
|
+
const result = analyzeBrandSegmentation(Array.isArray(rows) ? rows : [], {
|
|
139
|
+
brandTerms,
|
|
140
|
+
minImpressions: params.minImpressions
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
results: [...result.brand.map((r) => ({
|
|
144
|
+
...r,
|
|
145
|
+
segment: "brand"
|
|
146
|
+
})), ...result.nonBrand.map((r) => ({
|
|
147
|
+
...r,
|
|
148
|
+
segment: "non-brand"
|
|
149
|
+
}))],
|
|
150
|
+
meta: { summary: result.summary }
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
export { analyzeBrandSegmentation, brandAnalyzer };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { QueryPageRow, SortOrder } from "../types.mjs";
|
|
2
|
+
type CannibalizationSortMetric = 'clicks' | 'impressions' | 'positionSpread' | 'pageCount';
|
|
3
|
+
interface CannibalizationOptions {
|
|
4
|
+
/** Minimum impressions for a query to be considered. Default: 10 */
|
|
5
|
+
minImpressions?: number;
|
|
6
|
+
/** Maximum position spread to flag as cannibalization. Default: 10 */
|
|
7
|
+
maxPositionSpread?: number;
|
|
8
|
+
/** Minimum number of pages ranking for same query. Default: 2 */
|
|
9
|
+
minPages?: number;
|
|
10
|
+
/** Sort metric. Default: clicks */
|
|
11
|
+
sortBy?: CannibalizationSortMetric;
|
|
12
|
+
/** Sort order. Default: desc */
|
|
13
|
+
sortOrder?: SortOrder;
|
|
14
|
+
}
|
|
15
|
+
interface CannibalizationPage {
|
|
16
|
+
page: string;
|
|
17
|
+
clicks: number;
|
|
18
|
+
impressions: number;
|
|
19
|
+
ctr: number;
|
|
20
|
+
position: number;
|
|
21
|
+
}
|
|
22
|
+
interface CannibalizationResult {
|
|
23
|
+
query: string;
|
|
24
|
+
pages: CannibalizationPage[];
|
|
25
|
+
totalClicks: number;
|
|
26
|
+
totalImpressions: number;
|
|
27
|
+
positionSpread: number;
|
|
28
|
+
}
|
|
29
|
+
interface CannibalizationCompetitor {
|
|
30
|
+
url: string;
|
|
31
|
+
clicks: number;
|
|
32
|
+
impressions: number;
|
|
33
|
+
ctr: number;
|
|
34
|
+
position: number;
|
|
35
|
+
share: number;
|
|
36
|
+
rank: number;
|
|
37
|
+
}
|
|
38
|
+
interface CannibalizationEvent {
|
|
39
|
+
keyword: string;
|
|
40
|
+
totalImpressions: number;
|
|
41
|
+
totalClicks: number;
|
|
42
|
+
competitorCount: number;
|
|
43
|
+
leaderUrl: string;
|
|
44
|
+
leaderCtr: number;
|
|
45
|
+
leaderPosition: number;
|
|
46
|
+
hhi: number;
|
|
47
|
+
fragmentation: number;
|
|
48
|
+
stolenClicks: number;
|
|
49
|
+
severity: number;
|
|
50
|
+
competitors: CannibalizationCompetitor[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pure helper: detects keyword cannibalization from row data — queries
|
|
54
|
+
* ranking for multiple pages within a small position spread. Re-exported
|
|
55
|
+
* from `@gscdump/analysis` for portable callers.
|
|
56
|
+
*/
|
|
57
|
+
declare function analyzeCannibalization(rows: QueryPageRow[], options?: CannibalizationOptions): CannibalizationResult[];
|
|
58
|
+
export { CannibalizationCompetitor, CannibalizationEvent, CannibalizationOptions, CannibalizationPage, CannibalizationResult, CannibalizationSortMetric, analyzeCannibalization };
|