@adobe/mysticat-shared-seo-client 1.1.3 → 1.2.0
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/client.js +276 -128
- package/src/index.d.ts +21 -9
- package/src/index.js +3 -2
- package/src/utils.js +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [@adobe/mysticat-shared-seo-client-v1.2.0](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.1.3...@adobe/mysticat-shared-seo-client-v1.2.0) (2026-04-10)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **seo-client:** fan-out queries across markets using site region ([#1522](https://github.com/adobe/spacecat-shared/issues/1522)) ([8b2260c](https://github.com/adobe/spacecat-shared/commit/8b2260c6f49410a56c9d5fd7b37b2faf5ab36acb))
|
|
6
|
+
|
|
1
7
|
## [@adobe/mysticat-shared-seo-client-v1.1.3](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.1.2...@adobe/mysticat-shared-seo-client-v1.1.3) (2026-04-09)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -15,7 +15,7 @@ import { context as h2, h1 } from '@adobe/fetch';
|
|
|
15
15
|
|
|
16
16
|
import { ENDPOINTS } from './endpoints.js';
|
|
17
17
|
import {
|
|
18
|
-
parseCsvResponse, coerceValue, getLimit, toApiDate, fromApiDate,
|
|
18
|
+
parseCsvResponse, coerceValue, getLimit, toApiDate, fromApiDate, lastMonthISO, buildFilter,
|
|
19
19
|
extractBrand, INTENT_CODES,
|
|
20
20
|
} from './utils.js';
|
|
21
21
|
|
|
@@ -30,6 +30,30 @@ const MAX_PAID_KEYWORDS_FETCH = 10000;
|
|
|
30
30
|
const RATE_LIMIT_BASE_DELAY_MS = 1000;
|
|
31
31
|
const MAX_RETRIES = 4;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Major SEO provider databases by search volume. Used as the default fan-out
|
|
35
|
+
* set for getTopPages to aggregate traffic across top global markets.
|
|
36
|
+
*/
|
|
37
|
+
export const BIG_MARKETS = ['us', 'in', 'jp', 'br', 'uk', 'de', 'fr', 'ph', 'ca', 'it', 'au', 'mx', 'id', 'es', 'pk', 'nl', 'bd', 'pl', 'my', 'kr', 'th', 'co', 'ru', 'tr', 'ar', 'za', 'pe', 'vn', 'tw', 'ae'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the list of databases to query: BIG_MARKETS + site region if not already present.
|
|
41
|
+
* @param {string} [region] - ISO 3166-1 alpha-2 region code (e.g. 'CZ')
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
export function getDatabases(region) {
|
|
45
|
+
const databases = [...BIG_MARKETS];
|
|
46
|
+
if (region) {
|
|
47
|
+
const db = region.toLowerCase();
|
|
48
|
+
if (!databases.includes(db)) {
|
|
49
|
+
databases.push(db);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return databases;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FANOUT_BATCH_SIZE = 10;
|
|
56
|
+
|
|
33
57
|
const STUB_RESPONSE = { result: {}, fullAuditRef: '' };
|
|
34
58
|
|
|
35
59
|
export default class SeoClient {
|
|
@@ -65,6 +89,43 @@ export default class SeoClient {
|
|
|
65
89
|
this.log = log;
|
|
66
90
|
}
|
|
67
91
|
|
|
92
|
+
/**
|
|
93
|
+
* @private
|
|
94
|
+
* Fans out an async operation across items in batches, collects fulfilled
|
|
95
|
+
* results, and logs rejected ones. Each call to `fn(item)` already has
|
|
96
|
+
* per-request retry/backoff via sendRawRequest; this layer adds batching to
|
|
97
|
+
* respect rate limits and consistent error reporting for items that fail
|
|
98
|
+
* after all retries are exhausted.
|
|
99
|
+
*
|
|
100
|
+
* @param {string[]} items - Items to process (database codes, URLs, etc.)
|
|
101
|
+
* @param {function(string): Promise<T>} fn - Async operation per item
|
|
102
|
+
* @param {string} operation - Name for logging (e.g. 'getTopPages')
|
|
103
|
+
* @returns {Promise<Array<{key: string, value: T}>>} Fulfilled results
|
|
104
|
+
* @template T
|
|
105
|
+
*/
|
|
106
|
+
async fanOut(items, fn, operation) {
|
|
107
|
+
const fulfilled = [];
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < items.length; i += FANOUT_BATCH_SIZE) {
|
|
110
|
+
const batch = items.slice(i, i + FANOUT_BATCH_SIZE);
|
|
111
|
+
// eslint-disable-next-line no-await-in-loop
|
|
112
|
+
const results = await Promise.allSettled(batch.map((item) => fn(item)));
|
|
113
|
+
|
|
114
|
+
for (let j = 0; j < results.length; j += 1) {
|
|
115
|
+
const key = batch[j];
|
|
116
|
+
const result = results[j];
|
|
117
|
+
if (result.status === 'fulfilled') {
|
|
118
|
+
fulfilled.push({ key, value: result.value });
|
|
119
|
+
} else {
|
|
120
|
+
/* c8 ignore next */
|
|
121
|
+
this.log.warn(`${operation}: ${key} failed — ${result.reason?.message || result.reason}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return fulfilled;
|
|
127
|
+
}
|
|
128
|
+
|
|
68
129
|
/**
|
|
69
130
|
* Internal method that executes the HTTP request and returns the raw CSV body.
|
|
70
131
|
* Used by all endpoint methods for CSV parsing.
|
|
@@ -152,7 +213,11 @@ export default class SeoClient {
|
|
|
152
213
|
return { result, fullAuditRef };
|
|
153
214
|
}
|
|
154
215
|
|
|
155
|
-
async getTopPages(url,
|
|
216
|
+
async getTopPages(url, opts = {}) {
|
|
217
|
+
if (typeof opts !== 'object' || opts === null) {
|
|
218
|
+
throw new Error('Second argument must be an options object, not a positional value');
|
|
219
|
+
}
|
|
220
|
+
const { limit = 200, region } = opts;
|
|
156
221
|
if (!hasText(url)) {
|
|
157
222
|
throw new Error(`Invalid URL: ${url}`);
|
|
158
223
|
}
|
|
@@ -160,51 +225,70 @@ export default class SeoClient {
|
|
|
160
225
|
const ep = ENDPOINTS.topPages;
|
|
161
226
|
const epKw = ENDPOINTS.topPagesKeywords;
|
|
162
227
|
const effectiveLimit = getLimit(limit, 2000);
|
|
228
|
+
const databases = getDatabases(region);
|
|
229
|
+
|
|
230
|
+
const dbResults = await this.fanOut(databases, async (db) => {
|
|
231
|
+
const commonParams = { domain: url, database: db };
|
|
232
|
+
const [{ body: pagesBody, fullAuditRef }, { body: kwBody }] = await Promise.all([
|
|
233
|
+
this.sendRawRequest({
|
|
234
|
+
type: ep.type,
|
|
235
|
+
...commonParams,
|
|
236
|
+
display_limit: effectiveLimit,
|
|
237
|
+
export_columns: ep.columns,
|
|
238
|
+
display_filter: buildFilter([{
|
|
239
|
+
sign: '+', field: 'Tg', op: 'Gt', value: '0',
|
|
240
|
+
}]),
|
|
241
|
+
...ep.defaultParams,
|
|
242
|
+
}, ep.path),
|
|
243
|
+
this.sendRawRequest({
|
|
244
|
+
type: epKw.type,
|
|
245
|
+
...commonParams,
|
|
246
|
+
display_limit: effectiveLimit * 3,
|
|
247
|
+
export_columns: epKw.columns,
|
|
248
|
+
...epKw.defaultParams,
|
|
249
|
+
}, epKw.path),
|
|
250
|
+
]);
|
|
251
|
+
return {
|
|
252
|
+
pageRows: parseCsvResponse(pagesBody),
|
|
253
|
+
kwRows: parseCsvResponse(kwBody),
|
|
254
|
+
fullAuditRef,
|
|
255
|
+
};
|
|
256
|
+
}, 'getTopPages');
|
|
163
257
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
database: DEFAULT_DATABASE,
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// Two calls required: the SEO data provider does not offer a top-keyword-per-page
|
|
170
|
-
// field in its page-level report. Sorting organic keywords by traffic and grouping
|
|
171
|
-
// by URL client-side is the recommended approach.
|
|
172
|
-
const [{ body: pagesBody, fullAuditRef }, { body: kwBody }] = await Promise.all([
|
|
173
|
-
this.sendRawRequest({
|
|
174
|
-
type: ep.type,
|
|
175
|
-
...commonParams,
|
|
176
|
-
display_limit: effectiveLimit,
|
|
177
|
-
export_columns: ep.columns,
|
|
178
|
-
display_filter: buildFilter([{
|
|
179
|
-
sign: '+', field: 'Tg', op: 'Gt', value: '0',
|
|
180
|
-
}]),
|
|
181
|
-
...ep.defaultParams,
|
|
182
|
-
}, ep.path),
|
|
183
|
-
this.sendRawRequest({
|
|
184
|
-
type: epKw.type,
|
|
185
|
-
...commonParams,
|
|
186
|
-
display_limit: effectiveLimit * 3,
|
|
187
|
-
export_columns: epKw.columns,
|
|
188
|
-
...epKw.defaultParams,
|
|
189
|
-
}, epKw.path),
|
|
190
|
-
]);
|
|
191
|
-
|
|
192
|
-
const pageRows = parseCsvResponse(pagesBody);
|
|
193
|
-
const kwRows = parseCsvResponse(kwBody);
|
|
194
|
-
|
|
195
|
-
// Build keyword lookup: URL → top keyword (first occurrence = highest traffic)
|
|
258
|
+
// Merge pages: sum traffic across databases, keep first keyword per URL
|
|
259
|
+
const pageMap = new Map();
|
|
196
260
|
const keywordMap = new Map();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
261
|
+
let fullAuditRef = '';
|
|
262
|
+
|
|
263
|
+
for (const { value } of dbResults) {
|
|
264
|
+
if (!fullAuditRef) {
|
|
265
|
+
fullAuditRef = value.fullAuditRef;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const row of value.kwRows) {
|
|
269
|
+
if (!keywordMap.has(row.Ur)) {
|
|
270
|
+
keywordMap.set(row.Ur, row.Ph);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const row of value.pageRows) {
|
|
275
|
+
const traffic = coerceValue(row.Tg, 'int') || 0;
|
|
276
|
+
const existing = pageMap.get(row.Ur);
|
|
277
|
+
if (existing) {
|
|
278
|
+
existing.sum_traffic += traffic;
|
|
279
|
+
} else {
|
|
280
|
+
pageMap.set(row.Ur, { url: row.Ur, sum_traffic: traffic });
|
|
281
|
+
}
|
|
200
282
|
}
|
|
201
283
|
}
|
|
202
284
|
|
|
203
|
-
const pages =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
285
|
+
const pages = [...pageMap.values()]
|
|
286
|
+
.map((page) => ({
|
|
287
|
+
...page,
|
|
288
|
+
top_keyword: keywordMap.get(page.url) ?? null,
|
|
289
|
+
}))
|
|
290
|
+
.sort((a, b) => b.sum_traffic - a.sum_traffic)
|
|
291
|
+
.slice(0, effectiveLimit);
|
|
208
292
|
|
|
209
293
|
return {
|
|
210
294
|
result: { pages },
|
|
@@ -212,49 +296,54 @@ export default class SeoClient {
|
|
|
212
296
|
};
|
|
213
297
|
}
|
|
214
298
|
|
|
215
|
-
async getPaidPages(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// which scopes by report type (domain/subdomain/subfolder/URL) instead.
|
|
221
|
-
// eslint-disable-next-line no-unused-vars
|
|
222
|
-
mode = 'prefix',
|
|
223
|
-
) {
|
|
299
|
+
async getPaidPages(url, opts = {}) {
|
|
300
|
+
if (typeof opts !== 'object' || opts === null) {
|
|
301
|
+
throw new Error('Second argument must be an options object, not a positional value');
|
|
302
|
+
}
|
|
303
|
+
const { date = lastMonthISO(), limit = 200, region } = opts;
|
|
224
304
|
if (!hasText(url)) {
|
|
225
305
|
throw new Error(`Invalid URL: ${url}`);
|
|
226
306
|
}
|
|
227
307
|
|
|
228
308
|
const ep = ENDPOINTS.paidPages;
|
|
229
309
|
const effectiveLimit = getLimit(limit, 1000);
|
|
310
|
+
const databases = getDatabases(region);
|
|
230
311
|
|
|
231
312
|
// Over-fetch keywords to aggregate into pages. A domain typically has more
|
|
232
313
|
// keywords than pages, so we fetch a multiple of the requested limit.
|
|
233
314
|
// Capped at MAX_PAID_KEYWORDS_FETCH to bound cost.
|
|
234
315
|
const fetchLimit = Math.min(effectiveLimit * 10, MAX_PAID_KEYWORDS_FETCH);
|
|
235
316
|
|
|
236
|
-
const
|
|
317
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
237
318
|
type: ep.type,
|
|
238
319
|
domain: url,
|
|
239
|
-
database:
|
|
320
|
+
database: db,
|
|
240
321
|
display_date: toApiDate(date),
|
|
241
322
|
display_limit: fetchLimit,
|
|
242
323
|
export_columns: ep.columns,
|
|
243
324
|
...ep.defaultParams,
|
|
244
|
-
}, ep.path);
|
|
245
|
-
const rows = parseCsvResponse(body);
|
|
325
|
+
}, ep.path), 'getPaidPages');
|
|
246
326
|
|
|
247
|
-
// Group keywords by URL
|
|
327
|
+
// Group keywords by URL across all databases
|
|
248
328
|
const pageMap = new Map();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
329
|
+
let fullAuditRef = '';
|
|
330
|
+
|
|
331
|
+
for (const { key: db, value } of dbResults) {
|
|
332
|
+
if (!fullAuditRef) {
|
|
333
|
+
fullAuditRef = value.fullAuditRef;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const rows = parseCsvResponse(value.body);
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
const pageUrl = row.Ur;
|
|
339
|
+
if (!pageMap.has(pageUrl)) {
|
|
340
|
+
pageMap.set(pageUrl, { url: pageUrl, keywords: [], totalTraffic: 0 });
|
|
341
|
+
}
|
|
342
|
+
const page = pageMap.get(pageUrl);
|
|
343
|
+
const traffic = coerceValue(row.Tg, 'int') || 0;
|
|
344
|
+
page.totalTraffic += traffic;
|
|
345
|
+
page.keywords.push({ ...row, kwTraffic: traffic, db });
|
|
253
346
|
}
|
|
254
|
-
const page = pageMap.get(pageUrl);
|
|
255
|
-
const traffic = coerceValue(row.Tg, 'int') || 0;
|
|
256
|
-
page.totalTraffic += traffic;
|
|
257
|
-
page.keywords.push({ ...row, kwTraffic: traffic });
|
|
258
347
|
}
|
|
259
348
|
|
|
260
349
|
// Transform to page-level aggregation, sorted by traffic desc
|
|
@@ -270,7 +359,7 @@ export default class SeoClient {
|
|
|
270
359
|
url: page.url,
|
|
271
360
|
top_keyword: topKw.Ph,
|
|
272
361
|
top_keyword_best_position_title: topKw.Tt || null,
|
|
273
|
-
top_keyword_country:
|
|
362
|
+
top_keyword_country: topKw.db.toUpperCase(),
|
|
274
363
|
top_keyword_volume: coerceValue(topKw.Nq, 'int'),
|
|
275
364
|
sum_traffic: page.totalTraffic,
|
|
276
365
|
value: page.keywords.reduce(
|
|
@@ -288,37 +377,71 @@ export default class SeoClient {
|
|
|
288
377
|
};
|
|
289
378
|
}
|
|
290
379
|
|
|
291
|
-
async getMetrics(url,
|
|
380
|
+
async getMetrics(url, opts = {}) {
|
|
381
|
+
if (typeof opts !== 'object' || opts === null) {
|
|
382
|
+
throw new Error('Second argument must be an options object, not a positional value');
|
|
383
|
+
}
|
|
384
|
+
const { date = lastMonthISO(), region } = opts;
|
|
292
385
|
if (!hasText(url)) {
|
|
293
386
|
throw new Error(`Invalid URL: ${url}`);
|
|
294
387
|
}
|
|
295
388
|
|
|
296
389
|
const ep = ENDPOINTS.metrics;
|
|
390
|
+
const databases = getDatabases(region);
|
|
297
391
|
|
|
298
|
-
const
|
|
392
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
299
393
|
type: ep.type,
|
|
300
394
|
domain: url,
|
|
301
|
-
database:
|
|
395
|
+
database: db,
|
|
302
396
|
display_date: toApiDate(date),
|
|
303
397
|
export_columns: ep.columns,
|
|
304
398
|
...ep.defaultParams,
|
|
305
|
-
}, ep.path);
|
|
306
|
-
|
|
307
|
-
const
|
|
399
|
+
}, ep.path), 'getMetrics');
|
|
400
|
+
|
|
401
|
+
const metrics = {
|
|
402
|
+
org_keywords: 0,
|
|
403
|
+
paid_keywords: 0,
|
|
404
|
+
org_keywords_1_3: 0,
|
|
405
|
+
org_traffic: 0,
|
|
406
|
+
org_cost: 0,
|
|
407
|
+
paid_traffic: 0,
|
|
408
|
+
paid_cost: 0,
|
|
409
|
+
paid_pages: null,
|
|
410
|
+
};
|
|
411
|
+
let fullAuditRef = '';
|
|
412
|
+
let hasData = false;
|
|
413
|
+
|
|
414
|
+
for (const { value } of dbResults) {
|
|
415
|
+
if (!fullAuditRef) {
|
|
416
|
+
fullAuditRef = value.fullAuditRef;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const rows = parseCsvResponse(value.body);
|
|
420
|
+
const row = rows[0];
|
|
421
|
+
if (row) {
|
|
422
|
+
hasData = true;
|
|
423
|
+
metrics.org_keywords += coerceValue(row.Or, 'int') || 0;
|
|
424
|
+
metrics.paid_keywords += coerceValue(row.Ad, 'int') || 0;
|
|
425
|
+
metrics.org_keywords_1_3 += coerceValue(row.X0, 'int') || 0;
|
|
426
|
+
metrics.org_traffic += coerceValue(row.Ot, 'int') || 0;
|
|
427
|
+
metrics.org_cost += Math.round((coerceValue(row.Oc, 'float') || 0) * 100);
|
|
428
|
+
metrics.paid_traffic += coerceValue(row.At, 'int') || 0;
|
|
429
|
+
metrics.paid_cost += Math.round((coerceValue(row.Ac, 'float') || 0) * 100);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!hasData) {
|
|
434
|
+
metrics.org_keywords = null;
|
|
435
|
+
metrics.paid_keywords = null;
|
|
436
|
+
metrics.org_keywords_1_3 = null;
|
|
437
|
+
metrics.org_traffic = null;
|
|
438
|
+
metrics.org_cost = 0;
|
|
439
|
+
metrics.paid_traffic = null;
|
|
440
|
+
metrics.paid_cost = 0;
|
|
441
|
+
}
|
|
308
442
|
|
|
309
443
|
return {
|
|
310
|
-
result: {
|
|
311
|
-
metrics: {
|
|
312
|
-
org_keywords: coerceValue(row.Or, 'int'),
|
|
313
|
-
paid_keywords: coerceValue(row.Ad, 'int'),
|
|
314
|
-
org_keywords_1_3: coerceValue(row.X0, 'int'),
|
|
315
|
-
org_traffic: coerceValue(row.Ot, 'int'),
|
|
316
|
-
org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
|
|
317
|
-
paid_traffic: coerceValue(row.At, 'int'),
|
|
318
|
-
paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
|
|
319
|
-
paid_pages: null,
|
|
320
|
-
},
|
|
321
|
-
},
|
|
444
|
+
result: { metrics },
|
|
322
445
|
fullAuditRef,
|
|
323
446
|
};
|
|
324
447
|
}
|
|
@@ -329,41 +452,77 @@ export default class SeoClient {
|
|
|
329
452
|
* The full history is fetched and filtered client-side because the provider's
|
|
330
453
|
* history endpoint does not support date range parameters.
|
|
331
454
|
* @param {string} url - The target domain
|
|
332
|
-
* @param {
|
|
333
|
-
* @param {string}
|
|
455
|
+
* @param {object} options
|
|
456
|
+
* @param {string} options.startDate - Start date in YYYY-MM-DD format
|
|
457
|
+
* @param {string} options.endDate - End date in YYYY-MM-DD format
|
|
458
|
+
* @param {string} [options.region] - ISO 3166-1 alpha-2 region code
|
|
334
459
|
* @returns {Promise<{result: {metrics: Array}, fullAuditRef: string}>}
|
|
335
460
|
*/
|
|
336
|
-
async getOrganicTraffic(url,
|
|
461
|
+
async getOrganicTraffic(url, opts = {}) {
|
|
462
|
+
if (typeof opts !== 'object' || opts === null) {
|
|
463
|
+
throw new Error('Second argument must be an options object, not a positional value');
|
|
464
|
+
}
|
|
465
|
+
const { startDate, endDate, region } = opts;
|
|
337
466
|
if (!hasText(url)) {
|
|
338
467
|
throw new Error(`Invalid URL: ${url}`);
|
|
339
468
|
}
|
|
469
|
+
if (!hasText(startDate) || !hasText(endDate)) {
|
|
470
|
+
throw new Error('startDate and endDate are required');
|
|
471
|
+
}
|
|
340
472
|
|
|
341
473
|
const ep = ENDPOINTS.organicTraffic;
|
|
474
|
+
const databases = getDatabases(region);
|
|
342
475
|
|
|
343
|
-
const
|
|
476
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
344
477
|
type: ep.type,
|
|
345
478
|
domain: url,
|
|
346
|
-
database:
|
|
479
|
+
database: db,
|
|
347
480
|
export_columns: ep.columns,
|
|
348
481
|
...ep.defaultParams,
|
|
349
|
-
}, ep.path);
|
|
350
|
-
const rows = parseCsvResponse(body);
|
|
482
|
+
}, ep.path), 'getOrganicTraffic');
|
|
351
483
|
|
|
352
|
-
//
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
484
|
+
// Group by date across all databases, sum numeric fields
|
|
485
|
+
const dateMap = new Map();
|
|
486
|
+
let fullAuditRef = '';
|
|
487
|
+
|
|
488
|
+
for (const { value } of dbResults) {
|
|
489
|
+
if (!fullAuditRef) {
|
|
490
|
+
fullAuditRef = value.fullAuditRef;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const rows = parseCsvResponse(value.body);
|
|
494
|
+
for (const row of rows) {
|
|
495
|
+
const isoDate = fromApiDate(row.Dt);
|
|
496
|
+
if (!isoDate || isoDate < startDate || isoDate > endDate) {
|
|
497
|
+
// eslint-disable-next-line no-continue
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const existing = dateMap.get(isoDate);
|
|
502
|
+
if (existing) {
|
|
503
|
+
existing.org_traffic += coerceValue(row.Ot, 'int') || 0;
|
|
504
|
+
existing.paid_traffic += coerceValue(row.At, 'int') || 0;
|
|
505
|
+
existing.org_cost += Math.round((coerceValue(row.Oc, 'float') || 0) * 100);
|
|
506
|
+
/* c8 ignore next */
|
|
507
|
+
existing.paid_cost += Math.round((coerceValue(row.Ac, 'float') || 0) * 100);
|
|
508
|
+
} else {
|
|
509
|
+
/* c8 ignore next 6 - || 0 null-coercion branches */
|
|
510
|
+
dateMap.set(isoDate, {
|
|
511
|
+
date: `${isoDate}T00:00:00Z`,
|
|
512
|
+
org_traffic: coerceValue(row.Ot, 'int') || 0,
|
|
513
|
+
paid_traffic: coerceValue(row.At, 'int') || 0,
|
|
514
|
+
org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
|
|
515
|
+
paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const metrics = [...dateMap.values()]
|
|
522
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
356
523
|
|
|
357
524
|
return {
|
|
358
|
-
result: {
|
|
359
|
-
metrics: filtered.map((row) => ({
|
|
360
|
-
date: `${row.isoDate}T00:00:00Z`,
|
|
361
|
-
org_traffic: coerceValue(row.Ot, 'int'),
|
|
362
|
-
paid_traffic: coerceValue(row.At, 'int'),
|
|
363
|
-
org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
|
|
364
|
-
paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
|
|
365
|
-
})),
|
|
366
|
-
},
|
|
525
|
+
result: { metrics },
|
|
367
526
|
fullAuditRef,
|
|
368
527
|
};
|
|
369
528
|
}
|
|
@@ -516,32 +675,21 @@ export default class SeoClient {
|
|
|
516
675
|
}
|
|
517
676
|
|
|
518
677
|
// Step 2: For each broken page, fetch the top backlink by authority score.
|
|
519
|
-
// Parallelized in batches to balance throughput and rate limits.
|
|
520
|
-
const BATCH_SIZE = 10;
|
|
521
678
|
const brokenUrls = brokenPages.map((p) => p.source_url);
|
|
522
|
-
const
|
|
679
|
+
const linkResults = await this.fanOut(brokenUrls, (brokenUrl) => this.sendRawRequest({
|
|
680
|
+
type: epLinks.type,
|
|
681
|
+
target: brokenUrl,
|
|
682
|
+
target_type: 'url',
|
|
683
|
+
export_columns: epLinks.columns,
|
|
684
|
+
display_limit: 1,
|
|
685
|
+
...epLinks.defaultParams,
|
|
686
|
+
}, epLinks.path), 'getBrokenBacklinks');
|
|
523
687
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
type: epLinks.type,
|
|
530
|
-
target: brokenUrl,
|
|
531
|
-
target_type: 'url',
|
|
532
|
-
export_columns: epLinks.columns,
|
|
533
|
-
display_limit: 1,
|
|
534
|
-
...epLinks.defaultParams,
|
|
535
|
-
}, epLinks.path)),
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
for (const result of results) {
|
|
539
|
-
if (result.status === 'fulfilled') {
|
|
540
|
-
const links = parseCsvResponse(result.value.body);
|
|
541
|
-
if (links.length > 0) {
|
|
542
|
-
allBacklinks.push(links[0]);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
688
|
+
const allBacklinks = [];
|
|
689
|
+
for (const { value } of linkResults) {
|
|
690
|
+
const links = parseCsvResponse(value.body);
|
|
691
|
+
if (links.length > 0) {
|
|
692
|
+
allBacklinks.push(links[0]);
|
|
545
693
|
}
|
|
546
694
|
}
|
|
547
695
|
|
|
@@ -566,7 +714,7 @@ export default class SeoClient {
|
|
|
566
714
|
}
|
|
567
715
|
|
|
568
716
|
// eslint-disable-next-line no-unused-vars, class-methods-use-this
|
|
569
|
-
async getMetricsByCountry(url, date =
|
|
717
|
+
async getMetricsByCountry(url, date = lastMonthISO()) {
|
|
570
718
|
return STUB_RESPONSE;
|
|
571
719
|
}
|
|
572
720
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -41,14 +41,19 @@ export default class SeoClient {
|
|
|
41
41
|
getBrokenBacklinks(url: string, limit?: number):
|
|
42
42
|
Promise<{ result: object, fullAuditRef: string }>;
|
|
43
43
|
|
|
44
|
-
getTopPages(url: string,
|
|
45
|
-
|
|
44
|
+
getTopPages(url: string, options?: {
|
|
45
|
+
limit?: number,
|
|
46
|
+
region?: string,
|
|
47
|
+
}): Promise<{ result: object, fullAuditRef: string }>;
|
|
46
48
|
|
|
47
49
|
getBacklinks(url: string, limit?: number):
|
|
48
50
|
Promise<{ result: object, fullAuditRef: string }>;
|
|
49
51
|
|
|
50
|
-
getOrganicTraffic(url: string,
|
|
51
|
-
|
|
52
|
+
getOrganicTraffic(url: string, options?: {
|
|
53
|
+
startDate?: string,
|
|
54
|
+
endDate?: string,
|
|
55
|
+
region?: string,
|
|
56
|
+
}): Promise<{ result: object, fullAuditRef: string }>;
|
|
52
57
|
|
|
53
58
|
getOrganicKeywords(
|
|
54
59
|
url: string,
|
|
@@ -61,12 +66,16 @@ export default class SeoClient {
|
|
|
61
66
|
}):
|
|
62
67
|
Promise<{ result: object, fullAuditRef: string }>;
|
|
63
68
|
|
|
64
|
-
getPaidPages(url: string,
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
getPaidPages(url: string, options?: {
|
|
70
|
+
date?: string,
|
|
71
|
+
limit?: number,
|
|
72
|
+
region?: string,
|
|
73
|
+
}): Promise<{ result: object, fullAuditRef: string }>;
|
|
67
74
|
|
|
68
|
-
getMetrics(url: string,
|
|
69
|
-
|
|
75
|
+
getMetrics(url: string, options?: {
|
|
76
|
+
date?: string,
|
|
77
|
+
region?: string,
|
|
78
|
+
}): Promise<{ result: object, fullAuditRef: string }>;
|
|
70
79
|
|
|
71
80
|
getMetricsByCountry(url: string, date?: string):
|
|
72
81
|
Promise<{ result: object, fullAuditRef: string }>;
|
|
@@ -78,6 +87,9 @@ export function getLimit(limit: number, upperLimit: number): number;
|
|
|
78
87
|
export function toApiDate(date: string): string;
|
|
79
88
|
export function fromApiDate(apiDate: string): string | null;
|
|
80
89
|
export function todayISO(): string;
|
|
90
|
+
export function lastMonthISO(): string;
|
|
91
|
+
export function getDatabases(region?: string): string[];
|
|
92
|
+
export const BIG_MARKETS: readonly string[];
|
|
81
93
|
export function buildFilter(filters: Array<{sign: string, field: string, op: string, value: string}>): string;
|
|
82
94
|
export function extractBrand(domain: string): string;
|
|
83
95
|
export function buildQueryParams(defaults: object, overrides: object): object;
|
package/src/index.js
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
import SeoClient from './client.js';
|
|
14
14
|
|
|
15
15
|
export default SeoClient;
|
|
16
|
-
export { fetch } from './client.js';
|
|
16
|
+
export { fetch, BIG_MARKETS, getDatabases } from './client.js';
|
|
17
17
|
export { ENDPOINTS } from './endpoints.js';
|
|
18
18
|
export {
|
|
19
|
-
buildQueryParams, parseCsvResponse, coerceValue, getLimit,
|
|
19
|
+
buildQueryParams, parseCsvResponse, coerceValue, getLimit,
|
|
20
|
+
toApiDate, fromApiDate, todayISO, lastMonthISO,
|
|
20
21
|
buildFilter, extractBrand, INTENT_CODES,
|
|
21
22
|
} from './utils.js';
|
|
22
23
|
|
package/src/utils.js
CHANGED
|
@@ -168,6 +168,19 @@ export function todayISO() {
|
|
|
168
168
|
return new Date().toISOString().split('T')[0];
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Returns the 1st of the previous month as YYYY-MM-DD.
|
|
173
|
+
* The SEO provider publishes monthly snapshots with a delay, so the most
|
|
174
|
+
* recent available data is typically from the previous month.
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function lastMonthISO() {
|
|
178
|
+
const now = new Date();
|
|
179
|
+
now.setUTCDate(1);
|
|
180
|
+
now.setUTCMonth(now.getUTCMonth() - 1);
|
|
181
|
+
return now.toISOString().split('T')[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
/**
|
|
172
185
|
* Converts a YYYYMMDD date string from the API to YYYY-MM-DD.
|
|
173
186
|
* Returns null for invalid/missing inputs.
|