@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 +12 -0
- package/package.json +4 -3
- package/src/client.js +502 -20
- package/src/endpoints.js +65 -9
- package/src/index.d.ts +43 -61
- package/src/index.js +36 -3
- package/src/utils.js +201 -10
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.
|
|
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": "
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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 {
|
|
18
|
+
export {
|
|
19
|
+
buildQueryParams, parseCsvResponse, coerceValue, getLimit, toApiDate, fromApiDate, todayISO,
|
|
20
|
+
buildFilter, extractBrand, INTENT_CODES,
|
|
21
|
+
} from './utils.js';
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
/** @deprecated Use parseCsvResponse instead. Kept for backward compatibility. */
|
|
24
|
+
export function parseResponse(response) {
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
21
27
|
|
|
22
|
-
export 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
|
-
}
|