@gscdump/analysis 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,54 @@
1
+ /**
2
+ * Unified `striking-distance` analyzer. Spike for the `defineAnalyzer`
3
+ * pattern: one file owns the SQL plan, the row plan, the typed `InputRow`
4
+ * contract, and the shared reducer.
5
+ *
6
+ * SQL and row plans both emit rows matching `StrikingDistanceInputRow`.
7
+ * Filtering (`minPosition`/`maxPosition`/`minImpressions`/`maxCtr`),
8
+ * derivation (`potentialClicks`), and sorting all live in the reducer, so
9
+ * drift between plans cannot silently change results. A SQL-side `LIMIT`
10
+ * push-down can be added later as an optional optimization, but the
11
+ * correctness contract stays with the reducer.
12
+ */
13
+ /**
14
+ * Row shape both plans produce. Field names match GSC's native columns so
15
+ * the row-source path needs no rename step; the SQL plan aliases `url → page`.
16
+ */
17
+ interface StrikingDistanceInputRow {
18
+ query: string;
19
+ page: string | null;
20
+ clicks: number;
21
+ impressions: number;
22
+ ctr: number;
23
+ position: number;
24
+ }
25
+ interface StrikingDistanceResult {
26
+ keyword: string;
27
+ page: string | null;
28
+ clicks: number;
29
+ impressions: number;
30
+ ctr: number;
31
+ position: number;
32
+ /** Estimated clicks at ~15% CTR (the average for positions 1–3). */
33
+ potentialClicks: number;
34
+ }
35
+ interface StrikingDistanceFilterOptions {
36
+ minPosition?: number;
37
+ maxPosition?: number;
38
+ minImpressions?: number;
39
+ maxCtr?: number;
40
+ }
41
+ interface StrikingDistanceOptions extends StrikingDistanceFilterOptions {
42
+ limit?: number;
43
+ offset?: number;
44
+ }
45
+ /** Pure striking-distance analysis for consumers that already own the rows. */
46
+ declare function analyzeStrikingDistance(rows: readonly {
47
+ query: unknown;
48
+ page?: unknown;
49
+ clicks: unknown;
50
+ impressions: unknown;
51
+ ctr: unknown;
52
+ position: unknown;
53
+ }[], options?: StrikingDistanceOptions): StrikingDistanceResult[];
54
+ export { StrikingDistanceFilterOptions, StrikingDistanceInputRow, StrikingDistanceOptions, StrikingDistanceResult, analyzeStrikingDistance };
@@ -0,0 +1,82 @@
1
+ import { queriesQueryState } from "../analyzer/adapt-rows.mjs";
2
+ import { 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 { num } from "@gscdump/engine/analysis-types";
7
+ const DEFAULT_ROW_LIMIT = 25e3;
8
+ function paginateStrikingDistance(results, options) {
9
+ return paginateSortedInMemory(results, {
10
+ limit: options.limit ?? 1e3,
11
+ offset: options.offset
12
+ }, (left, right) => right.potentialClicks - left.potentialClicks);
13
+ }
14
+ function filterStrikingDistance(rows, options = {}) {
15
+ const minPosition = options.minPosition ?? 4;
16
+ const maxPosition = options.maxPosition ?? 20;
17
+ const minImpressions = options.minImpressions ?? 100;
18
+ const maxCtr = options.maxCtr ?? .05;
19
+ const results = [];
20
+ for (const row of rows) {
21
+ const position = num(row.position);
22
+ const impressions = num(row.impressions);
23
+ const ctr = num(row.ctr);
24
+ const clicks = num(row.clicks);
25
+ if (position < minPosition || position > maxPosition) continue;
26
+ if (impressions < minImpressions) continue;
27
+ if (ctr > maxCtr) continue;
28
+ results.push({
29
+ keyword: String(row.query ?? ""),
30
+ page: row.page == null ? null : String(row.page),
31
+ clicks,
32
+ impressions,
33
+ ctr,
34
+ position,
35
+ potentialClicks: Math.round(impressions * .15)
36
+ });
37
+ }
38
+ return results;
39
+ }
40
+ function analyzeStrikingDistance(rows, options = {}) {
41
+ return paginateStrikingDistance(filterStrikingDistance(rows, options), options);
42
+ }
43
+ const strikingDistanceAnalyzer = defineAnalyzer({
44
+ id: "striking-distance",
45
+ reduce(rows, params) {
46
+ const results = filterStrikingDistance(Array.isArray(rows) ? rows : [], params);
47
+ const paged = paginateStrikingDistance(results, params);
48
+ return {
49
+ results: paged,
50
+ meta: {
51
+ total: results.length,
52
+ returned: paged.length
53
+ }
54
+ };
55
+ },
56
+ buildSql(params) {
57
+ const { startDate, endDate } = periodOf(params);
58
+ return {
59
+ sql: `
60
+ SELECT
61
+ query,
62
+ url AS page,
63
+ CAST(SUM(clicks) AS DOUBLE) AS clicks,
64
+ CAST(SUM(impressions) AS DOUBLE) AS impressions,
65
+ CAST(SUM(clicks) AS DOUBLE) / NULLIF(SUM(impressions), 0) AS ctr,
66
+ SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1 AS position
67
+ FROM read_parquet({{FILES}}, union_by_name = true)
68
+ WHERE date >= ? AND date <= ?
69
+ GROUP BY query, url
70
+ `,
71
+ params: [startDate, endDate],
72
+ current: {
73
+ table: "page_queries",
74
+ partitions: enumeratePartitions(startDate, endDate)
75
+ }
76
+ };
77
+ },
78
+ buildRows(params) {
79
+ return { queries: queriesQueryState(periodOf(params), params.limit ?? DEFAULT_ROW_LIMIT) };
80
+ }
81
+ });
82
+ export { analyzeStrikingDistance, filterStrikingDistance, strikingDistanceAnalyzer };
@@ -0,0 +1,200 @@
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 { MS_PER_DAY, daysAgoUtc } from "gscdump/dates";
8
+ const survivalAnalyzer = defineAnalyzer({
9
+ id: "survival",
10
+ buildSql(params) {
11
+ const endDate = params.endDate ?? defaultEndDate();
12
+ const startDate = params.startDate ?? daysAgoUtc(183);
13
+ const minImpressions = params.minImpressions ?? 5;
14
+ return {
15
+ sql: `
16
+ WITH daily AS (
17
+ SELECT
18
+ query,
19
+ url,
20
+ date,
21
+ ${METRIC_EXPR.clicks} AS day_clicks,
22
+ ${METRIC_EXPR.impressions} AS day_impressions,
23
+ ${METRIC_EXPR.position} AS day_position
24
+ FROM read_parquet({{FILES}}, union_by_name = true)
25
+ WHERE date >= ? AND date <= ?
26
+ AND query IS NOT NULL AND query <> ''
27
+ AND url IS NOT NULL AND url <> ''
28
+ GROUP BY query, url, date
29
+ HAVING SUM(impressions) >= ?
30
+ ),
31
+ classified AS (
32
+ SELECT *,
33
+ (day_position <= 10) AS in_top10
34
+ FROM daily
35
+ ),
36
+ transitions AS (
37
+ SELECT *,
38
+ CASE
39
+ WHEN in_top10 AND (LAG(in_top10) OVER w IS NULL OR NOT LAG(in_top10) OVER w)
40
+ THEN 1 ELSE 0
41
+ END AS is_entry
42
+ FROM classified
43
+ WINDOW w AS (PARTITION BY query, url ORDER BY date)
44
+ ),
45
+ run_ids AS (
46
+ SELECT *,
47
+ SUM(is_entry) OVER (PARTITION BY query, url ORDER BY date) AS run_id
48
+ FROM transitions
49
+ WHERE in_top10
50
+ ),
51
+ window_bounds AS (
52
+ SELECT MIN(date) AS window_start, MAX(date) AS window_end FROM daily
53
+ ),
54
+ episodes_raw AS (
55
+ SELECT
56
+ query, url, run_id,
57
+ MIN(date) AS entry_date,
58
+ MAX(date) AS exit_date,
59
+ DATEDIFF('day', MIN(date), MAX(date)) + 1 AS tenure
60
+ FROM run_ids
61
+ GROUP BY query, url, run_id
62
+ ),
63
+ episodes AS (
64
+ SELECT
65
+ e.query, e.url, e.run_id, e.entry_date, e.exit_date, e.tenure,
66
+ (e.exit_date >= wb.window_end - INTERVAL 2 DAY) AS censored,
67
+ CASE
68
+ WHEN regexp_extract(e.url, '^(?:https?://[^/]+)?(/[^/?#]*)', 1) = '/' OR e.url = '/'
69
+ THEN 'home'
70
+ WHEN regexp_extract(e.url, '^(?:https?://[^/]+)?/([^/?#]+)', 1) = ''
71
+ THEN 'home'
72
+ ELSE regexp_extract(e.url, '^(?:https?://[^/]+)?/([^/?#]+)', 1)
73
+ END AS cohort
74
+ FROM episodes_raw e
75
+ CROSS JOIN window_bounds wb
76
+ ),
77
+ episodes_all AS (
78
+ SELECT query, url, tenure, censored, cohort FROM episodes
79
+ UNION ALL
80
+ SELECT query, url, tenure, censored, '__all__' AS cohort FROM episodes
81
+ ),
82
+ cohort_totals AS (
83
+ SELECT cohort, COUNT(*) AS n_total
84
+ FROM episodes_all
85
+ GROUP BY cohort
86
+ ),
87
+ events AS (
88
+ SELECT
89
+ cohort,
90
+ tenure,
91
+ COUNT(*) FILTER (WHERE NOT censored) AS d_t,
92
+ COUNT(*) AS n_ending_at_t
93
+ FROM episodes_all
94
+ GROUP BY cohort, tenure
95
+ ),
96
+ km AS (
97
+ SELECT
98
+ e.cohort,
99
+ e.tenure,
100
+ e.d_t,
101
+ e.n_ending_at_t,
102
+ SUM(e.n_ending_at_t) OVER (PARTITION BY e.cohort ORDER BY e.tenure DESC
103
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS at_risk
104
+ FROM events e
105
+ ),
106
+ km_surv AS (
107
+ SELECT
108
+ cohort, tenure, d_t, at_risk,
109
+ EXP(SUM(LN(GREATEST(1.0 - CAST(d_t AS DOUBLE) / NULLIF(at_risk, 0), 1e-9)))
110
+ OVER (PARTITION BY cohort ORDER BY tenure
111
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS survival
112
+ FROM km
113
+ ),
114
+ curve_agg AS (
115
+ SELECT
116
+ cohort,
117
+ to_json(list({
118
+ 'tenure': tenure,
119
+ 'survival': survival,
120
+ 'atRisk': at_risk,
121
+ 'events': d_t
122
+ } ORDER BY tenure)) AS curveJson
123
+ FROM km_surv
124
+ GROUP BY cohort
125
+ ),
126
+ cohort_stats AS (
127
+ SELECT
128
+ ea.cohort,
129
+ COUNT(*) AS episode_count,
130
+ AVG(CASE WHEN ea.censored THEN 1.0 ELSE 0.0 END) AS censoring_rate
131
+ FROM episodes_all ea
132
+ GROUP BY ea.cohort
133
+ )
134
+ SELECT
135
+ cs.cohort,
136
+ cs.episode_count AS episodeCount,
137
+ cs.censoring_rate AS censoringRate,
138
+ ca.curveJson
139
+ FROM cohort_stats cs
140
+ LEFT JOIN curve_agg ca USING (cohort)
141
+ ORDER BY cs.cohort
142
+ `,
143
+ params: [
144
+ startDate,
145
+ endDate,
146
+ minImpressions
147
+ ],
148
+ current: {
149
+ table: "page_queries",
150
+ partitions: enumeratePartitions(startDate, endDate)
151
+ }
152
+ };
153
+ },
154
+ reduceSql(rows, params) {
155
+ const arr = Array.isArray(rows) ? rows : [];
156
+ const endDate = params.endDate ?? defaultEndDate();
157
+ const startDate = params.startDate ?? daysAgoUtc(183);
158
+ const windowDays = Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / MS_PER_DAY) + 1;
159
+ const results = arr.map((r) => {
160
+ const curve = parseJsonRows(r.curveJson).map((p) => ({
161
+ tenure: num(p.tenure),
162
+ survival: num(p.survival),
163
+ atRisk: num(p.atRisk),
164
+ events: num(p.events)
165
+ }));
166
+ let medianTenure = 0;
167
+ for (let i = 0; i < curve.length; i++) {
168
+ const cur = curve[i];
169
+ if (cur.survival <= .5) {
170
+ if (i === 0) medianTenure = cur.tenure;
171
+ else {
172
+ const prev = curve[i - 1];
173
+ const span = prev.survival - cur.survival;
174
+ const frac = span > 0 ? (prev.survival - .5) / span : 0;
175
+ medianTenure = prev.tenure + frac * (cur.tenure - prev.tenure);
176
+ }
177
+ break;
178
+ }
179
+ }
180
+ const last = curve[curve.length - 1];
181
+ if (medianTenure === 0 && last && last.survival > .5) medianTenure = last.tenure;
182
+ return {
183
+ cohort: rowString(r.cohort),
184
+ episodeCount: num(r.episodeCount),
185
+ censoringRate: num(r.censoringRate),
186
+ medianTenure,
187
+ curve
188
+ };
189
+ });
190
+ return {
191
+ results,
192
+ meta: {
193
+ totalEpisodes: results.find((r) => r.cohort === "__all__")?.episodeCount ?? 0,
194
+ cohortCount: results.filter((r) => r.cohort !== "__all__").length,
195
+ windowDays
196
+ }
197
+ };
198
+ }
199
+ });
200
+ export { survivalAnalyzer };
@@ -0,0 +1,156 @@
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 { MS_PER_DAY, toIsoDate } from "gscdump/dates";
8
+ const trendsAnalyzer = defineAnalyzer({
9
+ id: "trends",
10
+ buildSql(params) {
11
+ const weeks = params.weeks ?? 28;
12
+ const endDate = params.endDate || defaultEndDate();
13
+ const startDate = params.startDate || toIsoDate(/* @__PURE__ */ new Date(Date.parse(endDate) - (weeks * 7 - 1) * MS_PER_DAY));
14
+ const minImpressions = params.minImpressions ?? 100;
15
+ const minWeeksWithData = params.minWeeksWithData ?? Math.max(2, Math.floor(weeks / 4));
16
+ const limit = params.limit ?? 500;
17
+ const dim = params.dimension === "keywords" ? "keywords" : "pages";
18
+ const table = dim === "keywords" ? "queries" : "pages";
19
+ return {
20
+ sql: `
21
+ WITH bucketed AS (
22
+ SELECT
23
+ ${dim === "keywords" ? "query" : "url"} AS entity,
24
+ date_trunc('week', CAST(date AS DATE)) AS week,
25
+ ${METRIC_EXPR.clicks} AS clicks,
26
+ ${METRIC_EXPR.impressions} AS impressions,
27
+ SUM(sum_position) AS sum_position_sum
28
+ FROM read_parquet({{FILES}}, union_by_name = true)
29
+ WHERE date >= ? AND date <= ?
30
+ GROUP BY entity, week
31
+ ),
32
+ with_meta AS (
33
+ SELECT
34
+ entity, week, clicks, impressions, sum_position_sum,
35
+ ROW_NUMBER() OVER (PARTITION BY entity ORDER BY week) - 1 AS week_idx,
36
+ COUNT(*) OVER (PARTITION BY entity) AS n_weeks,
37
+ (ROW_NUMBER() OVER (PARTITION BY entity ORDER BY week) - 1)
38
+ < (COUNT(*) OVER (PARTITION BY entity) / 2) AS is_first_half
39
+ FROM bucketed
40
+ ),
41
+ agg AS (
42
+ SELECT
43
+ entity,
44
+ SUM(clicks) AS totalClicks,
45
+ SUM(impressions) AS totalImpressions,
46
+ any_value(n_weeks) AS weeksWithData,
47
+ COALESCE(regr_slope(clicks, CAST(week_idx AS DOUBLE)), 0.0) AS slope,
48
+ SUM(CASE WHEN is_first_half THEN clicks ELSE 0 END) AS firstHalfClicks,
49
+ SUM(CASE WHEN NOT is_first_half THEN clicks ELSE 0 END) AS secondHalfClicks,
50
+ SUM(sum_position_sum) / NULLIF(SUM(impressions), 0) + 1 AS avgPosition,
51
+ to_json(list({
52
+ 'week': strftime(week, '%Y-%m-%d'),
53
+ 'clicks': clicks,
54
+ 'impressions': impressions
55
+ } ORDER BY week)) AS seriesJson
56
+ FROM with_meta
57
+ GROUP BY entity
58
+ HAVING SUM(impressions) >= ? AND any_value(n_weeks) >= ?
59
+ ),
60
+ classified AS (
61
+ SELECT
62
+ *,
63
+ CASE
64
+ WHEN firstHalfClicks = 0 AND secondHalfClicks > 0 THEN 10.0
65
+ WHEN firstHalfClicks = 0 THEN 1.0
66
+ ELSE secondHalfClicks / firstHalfClicks
67
+ END AS growthRatio
68
+ FROM agg
69
+ )
70
+ SELECT
71
+ entity,
72
+ totalClicks,
73
+ totalImpressions,
74
+ weeksWithData,
75
+ slope,
76
+ growthRatio,
77
+ avgPosition,
78
+ CASE
79
+ WHEN growthRatio >= 1.5 AND slope > 0 THEN 'accelerating'
80
+ WHEN growthRatio >= 1.1 AND slope >= 0 THEN 'growing'
81
+ WHEN growthRatio < 0.5 THEN 'cratering'
82
+ WHEN growthRatio < 0.9 AND slope < 0 THEN 'declining'
83
+ ELSE 'steady'
84
+ END AS trend,
85
+ seriesJson
86
+ FROM classified
87
+ ORDER BY
88
+ CASE
89
+ WHEN growthRatio >= 1.5 AND slope > 0 THEN 0
90
+ WHEN growthRatio < 0.5 THEN 1
91
+ WHEN growthRatio >= 1.1 AND slope >= 0 THEN 2
92
+ WHEN growthRatio < 0.9 AND slope < 0 THEN 3
93
+ ELSE 4
94
+ END,
95
+ ABS(growthRatio - 1) DESC,
96
+ totalClicks DESC
97
+ LIMIT ${Number(limit)}
98
+ `,
99
+ params: [
100
+ startDate,
101
+ endDate,
102
+ minImpressions,
103
+ minWeeksWithData
104
+ ],
105
+ current: {
106
+ table,
107
+ partitions: enumeratePartitions(startDate, endDate)
108
+ }
109
+ };
110
+ },
111
+ reduceSql(rows, params) {
112
+ const arr = Array.isArray(rows) ? rows : [];
113
+ const weeks = params.weeks ?? 28;
114
+ const endDate = params.endDate || defaultEndDate();
115
+ const startDate = params.startDate || toIsoDate(/* @__PURE__ */ new Date(Date.parse(endDate) - (weeks * 7 - 1) * MS_PER_DAY));
116
+ const dim = params.dimension === "keywords" ? "keywords" : "pages";
117
+ const results = arr.map((r) => {
118
+ const series = parseJsonRows(r.seriesJson).map((s) => ({
119
+ week: rowString(s.week),
120
+ clicks: num(s.clicks),
121
+ impressions: num(s.impressions)
122
+ }));
123
+ return {
124
+ [dim === "keywords" ? "query" : "page"]: rowString(r.entity),
125
+ totalClicks: num(r.totalClicks),
126
+ totalImpressions: num(r.totalImpressions),
127
+ weeksWithData: num(r.weeksWithData),
128
+ slope: num(r.slope),
129
+ growthRatio: num(r.growthRatio),
130
+ avgPosition: num(r.avgPosition),
131
+ trend: rowString(r.trend),
132
+ series
133
+ };
134
+ });
135
+ const counts = {
136
+ accelerating: 0,
137
+ growing: 0,
138
+ steady: 0,
139
+ declining: 0,
140
+ cratering: 0
141
+ };
142
+ for (const r of results) counts[r.trend] = (counts[r.trend] ?? 0) + 1;
143
+ return {
144
+ results,
145
+ meta: {
146
+ total: results.length,
147
+ dimension: dim,
148
+ weeks: Number(weeks),
149
+ startDate,
150
+ endDate,
151
+ counts
152
+ }
153
+ };
154
+ }
155
+ });
156
+ export { trendsAnalyzer };
@@ -0,0 +1,17 @@
1
+ import { QueryPageRow } from "../types.mjs";
2
+ interface ZeroClickResult {
3
+ query: string;
4
+ page: string;
5
+ clicks: number;
6
+ impressions: number;
7
+ ctr: number;
8
+ position: number;
9
+ }
10
+ interface ZeroClickOptions {
11
+ minImpressions?: number;
12
+ maxCtr?: number;
13
+ maxPosition?: number;
14
+ }
15
+ /** Pure zero-click detector shared by hosted and package analyzer callers. */
16
+ declare function analyzeZeroClick(rows: QueryPageRow[], options?: ZeroClickOptions): ZeroClickResult[];
17
+ export { ZeroClickOptions, ZeroClickResult, analyzeZeroClick };
@@ -0,0 +1,122 @@
1
+ import { paginateClause, paginateSortedInMemory } from "../analyzer/paginate.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
+ import { between, date, gsc, page, query } from "gscdump/query";
8
+ const DEFAULT_ROW_LIMIT = 25e3;
9
+ function analyzeZeroClick(rows, options = {}) {
10
+ const minImpressions = options.minImpressions ?? 1e3;
11
+ const maxCtr = options.maxCtr ?? .03;
12
+ const maxPosition = options.maxPosition ?? 10;
13
+ const queryMap = /* @__PURE__ */ new Map();
14
+ for (const row of rows) {
15
+ if (row.impressions < minImpressions || row.position > maxPosition || row.ctr > maxCtr) continue;
16
+ const existing = queryMap.get(row.query);
17
+ if (!existing || row.position < existing.position) queryMap.set(row.query, { ...row });
18
+ }
19
+ return [...queryMap.values()].sort((left, right) => right.impressions - left.impressions);
20
+ }
21
+ const zeroClickAnalyzer = defineAnalyzer({
22
+ id: "zero-click",
23
+ buildSql(params) {
24
+ const { startDate, endDate } = periodOf(params);
25
+ const minImpressions = params.minImpressions ?? 1e3;
26
+ const maxCtr = params.maxCtr ?? .03;
27
+ const maxPosition = params.maxPosition ?? 10;
28
+ const limit = params.limit ?? 1e3;
29
+ return {
30
+ sql: `
31
+ WITH agg AS (
32
+ SELECT
33
+ query,
34
+ url AS page,
35
+ ${METRIC_EXPR.clicks} AS clicks,
36
+ ${METRIC_EXPR.impressions} AS impressions,
37
+ ${METRIC_EXPR.ctr} AS ctr,
38
+ ${METRIC_EXPR.position} AS position
39
+ FROM read_parquet({{FILES}}, union_by_name = true)
40
+ WHERE date >= ? AND date <= ?
41
+ GROUP BY query, url
42
+ HAVING SUM(impressions) >= ?
43
+ )
44
+ SELECT
45
+ query, page, clicks, impressions, ctr, position,
46
+ CAST(GREATEST(0, ROUND(impressions * (
47
+ CASE
48
+ WHEN position <= 1 THEN 0.30
49
+ WHEN position <= 3 THEN 0.15
50
+ WHEN position <= 5 THEN 0.08
51
+ ELSE 0.04
52
+ END
53
+ )) - clicks) AS DOUBLE) AS missedClicks
54
+ FROM agg
55
+ WHERE position <= ? AND ctr < ?
56
+ ORDER BY impressions DESC
57
+ ${paginateClause({
58
+ limit,
59
+ offset: params.offset
60
+ })}
61
+ `,
62
+ params: [
63
+ startDate,
64
+ endDate,
65
+ minImpressions,
66
+ maxPosition,
67
+ maxCtr
68
+ ],
69
+ current: {
70
+ table: "page_queries",
71
+ partitions: enumeratePartitions(startDate, endDate)
72
+ }
73
+ };
74
+ },
75
+ reduceSql(rows, params) {
76
+ const arr = Array.isArray(rows) ? rows : [];
77
+ const minImpressions = params.minImpressions ?? 1e3;
78
+ const maxCtr = params.maxCtr ?? .03;
79
+ const maxPosition = params.maxPosition ?? 10;
80
+ return {
81
+ results: arr.map((r) => ({
82
+ query: r.query == null ? "" : String(r.query),
83
+ page: r.page == null ? "" : String(r.page),
84
+ clicks: num(r.clicks),
85
+ impressions: num(r.impressions),
86
+ ctr: num(r.ctr),
87
+ position: num(r.position),
88
+ missedClicks: num(r.missedClicks)
89
+ })),
90
+ meta: {
91
+ total: arr.length,
92
+ minImpressions,
93
+ maxCtr,
94
+ maxPosition
95
+ }
96
+ };
97
+ },
98
+ buildRows(params) {
99
+ const period = periodOf(params);
100
+ const limit = params.limit ?? DEFAULT_ROW_LIMIT;
101
+ return { rows: gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit).getState() };
102
+ },
103
+ reduceRows(rows, params) {
104
+ const results = analyzeZeroClick(Array.isArray(rows) ? rows : [], {
105
+ minImpressions: params.minImpressions ?? 1e3,
106
+ maxCtr: params.maxCtr ?? .03,
107
+ maxPosition: params.maxPosition ?? 10
108
+ });
109
+ const paged = paginateSortedInMemory(results, {
110
+ limit: params.limit,
111
+ offset: params.offset
112
+ }, (left, right) => right.impressions - left.impressions);
113
+ return {
114
+ results: paged,
115
+ meta: {
116
+ total: results.length,
117
+ returned: paged.length
118
+ }
119
+ };
120
+ }
121
+ });
122
+ export { analyzeZeroClick, zeroClickAnalyzer };
@@ -0,0 +1,5 @@
1
+ import { AnalyzerRegistry } from "@gscdump/engine/analyzer";
2
+ import { AnalysisParams, AnalysisResult } from "@gscdump/engine/analysis-types";
3
+ import { AttachedTableRunner, AttachedTableRunner as AnalyzerRunner, AttachedTableSourceOptions, AttachedTableSourceOptions as BrowserAnalyzeOptions } from "@gscdump/engine/source";
4
+ declare function analyzeInBrowser(runner: AttachedTableRunner, opts: AttachedTableSourceOptions, params: AnalysisParams, registry: AnalyzerRegistry): Promise<AnalysisResult>;
5
+ export { type AnalyzerRunner, type BrowserAnalyzeOptions, analyzeInBrowser };
@@ -0,0 +1,11 @@
1
+ import { runAnalyzerFromSource } from "@gscdump/engine/analyzer";
2
+ import { pgResolverAdapter } from "@gscdump/engine/resolver";
3
+ import { createAttachedTableSource } from "@gscdump/engine/source";
4
+ async function analyzeInBrowser(runner, opts, params, registry) {
5
+ opts.signal?.throwIfAborted();
6
+ return runAnalyzerFromSource(createAttachedTableSource(runner, {
7
+ adapter: pgResolverAdapter,
8
+ ...opts
9
+ }), params, registry);
10
+ }
11
+ export { analyzeInBrowser };
@@ -1,2 +1,4 @@
1
- import { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry } from "./_chunks/default-registry.mjs";
1
+ import { ROW_ANALYZERS } from "./analyzer/row-analyzers.mjs";
2
+ import { SQL_ANALYZERS } from "./sql-analyzers.mjs";
3
+ declare const defaultAnalyzerRegistry: import("@gscdump/engine/analyzer").AnalyzerRegistry;
2
4
  export { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry };
@@ -1,2 +1,6 @@
1
- import { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry } from "./_chunks/default-registry.mjs";
1
+ import { ALL_ANALYZERS } from "./analyzer/all.mjs";
2
+ import { ROW_ANALYZERS } from "./analyzer/row-analyzers.mjs";
3
+ import { SQL_ANALYZERS } from "./sql-analyzers.mjs";
4
+ import { createAnalyzerRegistry } from "@gscdump/engine/analyzer";
5
+ const defaultAnalyzerRegistry = createAnalyzerRegistry({ defined: ALL_ANALYZERS });
2
6
  export { ROW_ANALYZERS, SQL_ANALYZERS, defaultAnalyzerRegistry };