Stabilize SEO source, preview, and evidence flow

This commit is contained in:
DCCONSTRUCTIONS 2026-07-10 12:13:46 +03:00
parent 8379a4487f
commit 4ade361659
13 changed files with 826 additions and 79 deletions

View File

@ -5670,8 +5670,8 @@ function getSourceDescription(project: ProjectSummary) {
if (typeof rootName === "string") { if (typeof rootName === "string") {
return typeof fileCount === "number" return typeof fileCount === "number"
? `Импортированная папка «${rootName}», файлов: ${fileCount}${importDateText}` ? `Снимок папки «${rootName}», файлов: ${fileCount}${importDateText}`
: `Импортированная папка «${rootName}»${importDateText}`; : `Снимок папки «${rootName}»${importDateText}`;
} }
return project.source.type; return project.source.type;
@ -7718,6 +7718,17 @@ function getWorkspaceScopePreviewFocusIndex(scopeItem: WorkspaceScopeItem | null
return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ? 0 : scopeItem?.previewTargetIndex ?? 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[] { function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScopeItem[] {
const sectionPageIds = new Set( const sectionPageIds = new Set(
scan.siteSections scan.siteSections
@ -7906,9 +7917,13 @@ function getSelectedWorkspaceIssueCount(scan: ProjectScanSummary | null | undefi
return 0; return 0;
} }
const pageIds = new Set(scopeItems.map((item) => item.pageId)); const issueIds = new Set<string>();
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) { 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); 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]) { function getPageAuditLine(scan: ProjectScanSummary, page: ProjectScanSummary["pages"][number]) {
if (!scan.audit) { if (!scan.audit) {
return "Базовый аудит ещё не готов."; return "Базовый аудит ещё не готов.";
@ -10546,7 +10603,7 @@ export function App() {
return () => undefined; return () => undefined;
} }
const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); const issues = getWorkspaceScopeIssues(scanSummary, activeScopeItem);
const rawTargets = buildWorkspaceTargets(workspace, issues, { const rawTargets = buildWorkspaceTargets(workspace, issues, {
includeRewriteTargets: activeStageIndex === 6, includeRewriteTargets: activeStageIndex === 6,
includeSectionFallback: activeStageIndex === 6, includeSectionFallback: activeStageIndex === 6,
@ -10617,7 +10674,7 @@ export function App() {
return () => undefined; return () => undefined;
} }
const issues = getPageIssues(scanSummary, activeScopeItem?.pageId ?? null); const issues = getWorkspaceScopeIssues(scanSummary, activeScopeItem);
const rawTargets = buildWorkspaceTargets(workspace, issues, { const rawTargets = buildWorkspaceTargets(workspace, issues, {
includeRewriteTargets: activeStageIndex === 6, includeRewriteTargets: activeStageIndex === 6,
includeSectionFallback: 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<HTMLInputElement>) { function handleFolderInputChange(event: ChangeEvent<HTMLInputElement>) {
applyPickedFolder(folderFromInputFiles(event.target.files)); applyPickedFolder(folderFromInputFiles(event.target.files));
event.target.value = ""; event.target.value = "";
@ -14113,6 +14178,12 @@ export function App() {
const semanticBlockBoxes = semanticBlockEditModeEnabled const semanticBlockBoxes = semanticBlockEditModeEnabled
? serializeSemanticBlockEditBoxes(getSemanticBlockEditBoxes(workspaceKey)) ? serializeSemanticBlockEditBoxes(getSemanticBlockEditBoxes(workspaceKey))
: []; : [];
const activePreviewSourceKey = getWorkspaceScopePreviewSourceKey(activeScopeItem);
const previewScopeItems = activePreviewSourceKey
? scopeItems.filter((item) => getWorkspaceScopePreviewSourceKey(item) === activePreviewSourceKey)
: activeScopeItem
? [activeScopeItem]
: [];
frameWindow.postMessage( frameWindow.postMessage(
{ {
@ -14123,7 +14194,7 @@ export function App() {
hasManualSemanticBlockSet: semanticBlockEditModeEnabled ? hasSemanticBlockManualSet(workspaceKey) : false, hasManualSemanticBlockSet: semanticBlockEditModeEnabled ? hasSemanticBlockManualSet(workspaceKey) : false,
semanticBlockBoxes, semanticBlockBoxes,
semanticBlockEditMode: semanticBlockEditModeEnabled, semanticBlockEditMode: semanticBlockEditModeEnabled,
scopePages: scopeItems scopePages: previewScopeItems
.map((item) => ({ .map((item) => ({
label: item.title, label: item.title,
kind: item.kind, kind: item.kind,
@ -14155,6 +14226,7 @@ export function App() {
: null, : null,
id: target.id, id: target.id,
label: target.title, label: target.title,
message: target.issues[0]?.message ?? target.permissionReason,
number: target.number, number: target.number,
pageStartFallback: !shouldAnchorToRenderedSection && target.kind !== "media", pageStartFallback: !shouldAnchorToRenderedSection && target.kind !== "media",
preferPageStart: preferPageStart:
@ -14176,6 +14248,7 @@ export function App() {
selector: target.selector, selector: target.selector,
selectorIndex: null, selectorIndex: null,
selectors: shouldAnchorToRenderedSection ? [] : target.markerSelectors, selectors: shouldAnchorToRenderedSection ? [] : target.markerSelectors,
severity: target.issues[0]?.severity ?? "info",
source: target.source, source: target.source,
textNeedles: getWorkspaceTargetPreviewNeedles(target) textNeedles: getWorkspaceTargetPreviewNeedles(target)
}; };
@ -14709,7 +14782,7 @@ export function App() {
? getWorkspaceKey(activeProject.id, activeProjectWorkspaceScopeItem.scopeId) ? getWorkspaceKey(activeProject.id, activeProjectWorkspaceScopeItem.scopeId)
: null; : null;
const activeProjectWorkspace = activeProjectWorkspaceKey ? pageWorkspaces[activeProjectWorkspaceKey] : 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 activeProjectRewriteDiff = activeProject ? (rewriteDiffContracts[activeProject.id] ?? null) : null;
const activeProjectPatchArtifact = activeProject ? (patchArtifacts[activeProject.id] ?? null) : null; const activeProjectPatchArtifact = activeProject ? (patchArtifacts[activeProject.id] ?? null) : null;
const activeProjectVisualComposerVariantContract = activeProject const activeProjectVisualComposerVariantContract = activeProject
@ -14751,9 +14824,12 @@ export function App() {
const activeProjectWorkspaceDrawers = activeProject const activeProjectWorkspaceDrawers = activeProject
? WORKSPACE_DRAWER_ORDER.filter((drawer) => (workspaceDrawers[activeProject.id] ?? []).includes(drawer)) ? 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, 0,
activeProjectSelectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeProjectWorkspaceScopeItem?.scopeId) activeProjectWorkspaceKindItems.findIndex((item) => item.scopeId === activeProjectWorkspaceScopeItem?.scopeId)
); );
const activeProjectSiteTitle = getProjectSiteTitle(activeProjectScanSummary); const activeProjectSiteTitle = getProjectSiteTitle(activeProjectScanSummary);
const isActiveProjectSelectingPages = activeProject ? selectingPagesProjectId === activeProject.id : false; const isActiveProjectSelectingPages = activeProject ? selectingPagesProjectId === activeProject.id : false;
@ -14770,6 +14846,9 @@ export function App() {
const hasActiveProjectScanEntry = activeProject const hasActiveProjectScanEntry = activeProject
? Object.prototype.hasOwnProperty.call(scanSummaries, activeProject.id) ? Object.prototype.hasOwnProperty.call(scanSummaries, activeProject.id)
: false; : false;
const pendingDeleteProject = pendingDeleteProjectId
? (projects.find((project) => project.id === pendingDeleteProjectId) ?? null)
: null;
const visibleProjects = activeStageIndex === 0 ? projects : activeProject ? [activeProject] : []; const visibleProjects = activeStageIndex === 0 ? projects : activeProject ? [activeProject] : [];
const stageAccessList = pipeline.map((_, stageIndex) => const stageAccessList = pipeline.map((_, stageIndex) =>
getPipelineStageAccess(stageIndex, { getPipelineStageAccess(stageIndex, {
@ -15237,7 +15316,26 @@ export function App() {
} }
const topbarStageActions = const topbarStageActions =
activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( activeProject && activeStageIndex === 0 ? (
<button
className="seo-topbar-primary-action"
disabled={isActiveProjectScanning}
onClick={() => void handleProjectScan(activeProject)}
title={
activeProject.source?.type === "local_folder"
? "Перечитать исходную папку и создать новую версию скана"
: "Повторно просканировать загруженный снимок проекта"
}
type="button"
>
{isActiveProjectScanning ? <Loader2 className="spin" size={16} /> : <RefreshCw size={16} />}
{isActiveProjectScanning
? "Обновляю..."
: activeProject.source?.type === "local_folder"
? "Обновить источник"
: "Пересканировать снимок"}
</button>
) : activeProject && activeProjectScanSummary && activeStageIndex === 1 ? (
<button <button
className="seo-topbar-primary-action" className="seo-topbar-primary-action"
disabled={!canConfirmActiveProjectScope} disabled={!canConfirmActiveProjectScope}
@ -15255,9 +15353,9 @@ export function App() {
type="button" type="button"
> >
<FileText size={14} /> <FileText size={14} />
Страница {activeProjectWorkspaceScopeItem.kind === "section" ? "Секция" : "Страница"}
<em> <em>
{activeProjectWorkspacePageIndex + 1}/{activeProjectSelectedWorkspaceScopeItems.length} {activeProjectWorkspaceKindIndex + 1}/{activeProjectWorkspaceKindItems.length}
</em> </em>
<ChevronDown size={14} /> <ChevronDown size={14} />
</button> </button>
@ -15880,7 +15978,7 @@ export function App() {
})} })}
</div> </div>
)} )}
<button className="seo-project-menu-add" onClick={closeActiveProject} type="button"> <button className="seo-project-menu-add" onClick={handleOpenAddProject} type="button">
<Plus size={15} /> <Plus size={15} />
Добавить проект Добавить проект
</button> </button>
@ -16342,6 +16440,74 @@ export function App() {
</div> </div>
) : null} ) : null}
{pendingDeleteProject ? (
<div
className="seo-project-name-modal-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget && busyProjectId !== pendingDeleteProject.id) {
cancelProjectDelete();
}
}}
role="presentation"
>
<section
aria-describedby="seo-project-delete-description"
aria-labelledby="seo-project-delete-title"
aria-modal="true"
className="seo-project-name-modal seo-project-delete-modal"
role="dialog"
>
<div className="seo-project-name-modal-head">
<span>Подтверждение удаления</span>
<button
aria-label="Закрыть подтверждение удаления"
disabled={busyProjectId === pendingDeleteProject.id}
onClick={cancelProjectDelete}
type="button"
>
<X size={17} />
</button>
</div>
<div className="seo-project-delete-copy">
<span className="seo-project-delete-icon" aria-hidden="true">
<Trash2 size={20} />
</span>
<div>
<strong id="seo-project-delete-title">Удалить проект «{pendingDeleteProject.name}»?</strong>
<p id="seo-project-delete-description">
Проект, его импорт, сканы и рабочие данные исчезнут из SEO Mode. Это действие нельзя отменить.
</p>
</div>
</div>
{projectActionProjectId === pendingDeleteProject.id && projectActionError ? (
<p className="form-message error">
<CircleAlert size={16} />
<span>{projectActionError}</span>
</p>
) : null}
<div className="seo-project-name-modal-actions">
<button
className="tiny-action"
disabled={busyProjectId === pendingDeleteProject.id}
onClick={cancelProjectDelete}
type="button"
>
Отмена
</button>
<button
className="tiny-action danger-action"
disabled={busyProjectId === pendingDeleteProject.id}
onClick={() => void handleProjectDelete(pendingDeleteProject)}
type="button"
>
{busyProjectId === pendingDeleteProject.id ? <Loader2 className="spin" size={16} /> : <Trash2 size={16} />}
{busyProjectId === pendingDeleteProject.id ? "Удаляю..." : "Удалить проект"}
</button>
</div>
</section>
</div>
) : null}
<main className={`workspace stage-${activeStageIndex + 1}`}> <main className={`workspace stage-${activeStageIndex + 1}`}>
<section className="seo-zero-stage" aria-label="NodeDC SEO Mode"> <section className="seo-zero-stage" aria-label="NodeDC SEO Mode">
<div className="stage-video-shell"> <div className="stage-video-shell">
@ -16477,6 +16643,17 @@ export function App() {
{activeKeywordContextToolbar} {activeKeywordContextToolbar}
{topbarStageActions} {topbarStageActions}
<div className="seo-work-panel-actions"> <div className="seo-work-panel-actions">
{activeStageIndex === 0 ? (
<button
aria-label="Добавить проект"
disabled={isSubmitting}
onClick={handleOpenAddProject}
title="Добавить новый сайт"
type="button"
>
{isSubmitting ? <Loader2 className="spin" size={16} /> : <Plus size={17} />}
</button>
) : null}
{activeStageIndex === 4 ? ( {activeStageIndex === 4 ? (
<button <button
aria-label="Скачать ключи и спрос" aria-label="Скачать ключи и спрос"
@ -16746,11 +16923,11 @@ export function App() {
<p>{projects.length > 0 ? `Всего проектов: ${projects.length}` : "Пока список пустой"}</p> <p>{projects.length > 0 ? `Всего проектов: ${projects.length}` : "Пока список пустой"}</p>
</div> </div>
<button <button
aria-label="Обновить проекты" aria-label="Обновить список проектов"
className="ghost-action refresh-projects-action" className="ghost-action refresh-projects-action"
disabled={projectsLoading} disabled={projectsLoading}
onClick={loadProjects} onClick={loadProjects}
title="Обновить проекты" title="Обновить список проектов"
type="button" type="button"
> >
<RefreshCw className={projectsLoading ? "spin" : undefined} size={16} /> <RefreshCw className={projectsLoading ? "spin" : undefined} size={16} />
@ -18467,7 +18644,7 @@ export function App() {
? buildWorkingTextFromSections(activeWorkspace, activeWorkspaceSectionTextMap) ? buildWorkingTextFromSections(activeWorkspace, activeWorkspaceSectionTextMap)
: ""; : "";
const isWorkspaceBusy = activeWorkspaceKey ? busyWorkspaceKey === activeWorkspaceKey : false; const isWorkspaceBusy = activeWorkspaceKey ? busyWorkspaceKey === activeWorkspaceKey : false;
const activeWorkspaceIssues = getPageIssues(scanSummary, activeWorkspaceScopeItem?.pageId ?? null); const activeWorkspaceIssues = getWorkspaceScopeIssues(scanSummary, activeWorkspaceScopeItem);
const rawActiveWorkspaceTargets = activeWorkspace const rawActiveWorkspaceTargets = activeWorkspace
? buildWorkspaceTargets(activeWorkspace, activeWorkspaceIssues, { ? buildWorkspaceTargets(activeWorkspace, activeWorkspaceIssues, {
includeRewriteTargets: activeStageIndex === 6, includeRewriteTargets: activeStageIndex === 6,
@ -18562,6 +18739,13 @@ export function App() {
0, 0,
selectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeWorkspaceScopeItem?.scopeId) selectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeWorkspaceScopeItem?.scopeId)
); );
const activeWorkspaceKindItems = activeWorkspaceScopeItem
? selectedWorkspaceScopeItems.filter((item) => item.kind === activeWorkspaceScopeItem.kind)
: [];
const activeWorkspaceKindIndex = Math.max(
0,
activeWorkspaceKindItems.findIndex((item) => item.scopeId === activeWorkspaceScopeItem?.scopeId)
);
const activeWorkspacePreviewSourcePath = previewWorkspaceScopeItem const activeWorkspacePreviewSourcePath = previewWorkspaceScopeItem
? (previewWorkspaceScopeItem.previewSourcePath ?? previewWorkspaceScopeItem.sourcePath) ? (previewWorkspaceScopeItem.previewSourcePath ?? previewWorkspaceScopeItem.sourcePath)
: ""; : "";
@ -18954,6 +19138,43 @@ export function App() {
</div> </div>
</div> </div>
</div> </div>
{semanticAnalysis ? (
<section className="semantic-storyline" aria-label="Как читать контекст проекта">
<article>
<span>01 · Собрали</span>
<strong>
{semanticAnalysis.pageScope.selectedIndexablePages ?? semanticAnalysis.pageScope.indexablePages} страниц scope ·{" "}
{semanticAnalysis.pageScope.contentSources} источника контента
</strong>
<small>Текст, meta, headings, медиа и структурированные источники текущего SEO scope.</small>
</article>
<article>
<span>02 · Поняли</span>
<strong>
{semanticAnalysis.summary.coveredClusters} сильных · {semanticAnalysis.summary.weakClusters} слабых кластеров
</strong>
<small>Это ответ на вопрос «что сайт уже умеет доказать», а не прогноз поискового спроса.</small>
</article>
<article>
<span>03 · Ограничили</span>
<strong>
{semanticAnalysis.analysisVerdict
? getSemanticVerdictObjectivityLabel(semanticAnalysis.analysisVerdict.marketObjectivity)
: "Рынок ещё не проверен"}
</strong>
<small>Неподтверждённые обещания и смысловые пробелы должны быть сняты до Wordstat и SERP.</small>
</article>
<article className={contextReview ? "ready" : "next"}>
<span>04 · Следующий gate</span>
<strong>{contextReview ? "Контекст проверен моделью" : "Нужна модельная проверка"}</strong>
<small>
{contextReview
? "Можно утверждать базис и переходить к сбору рыночного языка."
: "Проверь запреты, неоднозначности и расширение смысла перед этапом ключей."}
</small>
</article>
</section>
) : null}
{semanticAnalysis ? ( {semanticAnalysis ? (
<> <>
<section className="semantic-review-summary"> <section className="semantic-review-summary">
@ -23487,9 +23708,9 @@ export function App() {
type="button" type="button"
> >
<FileText size={14} /> <FileText size={14} />
Страница {activeWorkspaceScopeItem.kind === "section" ? "Секция" : "Страница"}
<em> <em>
{activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length} {activeWorkspaceKindIndex + 1}/{activeWorkspaceKindItems.length}
</em> </em>
<ChevronDown size={14} /> <ChevronDown size={14} />
</button> </button>
@ -23762,7 +23983,7 @@ export function App() {
const pageWorkspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId); const pageWorkspaceKey = getWorkspaceKey(project.id, scopeItem.scopeId);
const isActivePage = activeWorkspaceScopeItem?.scopeId === scopeItem.scopeId; const isActivePage = activeWorkspaceScopeItem?.scopeId === scopeItem.scopeId;
const isOpening = busyWorkspaceKey === pageWorkspaceKey; const isOpening = busyWorkspaceKey === pageWorkspaceKey;
const pageIssues = getPageIssues(scanSummary, scopeItem.pageId); const pageIssues = getWorkspaceScopeIssues(scanSummary, scopeItem);
return ( return (
<button <button
@ -24298,38 +24519,25 @@ export function App() {
<span>{projectActionMessage}</span> <span>{projectActionMessage}</span>
</p> </p>
) : null} ) : null}
{pendingDeleteProjectId === project.id ? (
<div className="project-delete-confirmation" role="alert">
<div>
<strong>Удалить проект «{project.name}»?</strong>
<span>Проект исчезнет из списка, связанные данные импорта будут удалены.</span>
</div>
<div className="project-delete-actions">
<button
className="tiny-action"
disabled={busyProjectId === project.id}
onClick={cancelProjectDelete}
type="button"
>
Отмена
</button>
<button
className="tiny-action danger confirm-delete-action"
disabled={busyProjectId === project.id}
onClick={() => void handleProjectDelete(project)}
type="button"
>
{busyProjectId === project.id ? <Loader2 className="spin" size={14} /> : <Trash2 size={14} />}
Удалить
</button>
</div>
</div>
) : null}
</div> </div>
<div className="project-meta"> <div className="project-meta">
<span className="project-site-title">{projectSiteTitle}</span> <span className="project-site-title">{projectSiteTitle}</span>
{activeStageIndex === 0 ? ( {activeStageIndex === 0 ? (
<div className="project-actions"> <div className="project-actions">
<button
aria-label={`Обновить источник проекта ${project.name}`}
className="tiny-action icon-only"
disabled={busyProjectId === project.id || isScanning || isSelectingPages}
onClick={() => void handleProjectScan(project)}
title={
project.source?.type === "local_folder"
? "Перечитать исходную папку"
: "Повторно просканировать загруженный снимок"
}
type="button"
>
{isScanning ? <Loader2 className="spin" size={14} /> : <RefreshCw size={14} />}
</button>
<button <button
aria-label={`Скопировать проект ${project.name}`} aria-label={`Скопировать проект ${project.name}`}
className="tiny-action icon-only" className="tiny-action icon-only"

View File

@ -504,22 +504,47 @@ const ScopeNode = memo(function ScopeNode({ data }: NodeProps<ScopeFlowNode>) {
function SnapshotPreviewFrame({ label, url }: { label: string; url: string }) { function SnapshotPreviewFrame({ label, url }: { label: string; url: string }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = 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 ( return (
<div className={`scope-flow-preview-frame ${loaded ? "is-loaded" : "is-loading"} ${failed ? "is-failed" : ""}`}> <div className={`scope-flow-preview-frame ${loaded ? "is-loaded" : "is-loading"} ${failed ? "is-failed" : ""}`}>
{!loaded ? ( {!loaded ? (
<div className="scope-flow-preview-loader"> <div className="scope-flow-preview-loader">
<strong>{failed ? "Preview failed" : "Preview"}</strong> <strong>{failed && attempt >= 2 ? "Preview failed" : attempt > 0 ? "Повторяю preview" : "Preview"}</strong>
<span>{failed ? "Snapshot не построился" : "1920 x 1080"}</span> <span>{failed && attempt >= 2 ? "Snapshot не построился" : "1920 x 1080 · строится автоматически"}</span>
</div> </div>
) : null} ) : null}
<img <img
alt={`preview ${label}`} alt={`preview ${label}`}
decoding="async" decoding="async"
loading="eager" loading="lazy"
onError={() => setFailed(true)} onError={() => setFailed(true)}
onLoad={() => setLoaded(true)} onLoad={() => {
src={url} setFailed(false);
setLoaded(true);
}}
src={retryUrl}
/> />
</div> </div>
); );
@ -981,10 +1006,10 @@ function buildScopeFlow(
} }
otherIndexablePages.forEach((page, index) => { 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}`; 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"); 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 flowResetKey = `${projectId}:${scan.id}:${scan.pages.length}:${scan.siteSections.length}`;
const initialFlow = useMemo( const initialFlow = useMemo(
() => buildScopeFlow(scan, projectId, projectName, handleTogglePage), () => buildScopeFlow(scan, projectId, projectName, handleTogglePage),
[flowResetKey, handleTogglePage, projectName] [flowResetKey, handleTogglePage, projectId, projectName]
); );
const flowEdges = useMemo( const flowEdges = useMemo(
() => buildScopeFlow(scan, projectId, projectName, handleTogglePage).edges, () => buildScopeFlow(scan, projectId, projectName, handleTogglePage).edges,

View File

@ -1,4 +1,5 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:4100"; 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) { function encodePathname(value: string) {
return value return value
@ -3923,6 +3924,7 @@ export function getPreviewSnapshotUrl(
const baseUrl = `${apiBaseUrl}/preview/projects/${encodeURIComponent(projectId)}/snapshot/${encodePathname(sourcePath)}`; const baseUrl = `${apiBaseUrl}/preview/projects/${encodeURIComponent(projectId)}/snapshot/${encodePathname(sourcePath)}`;
const params = new URLSearchParams({ const params = new URLSearchParams({
cacheVersion, cacheVersion,
rendererVersion: previewRendererVersion,
viewportHeight: "1080", viewportHeight: "1080",
viewportWidth: "1920" viewportWidth: "1920"
}); });

View File

@ -14451,6 +14451,251 @@ body:has(.seo-launcher-shell) {
display: none !important; 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 { .seo-header-mode-switch {
min-height: var(--nodedc-shell-pill-height) !important; min-height: var(--nodedc-shell-pill-height) !important;
padding: var(--nodedc-shell-pill-padding) !important; padding: var(--nodedc-shell-pill-padding) !important;

View File

@ -3000,7 +3000,15 @@ export async function getLatestSemanticAnalysis(projectId: string) {
` `
select id, status, output, error_message, started_at, completed_at, created_at select id, status, output, error_message, started_at, completed_at, created_at
from runs 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 order by created_at desc
limit 1; limit 1;
`, `,

View File

@ -585,15 +585,24 @@ function buildSerpInterpretationOutput(projectId: string, evidence: YandexEviden
} }
export async function getLatestSerpInterpretationContract(projectId: string): Promise<SerpInterpretationContract> { export async function getLatestSerpInterpretationContract(projectId: string): Promise<SerpInterpretationContract> {
const evidence = await getLatestYandexEvidenceContract(projectId);
if (!evidence.semanticRunId || !evidence.runId) {
return getEmptySerpInterpretationContract(projectId);
}
const result = await pool.query<SerpInterpretationRunRow>( const result = await pool.query<SerpInterpretationRunRow>(
` `
select id, status, input, output, error_message, started_at, completed_at, created_at select id, status, input, output, error_message, started_at, completed_at, created_at
from runs 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 order by created_at desc
limit 1; limit 1;
`, `,
[projectId] [projectId, evidence.semanticRunId, evidence.runId]
); );
const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null;

View File

@ -923,15 +923,24 @@ export async function runYandexEvidenceCollection(
} }
export async function getLatestYandexEvidenceContract(projectId: string): Promise<YandexEvidenceContract> { export async function getLatestYandexEvidenceContract(projectId: string): Promise<YandexEvidenceContract> {
const market = await getMarketEnrichmentContract(projectId);
if (!market.semanticRunId) {
return emptyContract(projectId, "not_collected");
}
const result = await pool.query<YandexEvidenceRunRow>( const result = await pool.query<YandexEvidenceRunRow>(
` `
select id, output, created_at select id, output, created_at
from runs 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 order by created_at desc
limit 1; limit 1;
`, `,
[projectId] [projectId, market.semanticRunId]
); );
const row = result.rows[0]; const row = result.rows[0];
const output = asRecord(row?.output); const output = asRecord(row?.output);

View File

@ -146,7 +146,7 @@ async function buildProviderRegistry(): Promise<MarketProviderDescriptor[]> {
{ {
id: "wordstat", id: "wordstat",
label: "Yandex Wordstat", label: "Yandex Wordstat",
role: "Частотность, похожие запросы, региональные и сезонные сигналы по seed-фразам.", role: "GetTop: частотность за 30 дней и похожие запросы по seed-фразам. Dynamics и региональная раскладка ещё не подключены к сбору.",
status: wordstatStatus.connected ? "connected" : "not_configured", status: wordstatStatus.connected ? "connected" : "not_configured",
mode: wordstatStatus.mode, mode: wordstatStatus.mode,
userActionRequired: false, userActionRequired: false,
@ -180,12 +180,13 @@ async function buildProviderRegistry(): Promise<MarketProviderDescriptor[]> {
{ {
id: "text_quality", id: "text_quality",
label: "Text Quality", label: "Text Quality",
role: "Локальная проверка заспамленности, воды и перебора ключей после рерайта.", role: "Два независимых gate после рерайта: локальный checkOverseo для SEO-перебора и редакторская ru-text policy для качества русского текста.",
status: "not_implemented", status: "not_implemented",
mode: "local_ru_text", mode: "local_check_overseo_plus_editorial_policy",
userActionRequired: false, userActionRequired: false,
platformActionRequired: true, platformActionRequired: true,
message: "Text.ru API не подключается; по ТЗ нужен локальный quality checker по аналогичной модели показателей." message:
"Text.ru остаётся референсом и необязательным validation adapter. ru-text — rule/skill pack для ModelProvider, а не замена детерминированному checkOverseo."
} }
]; ];
} }

View File

@ -123,9 +123,13 @@ function getSiteRoot(config: Record<string, unknown>, currentPath: string) {
} }
} }
const firstSegment = currentPath.split("/").filter(Boolean)[0] ?? ""; // 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`
return firstSegment.includes(".") ? "" : firstSegment; // 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<string, unknown>) { function buildCandidatePaths(requestedPath: string, config: Record<string, unknown>) {
@ -567,7 +571,15 @@ function buildPreviewBridgeScript() {
} }
function getUsableRect(selector, selectorIndex, options) { 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; if (!element) return null;
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null; if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null;
@ -2581,9 +2593,26 @@ function buildPreviewBridgeScript() {
function createMarker(target, left, top) { function createMarker(target, left, top) {
const marker = document.createElement("button"); 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.type = "button";
marker.textContent = String(target.number); 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.position = "absolute";
marker.style.left = left + "px"; marker.style.left = left + "px";
marker.style.top = top + "px"; marker.style.top = top + "px";
@ -2593,10 +2622,10 @@ function buildPreviewBridgeScript() {
marker.style.placeItems = "center"; marker.style.placeItems = "center";
marker.style.padding = "0"; marker.style.padding = "0";
marker.style.color = "#fff"; marker.style.color = "#fff";
marker.style.background = "#ff4fb8"; marker.style.background = markerColor;
marker.style.border = "2px solid rgba(255,255,255,.95)"; marker.style.border = "2px solid rgba(255,255,255,.95)";
marker.style.borderRadius = "999px"; 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.cursor = "pointer";
marker.style.font = "700 12px/1 Inter, Arial, sans-serif"; marker.style.font = "700 12px/1 Inter, Arial, sans-serif";
marker.style.pointerEvents = "auto"; 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) { function isSafeDomIdFragment(value) {
return /^[A-Za-z][\\w:-]*$/.test(String(value || "")); return /^[A-Za-z][\\w:-]*$/.test(String(value || ""));
} }
@ -2673,7 +2793,7 @@ function buildPreviewBridgeScript() {
return getScopeOrderFallbackElements()[order] || null; return getScopeOrderFallbackElements()[order] || null;
} }
function getScopePageElement(page) { function getScopePageAnchorElement(page) {
const scopedAnchorElement = getScopedAnchorFallbackElement(page); const scopedAnchorElement = getScopedAnchorFallbackElement(page);
if (scopedAnchorElement) { if (scopedAnchorElement) {
@ -2693,6 +2813,10 @@ function buildPreviewBridgeScript() {
return getScopePageElementByOrder(page.order); return getScopePageElementByOrder(page.order);
} }
function getScopePageElement(page) {
return resolveRenderedAnchorElement(getScopePageAnchorElement(page));
}
function getProbeContentElement(x, y) { function getProbeContentElement(x, y) {
const elements = typeof document.elementsFromPoint === "function" const elements = typeof document.elementsFromPoint === "function"
? document.elementsFromPoint(x, y) ? 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 probeLine = Math.min(Math.max(window.innerHeight * 0.38, 180), Math.max(180, window.innerHeight - 120));
const scopeRecords = state.scopePages.map((page, index) => { const scopeRecords = state.scopePages.map((page, index) => {
const anchorElement = getScopePageAnchorElement(page);
const element = getScopePageElement(page); const element = getScopePageElement(page);
const rect = element ? element.getBoundingClientRect() : null; const rect = element ? element.getBoundingClientRect() : null;
const usableRect = rect && rect.height > 0 && rect.width > 0 ? rect : 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 { return {
anchorTop,
index, index,
element, element,
page, page,
@ -2781,6 +2909,33 @@ function buildPreviewBridgeScript() {
} }
const visibleCandidates = candidates.filter((candidate) => candidate.visibleArea > 0); 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 probeElement = getProbeContentElement(Math.max(1, Math.min(window.innerWidth - 1, window.innerWidth * 0.5)), probeLine);
const probeRecord = probeElement const probeRecord = probeElement
? scopeRecords.find((record) => record.element && record.rect && record.element.contains(probeElement)) ? scopeRecords.find((record) => record.element && record.rect && record.element.contains(probeElement))
@ -2796,7 +2951,11 @@ function buildPreviewBridgeScript() {
const bottomCandidate = isNearDocumentBottom && visibleCandidates.length > 0 const bottomCandidate = isNearDocumentBottom && visibleCandidates.length > 0
? [...visibleCandidates].sort((first, second) => second.index - first.index)[0] ? [...visibleCandidates].sort((first, second) => second.index - first.index)[0]
: null; : 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 const bestPage = forcedCandidate
? forcedCandidate ? forcedCandidate
: (visibleCandidates.length > 0 ? visibleCandidates : candidates) : (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; return Number.isInteger(parsed) && parsed >= 320 && parsed <= 4096 ? parsed : fallback;
} }
function buildPreviewStabilityStyle() {
return `
<style id="seo-mode-preview-stability">
/* SEO preview must expose the complete document immediately. Lazy layout primitives
such as content-visibility:auto otherwise move the scroll boundary while auditing. */
.c-viz,
[style*="content-visibility"] {
content-visibility: visible !important;
contain-intrinsic-size: none !important;
}
#seo-mode-preview-marker-layer {
contain: layout style paint;
}
</style>
`;
}
function rewriteHtml( function rewriteHtml(
html: string, html: string,
projectId: string, projectId: string,
@ -3453,16 +3630,17 @@ function rewriteHtml(
return ` ${attribute}=${quote}${rewriteSrcset(value, projectId, siteRoot)}${quote}`; return ` ${attribute}=${quote}${rewriteSrcset(value, projectId, siteRoot)}${quote}`;
}); });
const baseTag = `<base href="${baseHref}">`; const baseTag = `<base href="${baseHref}">`;
const stabilityStyle = buildPreviewStabilityStyle();
const bridgeScript = buildPreviewBridgeScript(); const bridgeScript = buildPreviewBridgeScript();
const focusScript = buildPreviewFocusScript(options.focusSelector, options.focusIndex, options.focusOrder); const focusScript = buildPreviewFocusScript(options.focusSelector, options.focusIndex, options.focusOrder);
if (/<head\b[^>]*>/i.test(withRootedAttributes)) { if (/<head\b[^>]*>/i.test(withRootedAttributes)) {
return withRootedAttributes return withRootedAttributes
.replace(/<head\b[^>]*>/i, (headTag) => `${headTag}${baseTag}`) .replace(/<head\b[^>]*>/i, (headTag) => `${headTag}${baseTag}${stabilityStyle}`)
.replace(/<\/body>/i, `${bridgeScript}${focusScript}</body>`); .replace(/<\/body>/i, `${bridgeScript}${focusScript}</body>`);
} }
return `<!doctype html><html><head>${baseTag}</head><body>${withRootedAttributes}${bridgeScript}${focusScript}</body></html>`; return `<!doctype html><html><head>${baseTag}${stabilityStyle}</head><body>${withRootedAttributes}${bridgeScript}${focusScript}</body></html>`;
} }
function rewriteCss(css: string, projectId: string, resolvedPath: string, sourceConfig: Record<string, unknown>) { function rewriteCss(css: string, projectId: string, resolvedPath: string, sourceConfig: Record<string, unknown>) {

View File

@ -24,6 +24,7 @@ const inFlightSnapshots = new Map<string, Promise<Uint8Array>>();
const queue: QueueTask<Uint8Array>[] = []; const queue: QueueTask<Uint8Array>[] = [];
let activeTasks = 0; let activeTasks = 0;
let browserPromise: Promise<Browser> | null = null; let browserPromise: Promise<Browser> | null = null;
const SNAPSHOT_RENDERER_VERSION = "2026-07-10-universal-root-and-anchor-v2";
function encodePathname(value: string) { function encodePathname(value: string) {
return value return value
@ -40,6 +41,7 @@ function getSnapshotCacheKey(input: SnapshotRequest) {
JSON.stringify({ JSON.stringify({
focusIndex: input.focusIndex, focusIndex: input.focusIndex,
focusSelector: input.focusSelector, focusSelector: input.focusSelector,
rendererVersion: SNAPSHOT_RENDERER_VERSION,
sourcePath: input.sourcePath, sourcePath: input.sourcePath,
viewportHeight: input.viewportHeight, viewportHeight: input.viewportHeight,
viewportWidth: input.viewportWidth viewportWidth: input.viewportWidth
@ -126,13 +128,51 @@ async function generateSnapshot(input: SnapshotRequest, cacheKey: string) {
width: input.viewportWidth width: input.viewportWidth
} }
}); });
const failedStylesheets = new Set<string>();
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 { try {
page.setDefaultTimeout(18_000); page.setDefaultTimeout(18_000);
await page.goto(buildPreviewUrl(input), { timeout: 25_000, waitUntil: "domcontentloaded" }); await page.goto(buildPreviewUrl(input), { timeout: 25_000, waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined); 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); await page.waitForTimeout(env.preview.snapshotWaitMs);
const stylesheetState = await page.evaluate(() => {
const linkedStylesheets = Array.from(document.querySelectorAll<HTMLLinkElement>('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({ const screenshot = await page.screenshot({
animations: "disabled", animations: "disabled",
fullPage: false, fullPage: false,

View File

@ -1823,8 +1823,18 @@ function buildBaselineAuditOutput(
let missingTitle = 0; let missingTitle = 0;
let missingDescription = 0; let missingDescription = 0;
let missingH1 = 0; let missingH1 = 0;
let pagesChecked = 0;
for (const page of pages) { 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 headings = normalizeHeadings(page.raw);
const headingCounts = getHeadingLevelCounts(headings); const headingCounts = getHeadingLevelCounts(headings);
const bodyTextLength = getRawNumber(page.raw, "bodyTextLength") ?? 0; const bodyTextLength = getRawNumber(page.raw, "bodyTextLength") ?? 0;
@ -2096,7 +2106,7 @@ function buildBaselineAuditOutput(
return { return {
scanVersionId: scan.id, scanVersionId: scan.id,
summary: { summary: {
pagesChecked: pages.length, pagesChecked,
assetsChecked: assets.length, assetsChecked: assets.length,
missingTitle, missingTitle,
missingDescription, missingDescription,

View File

@ -196,7 +196,7 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
yandex_wordstat: { yandex_wordstat: {
id: "yandex_wordstat", id: "yandex_wordstat",
label: "Yandex Wordstat", label: "Yandex Wordstat",
description: "Частотность, похожие запросы, динамика и региональная раскладка через Yandex Search API Wordstat.", description: "Сейчас подключён GetTop: частотность за 30 дней и associations. Dynamics и региональная раскладка остаются следующим provider-слоем.",
implementationStatus: "active", implementationStatus: "active",
defaultMode: "yandex_search_api", defaultMode: "yandex_search_api",
modes: [ modes: [

View File

@ -668,15 +668,27 @@ function buildStrategySynthesisOutput(
} }
export async function getLatestStrategySynthesisContract(projectId: string): Promise<StrategySynthesisContract> { export async function getLatestStrategySynthesisContract(projectId: string): Promise<StrategySynthesisContract> {
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<StrategySynthesisRunRow>( const result = await pool.query<StrategySynthesisRunRow>(
` `
select id, status, input, output, error_message, started_at, completed_at, created_at select id, status, input, output, error_message, started_at, completed_at, created_at
from runs 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 order by created_at desc
limit 1; limit 1;
`, `,
[projectId] [projectId, strategy.semanticRunId, serpInterpretation.runId]
); );
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null; const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;