Fix SEO strategy decision contracts

This commit is contained in:
DCCONSTRUCTIONS 2026-06-30 16:11:25 +03:00
parent 07337970ca
commit ccfcc24066
7 changed files with 596 additions and 129 deletions

View File

@ -427,7 +427,15 @@ type KeywordMapItemView = KeywordMapContract["items"][number];
type KeywordPagePlanStatus = "blocked" | "ready" | "review"; type KeywordPagePlanStatus = "blocked" | "ready" | "review";
type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen"; type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen";
type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support"; type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support";
type StrategyKeywordDecisionRole = "article" | "defer" | "differentiator" | "primary" | "secondary" | "support" | "trash"; type StrategyKeywordDecisionRole =
| "article"
| "defer"
| "differentiator"
| "primary"
| "secondary"
| "secondary_candidate"
| "support"
| "trash";
type StrategyForecastCtrModel = "base" | "optimistic" | "safe"; type StrategyForecastCtrModel = "base" | "optimistic" | "safe";
type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe"; type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe";
type StrategyDecisionLevel = "high" | "low" | "medium"; type StrategyDecisionLevel = "high" | "low" | "medium";
@ -445,6 +453,7 @@ type StrategyKeywordDecisionRow = {
intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null; intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null;
phrase: string; phrase: string;
reason: string; reason: string;
relatedLanding: string | null;
role: StrategyKeywordDecisionRole; role: StrategyKeywordDecisionRole;
targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null; targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null;
targetPath: string | null; targetPath: string | null;
@ -499,7 +508,22 @@ type StrategyLandingPortfolioLane = {
type StrategyDecisionContract = { type StrategyDecisionContract = {
conversionRate: number; conversionRate: number;
deferredDemand: number; deferredDemand: number;
forecastModel: StrategyForecastCtrModel; demandComposition: Array<{
demand: number;
label: string;
phrases: string[];
role: "differentiator" | "primary" | "support";
}>;
differentiatorClusters: string[];
forecastModel: {
conversionRate: number;
horizonLabel: string;
horizonMonths: number;
id: StrategyForecastCtrModel;
label: string;
visibilityMax: number;
visibilityMin: number;
};
horizonMonths: number; horizonMonths: number;
landing: string | null; landing: string | null;
nextStage: string; nextStage: string;
@ -507,6 +531,7 @@ type StrategyDecisionContract = {
primaryCluster: string; primaryCluster: string;
scenarioId: string; scenarioId: string;
scenarioLabel: string; scenarioLabel: string;
selectedScenarioId: string;
selectedDemand: number; selectedDemand: number;
supportClusters: string[]; supportClusters: string[];
visibilityMax: number; visibilityMax: number;
@ -558,7 +583,11 @@ type StrategyExportSnapshot = {
nextStage: string; nextStage: string;
primaryCluster: string; primaryCluster: string;
scenario: string; scenario: string;
selectedScenarioId: string;
selectedDemand: number; selectedDemand: number;
demandComposition: StrategyDecisionContract["demandComposition"];
differentiatorClusters: string[];
forecastModel: StrategyDecisionContract["forecastModel"];
supportClusters: string[]; supportClusters: string[];
}; };
domMirror?: { domMirror?: {
@ -1155,10 +1184,10 @@ function getKeywordRoleBars(keywordMap: KeywordMapContract): SeoChartDatum[] {
return accumulator; return accumulator;
}, {}); }, {});
return (["primary", "secondary", "support", "validate"] as const).map((role) => ({ return (["primary", "secondary", "support", "differentiator", "secondary_candidate", "validate"] as const).map((role) => ({
detail: getKeywordMapRoleLabel(role), detail: getKeywordMapRoleLabel(role),
label: getKeywordMapRoleLabel(role), label: getKeywordMapRoleLabel(role),
tone: role === "primary" ? "good" : role === "validate" ? "warning" : "neutral", tone: role === "primary" || role === "differentiator" ? "good" : role === "validate" || role === "secondary_candidate" ? "warning" : "neutral",
value: counts[role] ?? 0 value: counts[role] ?? 0
})); }));
} }
@ -1183,12 +1212,14 @@ function getKeywordTargetBars(keywordMap: KeywordMapContract): SeoChartDatum[] {
function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum { function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum {
const shortRoleLabels = { const shortRoleLabels = {
differentiator: "дифф.",
primary: "главн.", primary: "главн.",
secondary: "доп.", secondary: "доп.",
secondary_candidate: "канд.",
support: "подд.", support: "подд.",
validate: "пров." validate: "пров."
}; };
const columns = (["primary", "secondary", "support", "validate"] as const).map((role) => ({ const columns = (["primary", "secondary", "support", "differentiator", "secondary_candidate", "validate"] as const).map((role) => ({
key: role, key: role,
label: shortRoleLabels[role] label: shortRoleLabels[role]
})); }));
@ -1219,6 +1250,7 @@ function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum {
function isKeywordPlanItemPersistable(item: KeywordMapItemView) { function isKeywordPlanItemPersistable(item: KeywordMapItemView) {
return ( return (
item.role !== "validate" && item.role !== "validate" &&
item.role !== "secondary_candidate" &&
item.evidenceStatus === "collected" && item.evidenceStatus === "collected" &&
item.frequency !== null && item.frequency !== null &&
Boolean(item.projectOntologyVersionId) && Boolean(item.projectOntologyVersionId) &&
@ -1228,8 +1260,10 @@ function isKeywordPlanItemPersistable(item: KeywordMapItemView) {
function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) { function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) {
const labels = { const labels = {
differentiator: "секция / FAQ",
primary: "title / H1", primary: "title / H1",
secondary: "meta description", secondary: "meta description",
secondary_candidate: "проверить SERP",
support: "секция страницы", support: "секция страницы",
validate: "ручная проверка" validate: "ручная проверка"
}; };
@ -1316,8 +1350,10 @@ function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) {
const roleRank: Record<KeywordMapItemView["role"], number> = { const roleRank: Record<KeywordMapItemView["role"], number> = {
primary: 0, primary: 0,
secondary: 1, secondary: 1,
support: 2, differentiator: 2,
validate: 3 support: 3,
secondary_candidate: 4,
validate: 5
}; };
const groups = keywordMap.items.reduce<Map<string, KeywordMapItemView[]>>((accumulator, item) => { const groups = keywordMap.items.reduce<Map<string, KeywordMapItemView[]>>((accumulator, item) => {
const key = item.targetPath ?? "target pending"; const key = item.targetPath ?? "target pending";
@ -1334,7 +1370,7 @@ function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) {
accumulator[item.role] += 1; accumulator[item.role] += 1;
return accumulator; return accumulator;
}, },
{ primary: 0, secondary: 0, support: 0, validate: 0 } { differentiator: 0, primary: 0, secondary: 0, secondary_candidate: 0, support: 0, validate: 0 }
); );
const persistableItems = items.filter(isKeywordPlanItemPersistable); const persistableItems = items.filter(isKeywordPlanItemPersistable);
const reviewItems = items.filter((item) => !isKeywordPlanItemPersistable(item)); const reviewItems = items.filter((item) => !isKeywordPlanItemPersistable(item));
@ -1781,6 +1817,7 @@ function getStrategyKeywordRoleLabel(role: StrategyKeywordDecisionRole) {
differentiator: "дифференциатор", differentiator: "дифференциатор",
primary: "основная", primary: "основная",
secondary: "дополнительная", secondary: "дополнительная",
secondary_candidate: "кандидат",
support: "поддержка", support: "поддержка",
trash: "исключить" trash: "исключить"
}; };
@ -1795,6 +1832,7 @@ function getStrategyKeywordRoleClass(role: StrategyKeywordDecisionRole) {
differentiator: "partial_ready", differentiator: "partial_ready",
primary: "collected", primary: "collected",
secondary: "collected", secondary: "collected",
secondary_candidate: "ready_for_collection",
support: "ready_for_collection", support: "ready_for_collection",
trash: "not_configured" trash: "not_configured"
}; };
@ -1812,6 +1850,9 @@ function getStrategyKeywordDecisionRole(
return "trash"; return "trash";
} }
if (item.role === "differentiator") return "differentiator";
if (item.role === "secondary_candidate") return "secondary_candidate";
if (serpPhrase?.recommendedRole === "article_backlog" || reason.includes("article") || reason.includes("backlog")) { if (serpPhrase?.recommendedRole === "article_backlog" || reason.includes("article") || reason.includes("backlog")) {
return "article"; return "article";
} }
@ -1936,9 +1977,10 @@ function getStrategyKeywordRoleRank(role: StrategyKeywordDecisionRole) {
secondary: 1, secondary: 1,
differentiator: 2, differentiator: 2,
support: 3, support: 3,
article: 4, secondary_candidate: 4,
defer: 5, article: 5,
trash: 6 defer: 6,
trash: 7
}; };
return ranks[role]; return ranks[role];
@ -1961,6 +2003,10 @@ function getStrategyShortReason(row: StrategyKeywordDecisionRow) {
return row.intent === "informational" ? "инфо-интент" : "в отдельный материал"; return row.intent === "informational" ? "инфо-интент" : "в отдельный материал";
} }
if (row.role === "secondary_candidate") {
return "кандидат на проверку";
}
if (row.role === "defer") { if (row.role === "defer") {
return "нужна проверка"; return "нужна проверка";
} }
@ -2008,6 +2054,9 @@ function getStrategyKeywordRows(
const serpPhrase = serpByPhrase.get(item.phrase); const serpPhrase = serpByPhrase.get(item.phrase);
const role = getStrategyKeywordDecisionRole(item, serpPhrase); const role = getStrategyKeywordDecisionRole(item, serpPhrase);
const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason); const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason);
const usesDirectTarget = role !== "article" && role !== "defer" && role !== "secondary_candidate";
const targetPath = usesDirectTarget ? (item.targetPath ?? serpPhrase?.targetPath ?? null) : item.targetPath;
const relatedLanding = item.relatedLanding ?? (!targetPath ? (serpPhrase?.targetPath ?? null) : null);
return { return {
clusterTitle: item.clusterTitle, clusterTitle: item.clusterTitle,
@ -2017,9 +2066,10 @@ function getStrategyKeywordRows(
intent: serpPhrase?.intent ?? null, intent: serpPhrase?.intent ?? null,
phrase: item.phrase, phrase: item.phrase,
reason, reason,
relatedLanding,
role, role,
targetFit: serpPhrase?.targetFit ?? null, targetFit: serpPhrase?.targetFit ?? null,
targetPath: item.targetPath ?? serpPhrase?.targetPath ?? null targetPath
}; };
}) })
.filter((row) => row.role !== "trash") .filter((row) => row.role !== "trash")
@ -2066,6 +2116,115 @@ function getStrategyPrimaryTargetPath(
return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null; return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null;
} }
function isStrategySelectedPlanRole(role: StrategyKeywordDecisionRole) {
return role === "primary" || role === "secondary" || role === "support" || role === "differentiator";
}
function isStrategyDeferredRole(role: StrategyKeywordDecisionRole) {
return role === "article" || role === "defer" || role === "secondary_candidate";
}
function getStrategyScenarioLabel(id: string | null | undefined) {
if (id === "landing_first") return "A · рекомендовано";
if (id === "homepage_only") return "B · осторожно";
if (id === "landing_plus_article") return "C · рост";
if (id === "broad_attack") return "D · агрессивно";
return "A · рекомендовано";
}
function buildStrategyForecastModel(
forecastModelId: StrategyForecastCtrModel,
conversionRate: number,
horizonMonths: number
): StrategyDecisionContract["forecastModel"] {
const ctrRange = STRATEGY_CTR_RANGES[forecastModelId];
return {
conversionRate,
horizonLabel: `к ${horizonMonths}-му месяцу после индексации`,
horizonMonths,
id: forecastModelId,
label: ctrRange.label,
visibilityMax: ctrRange.max,
visibilityMin: ctrRange.min
};
}
function getStrategyDemandComposition(rows: StrategyKeywordDecisionRow[]) {
const groups = rows.reduce<
Record<StrategyDecisionContract["demandComposition"][number]["role"], { demand: number; phrases: string[] }>
>(
(accumulator, row) => {
const role = row.role === "differentiator" ? "differentiator" : row.role === "primary" ? "primary" : "support";
accumulator[role].demand += row.frequency ?? 0;
if (row.phrase) {
accumulator[role].phrases.push(row.phrase);
}
return accumulator;
},
{
differentiator: { demand: 0, phrases: [] },
primary: { demand: 0, phrases: [] },
support: { demand: 0, phrases: [] }
}
);
return ([
{ label: "Основная ставка", role: "primary" as const },
{ label: "Support-фразы", role: "support" as const },
{ label: "Дифференциатор", role: "differentiator" as const }
] satisfies Array<{ label: string; role: StrategyDecisionContract["demandComposition"][number]["role"] }>)
.map((group) => ({
demand: groups[group.role].demand,
label: group.label,
phrases: groups[group.role].phrases.slice(0, 8),
role: group.role
}))
.filter((group) => group.demand > 0 || group.phrases.length > 0);
}
function createStrategyDecisionContract(input: {
conversionRate: number;
deferredDemand: number;
demandComposition: StrategyDecisionContract["demandComposition"];
differentiatorClusters: string[];
forecastModelId: StrategyForecastCtrModel;
horizonMonths: number;
landing: string | null;
nextStage: string;
notPrimaryPhrases: string[];
primaryCluster: string;
scenarioId: string;
scenarioLabel: string;
selectedDemand: number;
supportClusters: string[];
visibilityMax: number;
visibilityMin: number;
}): StrategyDecisionContract {
return {
conversionRate: input.conversionRate,
deferredDemand: input.deferredDemand,
demandComposition: input.demandComposition,
differentiatorClusters: input.differentiatorClusters,
forecastModel: buildStrategyForecastModel(input.forecastModelId, input.conversionRate, input.horizonMonths),
horizonMonths: input.horizonMonths,
landing: input.landing,
nextStage: input.nextStage,
notPrimaryPhrases: input.notPrimaryPhrases,
primaryCluster: input.primaryCluster,
scenarioId: input.scenarioId,
scenarioLabel: input.scenarioLabel,
selectedScenarioId: input.scenarioId,
selectedDemand: input.selectedDemand,
supportClusters: input.supportClusters,
visibilityMax: input.visibilityMax,
visibilityMin: input.visibilityMin
};
}
function sanitizeExportFilename(value: string) { function sanitizeExportFilename(value: string) {
const sanitized = value const sanitized = value
.trim() .trim()
@ -2105,34 +2264,35 @@ function buildStrategyExportSnapshot(input: {
const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy); const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy);
const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows; const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows;
const decisionRows = rowsForLanding.filter((row) => { const decisionRows = rowsForLanding.filter((row) => {
return ( return isStrategySelectedPlanRole(row.role) && row.intent !== "informational" && row.targetFit !== "mismatch";
(row.role === "primary" || row.role === "secondary" || row.role === "support") &&
row.intent !== "informational" &&
row.targetFit !== "mismatch"
);
}); });
const notPrimaryRows = keywordRows.filter((row) => { const notPrimaryRows = keywordRows.filter((row) => {
return ( return isStrategyDeferredRole(row.role) || row.intent === "informational" || row.targetFit === "mismatch" || !row.targetPath;
row.role === "article" ||
row.role === "defer" ||
row.intent === "informational" ||
row.targetFit === "mismatch" ||
!row.targetPath
);
}); });
const selectedDemand = input.selectedContract?.selectedDemand ?? sumStrategyKeywordDemand(decisionRows); const selectedDemandFromRows = sumStrategyKeywordDemand(decisionRows);
const selectedDemand = input.selectedContract?.selectedDemand ?? selectedDemandFromRows;
const totalDemand = const totalDemand =
input.yandexEvidence?.summary.totalDemand ?? input.yandexEvidence?.summary.totalDemand ??
input.serpInterpretation?.yandexEvidence.totalDemand ?? input.serpInterpretation?.yandexEvidence.totalDemand ??
input.seoStrategy?.evidence.demand.totalFrequency ?? input.seoStrategy?.evidence.demand.totalFrequency ??
sumStrategyKeywordDemand(keywordRows); sumStrategyKeywordDemand(keywordRows);
const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand); const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand);
const differentiatorClusters =
input.selectedContract?.differentiatorClusters ??
Array.from(new Set(decisionRows.filter((row) => row.role === "differentiator").map((row) => row.clusterTitle).filter(Boolean))).slice(0, 4);
const supportClusters = const supportClusters =
input.selectedContract?.supportClusters ?? input.selectedContract?.supportClusters ??
Array.from(new Set(decisionRows.map((row) => row.clusterTitle).filter(Boolean))).slice(1, 4); Array.from(
new Set(
decisionRows
.filter((row) => row.role === "secondary" || row.role === "support")
.map((row) => row.clusterTitle)
.filter(Boolean)
)
).slice(0, 4);
const primaryCluster = const primaryCluster =
input.selectedContract?.primaryCluster || input.selectedContract?.primaryCluster ||
getStrategyTopCluster(decisionRows) || getStrategyTopCluster(decisionRows.filter((row) => row.role !== "differentiator")) ||
input.seoStrategy?.opportunities[0]?.title || input.seoStrategy?.opportunities[0]?.title ||
"Кластер не выбран"; "Кластер не выбран";
const landing = input.selectedContract?.landing ?? primaryTargetPath; const landing = input.selectedContract?.landing ?? primaryTargetPath;
@ -2140,18 +2300,41 @@ function buildStrategyExportSnapshot(input: {
const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05; const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05;
const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate; const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate;
const horizonMonths = input.selectedContract?.horizonMonths ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.horizonMonths; const horizonMonths = input.selectedContract?.horizonMonths ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.horizonMonths;
const forecastModelId = input.selectedContract?.forecastModel.id ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.ctrModel;
const forecast = `${formatForecastRange(visibilityMin, visibilityMax, " визитов/мес.")}, ${formatForecastRange( const forecast = `${formatForecastRange(visibilityMin, visibilityMax, " визитов/мес.")}, ${formatForecastRange(
visibilityMin * conversionRate, visibilityMin * conversionRate,
visibilityMax * conversionRate, visibilityMax * conversionRate,
" лидов/мес." " лидов/мес."
)}`; )}`;
const scenario = input.selectedContract?.scenarioLabel ?? input.selectedScenarioId ?? "Рекомендованный сценарий"; const selectedScenarioId = input.selectedContract?.selectedScenarioId ?? input.selectedContract?.scenarioId ?? input.selectedScenarioId ?? "landing_first";
const scenario = input.selectedContract?.scenarioLabel ?? getStrategyScenarioLabel(selectedScenarioId);
const notPrimaryPhrases = const notPrimaryPhrases =
input.selectedContract?.notPrimaryPhrases ?? input.selectedContract?.notPrimaryPhrases ??
notPrimaryRows notPrimaryRows
.map((row) => row.phrase) .map((row) => row.phrase)
.filter(Boolean) .filter(Boolean)
.slice(0, 12); .slice(0, 12);
const demandComposition = input.selectedContract?.demandComposition ?? getStrategyDemandComposition(decisionRows);
const selectedContract =
input.selectedContract ??
createStrategyDecisionContract({
conversionRate,
deferredDemand,
demandComposition,
differentiatorClusters,
forecastModelId,
horizonMonths,
landing,
nextStage: landing ? `План правок для ${landing}` : "План правок",
notPrimaryPhrases,
primaryCluster,
scenarioId: selectedScenarioId,
scenarioLabel: scenario,
selectedDemand,
supportClusters,
visibilityMax,
visibilityMin
});
const productLabel = getReadablePathProductLabel(landing); const productLabel = getReadablePathProductLabel(landing);
const siteTitle = getProjectSiteTitle(input.scanSummary); const siteTitle = getProjectSiteTitle(input.scanSummary);
const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim(); const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim();
@ -2175,7 +2358,7 @@ function buildStrategyExportSnapshot(input: {
formatCount(row.frequency), formatCount(row.frequency),
row.clusterTitle, row.clusterTitle,
getStrategyKeywordRoleLabel(row.role), getStrategyKeywordRoleLabel(row.role),
row.targetPath ?? "URL не выбран", row.targetPath ?? (row.relatedLanding ? `связано с ${row.relatedLanding}` : "URL не выбран"),
evidenceRefs.join("; ") || "связь из карты фраз" evidenceRefs.join("; ") || "связь из карты фраз"
]; ];
}); });
@ -2220,11 +2403,19 @@ function buildStrategyExportSnapshot(input: {
value: `${landing ?? "Посадочная не выбрана"} закрывает «${primaryCluster}»` value: `${landing ?? "Посадочная не выбрана"} закрывает «${primaryCluster}»`
}, },
{ {
detail: `${formatPercent(conversionRate)} конверсии, горизонт ${horizonMonths} мес.`, detail: `${formatPercent(conversionRate)} конверсии, ${selectedContract.forecastModel.horizonLabel}. Не обещание результата.`,
label: "Сценарный прогноз", label: "Прогноз к 3-му месяцу",
sourceId: "yandex-evidence.summary", sourceId: "yandex-evidence.summary",
value: forecast value: forecast
}, },
{
detail: demandComposition.length
? demandComposition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ")
: "Состав выбранного спроса не выделен.",
label: "Состав спроса",
sourceId: "keyword-map.items",
value: formatCount(selectedDemand)
},
{ {
detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.", detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.",
label: "Поддерживающие кластеры", label: "Поддерживающие кластеры",
@ -2242,7 +2433,7 @@ function buildStrategyExportSnapshot(input: {
detail: "Это вход для следующего этапа, сайт на этом шаге не меняется.", detail: "Это вход для следующего этапа, сайт на этом шаге не меняется.",
label: "Следующий этап", label: "Следующий этап",
sourceId: "seo-strategy.opportunities", sourceId: "seo-strategy.opportunities",
value: input.selectedContract?.nextStage ?? "План правок" value: selectedContract.nextStage
}, },
{ {
detail: notPrimaryPhrases.length ? notPrimaryPhrases.join(", ") : "Нет явных отложенных фраз.", detail: notPrimaryPhrases.length ? notPrimaryPhrases.join(", ") : "Нет явных отложенных фраз.",
@ -2299,17 +2490,29 @@ function buildStrategyExportSnapshot(input: {
]; ];
const sourceRoutes: StrategyExportRoute[] = [ const sourceRoutes: StrategyExportRoute[] = [
{ {
calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз.", calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз. Article/backlog использует relatedLanding, а не прямой targetPath.",
element: "decision.landing", element: "decision.landing",
jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"], jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"],
value: landing value: landing
}, },
{ {
calculation: "Сумма frequency по строкам, которые идут в выбранную посадочную и не являются информационным/спорным интентом.", calculation: "Сумма frequency по строкам выбранной посадочной с ролями primary/secondary/support/differentiator. Инфо, mismatch и secondary_candidate не входят.",
element: "decision.selectedDemand", element: "decision.selectedDemand",
jsonRoutes: ["keywordMap.items[].frequency", "serpInterpretation.phraseInterpretations[].intent", "keywordMap.items[].targetPath"], jsonRoutes: [
"keywordMap.items[].frequency",
"keywordMap.items[].role",
"keywordMap.items[].targetPath",
"keywordMap.items[].relatedLanding",
"serpInterpretation.phraseInterpretations[].intent"
],
value: selectedDemand value: selectedDemand
}, },
{
calculation: "Разложение выбранного спроса по ролям: основная ставка, support-фразы и дифференциатор.",
element: "decision.demandComposition",
jsonRoutes: ["keywordMap.items[].role", "keywordMap.items[].frequency", "keywordMap.items[].phrase"],
value: demandComposition.map((part) => `${part.label}: ${formatCount(part.demand)}`).join("; ")
},
{ {
calculation: "Проверенный спрос минус спрос, выбранный в текущий план.", calculation: "Проверенный спрос минус спрос, выбранный в текущий план.",
element: "decision.deferredDemand", element: "decision.deferredDemand",
@ -2333,19 +2536,23 @@ function buildStrategyExportSnapshot(input: {
return { return {
decision: { decision: {
deferredDemand, deferredDemand,
demandComposition,
differentiatorClusters,
forecast, forecast,
forecastModel: selectedContract.forecastModel,
landing, landing,
nextStage: input.selectedContract?.nextStage ?? "План правок", nextStage: selectedContract.nextStage,
primaryCluster, primaryCluster,
scenario, scenario,
selectedScenarioId,
selectedDemand, selectedDemand,
supportClusters supportClusters
}, },
evidenceContracts: { evidenceContracts: {
keywordMap: input.keywordMap, keywordMap: input.keywordMap,
scanSummary: input.scanSummary, scanSummary: input.scanSummary,
selectedContract: input.selectedContract, selectedContract,
selectedScenarioId: input.selectedScenarioId, selectedScenarioId,
seoStrategy: input.seoStrategy, seoStrategy: input.seoStrategy,
serpInterpretation: input.serpInterpretation, serpInterpretation: input.serpInterpretation,
yandexEvidence: input.yandexEvidence yandexEvidence: input.yandexEvidence
@ -2386,6 +2593,24 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) {
const routes = route.jsonRoutes.map((jsonRoute) => `\`${jsonRoute}\``).join(", "); const routes = route.jsonRoutes.map((jsonRoute) => `\`${jsonRoute}\``).join(", ");
return `- **${route.element}:** ${String(route.value ?? "n/a")} · ${route.calculation ?? "расчёт не указан"} · ${routes}`; return `- **${route.element}:** ${String(route.value ?? "n/a")} · ${route.calculation ?? "расчёт не указан"} · ${routes}`;
}), }),
"",
"## Контракт решения",
`- **Сценарий:** ${snapshot.decision.scenario} (\`${snapshot.decision.selectedScenarioId}\`)`,
`- **Посадочная:** ${snapshot.decision.landing ?? "URL не выбран"}`,
`- **Основной кластер:** ${snapshot.decision.primaryCluster}`,
`- **В текущий план:** ${formatCount(snapshot.decision.selectedDemand)}`,
`- **Отложено:** ${formatCount(snapshot.decision.deferredDemand)}`,
`- **Прогноз:** ${snapshot.decision.forecast}; ${snapshot.decision.forecastModel.horizonLabel}`,
"",
"### Состав выбранного спроса",
...(
snapshot.decision.demandComposition.length
? snapshot.decision.demandComposition.map(
(part) =>
`- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : "фразы не перечислены"}`
)
: ["- Состав не выделен."]
),
"" ""
]; ];
@ -2427,6 +2652,23 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) {
"", "",
"## Источники", "## Источники",
...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`), ...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`),
"",
"## Контракт решения",
`- **Сценарий:** ${snapshot.decision.scenario} (\`${snapshot.decision.selectedScenarioId}\`)`,
`- **Посадочная:** ${snapshot.decision.landing ?? "URL не выбран"}`,
`- **В текущий план:** ${formatCount(snapshot.decision.selectedDemand)}`,
`- **Отложено:** ${formatCount(snapshot.decision.deferredDemand)}`,
`- **Прогноз:** ${snapshot.decision.forecast}; ${snapshot.decision.forecastModel.horizonLabel}`,
"",
"### Состав выбранного спроса",
...(
snapshot.decision.demandComposition.length
? snapshot.decision.demandComposition.map(
(part) =>
`- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : "фразы не перечислены"}`
)
: ["- Состав не выделен."]
),
"" ""
]; ];
@ -2929,8 +3171,12 @@ function getSeoStrategyDemandBars(strategy: SeoStrategyContract): SeoChartDatum[
} }
function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatum[] { function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatum[] {
const connectedCount = strategy.evidence.providers.filter((provider) => provider.status === "connected").length; const connectedCount = strategy.evidence.providers.filter(
const plannedCount = strategy.evidence.providers.filter((provider) => provider.status !== "connected").length; (provider) => provider.status === "connected" || provider.status === "connected_partial"
).length;
const plannedCount = strategy.evidence.providers.filter(
(provider) => provider.status !== "connected" && provider.status !== "connected_partial"
).length;
return [ return [
{ {
@ -3992,6 +4238,7 @@ function getMarketStateLabel(state: MarketEnrichmentContract["state"]) {
function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) { function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) {
const labels = { const labels = {
connected: "подключено", connected: "подключено",
connected_partial: "частично подключено",
disabled: "выключено", disabled: "выключено",
not_configured: "не настроено", not_configured: "не настроено",
not_implemented: "планируется" not_implemented: "планируется"
@ -4054,8 +4301,10 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) {
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) { function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
const labels = { const labels = {
differentiator: "дифф.",
primary: "главный", primary: "главный",
secondary: "доп.", secondary: "доп.",
secondary_candidate: "кандидат",
support: "поддержка", support: "поддержка",
validate: "проверить" validate: "проверить"
}; };
@ -8791,7 +9040,7 @@ export function App() {
(row) => row.role === "primary" || row.role === "secondary" || row.role === "support" (row) => row.role === "primary" || row.role === "secondary" || row.role === "support"
); );
const strategyArticleOrDeferredRows = strategyKeywordRows.filter( const strategyArticleOrDeferredRows = strategyKeywordRows.filter(
(row) => row.role === "article" || row.role === "defer" || row.targetFit === "mismatch" || !row.targetPath (row) => isStrategyDeferredRole(row.role) || row.targetFit === "mismatch" || !row.targetPath
); );
const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce<Map<string, number>>((accumulator, row) => { const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce<Map<string, number>>((accumulator, row) => {
if (!row.targetPath) { if (!row.targetPath) {
@ -8811,14 +9060,16 @@ export function App() {
: []; : [];
const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter( const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter(
(row) => (row) =>
(row.role === "primary" || row.role === "secondary" || row.role === "support") && isStrategySelectedPlanRole(row.role) &&
row.targetFit !== "mismatch" && row.targetFit !== "mismatch" &&
row.intent !== "informational" row.intent !== "informational"
); );
const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => { const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget
accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); .filter((row) => row.role !== "differentiator" && !isStrategyDeferredRole(row.role))
return accumulator; .reduce<Map<string, number>>((accumulator, row) => {
}, new Map()); accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0));
return accumulator;
}, new Map());
const strategyPrimaryCluster = const strategyPrimaryCluster =
Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ??
seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ??
@ -8875,7 +9126,7 @@ export function App() {
const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand; const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand;
const strategySecondaryClusters = Array.from( const strategySecondaryClusters = Array.from(
strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => { strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => {
if (row.clusterTitle === strategyPrimaryCluster) { if (row.clusterTitle === strategyPrimaryCluster || row.role === "differentiator" || isStrategyDeferredRole(row.role)) {
return accumulator; return accumulator;
} }
@ -8887,7 +9138,9 @@ export function App() {
.map(([cluster]) => cluster) .map(([cluster]) => cluster)
.slice(0, 3); .slice(0, 3);
const strategyDifferentiatorCluster = const strategyDifferentiatorCluster =
strategySecondaryClusters[0] ?? null; strategyRowsForPrimaryTarget.find((row) => row.role === "differentiator")?.clusterTitle ??
strategySecondaryClusters[0] ??
null;
const strategyDisplayKeywordRows: StrategyKeywordDecisionRow[] = strategyKeywordRows.map((row) => { const strategyDisplayKeywordRows: StrategyKeywordDecisionRow[] = strategyKeywordRows.map((row) => {
if ( if (
row.targetPath === strategyPrimaryTargetPath && row.targetPath === strategyPrimaryTargetPath &&
@ -9011,7 +9264,7 @@ export function App() {
{ {
id: "article", id: "article",
label: "В статью / очередь", label: "В статью / очередь",
rows: strategyVisibleKeywordRows.filter((row) => row.role === "article" || row.role === "defer") rows: strategyVisibleKeywordRows.filter((row) => isStrategyDeferredRole(row.role))
}, },
{ {
id: "reject", id: "reject",
@ -9020,8 +9273,7 @@ export function App() {
} }
].filter((group) => group.rows.length > 0); ].filter((group) => group.rows.length > 0);
const isStrategyLandingKeywordRow = (row: StrategyKeywordDecisionRow) => const isStrategyLandingKeywordRow = (row: StrategyKeywordDecisionRow) =>
row.role !== "article" && !isStrategyDeferredRole(row.role) &&
row.role !== "defer" &&
row.targetFit !== "mismatch" && row.targetFit !== "mismatch" &&
row.intent !== "informational"; row.intent !== "informational";
const strategyLandingGroups = strategyDisplayKeywordRows.reduce<Map<string, StrategyKeywordDecisionRow[]>>((accumulator, row) => { const strategyLandingGroups = strategyDisplayKeywordRows.reduce<Map<string, StrategyKeywordDecisionRow[]>>((accumulator, row) => {
@ -9437,7 +9689,7 @@ export function App() {
strategyCtrRange.min strategyCtrRange.min
)}${formatPercent(strategyCtrRange.max)} видимости, ${formatPercent( )}${formatPercent(strategyCtrRange.max)} видимости, ${formatPercent(
strategyForecastSettings.conversionRate strategyForecastSettings.conversionRate
)} конверсии, ${strategyForecastSettings.horizonMonths} мес.`, )} конверсии, к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации.`,
label: "Модель прогноза", label: "Модель прогноза",
tone: "neutral", tone: "neutral",
value: selectedStrategyScenario value: selectedStrategyScenario
@ -9511,6 +9763,11 @@ export function App() {
)}` )}`
] ]
: []; : [];
const strategySelectedDemandComposition = getStrategyDemandComposition(strategySelectedKeywordRows);
const strategySelectedDemandCompositionDetail =
strategySelectedDemandComposition.length > 0
? strategySelectedDemandComposition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ")
: (strategyPrimaryTargetPath ?? "посадочная не выбрана");
const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [ const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [
{ {
detail: "очищенный пул спроса, который используется как база решения", detail: "очищенный пул спроса, который используется как база решения",
@ -9519,7 +9776,7 @@ export function App() {
value: formatCount(strategyTotalAnalyzedDemand) value: formatCount(strategyTotalAnalyzedDemand)
}, },
{ {
detail: strategyPrimaryTargetPath ?? "посадочная не выбрана", detail: strategySelectedDemandCompositionDetail,
label: "В текущий план", label: "В текущий план",
tone: "good", tone: "good",
value: formatCount(strategySelectedDemand) value: formatCount(strategySelectedDemand)
@ -9575,17 +9832,17 @@ export function App() {
const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length; const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length;
const strategySerpUnknownCount = strategySerpPhraseRows.filter((row) => row.intent === "unknown").length; const strategySerpUnknownCount = strategySerpPhraseRows.filter((row) => row.intent === "unknown").length;
const strategySerpQueueCount = strategyArticleOrDeferredRows.filter( const strategySerpQueueCount = strategyArticleOrDeferredRows.filter(
(row) => row.role === "article" || row.role === "defer" || row.intent === "informational" || row.intent === "mixed" (row) => isStrategyDeferredRole(row.role) || row.intent === "informational" || row.intent === "mixed"
).length; ).length;
const strategySerpSummaryRows: StrategyDemandLedgerRow[] = [ const strategySerpSummaryRows: StrategyDemandLedgerRow[] = [
{ {
detail: detail:
serpInterpretation?.state === "ready" serpInterpretation?.state === "ready"
? `${strategySelectedFitCount} совпадений по выбранным фразам` ? `${strategySelectedFitCount} совпадений по выбранным фразам; чисто коммерческих SERP-фраз: ${strategySerpCommercialCount}`
: "нужно интерпретировать выдачу", : "нужно интерпретировать выдачу",
label: "Коммерческий слой", label: "Fit посадочной",
tone: strategySelectedFitCount > 0 ? "good" : "warning", tone: strategySelectedFitCount > 0 ? "good" : "warning",
value: strategySelectedFitCount > 0 ? "подтверждён" : "нужна проверка" value: strategySelectedFitCount > 0 ? "смешанный fit" : "нужна проверка"
}, },
{ {
detail: detail:
@ -9605,7 +9862,7 @@ export function App() {
{ {
detail: detail:
strategyPrimaryTargetPath && strategySerpQueueCount > 0 strategyPrimaryTargetPath && strategySerpQueueCount > 0
? `${strategyPrimaryTargetPath} ведём как коммерческую посадочную, широкий слой выносим отдельно` ? `${strategyPrimaryTargetPath} ведём как коммерческую посадочную по смешанному/support fit, широкий broad-слой выносим отдельно`
: "маршрут зависит от посадочной и типа выдачи", : "маршрут зависит от посадочной и типа выдачи",
label: "Вывод", label: "Вывод",
tone: strategyPrimaryTargetPath ? "good" : "warning", tone: strategyPrimaryTargetPath ? "good" : "warning",
@ -9869,11 +10126,21 @@ export function App() {
value: "кластер не равен точной фразе" value: "кластер не равен точной фразе"
} }
]; ];
const strategyDecisionRowsForContract = strategySelectedKeywordRows.filter((row) => isStrategySelectedPlanRole(row.role));
const strategyDecisionDemandComposition = strategySelectedDemandComposition;
const strategyDifferentiatorClusters = Array.from(
new Set(strategyDecisionRowsForContract.filter((row) => row.role === "differentiator").map((row) => row.clusterTitle).filter(Boolean))
).slice(0, 4);
const strategySupportClustersForContract = strategySecondaryClusters
.filter((cluster) => !strategyDifferentiatorClusters.includes(cluster))
.slice(0, 4);
const strategyDecisionContract: StrategyDecisionContract | null = selectedStrategyScenario const strategyDecisionContract: StrategyDecisionContract | null = selectedStrategyScenario
? { ? createStrategyDecisionContract({
conversionRate: strategyForecastSettings.conversionRate, conversionRate: strategyForecastSettings.conversionRate,
deferredDemand: strategyDeferredDemand, deferredDemand: strategyDeferredDemand,
forecastModel: strategyForecastSettings.ctrModel, demandComposition: strategyDecisionDemandComposition,
differentiatorClusters: strategyDifferentiatorClusters,
forecastModelId: strategyForecastSettings.ctrModel,
horizonMonths: strategyForecastSettings.horizonMonths, horizonMonths: strategyForecastSettings.horizonMonths,
landing: strategyPrimaryTargetPath, landing: strategyPrimaryTargetPath,
nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную", nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную",
@ -9882,19 +10149,16 @@ export function App() {
scenarioId: selectedStrategyScenario.id, scenarioId: selectedStrategyScenario.id,
scenarioLabel: selectedStrategyScenario.label, scenarioLabel: selectedStrategyScenario.label,
selectedDemand: strategySelectedDemand, selectedDemand: strategySelectedDemand,
supportClusters: [strategyDifferentiatorCluster, ...strategySecondaryClusters] supportClusters: strategySupportClustersForContract,
.filter((cluster): cluster is string => Boolean(cluster))
.filter((cluster, index, clusters) => clusters.indexOf(cluster) === index)
.slice(0, 4),
visibilityMax: strategyCtrRange.max, visibilityMax: strategyCtrRange.max,
visibilityMin: strategyCtrRange.min visibilityMin: strategyCtrRange.min
} })
: null; : null;
const isStrategyDecisionConfirmOpen = pendingStrategyDecisionProjectId === project.id; const isStrategyDecisionConfirmOpen = pendingStrategyDecisionProjectId === project.id;
const strategyDecisionCompactSummary = strategyDecisionContract const strategyDecisionCompactSummary = strategyDecisionContract
? `${strategyDecisionContract.scenarioLabel} · ${strategyDecisionContract.landing ?? "URL не выбран"} · ${formatCount( ? `${strategyDecisionContract.scenarioLabel} · ${strategyDecisionContract.landing ?? "URL не выбран"} · ${formatCount(
strategyDecisionContract.selectedDemand strategyDecisionContract.selectedDemand
)} спроса · широкий слой в очередь · ${STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label}` )} спроса · широкий слой в очередь · ${strategyDecisionContract.forecastModel.label}`
: "Сначала нужно выбрать сценарий и посадочную."; : "Сначала нужно выбрать сценарий и посадочную.";
const strategyPositioningRows = [ const strategyPositioningRows = [
{ {
@ -10008,7 +10272,7 @@ export function App() {
: "n/a" : "n/a"
}, },
{ {
detail: `горизонт ${strategyForecastSettings.horizonMonths} мес. · не обещание результата`, detail: `к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации · не обещание результата`,
label: "Лиды/мес", label: "Лиды/мес",
value: selectedStrategyScenario value: selectedStrategyScenario
? formatForecastRange(selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax) ? formatForecastRange(selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax)
@ -11784,7 +12048,7 @@ export function App() {
<small> <small>
Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "} Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "}
{formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "} {formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "}
{STRATEGY_CTR_RANGES[acceptedStrategyDecisionContract.forecastModel].label} {acceptedStrategyDecisionContract.forecastModel.label}
</small> </small>
</div> </div>
<div> <div>
@ -11807,10 +12071,10 @@ export function App() {
<span>{keywordPlanDecision.label}</span> <span>{keywordPlanDecision.label}</span>
<strong>{keywordPlanDecision.title}</strong> <strong>{keywordPlanDecision.title}</strong>
<p>{keywordPlanDecision.summary}</p> <p>{keywordPlanDecision.summary}</p>
<p>{keywordPlanDecision.action}</p> <p>{keywordPlanDecision.action}</p>
<small> <small>
карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "} карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "}
{keywordMap.projectOntologyVersion {keywordMap.projectOntologyVersion
? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel( ? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel(
keywordMap.projectOntologyVersion.approvalStatus keywordMap.projectOntologyVersion.approvalStatus
)}` )}`
@ -11832,18 +12096,18 @@ export function App() {
<div className="market-wordstat-card keyword-cleaning-stage-summary"> <div className="market-wordstat-card keyword-cleaning-stage-summary">
<div> <div>
<strong>Источник плана правок</strong> <strong>Источник плана правок</strong>
<small> <small>
очистка фраз ·{" "} очистка фраз ·{" "}
{keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "} {keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "}
{keywordMap.cleaning.analystStatus} {keywordMap.cleaning.analystStatus}
</small> </small>
</div> </div>
<div> <div>
<span>главные {keywordMap.cleaning.useCount}</span> <span>главные {keywordMap.cleaning.useCount}</span>
<span>поддержка {keywordMap.cleaning.supportCount}</span> <span>поддержка {keywordMap.cleaning.supportCount}</span>
<span>контент {keywordMap.cleaning.articleCount}</span> <span>контент {keywordMap.cleaning.articleCount}</span>
<span>риск {keywordMap.cleaning.riskyCount}</span> <span>риск {keywordMap.cleaning.riskyCount}</span>
<span>мусор {keywordMap.cleaning.trashCount}</span> <span>мусор {keywordMap.cleaning.trashCount}</span>
</div> </div>
</div> </div>
@ -11888,7 +12152,8 @@ export function App() {
<span>{getKeywordMapRoleLabel(item.role)}</span> <span>{getKeywordMapRoleLabel(item.role)}</span>
<strong>{item.phrase}</strong> <strong>{item.phrase}</strong>
<small> <small>
{item.targetPath ?? "без страницы"} · {item.workspaceSectionId ?? "секция не найдена"} {item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "}
{item.workspaceSectionId ?? "секция не найдена"}
</small> </small>
</div> </div>
))} ))}
@ -11950,7 +12215,9 @@ export function App() {
<div className="keyword-page-plan-stats"> <div className="keyword-page-plan-stats">
<span>главн. {pagePlan.counts.primary}</span> <span>главн. {pagePlan.counts.primary}</span>
<span>доп. {pagePlan.counts.secondary}</span> <span>доп. {pagePlan.counts.secondary}</span>
<span>дифф. {pagePlan.counts.differentiator}</span>
<span>подд. {pagePlan.counts.support}</span> <span>подд. {pagePlan.counts.support}</span>
<span>канд. {pagePlan.counts.secondary_candidate}</span>
<span>review {pagePlan.reviewItems.length}</span> <span>review {pagePlan.reviewItems.length}</span>
</div> </div>
<div className="keyword-page-phrase-list"> <div className="keyword-page-phrase-list">
@ -11989,7 +12256,8 @@ export function App() {
<span>{getMarketEvidenceStatusLabel(item.evidenceStatus)}</span> <span>{getMarketEvidenceStatusLabel(item.evidenceStatus)}</span>
<strong>{item.phrase}</strong> <strong>{item.phrase}</strong>
<small> <small>
{item.targetPath ?? "без страницы"} · {getKeywordMapRoleLabel(item.role)} ·{" "} {item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "}
{getKeywordMapRoleLabel(item.role)} ·{" "}
{item.reason} {item.reason}
</small> </small>
</div> </div>
@ -12155,7 +12423,7 @@ export function App() {
selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMin,
selectedStrategyScenario.leadsMax, selectedStrategyScenario.leadsMax,
" лидов/мес" " лидов/мес"
)}. ` )} к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации. `
: ""} : ""}
Следующий артефакт:{" "} Следующий артефакт:{" "}
{strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "} {strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "}
@ -12214,10 +12482,10 @@ export function App() {
</article> </article>
<article> <article>
<span>Прогноз</span> <span>Прогноз</span>
<strong>{STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label}</strong> <strong>{strategyDecisionContract.forecastModel.label}</strong>
<small> <small>
{formatPercent(strategyDecisionContract.visibilityMin)}{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "} {formatPercent(strategyDecisionContract.visibilityMin)}{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "}
{formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.horizonMonths} мес. {formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.forecastModel.horizonLabel}
</small> </small>
</article> </article>
<article> <article>

View File

@ -739,7 +739,7 @@ export type MarketEnrichmentContract = {
id: "model" | "serp" | "text_quality" | "wordstat"; id: "model" | "serp" | "text_quality" | "wordstat";
label: string; label: string;
role: string; role: string;
status: "connected" | "disabled" | "not_configured" | "not_implemented"; status: "connected" | "connected_partial" | "disabled" | "not_configured" | "not_implemented";
mode: string; mode: string;
userActionRequired: boolean; userActionRequired: boolean;
platformActionRequired: boolean; platformActionRequired: boolean;
@ -1238,8 +1238,9 @@ export type KeywordMapContract = {
mapItemId: string | null; mapItemId: string | null;
sourceItemId: string | null; sourceItemId: string | null;
phrase: string; phrase: string;
role: "primary" | "secondary" | "support" | "validate" | string; role: "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate" | string;
targetPath: string | null; targetPath: string | null;
relatedLanding: string | null;
workspaceSectionId: string | null; workspaceSectionId: string | null;
pageId: string | null; pageId: string | null;
approvedAt: string; approvedAt: string;
@ -1248,10 +1249,11 @@ export type KeywordMapContract = {
items: Array<{ items: Array<{
id: string; id: string;
phrase: string; phrase: string;
role: "primary" | "secondary" | "support" | "validate"; role: "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
clusterId: string; clusterId: string;
clusterTitle: string; clusterTitle: string;
targetPath: string | null; targetPath: string | null;
relatedLanding: string | null;
priority: "high" | "medium" | "low"; priority: "high" | "medium" | "low";
source: "detected" | "ontology"; source: "detected" | "ontology";
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]; evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];

View File

@ -3030,6 +3030,7 @@ input {
.market-state-card.not_collected > div > span, .market-state-card.not_collected > div > span,
.market-state-card.partial_ready > div > span, .market-state-card.partial_ready > div > span,
.market-provider.connected > span, .market-provider.connected > span,
.market-provider.connected_partial > span,
.market-seed-list span.collected, .market-seed-list span.collected,
.market-seed-list span.collected_related_only, .market-seed-list span.collected_related_only,
.market-brief-list span.collected, .market-brief-list span.collected,
@ -11353,6 +11354,7 @@ textarea:focus {
.market-state-card.not_collected > div > span, .market-state-card.not_collected > div > span,
.market-state-card.partial_ready > div > span, .market-state-card.partial_ready > div > span,
.market-provider.connected > span, .market-provider.connected > span,
.market-provider.connected_partial > span,
.market-seed-list span.collected, .market-seed-list span.collected,
.market-seed-list span.collected_related_only, .market-seed-list span.collected_related_only,
.market-brief-list span.collected, .market-brief-list span.collected,

View File

@ -313,7 +313,7 @@ function selectPhrases(input: {
for (const item of input.keywordMap.persisted.items) { for (const item of input.keywordMap.persisted.items) {
add({ add({
phrase: item.phrase, phrase: item.phrase,
priority: item.role === "primary" ? "high" : item.role === "secondary" ? "medium" : "low", priority: item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low",
reason: "Approved keyword decision from SEO plan.", reason: "Approved keyword decision from SEO plan.",
source: "keyword_map", source: "keyword_map",
targetPath: item.targetPath targetPath: item.targetPath

View File

@ -3,7 +3,7 @@ import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../m
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js"; import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
type KeywordMapState = "blocked" | "draft"; type KeywordMapState = "blocked" | "draft";
type KeywordMapRole = "primary" | "secondary" | "support" | "validate"; type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
export type KeywordMapContract = { export type KeywordMapContract = {
schemaVersion: "keyword-map.v1"; schemaVersion: "keyword-map.v1";
@ -41,6 +41,7 @@ export type KeywordMapContract = {
clusterId: string; clusterId: string;
clusterTitle: string; clusterTitle: string;
targetPath: string | null; targetPath: string | null;
relatedLanding: string | null;
priority: "high" | "medium" | "low"; priority: "high" | "medium" | "low";
source: "detected" | "ontology"; source: "detected" | "ontology";
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]; evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
@ -64,6 +65,7 @@ export type KeywordMapPersistenceSummary = {
phrase: string; phrase: string;
role: KeywordMapRole | string; role: KeywordMapRole | string;
targetPath: string | null; targetPath: string | null;
relatedLanding: string | null;
workspaceSectionId: string | null; workspaceSectionId: string | null;
pageId: string | null; pageId: string | null;
approvedAt: string; approvedAt: string;
@ -84,6 +86,34 @@ function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase(); return value.trim().replace(/\s+/g, " ").toLowerCase();
} }
function getKeywordProfileText(item: Pick<KeywordCleaningItem, "clusterTitle" | "phrase">) {
return normalizePhrase(`${item.phrase} ${item.clusterTitle}`);
}
function isAiDifferentiatorCandidate(item: Pick<KeywordCleaningItem, "clusterTitle" | "phrase">) {
const text = getKeywordProfileText(item);
return /(^|\s)(ai|ии)(\s|$)/i.test(text) || /(агент|агентн|ассистент|assistant|agentic)/i.test(text);
}
function hasProductLikeHybridSignal(value: string) {
const text = normalizePhrase(value);
const hasProductNoun = /(систем[ауы]?|платформ[ауы]?|сервис|software|product|suite)/i.test(text);
const hasProcessNoun = /(бизнес[-\s]?процесс|bpms?|workflow|процесс[а-яё]*)/i.test(text);
return hasProductNoun && hasProcessNoun;
}
function isBroadArticleBacklogPhrase(value: string) {
const text = normalizePhrase(value);
return /^автоматизаци[яи]\s+бизнес[-\s]?процесс(ов|ы)?$/i.test(text);
}
function isHybridSecondaryCandidate(item: KeywordCleaningItem) {
return item.frequency !== null && hasProductLikeHybridSignal(item.phrase) && !isAiDifferentiatorCandidate(item);
}
function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) { function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) {
return ( return (
Boolean(version) && Boolean(version) &&
@ -111,8 +141,17 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) {
function getKeywordRole( function getKeywordRole(
item: KeywordCleaningItem, item: KeywordCleaningItem,
indexInTarget: number, indexInTarget: number,
targetHasCorePhrase: boolean,
reviewedOntology: boolean reviewedOntology: boolean
): KeywordMapRole { ): KeywordMapRole {
if (reviewedOntology && isHybridSecondaryCandidate(item)) {
return "secondary_candidate";
}
if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) {
return "validate";
}
if ( if (
!reviewedOntology || !reviewedOntology ||
item.decision === "article" || item.decision === "article" ||
@ -126,6 +165,10 @@ function getKeywordRole(
return "validate"; return "validate";
} }
if (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) {
return "differentiator";
}
if (item.decision === "support") { if (item.decision === "support") {
return "support"; return "support";
} }
@ -173,13 +216,38 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) { function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
const targetCounts = new Map<string, number>(); const targetCounts = new Map<string, number>();
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120);
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((accumulator, item) => {
const rawTargetPath = item.targetPath;
const targetKey = rawTargetPath ?? item.clusterId;
const canAnchorTarget =
reviewedOntology &&
item.decision !== "article" &&
item.decision !== "risky" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
!isBroadArticleBacklogPhrase(item.phrase) &&
!isHybridSecondaryCandidate(item) &&
!isAiDifferentiatorCandidate(item);
return cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120).map((item, index) => { if (canAnchorTarget) {
const targetPath = item.targetPath; accumulator.set(targetKey, true);
const targetKey = targetPath ?? item.clusterId; }
return accumulator;
}, new Map());
return sourceItems.map((item) => {
const rawTargetPath = item.targetPath;
const targetKey = rawTargetPath ?? item.clusterId;
const indexInTarget = targetCounts.get(targetKey) ?? 0; const indexInTarget = targetCounts.get(targetKey) ?? 0;
targetCounts.set(targetKey, indexInTarget + 1); const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology);
const role = getKeywordRole(item, indexInTarget, reviewedOntology); const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath;
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") {
targetCounts.set(targetKey, indexInTarget + 1);
}
return { return {
id: `clean:${item.id}`, id: `clean:${item.id}`,
@ -188,6 +256,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
clusterId: item.clusterId, clusterId: item.clusterId,
clusterTitle: item.clusterTitle, clusterTitle: item.clusterTitle,
targetPath, targetPath,
relatedLanding,
priority: item.priority, priority: item.priority,
source: item.seedSource, source: item.seedSource,
evidenceStatus: item.evidenceStatus, evidenceStatus: item.evidenceStatus,
@ -210,6 +279,18 @@ function getKeywordMapItemReason(
return "Фразу нельзя закреплять без reviewed ontology."; return "Фразу нельзя закреплять без reviewed ontology.";
} }
if (role === "secondary_candidate") {
return "Фраза похожа на гибридный продуктовый запрос: не отправляем автоматически в статью и не ставим в title до SERP page-type review.";
}
if (role === "differentiator") {
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
}
if (isBroadArticleBacklogPhrase(item.phrase)) {
return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
}
if (item.decision === "article") { if (item.decision === "article") {
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."; return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
} }
@ -248,6 +329,7 @@ function getKeywordMapItemReason(
function isPersistableKeywordMapItem(item: KeywordMapItem) { function isPersistableKeywordMapItem(item: KeywordMapItem) {
return ( return (
item.role !== "validate" && item.role !== "validate" &&
item.role !== "secondary_candidate" &&
item.evidenceStatus === "collected" && item.evidenceStatus === "collected" &&
item.frequency !== null && item.frequency !== null &&
Boolean(item.projectOntologyVersionId) && Boolean(item.projectOntologyVersionId) &&
@ -268,6 +350,10 @@ function getWorkspaceSectionIdForRole(role: KeywordMapRole) {
return "block-1"; return "block-1";
} }
if (role === "differentiator") {
return "block-1";
}
return null; return null;
} }
@ -322,6 +408,59 @@ async function resolveTargetPageId(projectId: string, targetPath: string | null)
return result.rows[0]?.page_id ?? null; return result.rows[0]?.page_id ?? null;
} }
function normalizePersistedKeywordMapItem(input: {
approvedAt: string;
decisionId: string;
mapItemId: string | null;
pageId: string | null;
phrase: string;
relatedLanding: string | null;
role: string;
sourceItemId: string | null;
targetPath: string | null;
workspaceSectionId: string | null;
}) {
let role = input.role;
let targetPath = input.targetPath;
let relatedLanding = input.relatedLanding;
let workspaceSectionId = input.workspaceSectionId;
let pageId = input.pageId;
if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
role = "differentiator";
workspaceSectionId = getWorkspaceSectionIdForRole("differentiator");
}
if (isBroadArticleBacklogPhrase(input.phrase)) {
role = "validate";
relatedLanding = relatedLanding ?? targetPath;
targetPath = null;
pageId = null;
workspaceSectionId = null;
}
if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
role = "secondary_candidate";
relatedLanding = relatedLanding ?? targetPath;
targetPath = null;
pageId = null;
workspaceSectionId = null;
}
return {
approvedAt: input.approvedAt,
decisionId: input.decisionId,
mapItemId: input.mapItemId,
pageId,
phrase: input.phrase,
relatedLanding,
role,
sourceItemId: input.sourceItemId,
targetPath,
workspaceSectionId
};
}
async function getKeywordMapPersistenceSummary( async function getKeywordMapPersistenceSummary(
projectId: string, projectId: string,
semanticRunId: string | null, semanticRunId: string | null,
@ -343,6 +482,7 @@ async function getKeywordMapPersistenceSummary(
phrase: string; phrase: string;
role: string | null; role: string | null;
target_path: string | null; target_path: string | null;
related_landing: string | null;
workspace_section_id: string | null; workspace_section_id: string | null;
page_id: string | null; page_id: string | null;
approved_at: Date; approved_at: Date;
@ -355,6 +495,7 @@ async function getKeywordMapPersistenceSummary(
kd.phrase, kd.phrase,
coalesce(kmi.role, kd.payload->>'role') as role, coalesce(kmi.role, kd.payload->>'role') as role,
coalesce(kmi.target_path, kd.target_path) as target_path, coalesce(kmi.target_path, kd.target_path) as target_path,
coalesce(kmi.payload->>'relatedLanding', kd.payload->>'relatedLanding') as related_landing,
kmi.workspace_section_id, kmi.workspace_section_id,
kmi.page_id, kmi.page_id,
kd.updated_at as approved_at kd.updated_at as approved_at
@ -376,17 +517,20 @@ async function getKeywordMapPersistenceSummary(
approvedDecisionCount: decisionIds.size, approvedDecisionCount: decisionIds.size,
mapItemCount: mapItemIds.size, mapItemCount: mapItemIds.size,
latestApprovedAt, latestApprovedAt,
items: result.rows.map((row) => ({ items: result.rows.map((row) =>
approvedAt: row.approved_at.toISOString(), normalizePersistedKeywordMapItem({
decisionId: row.decision_id, approvedAt: row.approved_at.toISOString(),
mapItemId: row.map_item_id, decisionId: row.decision_id,
pageId: row.page_id, mapItemId: row.map_item_id,
phrase: row.phrase, pageId: row.page_id,
role: row.role ?? "unknown", phrase: row.phrase,
sourceItemId: row.source_item_id, relatedLanding: row.related_landing,
targetPath: row.target_path, role: row.role ?? "unknown",
workspaceSectionId: row.workspace_section_id sourceItemId: row.source_item_id,
})) targetPath: row.target_path,
workspaceSectionId: row.workspace_section_id
})
)
}; };
} }
@ -623,6 +767,7 @@ export async function approveKeywordMapDecisions(
item.frequency, item.frequency,
item.frequencyGroup, item.frequencyGroup,
JSON.stringify({ JSON.stringify({
relatedLanding: item.relatedLanding,
role: item.role, role: item.role,
source: item.source, source: item.source,
priority: item.priority, priority: item.priority,
@ -698,6 +843,7 @@ export async function approveKeywordMapDecisions(
evidenceStatus: item.evidenceStatus, evidenceStatus: item.evidenceStatus,
frequency: item.frequency, frequency: item.frequency,
frequencyGroup: item.frequencyGroup, frequencyGroup: item.frequencyGroup,
relatedLanding: item.relatedLanding,
reason: item.reason reason: item.reason
}) })
] ]

View File

@ -15,7 +15,7 @@ import { getSeoNormalizationContract, type SeoNormalizationContract } from "../n
import { createWordstatProvider } from "./wordstatProvider.js"; import { createWordstatProvider } from "./wordstatProvider.js";
type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat"; type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat";
type MarketProviderStatus = "connected" | "disabled" | "not_configured" | "not_implemented"; type MarketProviderStatus = "connected" | "connected_partial" | "disabled" | "not_configured" | "not_implemented";
type MarketEnrichmentState = "not_ready" | "not_collected" | "not_configured" | "partial_ready"; type MarketEnrichmentState = "not_ready" | "not_collected" | "not_configured" | "partial_ready";
type MarketEvidenceStatus = type MarketEvidenceStatus =
| "collection_failed" | "collection_failed"

View File

@ -685,8 +685,25 @@ function buildPhaseStrategies(input: {
}); });
} }
function getProviderNextStep(provider: EvidenceProvider) { function getProviderNextStep(
if (provider.status === "connected") { provider: EvidenceProvider,
yandexEvidence?: YandexEvidenceContract,
serpInterpretation?: SerpInterpretationContract,
keywordMap?: KeywordMapContract
) {
if (provider.id === "wordstat" && yandexEvidence && yandexEvidence.summary.checkedPhraseCount > 0) {
return `Wordstat уже даёт спрос: ${yandexEvidence.summary.checkedPhraseCount} фраз, ${yandexEvidence.summary.totalDemand} суммарного спроса.`;
}
if (provider.id === "serp" && yandexEvidence && yandexEvidence.summary.serpDocCount > 0) {
const interpretedCount = serpInterpretation?.readiness.interpretedPhraseCount ?? 0;
const coveredCount = Math.max(interpretedCount, yandexEvidence.summary.checkedPhraseCount);
const targetCount = Math.max(keywordMap?.items.length ?? 0, coveredCount);
return `SERP уже используется в стратегии, покрытие частичное: ${coveredCount}/${targetCount} фраз. Дальше расширить до всей карты ключей и интерпретировать page-type.`;
}
if (provider.status === "connected" || provider.status === "connected_partial") {
return "Использовать как источник доказательств в стратегии и показывать дату/объём сбора."; return "Использовать как источник доказательств в стратегии и показывать дату/объём сбора.";
} }
@ -705,13 +722,45 @@ function getProviderNextStep(provider: EvidenceProvider) {
return "Подключить платформенный провайдер без ручной настройки пользователем."; return "Подключить платформенный провайдер без ручной настройки пользователем.";
} }
function mapProviders(providers: EvidenceProvider[]): SeoStrategyContract["evidence"]["providers"] { function getStrategyProviderStatus(
provider: EvidenceProvider,
yandexEvidence: YandexEvidenceContract,
serpInterpretation: SerpInterpretationContract,
keywordMap: KeywordMapContract
): EvidenceProvider["status"] {
if (provider.id === "wordstat" && yandexEvidence.summary.checkedPhraseCount > 0) {
return "connected";
}
if (provider.id === "serp") {
const interpretedCount = serpInterpretation.readiness.interpretedPhraseCount;
const coveredCount = Math.max(interpretedCount, yandexEvidence.summary.checkedPhraseCount);
const targetCount = Math.max(keywordMap.items.length, coveredCount);
if (coveredCount > 0 && coveredCount < targetCount) {
return "connected_partial";
}
if (coveredCount > 0 || yandexEvidence.summary.serpDocCount > 0) {
return "connected";
}
}
return provider.status;
}
function mapProviders(
providers: EvidenceProvider[],
yandexEvidence: YandexEvidenceContract,
serpInterpretation: SerpInterpretationContract,
keywordMap: KeywordMapContract
): SeoStrategyContract["evidence"]["providers"] {
return providers.map((provider) => ({ return providers.map((provider) => ({
id: provider.id, id: provider.id,
impact: provider.role, impact: provider.role,
label: provider.label, label: provider.label,
nextStep: getProviderNextStep(provider), nextStep: getProviderNextStep(provider, yandexEvidence, serpInterpretation, keywordMap),
status: provider.status status: getStrategyProviderStatus(provider, yandexEvidence, serpInterpretation, keywordMap)
})); }));
} }
@ -1317,10 +1366,10 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
serpInterpretation, serpInterpretation,
yandexEvidence yandexEvidence
}); });
const externalProviderConnectedCount = const strategyProviders = mapProviders(market.providers, yandexEvidence, serpInterpretation, keywordMap);
market.providers.filter((provider) => provider.status === "connected").length + const externalProviderConnectedCount = strategyProviders.filter(
(yandexEvidence.summary.serpDocCount > 0 ? 1 : 0) + (provider) => provider.status === "connected" || provider.status === "connected_partial"
(serpInterpretation.state === "ready" ? 1 : 0); ).length;
const contractWithoutActions: Omit<SeoStrategyContract, "nextActions"> = { const contractWithoutActions: Omit<SeoStrategyContract, "nextActions"> = {
schemaVersion: "seo-strategy.v1", schemaVersion: "seo-strategy.v1",
projectId, projectId,
@ -1343,7 +1392,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
? "Есть рабочая аналитическая база, но не хватает доказательств" ? "Есть рабочая аналитическая база, но не хватает доказательств"
: "Стратегия пока не доказана" : "Стратегия пока не доказана"
}, },
readiness: { readiness: {
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount, approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
contextReady, contextReady,
evidenceGapCount: missingEvidence.length, evidenceGapCount: missingEvidence.length,
@ -1352,23 +1401,23 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
opportunityCount: opportunities.length, opportunityCount: opportunities.length,
riskCount: risks.length, riskCount: risks.length,
strategyConfidenceScore, strategyConfidenceScore,
trashFilteredCount: cleaning.readiness.trashCount trashFilteredCount: cleaning.readiness.trashCount
}, },
siteMaturity, siteMaturity,
phasePlan, phasePlan,
phaseStrategies, phaseStrategies,
evidence: { evidence: {
context, context,
demand, demand,
providers: mapProviders(market.providers), providers: strategyProviders,
yandex: buildYandexEvidenceSummary(yandexEvidence), yandex: buildYandexEvidenceSummary(yandexEvidence),
serpInterpretation: buildSerpInterpretationSummary(serpInterpretation) serpInterpretation: buildSerpInterpretationSummary(serpInterpretation)
}, },
opportunities, opportunities,
risks, risks,
strategyOptions: buildStrategyOptions({ missingEvidence, opportunities, risks, siteMaturity }), strategyOptions: buildStrategyOptions({ missingEvidence, opportunities, risks, siteMaturity }),
missingEvidence missingEvidence
}; };
return { return {
...contractWithoutActions, ...contractWithoutActions,