Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Failing after 51s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m25s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m28s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
238 lines
11 KiB
TypeScript
238 lines
11 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import type {
|
|
SeoEngineOutput,
|
|
TopicCluster,
|
|
ContentGap,
|
|
CompetitorRanking,
|
|
} from "./types.js";
|
|
import type { ReverseEngineeredBriefing } from "./agents/scraper.js";
|
|
|
|
export interface ReportConfig {
|
|
projectName: string;
|
|
outputDir: string;
|
|
filename?: string;
|
|
}
|
|
|
|
/**
|
|
* Generates a comprehensive, human-readable SEO Strategy Report in Markdown.
|
|
* This is the "big picture" document that summarizes everything the SEO Engine found
|
|
* and gives the team a clear action plan.
|
|
*/
|
|
export async function generateSeoReport(
|
|
output: SeoEngineOutput,
|
|
config: ReportConfig,
|
|
): Promise<string> {
|
|
const dateStr = new Date().toLocaleDateString("de-DE", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
|
|
const allKeywords = output.topicClusters.flatMap((c) => [
|
|
c.primaryKeyword,
|
|
...c.secondaryKeywords.map((k) => k.term),
|
|
]);
|
|
|
|
let md = "";
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Header
|
|
// ══════════════════════════════════════════════
|
|
md += `# 📊 SEO Strategie-Report: ${config.projectName}\n\n`;
|
|
md += `> Erstellt am **${dateStr}** von der **@mintel/seo-engine**\n\n`;
|
|
|
|
md += `## Zusammenfassung auf einen Blick\n\n`;
|
|
md += `| Metrik | Wert |\n`;
|
|
md += `|--------|------|\n`;
|
|
md += `| Keywords gefunden | **${allKeywords.length}** |\n`;
|
|
md += `| Topic Clusters | **${output.topicClusters.length}** |\n`;
|
|
md += `| Konkurrenz-Rankings analysiert | **${output.competitorRankings.length}** |\n`;
|
|
md += `| Konkurrenz-Briefings erstellt | **${Object.keys(output.competitorBriefings).length}** |\n`;
|
|
md += `| Content Gaps identifiziert | **${output.contentGaps.length}** |\n`;
|
|
md += `| Autocomplete-Vorschläge | **${output.autocompleteSuggestions.length}** |\n`;
|
|
md += `| Verworfene Begriffe | **${output.discardedTerms.length}** |\n\n`;
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 1: Keywords zum Tracken
|
|
// ══════════════════════════════════════════════
|
|
md += `---\n\n`;
|
|
md += `## 🎯 Keywords zum Tracken\n\n`;
|
|
md += `Diese Keywords sind relevant für das Projekt und sollten in einem Ranking-Tracker (z.B. Serpbear) beobachtet werden:\n\n`;
|
|
md += `| # | Keyword | Intent | Relevanz | Cluster |\n`;
|
|
md += `|---|---------|--------|----------|--------|\n`;
|
|
|
|
let kwIndex = 1;
|
|
for (const cluster of output.topicClusters) {
|
|
md += `| ${kwIndex++} | **${cluster.primaryKeyword}** | — | 🏆 Primary | ${cluster.clusterName} |\n`;
|
|
for (const kw of cluster.secondaryKeywords) {
|
|
const intentEmoji =
|
|
kw.intent === "transactional"
|
|
? "💰"
|
|
: kw.intent === "commercial"
|
|
? "🛒"
|
|
: kw.intent === "navigational"
|
|
? "🧭"
|
|
: "📖";
|
|
md += `| ${kwIndex++} | ${kw.term} | ${intentEmoji} ${kw.intent} | ${kw.relevanceScore}/10 | ${cluster.clusterName} |\n`;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 2: Topic Clusters
|
|
// ══════════════════════════════════════════════
|
|
md += `\n---\n\n`;
|
|
md += `## 🗂️ Topic Clusters\n\n`;
|
|
md += `Die SEO Engine hat die Keywords automatisch in thematische Cluster gruppiert. Jeder Cluster sollte idealerweise durch eine **Pillar Page** und mehrere **Sub-Pages** abgedeckt werden.\n\n`;
|
|
|
|
for (const cluster of output.topicClusters) {
|
|
md += `### ${cluster.clusterName}\n\n`;
|
|
md += `- **Pillar Keyword:** \`${cluster.primaryKeyword}\`\n`;
|
|
md += `- **User Intent:** ${cluster.userIntent}\n`;
|
|
md += `- **Sub-Keywords:** ${cluster.secondaryKeywords.map((k) => `\`${k.term}\``).join(", ")}\n\n`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 3: Konkurrenz-Landscape
|
|
// ══════════════════════════════════════════════
|
|
if (output.competitorRankings.length > 0) {
|
|
md += `---\n\n`;
|
|
md += `## 🏁 Konkurrenz-Landscape\n\n`;
|
|
md += `Für die wichtigsten Keywords wurde geprüft, welche Konkurrenten aktuell bei Google ranken:\n\n`;
|
|
md += `| Keyword | Konkurrent | Position | Titel |\n`;
|
|
md += `|---------|-----------|----------|-------|\n`;
|
|
|
|
for (const r of output.competitorRankings) {
|
|
md += `| ${r.keyword} | **${r.domain}** | #${r.position} | ${r.title.slice(0, 60)}${r.title.length > 60 ? "…" : ""} |\n`;
|
|
}
|
|
md += `\n`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 4: Competitor Briefings
|
|
// ══════════════════════════════════════════════
|
|
if (Object.keys(output.competitorBriefings).length > 0) {
|
|
md += `---\n\n`;
|
|
md += `## 🔬 Konkurrenz-Briefings (Reverse Engineered)\n\n`;
|
|
md += `Für die folgenden Keywords wurde der aktuelle **Platz-1-Ranker** automatisch gescraped und analysiert. Diese Briefings zeigen exakt, was ein Artikel abdecken muss, um die Konkurrenz zu schlagen:\n\n`;
|
|
|
|
for (const [keyword, briefing] of Object.entries(
|
|
output.competitorBriefings,
|
|
)) {
|
|
const b = briefing as ReverseEngineeredBriefing;
|
|
md += `### Keyword: \`${keyword}\`\n\n`;
|
|
md += `- **Format:** ${b.contentFormat}\n`;
|
|
md += `- **Ziel-Wortanzahl:** ~${b.recommendedWordCount}\n`;
|
|
md += `- **Kernthemen:** ${b.coreTopicsToCover.join("; ")}\n`;
|
|
md += `- **Wichtige Entitäten:** ${b.entitiesToInclude.map((e) => `\`${e}\``).join(", ")}\n\n`;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 5: Content Gaps — Action Plan
|
|
// ══════════════════════════════════════════════
|
|
if (output.contentGaps.length > 0) {
|
|
md += `---\n\n`;
|
|
md += `## 🚧 Content Gaps — Fehlende Seiten\n\n`;
|
|
md += `Die folgenden Seiten existieren auf der Website noch **nicht**, werden aber von der Zielgruppe aktiv gesucht. Sie sind nach Priorität sortiert:\n\n`;
|
|
|
|
const highGaps = output.contentGaps.filter((g) => g.priority === "high");
|
|
const medGaps = output.contentGaps.filter((g) => g.priority === "medium");
|
|
const lowGaps = output.contentGaps.filter((g) => g.priority === "low");
|
|
|
|
if (highGaps.length > 0) {
|
|
md += `### 🔴 Hohe Priorität (direkt umsatzrelevant)\n\n`;
|
|
for (const g of highGaps) {
|
|
md += `- **${g.recommendedTitle}**\n`;
|
|
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
|
md += ` - ${g.rationale}\n\n`;
|
|
}
|
|
}
|
|
|
|
if (medGaps.length > 0) {
|
|
md += `### 🟡 Mittlere Priorität (stärkt Autorität)\n\n`;
|
|
for (const g of medGaps) {
|
|
md += `- **${g.recommendedTitle}**\n`;
|
|
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
|
md += ` - ${g.rationale}\n\n`;
|
|
}
|
|
}
|
|
|
|
if (lowGaps.length > 0) {
|
|
md += `### 🟢 Niedrige Priorität (Top-of-Funnel)\n\n`;
|
|
for (const g of lowGaps) {
|
|
md += `- **${g.recommendedTitle}**\n`;
|
|
md += ` - Keyword: \`${g.targetKeyword}\` · Cluster: ${g.relatedCluster}\n`;
|
|
md += ` - ${g.rationale}\n\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 6: Autocomplete Insights
|
|
// ══════════════════════════════════════════════
|
|
if (output.autocompleteSuggestions.length > 0) {
|
|
md += `---\n\n`;
|
|
md += `## 💡 Google Autocomplete — Long-Tail Insights\n\n`;
|
|
md += `Diese Begriffe stammen direkt aus der Google-Suchleiste und spiegeln echtes Nutzerverhalten wider. Sie eignen sich besonders für **FAQ-Sektionen**, **H2/H3-Überschriften** und **Long-Tail Content**:\n\n`;
|
|
|
|
for (const s of output.autocompleteSuggestions) {
|
|
md += `- ${s}\n`;
|
|
}
|
|
md += `\n`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 7: Verworfene Begriffe
|
|
// ══════════════════════════════════════════════
|
|
if (output.discardedTerms.length > 0) {
|
|
md += `---\n\n`;
|
|
md += `## 🗑️ Verworfene Begriffe\n\n`;
|
|
md += `Die folgenden Begriffe wurden von der KI als **nicht relevant** eingestuft:\n\n`;
|
|
md += `<details>\n<summary>Alle ${output.discardedTerms.length} verworfenen Begriffe anzeigen</summary>\n\n`;
|
|
for (const t of output.discardedTerms) {
|
|
md += `- ${t}\n`;
|
|
}
|
|
md += `\n</details>\n\n`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Section 8: Copy-Paste Snippets
|
|
// ══════════════════════════════════════════════
|
|
md += `---\n\n`;
|
|
md += `## 📋 Copy-Paste Snippets\n\n`;
|
|
md += `Diese Listen sind optimiert für das schnelle Kopieren in SEO-Tools oder Tabellen.\n\n`;
|
|
|
|
md += `### Rank-Tracker (z.B. Serpbear) — Ein Keyword pro Zeile\n`;
|
|
md += `\`\`\`text\n`;
|
|
md += allKeywords.join("\n");
|
|
md += `\n\`\`\`\n\n`;
|
|
|
|
md += `### Excel / Google Sheets — Kommagetrennt\n`;
|
|
md += `\`\`\`text\n`;
|
|
md += allKeywords.join(", ");
|
|
md += `\n\`\`\`\n\n`;
|
|
|
|
md += `### Pillar-Keywords (Nur Primary Keywords)\n`;
|
|
md += `\`\`\`text\n`;
|
|
md += output.topicClusters.map((c) => c.primaryKeyword).join("\n");
|
|
md += `\n\`\`\`\n\n`;
|
|
|
|
// ══════════════════════════════════════════════
|
|
// Footer
|
|
// ══════════════════════════════════════════════
|
|
md += `---\n\n`;
|
|
md += `*Dieser Report wurde automatisch von der @mintel/seo-engine generiert. Alle Daten basieren auf echten Google-Suchergebnissen (via Serper API) und wurden durch ein LLM ausgewertet.*\n`;
|
|
|
|
// Write to disk
|
|
const outDir = path.resolve(process.cwd(), config.outputDir);
|
|
await fs.mkdir(outDir, { recursive: true });
|
|
const filename =
|
|
config.filename ||
|
|
`seo-report-${config.projectName.toLowerCase().replace(/\s+/g, "-")}.md`;
|
|
const filePath = path.join(outDir, filename);
|
|
await fs.writeFile(filePath, md, "utf8");
|
|
console.log(`[Report] Written SEO Strategy Report to: ${filePath}`);
|
|
return filePath;
|
|
}
|