seomd-cli 1.0.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.
- package/bin/seomd.js +54 -0
- package/package.json +46 -0
- package/src/commands/analyze.js +145 -0
- package/src/commands/init.js +160 -0
- package/src/commands/status.js +192 -0
- package/src/commands/sync.js +122 -0
- package/src/commands/validate.js +58 -0
- package/src/generators/directory.js +136 -0
- package/src/generators/reverse.js +62 -0
- package/src/generators/seomd.js +55 -0
- package/src/templates/blog/SEO.REVERSE.md +176 -0
- package/src/templates/blog/SEO.md +348 -0
- package/src/templates/ecommerce/SEO.REVERSE.md +199 -0
- package/src/templates/ecommerce/SEO.md +354 -0
- package/src/templates/local/SEO.REVERSE.md +222 -0
- package/src/templates/local/SEO.md +360 -0
- package/src/templates/marketplace/SEO.REVERSE.md +222 -0
- package/src/templates/marketplace/SEO.md +360 -0
- package/src/templates/saas/SEO.REVERSE.md +268 -0
- package/src/templates/saas/SEO.md +372 -0
- package/src/utils/api-client.js +41 -0
- package/src/utils/constants.js +132 -0
- package/src/utils/parser.js +46 -0
- package/src/utils/writeback.js +130 -0
- package/src/validators/seomd.js +211 -0
package/bin/seomd.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
|
|
10
|
+
|
|
11
|
+
// Commands
|
|
12
|
+
import { initCommand } from '../src/commands/init.js';
|
|
13
|
+
import { analyzeCommand } from '../src/commands/analyze.js';
|
|
14
|
+
import { syncCommand } from '../src/commands/sync.js';
|
|
15
|
+
import { statusCommand } from '../src/commands/status.js';
|
|
16
|
+
import { validateCommand } from '../src/commands/validate.js';
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('seomd')
|
|
20
|
+
.description('AEO infrastructure for technical founders — seomd.dev')
|
|
21
|
+
.version(pkg.version);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('init')
|
|
25
|
+
.description('Scaffold SEO.md for your project')
|
|
26
|
+
.option('-y, --yes', 'skip prompts and use defaults')
|
|
27
|
+
.option('--type <type>', 'site type: saas, ecommerce, local, blog, marketplace')
|
|
28
|
+
.action(initCommand);
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command('analyze')
|
|
32
|
+
.description('Run citation analysis and write back _analysis blocks')
|
|
33
|
+
.option('--page <url>', 'analyze a specific page only')
|
|
34
|
+
.option('--intent <category>', 'analyze a specific intent category only')
|
|
35
|
+
.action(analyzeCommand);
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command('sync')
|
|
39
|
+
.description('Sync latest platform intelligence to your SEO.md files')
|
|
40
|
+
.option('--dry-run', 'preview changes without writing')
|
|
41
|
+
.action(syncCommand);
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command('status')
|
|
45
|
+
.description('Show current citation rates and gap scores')
|
|
46
|
+
.option('--json', 'output as JSON')
|
|
47
|
+
.action(statusCommand);
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command('validate')
|
|
51
|
+
.description('Validate your SEO.md against the spec')
|
|
52
|
+
.action(validateCommand);
|
|
53
|
+
|
|
54
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seomd-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The official CLI for the SEO.md open standard — AEO infrastructure for technical founders",
|
|
5
|
+
"homepage": "https://seomd.dev",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/seomd/seomd-cli"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"seomd": "./bin/seomd.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "node bin/seomd.js",
|
|
17
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
18
|
+
"lint": "eslint src/**/*.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.3.0",
|
|
22
|
+
"commander": "^11.0.0",
|
|
23
|
+
"enquirer": "^2.4.1",
|
|
24
|
+
"ora": "^7.0.1",
|
|
25
|
+
"yaml": "^2.3.4",
|
|
26
|
+
"fs-extra": "^11.1.1",
|
|
27
|
+
"axios": "^1.6.0",
|
|
28
|
+
"dotenv": "^16.3.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"jest": "^29.7.0",
|
|
32
|
+
"eslint": "^8.50.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"seo",
|
|
39
|
+
"aeo",
|
|
40
|
+
"geo",
|
|
41
|
+
"seomd",
|
|
42
|
+
"ai-search",
|
|
43
|
+
"citation-tracking",
|
|
44
|
+
"llm-seo"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
import { parseSeoMd } from '../utils/parser.js';
|
|
7
|
+
import { client } from '../utils/api-client.js';
|
|
8
|
+
import { writeAnalysisToSeoMd, writeReverseMd, writePageAnalysis } from '../utils/writeback.js';
|
|
9
|
+
|
|
10
|
+
dotenv.config();
|
|
11
|
+
|
|
12
|
+
export async function analyzeCommand(options) {
|
|
13
|
+
const apiKey = process.env.SEOMD_API_KEY;
|
|
14
|
+
const paymentToken = process.env.SEOMD_PAYMENT_TOKEN;
|
|
15
|
+
|
|
16
|
+
if (!apiKey && !paymentToken) {
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log(chalk.yellow('⚠ No API key or payment token found.'));
|
|
19
|
+
console.log('');
|
|
20
|
+
console.log('Add your API key to .env:');
|
|
21
|
+
console.log(chalk.cyan(' SEOMD_API_KEY=your_key_here'));
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log('Get your API key at ' + chalk.cyan('https://seomd.dev/connect'));
|
|
24
|
+
console.log('');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
let doc, data;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const parsed = await parseSeoMd(cwd);
|
|
33
|
+
doc = parsed.doc;
|
|
34
|
+
data = parsed.data;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.log(chalk.red(`\n❌ Error: ${err.message}`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const domain = data.site?.domain;
|
|
41
|
+
if (!domain) {
|
|
42
|
+
console.log(chalk.red('\n❌ Error: "site.domain" is required in SEO.md.'));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const niche = data.site?.type || 'saas';
|
|
47
|
+
|
|
48
|
+
// Extract intent queries
|
|
49
|
+
const queries = {};
|
|
50
|
+
if (data.intent) {
|
|
51
|
+
for (const [intentType, obj] of Object.entries(data.intent)) {
|
|
52
|
+
if (intentType !== '_analysis' && obj && Array.isArray(obj.queries)) {
|
|
53
|
+
queries[intentType] = obj.queries;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Extract pages
|
|
59
|
+
let pagesList = [];
|
|
60
|
+
if (data.pages) {
|
|
61
|
+
if (Array.isArray(data.pages.required)) {
|
|
62
|
+
pagesList = pagesList.concat(data.pages.required);
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(data.pages.optional)) {
|
|
65
|
+
pagesList = pagesList.concat(data.pages.optional);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Default to homepage if no pages defined
|
|
70
|
+
if (pagesList.length === 0) {
|
|
71
|
+
pagesList.push({
|
|
72
|
+
id: 'homepage',
|
|
73
|
+
url: '/',
|
|
74
|
+
primary_keyword: `best ${niche}`,
|
|
75
|
+
status: 'planned'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extract engines
|
|
80
|
+
const engines = data.aeo?._analysis?.engines_tracked || ['Claude', 'ChatGPT'];
|
|
81
|
+
|
|
82
|
+
console.log(chalk.bold.cyan(`\n📊 foxcite: Running AI Search Audit for ${chalk.white(domain)}`));
|
|
83
|
+
console.log(chalk.dim(`Engines: ${engines.join(', ')}`));
|
|
84
|
+
console.log(chalk.dim(`Pages to scan: ${pagesList.length}`));
|
|
85
|
+
console.log('');
|
|
86
|
+
|
|
87
|
+
const spinner = ora('Initializing scan sessions...').start();
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const payload = {
|
|
91
|
+
domain,
|
|
92
|
+
niche,
|
|
93
|
+
queries,
|
|
94
|
+
engines,
|
|
95
|
+
pages: pagesList.map(p => ({
|
|
96
|
+
id: p.id,
|
|
97
|
+
url: p.url,
|
|
98
|
+
primary_keyword: p.primary_keyword,
|
|
99
|
+
status: p.status
|
|
100
|
+
}))
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
spinner.text = 'Scanning AI search engines and compiling citations (this may take up to a minute)...';
|
|
104
|
+
|
|
105
|
+
const response = await client.post('/cli/analyze', payload);
|
|
106
|
+
const results = response.data;
|
|
107
|
+
|
|
108
|
+
spinner.text = 'Writing analysis blocks back to repository files...';
|
|
109
|
+
|
|
110
|
+
// Writeback to SEO.md
|
|
111
|
+
await writeAnalysisToSeoMd(doc, results, cwd);
|
|
112
|
+
|
|
113
|
+
// Writeback to SEO.REVERSE.md
|
|
114
|
+
await writeReverseMd(cwd, results);
|
|
115
|
+
|
|
116
|
+
// Writeback to .seomd/pages/*.md
|
|
117
|
+
await writePageAnalysis(cwd, results);
|
|
118
|
+
|
|
119
|
+
spinner.succeed(chalk.green('Analysis completed successfully!'));
|
|
120
|
+
console.log('');
|
|
121
|
+
|
|
122
|
+
// Display results summary
|
|
123
|
+
const aeo = results.aeo_analysis;
|
|
124
|
+
console.log(chalk.bold('--- Results Summary ---'));
|
|
125
|
+
console.log(`Overall Citation Rate : ${chalk.bold.green((aeo.overall_citation_rate * 100).toFixed(0) + '%')}`);
|
|
126
|
+
console.log(`Overall Gap Score : ${chalk.bold.red(aeo.overall_gap_score)}`);
|
|
127
|
+
|
|
128
|
+
if (results.credits_remaining !== null) {
|
|
129
|
+
console.log(`Credits Remaining : ${chalk.cyan(results.credits_remaining)}`);
|
|
130
|
+
}
|
|
131
|
+
console.log(`Last Analyzed : ${chalk.dim(aeo.last_analyzed)}`);
|
|
132
|
+
console.log(`Next Analysis Target : ${chalk.dim(aeo.next_analysis)}`);
|
|
133
|
+
console.log('-----------------------');
|
|
134
|
+
console.log('');
|
|
135
|
+
console.log(chalk.green('✔ SEO.md updated.'));
|
|
136
|
+
console.log(chalk.green('✔ SEO.REVERSE.md updated.'));
|
|
137
|
+
console.log(chalk.green('✔ .seomd/pages/ playbooks generated.'));
|
|
138
|
+
console.log('');
|
|
139
|
+
|
|
140
|
+
} catch (err) {
|
|
141
|
+
spinner.fail(chalk.red('Analysis failed'));
|
|
142
|
+
console.error(chalk.bold.red(`\nError: ${err.message}`));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import enquirer from 'enquirer';
|
|
3
|
+
const { prompt } = enquirer;
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { generateSeoMd } from '../generators/seomd.js';
|
|
8
|
+
import { generateReverseMd } from '../generators/reverse.js';
|
|
9
|
+
import { createSeomdDir } from '../generators/directory.js';
|
|
10
|
+
import { SITE_TYPES } from '../utils/constants.js';
|
|
11
|
+
|
|
12
|
+
export async function initCommand(options) {
|
|
13
|
+
console.log('');
|
|
14
|
+
console.log(chalk.bold('SEO.md') + chalk.dim(' v0.1.0 — https://seomd.dev'));
|
|
15
|
+
console.log('');
|
|
16
|
+
console.log(chalk.dim('The open standard for AI-era SEO configuration.'));
|
|
17
|
+
console.log('');
|
|
18
|
+
|
|
19
|
+
// Check if SEO.md already exists
|
|
20
|
+
const seomdPath = path.join(process.cwd(), 'SEO.md');
|
|
21
|
+
if (await fs.pathExists(seomdPath)) {
|
|
22
|
+
console.log(chalk.yellow('⚠ SEO.md already exists in this directory.'));
|
|
23
|
+
const { overwrite } = await prompt({
|
|
24
|
+
type: 'confirm',
|
|
25
|
+
name: 'overwrite',
|
|
26
|
+
message: 'Overwrite existing SEO.md?',
|
|
27
|
+
initial: false,
|
|
28
|
+
});
|
|
29
|
+
if (!overwrite) {
|
|
30
|
+
console.log(chalk.dim('Aborted.'));
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let answers;
|
|
36
|
+
|
|
37
|
+
if (options.yes) {
|
|
38
|
+
// Default values for --yes flag
|
|
39
|
+
answers = {
|
|
40
|
+
site_type: options.type || 'saas',
|
|
41
|
+
domain: 'example.com',
|
|
42
|
+
brand: 'My Brand',
|
|
43
|
+
primary_keyword: '',
|
|
44
|
+
competitors: '',
|
|
45
|
+
};
|
|
46
|
+
} else {
|
|
47
|
+
// The 5-question init flow
|
|
48
|
+
answers = await prompt([
|
|
49
|
+
{
|
|
50
|
+
type: 'select',
|
|
51
|
+
name: 'site_type',
|
|
52
|
+
message: 'Site type:',
|
|
53
|
+
choices: SITE_TYPES.map(t => ({ name: t.value, message: t.name })),
|
|
54
|
+
initial: options.type ? SITE_TYPES.findIndex(t => t.value === options.type) : 0,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'input',
|
|
58
|
+
name: 'domain',
|
|
59
|
+
message: 'Primary domain:',
|
|
60
|
+
hint: 'e.g. myapp.com',
|
|
61
|
+
validate(value) {
|
|
62
|
+
if (!value) return 'Domain is required';
|
|
63
|
+
// Strip protocol if provided
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
result(value) {
|
|
67
|
+
return value.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'input',
|
|
72
|
+
name: 'brand',
|
|
73
|
+
message: 'Brand name:',
|
|
74
|
+
hint: 'e.g. MyApp',
|
|
75
|
+
validate(value) {
|
|
76
|
+
if (!value) return 'Brand name is required';
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: 'input',
|
|
82
|
+
name: 'primary_keyword',
|
|
83
|
+
message: 'Primary keyword:',
|
|
84
|
+
hint: 'e.g. project management software',
|
|
85
|
+
validate(value) {
|
|
86
|
+
if (!value) return 'Primary keyword is required';
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'input',
|
|
92
|
+
name: 'competitors',
|
|
93
|
+
message: 'Top 3 competitors (comma separated):',
|
|
94
|
+
hint: 'e.g. asana.com, monday.com, notion.so',
|
|
95
|
+
result(value) {
|
|
96
|
+
return value
|
|
97
|
+
.split(',')
|
|
98
|
+
.map(c => c.trim())
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.slice(0, 3);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
const spinner = ora('Scaffolding your SEO.md files...').start();
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// 1. Generate SEO.md
|
|
111
|
+
const seomdContent = generateSeoMd(answers);
|
|
112
|
+
await fs.writeFile(seomdPath, seomdContent, 'utf8');
|
|
113
|
+
spinner.succeed(chalk.green('SEO.md created'));
|
|
114
|
+
|
|
115
|
+
// 2. Generate SEO.REVERSE.md
|
|
116
|
+
const reversePath = path.join(process.cwd(), 'SEO.REVERSE.md');
|
|
117
|
+
const reverseContent = generateReverseMd(answers);
|
|
118
|
+
await fs.writeFile(reversePath, reverseContent, 'utf8');
|
|
119
|
+
spinner.succeed(chalk.green('SEO.REVERSE.md initialized'));
|
|
120
|
+
|
|
121
|
+
// 3. Create .seomd/ directory structure
|
|
122
|
+
await createSeomdDir(process.cwd(), answers);
|
|
123
|
+
spinner.succeed(chalk.green('.seomd/ directory created'));
|
|
124
|
+
|
|
125
|
+
// 4. Add .seomd/ to .gitignore if it exists
|
|
126
|
+
await updateGitignore(process.cwd());
|
|
127
|
+
|
|
128
|
+
console.log('');
|
|
129
|
+
console.log(chalk.bold.green('✓ SEO.md initialized successfully'));
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(chalk.dim('Files created:'));
|
|
132
|
+
console.log(' ' + chalk.cyan('SEO.md') + chalk.dim(' — your living SEO config'));
|
|
133
|
+
console.log(' ' + chalk.cyan('SEO.REVERSE.md') + chalk.dim(' — reverse engineer output (platform generated)'));
|
|
134
|
+
console.log(' ' + chalk.cyan('.seomd/') + chalk.dim(' — intelligence directory'));
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(chalk.dim('Next steps:'));
|
|
137
|
+
console.log(' ' + chalk.white('npx seomd analyze') + chalk.dim(' — run your first citation analysis'));
|
|
138
|
+
console.log(' ' + chalk.white('npx seomd status') + chalk.dim(' — view current gap scores'));
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(chalk.dim('Connect your platform at ') + chalk.cyan('https://seomd.dev/connect'));
|
|
141
|
+
console.log('');
|
|
142
|
+
|
|
143
|
+
} catch (err) {
|
|
144
|
+
spinner.fail(chalk.red('Failed to scaffold SEO.md'));
|
|
145
|
+
console.error(chalk.dim(err.message));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function updateGitignore(cwd) {
|
|
151
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
152
|
+
const entry = '\n# seomd intelligence directory\n.seomd/reports/\n';
|
|
153
|
+
|
|
154
|
+
if (await fs.pathExists(gitignorePath)) {
|
|
155
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
156
|
+
if (!content.includes('.seomd')) {
|
|
157
|
+
await fs.appendFile(gitignorePath, entry);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { parseSeoMd } from '../utils/parser.js';
|
|
3
|
+
|
|
4
|
+
export async function statusCommand(options) {
|
|
5
|
+
try {
|
|
6
|
+
const { data } = await parseSeoMd(process.cwd());
|
|
7
|
+
|
|
8
|
+
const aeoAnalysis = data.aeo?._analysis || {};
|
|
9
|
+
const intentAnalysis = data.intent?._analysis || {};
|
|
10
|
+
const pagesRequired = data.pages?.required || [];
|
|
11
|
+
const pagesAnalysis = data.pages?._analysis?.pages || [];
|
|
12
|
+
|
|
13
|
+
// Check if there is any analysis data
|
|
14
|
+
const hasOverallData = aeoAnalysis.overall_citation_rate !== null && aeoAnalysis.overall_citation_rate !== undefined;
|
|
15
|
+
|
|
16
|
+
if (!hasOverallData) {
|
|
17
|
+
if (options.json) {
|
|
18
|
+
console.log(JSON.stringify({ status: "no_data", message: "No analysis data found" }, null, 2));
|
|
19
|
+
} else {
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log(chalk.yellow('⚠ No analysis data found in SEO.md.'));
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log('To populate analysis data:');
|
|
24
|
+
console.log(` 1. Get an API key at ${chalk.cyan('https://seomd.dev/connect')}`);
|
|
25
|
+
console.log(' 2. Add it to your .env file:');
|
|
26
|
+
console.log(chalk.cyan(' SEOMD_API_KEY=your_key_here'));
|
|
27
|
+
console.log(' 3. Run citation analysis:');
|
|
28
|
+
console.log(chalk.white(' npx seomd analyze'));
|
|
29
|
+
console.log('');
|
|
30
|
+
}
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Format overall metrics
|
|
35
|
+
const overallCitation = formatPercentage(aeoAnalysis.overall_citation_rate);
|
|
36
|
+
const overallGap = formatScore(aeoAnalysis.overall_gap_score);
|
|
37
|
+
|
|
38
|
+
if (options.json) {
|
|
39
|
+
// Output structured JSON
|
|
40
|
+
const output = {
|
|
41
|
+
site: {
|
|
42
|
+
type: data.site?.type || null,
|
|
43
|
+
domain: data.site?.domain || null,
|
|
44
|
+
brand: data.identity?.brand || null,
|
|
45
|
+
},
|
|
46
|
+
overall: {
|
|
47
|
+
citation_rate: aeoAnalysis.overall_citation_rate,
|
|
48
|
+
gap_score: aeoAnalysis.overall_gap_score,
|
|
49
|
+
last_analyzed: aeoAnalysis.last_analyzed || null,
|
|
50
|
+
},
|
|
51
|
+
intent: {},
|
|
52
|
+
pages: []
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const categories = ['informational', 'comparison', 'transactional', 'reputational', 'category'];
|
|
56
|
+
categories.forEach(cat => {
|
|
57
|
+
const catData = intentAnalysis[cat] || {};
|
|
58
|
+
const declared = data.intent?.[cat] || {};
|
|
59
|
+
output.intent[cat] = {
|
|
60
|
+
priority: declared.priority || null,
|
|
61
|
+
citation_rate: catData.citation_rate || null,
|
|
62
|
+
gap_score: catData.gap_score || null,
|
|
63
|
+
trend: catData.trend || null
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pagesRequired.forEach(req => {
|
|
68
|
+
const anal = pagesAnalysis.find(p => p.id === req.id) || {};
|
|
69
|
+
output.pages.push({
|
|
70
|
+
id: req.id,
|
|
71
|
+
url: req.url,
|
|
72
|
+
status: req.status || 'planned',
|
|
73
|
+
priority: req.priority || 0,
|
|
74
|
+
citation_rate: anal.citation_rate !== undefined ? anal.citation_rate : null,
|
|
75
|
+
gap_score: anal.gap_score !== undefined ? anal.gap_score : null,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log(JSON.stringify(output, null, 2));
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Output beautiful terminal dashboard
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(chalk.bold('SEO.md Status Dashboard') + chalk.dim(` — ${data.identity?.brand || 'Brand'} (${data.site?.domain || 'domain'})`));
|
|
86
|
+
console.log(chalk.dim(`Last analyzed: ${aeoAnalysis.last_analyzed || 'N/A'}`));
|
|
87
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
88
|
+
|
|
89
|
+
// Overall block
|
|
90
|
+
console.log(`Overall Citation Rate: ${colorCitation(aeoAnalysis.overall_citation_rate, overallCitation)}`);
|
|
91
|
+
console.log(`Overall Gap Score: ${colorGap(aeoAnalysis.overall_gap_score, overallGap)}`);
|
|
92
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
93
|
+
|
|
94
|
+
// Intent Categories Table
|
|
95
|
+
console.log(chalk.bold('INTENT CATEGORY SUMMARY'));
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(formatRow('Category', 'Priority', 'Citation Rate', 'Gap Score', 'Trend'));
|
|
98
|
+
console.log(chalk.dim('─'.repeat(65)));
|
|
99
|
+
|
|
100
|
+
const categories = ['informational', 'comparison', 'transactional', 'reputational', 'category'];
|
|
101
|
+
categories.forEach(cat => {
|
|
102
|
+
const declared = data.intent?.[cat] || {};
|
|
103
|
+
const anal = intentAnalysis[cat] || {};
|
|
104
|
+
|
|
105
|
+
const name = cat.charAt(0).toUpperCase() + cat.slice(1);
|
|
106
|
+
const priority = declared.priority || 'medium';
|
|
107
|
+
const citationVal = anal.citation_rate;
|
|
108
|
+
const gapVal = anal.gap_score;
|
|
109
|
+
const trend = anal.trend || '-';
|
|
110
|
+
|
|
111
|
+
const citationStr = colorCitation(citationVal, formatPercentage(citationVal));
|
|
112
|
+
const gapStr = colorGap(gapVal, formatScore(gapVal));
|
|
113
|
+
|
|
114
|
+
console.log(formatRow(name, priority, citationStr, gapStr, trend));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
console.log(chalk.dim('─'.repeat(65)));
|
|
118
|
+
console.log('');
|
|
119
|
+
|
|
120
|
+
// Pages Table
|
|
121
|
+
console.log(chalk.bold('PAGE ANALYSIS SUMMARY'));
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log(formatRowPage('Page ID', 'URL', 'Status', 'Citation Rate', 'Gap Score'));
|
|
124
|
+
console.log(chalk.dim('─'.repeat(70)));
|
|
125
|
+
|
|
126
|
+
pagesRequired.forEach(req => {
|
|
127
|
+
const anal = pagesAnalysis.find(p => p.id === req.id) || {};
|
|
128
|
+
|
|
129
|
+
const id = req.id;
|
|
130
|
+
const url = req.url;
|
|
131
|
+
const status = req.status || 'planned';
|
|
132
|
+
const citationVal = anal.citation_rate;
|
|
133
|
+
const gapVal = anal.gap_score;
|
|
134
|
+
|
|
135
|
+
const citationStr = colorCitation(citationVal, formatPercentage(citationVal));
|
|
136
|
+
const gapStr = colorGap(gapVal, formatScore(gapVal));
|
|
137
|
+
|
|
138
|
+
console.log(formatRowPage(id, url, status, citationStr, gapStr));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log(chalk.dim('─'.repeat(70)));
|
|
142
|
+
console.log('');
|
|
143
|
+
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.log(chalk.red('✗ ') + chalk.bold('Failed to display status:'));
|
|
146
|
+
console.log(` ${chalk.dim(err.message)}`);
|
|
147
|
+
console.log('');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Helpers for string formatting/alignment
|
|
153
|
+
function formatRow(cat, pri, cit, gap, trend) {
|
|
154
|
+
return `${cat.padEnd(16)} │ ${pri.padEnd(10)} │ ${cit.padEnd(23)} │ ${gap.padEnd(19)} │ ${trend}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatRowPage(id, url, status, cit, gap) {
|
|
158
|
+
return `${id.padEnd(14)} │ ${url.padEnd(20)} │ ${status.padEnd(8)} │ ${cit.padEnd(23)} │ ${gap.padEnd(19)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatPercentage(val) {
|
|
162
|
+
if (val === null || val === undefined) return '-';
|
|
163
|
+
if (typeof val === 'number') {
|
|
164
|
+
return `${(val * 100).toFixed(0)}%`;
|
|
165
|
+
}
|
|
166
|
+
return String(val);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatScore(val) {
|
|
170
|
+
if (val === null || val === undefined) return '-';
|
|
171
|
+
return String(val);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function colorCitation(val, str) {
|
|
175
|
+
if (val === null || val === undefined) return chalk.dim(str);
|
|
176
|
+
const num = typeof val === 'number' ? val * 100 : parseFloat(val);
|
|
177
|
+
if (isNaN(num)) return chalk.dim(str);
|
|
178
|
+
|
|
179
|
+
if (num >= 20) return chalk.green.bold(str);
|
|
180
|
+
if (num >= 5) return chalk.yellow.bold(str);
|
|
181
|
+
return chalk.red.bold(str);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function colorGap(val, str) {
|
|
185
|
+
if (val === null || val === undefined) return chalk.dim(str);
|
|
186
|
+
const num = parseInt(val, 10);
|
|
187
|
+
if (isNaN(num)) return chalk.dim(str);
|
|
188
|
+
|
|
189
|
+
if (num < 30) return chalk.green.bold(str);
|
|
190
|
+
if (num <= 70) return chalk.yellow.bold(str);
|
|
191
|
+
return chalk.red.bold(str);
|
|
192
|
+
}
|