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 KeywordPagePlanStatus = "blocked" | "ready" | "review";
|
||||||
type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen";
|
type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen";
|
||||||
type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support";
|
type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support";
|
||||||
type StrategyKeywordDecisionRole = "article" | "defer" | "differentiator" | "primary" | "secondary" | "support" | "trash";
|
type StrategyKeywordDecisionRole =
|
||||||
|
| "article"
|
||||||
|
| "defer"
|
||||||
|
| "differentiator"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "secondary_candidate"
|
||||||
|
| "support"
|
||||||
|
| "trash";
|
||||||
type StrategyForecastCtrModel = "base" | "optimistic" | "safe";
|
type StrategyForecastCtrModel = "base" | "optimistic" | "safe";
|
||||||
type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe";
|
type StrategyForecastRiskMode = "aggressive" | "balanced" | "safe";
|
||||||
type StrategyDecisionLevel = "high" | "low" | "medium";
|
type StrategyDecisionLevel = "high" | "low" | "medium";
|
||||||
|
|
@ -445,6 +453,7 @@ type StrategyKeywordDecisionRow = {
|
||||||
intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null;
|
intent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null;
|
||||||
phrase: string;
|
phrase: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
relatedLanding: string | null;
|
||||||
role: StrategyKeywordDecisionRole;
|
role: StrategyKeywordDecisionRole;
|
||||||
targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null;
|
targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"] | null;
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
|
|
@ -499,7 +508,22 @@ type StrategyLandingPortfolioLane = {
|
||||||
type StrategyDecisionContract = {
|
type StrategyDecisionContract = {
|
||||||
conversionRate: number;
|
conversionRate: number;
|
||||||
deferredDemand: number;
|
deferredDemand: number;
|
||||||
forecastModel: StrategyForecastCtrModel;
|
demandComposition: Array<{
|
||||||
|
demand: number;
|
||||||
|
label: string;
|
||||||
|
phrases: string[];
|
||||||
|
role: "differentiator" | "primary" | "support";
|
||||||
|
}>;
|
||||||
|
differentiatorClusters: string[];
|
||||||
|
forecastModel: {
|
||||||
|
conversionRate: number;
|
||||||
|
horizonLabel: string;
|
||||||
|
horizonMonths: number;
|
||||||
|
id: StrategyForecastCtrModel;
|
||||||
|
label: string;
|
||||||
|
visibilityMax: number;
|
||||||
|
visibilityMin: number;
|
||||||
|
};
|
||||||
horizonMonths: number;
|
horizonMonths: number;
|
||||||
landing: string | null;
|
landing: string | null;
|
||||||
nextStage: string;
|
nextStage: string;
|
||||||
|
|
@ -507,6 +531,7 @@ type StrategyDecisionContract = {
|
||||||
primaryCluster: string;
|
primaryCluster: string;
|
||||||
scenarioId: string;
|
scenarioId: string;
|
||||||
scenarioLabel: string;
|
scenarioLabel: string;
|
||||||
|
selectedScenarioId: string;
|
||||||
selectedDemand: number;
|
selectedDemand: number;
|
||||||
supportClusters: string[];
|
supportClusters: string[];
|
||||||
visibilityMax: number;
|
visibilityMax: number;
|
||||||
|
|
@ -558,7 +583,11 @@ type StrategyExportSnapshot = {
|
||||||
nextStage: string;
|
nextStage: string;
|
||||||
primaryCluster: string;
|
primaryCluster: string;
|
||||||
scenario: string;
|
scenario: string;
|
||||||
|
selectedScenarioId: string;
|
||||||
selectedDemand: number;
|
selectedDemand: number;
|
||||||
|
demandComposition: StrategyDecisionContract["demandComposition"];
|
||||||
|
differentiatorClusters: string[];
|
||||||
|
forecastModel: StrategyDecisionContract["forecastModel"];
|
||||||
supportClusters: string[];
|
supportClusters: string[];
|
||||||
};
|
};
|
||||||
domMirror?: {
|
domMirror?: {
|
||||||
|
|
@ -1155,10 +1184,10 @@ function getKeywordRoleBars(keywordMap: KeywordMapContract): SeoChartDatum[] {
|
||||||
return accumulator;
|
return accumulator;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (["primary", "secondary", "support", "validate"] as const).map((role) => ({
|
return (["primary", "secondary", "support", "differentiator", "secondary_candidate", "validate"] as const).map((role) => ({
|
||||||
detail: getKeywordMapRoleLabel(role),
|
detail: getKeywordMapRoleLabel(role),
|
||||||
label: getKeywordMapRoleLabel(role),
|
label: getKeywordMapRoleLabel(role),
|
||||||
tone: role === "primary" ? "good" : role === "validate" ? "warning" : "neutral",
|
tone: role === "primary" || role === "differentiator" ? "good" : role === "validate" || role === "secondary_candidate" ? "warning" : "neutral",
|
||||||
value: counts[role] ?? 0
|
value: counts[role] ?? 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -1183,12 +1212,14 @@ function getKeywordTargetBars(keywordMap: KeywordMapContract): SeoChartDatum[] {
|
||||||
|
|
||||||
function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum {
|
function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum {
|
||||||
const shortRoleLabels = {
|
const shortRoleLabels = {
|
||||||
|
differentiator: "дифф.",
|
||||||
primary: "главн.",
|
primary: "главн.",
|
||||||
secondary: "доп.",
|
secondary: "доп.",
|
||||||
|
secondary_candidate: "канд.",
|
||||||
support: "подд.",
|
support: "подд.",
|
||||||
validate: "пров."
|
validate: "пров."
|
||||||
};
|
};
|
||||||
const columns = (["primary", "secondary", "support", "validate"] as const).map((role) => ({
|
const columns = (["primary", "secondary", "support", "differentiator", "secondary_candidate", "validate"] as const).map((role) => ({
|
||||||
key: role,
|
key: role,
|
||||||
label: shortRoleLabels[role]
|
label: shortRoleLabels[role]
|
||||||
}));
|
}));
|
||||||
|
|
@ -1219,6 +1250,7 @@ function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum {
|
||||||
function isKeywordPlanItemPersistable(item: KeywordMapItemView) {
|
function isKeywordPlanItemPersistable(item: KeywordMapItemView) {
|
||||||
return (
|
return (
|
||||||
item.role !== "validate" &&
|
item.role !== "validate" &&
|
||||||
|
item.role !== "secondary_candidate" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.projectOntologyVersionId) &&
|
Boolean(item.projectOntologyVersionId) &&
|
||||||
|
|
@ -1228,8 +1260,10 @@ function isKeywordPlanItemPersistable(item: KeywordMapItemView) {
|
||||||
|
|
||||||
function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) {
|
function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) {
|
||||||
const labels = {
|
const labels = {
|
||||||
|
differentiator: "секция / FAQ",
|
||||||
primary: "title / H1",
|
primary: "title / H1",
|
||||||
secondary: "meta description",
|
secondary: "meta description",
|
||||||
|
secondary_candidate: "проверить SERP",
|
||||||
support: "секция страницы",
|
support: "секция страницы",
|
||||||
validate: "ручная проверка"
|
validate: "ручная проверка"
|
||||||
};
|
};
|
||||||
|
|
@ -1316,8 +1350,10 @@ function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) {
|
||||||
const roleRank: Record<KeywordMapItemView["role"], number> = {
|
const roleRank: Record<KeywordMapItemView["role"], number> = {
|
||||||
primary: 0,
|
primary: 0,
|
||||||
secondary: 1,
|
secondary: 1,
|
||||||
support: 2,
|
differentiator: 2,
|
||||||
validate: 3
|
support: 3,
|
||||||
|
secondary_candidate: 4,
|
||||||
|
validate: 5
|
||||||
};
|
};
|
||||||
const groups = keywordMap.items.reduce<Map<string, KeywordMapItemView[]>>((accumulator, item) => {
|
const groups = keywordMap.items.reduce<Map<string, KeywordMapItemView[]>>((accumulator, item) => {
|
||||||
const key = item.targetPath ?? "target pending";
|
const key = item.targetPath ?? "target pending";
|
||||||
|
|
@ -1334,7 +1370,7 @@ function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) {
|
||||||
accumulator[item.role] += 1;
|
accumulator[item.role] += 1;
|
||||||
return accumulator;
|
return accumulator;
|
||||||
},
|
},
|
||||||
{ primary: 0, secondary: 0, support: 0, validate: 0 }
|
{ differentiator: 0, primary: 0, secondary: 0, secondary_candidate: 0, support: 0, validate: 0 }
|
||||||
);
|
);
|
||||||
const persistableItems = items.filter(isKeywordPlanItemPersistable);
|
const persistableItems = items.filter(isKeywordPlanItemPersistable);
|
||||||
const reviewItems = items.filter((item) => !isKeywordPlanItemPersistable(item));
|
const reviewItems = items.filter((item) => !isKeywordPlanItemPersistable(item));
|
||||||
|
|
@ -1781,6 +1817,7 @@ function getStrategyKeywordRoleLabel(role: StrategyKeywordDecisionRole) {
|
||||||
differentiator: "дифференциатор",
|
differentiator: "дифференциатор",
|
||||||
primary: "основная",
|
primary: "основная",
|
||||||
secondary: "дополнительная",
|
secondary: "дополнительная",
|
||||||
|
secondary_candidate: "кандидат",
|
||||||
support: "поддержка",
|
support: "поддержка",
|
||||||
trash: "исключить"
|
trash: "исключить"
|
||||||
};
|
};
|
||||||
|
|
@ -1795,6 +1832,7 @@ function getStrategyKeywordRoleClass(role: StrategyKeywordDecisionRole) {
|
||||||
differentiator: "partial_ready",
|
differentiator: "partial_ready",
|
||||||
primary: "collected",
|
primary: "collected",
|
||||||
secondary: "collected",
|
secondary: "collected",
|
||||||
|
secondary_candidate: "ready_for_collection",
|
||||||
support: "ready_for_collection",
|
support: "ready_for_collection",
|
||||||
trash: "not_configured"
|
trash: "not_configured"
|
||||||
};
|
};
|
||||||
|
|
@ -1812,6 +1850,9 @@ function getStrategyKeywordDecisionRole(
|
||||||
return "trash";
|
return "trash";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.role === "differentiator") return "differentiator";
|
||||||
|
if (item.role === "secondary_candidate") return "secondary_candidate";
|
||||||
|
|
||||||
if (serpPhrase?.recommendedRole === "article_backlog" || reason.includes("article") || reason.includes("backlog")) {
|
if (serpPhrase?.recommendedRole === "article_backlog" || reason.includes("article") || reason.includes("backlog")) {
|
||||||
return "article";
|
return "article";
|
||||||
}
|
}
|
||||||
|
|
@ -1936,9 +1977,10 @@ function getStrategyKeywordRoleRank(role: StrategyKeywordDecisionRole) {
|
||||||
secondary: 1,
|
secondary: 1,
|
||||||
differentiator: 2,
|
differentiator: 2,
|
||||||
support: 3,
|
support: 3,
|
||||||
article: 4,
|
secondary_candidate: 4,
|
||||||
defer: 5,
|
article: 5,
|
||||||
trash: 6
|
defer: 6,
|
||||||
|
trash: 7
|
||||||
};
|
};
|
||||||
|
|
||||||
return ranks[role];
|
return ranks[role];
|
||||||
|
|
@ -1961,6 +2003,10 @@ function getStrategyShortReason(row: StrategyKeywordDecisionRow) {
|
||||||
return row.intent === "informational" ? "инфо-интент" : "в отдельный материал";
|
return row.intent === "informational" ? "инфо-интент" : "в отдельный материал";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.role === "secondary_candidate") {
|
||||||
|
return "кандидат на проверку";
|
||||||
|
}
|
||||||
|
|
||||||
if (row.role === "defer") {
|
if (row.role === "defer") {
|
||||||
return "нужна проверка";
|
return "нужна проверка";
|
||||||
}
|
}
|
||||||
|
|
@ -2008,6 +2054,9 @@ function getStrategyKeywordRows(
|
||||||
const serpPhrase = serpByPhrase.get(item.phrase);
|
const serpPhrase = serpByPhrase.get(item.phrase);
|
||||||
const role = getStrategyKeywordDecisionRole(item, serpPhrase);
|
const role = getStrategyKeywordDecisionRole(item, serpPhrase);
|
||||||
const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason);
|
const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason);
|
||||||
|
const usesDirectTarget = role !== "article" && role !== "defer" && role !== "secondary_candidate";
|
||||||
|
const targetPath = usesDirectTarget ? (item.targetPath ?? serpPhrase?.targetPath ?? null) : item.targetPath;
|
||||||
|
const relatedLanding = item.relatedLanding ?? (!targetPath ? (serpPhrase?.targetPath ?? null) : null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clusterTitle: item.clusterTitle,
|
clusterTitle: item.clusterTitle,
|
||||||
|
|
@ -2017,9 +2066,10 @@ function getStrategyKeywordRows(
|
||||||
intent: serpPhrase?.intent ?? null,
|
intent: serpPhrase?.intent ?? null,
|
||||||
phrase: item.phrase,
|
phrase: item.phrase,
|
||||||
reason,
|
reason,
|
||||||
|
relatedLanding,
|
||||||
role,
|
role,
|
||||||
targetFit: serpPhrase?.targetFit ?? null,
|
targetFit: serpPhrase?.targetFit ?? null,
|
||||||
targetPath: item.targetPath ?? serpPhrase?.targetPath ?? null
|
targetPath
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row) => row.role !== "trash")
|
.filter((row) => row.role !== "trash")
|
||||||
|
|
@ -2066,6 +2116,115 @@ function getStrategyPrimaryTargetPath(
|
||||||
return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null;
|
return keywordTarget ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath)?.targetPath ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStrategySelectedPlanRole(role: StrategyKeywordDecisionRole) {
|
||||||
|
return role === "primary" || role === "secondary" || role === "support" || role === "differentiator";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrategyDeferredRole(role: StrategyKeywordDecisionRole) {
|
||||||
|
return role === "article" || role === "defer" || role === "secondary_candidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrategyScenarioLabel(id: string | null | undefined) {
|
||||||
|
if (id === "landing_first") return "A · рекомендовано";
|
||||||
|
if (id === "homepage_only") return "B · осторожно";
|
||||||
|
if (id === "landing_plus_article") return "C · рост";
|
||||||
|
if (id === "broad_attack") return "D · агрессивно";
|
||||||
|
|
||||||
|
return "A · рекомендовано";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStrategyForecastModel(
|
||||||
|
forecastModelId: StrategyForecastCtrModel,
|
||||||
|
conversionRate: number,
|
||||||
|
horizonMonths: number
|
||||||
|
): StrategyDecisionContract["forecastModel"] {
|
||||||
|
const ctrRange = STRATEGY_CTR_RANGES[forecastModelId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversionRate,
|
||||||
|
horizonLabel: `к ${horizonMonths}-му месяцу после индексации`,
|
||||||
|
horizonMonths,
|
||||||
|
id: forecastModelId,
|
||||||
|
label: ctrRange.label,
|
||||||
|
visibilityMax: ctrRange.max,
|
||||||
|
visibilityMin: ctrRange.min
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrategyDemandComposition(rows: StrategyKeywordDecisionRow[]) {
|
||||||
|
const groups = rows.reduce<
|
||||||
|
Record<StrategyDecisionContract["demandComposition"][number]["role"], { demand: number; phrases: string[] }>
|
||||||
|
>(
|
||||||
|
(accumulator, row) => {
|
||||||
|
const role = row.role === "differentiator" ? "differentiator" : row.role === "primary" ? "primary" : "support";
|
||||||
|
|
||||||
|
accumulator[role].demand += row.frequency ?? 0;
|
||||||
|
if (row.phrase) {
|
||||||
|
accumulator[role].phrases.push(row.phrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
differentiator: { demand: 0, phrases: [] },
|
||||||
|
primary: { demand: 0, phrases: [] },
|
||||||
|
support: { demand: 0, phrases: [] }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return ([
|
||||||
|
{ label: "Основная ставка", role: "primary" as const },
|
||||||
|
{ label: "Support-фразы", role: "support" as const },
|
||||||
|
{ label: "Дифференциатор", role: "differentiator" as const }
|
||||||
|
] satisfies Array<{ label: string; role: StrategyDecisionContract["demandComposition"][number]["role"] }>)
|
||||||
|
.map((group) => ({
|
||||||
|
demand: groups[group.role].demand,
|
||||||
|
label: group.label,
|
||||||
|
phrases: groups[group.role].phrases.slice(0, 8),
|
||||||
|
role: group.role
|
||||||
|
}))
|
||||||
|
.filter((group) => group.demand > 0 || group.phrases.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStrategyDecisionContract(input: {
|
||||||
|
conversionRate: number;
|
||||||
|
deferredDemand: number;
|
||||||
|
demandComposition: StrategyDecisionContract["demandComposition"];
|
||||||
|
differentiatorClusters: string[];
|
||||||
|
forecastModelId: StrategyForecastCtrModel;
|
||||||
|
horizonMonths: number;
|
||||||
|
landing: string | null;
|
||||||
|
nextStage: string;
|
||||||
|
notPrimaryPhrases: string[];
|
||||||
|
primaryCluster: string;
|
||||||
|
scenarioId: string;
|
||||||
|
scenarioLabel: string;
|
||||||
|
selectedDemand: number;
|
||||||
|
supportClusters: string[];
|
||||||
|
visibilityMax: number;
|
||||||
|
visibilityMin: number;
|
||||||
|
}): StrategyDecisionContract {
|
||||||
|
return {
|
||||||
|
conversionRate: input.conversionRate,
|
||||||
|
deferredDemand: input.deferredDemand,
|
||||||
|
demandComposition: input.demandComposition,
|
||||||
|
differentiatorClusters: input.differentiatorClusters,
|
||||||
|
forecastModel: buildStrategyForecastModel(input.forecastModelId, input.conversionRate, input.horizonMonths),
|
||||||
|
horizonMonths: input.horizonMonths,
|
||||||
|
landing: input.landing,
|
||||||
|
nextStage: input.nextStage,
|
||||||
|
notPrimaryPhrases: input.notPrimaryPhrases,
|
||||||
|
primaryCluster: input.primaryCluster,
|
||||||
|
scenarioId: input.scenarioId,
|
||||||
|
scenarioLabel: input.scenarioLabel,
|
||||||
|
selectedScenarioId: input.scenarioId,
|
||||||
|
selectedDemand: input.selectedDemand,
|
||||||
|
supportClusters: input.supportClusters,
|
||||||
|
visibilityMax: input.visibilityMax,
|
||||||
|
visibilityMin: input.visibilityMin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeExportFilename(value: string) {
|
function sanitizeExportFilename(value: string) {
|
||||||
const sanitized = value
|
const sanitized = value
|
||||||
.trim()
|
.trim()
|
||||||
|
|
@ -2105,34 +2264,35 @@ function buildStrategyExportSnapshot(input: {
|
||||||
const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy);
|
const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows, input.seoStrategy);
|
||||||
const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows;
|
const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows;
|
||||||
const decisionRows = rowsForLanding.filter((row) => {
|
const decisionRows = rowsForLanding.filter((row) => {
|
||||||
return (
|
return isStrategySelectedPlanRole(row.role) && row.intent !== "informational" && row.targetFit !== "mismatch";
|
||||||
(row.role === "primary" || row.role === "secondary" || row.role === "support") &&
|
|
||||||
row.intent !== "informational" &&
|
|
||||||
row.targetFit !== "mismatch"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
const notPrimaryRows = keywordRows.filter((row) => {
|
const notPrimaryRows = keywordRows.filter((row) => {
|
||||||
return (
|
return isStrategyDeferredRole(row.role) || row.intent === "informational" || row.targetFit === "mismatch" || !row.targetPath;
|
||||||
row.role === "article" ||
|
|
||||||
row.role === "defer" ||
|
|
||||||
row.intent === "informational" ||
|
|
||||||
row.targetFit === "mismatch" ||
|
|
||||||
!row.targetPath
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
const selectedDemand = input.selectedContract?.selectedDemand ?? sumStrategyKeywordDemand(decisionRows);
|
const selectedDemandFromRows = sumStrategyKeywordDemand(decisionRows);
|
||||||
|
const selectedDemand = input.selectedContract?.selectedDemand ?? selectedDemandFromRows;
|
||||||
const totalDemand =
|
const totalDemand =
|
||||||
input.yandexEvidence?.summary.totalDemand ??
|
input.yandexEvidence?.summary.totalDemand ??
|
||||||
input.serpInterpretation?.yandexEvidence.totalDemand ??
|
input.serpInterpretation?.yandexEvidence.totalDemand ??
|
||||||
input.seoStrategy?.evidence.demand.totalFrequency ??
|
input.seoStrategy?.evidence.demand.totalFrequency ??
|
||||||
sumStrategyKeywordDemand(keywordRows);
|
sumStrategyKeywordDemand(keywordRows);
|
||||||
const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand);
|
const deferredDemand = input.selectedContract?.deferredDemand ?? Math.max(0, totalDemand - selectedDemand);
|
||||||
|
const differentiatorClusters =
|
||||||
|
input.selectedContract?.differentiatorClusters ??
|
||||||
|
Array.from(new Set(decisionRows.filter((row) => row.role === "differentiator").map((row) => row.clusterTitle).filter(Boolean))).slice(0, 4);
|
||||||
const supportClusters =
|
const supportClusters =
|
||||||
input.selectedContract?.supportClusters ??
|
input.selectedContract?.supportClusters ??
|
||||||
Array.from(new Set(decisionRows.map((row) => row.clusterTitle).filter(Boolean))).slice(1, 4);
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
decisionRows
|
||||||
|
.filter((row) => row.role === "secondary" || row.role === "support")
|
||||||
|
.map((row) => row.clusterTitle)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
).slice(0, 4);
|
||||||
const primaryCluster =
|
const primaryCluster =
|
||||||
input.selectedContract?.primaryCluster ||
|
input.selectedContract?.primaryCluster ||
|
||||||
getStrategyTopCluster(decisionRows) ||
|
getStrategyTopCluster(decisionRows.filter((row) => row.role !== "differentiator")) ||
|
||||||
input.seoStrategy?.opportunities[0]?.title ||
|
input.seoStrategy?.opportunities[0]?.title ||
|
||||||
"Кластер не выбран";
|
"Кластер не выбран";
|
||||||
const landing = input.selectedContract?.landing ?? primaryTargetPath;
|
const landing = input.selectedContract?.landing ?? primaryTargetPath;
|
||||||
|
|
@ -2140,18 +2300,41 @@ function buildStrategyExportSnapshot(input: {
|
||||||
const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05;
|
const visibilityMax = input.selectedContract?.visibilityMax ?? selectedDemand * 0.05;
|
||||||
const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate;
|
const conversionRate = input.selectedContract?.conversionRate ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.conversionRate;
|
||||||
const horizonMonths = input.selectedContract?.horizonMonths ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.horizonMonths;
|
const horizonMonths = input.selectedContract?.horizonMonths ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.horizonMonths;
|
||||||
|
const forecastModelId = input.selectedContract?.forecastModel.id ?? DEFAULT_STRATEGY_FORECAST_SETTINGS.ctrModel;
|
||||||
const forecast = `${formatForecastRange(visibilityMin, visibilityMax, " визитов/мес.")}, ${formatForecastRange(
|
const forecast = `${formatForecastRange(visibilityMin, visibilityMax, " визитов/мес.")}, ${formatForecastRange(
|
||||||
visibilityMin * conversionRate,
|
visibilityMin * conversionRate,
|
||||||
visibilityMax * conversionRate,
|
visibilityMax * conversionRate,
|
||||||
" лидов/мес."
|
" лидов/мес."
|
||||||
)}`;
|
)}`;
|
||||||
const scenario = input.selectedContract?.scenarioLabel ?? input.selectedScenarioId ?? "Рекомендованный сценарий";
|
const selectedScenarioId = input.selectedContract?.selectedScenarioId ?? input.selectedContract?.scenarioId ?? input.selectedScenarioId ?? "landing_first";
|
||||||
|
const scenario = input.selectedContract?.scenarioLabel ?? getStrategyScenarioLabel(selectedScenarioId);
|
||||||
const notPrimaryPhrases =
|
const notPrimaryPhrases =
|
||||||
input.selectedContract?.notPrimaryPhrases ??
|
input.selectedContract?.notPrimaryPhrases ??
|
||||||
notPrimaryRows
|
notPrimaryRows
|
||||||
.map((row) => row.phrase)
|
.map((row) => row.phrase)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
|
const demandComposition = input.selectedContract?.demandComposition ?? getStrategyDemandComposition(decisionRows);
|
||||||
|
const selectedContract =
|
||||||
|
input.selectedContract ??
|
||||||
|
createStrategyDecisionContract({
|
||||||
|
conversionRate,
|
||||||
|
deferredDemand,
|
||||||
|
demandComposition,
|
||||||
|
differentiatorClusters,
|
||||||
|
forecastModelId,
|
||||||
|
horizonMonths,
|
||||||
|
landing,
|
||||||
|
nextStage: landing ? `План правок для ${landing}` : "План правок",
|
||||||
|
notPrimaryPhrases,
|
||||||
|
primaryCluster,
|
||||||
|
scenarioId: selectedScenarioId,
|
||||||
|
scenarioLabel: scenario,
|
||||||
|
selectedDemand,
|
||||||
|
supportClusters,
|
||||||
|
visibilityMax,
|
||||||
|
visibilityMin
|
||||||
|
});
|
||||||
const productLabel = getReadablePathProductLabel(landing);
|
const productLabel = getReadablePathProductLabel(landing);
|
||||||
const siteTitle = getProjectSiteTitle(input.scanSummary);
|
const siteTitle = getProjectSiteTitle(input.scanSummary);
|
||||||
const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim();
|
const brandNameCandidate = siteTitle.split(/\s+[—-]\s+|\s+\|\s+/)[0]?.trim();
|
||||||
|
|
@ -2175,7 +2358,7 @@ function buildStrategyExportSnapshot(input: {
|
||||||
formatCount(row.frequency),
|
formatCount(row.frequency),
|
||||||
row.clusterTitle,
|
row.clusterTitle,
|
||||||
getStrategyKeywordRoleLabel(row.role),
|
getStrategyKeywordRoleLabel(row.role),
|
||||||
row.targetPath ?? "URL не выбран",
|
row.targetPath ?? (row.relatedLanding ? `связано с ${row.relatedLanding}` : "URL не выбран"),
|
||||||
evidenceRefs.join("; ") || "связь из карты фраз"
|
evidenceRefs.join("; ") || "связь из карты фраз"
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
@ -2220,11 +2403,19 @@ function buildStrategyExportSnapshot(input: {
|
||||||
value: `${landing ?? "Посадочная не выбрана"} закрывает «${primaryCluster}»`
|
value: `${landing ?? "Посадочная не выбрана"} закрывает «${primaryCluster}»`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: `${formatPercent(conversionRate)} конверсии, горизонт ${horizonMonths} мес.`,
|
detail: `${formatPercent(conversionRate)} конверсии, ${selectedContract.forecastModel.horizonLabel}. Не обещание результата.`,
|
||||||
label: "Сценарный прогноз",
|
label: "Прогноз к 3-му месяцу",
|
||||||
sourceId: "yandex-evidence.summary",
|
sourceId: "yandex-evidence.summary",
|
||||||
value: forecast
|
value: forecast
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
detail: demandComposition.length
|
||||||
|
? demandComposition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ")
|
||||||
|
: "Состав выбранного спроса не выделен.",
|
||||||
|
label: "Состав спроса",
|
||||||
|
sourceId: "keyword-map.items",
|
||||||
|
value: formatCount(selectedDemand)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.",
|
detail: supportClusters.length ? supportClusters.join(", ") : "Поддерживающие кластеры не выделены.",
|
||||||
label: "Поддерживающие кластеры",
|
label: "Поддерживающие кластеры",
|
||||||
|
|
@ -2242,7 +2433,7 @@ function buildStrategyExportSnapshot(input: {
|
||||||
detail: "Это вход для следующего этапа, сайт на этом шаге не меняется.",
|
detail: "Это вход для следующего этапа, сайт на этом шаге не меняется.",
|
||||||
label: "Следующий этап",
|
label: "Следующий этап",
|
||||||
sourceId: "seo-strategy.opportunities",
|
sourceId: "seo-strategy.opportunities",
|
||||||
value: input.selectedContract?.nextStage ?? "План правок"
|
value: selectedContract.nextStage
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: notPrimaryPhrases.length ? notPrimaryPhrases.join(", ") : "Нет явных отложенных фраз.",
|
detail: notPrimaryPhrases.length ? notPrimaryPhrases.join(", ") : "Нет явных отложенных фраз.",
|
||||||
|
|
@ -2299,17 +2490,29 @@ function buildStrategyExportSnapshot(input: {
|
||||||
];
|
];
|
||||||
const sourceRoutes: StrategyExportRoute[] = [
|
const sourceRoutes: StrategyExportRoute[] = [
|
||||||
{
|
{
|
||||||
calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз.",
|
calculation: "Самый сильный targetPath среди пригодных primary/secondary/support строк карты фраз. Article/backlog использует relatedLanding, а не прямой targetPath.",
|
||||||
element: "decision.landing",
|
element: "decision.landing",
|
||||||
jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"],
|
jsonRoutes: ["keywordMap.items[].targetPath", "serpInterpretation.phraseInterpretations[].targetPath"],
|
||||||
value: landing
|
value: landing
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
calculation: "Сумма frequency по строкам, которые идут в выбранную посадочную и не являются информационным/спорным интентом.",
|
calculation: "Сумма frequency по строкам выбранной посадочной с ролями primary/secondary/support/differentiator. Инфо, mismatch и secondary_candidate не входят.",
|
||||||
element: "decision.selectedDemand",
|
element: "decision.selectedDemand",
|
||||||
jsonRoutes: ["keywordMap.items[].frequency", "serpInterpretation.phraseInterpretations[].intent", "keywordMap.items[].targetPath"],
|
jsonRoutes: [
|
||||||
|
"keywordMap.items[].frequency",
|
||||||
|
"keywordMap.items[].role",
|
||||||
|
"keywordMap.items[].targetPath",
|
||||||
|
"keywordMap.items[].relatedLanding",
|
||||||
|
"serpInterpretation.phraseInterpretations[].intent"
|
||||||
|
],
|
||||||
value: selectedDemand
|
value: selectedDemand
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
calculation: "Разложение выбранного спроса по ролям: основная ставка, support-фразы и дифференциатор.",
|
||||||
|
element: "decision.demandComposition",
|
||||||
|
jsonRoutes: ["keywordMap.items[].role", "keywordMap.items[].frequency", "keywordMap.items[].phrase"],
|
||||||
|
value: demandComposition.map((part) => `${part.label}: ${formatCount(part.demand)}`).join("; ")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
calculation: "Проверенный спрос минус спрос, выбранный в текущий план.",
|
calculation: "Проверенный спрос минус спрос, выбранный в текущий план.",
|
||||||
element: "decision.deferredDemand",
|
element: "decision.deferredDemand",
|
||||||
|
|
@ -2333,19 +2536,23 @@ function buildStrategyExportSnapshot(input: {
|
||||||
return {
|
return {
|
||||||
decision: {
|
decision: {
|
||||||
deferredDemand,
|
deferredDemand,
|
||||||
|
demandComposition,
|
||||||
|
differentiatorClusters,
|
||||||
forecast,
|
forecast,
|
||||||
|
forecastModel: selectedContract.forecastModel,
|
||||||
landing,
|
landing,
|
||||||
nextStage: input.selectedContract?.nextStage ?? "План правок",
|
nextStage: selectedContract.nextStage,
|
||||||
primaryCluster,
|
primaryCluster,
|
||||||
scenario,
|
scenario,
|
||||||
|
selectedScenarioId,
|
||||||
selectedDemand,
|
selectedDemand,
|
||||||
supportClusters
|
supportClusters
|
||||||
},
|
},
|
||||||
evidenceContracts: {
|
evidenceContracts: {
|
||||||
keywordMap: input.keywordMap,
|
keywordMap: input.keywordMap,
|
||||||
scanSummary: input.scanSummary,
|
scanSummary: input.scanSummary,
|
||||||
selectedContract: input.selectedContract,
|
selectedContract,
|
||||||
selectedScenarioId: input.selectedScenarioId,
|
selectedScenarioId,
|
||||||
seoStrategy: input.seoStrategy,
|
seoStrategy: input.seoStrategy,
|
||||||
serpInterpretation: input.serpInterpretation,
|
serpInterpretation: input.serpInterpretation,
|
||||||
yandexEvidence: input.yandexEvidence
|
yandexEvidence: input.yandexEvidence
|
||||||
|
|
@ -2386,6 +2593,24 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) {
|
||||||
const routes = route.jsonRoutes.map((jsonRoute) => `\`${jsonRoute}\``).join(", ");
|
const routes = route.jsonRoutes.map((jsonRoute) => `\`${jsonRoute}\``).join(", ");
|
||||||
return `- **${route.element}:** ${String(route.value ?? "n/a")} · ${route.calculation ?? "расчёт не указан"} · ${routes}`;
|
return `- **${route.element}:** ${String(route.value ?? "n/a")} · ${route.calculation ?? "расчёт не указан"} · ${routes}`;
|
||||||
}),
|
}),
|
||||||
|
"",
|
||||||
|
"## Контракт решения",
|
||||||
|
`- **Сценарий:** ${snapshot.decision.scenario} (\`${snapshot.decision.selectedScenarioId}\`)`,
|
||||||
|
`- **Посадочная:** ${snapshot.decision.landing ?? "URL не выбран"}`,
|
||||||
|
`- **Основной кластер:** ${snapshot.decision.primaryCluster}`,
|
||||||
|
`- **В текущий план:** ${formatCount(snapshot.decision.selectedDemand)}`,
|
||||||
|
`- **Отложено:** ${formatCount(snapshot.decision.deferredDemand)}`,
|
||||||
|
`- **Прогноз:** ${snapshot.decision.forecast}; ${snapshot.decision.forecastModel.horizonLabel}`,
|
||||||
|
"",
|
||||||
|
"### Состав выбранного спроса",
|
||||||
|
...(
|
||||||
|
snapshot.decision.demandComposition.length
|
||||||
|
? snapshot.decision.demandComposition.map(
|
||||||
|
(part) =>
|
||||||
|
`- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : "фразы не перечислены"}`
|
||||||
|
)
|
||||||
|
: ["- Состав не выделен."]
|
||||||
|
),
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -2427,6 +2652,23 @@ function renderStrategyExportMarkdown(snapshot: StrategyExportSnapshot) {
|
||||||
"",
|
"",
|
||||||
"## Источники",
|
"## Источники",
|
||||||
...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`),
|
...snapshot.sources.map((source) => `- **${source.label}** (\`${source.id}\`): ${source.detail}`),
|
||||||
|
"",
|
||||||
|
"## Контракт решения",
|
||||||
|
`- **Сценарий:** ${snapshot.decision.scenario} (\`${snapshot.decision.selectedScenarioId}\`)`,
|
||||||
|
`- **Посадочная:** ${snapshot.decision.landing ?? "URL не выбран"}`,
|
||||||
|
`- **В текущий план:** ${formatCount(snapshot.decision.selectedDemand)}`,
|
||||||
|
`- **Отложено:** ${formatCount(snapshot.decision.deferredDemand)}`,
|
||||||
|
`- **Прогноз:** ${snapshot.decision.forecast}; ${snapshot.decision.forecastModel.horizonLabel}`,
|
||||||
|
"",
|
||||||
|
"### Состав выбранного спроса",
|
||||||
|
...(
|
||||||
|
snapshot.decision.demandComposition.length
|
||||||
|
? snapshot.decision.demandComposition.map(
|
||||||
|
(part) =>
|
||||||
|
`- **${part.label}:** ${formatCount(part.demand)} · ${part.phrases.length ? part.phrases.join(", ") : "фразы не перечислены"}`
|
||||||
|
)
|
||||||
|
: ["- Состав не выделен."]
|
||||||
|
),
|
||||||
""
|
""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -2929,8 +3171,12 @@ function getSeoStrategyDemandBars(strategy: SeoStrategyContract): SeoChartDatum[
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatum[] {
|
function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatum[] {
|
||||||
const connectedCount = strategy.evidence.providers.filter((provider) => provider.status === "connected").length;
|
const connectedCount = strategy.evidence.providers.filter(
|
||||||
const plannedCount = strategy.evidence.providers.filter((provider) => provider.status !== "connected").length;
|
(provider) => provider.status === "connected" || provider.status === "connected_partial"
|
||||||
|
).length;
|
||||||
|
const plannedCount = strategy.evidence.providers.filter(
|
||||||
|
(provider) => provider.status !== "connected" && provider.status !== "connected_partial"
|
||||||
|
).length;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -3992,6 +4238,7 @@ function getMarketStateLabel(state: MarketEnrichmentContract["state"]) {
|
||||||
function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) {
|
function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) {
|
||||||
const labels = {
|
const labels = {
|
||||||
connected: "подключено",
|
connected: "подключено",
|
||||||
|
connected_partial: "частично подключено",
|
||||||
disabled: "выключено",
|
disabled: "выключено",
|
||||||
not_configured: "не настроено",
|
not_configured: "не настроено",
|
||||||
not_implemented: "планируется"
|
not_implemented: "планируется"
|
||||||
|
|
@ -4054,8 +4301,10 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) {
|
||||||
|
|
||||||
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
|
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
|
||||||
const labels = {
|
const labels = {
|
||||||
|
differentiator: "дифф.",
|
||||||
primary: "главный",
|
primary: "главный",
|
||||||
secondary: "доп.",
|
secondary: "доп.",
|
||||||
|
secondary_candidate: "кандидат",
|
||||||
support: "поддержка",
|
support: "поддержка",
|
||||||
validate: "проверить"
|
validate: "проверить"
|
||||||
};
|
};
|
||||||
|
|
@ -8791,7 +9040,7 @@ export function App() {
|
||||||
(row) => row.role === "primary" || row.role === "secondary" || row.role === "support"
|
(row) => row.role === "primary" || row.role === "secondary" || row.role === "support"
|
||||||
);
|
);
|
||||||
const strategyArticleOrDeferredRows = strategyKeywordRows.filter(
|
const strategyArticleOrDeferredRows = strategyKeywordRows.filter(
|
||||||
(row) => row.role === "article" || row.role === "defer" || row.targetFit === "mismatch" || !row.targetPath
|
(row) => isStrategyDeferredRole(row.role) || row.targetFit === "mismatch" || !row.targetPath
|
||||||
);
|
);
|
||||||
const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce<Map<string, number>>((accumulator, row) => {
|
const strategyDemandByTargetPath = strategyCommercialKeywordRows.reduce<Map<string, number>>((accumulator, row) => {
|
||||||
if (!row.targetPath) {
|
if (!row.targetPath) {
|
||||||
|
|
@ -8811,14 +9060,16 @@ export function App() {
|
||||||
: [];
|
: [];
|
||||||
const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter(
|
const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
(row.role === "primary" || row.role === "secondary" || row.role === "support") &&
|
isStrategySelectedPlanRole(row.role) &&
|
||||||
row.targetFit !== "mismatch" &&
|
row.targetFit !== "mismatch" &&
|
||||||
row.intent !== "informational"
|
row.intent !== "informational"
|
||||||
);
|
);
|
||||||
const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => {
|
const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget
|
||||||
accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0));
|
.filter((row) => row.role !== "differentiator" && !isStrategyDeferredRole(row.role))
|
||||||
return accumulator;
|
.reduce<Map<string, number>>((accumulator, row) => {
|
||||||
}, new Map());
|
accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0));
|
||||||
|
return accumulator;
|
||||||
|
}, new Map());
|
||||||
const strategyPrimaryCluster =
|
const strategyPrimaryCluster =
|
||||||
Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ??
|
Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ??
|
||||||
seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ??
|
seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ??
|
||||||
|
|
@ -8875,7 +9126,7 @@ export function App() {
|
||||||
const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand;
|
const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand;
|
||||||
const strategySecondaryClusters = Array.from(
|
const strategySecondaryClusters = Array.from(
|
||||||
strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => {
|
strategyRowsForPrimaryTarget.reduce<Map<string, number>>((accumulator, row) => {
|
||||||
if (row.clusterTitle === strategyPrimaryCluster) {
|
if (row.clusterTitle === strategyPrimaryCluster || row.role === "differentiator" || isStrategyDeferredRole(row.role)) {
|
||||||
return accumulator;
|
return accumulator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -8887,7 +9138,9 @@ export function App() {
|
||||||
.map(([cluster]) => cluster)
|
.map(([cluster]) => cluster)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const strategyDifferentiatorCluster =
|
const strategyDifferentiatorCluster =
|
||||||
strategySecondaryClusters[0] ?? null;
|
strategyRowsForPrimaryTarget.find((row) => row.role === "differentiator")?.clusterTitle ??
|
||||||
|
strategySecondaryClusters[0] ??
|
||||||
|
null;
|
||||||
const strategyDisplayKeywordRows: StrategyKeywordDecisionRow[] = strategyKeywordRows.map((row) => {
|
const strategyDisplayKeywordRows: StrategyKeywordDecisionRow[] = strategyKeywordRows.map((row) => {
|
||||||
if (
|
if (
|
||||||
row.targetPath === strategyPrimaryTargetPath &&
|
row.targetPath === strategyPrimaryTargetPath &&
|
||||||
|
|
@ -9011,7 +9264,7 @@ export function App() {
|
||||||
{
|
{
|
||||||
id: "article",
|
id: "article",
|
||||||
label: "В статью / очередь",
|
label: "В статью / очередь",
|
||||||
rows: strategyVisibleKeywordRows.filter((row) => row.role === "article" || row.role === "defer")
|
rows: strategyVisibleKeywordRows.filter((row) => isStrategyDeferredRole(row.role))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
|
|
@ -9020,8 +9273,7 @@ export function App() {
|
||||||
}
|
}
|
||||||
].filter((group) => group.rows.length > 0);
|
].filter((group) => group.rows.length > 0);
|
||||||
const isStrategyLandingKeywordRow = (row: StrategyKeywordDecisionRow) =>
|
const isStrategyLandingKeywordRow = (row: StrategyKeywordDecisionRow) =>
|
||||||
row.role !== "article" &&
|
!isStrategyDeferredRole(row.role) &&
|
||||||
row.role !== "defer" &&
|
|
||||||
row.targetFit !== "mismatch" &&
|
row.targetFit !== "mismatch" &&
|
||||||
row.intent !== "informational";
|
row.intent !== "informational";
|
||||||
const strategyLandingGroups = strategyDisplayKeywordRows.reduce<Map<string, StrategyKeywordDecisionRow[]>>((accumulator, row) => {
|
const strategyLandingGroups = strategyDisplayKeywordRows.reduce<Map<string, StrategyKeywordDecisionRow[]>>((accumulator, row) => {
|
||||||
|
|
@ -9437,7 +9689,7 @@ export function App() {
|
||||||
strategyCtrRange.min
|
strategyCtrRange.min
|
||||||
)}–${formatPercent(strategyCtrRange.max)} видимости, ${formatPercent(
|
)}–${formatPercent(strategyCtrRange.max)} видимости, ${formatPercent(
|
||||||
strategyForecastSettings.conversionRate
|
strategyForecastSettings.conversionRate
|
||||||
)} конверсии, ${strategyForecastSettings.horizonMonths} мес.`,
|
)} конверсии, к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации.`,
|
||||||
label: "Модель прогноза",
|
label: "Модель прогноза",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
value: selectedStrategyScenario
|
value: selectedStrategyScenario
|
||||||
|
|
@ -9511,6 +9763,11 @@ export function App() {
|
||||||
)}`
|
)}`
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
const strategySelectedDemandComposition = getStrategyDemandComposition(strategySelectedKeywordRows);
|
||||||
|
const strategySelectedDemandCompositionDetail =
|
||||||
|
strategySelectedDemandComposition.length > 0
|
||||||
|
? strategySelectedDemandComposition.map((part) => `${formatCount(part.demand)} - ${part.label}`).join("; ")
|
||||||
|
: (strategyPrimaryTargetPath ?? "посадочная не выбрана");
|
||||||
const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [
|
const strategyDemandLedgerRows: StrategyDemandLedgerRow[] = [
|
||||||
{
|
{
|
||||||
detail: "очищенный пул спроса, который используется как база решения",
|
detail: "очищенный пул спроса, который используется как база решения",
|
||||||
|
|
@ -9519,7 +9776,7 @@ export function App() {
|
||||||
value: formatCount(strategyTotalAnalyzedDemand)
|
value: formatCount(strategyTotalAnalyzedDemand)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: strategyPrimaryTargetPath ?? "посадочная не выбрана",
|
detail: strategySelectedDemandCompositionDetail,
|
||||||
label: "В текущий план",
|
label: "В текущий план",
|
||||||
tone: "good",
|
tone: "good",
|
||||||
value: formatCount(strategySelectedDemand)
|
value: formatCount(strategySelectedDemand)
|
||||||
|
|
@ -9575,17 +9832,17 @@ export function App() {
|
||||||
const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length;
|
const strategySerpInfoCount = strategySerpPhraseRows.filter((row) => row.intent === "informational").length;
|
||||||
const strategySerpUnknownCount = strategySerpPhraseRows.filter((row) => row.intent === "unknown").length;
|
const strategySerpUnknownCount = strategySerpPhraseRows.filter((row) => row.intent === "unknown").length;
|
||||||
const strategySerpQueueCount = strategyArticleOrDeferredRows.filter(
|
const strategySerpQueueCount = strategyArticleOrDeferredRows.filter(
|
||||||
(row) => row.role === "article" || row.role === "defer" || row.intent === "informational" || row.intent === "mixed"
|
(row) => isStrategyDeferredRole(row.role) || row.intent === "informational" || row.intent === "mixed"
|
||||||
).length;
|
).length;
|
||||||
const strategySerpSummaryRows: StrategyDemandLedgerRow[] = [
|
const strategySerpSummaryRows: StrategyDemandLedgerRow[] = [
|
||||||
{
|
{
|
||||||
detail:
|
detail:
|
||||||
serpInterpretation?.state === "ready"
|
serpInterpretation?.state === "ready"
|
||||||
? `${strategySelectedFitCount} совпадений по выбранным фразам`
|
? `${strategySelectedFitCount} совпадений по выбранным фразам; чисто коммерческих SERP-фраз: ${strategySerpCommercialCount}`
|
||||||
: "нужно интерпретировать выдачу",
|
: "нужно интерпретировать выдачу",
|
||||||
label: "Коммерческий слой",
|
label: "Fit посадочной",
|
||||||
tone: strategySelectedFitCount > 0 ? "good" : "warning",
|
tone: strategySelectedFitCount > 0 ? "good" : "warning",
|
||||||
value: strategySelectedFitCount > 0 ? "подтверждён" : "нужна проверка"
|
value: strategySelectedFitCount > 0 ? "смешанный fit" : "нужна проверка"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail:
|
detail:
|
||||||
|
|
@ -9605,7 +9862,7 @@ export function App() {
|
||||||
{
|
{
|
||||||
detail:
|
detail:
|
||||||
strategyPrimaryTargetPath && strategySerpQueueCount > 0
|
strategyPrimaryTargetPath && strategySerpQueueCount > 0
|
||||||
? `${strategyPrimaryTargetPath} ведём как коммерческую посадочную, широкий слой выносим отдельно`
|
? `${strategyPrimaryTargetPath} ведём как коммерческую посадочную по смешанному/support fit, широкий broad-слой выносим отдельно`
|
||||||
: "маршрут зависит от посадочной и типа выдачи",
|
: "маршрут зависит от посадочной и типа выдачи",
|
||||||
label: "Вывод",
|
label: "Вывод",
|
||||||
tone: strategyPrimaryTargetPath ? "good" : "warning",
|
tone: strategyPrimaryTargetPath ? "good" : "warning",
|
||||||
|
|
@ -9869,11 +10126,21 @@ export function App() {
|
||||||
value: "кластер не равен точной фразе"
|
value: "кластер не равен точной фразе"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const strategyDecisionRowsForContract = strategySelectedKeywordRows.filter((row) => isStrategySelectedPlanRole(row.role));
|
||||||
|
const strategyDecisionDemandComposition = strategySelectedDemandComposition;
|
||||||
|
const strategyDifferentiatorClusters = Array.from(
|
||||||
|
new Set(strategyDecisionRowsForContract.filter((row) => row.role === "differentiator").map((row) => row.clusterTitle).filter(Boolean))
|
||||||
|
).slice(0, 4);
|
||||||
|
const strategySupportClustersForContract = strategySecondaryClusters
|
||||||
|
.filter((cluster) => !strategyDifferentiatorClusters.includes(cluster))
|
||||||
|
.slice(0, 4);
|
||||||
const strategyDecisionContract: StrategyDecisionContract | null = selectedStrategyScenario
|
const strategyDecisionContract: StrategyDecisionContract | null = selectedStrategyScenario
|
||||||
? {
|
? createStrategyDecisionContract({
|
||||||
conversionRate: strategyForecastSettings.conversionRate,
|
conversionRate: strategyForecastSettings.conversionRate,
|
||||||
deferredDemand: strategyDeferredDemand,
|
deferredDemand: strategyDeferredDemand,
|
||||||
forecastModel: strategyForecastSettings.ctrModel,
|
demandComposition: strategyDecisionDemandComposition,
|
||||||
|
differentiatorClusters: strategyDifferentiatorClusters,
|
||||||
|
forecastModelId: strategyForecastSettings.ctrModel,
|
||||||
horizonMonths: strategyForecastSettings.horizonMonths,
|
horizonMonths: strategyForecastSettings.horizonMonths,
|
||||||
landing: strategyPrimaryTargetPath,
|
landing: strategyPrimaryTargetPath,
|
||||||
nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную",
|
nextStage: strategyPrimaryTargetPath ? `план правок для ${strategyPrimaryTargetPath}` : "выбрать посадочную",
|
||||||
|
|
@ -9882,19 +10149,16 @@ export function App() {
|
||||||
scenarioId: selectedStrategyScenario.id,
|
scenarioId: selectedStrategyScenario.id,
|
||||||
scenarioLabel: selectedStrategyScenario.label,
|
scenarioLabel: selectedStrategyScenario.label,
|
||||||
selectedDemand: strategySelectedDemand,
|
selectedDemand: strategySelectedDemand,
|
||||||
supportClusters: [strategyDifferentiatorCluster, ...strategySecondaryClusters]
|
supportClusters: strategySupportClustersForContract,
|
||||||
.filter((cluster): cluster is string => Boolean(cluster))
|
|
||||||
.filter((cluster, index, clusters) => clusters.indexOf(cluster) === index)
|
|
||||||
.slice(0, 4),
|
|
||||||
visibilityMax: strategyCtrRange.max,
|
visibilityMax: strategyCtrRange.max,
|
||||||
visibilityMin: strategyCtrRange.min
|
visibilityMin: strategyCtrRange.min
|
||||||
}
|
})
|
||||||
: null;
|
: null;
|
||||||
const isStrategyDecisionConfirmOpen = pendingStrategyDecisionProjectId === project.id;
|
const isStrategyDecisionConfirmOpen = pendingStrategyDecisionProjectId === project.id;
|
||||||
const strategyDecisionCompactSummary = strategyDecisionContract
|
const strategyDecisionCompactSummary = strategyDecisionContract
|
||||||
? `${strategyDecisionContract.scenarioLabel} · ${strategyDecisionContract.landing ?? "URL не выбран"} · ${formatCount(
|
? `${strategyDecisionContract.scenarioLabel} · ${strategyDecisionContract.landing ?? "URL не выбран"} · ${formatCount(
|
||||||
strategyDecisionContract.selectedDemand
|
strategyDecisionContract.selectedDemand
|
||||||
)} спроса · широкий слой в очередь · ${STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label}`
|
)} спроса · широкий слой в очередь · ${strategyDecisionContract.forecastModel.label}`
|
||||||
: "Сначала нужно выбрать сценарий и посадочную.";
|
: "Сначала нужно выбрать сценарий и посадочную.";
|
||||||
const strategyPositioningRows = [
|
const strategyPositioningRows = [
|
||||||
{
|
{
|
||||||
|
|
@ -10008,7 +10272,7 @@ export function App() {
|
||||||
: "n/a"
|
: "n/a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: `горизонт ${strategyForecastSettings.horizonMonths} мес. · не обещание результата`,
|
detail: `к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации · не обещание результата`,
|
||||||
label: "Лиды/мес",
|
label: "Лиды/мес",
|
||||||
value: selectedStrategyScenario
|
value: selectedStrategyScenario
|
||||||
? formatForecastRange(selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax)
|
? formatForecastRange(selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax)
|
||||||
|
|
@ -11784,7 +12048,7 @@ export function App() {
|
||||||
<small>
|
<small>
|
||||||
Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "}
|
Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "}
|
||||||
{formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "}
|
{formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "}
|
||||||
{STRATEGY_CTR_RANGES[acceptedStrategyDecisionContract.forecastModel].label}
|
{acceptedStrategyDecisionContract.forecastModel.label}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -11807,10 +12071,10 @@ export function App() {
|
||||||
<span>{keywordPlanDecision.label}</span>
|
<span>{keywordPlanDecision.label}</span>
|
||||||
<strong>{keywordPlanDecision.title}</strong>
|
<strong>{keywordPlanDecision.title}</strong>
|
||||||
<p>{keywordPlanDecision.summary}</p>
|
<p>{keywordPlanDecision.summary}</p>
|
||||||
<p>{keywordPlanDecision.action}</p>
|
<p>{keywordPlanDecision.action}</p>
|
||||||
<small>
|
<small>
|
||||||
карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "}
|
карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "}
|
||||||
{keywordMap.projectOntologyVersion
|
{keywordMap.projectOntologyVersion
|
||||||
? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel(
|
? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel(
|
||||||
keywordMap.projectOntologyVersion.approvalStatus
|
keywordMap.projectOntologyVersion.approvalStatus
|
||||||
)}`
|
)}`
|
||||||
|
|
@ -11832,18 +12096,18 @@ export function App() {
|
||||||
<div className="market-wordstat-card keyword-cleaning-stage-summary">
|
<div className="market-wordstat-card keyword-cleaning-stage-summary">
|
||||||
<div>
|
<div>
|
||||||
<strong>Источник плана правок</strong>
|
<strong>Источник плана правок</strong>
|
||||||
<small>
|
<small>
|
||||||
очистка фраз ·{" "}
|
очистка фраз ·{" "}
|
||||||
{keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "}
|
{keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "}
|
||||||
{keywordMap.cleaning.analystStatus}
|
{keywordMap.cleaning.analystStatus}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>главные {keywordMap.cleaning.useCount}</span>
|
<span>главные {keywordMap.cleaning.useCount}</span>
|
||||||
<span>поддержка {keywordMap.cleaning.supportCount}</span>
|
<span>поддержка {keywordMap.cleaning.supportCount}</span>
|
||||||
<span>контент {keywordMap.cleaning.articleCount}</span>
|
<span>контент {keywordMap.cleaning.articleCount}</span>
|
||||||
<span>риск {keywordMap.cleaning.riskyCount}</span>
|
<span>риск {keywordMap.cleaning.riskyCount}</span>
|
||||||
<span>мусор {keywordMap.cleaning.trashCount}</span>
|
<span>мусор {keywordMap.cleaning.trashCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -11888,7 +12152,8 @@ export function App() {
|
||||||
<span>{getKeywordMapRoleLabel(item.role)}</span>
|
<span>{getKeywordMapRoleLabel(item.role)}</span>
|
||||||
<strong>{item.phrase}</strong>
|
<strong>{item.phrase}</strong>
|
||||||
<small>
|
<small>
|
||||||
{item.targetPath ?? "без страницы"} · {item.workspaceSectionId ?? "секция не найдена"}
|
{item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "}
|
||||||
|
{item.workspaceSectionId ?? "секция не найдена"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -11950,7 +12215,9 @@ export function App() {
|
||||||
<div className="keyword-page-plan-stats">
|
<div className="keyword-page-plan-stats">
|
||||||
<span>главн. {pagePlan.counts.primary}</span>
|
<span>главн. {pagePlan.counts.primary}</span>
|
||||||
<span>доп. {pagePlan.counts.secondary}</span>
|
<span>доп. {pagePlan.counts.secondary}</span>
|
||||||
|
<span>дифф. {pagePlan.counts.differentiator}</span>
|
||||||
<span>подд. {pagePlan.counts.support}</span>
|
<span>подд. {pagePlan.counts.support}</span>
|
||||||
|
<span>канд. {pagePlan.counts.secondary_candidate}</span>
|
||||||
<span>review {pagePlan.reviewItems.length}</span>
|
<span>review {pagePlan.reviewItems.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="keyword-page-phrase-list">
|
<div className="keyword-page-phrase-list">
|
||||||
|
|
@ -11989,7 +12256,8 @@ export function App() {
|
||||||
<span>{getMarketEvidenceStatusLabel(item.evidenceStatus)}</span>
|
<span>{getMarketEvidenceStatusLabel(item.evidenceStatus)}</span>
|
||||||
<strong>{item.phrase}</strong>
|
<strong>{item.phrase}</strong>
|
||||||
<small>
|
<small>
|
||||||
{item.targetPath ?? "без страницы"} · {getKeywordMapRoleLabel(item.role)} ·{" "}
|
{item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "}
|
||||||
|
{getKeywordMapRoleLabel(item.role)} ·{" "}
|
||||||
{item.reason}
|
{item.reason}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -12155,7 +12423,7 @@ export function App() {
|
||||||
selectedStrategyScenario.leadsMin,
|
selectedStrategyScenario.leadsMin,
|
||||||
selectedStrategyScenario.leadsMax,
|
selectedStrategyScenario.leadsMax,
|
||||||
" лидов/мес"
|
" лидов/мес"
|
||||||
)}. `
|
)} к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации. `
|
||||||
: ""}
|
: ""}
|
||||||
Следующий артефакт:{" "}
|
Следующий артефакт:{" "}
|
||||||
{strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "}
|
{strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "}
|
||||||
|
|
@ -12214,10 +12482,10 @@ export function App() {
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<span>Прогноз</span>
|
<span>Прогноз</span>
|
||||||
<strong>{STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label}</strong>
|
<strong>{strategyDecisionContract.forecastModel.label}</strong>
|
||||||
<small>
|
<small>
|
||||||
{formatPercent(strategyDecisionContract.visibilityMin)}–{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "}
|
{formatPercent(strategyDecisionContract.visibilityMin)}–{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "}
|
||||||
{formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.horizonMonths} мес.
|
{formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.forecastModel.horizonLabel}
|
||||||
</small>
|
</small>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
|
|
|
||||||
|
|
@ -739,7 +739,7 @@ export type MarketEnrichmentContract = {
|
||||||
id: "model" | "serp" | "text_quality" | "wordstat";
|
id: "model" | "serp" | "text_quality" | "wordstat";
|
||||||
label: string;
|
label: string;
|
||||||
role: string;
|
role: string;
|
||||||
status: "connected" | "disabled" | "not_configured" | "not_implemented";
|
status: "connected" | "connected_partial" | "disabled" | "not_configured" | "not_implemented";
|
||||||
mode: string;
|
mode: string;
|
||||||
userActionRequired: boolean;
|
userActionRequired: boolean;
|
||||||
platformActionRequired: boolean;
|
platformActionRequired: boolean;
|
||||||
|
|
@ -1238,8 +1238,9 @@ export type KeywordMapContract = {
|
||||||
mapItemId: string | null;
|
mapItemId: string | null;
|
||||||
sourceItemId: string | null;
|
sourceItemId: string | null;
|
||||||
phrase: string;
|
phrase: string;
|
||||||
role: "primary" | "secondary" | "support" | "validate" | string;
|
role: "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate" | string;
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
|
relatedLanding: string | null;
|
||||||
workspaceSectionId: string | null;
|
workspaceSectionId: string | null;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
approvedAt: string;
|
approvedAt: string;
|
||||||
|
|
@ -1248,10 +1249,11 @@ export type KeywordMapContract = {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
phrase: string;
|
phrase: string;
|
||||||
role: "primary" | "secondary" | "support" | "validate";
|
role: "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
clusterTitle: string;
|
clusterTitle: string;
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
|
relatedLanding: string | null;
|
||||||
priority: "high" | "medium" | "low";
|
priority: "high" | "medium" | "low";
|
||||||
source: "detected" | "ontology";
|
source: "detected" | "ontology";
|
||||||
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
|
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
|
||||||
|
|
|
||||||
|
|
@ -3030,6 +3030,7 @@ input {
|
||||||
.market-state-card.not_collected > div > span,
|
.market-state-card.not_collected > div > span,
|
||||||
.market-state-card.partial_ready > div > span,
|
.market-state-card.partial_ready > div > span,
|
||||||
.market-provider.connected > span,
|
.market-provider.connected > span,
|
||||||
|
.market-provider.connected_partial > span,
|
||||||
.market-seed-list span.collected,
|
.market-seed-list span.collected,
|
||||||
.market-seed-list span.collected_related_only,
|
.market-seed-list span.collected_related_only,
|
||||||
.market-brief-list span.collected,
|
.market-brief-list span.collected,
|
||||||
|
|
@ -11353,6 +11354,7 @@ textarea:focus {
|
||||||
.market-state-card.not_collected > div > span,
|
.market-state-card.not_collected > div > span,
|
||||||
.market-state-card.partial_ready > div > span,
|
.market-state-card.partial_ready > div > span,
|
||||||
.market-provider.connected > span,
|
.market-provider.connected > span,
|
||||||
|
.market-provider.connected_partial > span,
|
||||||
.market-seed-list span.collected,
|
.market-seed-list span.collected,
|
||||||
.market-seed-list span.collected_related_only,
|
.market-seed-list span.collected_related_only,
|
||||||
.market-brief-list span.collected,
|
.market-brief-list span.collected,
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,7 @@ function selectPhrases(input: {
|
||||||
for (const item of input.keywordMap.persisted.items) {
|
for (const item of input.keywordMap.persisted.items) {
|
||||||
add({
|
add({
|
||||||
phrase: item.phrase,
|
phrase: item.phrase,
|
||||||
priority: item.role === "primary" ? "high" : item.role === "secondary" ? "medium" : "low",
|
priority: item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low",
|
||||||
reason: "Approved keyword decision from SEO plan.",
|
reason: "Approved keyword decision from SEO plan.",
|
||||||
source: "keyword_map",
|
source: "keyword_map",
|
||||||
targetPath: item.targetPath
|
targetPath: item.targetPath
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../m
|
||||||
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
||||||
|
|
||||||
type KeywordMapState = "blocked" | "draft";
|
type KeywordMapState = "blocked" | "draft";
|
||||||
type KeywordMapRole = "primary" | "secondary" | "support" | "validate";
|
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||||
|
|
||||||
export type KeywordMapContract = {
|
export type KeywordMapContract = {
|
||||||
schemaVersion: "keyword-map.v1";
|
schemaVersion: "keyword-map.v1";
|
||||||
|
|
@ -41,6 +41,7 @@ export type KeywordMapContract = {
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
clusterTitle: string;
|
clusterTitle: string;
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
|
relatedLanding: string | null;
|
||||||
priority: "high" | "medium" | "low";
|
priority: "high" | "medium" | "low";
|
||||||
source: "detected" | "ontology";
|
source: "detected" | "ontology";
|
||||||
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
|
evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"];
|
||||||
|
|
@ -64,6 +65,7 @@ export type KeywordMapPersistenceSummary = {
|
||||||
phrase: string;
|
phrase: string;
|
||||||
role: KeywordMapRole | string;
|
role: KeywordMapRole | string;
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
|
relatedLanding: string | null;
|
||||||
workspaceSectionId: string | null;
|
workspaceSectionId: string | null;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
approvedAt: string;
|
approvedAt: string;
|
||||||
|
|
@ -84,6 +86,34 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getKeywordProfileText(item: Pick<KeywordCleaningItem, "clusterTitle" | "phrase">) {
|
||||||
|
return normalizePhrase(`${item.phrase} ${item.clusterTitle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAiDifferentiatorCandidate(item: Pick<KeywordCleaningItem, "clusterTitle" | "phrase">) {
|
||||||
|
const text = getKeywordProfileText(item);
|
||||||
|
|
||||||
|
return /(^|\s)(ai|ии)(\s|$)/i.test(text) || /(агент|агентн|ассистент|assistant|agentic)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasProductLikeHybridSignal(value: string) {
|
||||||
|
const text = normalizePhrase(value);
|
||||||
|
const hasProductNoun = /(систем[ауы]?|платформ[ауы]?|сервис|software|product|suite)/i.test(text);
|
||||||
|
const hasProcessNoun = /(бизнес[-\s]?процесс|bpms?|workflow|процесс[а-яё]*)/i.test(text);
|
||||||
|
|
||||||
|
return hasProductNoun && hasProcessNoun;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadArticleBacklogPhrase(value: string) {
|
||||||
|
const text = normalizePhrase(value);
|
||||||
|
|
||||||
|
return /^автоматизаци[яи]\s+бизнес[-\s]?процесс(ов|ы)?$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHybridSecondaryCandidate(item: KeywordCleaningItem) {
|
||||||
|
return item.frequency !== null && hasProductLikeHybridSignal(item.phrase) && !isAiDifferentiatorCandidate(item);
|
||||||
|
}
|
||||||
|
|
||||||
function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) {
|
function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) {
|
||||||
return (
|
return (
|
||||||
Boolean(version) &&
|
Boolean(version) &&
|
||||||
|
|
@ -111,8 +141,17 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) {
|
||||||
function getKeywordRole(
|
function getKeywordRole(
|
||||||
item: KeywordCleaningItem,
|
item: KeywordCleaningItem,
|
||||||
indexInTarget: number,
|
indexInTarget: number,
|
||||||
|
targetHasCorePhrase: boolean,
|
||||||
reviewedOntology: boolean
|
reviewedOntology: boolean
|
||||||
): KeywordMapRole {
|
): KeywordMapRole {
|
||||||
|
if (reviewedOntology && isHybridSecondaryCandidate(item)) {
|
||||||
|
return "secondary_candidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) {
|
||||||
|
return "validate";
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!reviewedOntology ||
|
!reviewedOntology ||
|
||||||
item.decision === "article" ||
|
item.decision === "article" ||
|
||||||
|
|
@ -126,6 +165,10 @@ function getKeywordRole(
|
||||||
return "validate";
|
return "validate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) {
|
||||||
|
return "differentiator";
|
||||||
|
}
|
||||||
|
|
||||||
if (item.decision === "support") {
|
if (item.decision === "support") {
|
||||||
return "support";
|
return "support";
|
||||||
}
|
}
|
||||||
|
|
@ -173,13 +216,38 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
||||||
|
|
||||||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
|
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120);
|
||||||
|
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((accumulator, item) => {
|
||||||
|
const rawTargetPath = item.targetPath;
|
||||||
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
|
const canAnchorTarget =
|
||||||
|
reviewedOntology &&
|
||||||
|
item.decision !== "article" &&
|
||||||
|
item.decision !== "risky" &&
|
||||||
|
item.evidenceStatus === "collected" &&
|
||||||
|
item.frequency !== null &&
|
||||||
|
!isBroadArticleBacklogPhrase(item.phrase) &&
|
||||||
|
!isHybridSecondaryCandidate(item) &&
|
||||||
|
!isAiDifferentiatorCandidate(item);
|
||||||
|
|
||||||
return cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120).map((item, index) => {
|
if (canAnchorTarget) {
|
||||||
const targetPath = item.targetPath;
|
accumulator.set(targetKey, true);
|
||||||
const targetKey = targetPath ?? item.clusterId;
|
}
|
||||||
|
|
||||||
|
return accumulator;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
return sourceItems.map((item) => {
|
||||||
|
const rawTargetPath = item.targetPath;
|
||||||
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||||
targetCounts.set(targetKey, indexInTarget + 1);
|
const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology);
|
||||||
const role = getKeywordRole(item, indexInTarget, reviewedOntology);
|
const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath;
|
||||||
|
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
||||||
|
|
||||||
|
if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") {
|
||||||
|
targetCounts.set(targetKey, indexInTarget + 1);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `clean:${item.id}`,
|
id: `clean:${item.id}`,
|
||||||
|
|
@ -188,6 +256,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
||||||
clusterId: item.clusterId,
|
clusterId: item.clusterId,
|
||||||
clusterTitle: item.clusterTitle,
|
clusterTitle: item.clusterTitle,
|
||||||
targetPath,
|
targetPath,
|
||||||
|
relatedLanding,
|
||||||
priority: item.priority,
|
priority: item.priority,
|
||||||
source: item.seedSource,
|
source: item.seedSource,
|
||||||
evidenceStatus: item.evidenceStatus,
|
evidenceStatus: item.evidenceStatus,
|
||||||
|
|
@ -210,6 +279,18 @@ function getKeywordMapItemReason(
|
||||||
return "Фразу нельзя закреплять без reviewed ontology.";
|
return "Фразу нельзя закреплять без reviewed ontology.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "secondary_candidate") {
|
||||||
|
return "Фраза похожа на гибридный продуктовый запрос: не отправляем автоматически в статью и не ставим в title до SERP page-type review.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "differentiator") {
|
||||||
|
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBroadArticleBacklogPhrase(item.phrase)) {
|
||||||
|
return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
|
||||||
|
}
|
||||||
|
|
||||||
if (item.decision === "article") {
|
if (item.decision === "article") {
|
||||||
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
|
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +329,7 @@ function getKeywordMapItemReason(
|
||||||
function isPersistableKeywordMapItem(item: KeywordMapItem) {
|
function isPersistableKeywordMapItem(item: KeywordMapItem) {
|
||||||
return (
|
return (
|
||||||
item.role !== "validate" &&
|
item.role !== "validate" &&
|
||||||
|
item.role !== "secondary_candidate" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.projectOntologyVersionId) &&
|
Boolean(item.projectOntologyVersionId) &&
|
||||||
|
|
@ -268,6 +350,10 @@ function getWorkspaceSectionIdForRole(role: KeywordMapRole) {
|
||||||
return "block-1";
|
return "block-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "differentiator") {
|
||||||
|
return "block-1";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,6 +408,59 @@ async function resolveTargetPageId(projectId: string, targetPath: string | null)
|
||||||
return result.rows[0]?.page_id ?? null;
|
return result.rows[0]?.page_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePersistedKeywordMapItem(input: {
|
||||||
|
approvedAt: string;
|
||||||
|
decisionId: string;
|
||||||
|
mapItemId: string | null;
|
||||||
|
pageId: string | null;
|
||||||
|
phrase: string;
|
||||||
|
relatedLanding: string | null;
|
||||||
|
role: string;
|
||||||
|
sourceItemId: string | null;
|
||||||
|
targetPath: string | null;
|
||||||
|
workspaceSectionId: string | null;
|
||||||
|
}) {
|
||||||
|
let role = input.role;
|
||||||
|
let targetPath = input.targetPath;
|
||||||
|
let relatedLanding = input.relatedLanding;
|
||||||
|
let workspaceSectionId = input.workspaceSectionId;
|
||||||
|
let pageId = input.pageId;
|
||||||
|
|
||||||
|
if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||||
|
role = "differentiator";
|
||||||
|
workspaceSectionId = getWorkspaceSectionIdForRole("differentiator");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBroadArticleBacklogPhrase(input.phrase)) {
|
||||||
|
role = "validate";
|
||||||
|
relatedLanding = relatedLanding ?? targetPath;
|
||||||
|
targetPath = null;
|
||||||
|
pageId = null;
|
||||||
|
workspaceSectionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||||
|
role = "secondary_candidate";
|
||||||
|
relatedLanding = relatedLanding ?? targetPath;
|
||||||
|
targetPath = null;
|
||||||
|
pageId = null;
|
||||||
|
workspaceSectionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvedAt: input.approvedAt,
|
||||||
|
decisionId: input.decisionId,
|
||||||
|
mapItemId: input.mapItemId,
|
||||||
|
pageId,
|
||||||
|
phrase: input.phrase,
|
||||||
|
relatedLanding,
|
||||||
|
role,
|
||||||
|
sourceItemId: input.sourceItemId,
|
||||||
|
targetPath,
|
||||||
|
workspaceSectionId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getKeywordMapPersistenceSummary(
|
async function getKeywordMapPersistenceSummary(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
semanticRunId: string | null,
|
semanticRunId: string | null,
|
||||||
|
|
@ -343,6 +482,7 @@ async function getKeywordMapPersistenceSummary(
|
||||||
phrase: string;
|
phrase: string;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
target_path: string | null;
|
target_path: string | null;
|
||||||
|
related_landing: string | null;
|
||||||
workspace_section_id: string | null;
|
workspace_section_id: string | null;
|
||||||
page_id: string | null;
|
page_id: string | null;
|
||||||
approved_at: Date;
|
approved_at: Date;
|
||||||
|
|
@ -355,6 +495,7 @@ async function getKeywordMapPersistenceSummary(
|
||||||
kd.phrase,
|
kd.phrase,
|
||||||
coalesce(kmi.role, kd.payload->>'role') as role,
|
coalesce(kmi.role, kd.payload->>'role') as role,
|
||||||
coalesce(kmi.target_path, kd.target_path) as target_path,
|
coalesce(kmi.target_path, kd.target_path) as target_path,
|
||||||
|
coalesce(kmi.payload->>'relatedLanding', kd.payload->>'relatedLanding') as related_landing,
|
||||||
kmi.workspace_section_id,
|
kmi.workspace_section_id,
|
||||||
kmi.page_id,
|
kmi.page_id,
|
||||||
kd.updated_at as approved_at
|
kd.updated_at as approved_at
|
||||||
|
|
@ -376,17 +517,20 @@ async function getKeywordMapPersistenceSummary(
|
||||||
approvedDecisionCount: decisionIds.size,
|
approvedDecisionCount: decisionIds.size,
|
||||||
mapItemCount: mapItemIds.size,
|
mapItemCount: mapItemIds.size,
|
||||||
latestApprovedAt,
|
latestApprovedAt,
|
||||||
items: result.rows.map((row) => ({
|
items: result.rows.map((row) =>
|
||||||
approvedAt: row.approved_at.toISOString(),
|
normalizePersistedKeywordMapItem({
|
||||||
decisionId: row.decision_id,
|
approvedAt: row.approved_at.toISOString(),
|
||||||
mapItemId: row.map_item_id,
|
decisionId: row.decision_id,
|
||||||
pageId: row.page_id,
|
mapItemId: row.map_item_id,
|
||||||
phrase: row.phrase,
|
pageId: row.page_id,
|
||||||
role: row.role ?? "unknown",
|
phrase: row.phrase,
|
||||||
sourceItemId: row.source_item_id,
|
relatedLanding: row.related_landing,
|
||||||
targetPath: row.target_path,
|
role: row.role ?? "unknown",
|
||||||
workspaceSectionId: row.workspace_section_id
|
sourceItemId: row.source_item_id,
|
||||||
}))
|
targetPath: row.target_path,
|
||||||
|
workspaceSectionId: row.workspace_section_id
|
||||||
|
})
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -623,6 +767,7 @@ export async function approveKeywordMapDecisions(
|
||||||
item.frequency,
|
item.frequency,
|
||||||
item.frequencyGroup,
|
item.frequencyGroup,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
relatedLanding: item.relatedLanding,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
source: item.source,
|
source: item.source,
|
||||||
priority: item.priority,
|
priority: item.priority,
|
||||||
|
|
@ -698,6 +843,7 @@ export async function approveKeywordMapDecisions(
|
||||||
evidenceStatus: item.evidenceStatus,
|
evidenceStatus: item.evidenceStatus,
|
||||||
frequency: item.frequency,
|
frequency: item.frequency,
|
||||||
frequencyGroup: item.frequencyGroup,
|
frequencyGroup: item.frequencyGroup,
|
||||||
|
relatedLanding: item.relatedLanding,
|
||||||
reason: item.reason
|
reason: item.reason
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { getSeoNormalizationContract, type SeoNormalizationContract } from "../n
|
||||||
import { createWordstatProvider } from "./wordstatProvider.js";
|
import { createWordstatProvider } from "./wordstatProvider.js";
|
||||||
|
|
||||||
type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat";
|
type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat";
|
||||||
type MarketProviderStatus = "connected" | "disabled" | "not_configured" | "not_implemented";
|
type MarketProviderStatus = "connected" | "connected_partial" | "disabled" | "not_configured" | "not_implemented";
|
||||||
type MarketEnrichmentState = "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
type MarketEnrichmentState = "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
||||||
type MarketEvidenceStatus =
|
type MarketEvidenceStatus =
|
||||||
| "collection_failed"
|
| "collection_failed"
|
||||||
|
|
|
||||||
|
|
@ -685,8 +685,25 @@ function buildPhaseStrategies(input: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderNextStep(provider: EvidenceProvider) {
|
function getProviderNextStep(
|
||||||
if (provider.status === "connected") {
|
provider: EvidenceProvider,
|
||||||
|
yandexEvidence?: YandexEvidenceContract,
|
||||||
|
serpInterpretation?: SerpInterpretationContract,
|
||||||
|
keywordMap?: KeywordMapContract
|
||||||
|
) {
|
||||||
|
if (provider.id === "wordstat" && yandexEvidence && yandexEvidence.summary.checkedPhraseCount > 0) {
|
||||||
|
return `Wordstat уже даёт спрос: ${yandexEvidence.summary.checkedPhraseCount} фраз, ${yandexEvidence.summary.totalDemand} суммарного спроса.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.id === "serp" && yandexEvidence && yandexEvidence.summary.serpDocCount > 0) {
|
||||||
|
const interpretedCount = serpInterpretation?.readiness.interpretedPhraseCount ?? 0;
|
||||||
|
const coveredCount = Math.max(interpretedCount, yandexEvidence.summary.checkedPhraseCount);
|
||||||
|
const targetCount = Math.max(keywordMap?.items.length ?? 0, coveredCount);
|
||||||
|
|
||||||
|
return `SERP уже используется в стратегии, покрытие частичное: ${coveredCount}/${targetCount} фраз. Дальше расширить до всей карты ключей и интерпретировать page-type.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.status === "connected" || provider.status === "connected_partial") {
|
||||||
return "Использовать как источник доказательств в стратегии и показывать дату/объём сбора.";
|
return "Использовать как источник доказательств в стратегии и показывать дату/объём сбора.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -705,13 +722,45 @@ function getProviderNextStep(provider: EvidenceProvider) {
|
||||||
return "Подключить платформенный провайдер без ручной настройки пользователем.";
|
return "Подключить платформенный провайдер без ручной настройки пользователем.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapProviders(providers: EvidenceProvider[]): SeoStrategyContract["evidence"]["providers"] {
|
function getStrategyProviderStatus(
|
||||||
|
provider: EvidenceProvider,
|
||||||
|
yandexEvidence: YandexEvidenceContract,
|
||||||
|
serpInterpretation: SerpInterpretationContract,
|
||||||
|
keywordMap: KeywordMapContract
|
||||||
|
): EvidenceProvider["status"] {
|
||||||
|
if (provider.id === "wordstat" && yandexEvidence.summary.checkedPhraseCount > 0) {
|
||||||
|
return "connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.id === "serp") {
|
||||||
|
const interpretedCount = serpInterpretation.readiness.interpretedPhraseCount;
|
||||||
|
const coveredCount = Math.max(interpretedCount, yandexEvidence.summary.checkedPhraseCount);
|
||||||
|
const targetCount = Math.max(keywordMap.items.length, coveredCount);
|
||||||
|
|
||||||
|
if (coveredCount > 0 && coveredCount < targetCount) {
|
||||||
|
return "connected_partial";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coveredCount > 0 || yandexEvidence.summary.serpDocCount > 0) {
|
||||||
|
return "connected";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProviders(
|
||||||
|
providers: EvidenceProvider[],
|
||||||
|
yandexEvidence: YandexEvidenceContract,
|
||||||
|
serpInterpretation: SerpInterpretationContract,
|
||||||
|
keywordMap: KeywordMapContract
|
||||||
|
): SeoStrategyContract["evidence"]["providers"] {
|
||||||
return providers.map((provider) => ({
|
return providers.map((provider) => ({
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
impact: provider.role,
|
impact: provider.role,
|
||||||
label: provider.label,
|
label: provider.label,
|
||||||
nextStep: getProviderNextStep(provider),
|
nextStep: getProviderNextStep(provider, yandexEvidence, serpInterpretation, keywordMap),
|
||||||
status: provider.status
|
status: getStrategyProviderStatus(provider, yandexEvidence, serpInterpretation, keywordMap)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1317,10 +1366,10 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
serpInterpretation,
|
serpInterpretation,
|
||||||
yandexEvidence
|
yandexEvidence
|
||||||
});
|
});
|
||||||
const externalProviderConnectedCount =
|
const strategyProviders = mapProviders(market.providers, yandexEvidence, serpInterpretation, keywordMap);
|
||||||
market.providers.filter((provider) => provider.status === "connected").length +
|
const externalProviderConnectedCount = strategyProviders.filter(
|
||||||
(yandexEvidence.summary.serpDocCount > 0 ? 1 : 0) +
|
(provider) => provider.status === "connected" || provider.status === "connected_partial"
|
||||||
(serpInterpretation.state === "ready" ? 1 : 0);
|
).length;
|
||||||
const contractWithoutActions: Omit<SeoStrategyContract, "nextActions"> = {
|
const contractWithoutActions: Omit<SeoStrategyContract, "nextActions"> = {
|
||||||
schemaVersion: "seo-strategy.v1",
|
schemaVersion: "seo-strategy.v1",
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -1343,7 +1392,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
? "Есть рабочая аналитическая база, но не хватает доказательств"
|
? "Есть рабочая аналитическая база, но не хватает доказательств"
|
||||||
: "Стратегия пока не доказана"
|
: "Стратегия пока не доказана"
|
||||||
},
|
},
|
||||||
readiness: {
|
readiness: {
|
||||||
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
|
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
|
||||||
contextReady,
|
contextReady,
|
||||||
evidenceGapCount: missingEvidence.length,
|
evidenceGapCount: missingEvidence.length,
|
||||||
|
|
@ -1352,23 +1401,23 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
opportunityCount: opportunities.length,
|
opportunityCount: opportunities.length,
|
||||||
riskCount: risks.length,
|
riskCount: risks.length,
|
||||||
strategyConfidenceScore,
|
strategyConfidenceScore,
|
||||||
trashFilteredCount: cleaning.readiness.trashCount
|
trashFilteredCount: cleaning.readiness.trashCount
|
||||||
},
|
},
|
||||||
siteMaturity,
|
siteMaturity,
|
||||||
phasePlan,
|
phasePlan,
|
||||||
phaseStrategies,
|
phaseStrategies,
|
||||||
evidence: {
|
evidence: {
|
||||||
context,
|
context,
|
||||||
demand,
|
demand,
|
||||||
providers: mapProviders(market.providers),
|
providers: strategyProviders,
|
||||||
yandex: buildYandexEvidenceSummary(yandexEvidence),
|
yandex: buildYandexEvidenceSummary(yandexEvidence),
|
||||||
serpInterpretation: buildSerpInterpretationSummary(serpInterpretation)
|
serpInterpretation: buildSerpInterpretationSummary(serpInterpretation)
|
||||||
},
|
},
|
||||||
opportunities,
|
opportunities,
|
||||||
risks,
|
risks,
|
||||||
strategyOptions: buildStrategyOptions({ missingEvidence, opportunities, risks, siteMaturity }),
|
strategyOptions: buildStrategyOptions({ missingEvidence, opportunities, risks, siteMaturity }),
|
||||||
missingEvidence
|
missingEvidence
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...contractWithoutActions,
|
...contractWithoutActions,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue