gscdump 0.4.0 → 0.5.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.
- package/README.md +7 -5
- package/dist/contracts.d.mts +136 -0
- package/dist/contracts.mjs +1 -0
- package/dist/driver.d.mts +78 -0
- package/dist/driver.mjs +1 -0
- package/dist/index.d.mts +158 -89
- package/dist/index.mjs +389 -257
- package/dist/normalize.d.mts +2 -0
- package/dist/normalize.mjs +16 -0
- package/dist/query/index.d.mts +73 -33
- package/dist/query/index.mjs +238 -190
- package/dist/query/plan.d.mts +130 -0
- package/dist/query/plan.mjs +296 -0
- package/dist/sitemap.d.mts +13 -0
- package/dist/sitemap.mjs +31 -0
- package/dist/tenant.d.mts +18 -0
- package/dist/tenant.mjs +18 -0
- package/dist/url.d.mts +9 -0
- package/dist/url.mjs +6 -0
- package/package.json +43 -9
- package/dist/analysis/index.d.mts +0 -513
- package/dist/analysis/index.mjs +0 -872
package/dist/analysis/index.mjs
DELETED
|
@@ -1,872 +0,0 @@
|
|
|
1
|
-
//#region src/analysis/types.ts
|
|
2
|
-
/** Coerce nullable number to number, defaulting to 0 */
|
|
3
|
-
function num(value) {
|
|
4
|
-
return value ?? 0;
|
|
5
|
-
}
|
|
6
|
-
/** Create a generic sorter for any metric type */
|
|
7
|
-
function createSorter(getValue, defaultMetric, defaultOrder = "desc") {
|
|
8
|
-
return (items, sortBy = defaultMetric, sortOrder = defaultOrder) => {
|
|
9
|
-
const mult = sortOrder === "desc" ? -1 : 1;
|
|
10
|
-
return [...items].sort((a, b) => (getValue(a, sortBy) - getValue(b, sortBy)) * mult);
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
//#endregion
|
|
15
|
-
//#region src/analysis/brand.ts
|
|
16
|
-
/**
|
|
17
|
-
* Segments keywords into brand and non-brand based on provided brand terms.
|
|
18
|
-
*/
|
|
19
|
-
function analyzeBrandSegmentation(keywords, options) {
|
|
20
|
-
const { brandTerms, minImpressions = 10 } = options;
|
|
21
|
-
const lowerBrandTerms = brandTerms.map((t) => t.toLowerCase());
|
|
22
|
-
const brand = [];
|
|
23
|
-
const nonBrand = [];
|
|
24
|
-
for (const row of keywords) {
|
|
25
|
-
if (num(row.impressions) < minImpressions) continue;
|
|
26
|
-
if (lowerBrandTerms.some((term) => row.query.toLowerCase().includes(term))) brand.push(row);
|
|
27
|
-
else nonBrand.push(row);
|
|
28
|
-
}
|
|
29
|
-
const brandClicks = brand.reduce((sum, k) => sum + num(k.clicks), 0);
|
|
30
|
-
const nonBrandClicks = nonBrand.reduce((sum, k) => sum + num(k.clicks), 0);
|
|
31
|
-
const totalClicks = brandClicks + nonBrandClicks;
|
|
32
|
-
return {
|
|
33
|
-
brand,
|
|
34
|
-
nonBrand,
|
|
35
|
-
summary: {
|
|
36
|
-
brandClicks,
|
|
37
|
-
nonBrandClicks,
|
|
38
|
-
brandShare: totalClicks > 0 ? brandClicks / totalClicks : 0,
|
|
39
|
-
brandImpressions: brand.reduce((sum, k) => sum + num(k.impressions), 0),
|
|
40
|
-
nonBrandImpressions: nonBrand.reduce((sum, k) => sum + num(k.impressions), 0)
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
//#endregion
|
|
46
|
-
//#region src/analysis/clustering.ts
|
|
47
|
-
const INTENT_PREFIXES = [
|
|
48
|
-
"how to",
|
|
49
|
-
"what is",
|
|
50
|
-
"what are",
|
|
51
|
-
"why is",
|
|
52
|
-
"why do",
|
|
53
|
-
"where to",
|
|
54
|
-
"when to",
|
|
55
|
-
"best",
|
|
56
|
-
"top",
|
|
57
|
-
"vs",
|
|
58
|
-
"versus",
|
|
59
|
-
"compare",
|
|
60
|
-
"review",
|
|
61
|
-
"buy",
|
|
62
|
-
"cheap",
|
|
63
|
-
"free",
|
|
64
|
-
"near me"
|
|
65
|
-
];
|
|
66
|
-
function extractIntentPrefix(keyword) {
|
|
67
|
-
const lower = keyword.toLowerCase();
|
|
68
|
-
for (const prefix of INTENT_PREFIXES) if (lower.startsWith(`${prefix} `) || lower.startsWith(prefix)) return prefix;
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
function extractWordPrefix(keyword, wordCount = 2) {
|
|
72
|
-
const words = keyword.toLowerCase().split(/\s+/).filter(Boolean);
|
|
73
|
-
if (words.length < wordCount + 1) return null;
|
|
74
|
-
return words.slice(0, wordCount).join(" ");
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Clusters keywords by intent prefix or common word prefix.
|
|
78
|
-
*/
|
|
79
|
-
function analyzeClustering(keywords, options = {}) {
|
|
80
|
-
const { minClusterSize = 2, minImpressions = 10, clusterBy = "both" } = options;
|
|
81
|
-
const filtered = keywords.filter((k) => num(k.impressions) >= minImpressions);
|
|
82
|
-
const clusterMap = /* @__PURE__ */ new Map();
|
|
83
|
-
const clusteredKeywords = /* @__PURE__ */ new Set();
|
|
84
|
-
if (clusterBy === "intent" || clusterBy === "both") for (const kw of filtered) {
|
|
85
|
-
const intent = extractIntentPrefix(kw.query);
|
|
86
|
-
if (intent) {
|
|
87
|
-
const existing = clusterMap.get(intent);
|
|
88
|
-
if (existing) existing.keywords.push(kw);
|
|
89
|
-
else clusterMap.set(intent, {
|
|
90
|
-
type: "intent",
|
|
91
|
-
keywords: [kw]
|
|
92
|
-
});
|
|
93
|
-
clusteredKeywords.add(kw.query);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (clusterBy === "prefix" || clusterBy === "both") {
|
|
97
|
-
const unclustered = filtered.filter((kw) => !clusteredKeywords.has(kw.query));
|
|
98
|
-
const prefixMap = /* @__PURE__ */ new Map();
|
|
99
|
-
for (const kw of unclustered) {
|
|
100
|
-
const prefix = extractWordPrefix(kw.query);
|
|
101
|
-
if (prefix) {
|
|
102
|
-
const existing = prefixMap.get(prefix);
|
|
103
|
-
if (existing) existing.push(kw);
|
|
104
|
-
else prefixMap.set(prefix, [kw]);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
for (const [prefix, kws] of prefixMap) if (kws.length >= minClusterSize) {
|
|
108
|
-
clusterMap.set(prefix, {
|
|
109
|
-
type: "prefix",
|
|
110
|
-
keywords: kws
|
|
111
|
-
});
|
|
112
|
-
kws.forEach((kw) => clusteredKeywords.add(kw.query));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const clusters = [];
|
|
116
|
-
for (const [name, data] of clusterMap) {
|
|
117
|
-
if (data.keywords.length < minClusterSize) continue;
|
|
118
|
-
const totalClicks = data.keywords.reduce((sum, k) => sum + num(k.clicks), 0);
|
|
119
|
-
const totalImpressions = data.keywords.reduce((sum, k) => sum + num(k.impressions), 0);
|
|
120
|
-
const avgPosition = data.keywords.reduce((sum, k) => sum + num(k.position), 0) / data.keywords.length;
|
|
121
|
-
clusters.push({
|
|
122
|
-
clusterName: name,
|
|
123
|
-
clusterType: data.type,
|
|
124
|
-
keywords: data.keywords,
|
|
125
|
-
totalClicks,
|
|
126
|
-
totalImpressions,
|
|
127
|
-
avgPosition,
|
|
128
|
-
keywordCount: data.keywords.length
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
clusters.sort((a, b) => b.totalClicks - a.totalClicks);
|
|
132
|
-
return {
|
|
133
|
-
clusters,
|
|
134
|
-
unclustered: filtered.filter((kw) => !clusteredKeywords.has(kw.query))
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
//#endregion
|
|
139
|
-
//#region src/analysis/concentration.ts
|
|
140
|
-
function calculateGini(values) {
|
|
141
|
-
if (values.length === 0) return 0;
|
|
142
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
143
|
-
const n = sorted.length;
|
|
144
|
-
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
145
|
-
if (sum === 0) return 0;
|
|
146
|
-
let weightedSum = 0;
|
|
147
|
-
for (let i = 0; i < n; i++) weightedSum += (2 * (i + 1) - n - 1) * sorted[i];
|
|
148
|
-
return weightedSum / (n * sum);
|
|
149
|
-
}
|
|
150
|
-
function calculateHHI(shares) {
|
|
151
|
-
return shares.reduce((sum, share) => sum + (share * 100) ** 2, 0);
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Analyzes traffic concentration across items (pages or keywords).
|
|
155
|
-
*/
|
|
156
|
-
function analyzeConcentration(items, options = {}) {
|
|
157
|
-
const { topN = 10 } = options;
|
|
158
|
-
if (items.length === 0) return {
|
|
159
|
-
giniCoefficient: 0,
|
|
160
|
-
hhi: 0,
|
|
161
|
-
topNConcentration: 0,
|
|
162
|
-
topNItems: [],
|
|
163
|
-
totalItems: 0,
|
|
164
|
-
totalClicks: 0,
|
|
165
|
-
riskLevel: "low"
|
|
166
|
-
};
|
|
167
|
-
const sorted = [...items].sort((a, b) => b.clicks - a.clicks);
|
|
168
|
-
const totalClicks = sorted.reduce((sum, item) => sum + item.clicks, 0);
|
|
169
|
-
const clickValues = sorted.map((i) => i.clicks);
|
|
170
|
-
const shares = totalClicks > 0 ? sorted.map((i) => i.clicks / totalClicks) : [];
|
|
171
|
-
const giniCoefficient = calculateGini(clickValues);
|
|
172
|
-
const hhi = calculateHHI(shares);
|
|
173
|
-
const topNItems = sorted.slice(0, topN).map((item) => ({
|
|
174
|
-
key: item.key,
|
|
175
|
-
clicks: item.clicks,
|
|
176
|
-
share: totalClicks > 0 ? item.clicks / totalClicks : 0
|
|
177
|
-
}));
|
|
178
|
-
const topNClicks = topNItems.reduce((sum, item) => sum + item.clicks, 0);
|
|
179
|
-
const topNConcentration = totalClicks > 0 ? topNClicks / totalClicks : 0;
|
|
180
|
-
let riskLevel = "low";
|
|
181
|
-
if (hhi > 2500) riskLevel = "high";
|
|
182
|
-
else if (hhi > 1500) riskLevel = "medium";
|
|
183
|
-
return {
|
|
184
|
-
giniCoefficient,
|
|
185
|
-
hhi,
|
|
186
|
-
topNConcentration,
|
|
187
|
-
topNItems,
|
|
188
|
-
totalItems: items.length,
|
|
189
|
-
totalClicks,
|
|
190
|
-
riskLevel
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Page concentration analysis.
|
|
195
|
-
*/
|
|
196
|
-
function analyzePageConcentration(pages, options) {
|
|
197
|
-
return analyzeConcentration(pages.map((p) => ({
|
|
198
|
-
key: p.page,
|
|
199
|
-
clicks: num(p.clicks)
|
|
200
|
-
})), options);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Keyword concentration analysis.
|
|
204
|
-
*/
|
|
205
|
-
function analyzeKeywordConcentration(keywords, options) {
|
|
206
|
-
return analyzeConcentration(keywords.map((k) => ({
|
|
207
|
-
key: k.query,
|
|
208
|
-
clicks: num(k.clicks)
|
|
209
|
-
})), options);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
//#endregion
|
|
213
|
-
//#region src/analysis/decay.ts
|
|
214
|
-
const SORT_ORDER$1 = {
|
|
215
|
-
lostClicks: "desc",
|
|
216
|
-
declinePercent: "desc",
|
|
217
|
-
currentClicks: "asc"
|
|
218
|
-
};
|
|
219
|
-
const sortResults$2 = createSorter((item, metric) => item[metric], "lostClicks");
|
|
220
|
-
/**
|
|
221
|
-
* Identifies "decaying" content - pages that have lost significant traffic.
|
|
222
|
-
*/
|
|
223
|
-
function analyzeDecay(input, options = {}) {
|
|
224
|
-
const { minPreviousClicks = 50, threshold = .2, sortBy = "lostClicks" } = options;
|
|
225
|
-
const currentMap = /* @__PURE__ */ new Map();
|
|
226
|
-
for (const row of input.current) currentMap.set(row.page, {
|
|
227
|
-
clicks: num(row.clicks),
|
|
228
|
-
position: num(row.position)
|
|
229
|
-
});
|
|
230
|
-
const previousMap = /* @__PURE__ */ new Map();
|
|
231
|
-
for (const row of input.previous) {
|
|
232
|
-
const clicks$1 = num(row.clicks);
|
|
233
|
-
if (clicks$1 >= minPreviousClicks) previousMap.set(row.page, {
|
|
234
|
-
clicks: clicks$1,
|
|
235
|
-
position: num(row.position)
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
const results = [];
|
|
239
|
-
for (const [page$1, prev] of previousMap) {
|
|
240
|
-
const curr = currentMap.get(page$1) || {
|
|
241
|
-
clicks: 0,
|
|
242
|
-
position: 0
|
|
243
|
-
};
|
|
244
|
-
const lostClicks = prev.clicks - curr.clicks;
|
|
245
|
-
const declinePercent = prev.clicks > 0 ? lostClicks / prev.clicks : 0;
|
|
246
|
-
if (declinePercent >= threshold && lostClicks > 0) results.push({
|
|
247
|
-
page: page$1,
|
|
248
|
-
currentClicks: curr.clicks,
|
|
249
|
-
previousClicks: prev.clicks,
|
|
250
|
-
lostClicks,
|
|
251
|
-
declinePercent,
|
|
252
|
-
currentPosition: curr.position,
|
|
253
|
-
previousPosition: prev.position,
|
|
254
|
-
positionDrop: curr.position - prev.position
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
return sortResults$2(results, sortBy, SORT_ORDER$1[sortBy]);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
//#endregion
|
|
261
|
-
//#region src/query/resolver.ts
|
|
262
|
-
const DATE_OPERATORS = [
|
|
263
|
-
"gte",
|
|
264
|
-
"gt",
|
|
265
|
-
"lte",
|
|
266
|
-
"lt",
|
|
267
|
-
"between"
|
|
268
|
-
];
|
|
269
|
-
const METRIC_OPERATORS = [
|
|
270
|
-
"metricGte",
|
|
271
|
-
"metricGt",
|
|
272
|
-
"metricLte",
|
|
273
|
-
"metricLt",
|
|
274
|
-
"metricBetween"
|
|
275
|
-
];
|
|
276
|
-
const SPECIAL_OPERATORS = ["topLevel"];
|
|
277
|
-
const QUERY_PARAMS = ["searchType"];
|
|
278
|
-
function isMetricOperator(op) {
|
|
279
|
-
return METRIC_OPERATORS.includes(op);
|
|
280
|
-
}
|
|
281
|
-
function isSpecialOperator(op) {
|
|
282
|
-
return SPECIAL_OPERATORS.includes(op);
|
|
283
|
-
}
|
|
284
|
-
function isDateOperator(op) {
|
|
285
|
-
return DATE_OPERATORS.includes(op);
|
|
286
|
-
}
|
|
287
|
-
function isQueryParam(dim) {
|
|
288
|
-
return QUERY_PARAMS.includes(dim);
|
|
289
|
-
}
|
|
290
|
-
function addDays(dateStr, days) {
|
|
291
|
-
const d = new Date(dateStr);
|
|
292
|
-
d.setDate(d.getDate() + days);
|
|
293
|
-
return d.toISOString().split("T")[0];
|
|
294
|
-
}
|
|
295
|
-
function extractSpecialFilters(filter) {
|
|
296
|
-
if (!filter) return {};
|
|
297
|
-
let startDate;
|
|
298
|
-
let endDate;
|
|
299
|
-
let searchType$1;
|
|
300
|
-
const otherFilters = [];
|
|
301
|
-
const cleanedNestedGroups = [];
|
|
302
|
-
for (const f of filter._filters) if (f.dimension === "date" && isDateOperator(f.operator)) switch (f.operator) {
|
|
303
|
-
case "gte":
|
|
304
|
-
startDate = f.expression;
|
|
305
|
-
break;
|
|
306
|
-
case "gt":
|
|
307
|
-
startDate = addDays(f.expression, 1);
|
|
308
|
-
break;
|
|
309
|
-
case "lte":
|
|
310
|
-
endDate = f.expression;
|
|
311
|
-
break;
|
|
312
|
-
case "lt":
|
|
313
|
-
endDate = addDays(f.expression, -1);
|
|
314
|
-
break;
|
|
315
|
-
case "between":
|
|
316
|
-
startDate = f.expression;
|
|
317
|
-
endDate = f.expression2;
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
else if (isQueryParam(f.dimension)) {
|
|
321
|
-
if (f.dimension === "searchType") searchType$1 = f.expression;
|
|
322
|
-
} else if (isMetricOperator(f.operator) || isSpecialOperator(f.operator)) otherFilters.push(f);
|
|
323
|
-
else otherFilters.push(f);
|
|
324
|
-
if (filter._nestedGroups) for (const nested of filter._nestedGroups) {
|
|
325
|
-
const extracted = extractSpecialFilters(nested);
|
|
326
|
-
if (extracted.startDate) startDate = extracted.startDate;
|
|
327
|
-
if (extracted.endDate) endDate = extracted.endDate;
|
|
328
|
-
if (extracted.searchType) searchType$1 = extracted.searchType;
|
|
329
|
-
if (extracted.dimensionFilter) cleanedNestedGroups.push(extracted.dimensionFilter);
|
|
330
|
-
}
|
|
331
|
-
const dimensionFilter = otherFilters.length > 0 || cleanedNestedGroups.length > 0 ? {
|
|
332
|
-
...filter,
|
|
333
|
-
_filters: otherFilters,
|
|
334
|
-
_nestedGroups: cleanedNestedGroups.length > 0 ? cleanedNestedGroups : void 0
|
|
335
|
-
} : void 0;
|
|
336
|
-
return {
|
|
337
|
-
startDate,
|
|
338
|
-
endDate,
|
|
339
|
-
searchType: searchType$1,
|
|
340
|
-
dimensionFilter
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
function resolveToBody(state) {
|
|
344
|
-
const { startDate, endDate, searchType: searchType$1, dimensionFilter } = extractSpecialFilters(state.filter);
|
|
345
|
-
if (!startDate || !endDate) throw new Error("Date range required: use .where(between(date, start, end)) or .where(and(gte(date, start), lte(date, end)))");
|
|
346
|
-
const body = {
|
|
347
|
-
dimensions: state.dimensions,
|
|
348
|
-
startDate,
|
|
349
|
-
endDate
|
|
350
|
-
};
|
|
351
|
-
if (searchType$1) body.searchType = searchType$1;
|
|
352
|
-
if (state.rowLimit) body.rowLimit = state.rowLimit;
|
|
353
|
-
if (state.startRow) body.startRow = state.startRow;
|
|
354
|
-
const filterGroups = resolveFilter(dimensionFilter);
|
|
355
|
-
if (filterGroups.length > 0) body.dimensionFilterGroups = filterGroups;
|
|
356
|
-
return body;
|
|
357
|
-
}
|
|
358
|
-
function isApiFilter(f) {
|
|
359
|
-
return !isMetricOperator(f.operator) && !isSpecialOperator(f.operator);
|
|
360
|
-
}
|
|
361
|
-
function resolveFilter(filter) {
|
|
362
|
-
if (!filter) return [];
|
|
363
|
-
const groups = [];
|
|
364
|
-
const groupType = filter._groupType ?? "and";
|
|
365
|
-
const apiFilters = filter._filters.filter(isApiFilter);
|
|
366
|
-
if (groupType === "or") {
|
|
367
|
-
if (apiFilters.length > 0) groups.push({
|
|
368
|
-
groupType: "or",
|
|
369
|
-
filters: apiFilters.map((f) => ({
|
|
370
|
-
dimension: f.dimension,
|
|
371
|
-
operator: f.operator,
|
|
372
|
-
expression: f.expression
|
|
373
|
-
}))
|
|
374
|
-
});
|
|
375
|
-
} else if (apiFilters.length > 0) groups.push({ filters: apiFilters.map((f) => ({
|
|
376
|
-
dimension: f.dimension,
|
|
377
|
-
operator: f.operator,
|
|
378
|
-
expression: f.expression
|
|
379
|
-
})) });
|
|
380
|
-
if (filter._nestedGroups) for (const nested of filter._nestedGroups) groups.push(...resolveFilter(nested));
|
|
381
|
-
return groups;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
//#endregion
|
|
385
|
-
//#region src/query/builder.ts
|
|
386
|
-
function isDimensionString(v) {
|
|
387
|
-
return typeof v === "string";
|
|
388
|
-
}
|
|
389
|
-
function isMetricColumn(v) {
|
|
390
|
-
return typeof v === "object" && v !== null && "metric" in v;
|
|
391
|
-
}
|
|
392
|
-
function isDimensionColumn(v) {
|
|
393
|
-
return typeof v === "object" && v !== null && "dimension" in v && !("metric" in v);
|
|
394
|
-
}
|
|
395
|
-
function createBuilder(state) {
|
|
396
|
-
return {
|
|
397
|
-
select(...args) {
|
|
398
|
-
const dimensions = [];
|
|
399
|
-
const metrics = [];
|
|
400
|
-
for (const arg of args) if (isDimensionString(arg)) dimensions.push(arg);
|
|
401
|
-
else if (isDimensionColumn(arg)) dimensions.push(arg.dimension);
|
|
402
|
-
else if (isMetricColumn(arg)) metrics.push(arg.metric);
|
|
403
|
-
return createBuilder({
|
|
404
|
-
...state,
|
|
405
|
-
dimensions,
|
|
406
|
-
metrics: metrics.length > 0 ? metrics : void 0
|
|
407
|
-
});
|
|
408
|
-
},
|
|
409
|
-
where(filter) {
|
|
410
|
-
return createBuilder({
|
|
411
|
-
...state,
|
|
412
|
-
filter
|
|
413
|
-
});
|
|
414
|
-
},
|
|
415
|
-
orderBy(col, dir) {
|
|
416
|
-
const column = isMetricColumn(col) ? col.metric : col.dimension;
|
|
417
|
-
return createBuilder({
|
|
418
|
-
...state,
|
|
419
|
-
orderBy: {
|
|
420
|
-
column,
|
|
421
|
-
dir
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
},
|
|
425
|
-
limit(n) {
|
|
426
|
-
return createBuilder({
|
|
427
|
-
...state,
|
|
428
|
-
rowLimit: n
|
|
429
|
-
});
|
|
430
|
-
},
|
|
431
|
-
offset(n) {
|
|
432
|
-
return createBuilder({
|
|
433
|
-
...state,
|
|
434
|
-
startRow: n
|
|
435
|
-
});
|
|
436
|
-
},
|
|
437
|
-
toBody() {
|
|
438
|
-
return resolveToBody(state);
|
|
439
|
-
},
|
|
440
|
-
getState() {
|
|
441
|
-
return { ...state };
|
|
442
|
-
}
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
const gsc = createBuilder({ dimensions: [] });
|
|
446
|
-
|
|
447
|
-
//#endregion
|
|
448
|
-
//#region src/query/columns.ts
|
|
449
|
-
function createColumn(dimension) {
|
|
450
|
-
return { dimension };
|
|
451
|
-
}
|
|
452
|
-
function createMetricColumn(metric) {
|
|
453
|
-
return { metric };
|
|
454
|
-
}
|
|
455
|
-
function createQueryParam(param) {
|
|
456
|
-
return { param };
|
|
457
|
-
}
|
|
458
|
-
const page = createColumn("page");
|
|
459
|
-
const query = createColumn("query");
|
|
460
|
-
const queryCanonical = createColumn("queryCanonical");
|
|
461
|
-
const device = createColumn("device");
|
|
462
|
-
const country = createColumn("country");
|
|
463
|
-
const searchAppearance = createColumn("searchAppearance");
|
|
464
|
-
const date = createColumn("date");
|
|
465
|
-
const clicks = createMetricColumn("clicks");
|
|
466
|
-
const impressions = createMetricColumn("impressions");
|
|
467
|
-
const ctr = createMetricColumn("ctr");
|
|
468
|
-
const position = createMetricColumn("position");
|
|
469
|
-
const searchType = createQueryParam("searchType");
|
|
470
|
-
|
|
471
|
-
//#endregion
|
|
472
|
-
//#region src/query/operators.ts
|
|
473
|
-
function between(column, start, end) {
|
|
474
|
-
if ("metric" in column) return {
|
|
475
|
-
_constraints: {},
|
|
476
|
-
_filters: [{
|
|
477
|
-
dimension: column.metric,
|
|
478
|
-
operator: "metricBetween",
|
|
479
|
-
expression: String(start),
|
|
480
|
-
expression2: String(end)
|
|
481
|
-
}]
|
|
482
|
-
};
|
|
483
|
-
return {
|
|
484
|
-
_constraints: {},
|
|
485
|
-
_filters: [{
|
|
486
|
-
dimension: column.dimension,
|
|
487
|
-
operator: "between",
|
|
488
|
-
expression: String(start),
|
|
489
|
-
expression2: String(end)
|
|
490
|
-
}]
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
//#endregion
|
|
495
|
-
//#region src/analysis/movers.ts
|
|
496
|
-
function percentDifference(current, previous) {
|
|
497
|
-
if (previous === 0) return current > 0 ? 100 : 0;
|
|
498
|
-
return (current - previous) / previous * 100;
|
|
499
|
-
}
|
|
500
|
-
/**
|
|
501
|
-
* Identifies movers and shakers - keywords with significant recent changes.
|
|
502
|
-
*/
|
|
503
|
-
function analyzeMovers(input, options = {}) {
|
|
504
|
-
const { changeThreshold = .2, minImpressions = 50, sortBy = "clicksChange" } = options;
|
|
505
|
-
const normFactor = input.normalizationFactor ?? 1;
|
|
506
|
-
const baselineMap = /* @__PURE__ */ new Map();
|
|
507
|
-
for (const row of input.previous) baselineMap.set(row.query, {
|
|
508
|
-
clicks: num(row.clicks) / normFactor,
|
|
509
|
-
impressions: num(row.impressions) / normFactor,
|
|
510
|
-
position: num(row.position),
|
|
511
|
-
page: row.page ?? null
|
|
512
|
-
});
|
|
513
|
-
const pageMap = /* @__PURE__ */ new Map();
|
|
514
|
-
for (const row of input.current) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
|
|
515
|
-
for (const row of input.previous) if (!pageMap.has(row.query) && row.page) pageMap.set(row.query, row.page);
|
|
516
|
-
const rising = [];
|
|
517
|
-
const declining = [];
|
|
518
|
-
const stable = [];
|
|
519
|
-
for (const row of input.current) {
|
|
520
|
-
const impressions$1 = num(row.impressions);
|
|
521
|
-
const clicks$1 = num(row.clicks);
|
|
522
|
-
const position$1 = num(row.position);
|
|
523
|
-
if (impressions$1 < minImpressions) continue;
|
|
524
|
-
const baseline = baselineMap.get(row.query) || {
|
|
525
|
-
clicks: 0,
|
|
526
|
-
impressions: 0,
|
|
527
|
-
position: 0,
|
|
528
|
-
page: null
|
|
529
|
-
};
|
|
530
|
-
const clicksChangePercent = percentDifference(clicks$1, baseline.clicks);
|
|
531
|
-
const impressionsChangePercent = percentDifference(impressions$1, baseline.impressions);
|
|
532
|
-
const data = {
|
|
533
|
-
keyword: row.query,
|
|
534
|
-
page: pageMap.get(row.query) ?? null,
|
|
535
|
-
recentClicks: clicks$1,
|
|
536
|
-
recentImpressions: impressions$1,
|
|
537
|
-
recentPosition: position$1,
|
|
538
|
-
baselineClicks: Math.round(baseline.clicks),
|
|
539
|
-
baselineImpressions: Math.round(baseline.impressions),
|
|
540
|
-
baselinePosition: baseline.position,
|
|
541
|
-
clicksChange: clicks$1 - Math.round(baseline.clicks),
|
|
542
|
-
clicksChangePercent,
|
|
543
|
-
impressionsChangePercent,
|
|
544
|
-
positionChange: position$1 - baseline.position
|
|
545
|
-
};
|
|
546
|
-
const absChange = Math.abs(clicksChangePercent / 100);
|
|
547
|
-
if (clicksChangePercent > 0 && absChange >= changeThreshold) rising.push(data);
|
|
548
|
-
else if (clicksChangePercent < 0 && absChange >= changeThreshold) declining.push(data);
|
|
549
|
-
else stable.push(data);
|
|
550
|
-
}
|
|
551
|
-
const sortFn = (a, b) => {
|
|
552
|
-
switch (sortBy) {
|
|
553
|
-
case "clicks": return b.recentClicks - a.recentClicks;
|
|
554
|
-
case "impressions": return b.recentImpressions - a.recentImpressions;
|
|
555
|
-
case "clicksChange": return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
556
|
-
case "impressionsChange": return Math.abs(b.impressionsChangePercent) - Math.abs(a.impressionsChangePercent);
|
|
557
|
-
case "positionChange": return Math.abs(b.positionChange) - Math.abs(a.positionChange);
|
|
558
|
-
default: return Math.abs(b.clicksChangePercent) - Math.abs(a.clicksChangePercent);
|
|
559
|
-
}
|
|
560
|
-
};
|
|
561
|
-
rising.sort(sortFn);
|
|
562
|
-
declining.sort(sortFn);
|
|
563
|
-
stable.sort((a, b) => b.recentClicks - a.recentClicks);
|
|
564
|
-
return {
|
|
565
|
-
rising,
|
|
566
|
-
declining,
|
|
567
|
-
stable
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
//#endregion
|
|
572
|
-
//#region src/analysis/opportunity.ts
|
|
573
|
-
const EXPECTED_CTR_BY_POSITION = {
|
|
574
|
-
1: .3,
|
|
575
|
-
2: .15,
|
|
576
|
-
3: .1,
|
|
577
|
-
4: .07,
|
|
578
|
-
5: .05,
|
|
579
|
-
6: .04,
|
|
580
|
-
7: .03,
|
|
581
|
-
8: .025,
|
|
582
|
-
9: .02,
|
|
583
|
-
10: .015
|
|
584
|
-
};
|
|
585
|
-
function getExpectedCtr(position$1) {
|
|
586
|
-
return EXPECTED_CTR_BY_POSITION[Math.round(Math.max(1, Math.min(position$1, 10)))] || .01;
|
|
587
|
-
}
|
|
588
|
-
function calculatePositionScore(position$1) {
|
|
589
|
-
if (position$1 <= 3) return .2;
|
|
590
|
-
if (position$1 > 50) return .1;
|
|
591
|
-
const distance = Math.abs(position$1 - 11);
|
|
592
|
-
return Math.max(0, 1 - distance / 15);
|
|
593
|
-
}
|
|
594
|
-
function calculateImpressionScore(impressions$1) {
|
|
595
|
-
if (impressions$1 <= 0) return 0;
|
|
596
|
-
return Math.min(Math.log10(impressions$1) / 5, 1);
|
|
597
|
-
}
|
|
598
|
-
function calculateCtrGapScore(actualCtr, position$1) {
|
|
599
|
-
const expectedCtr = getExpectedCtr(position$1);
|
|
600
|
-
if (actualCtr >= expectedCtr) return 0;
|
|
601
|
-
const gap = expectedCtr - actualCtr;
|
|
602
|
-
return Math.min(gap / expectedCtr, 1);
|
|
603
|
-
}
|
|
604
|
-
const SORT_ORDER = {
|
|
605
|
-
opportunityScore: "desc",
|
|
606
|
-
potentialClicks: "desc",
|
|
607
|
-
impressions: "desc",
|
|
608
|
-
position: "asc"
|
|
609
|
-
};
|
|
610
|
-
const sortResults$1 = createSorter((item, metric) => item[metric], "opportunityScore");
|
|
611
|
-
/**
|
|
612
|
-
* Scores keywords by optimization opportunity.
|
|
613
|
-
* Composite score combining position, impressions, and CTR gap factors.
|
|
614
|
-
*/
|
|
615
|
-
function analyzeOpportunity(keywords, options = {}) {
|
|
616
|
-
const { minImpressions = 100, weights = {}, sortBy = "opportunityScore" } = options;
|
|
617
|
-
const positionWeight = weights.position ?? 1;
|
|
618
|
-
const impressionsWeight = weights.impressions ?? 1;
|
|
619
|
-
const ctrGapWeight = weights.ctrGap ?? 1;
|
|
620
|
-
const results = [];
|
|
621
|
-
for (const row of keywords) {
|
|
622
|
-
const impressions$1 = num(row.impressions);
|
|
623
|
-
const position$1 = num(row.position);
|
|
624
|
-
const ctr$1 = num(row.ctr);
|
|
625
|
-
const clicks$1 = num(row.clicks);
|
|
626
|
-
if (impressions$1 < minImpressions) continue;
|
|
627
|
-
const positionScore = calculatePositionScore(position$1);
|
|
628
|
-
const impressionScore = calculateImpressionScore(impressions$1);
|
|
629
|
-
const ctrGapScore = calculateCtrGapScore(ctr$1, position$1);
|
|
630
|
-
const geometricMean = (positionScore ** positionWeight * impressionScore ** impressionsWeight * ctrGapScore ** ctrGapWeight) ** (1 / (positionWeight + impressionsWeight + ctrGapWeight));
|
|
631
|
-
const opportunityScore = Math.round(geometricMean * 100);
|
|
632
|
-
const targetCtr = getExpectedCtr(Math.min(3, position$1));
|
|
633
|
-
const potentialClicks = Math.round(impressions$1 * targetCtr);
|
|
634
|
-
results.push({
|
|
635
|
-
keyword: row.query,
|
|
636
|
-
page: row.page ?? null,
|
|
637
|
-
clicks: clicks$1,
|
|
638
|
-
impressions: impressions$1,
|
|
639
|
-
ctr: ctr$1,
|
|
640
|
-
position: position$1,
|
|
641
|
-
opportunityScore,
|
|
642
|
-
potentialClicks,
|
|
643
|
-
factors: {
|
|
644
|
-
positionScore,
|
|
645
|
-
impressionScore,
|
|
646
|
-
ctrGapScore
|
|
647
|
-
}
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
return sortResults$1(results, sortBy, SORT_ORDER[sortBy]);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
//#endregion
|
|
654
|
-
//#region src/analysis/seasonality.ts
|
|
655
|
-
function calculateCV(values) {
|
|
656
|
-
if (values.length === 0) return 0;
|
|
657
|
-
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
658
|
-
if (mean === 0) return 0;
|
|
659
|
-
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
|
660
|
-
const stdDev = Math.sqrt(variance);
|
|
661
|
-
return Math.min(stdDev / mean, 1);
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Detects seasonality patterns by analyzing monthly traffic variation.
|
|
665
|
-
*/
|
|
666
|
-
function analyzeSeasonality(dates, options = {}) {
|
|
667
|
-
const { metric = "clicks" } = options;
|
|
668
|
-
if (dates.length === 0) return {
|
|
669
|
-
hasSeasonality: false,
|
|
670
|
-
strength: 0,
|
|
671
|
-
peakMonths: [],
|
|
672
|
-
troughMonths: [],
|
|
673
|
-
monthlyBreakdown: [],
|
|
674
|
-
insufficientData: true
|
|
675
|
-
};
|
|
676
|
-
const monthlyMap = /* @__PURE__ */ new Map();
|
|
677
|
-
for (const row of dates) {
|
|
678
|
-
const month = row.date.substring(0, 7);
|
|
679
|
-
const value = metric === "clicks" ? row.clicks : row.impressions;
|
|
680
|
-
monthlyMap.set(month, (monthlyMap.get(month) || 0) + value);
|
|
681
|
-
}
|
|
682
|
-
const months = Array.from(monthlyMap.keys()).sort();
|
|
683
|
-
const values = months.map((m) => monthlyMap.get(m) || 0);
|
|
684
|
-
const insufficientData = months.length < 12;
|
|
685
|
-
const totalValue = values.reduce((a, b) => a + b, 0);
|
|
686
|
-
const avgValue = values.length > 0 ? totalValue / values.length : 0;
|
|
687
|
-
const monthlyBreakdown = months.map((month, i) => {
|
|
688
|
-
const value = values[i];
|
|
689
|
-
const vsAverage = avgValue > 0 ? value / avgValue : 0;
|
|
690
|
-
return {
|
|
691
|
-
month,
|
|
692
|
-
value,
|
|
693
|
-
vsAverage,
|
|
694
|
-
isPeak: vsAverage > 1.5,
|
|
695
|
-
isTrough: vsAverage < .5
|
|
696
|
-
};
|
|
697
|
-
});
|
|
698
|
-
const peakMonths = [...new Set(monthlyBreakdown.filter((m) => m.isPeak).map((m) => m.month.substring(5, 7)))];
|
|
699
|
-
const troughMonths = [...new Set(monthlyBreakdown.filter((m) => m.isTrough).map((m) => m.month.substring(5, 7)))];
|
|
700
|
-
const strength = calculateCV(values);
|
|
701
|
-
return {
|
|
702
|
-
hasSeasonality: peakMonths.length > 0 || troughMonths.length > 0 || strength > .3,
|
|
703
|
-
strength,
|
|
704
|
-
peakMonths,
|
|
705
|
-
troughMonths,
|
|
706
|
-
monthlyBreakdown,
|
|
707
|
-
insufficientData
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
//#endregion
|
|
712
|
-
//#region src/analysis/striking-distance.ts
|
|
713
|
-
const sortResults = createSorter((item, metric) => item[metric], "potentialClicks");
|
|
714
|
-
/**
|
|
715
|
-
* Finds striking distance keywords - high impressions, low CTR, position 4-20.
|
|
716
|
-
* These are "quick wins" that could gain significant traffic with small ranking improvements.
|
|
717
|
-
*/
|
|
718
|
-
function analyzeStrikingDistance(keywords, options = {}) {
|
|
719
|
-
const { minPosition = 4, maxPosition = 20, minImpressions = 100, maxCtr = .05, sortBy = "potentialClicks", sortOrder = "desc" } = options;
|
|
720
|
-
const results = [];
|
|
721
|
-
for (const row of keywords) {
|
|
722
|
-
const position$1 = num(row.position);
|
|
723
|
-
const impressions$1 = num(row.impressions);
|
|
724
|
-
const ctr$1 = num(row.ctr);
|
|
725
|
-
const clicks$1 = num(row.clicks);
|
|
726
|
-
if (position$1 < minPosition || position$1 > maxPosition) continue;
|
|
727
|
-
if (impressions$1 < minImpressions) continue;
|
|
728
|
-
if (ctr$1 > maxCtr) continue;
|
|
729
|
-
const potentialClicks = Math.round(impressions$1 * .15);
|
|
730
|
-
results.push({
|
|
731
|
-
keyword: row.query,
|
|
732
|
-
page: row.page ?? null,
|
|
733
|
-
clicks: clicks$1,
|
|
734
|
-
impressions: impressions$1,
|
|
735
|
-
ctr: ctr$1,
|
|
736
|
-
position: position$1,
|
|
737
|
-
potentialClicks
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
|
-
return sortResults(results, sortBy, sortOrder);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
//#endregion
|
|
744
|
-
//#region src/analysis/fetch.ts
|
|
745
|
-
/** Collect all rows from async generator */
|
|
746
|
-
async function collectRows(generator) {
|
|
747
|
-
const rows = [];
|
|
748
|
-
for await (const batch of generator) rows.push(...batch);
|
|
749
|
-
return rows;
|
|
750
|
-
}
|
|
751
|
-
/** Fetch keywords (query+page dimensions) for a period */
|
|
752
|
-
async function fetchKeywordsInternal(client, siteUrl, period, limit = 25e3) {
|
|
753
|
-
const builder = gsc.select(query, page).where(between(date, period.startDate, period.endDate)).limit(limit);
|
|
754
|
-
return collectRows(client.query(siteUrl, builder));
|
|
755
|
-
}
|
|
756
|
-
/** Fetch pages (page dimension) for a period */
|
|
757
|
-
async function fetchPagesInternal(client, siteUrl, period, limit = 25e3) {
|
|
758
|
-
const builder = gsc.select(page).where(between(date, period.startDate, period.endDate)).limit(limit);
|
|
759
|
-
return collectRows(client.query(siteUrl, builder));
|
|
760
|
-
}
|
|
761
|
-
/** Fetch dates (date dimension) for a period */
|
|
762
|
-
async function fetchDatesInternal(client, siteUrl, period, limit = 25e3) {
|
|
763
|
-
const builder = gsc.select(date).where(between(date, period.startDate, period.endDate)).limit(limit);
|
|
764
|
-
return collectRows(client.query(siteUrl, builder));
|
|
765
|
-
}
|
|
766
|
-
/**
|
|
767
|
-
* Query search analytics data for a single period.
|
|
768
|
-
* Returns keywords, pages, and dates data.
|
|
769
|
-
* API calls: 3 (one per dimension)
|
|
770
|
-
*/
|
|
771
|
-
async function queryAnalytics(client, siteUrl, period, options = {}) {
|
|
772
|
-
const limit = options.limit ?? 25e3;
|
|
773
|
-
const [keywords, pages, dates] = await Promise.all([
|
|
774
|
-
fetchKeywordsInternal(client, siteUrl, period, limit),
|
|
775
|
-
fetchPagesInternal(client, siteUrl, period, limit),
|
|
776
|
-
fetchDatesInternal(client, siteUrl, period, limit)
|
|
777
|
-
]);
|
|
778
|
-
return {
|
|
779
|
-
keywords,
|
|
780
|
-
pages,
|
|
781
|
-
dates
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
/**
|
|
785
|
-
* Query search analytics data for current and previous periods.
|
|
786
|
-
* Returns comparison data for all dimensions.
|
|
787
|
-
* API calls: 6 (3 per period)
|
|
788
|
-
*/
|
|
789
|
-
async function queryComparison(client, siteUrl, periods, options = {}) {
|
|
790
|
-
const [current, previous] = await Promise.all([queryAnalytics(client, siteUrl, periods.current, options), queryAnalytics(client, siteUrl, periods.previous, options)]);
|
|
791
|
-
return {
|
|
792
|
-
current,
|
|
793
|
-
previous
|
|
794
|
-
};
|
|
795
|
-
}
|
|
796
|
-
const fetchKeywords = fetchKeywordsInternal;
|
|
797
|
-
const fetchPages = fetchPagesInternal;
|
|
798
|
-
const fetchDates = fetchDatesInternal;
|
|
799
|
-
/**
|
|
800
|
-
* Fetch keywords and analyze striking distance opportunities.
|
|
801
|
-
* API calls: 1
|
|
802
|
-
*/
|
|
803
|
-
async function fetchStrikingDistance(client, siteUrl, period, options) {
|
|
804
|
-
return analyzeStrikingDistance(await fetchKeywords(client, siteUrl, period), options);
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Fetch keywords and analyze optimization opportunities.
|
|
808
|
-
* API calls: 1
|
|
809
|
-
*/
|
|
810
|
-
async function fetchOpportunity(client, siteUrl, period, options) {
|
|
811
|
-
return analyzeOpportunity(await fetchKeywords(client, siteUrl, period), options);
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Fetch keywords and analyze brand vs non-brand segmentation.
|
|
815
|
-
* API calls: 1
|
|
816
|
-
*/
|
|
817
|
-
async function fetchBrandSegmentation(client, siteUrl, period, options) {
|
|
818
|
-
return analyzeBrandSegmentation(await fetchKeywords(client, siteUrl, period), options);
|
|
819
|
-
}
|
|
820
|
-
/**
|
|
821
|
-
* Fetch pages and analyze traffic concentration.
|
|
822
|
-
* API calls: 1
|
|
823
|
-
*/
|
|
824
|
-
async function fetchPageConcentration(client, siteUrl, period, options) {
|
|
825
|
-
return analyzePageConcentration(await fetchPages(client, siteUrl, period), options);
|
|
826
|
-
}
|
|
827
|
-
/**
|
|
828
|
-
* Fetch keywords and analyze traffic concentration.
|
|
829
|
-
* API calls: 1
|
|
830
|
-
*/
|
|
831
|
-
async function fetchKeywordConcentration(client, siteUrl, period, options) {
|
|
832
|
-
return analyzeKeywordConcentration(await fetchKeywords(client, siteUrl, period), options);
|
|
833
|
-
}
|
|
834
|
-
/**
|
|
835
|
-
* Fetch keywords and analyze clusters.
|
|
836
|
-
* API calls: 1
|
|
837
|
-
*/
|
|
838
|
-
async function fetchClustering(client, siteUrl, period, options) {
|
|
839
|
-
return analyzeClustering(await fetchKeywords(client, siteUrl, period), options);
|
|
840
|
-
}
|
|
841
|
-
/**
|
|
842
|
-
* Fetch dates and analyze seasonality patterns.
|
|
843
|
-
* API calls: 1
|
|
844
|
-
*/
|
|
845
|
-
async function fetchSeasonality(client, siteUrl, period, options) {
|
|
846
|
-
return analyzeSeasonality(await fetchDates(client, siteUrl, period), options);
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* Fetch pages for both periods and analyze content decay.
|
|
850
|
-
* API calls: 2
|
|
851
|
-
*/
|
|
852
|
-
async function fetchDecay(client, siteUrl, periods, options) {
|
|
853
|
-
const [current, previous] = await Promise.all([fetchPages(client, siteUrl, periods.current), fetchPages(client, siteUrl, periods.previous)]);
|
|
854
|
-
return analyzeDecay({
|
|
855
|
-
current,
|
|
856
|
-
previous
|
|
857
|
-
}, options);
|
|
858
|
-
}
|
|
859
|
-
/**
|
|
860
|
-
* Fetch keywords for both periods and analyze movers/shakers.
|
|
861
|
-
* API calls: 2
|
|
862
|
-
*/
|
|
863
|
-
async function fetchMovers(client, siteUrl, periods, options) {
|
|
864
|
-
const [current, previous] = await Promise.all([fetchKeywords(client, siteUrl, periods.current), fetchKeywords(client, siteUrl, periods.previous)]);
|
|
865
|
-
return analyzeMovers({
|
|
866
|
-
current,
|
|
867
|
-
previous
|
|
868
|
-
}, options);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
//#endregion
|
|
872
|
-
export { analyzeBrandSegmentation, analyzeClustering, analyzeConcentration, analyzeDecay, analyzeKeywordConcentration, analyzeMovers, analyzeOpportunity, analyzePageConcentration, analyzeSeasonality, analyzeStrikingDistance, createSorter, fetchBrandSegmentation, fetchClustering, fetchDecay, fetchKeywordConcentration, fetchMovers, fetchOpportunity, fetchPageConcentration, fetchSeasonality, fetchStrikingDistance, num, queryAnalytics, queryComparison };
|