@nuxtseo/protocol 0.1.5 → 0.2.0

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', '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: {
package/dist/v1/core.js CHANGED
@@ -49,6 +49,7 @@ export const PUBLIC_V1_SCOPES = [
49
49
  'sites:read',
50
50
  'sites:write',
51
51
  'timeline:read',
52
+ 'timeline:write',
52
53
  ];
53
54
  export const PUBLIC_V1_ROLES = ['viewer', 'member', 'admin', 'owner'];
54
55
  export const PUBLIC_V1_ERROR_CODES = [
@@ -154,6 +155,15 @@ function assertExamples(operation) {
154
155
  throw new TypeError(`${operation.id}: example response does not satisfy the producer schema`);
155
156
  }
156
157
  }
158
+ /**
159
+ * Runs a real `safeParse` per declared example, and runs three times per
160
+ * operation: `defineOperation`, then `defineRegistry`, then `defineProtocol`
161
+ * calling `defineRegistry` again. Cheap only because no eager module in the pro
162
+ * Worker imports a registry: routes take per-feature deep specifiers and the
163
+ * barrel is test-only. If a registry ever lands in the cold-start closure, move
164
+ * `docs.examples` to a sibling module the OpenAPI generator and tests import,
165
+ * because nothing reads them at runtime.
166
+ */
157
167
  function assertOperation(operation) {
158
168
  if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/.test(operation.id))
159
169
  throw new TypeError(`${operation.id}: operation ID must be a stable dotted lowercase identifier`);