@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,301 @@
1
+ import { padTimeseries } from "@gscdump/engine/period";
2
+ import { extractDateRange } from "gscdump/query";
3
+ import { buildExtrasQueries, buildTotalsSql, mergeExtras, resolveComparisonSQL, resolveToSQLOptimized } from "@gscdump/engine/resolver";
4
+ function requireBuilderState(input, tool) {
5
+ if (!input || typeof input !== "object" || !("dimensions" in input) || !Array.isArray(input.dimensions)) throw new Error(`${tool}: params.q is required (BuilderState)`);
6
+ return input;
7
+ }
8
+ function optionalBuilderState(input, tool, key) {
9
+ if (input == null) return null;
10
+ if (typeof input !== "object" || !("dimensions" in input) || !Array.isArray(input.dimensions)) throw new Error(`${tool}: params.${key} must be a BuilderState`);
11
+ return input;
12
+ }
13
+ const NUMERIC_METRIC_COLS = [
14
+ "clicks",
15
+ "impressions",
16
+ "ctr",
17
+ "position",
18
+ "prevClicks",
19
+ "prevImpressions",
20
+ "prevCtr",
21
+ "prevPosition",
22
+ "variantCount",
23
+ "totalCount"
24
+ ];
25
+ function coerceNumericCols(row) {
26
+ const out = { ...row };
27
+ for (const col of NUMERIC_METRIC_COLS) if (col in out && out[col] != null) out[col] = Number(out[col]);
28
+ return out;
29
+ }
30
+ function shapeDataQuery(rows, extras, opts) {
31
+ let totalCount;
32
+ let cleaned;
33
+ let totals;
34
+ if (opts.hasPrev) {
35
+ cleaned = rows.map(coerceNumericCols);
36
+ totalCount = Number((extras?.count?.[0])?.total ?? cleaned.length);
37
+ const totalsRow = extras?.totals?.[0] ?? {};
38
+ totals = {
39
+ clicks: Number(totalsRow.clicks ?? 0),
40
+ impressions: Number(totalsRow.impressions ?? 0),
41
+ ctr: Number(totalsRow.ctr ?? 0),
42
+ position: Number(totalsRow.position ?? 0)
43
+ };
44
+ } else {
45
+ const first = rows[0];
46
+ totalCount = Number(first?.totalCount ?? 0);
47
+ totals = {
48
+ clicks: Number(first?.totalClicks ?? 0),
49
+ impressions: Number(first?.totalImpressions ?? 0),
50
+ ctr: Number(first?.totalCtr ?? 0),
51
+ position: Number(first?.totalPosition ?? 0)
52
+ };
53
+ cleaned = rows.map((raw) => {
54
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
55
+ return coerceNumericCols(rest);
56
+ });
57
+ }
58
+ const extrasResults = [];
59
+ if (extras?.canonicalExtras) extrasResults.push({
60
+ key: "canonicalExtras",
61
+ results: extras.canonicalExtras
62
+ });
63
+ return {
64
+ results: mergeExtras(cleaned, extrasResults),
65
+ meta: {
66
+ totalCount,
67
+ totals
68
+ }
69
+ };
70
+ }
71
+ function buildDataQueryPlan(params, options) {
72
+ const state = requireBuilderState(params.q, "data-query");
73
+ if (state.dimensions.includes("date")) throw new Error("data-query: date dimension not supported; use data-detail");
74
+ const prev = optionalBuilderState(params.qc, "data-query", "qc");
75
+ const extraQueries = [...buildExtrasQueries(state, options).map((extra) => ({
76
+ name: extra.key,
77
+ sql: extra.sql,
78
+ params: extra.params
79
+ }))];
80
+ const tableKey = options.adapter.inferTable(state.dimensions);
81
+ if (prev) {
82
+ const totals = buildTotalsSql(state, options);
83
+ const comparison = resolveComparisonSQL(state, prev, options, params.comparisonFilter);
84
+ extraQueries.unshift({
85
+ name: "totals",
86
+ sql: totals.sql,
87
+ params: totals.params
88
+ });
89
+ extraQueries.push({
90
+ name: "count",
91
+ sql: comparison.countSql,
92
+ params: comparison.countParams
93
+ });
94
+ return {
95
+ tableKey,
96
+ sql: comparison.sql,
97
+ params: comparison.params,
98
+ extraQueries
99
+ };
100
+ }
101
+ const optimized = resolveToSQLOptimized(state, options);
102
+ return {
103
+ tableKey,
104
+ sql: optimized.sql,
105
+ params: optimized.params,
106
+ extraQueries
107
+ };
108
+ }
109
+ function totalsState(state) {
110
+ return {
111
+ ...state,
112
+ dimensions: [],
113
+ orderBy: void 0,
114
+ rowLimit: 1
115
+ };
116
+ }
117
+ function readTotals(row) {
118
+ return {
119
+ clicks: Number(row?.clicks ?? 0),
120
+ impressions: Number(row?.impressions ?? 0),
121
+ ctr: Number(row?.ctr ?? 0),
122
+ position: Number(row?.position ?? 0)
123
+ };
124
+ }
125
+ function buildDataQueryRows(params) {
126
+ const state = requireBuilderState(params.q, "data-query");
127
+ if (state.dimensions.includes("date")) throw new Error("data-query: date dimension not supported; use data-detail");
128
+ const queries = {
129
+ main: state,
130
+ totals: totalsState(state)
131
+ };
132
+ const prev = optionalBuilderState(params.qc, "data-query", "qc");
133
+ if (prev) {
134
+ queries.prevMain = prev;
135
+ queries.prevTotals = totalsState(prev);
136
+ }
137
+ return queries;
138
+ }
139
+ function shapeDataQueryRowResults(rowMap, params) {
140
+ const main = (rowMap.main ?? []).map((r) => coerceNumericCols(r));
141
+ const totals = readTotals(rowMap.totals?.[0]);
142
+ if (params.qc == null) return {
143
+ results: main,
144
+ meta: {
145
+ totalCount: main.length,
146
+ totals
147
+ }
148
+ };
149
+ const state = requireBuilderState(params.q, "data-query");
150
+ const dims = state.dimensions;
151
+ const keyOf = (row) => dims.map((d) => String(row[d] ?? "")).join("");
152
+ const prevByKey = /* @__PURE__ */ new Map();
153
+ for (const r of rowMap.prevMain ?? []) prevByKey.set(keyOf(r), r);
154
+ const filter = params.comparisonFilter;
155
+ const merged = [];
156
+ const seen = /* @__PURE__ */ new Set();
157
+ for (const cur of main) {
158
+ const key = keyOf(cur);
159
+ seen.add(key);
160
+ const prev = prevByKey.get(key);
161
+ const row = {
162
+ ...cur,
163
+ prevClicks: Number(prev?.clicks ?? 0),
164
+ prevImpressions: Number(prev?.impressions ?? 0),
165
+ prevCtr: Number(prev?.ctr ?? 0),
166
+ prevPosition: Number(prev?.position ?? 0)
167
+ };
168
+ if (passesComparisonFilter(filter, {
169
+ isNew: !prev,
170
+ isLost: false,
171
+ clicksChange: Number(cur.clicks ?? 0) - Number(prev?.clicks ?? 0)
172
+ })) merged.push(row);
173
+ }
174
+ for (const prev of rowMap.prevMain ?? []) {
175
+ const p = prev;
176
+ const key = keyOf(p);
177
+ if (seen.has(key)) continue;
178
+ const row = {
179
+ ...p,
180
+ clicks: 0,
181
+ impressions: 0,
182
+ ctr: 0,
183
+ position: 0,
184
+ prevClicks: Number(p.clicks ?? 0),
185
+ prevImpressions: Number(p.impressions ?? 0),
186
+ prevCtr: Number(p.ctr ?? 0),
187
+ prevPosition: Number(p.position ?? 0)
188
+ };
189
+ if (passesComparisonFilter(filter, {
190
+ isNew: false,
191
+ isLost: true,
192
+ clicksChange: -Number(p.clicks ?? 0)
193
+ })) merged.push(row);
194
+ }
195
+ if (state.orderBy) {
196
+ const { column, dir } = state.orderBy;
197
+ merged.sort((a, b) => {
198
+ const av = Number(a[column]) || 0;
199
+ const bv = Number(b[column]) || 0;
200
+ return dir === "asc" ? av - bv : bv - av;
201
+ });
202
+ }
203
+ return {
204
+ results: merged.map((r) => coerceNumericCols(r)),
205
+ meta: {
206
+ totalCount: merged.length,
207
+ totals
208
+ }
209
+ };
210
+ }
211
+ function passesComparisonFilter(filter, ctx) {
212
+ switch (filter) {
213
+ case "new": return ctx.isNew;
214
+ case "lost": return ctx.isLost;
215
+ case "improving": return ctx.clicksChange > 0;
216
+ case "declining": return ctx.clicksChange < 0;
217
+ default: return true;
218
+ }
219
+ }
220
+ function buildDataDetailRows(params) {
221
+ const state = requireBuilderState(params.q, "data-detail");
222
+ if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
223
+ const queries = {
224
+ main: state,
225
+ totals: totalsState(state)
226
+ };
227
+ const prev = optionalBuilderState(params.qc, "data-detail", "qc");
228
+ if (prev) queries.prevTotals = totalsState(prev);
229
+ return queries;
230
+ }
231
+ function shapeDataDetailRowResults(rowMap, params) {
232
+ const { startDate, endDate } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
233
+ const coerced = (rowMap.main ?? []).map((r) => coerceNumericCols(r));
234
+ const daily = startDate && endDate ? padTimeseries(coerced, {
235
+ startDate,
236
+ endDate
237
+ }) : coerced;
238
+ const meta = { totals: readTotals(rowMap.totals?.[0]) };
239
+ if (rowMap.prevTotals) meta.previousTotals = readTotals(rowMap.prevTotals[0]);
240
+ return {
241
+ results: daily,
242
+ meta
243
+ };
244
+ }
245
+ function shapeDataQueryRows(rows, params, extras) {
246
+ return shapeDataQuery(rows, extras, { hasPrev: params.qc != null });
247
+ }
248
+ function buildDataDetailPlan(params, options) {
249
+ const state = requireBuilderState(params.q, "data-detail");
250
+ if (!state.dimensions.includes("date")) throw new Error("data-detail: `date` dimension is required");
251
+ const main = resolveToSQLOptimized(state, options);
252
+ const prev = optionalBuilderState(params.qc, "data-detail", "qc");
253
+ const extraQueries = [];
254
+ if (prev) {
255
+ const previousTotals = buildTotalsSql(prev, options);
256
+ extraQueries.push({
257
+ name: "prevTotals",
258
+ sql: previousTotals.sql,
259
+ params: previousTotals.params
260
+ });
261
+ }
262
+ return {
263
+ tableKey: options.adapter.inferTable(state.dimensions),
264
+ sql: main.sql,
265
+ params: main.params,
266
+ extraQueries
267
+ };
268
+ }
269
+ function shapeDataDetailRows(rows, params, extras) {
270
+ const { startDate: rangeStart, endDate: rangeEnd } = extractDateRange(requireBuilderState(params.q, "data-detail").filter);
271
+ const first = rows[0];
272
+ const totals = {
273
+ clicks: Number(first?.totalClicks ?? 0),
274
+ impressions: Number(first?.totalImpressions ?? 0),
275
+ ctr: Number(first?.totalCtr ?? 0),
276
+ position: Number(first?.totalPosition ?? 0)
277
+ };
278
+ const coerced = rows.map((raw) => {
279
+ const { totalCount: _tc, totalClicks: _tclk, totalImpressions: _timp, totalCtr: _tctr, totalPosition: _tpos, sum_position: _sp, ...rest } = raw;
280
+ return coerceNumericCols(rest);
281
+ });
282
+ const daily = rangeStart && rangeEnd ? padTimeseries(coerced, {
283
+ startDate: rangeStart,
284
+ endDate: rangeEnd
285
+ }) : coerced;
286
+ const meta = { totals };
287
+ if (extras?.prevTotals) {
288
+ const previousTotalsRow = extras.prevTotals[0] ?? {};
289
+ meta.previousTotals = {
290
+ clicks: Number(previousTotalsRow.clicks ?? 0),
291
+ impressions: Number(previousTotalsRow.impressions ?? 0),
292
+ ctr: Number(previousTotalsRow.ctr ?? 0),
293
+ position: Number(previousTotalsRow.position ?? 0)
294
+ };
295
+ }
296
+ return {
297
+ results: daily,
298
+ meta
299
+ };
300
+ }
301
+ export { buildDataDetailPlan, buildDataDetailRows, buildDataQueryPlan, buildDataQueryRows, shapeDataDetailRowResults, shapeDataDetailRows, shapeDataQueryRowResults, shapeDataQueryRows };
@@ -0,0 +1,4 @@
1
+ import { buildDataDetailPlan, buildDataDetailRows, buildDataQueryPlan, buildDataQueryRows, shapeDataDetailRowResults, shapeDataDetailRows, shapeDataQueryRowResults, shapeDataQueryRows } from "./analyzers.mjs";
2
+ import "./intent.mjs";
3
+ import "./normalize.mjs";
4
+ export { buildDataDetailPlan, buildDataDetailRows, buildDataQueryPlan, buildDataQueryRows, shapeDataDetailRowResults, shapeDataDetailRows, shapeDataQueryRowResults, shapeDataQueryRows };
@@ -0,0 +1,20 @@
1
+ declare const INTENT_CLASSIFIER_VERSION = 1;
2
+ type SearchIntent = 'transactional' | 'commercial' | 'informational' | 'unknown';
3
+ interface IntentClassification {
4
+ /** Primary intent, by priority transactional > commercial > informational. */
5
+ intent: SearchIntent;
6
+ /** Procedural sub-signal of informational ("how to …"). */
7
+ howTo: boolean;
8
+ /** Matched cue tokens/phrases, for explainability + debugging. */
9
+ signals: readonly string[];
10
+ }
11
+ declare const SEARCH_INTENT_CODE: Record<SearchIntent, number>;
12
+ /** Pack a classification into one small int for cheap dimension storage. */
13
+ declare function encodeIntent(c: Pick<IntentClassification, 'intent' | 'howTo'>): number;
14
+ /**
15
+ * Classify the search intent of a raw query from lexical cues. Deterministic;
16
+ * version it (`INTENT_CLASSIFIER_VERSION`) and bump on any rule change so a
17
+ * materialized `intent` can detect staleness, like the canonical normalizer.
18
+ */
19
+ declare function classifyQueryIntent(query: string): IntentClassification;
20
+ export { INTENT_CLASSIFIER_VERSION, IntentClassification, SEARCH_INTENT_CODE, SearchIntent, classifyQueryIntent, encodeIntent };
@@ -0,0 +1,139 @@
1
+ const INTENT_CLASSIFIER_VERSION = 1;
2
+ const SEARCH_INTENT_CODE = {
3
+ unknown: 0,
4
+ informational: 1,
5
+ commercial: 2,
6
+ transactional: 3
7
+ };
8
+ const HOWTO_BIT = 4;
9
+ function encodeIntent(c) {
10
+ return SEARCH_INTENT_CODE[c.intent] | (c.howTo ? HOWTO_BIT : 0);
11
+ }
12
+ const INFORMATIONAL = /* @__PURE__ */ new Set([
13
+ "how",
14
+ "what",
15
+ "why",
16
+ "when",
17
+ "where",
18
+ "who",
19
+ "which",
20
+ "whose",
21
+ "whom",
22
+ "guide",
23
+ "tutorial",
24
+ "example",
25
+ "examples",
26
+ "meaning",
27
+ "definition",
28
+ "define",
29
+ "explained",
30
+ "explain",
31
+ "tip",
32
+ "idea",
33
+ "learn",
34
+ "basics",
35
+ "intro",
36
+ "introduction",
37
+ "overview",
38
+ "cheatsheet",
39
+ "documentation",
40
+ "docs",
41
+ "faq"
42
+ ]);
43
+ const COMMERCIAL = /* @__PURE__ */ new Set([
44
+ "best",
45
+ "top",
46
+ "vs",
47
+ "versus",
48
+ "review",
49
+ "comparison",
50
+ "compare",
51
+ "alternative",
52
+ "alternatives",
53
+ "cheapest",
54
+ "recommended",
55
+ "popular"
56
+ ]);
57
+ const TRANSACTIONAL = /* @__PURE__ */ new Set([
58
+ "buy",
59
+ "price",
60
+ "pricing",
61
+ "cost",
62
+ "cheap",
63
+ "coupon",
64
+ "deal",
65
+ "discount",
66
+ "order",
67
+ "purchase",
68
+ "download",
69
+ "subscribe",
70
+ "subscription",
71
+ "signup",
72
+ "trial",
73
+ "demo",
74
+ "install",
75
+ "free",
76
+ "sale",
77
+ "quote",
78
+ "checkout",
79
+ "cart"
80
+ ]);
81
+ const PHRASE_CUES = [
82
+ {
83
+ re: /\bfor sale\b/,
84
+ intent: "transactional"
85
+ },
86
+ {
87
+ re: /\bsign up\b/,
88
+ intent: "transactional"
89
+ },
90
+ {
91
+ re: /\bnear me\b/,
92
+ intent: "transactional"
93
+ },
94
+ {
95
+ re: /\bhow much\b/,
96
+ intent: "transactional"
97
+ }
98
+ ];
99
+ const SEPARATOR_RE = /[-_/.@#:+]+/g;
100
+ const WHITESPACE_RE = /\s+/g;
101
+ const DIACRITICS_RE = /\p{Diacritic}/gu;
102
+ function tokenize(query) {
103
+ let index = 0;
104
+ while (index < query.length && query.charCodeAt(index) <= 127) index++;
105
+ const text = (index === query.length ? query : query.normalize("NFKD").replace(DIACRITICS_RE, "")).toLowerCase().replace(SEPARATOR_RE, " ").replace(WHITESPACE_RE, " ").trim();
106
+ return {
107
+ text,
108
+ tokens: text.length === 0 ? [] : text.split(" ")
109
+ };
110
+ }
111
+ function classifyQueryIntent(query) {
112
+ const { text, tokens } = tokenize(query);
113
+ const signals = [];
114
+ let transactional = false;
115
+ let commercial = false;
116
+ let informational = false;
117
+ for (const t of tokens) if (TRANSACTIONAL.has(t)) {
118
+ transactional = true;
119
+ signals.push(t);
120
+ } else if (COMMERCIAL.has(t)) {
121
+ commercial = true;
122
+ signals.push(t);
123
+ } else if (INFORMATIONAL.has(t)) {
124
+ informational = true;
125
+ signals.push(t);
126
+ }
127
+ for (const { re, intent } of PHRASE_CUES) if (re.test(text)) {
128
+ signals.push(re.source);
129
+ if (intent === "transactional") transactional = true;
130
+ }
131
+ const howTo = /\bhow to\b/.test(text);
132
+ if (howTo) informational = true;
133
+ return {
134
+ intent: transactional ? "transactional" : commercial ? "commercial" : informational ? "informational" : "unknown",
135
+ howTo,
136
+ signals
137
+ };
138
+ }
139
+ export { INTENT_CLASSIFIER_VERSION, SEARCH_INTENT_CODE, classifyQueryIntent, encodeIntent };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Algorithm version. Bump on ANY behaviour change (synonyms, depluralize,
3
+ * folding) so downstream stores can record which version produced a
4
+ * `query_canonical` and detect/repair staleness on a rule change instead of
5
+ * silently mixing old and new keys. v1 = the original ASCII heuristic;
6
+ * v2 adds Unicode folding, the empty-canonical guard, and `pluralize`-based
7
+ * singularization.
8
+ */
9
+ declare const NORMALIZER_VERSION = 2;
10
+ /**
11
+ * Produce a canonical form of a search query for grouping near-duplicates.
12
+ * Idempotent: `normalizeQuery(normalizeQuery(q)) === normalizeQuery(q)`.
13
+ */
14
+ declare function normalizeQuery(query: string): string;
15
+ export { NORMALIZER_VERSION, normalizeQuery };
@@ -0,0 +1,204 @@
1
+ import pluralize from "pluralize";
2
+ const SYNONYMS = {
3
+ checker: "validator",
4
+ tester: "validator",
5
+ verifier: "validator",
6
+ verify: "validate",
7
+ check: "validate",
8
+ test: "validate",
9
+ checking: "validate",
10
+ testing: "validate",
11
+ creator: "generator",
12
+ builder: "generator",
13
+ maker: "generator",
14
+ create: "generate",
15
+ build: "generate",
16
+ make: "generate",
17
+ lookup: "search",
18
+ finder: "search",
19
+ find: "search",
20
+ online: "",
21
+ free: ""
22
+ };
23
+ const NO_STRIP_S = /* @__PURE__ */ new Set([
24
+ "css",
25
+ "js",
26
+ "ts",
27
+ "os",
28
+ "as",
29
+ "is",
30
+ "us",
31
+ "has",
32
+ "was",
33
+ "its",
34
+ "this",
35
+ "yes",
36
+ "no",
37
+ "bus",
38
+ "gas",
39
+ "dns",
40
+ "rss",
41
+ "sms",
42
+ "gps",
43
+ "aws",
44
+ "sas",
45
+ "cms",
46
+ "ios",
47
+ "less",
48
+ "loss",
49
+ "miss",
50
+ "pass",
51
+ "class",
52
+ "access",
53
+ "process",
54
+ "express",
55
+ "address",
56
+ "cross",
57
+ "press",
58
+ "stress",
59
+ "progress",
60
+ "success",
61
+ "business",
62
+ "wordpress",
63
+ "status",
64
+ "radius",
65
+ "nexus",
66
+ "focus",
67
+ "bonus",
68
+ "campus",
69
+ "census",
70
+ "corpus",
71
+ "nucleus",
72
+ "stimulus",
73
+ "terminus",
74
+ "versus",
75
+ "virus",
76
+ "surplus",
77
+ "cactus",
78
+ "analysis",
79
+ "basis",
80
+ "thesis",
81
+ "crisis",
82
+ "axis",
83
+ "genesis",
84
+ "synopsis",
85
+ "diagnosis",
86
+ "emphasis",
87
+ "hypothesis",
88
+ "synthesis",
89
+ "parenthesis",
90
+ "redis",
91
+ "apis",
92
+ "chaos",
93
+ "demos",
94
+ "logos",
95
+ "photos",
96
+ "videos",
97
+ "nuxtjs",
98
+ "nextjs",
99
+ "nodejs",
100
+ "reactjs",
101
+ "vuejs",
102
+ "angularjs",
103
+ "expressjs",
104
+ "nestjs",
105
+ "threejs",
106
+ "alpinejs",
107
+ "solidjs",
108
+ "sveltejs",
109
+ "dejs",
110
+ "bunjs",
111
+ "denojs",
112
+ "canvas",
113
+ "atlas",
114
+ "alias",
115
+ "bias",
116
+ "perhaps",
117
+ "whereas",
118
+ "kubernetes",
119
+ "sass",
120
+ "postgres",
121
+ "always",
122
+ "across",
123
+ "previous",
124
+ "various",
125
+ "serious",
126
+ "famous",
127
+ "anonymous",
128
+ "continuous",
129
+ "dangerous",
130
+ "generous",
131
+ "obvious",
132
+ "numerous",
133
+ "curious",
134
+ "nervous",
135
+ "conscious"
136
+ ]);
137
+ const DEPLURALIZED_CACHE_MAX = 4096;
138
+ const depluralizedCache = /* @__PURE__ */ new Map();
139
+ function depluralize(token) {
140
+ if (token.length <= 3 || NO_STRIP_S.has(token) || token.endsWith("sis")) return token;
141
+ const last = token.charCodeAt(token.length - 1);
142
+ if (last < 97 || last > 122) return token;
143
+ const cached = depluralizedCache.get(token);
144
+ if (cached !== void 0) return cached;
145
+ const singular = pluralize.singular(token);
146
+ if (depluralizedCache.size >= DEPLURALIZED_CACHE_MAX) depluralizedCache.clear();
147
+ depluralizedCache.set(token, singular);
148
+ return singular;
149
+ }
150
+ const SEPARATOR_RE = /[-_/.@#:+]+/g;
151
+ const WHITESPACE_RE = /\s+/g;
152
+ const DIACRITICS_RE = /\p{Diacritic}/gu;
153
+ const NORMALIZER_VERSION = 2;
154
+ function foldUnicode(s) {
155
+ let index = 0;
156
+ while (index < s.length && s.charCodeAt(index) <= 127) index++;
157
+ if (index === s.length) return s;
158
+ return s.normalize("NFKD").replace(DIACRITICS_RE, "");
159
+ }
160
+ const DIRECTIONAL_CONNECTORS = /* @__PURE__ */ new Set([
161
+ "to",
162
+ "into",
163
+ ">",
164
+ "→"
165
+ ]);
166
+ const NON_DIRECTIONAL_BEFORE = /* @__PURE__ */ new Set([
167
+ "how",
168
+ "what",
169
+ "why",
170
+ "when",
171
+ "where",
172
+ "who",
173
+ "which",
174
+ "guide",
175
+ "way",
176
+ "tip",
177
+ "intro",
178
+ "introduction",
179
+ "learn",
180
+ "tutorial",
181
+ "step",
182
+ "reason",
183
+ "idea",
184
+ "example",
185
+ "benefit",
186
+ "need"
187
+ ]);
188
+ function isOrderSensitive(tokens) {
189
+ for (let i = 1; i < tokens.length - 1; i++) if (DIRECTIONAL_CONNECTORS.has(tokens[i]) && !NON_DIRECTIONAL_BEFORE.has(tokens[i - 1])) return true;
190
+ return false;
191
+ }
192
+ function normalizeQuery(query) {
193
+ const normalized = foldUnicode(query).toLowerCase().replace(SEPARATOR_RE, " ").replace(WHITESPACE_RE, " ").trim();
194
+ if (normalized.length === 0) return "";
195
+ const cleaned = normalized.split(" ");
196
+ const mapped = [];
197
+ for (const token of cleaned) {
198
+ const synonym = SYNONYMS[token] ?? token;
199
+ if (synonym) mapped.push(depluralize(synonym));
200
+ }
201
+ const tokens = mapped.length > 0 ? mapped : cleaned.map(depluralize);
202
+ return (isOrderSensitive(tokens) ? tokens : tokens.sort()).join(" ");
203
+ }
204
+ export { NORMALIZER_VERSION, normalizeQuery };
@@ -0,0 +1,7 @@
1
+ import { ReportResult } from "@gscdump/engine/report";
2
+ interface FormatReportOptions {
3
+ /** Cap findings rendered per section. Defaults to all (already bounded by report). */
4
+ maxFindingsPerSection?: number;
5
+ }
6
+ declare function formatReport(report: ReportResult, opts?: FormatReportOptions): string;
7
+ export { FormatReportOptions, formatReport };