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 { 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 += `
\nAlle ${output.discardedTerms.length} verworfenen Begriffe anzeigen\n\n`; for (const t of output.discardedTerms) { md += `- ${t}\n`; } md += `\n
\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; }