Stabilize SEO source, preview, and evidence flow
This commit is contained in:
parent
8379a4487f
commit
4ade361659
|
|
@ -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<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) {
|
||||
|
|
@ -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<HTMLInputElement>) {
|
||||
applyPickedFolder(folderFromInputFiles(event.target.files));
|
||||
event.target.value = "";
|
||||
|
|
@ -14112,6 +14177,12 @@ export function App() {
|
|||
SEMANTIC_BLOCK_EDITOR_ENABLED && activeStageIndex === 6 && Boolean(semanticBlockEditProjectModes[projectId]);
|
||||
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 ? (
|
||||
<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
|
||||
className="seo-topbar-primary-action"
|
||||
disabled={!canConfirmActiveProjectScope}
|
||||
|
|
@ -15255,9 +15353,9 @@ export function App() {
|
|||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Страница
|
||||
{activeProjectWorkspaceScopeItem.kind === "section" ? "Секция" : "Страница"}
|
||||
<em>
|
||||
{activeProjectWorkspacePageIndex + 1}/{activeProjectSelectedWorkspaceScopeItems.length}
|
||||
{activeProjectWorkspaceKindIndex + 1}/{activeProjectWorkspaceKindItems.length}
|
||||
</em>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
|
@ -15880,7 +15978,7 @@ export function App() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
<button className="seo-project-menu-add" onClick={closeActiveProject} type="button">
|
||||
<button className="seo-project-menu-add" onClick={handleOpenAddProject} type="button">
|
||||
<Plus size={15} />
|
||||
Добавить проект
|
||||
</button>
|
||||
|
|
@ -16342,6 +16440,74 @@ export function App() {
|
|||
</div>
|
||||
) : 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}`}>
|
||||
<section className="seo-zero-stage" aria-label="NodeDC SEO Mode">
|
||||
<div className="stage-video-shell">
|
||||
|
|
@ -16477,6 +16643,17 @@ export function App() {
|
|||
{activeKeywordContextToolbar}
|
||||
{topbarStageActions}
|
||||
<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 ? (
|
||||
<button
|
||||
aria-label="Скачать ключи и спрос"
|
||||
|
|
@ -16746,11 +16923,11 @@ export function App() {
|
|||
<p>{projects.length > 0 ? `Всего проектов: ${projects.length}` : "Пока список пустой"}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Обновить проекты"
|
||||
aria-label="Обновить список проектов"
|
||||
className="ghost-action refresh-projects-action"
|
||||
disabled={projectsLoading}
|
||||
onClick={loadProjects}
|
||||
title="Обновить проекты"
|
||||
title="Обновить список проектов"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={projectsLoading ? "spin" : undefined} size={16} />
|
||||
|
|
@ -18467,7 +18644,7 @@ export function App() {
|
|||
? buildWorkingTextFromSections(activeWorkspace, activeWorkspaceSectionTextMap)
|
||||
: "";
|
||||
const isWorkspaceBusy = activeWorkspaceKey ? busyWorkspaceKey === activeWorkspaceKey : false;
|
||||
const activeWorkspaceIssues = getPageIssues(scanSummary, activeWorkspaceScopeItem?.pageId ?? null);
|
||||
const activeWorkspaceIssues = getWorkspaceScopeIssues(scanSummary, activeWorkspaceScopeItem);
|
||||
const rawActiveWorkspaceTargets = activeWorkspace
|
||||
? buildWorkspaceTargets(activeWorkspace, activeWorkspaceIssues, {
|
||||
includeRewriteTargets: activeStageIndex === 6,
|
||||
|
|
@ -18562,6 +18739,13 @@ export function App() {
|
|||
0,
|
||||
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
|
||||
? (previewWorkspaceScopeItem.previewSourcePath ?? previewWorkspaceScopeItem.sourcePath)
|
||||
: "";
|
||||
|
|
@ -18954,6 +19138,43 @@ export function App() {
|
|||
</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 ? (
|
||||
<>
|
||||
<section className="semantic-review-summary">
|
||||
|
|
@ -23487,9 +23708,9 @@ export function App() {
|
|||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Страница
|
||||
{activeWorkspaceScopeItem.kind === "section" ? "Секция" : "Страница"}
|
||||
<em>
|
||||
{activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length}
|
||||
{activeWorkspaceKindIndex + 1}/{activeWorkspaceKindItems.length}
|
||||
</em>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
|
@ -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 (
|
||||
<button
|
||||
|
|
@ -24298,38 +24519,25 @@ export function App() {
|
|||
<span>{projectActionMessage}</span>
|
||||
</p>
|
||||
) : 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 className="project-meta">
|
||||
<span className="project-site-title">{projectSiteTitle}</span>
|
||||
{activeStageIndex === 0 ? (
|
||||
<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
|
||||
aria-label={`Скопировать проект ${project.name}`}
|
||||
className="tiny-action icon-only"
|
||||
|
|
|
|||
|
|
@ -504,22 +504,47 @@ const ScopeNode = memo(function ScopeNode({ data }: NodeProps<ScopeFlowNode>) {
|
|||
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 (
|
||||
<div className={`scope-flow-preview-frame ${loaded ? "is-loaded" : "is-loading"} ${failed ? "is-failed" : ""}`}>
|
||||
{!loaded ? (
|
||||
<div className="scope-flow-preview-loader">
|
||||
<strong>{failed ? "Preview failed" : "Preview"}</strong>
|
||||
<span>{failed ? "Snapshot не построился" : "1920 x 1080"}</span>
|
||||
<strong>{failed && attempt >= 2 ? "Preview failed" : attempt > 0 ? "Повторяю preview" : "Preview"}</strong>
|
||||
<span>{failed && attempt >= 2 ? "Snapshot не построился" : "1920 x 1080 · строится автоматически"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<img
|
||||
alt={`preview ${label}`}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
src={url}
|
||||
onLoad={() => {
|
||||
setFailed(false);
|
||||
setLoaded(true);
|
||||
}}
|
||||
src={retryUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -585,15 +585,24 @@ function buildSerpInterpretationOutput(projectId: string, evidence: YandexEviden
|
|||
}
|
||||
|
||||
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>(
|
||||
`
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -923,15 +923,24 @@ export async function runYandexEvidenceCollection(
|
|||
}
|
||||
|
||||
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>(
|
||||
`
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ async function buildProviderRegistry(): Promise<MarketProviderDescriptor[]> {
|
|||
{
|
||||
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<MarketProviderDescriptor[]> {
|
|||
{
|
||||
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."
|
||||
}
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,9 +123,13 @@ function getSiteRoot(config: Record<string, unknown>, 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<string, unknown>) {
|
||||
|
|
@ -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 `
|
||||
<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(
|
||||
html: string,
|
||||
projectId: string,
|
||||
|
|
@ -3453,16 +3630,17 @@ function rewriteHtml(
|
|||
return ` ${attribute}=${quote}${rewriteSrcset(value, projectId, siteRoot)}${quote}`;
|
||||
});
|
||||
const baseTag = `<base href="${baseHref}">`;
|
||||
const stabilityStyle = buildPreviewStabilityStyle();
|
||||
const bridgeScript = buildPreviewBridgeScript();
|
||||
const focusScript = buildPreviewFocusScript(options.focusSelector, options.focusIndex, options.focusOrder);
|
||||
|
||||
if (/<head\b[^>]*>/i.test(withRootedAttributes)) {
|
||||
return withRootedAttributes
|
||||
.replace(/<head\b[^>]*>/i, (headTag) => `${headTag}${baseTag}`)
|
||||
.replace(/<head\b[^>]*>/i, (headTag) => `${headTag}${baseTag}${stabilityStyle}`)
|
||||
.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>) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const inFlightSnapshots = new Map<string, Promise<Uint8Array>>();
|
|||
const queue: QueueTask<Uint8Array>[] = [];
|
||||
let activeTasks = 0;
|
||||
let browserPromise: Promise<Browser> | 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<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 {
|
||||
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<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({
|
||||
animations: "disabled",
|
||||
fullPage: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
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: [
|
||||
|
|
|
|||
|
|
@ -668,15 +668,27 @@ function buildStrategySynthesisOutput(
|
|||
}
|
||||
|
||||
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>(
|
||||
`
|
||||
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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue