@nuxtseo/protocol 0.1.0 → 0.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.
@@ -3,6 +3,10 @@ import { defineOperation, defineRegistry, defineResponseObject, defineSuccessEnv
3
3
  export const researchSiteParamsSchema = schema.strictObject({
4
4
  siteId: schema.string().min(1),
5
5
  });
6
+ const queryBooleanSchema = schema.union([
7
+ schema.boolean(),
8
+ schema.enum(['true', 'false', '1', '0']),
9
+ ]).transform(value => value === true || value === 'true' || value === '1');
6
10
  export const researchMetricsSchema = defineResponseObject({
7
11
  organicTraffic: schema.number().nullable(),
8
12
  totalKeywords: schema.number().nullable(),
@@ -109,6 +113,223 @@ export const storedResearchOverviewDataSchema = defineResponseObject({
109
113
  competitors: schema.array(researchCompetitorSchema.client),
110
114
  });
111
115
  export const storedResearchOverviewResponseSchema = defineSuccessEnvelope(storedResearchOverviewDataSchema);
116
+ export const keywordResearchQuerySchema = schema.strictObject({
117
+ topic: schema.string().trim().min(2).max(200),
118
+ minVolume: schema.coerce.number().int().nonnegative().default(10),
119
+ maxVolume: schema.coerce.number().int().nonnegative().default(10_000),
120
+ minDifficulty: schema.coerce.number().min(0).max(100).default(0),
121
+ maxDifficulty: schema.coerce.number().min(0).max(100).default(60),
122
+ intent: schema.string().trim().min(1).optional(),
123
+ includeRelated: queryBooleanSchema.default(true),
124
+ verbose: queryBooleanSchema.default(false),
125
+ limit: schema.coerce.number().int().min(1).max(100).default(20),
126
+ locationCode: schema.coerce.number().int().positive().default(2840),
127
+ }).superRefine((value, context) => {
128
+ if (value.maxVolume < value.minVolume)
129
+ context.addIssue({ code: 'custom', path: ['maxVolume'], message: 'maxVolume must be at least minVolume.' });
130
+ if (value.maxDifficulty < value.minDifficulty)
131
+ context.addIssue({ code: 'custom', path: ['maxDifficulty'], message: 'maxDifficulty must be at least minDifficulty.' });
132
+ });
133
+ const keywordTrendPointSchema = defineResponseObject({
134
+ year: schema.number().int(),
135
+ month: schema.number().int().min(1).max(12),
136
+ volume: schema.number().int().nonnegative(),
137
+ });
138
+ const liveKeywordSchema = defineResponseObject({
139
+ keyword: schema.string().min(1),
140
+ volume: schema.number().nullable(),
141
+ difficulty: schema.number().nullable(),
142
+ intent: schema.string().nullable(),
143
+ secondaryIntents: schema.array(schema.string()).nullable(),
144
+ cpc: schema.number().nullable(),
145
+ competition: schema.number().nullable(),
146
+ competitionLevel: schema.enum(['LOW', 'MEDIUM', 'HIGH']).nullable(),
147
+ lowBid: schema.number().nullable(),
148
+ highBid: schema.number().nullable(),
149
+ trend: schema.array(keywordTrendPointSchema.producer).nullable(),
150
+ serpFeatures: schema.array(schema.string()).nullable(),
151
+ backlinks: schema.number().nullable(),
152
+ referringDomains: schema.number().nullable(),
153
+ });
154
+ const serpFallbackResultSchema = defineResponseObject({
155
+ position: schema.number(),
156
+ title: schema.string(),
157
+ domain: schema.string(),
158
+ url: schema.string(),
159
+ });
160
+ const keywordResearchEvidenceProducerSchema = schema.discriminatedUnion('_tag', [
161
+ schema.strictObject({
162
+ _tag: schema.literal('no-provider'),
163
+ provider: schema.null(),
164
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
165
+ }),
166
+ schema.strictObject({
167
+ _tag: schema.literal('cache'),
168
+ provider: schema.literal('dataforseo'),
169
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
170
+ }),
171
+ schema.strictObject({
172
+ _tag: schema.literal('live'),
173
+ provider: schema.literal('dataforseo'),
174
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(1) }),
175
+ }),
176
+ ]);
177
+ const keywordResearchEvidenceClientSchema = schema.discriminatedUnion('_tag', [
178
+ schema.looseObject({
179
+ _tag: schema.literal('no-provider'),
180
+ provider: schema.null(),
181
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
182
+ }),
183
+ schema.looseObject({
184
+ _tag: schema.literal('cache'),
185
+ provider: schema.literal('dataforseo'),
186
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
187
+ }),
188
+ schema.looseObject({
189
+ _tag: schema.literal('live'),
190
+ provider: schema.literal('dataforseo'),
191
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(1) }),
192
+ }),
193
+ ]);
194
+ const keywordResearchDataSchema = defineResponseObject({
195
+ topic: schema.string().min(1),
196
+ keywords: schema.array(liveKeywordSchema.producer),
197
+ totalFound: schema.number().int().nonnegative(),
198
+ filters: schema.strictObject({
199
+ minVolume: schema.number(),
200
+ maxVolume: schema.number(),
201
+ minDifficulty: schema.number(),
202
+ maxDifficulty: schema.number(),
203
+ intent: schema.string().optional(),
204
+ }),
205
+ message: schema.string().optional(),
206
+ tip: schema.string().optional(),
207
+ serpFallback: schema.strictObject({
208
+ message: schema.string(),
209
+ topResults: schema.array(serpFallbackResultSchema.producer),
210
+ tip: schema.string(),
211
+ }).nullable().optional(),
212
+ evidence: keywordResearchEvidenceProducerSchema,
213
+ }, {
214
+ topic: schema.string().min(1),
215
+ keywords: schema.array(liveKeywordSchema.client),
216
+ totalFound: schema.number().int().nonnegative(),
217
+ filters: schema.looseObject({
218
+ minVolume: schema.number(),
219
+ maxVolume: schema.number(),
220
+ minDifficulty: schema.number(),
221
+ maxDifficulty: schema.number(),
222
+ intent: schema.string().optional(),
223
+ }),
224
+ message: schema.string().optional(),
225
+ tip: schema.string().optional(),
226
+ serpFallback: schema.looseObject({
227
+ message: schema.string(),
228
+ topResults: schema.array(serpFallbackResultSchema.client),
229
+ tip: schema.string(),
230
+ }).nullable().optional(),
231
+ evidence: keywordResearchEvidenceClientSchema,
232
+ });
233
+ export const keywordResearchResponseSchema = defineSuccessEnvelope(keywordResearchDataSchema);
234
+ export const serpResearchQuerySchema = schema.strictObject({
235
+ keyword: schema.string().trim().min(2).max(200),
236
+ depth: schema.coerce.number().int().min(1).max(20).default(10),
237
+ locationCode: schema.coerce.number().int().positive().default(2840),
238
+ });
239
+ const serpOrganicResultSchema = defineResponseObject({
240
+ position: schema.number(),
241
+ url: schema.string(),
242
+ title: schema.string(),
243
+ domain: schema.string(),
244
+ description: schema.string().optional(),
245
+ });
246
+ const serpResearchDataSchema = defineResponseObject({
247
+ keyword: schema.string().min(1),
248
+ depth: schema.number().int().min(1).max(20),
249
+ results: schema.array(serpOrganicResultSchema.producer),
250
+ serpFeatures: schema.array(schema.string()),
251
+ hasAiOverview: schema.boolean(),
252
+ hasFeaturedSnippet: schema.boolean(),
253
+ hasLocalPack: schema.boolean(),
254
+ hasPeopleAlsoAsk: schema.boolean(),
255
+ fetchedAt: schema.string().datetime(),
256
+ cached: schema.boolean(),
257
+ }, {
258
+ keyword: schema.string().min(1),
259
+ depth: schema.number().int().min(1).max(20),
260
+ results: schema.array(serpOrganicResultSchema.client),
261
+ serpFeatures: schema.array(schema.string()),
262
+ hasAiOverview: schema.boolean(),
263
+ hasFeaturedSnippet: schema.boolean(),
264
+ hasLocalPack: schema.boolean(),
265
+ hasPeopleAlsoAsk: schema.boolean(),
266
+ fetchedAt: schema.string().datetime(),
267
+ cached: schema.boolean(),
268
+ });
269
+ export const serpResearchResponseSchema = defineSuccessEnvelope(serpResearchDataSchema);
270
+ export const rankingsResearchQuerySchema = schema.strictObject({
271
+ domain: schema.string().trim().min(1).max(253),
272
+ limit: schema.coerce.number().int().min(1).max(100).default(50),
273
+ minPosition: schema.coerce.number().int().min(1).max(100).default(1),
274
+ maxPosition: schema.coerce.number().int().min(1).max(100).default(20),
275
+ locationCode: schema.coerce.number().int().positive().default(2840),
276
+ order: schema.enum(['position', 'traffic']).default('position'),
277
+ }).superRefine((value, context) => {
278
+ if (value.maxPosition < value.minPosition)
279
+ context.addIssue({ code: 'custom', path: ['maxPosition'], message: 'maxPosition must be at least minPosition.' });
280
+ });
281
+ const rankingKeywordSchema = defineResponseObject({
282
+ keyword: schema.string().min(1),
283
+ position: schema.number(),
284
+ volume: schema.number(),
285
+ traffic: schema.number(),
286
+ url: schema.string(),
287
+ intent: schema.string().nullable().optional(),
288
+ });
289
+ const rankingPageSchema = defineResponseObject({
290
+ path: schema.string(),
291
+ traffic: schema.number(),
292
+ keywords: schema.number(),
293
+ });
294
+ const rankingMetricsSchema = defineResponseObject({
295
+ organicTraffic: schema.number(),
296
+ totalKeywords: schema.number(),
297
+ keywordFootprint: schema.strictObject({
298
+ score: schema.number().nullable(),
299
+ scale: schema.strictObject({ min: schema.literal(0), max: schema.literal(100) }),
300
+ provenance: schema.literal('local-keyword-count'),
301
+ }),
302
+ trafficValue: schema.number().nullable(),
303
+ topPages: schema.array(rankingPageSchema.producer),
304
+ }, {
305
+ organicTraffic: schema.number(),
306
+ totalKeywords: schema.number(),
307
+ keywordFootprint: schema.looseObject({
308
+ score: schema.number().nullable(),
309
+ scale: schema.looseObject({ min: schema.literal(0), max: schema.literal(100) }),
310
+ provenance: schema.literal('local-keyword-count'),
311
+ }),
312
+ trafficValue: schema.number().nullable(),
313
+ topPages: schema.array(rankingPageSchema.client),
314
+ });
315
+ const rankingsResearchDataSchema = defineResponseObject({
316
+ domain: schema.string().min(1),
317
+ keywords: schema.array(rankingKeywordSchema.producer),
318
+ totalFound: schema.number().int().nonnegative(),
319
+ metrics: rankingMetricsSchema.producer.nullable(),
320
+ positionRange: schema.strictObject({ min: schema.number(), max: schema.number() }).optional(),
321
+ message: schema.string().optional(),
322
+ cached: schema.boolean(),
323
+ }, {
324
+ domain: schema.string().min(1),
325
+ keywords: schema.array(rankingKeywordSchema.client),
326
+ totalFound: schema.number().int().nonnegative(),
327
+ metrics: rankingMetricsSchema.client.nullable(),
328
+ positionRange: schema.looseObject({ min: schema.number(), max: schema.number() }).optional(),
329
+ message: schema.string().optional(),
330
+ cached: schema.boolean(),
331
+ });
332
+ export const rankingsResearchResponseSchema = defineSuccessEnvelope(rankingsResearchDataSchema);
112
333
  export const storedResearchOverviewOperation = defineOperation({
113
334
  id: 'research.overview',
114
335
  method: 'GET',
@@ -166,9 +387,116 @@ export const storedResearchOverviewOperation = defineOperation({
166
387
  }],
167
388
  },
168
389
  });
390
+ const liveResearchErrors = [
391
+ 'invalid_request',
392
+ 'unauthorized',
393
+ 'auth_expired',
394
+ 'forbidden',
395
+ 'not_found',
396
+ 'entitlement_required',
397
+ 'quota_exhausted',
398
+ 'provider_unavailable',
399
+ 'conflict',
400
+ 'rate_limited',
401
+ 'contract_violation',
402
+ 'internal_error',
403
+ ];
404
+ const liveResearchSemantics = {
405
+ kind: 'query',
406
+ sideEffects: 'none',
407
+ retry: 'idempotent',
408
+ readConsistency: 'primary',
409
+ };
410
+ const liveResearchAuth = {
411
+ credentials: ['session', 'api_token'],
412
+ scopes: ['research:read'],
413
+ minimumRole: 'viewer',
414
+ ownership: 'site',
415
+ };
416
+ const liveResearchPolicy = {
417
+ rate: publicV1StandardRatePolicy,
418
+ idempotency: { _tag: 'none' },
419
+ entitlement: 'growth',
420
+ meter: { key: 'dataforseo', units: 1, charge: 'live-only' },
421
+ };
422
+ export const keywordResearchOperation = defineOperation({
423
+ id: 'research.keywords',
424
+ method: 'GET',
425
+ path: '/api/v1/sites/{siteId}/research/keywords',
426
+ visibility: 'public',
427
+ semantics: liveResearchSemantics,
428
+ auth: liveResearchAuth,
429
+ request: { params: researchSiteParamsSchema, query: keywordResearchQuerySchema, headers: null, body: null },
430
+ responses: { 200: keywordResearchResponseSchema },
431
+ errors: liveResearchErrors,
432
+ errorResponse: publicV1ErrorEnvelope,
433
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
434
+ policy: liveResearchPolicy,
435
+ lifecycle: { introduced: '1.0.0' },
436
+ docs: {
437
+ summary: 'Research keyword ideas',
438
+ description: 'Returns keyword ideas with volume, difficulty, intent, cost data, and explicit provider execution and spend evidence. Local guidance and cached reads consume no units.',
439
+ tags: ['Research'],
440
+ examples: [{
441
+ request: { params: { siteId: 'site_01JXYZ' }, query: { topic: 'nuxt seo', limit: 20 } },
442
+ response: { status: 200, body: { data: { topic: 'nuxt seo', keywords: [], totalFound: 0, filters: { minVolume: 10, maxVolume: 10000, minDifficulty: 0, maxDifficulty: 60 }, evidence: { _tag: 'live', provider: 'dataforseo', spend: { key: 'dataforseo', units: 1 } } }, meta: { requestId: 'req_01JXYZ', version: '1.0' } } },
443
+ }],
444
+ },
445
+ });
446
+ export const serpResearchOperation = defineOperation({
447
+ id: 'research.serp',
448
+ method: 'GET',
449
+ path: '/api/v1/sites/{siteId}/research/serp',
450
+ visibility: 'public',
451
+ semantics: liveResearchSemantics,
452
+ auth: liveResearchAuth,
453
+ request: { params: researchSiteParamsSchema, query: serpResearchQuerySchema, headers: null, body: null },
454
+ responses: { 200: serpResearchResponseSchema },
455
+ errors: liveResearchErrors,
456
+ errorResponse: publicV1ErrorEnvelope,
457
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
458
+ policy: liveResearchPolicy,
459
+ lifecycle: { introduced: '1.0.0' },
460
+ docs: {
461
+ summary: 'Analyze a Google SERP',
462
+ description: 'Returns one dated SERP snapshot and its result features. Cached reads consume no units.',
463
+ tags: ['Research'],
464
+ examples: [{
465
+ request: { params: { siteId: 'site_01JXYZ' }, query: { keyword: 'nuxt seo', depth: 10 } },
466
+ response: { status: 200, body: { data: { keyword: 'nuxt seo', depth: 10, results: [], serpFeatures: [], hasAiOverview: false, hasFeaturedSnippet: false, hasLocalPack: false, hasPeopleAlsoAsk: false, fetchedAt: '2026-08-20T00:00:00.000Z', cached: false }, meta: { requestId: 'req_01JXYZ', version: '1.0' } } },
467
+ }],
468
+ },
469
+ });
470
+ export const rankingsResearchOperation = defineOperation({
471
+ id: 'research.rankings',
472
+ method: 'GET',
473
+ path: '/api/v1/sites/{siteId}/research/rankings',
474
+ visibility: 'public',
475
+ semantics: liveResearchSemantics,
476
+ auth: liveResearchAuth,
477
+ request: { params: researchSiteParamsSchema, query: rankingsResearchQuerySchema, headers: null, body: null },
478
+ responses: { 200: rankingsResearchResponseSchema },
479
+ errors: liveResearchErrors,
480
+ errorResponse: publicV1ErrorEnvelope,
481
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
482
+ policy: liveResearchPolicy,
483
+ lifecycle: { introduced: '1.0.0' },
484
+ docs: {
485
+ summary: 'Research domain rankings',
486
+ description: 'Returns current keyword rankings and domain metrics. Cached reads consume no units.',
487
+ tags: ['Research'],
488
+ examples: [{
489
+ request: { params: { siteId: 'site_01JXYZ' }, query: { domain: 'nuxtseo.com', limit: 20 } },
490
+ response: { status: 200, body: { data: { domain: 'nuxtseo.com', keywords: [], totalFound: 0, metrics: null, positionRange: { min: 1, max: 20 }, cached: false }, meta: { requestId: 'req_01JXYZ', version: '1.0' } } },
491
+ }],
492
+ },
493
+ });
169
494
  export const researchV1Registry = defineRegistry({
170
495
  feature: 'research',
171
496
  operations: {
172
497
  overview: storedResearchOverviewOperation,
498
+ keywords: keywordResearchOperation,
499
+ serp: serpResearchOperation,
500
+ rankings: rankingsResearchOperation,
173
501
  },
174
502
  });