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") {
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue