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
149 lines
6.1 KiB
TypeScript
149 lines
6.1 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import type { ContentGap } from "./types.js";
|
|
import type { ReverseEngineeredBriefing } from "./agents/scraper.js";
|
|
|
|
export interface FileEditorConfig {
|
|
outputDir: string;
|
|
authorName?: string;
|
|
}
|
|
|
|
/**
|
|
* Generates an SEO-friendly URL slug from a title.
|
|
*/
|
|
function createSlug(title: string): string {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Automatically creates local .mdx draft files for identified high-priority content gaps.
|
|
* Each file is self-explanatory: it tells the writer exactly WHY this page needs to exist,
|
|
* WHAT to write, and HOW to structure the content — all based on real competitor data.
|
|
*/
|
|
export async function createGapDrafts(
|
|
gaps: ContentGap[],
|
|
briefings: Map<string, ReverseEngineeredBriefing>,
|
|
config: FileEditorConfig,
|
|
): Promise<string[]> {
|
|
const createdFiles: string[] = [];
|
|
|
|
try {
|
|
await fs.mkdir(path.resolve(process.cwd(), config.outputDir), {
|
|
recursive: true,
|
|
});
|
|
} catch (e) {
|
|
console.error(
|
|
`[File Editor] Could not create directory ${config.outputDir}:`,
|
|
e,
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const dateStr = new Date().toISOString().split("T")[0];
|
|
|
|
for (const gap of gaps) {
|
|
if (gap.priority === "low") continue;
|
|
|
|
const slug = createSlug(gap.recommendedTitle);
|
|
const filePath = path.join(
|
|
path.resolve(process.cwd(), config.outputDir),
|
|
`${slug}.mdx`,
|
|
);
|
|
const briefing = briefings.get(gap.targetKeyword);
|
|
|
|
const priorityEmoji = gap.priority === "high" ? "🔴" : "🟡";
|
|
|
|
let body = "";
|
|
|
|
// ── Intro: Explain WHY this file exists ──
|
|
body += `{/* ═══════════════════════════════════════════════════════════════════\n`;
|
|
body += ` 📋 SEO CONTENT BRIEFING — Auto-generated by @mintel/seo-engine\n`;
|
|
body += ` ═══════════════════════════════════════════════════════════════════\n\n`;
|
|
body += ` Dieses Dokument wurde automatisch erstellt.\n`;
|
|
body += ` Es basiert auf einer Analyse der aktuellen Google-Suchergebnisse\n`;
|
|
body += ` und der Webseiten deiner Konkurrenz.\n\n`;
|
|
body += ` ▸ Du kannst dieses File direkt als MDX-Seite verwenden.\n`;
|
|
body += ` ▸ Ersetze den Briefing-Block unten durch deinen eigenen Text.\n`;
|
|
body += ` ▸ Setze isDraft auf false, wenn der Text fertig ist.\n`;
|
|
body += ` ═══════════════════════════════════════════════════════════════════ */}\n\n`;
|
|
|
|
// ── Section 1: Warum diese Seite? ──
|
|
body += `## ${priorityEmoji} Warum diese Seite erstellt werden sollte\n\n`;
|
|
body += `**Priorität:** ${gap.priority === "high" ? "Hoch — Direkt umsatzrelevant" : "Mittel — Stärkt die thematische Autorität"}\n\n`;
|
|
body += `${gap.rationale}\n\n`;
|
|
body += `| Feld | Wert |\n`;
|
|
body += `|------|------|\n`;
|
|
body += `| **Focus Keyword** | \`${gap.targetKeyword}\` |\n`;
|
|
body += `| **Topic Cluster** | ${gap.relatedCluster} |\n`;
|
|
body += `| **Priorität** | ${gap.priority} |\n\n`;
|
|
|
|
// ── Section 2: Competitor Briefing ──
|
|
if (briefing) {
|
|
body += `## 🔍 Konkurrenz-Analyse (Reverse Engineered)\n\n`;
|
|
body += `> Die folgenden Daten stammen aus einer automatischen Analyse der Webseite,\n`;
|
|
body += `> die aktuell auf **Platz 1 bei Google** für das Keyword \`${gap.targetKeyword}\` rankt.\n`;
|
|
body += `> Nutze diese Informationen, um **besseren Content** zu schreiben.\n\n`;
|
|
|
|
body += `### Content-Format des Konkurrenten\n\n`;
|
|
body += `**${briefing.contentFormat}** — Empfohlene Mindestlänge: **~${briefing.recommendedWordCount} Wörter**\n\n`;
|
|
|
|
body += `### Diese Themen MUSS dein Artikel abdecken\n\n`;
|
|
body += `Die folgenden Punkte werden vom aktuellen Platz-1-Ranker behandelt. Wenn dein Artikel diese Themen nicht abdeckt, wird es schwer, ihn zu überholen:\n\n`;
|
|
briefing.coreTopicsToCover.forEach(
|
|
(t, i) => (body += `${i + 1}. ${t}\n`),
|
|
);
|
|
|
|
body += `\n### Fachbegriffe & Entitäten die im Text vorkommen müssen\n\n`;
|
|
body += `Diese Begriffe signalisieren Google, dass dein Text fachlich tiefgreifend ist. Versuche, möglichst viele davon natürlich in deinen Text einzubauen:\n\n`;
|
|
briefing.entitiesToInclude.forEach((e) => (body += `- \`${e}\`\n`));
|
|
|
|
body += `\n### Empfohlene Gliederung\n\n`;
|
|
body += `Orientiere dich an dieser Struktur (du kannst sie anpassen):\n\n`;
|
|
briefing.suggestedHeadings.forEach(
|
|
(h, i) => (body += `${i + 1}. **${h}**\n`),
|
|
);
|
|
} else {
|
|
body += `## 🔍 Konkurrenz-Analyse\n\n`;
|
|
body += `> Für dieses Keyword konnte kein Konkurrent gescraped werden.\n`;
|
|
body += `> Schreibe den Artikel trotzdem — du hast weniger Wettbewerb!\n`;
|
|
}
|
|
|
|
body += `\n---\n\n`;
|
|
body += `## ✍️ Dein Content (hier schreiben)\n\n`;
|
|
body += `{/* Lösche alles oberhalb dieser Zeile, wenn dein Text fertig ist. */}\n\n`;
|
|
body += `Hier beginnt dein eigentlicher Artikel...\n`;
|
|
|
|
const file = `---
|
|
title: "${gap.recommendedTitle}"
|
|
description: "TODO: Meta-Description mit dem Keyword '${gap.targetKeyword}' schreiben."
|
|
date: "${dateStr}"
|
|
author: "${config.authorName || "Mintel SEO Engine"}"
|
|
tags: ["${gap.relatedCluster}"]
|
|
isDraft: true
|
|
focus_keyword: "${gap.targetKeyword}"
|
|
---
|
|
|
|
${body}`;
|
|
|
|
try {
|
|
await fs.writeFile(filePath, file, "utf8");
|
|
console.log(`[File Editor] Created draft: ${filePath}`);
|
|
createdFiles.push(filePath);
|
|
} catch (err) {
|
|
console.error(
|
|
`[File Editor] Failed to write ${filePath}:`,
|
|
(err as Error).message,
|
|
);
|
|
}
|
|
}
|
|
|
|
return createdFiles;
|
|
}
|