@adobe/mysticat-shared-seo-client 1.2.0 → 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,9 @@
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
+
1
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)
2
8
 
3
9
  ### Features
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.2.0",
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
@@ -222,28 +222,55 @@ export default class SeoClient {
222
222
  throw new Error(`Invalid URL: ${url}`);
223
223
  }
224
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.');
225
238
  const ep = ENDPOINTS.topPages;
226
239
  const epKw = ENDPOINTS.topPagesKeywords;
227
- const effectiveLimit = getLimit(limit, 2000);
240
+
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);
228
247
  const databases = getDatabases(region);
229
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
+
230
259
  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([
260
+ const commonParams = { domain, database: db };
261
+ const [{ body: pagesBody, fullAuditRef: ref }, { body: kwBody }] = await Promise.all([
233
262
  this.sendRawRequest({
234
263
  type: ep.type,
235
264
  ...commonParams,
236
- display_limit: effectiveLimit,
265
+ display_limit: requestLimit,
237
266
  export_columns: ep.columns,
238
- display_filter: buildFilter([{
239
- sign: '+', field: 'Tg', op: 'Gt', value: '0',
240
- }]),
267
+ display_filter: buildFilter(filters),
241
268
  ...ep.defaultParams,
242
269
  }, ep.path),
243
270
  this.sendRawRequest({
244
271
  type: epKw.type,
245
272
  ...commonParams,
246
- display_limit: effectiveLimit * 3,
273
+ display_limit: requestLimit * 3,
247
274
  export_columns: epKw.columns,
248
275
  ...epKw.defaultParams,
249
276
  }, epKw.path),
@@ -251,7 +278,7 @@ export default class SeoClient {
251
278
  return {
252
279
  pageRows: parseCsvResponse(pagesBody),
253
280
  kwRows: parseCsvResponse(kwBody),
254
- fullAuditRef,
281
+ fullAuditRef: ref,
255
282
  };
256
283
  }, 'getTopPages');
257
284
 
@@ -282,13 +309,30 @@ export default class SeoClient {
282
309
  }
283
310
  }
284
311
 
285
- const pages = [...pageMap.values()]
312
+ let pages = [...pageMap.values()]
286
313
  .map((page) => ({
287
314
  ...page,
288
315
  top_keyword: keywordMap.get(page.url) ?? null,
289
316
  }))
290
- .sort((a, b) => b.sum_traffic - a.sum_traffic)
291
- .slice(0, effectiveLimit);
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);
292
336
 
293
337
  return {
294
338
  result: { pages },