Files
at-mintel/packages/seo-engine/src/report.ts
Marc Mintel ded9da7d32
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
feat(seo-engine): implement competitor scraper, MDX draft editor, and strategy report generator
2026-03-02 10:16:11 +01:00

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;
}