diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index ee2d503..3978324 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -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 = { primary: 0, secondary: 1, - support: 2, - validate: 3 + differentiator: 2, + support: 3, + secondary_candidate: 4, + validate: 5 }; const groups = keywordMap.items.reduce>((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 + >( + (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>((accumulator, row) => { if (!row.targetPath) { @@ -8811,14 +9060,16 @@ export function App() { : []; const strategySelectedKeywordRows = strategyRowsForPrimaryTarget.filter( (row) => - (row.role === "primary" || row.role === "secondary" || row.role === "support") && + isStrategySelectedPlanRole(row.role) && row.targetFit !== "mismatch" && row.intent !== "informational" ); - const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget.reduce>((accumulator, row) => { - accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); - return accumulator; - }, new Map()); + const strategyPrimaryClusterByDemand = strategyRowsForPrimaryTarget + .filter((row) => row.role !== "differentiator" && !isStrategyDeferredRole(row.role)) + .reduce>((accumulator, row) => { + accumulator.set(row.clusterTitle, (accumulator.get(row.clusterTitle) ?? 0) + (row.frequency ?? 0)); + return accumulator; + }, new Map()); const strategyPrimaryCluster = Array.from(strategyPrimaryClusterByDemand.entries()).sort((left, right) => right[1] - left[1])[0]?.[0] ?? seoStrategy?.opportunities.find((opportunity) => opportunity.targetPath === strategyPrimaryTargetPath)?.title ?? @@ -8875,7 +9126,7 @@ export function App() { const strategyNeedsLandingDemand = strategyPrimaryPageId ? 0 : strategySelectedDemand; const strategySecondaryClusters = Array.from( strategyRowsForPrimaryTarget.reduce>((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>((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() { Основной кластер: {acceptedStrategyDecisionContract.primaryCluster} · отложено{" "} {formatCount(acceptedStrategyDecisionContract.deferredDemand)} · модель{" "} - {STRATEGY_CTR_RANGES[acceptedStrategyDecisionContract.forecastModel].label} + {acceptedStrategyDecisionContract.forecastModel.label}
@@ -11807,10 +12071,10 @@ export function App() { {keywordPlanDecision.label} {keywordPlanDecision.title}

{keywordPlanDecision.summary}

-

{keywordPlanDecision.action}

- - карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "} - {keywordMap.projectOntologyVersion +

{keywordPlanDecision.action}

+ + карта фраз · {getKeywordMapStateLabel(keywordMap.state)} · онтология{" "} + {keywordMap.projectOntologyVersion ? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel( keywordMap.projectOntologyVersion.approvalStatus )}` @@ -11832,18 +12096,18 @@ export function App() {
Источник плана правок - - очистка фраз ·{" "} - {keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "} - {keywordMap.cleaning.analystStatus} - + + очистка фраз ·{" "} + {keywordMap.cleaning.providerMode === "codex_manual" ? "ручная модельная проверка" : "резервный режим"} ·{" "} + {keywordMap.cleaning.analystStatus} +
- главные {keywordMap.cleaning.useCount} - поддержка {keywordMap.cleaning.supportCount} - контент {keywordMap.cleaning.articleCount} - риск {keywordMap.cleaning.riskyCount} - мусор {keywordMap.cleaning.trashCount} + главные {keywordMap.cleaning.useCount} + поддержка {keywordMap.cleaning.supportCount} + контент {keywordMap.cleaning.articleCount} + риск {keywordMap.cleaning.riskyCount} + мусор {keywordMap.cleaning.trashCount}
@@ -11888,7 +12152,8 @@ export function App() { {getKeywordMapRoleLabel(item.role)} {item.phrase} - {item.targetPath ?? "без страницы"} · {item.workspaceSectionId ?? "секция не найдена"} + {item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "} + {item.workspaceSectionId ?? "секция не найдена"}
))} @@ -11950,7 +12215,9 @@ export function App() {
главн. {pagePlan.counts.primary} доп. {pagePlan.counts.secondary} + дифф. {pagePlan.counts.differentiator} подд. {pagePlan.counts.support} + канд. {pagePlan.counts.secondary_candidate} review {pagePlan.reviewItems.length}
@@ -11989,7 +12256,8 @@ export function App() { {getMarketEvidenceStatusLabel(item.evidenceStatus)} {item.phrase} - {item.targetPath ?? "без страницы"} · {getKeywordMapRoleLabel(item.role)} ·{" "} + {item.targetPath ?? (item.relatedLanding ? `связано с ${item.relatedLanding}` : "без страницы")} ·{" "} + {getKeywordMapRoleLabel(item.role)} ·{" "} {item.reason}
@@ -12155,7 +12423,7 @@ export function App() { selectedStrategyScenario.leadsMin, selectedStrategyScenario.leadsMax, " лидов/мес" - )}. ` + )} к ${strategyForecastSettings.horizonMonths}-му месяцу после индексации. ` : ""} Следующий артефакт:{" "} {strategyPrimaryPageId ? "план правок" : "связать или создать посадочную"} для{" "} @@ -12214,10 +12482,10 @@ export function App() {
Прогноз - {STRATEGY_CTR_RANGES[strategyDecisionContract.forecastModel].label} + {strategyDecisionContract.forecastModel.label} {formatPercent(strategyDecisionContract.visibilityMin)}–{formatPercent(strategyDecisionContract.visibilityMax)} ·{" "} - {formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.horizonMonths} мес. + {formatPercent(strategyDecisionContract.conversionRate)} · {strategyDecisionContract.forecastModel.horizonLabel}
diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index 7f6d9d5..ac260b5 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -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"]; diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index 852144a..63f4414 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -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, diff --git a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts index 181d8a2..836a2d8 100644 --- a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts +++ b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts @@ -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 diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts index 7d8d474..dbff57f 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -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) { + return normalizePhrase(`${item.phrase} ${item.clusterTitle}`); +} + +function isAiDifferentiatorCandidate(item: Pick) { + 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(); + const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120); + const targetHasCorePhrase = sourceItems.reduce>((accumulator, item) => { + const rawTargetPath = item.targetPath; + const targetKey = rawTargetPath ?? item.clusterId; + const canAnchorTarget = + reviewedOntology && + item.decision !== "article" && + item.decision !== "risky" && + item.evidenceStatus === "collected" && + item.frequency !== null && + !isBroadArticleBacklogPhrase(item.phrase) && + !isHybridSecondaryCandidate(item) && + !isAiDifferentiatorCandidate(item); - return cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120).map((item, index) => { - const targetPath = item.targetPath; - const targetKey = targetPath ?? item.clusterId; + if (canAnchorTarget) { + accumulator.set(targetKey, true); + } + + return accumulator; + }, new Map()); + + return sourceItems.map((item) => { + const rawTargetPath = item.targetPath; + const targetKey = rawTargetPath ?? item.clusterId; const indexInTarget = targetCounts.get(targetKey) ?? 0; - targetCounts.set(targetKey, indexInTarget + 1); - const role = getKeywordRole(item, indexInTarget, reviewedOntology); + const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology); + const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath; + const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null; + + if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") { + targetCounts.set(targetKey, indexInTarget + 1); + } return { id: `clean:${item.id}`, @@ -188,6 +256,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin clusterId: item.clusterId, clusterTitle: item.clusterTitle, targetPath, + relatedLanding, priority: item.priority, source: item.seedSource, evidenceStatus: item.evidenceStatus, @@ -210,6 +279,18 @@ function getKeywordMapItemReason( return "Фразу нельзя закреплять без reviewed ontology."; } + if (role === "secondary_candidate") { + return "Фраза похожа на гибридный продуктовый запрос: не отправляем автоматически в статью и не ставим в title до SERP page-type review."; + } + + if (role === "differentiator") { + return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title."; + } + + if (isBroadArticleBacklogPhrase(item.phrase)) { + return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения."; + } + if (item.decision === "article") { return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."; } @@ -248,6 +329,7 @@ function getKeywordMapItemReason( function isPersistableKeywordMapItem(item: KeywordMapItem) { return ( item.role !== "validate" && + item.role !== "secondary_candidate" && item.evidenceStatus === "collected" && item.frequency !== null && Boolean(item.projectOntologyVersionId) && @@ -268,6 +350,10 @@ function getWorkspaceSectionIdForRole(role: KeywordMapRole) { return "block-1"; } + if (role === "differentiator") { + return "block-1"; + } + return null; } @@ -322,6 +408,59 @@ async function resolveTargetPageId(projectId: string, targetPath: string | null) return result.rows[0]?.page_id ?? null; } +function normalizePersistedKeywordMapItem(input: { + approvedAt: string; + decisionId: string; + mapItemId: string | null; + pageId: string | null; + phrase: string; + relatedLanding: string | null; + role: string; + sourceItemId: string | null; + targetPath: string | null; + workspaceSectionId: string | null; +}) { + let role = input.role; + let targetPath = input.targetPath; + let relatedLanding = input.relatedLanding; + let workspaceSectionId = input.workspaceSectionId; + let pageId = input.pageId; + + if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) { + role = "differentiator"; + workspaceSectionId = getWorkspaceSectionIdForRole("differentiator"); + } + + if (isBroadArticleBacklogPhrase(input.phrase)) { + role = "validate"; + relatedLanding = relatedLanding ?? targetPath; + targetPath = null; + pageId = null; + workspaceSectionId = null; + } + + if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) { + role = "secondary_candidate"; + relatedLanding = relatedLanding ?? targetPath; + targetPath = null; + pageId = null; + workspaceSectionId = null; + } + + return { + approvedAt: input.approvedAt, + decisionId: input.decisionId, + mapItemId: input.mapItemId, + pageId, + phrase: input.phrase, + relatedLanding, + role, + sourceItemId: input.sourceItemId, + targetPath, + workspaceSectionId + }; +} + async function getKeywordMapPersistenceSummary( projectId: string, semanticRunId: string | null, @@ -343,6 +482,7 @@ async function getKeywordMapPersistenceSummary( phrase: string; role: string | null; target_path: string | null; + related_landing: string | null; workspace_section_id: string | null; page_id: string | null; approved_at: Date; @@ -355,6 +495,7 @@ async function getKeywordMapPersistenceSummary( kd.phrase, coalesce(kmi.role, kd.payload->>'role') as role, coalesce(kmi.target_path, kd.target_path) as target_path, + coalesce(kmi.payload->>'relatedLanding', kd.payload->>'relatedLanding') as related_landing, kmi.workspace_section_id, kmi.page_id, kd.updated_at as approved_at @@ -376,17 +517,20 @@ async function getKeywordMapPersistenceSummary( approvedDecisionCount: decisionIds.size, mapItemCount: mapItemIds.size, latestApprovedAt, - items: result.rows.map((row) => ({ - approvedAt: row.approved_at.toISOString(), - decisionId: row.decision_id, - mapItemId: row.map_item_id, - pageId: row.page_id, - phrase: row.phrase, - role: row.role ?? "unknown", - sourceItemId: row.source_item_id, - targetPath: row.target_path, - workspaceSectionId: row.workspace_section_id - })) + items: result.rows.map((row) => + normalizePersistedKeywordMapItem({ + approvedAt: row.approved_at.toISOString(), + decisionId: row.decision_id, + mapItemId: row.map_item_id, + pageId: row.page_id, + phrase: row.phrase, + relatedLanding: row.related_landing, + role: row.role ?? "unknown", + sourceItemId: row.source_item_id, + targetPath: row.target_path, + workspaceSectionId: row.workspace_section_id + }) + ) }; } @@ -623,6 +767,7 @@ export async function approveKeywordMapDecisions( item.frequency, item.frequencyGroup, JSON.stringify({ + relatedLanding: item.relatedLanding, role: item.role, source: item.source, priority: item.priority, @@ -698,6 +843,7 @@ export async function approveKeywordMapDecisions( evidenceStatus: item.evidenceStatus, frequency: item.frequency, frequencyGroup: item.frequencyGroup, + relatedLanding: item.relatedLanding, reason: item.reason }) ] diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts index 4f3d6b1..75eabc2 100644 --- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts +++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts @@ -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" diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts index a94e25f..503a6b3 100644 --- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -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 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 = { schemaVersion: "seo-strategy.v1", projectId, @@ -1343,7 +1392,7 @@ export async function getSeoStrategyContract(projectId: string): Promise