@gscdump/analysis 1.3.2 → 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.
Files changed (99) hide show
  1. package/dist/action-priority.d.mts +34 -0
  2. package/dist/action-priority.mjs +201 -0
  3. package/dist/analyzer/adapt-rows.mjs +12 -0
  4. package/dist/analyzer/all.mjs +61 -0
  5. package/dist/analyzer/paginate.mjs +85 -0
  6. package/dist/analyzer/row-analyzers.d.mts +3 -0
  7. package/dist/analyzer/row-analyzers.mjs +3 -0
  8. package/dist/analyzer/row-values.mjs +22 -0
  9. package/dist/analyzers/bayesian-ctr.mjs +177 -0
  10. package/dist/analyzers/bipartite-pagerank.mjs +243 -0
  11. package/dist/analyzers/brand.d.mts +33 -0
  12. package/dist/analyzers/brand.mjs +154 -0
  13. package/dist/analyzers/cannibalization.d.mts +58 -0
  14. package/dist/analyzers/cannibalization.mjs +267 -0
  15. package/dist/analyzers/change-point.mjs +197 -0
  16. package/dist/analyzers/clustering.d.mts +29 -0
  17. package/dist/analyzers/clustering.mjs +217 -0
  18. package/dist/analyzers/concentration.d.mts +42 -0
  19. package/dist/analyzers/concentration.mjs +180 -0
  20. package/dist/analyzers/content-velocity.mjs +87 -0
  21. package/dist/analyzers/ctr-anomaly.mjs +189 -0
  22. package/dist/analyzers/ctr-curve.mjs +128 -0
  23. package/dist/analyzers/dark-traffic.mjs +130 -0
  24. package/dist/analyzers/data-detail.mjs +44 -0
  25. package/dist/analyzers/data-query.mjs +44 -0
  26. package/dist/analyzers/decay.d.mts +38 -0
  27. package/dist/analyzers/decay.mjs +187 -0
  28. package/dist/analyzers/device-gap.mjs +135 -0
  29. package/dist/analyzers/intent-atlas.mjs +175 -0
  30. package/dist/analyzers/keyword-breadth.mjs +115 -0
  31. package/dist/analyzers/long-tail.mjs +150 -0
  32. package/dist/analyzers/movers.d.mts +43 -0
  33. package/dist/analyzers/movers.mjs +283 -0
  34. package/dist/analyzers/opportunity.d.mts +32 -0
  35. package/dist/analyzers/opportunity.mjs +212 -0
  36. package/dist/analyzers/position-distribution.mjs +57 -0
  37. package/dist/analyzers/position-volatility.mjs +143 -0
  38. package/dist/analyzers/query-migration.mjs +178 -0
  39. package/dist/analyzers/seasonality.d.mts +28 -0
  40. package/dist/analyzers/seasonality.mjs +136 -0
  41. package/dist/analyzers/stl-decompose.mjs +187 -0
  42. package/dist/analyzers/striking-distance.d.mts +54 -0
  43. package/dist/analyzers/striking-distance.mjs +82 -0
  44. package/dist/analyzers/survival.mjs +200 -0
  45. package/dist/analyzers/trends.mjs +156 -0
  46. package/dist/analyzers/zero-click.d.mts +17 -0
  47. package/dist/analyzers/zero-click.mjs +122 -0
  48. package/dist/browser.d.mts +5 -0
  49. package/dist/browser.mjs +11 -0
  50. package/dist/default-registry.d.mts +3 -1
  51. package/dist/default-registry.mjs +5 -1
  52. package/dist/errors.d.mts +51 -1
  53. package/dist/index.d.mts +30 -494
  54. package/dist/index.mjs +28 -49
  55. package/dist/query/analyzers.mjs +301 -0
  56. package/dist/query/index.mjs +4 -0
  57. package/dist/query/intent.d.mts +20 -0
  58. package/dist/query/intent.mjs +139 -0
  59. package/dist/query/normalize.d.mts +15 -0
  60. package/dist/query/normalize.mjs +204 -0
  61. package/dist/report/format.d.mts +7 -0
  62. package/dist/report/format.mjs +45 -0
  63. package/dist/report/index.d.mts +4 -1
  64. package/dist/report/index.mjs +4 -1
  65. package/dist/report/registry.d.mts +4 -0
  66. package/dist/report/registry.mjs +26 -0
  67. package/dist/report/reports/brand.mjs +104 -0
  68. package/dist/report/reports/growth.mjs +144 -0
  69. package/dist/report/reports/health.mjs +141 -0
  70. package/dist/report/reports/movers.mjs +203 -0
  71. package/dist/report/reports/opportunities.mjs +184 -0
  72. package/dist/report/reports/pre-publish.mjs +111 -0
  73. package/dist/report/reports/priority.mjs +97 -0
  74. package/dist/report/reports/risks.mjs +201 -0
  75. package/dist/report/reports/triage.mjs +155 -0
  76. package/dist/report/require.mjs +15 -0
  77. package/dist/report/resolve-target.d.mts +29 -0
  78. package/dist/report/resolve-target.mjs +32 -0
  79. package/dist/{_chunks/index.d.mts → report/runtime.d.mts} +2 -37
  80. package/dist/report/runtime.mjs +80 -0
  81. package/dist/report/sections.mjs +22 -0
  82. package/dist/sitemap-health.d.mts +34 -0
  83. package/dist/sitemap-health.mjs +37 -0
  84. package/dist/{_chunks/index2.d.mts → source/composite.d.mts} +2 -13
  85. package/dist/{_chunks/source.mjs → source/composite.mjs} +1 -17
  86. package/dist/source/in-memory.d.mts +14 -0
  87. package/dist/source/in-memory.mjs +17 -0
  88. package/dist/source/index.d.mts +2 -1
  89. package/dist/source/index.mjs +2 -1
  90. package/dist/sql-analyzers.d.mts +3 -0
  91. package/dist/sql-analyzers.mjs +3 -0
  92. package/dist/types.d.mts +34 -0
  93. package/dist/types.mjs +21 -0
  94. package/package.json +4 -4
  95. package/dist/_chunks/default-registry.d.mts +0 -5
  96. package/dist/_chunks/default-registry.mjs +0 -5183
  97. package/dist/_chunks/errors.d.mts +0 -52
  98. package/dist/_chunks/report.mjs +0 -1710
  99. /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 };