@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.
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,175 @@
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 { daysAgoUtc } from "gscdump/dates";
8
+ const INTENT_ATLAS_STOP_WORDS = [
9
+ "the",
10
+ "a",
11
+ "an",
12
+ "is",
13
+ "are",
14
+ "was",
15
+ "were",
16
+ "be",
17
+ "been",
18
+ "of",
19
+ "to",
20
+ "in",
21
+ "for",
22
+ "on",
23
+ "and",
24
+ "or",
25
+ "with",
26
+ "at",
27
+ "by",
28
+ "from",
29
+ "into",
30
+ "about",
31
+ "as",
32
+ "so",
33
+ "than",
34
+ "then",
35
+ "that",
36
+ "this",
37
+ "my",
38
+ "your",
39
+ "our",
40
+ "their",
41
+ "his",
42
+ "her",
43
+ "its",
44
+ "me",
45
+ "you",
46
+ "what",
47
+ "how",
48
+ "why",
49
+ "when",
50
+ "where",
51
+ "who",
52
+ "which",
53
+ "do",
54
+ "does"
55
+ ];
56
+ const intentAtlasAnalyzer = defineAnalyzer({
57
+ id: "intent-atlas",
58
+ buildSql(params) {
59
+ const endDate = params.endDate ?? defaultEndDate();
60
+ const startDate = params.startDate ?? daysAgoUtc(90);
61
+ const minQueryImpressions = params.minImpressions ?? 20;
62
+ const minClusterSize = params.minClusterSize ?? 3;
63
+ const minTokenImpressions = 50;
64
+ const limit = params.limit ?? 200;
65
+ const stopList = INTENT_ATLAS_STOP_WORDS.map((w) => `'${w}'`).join(", ");
66
+ return {
67
+ sql: `
68
+ WITH queries AS (
69
+ SELECT
70
+ query,
71
+ ${METRIC_EXPR.impressions} AS impressions,
72
+ ${METRIC_EXPR.clicks} AS clicks,
73
+ ${METRIC_EXPR.position} AS position
74
+ FROM read_parquet({{FILES}}, union_by_name = true)
75
+ WHERE date >= ? AND date <= ?
76
+ AND query IS NOT NULL AND query <> ''
77
+ GROUP BY query
78
+ HAVING SUM(impressions) >= ?
79
+ ),
80
+ tokens AS (
81
+ SELECT q.query, q.impressions, q.clicks, q.position,
82
+ LOWER(t.token) AS token
83
+ FROM queries q,
84
+ unnest(regexp_split_to_array(LOWER(q.query), '\\s+')) AS t(token)
85
+ WHERE LENGTH(t.token) >= 3
86
+ AND LOWER(t.token) NOT IN (${stopList})
87
+ ),
88
+ token_weights AS (
89
+ SELECT token,
90
+ SUM(impressions) AS token_impressions,
91
+ COUNT(DISTINCT query) AS query_count
92
+ FROM tokens
93
+ GROUP BY token
94
+ HAVING SUM(impressions) >= ${Number(minTokenImpressions)}
95
+ ),
96
+ ranked_tokens AS (
97
+ SELECT t.query, t.token, tw.token_impressions,
98
+ ROW_NUMBER() OVER (
99
+ PARTITION BY t.query
100
+ ORDER BY tw.token_impressions DESC, t.token ASC
101
+ ) AS rnk
102
+ FROM tokens t
103
+ JOIN token_weights tw USING (token)
104
+ ),
105
+ cluster_keys AS (
106
+ SELECT query,
107
+ array_to_string(list(token ORDER BY token), ' + ') AS cluster_key
108
+ FROM ranked_tokens
109
+ WHERE rnk <= 2
110
+ GROUP BY query
111
+ HAVING COUNT(*) >= 2
112
+ ),
113
+ clustered AS (
114
+ SELECT q.query, q.impressions, q.clicks, q.position, ck.cluster_key
115
+ FROM queries q
116
+ JOIN cluster_keys ck USING (query)
117
+ )
118
+ SELECT
119
+ cluster_key AS clusterKey,
120
+ COUNT(*) AS keywordCount,
121
+ SUM(impressions) AS totalImpressions,
122
+ SUM(clicks) AS totalClicks,
123
+ SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr,
124
+ SUM((position - 1) * impressions) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
125
+ to_json(list({
126
+ 'query': query,
127
+ 'impressions': impressions,
128
+ 'clicks': clicks,
129
+ 'position': position
130
+ } ORDER BY impressions DESC)) AS keywords
131
+ FROM clustered
132
+ GROUP BY cluster_key
133
+ HAVING COUNT(*) >= ${Number(minClusterSize)}
134
+ ORDER BY totalImpressions DESC
135
+ LIMIT ${Number(limit)}
136
+ `,
137
+ params: [
138
+ startDate,
139
+ endDate,
140
+ minQueryImpressions
141
+ ],
142
+ current: {
143
+ table: "queries",
144
+ partitions: enumeratePartitions(startDate, endDate)
145
+ }
146
+ };
147
+ },
148
+ reduceSql(rows) {
149
+ const clusters = (Array.isArray(rows) ? rows : []).map((r) => ({
150
+ clusterKey: rowString(r.clusterKey),
151
+ keywordCount: num(r.keywordCount),
152
+ totalImpressions: num(r.totalImpressions),
153
+ totalClicks: num(r.totalClicks),
154
+ ctr: num(r.ctr),
155
+ avgPosition: num(r.avgPosition),
156
+ keywords: parseJsonRows(r.keywords).slice(0, 25).map((k) => ({
157
+ query: rowString(k.query),
158
+ impressions: num(k.impressions),
159
+ clicks: num(k.clicks),
160
+ position: num(k.position)
161
+ }))
162
+ }));
163
+ const totalImpressions = clusters.reduce((s, c) => s + c.totalImpressions, 0);
164
+ const totalKeywords = clusters.reduce((s, c) => s + c.keywordCount, 0);
165
+ return {
166
+ results: clusters,
167
+ meta: {
168
+ total: clusters.length,
169
+ totalImpressions,
170
+ totalKeywords
171
+ }
172
+ };
173
+ }
174
+ });
175
+ export { intentAtlasAnalyzer };
@@ -0,0 +1,115 @@
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 { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
6
+ import { num } from "@gscdump/engine/analysis-types";
7
+ const keywordBreadthAnalyzer = defineAnalyzer({
8
+ id: "keyword-breadth",
9
+ buildSql(params) {
10
+ const { startDate, endDate } = periodOf(params);
11
+ return {
12
+ sql: `
13
+ WITH per_page AS (
14
+ SELECT
15
+ url,
16
+ CAST(COUNT(DISTINCT query) AS DOUBLE) AS keywordCount,
17
+ ${METRIC_EXPR.clicks} AS clicks,
18
+ ${METRIC_EXPR.impressions} AS impressions
19
+ FROM read_parquet({{FILES}}, union_by_name = true)
20
+ WHERE date >= ? AND date <= ? AND impressions > 0
21
+ GROUP BY url
22
+ ),
23
+ bucketed AS (
24
+ SELECT
25
+ CASE
26
+ WHEN keywordCount = 1 THEN '1'
27
+ WHEN keywordCount BETWEEN 2 AND 5 THEN '2-5'
28
+ WHEN keywordCount BETWEEN 6 AND 15 THEN '6-15'
29
+ WHEN keywordCount BETWEEN 16 AND 50 THEN '16-50'
30
+ ELSE '50+'
31
+ END AS bucket,
32
+ MIN(keywordCount) AS sort_key,
33
+ CAST(COUNT(*) AS DOUBLE) AS pageCount
34
+ FROM per_page
35
+ GROUP BY bucket
36
+ ),
37
+ fragile AS (
38
+ SELECT url, keywordCount, clicks, impressions
39
+ FROM per_page
40
+ WHERE keywordCount <= 2 AND clicks >= 5
41
+ ORDER BY clicks DESC
42
+ LIMIT 20
43
+ ),
44
+ authority AS (
45
+ SELECT url, keywordCount, clicks, impressions
46
+ FROM per_page
47
+ WHERE keywordCount >= 20
48
+ ORDER BY keywordCount DESC
49
+ LIMIT 20
50
+ ),
51
+ stats AS (
52
+ SELECT
53
+ CAST(COUNT(*) AS DOUBLE) AS totalPages,
54
+ CAST(AVG(keywordCount) AS DOUBLE) AS avgKeywordsPerPage,
55
+ CAST(SUM(CASE WHEN keywordCount <= 2 THEN 1 ELSE 0 END) AS DOUBLE) AS fragileCount,
56
+ CAST(SUM(CASE WHEN keywordCount >= 20 THEN 1 ELSE 0 END) AS DOUBLE) AS authorityCount
57
+ FROM per_page
58
+ )
59
+ SELECT
60
+ (SELECT to_json(list({ 'bucket': bucket, 'pageCount': pageCount, 'sortKey': sort_key })) FROM bucketed) AS distribution_json,
61
+ (SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM fragile) AS fragile_json,
62
+ (SELECT to_json(list({ 'url': url, 'keywordCount': keywordCount, 'clicks': clicks, 'impressions': impressions })) FROM authority) AS authority_json,
63
+ (SELECT to_json({
64
+ 'totalPages': totalPages,
65
+ 'avgKeywordsPerPage': avgKeywordsPerPage,
66
+ 'fragileCount': fragileCount,
67
+ 'authorityCount': authorityCount
68
+ }) FROM stats) AS stats_json
69
+ `,
70
+ params: [startDate, endDate],
71
+ current: {
72
+ table: "page_queries",
73
+ partitions: enumeratePartitions(startDate, endDate)
74
+ }
75
+ };
76
+ },
77
+ reduceSql(rows, params) {
78
+ const arr = Array.isArray(rows) ? rows : [];
79
+ const { startDate, endDate } = periodOf(params);
80
+ const row = arr[0] ?? {};
81
+ const distribution = parseJsonRows(row.distribution_json).sort((a, b) => num(a.sortKey) - num(b.sortKey)).map((r) => ({
82
+ bucket: rowString(r.bucket),
83
+ pageCount: num(r.pageCount)
84
+ }));
85
+ const fragile = parseJsonRows(row.fragile_json).map((r) => ({
86
+ url: rowString(r.url),
87
+ keywordCount: num(r.keywordCount),
88
+ clicks: num(r.clicks),
89
+ impressions: num(r.impressions)
90
+ }));
91
+ const authority = parseJsonRows(row.authority_json).map((r) => ({
92
+ url: rowString(r.url),
93
+ keywordCount: num(r.keywordCount),
94
+ clicks: num(r.clicks),
95
+ impressions: num(r.impressions)
96
+ }));
97
+ const stats = typeof row.stats_json === "string" ? JSON.parse(row.stats_json) : row.stats_json ?? {};
98
+ return {
99
+ results: distribution,
100
+ meta: {
101
+ fragilePages: fragile,
102
+ authorityPages: authority,
103
+ summary: {
104
+ totalPages: num(stats.totalPages),
105
+ avgKeywordsPerPage: num(stats.avgKeywordsPerPage),
106
+ fragileCount: num(stats.fragileCount),
107
+ authorityCount: num(stats.authorityCount)
108
+ },
109
+ startDate,
110
+ endDate
111
+ }
112
+ };
113
+ }
114
+ });
115
+ export { keywordBreadthAnalyzer };
@@ -0,0 +1,150 @@
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 { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
6
+ import { num } from "@gscdump/engine/analysis-types";
7
+ function downsampleLogRank(points) {
8
+ const toPoint = (p) => ({
9
+ rank: num(p.rank),
10
+ impressions: num(p.impressions),
11
+ clicks: num(p.clicks),
12
+ query: rowString(p.query)
13
+ });
14
+ if (points.length <= 80) return points.map(toPoint);
15
+ const sampled = points.slice(0, 10).map(toPoint);
16
+ let nextThreshold = 1.15;
17
+ for (let i = 10; i < points.length; i++) {
18
+ const point = points[i];
19
+ if (num(point.rank) >= nextThreshold) {
20
+ sampled.push(toPoint(point));
21
+ nextThreshold *= 1.15;
22
+ }
23
+ }
24
+ return sampled;
25
+ }
26
+ const longTailAnalyzer = defineAnalyzer({
27
+ id: "long-tail",
28
+ buildSql(params) {
29
+ const { startDate, endDate } = periodOf(params);
30
+ const minQueries = 10;
31
+ const minQueryImpressions = params.minImpressions ?? 5;
32
+ const limit = params.limit ?? 100;
33
+ return {
34
+ sql: `
35
+ WITH pq AS (
36
+ SELECT
37
+ url AS page,
38
+ query,
39
+ ${METRIC_EXPR.impressions} AS impressions,
40
+ ${METRIC_EXPR.clicks} AS clicks
41
+ FROM read_parquet({{FILES}}, union_by_name = true)
42
+ WHERE date >= ? AND date <= ?
43
+ AND query IS NOT NULL AND query <> ''
44
+ AND url IS NOT NULL AND url <> ''
45
+ GROUP BY url, query
46
+ HAVING SUM(impressions) >= ?
47
+ ),
48
+ ranked AS (
49
+ SELECT
50
+ page, query, impressions, clicks,
51
+ ROW_NUMBER() OVER (PARTITION BY page ORDER BY impressions DESC, query ASC) AS rnk
52
+ FROM pq
53
+ ),
54
+ log_space AS (
55
+ SELECT *,
56
+ LN(rnk) AS log_rank,
57
+ LN(impressions) AS log_impr
58
+ FROM ranked
59
+ ),
60
+ fit AS (
61
+ SELECT
62
+ page,
63
+ COUNT(*) AS query_count,
64
+ SUM(impressions) AS total_impressions,
65
+ SUM(clicks) AS total_clicks,
66
+ REGR_SLOPE(log_impr, log_rank) AS slope,
67
+ REGR_INTERCEPT(log_impr, log_rank) AS intercept,
68
+ REGR_R2(log_impr, log_rank) AS r2,
69
+ MAX(impressions) AS head_impressions,
70
+ MAX(CASE WHEN rnk = 1 THEN impressions END) / NULLIF(SUM(impressions), 0) AS head_share
71
+ FROM log_space
72
+ GROUP BY page
73
+ HAVING COUNT(*) >= ${Number(minQueries)}
74
+ ),
75
+ scatter AS (
76
+ SELECT
77
+ l.page,
78
+ to_json(list({
79
+ 'rank': l.rnk,
80
+ 'impressions': l.impressions,
81
+ 'clicks': l.clicks,
82
+ 'query': l.query
83
+ } ORDER BY l.rnk)) AS pointsJson
84
+ FROM log_space l
85
+ JOIN fit f USING (page)
86
+ GROUP BY l.page
87
+ )
88
+ SELECT
89
+ f.page,
90
+ f.query_count AS queryCount,
91
+ f.total_impressions AS totalImpressions,
92
+ f.total_clicks AS totalClicks,
93
+ f.slope AS slope,
94
+ f.intercept AS intercept,
95
+ f.r2 AS r2,
96
+ f.head_impressions AS headImpressions,
97
+ f.head_share AS headShare,
98
+ s.pointsJson AS pointsJson,
99
+ CASE
100
+ WHEN f.slope > -0.6 THEN 'flat-tail'
101
+ WHEN f.slope > -1.2 THEN 'balanced'
102
+ ELSE 'head-heavy'
103
+ END AS fingerprint
104
+ FROM fit f
105
+ LEFT JOIN scatter s USING (page)
106
+ ORDER BY f.total_impressions DESC
107
+ LIMIT ${Number(limit)}
108
+ `,
109
+ params: [
110
+ startDate,
111
+ endDate,
112
+ minQueryImpressions
113
+ ],
114
+ current: {
115
+ table: "page_queries",
116
+ partitions: enumeratePartitions(startDate, endDate)
117
+ }
118
+ };
119
+ },
120
+ reduceSql(rows) {
121
+ const results = (Array.isArray(rows) ? rows : []).map((r) => ({
122
+ page: rowString(r.page),
123
+ queryCount: num(r.queryCount),
124
+ totalImpressions: num(r.totalImpressions),
125
+ totalClicks: num(r.totalClicks),
126
+ slope: num(r.slope),
127
+ intercept: num(r.intercept),
128
+ r2: num(r.r2),
129
+ headImpressions: num(r.headImpressions),
130
+ headShare: num(r.headShare),
131
+ fingerprint: rowString(r.fingerprint),
132
+ points: downsampleLogRank(parseJsonRows(r.pointsJson))
133
+ }));
134
+ const counts = {
135
+ "flat-tail": 0,
136
+ "balanced": 0,
137
+ "head-heavy": 0
138
+ };
139
+ for (const r of results) counts[r.fingerprint]++;
140
+ return {
141
+ results,
142
+ meta: {
143
+ total: results.length,
144
+ fingerprints: counts,
145
+ avgSlope: results.length > 0 ? results.reduce((s, r) => s + r.slope, 0) / results.length : 0
146
+ }
147
+ };
148
+ }
149
+ });
150
+ export { longTailAnalyzer };
@@ -0,0 +1,43 @@
1
+ import { QueriesRow } from "../types.mjs";
2
+ type MoversSortMetric = 'clicks' | 'impressions' | 'clicksChange' | 'impressionsChange' | 'positionChange';
3
+ interface MoversOptions {
4
+ /** Minimum change threshold to flag. Default: 0.2 (20%) */
5
+ changeThreshold?: number;
6
+ /** Minimum impressions in recent period. Default: 50 */
7
+ minImpressions?: number;
8
+ /** Metric to sort results by. Default: clicksChange */
9
+ sortBy?: MoversSortMetric;
10
+ }
11
+ interface MoversInput {
12
+ current: QueriesRow[];
13
+ previous: QueriesRow[];
14
+ /** If periods have different lengths, provide normalization factor (previous/current) */
15
+ normalizationFactor?: number;
16
+ }
17
+ interface MoverData {
18
+ keyword: string;
19
+ page: string | null;
20
+ recentClicks: number;
21
+ recentImpressions: number;
22
+ recentPosition: number;
23
+ baselineClicks: number;
24
+ baselineImpressions: number;
25
+ /** `null` when the keyword has no previous-period data (a genuinely new query), not "position 0". */
26
+ baselinePosition: number | null;
27
+ clicksChange: number;
28
+ clicksChangePercent: number;
29
+ impressionsChangePercent: number;
30
+ /** `null` unless both recentPosition and baselinePosition exist. */
31
+ positionChange: number | null;
32
+ }
33
+ interface MoversResult {
34
+ rising: MoverData[];
35
+ declining: MoverData[];
36
+ stable: MoverData[];
37
+ }
38
+ /**
39
+ * Pure helper: identify "movers and shakers" — keywords with significant
40
+ * recent changes between two periods.
41
+ */
42
+ declare function analyzeMovers(input: MoversInput, options?: MoversOptions): MoversResult;
43
+ export { MoverData, MoversInput, MoversOptions, MoversResult, MoversSortMetric, analyzeMovers };