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 +20 -0
- package/analyses/blog-draft/index.js +227 -0
- package/analyses/blog-draft/prescorer.js +60 -0
- package/cli.js +124 -0
- package/lib/gate.js +1 -0
- package/package.json +1 -1
- package/reports/generate-html.js +183 -0
- package/server.js +6 -1
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
package/reports/generate-html.js
CHANGED
|
@@ -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, {
|