Export keyword demand as Markdown

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 10:00:47 +03:00
parent bcc3d9e00e
commit 8379a4487f
1 changed files with 225 additions and 0 deletions

View File

@ -2915,6 +2915,184 @@ function downloadClientFile(filename: string, blob: Blob) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function getMarkdownValue(value: string | null | undefined, fallback = "нет данных") {
const normalized = value?.trim().replace(/\s+/g, " ");
return normalized || fallback;
}
function getKeywordExportStatusLabel(status: AnchorReviewAnchorView["decision"]["status"]) {
if (status === "approved") {
return "в Wordstat";
}
return getAnchorDecisionStatusLabel(status);
}
function appendMarkdownTable(lines: string[], headers: string[], rows: string[][], emptyLabel: string) {
lines.push(`| ${headers.map(escapeMarkdownTableCell).join(" | ")} |`);
lines.push(`| ${headers.map(() => "---").join(" | ")} |`);
if (rows.length === 0) {
lines.push(`| ${escapeMarkdownTableCell(emptyLabel)} | ${headers.slice(1).map(() => "").join(" | ")} |`);
return;
}
rows.forEach((row) => {
lines.push(`| ${row.map((cell) => escapeMarkdownTableCell(cell)).join(" | ")} |`);
});
}
function renderKeywordDemandExportMarkdown(input: {
anchorReview: AnchorReviewContract | null;
generatedAt: string;
keywordCleaning: KeywordCleaningContract | null;
keywordMap: KeywordMapContract | null;
marketEnrichment: MarketEnrichmentContract | null;
profileLabel: string;
profileStatus: "current" | "snapshot";
project: ProjectSummary;
suppressedItemIds: string[];
workflow: KeywordAnalysisWorkflowContract | null;
roleOverrides: Record<string, KeywordCurationRole>;
}) {
const lines: string[] = [];
const suppressedItemIdSet = new Set(input.suppressedItemIds);
const visibleAnchors = (input.anchorReview?.anchors ?? []).filter(
(anchor) => anchor.decision.status !== "disabled" && anchor.decision.status !== "deleted"
);
const visibleAnchorRows = visibleAnchors.map((anchor) => [
anchor.phrase,
getKeywordExportStatusLabel(anchor.decision.status),
anchor.proposal.sendToWordstat ? "да" : "нет",
anchor.decision.sendToWordstat ? "да" : "нет",
anchor.decision.sendToSerp ? "да" : "нет",
anchor.clusterTitle,
getMarkdownValue(getAnchorReviewReason(anchor))
]);
const wordstatSummary = input.marketEnrichment?.wordstat.summary ?? null;
const latestWordstatJob = input.marketEnrichment?.wordstat.latestJob ?? null;
const topWordstatRows = (input.marketEnrichment?.wordstat.topResults ?? [])
.slice()
.sort((left, right) => (right.frequency ?? -1) - (left.frequency ?? -1))
.slice(0, 20)
.map((result) => [
result.phrase,
formatCount(result.frequency),
getMarkdownValue(result.sourcePhrase, "без seed"),
getMarkdownValue(result.sourceClusterTitle, "кластер не указан")
]);
const curationGroups =
input.keywordMap && getKeywordCurationGroups(input.keywordMap, input.roleOverrides, suppressedItemIdSet);
lines.push(`# ${input.project.name} · ключи и спрос`);
lines.push("");
lines.push(`- Проект: ${input.project.name}`);
lines.push(`- Профиль: ${input.profileLabel}${input.profileStatus === "snapshot" ? " (snapshot)" : ""}`);
lines.push(`- Выгружено: ${formatDate(input.generatedAt)}`);
lines.push("- Скрытые и удалённые ключи исключены из выгрузки.");
lines.push("");
lines.push("## Этап 1. Семантический базис");
lines.push("");
if (!input.anchorReview) {
lines.push("Семантический базис ещё не сформирован.");
} else {
lines.push(
`Статус: ${getAnchorReviewStateLabel(input.anchorReview.state)}. Видимых ключей: ${visibleAnchors.length}. В Wordstat: ${
visibleAnchors.filter((anchor) => anchor.decision.status === "approved" && anchor.decision.sendToWordstat).length
}. На разборе: ${visibleAnchors.filter((anchor) => anchor.decision.status === "pending").length}.`
);
lines.push("");
appendMarkdownTable(
lines,
["Фраза", "Статус", "Предложено", "Wordstat", "SERP", "Кластер", "Причина"],
visibleAnchorRows,
"Нет видимых ключей"
);
}
lines.push("");
lines.push("## Этап 2. Аналитика спроса");
lines.push("");
if (!input.marketEnrichment && !input.keywordCleaning && !input.workflow) {
lines.push("Аналитика спроса ещё не собрана.");
} else {
if (input.workflow) {
lines.push(`- Workflow: ${input.workflow.statusLabel || input.workflow.status}`);
lines.push(`- Текущий шаг: ${input.workflow.activeStepLabel ?? "нет активного шага"}`);
lines.push(`- Резюме: ${getMarkdownValue(input.workflow.summary)}`);
}
if (input.marketEnrichment) {
lines.push(`- Профиль сбора: ${demandCollectionProfileCopy[input.marketEnrichment.demandCollectionProfile].label}`);
lines.push(`- Seed queue: ${input.marketEnrichment.seedQueue.length}`);
lines.push(
`- Wordstat: ${wordstatSummary ? `${formatCount(wordstatSummary.resultCount)} результатов, суммарный спрос ${formatCount(wordstatSummary.totalFrequency)}` : "нет сводки"}`
);
lines.push(
`- Последний Wordstat job: ${
latestWordstatJob
? `${latestWordstatJob.status}, запрошено ${formatCount(latestWordstatJob.requestedPhraseCount)}, получено ${formatCount(latestWordstatJob.resultCount)}`
: "нет job"
}`
);
}
if (input.keywordCleaning) {
lines.push(`- Чистка: ${input.keywordCleaning.summary}`);
lines.push(
`- Решения: use ${input.keywordCleaning.readiness.useCount}, support ${input.keywordCleaning.readiness.supportCount}, article ${input.keywordCleaning.readiness.articleCount}, risky ${input.keywordCleaning.readiness.riskyCount}, trash ${input.keywordCleaning.readiness.trashCount}`
);
lines.push(`- Кандидатов в карту: ${input.keywordCleaning.readiness.keywordMapCandidateCount}`);
}
lines.push("");
lines.push("### Топ Wordstat/related сигналов");
lines.push("");
appendMarkdownTable(
lines,
["Фраза", "Спрос", "Seed", "Кластер"],
topWordstatRows,
"Нет Wordstat/related сигналов"
);
}
lines.push("");
lines.push("## Этап 3. Распределение ключей");
lines.push("");
if (!input.keywordMap || !curationGroups) {
lines.push("Распределение ключей ещё не сформировано.");
} else {
const visibleKeywordCount = input.keywordMap.items.filter((item) => !suppressedItemIdSet.has(item.id)).length;
lines.push(
`Карта: ${getKeywordMapStateLabel(input.keywordMap.state)}. Видимых ключей: ${visibleKeywordCount}. Скрыто пользователем: ${suppressedItemIdSet.size}.`
);
lines.push("");
keywordCurationColumns.forEach((column) => {
const items = curationGroups.get(column.role) ?? [];
const rows = items.map((item) => [
item.phrase,
formatCount(item.frequency),
getMarketEvidenceStatusLabel(item.evidenceStatus),
getMarkdownValue(item.targetPath, "без страницы"),
getMarkdownValue(item.clusterTitle, "кластер не указан"),
getMarkdownValue(item.reason)
]);
lines.push(`### ${column.title}`);
lines.push("");
lines.push(`${column.description}. Ключей: ${items.length}.`);
lines.push("");
appendMarkdownTable(lines, ["Фраза", "Спрос", "Evidence", "Страница", "Кластер", "Причина"], rows, "Нет ключей");
lines.push("");
});
}
return `${lines.join("\n")}\n`;
}
function buildStrategyExportSnapshot(input: { function buildStrategyExportSnapshot(input: {
keywordMap: KeywordMapContract | null; keywordMap: KeywordMapContract | null;
project: ProjectSummary; project: ProjectSummary;
@ -14365,6 +14543,9 @@ export function App() {
activeKeywordContextSnapshotDetail?.snapshot.marketEnrichment ?? activeKeywordContextSnapshotDetail?.snapshot.marketEnrichment ??
(isActiveKeywordContextSnapshotPending ? null : activeProject ? (marketEnrichments[activeProject.id] ?? null) : null); (isActiveKeywordContextSnapshotPending ? null : activeProject ? (marketEnrichments[activeProject.id] ?? null) : null);
const activeKeywordAnalysisWorkflow = activeProject ? (keywordAnalysisWorkflows[activeProject.id] ?? null) : null; const activeKeywordAnalysisWorkflow = activeProject ? (keywordAnalysisWorkflows[activeProject.id] ?? null) : null;
const activeKeywordCleaning =
activeKeywordContextSnapshotDetail?.snapshot.keywordCleaning ??
(isActiveKeywordContextSnapshotPending ? null : activeProject ? (keywordCleanings[activeProject.id] ?? null) : null);
const activeKeywordMap = const activeKeywordMap =
activeKeywordContextSnapshotDetail?.snapshot.keywordMap ?? activeKeywordContextSnapshotDetail?.snapshot.keywordMap ??
(isActiveKeywordContextSnapshotPending ? null : activeProject ? (keywordMaps[activeProject.id] ?? null) : null); (isActiveKeywordContextSnapshotPending ? null : activeProject ? (keywordMaps[activeProject.id] ?? null) : null);
@ -15026,6 +15207,35 @@ export function App() {
</button> </button>
</div> </div>
) : null; ) : null;
const canExportActiveKeywordDemand = Boolean(
activeProject &&
activeStageIndex === 4 &&
!isActiveKeywordContextSnapshotLoading &&
(activeAnchorReview || activeMarketEnrichment || activeKeywordCleaning || activeKeywordMap)
);
function handleKeywordDemandExport(project: ProjectSummary) {
const markdown = renderKeywordDemandExportMarkdown({
anchorReview: activeAnchorReview,
generatedAt: new Date().toISOString(),
keywordCleaning: activeKeywordCleaning,
keywordMap: activeKeywordMap,
marketEnrichment: activeMarketEnrichment,
profileLabel: activeKeywordContextProfileLabel,
profileStatus: isActiveKeywordContextSnapshotReplay ? "snapshot" : "current",
project,
roleOverrides: activeKeywordCurationOverrides,
suppressedItemIds: activeKeywordCurationSuppressedIds,
workflow: isActiveKeywordContextSnapshotReplay ? null : activeKeywordAnalysisWorkflow
});
const fileBase = `${sanitizeExportFilename(project.name)}-keywords-${new Date().toISOString().slice(0, 10)}`;
downloadClientFile(`${fileBase}.md`, new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
setProjectActionProjectId(project.id);
setProjectActionError(null);
setProjectActionMessage(`Ключи и спрос выгружены в Markdown: ${fileBase}.md`);
}
const topbarStageActions = const topbarStageActions =
activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( activeProject && activeProjectScanSummary && activeStageIndex === 1 ? (
<button <button
@ -16267,6 +16477,21 @@ export function App() {
{activeKeywordContextToolbar} {activeKeywordContextToolbar}
{topbarStageActions} {topbarStageActions}
<div className="seo-work-panel-actions"> <div className="seo-work-panel-actions">
{activeStageIndex === 4 ? (
<button
aria-label="Скачать ключи и спрос"
disabled={!canExportActiveKeywordDemand}
onClick={() => handleKeywordDemandExport(activeProject)}
title={
isActiveKeywordContextSnapshotLoading
? "Snapshot ещё загружается"
: "Скачать ключи и спрос в Markdown"
}
type="button"
>
<Download size={16} />
</button>
) : null}
{activeStageIndex === 5 ? ( {activeStageIndex === 5 ? (
<div className="seo-work-panel-export"> <div className="seo-work-panel-export">
<button <button