@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,130 @@
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 darkTrafficAnalyzer = defineAnalyzer({
7
+ id: "dark-traffic",
8
+ buildSql(params) {
9
+ const { startDate, endDate } = periodOf(params);
10
+ return {
11
+ sql: `
12
+ WITH page_totals AS (
13
+ SELECT SUM(clicks) AS total_clicks, SUM(impressions) AS total_impressions
14
+ FROM read_parquet({{FILES}}, union_by_name = true)
15
+ WHERE date >= ? AND date <= ?
16
+ ),
17
+ kw_totals AS (
18
+ SELECT SUM(clicks) AS total_clicks, SUM(impressions) AS total_impressions
19
+ FROM read_parquet({{FILES_KEYWORDS}}, union_by_name = true)
20
+ WHERE date >= ? AND date <= ?
21
+ ),
22
+ per_page AS (
23
+ SELECT url, SUM(clicks) AS page_clicks
24
+ FROM read_parquet({{FILES}}, union_by_name = true)
25
+ WHERE date >= ? AND date <= ?
26
+ GROUP BY url
27
+ HAVING SUM(clicks) > 0
28
+ ),
29
+ per_page_kw AS (
30
+ SELECT url, SUM(clicks) AS attributed_clicks, COUNT(DISTINCT query) AS kw_count
31
+ FROM read_parquet({{FILES_PAGE_KEYWORDS}}, union_by_name = true)
32
+ WHERE date >= ? AND date <= ?
33
+ GROUP BY url
34
+ ),
35
+ page_rows AS (
36
+ SELECT
37
+ p.url AS url,
38
+ CAST(p.page_clicks AS DOUBLE) AS totalClicks,
39
+ CAST(COALESCE(k.attributed_clicks, 0) AS DOUBLE) AS attributedClicks,
40
+ CAST(p.page_clicks - COALESCE(k.attributed_clicks, 0) AS DOUBLE) AS darkClicks,
41
+ CAST(p.page_clicks - COALESCE(k.attributed_clicks, 0) AS DOUBLE)
42
+ / NULLIF(p.page_clicks, 0) AS darkPercent,
43
+ CAST(COALESCE(k.kw_count, 0) AS DOUBLE) AS keywordCount
44
+ FROM per_page p
45
+ LEFT JOIN per_page_kw k ON p.url = k.url
46
+ WHERE p.page_clicks - COALESCE(k.attributed_clicks, 0) > 0
47
+ ORDER BY darkClicks DESC
48
+ LIMIT 50
49
+ )
50
+ SELECT
51
+ (SELECT to_json({
52
+ 'totalClicks': CAST(total_clicks AS DOUBLE),
53
+ 'totalImpressions': CAST(total_impressions AS DOUBLE)
54
+ }) FROM page_totals) AS page_totals_json,
55
+ (SELECT to_json({
56
+ 'attributedClicks': CAST(total_clicks AS DOUBLE),
57
+ 'attributedImpressions': CAST(total_impressions AS DOUBLE)
58
+ }) FROM kw_totals) AS kw_totals_json,
59
+ (SELECT to_json(list({
60
+ 'url': url,
61
+ 'totalClicks': totalClicks,
62
+ 'attributedClicks': attributedClicks,
63
+ 'darkClicks': darkClicks,
64
+ 'darkPercent': darkPercent,
65
+ 'keywordCount': keywordCount
66
+ })) FROM page_rows) AS pages_json
67
+ `,
68
+ params: [
69
+ startDate,
70
+ endDate,
71
+ startDate,
72
+ endDate,
73
+ startDate,
74
+ endDate,
75
+ startDate,
76
+ endDate
77
+ ],
78
+ current: {
79
+ table: "pages",
80
+ partitions: enumeratePartitions(startDate, endDate)
81
+ },
82
+ extraFiles: {
83
+ KEYWORDS: {
84
+ table: "queries",
85
+ partitions: enumeratePartitions(startDate, endDate)
86
+ },
87
+ PAGE_KEYWORDS: {
88
+ table: "page_queries",
89
+ partitions: enumeratePartitions(startDate, endDate)
90
+ }
91
+ }
92
+ };
93
+ },
94
+ reduceSql(rows, params) {
95
+ const arr = Array.isArray(rows) ? rows : [];
96
+ const { startDate, endDate } = periodOf(params);
97
+ const row = arr[0] ?? {};
98
+ const pageTotals = typeof row.page_totals_json === "string" ? JSON.parse(row.page_totals_json) : row.page_totals_json ?? {};
99
+ const kwTotals = typeof row.kw_totals_json === "string" ? JSON.parse(row.kw_totals_json) : row.kw_totals_json ?? {};
100
+ const totalClicks = num(pageTotals.totalClicks);
101
+ const totalImpressions = num(pageTotals.totalImpressions);
102
+ const attributedClicks = num(kwTotals.attributedClicks);
103
+ const attributedImpressions = num(kwTotals.attributedImpressions);
104
+ const darkClicks = Math.max(0, totalClicks - attributedClicks);
105
+ const darkPercent = totalClicks > 0 ? darkClicks / totalClicks : 0;
106
+ return {
107
+ results: parseJsonRows(row.pages_json).map((r) => ({
108
+ url: rowString(r.url),
109
+ totalClicks: num(r.totalClicks),
110
+ attributedClicks: num(r.attributedClicks),
111
+ darkClicks: num(r.darkClicks),
112
+ darkPercent: num(r.darkPercent),
113
+ keywordCount: num(r.keywordCount)
114
+ })),
115
+ meta: {
116
+ summary: {
117
+ totalClicks,
118
+ attributedClicks,
119
+ darkClicks,
120
+ darkPercent,
121
+ totalImpressions,
122
+ attributedImpressions
123
+ },
124
+ startDate,
125
+ endDate
126
+ }
127
+ };
128
+ }
129
+ });
130
+ export { darkTrafficAnalyzer };
@@ -0,0 +1,44 @@
1
+ import { buildDataDetailPlan, buildDataDetailRows, shapeDataDetailRowResults, shapeDataDetailRows } from "../query/analyzers.mjs";
2
+ import "../query/index.mjs";
3
+ import { defineAnalyzer, requireAdapter } from "@gscdump/engine/analyzer";
4
+ const dataDetailAnalyzer = defineAnalyzer({
5
+ id: "data-detail",
6
+ sqlRequires: [
7
+ "executeSql",
8
+ "attachedTables",
9
+ "adapter"
10
+ ],
11
+ buildSql(params, ctx) {
12
+ const plan = buildDataDetailPlan(params, {
13
+ adapter: requireAdapter(ctx, "data-detail"),
14
+ siteId: ctx.siteId
15
+ });
16
+ return {
17
+ sql: plan.sql,
18
+ params: plan.params,
19
+ current: {
20
+ table: plan.tableKey,
21
+ partitions: []
22
+ },
23
+ extraQueries: plan.extraQueries
24
+ };
25
+ },
26
+ reduceSql(rows, params, ctx) {
27
+ const { results, meta } = shapeDataDetailRows(Array.isArray(rows) ? rows : [], params, ctx.extras);
28
+ return {
29
+ results,
30
+ meta
31
+ };
32
+ },
33
+ buildRows(params) {
34
+ return buildDataDetailRows(params);
35
+ },
36
+ reduceRows(rows, params) {
37
+ const { results, meta } = shapeDataDetailRowResults(Array.isArray(rows) ? {} : rows, params);
38
+ return {
39
+ results,
40
+ meta
41
+ };
42
+ }
43
+ });
44
+ export { dataDetailAnalyzer };
@@ -0,0 +1,44 @@
1
+ import { buildDataQueryPlan, buildDataQueryRows, shapeDataQueryRowResults, shapeDataQueryRows } from "../query/analyzers.mjs";
2
+ import "../query/index.mjs";
3
+ import { defineAnalyzer, requireAdapter } from "@gscdump/engine/analyzer";
4
+ const dataQueryAnalyzer = defineAnalyzer({
5
+ id: "data-query",
6
+ sqlRequires: [
7
+ "executeSql",
8
+ "attachedTables",
9
+ "adapter"
10
+ ],
11
+ buildSql(params, ctx) {
12
+ const plan = buildDataQueryPlan(params, {
13
+ adapter: requireAdapter(ctx, "data-query"),
14
+ siteId: ctx.siteId
15
+ });
16
+ return {
17
+ sql: plan.sql,
18
+ params: plan.params,
19
+ current: {
20
+ table: plan.tableKey,
21
+ partitions: []
22
+ },
23
+ extraQueries: plan.extraQueries
24
+ };
25
+ },
26
+ reduceSql(rows, params, ctx) {
27
+ const { results, meta } = shapeDataQueryRows(Array.isArray(rows) ? rows : [], params, ctx.extras);
28
+ return {
29
+ results,
30
+ meta
31
+ };
32
+ },
33
+ buildRows(params) {
34
+ return buildDataQueryRows(params);
35
+ },
36
+ reduceRows(rows, params) {
37
+ const { results, meta } = shapeDataQueryRowResults(Array.isArray(rows) ? {} : rows, params);
38
+ return {
39
+ results,
40
+ meta
41
+ };
42
+ }
43
+ });
44
+ export { dataQueryAnalyzer };
@@ -0,0 +1,38 @@
1
+ import { PageRow } from "../types.mjs";
2
+ type DecaySortMetric = 'lostClicks' | 'declinePercent' | 'currentClicks';
3
+ interface DecayOptions {
4
+ /** Minimum clicks in previous period to consider. Default: 50 */
5
+ minPreviousClicks?: number;
6
+ /** Minimum decline percentage (0-1). Default: 0.2 (20%) */
7
+ threshold?: number;
8
+ /** Metric to sort results by. Default: lostClicks */
9
+ sortBy?: DecaySortMetric;
10
+ }
11
+ interface DecayInput {
12
+ current: PageRow[];
13
+ previous: PageRow[];
14
+ }
15
+ interface DecaySeriesPoint {
16
+ week: string;
17
+ clicks: number;
18
+ impressions: number;
19
+ }
20
+ interface DecayResult {
21
+ page: string;
22
+ currentClicks: number;
23
+ previousClicks: number;
24
+ lostClicks: number;
25
+ declinePercent: number;
26
+ /** `null` when the page had no ranking data in the current period (not "position 0"). */
27
+ currentPosition: number | null;
28
+ previousPosition: number;
29
+ /** `null` unless both currentPosition and previousPosition exist. */
30
+ positionDrop: number | null;
31
+ series?: DecaySeriesPoint[];
32
+ }
33
+ /**
34
+ * Pure helper: identify "decaying" content — pages that have lost
35
+ * significant traffic between two periods.
36
+ */
37
+ declare function analyzeDecay(input: DecayInput, options?: DecayOptions): DecayResult[];
38
+ export { DecayInput, DecayOptions, DecayResult, DecaySeriesPoint, DecaySortMetric, analyzeDecay };
@@ -0,0 +1,187 @@
1
+ import { parseJsonRows, rowString } from "../analyzer/row-values.mjs";
2
+ import { pagesQueryState } from "../analyzer/adapt-rows.mjs";
3
+ import { buildPeriodMap, createMetricSorter } from "../types.mjs";
4
+ import { paginateInMemory } from "../analyzer/paginate.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
+ const sortResults = createMetricSorter("lostClicks", {
11
+ lostClicks: "desc",
12
+ declinePercent: "desc",
13
+ currentClicks: "asc"
14
+ });
15
+ function analyzeDecay(input, options = {}) {
16
+ const { minPreviousClicks = 50, threshold = .2, sortBy = "lostClicks" } = options;
17
+ const currentMap = buildPeriodMap(input.current, (r) => r.page, (r) => ({
18
+ clicks: num(r.clicks),
19
+ position: num(r.position)
20
+ }));
21
+ const previousMap = buildPeriodMap(input.previous, (r) => r.page, (r) => ({
22
+ clicks: num(r.clicks),
23
+ position: num(r.position)
24
+ }), (r) => num(r.clicks) >= minPreviousClicks);
25
+ const results = [];
26
+ for (const [page, prev] of previousMap) {
27
+ const curr = currentMap.get(page);
28
+ const currentClicks = curr?.clicks ?? 0;
29
+ const currentPosition = curr?.position ?? null;
30
+ const lostClicks = prev.clicks - currentClicks;
31
+ const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
32
+ if (declinePercent >= threshold && lostClicks > 0) results.push({
33
+ page,
34
+ currentClicks,
35
+ previousClicks: prev.clicks,
36
+ lostClicks,
37
+ declinePercent,
38
+ currentPosition,
39
+ previousPosition: prev.position,
40
+ positionDrop: currentPosition != null ? currentPosition - prev.position : null
41
+ });
42
+ }
43
+ return sortResults(results, sortBy);
44
+ }
45
+ const decayAnalyzer = defineAnalyzer({
46
+ id: "decay",
47
+ buildSql(params) {
48
+ const { current: cur, previous: prev } = comparisonOf(params);
49
+ const minPreviousClicks = params.minPreviousClicks ?? 50;
50
+ const threshold = params.threshold ?? .2;
51
+ return {
52
+ sql: `
53
+ WITH cur AS (
54
+ SELECT
55
+ url,
56
+ ${METRIC_EXPR.clicks} AS clicks,
57
+ ${METRIC_EXPR.position} AS position
58
+ FROM read_parquet({{FILES}}, union_by_name = true)
59
+ WHERE date >= ? AND date <= ?
60
+ GROUP BY url
61
+ ),
62
+ prev AS (
63
+ SELECT
64
+ url,
65
+ ${METRIC_EXPR.clicks} AS clicks,
66
+ ${METRIC_EXPR.position} AS position
67
+ FROM read_parquet({{FILES_PREV}}, union_by_name = true)
68
+ WHERE date >= ? AND date <= ?
69
+ GROUP BY url
70
+ HAVING SUM(clicks) >= ?
71
+ ),
72
+ weekly AS (
73
+ SELECT url, date_trunc('week', CAST(date AS DATE)) AS week,
74
+ ${METRIC_EXPR.clicks} AS clicks,
75
+ ${METRIC_EXPR.impressions} AS impressions
76
+ FROM (
77
+ SELECT url, date, clicks, impressions
78
+ FROM read_parquet({{FILES}}, union_by_name = true)
79
+ WHERE date >= ? AND date <= ?
80
+ UNION ALL
81
+ SELECT url, date, clicks, impressions
82
+ FROM read_parquet({{FILES_PREV}}, union_by_name = true)
83
+ WHERE date >= ? AND date <= ?
84
+ )
85
+ GROUP BY url, week
86
+ ),
87
+ series_by_url AS (
88
+ SELECT url, to_json(list({
89
+ 'week': strftime(week, '%Y-%m-%d'),
90
+ 'clicks': clicks,
91
+ 'impressions': impressions
92
+ } ORDER BY week)) AS seriesJson
93
+ FROM weekly GROUP BY url
94
+ ),
95
+ joined AS (
96
+ SELECT
97
+ p.url AS page,
98
+ COALESCE(c.clicks, 0.0) AS currentClicks,
99
+ p.clicks AS previousClicks,
100
+ (p.clicks - COALESCE(c.clicks, 0.0)) AS lostClicks,
101
+ (p.clicks - COALESCE(c.clicks, 0.0)) / NULLIF(p.clicks, 0) AS declinePercent,
102
+ c.position AS currentPosition,
103
+ p.position AS previousPosition,
104
+ CASE WHEN c.position IS NOT NULL THEN (c.position - p.position) ELSE NULL END AS positionDrop,
105
+ s.seriesJson
106
+ FROM prev p
107
+ LEFT JOIN cur c ON p.url = c.url
108
+ LEFT JOIN series_by_url s ON p.url = s.url
109
+ )
110
+ SELECT *
111
+ FROM joined
112
+ WHERE declinePercent >= ? AND lostClicks > 0
113
+ ORDER BY lostClicks DESC
114
+ LIMIT 2000
115
+ `,
116
+ params: [
117
+ cur.startDate,
118
+ cur.endDate,
119
+ prev.startDate,
120
+ prev.endDate,
121
+ minPreviousClicks,
122
+ cur.startDate,
123
+ cur.endDate,
124
+ prev.startDate,
125
+ prev.endDate,
126
+ threshold
127
+ ],
128
+ current: {
129
+ table: "pages",
130
+ partitions: enumeratePartitions(cur.startDate, cur.endDate)
131
+ },
132
+ previous: {
133
+ table: "pages",
134
+ partitions: enumeratePartitions(prev.startDate, prev.endDate)
135
+ }
136
+ };
137
+ },
138
+ reduceSql(rows, params) {
139
+ const mapped = (Array.isArray(rows) ? rows : []).map((r) => ({
140
+ page: rowString(r.page),
141
+ currentClicks: num(r.currentClicks),
142
+ previousClicks: num(r.previousClicks),
143
+ lostClicks: num(r.lostClicks),
144
+ declinePercent: num(r.declinePercent),
145
+ currentPosition: r.currentPosition == null ? null : num(r.currentPosition),
146
+ previousPosition: num(r.previousPosition),
147
+ positionDrop: r.positionDrop == null ? null : num(r.positionDrop),
148
+ series: parseJsonRows(r.seriesJson).map((s) => ({
149
+ week: rowString(s.week),
150
+ clicks: num(s.clicks),
151
+ impressions: num(s.impressions)
152
+ }))
153
+ }));
154
+ return {
155
+ results: paginateInMemory(mapped, {
156
+ limit: params.limit ?? 2e3,
157
+ offset: params.offset
158
+ }),
159
+ meta: { total: mapped.length }
160
+ };
161
+ },
162
+ buildRows(params) {
163
+ const { current, previous } = comparisonOf(params);
164
+ return {
165
+ current: pagesQueryState(current, params.limit),
166
+ previous: pagesQueryState(previous, params.limit)
167
+ };
168
+ },
169
+ reduceRows(rows, params) {
170
+ const map = rows && !Array.isArray(rows) ? rows : {
171
+ current: [],
172
+ previous: []
173
+ };
174
+ const results = analyzeDecay({
175
+ current: map.current ?? [],
176
+ previous: map.previous ?? []
177
+ }, {
178
+ minPreviousClicks: params.minPreviousClicks,
179
+ threshold: params.threshold
180
+ });
181
+ return {
182
+ results,
183
+ meta: { total: results.length }
184
+ };
185
+ }
186
+ });
187
+ export { analyzeDecay, decayAnalyzer };
@@ -0,0 +1,135 @@
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 deviceGapAnalyzer = defineAnalyzer({
7
+ id: "device-gap",
8
+ buildSql(params) {
9
+ const { startDate, endDate } = periodOf(params);
10
+ return {
11
+ sql: `
12
+ WITH raw AS (
13
+ SELECT
14
+ date,
15
+ COALESCE(clicks_desktop, 0) AS c_desktop,
16
+ COALESCE(clicks_mobile, 0) AS c_mobile,
17
+ COALESCE(clicks_tablet, 0) AS c_tablet,
18
+ COALESCE(impressions_desktop, 0) AS i_desktop,
19
+ COALESCE(impressions_mobile, 0) AS i_mobile,
20
+ COALESCE(impressions_tablet, 0) AS i_tablet,
21
+ COALESCE(sum_position_desktop, 0) AS p_desktop,
22
+ COALESCE(sum_position_mobile, 0) AS p_mobile,
23
+ COALESCE(sum_position_tablet, 0) AS p_tablet
24
+ FROM read_parquet({{FILES}}, union_by_name = true)
25
+ WHERE date >= ? AND date <= ?
26
+ ),
27
+ device_long AS (
28
+ SELECT date, 'DESKTOP' AS device, c_desktop AS clicks, i_desktop AS impressions, p_desktop AS sum_position FROM raw
29
+ UNION ALL
30
+ SELECT date, 'MOBILE', c_mobile, i_mobile, p_mobile FROM raw
31
+ UNION ALL
32
+ SELECT date, 'TABLET', c_tablet, i_tablet, p_tablet FROM raw
33
+ )
34
+ SELECT
35
+ date,
36
+ device,
37
+ CAST(SUM(clicks) AS DOUBLE) AS clicks,
38
+ CAST(SUM(impressions) AS DOUBLE) AS impressions,
39
+ CAST(SUM(clicks) AS DOUBLE) / NULLIF(SUM(impressions), 0) AS ctr,
40
+ SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS position
41
+ FROM device_long
42
+ GROUP BY date, device
43
+ ORDER BY date ASC
44
+ `,
45
+ params: [startDate, endDate],
46
+ current: {
47
+ table: "dates",
48
+ partitions: enumeratePartitions(startDate, endDate)
49
+ }
50
+ };
51
+ },
52
+ reduceSql(rows, params) {
53
+ const arr = Array.isArray(rows) ? rows : [];
54
+ const { startDate, endDate } = periodOf(params);
55
+ const typed = arr.map((r) => ({
56
+ date: rowString(r.date),
57
+ device: rowString(r.device).toUpperCase(),
58
+ clicks: num(r.clicks),
59
+ impressions: num(r.impressions),
60
+ ctr: num(r.ctr),
61
+ position: num(r.position)
62
+ }));
63
+ const byDate = /* @__PURE__ */ new Map();
64
+ for (const r of typed) {
65
+ const entry = byDate.get(r.date) ?? {};
66
+ const metrics = {
67
+ clicks: r.clicks,
68
+ impressions: r.impressions,
69
+ ctr: r.ctr,
70
+ position: r.position
71
+ };
72
+ if (r.device === "DESKTOP") entry.desktop = metrics;
73
+ else if (r.device === "MOBILE") entry.mobile = metrics;
74
+ byDate.set(r.date, entry);
75
+ }
76
+ const zero = {
77
+ clicks: 0,
78
+ impressions: 0,
79
+ ctr: 0,
80
+ position: 0
81
+ };
82
+ const daily = [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, sides]) => {
83
+ const d = sides.desktop ?? zero;
84
+ const m = sides.mobile ?? zero;
85
+ return {
86
+ date,
87
+ desktop: d,
88
+ mobile: m,
89
+ gaps: {
90
+ ctrGap: d.ctr - m.ctr,
91
+ positionGap: m.position - d.position
92
+ }
93
+ };
94
+ });
95
+ const weekly = (start, end) => {
96
+ const slice = daily.slice(start, end);
97
+ if (slice.length === 0) return {
98
+ ctr: 0,
99
+ pos: 0
100
+ };
101
+ const sum = slice.reduce((acc, d) => ({
102
+ ctr: acc.ctr + d.gaps.ctrGap,
103
+ pos: acc.pos + d.gaps.positionGap
104
+ }), {
105
+ ctr: 0,
106
+ pos: 0
107
+ });
108
+ return {
109
+ ctr: sum.ctr / slice.length,
110
+ pos: sum.pos / slice.length
111
+ };
112
+ };
113
+ const first = weekly(0, 7);
114
+ const last = weekly(Math.max(0, daily.length - 7), daily.length);
115
+ const classify = (firstVal, lastVal) => {
116
+ const diff = Math.abs(lastVal) - Math.abs(firstVal);
117
+ if (Math.abs(diff) < .005) return "stable";
118
+ return diff < 0 ? "improving" : "worsening";
119
+ };
120
+ return {
121
+ results: daily,
122
+ meta: {
123
+ summary: {
124
+ avgCtrGap: daily.reduce((s, d) => s + d.gaps.ctrGap, 0) / Math.max(1, daily.length),
125
+ avgPositionGap: daily.reduce((s, d) => s + d.gaps.positionGap, 0) / Math.max(1, daily.length),
126
+ ctrGapTrend: classify(first.ctr, last.ctr),
127
+ positionGapTrend: classify(first.pos, last.pos)
128
+ },
129
+ startDate,
130
+ endDate
131
+ }
132
+ };
133
+ }
134
+ });
135
+ export { deviceGapAnalyzer };