@nuxtseo/protocol 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -118,12 +118,6 @@ export const recoverableBacklinksOperation = defineOperation({
118
118
  }],
119
119
  },
120
120
  });
121
- export const backlinksV1Registry = defineRegistry({
122
- feature: 'backlinks',
123
- operations: {
124
- recoverable: recoverableBacklinksOperation,
125
- },
126
- });
127
121
  export const mentionsQuerySchema = schema.strictObject({
128
122
  limit: schema.coerce.number().int().min(1).max(200).default(100),
129
123
  includeFiltered: publicV1BooleanFlag(false),
@@ -237,3 +231,418 @@ export const mentionsV1Registry = defineRegistry({
237
231
  list: mentionsListOperation,
238
232
  },
239
233
  });
234
+ // ---------------------------------------------------------------------------
235
+ // Live Backlinks (DataForSEO). These four operations reproduce the non-stored
236
+ // modes of the `backlinks` MCP tool. Each one may start provider work, so each
237
+ // carries the Growth entitlement and the `dataforseo` meter, and each returns
238
+ // `evidence` saying whether the answer came from cache or from a live call.
239
+ // `backlinks.recoverable` above stays free: it reads retained rows only.
240
+ // ---------------------------------------------------------------------------
241
+ const liveBacklinksSemantics = {
242
+ kind: 'query',
243
+ sideEffects: 'none',
244
+ retry: 'idempotent',
245
+ readConsistency: 'primary',
246
+ };
247
+ const liveBacklinksAuth = {
248
+ credentials: ['session', 'api_token'],
249
+ scopes: ['research:read'],
250
+ minimumRole: 'viewer',
251
+ ownership: 'site',
252
+ };
253
+ const liveBacklinksErrors = [
254
+ 'invalid_request',
255
+ 'unauthorized',
256
+ 'auth_expired',
257
+ 'forbidden',
258
+ 'not_found',
259
+ 'entitlement_required',
260
+ 'quota_exhausted',
261
+ 'provider_unavailable',
262
+ 'conflict',
263
+ 'rate_limited',
264
+ 'contract_violation',
265
+ 'internal_error',
266
+ ];
267
+ const liveBacklinksPolicy = {
268
+ rate: publicV1StandardRatePolicy,
269
+ idempotency: { _tag: 'none' },
270
+ entitlement: 'growth',
271
+ meter: { key: 'dataforseo', units: 1, charge: 'live-only' },
272
+ };
273
+ const liveBacklinksEvidenceProducerSchema = schema.discriminatedUnion('_tag', [
274
+ schema.strictObject({
275
+ _tag: schema.literal('no-provider'),
276
+ provider: schema.null(),
277
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
278
+ }),
279
+ schema.strictObject({
280
+ _tag: schema.literal('cache'),
281
+ provider: schema.literal('dataforseo'),
282
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
283
+ }),
284
+ schema.strictObject({
285
+ _tag: schema.literal('live'),
286
+ provider: schema.literal('dataforseo'),
287
+ spend: schema.strictObject({ key: schema.literal('dataforseo'), units: schema.literal(1) }),
288
+ }),
289
+ ]);
290
+ const liveBacklinksEvidenceClientSchema = schema.discriminatedUnion('_tag', [
291
+ schema.looseObject({
292
+ _tag: schema.literal('no-provider'),
293
+ provider: schema.null(),
294
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
295
+ }),
296
+ schema.looseObject({
297
+ _tag: schema.literal('cache'),
298
+ provider: schema.literal('dataforseo'),
299
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(0) }),
300
+ }),
301
+ schema.looseObject({
302
+ _tag: schema.literal('live'),
303
+ provider: schema.literal('dataforseo'),
304
+ spend: schema.looseObject({ key: schema.literal('dataforseo'), units: schema.literal(1) }),
305
+ }),
306
+ ]);
307
+ /** The target a live Backlinks read resolves to: the Site's own bare domain. */
308
+ export const liveBacklinksQuerySchema = schema.strictObject({
309
+ limit: schema.coerce.number().int().min(1).max(1000).default(100),
310
+ });
311
+ const nullableCount = schema.number().nullable();
312
+ export const backlinksSummaryDataSchema = defineResponseObject({
313
+ target: schema.string().min(1),
314
+ rank: nullableCount,
315
+ backlinks: nullableCount,
316
+ brokenBacklinks: nullableCount,
317
+ brokenPages: nullableCount,
318
+ crawledPages: nullableCount,
319
+ spamScore: nullableCount,
320
+ referringDomains: nullableCount,
321
+ referringDomainsNofollow: nullableCount,
322
+ referringMainDomains: nullableCount,
323
+ referringIps: nullableCount,
324
+ referringSubnets: nullableCount,
325
+ referringPages: nullableCount,
326
+ evidence: liveBacklinksEvidenceProducerSchema,
327
+ }, {
328
+ target: schema.string().min(1),
329
+ rank: nullableCount,
330
+ backlinks: nullableCount,
331
+ brokenBacklinks: nullableCount,
332
+ brokenPages: nullableCount,
333
+ crawledPages: nullableCount,
334
+ spamScore: nullableCount,
335
+ referringDomains: nullableCount,
336
+ referringDomainsNofollow: nullableCount,
337
+ referringMainDomains: nullableCount,
338
+ referringIps: nullableCount,
339
+ referringSubnets: nullableCount,
340
+ referringPages: nullableCount,
341
+ evidence: liveBacklinksEvidenceClientSchema,
342
+ });
343
+ export const backlinksSummaryResponseSchema = defineSuccessEnvelope(backlinksSummaryDataSchema);
344
+ export const backlinksSummaryOperation = defineOperation({
345
+ id: 'backlinks.summary',
346
+ method: 'GET',
347
+ path: '/api/v1/sites/{siteId}/backlinks/summary',
348
+ visibility: 'public',
349
+ semantics: liveBacklinksSemantics,
350
+ auth: liveBacklinksAuth,
351
+ request: { params: backlinksSiteParamsSchema, query: null, headers: null, body: null },
352
+ responses: { 200: backlinksSummaryResponseSchema },
353
+ errors: liveBacklinksErrors,
354
+ errorResponse: publicV1ErrorEnvelope,
355
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
356
+ policy: liveBacklinksPolicy,
357
+ lifecycle: { introduced: '1.2.0' },
358
+ docs: {
359
+ summary: 'Get Backlinks summary',
360
+ description: 'Returns whole-domain inbound link totals for the Site: backlinks, referring domains, broken counts, and the domain rank. Cached reads consume no units.',
361
+ tags: ['Research'],
362
+ examples: [{
363
+ request: { params: { siteId: 'site_01JXYZ' } },
364
+ response: {
365
+ status: 200,
366
+ body: {
367
+ data: {
368
+ target: 'example.com',
369
+ rank: 214,
370
+ backlinks: 5120,
371
+ brokenBacklinks: 34,
372
+ brokenPages: 7,
373
+ crawledPages: 890,
374
+ spamScore: 4,
375
+ referringDomains: 412,
376
+ referringDomainsNofollow: 88,
377
+ referringMainDomains: 388,
378
+ referringIps: 350,
379
+ referringSubnets: 322,
380
+ referringPages: 4800,
381
+ evidence: { _tag: 'live', provider: 'dataforseo', spend: { key: 'dataforseo', units: 1 } },
382
+ },
383
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
384
+ },
385
+ },
386
+ }],
387
+ },
388
+ });
389
+ export const referringDomainSchema = defineResponseObject({
390
+ domain: schema.string().min(1),
391
+ rank: nullableCount,
392
+ backlinks: nullableCount,
393
+ brokenBacklinks: nullableCount,
394
+ brokenPages: nullableCount,
395
+ referringPages: nullableCount,
396
+ spamScore: nullableCount,
397
+ firstSeen: schema.string().nullable(),
398
+ lostDate: schema.string().nullable(),
399
+ /** 90-day referral sessions from this domain, or null when no Web Analytics property is linked. */
400
+ sessions: nullableCount,
401
+ }, {
402
+ domain: schema.string().min(1),
403
+ rank: nullableCount,
404
+ backlinks: nullableCount,
405
+ brokenBacklinks: nullableCount,
406
+ brokenPages: nullableCount,
407
+ referringPages: nullableCount,
408
+ spamScore: nullableCount,
409
+ firstSeen: schema.string().nullable(),
410
+ lostDate: schema.string().nullable(),
411
+ sessions: nullableCount,
412
+ });
413
+ export const referringDomainsDataSchema = defineResponseObject({
414
+ target: schema.string().min(1),
415
+ items: schema.array(referringDomainSchema.producer),
416
+ total: schema.number().int().nonnegative(),
417
+ limit: schema.number().int().positive(),
418
+ evidence: liveBacklinksEvidenceProducerSchema,
419
+ }, {
420
+ target: schema.string().min(1),
421
+ items: schema.array(referringDomainSchema.client),
422
+ total: schema.number().int().nonnegative(),
423
+ limit: schema.number().int().positive(),
424
+ evidence: liveBacklinksEvidenceClientSchema,
425
+ });
426
+ export const referringDomainsResponseSchema = defineSuccessEnvelope(referringDomainsDataSchema);
427
+ export const referringDomainsOperation = defineOperation({
428
+ id: 'backlinks.referring.domains',
429
+ method: 'GET',
430
+ path: '/api/v1/sites/{siteId}/backlinks/referring-domains',
431
+ visibility: 'public',
432
+ semantics: liveBacklinksSemantics,
433
+ auth: liveBacklinksAuth,
434
+ request: { params: backlinksSiteParamsSchema, query: liveBacklinksQuerySchema, headers: null, body: null },
435
+ responses: { 200: referringDomainsResponseSchema },
436
+ errors: liveBacklinksErrors,
437
+ errorResponse: publicV1ErrorEnvelope,
438
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
439
+ policy: liveBacklinksPolicy,
440
+ lifecycle: { introduced: '1.2.0' },
441
+ docs: {
442
+ summary: 'List referring domains',
443
+ description: 'Returns the domains that link to the Site, highest authority first, with spam score and broken-link counts. When a Web Analytics property is linked, each row also carries its 90-day referral sessions, so a link that sends nobody is visible. Cached reads consume no units.',
444
+ tags: ['Research'],
445
+ examples: [{
446
+ request: { params: { siteId: 'site_01JXYZ' }, query: { limit: 100 } },
447
+ response: {
448
+ status: 200,
449
+ body: {
450
+ data: {
451
+ target: 'example.com',
452
+ items: [{
453
+ domain: 'news.example',
454
+ rank: 529,
455
+ backlinks: 12,
456
+ brokenBacklinks: 0,
457
+ brokenPages: 0,
458
+ referringPages: 9,
459
+ spamScore: 2,
460
+ firstSeen: '2026-02-09 20:43:50 +00:00',
461
+ lostDate: null,
462
+ sessions: 143,
463
+ }],
464
+ total: 1,
465
+ limit: 100,
466
+ evidence: { _tag: 'cache', provider: 'dataforseo', spend: { key: 'dataforseo', units: 0 } },
467
+ },
468
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
469
+ },
470
+ },
471
+ }],
472
+ },
473
+ });
474
+ export const backlinkAnchorSchema = defineResponseObject({
475
+ anchor: schema.string(),
476
+ rank: nullableCount,
477
+ backlinks: nullableCount,
478
+ referringDomains: nullableCount,
479
+ referringMainDomains: nullableCount,
480
+ referringPages: nullableCount,
481
+ spamScore: nullableCount,
482
+ firstSeen: schema.string().nullable(),
483
+ lostDate: schema.string().nullable(),
484
+ }, {
485
+ anchor: schema.string(),
486
+ rank: nullableCount,
487
+ backlinks: nullableCount,
488
+ referringDomains: nullableCount,
489
+ referringMainDomains: nullableCount,
490
+ referringPages: nullableCount,
491
+ spamScore: nullableCount,
492
+ firstSeen: schema.string().nullable(),
493
+ lostDate: schema.string().nullable(),
494
+ });
495
+ export const backlinkAnchorsDataSchema = defineResponseObject({
496
+ target: schema.string().min(1),
497
+ items: schema.array(backlinkAnchorSchema.producer),
498
+ total: schema.number().int().nonnegative(),
499
+ limit: schema.number().int().positive(),
500
+ evidence: liveBacklinksEvidenceProducerSchema,
501
+ }, {
502
+ target: schema.string().min(1),
503
+ items: schema.array(backlinkAnchorSchema.client),
504
+ total: schema.number().int().nonnegative(),
505
+ limit: schema.number().int().positive(),
506
+ evidence: liveBacklinksEvidenceClientSchema,
507
+ });
508
+ export const backlinkAnchorsResponseSchema = defineSuccessEnvelope(backlinkAnchorsDataSchema);
509
+ export const backlinkAnchorsOperation = defineOperation({
510
+ id: 'backlinks.anchors',
511
+ method: 'GET',
512
+ path: '/api/v1/sites/{siteId}/backlinks/anchors',
513
+ visibility: 'public',
514
+ semantics: liveBacklinksSemantics,
515
+ auth: liveBacklinksAuth,
516
+ request: { params: backlinksSiteParamsSchema, query: liveBacklinksQuerySchema, headers: null, body: null },
517
+ responses: { 200: backlinkAnchorsResponseSchema },
518
+ errors: liveBacklinksErrors,
519
+ errorResponse: publicV1ErrorEnvelope,
520
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
521
+ policy: liveBacklinksPolicy,
522
+ lifecycle: { introduced: '1.2.0' },
523
+ docs: {
524
+ summary: 'List Backlink anchors',
525
+ description: 'Returns the anchor text distribution of the Site inbound links, most linked first. Use it to find over-optimised or off-brand anchors. Cached reads consume no units.',
526
+ tags: ['Research'],
527
+ examples: [{
528
+ request: { params: { siteId: 'site_01JXYZ' }, query: { limit: 100 } },
529
+ response: {
530
+ status: 200,
531
+ body: {
532
+ data: {
533
+ target: 'example.com',
534
+ items: [{
535
+ anchor: 'nuxt seo',
536
+ rank: 480,
537
+ backlinks: 212,
538
+ referringDomains: 64,
539
+ referringMainDomains: 60,
540
+ referringPages: 190,
541
+ spamScore: 3,
542
+ firstSeen: '2026-01-04 11:02:11 +00:00',
543
+ lostDate: null,
544
+ }],
545
+ total: 1,
546
+ limit: 100,
547
+ evidence: { _tag: 'cache', provider: 'dataforseo', spend: { key: 'dataforseo', units: 0 } },
548
+ },
549
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
550
+ },
551
+ },
552
+ }],
553
+ },
554
+ });
555
+ export const backlinksHistoryQuerySchema = schema.strictObject({
556
+ dateFrom: schema.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
557
+ });
558
+ export const backlinksHistoryPointSchema = defineResponseObject({
559
+ date: schema.string().min(1),
560
+ rank: nullableCount,
561
+ backlinks: nullableCount,
562
+ referringDomains: nullableCount,
563
+ referringMainDomains: nullableCount,
564
+ referringPages: nullableCount,
565
+ newReferringDomains: nullableCount,
566
+ lostReferringDomains: nullableCount,
567
+ newBacklinks: nullableCount,
568
+ lostBacklinks: nullableCount,
569
+ }, {
570
+ date: schema.string().min(1),
571
+ rank: nullableCount,
572
+ backlinks: nullableCount,
573
+ referringDomains: nullableCount,
574
+ referringMainDomains: nullableCount,
575
+ referringPages: nullableCount,
576
+ newReferringDomains: nullableCount,
577
+ lostReferringDomains: nullableCount,
578
+ newBacklinks: nullableCount,
579
+ lostBacklinks: nullableCount,
580
+ });
581
+ export const backlinksHistoryDataSchema = defineResponseObject({
582
+ target: schema.string().min(1),
583
+ items: schema.array(backlinksHistoryPointSchema.producer),
584
+ total: schema.number().int().nonnegative(),
585
+ evidence: liveBacklinksEvidenceProducerSchema,
586
+ }, {
587
+ target: schema.string().min(1),
588
+ items: schema.array(backlinksHistoryPointSchema.client),
589
+ total: schema.number().int().nonnegative(),
590
+ evidence: liveBacklinksEvidenceClientSchema,
591
+ });
592
+ export const backlinksHistoryResponseSchema = defineSuccessEnvelope(backlinksHistoryDataSchema);
593
+ export const backlinksHistoryOperation = defineOperation({
594
+ id: 'backlinks.history',
595
+ method: 'GET',
596
+ path: '/api/v1/sites/{siteId}/backlinks/history',
597
+ visibility: 'public',
598
+ semantics: liveBacklinksSemantics,
599
+ auth: liveBacklinksAuth,
600
+ request: { params: backlinksSiteParamsSchema, query: backlinksHistoryQuerySchema, headers: null, body: null },
601
+ responses: { 200: backlinksHistoryResponseSchema },
602
+ errors: liveBacklinksErrors,
603
+ errorResponse: publicV1ErrorEnvelope,
604
+ resources: { reads: [{ type: 'site.research', idFrom: 'params.siteId' }], changes: [] },
605
+ policy: liveBacklinksPolicy,
606
+ lifecycle: { introduced: '1.2.0' },
607
+ docs: {
608
+ summary: 'Get Backlinks history',
609
+ description: 'Returns a monthly series of inbound link totals with the domains gained and lost in each month. It defaults to about twelve months; pass dateFrom for a longer window. Cached reads consume no units.',
610
+ tags: ['Research'],
611
+ examples: [{
612
+ request: { params: { siteId: 'site_01JXYZ' }, query: { dateFrom: '2026-01-01' } },
613
+ response: {
614
+ status: 200,
615
+ body: {
616
+ data: {
617
+ target: 'example.com',
618
+ items: [{
619
+ date: '2026-08-01',
620
+ rank: 214,
621
+ backlinks: 5120,
622
+ referringDomains: 412,
623
+ referringMainDomains: 388,
624
+ referringPages: 4800,
625
+ newReferringDomains: 18,
626
+ lostReferringDomains: 4,
627
+ newBacklinks: 210,
628
+ lostBacklinks: 62,
629
+ }],
630
+ total: 1,
631
+ evidence: { _tag: 'live', provider: 'dataforseo', spend: { key: 'dataforseo', units: 1 } },
632
+ },
633
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
634
+ },
635
+ },
636
+ }],
637
+ },
638
+ });
639
+ export const backlinksV1Registry = defineRegistry({
640
+ feature: 'backlinks',
641
+ operations: {
642
+ recoverable: recoverableBacklinksOperation,
643
+ summary: backlinksSummaryOperation,
644
+ referringDomains: referringDomainsOperation,
645
+ anchors: backlinkAnchorsOperation,
646
+ history: backlinksHistoryOperation,
647
+ },
648
+ });
package/dist/v1/core.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare const publicV1StandardRatePolicy: {
20
20
  export declare const PUBLIC_V1_FEATURES: readonly ['account', 'actions', 'analytics', 'audit', 'backlinks', 'content', 'mentions', 'page', 'research', 'sites', 'timeline'];
21
21
  export declare const PUBLIC_V1_METHODS: readonly ['DELETE', 'GET', 'PATCH', 'POST'];
22
22
  export declare const PUBLIC_V1_CREDENTIALS: readonly ['api_token', 'session'];
23
- export declare const PUBLIC_V1_SCOPES: readonly ['account:read', 'actions:read', 'actions:write', 'analytics:read', 'audit:read', 'content:read', 'content:write', 'page:read', 'page:write', 'research:read', 'research:write', 'sites:read', 'sites:write', 'timeline:read'];
23
+ export declare const PUBLIC_V1_SCOPES: readonly ['account:read', 'actions:read', 'actions:write', 'analytics:read', 'audit:read', 'content:read', 'content:write', 'feedback:write', 'page:read', 'page:write', 'research:read', 'research:write', 'sites:read', 'sites:write', 'timeline:read', 'timeline:write'];
24
24
  export declare const PUBLIC_V1_ROLES: readonly ['viewer', 'member', 'admin', 'owner'];
25
25
  export declare const PUBLIC_V1_ERROR_CODES: readonly ['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'site_ambiguous', 'auth_expired', 'entitlement_required', 'quota_exhausted', 'provider_unavailable', 'stale_evidence', 'conflict', 'rate_limited', 'contract_violation', 'internal_error'];
26
26
  export declare const PUBLIC_V1_ERROR_STATUS: {
@@ -47,7 +47,7 @@ export type PublicV1Role = typeof PUBLIC_V1_ROLES[number];
47
47
  export type PublicV1ErrorCode = typeof PUBLIC_V1_ERROR_CODES[number];
48
48
  export type PublicV1Visibility = 'internal' | 'public';
49
49
  export type PublicV1OwnershipRule = 'principal' | 'site' | 'team';
50
- export type PublicV1ResourceType = 'account.usage' | 'site.actions' | 'site.analytics' | 'site.audit' | 'site.collection' | 'site.content' | 'site.identity' | 'site.page' | 'site.research' | 'site.sitemaps' | 'site.timeline';
50
+ export type PublicV1ResourceType = 'account.usage' | 'account.feedback' | 'site.actions' | 'site.analytics' | 'site.audit' | 'site.collection' | 'site.content' | 'site.identity' | 'site.page' | 'site.research' | 'site.sitemaps' | 'site.timeline';
51
51
  export interface PublicV1ExpectedFailure {
52
52
  _tag: 'ApiFailure';
53
53
  code: PublicV1ErrorCode;
package/dist/v1/core.js CHANGED
@@ -42,6 +42,7 @@ export const PUBLIC_V1_SCOPES = [
42
42
  'audit:read',
43
43
  'content:read',
44
44
  'content:write',
45
+ 'feedback:write',
45
46
  'page:read',
46
47
  'page:write',
47
48
  'research:read',
@@ -49,6 +50,7 @@ export const PUBLIC_V1_SCOPES = [
49
50
  'sites:read',
50
51
  'sites:write',
51
52
  'timeline:read',
53
+ 'timeline:write',
52
54
  ];
53
55
  export const PUBLIC_V1_ROLES = ['viewer', 'member', 'admin', 'owner'];
54
56
  export const PUBLIC_V1_ERROR_CODES = [
@@ -154,6 +156,15 @@ function assertExamples(operation) {
154
156
  throw new TypeError(`${operation.id}: example response does not satisfy the producer schema`);
155
157
  }
156
158
  }
159
+ /**
160
+ * Runs a real `safeParse` per declared example, and runs three times per
161
+ * operation: `defineOperation`, then `defineRegistry`, then `defineProtocol`
162
+ * calling `defineRegistry` again. Cheap only because no eager module in the pro
163
+ * Worker imports a registry: routes take per-feature deep specifiers and the
164
+ * barrel is test-only. If a registry ever lands in the cold-start closure, move
165
+ * `docs.examples` to a sibling module the OpenAPI generator and tests import,
166
+ * because nothing reads them at runtime.
167
+ */
157
168
  function assertOperation(operation) {
158
169
  if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/.test(operation.id))
159
170
  throw new TypeError(`${operation.id}: operation ID must be a stable dotted lowercase identifier`);