@adobe/mysticat-shared-seo-client 1.1.3 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [@adobe/mysticat-shared-seo-client-v1.2.1](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.2.0...@adobe/mysticat-shared-seo-client-v1.2.1) (2026-04-10)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **seo-client:** add prefix URL filter to getTopPages ([#1528](https://github.com/adobe/spacecat-shared/issues/1528)) ([60e0a31](https://github.com/adobe/spacecat-shared/commit/60e0a31cb2948282c61498baaa0442791ed210b2))
6
+
7
+ ## [@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)
8
+
9
+ ### Features
10
+
11
+ * **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))
12
+
1
13
  ## [@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
14
 
3
15
  ### Bug Fixes
package/README.md CHANGED
@@ -29,6 +29,43 @@ const config = {
29
29
  const client = new SeoClient(config, fetch);
30
30
  ```
31
31
 
32
+ ## API Methods
33
+
34
+ ### `getTopPages(url, opts)`
35
+
36
+ Returns the top organic pages for a given URL prefix, sorted by traffic. Fans out across multiple databases to aggregate traffic globally.
37
+
38
+ **Parameters:**
39
+
40
+ | Parameter | Type | Default | Description |
41
+ |-----------|------|---------|-------------|
42
+ | `url` | `string` | *(required)* | A **prefix URL** scoping which pages to return. Can include protocol (e.g., `https://www.example.com`) or omit it (e.g., `www.example.com`, `example.com/us`). |
43
+ | `opts.limit` | `number` | `200` | Maximum number of pages to return (capped at 2000). |
44
+ | `opts.region` | `string` | *(optional)* | ISO 3166-1 alpha-2 region code (e.g., `CZ`). Added to the default database list if not already present. |
45
+
46
+ **Prefix URL filtering:**
47
+
48
+ The `url` parameter acts as a prefix filter, not just a plain domain. This ensures that only pages belonging to the intended hostname (and optional path prefix) are returned. For example, passing `https://www.example.com` excludes pages from subdomains like `blog.example.com` or `shop.example.com`.
49
+
50
+ - **www prefixes** (e.g., `https://www.example.com`): The method applies a server-side `Bw` (begins with) display filter on the SEO API call. This reliably scopes results to the correct hostname.
51
+ - **Non-www prefixes** (e.g., `https://example.com`, `example.com/us`): The `Bw` API filter is unreliable for non-www domains because it can match subdomains (e.g., a filter for `https://example.com` also matches `https://example.com.au`). To handle this, the method over-fetches (2x the requested limit) and applies client-side filtering, keeping only pages whose URL equals the prefix or starts with `{prefix}/`.
52
+ - If client-side filtering for non-www prefixes cannot meet the requested `limit`, a warning is logged (when the API returned a full result set, suggesting more pages exist but were filtered out) or a debug message is logged (when the provider simply has fewer matching pages).
53
+
54
+ **Returns:** `{ result: { pages: Array<{ url, sum_traffic, top_keyword }> }, fullAuditRef }`
55
+
56
+ **Example:**
57
+
58
+ ```js
59
+ // Fetch top 100 pages for www.example.com (API-level filtering)
60
+ const { result } = await client.getTopPages('https://www.example.com', { limit: 100 });
61
+
62
+ // Fetch top 50 pages for a subfolder prefix (client-side filtering)
63
+ const { result: subResult } = await client.getTopPages('example.com/us', { limit: 50 });
64
+
65
+ // Fetch with a specific region database
66
+ const { result: czResult } = await client.getTopPages('https://www.example.cz', { limit: 100, region: 'CZ' });
67
+ ```
68
+
32
69
  ## Testing
33
70
 
34
71
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/mysticat-shared-seo-client",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Shared modules of the SpaceCat Services - SEO Client",
5
5
  "type": "module",
6
6
  "engines": {
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,59 +213,126 @@ 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
  }
159
224
 
225
+ // Ensure url has protocol for the Bw filter and extract hostname for the API.
226
+ // Input is a prefix URL, either full (https://www.example.com) or protocol-stripped
227
+ // (www.example.com, example.com/us).
228
+ const prefixUrl = (url.includes('://') ? url : `https://${url}`).replace(/\/+$/, '');
229
+ let domain;
230
+ try {
231
+ domain = new URL(prefixUrl).hostname;
232
+ } catch {
233
+ this.log.warn(`[SEO] Could not parse URL "${url}", using raw value as domain`);
234
+ domain = url;
235
+ }
236
+
237
+ const isWww = domain.startsWith('www.');
160
238
  const ep = ENDPOINTS.topPages;
161
239
  const epKw = ENDPOINTS.topPagesKeywords;
162
- const effectiveLimit = getLimit(limit, 2000);
163
240
 
164
- const commonParams = {
165
- domain: url,
166
- database: DEFAULT_DATABASE,
167
- };
241
+ // For non-www prefixes, the Bw filter is unreliable (matches subdomains),
242
+ // so we over-fetch and filter client-side. Note: at limit >= 1000 the 2000
243
+ // cap leaves no over-fetch headroom.
244
+ const requestLimit = !isWww
245
+ ? getLimit(limit * 2, 2000)
246
+ : getLimit(limit, 2000);
247
+ const databases = getDatabases(region);
248
+
249
+ // Build display_filter: traffic > 0, plus Bw prefix filter
250
+ const filters = [
251
+ {
252
+ sign: '+', field: 'Tg', op: 'Gt', value: '0',
253
+ },
254
+ {
255
+ sign: '+', field: 'Ur', op: 'Bw', value: prefixUrl,
256
+ },
257
+ ];
258
+
259
+ const dbResults = await this.fanOut(databases, async (db) => {
260
+ const commonParams = { domain, database: db };
261
+ const [{ body: pagesBody, fullAuditRef: ref }, { body: kwBody }] = await Promise.all([
262
+ this.sendRawRequest({
263
+ type: ep.type,
264
+ ...commonParams,
265
+ display_limit: requestLimit,
266
+ export_columns: ep.columns,
267
+ display_filter: buildFilter(filters),
268
+ ...ep.defaultParams,
269
+ }, ep.path),
270
+ this.sendRawRequest({
271
+ type: epKw.type,
272
+ ...commonParams,
273
+ display_limit: requestLimit * 3,
274
+ export_columns: epKw.columns,
275
+ ...epKw.defaultParams,
276
+ }, epKw.path),
277
+ ]);
278
+ return {
279
+ pageRows: parseCsvResponse(pagesBody),
280
+ kwRows: parseCsvResponse(kwBody),
281
+ fullAuditRef: ref,
282
+ };
283
+ }, 'getTopPages');
168
284
 
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)
285
+ // Merge pages: sum traffic across databases, keep first keyword per URL
286
+ const pageMap = new Map();
196
287
  const keywordMap = new Map();
197
- for (const row of kwRows) {
198
- if (!keywordMap.has(row.Ur)) {
199
- keywordMap.set(row.Ur, row.Ph);
288
+ let fullAuditRef = '';
289
+
290
+ for (const { value } of dbResults) {
291
+ if (!fullAuditRef) {
292
+ fullAuditRef = value.fullAuditRef;
293
+ }
294
+
295
+ for (const row of value.kwRows) {
296
+ if (!keywordMap.has(row.Ur)) {
297
+ keywordMap.set(row.Ur, row.Ph);
298
+ }
299
+ }
300
+
301
+ for (const row of value.pageRows) {
302
+ const traffic = coerceValue(row.Tg, 'int') || 0;
303
+ const existing = pageMap.get(row.Ur);
304
+ if (existing) {
305
+ existing.sum_traffic += traffic;
306
+ } else {
307
+ pageMap.set(row.Ur, { url: row.Ur, sum_traffic: traffic });
308
+ }
200
309
  }
201
310
  }
202
311
 
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
- }));
312
+ let pages = [...pageMap.values()]
313
+ .map((page) => ({
314
+ ...page,
315
+ top_keyword: keywordMap.get(page.url) ?? null,
316
+ }))
317
+ .sort((a, b) => b.sum_traffic - a.sum_traffic);
318
+
319
+ // For non-www prefixes, apply client-side filtering since Bw is unreliable
320
+ if (!isWww) {
321
+ const filteredPages = pages.filter(
322
+ (page) => page.url === prefixUrl
323
+ || page.url.startsWith(`${prefixUrl}/`),
324
+ );
325
+
326
+ if (filteredPages.length < limit && pages.length >= requestLimit) {
327
+ this.log.warn(`[SEO] Could not meet ${limit} top pages for ${prefixUrl} after requesting ${requestLimit} (got ${filteredPages.length} matching)`);
328
+ } else if (filteredPages.length < limit) {
329
+ this.log.debug(`[SEO] Provider has only ${filteredPages.length} pages matching prefix ${prefixUrl}`);
330
+ }
331
+
332
+ pages = filteredPages;
333
+ }
334
+
335
+ pages = pages.slice(0, limit);
208
336
 
209
337
  return {
210
338
  result: { pages },
@@ -212,49 +340,54 @@ export default class SeoClient {
212
340
  };
213
341
  }
214
342
 
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
- ) {
343
+ async getPaidPages(url, opts = {}) {
344
+ if (typeof opts !== 'object' || opts === null) {
345
+ throw new Error('Second argument must be an options object, not a positional value');
346
+ }
347
+ const { date = lastMonthISO(), limit = 200, region } = opts;
224
348
  if (!hasText(url)) {
225
349
  throw new Error(`Invalid URL: ${url}`);
226
350
  }
227
351
 
228
352
  const ep = ENDPOINTS.paidPages;
229
353
  const effectiveLimit = getLimit(limit, 1000);
354
+ const databases = getDatabases(region);
230
355
 
231
356
  // Over-fetch keywords to aggregate into pages. A domain typically has more
232
357
  // keywords than pages, so we fetch a multiple of the requested limit.
233
358
  // Capped at MAX_PAID_KEYWORDS_FETCH to bound cost.
234
359
  const fetchLimit = Math.min(effectiveLimit * 10, MAX_PAID_KEYWORDS_FETCH);
235
360
 
236
- const { body, fullAuditRef } = await this.sendRawRequest({
361
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
237
362
  type: ep.type,
238
363
  domain: url,
239
- database: DEFAULT_DATABASE,
364
+ database: db,
240
365
  display_date: toApiDate(date),
241
366
  display_limit: fetchLimit,
242
367
  export_columns: ep.columns,
243
368
  ...ep.defaultParams,
244
- }, ep.path);
245
- const rows = parseCsvResponse(body);
369
+ }, ep.path), 'getPaidPages');
246
370
 
247
- // Group keywords by URL
371
+ // Group keywords by URL across all databases
248
372
  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 });
373
+ let fullAuditRef = '';
374
+
375
+ for (const { key: db, value } of dbResults) {
376
+ if (!fullAuditRef) {
377
+ fullAuditRef = value.fullAuditRef;
378
+ }
379
+
380
+ const rows = parseCsvResponse(value.body);
381
+ for (const row of rows) {
382
+ const pageUrl = row.Ur;
383
+ if (!pageMap.has(pageUrl)) {
384
+ pageMap.set(pageUrl, { url: pageUrl, keywords: [], totalTraffic: 0 });
385
+ }
386
+ const page = pageMap.get(pageUrl);
387
+ const traffic = coerceValue(row.Tg, 'int') || 0;
388
+ page.totalTraffic += traffic;
389
+ page.keywords.push({ ...row, kwTraffic: traffic, db });
253
390
  }
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
391
  }
259
392
 
260
393
  // Transform to page-level aggregation, sorted by traffic desc
@@ -270,7 +403,7 @@ export default class SeoClient {
270
403
  url: page.url,
271
404
  top_keyword: topKw.Ph,
272
405
  top_keyword_best_position_title: topKw.Tt || null,
273
- top_keyword_country: DEFAULT_DATABASE.toUpperCase(),
406
+ top_keyword_country: topKw.db.toUpperCase(),
274
407
  top_keyword_volume: coerceValue(topKw.Nq, 'int'),
275
408
  sum_traffic: page.totalTraffic,
276
409
  value: page.keywords.reduce(
@@ -288,37 +421,71 @@ export default class SeoClient {
288
421
  };
289
422
  }
290
423
 
291
- async getMetrics(url, date = todayISO()) {
424
+ async getMetrics(url, opts = {}) {
425
+ if (typeof opts !== 'object' || opts === null) {
426
+ throw new Error('Second argument must be an options object, not a positional value');
427
+ }
428
+ const { date = lastMonthISO(), region } = opts;
292
429
  if (!hasText(url)) {
293
430
  throw new Error(`Invalid URL: ${url}`);
294
431
  }
295
432
 
296
433
  const ep = ENDPOINTS.metrics;
434
+ const databases = getDatabases(region);
297
435
 
298
- const { body, fullAuditRef } = await this.sendRawRequest({
436
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
299
437
  type: ep.type,
300
438
  domain: url,
301
- database: DEFAULT_DATABASE,
439
+ database: db,
302
440
  display_date: toApiDate(date),
303
441
  export_columns: ep.columns,
304
442
  ...ep.defaultParams,
305
- }, ep.path);
306
- const rows = parseCsvResponse(body);
307
- const row = rows[0] || {};
443
+ }, ep.path), 'getMetrics');
444
+
445
+ const metrics = {
446
+ org_keywords: 0,
447
+ paid_keywords: 0,
448
+ org_keywords_1_3: 0,
449
+ org_traffic: 0,
450
+ org_cost: 0,
451
+ paid_traffic: 0,
452
+ paid_cost: 0,
453
+ paid_pages: null,
454
+ };
455
+ let fullAuditRef = '';
456
+ let hasData = false;
457
+
458
+ for (const { value } of dbResults) {
459
+ if (!fullAuditRef) {
460
+ fullAuditRef = value.fullAuditRef;
461
+ }
462
+
463
+ const rows = parseCsvResponse(value.body);
464
+ const row = rows[0];
465
+ if (row) {
466
+ hasData = true;
467
+ metrics.org_keywords += coerceValue(row.Or, 'int') || 0;
468
+ metrics.paid_keywords += coerceValue(row.Ad, 'int') || 0;
469
+ metrics.org_keywords_1_3 += coerceValue(row.X0, 'int') || 0;
470
+ metrics.org_traffic += coerceValue(row.Ot, 'int') || 0;
471
+ metrics.org_cost += Math.round((coerceValue(row.Oc, 'float') || 0) * 100);
472
+ metrics.paid_traffic += coerceValue(row.At, 'int') || 0;
473
+ metrics.paid_cost += Math.round((coerceValue(row.Ac, 'float') || 0) * 100);
474
+ }
475
+ }
476
+
477
+ if (!hasData) {
478
+ metrics.org_keywords = null;
479
+ metrics.paid_keywords = null;
480
+ metrics.org_keywords_1_3 = null;
481
+ metrics.org_traffic = null;
482
+ metrics.org_cost = 0;
483
+ metrics.paid_traffic = null;
484
+ metrics.paid_cost = 0;
485
+ }
308
486
 
309
487
  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
- },
488
+ result: { metrics },
322
489
  fullAuditRef,
323
490
  };
324
491
  }
@@ -329,41 +496,77 @@ export default class SeoClient {
329
496
  * The full history is fetched and filtered client-side because the provider's
330
497
  * history endpoint does not support date range parameters.
331
498
  * @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
499
+ * @param {object} options
500
+ * @param {string} options.startDate - Start date in YYYY-MM-DD format
501
+ * @param {string} options.endDate - End date in YYYY-MM-DD format
502
+ * @param {string} [options.region] - ISO 3166-1 alpha-2 region code
334
503
  * @returns {Promise<{result: {metrics: Array}, fullAuditRef: string}>}
335
504
  */
336
- async getOrganicTraffic(url, startDate, endDate) {
505
+ async getOrganicTraffic(url, opts = {}) {
506
+ if (typeof opts !== 'object' || opts === null) {
507
+ throw new Error('Second argument must be an options object, not a positional value');
508
+ }
509
+ const { startDate, endDate, region } = opts;
337
510
  if (!hasText(url)) {
338
511
  throw new Error(`Invalid URL: ${url}`);
339
512
  }
513
+ if (!hasText(startDate) || !hasText(endDate)) {
514
+ throw new Error('startDate and endDate are required');
515
+ }
340
516
 
341
517
  const ep = ENDPOINTS.organicTraffic;
518
+ const databases = getDatabases(region);
342
519
 
343
- const { body, fullAuditRef } = await this.sendRawRequest({
520
+ const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
344
521
  type: ep.type,
345
522
  domain: url,
346
- database: DEFAULT_DATABASE,
523
+ database: db,
347
524
  export_columns: ep.columns,
348
525
  ...ep.defaultParams,
349
- }, ep.path);
350
- const rows = parseCsvResponse(body);
526
+ }, ep.path), 'getOrganicTraffic');
527
+
528
+ // Group by date across all databases, sum numeric fields
529
+ const dateMap = new Map();
530
+ let fullAuditRef = '';
531
+
532
+ for (const { value } of dbResults) {
533
+ if (!fullAuditRef) {
534
+ fullAuditRef = value.fullAuditRef;
535
+ }
536
+
537
+ const rows = parseCsvResponse(value.body);
538
+ for (const row of rows) {
539
+ const isoDate = fromApiDate(row.Dt);
540
+ if (!isoDate || isoDate < startDate || isoDate > endDate) {
541
+ // eslint-disable-next-line no-continue
542
+ continue;
543
+ }
544
+
545
+ const existing = dateMap.get(isoDate);
546
+ if (existing) {
547
+ existing.org_traffic += coerceValue(row.Ot, 'int') || 0;
548
+ existing.paid_traffic += coerceValue(row.At, 'int') || 0;
549
+ existing.org_cost += Math.round((coerceValue(row.Oc, 'float') || 0) * 100);
550
+ /* c8 ignore next */
551
+ existing.paid_cost += Math.round((coerceValue(row.Ac, 'float') || 0) * 100);
552
+ } else {
553
+ /* c8 ignore next 6 - || 0 null-coercion branches */
554
+ dateMap.set(isoDate, {
555
+ date: `${isoDate}T00:00:00Z`,
556
+ org_traffic: coerceValue(row.Ot, 'int') || 0,
557
+ paid_traffic: coerceValue(row.At, 'int') || 0,
558
+ org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
559
+ paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
560
+ });
561
+ }
562
+ }
563
+ }
351
564
 
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);
565
+ const metrics = [...dateMap.values()]
566
+ .sort((a, b) => a.date.localeCompare(b.date));
356
567
 
357
568
  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
- },
569
+ result: { metrics },
367
570
  fullAuditRef,
368
571
  };
369
572
  }
@@ -516,32 +719,21 @@ export default class SeoClient {
516
719
  }
517
720
 
518
721
  // 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
722
  const brokenUrls = brokenPages.map((p) => p.source_url);
522
- const allBacklinks = [];
723
+ const linkResults = await this.fanOut(brokenUrls, (brokenUrl) => this.sendRawRequest({
724
+ type: epLinks.type,
725
+ target: brokenUrl,
726
+ target_type: 'url',
727
+ export_columns: epLinks.columns,
728
+ display_limit: 1,
729
+ ...epLinks.defaultParams,
730
+ }, epLinks.path), 'getBrokenBacklinks');
523
731
 
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
- }
732
+ const allBacklinks = [];
733
+ for (const { value } of linkResults) {
734
+ const links = parseCsvResponse(value.body);
735
+ if (links.length > 0) {
736
+ allBacklinks.push(links[0]);
545
737
  }
546
738
  }
547
739
 
@@ -566,7 +758,7 @@ export default class SeoClient {
566
758
  }
567
759
 
568
760
  // eslint-disable-next-line no-unused-vars, class-methods-use-this
569
- async getMetricsByCountry(url, date = todayISO()) {
761
+ async getMetricsByCountry(url, date = lastMonthISO()) {
570
762
  return STUB_RESPONSE;
571
763
  }
572
764
  }
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
@@ -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.