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

View File

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

View File

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

View File

@ -313,7 +313,7 @@ function selectPhrases(input: {
for (const item of input.keywordMap.persisted.items) {
add({
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.",
source: "keyword_map",
targetPath: item.targetPath

View File

@ -3,7 +3,7 @@ import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../m
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
type KeywordMapState = "blocked" | "draft";
type KeywordMapRole = "primary" | "secondary" | "support" | "validate";
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
export type KeywordMapContract = {
schemaVersion: "keyword-map.v1";
@ -41,6 +41,7 @@ export type KeywordMapContract = {
clusterId: string;
clusterTitle: string;
targetPath: string | null;
relatedLanding: string | null;
priority: "high" | "medium" | "low";
source: "detected" | "ontology";
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
@ -64,6 +65,7 @@ export type KeywordMapPersistenceSummary = {
phrase: string;
role: KeywordMapRole | string;
targetPath: string | null;
relatedLanding: string | null;
workspaceSectionId: string | null;
pageId: string | null;
approvedAt: string;
@ -84,6 +86,34 @@ function normalizePhrase(value: string) {
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"]) {
return (
Boolean(version) &&
@ -111,8 +141,17 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) {
function getKeywordRole(
item: KeywordCleaningItem,
indexInTarget: number,
targetHasCorePhrase: boolean,
reviewedOntology: boolean
): KeywordMapRole {
if (reviewedOntology && isHybridSecondaryCandidate(item)) {
return "secondary_candidate";
}
if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) {
return "validate";
}
if (
!reviewedOntology ||
item.decision === "article" ||
@ -126,6 +165,10 @@ function getKeywordRole(
return "validate";
}
if (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) {
return "differentiator";
}
if (item.decision === "support") {
return "support";
}
@ -173,13 +216,38 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
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) => {
const targetPath = item.targetPath;
const targetKey = targetPath ?? item.clusterId;
if (canAnchorTarget) {
accumulator.set(targetKey, true);
}
return accumulator;
}, new Map());
return sourceItems.map((item) => {
const rawTargetPath = item.targetPath;
const targetKey = rawTargetPath ?? item.clusterId;
const indexInTarget = targetCounts.get(targetKey) ?? 0;
targetCounts.set(targetKey, indexInTarget + 1);
const role = getKeywordRole(item, indexInTarget, reviewedOntology);
const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, 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 {
id: `clean:${item.id}`,
@ -188,6 +256,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
clusterId: item.clusterId,
clusterTitle: item.clusterTitle,
targetPath,
relatedLanding,
priority: item.priority,
source: item.seedSource,
evidenceStatus: item.evidenceStatus,
@ -210,6 +279,18 @@ function getKeywordMapItemReason(
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") {
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
}
@ -248,6 +329,7 @@ function getKeywordMapItemReason(
function isPersistableKeywordMapItem(item: KeywordMapItem) {
return (
item.role !== "validate" &&
item.role !== "secondary_candidate" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.projectOntologyVersionId) &&
@ -268,6 +350,10 @@ function getWorkspaceSectionIdForRole(role: KeywordMapRole) {
return "block-1";
}
if (role === "differentiator") {
return "block-1";
}
return null;
}
@ -322,6 +408,59 @@ async function resolveTargetPageId(projectId: string, targetPath: string | 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(
projectId: string,
semanticRunId: string | null,
@ -343,6 +482,7 @@ async function getKeywordMapPersistenceSummary(
phrase: string;
role: string | null;
target_path: string | null;
related_landing: string | null;
workspace_section_id: string | null;
page_id: string | null;
approved_at: Date;
@ -355,6 +495,7 @@ async function getKeywordMapPersistenceSummary(
kd.phrase,
coalesce(kmi.role, kd.payload->>'role') as role,
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.page_id,
kd.updated_at as approved_at
@ -376,17 +517,20 @@ async function getKeywordMapPersistenceSummary(
approvedDecisionCount: decisionIds.size,
mapItemCount: mapItemIds.size,
latestApprovedAt,
items: result.rows.map((row) => ({
approvedAt: row.approved_at.toISOString(),
decisionId: row.decision_id,
mapItemId: row.map_item_id,
pageId: row.page_id,
phrase: row.phrase,
role: row.role ?? "unknown",
sourceItemId: row.source_item_id,
targetPath: row.target_path,
workspaceSectionId: row.workspace_section_id
}))
items: result.rows.map((row) =>
normalizePersistedKeywordMapItem({
approvedAt: row.approved_at.toISOString(),
decisionId: row.decision_id,
mapItemId: row.map_item_id,
pageId: row.page_id,
phrase: row.phrase,
relatedLanding: row.related_landing,
role: row.role ?? "unknown",
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.frequencyGroup,
JSON.stringify({
relatedLanding: item.relatedLanding,
role: item.role,
source: item.source,
priority: item.priority,
@ -698,6 +843,7 @@ export async function approveKeywordMapDecisions(
evidenceStatus: item.evidenceStatus,
frequency: item.frequency,
frequencyGroup: item.frequencyGroup,
relatedLanding: item.relatedLanding,
reason: item.reason
})
]

View File

@ -15,7 +15,7 @@ import { getSeoNormalizationContract, type SeoNormalizationContract } from "../n
import { createWordstatProvider } from "./wordstatProvider.js";
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 MarketEvidenceStatus =
| "collection_failed"

View File

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