From 4ade361659dc02fc2066ab1b44b6183b741f9e61 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 10 Jul 2026 12:13:46 +0300 Subject: [PATCH] Stabilize SEO source, preview, and evidence flow --- seo_mode/seo_mode/app/src/App.tsx | 302 +++++++++++++++--- .../seo_mode/app/src/ProjectScopeFlow.tsx | 41 ++- seo_mode/seo_mode/app/src/api.ts | 2 + seo_mode/seo_mode/app/src/styles.css | 245 ++++++++++++++ .../server/src/analysis/semanticAnalysis.ts | 10 +- .../server/src/evidence/serpInterpretation.ts | 13 +- .../server/src/evidence/yandexEvidence.ts | 13 +- .../server/src/market/marketEnrichment.ts | 9 +- .../seo_mode/server/src/preview/routes.ts | 200 +++++++++++- .../server/src/preview/snapshotCache.ts | 40 +++ .../server/src/scans/projectScanner.ts | 12 +- .../src/settings/externalServiceSettings.ts | 2 +- .../server/src/strategy/strategySynthesis.ts | 16 +- 13 files changed, 826 insertions(+), 79 deletions(-) diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index 453837a..a58fa16 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -5670,8 +5670,8 @@ function getSourceDescription(project: ProjectSummary) { if (typeof rootName === "string") { return typeof fileCount === "number" - ? `Импортированная папка «${rootName}», файлов: ${fileCount}${importDateText}` - : `Импортированная папка «${rootName}»${importDateText}`; + ? `Снимок папки «${rootName}», файлов: ${fileCount}${importDateText}` + : `Снимок папки «${rootName}»${importDateText}`; } return project.source.type; @@ -7718,6 +7718,17 @@ function getWorkspaceScopePreviewFocusIndex(scopeItem: WorkspaceScopeItem | null return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ? 0 : scopeItem?.previewTargetIndex ?? null; } +function normalizeWorkspacePreviewSourceKey(value: string | null | undefined) { + return (value ?? "") + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .toLocaleLowerCase("ru-RU"); +} + +function getWorkspaceScopePreviewSourceKey(scopeItem: WorkspaceScopeItem | null | undefined) { + return normalizeWorkspacePreviewSourceKey(scopeItem?.previewSourcePath ?? scopeItem?.sourcePath); +} + function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScopeItem[] { const sectionPageIds = new Set( scan.siteSections @@ -7906,9 +7917,13 @@ function getSelectedWorkspaceIssueCount(scan: ProjectScanSummary | null | undefi return 0; } - const pageIds = new Set(scopeItems.map((item) => item.pageId)); + const issueIds = new Set(); - return scan.audit.issues.filter((issue) => issue.pageId && pageIds.has(issue.pageId)).length; + scopeItems.forEach((item) => { + getWorkspaceScopeIssues(scan, item).forEach((issue) => issueIds.add(issue.id)); + }); + + return issueIds.size; } function getWorkspaceKey(projectId: string, scopeId: string) { @@ -9490,6 +9505,48 @@ function getPageIssues(scan: ProjectScanSummary | null | undefined, pageId: stri return scan.audit.issues.filter((issue) => issue.pageId === pageId); } +function getWorkspaceScopeIssues( + scan: ProjectScanSummary | null | undefined, + scopeItem: WorkspaceScopeItem | null | undefined +) { + const issues = getPageIssues(scan, scopeItem?.pageId ?? null); + + if (scopeItem?.kind !== "section") { + return issues; + } + + // A source section is rendered inside its parent page. Missing title/description/H1 on + // the standalone template file are scanner implementation details, not user-facing SEO errors. + const sectionIssues = issues.filter( + (issue) => + issue.scope === "media" || + !["title", "description", "h1", "headings"].includes(issue.field ?? "") + ); + const previewSourceKey = getWorkspaceScopePreviewSourceKey(scopeItem); + const renderedPage = scan?.pages.find( + (page) => + page.scope === "indexable_page" && + normalizeWorkspacePreviewSourceKey(page.previewSourcePath ?? page.sourcePath) === previewSourceKey + ); + const mediaSignals = scopeItem.editableFields + .map((field) => normalizeIssueLookupText(field.value)) + .filter((value) => value.length >= 4); + const renderedMediaIssues = getPageIssues(scan, renderedPage?.pageId ?? null).filter((issue) => { + if (issue.scope !== "media" || mediaSignals.length === 0) { + return false; + } + + const normalizedMessage = normalizeIssueLookupText(issue.message); + + return mediaSignals.some((signal) => { + const filename = normalizeIssueLookupText(getFilenameFromHint(signal)); + return normalizedMessage.includes(signal) || (filename.length >= 4 && normalizedMessage.includes(filename)); + }); + }); + + return Array.from(new Map([...sectionIssues, ...renderedMediaIssues].map((issue) => [issue.id, issue])).values()); +} + function getPageAuditLine(scan: ProjectScanSummary, page: ProjectScanSummary["pages"][number]) { if (!scan.audit) { return "Базовый аудит ещё не готов."; @@ -10546,7 +10603,7 @@ export function App() { return () => undefined; } - const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); + const issues = getWorkspaceScopeIssues(scanSummary, activeScopeItem); const rawTargets = buildWorkspaceTargets(workspace, issues, { includeRewriteTargets: activeStageIndex === 6, includeSectionFallback: activeStageIndex === 6, @@ -10617,7 +10674,7 @@ export function App() { return () => undefined; } - const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); + const issues = getWorkspaceScopeIssues(scanSummary, activeScopeItem); const rawTargets = buildWorkspaceTargets(workspace, issues, { includeRewriteTargets: activeStageIndex === 6, includeSectionFallback: activeStageIndex === 6, @@ -11066,6 +11123,14 @@ export function App() { } } + function handleOpenAddProject() { + setIsProjectMenuOpen(false); + setCreateError(null); + setCreateMessage(null); + setFolderPickerMessage(null); + void handlePickFolder(); + } + function handleFolderInputChange(event: ChangeEvent) { applyPickedFolder(folderFromInputFiles(event.target.files)); event.target.value = ""; @@ -14113,6 +14178,12 @@ export function App() { const semanticBlockBoxes = semanticBlockEditModeEnabled ? serializeSemanticBlockEditBoxes(getSemanticBlockEditBoxes(workspaceKey)) : []; + const activePreviewSourceKey = getWorkspaceScopePreviewSourceKey(activeScopeItem); + const previewScopeItems = activePreviewSourceKey + ? scopeItems.filter((item) => getWorkspaceScopePreviewSourceKey(item) === activePreviewSourceKey) + : activeScopeItem + ? [activeScopeItem] + : []; frameWindow.postMessage( { @@ -14123,7 +14194,7 @@ export function App() { hasManualSemanticBlockSet: semanticBlockEditModeEnabled ? hasSemanticBlockManualSet(workspaceKey) : false, semanticBlockBoxes, semanticBlockEditMode: semanticBlockEditModeEnabled, - scopePages: scopeItems + scopePages: previewScopeItems .map((item) => ({ label: item.title, kind: item.kind, @@ -14155,6 +14226,7 @@ export function App() { : null, id: target.id, label: target.title, + message: target.issues[0]?.message ?? target.permissionReason, number: target.number, pageStartFallback: !shouldAnchorToRenderedSection && target.kind !== "media", preferPageStart: @@ -14176,6 +14248,7 @@ export function App() { selector: target.selector, selectorIndex: null, selectors: shouldAnchorToRenderedSection ? [] : target.markerSelectors, + severity: target.issues[0]?.severity ?? "info", source: target.source, textNeedles: getWorkspaceTargetPreviewNeedles(target) }; @@ -14709,7 +14782,7 @@ export function App() { ? getWorkspaceKey(activeProject.id, activeProjectWorkspaceScopeItem.scopeId) : null; const activeProjectWorkspace = activeProjectWorkspaceKey ? pageWorkspaces[activeProjectWorkspaceKey] : null; - const activeProjectWorkspaceIssues = getPageIssues(activeProjectScanSummary, activeProjectWorkspaceScopeItem?.pageId ?? null); + const activeProjectWorkspaceIssues = getWorkspaceScopeIssues(activeProjectScanSummary, activeProjectWorkspaceScopeItem); const activeProjectRewriteDiff = activeProject ? (rewriteDiffContracts[activeProject.id] ?? null) : null; const activeProjectPatchArtifact = activeProject ? (patchArtifacts[activeProject.id] ?? null) : null; const activeProjectVisualComposerVariantContract = activeProject @@ -14751,9 +14824,12 @@ export function App() { const activeProjectWorkspaceDrawers = activeProject ? WORKSPACE_DRAWER_ORDER.filter((drawer) => (workspaceDrawers[activeProject.id] ?? []).includes(drawer)) : []; - const activeProjectWorkspacePageIndex = Math.max( + const activeProjectWorkspaceKindItems = activeProjectWorkspaceScopeItem + ? activeProjectSelectedWorkspaceScopeItems.filter((item) => item.kind === activeProjectWorkspaceScopeItem.kind) + : []; + const activeProjectWorkspaceKindIndex = Math.max( 0, - activeProjectSelectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeProjectWorkspaceScopeItem?.scopeId) + activeProjectWorkspaceKindItems.findIndex((item) => item.scopeId === activeProjectWorkspaceScopeItem?.scopeId) ); const activeProjectSiteTitle = getProjectSiteTitle(activeProjectScanSummary); const isActiveProjectSelectingPages = activeProject ? selectingPagesProjectId === activeProject.id : false; @@ -14770,6 +14846,9 @@ export function App() { const hasActiveProjectScanEntry = activeProject ? Object.prototype.hasOwnProperty.call(scanSummaries, activeProject.id) : false; + const pendingDeleteProject = pendingDeleteProjectId + ? (projects.find((project) => project.id === pendingDeleteProjectId) ?? null) + : null; const visibleProjects = activeStageIndex === 0 ? projects : activeProject ? [activeProject] : []; const stageAccessList = pipeline.map((_, stageIndex) => getPipelineStageAccess(stageIndex, { @@ -15237,7 +15316,26 @@ export function App() { } const topbarStageActions = - activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( + activeProject && activeStageIndex === 0 ? ( + + ) : activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( @@ -15880,7 +15978,7 @@ export function App() { })} )} - @@ -16342,6 +16440,74 @@ export function App() { ) : null} + {pendingDeleteProject ? ( +
{ + if (event.target === event.currentTarget && busyProjectId !== pendingDeleteProject.id) { + cancelProjectDelete(); + } + }} + role="presentation" + > +
+
+ Подтверждение удаления + +
+
+ +
+ Удалить проект «{pendingDeleteProject.name}»? +

+ Проект, его импорт, сканы и рабочие данные исчезнут из SEO Mode. Это действие нельзя отменить. +

+
+
+ {projectActionProjectId === pendingDeleteProject.id && projectActionError ? ( +

+ + {projectActionError} +

+ ) : null} +
+ + +
+
+
+ ) : null} +
@@ -16477,6 +16643,17 @@ export function App() { {activeKeywordContextToolbar} {topbarStageActions}
+ {activeStageIndex === 0 ? ( + + ) : null} {activeStageIndex === 4 ? (
+ {semanticAnalysis ? ( +
+
+ 01 · Собрали + + {semanticAnalysis.pageScope.selectedIndexablePages ?? semanticAnalysis.pageScope.indexablePages} страниц scope ·{" "} + {semanticAnalysis.pageScope.contentSources} источника контента + + Текст, meta, headings, медиа и структурированные источники текущего SEO scope. +
+
+ 02 · Поняли + + {semanticAnalysis.summary.coveredClusters} сильных · {semanticAnalysis.summary.weakClusters} слабых кластеров + + Это ответ на вопрос «что сайт уже умеет доказать», а не прогноз поискового спроса. +
+
+ 03 · Ограничили + + {semanticAnalysis.analysisVerdict + ? getSemanticVerdictObjectivityLabel(semanticAnalysis.analysisVerdict.marketObjectivity) + : "Рынок ещё не проверен"} + + Неподтверждённые обещания и смысловые пробелы должны быть сняты до Wordstat и SERP. +
+
+ 04 · Следующий gate + {contextReview ? "Контекст проверен моделью" : "Нужна модельная проверка"} + + {contextReview + ? "Можно утверждать базис и переходить к сбору рыночного языка." + : "Проверь запреты, неоднозначности и расширение смысла перед этапом ключей."} + +
+
+ ) : null} {semanticAnalysis ? ( <>
@@ -23487,9 +23708,9 @@ export function App() { type="button" > - Страница + {activeWorkspaceScopeItem.kind === "section" ? "Секция" : "Страница"} - {activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length} + {activeWorkspaceKindIndex + 1}/{activeWorkspaceKindItems.length} @@ -23762,7 +23983,7 @@ export function App() { const pageWorkspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId); const isActivePage = activeWorkspaceScopeItem?.scopeId === scopeItem.scopeId; const isOpening = busyWorkspaceKey === pageWorkspaceKey; - const pageIssues = getPageIssues(scanSummary, scopeItem.pageId); + const pageIssues = getWorkspaceScopeIssues(scanSummary, scopeItem); return ( - - - - ) : null}
{projectSiteTitle} {activeStageIndex === 0 ? (
+