seo-intel 1.2.6 → 1.3.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.
- package/CHANGELOG.md +33 -0
- package/analyses/blog-draft/index.js +227 -0
- package/analyses/blog-draft/prescorer.js +60 -0
- package/cli.js +261 -70
- package/lib/gate.js +1 -0
- package/package.json +1 -1
- package/reports/generate-html.js +267 -4
- package/server.js +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.1 (2026-04-02)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
- **AI Citability Audit** now renders output in dashboard export viewer (was showing "No output")
|
|
7
|
+
- AEO command accepts `--format markdown|json|brief` for structured output
|
|
8
|
+
- Dashboard export viewer captures stderr — command errors are now visible instead of silent
|
|
9
|
+
|
|
10
|
+
### CI
|
|
11
|
+
- Added job-level timeout (15 min) — prevents 6-hour runaway jobs
|
|
12
|
+
- Cross-platform path handling — Windows CI no longer fails on backslash paths
|
|
13
|
+
- Playwright auto-installed for mock crawl test
|
|
14
|
+
- Step-level timeouts on crawl, setup wizard, and server tests
|
|
15
|
+
|
|
16
|
+
## 1.3.0 (2026-04-01)
|
|
17
|
+
|
|
18
|
+
### New Feature: AEO Blog Draft Generator
|
|
19
|
+
- `seo-intel blog-draft <project>` — generate AEO-optimised blog post drafts from Intelligence Ledger data
|
|
20
|
+
- Gathers keyword gaps, long-tails, citability insights, entities, and top citable pages
|
|
21
|
+
- Builds structured prompt with 10 AEO signal rules for maximum AI citability
|
|
22
|
+
- Pre-scores generated draft against AEO signals before publishing
|
|
23
|
+
- Options: `--topic`, `--lang en|fi`, `--model gemini|claude|gpt|deepseek`, `--save`
|
|
24
|
+
- Pro feature gated via Lemon Squeezy license
|
|
25
|
+
|
|
26
|
+
### Dashboard
|
|
27
|
+
- New "Create" section in export sidebar with interactive draft generator
|
|
28
|
+
- "Create a Draft" dropdown: select type (Blog Post / Documentation), topic, language, then generate
|
|
29
|
+
- "AI Citability Audit" button added to export sidebar — run AEO from dashboard
|
|
30
|
+
- Both `aeo` and `blog-draft` commands now available via dashboard terminal
|
|
31
|
+
|
|
32
|
+
### Server
|
|
33
|
+
- Added `aeo` and `blog-draft` to terminal command whitelist
|
|
34
|
+
- Forward `--topic`, `--lang`, `--model`, `--save` params from dashboard to CLI
|
|
35
|
+
|
|
3
36
|
## 1.2.6 (2026-03-31)
|
|
4
37
|
|
|
5
38
|
### Critical Fix
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AEO Blog Draft Generator — Data Gathering & Prompt Builder
|
|
3
|
+
*
|
|
4
|
+
* Pulls intelligence from the Ledger (keyword gaps, long-tails, citability gaps,
|
|
5
|
+
* entities, positioning) and builds a prompt that produces a publish-ready,
|
|
6
|
+
* AEO-optimised blog post in .md format with YAML frontmatter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getActiveInsights } from '../../db/db.js';
|
|
10
|
+
|
|
11
|
+
// ── Data Gathering ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export function gatherBlogDraftContext(db, project, topic = null) {
|
|
14
|
+
const insights = getActiveInsights(db, project);
|
|
15
|
+
|
|
16
|
+
// citability_gap insights — not in getActiveInsights grouped return
|
|
17
|
+
let citabilityGaps = [];
|
|
18
|
+
try {
|
|
19
|
+
citabilityGaps = db.prepare(
|
|
20
|
+
`SELECT data FROM insights WHERE project = ? AND type = 'citability_gap' AND status = 'active' ORDER BY last_seen DESC LIMIT 15`
|
|
21
|
+
).all(project).map(r => JSON.parse(r.data));
|
|
22
|
+
} catch { /* table may not exist yet */ }
|
|
23
|
+
|
|
24
|
+
// Top entities across target pages
|
|
25
|
+
let entityRows = [];
|
|
26
|
+
try {
|
|
27
|
+
entityRows = db.prepare(`
|
|
28
|
+
SELECT e.primary_entities, p.title, p.url
|
|
29
|
+
FROM extractions e
|
|
30
|
+
JOIN pages p ON p.id = e.page_id
|
|
31
|
+
JOIN domains d ON d.id = p.domain_id
|
|
32
|
+
WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned')
|
|
33
|
+
AND e.primary_entities IS NOT NULL AND e.primary_entities != '[]'
|
|
34
|
+
ORDER BY p.word_count DESC LIMIT 20
|
|
35
|
+
`).all(project);
|
|
36
|
+
} catch { /* extraction may not have run */ }
|
|
37
|
+
|
|
38
|
+
// Best AEO-scoring pages (content to emulate)
|
|
39
|
+
let topCitablePages = [];
|
|
40
|
+
try {
|
|
41
|
+
topCitablePages = db.prepare(`
|
|
42
|
+
SELECT p.url, p.title, cs.total_score as score, cs.ai_intents, cs.tier
|
|
43
|
+
FROM citability_scores cs
|
|
44
|
+
JOIN pages p ON p.id = cs.page_id
|
|
45
|
+
JOIN domains d ON d.id = p.domain_id
|
|
46
|
+
WHERE d.project = ? AND (d.role = 'target' OR d.role = 'owned') AND cs.total_score >= 55
|
|
47
|
+
ORDER BY cs.total_score DESC LIMIT 5
|
|
48
|
+
`).all(project);
|
|
49
|
+
} catch { /* AEO may not have run */ }
|
|
50
|
+
|
|
51
|
+
// Filter by topic if given
|
|
52
|
+
const matchesTopic = (text) => {
|
|
53
|
+
if (!topic || !text) return true;
|
|
54
|
+
return text.toLowerCase().includes(topic.toLowerCase());
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const kwInventor = insights.keyword_inventor
|
|
58
|
+
.filter(k => matchesTopic(k.phrase) || matchesTopic(k.cluster))
|
|
59
|
+
.slice(0, 30);
|
|
60
|
+
|
|
61
|
+
const longTails = topic
|
|
62
|
+
? [
|
|
63
|
+
...insights.long_tails.filter(lt => matchesTopic(lt.phrase)).slice(0, 20),
|
|
64
|
+
...insights.long_tails.filter(lt => !matchesTopic(lt.phrase)).slice(0, 10),
|
|
65
|
+
]
|
|
66
|
+
: insights.long_tails.slice(0, 30);
|
|
67
|
+
|
|
68
|
+
const keywordGaps = topic
|
|
69
|
+
? [
|
|
70
|
+
...insights.keyword_gaps.filter(kg => matchesTopic(kg.keyword)).slice(0, 15),
|
|
71
|
+
...insights.keyword_gaps.filter(kg => !matchesTopic(kg.keyword)).slice(0, 10),
|
|
72
|
+
]
|
|
73
|
+
: insights.keyword_gaps.filter(kg => kg.priority === 'high').slice(0, 25);
|
|
74
|
+
|
|
75
|
+
const contentGaps = (insights.content_gaps || []).slice(0, 8);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
insights,
|
|
79
|
+
citabilityGaps,
|
|
80
|
+
entityRows,
|
|
81
|
+
topCitablePages,
|
|
82
|
+
kwInventor,
|
|
83
|
+
longTails,
|
|
84
|
+
keywordGaps,
|
|
85
|
+
contentGaps,
|
|
86
|
+
topic,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Prompt Builder ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export function buildBlogDraftPrompt(context, { config, lang = 'en', topic = null }) {
|
|
93
|
+
const { longTails, keywordGaps, citabilityGaps, entityRows, topCitablePages, kwInventor, contentGaps, insights } = context;
|
|
94
|
+
const isFi = lang === 'fi';
|
|
95
|
+
|
|
96
|
+
// Extract unique entities from extraction data
|
|
97
|
+
const allEntities = new Set();
|
|
98
|
+
for (const row of entityRows) {
|
|
99
|
+
try {
|
|
100
|
+
const ents = JSON.parse(row.primary_entities);
|
|
101
|
+
if (Array.isArray(ents)) ents.forEach(e => allEntities.add(typeof e === 'string' ? e : e.name || e));
|
|
102
|
+
} catch { /* skip */ }
|
|
103
|
+
}
|
|
104
|
+
const topEntities = [...allEntities].slice(0, 15);
|
|
105
|
+
|
|
106
|
+
// ── Section 1: Role ──
|
|
107
|
+
let prompt = `You are an expert content strategist and copywriter specialising in AEO (Answer Engine Optimisation).
|
|
108
|
+
|
|
109
|
+
Your task: write a complete, publish-ready blog post draft in ${isFi ? 'Finnish' : 'English'}.
|
|
110
|
+
The post must score 70+ on the AEO citability scale (entity authority, structured claims, answer density, Q&A proximity, freshness signals, schema coverage).
|
|
111
|
+
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// ── Section 2: Site intelligence ──
|
|
115
|
+
prompt += `## Site Context
|
|
116
|
+
|
|
117
|
+
- **Site:** ${config.context?.siteName || config.target?.domain} (${config.target?.url})
|
|
118
|
+
- **Industry:** ${config.context?.industry || 'N/A'}
|
|
119
|
+
- **Audience:** ${config.context?.audience || 'N/A'}
|
|
120
|
+
- **Goal:** ${config.context?.goal || 'N/A'}
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
if (insights.positioning) {
|
|
124
|
+
prompt += `- **Positioning:** ${typeof insights.positioning === 'string' ? insights.positioning : JSON.stringify(insights.positioning)}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (topEntities.length) {
|
|
128
|
+
prompt += `- **Core entities:** ${topEntities.join(', ')}\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (topCitablePages.length) {
|
|
132
|
+
prompt += `\n### Highest-scoring pages on the site (emulate their structure)\n`;
|
|
133
|
+
for (const p of topCitablePages) {
|
|
134
|
+
prompt += `- ${p.url} — AEO score: ${p.score}/100 (${p.tier})\n`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Section 3: Topic focus ──
|
|
139
|
+
prompt += `\n## Topic\n\n`;
|
|
140
|
+
if (topic) {
|
|
141
|
+
prompt += `Primary focus: **${topic}**. All keyword and gap data below has been filtered to this topic. Build the entire post around this subject.\n`;
|
|
142
|
+
} else {
|
|
143
|
+
prompt += `Select the highest-opportunity topic from the gaps below. Choose the gap that: (a) has the most keyword_gap entries or (b) is flagged as a high priority long-tail. Explain your topic choice in the frontmatter \`topic_selection_rationale\` field.\n`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Section 4: Intelligence data ──
|
|
147
|
+
if (keywordGaps.length) {
|
|
148
|
+
prompt += `\n## Keyword Gaps to Target (include these as primary/secondary keywords)\n\n`;
|
|
149
|
+
prompt += `| Keyword | Priority | Notes |\n|---|---|---|\n`;
|
|
150
|
+
for (const kg of keywordGaps) {
|
|
151
|
+
prompt += `| ${kg.keyword || kg.phrase || '—'} | ${kg.priority || 'medium'} | ${(kg.notes || '').slice(0, 80)} |\n`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (longTails.length) {
|
|
156
|
+
prompt += `\n## Long-tail Phrases to Answer (each should have a direct answer in the post)\n\n`;
|
|
157
|
+
prompt += `| Phrase | Intent | Priority |\n|---|---|---|\n`;
|
|
158
|
+
for (const lt of longTails) {
|
|
159
|
+
prompt += `| ${lt.phrase || '—'} | ${lt.intent || '—'} | ${lt.priority || 'medium'} |\n`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (kwInventor.length) {
|
|
164
|
+
prompt += `\n## Keyword Inventor Phrases (weave these naturally into headings/body)\n\n`;
|
|
165
|
+
for (const kw of kwInventor.slice(0, 20)) {
|
|
166
|
+
prompt += `- "${kw.phrase}" (${kw.type || 'traditional'}, ${kw.intent || '—'})\n`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (citabilityGaps.length) {
|
|
171
|
+
prompt += `\n## Citability Gaps (pages scoring <60 on AEO — model the fix in this post)\n\n`;
|
|
172
|
+
prompt += `| URL | Score | Weakest Signals |\n|---|---|---|\n`;
|
|
173
|
+
for (const cg of citabilityGaps) {
|
|
174
|
+
prompt += `| ${cg.url || '—'} | ${cg.score || '—'} | ${cg.weakest || cg.weakest_signal || '—'} |\n`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (contentGaps.length) {
|
|
179
|
+
prompt += `\n## Content Gaps (topics competitors cover that you don't)\n\n`;
|
|
180
|
+
for (const cg of contentGaps) {
|
|
181
|
+
const desc = typeof cg === 'string' ? cg : (cg.topic || cg.description || cg.gap || JSON.stringify(cg));
|
|
182
|
+
prompt += `- ${desc}\n`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Section 5: AEO structural requirements ──
|
|
187
|
+
prompt += `
|
|
188
|
+
## AEO Structural Requirements
|
|
189
|
+
|
|
190
|
+
The draft MUST include:
|
|
191
|
+
1. YAML frontmatter with: title, slug, description (155 chars max), primary_keyword, secondary_keywords[], date (${new Date().toISOString().slice(0, 10)}), updated (same), lang (${lang}), tags[]${!topic ? ', topic_selection_rationale' : ''}
|
|
192
|
+
2. An H1 that contains the primary keyword
|
|
193
|
+
3. A 2-3 sentence summary immediately after the H1 (answer-first structure — inverted pyramid). This paragraph will be cited by AI assistants.
|
|
194
|
+
4. Minimum 6 H2 subheadings
|
|
195
|
+
5. At least 3 H2s phrased as direct questions (What is / How to / Why / When)
|
|
196
|
+
6. At least one numbered or bulleted list with 4+ items
|
|
197
|
+
7. At least one "X is Y because Z" definitional sentence per major concept
|
|
198
|
+
8. A FAQ section at the end with minimum 4 Q&A pairs (### H3 questions, 2-4 sentence answers)
|
|
199
|
+
9. A closing CTA paragraph referencing ${config.context?.siteName || config.target?.domain}
|
|
200
|
+
10. Word count: 1,200-2,000 words
|
|
201
|
+
11. Internal link suggestions: include 2-3 \`[anchor text](URL)\` links back to the site where natural
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
// ── Section 6: Language ──
|
|
205
|
+
if (isFi) {
|
|
206
|
+
prompt += `
|
|
207
|
+
## Language: Finnish
|
|
208
|
+
|
|
209
|
+
Write in Finnish. Use informal, direct register (sinuttelu where natural). Avoid marketing clichés common in Finnish B2B copy. Prefer short sentences. Finnish SEO keywords must appear in their exact searched base form in headings — Finnish inflection reduces exact-match keyword presence.
|
|
210
|
+
`;
|
|
211
|
+
} else {
|
|
212
|
+
prompt += `
|
|
213
|
+
## Language: English
|
|
214
|
+
|
|
215
|
+
Write in clear, direct international English. No filler phrases. No "in today's digital landscape" or "it's no secret that" openers. Every sentence should contain a fact, insight, or actionable point.
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Section 7: Output format ──
|
|
220
|
+
prompt += `
|
|
221
|
+
## Output Format
|
|
222
|
+
|
|
223
|
+
Respond with ONLY the complete markdown document. Start with --- (YAML frontmatter open fence). End with the FAQ section and CTA. No explanation before or after. No triple backticks wrapping the response.
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
return prompt;
|
|
227
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AEO Pre-Scorer — scores a generated markdown draft against citability signals
|
|
3
|
+
*
|
|
4
|
+
* Uses the same scorePage() function as the full AEO audit, but constructs
|
|
5
|
+
* synthetic inputs from the markdown text instead of reading from the DB.
|
|
6
|
+
*
|
|
7
|
+
* Freshness always scores 0 (no publish date yet) — the reported score
|
|
8
|
+
* accounts for this by adding +10 for "what it will score once published."
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { scorePage } from '../aeo/scorer.js';
|
|
12
|
+
|
|
13
|
+
export function prescore(markdownText) {
|
|
14
|
+
// Strip YAML frontmatter
|
|
15
|
+
const bodyMatch = markdownText.match(/^---[\s\S]*?---\n([\s\S]*)$/);
|
|
16
|
+
const body = bodyMatch ? bodyMatch[1] : markdownText;
|
|
17
|
+
|
|
18
|
+
// Extract headings
|
|
19
|
+
const headings = [];
|
|
20
|
+
for (const line of body.split('\n')) {
|
|
21
|
+
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
|
22
|
+
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Word count
|
|
26
|
+
const wordCount = body.split(/\s+/).filter(Boolean).length;
|
|
27
|
+
|
|
28
|
+
// Extract frontmatter fields
|
|
29
|
+
let fmSchemaType = null;
|
|
30
|
+
const fmMatch = markdownText.match(/^---([\s\S]*?)---/);
|
|
31
|
+
if (fmMatch) {
|
|
32
|
+
const schemaLine = fmMatch[1].match(/schema_type:\s*(.+)/);
|
|
33
|
+
if (schemaLine) fmSchemaType = schemaLine[1].trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build synthetic page object
|
|
37
|
+
const syntheticPage = {
|
|
38
|
+
body_text: body,
|
|
39
|
+
word_count: wordCount,
|
|
40
|
+
published_date: null, // not published yet — freshness = 0
|
|
41
|
+
modified_date: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Extract entity candidates from headings (capitalised noun phrases)
|
|
45
|
+
const entityCandidates = headings
|
|
46
|
+
.filter(h => h.level <= 3)
|
|
47
|
+
.flatMap(h => h.text.match(/\b[A-ZÄÖÅ][a-zäöå]+(?:\s+[A-ZÄÖÅ][a-zäöå]+)*/g) || []);
|
|
48
|
+
const entities = [...new Set(entityCandidates)].slice(0, 8);
|
|
49
|
+
|
|
50
|
+
const schemaTypes = fmSchemaType ? [fmSchemaType] : [];
|
|
51
|
+
const schemas = [];
|
|
52
|
+
|
|
53
|
+
const result = scorePage(syntheticPage, headings, entities, schemaTypes, schemas, 'Informational');
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...result,
|
|
57
|
+
wordCount,
|
|
58
|
+
headingCount: headings.length,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/cli.js
CHANGED
|
@@ -3869,24 +3869,30 @@ program
|
|
|
3869
3869
|
.alias('citability')
|
|
3870
3870
|
.description('AI Citability Audit — score every page for how well AI assistants can cite it')
|
|
3871
3871
|
.option('--target-only', 'Only score target domain (skip competitors)')
|
|
3872
|
+
.option('--format <type>', 'Output format: brief or json', 'brief')
|
|
3872
3873
|
.option('--save', 'Save report to reports/')
|
|
3873
3874
|
.action(async (project, opts) => {
|
|
3874
3875
|
if (!requirePro('aeo')) return;
|
|
3875
3876
|
const db = getDb();
|
|
3876
3877
|
const config = loadConfig(project);
|
|
3878
|
+
const isBrief = opts.format !== 'json';
|
|
3877
3879
|
|
|
3878
|
-
|
|
3880
|
+
if (!isBrief) {
|
|
3881
|
+
// JSON mode — skip header
|
|
3882
|
+
} else {
|
|
3883
|
+
printAttackHeader('🤖 AEO — AI Citability Audit', project);
|
|
3884
|
+
}
|
|
3879
3885
|
|
|
3880
3886
|
const { runAeoAnalysis, persistAeoScores, upsertCitabilityInsights } = await import('./analyses/aeo/index.js');
|
|
3881
3887
|
|
|
3882
3888
|
const results = runAeoAnalysis(db, project, {
|
|
3883
3889
|
includeCompetitors: !opts.targetOnly,
|
|
3884
|
-
log: (msg) => console.log(chalk.gray(msg)),
|
|
3890
|
+
log: (msg) => isBrief ? console.log(chalk.gray(msg)) : null,
|
|
3885
3891
|
});
|
|
3886
3892
|
|
|
3887
3893
|
if (!results.target.length && !results.competitors.size) {
|
|
3888
|
-
console.log(chalk.yellow('\n ⚠️ No pages with body_text found.'));
|
|
3889
|
-
console.log(chalk.gray(' Run: seo-intel crawl ' + project + ' (crawl stores body text since v1.1.6)\n'));
|
|
3894
|
+
console.log(isBrief ? chalk.yellow('\n ⚠️ No pages with body_text found.') : 'No pages with body_text found.');
|
|
3895
|
+
console.log(isBrief ? chalk.gray(' Run: seo-intel crawl ' + project + ' (crawl stores body text since v1.1.6)\n') : 'Run: seo-intel crawl ' + project);
|
|
3890
3896
|
return;
|
|
3891
3897
|
}
|
|
3892
3898
|
|
|
@@ -3895,88 +3901,149 @@ program
|
|
|
3895
3901
|
upsertCitabilityInsights(db, project, results.target);
|
|
3896
3902
|
|
|
3897
3903
|
const { summary } = results;
|
|
3904
|
+
const { tierCounts } = summary;
|
|
3905
|
+
const worst = results.target.filter(r => r.score < 55).slice(0, 10);
|
|
3906
|
+
const best = results.target.filter(r => r.score >= 55).slice(-5).reverse();
|
|
3898
3907
|
|
|
3899
|
-
// ──
|
|
3900
|
-
|
|
3901
|
-
console.log(chalk.bold(' 📊 Citability Summary'));
|
|
3902
|
-
console.log('');
|
|
3908
|
+
// ── Markdown output (used by dashboard export viewer) ──
|
|
3909
|
+
if (opts.format === 'markdown') {
|
|
3903
3910
|
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
if (
|
|
3908
|
-
|
|
3909
|
-
|
|
3911
|
+
console.log('# AEO — AI Citability Audit\n');
|
|
3912
|
+
console.log(`## Summary\n`);
|
|
3913
|
+
console.log(`- **Target average:** ${summary.avgTargetScore}/100`);
|
|
3914
|
+
if (summary.competitorPages > 0) {
|
|
3915
|
+
console.log(`- **Competitor average:** ${summary.avgCompetitorScore}/100`);
|
|
3916
|
+
const delta = summary.scoreDelta;
|
|
3917
|
+
console.log(`- **Delta:** ${delta > 0 ? '+' : ''}${delta}`);
|
|
3918
|
+
}
|
|
3919
|
+
console.log(`- **Pages scored:** ${results.target.length}\n`);
|
|
3920
|
+
|
|
3921
|
+
console.log(`## Tier Breakdown\n`);
|
|
3922
|
+
console.log(`- Excellent (75+): ${tierCounts.excellent}`);
|
|
3923
|
+
console.log(`- Good (55-74): ${tierCounts.good}`);
|
|
3924
|
+
console.log(`- Needs work (35-54): ${tierCounts.needs_work}`);
|
|
3925
|
+
console.log(`- Poor (<35): ${tierCounts.poor}\n`);
|
|
3926
|
+
|
|
3927
|
+
if (summary.weakestSignals.length) {
|
|
3928
|
+
console.log(`## Weakest Signals\n`);
|
|
3929
|
+
for (const s of summary.weakestSignals) {
|
|
3930
|
+
const pct = Math.round(s.avg);
|
|
3931
|
+
const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5));
|
|
3932
|
+
console.log(`- **${s.signal}** ${bar} ${pct}/100`);
|
|
3933
|
+
}
|
|
3934
|
+
console.log('');
|
|
3935
|
+
}
|
|
3910
3936
|
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3937
|
+
if (worst.length) {
|
|
3938
|
+
console.log(`## Pages Needing Work\n`);
|
|
3939
|
+
for (const p of worst) {
|
|
3940
|
+
const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
|
|
3941
|
+
const weakest = Object.entries(p.breakdown)
|
|
3942
|
+
.sort(([, a], [, b]) => a - b)
|
|
3943
|
+
.slice(0, 2)
|
|
3944
|
+
.map(([k]) => k.replace(/_/g, ' '));
|
|
3945
|
+
console.log(`- **${path.slice(0, 60)}** — ${p.score}/100 (weak: ${weakest.join(', ')})`);
|
|
3946
|
+
}
|
|
3947
|
+
console.log('');
|
|
3948
|
+
}
|
|
3919
3949
|
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3950
|
+
if (best.length) {
|
|
3951
|
+
console.log(`## Top Citable Pages\n`);
|
|
3952
|
+
for (const p of best) {
|
|
3953
|
+
const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
|
|
3954
|
+
console.log(`- **${path.slice(0, 60)}** — ${p.score}/100 (${p.aiIntents.join(', ')})`);
|
|
3955
|
+
}
|
|
3956
|
+
console.log('');
|
|
3957
|
+
}
|
|
3927
3958
|
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3959
|
+
console.log(`## Actions\n`);
|
|
3960
|
+
if (tierCounts.poor > 0) {
|
|
3961
|
+
console.log(`1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`);
|
|
3962
|
+
}
|
|
3963
|
+
if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
|
|
3964
|
+
console.log(`2. Site-wide weakness: "${summary.weakestSignals[0].signal}" — systematically improve across all pages`);
|
|
3965
|
+
}
|
|
3966
|
+
if (summary.scoreDelta < 0) {
|
|
3967
|
+
console.log(`3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`);
|
|
3935
3968
|
}
|
|
3936
3969
|
console.log('');
|
|
3937
|
-
}
|
|
3970
|
+
} else if (opts.format === 'json') {
|
|
3971
|
+
// ── JSON output ──
|
|
3972
|
+
console.log(JSON.stringify({ summary, target: results.target, competitors: [...results.competitors.entries()] }, null, 2));
|
|
3973
|
+
} else {
|
|
3974
|
+
// ── Rich CLI output (default brief format) ──
|
|
3975
|
+
const scoreFmt = (s) => {
|
|
3976
|
+
if (s >= 75) return chalk.bold.green(s + '/100');
|
|
3977
|
+
if (s >= 55) return chalk.bold.yellow(s + '/100');
|
|
3978
|
+
if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
|
|
3979
|
+
return chalk.bold.red(s + '/100');
|
|
3980
|
+
};
|
|
3938
3981
|
|
|
3939
|
-
// ── Worst pages (actionable) ──
|
|
3940
|
-
const worst = results.target.filter(r => r.score < 55).slice(0, 10);
|
|
3941
|
-
if (worst.length) {
|
|
3942
|
-
console.log(chalk.bold.red(' ⚡ Pages Needing Work'));
|
|
3943
3982
|
console.log('');
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
console.log(
|
|
3983
|
+
console.log(chalk.bold(' 📊 Citability Summary'));
|
|
3984
|
+
console.log('');
|
|
3985
|
+
console.log(` Target average: ${scoreFmt(summary.avgTargetScore)}`);
|
|
3986
|
+
if (summary.competitorPages > 0) {
|
|
3987
|
+
console.log(` Competitor average: ${scoreFmt(summary.avgCompetitorScore)}`);
|
|
3988
|
+
const delta = summary.scoreDelta;
|
|
3989
|
+
const deltaStr = delta > 0 ? chalk.green(`+${delta}`) : delta < 0 ? chalk.red(`${delta}`) : chalk.gray('0');
|
|
3990
|
+
console.log(` Delta: ${deltaStr}`);
|
|
3952
3991
|
}
|
|
3953
3992
|
console.log('');
|
|
3954
|
-
}
|
|
3955
3993
|
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
console.log(chalk.
|
|
3994
|
+
console.log(` ${chalk.green('●')} Excellent (75+): ${tierCounts.excellent}`);
|
|
3995
|
+
console.log(` ${chalk.yellow('●')} Good (55-74): ${tierCounts.good}`);
|
|
3996
|
+
console.log(` ${chalk.hex('#ff8c00')('●')} Needs work (35-54): ${tierCounts.needs_work}`);
|
|
3997
|
+
console.log(` ${chalk.red('●')} Poor (<35): ${tierCounts.poor}`);
|
|
3960
3998
|
console.log('');
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
console.log(
|
|
3999
|
+
|
|
4000
|
+
if (summary.weakestSignals.length) {
|
|
4001
|
+
console.log(chalk.bold(' 🔍 Weakest Signals (target average)'));
|
|
4002
|
+
console.log('');
|
|
4003
|
+
for (const s of summary.weakestSignals) {
|
|
4004
|
+
const bar = '█'.repeat(Math.round(s.avg / 5)) + chalk.gray('░'.repeat(20 - Math.round(s.avg / 5)));
|
|
4005
|
+
console.log(` ${s.signal.padEnd(20)} ${bar} ${s.avg}/100`);
|
|
4006
|
+
}
|
|
4007
|
+
console.log('');
|
|
3964
4008
|
}
|
|
3965
|
-
console.log('');
|
|
3966
|
-
}
|
|
3967
4009
|
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
4010
|
+
if (worst.length) {
|
|
4011
|
+
console.log(chalk.bold.red(' ⚡ Pages Needing Work'));
|
|
4012
|
+
console.log('');
|
|
4013
|
+
for (const p of worst) {
|
|
4014
|
+
const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
|
|
4015
|
+
const weakest = Object.entries(p.breakdown)
|
|
4016
|
+
.sort(([, a], [, b]) => a - b)
|
|
4017
|
+
.slice(0, 2)
|
|
4018
|
+
.map(([k]) => k.replace(/_/g, ' '));
|
|
4019
|
+
console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))}`);
|
|
4020
|
+
console.log(chalk.gray(` Weak: ${weakest.join(', ')}`));
|
|
4021
|
+
}
|
|
4022
|
+
console.log('');
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
if (best.length) {
|
|
4026
|
+
console.log(chalk.bold.green(' ✨ Top Citable Pages'));
|
|
4027
|
+
console.log('');
|
|
4028
|
+
for (const p of best) {
|
|
4029
|
+
const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
|
|
4030
|
+
console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))} ${chalk.gray(p.aiIntents.join(', '))}`);
|
|
4031
|
+
}
|
|
4032
|
+
console.log('');
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
console.log(chalk.bold.green(' 💡 Actions:'));
|
|
4036
|
+
if (tierCounts.poor > 0) {
|
|
4037
|
+
console.log(chalk.green(` 1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`));
|
|
4038
|
+
}
|
|
4039
|
+
if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
|
|
4040
|
+
console.log(chalk.green(` 2. Site-wide weakness: "${summary.weakestSignals[0].signal}" — systematically improve across all pages`));
|
|
4041
|
+
}
|
|
4042
|
+
if (summary.scoreDelta < 0) {
|
|
4043
|
+
console.log(chalk.green(` 3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`));
|
|
4044
|
+
}
|
|
4045
|
+
console.log('');
|
|
3978
4046
|
}
|
|
3979
|
-
console.log('');
|
|
3980
4047
|
|
|
3981
4048
|
// ── Regenerate dashboard ──
|
|
3982
4049
|
try {
|
|
@@ -4002,6 +4069,130 @@ program
|
|
|
4002
4069
|
}
|
|
4003
4070
|
});
|
|
4004
4071
|
|
|
4072
|
+
// ── AEO BLOG DRAFT GENERATOR ─────────────────────────────────────────────
|
|
4073
|
+
|
|
4074
|
+
let _blogDraftModule;
|
|
4075
|
+
async function getBlogDraftModule() {
|
|
4076
|
+
if (!_blogDraftModule) _blogDraftModule = await import('./analyses/blog-draft/index.js');
|
|
4077
|
+
return _blogDraftModule;
|
|
4078
|
+
}
|
|
4079
|
+
let _prescorerModule;
|
|
4080
|
+
async function getPrescorerModule() {
|
|
4081
|
+
if (!_prescorerModule) _prescorerModule = await import('./analyses/blog-draft/prescorer.js');
|
|
4082
|
+
return _prescorerModule;
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
program
|
|
4086
|
+
.command('blog-draft <project>')
|
|
4087
|
+
.description('Generate an AEO-optimised blog post draft from Intelligence Ledger data')
|
|
4088
|
+
.option('--topic <keyword>', 'Focus the post on a specific topic')
|
|
4089
|
+
.option('--lang <code>', 'Language: en or fi', 'en')
|
|
4090
|
+
.option('--model <name>', 'Model to use for generation (gemini, claude, gpt, deepseek)', 'gemini')
|
|
4091
|
+
.option('--save', 'Save the generated draft to reports/')
|
|
4092
|
+
.action(async (project, opts) => {
|
|
4093
|
+
if (!requirePro('blog-draft')) return;
|
|
4094
|
+
const db = getDb();
|
|
4095
|
+
const config = loadConfig(project);
|
|
4096
|
+
|
|
4097
|
+
printAttackHeader('✍️ AEO Blog Draft Generator', project);
|
|
4098
|
+
|
|
4099
|
+
const { gatherBlogDraftContext, buildBlogDraftPrompt } = await getBlogDraftModule();
|
|
4100
|
+
const { prescore } = await getPrescorerModule();
|
|
4101
|
+
|
|
4102
|
+
// ── Gather intelligence ──
|
|
4103
|
+
console.log(chalk.gray(' Gathering intelligence from Ledger...'));
|
|
4104
|
+
const context = gatherBlogDraftContext(db, project, opts.topic || null);
|
|
4105
|
+
|
|
4106
|
+
const stats = {
|
|
4107
|
+
keywordGaps: context.keywordGaps.length,
|
|
4108
|
+
longTails: context.longTails.length,
|
|
4109
|
+
citabilityGaps: context.citabilityGaps.length,
|
|
4110
|
+
kwInventor: context.kwInventor.length,
|
|
4111
|
+
contentGaps: context.contentGaps.length,
|
|
4112
|
+
entities: context.entityRows.length,
|
|
4113
|
+
topCitable: context.topCitablePages.length,
|
|
4114
|
+
};
|
|
4115
|
+
|
|
4116
|
+
console.log(chalk.gray(` Keyword gaps: ${stats.keywordGaps} Long-tails: ${stats.longTails} Citability gaps: ${stats.citabilityGaps}`));
|
|
4117
|
+
console.log(chalk.gray(` Keyword inventor: ${stats.kwInventor} Content gaps: ${stats.contentGaps} Entities: ${stats.entities}`));
|
|
4118
|
+
|
|
4119
|
+
if (stats.keywordGaps + stats.longTails + stats.kwInventor === 0) {
|
|
4120
|
+
console.log(chalk.yellow('\n ⚠️ No intelligence data found in the Ledger.'));
|
|
4121
|
+
console.log(chalk.gray(' Run: seo-intel analyze ' + project + ' and seo-intel keywords ' + project + '\n'));
|
|
4122
|
+
return;
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
// ── Build prompt ──
|
|
4126
|
+
console.log(chalk.gray('\n Building AEO-optimised prompt...'));
|
|
4127
|
+
const prompt = buildBlogDraftPrompt(context, {
|
|
4128
|
+
config,
|
|
4129
|
+
lang: opts.lang,
|
|
4130
|
+
topic: opts.topic || null,
|
|
4131
|
+
});
|
|
4132
|
+
console.log(chalk.gray(` Prompt size: ${(prompt.length / 1024).toFixed(1)}KB`));
|
|
4133
|
+
|
|
4134
|
+
// ── Generate ──
|
|
4135
|
+
console.log(chalk.cyan(`\n 🚀 Generating draft via ${opts.model}...\n`));
|
|
4136
|
+
const draft = await callAnalysisModel(prompt, opts.model);
|
|
4137
|
+
|
|
4138
|
+
if (!draft) {
|
|
4139
|
+
console.log(chalk.red('\n ✗ Generation failed — no output from model.\n'));
|
|
4140
|
+
return;
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
// ── Pre-score ──
|
|
4144
|
+
console.log(chalk.gray(' Pre-scoring draft against AEO signals...'));
|
|
4145
|
+
const scoreResult = prescore(draft);
|
|
4146
|
+
const adjustedScore = Math.min(100, scoreResult.score + 10); // +10 for freshness on publish
|
|
4147
|
+
|
|
4148
|
+
const scoreFmt = (s) => {
|
|
4149
|
+
if (s >= 75) return chalk.bold.green(s + '/100');
|
|
4150
|
+
if (s >= 55) return chalk.bold.yellow(s + '/100');
|
|
4151
|
+
if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
|
|
4152
|
+
return chalk.bold.red(s + '/100');
|
|
4153
|
+
};
|
|
4154
|
+
|
|
4155
|
+
console.log('');
|
|
4156
|
+
console.log(chalk.bold(' 📊 Pre-Score Results'));
|
|
4157
|
+
console.log('');
|
|
4158
|
+
console.log(` AEO Score (raw): ${scoreFmt(scoreResult.score)}`);
|
|
4159
|
+
console.log(` AEO Score (published): ${scoreFmt(adjustedScore)} ${chalk.gray('(+10 freshness on publish)')}`);
|
|
4160
|
+
console.log(` Word count: ${scoreResult.wordCount}`);
|
|
4161
|
+
console.log(` Headings: ${scoreResult.headingCount}`);
|
|
4162
|
+
console.log(` Tier: ${scoreResult.tier}`);
|
|
4163
|
+
console.log('');
|
|
4164
|
+
|
|
4165
|
+
if (scoreResult.breakdown) {
|
|
4166
|
+
console.log(chalk.bold(' 🔍 Signal Breakdown'));
|
|
4167
|
+
console.log('');
|
|
4168
|
+
for (const [signal, value] of Object.entries(scoreResult.breakdown)) {
|
|
4169
|
+
const bar = '█'.repeat(Math.round(value / 5)) + chalk.gray('░'.repeat(20 - Math.round(value / 5)));
|
|
4170
|
+
console.log(` ${signal.replace(/_/g, ' ').padEnd(22)} ${bar} ${value}/100`);
|
|
4171
|
+
}
|
|
4172
|
+
console.log('');
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// ── Output ──
|
|
4176
|
+
if (opts.save) {
|
|
4177
|
+
const slug = opts.topic
|
|
4178
|
+
? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
|
|
4179
|
+
: 'auto';
|
|
4180
|
+
const filename = `${project}-blog-draft-${slug}-${Date.now()}.md`;
|
|
4181
|
+
const outPath = join(__dirname, 'reports', filename);
|
|
4182
|
+
writeFileSync(outPath, draft, 'utf8');
|
|
4183
|
+
console.log(chalk.bold.green(` ✅ Draft saved: ${outPath}`));
|
|
4184
|
+
console.log('');
|
|
4185
|
+
} else {
|
|
4186
|
+
console.log(chalk.bold(' 📝 Generated Draft'));
|
|
4187
|
+
console.log(chalk.dim('─'.repeat(62)));
|
|
4188
|
+
console.log(draft);
|
|
4189
|
+
console.log(chalk.dim('─'.repeat(62)));
|
|
4190
|
+
console.log('');
|
|
4191
|
+
console.log(chalk.gray(' Tip: add --save to write the draft to reports/'));
|
|
4192
|
+
console.log('');
|
|
4193
|
+
}
|
|
4194
|
+
});
|
|
4195
|
+
|
|
4005
4196
|
// ── GUIDE (Coach-style chapter map) ──────────────────────────────────────
|
|
4006
4197
|
program
|
|
4007
4198
|
.command('guide')
|
package/lib/gate.js
CHANGED
|
@@ -59,6 +59,7 @@ const FEATURE_NAMES = {
|
|
|
59
59
|
'competitive': 'Competitive Landscape Sections',
|
|
60
60
|
'unlimited-pages': 'Unlimited Crawl Pages',
|
|
61
61
|
'unlimited-projects': 'Unlimited Projects',
|
|
62
|
+
'blog-draft': 'AEO Blog Draft Generator',
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
// ── CLI Gate — blocks command and shows upgrade message ──────────────────────
|
package/package.json
CHANGED
package/reports/generate-html.js
CHANGED
|
@@ -1488,6 +1488,108 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
1488
1488
|
.export-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
|
|
1489
1489
|
.export-btn i { margin-right: 5px; font-size: 0.6rem; }
|
|
1490
1490
|
.export-btn.active { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(232,213,163,0.06); }
|
|
1491
|
+
.draft-dropdown { position: relative; }
|
|
1492
|
+
.draft-trigger { display: flex; align-items: center; width: 100%; }
|
|
1493
|
+
.draft-menu {
|
|
1494
|
+
display: none;
|
|
1495
|
+
position: absolute;
|
|
1496
|
+
top: calc(100% + 4px);
|
|
1497
|
+
left: 0; right: 0;
|
|
1498
|
+
background: #1a1a1a;
|
|
1499
|
+
border: 1px solid var(--accent-gold);
|
|
1500
|
+
border-radius: var(--radius);
|
|
1501
|
+
padding: 10px;
|
|
1502
|
+
z-index: 50;
|
|
1503
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
|
1504
|
+
}
|
|
1505
|
+
.draft-menu.open { display: block; }
|
|
1506
|
+
.draft-menu-section {
|
|
1507
|
+
font-size: 0.58rem;
|
|
1508
|
+
color: var(--text-muted);
|
|
1509
|
+
text-transform: uppercase;
|
|
1510
|
+
letter-spacing: 0.5px;
|
|
1511
|
+
margin-bottom: 5px;
|
|
1512
|
+
font-family: var(--font-body);
|
|
1513
|
+
}
|
|
1514
|
+
.draft-option {
|
|
1515
|
+
display: flex;
|
|
1516
|
+
align-items: center;
|
|
1517
|
+
gap: 6px;
|
|
1518
|
+
padding: 5px 8px;
|
|
1519
|
+
font-size: 0.65rem;
|
|
1520
|
+
color: var(--text-secondary);
|
|
1521
|
+
cursor: pointer;
|
|
1522
|
+
border-radius: 4px;
|
|
1523
|
+
font-family: var(--font-body);
|
|
1524
|
+
}
|
|
1525
|
+
.draft-option:hover { background: rgba(232,213,163,0.06); color: var(--text-primary); }
|
|
1526
|
+
.draft-option input[type="radio"] { accent-color: var(--accent-gold); width: 12px; height: 12px; }
|
|
1527
|
+
.draft-option i { font-size: 0.6rem; width: 14px; text-align: center; }
|
|
1528
|
+
.draft-topic-input {
|
|
1529
|
+
width: 100%;
|
|
1530
|
+
background: #111;
|
|
1531
|
+
border: 1px solid var(--border-subtle);
|
|
1532
|
+
border-radius: 4px;
|
|
1533
|
+
padding: 6px 8px;
|
|
1534
|
+
color: var(--text-primary);
|
|
1535
|
+
font-size: 0.65rem;
|
|
1536
|
+
font-family: 'SF Mono', monospace;
|
|
1537
|
+
outline: none;
|
|
1538
|
+
box-sizing: border-box;
|
|
1539
|
+
}
|
|
1540
|
+
.draft-topic-input:focus { border-color: var(--accent-gold); }
|
|
1541
|
+
.draft-generate-btn {
|
|
1542
|
+
width: 100%;
|
|
1543
|
+
margin-top: 10px;
|
|
1544
|
+
padding: 8px;
|
|
1545
|
+
background: var(--accent-gold);
|
|
1546
|
+
color: var(--text-dark);
|
|
1547
|
+
border: none;
|
|
1548
|
+
border-radius: var(--radius);
|
|
1549
|
+
font-size: 0.68rem;
|
|
1550
|
+
font-weight: 600;
|
|
1551
|
+
cursor: pointer;
|
|
1552
|
+
font-family: var(--font-body);
|
|
1553
|
+
transition: opacity 0.15s;
|
|
1554
|
+
}
|
|
1555
|
+
.draft-generate-btn:hover { opacity: 0.9; }
|
|
1556
|
+
.export-expand-btn {
|
|
1557
|
+
position: absolute;
|
|
1558
|
+
top: 6px;
|
|
1559
|
+
right: 6px;
|
|
1560
|
+
z-index: 10;
|
|
1561
|
+
background: rgba(255,255,255,0.06);
|
|
1562
|
+
border: 1px solid var(--border-subtle);
|
|
1563
|
+
color: var(--text-muted);
|
|
1564
|
+
width: 24px; height: 24px;
|
|
1565
|
+
border-radius: 4px;
|
|
1566
|
+
cursor: pointer;
|
|
1567
|
+
display: flex;
|
|
1568
|
+
align-items: center;
|
|
1569
|
+
justify-content: center;
|
|
1570
|
+
font-size: 0.55rem;
|
|
1571
|
+
transition: all 0.15s;
|
|
1572
|
+
}
|
|
1573
|
+
.export-expand-btn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
|
|
1574
|
+
.export-viewer-expanded {
|
|
1575
|
+
position: fixed !important;
|
|
1576
|
+
top: 5vh; left: 5vw; right: 5vw; bottom: 5vh;
|
|
1577
|
+
max-height: none !important;
|
|
1578
|
+
z-index: 9999;
|
|
1579
|
+
background: #111;
|
|
1580
|
+
border: 1px solid var(--accent-gold);
|
|
1581
|
+
border-radius: var(--radius);
|
|
1582
|
+
padding: 24px;
|
|
1583
|
+
overflow-y: auto;
|
|
1584
|
+
box-shadow: 0 0 80px rgba(0,0,0,0.8);
|
|
1585
|
+
}
|
|
1586
|
+
.export-viewer-backdrop {
|
|
1587
|
+
position: fixed;
|
|
1588
|
+
inset: 0;
|
|
1589
|
+
background: rgba(0,0,0,0.7);
|
|
1590
|
+
z-index: 9998;
|
|
1591
|
+
cursor: pointer;
|
|
1592
|
+
}
|
|
1491
1593
|
.export-viewer {
|
|
1492
1594
|
flex: 1;
|
|
1493
1595
|
padding: 12px;
|
|
@@ -2017,6 +2119,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2017
2119
|
<div class="export-sidebar">
|
|
2018
2120
|
<div class="export-sidebar-header">
|
|
2019
2121
|
<i class="fa-solid fa-file-export"></i> Exports
|
|
2122
|
+
<span style="margin-left:auto;font-size:.55rem;color:var(--text-muted);font-weight:400;letter-spacing:0;">→ reports/</span>
|
|
2020
2123
|
</div>
|
|
2021
2124
|
${pro ? `
|
|
2022
2125
|
<div class="export-sidebar-btns">
|
|
@@ -2024,10 +2127,38 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2024
2127
|
<button class="export-btn" data-export-cmd="export-actions" data-export-project="${project}" data-export-scope="competitive"><i class="fa-solid fa-users"></i> Competitive Gaps</button>
|
|
2025
2128
|
<button class="export-btn" data-export-cmd="suggest-usecases" data-export-project="${project}"><i class="fa-solid fa-lightbulb"></i> Suggest What to Build</button>
|
|
2026
2129
|
</div>
|
|
2027
|
-
<div
|
|
2028
|
-
<
|
|
2029
|
-
|
|
2030
|
-
|
|
2130
|
+
<div class="export-sidebar-header" style="margin-top:12px;">
|
|
2131
|
+
<i class="fa-solid fa-pen-fancy"></i> Create
|
|
2132
|
+
</div>
|
|
2133
|
+
<div class="export-sidebar-btns">
|
|
2134
|
+
<div class="draft-dropdown" id="draftDropdown${suffix}">
|
|
2135
|
+
<button class="export-btn draft-trigger" id="draftTrigger${suffix}"><i class="fa-solid fa-file-pen"></i> Create a Draft <i class="fa-solid fa-chevron-down" style="font-size:0.55rem;margin-left:auto;opacity:0.5;"></i></button>
|
|
2136
|
+
<div class="draft-menu" id="draftMenu${suffix}">
|
|
2137
|
+
<div class="draft-menu-section">Type</div>
|
|
2138
|
+
<label class="draft-option"><input type="radio" name="draftType${suffix}" value="blog" checked /> <i class="fa-solid fa-blog"></i> Blog Post</label>
|
|
2139
|
+
<label class="draft-option"><input type="radio" name="draftType${suffix}" value="docs" disabled /> <i class="fa-solid fa-book"></i> Documentation <span style="font-size:0.55rem;opacity:0.4;margin-left:4px;">soon</span></label>
|
|
2140
|
+
<div class="draft-menu-section" style="margin-top:8px;">Topic <span style="font-size:0.55rem;opacity:0.4;">(optional)</span></div>
|
|
2141
|
+
<input type="text" id="draftTopic${suffix}" class="draft-topic-input" placeholder="e.g. solana rpc, site speed..." />
|
|
2142
|
+
<div class="draft-menu-section" style="margin-top:8px;">Language</div>
|
|
2143
|
+
<div style="display:flex;gap:6px;">
|
|
2144
|
+
<label class="draft-option" style="flex:1;"><input type="radio" name="draftLang${suffix}" value="en" checked /> EN</label>
|
|
2145
|
+
<label class="draft-option" style="flex:1;"><input type="radio" name="draftLang${suffix}" value="fi" /> FI</label>
|
|
2146
|
+
</div>
|
|
2147
|
+
<button class="draft-generate-btn" id="draftGenerate${suffix}" data-project="${project}"><i class="fa-solid fa-wand-magic-sparkles"></i> Generate Draft</button>
|
|
2148
|
+
</div>
|
|
2149
|
+
</div>
|
|
2150
|
+
<button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
|
|
2151
|
+
</div>
|
|
2152
|
+
<div style="position:relative;">
|
|
2153
|
+
<div id="exportSaveStatus${suffix}" style="display:none;padding:4px 10px;font-size:.6rem;color:var(--color-success);background:rgba(80,200,120,0.06);border-bottom:1px solid rgba(80,200,120,0.15);font-family:'SF Mono',monospace;">
|
|
2154
|
+
<i class="fa-solid fa-check" style="margin-right:4px;"></i><span></span>
|
|
2155
|
+
</div>
|
|
2156
|
+
<button id="exportExpand${suffix}" class="export-expand-btn" title="Expand viewer"><i class="fa-solid fa-expand"></i></button>
|
|
2157
|
+
<div id="exportViewer${suffix}" class="export-viewer">
|
|
2158
|
+
<div style="color:#444;padding:20px 0;text-align:center;">
|
|
2159
|
+
<i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
|
|
2160
|
+
Click an export to generate an<br/>implementation-ready action brief.
|
|
2161
|
+
</div>
|
|
2031
2162
|
</div>
|
|
2032
2163
|
</div>
|
|
2033
2164
|
` : `
|
|
@@ -2211,6 +2342,7 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2211
2342
|
try {
|
|
2212
2343
|
const msg = JSON.parse(e.data);
|
|
2213
2344
|
if (msg.type === 'stdout') mdContent += msg.data + '\\n';
|
|
2345
|
+
else if (msg.type === 'stderr') mdContent += msg.data + '\\n';
|
|
2214
2346
|
else if (msg.type === 'exit') {
|
|
2215
2347
|
running = false;
|
|
2216
2348
|
status.textContent = 'done';
|
|
@@ -2232,6 +2364,14 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2232
2364
|
exportViewer.innerHTML = html || '<div style="color:var(--text-muted);">No output.</div>';
|
|
2233
2365
|
exportViewer.scrollTop = 0;
|
|
2234
2366
|
}
|
|
2367
|
+
// Show save status
|
|
2368
|
+
var saveEl = document.getElementById('exportSaveStatus' + suffix);
|
|
2369
|
+
if (saveEl && code === 0) {
|
|
2370
|
+
var slugName = cmd === 'suggest-usecases' ? 'suggestions' : (scope || 'all');
|
|
2371
|
+
var dateStr = new Date().toISOString().slice(0, 10);
|
|
2372
|
+
saveEl.style.display = 'block';
|
|
2373
|
+
saveEl.querySelector('span').textContent = 'Saved → reports/' + proj + '-' + slugName + '-' + dateStr + '.md';
|
|
2374
|
+
}
|
|
2235
2375
|
}
|
|
2236
2376
|
} catch (_) {}
|
|
2237
2377
|
};
|
|
@@ -2245,6 +2385,129 @@ function buildHtmlTemplate(data, opts = {}) {
|
|
|
2245
2385
|
});
|
|
2246
2386
|
});
|
|
2247
2387
|
|
|
2388
|
+
// Draft dropdown
|
|
2389
|
+
var draftTrigger = document.getElementById('draftTrigger' + suffix);
|
|
2390
|
+
var draftMenu = document.getElementById('draftMenu' + suffix);
|
|
2391
|
+
var draftGenerate = document.getElementById('draftGenerate' + suffix);
|
|
2392
|
+
if (draftTrigger && draftMenu) {
|
|
2393
|
+
draftTrigger.addEventListener('click', function(e) {
|
|
2394
|
+
e.stopPropagation();
|
|
2395
|
+
draftMenu.classList.toggle('open');
|
|
2396
|
+
});
|
|
2397
|
+
document.addEventListener('click', function(e) {
|
|
2398
|
+
if (!draftMenu.contains(e.target) && e.target !== draftTrigger) {
|
|
2399
|
+
draftMenu.classList.remove('open');
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
if (draftGenerate) {
|
|
2404
|
+
draftGenerate.addEventListener('click', function() {
|
|
2405
|
+
if (running) return;
|
|
2406
|
+
var proj = draftGenerate.getAttribute('data-project');
|
|
2407
|
+
var typeEl = document.querySelector('input[name="draftType' + suffix + '"]:checked');
|
|
2408
|
+
var langEl = document.querySelector('input[name="draftLang' + suffix + '"]:checked');
|
|
2409
|
+
var topicEl = document.getElementById('draftTopic' + suffix);
|
|
2410
|
+
var draftType = typeEl ? typeEl.value : 'blog';
|
|
2411
|
+
var lang = langEl ? langEl.value : 'en';
|
|
2412
|
+
var topic = topicEl ? topicEl.value.trim() : '';
|
|
2413
|
+
|
|
2414
|
+
if (draftType !== 'blog') return; // docs not yet supported
|
|
2415
|
+
|
|
2416
|
+
draftMenu.classList.remove('open');
|
|
2417
|
+
|
|
2418
|
+
// Run blog-draft via terminal SSE
|
|
2419
|
+
var extra = { lang: lang };
|
|
2420
|
+
if (topic) extra.topic = topic;
|
|
2421
|
+
|
|
2422
|
+
var params = new URLSearchParams({ command: 'blog-draft' });
|
|
2423
|
+
params.set('project', proj);
|
|
2424
|
+
params.set('lang', lang);
|
|
2425
|
+
params.set('save', '1');
|
|
2426
|
+
if (topic) params.set('topic', topic);
|
|
2427
|
+
|
|
2428
|
+
if (!isServed) {
|
|
2429
|
+
var cmd = 'seo-intel blog-draft ' + proj + (topic ? ' --topic "' + topic + '"' : '') + ' --lang ' + lang + ' --save';
|
|
2430
|
+
if (exportViewer) {
|
|
2431
|
+
exportViewer.innerHTML = '<div style="color:var(--color-danger);padding:12px;">Not connected. Run in terminal:<br/><code style="color:var(--accent-gold);">' + cmd + '</code></div>';
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
if (exportViewer) exportViewer.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center;"><i class="fa-solid fa-wand-magic-sparkles fa-spin" style="margin-right:6px;color:var(--accent-gold);"></i>Generating AEO draft...</div>';
|
|
2437
|
+
|
|
2438
|
+
var mdContent = '';
|
|
2439
|
+
var es = new EventSource('/api/terminal?' + params.toString());
|
|
2440
|
+
running = true;
|
|
2441
|
+
status.textContent = 'generating draft...';
|
|
2442
|
+
status.style.color = 'var(--color-warning)';
|
|
2443
|
+
|
|
2444
|
+
es.onmessage = function(e) {
|
|
2445
|
+
try {
|
|
2446
|
+
var msg = JSON.parse(e.data);
|
|
2447
|
+
if (msg.type === 'stdout') mdContent += msg.data + '\\n';
|
|
2448
|
+
else if (msg.type === 'stderr') appendLine(msg.data, 'stderr');
|
|
2449
|
+
else if (msg.type === 'exit') {
|
|
2450
|
+
running = false;
|
|
2451
|
+
var code = msg.data?.code ?? msg.data;
|
|
2452
|
+
status.textContent = code === 0 ? 'draft saved' : 'failed';
|
|
2453
|
+
status.style.color = code === 0 ? 'var(--color-success)' : 'var(--color-danger)';
|
|
2454
|
+
es.close();
|
|
2455
|
+
if (exportViewer && mdContent.trim()) {
|
|
2456
|
+
var bt = String.fromCharCode(96);
|
|
2457
|
+
var codeRe = new RegExp(bt + '([^' + bt + ']+)' + bt, 'g');
|
|
2458
|
+
var html = mdContent
|
|
2459
|
+
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
|
2460
|
+
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
|
2461
|
+
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
|
2462
|
+
.replace(/\\*\\*(.*?)\\*\\*/g, '<strong>$1</strong>')
|
|
2463
|
+
.replace(/^- (.*$)/gm, '<li>$1</li>')
|
|
2464
|
+
.replace(codeRe, '<code>$1</code>')
|
|
2465
|
+
.replace(/\\n/g, '<br/>');
|
|
2466
|
+
exportViewer.innerHTML = html;
|
|
2467
|
+
exportViewer.scrollTop = 0;
|
|
2468
|
+
} else if (exportViewer) {
|
|
2469
|
+
exportViewer.innerHTML = '<div style="color:var(--text-muted);">Draft generated — check reports/ folder.</div>';
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
} catch (_) {}
|
|
2473
|
+
};
|
|
2474
|
+
es.onerror = function() {
|
|
2475
|
+
running = false;
|
|
2476
|
+
status.textContent = 'error';
|
|
2477
|
+
status.style.color = 'var(--color-danger)';
|
|
2478
|
+
es.close();
|
|
2479
|
+
if (exportViewer) exportViewer.innerHTML = '<div style="color:var(--color-danger);">Connection failed.</div>';
|
|
2480
|
+
};
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// Expand viewer button
|
|
2485
|
+
var expandBtn = document.getElementById('exportExpand' + suffix);
|
|
2486
|
+
if (expandBtn && exportViewer) {
|
|
2487
|
+
expandBtn.addEventListener('click', function() {
|
|
2488
|
+
if (exportViewer.classList.contains('export-viewer-expanded')) {
|
|
2489
|
+
// Collapse
|
|
2490
|
+
exportViewer.classList.remove('export-viewer-expanded');
|
|
2491
|
+
expandBtn.innerHTML = '<i class="fa-solid fa-expand"></i>';
|
|
2492
|
+
var bd = document.getElementById('exportBackdrop' + suffix);
|
|
2493
|
+
if (bd) bd.remove();
|
|
2494
|
+
} else {
|
|
2495
|
+
// Expand
|
|
2496
|
+
exportViewer.classList.add('export-viewer-expanded');
|
|
2497
|
+
expandBtn.innerHTML = '<i class="fa-solid fa-compress"></i>';
|
|
2498
|
+
var bd = document.createElement('div');
|
|
2499
|
+
bd.id = 'exportBackdrop' + suffix;
|
|
2500
|
+
bd.className = 'export-viewer-backdrop';
|
|
2501
|
+
document.body.appendChild(bd);
|
|
2502
|
+
bd.addEventListener('click', function() {
|
|
2503
|
+
exportViewer.classList.remove('export-viewer-expanded');
|
|
2504
|
+
expandBtn.innerHTML = '<i class="fa-solid fa-expand"></i>';
|
|
2505
|
+
bd.remove();
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2248
2511
|
// Input enter
|
|
2249
2512
|
input.addEventListener('keydown', function(e) {
|
|
2250
2513
|
if (e.key !== 'Enter') return;
|
package/server.js
CHANGED
|
@@ -595,7 +595,8 @@ async function handleRequest(req, res) {
|
|
|
595
595
|
// Whitelist allowed commands
|
|
596
596
|
const ALLOWED = ['crawl', 'extract', 'analyze', 'export-actions', 'competitive-actions',
|
|
597
597
|
'suggest-usecases', 'html', 'status', 'brief', 'keywords', 'report', 'guide',
|
|
598
|
-
'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates'
|
|
598
|
+
'schemas', 'headings-audit', 'orphans', 'entities', 'friction', 'shallow', 'decay', 'export', 'templates',
|
|
599
|
+
'aeo', 'blog-draft'];
|
|
599
600
|
|
|
600
601
|
if (!command || !ALLOWED.includes(command)) {
|
|
601
602
|
json(res, 400, { error: `Invalid command. Allowed: ${ALLOWED.join(', ')}` });
|
|
@@ -608,6 +609,21 @@ async function handleRequest(req, res) {
|
|
|
608
609
|
if (params.get('stealth') === 'true') args.push('--stealth');
|
|
609
610
|
if (params.get('scope')) args.push('--scope', params.get('scope'));
|
|
610
611
|
if (params.get('format')) args.push('--format', params.get('format'));
|
|
612
|
+
if (params.get('topic')) args.push('--topic', params.get('topic'));
|
|
613
|
+
if (params.get('lang')) args.push('--lang', params.get('lang'));
|
|
614
|
+
if (params.get('model')) args.push('--model', params.get('model'));
|
|
615
|
+
if (params.has('save')) args.push('--save');
|
|
616
|
+
|
|
617
|
+
// Auto-save exports from dashboard to reports/
|
|
618
|
+
const EXPORT_CMDS = ['export-actions', 'suggest-usecases', 'competitive-actions'];
|
|
619
|
+
if (EXPORT_CMDS.includes(command) && project) {
|
|
620
|
+
const scope = params.get('scope') || 'all';
|
|
621
|
+
const ts = new Date().toISOString().slice(0, 10);
|
|
622
|
+
const slug = command === 'suggest-usecases' ? 'suggestions' : scope;
|
|
623
|
+
const outFile = join(__dirname, 'reports', `${project}-${slug}-${ts}.md`);
|
|
624
|
+
args.push('--output', outFile);
|
|
625
|
+
args.push('--format', 'brief');
|
|
626
|
+
}
|
|
611
627
|
|
|
612
628
|
// SSE headers
|
|
613
629
|
res.writeHead(200, {
|