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() {
})}
)}
-
@@ -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 (
{projectActionMessage}
) : null}
- {pendingDeleteProjectId === project.id ? (
-
-
- Удалить проект «{project.name}»?
- Проект исчезнет из списка, связанные данные импорта будут удалены.
-
-
-
- Отмена
-
- void handleProjectDelete(project)}
- type="button"
- >
- {busyProjectId === project.id ? : }
- Удалить
-
-
-
- ) : null}
{projectSiteTitle}
{activeStageIndex === 0 ? (
+
void handleProjectScan(project)}
+ title={
+ project.source?.type === "local_folder"
+ ? "Перечитать исходную папку"
+ : "Повторно просканировать загруженный снимок"
+ }
+ type="button"
+ >
+ {isScanning ? : }
+
) {
function SnapshotPreviewFrame({ label, url }: { label: string; url: string }) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+
+ useEffect(() => {
+ setLoaded(false);
+ setFailed(false);
+ setAttempt(0);
+ }, [url]);
+
+ useEffect(() => {
+ if (!failed || attempt >= 2) {
+ return;
+ }
+
+ const retryTimer = window.setTimeout(() => {
+ setFailed(false);
+ setAttempt((currentAttempt) => currentAttempt + 1);
+ }, 900 * (attempt + 1));
+
+ return () => window.clearTimeout(retryTimer);
+ }, [attempt, failed]);
+
+ const retryUrl = `${url}${url.includes("?") ? "&" : "?"}previewAttempt=${attempt}`;
return (
{!loaded ? (
- {failed ? "Preview failed" : "Preview"}
- {failed ? "Snapshot не построился" : "1920 x 1080"}
+ {failed && attempt >= 2 ? "Preview failed" : attempt > 0 ? "Повторяю preview" : "Preview"}
+ {failed && attempt >= 2 ? "Snapshot не построился" : "1920 x 1080 · строится автоматически"}
) : null}
![{`preview]()
setFailed(true)}
- onLoad={() => setLoaded(true)}
- src={url}
+ onLoad={() => {
+ setFailed(false);
+ setLoaded(true);
+ }}
+ src={retryUrl}
/>
);
@@ -981,10 +1006,10 @@ function buildScopeFlow(
}
otherIndexablePages.forEach((page, index) => {
- const y = SELECTED_NODE_HEIGHT + Y_GAP + index * (COMPACT_NODE_HEIGHT + 56);
+ const y = SELECTED_NODE_HEIGHT + Y_GAP + index * (NODE_HEIGHT + Y_GAP);
const nodeId = `${page.scope}-${page.pageId}`;
- addPageNode(page, { x: routeX, y }, COMPACT_NODE_WIDTH, COMPACT_NODE_HEIGHT, "none");
+ addPageNode(page, { x: routeX, y }, NODE_WIDTH, NODE_HEIGHT);
addEdge("project-root", nodeId, page.usedForCoverage ? "scope-flow-edge coverage" : "scope-flow-edge muted");
});
@@ -1106,7 +1131,7 @@ export default function ProjectScopeFlow({
const flowResetKey = `${projectId}:${scan.id}:${scan.pages.length}:${scan.siteSections.length}`;
const initialFlow = useMemo(
() => buildScopeFlow(scan, projectId, projectName, handleTogglePage),
- [flowResetKey, handleTogglePage, projectName]
+ [flowResetKey, handleTogglePage, projectId, projectName]
);
const flowEdges = useMemo(
() => buildScopeFlow(scan, projectId, projectName, handleTogglePage).edges,
diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts
index 459c7a2..9143b36 100644
--- a/seo_mode/seo_mode/app/src/api.ts
+++ b/seo_mode/seo_mode/app/src/api.ts
@@ -1,4 +1,5 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:4100";
+const previewRendererVersion = "2026-07-10-universal-root-and-anchor-v2";
function encodePathname(value: string) {
return value
@@ -3923,6 +3924,7 @@ export function getPreviewSnapshotUrl(
const baseUrl = `${apiBaseUrl}/preview/projects/${encodeURIComponent(projectId)}/snapshot/${encodePathname(sourcePath)}`;
const params = new URLSearchParams({
cacheVersion,
+ rendererVersion: previewRendererVersion,
viewportHeight: "1080",
viewportWidth: "1920"
});
diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css
index 834014b..b58c528 100644
--- a/seo_mode/seo_mode/app/src/styles.css
+++ b/seo_mode/seo_mode/app/src/styles.css
@@ -14451,6 +14451,251 @@ body:has(.seo-launcher-shell) {
display: none !important;
}
+/* Stage 01 keeps every source as a complete, compact card instead of a clipped continuous list. */
+.seo-launcher-shell.project-open .workspace.stage-1 .project-grid,
+.seo-launcher-shell.project-open .workspace.stage-1 .project-list-card,
+.seo-launcher-shell.project-open .workspace.stage-1 .project-list {
+ width: 100% !important;
+ min-width: 0;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-grid {
+ grid-template-columns: minmax(0, 1fr) !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-list-card {
+ padding-bottom: 0.15rem !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-list {
+ gap: 0.9rem !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row {
+ display: grid !important;
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) minmax(12rem, 0.3fr);
+ gap: 0.85rem 1rem !important;
+ padding: 1rem 1.1rem !important;
+ border: 1px solid rgba(20, 26, 24, 0.1) !important;
+ border-radius: 1.15rem !important;
+ background: rgba(255, 255, 255, 0.72) !important;
+ box-shadow: 0 12px 34px rgba(20, 26, 24, 0.06) !important;
+ overflow: hidden !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row > div:first-child {
+ min-width: 0;
+ gap: 0.55rem !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row > div:first-child > .project-title-row,
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row > div:first-child > .project-source,
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row > div:first-child > .project-scan-status {
+ display: flex !important;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-row-openable .project-title-row {
+ min-height: 1.75rem;
+ padding-right: 2.5rem;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-source,
+.seo-launcher-shell.project-open .workspace.stage-1 .project-scan-status {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-flow-actions {
+ margin-top: 0.15rem;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-meta {
+ display: flex !important;
+ min-width: 0;
+ align-self: stretch;
+ align-items: flex-end;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 0.8rem;
+ padding-top: 2rem;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-site-title {
+ max-width: 100%;
+ color: var(--muted);
+ font-size: 0.75rem;
+ line-height: 1.35;
+ text-align: right;
+}
+
+.seo-launcher-shell.project-open .workspace.stage-1 .project-open-arrow {
+ top: 1.05rem;
+ right: 1.15rem;
+}
+
+@media (max-width: 760px) {
+ .seo-launcher-shell.project-open .workspace.stage-1 .project-row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .seo-launcher-shell.project-open .workspace.stage-1 .project-meta {
+ align-items: flex-start;
+ padding-top: 0;
+ }
+
+ .seo-launcher-shell.project-open .workspace.stage-1 .project-site-title {
+ text-align: left;
+ }
+}
+
+.seo-project-delete-modal {
+ width: min(34rem, 100%);
+}
+
+.seo-project-delete-copy {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 0.9rem;
+ padding: 0.95rem;
+ border: 1px solid rgba(12, 14, 16, 0.1);
+ border-radius: 1.1rem;
+ background: rgba(255, 255, 255, 0.58);
+}
+
+.seo-project-delete-copy > div {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.seo-project-delete-copy strong,
+.seo-project-delete-copy p {
+ margin: 0;
+}
+
+.seo-project-delete-copy strong {
+ color: rgba(10, 12, 14, 0.96);
+ font-size: 1rem;
+ line-height: 1.25;
+}
+
+.seo-project-delete-copy p {
+ color: rgba(28, 31, 33, 0.64);
+ font-size: 0.84rem;
+ line-height: 1.45;
+}
+
+.seo-project-delete-icon {
+ display: grid;
+ width: 2.65rem;
+ height: 2.65rem;
+ place-items: center;
+ color: #fff;
+ border-radius: 999px;
+ background: #17191b;
+}
+
+.seo-project-delete-modal .danger-action {
+ color: #fff !important;
+ background: #17191b !important;
+ border-color: #17191b !important;
+ box-shadow: 0 12px 26px rgba(0, 0, 0, 0.16) !important;
+ outline: none !important;
+}
+
+.seo-project-delete-modal .danger-action:hover {
+ background: #000 !important;
+ border-color: #000 !important;
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.2) !important;
+}
+
+.semantic-storyline {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.65rem;
+}
+
+.semantic-storyline article {
+ position: relative;
+ display: grid;
+ min-width: 0;
+ align-content: start;
+ gap: 0.45rem;
+ padding: 0.95rem;
+ border: 1px solid rgba(24, 32, 29, 0.09);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.78);
+}
+
+.semantic-storyline article:not(:last-child)::after {
+ position: absolute;
+ top: 50%;
+ right: -0.53rem;
+ z-index: 2;
+ display: grid;
+ width: 1.05rem;
+ height: 1.05rem;
+ place-items: center;
+ color: rgba(24, 32, 29, 0.38);
+ content: "→";
+ font-size: 0.72rem;
+ transform: translateY(-50%);
+}
+
+.semantic-storyline span {
+ color: rgba(43, 105, 80, 0.9);
+ font-size: 0.68rem;
+ font-weight: 860;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.semantic-storyline strong {
+ color: rgba(10, 12, 14, 0.94);
+ font-size: 0.88rem;
+ line-height: 1.25;
+}
+
+.semantic-storyline small {
+ color: rgba(10, 12, 14, 0.56);
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.semantic-storyline article.next {
+ border-color: rgba(255, 149, 0, 0.24);
+ background: rgba(255, 248, 235, 0.84);
+}
+
+.semantic-storyline article.ready {
+ border-color: rgba(43, 105, 80, 0.2);
+ background: rgba(240, 250, 244, 0.84);
+}
+
+/* Context Dev is a decision gate. Scope editing belongs to Stage 02 and raw scan data stays inside diagnostics. */
+.seo-launcher-shell .workspace.stage-4 .scan-metrics,
+.seo-launcher-shell .workspace.stage-4 .scan-status-note,
+.seo-launcher-shell .workspace.stage-4 .scan-selection {
+ display: none !important;
+}
+
+@media (max-width: 1050px) {
+ .semantic-storyline {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .semantic-storyline article::after {
+ content: none !important;
+ }
+}
+
+@media (max-width: 640px) {
+ .semantic-storyline {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
.seo-header-mode-switch {
min-height: var(--nodedc-shell-pill-height) !important;
padding: var(--nodedc-shell-pill-padding) !important;
diff --git a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts
index fa9070a..9f686b2 100644
--- a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts
+++ b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts
@@ -3000,7 +3000,15 @@ export async function getLatestSemanticAnalysis(projectId: string) {
`
select id, status, output, error_message, started_at, completed_at, created_at
from runs
- where project_id = $1 and run_type = 'semantic_analysis'
+ where project_id = $1
+ and run_type = 'semantic_analysis'
+ and output->>'scanVersionId' = (
+ select sv.id::text
+ from scan_versions sv
+ where sv.project_id = $1 and sv.status = 'done'
+ order by sv.completed_at desc nulls last, sv.created_at desc
+ limit 1
+ )
order by created_at desc
limit 1;
`,
diff --git a/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts
index 767b965..a2991d3 100644
--- a/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts
+++ b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts
@@ -585,15 +585,24 @@ function buildSerpInterpretationOutput(projectId: string, evidence: YandexEviden
}
export async function getLatestSerpInterpretationContract(projectId: string): Promise {
+ const evidence = await getLatestYandexEvidenceContract(projectId);
+
+ if (!evidence.semanticRunId || !evidence.runId) {
+ return getEmptySerpInterpretationContract(projectId);
+ }
+
const result = await pool.query(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
from runs
- where project_id = $1 and run_type = 'seo_serp_interpretation'
+ where project_id = $1
+ and run_type = 'seo_serp_interpretation'
+ and output->>'semanticRunId' = $2
+ and output->>'yandexEvidenceRunId' = $3
order by created_at desc
limit 1;
`,
- [projectId]
+ [projectId, evidence.semanticRunId, evidence.runId]
);
const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null;
diff --git a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts
index e68ec01..b2b7f9e 100644
--- a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts
+++ b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts
@@ -923,15 +923,24 @@ export async function runYandexEvidenceCollection(
}
export async function getLatestYandexEvidenceContract(projectId: string): Promise {
+ const market = await getMarketEnrichmentContract(projectId);
+
+ if (!market.semanticRunId) {
+ return emptyContract(projectId, "not_collected");
+ }
+
const result = await pool.query(
`
select id, output, created_at
from runs
- where project_id = $1 and run_type = 'yandex_evidence' and status = 'done'
+ where project_id = $1
+ and run_type = 'yandex_evidence'
+ and status = 'done'
+ and output->>'semanticRunId' = $2
order by created_at desc
limit 1;
`,
- [projectId]
+ [projectId, market.semanticRunId]
);
const row = result.rows[0];
const output = asRecord(row?.output);
diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
index 7efd596..a364f0c 100644
--- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
+++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
@@ -146,7 +146,7 @@ async function buildProviderRegistry(): Promise {
{
id: "wordstat",
label: "Yandex Wordstat",
- role: "Частотность, похожие запросы, региональные и сезонные сигналы по seed-фразам.",
+ role: "GetTop: частотность за 30 дней и похожие запросы по seed-фразам. Dynamics и региональная раскладка ещё не подключены к сбору.",
status: wordstatStatus.connected ? "connected" : "not_configured",
mode: wordstatStatus.mode,
userActionRequired: false,
@@ -180,12 +180,13 @@ async function buildProviderRegistry(): Promise {
{
id: "text_quality",
label: "Text Quality",
- role: "Локальная проверка заспамленности, воды и перебора ключей после рерайта.",
+ role: "Два независимых gate после рерайта: локальный checkOverseo для SEO-перебора и редакторская ru-text policy для качества русского текста.",
status: "not_implemented",
- mode: "local_ru_text",
+ mode: "local_check_overseo_plus_editorial_policy",
userActionRequired: false,
platformActionRequired: true,
- message: "Text.ru API не подключается; по ТЗ нужен локальный quality checker по аналогичной модели показателей."
+ message:
+ "Text.ru остаётся референсом и необязательным validation adapter. ru-text — rule/skill pack для ModelProvider, а не замена детерминированному checkOverseo."
}
];
}
diff --git a/seo_mode/seo_mode/server/src/preview/routes.ts b/seo_mode/seo_mode/server/src/preview/routes.ts
index 1f50a88..c134386 100644
--- a/seo_mode/seo_mode/server/src/preview/routes.ts
+++ b/seo_mode/seo_mode/server/src/preview/routes.ts
@@ -123,9 +123,13 @@ function getSiteRoot(config: Record, currentPath: string) {
}
}
- const firstSegment = currentPath.split("/").filter(Boolean)[0] ?? "";
-
- return firstSegment.includes(".") ? "" : firstSegment;
+ // A leading slash in HTML/CSS is resolved from the web-site root, not from the
+ // first directory of the current document. Treating `knowledge/page/index.html`
+ // as if `knowledge` were the site root rewrites `/assets/app.css` to
+ // `knowledge/assets/app.css` and produces an unstyled snapshot. Imported browser
+ // folders can still declare an explicit rootName; local/static sites default to
+ // their actual source root.
+ return "";
}
function buildCandidatePaths(requestedPath: string, config: Record) {
@@ -567,7 +571,15 @@ function buildPreviewBridgeScript() {
}
function getUsableRect(selector, selectorIndex, options) {
- const element = getElementForSelector(selector, selectorIndex);
+ const selectedElement = getElementForSelector(selector, selectorIndex);
+
+ if (!selectedElement) return null;
+
+ const selectedRect = selectedElement.getBoundingClientRect();
+ const element = selectedRect.width >= 2 && selectedRect.height >= 2
+ ? selectedElement
+ : resolveRenderedAnchorElement(selectedElement);
+
if (!element) return null;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
@@ -2581,9 +2593,26 @@ function buildPreviewBridgeScript() {
function createMarker(target, left, top) {
const marker = document.createElement("button");
+ const severityLabel = target.severity === "error"
+ ? "Блокер"
+ : target.severity === "warning"
+ ? "Предупреждение"
+ : "Подсказка";
+ const markerColor = target.severity === "error"
+ ? "#d70015"
+ : target.severity === "warning"
+ ? "#ff9500"
+ : "#0a84ff";
+ const markerShadow = target.severity === "error"
+ ? "rgba(215,0,21,.3)"
+ : target.severity === "warning"
+ ? "rgba(255,149,0,.3)"
+ : "rgba(10,132,255,.28)";
marker.type = "button";
marker.textContent = String(target.number);
- marker.title = target.label || "Открыть поле правки";
+ marker.title = severityLabel + ": " + (target.message || target.label || "Открыть поле правки");
+ marker.setAttribute("aria-label", marker.title);
+ marker.dataset.severity = target.severity || "info";
marker.style.position = "absolute";
marker.style.left = left + "px";
marker.style.top = top + "px";
@@ -2593,10 +2622,10 @@ function buildPreviewBridgeScript() {
marker.style.placeItems = "center";
marker.style.padding = "0";
marker.style.color = "#fff";
- marker.style.background = "#ff4fb8";
+ marker.style.background = markerColor;
marker.style.border = "2px solid rgba(255,255,255,.95)";
marker.style.borderRadius = "999px";
- marker.style.boxShadow = "0 6px 16px rgba(255,79,184,.32)";
+ marker.style.boxShadow = "0 6px 16px " + markerShadow;
marker.style.cursor = "pointer";
marker.style.font = "700 12px/1 Inter, Arial, sans-serif";
marker.style.pointerEvents = "auto";
@@ -2640,6 +2669,97 @@ function buildPreviewBridgeScript() {
});
}
+ function hasRenderedBox(element) {
+ if (!(element instanceof Element)) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+
+ return (
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ Number(style.opacity) !== 0 &&
+ rect.width >= 2 &&
+ rect.height >= 2
+ );
+ }
+
+ function isSemanticScopeContainer(element) {
+ if (!(element instanceof Element)) {
+ return false;
+ }
+
+ const tag = element.tagName.toLowerCase();
+ const role = element.getAttribute("role")?.toLowerCase() || "";
+
+ return /^(header|main|section|article|footer|aside|nav)$/.test(tag) || role === "region" || role === "main";
+ }
+
+ function resolveRenderedAnchorElement(anchor) {
+ if (!(anchor instanceof Element)) {
+ return null;
+ }
+
+ if (hasRenderedBox(anchor)) {
+ return anchor;
+ }
+
+ // CMSs and builders often render zero-height navigation anchors either
+ // inside the target section or immediately before it. Resolve those
+ // anchors by DOM semantics instead of relying on site-specific classes.
+ let ancestor = anchor.parentElement;
+
+ while (ancestor && ancestor !== document.body && ancestor !== document.documentElement) {
+ if (isSemanticScopeContainer(ancestor) && hasRenderedBox(ancestor)) {
+ return ancestor;
+ }
+
+ ancestor = ancestor.parentElement;
+ }
+
+ let sibling = anchor.nextElementSibling;
+ let siblingDepth = 0;
+
+ while (sibling && siblingDepth < 8) {
+ if (isSemanticScopeContainer(sibling) && hasRenderedBox(sibling)) {
+ return sibling;
+ }
+
+ sibling = sibling.nextElementSibling;
+ siblingDepth += 1;
+ }
+
+ const anchorRect = anchor.getBoundingClientRect();
+ const anchorDocumentTop = anchorRect.top + window.scrollY;
+ const followingSemanticContainer = getScopeOrderFallbackElements()
+ .filter((candidate) => candidate !== anchor && !candidate.contains(anchor) && hasRenderedBox(candidate))
+ .map((candidate) => ({
+ candidate,
+ top: candidate.getBoundingClientRect().top + window.scrollY
+ }))
+ .filter((entry) => entry.top >= anchorDocumentTop - 8)
+ .sort((first, second) => first.top - second.top)[0]?.candidate;
+
+ if (followingSemanticContainer) {
+ return followingSemanticContainer;
+ }
+
+ const parent = anchor.parentElement;
+
+ if (parent && parent !== document.body && parent !== document.documentElement && hasRenderedBox(parent)) {
+ const parentRect = parent.getBoundingClientRect();
+ const documentHeight = Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0, window.innerHeight);
+
+ if (parentRect.height < Math.max(window.innerHeight * 4, documentHeight * 0.72)) {
+ return parent;
+ }
+ }
+
+ return anchor;
+ }
+
function isSafeDomIdFragment(value) {
return /^[A-Za-z][\\w:-]*$/.test(String(value || ""));
}
@@ -2673,7 +2793,7 @@ function buildPreviewBridgeScript() {
return getScopeOrderFallbackElements()[order] || null;
}
- function getScopePageElement(page) {
+ function getScopePageAnchorElement(page) {
const scopedAnchorElement = getScopedAnchorFallbackElement(page);
if (scopedAnchorElement) {
@@ -2693,6 +2813,10 @@ function buildPreviewBridgeScript() {
return getScopePageElementByOrder(page.order);
}
+ function getScopePageElement(page) {
+ return resolveRenderedAnchorElement(getScopePageAnchorElement(page));
+ }
+
function getProbeContentElement(x, y) {
const elements = typeof document.elementsFromPoint === "function"
? document.elementsFromPoint(x, y)
@@ -2708,11 +2832,15 @@ function buildPreviewBridgeScript() {
const probeLine = Math.min(Math.max(window.innerHeight * 0.38, 180), Math.max(180, window.innerHeight - 120));
const scopeRecords = state.scopePages.map((page, index) => {
+ const anchorElement = getScopePageAnchorElement(page);
const element = getScopePageElement(page);
const rect = element ? element.getBoundingClientRect() : null;
const usableRect = rect && rect.height > 0 && rect.width > 0 ? rect : null;
+ const anchorRect = anchorElement ? anchorElement.getBoundingClientRect() : null;
+ const anchorTop = anchorRect ? anchorRect.top + window.scrollY : null;
return {
+ anchorTop,
index,
element,
page,
@@ -2781,6 +2909,33 @@ function buildPreviewBridgeScript() {
}
const visibleCandidates = candidates.filter((candidate) => candidate.visibleArea > 0);
+ const probeDocumentY = window.scrollY + probeLine;
+ const orderedSectionAnchors = scopeRecords
+ .filter(
+ (record) =>
+ record.page.kind === "section" &&
+ typeof record.anchorTop === "number" &&
+ Number.isFinite(record.anchorTop)
+ )
+ .sort((first, second) => {
+ const topDifference = first.anchorTop - second.anchorTop;
+
+ if (Math.abs(topDifference) > 1) {
+ return topDifference;
+ }
+
+ const firstOrder = typeof first.page.order === "number" ? first.page.order : first.index;
+ const secondOrder = typeof second.page.order === "number" ? second.page.order : second.index;
+ return firstOrder - secondOrder;
+ });
+ const uniqueSectionAnchorBands = new Set(orderedSectionAnchors.map((record) => Math.round(record.anchorTop / 4)));
+ const intervalRecord = uniqueSectionAnchorBands.size >= 2
+ ? ([...orderedSectionAnchors].reverse().find((record) => record.anchorTop <= probeDocumentY + 1) ?? orderedSectionAnchors[0] ?? null)
+ : null;
+ const intervalScopeId = intervalRecord?.page?.scopeId || intervalRecord?.page?.pageId || "";
+ const intervalCandidate = intervalScopeId
+ ? candidates.find((candidate) => candidate.scopeId === intervalScopeId) || null
+ : null;
const probeElement = getProbeContentElement(Math.max(1, Math.min(window.innerWidth - 1, window.innerWidth * 0.5)), probeLine);
const probeRecord = probeElement
? scopeRecords.find((record) => record.element && record.rect && record.element.contains(probeElement))
@@ -2796,7 +2951,11 @@ function buildPreviewBridgeScript() {
const bottomCandidate = isNearDocumentBottom && visibleCandidates.length > 0
? [...visibleCandidates].sort((first, second) => second.index - first.index)[0]
: null;
- const forcedCandidate = bottomCandidate || probeCandidate || null;
+ // Anchor intervals are a strict, DOM-ordered identity map. They remain
+ // correct for zero-height anchors, overlapping wrappers and arbitrary
+ // section heights, while visible-area scoring remains a fallback for
+ // pages without stable anchors.
+ const forcedCandidate = intervalCandidate || bottomCandidate || probeCandidate || null;
const bestPage = forcedCandidate
? forcedCandidate
: (visibleCandidates.length > 0 ? visibleCandidates : candidates)
@@ -3432,6 +3591,24 @@ function getQueryDimension(value: unknown, fallback: number) {
return Number.isInteger(parsed) && parsed >= 320 && parsed <= 4096 ? parsed : fallback;
}
+function buildPreviewStabilityStyle() {
+ return `
+
+ `;
+}
+
function rewriteHtml(
html: string,
projectId: string,
@@ -3453,16 +3630,17 @@ function rewriteHtml(
return ` ${attribute}=${quote}${rewriteSrcset(value, projectId, siteRoot)}${quote}`;
});
const baseTag = ``;
+ const stabilityStyle = buildPreviewStabilityStyle();
const bridgeScript = buildPreviewBridgeScript();
const focusScript = buildPreviewFocusScript(options.focusSelector, options.focusIndex, options.focusOrder);
if (/]*>/i.test(withRootedAttributes)) {
return withRootedAttributes
- .replace(/]*>/i, (headTag) => `${headTag}${baseTag}`)
+ .replace(/]*>/i, (headTag) => `${headTag}${baseTag}${stabilityStyle}`)
.replace(/<\/body>/i, `${bridgeScript}${focusScript}`);
}
- return `${baseTag}${withRootedAttributes}${bridgeScript}${focusScript}`;
+ return `${baseTag}${stabilityStyle}${withRootedAttributes}${bridgeScript}${focusScript}`;
}
function rewriteCss(css: string, projectId: string, resolvedPath: string, sourceConfig: Record) {
diff --git a/seo_mode/seo_mode/server/src/preview/snapshotCache.ts b/seo_mode/seo_mode/server/src/preview/snapshotCache.ts
index 3eee04a..fbfa4a9 100644
--- a/seo_mode/seo_mode/server/src/preview/snapshotCache.ts
+++ b/seo_mode/seo_mode/server/src/preview/snapshotCache.ts
@@ -24,6 +24,7 @@ const inFlightSnapshots = new Map>();
const queue: QueueTask[] = [];
let activeTasks = 0;
let browserPromise: Promise | null = null;
+const SNAPSHOT_RENDERER_VERSION = "2026-07-10-universal-root-and-anchor-v2";
function encodePathname(value: string) {
return value
@@ -40,6 +41,7 @@ function getSnapshotCacheKey(input: SnapshotRequest) {
JSON.stringify({
focusIndex: input.focusIndex,
focusSelector: input.focusSelector,
+ rendererVersion: SNAPSHOT_RENDERER_VERSION,
sourcePath: input.sourcePath,
viewportHeight: input.viewportHeight,
viewportWidth: input.viewportWidth
@@ -126,13 +128,51 @@ async function generateSnapshot(input: SnapshotRequest, cacheKey: string) {
width: input.viewportWidth
}
});
+ const failedStylesheets = new Set();
+
+ page.on("response", (response) => {
+ if (response.request().resourceType() === "stylesheet" && response.status() >= 400) {
+ failedStylesheets.add(`${response.status()} ${response.url()}`);
+ }
+ });
+ page.on("requestfailed", (request) => {
+ if (request.resourceType() === "stylesheet") {
+ failedStylesheets.add(`${request.failure()?.errorText ?? "request failed"} ${request.url()}`);
+ }
+ });
try {
page.setDefaultTimeout(18_000);
await page.goto(buildPreviewUrl(input), { timeout: 25_000, waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
+ await page.evaluate(async () => {
+ if (document.fonts?.ready) {
+ await document.fonts.ready;
+ }
+ });
await page.waitForTimeout(env.preview.snapshotWaitMs);
+ const stylesheetState = await page.evaluate(() => {
+ const linkedStylesheets = Array.from(document.querySelectorAll('link[rel~="stylesheet"]'))
+ .filter((link) => !link.media || link.media === "all" || link.media === "screen")
+ .length;
+ const loadedLinkedStylesheets = Array.from(document.styleSheets)
+ .filter((sheet) => sheet.ownerNode instanceof HTMLLinkElement)
+ .length;
+
+ return { linkedStylesheets, loadedLinkedStylesheets };
+ });
+
+ // A single optional stylesheet may legitimately be unavailable. Refuse to
+ // cache only a wholly unstyled render; partial but usable pages remain visible.
+ if (
+ failedStylesheets.size > 0 &&
+ stylesheetState.linkedStylesheets > 0 &&
+ stylesheetState.loadedLinkedStylesheets === 0
+ ) {
+ throw new Error(`Preview stylesheets failed: ${Array.from(failedStylesheets).slice(0, 4).join("; ")}`);
+ }
+
const screenshot = await page.screenshot({
animations: "disabled",
fullPage: false,
diff --git a/seo_mode/seo_mode/server/src/scans/projectScanner.ts b/seo_mode/seo_mode/server/src/scans/projectScanner.ts
index 9957444..70f5d8d 100644
--- a/seo_mode/seo_mode/server/src/scans/projectScanner.ts
+++ b/seo_mode/seo_mode/server/src/scans/projectScanner.ts
@@ -1823,8 +1823,18 @@ function buildBaselineAuditOutput(
let missingTitle = 0;
let missingDescription = 0;
let missingH1 = 0;
+ let pagesChecked = 0;
for (const page of pages) {
+ const pageScope = classifyScannedPageScope(page);
+
+ // Templates, admin screens and technical fragments are context for the scanner,
+ // but they are not standalone landing pages and must not create fake title/H1/meta errors.
+ if (pageScope.scope !== "indexable_page") {
+ continue;
+ }
+
+ pagesChecked += 1;
const headings = normalizeHeadings(page.raw);
const headingCounts = getHeadingLevelCounts(headings);
const bodyTextLength = getRawNumber(page.raw, "bodyTextLength") ?? 0;
@@ -2096,7 +2106,7 @@ function buildBaselineAuditOutput(
return {
scanVersionId: scan.id,
summary: {
- pagesChecked: pages.length,
+ pagesChecked,
assetsChecked: assets.length,
missingTitle,
missingDescription,
diff --git a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts
index 1ab6ee1..838ae41 100644
--- a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts
+++ b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts
@@ -196,7 +196,7 @@ const serviceDefinitions: Record =
yandex_wordstat: {
id: "yandex_wordstat",
label: "Yandex Wordstat",
- description: "Частотность, похожие запросы, динамика и региональная раскладка через Yandex Search API Wordstat.",
+ description: "Сейчас подключён GetTop: частотность за 30 дней и associations. Dynamics и региональная раскладка остаются следующим provider-слоем.",
implementationStatus: "active",
defaultMode: "yandex_search_api",
modes: [
diff --git a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts
index e130f51..ecf9c3f 100644
--- a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts
+++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts
@@ -668,15 +668,27 @@ function buildStrategySynthesisOutput(
}
export async function getLatestStrategySynthesisContract(projectId: string): Promise {
+ const [strategy, serpInterpretation] = await Promise.all([
+ getSeoStrategyContract(projectId),
+ getLatestSerpInterpretationContract(projectId)
+ ]);
+
+ if (!strategy.semanticRunId || serpInterpretation.state !== "ready" || !serpInterpretation.runId) {
+ return getEmptyStrategySynthesisContract(projectId);
+ }
+
const result = await pool.query(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
from runs
- where project_id = $1 and run_type = 'seo_strategy_synthesis'
+ where project_id = $1
+ and run_type = 'seo_strategy_synthesis'
+ and output->>'semanticRunId' = $2
+ and output->>'sourceSerpInterpretationRunId' = $3
order by created_at desc
limit 1;
`,
- [projectId]
+ [projectId, strategy.semanticRunId, serpInterpretation.runId]
);
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;