From 8df64918f8bbb89e63e2b86807c90448d0777fdd Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 30 Jun 2026 00:15:53 +0300 Subject: [PATCH] Add phase-specific SEO strategy runway --- seo_mode/seo_mode/app/src/App.tsx | 106 +++++-- seo_mode/seo_mode/app/src/api.ts | 31 ++ .../server/src/strategy/seoStrategy.ts | 296 ++++++++++++++++++ .../src/strategy/strategyQualityReviewTask.ts | 4 + .../server/src/strategy/strategySynthesis.ts | 7 +- .../src/strategy/strategySynthesisTask.ts | 3 + 6 files changed, 414 insertions(+), 33 deletions(-) diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index e003687..eb4c1e9 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -7012,6 +7012,11 @@ export function App() { ? (selectedStrategyPhaseIds[project.id] ?? seoStrategy.siteMaturity.phase) : null; const selectedStrategyPhase = seoStrategy?.phasePlan.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null; + const selectedPhaseStrategy = + seoStrategy?.phaseStrategies.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null; + const selectedSynthesisPhaseStrategy = + strategySynthesis?.phaseStrategies.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null; + const selectedStrategyNarrative = selectedSynthesisPhaseStrategy ?? selectedPhaseStrategy; const isExportingSemantic = exportingSemanticKey?.startsWith(`${project.id}:`) ?? false; const isSelectingPages = selectingPagesProjectId === project.id; const selectedPagesCount = scanSummary ? getSelectedPagesCount(scanSummary) : 0; @@ -9248,29 +9253,35 @@ export function App() {
{getSeoPhaseStatusLabel(selectedStrategyPhase.status)} {selectedStrategyPhase.title} -

{selectedStrategyPhase.goal}

+

{selectedPhaseStrategy?.summary ?? selectedStrategyPhase.goal}

+ {selectedPhaseStrategy ? ( + + impact {getSeoStrategyPriorityLabel(selectedPhaseStrategy.expectedImpact)} · confidence{" "} + {getSeoStrategyConfidenceLabel(selectedPhaseStrategy.confidence)} + + ) : null}
можно делать - {selectedStrategyPhase.allowedTargets.slice(0, 4).map((target) => ( + {(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase.allowedTargets).slice(0, 4).map((target) => ( {target} ))}
не сейчас - {selectedStrategyPhase.deferredTargets.slice(0, 4).map((target) => ( + {(selectedPhaseStrategy?.hold ?? selectedStrategyPhase.deferredTargets).slice(0, 4).map((target) => ( {target} ))}
переход дальше - {selectedStrategyPhase.promotionSignals.slice(0, 4).map((signal) => ( + {(selectedPhaseStrategy?.prepareNext ?? selectedStrategyPhase.promotionSignals).slice(0, 4).map((signal) => ( {signal} ))}
-

{selectedStrategyPhase.risk}

+

{selectedPhaseStrategy?.risks[0] ?? selectedStrategyPhase.risk}

) : null} @@ -9278,19 +9289,30 @@ export function App() {
- SEO runway - Фазы роста: что можно сейчас и что откроется после сигналов. + SEO runway · {selectedStrategyPhase?.title ?? "фаза не выбрана"} + + {selectedPhaseStrategy + ? selectedPhaseStrategy.goal + : "Выбери фазу, чтобы увидеть отдельный маршрут действий."} +
- {seoStrategy.phasePlan.map((phase) => ( -
- - {getSeoPhaseStatusLabel(phase.status)} - - {phase.title} - {phase.goal} - Можно: {phase.allowedTargets.slice(0, 2).join(" · ")} - Не сейчас: {phase.deferredTargets.slice(0, 2).join(" · ")} + {(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase?.allowedTargets ?? []).slice(0, 5).map((action) => ( +
+ now + {action} +
+ ))} + {(selectedPhaseStrategy?.prepareNext ?? []).slice(0, 4).map((action) => ( +
+ prepare + {action} +
+ ))} + {(selectedPhaseStrategy?.hold ?? selectedStrategyPhase?.deferredTargets ?? []).slice(0, 4).map((action) => ( +
+ hold + {action}
))}
@@ -9298,20 +9320,30 @@ export function App() {
- Переход в следующую фазу - Какие сигналы нужны, чтобы повышать конкуренцию ключей. + Ключи и посадочные + Что именно проверяем в выбранной фазе, без общей таблицы ключей.
- {seoStrategy.siteMaturity.promotionSignals.map((signal) => ( -
- signal - {signal} + {(selectedPhaseStrategy?.keywordMix ?? []).slice(0, 4).map((item) => ( +
+ + {getSeoCompetitionTierLabel(item.competitionTier)} + + {item.label} + {item.description}
))} - {seoStrategy.siteMaturity.guardrails.map((guardrail) => ( -
- guardrail - {guardrail} + {(selectedPhaseStrategy?.landingPlan ?? []).slice(0, 4).map((item) => ( +
+ {getSeoOpportunityActionLabel(item.action)} + {item.targetPath ?? item.title} + {item.reason} +
+ ))} + {(selectedPhaseStrategy?.nextEvidenceTasks ?? []).slice(0, 3).map((task) => ( +
+ evidence + {task}
))}
@@ -9463,15 +9495,19 @@ export function App() {
- {strategySynthesis.phaseDecision.title} - {getSeoMaturityPhaseLabel(strategySynthesis.phaseDecision.currentPhase)} + {selectedStrategyNarrative?.title ?? strategySynthesis.phaseDecision.title} + + {selectedStrategyNarrative + ? getSeoMaturityPhaseLabel(selectedStrategyNarrative.phase) + : getSeoMaturityPhaseLabel(strategySynthesis.phaseDecision.currentPhase)} +
phase - {strategySynthesis.phaseDecision.summary} + {selectedStrategyNarrative?.summary ?? strategySynthesis.phaseDecision.summary}
- {strategySynthesis.phaseDecision.allowedNow.slice(0, 4).map((action) => ( + {(selectedStrategyNarrative?.doNow ?? strategySynthesis.phaseDecision.allowedNow).slice(0, 4).map((action) => (
now {action} @@ -9486,18 +9522,24 @@ export function App() { Что нельзя выдавать как immediate route до зрелости сайта.
- {strategySynthesis.phaseDecision.prepareNext.slice(0, 3).map((action) => ( + {(selectedStrategyNarrative?.prepareNext ?? strategySynthesis.phaseDecision.prepareNext).slice(0, 3).map((action) => (
prepare {action}
))} - {strategySynthesis.phaseDecision.hold.slice(0, 5).map((action) => ( + {(selectedStrategyNarrative?.hold ?? strategySynthesis.phaseDecision.hold).slice(0, 5).map((action) => (
hold {action}
))} + {(selectedStrategyNarrative?.nextEvidenceTasks ?? []).slice(0, 3).map((action) => ( +
+ evidence + {action} +
+ ))}
diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index 0a792d0..7f6d9d5 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -1318,6 +1318,36 @@ export type SeoStrategyContract = { promotionSignals: string[]; risk: string; }>; + phaseStrategies: Array<{ + phase: SeoStrategyContract["siteMaturity"]["phase"]; + title: string; + status: "current" | "future" | "locked" | "ready"; + goal: string; + summary: string; + expectedImpact: "high" | "low" | "medium"; + confidence: "high" | "low" | "medium"; + keywordMix: Array<{ + label: string; + description: string; + competitionTier: "head" | "long_tail" | "mid" | "unknown"; + evidenceRefs: string[]; + priority: "high" | "low" | "medium"; + }>; + landingPlan: Array<{ + title: string; + targetPath: string | null; + action: "defer" | "do_now" | "prepare"; + reason: string; + evidenceRefs: string[]; + priority: "high" | "low" | "medium"; + }>; + doNow: string[]; + prepareNext: string[]; + hold: string[]; + evidenceRefs: string[]; + risks: string[]; + nextEvidenceTasks: string[]; + }>; evidence: { context: { status: "missing" | "needs_review" | "ready"; @@ -1465,6 +1495,7 @@ export type StrategySynthesisContract = { promotionSignals: string[]; phaseRoadmap: SeoStrategyContract["phasePlan"]; }; + phaseStrategies: SeoStrategyContract["phaseStrategies"]; recommendedPath: { id: string; title: string; diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts index a013f9d..e3f1f0a 100644 --- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -73,6 +73,36 @@ export type SeoStrategyContract = { promotionSignals: string[]; risk: string; }>; + phaseStrategies: Array<{ + phase: SeoMaturityPhase; + title: string; + status: "current" | "future" | "locked" | "ready"; + goal: string; + summary: string; + expectedImpact: SeoStrategyPriority; + confidence: SeoStrategyConfidence; + keywordMix: Array<{ + label: string; + description: string; + competitionTier: SeoCompetitionTier; + evidenceRefs: string[]; + priority: SeoStrategyPriority; + }>; + landingPlan: Array<{ + title: string; + targetPath: string | null; + action: SeoOpportunityAction; + reason: string; + evidenceRefs: string[]; + priority: SeoStrategyPriority; + }>; + doNow: string[]; + prepareNext: string[]; + hold: string[]; + evidenceRefs: string[]; + risks: string[]; + nextEvidenceTasks: string[]; + }>; evidence: { context: { status: "missing" | "needs_review" | "ready"; @@ -383,6 +413,264 @@ function buildPhasePlan(siteMaturity: SeoStrategyContract["siteMaturity"]): SeoS }); } +function getPhaseAllowedTiers(phase: SeoMaturityPhase): SeoCompetitionTier[] { + if (phase === "bootstrap_indexing") { + return ["long_tail", "unknown"]; + } + + if (phase === "traction") { + return ["long_tail", "mid", "unknown"]; + } + + if (phase === "growth") { + return ["long_tail", "mid", "head", "unknown"]; + } + + return ["head", "mid", "long_tail", "unknown"]; +} + +function getPhaseSummary(phase: SeoMaturityPhase, opportunities: SeoStrategyContract["opportunities"]) { + const phaseOpportunityCount = opportunities.length; + + if (phase === "bootstrap_indexing") { + return `Стартовая стратегия для нового/нулевого сайта: закрепить понятный индексируемый смысл, выбрать low-competition фразы и не расходовать ресурс на head terms. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; + } + + if (phase === "traction") { + return `Стратегия первых сигналов: расширять только те кластеры, где уже виден target-fit, индексируемость и первые признаки спроса. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; + } + + if (phase === "growth") { + return `Стратегия роста: усиливать подтверждённые посадочные, добавлять support-контент и готовить конкурентные кластеры через SERP evidence. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; + } + + return `Стратегия authority/head terms: атаковать широкие конкурентные запросы только после индексации, traction, external trust и performance signals. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; +} + +function getPhaseDefaultTasks( + phase: SeoMaturityPhase, + missingEvidence: SeoStrategyContract["missingEvidence"] +): Pick { + const highPriorityEvidence = missingEvidence + .filter((evidence) => evidence.priority === "high") + .slice(0, 3) + .map((evidence) => evidence.title); + + if (phase === "bootstrap_indexing") { + return { + doNow: [ + "Собрать индексируемое long-tail ядро по понятному offer/intent.", + "Удержать один основной смысл на посадочную или секцию.", + "Закрыть technical/indexability blockers перед любым rewrite." + ], + hold: [ + "Не атаковать head/top keywords как immediate route.", + "Не расширять бизнес-направление без отдельной expansion branch.", + "Не считать Wordstat demand доказательством traction сайта." + ], + nextEvidenceTasks: highPriorityEvidence.length + ? highPriorityEvidence + : ["SERP top-10 для стартовых фраз", "Webmaster/indexation после deploy", "первые impressions/CTR после индексации"], + prepareNext: [ + "Подготовить mid-tail backlog для traction.", + "Собрать SERP page type evidence по будущим growth clusters.", + "Зафиксировать promotion signals для перехода к traction." + ] + }; + } + + if (phase === "traction") { + return { + doNow: [ + "Расширить exact/mid-tail кластеры с подтверждённым target-fit.", + "Связать запросы с посадочными и evidence refs.", + "Проверить, что CTR/impressions не конфликтуют с выбранным интентом." + ], + hold: [ + "Не включать самые конкурентные head terms без authority signals.", + "Не плодить посадочные без SERP page-type evidence.", + "Не переписывать контент без approved page/field plan." + ], + nextEvidenceTasks: highPriorityEvidence.length + ? highPriorityEvidence + : ["SERP intent для mid-tail", "первые query/page signals", "CTR по стартовым кластерам"], + prepareNext: [ + "Готовить support-контент для growth clusters.", + "Уточнить internal linking между подтверждёнными посадочными.", + "Накопить данные для uplift в growth." + ] + }; + } + + if (phase === "growth") { + return { + doNow: [ + "Усилить посадочные с доказанным спросом и target-fit.", + "Добавить support layers вокруг core clusters.", + "Использовать competitor gaps только после SERP interpretation." + ], + hold: [ + "Не идти в authority/head terms без устойчивой динамики.", + "Не смешивать core SEO route и optional business expansion.", + "Не добавлять страницы ради частотности без page role." + ], + nextEvidenceTasks: highPriorityEvidence.length + ? highPriorityEvidence + : ["competitor gap evidence", "ranking movement", "поведенческие/конверсионные signals"], + prepareNext: [ + "Готовить authority content и trust signals.", + "Выделить head-term candidates как отдельный runway.", + "Проверить quality/overseo перед масштабированием." + ] + }; + } + + return { + doNow: [ + "Работать с head terms только при наличии authority/performance evidence.", + "Сравнивать себя с top competitors по page type, depth, trust and snippet fit.", + "Масштабировать только validated clusters." + ], + hold: [ + "Не считать authority-фазу доступной для zero-site.", + "Не заменять доказанные core clusters широкими запросами.", + "Не запускать programmatic expansion без validation/quality gate." + ], + nextEvidenceTasks: highPriorityEvidence.length + ? highPriorityEvidence + : ["authority signals", "стабильный organic traffic", "top competitor gap evidence"], + prepareNext: [ + "Собрать authority-level landing/content clusters.", + "Подготовить branded/external trust план.", + "Проверить масштабирование через quality review." + ] + }; +} + +function buildPhaseStrategies(input: { + missingEvidence: SeoStrategyContract["missingEvidence"]; + opportunities: SeoStrategyContract["opportunities"]; + phasePlan: SeoStrategyContract["phasePlan"]; + risks: SeoStrategyContract["risks"]; + siteMaturity: SeoStrategyContract["siteMaturity"]; +}): SeoStrategyContract["phaseStrategies"] { + return input.phasePlan.map((phasePlanItem) => { + const allowedTiers = getPhaseAllowedTiers(phasePlanItem.phase); + const phaseRank = getPhaseRank(phasePlanItem.phase); + const phaseOpportunities = input.opportunities + .filter((opportunity) => { + const earliestRank = getPhaseRank(opportunity.timing.earliestPhase); + + if (phasePlanItem.phase === "authority_head_terms") { + return true; + } + + if (phasePlanItem.phase === "growth") { + return earliestRank <= phaseRank + 1; + } + + return earliestRank <= phaseRank && allowedTiers.includes(opportunity.competitionTier); + }) + .slice(0, 8); + const topOpportunity = phaseOpportunities[0] ?? input.opportunities[0] ?? null; + const evidenceRefs = Array.from( + new Set(phaseOpportunities.flatMap((opportunity) => opportunity.evidenceRefs).slice(0, 16)) + ); + const defaultTasks = getPhaseDefaultTasks(phasePlanItem.phase, input.missingEvidence); + const phaseRisks = input.risks + .filter((risk) => { + if (phasePlanItem.phase === "bootstrap_indexing") { + return risk.id.includes("zero_site") || risk.id.includes("serp") || risk.level === "critical"; + } + + if (phasePlanItem.phase === "authority_head_terms") { + return risk.id.includes("head") || risk.id.includes("authority") || risk.level !== "medium"; + } + + return risk.level !== "medium" || risk.id.includes("serp"); + }) + .slice(0, 5) + .map((risk) => `${risk.title}: ${risk.mitigation}`); + + return { + confidence: topOpportunity?.confidence ?? input.siteMaturity.confidence, + doNow: [ + ...defaultTasks.doNow, + ...phaseOpportunities + .filter((opportunity) => opportunity.timing.action === "do_now") + .slice(0, 3) + .map((opportunity) => `${opportunity.title}: ${opportunity.recommendation}`) + ].slice(0, 6), + evidenceRefs, + expectedImpact: + phasePlanItem.phase === "authority_head_terms" + ? "high" + : phasePlanItem.phase === "growth" || phasePlanItem.phase === "traction" + ? "medium" + : "low", + goal: phasePlanItem.goal, + hold: [ + ...defaultTasks.hold, + ...phaseOpportunities + .filter((opportunity) => opportunity.timing.action === "defer") + .slice(0, 3) + .map((opportunity) => `${opportunity.title}: ${opportunity.timing.reason}`) + ].slice(0, 7), + keywordMix: + phaseOpportunities.length > 0 + ? phaseOpportunities.slice(0, 5).map((opportunity) => ({ + competitionTier: opportunity.competitionTier, + description: `${opportunity.demand.totalFrequency} demand · ${opportunity.timing.reason}`, + evidenceRefs: opportunity.evidenceRefs.slice(0, 6), + label: opportunity.demand.topPhrase ?? opportunity.title, + priority: opportunity.priority + })) + : [ + { + competitionTier: allowedTiers[0] ?? "unknown", + description: "Недостаточно очищенных фраз для этой фазы: сначала нужен keyword cleaning/SERP evidence.", + evidenceRefs: [], + label: phasePlanItem.allowedTargets[0] ?? "нет фраз", + priority: "medium" + } + ], + landingPlan: + phaseOpportunities.length > 0 + ? phaseOpportunities.slice(0, 5).map((opportunity) => ({ + action: opportunity.timing.action, + evidenceRefs: opportunity.evidenceRefs.slice(0, 6), + priority: opportunity.priority, + reason: opportunity.whyItMatters, + targetPath: opportunity.targetPath, + title: opportunity.title + })) + : [ + { + action: "prepare", + evidenceRefs: [], + priority: "medium", + reason: "Нужно сначала получить очищенную карту ключей и SERP target-fit.", + targetPath: null, + title: "Посадочные пока не доказаны" + } + ], + nextEvidenceTasks: defaultTasks.nextEvidenceTasks.slice(0, 5), + phase: phasePlanItem.phase, + prepareNext: [ + ...defaultTasks.prepareNext, + ...phaseOpportunities + .filter((opportunity) => opportunity.timing.action === "prepare") + .slice(0, 3) + .map((opportunity) => `${opportunity.title}: ${opportunity.timing.promotionSignal}`) + ].slice(0, 6), + risks: phaseRisks.length > 0 ? phaseRisks : [phasePlanItem.risk], + status: phasePlanItem.status, + summary: getPhaseSummary(phasePlanItem.phase, phaseOpportunities), + title: phasePlanItem.title + }; + }); +} + function getProviderNextStep(provider: EvidenceProvider) { if (provider.status === "connected") { return "Использовать как evidence source в стратегии и показывать дату/объём сбора."; @@ -999,6 +1287,13 @@ export async function getSeoStrategyContract(projectId: string): Promise