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 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
- printAttackHeader('🤖 AEO — AI Citability Audit', project);
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
- // ── Summary ──
3900
- console.log('');
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
- const scoreFmt = (s) => {
3905
- if (s >= 75) return chalk.bold.green(s + '/100');
3906
- if (s >= 55) return chalk.bold.yellow(s + '/100');
3907
- if (s >= 35) return chalk.hex('#ff8c00')(s + '/100');
3908
- return chalk.bold.red(s + '/100');
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
- console.log(` Target average: ${scoreFmt(summary.avgTargetScore)}`);
3912
- if (summary.competitorPages > 0) {
3913
- console.log(` Competitor average: ${scoreFmt(summary.avgCompetitorScore)}`);
3914
- const delta = summary.scoreDelta;
3915
- const deltaStr = delta > 0 ? chalk.green(`+${delta}`) : delta < 0 ? chalk.red(`${delta}`) : chalk.gray('0');
3916
- console.log(` Delta: ${deltaStr}`);
3917
- }
3918
- console.log('');
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
- // ── Tier breakdown ──
3921
- const { tierCounts } = summary;
3922
- console.log(` ${chalk.green('●')} Excellent (75+): ${tierCounts.excellent}`);
3923
- console.log(` ${chalk.yellow('')} Good (55-74): ${tierCounts.good}`);
3924
- console.log(` ${chalk.hex('#ff8c00')('●')} Needs work (35-54): ${tierCounts.needs_work}`);
3925
- console.log(` ${chalk.red('●')} Poor (<35): ${tierCounts.poor}`);
3926
- console.log('');
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
- // ── Weakest signals ──
3929
- if (summary.weakestSignals.length) {
3930
- console.log(chalk.bold(' 🔍 Weakest Signals (target average)'));
3931
- console.log('');
3932
- for (const s of summary.weakestSignals) {
3933
- const bar = '█'.repeat(Math.round(s.avg / 5)) + chalk.gray('░'.repeat(20 - Math.round(s.avg / 5)));
3934
- console.log(` ${s.signal.padEnd(20)} ${bar} ${s.avg}/100`);
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
- for (const p of worst) {
3945
- const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3946
- const weakest = Object.entries(p.breakdown)
3947
- .sort(([, a], [, b]) => a - b)
3948
- .slice(0, 2)
3949
- .map(([k]) => k.replace(/_/g, ' '));
3950
- console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))}`);
3951
- console.log(chalk.gray(` Weak: ${weakest.join(', ')}`));
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
- // ── Best pages ──
3957
- const best = results.target.filter(r => r.score >= 55).slice(-5).reverse();
3958
- if (best.length) {
3959
- console.log(chalk.bold.green(' Top Citable Pages'));
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
- for (const p of best) {
3962
- const path = p.url.replace(/https?:\/\/[^/]+/, '') || '/';
3963
- console.log(` ${scoreFmt(p.score)} ${chalk.bold(path.slice(0, 50))} ${chalk.gray(p.aiIntents.join(', '))}`);
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
- // ── Actions ──
3969
- console.log(chalk.bold.green(' 💡 Actions:'));
3970
- if (tierCounts.poor > 0) {
3971
- console.log(chalk.green(` 1. Fix ${tierCounts.poor} poor-scoring pages — add structured headings, Q&A format, entity depth`));
3972
- }
3973
- if (summary.weakestSignals.length && summary.weakestSignals[0].avg < 40) {
3974
- console.log(chalk.green(` 2. Site-wide weakness: "${summary.weakestSignals[0].signal}" systematically improve across all pages`));
3975
- }
3976
- if (summary.scoreDelta < 0) {
3977
- console.log(chalk.green(` 3. Competitors are ${Math.abs(summary.scoreDelta)} points ahead — prioritise top-traffic pages first`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.2.6",
3
+ "version": "1.3.1",
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,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 id="exportViewer${suffix}" class="export-viewer">
2028
- <div style="color:#444;padding:20px 0;text-align:center;">
2029
- <i class="fa-solid fa-file-export" style="font-size:1.2rem;margin-bottom:8px;display:block;"></i>
2030
- Click an export to generate an<br/>implementation-ready action brief.
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, {