@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 +12 -0
- package/README.md +37 -0
- package/package.json +1 -1
- package/src/client.js +320 -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,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
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,59 +213,126 @@ 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
|
}
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
//
|
|
170
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
) {
|
|
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
|
|
361
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
237
362
|
type: ep.type,
|
|
238
363
|
domain: url,
|
|
239
|
-
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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:
|
|
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,
|
|
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
|
|
436
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
299
437
|
type: ep.type,
|
|
300
438
|
domain: url,
|
|
301
|
-
database:
|
|
439
|
+
database: db,
|
|
302
440
|
display_date: toApiDate(date),
|
|
303
441
|
export_columns: ep.columns,
|
|
304
442
|
...ep.defaultParams,
|
|
305
|
-
}, ep.path);
|
|
306
|
-
|
|
307
|
-
const
|
|
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 {
|
|
333
|
-
* @param {string}
|
|
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,
|
|
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
|
|
520
|
+
const dbResults = await this.fanOut(databases, (db) => this.sendRawRequest({
|
|
344
521
|
type: ep.type,
|
|
345
522
|
domain: url,
|
|
346
|
-
database:
|
|
523
|
+
database: db,
|
|
347
524
|
export_columns: ep.columns,
|
|
348
525
|
...ep.defaultParams,
|
|
349
|
-
}, ep.path);
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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,
|
|
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.
|