From 8379a4487fab5d3e9cc95bc200f56968924f0e15 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 10 Jul 2026 10:00:47 +0300 Subject: [PATCH] Export keyword demand as Markdown --- seo_mode/seo_mode/app/src/App.tsx | 225 ++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index 7a791b3..453837a 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -2915,6 +2915,184 @@ function downloadClientFile(filename: string, blob: Blob) { 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; +}) { + 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: { keywordMap: KeywordMapContract | null; project: ProjectSummary; @@ -14365,6 +14543,9 @@ export function App() { activeKeywordContextSnapshotDetail?.snapshot.marketEnrichment ?? (isActiveKeywordContextSnapshotPending ? null : activeProject ? (marketEnrichments[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 = activeKeywordContextSnapshotDetail?.snapshot.keywordMap ?? (isActiveKeywordContextSnapshotPending ? null : activeProject ? (keywordMaps[activeProject.id] ?? null) : null); @@ -15026,6 +15207,35 @@ export function App() { ) : 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 = activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( + ) : null} {activeStageIndex === 5 ? (