@adobe/mysticat-shared-seo-client 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +37 -0
- package/package.json +1 -1
- package/src/client.js +57 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [@adobe/mysticat-shared-seo-client-v1.2.2](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.2.1...@adobe/mysticat-shared-seo-client-v1.2.2) (2026-04-14)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* Fix request params for the original results in SEO client ([#1535](https://github.com/adobe/spacecat-shared/issues/1535)) ([b8687cf](https://github.com/adobe/spacecat-shared/commit/b8687cf8d42098ee746df79f1268d729e73f6587))
|
|
6
|
+
|
|
7
|
+
## [@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)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **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))
|
|
12
|
+
|
|
1
13
|
## [@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
14
|
|
|
3
15
|
### 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
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
@@ -660,7 +704,7 @@ export default class SeoClient {
|
|
|
660
704
|
const { body: pagesBody, fullAuditRef } = await this.sendRawRequest({
|
|
661
705
|
type: epPages.type,
|
|
662
706
|
target: url,
|
|
663
|
-
target_type: '
|
|
707
|
+
target_type: 'domain',
|
|
664
708
|
export_columns: epPages.columns,
|
|
665
709
|
display_limit: effectiveLimit,
|
|
666
710
|
display_filter: buildFilter([{
|