@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,283 @@
1
+ import { percentDifference } from "../scoring.mjs";
2
+ import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
3
+ import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
4
+ import { buildPeriodMap } from "../types.mjs";
5
+ import { defineAnalyzer } from "@gscdump/engine/analyzer";
6
+ import { comparisonOf } from "@gscdump/engine/period";
7
+ import { enumeratePartitions } from "@gscdump/engine/planner";
8
+ import { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
9
+ import { num } from "@gscdump/engine/analysis-types";
10
+ function withCanonicalFields(r) {
11
+ return {
12
+ ...r,
13
+ clicks: r.recentClicks,
14
+ impressions: r.recentImpressions,
15
+ ctr: r.recentImpressions > 0 ? r.recentClicks / r.recentImpressions : 0,
16
+ position: r.recentPosition,
17
+ prevClicks: r.baselineClicks,
18
+ prevImpressions: r.baselineImpressions,
19
+ prevPosition: r.baselinePosition
20
+ };
21
+ }
22
+ function selectMoversDirection(rising, declining, stable, params) {
23
+ if (params.direction) {
24
+ const selected = params.direction === "rising" ? rising : declining;
25
+ const total = selected.length;
26
+ const offset = params.offset ?? 0;
27
+ const limit = params.limit ?? total;
28
+ return {
29
+ results: selected.slice(offset, offset + limit),
30
+ meta: {
31
+ total,
32
+ rising: rising.length,
33
+ declining: declining.length,
34
+ stable: stable.length
35
+ }
36
+ };
37
+ }
38
+ const combined = [...rising, ...declining];
39
+ return {
40
+ results: combined,
41
+ meta: {
42
+ total: combined.length,
43
+ rising: rising.length,
44
+ declining: declining.length,
45
+ stable: stable.length
46
+ }
47
+ };
48
+ }
49
+ function analyzeMovers(input, options = {}) {
50
+ const { changeThreshold = .2, minImpressions = 50, sortBy = "clicksChange" } = options;
51
+ const normFactor = input.normalizationFactor ?? 1;
52
+ const baselineMap = buildPeriodMap(input.previous, (r) => r.query, (r) => ({
53
+ clicks: num(r.clicks) / normFactor,
54
+ impressions: num(r.impressions) / normFactor,
55
+ position: num(r.position),
56
+ page: r.page ?? null
57
+ }));
58
+ const pageMap = /* @__PURE__ */ new Map();
59
+ for (const row of input.current) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
60
+ for (const row of input.previous) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
61
+ const rising = [];
62
+ const declining = [];
63
+ const stable = [];
64
+ for (const row of input.current) {
65
+ const impressions = num(row.impressions);
66
+ const clicks = num(row.clicks);
67
+ const position = num(row.position);
68
+ if (impressions < minImpressions) continue;
69
+ const baseline = baselineMap.get(row.query);
70
+ const baselineClicksRaw = baseline?.clicks ?? 0;
71
+ const baselineImpressionsRaw = baseline?.impressions ?? 0;
72
+ const baselinePosition = baseline?.position ?? null;
73
+ const clicksChangePercent = percentDifference(clicks, baselineClicksRaw);
74
+ const impressionsChangePercent = percentDifference(impressions, baselineImpressionsRaw);
75
+ const data = {
76
+ keyword: row.query,
77
+ page: pageMap.get(row.query) ?? null,
78
+ recentClicks: clicks,
79
+ recentImpressions: impressions,
80
+ recentPosition: position,
81
+ baselineClicks: Math.round(baselineClicksRaw),
82
+ baselineImpressions: Math.round(baselineImpressionsRaw),
83
+ baselinePosition,
84
+ clicksChange: clicks - Math.round(baselineClicksRaw),
85
+ clicksChangePercent,
86
+ impressionsChangePercent,
87
+ positionChange: baselinePosition != null ? position - baselinePosition : null
88
+ };
89
+ const absChange = Math.abs(clicksChangePercent / 100);
90
+ if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
91
+ else if (clicksChangePercent < 0 && absChange >= changeThreshold) declining.push(data);
92
+ else stable.push(data);
93
+ }
94
+ const sortFn = (a, b) => {
95
+ switch (sortBy) {
96
+ case "clicks": return b.recentClicks - a.recentClicks;
97
+ case "impressions": return b.recentImpressions - a.recentImpressions;
98
+ case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
99
+ case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
100
+ case "positionChange": return Math.abs(b.positionChange ?? 0) - Math.abs(a.positionChange ?? 0);
101
+ default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
102
+ }
103
+ };
104
+ rising.sort(sortFn);
105
+ declining.sort(sortFn);
106
+ stable.sort((a, b) => b.recentClicks - a.recentClicks);
107
+ return {
108
+ rising,
109
+ declining,
110
+ stable
111
+ };
112
+ }
113
+ const moversAnalyzer = defineAnalyzer({
114
+ id: "movers",
115
+ buildSql(params) {
116
+ const { current: cur, previous: prev } = comparisonOf(params);
117
+ const minImpressions = params.minImpressions ?? 50;
118
+ const changeThreshold = params.changeThreshold ?? .2;
119
+ const limit = params.direction ? 5e3 : params.limit ?? 2e3;
120
+ return {
121
+ sql: `
122
+ WITH cur AS (
123
+ SELECT
124
+ query, url,
125
+ ${METRIC_EXPR.clicks} AS clicks,
126
+ ${METRIC_EXPR.impressions} AS impressions,
127
+ ${METRIC_EXPR.position} AS position
128
+ FROM read_parquet({{FILES}}, union_by_name = true)
129
+ WHERE date >= ? AND date <= ?
130
+ GROUP BY query, url
131
+ ),
132
+ prev AS (
133
+ SELECT
134
+ query, url,
135
+ ${METRIC_EXPR.clicks} AS clicks,
136
+ ${METRIC_EXPR.impressions} AS impressions,
137
+ ${METRIC_EXPR.position} AS position
138
+ FROM read_parquet({{FILES_PREV}}, union_by_name = true)
139
+ WHERE date >= ? AND date <= ?
140
+ GROUP BY query, url
141
+ ),
142
+ weekly AS (
143
+ SELECT query, url, date_trunc('week', CAST(date AS DATE)) AS week,
144
+ ${METRIC_EXPR.clicks} AS clicks,
145
+ ${METRIC_EXPR.impressions} AS impressions
146
+ FROM (
147
+ SELECT query, url, date, clicks, impressions
148
+ FROM read_parquet({{FILES}}, union_by_name = true)
149
+ WHERE date >= ? AND date <= ?
150
+ UNION ALL
151
+ SELECT query, url, date, clicks, impressions
152
+ FROM read_parquet({{FILES_PREV}}, union_by_name = true)
153
+ WHERE date >= ? AND date <= ?
154
+ )
155
+ GROUP BY query, url, week
156
+ ),
157
+ series_by_entity AS (
158
+ SELECT query, url, to_json(list({
159
+ 'week': strftime(week, '%Y-%m-%d'),
160
+ 'clicks': clicks,
161
+ 'impressions': impressions
162
+ } ORDER BY week)) AS seriesJson
163
+ FROM weekly GROUP BY query, url
164
+ ),
165
+ joined AS (
166
+ SELECT
167
+ c.query AS keyword,
168
+ c.url AS page,
169
+ c.clicks AS recentClicks,
170
+ c.impressions AS recentImpressions,
171
+ c.position AS recentPosition,
172
+ COALESCE(p.clicks, 0.0) AS baselineClicks,
173
+ COALESCE(p.impressions, 0.0) AS baselineImpressions,
174
+ p.position AS baselinePosition,
175
+ (c.clicks - COALESCE(p.clicks, 0.0)) AS clicksChange,
176
+ CASE
177
+ WHEN COALESCE(p.clicks, 0.0) = 0 THEN CASE WHEN c.clicks > 0 THEN 100.0 ELSE 0.0 END
178
+ ELSE (c.clicks - p.clicks) * 100.0 / p.clicks
179
+ END AS clicksChangePercent,
180
+ CASE
181
+ WHEN COALESCE(p.impressions, 0.0) = 0 THEN CASE WHEN c.impressions > 0 THEN 100.0 ELSE 0.0 END
182
+ ELSE (c.impressions - p.impressions) * 100.0 / p.impressions
183
+ END AS impressionsChangePercent,
184
+ CASE WHEN p.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionChange,
185
+ s.seriesJson
186
+ FROM cur c
187
+ LEFT JOIN prev p ON c.query = p.query AND c.url = p.url
188
+ LEFT JOIN series_by_entity s ON c.query = s.query AND c.url = s.url
189
+ WHERE c.impressions >= ?
190
+ )
191
+ SELECT *,
192
+ CASE
193
+ WHEN clicksChangePercent > 0 AND ABS(clicksChangePercent) / 100.0 >= ? THEN 'rising'
194
+ WHEN clicksChangePercent < 0 AND ABS(clicksChangePercent) / 100.0 >= ? THEN 'declining'
195
+ ELSE 'stable'
196
+ END AS direction
197
+ FROM joined
198
+ ORDER BY ABS(clicksChangePercent) DESC
199
+ LIMIT ${Number(limit)}
200
+ `,
201
+ params: [
202
+ cur.startDate,
203
+ cur.endDate,
204
+ prev.startDate,
205
+ prev.endDate,
206
+ cur.startDate,
207
+ cur.endDate,
208
+ prev.startDate,
209
+ prev.endDate,
210
+ minImpressions,
211
+ changeThreshold,
212
+ changeThreshold
213
+ ],
214
+ current: {
215
+ table: "page_queries",
216
+ partitions: enumeratePartitions(cur.startDate, cur.endDate)
217
+ },
218
+ previous: {
219
+ table: "page_queries",
220
+ partitions: enumeratePartitions(prev.startDate, prev.endDate)
221
+ }
222
+ };
223
+ },
224
+ reduceSql(rows, params) {
225
+ const normalized = (Array.isArray(rows) ? rows : []).map((r) => {
226
+ const recentClicks = num(r.recentClicks);
227
+ const recentImpressions = num(r.recentImpressions);
228
+ const recentPosition = num(r.recentPosition);
229
+ const baselineClicks = Math.round(num(r.baselineClicks));
230
+ const baselineImpressions = Math.round(num(r.baselineImpressions));
231
+ const baselinePosition = r.baselinePosition == null ? null : num(r.baselinePosition);
232
+ return {
233
+ keyword: rowString(r.keyword),
234
+ page: r.page == null ? null : rowString(r.page),
235
+ recentClicks,
236
+ recentImpressions,
237
+ recentPosition,
238
+ baselineClicks,
239
+ baselineImpressions,
240
+ baselinePosition,
241
+ clicksChange: num(r.clicksChange),
242
+ clicksChangePercent: num(r.clicksChangePercent),
243
+ impressionsChangePercent: num(r.impressionsChangePercent),
244
+ positionChange: r.positionChange == null ? null : num(r.positionChange),
245
+ direction: rowString(r.direction),
246
+ series: parseJsonRows(r.seriesJson).map((s) => ({
247
+ week: rowString(s.week),
248
+ clicks: num(s.clicks),
249
+ impressions: num(s.impressions)
250
+ }))
251
+ };
252
+ }).map(withCanonicalFields);
253
+ return selectMoversDirection(normalized.filter((r) => r.direction === "rising"), normalized.filter((r) => r.direction === "declining"), normalized.filter((r) => r.direction === "stable"), params);
254
+ },
255
+ buildRows(params) {
256
+ const { current, previous } = comparisonOf(params);
257
+ return {
258
+ current: queriesQueryState(current, params.limit),
259
+ previous: queriesQueryState(previous, params.limit)
260
+ };
261
+ },
262
+ reduceRows(rows, params) {
263
+ const map = rows && !Array.isArray(rows) ? rows : {
264
+ current: [],
265
+ previous: []
266
+ };
267
+ const result = analyzeMovers({
268
+ current: map.current ?? [],
269
+ previous: map.previous ?? []
270
+ }, {
271
+ changeThreshold: params.changeThreshold,
272
+ minImpressions: params.minImpressions
273
+ });
274
+ return selectMoversDirection(result.rising.map((r) => withCanonicalFields({
275
+ ...r,
276
+ direction: "rising"
277
+ })), result.declining.map((r) => withCanonicalFields({
278
+ ...r,
279
+ direction: "declining"
280
+ })), [], params);
281
+ }
282
+ });
283
+ export { analyzeMovers, moversAnalyzer };
@@ -0,0 +1,32 @@
1
+ import { QueriesRow } from "../types.mjs";
2
+ type OpportunitySortMetric = 'opportunityScore' | 'potentialClicks' | 'impressions' | 'position';
3
+ interface OpportunityFactors {
4
+ positionScore: number;
5
+ impressionScore: number;
6
+ ctrGapScore: number;
7
+ }
8
+ interface OpportunityResult {
9
+ keyword: string;
10
+ page: string | null;
11
+ clicks: number;
12
+ impressions: number;
13
+ ctr: number;
14
+ position: number;
15
+ opportunityScore: number;
16
+ potentialClicks: number;
17
+ factors: OpportunityFactors;
18
+ }
19
+ interface OpportunityWeights {
20
+ position?: number;
21
+ impressions?: number;
22
+ ctrGap?: number;
23
+ }
24
+ interface OpportunityOptions {
25
+ minImpressions?: number;
26
+ weights?: OpportunityWeights;
27
+ sortBy?: OpportunitySortMetric;
28
+ limit?: number;
29
+ }
30
+ /** Pure opportunity scorer shared by hosted and package analyzer callers. */
31
+ declare function analyzeOpportunity(keywords: QueriesRow[], options?: OpportunityOptions): OpportunityResult[];
32
+ export { OpportunityFactors, OpportunityOptions, OpportunityResult, OpportunitySortMetric, OpportunityWeights, analyzeOpportunity };
@@ -0,0 +1,212 @@
1
+ import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { paginateClause, 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 { METRIC_EXPR } from "@gscdump/engine/sql-fragments";
7
+ import { num } from "@gscdump/engine/analysis-types";
8
+ const EXPECTED_CTR_BY_POSITION = {
9
+ 1: .3,
10
+ 2: .15,
11
+ 3: .1,
12
+ 4: .07,
13
+ 5: .05,
14
+ 6: .04,
15
+ 7: .03,
16
+ 8: .025,
17
+ 9: .02,
18
+ 10: .015
19
+ };
20
+ function getExpectedCtr(position) {
21
+ return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position, 10)))] || .01;
22
+ }
23
+ function calculatePositionScore(position) {
24
+ if (position <= 3) return .2;
25
+ if (position > 50) return .1;
26
+ const distance = Math.abs(position - 11);
27
+ return Math.max(0, 1 - distance / 15);
28
+ }
29
+ function calculateImpressionScore(impressions) {
30
+ if (impressions <= 0) return 0;
31
+ return Math.min(Math.log10(impressions) / 5, 1);
32
+ }
33
+ function calculateCtrGapScore(actualCtr, position) {
34
+ const expectedCtr = getExpectedCtr(position);
35
+ if (actualCtr >= expectedCtr) return 0;
36
+ const gap = expectedCtr - actualCtr;
37
+ return Math.min(gap / expectedCtr, 1);
38
+ }
39
+ const SORT_DIR = {
40
+ opportunityScore: "desc",
41
+ potentialClicks: "desc",
42
+ impressions: "desc",
43
+ position: "asc"
44
+ };
45
+ function analyzeOpportunity(keywords, options = {}) {
46
+ const minImpressions = options.minImpressions ?? 100;
47
+ const positionWeight = options.weights?.position ?? 1;
48
+ const impressionsWeight = options.weights?.impressions ?? 1;
49
+ const ctrGapWeight = options.weights?.ctrGap ?? 1;
50
+ const totalWeight = positionWeight + impressionsWeight + ctrGapWeight;
51
+ const sortBy = options.sortBy ?? "opportunityScore";
52
+ const results = [];
53
+ for (const row of keywords) {
54
+ const impressions = num(row.impressions);
55
+ if (impressions < minImpressions) continue;
56
+ const position = num(row.position);
57
+ const ctr = num(row.ctr);
58
+ const clicks = num(row.clicks);
59
+ const positionScore = calculatePositionScore(position);
60
+ const impressionScore = calculateImpressionScore(impressions);
61
+ const ctrGapScore = calculateCtrGapScore(ctr, position);
62
+ const weightedProduct = positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight;
63
+ const opportunityScore = Math.round(weightedProduct ** (1 / totalWeight) * 100);
64
+ const potentialClicks = Math.round(impressions * getExpectedCtr(Math.min(3, position)));
65
+ results.push({
66
+ keyword: row.query,
67
+ page: row.page ?? null,
68
+ clicks,
69
+ impressions,
70
+ ctr,
71
+ position,
72
+ opportunityScore,
73
+ potentialClicks,
74
+ factors: {
75
+ positionScore,
76
+ impressionScore,
77
+ ctrGapScore
78
+ }
79
+ });
80
+ }
81
+ const direction = SORT_DIR[sortBy];
82
+ results.sort((left, right) => direction === "asc" ? left[sortBy] - right[sortBy] : right[sortBy] - left[sortBy]);
83
+ return options.limit ? results.slice(0, options.limit) : results;
84
+ }
85
+ const opportunityAnalyzer = defineAnalyzer({
86
+ id: "opportunity",
87
+ buildSql(params) {
88
+ const { startDate, endDate } = periodOf(params);
89
+ const minImpressions = params.minImpressions ?? 100;
90
+ const w1 = 1;
91
+ const w2 = 1;
92
+ const w3 = 1;
93
+ const totalW = 3;
94
+ const limit = params.limit ?? 1e3;
95
+ return {
96
+ sql: `
97
+ WITH agg AS (
98
+ SELECT
99
+ query AS keyword,
100
+ url AS page,
101
+ ${METRIC_EXPR.clicks} AS clicks,
102
+ ${METRIC_EXPR.impressions} AS impressions,
103
+ ${METRIC_EXPR.ctr} AS ctr,
104
+ ${METRIC_EXPR.position} AS position
105
+ FROM read_parquet({{FILES}}, union_by_name = true)
106
+ WHERE date >= ? AND date <= ?
107
+ GROUP BY query, url
108
+ HAVING SUM(impressions) >= ?
109
+ ),
110
+ scored AS (
111
+ SELECT
112
+ keyword, page, clicks, impressions, ctr, position,
113
+ CASE
114
+ WHEN position <= 3 THEN 0.2
115
+ WHEN position > 50 THEN 0.1
116
+ ELSE GREATEST(0.0, 1.0 - ABS(position - 11.0) / 15.0)
117
+ END AS positionScore,
118
+ CASE WHEN impressions <= 0 THEN 0.0 ELSE LEAST(LOG10(impressions) / 5.0, 1.0) END AS impressionScore,
119
+ CASE CAST(ROUND(GREATEST(LEAST(position, 10.0), 1.0)) AS INTEGER)
120
+ WHEN 1 THEN 0.30
121
+ WHEN 2 THEN 0.15
122
+ WHEN 3 THEN 0.10
123
+ WHEN 4 THEN 0.07
124
+ WHEN 5 THEN 0.05
125
+ WHEN 6 THEN 0.04
126
+ WHEN 7 THEN 0.03
127
+ WHEN 8 THEN 0.025
128
+ WHEN 9 THEN 0.02
129
+ WHEN 10 THEN 0.015
130
+ ELSE 0.01
131
+ END AS expectedCtr
132
+ FROM agg
133
+ ),
134
+ gapped AS (
135
+ SELECT
136
+ *,
137
+ CASE WHEN ctr >= expectedCtr THEN 0.0 ELSE LEAST((expectedCtr - ctr) / expectedCtr, 1.0) END AS ctrGapScore
138
+ FROM scored
139
+ )
140
+ SELECT
141
+ keyword, page, clicks, impressions, ctr, position,
142
+ CAST(ROUND(POWER(
143
+ POWER(positionScore, ${w1}) * POWER(impressionScore, ${w2}) * POWER(ctrGapScore, ${w3}),
144
+ 1.0 / ${totalW}
145
+ ) * 100) AS DOUBLE) AS opportunityScore,
146
+ CAST(ROUND(impressions * (
147
+ CASE CAST(ROUND(GREATEST(LEAST(position, 3.0), 1.0)) AS INTEGER)
148
+ WHEN 1 THEN 0.30
149
+ WHEN 2 THEN 0.15
150
+ WHEN 3 THEN 0.10
151
+ ELSE 0.10
152
+ END
153
+ )) AS DOUBLE) AS potentialClicks,
154
+ positionScore, impressionScore, ctrGapScore
155
+ FROM gapped
156
+ ORDER BY opportunityScore DESC
157
+ ${paginateClause({
158
+ limit,
159
+ offset: params.offset
160
+ })}
161
+ `,
162
+ params: [
163
+ startDate,
164
+ endDate,
165
+ minImpressions
166
+ ],
167
+ current: {
168
+ table: "page_queries",
169
+ partitions: enumeratePartitions(startDate, endDate)
170
+ }
171
+ };
172
+ },
173
+ reduceSql(rows) {
174
+ const arr = Array.isArray(rows) ? rows : [];
175
+ return {
176
+ results: arr.map((r) => ({
177
+ keyword: r.keyword == null ? "" : String(r.keyword),
178
+ page: r.page == null ? null : String(r.page),
179
+ clicks: num(r.clicks),
180
+ impressions: num(r.impressions),
181
+ ctr: num(r.ctr),
182
+ position: num(r.position),
183
+ opportunityScore: num(r.opportunityScore),
184
+ potentialClicks: num(r.potentialClicks),
185
+ factors: {
186
+ positionScore: num(r.positionScore),
187
+ impressionScore: num(r.impressionScore),
188
+ ctrGapScore: num(r.ctrGapScore)
189
+ }
190
+ })),
191
+ meta: { total: arr.length }
192
+ };
193
+ },
194
+ buildRows(params) {
195
+ return { queries: queriesQueryState(periodOf(params), params.limit) };
196
+ },
197
+ reduceRows(rows, params) {
198
+ const results = analyzeOpportunity((Array.isArray(rows) ? rows : []) ?? [], { minImpressions: params.minImpressions });
199
+ const paged = paginateSortedInMemory(results, {
200
+ limit: params.limit,
201
+ offset: params.offset
202
+ }, (left, right) => right.opportunityScore - left.opportunityScore);
203
+ return {
204
+ results: paged,
205
+ meta: {
206
+ total: results.length,
207
+ returned: paged.length
208
+ }
209
+ };
210
+ }
211
+ });
212
+ export { analyzeOpportunity, opportunityAnalyzer };
@@ -0,0 +1,57 @@
1
+ import { 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 positionDistributionAnalyzer = defineAnalyzer({
7
+ id: "position-distribution",
8
+ buildSql(params) {
9
+ const { startDate, endDate } = periodOf(params);
10
+ return {
11
+ sql: `
12
+ WITH pos AS (
13
+ SELECT
14
+ date,
15
+ (sum_position / NULLIF(impressions, 0) + 1) AS avg_pos
16
+ FROM read_parquet({{FILES}}, union_by_name = true)
17
+ WHERE date >= ? AND date <= ? AND impressions > 0
18
+ )
19
+ SELECT
20
+ date,
21
+ CAST(SUM(CASE WHEN avg_pos <= 3 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_1_3,
22
+ CAST(SUM(CASE WHEN avg_pos > 3 AND avg_pos <= 10 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_4_10,
23
+ CAST(SUM(CASE WHEN avg_pos > 10 AND avg_pos <= 20 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_11_20,
24
+ CAST(SUM(CASE WHEN avg_pos > 20 THEN 1 ELSE 0 END) AS DOUBLE) AS pos_20_plus,
25
+ CAST(COUNT(*) AS DOUBLE) AS total
26
+ FROM pos
27
+ GROUP BY date
28
+ ORDER BY date ASC
29
+ `,
30
+ params: [startDate, endDate],
31
+ current: {
32
+ table: "queries",
33
+ partitions: enumeratePartitions(startDate, endDate)
34
+ }
35
+ };
36
+ },
37
+ reduceSql(rows, params) {
38
+ const arr = Array.isArray(rows) ? rows : [];
39
+ const { startDate, endDate } = periodOf(params);
40
+ return {
41
+ results: arr.map((r) => ({
42
+ date: rowString(r.date),
43
+ pos_1_3: num(r.pos_1_3),
44
+ pos_4_10: num(r.pos_4_10),
45
+ pos_11_20: num(r.pos_11_20),
46
+ pos_20_plus: num(r.pos_20_plus),
47
+ total: num(r.total)
48
+ })),
49
+ meta: {
50
+ total: arr.length,
51
+ startDate,
52
+ endDate
53
+ }
54
+ };
55
+ }
56
+ });
57
+ export { positionDistributionAnalyzer };