@adobe/mysticat-shared-seo-client 1.1.2 → 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 CHANGED
@@ -1,3 +1,15 @@
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
+
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)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **deps:** update adobe fixes ([#1503](https://github.com/adobe/spacecat-shared/issues/1503)) ([21d4d4b](https://github.com/adobe/spacecat-shared/commit/21d4d4b3cfa95ca7748ef4b65b04cace24768dfc))
12
+
1
13
  ## [@adobe/mysticat-shared-seo-client-v1.1.2](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.1.1...@adobe/mysticat-shared-seo-client-v1.1.2) (2026-04-06)
2
14
 
3
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/mysticat-shared-seo-client",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Shared modules of the SpaceCat Services - SEO Client",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@adobe/fetch": "4.2.3",
38
- "@adobe/helix-universal": "5.4.0",
37
+ "@adobe/fetch": "4.3.0",
38
+ "@adobe/helix-universal": "5.4.1",
39
39
  "@adobe/spacecat-shared-utils": "1.81.1",
40
40
  "urijs": "1.19.11"
41
41
  },
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, todayISO, buildFilter,
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, limit = 200) {
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
- const commonParams = {
165
- domain: url,
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
- for (const row of kwRows) {
198
- if (!keywordMap.has(row.Ur)) {
199
- keywordMap.set(row.Ur, row.Ph);
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 = pageRows.map((row) => ({
204
- url: row.Ur,
205
- sum_traffic: coerceValue(row.Tg, 'int'),
206
- top_keyword: keywordMap.get(row.Ur) ?? null,
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
- url,
217
- date = todayISO(),
218
- limit = 200,
219
- // Accepted for contract compatibility but not supported by this provider,
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 { body, fullAuditRef } = await this.sendRawRequest({
317
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
237
318
  type: ep.type,
238
319
  domain: url,
239
- database: DEFAULT_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
- for (const row of rows) {
250
- const pageUrl = row.Ur;
251
- if (!pageMap.has(pageUrl)) {
252
- pageMap.set(pageUrl, { url: pageUrl, keywords: [], totalTraffic: 0 });
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: DEFAULT_DATABASE.toUpperCase(),
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, date = todayISO()) {
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 { body, fullAuditRef } = await this.sendRawRequest({
392
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
299
393
  type: ep.type,
300
394
  domain: url,
301
- database: DEFAULT_DATABASE,
395
+ database: db,
302
396
  display_date: toApiDate(date),
303
397
  export_columns: ep.columns,
304
398
  ...ep.defaultParams,
305
- }, ep.path);
306
- const rows = parseCsvResponse(body);
307
- const row = rows[0] || {};
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 {string} startDate - Start date in YYYY-MM-DD format
333
- * @param {string} endDate - End date in YYYY-MM-DD format
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, startDate, endDate) {
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 { body, fullAuditRef } = await this.sendRawRequest({
476
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
344
477
  type: ep.type,
345
478
  domain: url,
346
- database: DEFAULT_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
- // Convert API dates (YYYYMMDD) to ISO (YYYY-MM-DD) and filter to requested range
353
- const filtered = rows
354
- .map((row) => ({ ...row, isoDate: fromApiDate(row.Dt) }))
355
- .filter((row) => row.isoDate && row.isoDate >= startDate && row.isoDate <= endDate);
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 allBacklinks = [];
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
- for (let i = 0; i < brokenUrls.length; i += BATCH_SIZE) {
525
- const batch = brokenUrls.slice(i, i + BATCH_SIZE);
526
- // eslint-disable-next-line no-await-in-loop
527
- const results = await Promise.allSettled(
528
- batch.map((brokenUrl) => this.sendRawRequest({
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 = todayISO()) {
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, limit?: number):
45
- Promise<{ result: object, fullAuditRef: string }>;
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, startDate: string, endDate: string):
51
- Promise<{ result: object, fullAuditRef: string }>;
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, date?: string, limit?: number,
65
- mode?: 'exact' | 'prefix' | 'domain' | 'subdomains'):
66
- Promise<{ result: object, fullAuditRef: string }>;
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, date?: string):
69
- Promise<{ result: object, fullAuditRef: string }>;
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, toApiDate, fromApiDate, todayISO,
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
@@ -101,9 +101,13 @@ function splitCsvLine(line) {
101
101
  * @returns {object[]} Array of row objects keyed by column codes
102
102
  */
103
103
  export function parseCsvResponse(text) {
104
- if (!text || typeof text !== 'string') return [];
104
+ if (!text || typeof text !== 'string') {
105
+ return [];
106
+ }
105
107
  const lines = text.trim().split('\n');
106
- if (lines.length < 2) return [];
108
+ if (lines.length < 2) {
109
+ return [];
110
+ }
107
111
  const headers = splitCsvLine(lines[0]).map((h) => normalizeHeader(h.trim()));
108
112
  return lines.slice(1).map((line) => {
109
113
  const values = splitCsvLine(line);
@@ -120,7 +124,9 @@ export function parseCsvResponse(text) {
120
124
  * @returns {*} Coerced value
121
125
  */
122
126
  export function coerceValue(value, type) {
123
- if (value === '' || value === undefined || value === null) return null;
127
+ if (value === '' || value === undefined || value === null) {
128
+ return null;
129
+ }
124
130
  switch (type) {
125
131
  case 'int': {
126
132
  const parsed = parseInt(value, 10);
@@ -162,6 +168,19 @@ export function todayISO() {
162
168
  return new Date().toISOString().split('T')[0];
163
169
  }
164
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
+
165
184
  /**
166
185
  * Converts a YYYYMMDD date string from the API to YYYY-MM-DD.
167
186
  * Returns null for invalid/missing inputs.
@@ -169,7 +188,9 @@ export function todayISO() {
169
188
  * @returns {string|null} Date in YYYY-MM-DD format, or null if invalid
170
189
  */
171
190
  export function fromApiDate(apiDate) {
172
- if (!apiDate || apiDate.length < 8 || !/^\d{8}$/.test(apiDate)) return null;
191
+ if (!apiDate || apiDate.length < 8 || !/^\d{8}$/.test(apiDate)) {
192
+ return null;
193
+ }
173
194
  return `${apiDate.slice(0, 4)}-${apiDate.slice(4, 6)}-${apiDate.slice(6, 8)}`;
174
195
  }
175
196
 
@@ -205,7 +226,9 @@ export function buildFilter(filters) {
205
226
  * @returns {string} Lowercase brand name
206
227
  */
207
228
  export function extractBrand(domain) {
208
- if (!domain) return '';
229
+ if (!domain) {
230
+ return '';
231
+ }
209
232
  const normalized = domain.includes('://') ? domain : `https://${domain}`;
210
233
  const registrable = new URI(normalized).domain();
211
234
  return registrable.split('.')[0].toLowerCase();