Fix SEO strategy decision contracts
This commit is contained in:
parent
07337970ca
commit
ccfcc24066
|
|
@ -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,11 +9060,13 @@ 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) => {
|
||||
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());
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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);
|
||||
const role = getKeywordRole(item, indexInTarget, reviewedOntology);
|
||||
}
|
||||
|
||||
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) => ({
|
||||
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
|
||||
})
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1360,7 +1409,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
evidence: {
|
||||
context,
|
||||
demand,
|
||||
providers: mapProviders(market.providers),
|
||||
providers: strategyProviders,
|
||||
yandex: buildYandexEvidenceSummary(yandexEvidence),
|
||||
serpInterpretation: buildSerpInterpretationSummary(serpInterpretation)
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue