seo-intel 1.2.6 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 (2026-04-01)
4
+
5
+ ### New Feature: AEO Blog Draft Generator
6
+ - `seo-intel blog-draft <project>` — generate AEO-optimised blog post drafts from Intelligence Ledger data
7
+ - Gathers keyword gaps, long-tails, citability insights, entities, and top citable pages
8
+ - Builds structured prompt with 10 AEO signal rules for maximum AI citability
9
+ - Pre-scores generated draft against AEO signals before publishing
10
+ - Options: `--topic`, `--lang en|fi`, `--model gemini|claude|gpt|deepseek`, `--save`
11
+ - Pro feature gated via Lemon Squeezy license
12
+
13
+ ### Dashboard
14
+ - New "Create" section in export sidebar with interactive draft generator
15
+ - "Create a Draft" dropdown: select type (Blog Post / Documentation), topic, language, then generate
16
+ - "AI Citability Audit" button added to export sidebar — run AEO from dashboard
17
+ - Both `aeo` and `blog-draft` commands now available via dashboard terminal
18
+
19
+ ### Server
20
+ - Added `aeo` and `blog-draft` to terminal command whitelist
21
+ - Forward `--topic`, `--lang`, `--model`, `--save` params from dashboard to CLI
22
+
3
23
  ## 1.2.6 (2026-03-31)
4
24
 
5
25
  ### 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
@@ -4002,6 +4002,130 @@ program
4002
4002
  }
4003
4003
  });
4004
4004
 
4005
+ // ── AEO BLOG DRAFT GENERATOR ─────────────────────────────────────────────
4006
+
4007
+ let _blogDraftModule;
4008
+ async function getBlogDraftModule() {
4009
+ if (!_blogDraftModule) _blogDraftModule = await import('./analyses/blog-draft/index.js');
4010
+ return _blogDraftModule;
4011
+ }
4012
+ let _prescorerModule;
4013
+ async function getPrescorerModule() {
4014
+ if (!_prescorerModule) _prescorerModule = await import('./analyses/blog-draft/prescorer.js');
4015
+ return _prescorerModule;
4016
+ }
4017
+
4018
+ program
4019
+ .command('blog-draft <project>')
4020
+ .description('Generate an AEO-optimised blog post draft from Intelligence Ledger data')
4021
+ .option('--topic <keyword>', 'Focus the post on a specific topic')
4022
+ .option('--lang <code>', 'Language: en or fi', 'en')
4023
+ .option('--model <name>', 'Model to use for generation (gemini, claude, gpt, deepseek)', 'gemini')
4024
+ .option('--save', 'Save the generated draft to reports/')
4025
+ .action(async (project, opts) => {
4026
+ if (!requirePro('blog-draft')) return;
4027
+ const db = getDb();
4028
+ const config = loadConfig(project);
4029
+
4030
+ printAttackHeader('✍️ AEO Blog Draft Generator', project);
4031
+
4032
+ const { gatherBlogDraftContext, buildBlogDraftPrompt } = await getBlogDraftModule();
4033
+ const { prescore } = await getPrescorerModule();
4034
+
4035
+ // ── Gather intelligence ──
4036
+ console.log(chalk.gray(' Gathering intelligence from Ledger...'));
4037
+ const context = gatherBlogDraftContext(db, project, opts.topic || null);
4038
+
4039
+ const stats = {
4040
+ keywordGaps: context.keywordGaps.length,
4041
+ longTails: context.longTails.length,
4042
+ citabilityGaps: context.citabilityGaps.length,
4043
+ kwInventor: context.kwInventor.length,
4044
+ contentGaps: context.contentGaps.length,
4045
+ entities: context.entityRows.length,
4046
+ topCitable: context.topCitablePages.length,
4047
+ };
4048
+
4049
+ console.log(chalk.gray(` Keyword gaps: ${stats.keywordGaps} Long-tails: ${stats.longTails} Citability gaps: ${stats.citabilityGaps}`));
4050
+ console.log(chalk.gray(` Keyword inventor: ${stats.kwInventor} Content gaps: ${stats.contentGaps} Entities: ${stats.entities}`));
4051
+
4052
+ if (stats.keywordGaps + stats.longTails + stats.kwInventor === 0) {
4053
+ console.log(chalk.yellow('\n ⚠️ No intelligence data found in the Ledger.'));
4054
+ console.log(chalk.gray(' Run: seo-intel analyze ' + project + ' and seo-intel keywords ' + project + '\n'));
4055
+ return;
4056
+ }
4057
+
4058
+ // ── Build prompt ──
4059
+ console.log(chalk.gray('\n Building AEO-optimised prompt...'));
4060
+ const prompt = buildBlogDraftPrompt(context, {
4061
+ config,
4062
+ lang: opts.lang,
4063
+ topic: opts.topic || null,
4064
+ });
4065
+ console.log(chalk.gray(` Prompt size: ${(prompt.length / 1024).toFixed(1)}KB`));
4066
+
4067
+ // ── Generate ──
4068
+ console.log(chalk.cyan(`\n 🚀 Generating draft via ${opts.model}...\n`));
4069
+ const draft = await callAnalysisModel(prompt, opts.model);
4070
+
4071
+ if (!draft) {
4072
+ console.log(chalk.red('\n ✗ Generation failed — no output from model.\n'));
4073
+ return;
4074
+ }
4075
+
4076
+ // ── Pre-score ──
4077
+ console.log(chalk.gray(' Pre-scoring draft against AEO signals...'));
4078
+ const scoreResult = prescore(draft);
4079
+ const adjustedScore = Math.min(100, scoreResult.score + 10); // +10 for freshness on publish
4080
+
4081
+ const scoreFmt = (s) => {
4082
+ if (s >= 75) return chalk.bold.green(s + '/100');
4083
+ if (s >= 55) return chalk.bold.yellow(s + '/100');
4084
+ if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
4085
+ return chalk.bold.red(s + '/100');
4086
+ };
4087
+
4088
+ console.log('');
4089
+ console.log(chalk.bold(' 📊 Pre-Score Results'));
4090
+ console.log('');
4091
+ console.log(` AEO Score (raw): ${scoreFmt(scoreResult.score)}`);
4092
+ console.log(` AEO Score (published): ${scoreFmt(adjustedScore)} ${chalk.gray('(+10 freshness on publish)')}`);
4093
+ console.log(` Word count: ${scoreResult.wordCount}`);
4094
+ console.log(` Headings: ${scoreResult.headingCount}`);
4095
+ console.log(` Tier: ${scoreResult.tier}`);
4096
+ console.log('');
4097
+
4098
+ if (scoreResult.breakdown) {
4099
+ console.log(chalk.bold(' 🔍 Signal Breakdown'));
4100
+ console.log('');
4101
+ for (const [signal, value] of Object.entries(scoreResult.breakdown)) {
4102
+ const bar = '█'.repeat(Math.round(value / 5)) + chalk.gray('░'.repeat(20 - Math.round(value / 5)));
4103
+ console.log(` ${signal.replace(/_/g, ' ').padEnd(22)} ${bar} ${value}/100`);
4104
+ }
4105
+ console.log('');
4106
+ }
4107
+
4108
+ // ── Output ──
4109
+ if (opts.save) {
4110
+ const slug = opts.topic
4111
+ ? opts.topic.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
4112
+ : 'auto';
4113
+ const filename = `${project}-blog-draft-${slug}-${Date.now()}.md`;
4114
+ const outPath = join(__dirname, 'reports', filename);
4115
+ writeFileSync(outPath, draft, 'utf8');
4116
+ console.log(chalk.bold.green(` ✅ Draft saved: ${outPath}`));
4117
+ console.log('');
4118
+ } else {
4119
+ console.log(chalk.bold(' 📝 Generated Draft'));
4120
+ console.log(chalk.dim('─'.repeat(62)));
4121
+ console.log(draft);
4122
+ console.log(chalk.dim('─'.repeat(62)));
4123
+ console.log('');
4124
+ console.log(chalk.gray(' Tip: add --save to write the draft to reports/'));
4125
+ console.log('');
4126
+ }
4127
+ });
4128
+
4005
4129
  // ── GUIDE (Coach-style chapter map) ──────────────────────────────────────
4006
4130
  program
4007
4131
  .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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -1488,6 +1488,71 @@ 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; }
1491
1556
  .export-viewer {
1492
1557
  flex: 1;
1493
1558
  padding: 12px;
@@ -2024,6 +2089,28 @@ function buildHtmlTemplate(data, opts = {}) {
2024
2089
  <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
2090
  <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
2091
  </div>
2092
+ <div class="export-sidebar-header" style="margin-top:12px;">
2093
+ <i class="fa-solid fa-pen-fancy"></i> Create
2094
+ </div>
2095
+ <div class="export-sidebar-btns">
2096
+ <div class="draft-dropdown" id="draftDropdown${suffix}">
2097
+ <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>
2098
+ <div class="draft-menu" id="draftMenu${suffix}">
2099
+ <div class="draft-menu-section">Type</div>
2100
+ <label class="draft-option"><input type="radio" name="draftType${suffix}" value="blog" checked /> <i class="fa-solid fa-blog"></i> Blog Post</label>
2101
+ <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>
2102
+ <div class="draft-menu-section" style="margin-top:8px;">Topic <span style="font-size:0.55rem;opacity:0.4;">(optional)</span></div>
2103
+ <input type="text" id="draftTopic${suffix}" class="draft-topic-input" placeholder="e.g. solana rpc, site speed..." />
2104
+ <div class="draft-menu-section" style="margin-top:8px;">Language</div>
2105
+ <div style="display:flex;gap:6px;">
2106
+ <label class="draft-option" style="flex:1;"><input type="radio" name="draftLang${suffix}" value="en" checked /> EN</label>
2107
+ <label class="draft-option" style="flex:1;"><input type="radio" name="draftLang${suffix}" value="fi" /> FI</label>
2108
+ </div>
2109
+ <button class="draft-generate-btn" id="draftGenerate${suffix}" data-project="${project}"><i class="fa-solid fa-wand-magic-sparkles"></i> Generate Draft</button>
2110
+ </div>
2111
+ </div>
2112
+ <button class="export-btn" data-export-cmd="aeo" data-export-project="${project}"><i class="fa-solid fa-robot"></i> AI Citability Audit</button>
2113
+ </div>
2027
2114
  <div id="exportViewer${suffix}" class="export-viewer">
2028
2115
  <div style="color:#444;padding:20px 0;text-align:center;">
2029
2116
  <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
@@ -2245,6 +2332,102 @@ function buildHtmlTemplate(data, opts = {}) {
2245
2332
  });
2246
2333
  });
2247
2334
 
2335
+ // Draft dropdown
2336
+ var draftTrigger = document.getElementById('draftTrigger' + suffix);
2337
+ var draftMenu = document.getElementById('draftMenu' + suffix);
2338
+ var draftGenerate = document.getElementById('draftGenerate' + suffix);
2339
+ if (draftTrigger && draftMenu) {
2340
+ draftTrigger.addEventListener('click', function(e) {
2341
+ e.stopPropagation();
2342
+ draftMenu.classList.toggle('open');
2343
+ });
2344
+ document.addEventListener('click', function(e) {
2345
+ if (!draftMenu.contains(e.target) && e.target !== draftTrigger) {
2346
+ draftMenu.classList.remove('open');
2347
+ }
2348
+ });
2349
+ }
2350
+ if (draftGenerate) {
2351
+ draftGenerate.addEventListener('click', function() {
2352
+ if (running) return;
2353
+ var proj = draftGenerate.getAttribute('data-project');
2354
+ var typeEl = document.querySelector('input[name="draftType' + suffix + '"]:checked');
2355
+ var langEl = document.querySelector('input[name="draftLang' + suffix + '"]:checked');
2356
+ var topicEl = document.getElementById('draftTopic' + suffix);
2357
+ var draftType = typeEl ? typeEl.value : 'blog';
2358
+ var lang = langEl ? langEl.value : 'en';
2359
+ var topic = topicEl ? topicEl.value.trim() : '';
2360
+
2361
+ if (draftType !== 'blog') return; // docs not yet supported
2362
+
2363
+ draftMenu.classList.remove('open');
2364
+
2365
+ // Run blog-draft via terminal SSE
2366
+ var extra = { lang: lang };
2367
+ if (topic) extra.topic = topic;
2368
+
2369
+ var params = new URLSearchParams({ command: 'blog-draft' });
2370
+ params.set('project', proj);
2371
+ params.set('lang', lang);
2372
+ params.set('save', '1');
2373
+ if (topic) params.set('topic', topic);
2374
+
2375
+ if (!isServed) {
2376
+ var cmd = 'seo-intel blog-draft ' + proj + (topic ? ' --topic "' + topic + '"' : '') + ' --lang ' + lang + ' --save';
2377
+ if (exportViewer) {
2378
+ 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>';
2379
+ }
2380
+ return;
2381
+ }
2382
+
2383
+ 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>';
2384
+
2385
+ var mdContent = '';
2386
+ var es = new EventSource('/api/terminal?' + params.toString());
2387
+ running = true;
2388
+ status.textContent = 'generating draft...';
2389
+ status.style.color = 'var(--color-warning)';
2390
+
2391
+ es.onmessage = function(e) {
2392
+ try {
2393
+ var msg = JSON.parse(e.data);
2394
+ if (msg.type === 'stdout') mdContent += msg.data + '\\n';
2395
+ else if (msg.type === 'stderr') appendLine(msg.data, 'stderr');
2396
+ else if (msg.type === 'exit') {
2397
+ running = false;
2398
+ var code = msg.data?.code ?? msg.data;
2399
+ status.textContent = code === 0 ? 'draft saved' : 'failed';
2400
+ status.style.color = code === 0 ? 'var(--color-success)' : 'var(--color-danger)';
2401
+ es.close();
2402
+ if (exportViewer && mdContent.trim()) {
2403
+ var bt = String.fromCharCode(96);
2404
+ var codeRe = new RegExp(bt + '([^' + bt + ']+)' + bt, 'g');
2405
+ var html = mdContent
2406
+ .replace(/^### (.*$)/gm, '<h3>$1</h3>')
2407
+ .replace(/^## (.*$)/gm, '<h2>$1</h2>')
2408
+ .replace(/^# (.*$)/gm, '<h1>$1</h1>')
2409
+ .replace(/\\*\\*(.*?)\\*\\*/g, '<strong>$1</strong>')
2410
+ .replace(/^- (.*$)/gm, '<li>$1</li>')
2411
+ .replace(codeRe, '<code>$1</code>')
2412
+ .replace(/\\n/g, '<br/>');
2413
+ exportViewer.innerHTML = html;
2414
+ exportViewer.scrollTop = 0;
2415
+ } else if (exportViewer) {
2416
+ exportViewer.innerHTML = '<div style="color:var(--text-muted);">Draft generated — check reports/ folder.</div>';
2417
+ }
2418
+ }
2419
+ } catch (_) {}
2420
+ };
2421
+ es.onerror = function() {
2422
+ running = false;
2423
+ status.textContent = 'error';
2424
+ status.style.color = 'var(--color-danger)';
2425
+ es.close();
2426
+ if (exportViewer) exportViewer.innerHTML = '<div style="color:var(--color-danger);">Connection failed.</div>';
2427
+ };
2428
+ });
2429
+ }
2430
+
2248
2431
  // Input enter
2249
2432
  input.addEventListener('keydown', function(e) {
2250
2433
  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,10 @@ 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');
611
616
 
612
617
  // SSE headers
613
618
  res.writeHead(200, {