@nuxtseo/cli 0.5.4 → 0.5.5

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/README.md CHANGED
@@ -206,6 +206,9 @@ nuxtseo sitemaps urls
206
206
  nuxtseo sitemaps submit <sitemap-url>
207
207
  nuxtseo sitemaps delete <sitemap-url>
208
208
  nuxtseo research overview
209
+ nuxtseo research competitors list
210
+ nuxtseo research competitors add <domain> --yes
211
+ nuxtseo research competitors remove <competitor-id> --yes
209
212
  nuxtseo research domain-traffic <domain>
210
213
  nuxtseo research domain-availability <domains>
211
214
  nuxtseo research keywords <topic>
package/dist/commands.js CHANGED
@@ -762,6 +762,40 @@ async function researchOverview(runtime, globals) {
762
762
  }, { signal: runtime.requestSignal }));
763
763
  return present(runtime, globals, response, value => renderResearchOverview(value.data));
764
764
  }
765
+ async function competitorsList(runtime, globals) {
766
+ const resolved = await apiAndSite(runtime, globals);
767
+ if (resolved._tag === 'Err')
768
+ return resolved;
769
+ const response = await withSpinner(runtime, 'Loading competitors', () => resolved.value.api.client.research.listCompetitors({
770
+ params: { siteId: resolved.value.siteId },
771
+ }, { signal: runtime.requestSignal }));
772
+ return present(runtime, globals, response, value => value.data.competitors.map(competitor => `${competitor.id}\t${competitor.domain}`).join('\n') || '0 tracked competitors');
773
+ }
774
+ async function competitorWrite(runtime, globals, action) {
775
+ const confirmed = await confirmMutation(runtime, globals, action._tag === 'Add'
776
+ ? `Track ${action.domain}? This uses the research limit.`
777
+ : `Remove competitor ${action.competitorId}? This cannot be undone.`);
778
+ if (confirmed._tag === 'Err')
779
+ return confirmed;
780
+ if (!confirmed.value) {
781
+ writeDiagnostic(runtime, 'Mutation cancelled.');
782
+ return ok(undefined);
783
+ }
784
+ const resolved = await apiAndSite(runtime, globals);
785
+ if (resolved._tag === 'Err')
786
+ return resolved;
787
+ if (action._tag === 'Add') {
788
+ const response = await withSpinner(runtime, 'Adding competitor', () => resolved.value.api.client.research.addCompetitor({
789
+ params: { siteId: resolved.value.siteId },
790
+ body: { domain: action.domain },
791
+ }, { signal: runtime.requestSignal }));
792
+ return present(runtime, globals, response, value => `${value.data.competitor.id}\t${value.data.competitor.domain}\tAnalysis queued`);
793
+ }
794
+ const response = await withSpinner(runtime, 'Removing competitor', () => resolved.value.api.client.research.removeCompetitor({
795
+ params: { siteId: resolved.value.siteId, competitorId: action.competitorId },
796
+ }, { signal: runtime.requestSignal }));
797
+ return present(runtime, globals, response, value => `Removed competitor ${value.data.competitorId}`);
798
+ }
765
799
  /**
766
800
  * DataForSEO refuses these characters, so the research provider removes them
767
801
  * before it counts the words of a seed. Mirrored from
@@ -1833,6 +1867,25 @@ export function createRootCommand(runtime, globals, execution) {
1833
1867
  const research = defineCommand({
1834
1868
  meta: { name: 'research', description: 'Read stored and live search research' },
1835
1869
  subCommands: {
1870
+ 'competitors': defineCommand({
1871
+ meta: { name: 'competitors', description: 'Manage tracked competitors for the Site' },
1872
+ subCommands: {
1873
+ list: defineCommand({
1874
+ meta: { name: 'list', description: 'List tracked competitor domains and ids' },
1875
+ run: ({ args }) => capture(execution, () => competitorsList(runtime, globals), args._, 0)(),
1876
+ }),
1877
+ add: defineCommand({
1878
+ meta: { name: 'add', description: 'Track one competitor and queue its analysis' },
1879
+ args: { domain: positional('domain', 'Public competitor domain') },
1880
+ run: ({ args }) => capture(execution, () => competitorWrite(runtime, globals, { _tag: 'Add', domain: args.domain }), args._, 1)(),
1881
+ }),
1882
+ remove: defineCommand({
1883
+ meta: { name: 'remove', description: 'Remove one competitor using its listed id' },
1884
+ args: { competitorId: positional('competitorId', 'Competitor id from research competitors list') },
1885
+ run: ({ args }) => capture(execution, () => competitorWrite(runtime, globals, { _tag: 'Remove', competitorId: args.competitorId }), args._, 1)(),
1886
+ }),
1887
+ },
1888
+ }),
1836
1889
  'overview': defineCommand({
1837
1890
  meta: { name: 'overview', description: 'Read stored Site metrics and competitor research' },
1838
1891
  run: ({ args }) => capture(execution, () => researchOverview(runtime, globals), args._, 0)(),
package/dist/render.js CHANGED
@@ -484,9 +484,19 @@ export function renderResearchOverview(data) {
484
484
  return [subject, `Competitors: ${data.summary.competitorCount}`, competitors].join('\n');
485
485
  }
486
486
  export function renderKeywordResearch(data) {
487
+ const measured = data.topicMetrics.filter(row => row._tag === 'measured').length;
488
+ const topics = data.topicMetrics.length
489
+ ? [
490
+ `Requested topics (${measured}/${data.topicMetrics.length} measured)`,
491
+ ...data.topicMetrics.map(row => row._tag === 'measured'
492
+ ? `${row.metrics.volume ?? 'unknown'} volume ${row.metrics.difficulty ?? 'unknown'} difficulty ${row.metrics.intent ?? 'unknown intent'}\n ${row.topic}`
493
+ : `No data returned\n ${row.topic}`),
494
+ ]
495
+ : [];
487
496
  if (data.keywords.length === 0)
488
- return data.message ?? `No keyword ideas found for ${data.topic}.`;
497
+ return [...topics, data.message ?? `No keyword ideas found for ${data.topic}.`, ...(data.tip ? [`Tip: ${data.tip}`] : [])].join('\n');
489
498
  return [
499
+ ...topics,
490
500
  `Keyword ideas for ${data.topic} (${data.keywords.length} of ${data.totalFound})${data.evidence._tag === 'cache' ? ' cached' : ''}`,
491
501
  ...data.keywords.map(row => `${row.volume ?? 'unknown'} volume ${row.difficulty ?? 'unknown'} difficulty ${row.intent ?? 'unknown intent'}\n ${row.keyword}`),
492
502
  ...(data.tip ? [`Tip: ${data.tip}`] : []),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/cli",
3
3
  "type": "module",
4
- "version": "0.5.4",
4
+ "version": "0.5.5",
5
5
  "description": "Command line interface for the Nuxt SEO public API.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com/pro",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^1.8.0",
36
- "@nuxtseo/protocol": "^0.5.4",
37
- "@nuxtseo/sdk": "^0.5.4",
36
+ "@nuxtseo/protocol": "^0.5.5",
37
+ "@nuxtseo/sdk": "^0.5.5",
38
38
  "citty": "^0.2.2",
39
39
  "pathe": "^2.0.3"
40
40
  },
@@ -64,6 +64,7 @@ Read the matching file before you act on its subject:
64
64
  | `page scan` | `page:write` |
65
65
  | `actions resolve`, `actions dismiss` | `actions:write` |
66
66
  | `annotations` writes | `timeline:write` |
67
+ | `research competitors add`, `research competitors remove` | `research:write` |
67
68
  | `sitemaps submit`, `sitemaps delete` | `sites:write` |
68
69
 
69
70
  If a write scope is missing, do the reads, then hand the user the exact
@@ -252,12 +252,16 @@ A missing query row is never evidence of no demand.
252
252
  ## Research, backlinks, and mentions
253
253
 
254
254
  Every command here can start live research, except `research overview`,
255
+ `research competitors list`, `research competitors remove`,
255
256
  `backlinks recoverable` and `mentions list`, which read retained rows. Read the
256
257
  research boundary in SKILL.md first.
257
258
 
258
259
  | Command | What it returns, and its flags |
259
260
  | --- | --- |
260
261
  | `research overview` | Stored Site and competitor research: metrics, history, gaps, quick wins. Takes no flags. Returns `data.competitors` and `data.summary` |
262
+ | `research competitors list` | Tracked competitor domains and ids for this Site. Suggested competitors stay excluded. Reads start no provider work. |
263
+ | `research competitors add <domain>` | Mutation, requires `research:write` and `--yes`. Accepts a public domain or URL. Queues analysis within existing Site and Team limits. Returns `data.competitor.id` and `data.status: queued`. |
264
+ | `research competitors remove <competitor-id>` | Mutation, requires `research:write` and `--yes`. Use an id from the list. Removes that Site's competitor without provider work. |
261
265
  | `research keywords <topic>` | Live keyword ideas, with volume, difficulty, intent, and cost data. The seed must be 1 to 3 words. The server refuses a longer seed. The refusal arrives as exit `0` with an empty `data.keywords`, so read `data.message` first. Comma-separate several short seeds instead. Special characters are stripped before the lookup. If a seed is under 2 characters after stripping, the server refuses it the same way. The server applies default filters and echoes them in `data.filters`: `--min-volume` (default 10), `--max-volume` (default 10000), `--min-difficulty 0..100` (default 0), `--max-difficulty 0..100` (default 60). So a default run hides keywords over difficulty 60 or under 10 searches. Also `--limit 1..100` (default 20), `--intent`, `--location-code` (default 2840), `--no-related` to drop related keywords, and `--verbose` for full evidence. If `--max-volume` is under `--min-volume`, the run exits `2`. The same holds for `--max-difficulty` under `--min-difficulty` |
262
266
  | `research serp <keyword>` | A live SERP snapshot: results, features, fetch time. `--depth 1..20` (default 10) sets how many organic results return. `--location-code` (default 2840). The keyword must be 2 to 200 characters. `data.cached` reports cache use |
263
267
  | `research rankings <domain>` | Live domain rankings: current keywords and domain metrics. `--limit 1..100` (default 50), `--min-position 1..100` (default 1), `--max-position 1..100` (default 20), `--location-code` (default 2840), `--order position\|traffic` (default `position`). So a default run returns only the top 20 positions. If `--max-position` is under `--min-position`, the run exits `2`. An unusable domain also exits `2`, with `invalid_request`. An empty `data.keywords` with exit `0` carries the reason in `data.message`. `data.cached` reports cache use |
@@ -270,6 +274,13 @@ research boundary in SKILL.md first.
270
274
  | `backlinks recoverable` | Stored recoverable backlinks. `--limit 1..200`, `--offset`. Reads retained rows |
271
275
  | `mentions list` | Stored mentions. `--limit 1..200` (default 100), `--include-filtered` to keep the Mentions AI triage marked a false positive. Reads retained rows |
272
276
 
277
+ Keyword research returns `data.topicMetrics` for every accepted comma-separated topic.
278
+ Read it separately from filtered `data.keywords`, which contains keyword ideas.
279
+ A `measured` row preserves exact metrics, including zero volume and values outside idea filters.
280
+ A `no-data` row means the provider returned no exact metrics. It does not mean zero volume.
281
+ If any topic becomes shorter than two characters after sanitization, the server refuses the whole request.
282
+ Cached reads preserve all topic metrics without another provider call.
283
+
273
284
  ## Crawl audit
274
285
 
275
286
  | Command | What it returns, and its flags |