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);
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue