@adobe/mysticat-shared-seo-client 1.0.0 → 1.1.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.1.1](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.1.0...@adobe/mysticat-shared-seo-client-v1.1.1) (2026-04-06)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **deps:** update external major (major) ([#1087](https://github.com/adobe/spacecat-shared/issues/1087)) ([72e1ab6](https://github.com/adobe/spacecat-shared/commit/72e1ab65892120f94ba409d5ab10370947329188))
6
+
7
+ ## [@adobe/mysticat-shared-seo-client-v1.1.0](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.0.0...@adobe/mysticat-shared-seo-client-v1.1.0) (2026-04-01)
8
+
9
+ ### Features
10
+
11
+ * **seo-client:** implement SEO data provider client ([#1493](https://github.com/adobe/spacecat-shared/issues/1493)) ([0b6e043](https://github.com/adobe/spacecat-shared/commit/0b6e043ac166bbdf9df6b70172e7fb7e87928170))
12
+
1
13
  ## @adobe/mysticat-shared-seo-client-v1.0.0 (2026-04-01)
2
14
 
3
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/mysticat-shared-seo-client",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Shared modules of the SpaceCat Services - SEO Client",
5
5
  "type": "module",
6
6
  "engines": {
@@ -36,7 +36,8 @@
36
36
  "dependencies": {
37
37
  "@adobe/fetch": "4.2.3",
38
38
  "@adobe/helix-universal": "5.4.0",
39
- "@adobe/spacecat-shared-utils": "1.81.1"
39
+ "@adobe/spacecat-shared-utils": "1.81.1",
40
+ "urijs": "1.19.11"
40
41
  },
41
42
  "devDependencies": {
42
43
  "chai": "6.2.2",
@@ -44,6 +45,6 @@
44
45
  "nock": "14.0.11",
45
46
  "sinon": "21.0.3",
46
47
  "sinon-chai": "4.0.1",
47
- "typescript": "5.9.3"
48
+ "typescript": "6.0.2"
48
49
  }
49
50
  }
package/src/client.js CHANGED
@@ -10,17 +10,35 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { isValidUrl } from '@adobe/spacecat-shared-utils';
13
+ import { hasText, isValidUrl, isArray } from '@adobe/spacecat-shared-utils';
14
14
  import { context as h2, h1 } from '@adobe/fetch';
15
15
 
16
+ import { ENDPOINTS } from './endpoints.js';
17
+ import {
18
+ parseCsvResponse, coerceValue, getLimit, toApiDate, fromApiDate, todayISO, buildFilter,
19
+ extractBrand, INTENT_CODES,
20
+ } from './utils.js';
21
+
16
22
  /* c8 ignore next 3 */
17
23
  export const { fetch } = process.env.HELIX_FETCH_FORCE_HTTP1
18
24
  ? h1()
19
25
  : h2();
20
26
 
27
+ const DEFAULT_DATABASE = 'us';
28
+ const MAX_ERROR_BODY_LENGTH = 500;
29
+ const MAX_PAID_KEYWORDS_FETCH = 10000;
30
+ const RATE_LIMIT_BASE_DELAY_MS = 1000;
31
+ const MAX_RETRIES = 4;
32
+
21
33
  const STUB_RESPONSE = { result: {}, fullAuditRef: '' };
22
34
 
23
35
  export default class SeoClient {
36
+ static delay(ms) {
37
+ return new Promise((resolve) => {
38
+ setTimeout(resolve, ms);
39
+ });
40
+ }
41
+
24
42
  static createFrom(context) {
25
43
  const { SEO_API_BASE_URL: apiBaseUrl, SEO_API_KEY: apiKey } = context.env;
26
44
  return new SeoClient({ apiBaseUrl, apiKey }, fetch, context.log);
@@ -33,6 +51,10 @@ export default class SeoClient {
33
51
  throw new Error(`Invalid SEO API Base URL: ${apiBaseUrl}`);
34
52
  }
35
53
 
54
+ if (!hasText(apiKey)) {
55
+ throw new Error('Missing SEO API key');
56
+ }
57
+
36
58
  if (typeof fetchAPI !== 'function') {
37
59
  throw Error('"fetchAPI" must be a function');
38
60
  }
@@ -43,43 +65,503 @@ export default class SeoClient {
43
65
  this.log = log;
44
66
  }
45
67
 
46
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
47
- async sendRequest(endpoint, queryParams = {}) {
48
- return STUB_RESPONSE;
68
+ /**
69
+ * Internal method that executes the HTTP request and returns the raw CSV body.
70
+ * Used by all endpoint methods for CSV parsing.
71
+ * @param {object} queryParams - Query parameters including type, domain, etc.
72
+ * @param {string} [apiPath=''] - Optional API path segment (e.g., 'analytics/v1/')
73
+ * @returns {Promise<{body: string, fullAuditRef: string}>}
74
+ */
75
+ async sendRawRequest(queryParams = {}, apiPath = '') {
76
+ const params = { ...queryParams, key: this.apiKey };
77
+
78
+ const queryString = Object.entries(params)
79
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
80
+ .join('&');
81
+
82
+ const baseUrl = this.apiBaseUrl.replace(/\/$/, '');
83
+ const pathSegment = apiPath ? `/${apiPath.replace(/^\//, '')}` : '';
84
+ const requestUrl = `${baseUrl}${pathSegment}?${queryString}`;
85
+
86
+ // Mask the API key in the audit ref URL
87
+ const fullAuditRef = requestUrl.replace(
88
+ `key=${encodeURIComponent(this.apiKey)}`,
89
+ 'key=REDACTED',
90
+ );
91
+
92
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
93
+ this.log.debug(`SEO API request: ${fullAuditRef}${attempt > 0 ? ` (retry ${attempt})` : ''}`);
94
+ // eslint-disable-next-line no-await-in-loop
95
+ const response = await this.fetchAPI(requestUrl, { method: 'GET' });
96
+ // eslint-disable-next-line no-await-in-loop
97
+ const body = await response.text();
98
+
99
+ // SEO API returns HTTP 200 with "ERROR XX :: message" body on errors
100
+ if (body.startsWith('ERROR')) {
101
+ const isRateLimit = body.includes('LIMIT EXCEEDED');
102
+ if (isRateLimit && attempt < MAX_RETRIES) {
103
+ // Exponential backoff: 1s, 2s, 4s, 8s + random jitter 0-500ms
104
+ const retryDelay = (RATE_LIMIT_BASE_DELAY_MS * (2 ** attempt))
105
+ + Math.floor(Math.random() * 500);
106
+ this.log.warn(`SEO API rate limited, retrying in ${retryDelay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
107
+ // eslint-disable-next-line no-await-in-loop
108
+ await SeoClient.delay(retryDelay);
109
+ // eslint-disable-next-line no-continue
110
+ continue;
111
+ }
112
+ const prefix = isRateLimit && attempt >= MAX_RETRIES
113
+ ? `SEO API request failed after ${MAX_RETRIES} retries: `
114
+ : 'SEO API request failed: ';
115
+ const errorMessage = `${prefix}${body.slice(0, MAX_ERROR_BODY_LENGTH)}`;
116
+ this.log.error(errorMessage);
117
+ throw new Error(errorMessage);
118
+ }
119
+
120
+ if (!response.ok) {
121
+ const errorMessage = `SEO API request failed with status: ${response.status} - ${body.slice(0, MAX_ERROR_BODY_LENGTH)}`;
122
+ this.log.error(errorMessage);
123
+ throw new Error(errorMessage);
124
+ }
125
+
126
+ const rowCount = body.split('\n').length - 1;
127
+ this.log.info(`SEO API call: type=${queryParams.type || 'unknown'} domain=${queryParams.domain || queryParams.target || '-'} rows=${rowCount} path=${apiPath || '/'}`);
128
+
129
+ return { body, fullAuditRef };
130
+ }
131
+ /* c8 ignore next */
132
+ throw new Error('SEO API request failed: unexpected retry loop exit');
49
133
  }
50
134
 
51
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
52
- async getBrokenBacklinks(url, limit = 50) {
53
- return STUB_RESPONSE;
135
+ /**
136
+ * Public method matching the old AhrefsAPIClient.sendRequest(endpoint, queryParams) signature.
137
+ * Sends a request and returns a parsed result object.
138
+ * @param {string} endpoint - API endpoint path (e.g., '/site-explorer/top-pages')
139
+ * @param {object} [queryParams={}] - Query parameters
140
+ * @returns {Promise<{result: object, fullAuditRef: string}>}
141
+ */
142
+ async sendRequest(endpoint, queryParams = {}) {
143
+ const { body, fullAuditRef } = await this.sendRawRequest(queryParams, endpoint);
144
+ const rows = parseCsvResponse(body);
145
+ const result = rows.length === 1 ? rows[0] : rows;
146
+
147
+ return { result, fullAuditRef };
54
148
  }
55
149
 
56
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
57
150
  async getTopPages(url, limit = 200) {
58
- return STUB_RESPONSE;
151
+ if (!hasText(url)) {
152
+ throw new Error(`Invalid URL: ${url}`);
153
+ }
154
+
155
+ const ep = ENDPOINTS.topPages;
156
+ const epKw = ENDPOINTS.topPagesKeywords;
157
+ const effectiveLimit = getLimit(limit, 2000);
158
+
159
+ const commonParams = {
160
+ domain: url,
161
+ database: DEFAULT_DATABASE,
162
+ };
163
+
164
+ // Two calls required: the SEO data provider does not offer a top-keyword-per-page
165
+ // field in its page-level report. Sorting organic keywords by traffic and grouping
166
+ // by URL client-side is the recommended approach.
167
+ const [{ body: pagesBody, fullAuditRef }, { body: kwBody }] = await Promise.all([
168
+ this.sendRawRequest({
169
+ type: ep.type,
170
+ ...commonParams,
171
+ display_limit: effectiveLimit,
172
+ export_columns: ep.columns,
173
+ display_filter: buildFilter([{
174
+ sign: '+', field: 'Tg', op: 'Gt', value: '0',
175
+ }]),
176
+ ...ep.defaultParams,
177
+ }, ep.path),
178
+ this.sendRawRequest({
179
+ type: epKw.type,
180
+ ...commonParams,
181
+ display_limit: effectiveLimit * 3,
182
+ export_columns: epKw.columns,
183
+ ...epKw.defaultParams,
184
+ }, epKw.path),
185
+ ]);
186
+
187
+ const pageRows = parseCsvResponse(pagesBody);
188
+ const kwRows = parseCsvResponse(kwBody);
189
+
190
+ // Build keyword lookup: URL → top keyword (first occurrence = highest traffic)
191
+ const keywordMap = new Map();
192
+ for (const row of kwRows) {
193
+ if (!keywordMap.has(row.Ur)) {
194
+ keywordMap.set(row.Ur, row.Ph);
195
+ }
196
+ }
197
+
198
+ const pages = pageRows.map((row) => ({
199
+ url: row.Ur,
200
+ sum_traffic: coerceValue(row.Tg, 'int'),
201
+ top_keyword: keywordMap.get(row.Ur) ?? null,
202
+ }));
203
+
204
+ return {
205
+ result: { pages },
206
+ fullAuditRef,
207
+ };
59
208
  }
60
209
 
61
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
62
- async getBacklinks(url, limit = 200) {
63
- return STUB_RESPONSE;
210
+ async getPaidPages(
211
+ url,
212
+ date = todayISO(),
213
+ limit = 200,
214
+ // Accepted for contract compatibility but not supported by this provider,
215
+ // which scopes by report type (domain/subdomain/subfolder/URL) instead.
216
+ // eslint-disable-next-line no-unused-vars
217
+ mode = 'prefix',
218
+ ) {
219
+ if (!hasText(url)) {
220
+ throw new Error(`Invalid URL: ${url}`);
221
+ }
222
+
223
+ const ep = ENDPOINTS.paidPages;
224
+ const effectiveLimit = getLimit(limit, 1000);
225
+
226
+ // Over-fetch keywords to aggregate into pages. A domain typically has more
227
+ // keywords than pages, so we fetch a multiple of the requested limit.
228
+ // Capped at MAX_PAID_KEYWORDS_FETCH to bound cost.
229
+ const fetchLimit = Math.min(effectiveLimit * 10, MAX_PAID_KEYWORDS_FETCH);
230
+
231
+ const { body, fullAuditRef } = await this.sendRawRequest({
232
+ type: ep.type,
233
+ domain: url,
234
+ database: DEFAULT_DATABASE,
235
+ display_date: toApiDate(date),
236
+ display_limit: fetchLimit,
237
+ export_columns: ep.columns,
238
+ ...ep.defaultParams,
239
+ }, ep.path);
240
+ const rows = parseCsvResponse(body);
241
+
242
+ // Group keywords by URL
243
+ const pageMap = new Map();
244
+ for (const row of rows) {
245
+ const pageUrl = row.Ur;
246
+ if (!pageMap.has(pageUrl)) {
247
+ pageMap.set(pageUrl, { url: pageUrl, keywords: [], totalTraffic: 0 });
248
+ }
249
+ const page = pageMap.get(pageUrl);
250
+ const traffic = coerceValue(row.Tg, 'int') || 0;
251
+ page.totalTraffic += traffic;
252
+ page.keywords.push({ ...row, kwTraffic: traffic });
253
+ }
254
+
255
+ // Transform to page-level aggregation, sorted by traffic desc
256
+ const pages = [...pageMap.values()]
257
+ .sort((a, b) => b.totalTraffic - a.totalTraffic)
258
+ .slice(0, effectiveLimit)
259
+ .map((page) => {
260
+ const topKw = page.keywords.reduce(
261
+ (best, kw) => (kw.kwTraffic > best.kwTraffic ? kw : best),
262
+ page.keywords[0],
263
+ );
264
+ return {
265
+ url: page.url,
266
+ top_keyword: topKw.Ph,
267
+ top_keyword_best_position_title: topKw.Tt || null,
268
+ top_keyword_country: DEFAULT_DATABASE.toUpperCase(),
269
+ top_keyword_volume: coerceValue(topKw.Nq, 'int'),
270
+ sum_traffic: page.totalTraffic,
271
+ value: page.keywords.reduce(
272
+ (sum, kw) => sum + Math.round(
273
+ (kw.kwTraffic || 0) * (coerceValue(kw.Cp, 'float') || 0) * 100,
274
+ ),
275
+ 0,
276
+ ),
277
+ };
278
+ });
279
+
280
+ return {
281
+ result: { pages },
282
+ fullAuditRef,
283
+ };
64
284
  }
65
285
 
66
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
67
- async getOrganicKeywords(url, options = {}) {
68
- return STUB_RESPONSE;
286
+ async getMetrics(url, date = todayISO()) {
287
+ if (!hasText(url)) {
288
+ throw new Error(`Invalid URL: ${url}`);
289
+ }
290
+
291
+ const ep = ENDPOINTS.metrics;
292
+
293
+ const { body, fullAuditRef } = await this.sendRawRequest({
294
+ type: ep.type,
295
+ domain: url,
296
+ database: DEFAULT_DATABASE,
297
+ display_date: toApiDate(date),
298
+ export_columns: ep.columns,
299
+ ...ep.defaultParams,
300
+ }, ep.path);
301
+ const rows = parseCsvResponse(body);
302
+ const row = rows[0] || {};
303
+
304
+ return {
305
+ result: {
306
+ metrics: {
307
+ org_keywords: coerceValue(row.Or, 'int'),
308
+ paid_keywords: coerceValue(row.Ad, 'int'),
309
+ org_keywords_1_3: coerceValue(row.X0, 'int'),
310
+ org_traffic: coerceValue(row.Ot, 'int'),
311
+ org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
312
+ paid_traffic: coerceValue(row.At, 'int'),
313
+ paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
314
+ paid_pages: null,
315
+ },
316
+ },
317
+ fullAuditRef,
318
+ };
69
319
  }
70
320
 
71
- // eslint-disable-next-line no-unused-vars, class-methods-use-this
72
- async getPaidPages(url, date = new Date().toISOString().split('T')[0], limit = 200, mode = 'prefix') {
73
- return STUB_RESPONSE;
321
+ /**
322
+ * Retrieves historical organic traffic metrics for a URL within a date range.
323
+ * Note: The SEO data provider returns monthly data points only (no weekly option).
324
+ * The full history is fetched and filtered client-side because the provider's
325
+ * history endpoint does not support date range parameters.
326
+ * @param {string} url - The target domain
327
+ * @param {string} startDate - Start date in YYYY-MM-DD format
328
+ * @param {string} endDate - End date in YYYY-MM-DD format
329
+ * @returns {Promise<{result: {metrics: Array}, fullAuditRef: string}>}
330
+ */
331
+ async getOrganicTraffic(url, startDate, endDate) {
332
+ if (!hasText(url)) {
333
+ throw new Error(`Invalid URL: ${url}`);
334
+ }
335
+
336
+ const ep = ENDPOINTS.organicTraffic;
337
+
338
+ const { body, fullAuditRef } = await this.sendRawRequest({
339
+ type: ep.type,
340
+ domain: url,
341
+ database: DEFAULT_DATABASE,
342
+ export_columns: ep.columns,
343
+ ...ep.defaultParams,
344
+ }, ep.path);
345
+ const rows = parseCsvResponse(body);
346
+
347
+ // Convert API dates (YYYYMMDD) to ISO (YYYY-MM-DD) and filter to requested range
348
+ const filtered = rows
349
+ .map((row) => ({ ...row, isoDate: fromApiDate(row.Dt) }))
350
+ .filter((row) => row.isoDate && row.isoDate >= startDate && row.isoDate <= endDate);
351
+
352
+ return {
353
+ result: {
354
+ metrics: filtered.map((row) => ({
355
+ date: `${row.isoDate}T00:00:00Z`,
356
+ org_traffic: coerceValue(row.Ot, 'int'),
357
+ paid_traffic: coerceValue(row.At, 'int'),
358
+ org_cost: Math.round((coerceValue(row.Oc, 'float') || 0) * 100),
359
+ paid_cost: Math.round((coerceValue(row.Ac, 'float') || 0) * 100),
360
+ })),
361
+ },
362
+ fullAuditRef,
363
+ };
364
+ }
365
+
366
+ async getOrganicKeywords(url, {
367
+ country = DEFAULT_DATABASE,
368
+ keywordFilter = [],
369
+ limit = 10,
370
+ // Accepted for contract compatibility but not supported by this provider,
371
+ // which scopes by report type (domain/subdomain/subfolder/URL) instead.
372
+ mode = 'prefix',
373
+ excludeBranded = false,
374
+ } = {}) {
375
+ if (!hasText(url)) {
376
+ throw new Error(`Invalid URL: ${url}`);
377
+ }
378
+ if (!hasText(country)) {
379
+ throw new Error(`Invalid country: ${country}`);
380
+ }
381
+ if (!isArray(keywordFilter)) {
382
+ throw new Error(`Invalid keyword filter: ${keywordFilter}`);
383
+ }
384
+ if (!Number.isInteger(limit) || limit < 1) {
385
+ throw new Error(`Invalid limit: ${limit}`);
386
+ }
387
+ if (!['prefix', 'exact'].includes(mode)) {
388
+ throw new Error(`Invalid mode: ${mode}`);
389
+ }
390
+
391
+ const ep = ENDPOINTS.organicKeywords;
392
+
393
+ // Over-fetch when excluding branded (client-side filter)
394
+ const fetchLimit = excludeBranded
395
+ ? getLimit(limit * 3, 300)
396
+ : getLimit(limit, 100);
397
+
398
+ const params = {
399
+ type: ep.type,
400
+ domain: url,
401
+ database: country,
402
+ display_limit: fetchLimit,
403
+ export_columns: ep.columns,
404
+ ...ep.defaultParams,
405
+ };
406
+
407
+ // Build keyword display_filter if keyword filters provided
408
+ if (keywordFilter.length > 0) {
409
+ params.display_filter = buildFilter(
410
+ keywordFilter.map((kw) => ({
411
+ sign: '+', field: 'Ph', op: 'Co', value: kw,
412
+ })),
413
+ );
414
+ }
415
+
416
+ this.log.debug(`Getting organic keywords for ${url} with country ${country}, mode ${mode}, limit ${limit}, excludeBranded ${excludeBranded}`);
417
+
418
+ const { body, fullAuditRef } = await this.sendRawRequest(params, ep.path);
419
+ const rows = parseCsvResponse(body);
420
+
421
+ // Client-side brand detection since the SEO data provider
422
+ // does not expose a branded keyword flag
423
+ const brand = extractBrand(url);
424
+
425
+ let keywords = rows.map((row) => {
426
+ const intents = row.In ? row.In.split(',').map(Number) : [];
427
+ return {
428
+ keyword: row.Ph,
429
+ keyword_country: country,
430
+ language: null,
431
+ sum_traffic: coerceValue(row.Tg, 'int'),
432
+ volume: coerceValue(row.Nq, 'int'),
433
+ best_position: coerceValue(row.Po, 'int'),
434
+ best_position_url: row.Ur,
435
+ cpc: Math.round((coerceValue(row.Cp, 'float') || 0) * 100),
436
+ last_update: row.Ts
437
+ ? new Date(parseInt(row.Ts, 10) * 1000).toISOString()
438
+ : null,
439
+ is_branded: brand.length > 1 && (row.Ph || '').toLowerCase().includes(brand),
440
+ is_navigational: intents.includes(INTENT_CODES.NAVIGATIONAL),
441
+ is_informational: intents.includes(INTENT_CODES.INFORMATIONAL),
442
+ is_commercial: intents.includes(INTENT_CODES.COMMERCIAL),
443
+ is_transactional: intents.includes(INTENT_CODES.TRANSACTIONAL),
444
+ serp_features: row.Fp || null,
445
+ };
446
+ });
447
+
448
+ if (excludeBranded) {
449
+ keywords = keywords.filter((kw) => !kw.is_branded);
450
+ }
451
+ keywords = keywords.slice(0, limit);
452
+
453
+ return {
454
+ result: { keywords },
455
+ fullAuditRef,
456
+ };
74
457
  }
75
458
 
459
+ /**
460
+ * Retrieves broken backlinks for a domain — links pointing to pages that return 404.
461
+ *
462
+ * The SEO data provider does not offer a single broken-backlinks endpoint (unlike the
463
+ * previous provider). This method uses a two-step approach:
464
+ *
465
+ * Step 1: Call `backlinks_pages` with a server-side 404 filter to discover which of
466
+ * the domain's pages are broken, sorted by referring domain count (highest first).
467
+ * This is a single API call.
468
+ *
469
+ * Step 2: For each broken page (up to `limit`), fetch the single highest-quality
470
+ * backlink (by authority score) using `backlinks` with `display_limit=1`. These calls
471
+ * are parallelized in batches of 10 to respect rate limits while minimizing latency.
472
+ *
473
+ * This approach maximizes broken page diversity — each result row represents a different
474
+ * broken page on the domain, paired with its best referring link. This matches the
475
+ * practical value of the old provider's single-call endpoint, where results typically
476
+ * surfaced many unique broken target URLs.
477
+ *
478
+ * Note on `traffic_domain` field: The previous provider returned estimated monthly
479
+ * organic traffic (0 to millions). This implementation maps it to the referring page's
480
+ * authority score (0-100). Relative ranking is preserved but absolute values differ.
481
+ *
482
+ * @param {string} url - The target domain
483
+ * @param {number} [limit=50] - Maximum results (default: 50, max: 100)
484
+ * @returns {Promise<{result: {backlinks: Array}, fullAuditRef: string}>}
485
+ */
486
+ async getBrokenBacklinks(url, limit = 50) {
487
+ if (!hasText(url)) {
488
+ throw new Error(`Invalid URL: ${url}`);
489
+ }
490
+
491
+ const epPages = ENDPOINTS.brokenBacklinksPages;
492
+ const epLinks = ENDPOINTS.brokenBacklinks;
493
+ const effectiveLimit = getLimit(limit, 100);
494
+
495
+ // Step 1: Find broken (404) target pages, sorted by referring domains
496
+ const { body: pagesBody, fullAuditRef } = await this.sendRawRequest({
497
+ type: epPages.type,
498
+ target: url,
499
+ target_type: 'root_domain',
500
+ export_columns: epPages.columns,
501
+ display_limit: effectiveLimit,
502
+ display_filter: buildFilter([{
503
+ sign: '+', field: 'responsecode', op: 'Eq', value: '404',
504
+ }]),
505
+ ...epPages.defaultParams,
506
+ }, epPages.path);
507
+ const brokenPages = parseCsvResponse(pagesBody);
508
+
509
+ if (brokenPages.length === 0) {
510
+ return { result: { backlinks: [] }, fullAuditRef };
511
+ }
512
+
513
+ // Step 2: For each broken page, fetch the top backlink by authority score.
514
+ // Parallelized in batches to balance throughput and rate limits.
515
+ const BATCH_SIZE = 10;
516
+ const brokenUrls = brokenPages.map((p) => p.source_url);
517
+ const allBacklinks = [];
518
+
519
+ for (let i = 0; i < brokenUrls.length; i += BATCH_SIZE) {
520
+ const batch = brokenUrls.slice(i, i + BATCH_SIZE);
521
+ // eslint-disable-next-line no-await-in-loop
522
+ const results = await Promise.allSettled(
523
+ batch.map((brokenUrl) => this.sendRawRequest({
524
+ type: epLinks.type,
525
+ target: brokenUrl,
526
+ target_type: 'url',
527
+ export_columns: epLinks.columns,
528
+ display_limit: 1,
529
+ ...epLinks.defaultParams,
530
+ }, epLinks.path)),
531
+ );
532
+
533
+ for (const result of results) {
534
+ if (result.status === 'fulfilled') {
535
+ const links = parseCsvResponse(result.value.body);
536
+ if (links.length > 0) {
537
+ allBacklinks.push(links[0]);
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ const backlinks = allBacklinks.map((row) => ({
544
+ title: row.source_title || null,
545
+ url_from: row.source_url,
546
+ url_to: row.target_url,
547
+ traffic_domain: coerceValue(row.page_ascore, 'int'),
548
+ }));
549
+
550
+ return {
551
+ result: { backlinks },
552
+ fullAuditRef,
553
+ };
554
+ }
555
+
556
+ // --- Out of scope methods (stubs) ---
557
+
76
558
  // eslint-disable-next-line no-unused-vars, class-methods-use-this
77
- async getMetrics(url, date = new Date().toISOString().split('T')[0]) {
559
+ async getBacklinks(url, limit = 200) {
78
560
  return STUB_RESPONSE;
79
561
  }
80
562
 
81
563
  // eslint-disable-next-line no-unused-vars, class-methods-use-this
82
- async getMetricsByCountry(url, date = new Date().toISOString().split('T')[0]) {
564
+ async getMetricsByCountry(url, date = todayISO()) {
83
565
  return STUB_RESPONSE;
84
566
  }
85
567
  }
package/src/endpoints.js CHANGED
@@ -10,17 +10,73 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ /**
14
+ * API path segments. Domain/overview reports use the root path,
15
+ * while backlinks reports require the /analytics/v1/ path.
16
+ */
17
+ export const API_PATHS = {
18
+ root: '',
19
+ analytics: 'analytics/v1/',
20
+ };
21
+
13
22
  /**
14
23
  * Endpoint definitions for the SEO API.
15
- * Each entry maps a method name to its API path and default query parameters.
16
- * Paths and params are stubs - to be populated when implementing API calls.
24
+ * Each entry maps a method name to its report type, columns, path, and default parameters.
17
25
  */
18
26
  export const ENDPOINTS = {
19
- brokenBacklinks: { path: '', defaultParams: {} },
20
- topPages: { path: '', defaultParams: {} },
21
- backlinks: { path: '', defaultParams: {} },
22
- organicKeywords: { path: '', defaultParams: {} },
23
- paidPages: { path: '', defaultParams: {} },
24
- metrics: { path: '', defaultParams: {} },
25
- metricsByCountry: { path: '', defaultParams: {} },
27
+ topPages: {
28
+ type: 'domain_organic_unique',
29
+ path: API_PATHS.root,
30
+ columns: 'Ur,Tg',
31
+ defaultParams: { display_sort: 'tg_desc', export_escape: 1 },
32
+ },
33
+ topPagesKeywords: {
34
+ type: 'domain_organic',
35
+ path: API_PATHS.root,
36
+ columns: 'Ur,Ph,Tg',
37
+ defaultParams: { display_sort: 'tg_desc', export_escape: 1 },
38
+ },
39
+ paidPages: {
40
+ type: 'domain_adwords',
41
+ path: API_PATHS.root,
42
+ columns: 'Ph,Ur,Tg,Nq,Cp,Po,Tt',
43
+ defaultParams: { display_sort: 'tg_desc', export_escape: 1 },
44
+ },
45
+ metrics: {
46
+ type: 'domain_rank',
47
+ path: API_PATHS.root,
48
+ columns: 'Or,Ad,Ot,Oc,At,Ac,X0',
49
+ defaultParams: { export_escape: 1 },
50
+ },
51
+ organicTraffic: {
52
+ type: 'domain_rank_history',
53
+ path: API_PATHS.root,
54
+ columns: 'Dt,Ot,Oc,At,Ac',
55
+ defaultParams: { display_sort: 'dt_asc', export_escape: 1 },
56
+ },
57
+ organicKeywords: {
58
+ type: 'domain_organic',
59
+ path: API_PATHS.root,
60
+ columns: 'Ph,Po,Pp,Nq,Cp,Ur,Tg,Tr,Kd,Fp,Fk,Ts,In',
61
+ defaultParams: { display_sort: 'tg_desc', export_escape: 1 },
62
+ },
63
+ brokenBacklinksPages: {
64
+ type: 'backlinks_pages',
65
+ path: API_PATHS.analytics,
66
+ columns: 'source_url,response_code,backlinks_num,domains_num',
67
+ defaultParams: { display_sort: 'domainsnum_desc', export_escape: 1 },
68
+ },
69
+ brokenBacklinks: {
70
+ type: 'backlinks',
71
+ path: API_PATHS.analytics,
72
+ columns: 'source_title,source_url,target_url,page_ascore,external_num',
73
+ defaultParams: { display_sort: 'page_ascore_desc', export_escape: 1 },
74
+ },
75
+ // Out of scope — stubs
76
+ backlinks: {
77
+ type: '', path: '', columns: '', defaultParams: {},
78
+ },
79
+ metricsByCountry: {
80
+ type: '', path: '', columns: '', defaultParams: {},
81
+ },
26
82
  };
package/src/index.d.ts CHANGED
@@ -13,67 +13,43 @@
13
13
  import { UniversalContext } from '@adobe/helix-universal';
14
14
 
15
15
  export interface SeoAPIOptions {
16
- select: string;
17
- where: string;
18
- order_by: string;
19
- date: string;
20
- target: string;
21
- limit: number;
22
- mode: string;
23
- output: string;
16
+ type: string;
17
+ domain?: string;
18
+ target?: string;
19
+ target_type?: string;
20
+ database?: string;
21
+ display_date?: string;
22
+ display_limit?: number;
23
+ display_sort?: string;
24
+ display_filter?: string;
25
+ export_columns?: string;
26
+ export_escape?: number;
27
+ key?: string;
24
28
  }
25
29
 
26
30
  export default class SeoClient {
27
- /**
28
- * Static factory method to create an instance of SeoClient.
29
- * @param {UniversalContext} context - An object containing the AWS Lambda context information
30
- * @returns An instance of SeoClient.
31
- */
32
31
  static createFrom(context: UniversalContext): SeoClient;
33
32
 
34
- /**
35
- * Constructor for creating an instance of SeoClient.
36
- * @param config
37
- * @param fetchAPI
38
- * @param log
39
- */
40
- constructor(config: object, fetchAPI, log?: Console);
41
-
42
- /**
43
- * Asynchronous method to send a request to the SEO API.
44
- * @param endpoint
45
- * @param queryParams
46
- */
33
+ constructor(config: object, fetchAPI: Function, log?: Console);
34
+
47
35
  sendRequest(endpoint: string, queryParams?: SeoAPIOptions):
48
36
  Promise<{ result: object, fullAuditRef: string }>;
49
37
 
50
- /**
51
- * Asynchronous method to get broken backlinks.
52
- * @param url
53
- * @param limit
54
- */
38
+ sendRawRequest(queryParams?: SeoAPIOptions, apiPath?: string):
39
+ Promise<{ body: string, fullAuditRef: string }>;
40
+
55
41
  getBrokenBacklinks(url: string, limit?: number):
56
42
  Promise<{ result: object, fullAuditRef: string }>;
57
43
 
58
- /**
59
- * Asynchronous method to get top pages.
60
- * @param url
61
- * @param limit
62
- */
63
44
  getTopPages(url: string, limit?: number):
64
45
  Promise<{ result: object, fullAuditRef: string }>;
65
46
 
66
- /**
67
- * Asynchronous method to get backlinks.
68
- * @param url
69
- * @param limit
70
- */
71
47
  getBacklinks(url: string, limit?: number):
72
48
  Promise<{ result: object, fullAuditRef: string }>;
73
49
 
74
- /**
75
- * Asynchronous method to get organic keywords.
76
- */
50
+ getOrganicTraffic(url: string, startDate: string, endDate: string):
51
+ Promise<{ result: object, fullAuditRef: string }>;
52
+
77
53
  getOrganicKeywords(
78
54
  url: string,
79
55
  options?: {
@@ -85,30 +61,36 @@ export default class SeoClient {
85
61
  }):
86
62
  Promise<{ result: object, fullAuditRef: string }>;
87
63
 
88
- /**
89
- * Asynchronous method to get paid pages for a URL.
90
- * @param url - The target URL
91
- * @param date - Optional date in YYYY-MM-DD format, defaults to today
92
- * @param limit - Maximum number of results to return (max: 1000)
93
- * @param mode - Search mode
94
- */
95
64
  getPaidPages(url: string, date?: string, limit?: number,
96
65
  mode?: 'exact' | 'prefix' | 'domain' | 'subdomains'):
97
66
  Promise<{ result: object, fullAuditRef: string }>;
98
67
 
99
- /**
100
- * Asynchronous method to get metrics for a URL.
101
- * @param url - The target URL
102
- * @param date - Optional date in YYYY-MM-DD format, defaults to today
103
- */
104
68
  getMetrics(url: string, date?: string):
105
69
  Promise<{ result: object, fullAuditRef: string }>;
106
70
 
107
- /**
108
- * Asynchronous method to get metrics by country for a URL.
109
- * @param url - The target URL
110
- * @param date - Optional date in YYYY-MM-DD format, defaults to today
111
- */
112
71
  getMetricsByCountry(url: string, date?: string):
113
72
  Promise<{ result: object, fullAuditRef: string }>;
114
73
  }
74
+
75
+ export function parseCsvResponse(text: string): object[];
76
+ export function coerceValue(value: string, type: 'int' | 'float' | 'string' | 'bool'): any;
77
+ export function getLimit(limit: number, upperLimit: number): number;
78
+ export function toApiDate(date: string): string;
79
+ export function fromApiDate(apiDate: string): string | null;
80
+ export function todayISO(): string;
81
+ export function buildFilter(filters: Array<{sign: string, field: string, op: string, value: string}>): string;
82
+ export function extractBrand(domain: string): string;
83
+ export function buildQueryParams(defaults: object, overrides: object): object;
84
+ /** @deprecated Use parseCsvResponse instead. */
85
+ export function parseResponse(response: any): any;
86
+
87
+ export const INTENT_CODES: {
88
+ COMMERCIAL: 0,
89
+ INFORMATIONAL: 1,
90
+ NAVIGATIONAL: 2,
91
+ TRANSACTIONAL: 3,
92
+ };
93
+ export const ORGANIC_KEYWORDS_FIELDS: readonly string[];
94
+ export const METRICS_BY_COUNTRY_FILTER_FIELDS: readonly string[];
95
+ export const ENDPOINTS: Record<string, { type: string, path: string, columns: string, defaultParams: object }>;
96
+ export function fetch(...args: any[]): Promise<Response>;
package/src/index.js CHANGED
@@ -15,8 +15,41 @@ import SeoClient from './client.js';
15
15
  export default SeoClient;
16
16
  export { fetch } from './client.js';
17
17
  export { ENDPOINTS } from './endpoints.js';
18
- export { buildQueryParams, parseResponse } from './utils.js';
18
+ export {
19
+ buildQueryParams, parseCsvResponse, coerceValue, getLimit, toApiDate, fromApiDate, todayISO,
20
+ buildFilter, extractBrand, INTENT_CODES,
21
+ } from './utils.js';
19
22
 
20
- export const ORGANIC_KEYWORDS_FIELDS = /** @type {const} */ ([]);
23
+ /** @deprecated Use parseCsvResponse instead. Kept for backward compatibility. */
24
+ export function parseResponse(response) {
25
+ return response;
26
+ }
21
27
 
22
- export const METRICS_BY_COUNTRY_FILTER_FIELDS = /** @type {const} */ ([]);
28
+ export const ORGANIC_KEYWORDS_FIELDS = /** @type {const} */ ([
29
+ 'keyword',
30
+ 'keyword_country',
31
+ 'language',
32
+ 'sum_traffic',
33
+ 'volume',
34
+ 'best_position',
35
+ 'best_position_url',
36
+ 'cpc',
37
+ 'last_update',
38
+ 'is_branded',
39
+ 'is_navigational',
40
+ 'is_informational',
41
+ 'is_commercial',
42
+ 'is_transactional',
43
+ 'serp_features',
44
+ ]);
45
+
46
+ export const METRICS_BY_COUNTRY_FILTER_FIELDS = /** @type {const} */ ([
47
+ 'org_keywords',
48
+ 'paid_keywords',
49
+ 'org_keywords_1_3',
50
+ 'org_traffic',
51
+ 'org_cost',
52
+ 'paid_traffic',
53
+ 'paid_cost',
54
+ 'paid_pages',
55
+ ]);
package/src/utils.js CHANGED
@@ -10,6 +10,207 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import URI from 'urijs';
14
+
15
+ /**
16
+ * Maps SEO API response header names back to their column codes.
17
+ * The API returns full names in headers even when requested by code.
18
+ */
19
+ const HEADER_TO_CODE = {
20
+ Domain: 'Dn',
21
+ Rank: 'Rk',
22
+ 'Organic Keywords': 'Or',
23
+ 'Organic Traffic': 'Ot',
24
+ 'Organic Cost': 'Oc',
25
+ 'Adwords Keywords': 'Ad',
26
+ 'Adwords Traffic': 'At',
27
+ 'Adwords Cost': 'Ac',
28
+ Date: 'Dt',
29
+ Keyword: 'Ph',
30
+ Position: 'Po',
31
+ 'Previous Position': 'Pp',
32
+ 'Search Volume': 'Nq',
33
+ CPC: 'Cp',
34
+ Url: 'Ur',
35
+ URL: 'Ur',
36
+ Traffic: 'Tg',
37
+ 'Traffic (%)': 'Tr',
38
+ 'Traffic Cost (%)': 'Tc',
39
+ Competition: 'Co',
40
+ 'Number of Results': 'Nr',
41
+ Trends: 'Td',
42
+ 'Keyword Difficulty': 'Kd',
43
+ 'SERP Features': 'Fp',
44
+ 'SERP Features by Position': 'Fp',
45
+ 'Keywords SERP Features': 'Fk',
46
+ 'SERP Features by Keyword': 'Fk',
47
+ Timestamp: 'Ts',
48
+ Intents: 'In',
49
+ Branded: 'Br',
50
+ Title: 'Tt',
51
+ Description: 'Ds',
52
+ 'Visible Url': 'Vu',
53
+ 'Number of Keywords': 'Pc',
54
+ };
55
+
56
+ /**
57
+ * Normalizes a CSV header to its column code.
58
+ * If already a known code or not in the map, returns as-is.
59
+ */
60
+ function normalizeHeader(header) {
61
+ return HEADER_TO_CODE[header] || header;
62
+ }
63
+
64
+ /**
65
+ * Splits a CSV line by semicolons, respecting double-quoted fields.
66
+ * Handles semicolons inside quoted values (e.g., "Products; Services").
67
+ * @param {string} line
68
+ * @returns {string[]}
69
+ */
70
+ function splitCsvLine(line) {
71
+ const fields = [];
72
+ let current = '';
73
+ let inQuotes = false;
74
+
75
+ for (let i = 0; i < line.length; i += 1) {
76
+ const ch = line[i];
77
+ if (ch === '"') {
78
+ if (inQuotes && line[i + 1] === '"') {
79
+ current += '"';
80
+ i += 1;
81
+ } else {
82
+ inQuotes = !inQuotes;
83
+ }
84
+ } else if (ch === ';' && !inQuotes) {
85
+ fields.push(current);
86
+ current = '';
87
+ } else {
88
+ current += ch;
89
+ }
90
+ }
91
+ fields.push(current);
92
+ return fields;
93
+ }
94
+
95
+ /**
96
+ * Parses semicolon-delimited CSV text into an array of objects.
97
+ * First row is treated as headers. Handles double-quoted fields (export_escape=1),
98
+ * including semicolons and escaped quotes inside quoted values.
99
+ * Headers are normalized to column codes via HEADER_TO_CODE map.
100
+ * @param {string} text - Raw CSV response body
101
+ * @returns {object[]} Array of row objects keyed by column codes
102
+ */
103
+ export function parseCsvResponse(text) {
104
+ if (!text || typeof text !== 'string') return [];
105
+ const lines = text.trim().split('\n');
106
+ if (lines.length < 2) return [];
107
+ const headers = splitCsvLine(lines[0]).map((h) => normalizeHeader(h.trim()));
108
+ return lines.slice(1).map((line) => {
109
+ const values = splitCsvLine(line);
110
+ return Object.fromEntries(
111
+ headers.map((h, i) => [h, (values[i] ?? '').trim()]),
112
+ );
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Coerces a string CSV value to the appropriate JS type.
118
+ * @param {string} value - Raw string from CSV
119
+ * @param {'int'|'float'|'string'|'bool'} type - Target type
120
+ * @returns {*} Coerced value
121
+ */
122
+ export function coerceValue(value, type) {
123
+ if (value === '' || value === undefined || value === null) return null;
124
+ switch (type) {
125
+ case 'int': {
126
+ const parsed = parseInt(value, 10);
127
+ return Number.isNaN(parsed) ? null : parsed;
128
+ }
129
+ case 'float': {
130
+ const parsed = parseFloat(value);
131
+ return Number.isNaN(parsed) ? null : parsed;
132
+ }
133
+ case 'bool':
134
+ return value === 'true' || value === '1' || value === 'True';
135
+ default:
136
+ return value;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Enforces an upper bound on a limit value.
142
+ * @param {number} limit
143
+ * @param {number} upperLimit
144
+ * @returns {number}
145
+ */
146
+ export const getLimit = (limit, upperLimit) => Math.min(limit, upperLimit);
147
+
148
+ /**
149
+ * Converts YYYY-MM-DD to YYYYMM15 format used by the SEO API for monthly data.
150
+ * @param {string} date - ISO date string (YYYY-MM-DD)
151
+ * @returns {string} Date in YYYYMM15 format
152
+ */
153
+ export function toApiDate(date) {
154
+ return `${date.slice(0, 4)}${date.slice(5, 7)}15`;
155
+ }
156
+
157
+ /**
158
+ * Returns today's date as YYYY-MM-DD.
159
+ * @returns {string}
160
+ */
161
+ export function todayISO() {
162
+ return new Date().toISOString().split('T')[0];
163
+ }
164
+
165
+ /**
166
+ * Converts a YYYYMMDD date string from the API to YYYY-MM-DD.
167
+ * Returns null for invalid/missing inputs.
168
+ * @param {string} apiDate - Date in YYYYMMDD format
169
+ * @returns {string|null} Date in YYYY-MM-DD format, or null if invalid
170
+ */
171
+ export function fromApiDate(apiDate) {
172
+ if (!apiDate || apiDate.length < 8 || !/^\d{8}$/.test(apiDate)) return null;
173
+ return `${apiDate.slice(0, 4)}-${apiDate.slice(4, 6)}-${apiDate.slice(6, 8)}`;
174
+ }
175
+
176
+ /**
177
+ * SEO data provider intent codes mapped to field names.
178
+ */
179
+ export const INTENT_CODES = {
180
+ COMMERCIAL: 0,
181
+ INFORMATIONAL: 1,
182
+ NAVIGATIONAL: 2,
183
+ TRANSACTIONAL: 3,
184
+ };
185
+
186
+ /**
187
+ * Builds a SEO API display_filter string from an array of filter descriptors.
188
+ * Pipe characters in values are stripped to prevent filter injection.
189
+ * @param {Array<{sign: string, field: string, op: string, value: string}>} filters
190
+ * @returns {string} Pipe-delimited filter string
191
+ */
192
+ export function buildFilter(filters) {
193
+ return filters
194
+ .map(({
195
+ sign, field, op, value,
196
+ }) => `${sign}|${field}|${op}|${String(value).replace(/\|/g, '')}`)
197
+ .join('|');
198
+ }
199
+
200
+ /**
201
+ * Extracts a brand name from a domain for client-side branded keyword detection.
202
+ * Uses URI.js to resolve the registrable domain, then takes the first label.
203
+ * E.g., "adobe.com" → "adobe", "blog.adobe.com" → "adobe", "example.co.uk" → "example"
204
+ * @param {string} domain
205
+ * @returns {string} Lowercase brand name
206
+ */
207
+ export function extractBrand(domain) {
208
+ if (!domain) return '';
209
+ const normalized = domain.includes('://') ? domain : `https://${domain}`;
210
+ const registrable = new URI(normalized).domain();
211
+ return registrable.split('.')[0].toLowerCase();
212
+ }
213
+
13
214
  /**
14
215
  * Merges default query parameters with caller-provided overrides.
15
216
  * @param {object} defaults - Default parameter values
@@ -19,13 +220,3 @@
19
220
  export function buildQueryParams(defaults, overrides) {
20
221
  return { ...defaults, ...overrides };
21
222
  }
22
-
23
- /**
24
- * Parses/normalizes an API response.
25
- * Stub - passes through unchanged. To be extended when implementing API calls.
26
- * @param {*} response - Raw API response
27
- * @returns {*} Parsed response
28
- */
29
- export function parseResponse(response) {
30
- return response;
31
- }