Export keyword demand as Markdown
This commit is contained in:
parent
bcc3d9e00e
commit
8379a4487f
|
|
@ -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<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: {
|
||||
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() {
|
|||
</button>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<button
|
||||
|
|
@ -16267,6 +16477,21 @@ export function App() {
|
|||
{activeKeywordContextToolbar}
|
||||
{topbarStageActions}
|
||||
<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 ? (
|
||||
<div className="seo-work-panel-export">
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in New Issue