feat: add SEO rewrite workspace contracts

This commit is contained in:
DCCONSTRUCTIONS 2026-07-02 13:48:13 +03:00
parent 4b46936d0b
commit f9b240dbd1
21 changed files with 8380 additions and 316 deletions

View File

@ -91,7 +91,7 @@ export type ProjectScanSummary = {
scopeReason: string;
previewSourcePath: string | null;
previewTargetSelector: string | null;
previewKind: "page" | "home_section" | "raw_template" | "none";
previewKind: "page" | "source_section" | "raw_template" | "none";
structureLabel: string | null;
structureOrder: number | null;
visibleInSite: boolean | null;
@ -103,6 +103,7 @@ export type ProjectScanSummary = {
pageId: string | null;
selected: boolean;
title: string;
contentSourcePath: string | null;
sourcePath: string;
template: string;
enabled: boolean;
@ -112,8 +113,27 @@ export type ProjectScanSummary = {
previewSourcePath: string | null;
previewTargetSelector: string | null;
previewTargetIndex: number | null;
previewKind: "page" | "home_section" | "raw_template" | "none";
previewKind: "page" | "source_section" | "raw_template" | "none";
scopeReason: string;
semanticBlock: {
confidence: number;
fieldCount: number;
id: string;
reasons: string[];
source: "source_object" | "visual_section";
title: string;
} | null;
editableFields: Array<{
group: string | null;
id: string;
kind: string;
label: string;
order: number;
path: string;
renderAs: string | null;
role: string;
value: string;
}>;
headings: Array<{ level: number; text: string }>;
}>;
assets: Array<{
@ -1777,11 +1797,87 @@ export type RewriteDiffContract = {
blockerCount: number;
materializationTargetCount: number;
modelReadySlotCount: number;
semanticBlockCount: number;
semanticGroupedSlotCount: number;
semanticRewriteGroupCount: number;
sourcePendingSlotCount: number;
slotLevelFallbackCount: number;
slotCount: number;
targetCount: number;
};
blockers: string[];
semanticRewriteGroups: Array<{
id: string;
pageId: string;
pageTitle: string | null;
sourcePath: string | null;
urlPath: string | null;
title: string;
status: "semantic_block" | "slot_level_fallback";
fallbackReason: "no_semantic_block" | null;
semanticBlock: {
id: string;
blockKey: string;
title: string;
role: string;
source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section";
workspaceKey: string;
scopeId: string;
anchors: unknown[];
capturedEntities: unknown[];
mediaContext: Record<string, unknown>;
targetIds: string[];
sourceBindings: unknown[];
} | null;
sourceBindings: unknown[];
sourceGraph: {
contentFieldCount: number;
mediaTargetIds: string[];
permissionLabels: string[];
selectorCount: number;
sourceBindingCount: number;
sourceTypes: string[];
contentFields: Array<{
fieldPath: string | null;
group: string | null;
kind: string | null;
renderAs: string | null;
role: string | null;
sectionId: string | null;
sourcePath: string | null;
title: string | null;
}>;
workspaceTargets: Array<{
fallbackSelector: string | null;
kind: string | null;
permission: string | null;
sectionId: string | null;
selector: string | null;
source: string | null;
targetId: string | null;
title: string | null;
}>;
};
groupText: string;
keywordBindings: Array<{
decisionId: string;
phrase: string;
role: string;
frequency: number | null;
frequencyGroup: string | null;
exactLimit: number | null;
}>;
slots: Array<{
blockers: string[];
currentValue: string | null;
field: "body_section" | "h1" | "meta_description" | "title";
instruction: string;
rewriteSlotId: string;
source: "missing_source" | "page_version" | "workspace_snapshot";
status: "ready_for_model_review" | "source_pending";
workspaceSectionId: string | null;
}>;
}>;
targets: Array<{
id: string;
pageId: string;
@ -1807,6 +1903,20 @@ export type RewriteDiffContract = {
frequencyGroup: string | null;
exactLimit: number | null;
}>;
semanticBlock: {
id: string;
blockKey: string;
title: string;
role: string;
source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section";
workspaceKey: string;
scopeId: string;
anchors: unknown[];
capturedEntities: unknown[];
mediaContext: Record<string, unknown>;
targetIds: string[];
sourceBindings: unknown[];
} | null;
}>;
}>;
nextActions: string[];
@ -1927,14 +2037,316 @@ export type PageWorkspace = {
kind: "meta" | "content_block" | "heading_group" | "media";
title: string;
selector: string | null;
anchor: {
entityType: "document_meta" | "dom_section" | "heading_group" | "media_container";
fingerprint: {
selector: string | null;
sourceHint: string;
text: string;
};
selector: string | null;
sourceHint: string;
};
originalText: string;
workingText: string;
charCount: number;
semanticGroup: {
confidence: number;
evidence: string[];
fieldCount: number;
fieldOrder: number;
fieldRole: string;
id: string;
source: "workspace_section";
title: string;
} | null;
summary: string;
sourceHint: string;
}>;
};
export type ContentFieldDraft = {
draftId: string;
draftItemId: string;
fieldKey: string;
fieldPath: string;
originalSnapshotKey: string | null;
originalText: string;
pageId: string;
scopeId: string;
sectionId: string;
sourcePath: string;
title: string;
updatedAt: string;
workingSnapshotKey: string | null;
workingText: string;
};
export type SemanticBlockModel = {
id: string;
projectId: string;
scanVersionId: string | null;
pageId: string;
workspaceKey: string;
scopeId: string;
blockKey: string;
title: string;
role: string;
source: "auto" | "manual" | "source_object" | "visual_section" | "workspace_section";
identity: Record<string, unknown>;
anchors: unknown[];
capturedEntities: unknown[];
sourceBindings: unknown[];
mediaContext: Record<string, unknown>;
geometryCache: Record<string, unknown>;
rewriteState: Record<string, unknown>;
targetIds: string[];
archivedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type SemanticBlockSaveInput = {
scanVersionId?: string | null;
workspaceKey: string;
scopeId: string;
blocks: Array<{
anchors?: unknown[];
blockKey: string;
capturedEntities?: unknown[];
geometryCache?: Record<string, unknown>;
identity?: Record<string, unknown>;
mediaContext?: Record<string, unknown>;
rewriteState?: Record<string, unknown>;
role?: string;
source?: SemanticBlockModel["source"];
sourceBindings?: unknown[];
targetIds?: string[];
title: string;
}>;
};
export type PatchArtifactContract = {
schemaVersion: "seo-patch-artifact.v1";
artifactKey: string;
changesetId: string;
generatedAt: string;
mode: "manifest_only";
projectId: string;
state: "empty" | "ready_for_dry_run";
readiness: {
affectedFileCount: number;
blockerCount: number;
changeCount: number;
changedDraftCount: number;
draftCount: number;
provenanceBlockerCount?: number;
sourceFingerprintCount?: number;
sourceUnreadableCount?: number;
};
provenance?: {
schemaVersion: "seo-patch-provenance.v1";
generatedAt: string;
semanticRunId: string | null;
projectOntologyVersionId: string | null;
projectOntology: {
approvalStatus: string;
brandName: string;
domainPackage: {
id: string;
version: string;
coreOntologyVersion: string;
projectOntologySchema: string;
packagePath: string;
};
id: string;
reviewProblemCount: number;
schemaVersion: string;
source: string;
version: number;
} | null;
rewritePlan: {
generatedAt: string;
readiness: RewritePlanContract["readiness"];
state: RewritePlanContract["state"];
} | null;
rewriteDiff: {
generatedAt: string;
readiness: RewriteDiffContract["readiness"];
state: RewriteDiffContract["state"];
} | null;
sourceRefs: {
draftIds: string[];
draftItemIds: string[];
scanVersionIds: string[];
sourcePaths: string[];
};
assistantPolicy: {
mayProposeOntologyDelta: true;
mayMutateApprovedOntology: false;
};
gates: {
productionPatchRun: {
blockers: string[];
ready: boolean;
};
visualComposer: {
blockers: string[];
ready: boolean;
};
};
blockers: string[];
warnings: string[];
};
sourceFingerprints?: Array<{
byteLength: number | null;
capturedAt: string;
readable: boolean;
sha256: string | null;
sourcePath: string;
}>;
sourceSafety: {
editorWritesSourceFiles: false;
productionRequiresExplicitPatchRun: true;
sourceTreeReadOnly: true;
};
storage: {
objectKey: string;
};
changes: Array<{
id: string;
afterLength: number;
afterValue: string;
beforeLength: number;
beforeValue: string;
changeType: "content_field" | "text_section";
delta: number;
draftId: string;
draftItemId: string;
kind: string;
pageId: string;
selector: string | null;
sourceHint: string;
sourcePath: string;
title: string;
urlPath: string | null;
workspaceSectionId: string;
}>;
nextActions: string[];
};
export type PatchDryRunContract = {
schemaVersion: "seo-patch-dry-run.v1";
artifactKey: string;
changesetId: string;
generatedAt: string;
projectId: string;
state: "blocked" | "empty" | "passed";
readiness: {
affectedFileCount: number;
blockerCount: number;
checkCount: number;
emptyAfterCount: number;
fingerprintMismatchCount?: number;
fingerprintMissingCount?: number;
missingSourceCount: number;
passedCount: number;
provenanceBlockerCount?: number;
staleChangeCount: number;
};
provenance?: NonNullable<PatchArtifactContract["provenance"]>;
sourceSafety: {
dryRunWritesSourceFiles: false;
patchRunExecuted: false;
sourceTreeReadOnly: true;
};
storage: {
objectKey: string;
};
checks: Array<{
id: string;
afterNonEmpty: boolean;
beforeFound: boolean;
blockers: string[];
changeId: string;
fingerprint?: {
currentSha256: string | null;
expectedSha256: string | null;
status: "matched" | "mismatch" | "missing_at_build" | "missing_at_dry_run";
};
fingerprintMatched?: boolean;
sourceMatch?: {
coverage: number;
matchedTokenCount: number;
strategy: "exact_raw" | "exact_visible" | "page_token_coverage" | "selector_token_coverage" | "none";
totalTokenCount: number;
};
sourceReadable?: boolean;
sourcePath: string;
status: "blocked" | "passed";
title: string;
workspaceSectionId: string;
}>;
nextActions: string[];
};
export type VisualComposerVariantsContract = {
schemaVersion: "visual-composer-variants.v1";
generatedAt: string;
projectId: string;
state: "blocked" | "partial_ready" | "ready";
generator: {
id: "local_deterministic_v0";
modelBacked: false;
notes: string[];
};
readiness: {
blockerCount: number;
generatedSlotCount: number;
readySlotCount: number;
semanticBlockCount: number;
semanticGroupedSlotCount: number;
slotCount: number;
slotLevelFallbackCount: number;
targetCount: number;
variantCount: number;
};
blockers: string[];
variants: Array<{
id: "balanced" | "commercial" | "concise" | "expert" | "seo_dense";
label: string;
mode: "balanced" | "commercial" | "concise" | "expert" | "seo_dense";
summary: string;
metrics: {
changedSlotCount: number;
includedPhraseCount: number;
totalDelta: number;
totalPhraseCount: number;
};
slots: Array<{
id: string;
afterText: string;
beforeText: string;
field: RewriteDiffContract["targets"][number]["slots"][number]["field"];
includedPhraseCount: number;
instruction: string;
keywordPhrases: string[];
pageContextText: string | null;
pageId: string;
pageTitle: string | null;
riskNotes: string[];
rewriteSlotId: string;
semanticGroup: VisualComposerSeedInput["semanticGroup"] | null;
sourcePath: string | null;
title: string;
totalPhraseCount: number;
urlPath: string | null;
workspaceSectionId: string;
}>;
}>;
nextActions: string[];
};
export type CreateProjectInput = {
name: string;
sourcePath: string;
@ -2358,7 +2770,7 @@ export async function fetchLatestYandexEvidence(projectId: string) {
return response.json() as Promise<{ yandexEvidence: YandexEvidenceContract }>;
}
export async function runYandexEvidence(projectId: string, phraseLimit = 5) {
export async function runYandexEvidence(projectId: string, phraseLimit = 18) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/yandex-evidence`, {
body: JSON.stringify({ phraseLimit }),
headers: {
@ -2601,7 +3013,11 @@ export async function openPageWorkspace(projectId: string, pageId: string) {
throw new Error(await getErrorMessage(response, `Не удалось открыть рабочую зону: ${response.status}`));
}
return response.json() as Promise<{ workspace: PageWorkspace }>;
return response.json() as Promise<{
contentDrafts?: ContentFieldDraft[];
semanticBlocks?: SemanticBlockModel[];
workspace: PageWorkspace;
}>;
}
export async function savePageWorkspace(projectId: string, pageId: string, workingText: string) {
@ -2620,6 +3036,54 @@ export async function savePageWorkspace(projectId: string, pageId: string, worki
return response.json() as Promise<{ workspace: PageWorkspace }>;
}
export async function saveContentFieldDraft(
projectId: string,
pageId: string,
input: {
fieldId: string | null;
fieldPath: string;
group: string | null;
kind: string;
originalText: string;
renderAs: string | null;
scopeId: string;
sectionId: string;
sourcePath: string;
title: string;
workingText: string;
}
) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/content-fields`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось сохранить content field draft: ${response.status}`));
}
return response.json() as Promise<{ contentDraft: ContentFieldDraft }>;
}
export async function savePageSemanticBlocks(projectId: string, pageId: string, input: SemanticBlockSaveInput) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/semantic-blocks`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось сохранить semantic blocks: ${response.status}`));
}
return response.json() as Promise<{ semanticBlocks: SemanticBlockModel[] }>;
}
export async function resetPageWorkspace(projectId: string, pageId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/${pageId}/workspace/reset`, {
method: "POST"
@ -2632,10 +3096,92 @@ export async function resetPageWorkspace(projectId: string, pageId: string) {
return response.json() as Promise<{ workspace: PageWorkspace }>;
}
export function getPreviewPageUrl(projectId: string, sourcePath: string, focusSelector?: string | null, focusIndex?: number | null) {
export async function fetchLatestPatchArtifact(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/latest`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось загрузить patch artifact: ${response.status}`));
}
return response.json() as Promise<{ patchArtifact: PatchArtifactContract | null }>;
}
export async function buildPatchArtifact(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/build`, {
method: "POST"
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось собрать patch artifact: ${response.status}`));
}
return response.json() as Promise<{ patchArtifact: PatchArtifactContract }>;
}
export async function runPatchDryRun(projectId: string, changesetId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/patches/${changesetId}/dry-run`, {
method: "POST"
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось выполнить dry-run: ${response.status}`));
}
return response.json() as Promise<{ dryRun: PatchDryRunContract }>;
}
export type VisualComposerSeedInput = {
currentValue: string;
field?: "body_section" | "h1" | "meta_description" | "title";
id: string;
instruction?: string;
keywordPhrases?: string[];
pageContextText?: string | null;
pageId: string;
pageTitle?: string | null;
semanticGroup?: {
confidence: number;
evidence: string[];
fieldCount: number;
fieldOrder: number;
fieldRole: string;
groupText: string;
id: string;
source: "source_object" | "visual_section" | "workspace_section";
title: string;
};
sourcePath?: string | null;
title: string;
urlPath?: string | null;
workspaceSectionId: string;
};
export async function generateVisualComposerVariants(projectId: string, seeds: VisualComposerSeedInput[] = []) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/visual-composer/variants`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ seeds })
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось собрать visual composer variants: ${response.status}`));
}
return response.json() as Promise<{ visualComposerVariants: VisualComposerVariantsContract }>;
}
export function getPreviewPageUrl(
projectId: string,
sourcePath: string,
focusSelector?: string | null,
focusIndex?: number | null,
focusOrder?: number | null
) {
const baseUrl = `${apiBaseUrl}/preview/projects/${encodeURIComponent(projectId)}/site/${encodePathname(sourcePath)}`;
if (!focusSelector && focusIndex == null) {
if (!focusSelector && focusIndex == null && focusOrder == null) {
return baseUrl;
}
@ -2649,6 +3195,10 @@ export function getPreviewPageUrl(projectId: string, sourcePath: string, focusSe
params.set("focusIndex", String(focusIndex));
}
if (focusOrder != null) {
params.set("focusOrder", String(focusOrder));
}
return `${baseUrl}?${params.toString()}`;
}

View File

@ -12,6 +12,9 @@
"dev:app": "npm run dev -w app",
"dev:server": "npm run dev -w server",
"build": "npm run build -w server && npm run build -w app",
"check:demand-coverage": "npm run check:demand-coverage -w server",
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
"check:source-models": "npm run check:source-models -w server",
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
"db:migrate": "npm run db:migrate -w server"
},

View File

@ -0,0 +1,36 @@
create table if not exists semantic_blocks (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references projects(id) on delete cascade,
scan_version_id uuid references scan_versions(id) on delete set null,
page_id uuid not null references pages(id) on delete cascade,
workspace_key text not null,
scope_id text not null,
block_key text not null,
title text not null,
role text not null default 'content',
source text not null default 'manual',
identity jsonb not null default '{}'::jsonb,
anchors jsonb not null default '[]'::jsonb,
captured_entities jsonb not null default '[]'::jsonb,
source_bindings jsonb not null default '[]'::jsonb,
media_context jsonb not null default '{}'::jsonb,
geometry_cache jsonb not null default '{}'::jsonb,
rewrite_state jsonb not null default '{}'::jsonb,
target_ids text[] not null default '{}'::text[],
archived_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(project_id, page_id, workspace_key, scope_id, block_key),
constraint semantic_blocks_source_check check (source in ('auto', 'manual', 'source_object', 'visual_section', 'workspace_section')),
constraint semantic_blocks_role_check check (role <> '')
);
create index if not exists semantic_blocks_project_page_idx
on semantic_blocks(project_id, page_id, workspace_key, scope_id, updated_at desc);
create index if not exists semantic_blocks_project_scan_idx
on semantic_blocks(project_id, scan_version_id, updated_at desc);
create index if not exists semantic_blocks_active_idx
on semantic_blocks(project_id, page_id, workspace_key, scope_id)
where archived_at is null;

View File

@ -8,6 +8,9 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check:demand-coverage": "tsx src/scripts/checkDemandCoverage.ts",
"check:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts",
"check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts",
"check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts",
"db:migrate": "tsx src/scripts/migrate.ts"
},

View File

@ -923,7 +923,7 @@ async function buildPageDocument(source: LatestScanRow, page: PageRow): Promise<
selected: page.selected,
scope: pageScope.scope,
scopeReason: pageScope.reason,
usedForCoverage: page.selected && pageScope.scope === "indexable_page",
usedForCoverage: page.selected && (pageScope.scope === "indexable_page" || pageScope.scope === "template_block"),
title,
sourcePath: page.source_path,
urlPath: page.url_path,
@ -1393,12 +1393,13 @@ function pickHighestSemanticPriority(priorities: SemanticClusterDefinition["prio
function buildLandingOpportunities(clusters: ReturnType<typeof analyzeClusters>, documents: ContentDocument[]) {
const indexableDocuments = documents.filter((document) => document.scope === "indexable_page");
const portfolioDocuments = documents.filter((document) => document.scope === "indexable_page" || document.scope === "template_block");
return clusters.map((cluster) => {
const currentLandingPages = Array.from(
new Set(
cluster.sourceRefs
.filter((ref) => ref.scope === "indexable_page")
.filter((ref) => ref.scope === "indexable_page" || ref.scope === "template_block")
.map((ref) => normalizeUrlLikePath(ref.urlPath ?? ref.sourcePath))
)
).sort((first, second) => first.localeCompare(second, "ru-RU"));
@ -1409,46 +1410,44 @@ function buildLandingOpportunities(clusters: ReturnType<typeof analyzeClusters>,
const currentNonTargetPages = currentLandingPages.filter(
(landingPage) => !cluster.targetPages.some((targetPage) => normalizeUrlLikePath(targetPage) === landingPage)
);
const primaryTargetPage = missingTargetPages[0] ?? existingTargetPages[0] ?? cluster.targetPages[0] ?? "/";
const actualPortfolioPages = currentLandingPages.filter((landingPage) =>
portfolioDocuments.some((document) => normalizeUrlLikePath(document.urlPath ?? document.sourcePath) === landingPage)
);
const existingOrCurrentPages = existingTargetPages.length > 0 ? existingTargetPages : actualPortfolioPages;
const primaryTargetPage = existingOrCurrentPages[0] ?? currentLandingPages[0] ?? existingTargetPages[0] ?? "/";
const pageType = getLandingOpportunityPageType(primaryTargetPage);
const action: SemanticAnalysisOutput["landingOpportunities"][number]["action"] =
cluster.status === "covered" && missingTargetPages.length === 0
existingOrCurrentPages.length === 0
? "validate_external"
: cluster.status === "covered" && missingTargetPages.length === 0
? "validate_external"
: cluster.status === "covered"
? "keep_home"
: missingTargetPages.length > 0
? "create_target_page"
: "strengthen_existing";
const recommendedPages =
action === "create_target_page" || action === "keep_home"
? missingTargetPages.slice(0, 4).map((targetPage) => ({
path: targetPage,
pageType: getLandingOpportunityPageType(targetPage),
action: "create" as const,
reason:
currentNonTargetPages.length > 0
? `Сейчас кластер найден на ${currentNonTargetPages.slice(0, 3).join(", ")}, но целевой страницы ${targetPage} нет.`
: `Целевая страница ${targetPage} отсутствует в indexable scope.`
}))
: existingTargetPages.slice(0, 4).map((targetPage) => ({
existingOrCurrentPages.length > 0
? existingOrCurrentPages.slice(0, 4).map((targetPage) => ({
path: targetPage,
pageType: getLandingOpportunityPageType(targetPage),
action: (cluster.status === "covered" ? "validate" : "strengthen") as "strengthen" | "validate",
reason:
cluster.status === "covered"
? "Страница уже есть и кластер подтверждён; следующий шаг — Wordstat/SERP validation."
: `Страница есть, но evidence ${cluster.evidence.level}; нужно усилить текст, интент и коммерческие блоки.`
}));
currentNonTargetPages.length > 0
? `Сейчас кластер найден на ${currentNonTargetPages.slice(0, 3).join(", ")}; усиливаем реальный выбранный scope ${targetPage}.`
: cluster.status === "covered"
? "Страница или блок уже есть и кластер подтверждён; следующий шаг — Wordstat/SERP validation."
: `Страница или блок есть в выбранном scope, но evidence ${cluster.evidence.level}; нужно усилить текст, интент и коммерческие блоки.`
}))
: [];
const reason =
action === "validate_external"
existingOrCurrentPages.length === 0
? "Кластер остаётся темой для проверки, но deterministic analysis не создаёт страницу из ontology targetPages без реального project scope."
: action === "validate_external"
? "Кластер уже имеет сильную локальную evidence. Дальше не расширять вслепую, а проверять спрос и конкурентов."
: action === "keep_home"
? `Кластер можно оставить на главной как общий сигнал, но для SEO-охвата полезно вынести часть интента в ${missingTargetPages
.slice(0, 3)
.join(", ")}.`
: action === "create_target_page"
? `Кластер словарно найден, но не имеет целевой посадочной из карты: ${missingTargetPages.slice(0, 3).join(", ")}.`
: `Кластер имеет целевую страницу, но evidence ${cluster.evidence.level}; нужна доработка существующей посадочной.`;
: `Кластер имеет целевую страницу или выбранный блок, но evidence ${cluster.evidence.level}; нужна доработка существующего scope.`;
return {
clusterId: cluster.id,
@ -1499,16 +1498,7 @@ function buildLandingPagePlan(
pageType: page.pageType,
action: page.action
}));
const fallbackPageActions =
existingPageActions.length === 0 && recommendedPageActions.length === 0
? opportunity.targetPages.slice(0, 1).map((targetPage) => ({
path: targetPage,
pageType: getLandingOpportunityPageType(targetPage),
action: "create" as const
}))
: [];
for (const page of [...existingPageActions, ...recommendedPageActions, ...fallbackPageActions]) {
for (const page of [...existingPageActions, ...recommendedPageActions]) {
const normalizedPath = normalizeUrlLikePath(page.path);
const plan =
planMap.get(normalizedPath) ??

View File

@ -25,6 +25,12 @@ type EvidencePhraseInput = {
reason: string;
};
type EvidencePhraseCandidate = EvidencePhraseInput & {
bucketKey: string;
order: number;
score: number;
};
export type YandexEvidenceContract = {
schemaVersion: "yandex-evidence.v1";
projectId: string;
@ -110,12 +116,32 @@ export type RunYandexEvidenceInput = {
};
const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search";
const DEFAULT_PHRASE_LIMIT = 5;
const DEFAULT_PHRASE_LIMIT = 18;
const MAX_PHRASE_LIMIT = 48;
function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
}
function getPriorityScore(priority: YandexEvidencePriority) {
return priority === "high" ? 300 : priority === "medium" ? 200 : 100;
}
function getKeywordMapRoleScore(role: string | null | undefined) {
if (role === "primary") return 700;
if (role === "secondary" || role === "differentiator") return 600;
if (role === "support") return 500;
return 350;
}
function getCleaningDecisionScore(decision: KeywordCleaningContract["items"][number]["decision"]) {
if (decision === "use") return 500;
if (decision === "support") return 430;
if (decision === "article") return 240;
if (decision === "risky") return 120;
return 0;
}
function maskFolderId(folderId: string) {
return folderId.length > 8 ? `${folderId.slice(0, 4)}...${folderId.slice(-4)}` : "configured";
}
@ -298,53 +324,120 @@ function selectPhrases(input: {
market: MarketEnrichmentContract;
phraseLimit: number;
}) {
const selected = new Map<string, EvidencePhraseInput>();
const candidates: EvidencePhraseCandidate[] = [];
const candidatePhraseKeys = new Set<string>();
function add(item: EvidencePhraseInput) {
const key = normalizePhrase(item.phrase);
function addCandidate(item: EvidencePhraseInput, bucketKey: string, score: number) {
const phraseKey = normalizePhrase(item.phrase);
if (!key || selected.has(key) || selected.size >= input.phraseLimit) {
if (!phraseKey || candidatePhraseKeys.has(phraseKey)) {
return;
}
selected.set(key, item);
}
for (const item of input.keywordMap.persisted.items) {
add({
phrase: item.phrase,
priority: item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low",
reason: "Approved keyword decision from SEO plan.",
source: "keyword_map",
targetPath: item.targetPath
candidatePhraseKeys.add(phraseKey);
candidates.push({
...item,
bucketKey: bucketKey || item.targetPath || item.source,
order: candidates.length,
score
});
}
for (const item of input.cleaning.items.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))) {
for (const item of input.keywordMap.persisted.items) {
const priority = item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low";
addCandidate(
{
phrase: item.phrase,
priority,
reason: "Approved keyword decision from SEO plan.",
source: "keyword_map",
targetPath: item.targetPath
},
item.targetPath ?? item.relatedLanding ?? "keyword_map",
10_000 + getKeywordMapRoleScore(item.role) + getPriorityScore(priority)
);
}
for (const item of input.cleaning.items) {
if (item.decision === "trash") {
continue;
}
add({
const priority = (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low";
addCandidate(
{
phrase: item.phrase,
priority: (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low",
priority,
reason: "High demand candidate from keyword cleaning.",
source: "top_demand",
targetPath: item.targetPath
});
},
item.targetPath ?? item.clusterId,
getCleaningDecisionScore(item.decision) + getPriorityScore(priority) + Math.min(item.frequency ?? 0, 10_000) / 20
);
}
for (const item of input.market.seedQueue) {
add({
addCandidate(
{
phrase: item.phrase,
priority: item.priority,
reason: item.normalization?.reviewReason ?? "Approved market seed.",
source: "market_seed",
targetPath: null
});
},
item.clusterId,
getPriorityScore(item.priority) + (item.frequency ?? 0) / 50
);
}
return Array.from(selected.values());
const sortedCandidates = candidates.sort((left, right) => right.score - left.score || left.order - right.order);
const candidatesByBucket = sortedCandidates.reduce<Map<string, EvidencePhraseCandidate[]>>((accumulator, candidate) => {
accumulator.set(candidate.bucketKey, [...(accumulator.get(candidate.bucketKey) ?? []), candidate]);
return accumulator;
}, new Map());
const bucketKeys = Array.from(candidatesByBucket.keys()).sort((left, right) => {
const leftScore = candidatesByBucket.get(left)?.[0]?.score ?? 0;
const rightScore = candidatesByBucket.get(right)?.[0]?.score ?? 0;
return rightScore - leftScore || left.localeCompare(right, "ru");
});
const selected = new Map<string, EvidencePhraseCandidate>();
for (let index = 0; selected.size < input.phraseLimit; index += 1) {
let added = false;
for (const bucketKey of bucketKeys) {
const candidate = candidatesByBucket.get(bucketKey)?.[index];
if (!candidate) {
continue;
}
selected.set(normalizePhrase(candidate.phrase), candidate);
added = true;
if (selected.size >= input.phraseLimit) {
break;
}
}
if (!added) {
break;
}
}
for (const candidate of sortedCandidates) {
if (selected.size >= input.phraseLimit) {
break;
}
selected.set(normalizePhrase(candidate.phrase), candidate);
}
return Array.from(selected.values()).map(({ bucketKey, order, score, ...phrase }) => phrase);
}
async function postJson(input: {
@ -761,7 +854,7 @@ function normalizePhraseLimit(value: number | undefined) {
return DEFAULT_PHRASE_LIMIT;
}
return Math.max(1, Math.min(12, Math.round(value ?? DEFAULT_PHRASE_LIMIT)));
return Math.max(1, Math.min(MAX_PHRASE_LIMIT, Math.round(value ?? DEFAULT_PHRASE_LIMIT)));
}
async function createStartedRun(projectId: string, input: Record<string, unknown>) {

View File

@ -116,6 +116,9 @@ function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
const WORDSTAT_TOP_RESULTS_LIMIT = 160;
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12;
const RELATED_QUERY_STOP_WORDS = new Set([
"a",
"an",
@ -299,24 +302,94 @@ function getFrequencyBucket(frequencyGroup: string | null) {
return "unknown";
}
function compareWordstatResultRows(left: WordstatResultRow, right: WordstatResultRow) {
return (
(right.frequency ?? 0) - (left.frequency ?? 0) ||
(left.position ?? Number.MAX_SAFE_INTEGER) - (right.position ?? Number.MAX_SAFE_INTEGER) ||
left.phrase.localeCompare(right.phrase, "ru")
);
}
function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
const topResultByPhrase = new Map<string, WordstatResultRow>();
for (const result of results) {
if (result.frequency === null) {
continue;
}
const normalizedPhrase = result.normalized_phrase ?? normalizePhrase(result.phrase);
const currentResult = topResultByPhrase.get(normalizedPhrase);
if (!currentResult || compareWordstatResultRows(result, currentResult) < 0) {
topResultByPhrase.set(normalizedPhrase, result);
}
}
const rows = Array.from(topResultByPhrase.values()).sort(compareWordstatResultRows);
const rowsBySource = rows.reduce<Map<string, WordstatResultRow[]>>((accumulator, row) => {
const sourceKey = normalizePhrase(row.source_phrase || row.phrase);
accumulator.set(sourceKey, [...(accumulator.get(sourceKey) ?? []), row]);
return accumulator;
}, new Map());
const sourceKeys = Array.from(rowsBySource.keys()).sort((left, right) => {
const leftFirst = rowsBySource.get(left)?.[0];
const rightFirst = rowsBySource.get(right)?.[0];
if (!leftFirst || !rightFirst) {
return left.localeCompare(right, "ru");
}
return compareWordstatResultRows(leftFirst, rightFirst);
});
const selectedRows = new Map<string, WordstatResultRow>();
for (let index = 0; selectedRows.size < WORDSTAT_TOP_RESULTS_LIMIT; index += 1) {
let added = false;
for (const sourceKey of sourceKeys) {
if (index >= WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT) {
continue;
}
const row = rowsBySource.get(sourceKey)?.[index];
if (!row) {
continue;
}
selectedRows.set(row.normalized_phrase ?? normalizePhrase(row.phrase), row);
added = true;
if (selectedRows.size >= WORDSTAT_TOP_RESULTS_LIMIT) {
break;
}
}
if (!added) {
break;
}
}
for (const row of rows) {
if (selectedRows.size >= WORDSTAT_TOP_RESULTS_LIMIT) {
break;
}
selectedRows.set(row.normalized_phrase ?? normalizePhrase(row.phrase), row);
}
return Array.from(selectedRows.values()).sort(compareWordstatResultRows);
}
function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
const relatedCounts = new Map<string, number>();
const topResultByPhrase = new Map<string, WordstatResultRow>();
for (const result of results) {
if (result.source_phrase && result.source_phrase !== result.phrase) {
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
}
if (result.frequency !== null) {
const normalizedPhrase = result.normalized_phrase ?? normalizePhrase(result.phrase);
const currentResult = topResultByPhrase.get(normalizedPhrase);
if (!currentResult || (result.frequency ?? 0) > (currentResult.frequency ?? 0)) {
topResultByPhrase.set(normalizedPhrase, result);
}
}
}
return {
@ -332,9 +405,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
relatedCount: relatedCounts.get(result.phrase) ?? 0,
collectedAt: result.created_at.toISOString()
})),
topResults: Array.from(topResultByPhrase.values())
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
.slice(0, 20)
topResults: getRepresentativeTopResultRows(results)
.map((result) => ({
id: result.id,
phrase: result.phrase,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,163 @@
export const CAROUSEL_LIKE_SEMANTIC_PATTERN_SOURCE =
"carousel|slider|slides?|swiper|slick|splide|keen|flickity|owl|gallery|galleries|rail|marquee|track|strip|media|videos?|images?|photos?|pictures?|poster|thumbnail|карусел|слайдер|слайды?|галере|медиа|видео|изображ|картин|фото|лента|витрин";
export const CAROUSEL_LIKE_SEMANTIC_PATTERN = new RegExp(CAROUSEL_LIKE_SEMANTIC_PATTERN_SOURCE, "iu");
export type SemanticBlockBoxLike = {
anchors?: SemanticBlockAnchorLike[];
capturedEntities?: unknown[];
geometry?: {
documentHeight?: number;
documentWidth?: number;
height: number;
left: number;
top: number;
width: number;
};
height: number;
id: string;
left: number;
targetBound?: boolean;
targetIds: string[];
title: string;
top: number;
width: number;
};
export type SemanticBlockAnchorLike = {
entityType: string;
fingerprint?: {
mediaSrc?: string;
tagName?: string;
text?: string;
targetId?: string;
};
id: string;
pageId?: string;
scopeId?: string;
selector?: string;
selectorIndex?: number;
source: "captured_dom" | "manual_scope" | "workspace_target";
targetId?: string;
text?: string;
};
export function normalizeSemanticBlockIdFragment(value: string) {
return value.replace(/[^a-zA-Z0-9а-яА-ЯёЁ._-]+/g, "-").replace(/^-+|-+$/g, "");
}
export function isCarouselLikeSemanticValue(values: Array<string | null | undefined>) {
return CAROUSEL_LIKE_SEMANTIC_PATTERN.test(values.filter(Boolean).join(" ").toLocaleLowerCase("ru-RU"));
}
export function getCollapsedCarouselOverrideTitle(value: string) {
return value.replace(/\s+\d+$/g, "").trim() || value;
}
export function getCarouselManualBoxBase(box: Pick<SemanticBlockBoxLike, "id" | "title">) {
const visualIdMatch = /^(.*):visual-\d+$/i.exec(box.id || "");
const numberedTitleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(box.title || "");
const baseTitle = (numberedTitleMatch?.[1] || box.title || "").trim();
const hasGeometry = Boolean("geometry" in box && box.geometry);
const searchableValue = [
visualIdMatch?.[1],
baseTitle,
box.id,
box.title
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("ru-RU");
if (!CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
return null;
}
if (!visualIdMatch && numberedTitleMatch && !hasGeometry) {
return null;
}
return {
id: visualIdMatch?.[1] || `manual-carousel:${normalizeSemanticBlockIdFragment(baseTitle).toLocaleLowerCase("ru-RU")}`,
title: baseTitle || box.title || "Слайдер"
};
}
function mergeGeometry<T extends SemanticBlockBoxLike>(current: T, next: T) {
const left = Math.min(current.left, next.left);
const top = Math.min(current.top, next.top);
const right = Math.max(current.left + current.width, next.left + next.width);
const bottom = Math.max(current.top + current.height, next.top + next.height);
current.left = left;
current.top = top;
current.width = Math.max(80, right - left);
current.height = Math.max(56, bottom - top);
current.geometry = {
...(current.geometry || {}),
height: current.height,
left: current.left,
top: current.top,
width: current.width
};
}
function getAnchorKey(anchor: SemanticBlockAnchorLike) {
return [
anchor.source,
anchor.targetId ?? "",
anchor.selector ?? "",
anchor.selectorIndex ?? 0,
anchor.scopeId ?? "",
anchor.pageId ?? "",
anchor.entityType
].join("::");
}
function mergeAnchors(current: SemanticBlockAnchorLike[] | undefined, next: SemanticBlockAnchorLike[] | undefined) {
const anchorsByKey = new Map<string, SemanticBlockAnchorLike>();
for (const anchor of [...(current ?? []), ...(next ?? [])]) {
anchorsByKey.set(getAnchorKey(anchor), anchor);
}
return Array.from(anchorsByKey.values()).slice(0, 120);
}
export function collapseCarouselManualSemanticBlockBoxes<T extends SemanticBlockBoxLike>(boxes: T[]) {
const mergedByKey = new Map<string, T>();
const result: T[] = [];
for (const box of boxes) {
const base = getCarouselManualBoxBase(box);
if (!base) {
result.push(box);
continue;
}
const band = Math.round(box.top / Math.max(220, box.height || 220));
const key = base.id.startsWith("manual-carousel:") ? `${base.id}:band-${band}` : base.id;
const existing = mergedByKey.get(key);
if (!existing) {
const collapsed = {
...box,
id: base.id,
targetBound: (box.targetIds || []).length > 0 || (box.capturedEntities || []).length > 0 || (box.anchors || []).length > 0,
title: base.title
};
mergedByKey.set(key, collapsed);
result.push(collapsed);
continue;
}
existing.targetIds = Array.from(new Set([...(existing.targetIds || []), ...(box.targetIds || [])]));
existing.capturedEntities = [...(existing.capturedEntities || []), ...(box.capturedEntities || [])];
existing.anchors = mergeAnchors(existing.anchors, box.anchors);
existing.targetBound = existing.targetIds.length > 0 || existing.capturedEntities.length > 0 || (existing.anchors || []).length > 0;
mergeGeometry(existing, box);
}
return result;
}

View File

@ -25,6 +25,16 @@ import {
resetPageWorkspace,
savePageWorkspace
} from "../workspace/pageWorkspace.js";
import {
ContentFieldWorkspaceError,
listContentFieldDraftsForPage,
saveContentFieldDraft
} from "../workspace/contentFieldWorkspace.js";
import {
listSemanticBlocksForPage,
saveSemanticBlocksForWorkspace,
SemanticBlockWorkspaceError
} from "../workspace/semanticBlocks.js";
import {
getLatestSemanticAnalysis,
getSemanticAnalysisExport,
@ -92,8 +102,10 @@ import {
getMaterializationContract,
MaterializationError
} from "../rewrite/materialization.js";
import { buildPatchArtifact, getLatestPatchArtifact, runPatchDryRun } from "../patches/patchArtifact.js";
import { getRewriteDiffContract } from "../rewrite/rewriteDiff.js";
import { getRewritePlanContract } from "../rewrite/rewritePlan.js";
import { generateVisualComposerVariants } from "../rewrite/visualComposerVariants.js";
import { storageProvider } from "../storage/index.js";
export const projectsRouter = Router();
@ -131,6 +143,71 @@ const updatePageSelectionSchema = z.object({
const updatePageWorkspaceSchema = z.object({
workingText: z.string().max(1_000_000, "Рабочий текст слишком большой для текущего MVP.")
});
const updateContentFieldDraftSchema = z.object({
fieldId: z.string().trim().min(1).max(240).nullable().optional(),
fieldPath: z.string().trim().min(1, "Нужен путь content field.").max(300),
group: z.string().trim().max(160).nullable().optional(),
kind: z.string().trim().min(1).max(80),
originalText: z.string().max(120_000, "Original text слишком большой для content field."),
renderAs: z.string().trim().max(80).nullable().optional(),
scopeId: z.string().trim().min(1).max(240),
sectionId: z.string().trim().min(1).max(240),
sourcePath: z.string().trim().min(1, "Нужен sourcePath content field.").max(500),
title: z.string().trim().min(1).max(240),
workingText: z.string().max(120_000, "Working text слишком большой для content field.")
});
const semanticBlockSourceSchema = z.enum(["auto", "manual", "source_object", "visual_section", "workspace_section"]);
const semanticBlockJsonRecordSchema = z.record(z.string(), z.unknown());
const semanticBlockSaveSchema = z.object({
scanVersionId: projectIdSchema.nullable().optional(),
workspaceKey: z.string().trim().min(1, "Нужен workspaceKey semantic block.").max(240),
scopeId: z.string().trim().min(1, "Нужен scopeId semantic block.").max(240),
blocks: z.array(
z.object({
anchors: z.array(z.unknown()).max(200).optional(),
blockKey: z.string().trim().min(1, "Нужен blockKey semantic block.").max(260),
capturedEntities: z.array(z.unknown()).max(200).optional(),
geometryCache: semanticBlockJsonRecordSchema.optional(),
identity: semanticBlockJsonRecordSchema.optional(),
mediaContext: semanticBlockJsonRecordSchema.optional(),
rewriteState: semanticBlockJsonRecordSchema.optional(),
role: z.string().trim().min(1).max(80).optional(),
source: semanticBlockSourceSchema.optional(),
sourceBindings: z.array(z.unknown()).max(200).optional(),
targetIds: z.array(z.string().trim().min(1).max(260)).max(200).optional(),
title: z.string().trim().min(1).max(260)
})
).max(120)
});
const visualComposerSemanticGroupSchema = z.object({
confidence: z.number().min(0).max(1),
evidence: z.array(z.string().trim().min(1).max(120)).max(20),
fieldCount: z.number().int().positive().max(500),
fieldOrder: z.number().int().nonnegative().max(500),
fieldRole: z.string().trim().min(1).max(80),
groupText: z.string().max(200_000),
id: z.string().trim().min(1).max(360),
source: z.enum(["source_object", "visual_section", "workspace_section"]),
title: z.string().trim().min(1).max(260)
});
const visualComposerSeedSchema = z.object({
currentValue: z.string().max(120_000),
field: z.enum(["body_section", "h1", "meta_description", "title"]).optional(),
id: z.string().trim().min(1).max(260),
instruction: z.string().trim().max(1_000).optional(),
keywordPhrases: z.array(z.string().trim().min(1).max(180)).max(40).optional(),
pageContextText: z.string().max(200_000).nullable().optional(),
pageId: z.string().uuid("Некорректный id страницы."),
pageTitle: z.string().trim().max(300).nullable().optional(),
semanticGroup: visualComposerSemanticGroupSchema.optional(),
sourcePath: z.string().trim().max(500).nullable().optional(),
title: z.string().trim().min(1).max(260),
urlPath: z.string().trim().max(500).nullable().optional(),
workspaceSectionId: z.string().trim().min(1).max(360)
});
const visualComposerVariantsSchema = z.object({
seeds: z.array(visualComposerSeedSchema).max(120).optional()
});
const semanticExportFormatSchema = z.enum(["csv", "json", "markdown"]).default("json");
const ontologyApprovalSchema = z.object({
@ -175,7 +252,7 @@ const modelTaskRunSchema = z
message: "Нужно передать taskId или taskType."
});
const yandexEvidenceRunSchema = z.object({
phraseLimit: z.number().int().min(1).max(12).optional()
phraseLimit: z.number().int().min(1).max(48).optional()
});
const materializationActionSchema = z.discriminatedUnion("action", [
z.object({
@ -1127,6 +1204,86 @@ projectsRouter.get("/:projectId/rewrite-diff/latest", async (request, response)
}
});
projectsRouter.get("/:projectId/patches/latest", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.json({
patchArtifact: await getLatestPatchArtifact(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось загрузить patch artifact проекта.");
}
});
projectsRouter.post("/:projectId/patches/build", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.status(202).json({
patchArtifact: await buildPatchArtifact(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось собрать patch artifact проекта.");
}
});
projectsRouter.post("/:projectId/patches/:changesetId/dry-run", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const changesetId = projectIdSchema.parse(request.params.changesetId);
const dryRun = await runPatchDryRun(projectId, changesetId);
if (!dryRun) {
sendError(response, 404, "Patch artifact не найден для этого changeset.");
return;
}
response.status(202).json({
dryRun
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id patch artifact.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось выполнить dry-run patch artifact.");
}
});
projectsRouter.post("/:projectId/visual-composer/variants", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const input = visualComposerVariantsSchema.parse(request.body ?? {});
response.status(202).json({
visualComposerVariants: await generateVisualComposerVariants(projectId, input.seeds ?? [])
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось собрать visual composer variants.");
}
});
projectsRouter.post("/:projectId/materialization/actions", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
@ -1226,7 +1383,14 @@ projectsRouter.post("/:projectId/pages/:pageId/workspace", async (request, respo
return;
}
const [contentDrafts, semanticBlocks] = await Promise.all([
listContentFieldDraftsForPage(projectId, pageId),
listSemanticBlocksForPage(projectId, pageId)
]);
response.status(201).json({
contentDrafts,
semanticBlocks,
workspace
});
} catch (error) {
@ -1276,6 +1440,95 @@ projectsRouter.patch("/:projectId/pages/:pageId/workspace", async (request, resp
}
});
projectsRouter.get("/:projectId/pages/:pageId/semantic-blocks", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const pageId = projectIdSchema.parse(request.params.pageId);
response.json({
semanticBlocks: await listSemanticBlocksForPage(projectId, pageId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id страницы.");
return;
}
if (error instanceof SemanticBlockWorkspaceError) {
sendError(response, 400, error.message);
return;
}
console.error(error);
sendError(response, 500, "Не удалось загрузить semantic blocks страницы.");
}
});
projectsRouter.put("/:projectId/pages/:pageId/semantic-blocks", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const pageId = projectIdSchema.parse(request.params.pageId);
const input = semanticBlockSaveSchema.parse(request.body);
const semanticBlocks = await saveSemanticBlocksForWorkspace(projectId, pageId, input);
response.json({
semanticBlocks
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь semantic block payload.");
return;
}
if (error instanceof SemanticBlockWorkspaceError) {
sendError(response, 400, error.message);
return;
}
console.error(error);
sendError(response, 500, "Не удалось сохранить semantic blocks страницы.");
}
});
projectsRouter.patch("/:projectId/pages/:pageId/content-fields", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const pageId = projectIdSchema.parse(request.params.pageId);
const input = updateContentFieldDraftSchema.parse(request.body);
const contentDraft = await saveContentFieldDraft(projectId, {
fieldId: input.fieldId ?? null,
fieldPath: input.fieldPath,
group: input.group ?? null,
kind: input.kind,
originalText: input.originalText,
pageId,
renderAs: input.renderAs ?? null,
scopeId: input.scopeId,
sectionId: input.sectionId,
sourcePath: input.sourcePath,
title: input.title,
workingText: input.workingText
});
response.json({
contentDraft
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь content field draft.");
return;
}
if (error instanceof ContentFieldWorkspaceError) {
sendError(response, 400, error.message);
return;
}
console.error(error);
sendError(response, 500, "Не удалось сохранить content field draft.");
}
});
projectsRouter.post("/:projectId/pages/:pageId/workspace/reset", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);

View File

@ -1,5 +1,6 @@
import { pool } from "../db/client.js";
import { storageProvider } from "../storage/index.js";
import { listSemanticBlocksForPages, type SemanticBlockModel } from "../workspace/semanticBlocks.js";
import { getRewritePlanContract, type RewritePlanContract } from "./rewritePlan.js";
type RewriteDiffState = "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review";
@ -43,11 +44,16 @@ export type RewriteDiffContract = {
blockerCount: number;
materializationTargetCount: number;
modelReadySlotCount: number;
semanticBlockCount: number;
semanticGroupedSlotCount: number;
semanticRewriteGroupCount: number;
sourcePendingSlotCount: number;
slotLevelFallbackCount: number;
slotCount: number;
targetCount: number;
};
blockers: string[];
semanticRewriteGroups: RewriteSemanticGroup[];
targets: RewriteDiffTarget[];
nextActions: string[];
};
@ -80,6 +86,77 @@ export type RewriteDiffSlot = {
frequencyGroup: string | null;
exactLimit: number | null;
}>;
semanticBlock: RewriteDiffSemanticBlock | null;
};
export type RewriteDiffSemanticBlock = {
id: string;
blockKey: string;
title: string;
role: string;
source: SemanticBlockModel["source"];
workspaceKey: string;
scopeId: string;
anchors: unknown[];
capturedEntities: unknown[];
mediaContext: Record<string, unknown>;
targetIds: string[];
sourceBindings: unknown[];
};
export type RewriteSemanticGroupSourceGraph = {
contentFieldCount: number;
mediaTargetIds: string[];
permissionLabels: string[];
selectorCount: number;
sourceBindingCount: number;
sourceTypes: string[];
contentFields: Array<{
fieldPath: string | null;
group: string | null;
kind: string | null;
renderAs: string | null;
role: string | null;
sectionId: string | null;
sourcePath: string | null;
title: string | null;
}>;
workspaceTargets: Array<{
fallbackSelector: string | null;
kind: string | null;
permission: string | null;
sectionId: string | null;
selector: string | null;
source: string | null;
targetId: string | null;
title: string | null;
}>;
};
export type RewriteSemanticGroup = {
id: string;
pageId: string;
pageTitle: string | null;
sourcePath: string | null;
urlPath: string | null;
title: string;
status: "semantic_block" | "slot_level_fallback";
fallbackReason: "no_semantic_block" | null;
semanticBlock: RewriteDiffSemanticBlock | null;
sourceBindings: unknown[];
sourceGraph: RewriteSemanticGroupSourceGraph;
groupText: string;
keywordBindings: RewriteDiffSlot["keywordBindings"];
slots: Array<{
blockers: string[];
currentValue: string | null;
field: RewriteDiffField;
instruction: string;
rewriteSlotId: string;
source: RewriteDiffSlot["source"];
status: RewriteDiffSlotStatus;
workspaceSectionId: string | null;
}>;
};
function normalizeHeadings(raw: Record<string, unknown>) {
@ -263,9 +340,87 @@ function getSlotBlockers(input: {
return blockers;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function getSemanticBlockSourceBindings(block: SemanticBlockModel) {
return block.sourceBindings.map(asRecord).filter((binding): binding is Record<string, unknown> => Boolean(binding));
}
function mapRewriteDiffSemanticBlock(block: SemanticBlockModel): RewriteDiffSemanticBlock {
return {
anchors: block.anchors,
blockKey: block.blockKey,
capturedEntities: block.capturedEntities,
id: block.id,
mediaContext: block.mediaContext,
role: block.role,
scopeId: block.scopeId,
source: block.source,
sourceBindings: block.sourceBindings,
targetIds: block.targetIds,
title: block.title,
workspaceKey: block.workspaceKey
};
}
function getSemanticBlockMatchScore(input: {
block: SemanticBlockModel;
rewriteTargetId: string;
slotId: string;
workspaceSectionId: string | null;
}) {
const targetIds = new Set(input.block.targetIds);
const bindings = getSemanticBlockSourceBindings(input.block);
if (targetIds.has(input.rewriteTargetId) || bindings.some((binding) => binding.targetId === input.rewriteTargetId)) {
return 100;
}
if (input.workspaceSectionId) {
if (
targetIds.has(input.workspaceSectionId) ||
targetIds.has(`section-${input.workspaceSectionId}`) ||
bindings.some((binding) => binding.sectionId === input.workspaceSectionId)
) {
return 70;
}
}
if (targetIds.has(input.slotId) || bindings.some((binding) => binding.targetId === input.slotId)) {
return 40;
}
return 0;
}
function findSemanticBlockForSlot(input: {
blocks: SemanticBlockModel[];
rewriteTargetId: string;
slotId: string;
workspaceSectionId: string | null;
}) {
return (
input.blocks
.map((block) => ({
block,
score: getSemanticBlockMatchScore({
block,
rewriteTargetId: input.rewriteTargetId,
slotId: input.slotId,
workspaceSectionId: input.workspaceSectionId
})
}))
.filter((match) => match.score > 0)
.sort((left, right) => right.score - left.score || right.block.updatedAt.localeCompare(left.block.updatedAt))[0]?.block ?? null
);
}
function buildSlots(input: {
draft: DraftSnapshotRow | undefined;
page: LatestPageRow | undefined;
semanticBlocks: SemanticBlockModel[];
snapshot: WorkspaceSnapshot | null | undefined;
target: RewritePlanContract["existingPageTargets"][number];
}) {
@ -296,12 +451,20 @@ function buildSlots(input: {
source: current.source,
workspaceSectionId
});
const slotId = `${input.target.id}:${key}`;
const rewriteTargetId = `rewrite-${slotId}`;
const semanticBlock = findSemanticBlockForSlot({
blocks: input.semanticBlocks,
rewriteTargetId,
slotId,
workspaceSectionId
});
return {
blockers,
currentValue: current.value,
field,
id: `${input.target.id}:${key}`,
id: slotId,
instruction: bindings.map((binding) => binding.instruction).join(" "),
keywordBindings: bindings.map((binding) => ({
decisionId: binding.decisionId,
@ -312,6 +475,7 @@ function buildSlots(input: {
role: binding.role
})),
proposedValue: null,
semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock(semanticBlock) : null,
source: current.source,
status: blockers.length === 0 ? "ready_for_model_review" : "source_pending",
workspaceSectionId
@ -340,6 +504,12 @@ function buildNextActions(contract: Omit<RewriteDiffContract, "nextActions">) {
actions.push(`Обсудить model provider и prompt contract для ${contract.readiness.modelReadySlotCount} ready slots.`);
}
if (contract.readiness.slotLevelFallbackCount > 0) {
actions.push(
`Semantic grouping неполный: ${contract.readiness.slotLevelFallbackCount} slots идут через fallbackReason=no_semantic_block.`
);
}
if (actions.length === 0) {
actions.push("Rewrite diff contract ждёт approved SEO-план и materialized page targets.");
}
@ -373,11 +543,180 @@ function getState(input: {
return "ready_for_model_review" satisfies RewriteDiffState;
}
function uniqueKeywordBindings(bindings: RewriteDiffSlot["keywordBindings"]) {
const seen = new Set<string>();
const result: RewriteDiffSlot["keywordBindings"] = [];
for (const binding of bindings) {
const key = `${binding.decisionId}:${binding.phrase}:${binding.role}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(binding);
}
return result;
}
function getSemanticGroupTitle(slot: RewriteDiffSlot) {
if (slot.semanticBlock) {
return slot.semanticBlock.title;
}
if (slot.workspaceSectionId) {
return slot.workspaceSectionId;
}
return slot.field;
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function uniqueStrings(values: Array<string | null | undefined>) {
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
}
function getSourceBindingRecords(semanticBlock: RewriteDiffSemanticBlock | null) {
return (semanticBlock?.sourceBindings ?? []).map(asRecord).filter((binding): binding is Record<string, unknown> => Boolean(binding));
}
function getContentFieldRecord(binding: Record<string, unknown>) {
return asRecord(binding.contentField);
}
function getMediaTargetIds(semanticBlock: RewriteDiffSemanticBlock | null) {
const rawMediaTargetIds = semanticBlock?.mediaContext.mediaTargetIds;
if (!Array.isArray(rawMediaTargetIds)) {
return [];
}
return uniqueStrings(rawMediaTargetIds.map(getString));
}
function buildSemanticGroupSourceGraph(semanticBlock: RewriteDiffSemanticBlock | null): RewriteSemanticGroupSourceGraph {
const bindings = getSourceBindingRecords(semanticBlock);
const contentFieldRows = bindings
.map(getContentFieldRecord)
.filter((contentField): contentField is Record<string, unknown> => Boolean(contentField));
const contentFields = contentFieldRows.map((contentField) => ({
fieldPath: getString(contentField.fieldPath),
group: getString(contentField.group),
kind: getString(contentField.kind),
renderAs: getString(contentField.renderAs),
role: getString(contentField.role),
sectionId: getString(contentField.sectionId),
sourcePath: getString(contentField.sourcePath),
title: getString(contentField.title)
}));
const workspaceTargets = bindings.map((binding) => ({
fallbackSelector: getString(binding.fallbackSelector),
kind: getString(binding.kind),
permission: getString(binding.permission),
sectionId: getString(binding.sectionId),
selector: getString(binding.selector),
source: getString(binding.source),
targetId: getString(binding.targetId),
title: getString(binding.title)
}));
const selectors = uniqueStrings(
bindings.flatMap((binding) => [
getString(binding.selector),
getString(binding.fallbackSelector),
...(Array.isArray(binding.markerSelectors) ? binding.markerSelectors.map(getString) : [])
])
);
return {
contentFieldCount: contentFields.length,
contentFields,
mediaTargetIds: getMediaTargetIds(semanticBlock),
permissionLabels: uniqueStrings(bindings.map((binding) => getString(binding.permission))),
selectorCount: selectors.length,
sourceBindingCount: bindings.length,
sourceTypes: uniqueStrings(bindings.map((binding) => getString(binding.source))),
workspaceTargets
};
}
function buildSemanticRewriteGroups(targets: RewriteDiffTarget[]): RewriteSemanticGroup[] {
const groupMap = new Map<string, RewriteSemanticGroup>();
for (const target of targets) {
for (const slot of target.slots) {
const semanticGroupId = slot.semanticBlock ? `semantic-block:${slot.semanticBlock.id}` : `slot:${slot.id}`;
const currentGroup = groupMap.get(semanticGroupId);
const group =
currentGroup ??
({
fallbackReason: slot.semanticBlock ? null : "no_semantic_block",
groupText: "",
id: semanticGroupId,
keywordBindings: [],
pageId: target.pageId,
pageTitle: target.pageTitle,
semanticBlock: slot.semanticBlock,
slots: [],
sourceBindings: slot.semanticBlock?.sourceBindings ?? [],
sourceGraph: buildSemanticGroupSourceGraph(slot.semanticBlock),
sourcePath: target.sourcePath,
status: slot.semanticBlock ? "semantic_block" : "slot_level_fallback",
title: getSemanticGroupTitle(slot),
urlPath: target.urlPath
} satisfies RewriteSemanticGroup);
group.keywordBindings = uniqueKeywordBindings([...group.keywordBindings, ...slot.keywordBindings]);
group.slots.push({
blockers: slot.blockers,
currentValue: slot.currentValue,
field: slot.field,
instruction: slot.instruction,
rewriteSlotId: slot.id,
source: slot.source,
status: slot.status,
workspaceSectionId: slot.workspaceSectionId
});
group.groupText = group.slots
.map((groupSlot) => {
const label = groupSlot.workspaceSectionId ?? groupSlot.field;
const value = groupSlot.currentValue?.replace(/\s+/g, " ").trim() ?? "";
return value ? `${label}: ${value}` : "";
})
.filter(Boolean)
.join("\n");
groupMap.set(semanticGroupId, group);
}
}
return Array.from(groupMap.values()).sort((left, right) => {
if (left.status !== right.status) {
return left.status === "semantic_block" ? -1 : 1;
}
return left.title.localeCompare(right.title, "ru");
});
}
export async function getRewriteDiffContract(projectId: string): Promise<RewriteDiffContract> {
const rewritePlan = await getRewritePlanContract(projectId);
const pageIds = rewritePlan.existingPageTargets.map((target) => target.pageId).filter(Boolean) as string[];
const [pageMap, draftMap] = await Promise.all([getLatestPages(projectId, pageIds), getDraftSnapshots(projectId, pageIds)]);
const [pageMap, draftMap, semanticBlocks] = await Promise.all([
getLatestPages(projectId, pageIds),
getDraftSnapshots(projectId, pageIds),
listSemanticBlocksForPages(projectId, pageIds)
]);
const snapshotMap = await getWorkspaceSnapshotMap(draftMap);
const semanticBlocksByPageId = semanticBlocks.reduce<Map<string, SemanticBlockModel[]>>((accumulator, block) => {
accumulator.set(block.pageId, [...(accumulator.get(block.pageId) ?? []), block]);
return accumulator;
}, new Map());
const targets = rewritePlan.existingPageTargets
.filter((target): target is RewritePlanContract["existingPageTargets"][number] & { pageId: string } => Boolean(target.pageId))
.map((target) => {
@ -385,6 +724,7 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
const slots = buildSlots({
draft: draftMap.get(target.pageId),
page,
semanticBlocks: semanticBlocksByPageId.get(target.pageId) ?? [],
snapshot: snapshotMap.get(target.pageId),
target
});
@ -408,6 +748,14 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
0
);
const modelReadySlotCount = slotCount - sourcePendingSlotCount;
const semanticGroupedSlotCount = targets.reduce(
(sum, target) => sum + target.slots.filter((slot) => Boolean(slot.semanticBlock)).length,
0
);
const semanticBlockCount = new Set(
targets.flatMap((target) => target.slots.map((slot) => slot.semanticBlock?.id).filter((id): id is string => Boolean(id)))
).size;
const semanticRewriteGroups = buildSemanticRewriteGroups(targets);
const contractWithoutActions: Omit<RewriteDiffContract, "nextActions"> = {
schemaVersion: "rewrite-diff.v1",
projectId,
@ -425,11 +773,16 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
blockerCount: blockers.length,
materializationTargetCount: rewritePlan.materializationQueue.length,
modelReadySlotCount,
semanticBlockCount,
semanticGroupedSlotCount,
semanticRewriteGroupCount: semanticRewriteGroups.length,
sourcePendingSlotCount,
slotLevelFallbackCount: Math.max(0, slotCount - semanticGroupedSlotCount),
slotCount,
targetCount: targets.length
},
blockers,
semanticRewriteGroups,
targets
};

View File

@ -0,0 +1,825 @@
import { getLatestPatchArtifact, type PatchArtifactChange } from "../patches/patchArtifact.js";
import { getRewriteDiffContract, type RewriteDiffContract, type RewriteDiffSlot } from "./rewriteDiff.js";
type VisualComposerVariantMode = "balanced" | "commercial" | "concise" | "expert" | "seo_dense";
type VisualComposerVariantState = "blocked" | "partial_ready" | "ready";
export type VisualComposerSemanticGroup = {
confidence: number;
evidence: string[];
fieldCount: number;
fieldOrder: number;
fieldRole: string;
groupText: string;
id: string;
source: "source_object" | "visual_section" | "workspace_section";
title: string;
};
export type VisualComposerVariantSlot = {
id: string;
afterText: string;
beforeText: string;
field: RewriteDiffSlot["field"];
includedPhraseCount: number;
instruction: string;
keywordPhrases: string[];
pageContextText: string | null;
pageId: string;
pageTitle: string | null;
riskNotes: string[];
rewriteSlotId: string;
semanticGroup: VisualComposerSemanticGroup | null;
sourcePath: string | null;
title: string;
totalPhraseCount: number;
urlPath: string | null;
workspaceSectionId: string;
};
export type VisualComposerVariant = {
id: VisualComposerVariantMode;
label: string;
mode: VisualComposerVariantMode;
slots: VisualComposerVariantSlot[];
summary: string;
metrics: {
changedSlotCount: number;
includedPhraseCount: number;
totalDelta: number;
totalPhraseCount: number;
};
};
export type VisualComposerVariantsContract = {
schemaVersion: "visual-composer-variants.v1";
generatedAt: string;
projectId: string;
state: VisualComposerVariantState;
generator: {
id: "local_deterministic_v0";
modelBacked: false;
notes: string[];
};
readiness: {
blockerCount: number;
generatedSlotCount: number;
readySlotCount: number;
semanticBlockCount: number;
semanticGroupedSlotCount: number;
slotCount: number;
slotLevelFallbackCount: number;
targetCount: number;
variantCount: number;
};
blockers: string[];
variants: VisualComposerVariant[];
nextActions: string[];
};
type VariantBlueprint = {
id: VisualComposerVariantMode;
label: string;
phraseLimit: number;
summary: string;
};
type VisualComposerSlotSeed = {
currentValue: string;
field: RewriteDiffSlot["field"];
id: string;
instruction: string;
keywordBindings: RewriteDiffSlot["keywordBindings"];
pageContextText: string | null;
pageId: string;
pageTitle: string | null;
semanticGroup: VisualComposerSemanticGroup | null;
sourcePath: string | null;
title: string;
urlPath: string | null;
workspaceSectionId: string;
};
export type VisualComposerSeedInput = {
currentValue: string;
field?: RewriteDiffSlot["field"];
id: string;
instruction?: string;
keywordPhrases?: string[];
pageContextText?: string | null;
pageId: string;
pageTitle?: string | null;
semanticGroup?: VisualComposerSemanticGroup;
sourcePath?: string | null;
title: string;
urlPath?: string | null;
workspaceSectionId: string;
};
const VARIANT_BLUEPRINTS: VariantBlueprint[] = [
{
id: "balanced",
label: "Balanced",
phraseLimit: 2,
summary: "Умеренно усиливает смысл и сохраняет текущую структуру текста."
},
{
id: "concise",
label: "Compact",
phraseLimit: 1,
summary: "Держит текст короче и без плотной SEO-нагрузки."
},
{
id: "commercial",
label: "Offer",
phraseLimit: 2,
summary: "Сдвигает блок к офферу, пользе и следующему действию."
},
{
id: "expert",
label: "Expert",
phraseLimit: 3,
summary: "Добавляет аргументацию, процесс и доверие без жёсткого sales-тонa."
},
{
id: "seo_dense",
label: "SEO+",
phraseLimit: 4,
summary: "Даёт более плотное покрытие фраз, но не считается прогнозом ранжирования."
}
];
function normalizeSpaces(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeForSearch(value: string) {
return normalizeSpaces(value).toLocaleLowerCase("ru-RU");
}
function uniquePhrases(bindings: RewriteDiffSlot["keywordBindings"]) {
const seen = new Set<string>();
const phrases: string[] = [];
for (const binding of bindings) {
const phrase = normalizeSpaces(binding.phrase);
const key = normalizeForSearch(phrase);
if (!phrase || seen.has(key) || binding.exactLimit === 0) {
continue;
}
seen.add(key);
phrases.push(phrase);
}
return phrases;
}
function countIncludedPhrases(text: string, phrases: string[]) {
const normalizedText = normalizeForSearch(text);
return phrases.filter((phrase) => normalizedText.includes(normalizeForSearch(phrase))).length;
}
function getMissingPhrases(text: string, phrases: string[]) {
const normalizedText = normalizeForSearch(text);
return phrases.filter((phrase) => !normalizedText.includes(normalizeForSearch(phrase)));
}
function trimSentence(value: string, maxLength: number) {
const cleanValue = normalizeSpaces(value);
if (cleanValue.length <= maxLength) {
return cleanValue;
}
return `${cleanValue.slice(0, Math.max(0, maxLength - 1)).trimEnd()}.`;
}
function trimBodyText(value: string, maxLength: number) {
const cleanValue = value
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (cleanValue.length <= maxLength) {
return cleanValue;
}
return `${cleanValue.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
}
function getFieldLimit(field: RewriteDiffSlot["field"], originalLength: number) {
if (field === "title") {
return 72;
}
if (field === "meta_description") {
return 170;
}
if (field === "h1") {
return 96;
}
return Math.max(900, Math.min(6_000, originalLength + 420));
}
function getPhraseText(phrases: string[], limit: number) {
return phrases.slice(0, limit).join(", ");
}
function buildPhraseSentence(mode: VisualComposerVariantMode, phrases: string[]) {
const phraseText = getPhraseText(phrases, phrases.length);
if (!phraseText) {
return "";
}
if (mode === "commercial") {
return `Показываем, как ${phraseText} помогает быстрее перейти от идеи к рабочему результату.`;
}
if (mode === "expert") {
return `В тексте фиксируем практический контекст: ${phraseText}, ограничения внедрения и понятный порядок работы.`;
}
if (mode === "seo_dense") {
return `Дополнительно раскрываем ключевые направления: ${phraseText}, без отрыва от смысла страницы.`;
}
if (mode === "concise") {
return `Фокус: ${phraseText}.`;
}
return `Аккуратно усиливаем смысл через ${phraseText}, сохраняя исходную подачу блока.`;
}
function buildTitleText(input: {
currentText: string;
field: RewriteDiffSlot["field"];
mode: VisualComposerVariantMode;
phrases: string[];
}) {
const primaryPhrase = input.phrases[0] ?? "";
const currentText = normalizeSpaces(input.currentText);
if (!primaryPhrase) {
if (!currentText) {
return currentText;
}
if (input.mode === "commercial") {
return `${currentText}: польза и запуск`;
}
if (input.mode === "expert") {
return `${currentText}: архитектура и практика`;
}
if (input.mode === "seo_dense") {
return `${currentText}: ключевые сценарии`;
}
if (input.mode === "balanced") {
return `${currentText}: смысл и результат`;
}
return currentText;
}
if (input.field === "title") {
if (input.mode === "commercial") {
return `${primaryPhrase}: внедрение и результат`;
}
if (input.mode === "expert") {
return `${primaryPhrase}: архитектура и практика`;
}
if (input.mode === "seo_dense" && input.phrases[1]) {
return `${primaryPhrase} и ${input.phrases[1]}`;
}
return currentText ? `${primaryPhrase} | ${currentText}` : primaryPhrase;
}
if (input.field === "h1") {
if (input.mode === "commercial") {
return `${currentText || primaryPhrase}: понятный путь к запуску`;
}
if (input.mode === "expert") {
return `${currentText || primaryPhrase}: системный подход`;
}
return currentText.includes(primaryPhrase) ? currentText : `${currentText || primaryPhrase}: ${primaryPhrase}`;
}
return currentText;
}
function buildMetaDescription(input: {
currentText: string;
mode: VisualComposerVariantMode;
phrases: string[];
}) {
const currentText = normalizeSpaces(input.currentText);
const phraseText = getPhraseText(input.phrases, input.mode === "seo_dense" ? 3 : 2);
if (!phraseText) {
return currentText;
}
if (input.mode === "commercial") {
return `${currentText} Подберите подход: ${phraseText}, понятные этапы и контроль результата.`;
}
if (input.mode === "expert") {
return `${currentText} Разбираем ${phraseText}: контекст, ограничения, внедрение и качество результата.`;
}
if (input.mode === "concise") {
return `${currentText} Коротко о главном: ${phraseText}.`;
}
if (input.mode === "seo_dense") {
return `${currentText} Дополняем страницу темами ${phraseText} без переспама и потери смысла.`;
}
return `${currentText} Уточняем ключевые смыслы: ${phraseText}.`;
}
function buildBodyText(input: {
currentText: string;
mode: VisualComposerVariantMode;
phrases: string[];
}) {
const currentText = input.currentText.trim();
const sentence = buildPhraseSentence(input.mode, input.phrases);
if (!sentence) {
if (!currentText) {
return currentText;
}
if (input.mode === "commercial") {
return `${currentText}\n\nУточняем оффер, пользу для пользователя и следующий шаг.`;
}
if (input.mode === "expert") {
return `${currentText}\n\обавляем контекст применения, ограничения и критерии качества результата.`;
}
if (input.mode === "seo_dense") {
return `${currentText}\n\nРаскрываем дополнительные сценарии, термины и смысловые связи без переспама.`;
}
if (input.mode === "balanced") {
return `${currentText}\n\nУсиливаем смысл блока: что получает пользователь, где это применяется и почему это важно.`;
}
return currentText;
}
if (!currentText) {
return sentence;
}
return `${currentText}\n\n${sentence}`;
}
function buildVariantText(input: {
blueprint: VariantBlueprint;
currentText: string;
field: RewriteDiffSlot["field"];
phrases: string[];
}) {
const missingPhrases = getMissingPhrases(input.currentText, input.phrases).slice(0, input.blueprint.phraseLimit);
const phraseScope = missingPhrases.length > 0 ? missingPhrases : input.phrases.slice(0, Math.min(1, input.blueprint.phraseLimit));
const rawText =
input.field === "title" || input.field === "h1"
? buildTitleText({
currentText: input.currentText,
field: input.field,
mode: input.blueprint.id,
phrases: phraseScope
})
: input.field === "meta_description"
? buildMetaDescription({
currentText: input.currentText,
mode: input.blueprint.id,
phrases: phraseScope
})
: buildBodyText({
currentText: input.currentText,
mode: input.blueprint.id,
phrases: phraseScope
});
const fieldLimit = getFieldLimit(input.field, input.currentText.length);
return input.field === "body_section" ? trimBodyText(rawText, fieldLimit) : trimSentence(rawText, fieldLimit);
}
function getSlotRiskNotes(input: {
afterText: string;
beforeText: string;
field: RewriteDiffSlot["field"];
includedPhraseCount: number;
totalPhraseCount: number;
}) {
const notes: string[] = [];
const delta = input.afterText.length - input.beforeText.length;
if (input.field === "title" && input.afterText.length > 72) {
notes.push("Title длиннее безопасного preview-лимита.");
}
if (input.field === "meta_description" && input.afterText.length > 170) {
notes.push("Meta description длиннее рекомендованного preview-лимита.");
}
if (delta > Math.max(240, input.beforeText.length * 0.35)) {
notes.push("Большой рост текста: нужен ручной UX-review.");
}
if (input.totalPhraseCount > 0 && input.includedPhraseCount === 0) {
notes.push("Фразы не вошли в текст: вариант требует ручной проверки.");
}
return notes;
}
function getSlotTitle(slot: RewriteDiffSlot) {
if (slot.semanticBlock) {
return slot.semanticBlock.title;
}
if (slot.field === "title") {
return "Title";
}
if (slot.field === "meta_description") {
return "Meta description";
}
if (slot.field === "h1") {
return "H1";
}
return slot.workspaceSectionId ?? "Body section";
}
function getVisualComposerSemanticGroupSource(source: NonNullable<RewriteDiffSlot["semanticBlock"]>["source"]): VisualComposerSemanticGroup["source"] {
if (source === "source_object" || source === "visual_section" || source === "workspace_section") {
return source;
}
return "workspace_section";
}
function getRewriteSemanticGroupId(slot: RewriteDiffSlot) {
return slot.semanticBlock ? `semantic-block:${slot.semanticBlock.id}` : null;
}
function getRewriteSemanticGroupTexts(
readySlots: Array<{ slot: RewriteDiffSlot & { currentValue: string; workspaceSectionId: string }; target: RewriteDiffContract["targets"][number] }>
) {
const groupTextEntries = new Map<string, string[]>();
for (const item of readySlots) {
const groupId = getRewriteSemanticGroupId(item.slot);
if (!groupId) {
continue;
}
const fieldLabel = getSlotTitle(item.slot);
const value = normalizeSpaces(item.slot.currentValue);
groupTextEntries.set(groupId, [...(groupTextEntries.get(groupId) ?? []), `${fieldLabel}: ${value}`]);
}
return new Map(Array.from(groupTextEntries.entries()).map(([groupId, entries]) => [groupId, entries.join("\n")]));
}
function getRewriteSlotSeeds(diff: RewriteDiffContract): VisualComposerSlotSeed[] {
const readySlots = diff.targets.flatMap((target) =>
target.slots
.filter((slot): slot is RewriteDiffSlot & { currentValue: string; workspaceSectionId: string } =>
Boolean(slot.currentValue && slot.workspaceSectionId && slot.status === "ready_for_model_review")
)
.map((slot) => ({ slot, target }))
);
const semanticRewriteGroupById = new Map(diff.semanticRewriteGroups.map((group) => [group.id, group]));
const groupTexts = getRewriteSemanticGroupTexts(readySlots);
const groupTotals = readySlots.reduce<Map<string, number>>((accumulator, item) => {
const groupId = getRewriteSemanticGroupId(item.slot);
if (groupId) {
accumulator.set(groupId, (accumulator.get(groupId) ?? 0) + 1);
}
return accumulator;
}, new Map());
const groupOrders = new Map<string, number>();
return readySlots.map(({ slot, target }) => {
const groupId = getRewriteSemanticGroupId(slot);
const groupOrder = groupId ? groupOrders.get(groupId) ?? 0 : 0;
if (groupId) {
groupOrders.set(groupId, groupOrder + 1);
}
return {
currentValue: slot.currentValue,
field: slot.field,
id: slot.id,
instruction: slot.instruction,
keywordBindings: slot.keywordBindings,
pageContextText: null,
pageId: target.pageId,
pageTitle: target.pageTitle,
semanticGroup: slot.semanticBlock && groupId
? {
confidence: 0.96,
evidence: [
"persisted_semantic_block",
`semantic_block_source:${slot.semanticBlock.source}`,
...slot.semanticBlock.targetIds.slice(0, 8).map((targetId) => `target:${targetId}`)
],
fieldCount: groupTotals.get(groupId) ?? 1,
fieldOrder: groupOrder,
fieldRole: slot.field,
groupText: semanticRewriteGroupById.get(groupId)?.groupText ?? groupTexts.get(groupId) ?? slot.currentValue,
id: groupId,
source: getVisualComposerSemanticGroupSource(slot.semanticBlock.source),
title: slot.semanticBlock.title
}
: null,
sourcePath: target.sourcePath,
title: getSlotTitle(slot),
urlPath: target.urlPath,
workspaceSectionId: slot.workspaceSectionId
};
});
}
function getPatchChangeField(change: PatchArtifactChange): RewriteDiffSlot["field"] {
if (change.workspaceSectionId === "seo-title") {
return "title";
}
if (change.workspaceSectionId === "seo-description") {
return "meta_description";
}
if (change.workspaceSectionId === "seo-h1") {
return "h1";
}
return "body_section";
}
function getFallbackPhraseFromPatchChange(change: PatchArtifactChange) {
const firstTitleLine = change.title
.split("\n")
.map((line) => normalizeSpaces(line))
.find((line) => line.length > 0);
const sourceLabel = change.sourceHint && !/^<.+>$/.test(change.sourceHint) ? normalizeSpaces(change.sourceHint) : "";
const phrase = firstTitleLine || sourceLabel || change.urlPath || change.sourcePath;
return trimSentence(phrase, 72);
}
function getPatchFallbackBindings(change: PatchArtifactChange): RewriteDiffSlot["keywordBindings"] {
const phrase = getFallbackPhraseFromPatchChange(change);
if (!phrase) {
return [];
}
return [
{
decisionId: `patch-fallback:${change.id}`,
exactLimit: null,
frequency: null,
frequencyGroup: null,
phrase,
role: "patch_fallback"
}
];
}
function getPatchSlotSeeds(changes: PatchArtifactChange[] | undefined): VisualComposerSlotSeed[] {
return (changes ?? [])
.filter((change) => change.afterValue && change.workspaceSectionId)
.map((change) => ({
currentValue: change.afterValue,
field: getPatchChangeField(change),
id: `patch:${change.id}`,
instruction:
"Fallback seed из текущего patch artifact: сгенерировать безопасный вариант вокруг saved working draft, не меняя source tree.",
keywordBindings: getPatchFallbackBindings(change),
pageContextText: null,
pageId: change.pageId,
pageTitle: change.title,
semanticGroup: null,
sourcePath: change.sourcePath,
title: change.title,
urlPath: change.urlPath,
workspaceSectionId: change.workspaceSectionId
}));
}
function getInputSlotSeeds(inputs: VisualComposerSeedInput[] | undefined): VisualComposerSlotSeed[] {
return (inputs ?? [])
.filter((input) => input.currentValue && input.pageId && input.workspaceSectionId)
.map((input) => {
const phraseInputs =
input.keywordPhrases && input.keywordPhrases.length > 0
? input.keywordPhrases
: [input.semanticGroup?.title].filter((phrase): phrase is string => Boolean(phrase));
const keywordBindings: RewriteDiffSlot["keywordBindings"] = phraseInputs
.map((phrase) => normalizeSpaces(phrase))
.filter(Boolean)
.map((phrase, index) => ({
decisionId: `content-seed:${input.id}:${index}`,
exactLimit: null,
frequency: null,
frequencyGroup: null,
phrase,
role: "content_seed"
}));
return {
currentValue: input.currentValue,
field: input.field ?? "body_section",
id: `content:${input.id}`,
instruction:
input.instruction ??
"Content seed из visual composer: предложить безопасный вариант текста без изменения source tree.",
keywordBindings,
pageContextText: input.pageContextText ?? null,
pageId: input.pageId,
pageTitle: input.pageTitle ?? null,
semanticGroup: input.semanticGroup ?? null,
sourcePath: input.sourcePath ?? null,
title: input.title,
urlPath: input.urlPath ?? null,
workspaceSectionId: input.workspaceSectionId
};
});
}
function buildVariant(blueprint: VariantBlueprint, seeds: VisualComposerSlotSeed[]): VisualComposerVariant {
const slots = seeds.map((seed) => {
const phrases = uniquePhrases(seed.keywordBindings);
const afterText = buildVariantText({
blueprint,
currentText: seed.currentValue,
field: seed.field,
phrases
});
const includedPhraseCount = countIncludedPhrases(afterText, phrases);
return {
afterText,
beforeText: seed.currentValue,
field: seed.field,
id: `${blueprint.id}:${seed.id}`,
includedPhraseCount,
instruction: seed.instruction,
keywordPhrases: phrases,
pageContextText: seed.pageContextText,
pageId: seed.pageId,
pageTitle: seed.pageTitle,
riskNotes: getSlotRiskNotes({
afterText,
beforeText: seed.currentValue,
field: seed.field,
includedPhraseCount,
totalPhraseCount: phrases.length
}),
rewriteSlotId: seed.id,
semanticGroup: seed.semanticGroup,
sourcePath: seed.sourcePath,
title: seed.title,
totalPhraseCount: phrases.length,
urlPath: seed.urlPath,
workspaceSectionId: seed.workspaceSectionId
} satisfies VisualComposerVariantSlot;
});
const metrics = slots.reduce(
(summary, slot) => {
summary.changedSlotCount += normalizeSpaces(slot.afterText) === normalizeSpaces(slot.beforeText) ? 0 : 1;
summary.includedPhraseCount += slot.includedPhraseCount;
summary.totalDelta += slot.afterText.length - slot.beforeText.length;
summary.totalPhraseCount += slot.totalPhraseCount;
return summary;
},
{
changedSlotCount: 0,
includedPhraseCount: 0,
totalDelta: 0,
totalPhraseCount: 0
}
);
return {
id: blueprint.id,
label: blueprint.label,
metrics,
mode: blueprint.id,
slots,
summary: blueprint.summary
};
}
function buildNextActions(contract: Omit<VisualComposerVariantsContract, "nextActions">) {
if (contract.blockers.length > 0) {
return contract.blockers;
}
return [
"Выбрать вариант в visual composer и проверить живой preview.",
"После ручной правки сохранить working draft, затем rebuild patch artifact и dry-run.",
"Provider-backed генерацию подключать поверх этого же visual-composer-variants.v1 contract."
];
}
export async function generateVisualComposerVariants(
projectId: string,
inputSeeds: VisualComposerSeedInput[] = []
): Promise<VisualComposerVariantsContract> {
const [diff, patchArtifact] = await Promise.all([getRewriteDiffContract(projectId), getLatestPatchArtifact(projectId)]);
const contentSeeds = getInputSlotSeeds(inputSeeds);
const rewriteSeeds = getRewriteSlotSeeds(diff);
const patchSeeds = contentSeeds.length === 0 && rewriteSeeds.length === 0 ? getPatchSlotSeeds(patchArtifact?.changes) : [];
const seeds = contentSeeds.length > 0 ? contentSeeds : rewriteSeeds.length > 0 ? rewriteSeeds : patchSeeds;
const readySlotCount = seeds.length;
const semanticBlockCount = new Set(
seeds.map((seed) => seed.semanticGroup?.id).filter((id): id is string => Boolean(id))
).size;
const semanticGroupedSlotCount = seeds.filter((seed) => Boolean(seed.semanticGroup)).length;
const slotCount =
contentSeeds.length > 0
? contentSeeds.length
: diff.readiness.slotCount > 0
? diff.readiness.slotCount
: (patchArtifact?.readiness.changeCount ?? 0);
const targetCount =
contentSeeds.length > 0
? new Set(contentSeeds.map((seed) => seed.pageId)).size
: diff.readiness.targetCount > 0
? diff.readiness.targetCount
: new Set(seeds.map((seed) => seed.pageId)).size;
const blockers = contentSeeds.length > 0 ? [] : [...diff.blockers];
if (readySlotCount === 0) {
blockers.push("Нет ready slots для visual composer generation.");
}
const variants = blockers.length === 0 ? VARIANT_BLUEPRINTS.map((blueprint) => buildVariant(blueprint, seeds)) : [];
const contractWithoutActions: Omit<VisualComposerVariantsContract, "nextActions"> = {
schemaVersion: "visual-composer-variants.v1",
blockers,
generatedAt: new Date().toISOString(),
generator: {
id: "local_deterministic_v0",
modelBacked: false,
notes: [
"Генератор не вызывает внешний LLM.",
"Варианты являются безопасными draft-предложениями и требуют ручного UX/SEO review.",
contentSeeds.length > 0
? "Источник seed-ов: активные content-поля visual composer."
: rewriteSeeds.length === 0
? "Источник seed-ов: latest patch artifact changes."
: "Источник seed-ов: rewrite diff ready slots.",
"Source tree не изменяется; применение идёт только через working draft и отдельный patch-run."
]
},
projectId,
readiness: {
blockerCount: blockers.length,
generatedSlotCount: variants.reduce((sum, variant) => sum + variant.slots.length, 0),
readySlotCount,
semanticBlockCount,
semanticGroupedSlotCount,
slotCount,
slotLevelFallbackCount: Math.max(0, slotCount - semanticGroupedSlotCount),
targetCount,
variantCount: variants.length
},
state: blockers.length > 0 ? "blocked" : readySlotCount < slotCount ? "partial_ready" : "ready",
variants
};
return {
...contractWithoutActions,
nextActions: buildNextActions(contractWithoutActions)
};
}

View File

@ -38,25 +38,51 @@ type PageVersionRow = {
};
type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
type PagePreviewKind = "page" | "home_section" | "raw_template" | "none";
type PagePreviewKind = "page" | "source_section" | "raw_template" | "none";
type HomeBlockStructure = {
type EditableTextField = {
group: string | null;
id: string;
kind: string;
label: string;
order: number;
path: string;
renderAs: string | null;
role: string;
value: string;
};
type SemanticBlockRef = {
confidence: number;
fieldCount: number;
id: string;
reasons: string[];
source: "source_object" | "visual_section";
title: string;
};
type SourceModelSection = {
adminLabel: string | null;
anchor: string | null;
contentSourcePath: string;
editableFields: EditableTextField[];
enabled: boolean;
id: string | null;
modelSourcePath: string;
order: number;
outputPath: string;
previewTargetIndex: number | null;
previewTargetSelector: string | null;
regionId: string | null;
regionLabel: string | null;
sourcePath: string;
template: string;
};
type HomeStructure = {
sections: HomeBlockStructure[];
outputPath: string;
byTemplate: Map<string, HomeBlockStructure[]>;
type ProjectSourceStructure = {
sections: SourceModelSection[];
byTemplate: Map<string, SourceModelSection[]>;
modelSourcePaths: string[];
};
type MediaVersionRow = {
@ -195,6 +221,7 @@ export type ProjectScanSummary = {
pageId: string | null;
selected: boolean;
title: string;
contentSourcePath: string | null;
sourcePath: string;
template: string;
enabled: boolean;
@ -206,6 +233,8 @@ export type ProjectScanSummary = {
previewTargetIndex: number | null;
previewKind: PagePreviewKind;
scopeReason: string;
semanticBlock: SemanticBlockRef | null;
editableFields: EditableTextField[];
headings: Array<{ level: number; text: string }>;
}>;
assets: Array<{
@ -269,6 +298,197 @@ function getRecordBoolean(record: Record<string, unknown>, key: string, fallback
return typeof value === "boolean" ? value : fallback;
}
function normalizeFieldText(value: string) {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.split(/\n+/)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join("\n")
.trim();
}
function getPathValue(record: Record<string, unknown>, rawPath: string) {
return rawPath.split(".").reduce<unknown>((current, part) => {
if (current == null) {
return null;
}
if (Array.isArray(current)) {
const index = Number.parseInt(part, 10);
return Number.isInteger(index) ? current[index] : null;
}
if (typeof current === "object") {
return (current as Record<string, unknown>)[part];
}
return null;
}, record);
}
function isTextEditableField(field: Record<string, unknown>) {
const kind = getRecordString(field, "kind");
const renderAs = getRecordString(field, "renderAs");
return (
kind === "text" ||
kind === "textarea" ||
kind === "html" ||
renderAs === "text" ||
renderAs === "html"
);
}
function getTextFieldValue(source: Record<string, unknown>, pathValue: string) {
const value = getPathValue(source, pathValue);
if (typeof value === "string") {
return normalizeFieldText(value);
}
return null;
}
function makeEditableFieldId(pathValue: string) {
return `field:${pathValue}`.replace(/[^a-zA-Z0-9:._-]/g, "_");
}
function getEditableFieldRole(input: { kind: string | null; label: string; path: string; renderAs: string | null }) {
const value = `${input.label} ${input.path} ${input.kind ?? ""} ${input.renderAs ?? ""}`.toLocaleLowerCase("ru-RU");
if (/question|вопрос/.test(value)) return "question";
if (/answer|ответ/.test(value)) return "answer";
if (/cta|button|btn|submit|action|кноп|заяв|инвайт/.test(value)) return "cta";
if (/eyebrow|kicker|muted|label|badge|надзаг|приглуш/.test(value)) return "eyebrow";
if (/heading|headline|title|h1|h2|main|заголов|назван/.test(value)) return "heading";
if (/intro|lead|description|summary|subtitle|подзаг|описан|ввод/.test(value)) return "intro";
if (/body|text|content|html|paragraph|текст|контент/.test(value)) return "body";
return "text";
}
function buildEditableField(input: {
field: Record<string, unknown>;
label: string;
pathValue: string;
source: Record<string, unknown>;
}): EditableTextField | null {
if (!isTextEditableField(input.field)) {
return null;
}
const value = getTextFieldValue(input.source, input.pathValue);
if (!value) {
return null;
}
return {
group: getRecordString(input.field, "group"),
id: makeEditableFieldId(input.pathValue),
kind: getRecordString(input.field, "kind") ?? "text",
label: input.label,
order: 0,
path: input.pathValue,
renderAs: getRecordString(input.field, "renderAs"),
role: getEditableFieldRole({
kind: getRecordString(input.field, "kind"),
label: input.label,
path: input.pathValue,
renderAs: getRecordString(input.field, "renderAs")
}),
value
};
}
function extractSourceModelEditableFields(block: Record<string, unknown>) {
const fields: EditableTextField[] = [];
for (const fieldValue of asArray(block.editableFields)) {
const field = asRecord(fieldValue);
if (!field) {
continue;
}
const pathValue = getRecordString(field, "path");
const label = getRecordString(field, "label") ?? pathValue;
if (!pathValue || !label) {
continue;
}
if (getRecordString(field, "kind") === "collection") {
const collection = getPathValue(block, pathValue);
if (!Array.isArray(collection)) {
continue;
}
const itemFields = asArray(field.itemFields)
.map(asRecord)
.filter((itemField): itemField is Record<string, unknown> => Boolean(itemField));
collection.forEach((itemValue, itemIndex) => {
const item = asRecord(itemValue);
if (!item) {
return;
}
for (const itemField of itemFields) {
const itemPath = getRecordString(itemField, "path");
const itemLabel = getRecordString(itemField, "label") ?? itemPath;
if (!itemPath || !itemLabel) {
continue;
}
const editableField = buildEditableField({
field: itemField,
label: `${label}: ${itemIndex + 1}. ${itemLabel}`,
pathValue: `${pathValue}.${itemIndex}.${itemPath}`,
source: block
});
if (editableField) {
fields.push({
...editableField,
group: getRecordString(field, "group") ?? editableField.group
});
}
}
});
continue;
}
const editableField = buildEditableField({
field,
label,
pathValue,
source: block
});
if (editableField) {
fields.push(editableField);
}
}
return fields.map((field, index) => ({
...field,
order: index
}));
}
function stripRootName(relativePath: string, rootName: string | null) {
const normalizedPath = normalizePath(relativePath);
const normalizedRoot = rootName ? normalizePath(rootName) : null;
@ -337,39 +557,190 @@ function classifyScannedPageScope(page: {
};
}
const HOME_TEMPLATE_SELECTORS: Record<string, string> = {
"central-text-block.html": ".central-text-block",
"demo-video-folder.html": ".folder-w.video-w",
"faq.html": "section.s:has(#faq-item)",
"footer.html": "footer",
"pricing-intro.html": "#pricing",
"pricing-table.html": "#pricing-wrap",
"project-composition.html": ".project-composition",
"project-video-detail.html": ".project-video-detail",
"project-video-first.html": "section.mobile-flip",
"publish-cta.html": "section.s:has(.form-btn.static.mt-10)",
"request-demo-txt-files.html": "#waitlist",
"screenshot-cards.html": ".component-lib",
"theses-request-demo.html": "#hero"
};
function normalizeTemplateName(value: string) {
return path.posix.basename(normalizePath(value)).toLocaleLowerCase("ru-RU");
}
function selectorFromAnchor(anchor: string | null) {
if (!anchor || !/^[a-zA-Z][\w:-]*$/.test(anchor)) {
function templateStem(value: string) {
return path.posix.basename(normalizePath(value), path.posix.extname(value)).trim();
}
function getFirstRecordString(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = getRecordString(record, key);
if (value) {
return value;
}
}
return null;
}
return `#${anchor}`;
function isSafeCssIdentifier(value: string | null) {
return Boolean(value && /^[a-zA-Z][\w:-]*$/.test(value));
}
function selectorForHomeBlock(block: HomeBlockStructure) {
return block.previewTargetSelector ?? HOME_TEMPLATE_SELECTORS[normalizeTemplateName(block.template)] ?? selectorFromAnchor(block.anchor);
function selectorFromAnchor(anchor: string | null) {
return isSafeCssIdentifier(anchor) ? `#${anchor}` : null;
}
function parseHomeStructure(rawText: string | null): HomeStructure | null {
function selectorFromTemplate(template: string | null) {
const stem = template ? templateStem(template) : null;
return isSafeCssIdentifier(stem) ? `.${stem}` : null;
}
function getBlockSelector(block: Record<string, unknown>) {
return getFirstRecordString(block, [
"previewTargetSelector",
"targetSelector",
"selector",
"cssSelector",
"previewSelector"
]);
}
function getBlockTemplate(block: Record<string, unknown>) {
return getFirstRecordString(block, ["template", "component", "partial", "view", "type"]);
}
function getBlockAnchor(block: Record<string, unknown>) {
return getFirstRecordString(block, ["anchor", "anchorId", "targetId", "htmlId"]);
}
function selectorForSourceModelSection(section: SourceModelSection) {
return section.previewTargetSelector ?? selectorFromAnchor(section.anchor) ?? selectorFromTemplate(section.template);
}
function getSourceFileProjectPath(file: SourceFile) {
return normalizePath(file.projectPath || file.sourcePath);
}
function isSourceHtmlCandidate(file: SourceFile) {
const extension = getExtension(file.sourcePath || file.projectPath);
return (extension === ".html" || extension === ".htm") && file.size <= 5_000_000;
}
function extractHtmlIds(rawText: string) {
const ids = new Set<string>();
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi;
let match: RegExpExecArray | null = null;
while ((match = idPattern.exec(rawText))) {
const value = match[1]?.trim();
if (value) {
ids.add(value);
}
}
return ids;
}
function getScopedAnchorSelector(section: SourceModelSection, htmlIds: Set<string> | null | undefined) {
if (!htmlIds || !section.id || !section.anchor || !isSafeCssIdentifier(section.id) || !isSafeCssIdentifier(section.anchor)) {
return null;
}
const scopedAnchorId = `${section.id}-${section.anchor}`;
return htmlIds.has(scopedAnchorId) ? `#${scopedAnchorId}` : null;
}
function rebuildProjectSourceStructure(sections: SourceModelSection[], modelSourcePaths: string[]): ProjectSourceStructure {
const byTemplate = new Map<string, SourceModelSection[]>();
for (const section of sections) {
const normalizedTemplate = normalizeTemplateName(section.template);
byTemplate.set(normalizedTemplate, [...(byTemplate.get(normalizedTemplate) ?? []), section]);
}
return {
sections,
byTemplate,
modelSourcePaths
};
}
function hasSourceModelBlockSignal(block: Record<string, unknown>) {
return Boolean(
getBlockTemplate(block) ||
getBlockSelector(block) ||
getBlockAnchor(block) ||
asArray(block.editableFields).length > 0 ||
asRecord(block.content) ||
asRecord(block.fields)
);
}
function getSourceModelBlockGroups(root: Record<string, unknown>) {
const groups: Array<{
blocks: unknown[];
regionId: string | null;
regionLabel: string | null;
}> = [];
for (const regionValue of asArray(root.regions)) {
const region = asRecord(regionValue);
if (!region) {
continue;
}
const blocks = asArray(region.blocks);
if (blocks.length > 0) {
groups.push({
blocks,
regionId: getRecordString(region, "id"),
regionLabel: getRecordString(region, "label") ?? getRecordString(region, "adminLabel") ?? getRecordString(region, "title")
});
}
}
const rootBlocks = asArray(root.blocks);
if (rootBlocks.length > 0) {
groups.push({
blocks: rootBlocks,
regionId: getRecordString(root, "id"),
regionLabel: getRecordString(root, "label") ?? getRecordString(root, "title")
});
}
const rootSections = asArray(root.sections);
if (rootSections.length > 0) {
groups.push({
blocks: rootSections,
regionId: getRecordString(root, "id"),
regionLabel: getRecordString(root, "label") ?? getRecordString(root, "title")
});
}
return groups;
}
function sourcePathForTemplate(template: string, modelSourcePath: string) {
const normalizedTemplate = normalizePath(template);
if (/\.[a-z0-9]+$/i.test(normalizedTemplate)) {
if (normalizedTemplate.includes("/")) {
return normalizedTemplate;
}
return normalizePath(path.posix.join(path.posix.dirname(modelSourcePath), normalizedTemplate));
}
return normalizePath(path.posix.join(path.posix.dirname(modelSourcePath), `${normalizedTemplate}.html`));
}
export function parseProjectSourceStructure(input: { rawText: string | null; sourcePath: string }): ProjectSourceStructure | null {
const rawText = input.rawText;
if (!rawText) {
return null;
}
@ -388,50 +759,54 @@ function parseHomeStructure(rawText: string | null): HomeStructure | null {
return null;
}
const outputPath = normalizePath(getRecordString(root, "output") ?? "index.html");
const byTemplate = new Map<string, HomeBlockStructure[]>();
const sections: HomeBlockStructure[] = [];
const blockGroups = getSourceModelBlockGroups(root);
if (blockGroups.length === 0) {
return null;
}
const outputPath = normalizePath(
getFirstRecordString(root, ["output", "outputPath", "page", "urlPath", "route"]) ?? "index.html"
);
const byTemplate = new Map<string, SourceModelSection[]>();
const sections: SourceModelSection[] = [];
const enabledSelectorCounts = new Map<string, number>();
let order = 0;
for (const regionValue of asArray(root.regions)) {
const region = asRecord(regionValue);
if (!region) {
continue;
}
const regionId = getRecordString(region, "id");
const regionLabel = getRecordString(region, "label") ?? getRecordString(region, "adminLabel");
for (const blockValue of asArray(region.blocks)) {
for (const group of blockGroups) {
for (const blockValue of group.blocks) {
const block = asRecord(blockValue);
if (!block) {
continue;
}
const template = getRecordString(block, "template");
if (!template) {
if (!hasSourceModelBlockSignal(block)) {
continue;
}
const template = getBlockTemplate(block) ?? getFirstRecordString(block, ["id", "key", "slug", "name"]) ?? `section-${order + 1}`;
const normalizedTemplate = normalizeTemplateName(template);
const enabled = getRecordBoolean(block, "enabled", true);
const anchor = getRecordString(block, "anchor");
const selectorSeed = HOME_TEMPLATE_SELECTORS[normalizedTemplate] ?? selectorFromAnchor(anchor);
const anchor = getBlockAnchor(block);
const selectorSeed = getBlockSelector(block) ?? selectorFromAnchor(anchor) ?? selectorFromTemplate(template);
const previewTargetIndex = enabled && selectorSeed ? enabledSelectorCounts.get(selectorSeed) ?? 0 : null;
const item: HomeBlockStructure = {
adminLabel: getRecordString(block, "adminLabel"),
const sourcePath = sourcePathForTemplate(template, input.sourcePath);
const item: SourceModelSection = {
adminLabel: getFirstRecordString(block, ["adminLabel", "label", "title", "name"]),
anchor,
contentSourcePath: input.sourcePath,
editableFields: extractSourceModelEditableFields(block),
enabled,
id: getRecordString(block, "id"),
id: getFirstRecordString(block, ["id", "key", "slug", "name"]),
modelSourcePath: input.sourcePath,
order,
outputPath,
previewTargetIndex,
previewTargetSelector: selectorSeed,
regionId,
regionLabel,
regionId: group.regionId,
regionLabel: group.regionLabel,
sourcePath,
template
};
const templateBlocks = byTemplate.get(normalizedTemplate) ?? [];
@ -446,14 +821,18 @@ function parseHomeStructure(rawText: string | null): HomeStructure | null {
}
}
if (sections.length === 0) {
return null;
}
return {
sections,
outputPath,
byTemplate
byTemplate,
modelSourcePaths: [input.sourcePath]
};
}
function summarizeTemplateLabel(blocks: HomeBlockStructure[]) {
function summarizeSourceModelLabel(blocks: SourceModelSection[]) {
if (blocks.length === 0) {
return null;
}
@ -473,15 +852,15 @@ function summarizeTemplateLabel(blocks: HomeBlockStructure[]) {
return `${baseLabel} + ${blocks.length - 1}`;
}
function getTemplatePreviewMeta(sourcePath: string, homeStructure: HomeStructure | null) {
function getSourceModelPreviewMeta(sourcePath: string, projectStructure: ProjectSourceStructure | null) {
const normalizedTemplate = normalizeTemplateName(sourcePath);
const blocks = homeStructure?.byTemplate.get(normalizedTemplate) ?? [];
const blocks = projectStructure?.byTemplate.get(normalizedTemplate) ?? [];
const enabledBlocks = blocks.filter((block) => block.enabled);
const primaryBlock = enabledBlocks[0] ?? blocks[0] ?? null;
const structureLabel = summarizeTemplateLabel(blocks);
const structureLabel = summarizeSourceModelLabel(blocks);
const structureOrder = blocks.length > 0 ? Math.min(...blocks.map((block) => block.order)) : null;
if (!homeStructure || !primaryBlock) {
if (!projectStructure || !primaryBlock) {
return {
previewKind: "raw_template" as const,
previewSourcePath: null,
@ -504,18 +883,18 @@ function getTemplatePreviewMeta(sourcePath: string, homeStructure: HomeStructure
}
return {
previewKind: "home_section" as const,
previewSourcePath: homeStructure.outputPath,
previewTargetSelector: selectorForHomeBlock(primaryBlock),
previewKind: "source_section" as const,
previewSourcePath: primaryBlock.outputPath,
previewTargetSelector: selectorForSourceModelSection(primaryBlock),
structureLabel,
structureOrder,
visibleInSite: true
};
}
function getPagePreviewMeta(row: PageVersionRow, pageScope: { scope: PageScope }, homeStructure: HomeStructure | null) {
function getPagePreviewMeta(row: PageVersionRow, pageScope: { scope: PageScope }, projectStructure: ProjectSourceStructure | null) {
if (pageScope.scope === "template_block") {
return getTemplatePreviewMeta(row.source_path, homeStructure);
return getSourceModelPreviewMeta(row.source_path, projectStructure);
}
return {
@ -528,41 +907,75 @@ function getPagePreviewMeta(row: PageVersionRow, pageScope: { scope: PageScope }
};
}
function sourcePathForHomeTemplate(template: string) {
return `templates/pages/home/blocks/${normalizeTemplateName(template)}`;
function buildSemanticBlockRef(section: SourceModelSection, input: { hasPreview: boolean; title: string }): SemanticBlockRef | null {
if (section.editableFields.length === 0) {
return null;
}
function buildSiteSections(homeStructure: HomeStructure | null, pages: ProjectScanSummary["pages"]) {
if (!homeStructure) {
const stableId = section.id ?? `${normalizeTemplateName(section.template)}:${section.order}`;
const reasons = ["same_source_object", "source_field_order"];
if (input.hasPreview) {
reasons.push("same_rendered_section");
}
if (section.regionId || section.regionLabel) {
reasons.push("same_declared_region");
}
return {
confidence: input.hasPreview ? 0.86 : 0.72,
fieldCount: section.editableFields.length,
id: `semantic-block:${stableId}`,
reasons,
source: input.hasPreview ? "visual_section" : "source_object",
title: input.title
};
}
function buildSiteSections(projectStructure: ProjectSourceStructure | null, pages: ProjectScanSummary["pages"]) {
if (!projectStructure) {
return [];
}
const pagesBySourcePath = new Map(pages.map((page) => [normalizePath(page.sourcePath).toLocaleLowerCase("ru-RU"), page]));
const pagesByTemplateName = new Map<string, ProjectScanSummary["pages"][number]>();
return homeStructure.sections.map((section) => {
const sourcePath = sourcePathForHomeTemplate(section.template);
const page = pagesBySourcePath.get(sourcePath.toLocaleLowerCase("ru-RU")) ?? null;
pages.forEach((page) => {
pagesByTemplateName.set(normalizeTemplateName(page.sourcePath), page);
});
return projectStructure.sections.map((section) => {
const sourcePath = section.sourcePath;
const page =
pagesBySourcePath.get(sourcePath.toLocaleLowerCase("ru-RU")) ??
pagesByTemplateName.get(normalizeTemplateName(sourcePath)) ??
null;
const title = section.adminLabel ?? section.id ?? section.template;
const hasPreview = section.enabled && Boolean(section.previewTargetSelector);
const semanticBlock = buildSemanticBlockRef(section, { hasPreview, title });
return {
sectionId: section.id ?? `home-section-${section.order}`,
sectionId: section.id ?? `source-section-${section.order}`,
pageId: page?.pageId ?? null,
selected: page?.selected ?? false,
title,
contentSourcePath: section.contentSourcePath,
sourcePath,
template: section.template,
enabled: section.enabled,
order: section.order,
regionId: section.regionId,
regionLabel: section.regionLabel,
previewSourcePath: hasPreview ? homeStructure.outputPath : null,
previewSourcePath: hasPreview ? section.outputPath : null,
previewTargetSelector: hasPreview ? section.previewTargetSelector : null,
previewTargetIndex: hasPreview ? section.previewTargetIndex : null,
previewKind: hasPreview ? ("home_section" as const) : ("none" as const),
previewKind: hasPreview ? ("source_section" as const) : ("none" as const),
scopeReason: section.enabled
? "Секция собранной главной страницы из content/pages/home.json."
? `Секция из source model ${section.modelSourcePath}.`
: "Секция есть в модели страницы, но сейчас отключена и не рендерится.",
semanticBlock,
editableFields: section.editableFields,
headings: page?.headings ?? []
};
});
@ -951,7 +1364,7 @@ async function listSourceFiles(source: ProjectSourceForScan) {
throw new ProjectScanError("Сканирование этого типа источника пока не поддерживается.");
}
async function readHtmlFile(file: SourceFile) {
async function readSourceFile(file: SourceFile) {
if (file.storageKey) {
return storageProvider.getObjectText(file.storageKey);
}
@ -960,54 +1373,139 @@ async function readHtmlFile(file: SourceFile) {
return fs.readFile(file.absolutePath, "utf8");
}
throw new ProjectScanError(`Не удалось прочитать HTML-файл ${file.sourcePath}.`);
throw new ProjectScanError(`Не удалось прочитать файл ${file.sourcePath}.`);
}
async function readSourceText(source: ProjectSourceForScan, requestedPath: string) {
const normalizedRequestedPath = normalizePath(requestedPath);
function isSourceModelJsonCandidate(file: SourceFile) {
const extension = getExtension(file.sourcePath);
const normalizedPath = normalizePath(file.projectPath || file.sourcePath).toLocaleLowerCase("ru-RU");
const filename = path.posix.basename(normalizedPath);
if (source.source_type === "local_folder") {
const rootPath = getStringConfig(source.source_config, "path");
if (extension !== ".json") {
return false;
}
if (!rootPath) {
if (file.size > 2_000_000) {
return false;
}
if (/^(package-lock|package|tsconfig|jsconfig|manifest|project-index)\.json$/i.test(filename)) {
return false;
}
return true;
}
function mergeProjectSourceStructures(structures: ProjectSourceStructure[]) {
const sections: SourceModelSection[] = [];
const byTemplate = new Map<string, SourceModelSection[]>();
const modelSourcePaths: string[] = [];
let order = 0;
for (const structure of structures) {
modelSourcePaths.push(...structure.modelSourcePaths);
for (const section of structure.sections) {
const normalizedSection = {
...section,
order
};
const normalizedTemplate = normalizeTemplateName(normalizedSection.template);
sections.push(normalizedSection);
byTemplate.set(normalizedTemplate, [...(byTemplate.get(normalizedTemplate) ?? []), normalizedSection]);
order += 1;
}
}
if (sections.length === 0) {
return null;
}
try {
return await fs.readFile(path.join(rootPath, normalizedRequestedPath), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {
sections,
byTemplate,
modelSourcePaths: Array.from(new Set(modelSourcePaths))
};
}
async function buildHtmlIdMap(files: SourceFile[]) {
const htmlIdsByPath = new Map<string, Set<string>>();
for (const file of files.filter(isSourceHtmlCandidate).slice(0, 100)) {
const rawText = await readSourceFile(file);
const filePath = getSourceFileProjectPath(file).toLocaleLowerCase("ru-RU");
htmlIdsByPath.set(filePath, extractHtmlIds(rawText));
}
return htmlIdsByPath;
}
function refineProjectSourceStructureSelectors(
structure: ProjectSourceStructure | null,
htmlIdsByPath: Map<string, Set<string>>
) {
if (!structure) {
return null;
}
throw error;
const sectionsWithScopedSelectors = structure.sections.map((section) => {
const outputPath = normalizePath(section.outputPath).toLocaleLowerCase("ru-RU");
const scopedSelector = getScopedAnchorSelector(section, htmlIdsByPath.get(outputPath));
if (!scopedSelector) {
return section;
}
return {
...section,
previewTargetIndex: 0,
previewTargetSelector: scopedSelector
};
});
const selectorCounts = new Map<string, number>();
const sections = sectionsWithScopedSelectors.map((section) => {
if (!section.enabled || !section.previewTargetSelector) {
return {
...section,
previewTargetIndex: null
};
}
const selectorKey = `${normalizePath(section.outputPath).toLocaleLowerCase("ru-RU")}\0${section.previewTargetSelector}`;
const previewTargetIndex = selectorCounts.get(selectorKey) ?? 0;
selectorCounts.set(selectorKey, previewTargetIndex + 1);
return {
...section,
previewTargetIndex
};
});
return rebuildProjectSourceStructure(sections, structure.modelSourcePaths);
}
async function discoverProjectSourceStructure(source: ProjectSourceForScan) {
const files = await listSourceFiles(source);
const structures: ProjectSourceStructure[] = [];
for (const file of files.filter(isSourceModelJsonCandidate).slice(0, 200)) {
const structure = parseProjectSourceStructure({
rawText: await readSourceFile(file),
sourcePath: normalizePath(file.projectPath || file.sourcePath)
});
if (structure && structure.sections.length > 0) {
structures.push(structure);
}
}
if (source.source_type === "browser_folder") {
const importPrefix = getStringConfig(source.source_config, "importPrefix");
const rootName = getStringConfig(source.source_config, "rootName");
const mergedStructure = mergeProjectSourceStructures(structures);
const htmlIdsByPath = await buildHtmlIdMap(files);
if (!importPrefix) {
return null;
}
const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`;
const candidates = [
normalizedRequestedPath,
rootName ? `${normalizePath(rootName)}/${normalizedRequestedPath}` : null
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
try {
return await storageProvider.getObjectText(`${normalizedPrefix}${candidate}`);
} catch {
// Browser-folder imports can either include or strip the top folder name.
}
}
}
return null;
return refineProjectSourceStructureSelectors(mergedStructure, htmlIdsByPath);
}
async function createRunningScan(source: ProjectSourceForScan) {
@ -1779,11 +2277,11 @@ async function getScanSummaryById(scanVersionId: string): Promise<ProjectScanSum
[scanVersionId]
);
const source = await getProjectSourceById(scan.source_id);
const homeStructure = source ? parseHomeStructure(await readSourceText(source, "content/pages/home.json")) : null;
const projectStructure = source ? await discoverProjectSourceStructure(source) : null;
const pages = pageResult.rows.map((row) => {
const pageScope = classifyScannedPageScope(row);
const previewMeta = getPagePreviewMeta(row, pageScope, homeStructure);
const previewMeta = getPagePreviewMeta(row, pageScope, projectStructure);
return {
pageId: row.page_id,
@ -1801,7 +2299,7 @@ async function getScanSummaryById(scanVersionId: string): Promise<ProjectScanSum
headings: normalizeHeadings(row.raw)
};
});
const siteSections = buildSiteSections(homeStructure, pages);
const siteSections = buildSiteSections(projectStructure, pages);
const assets = mediaResult.rows.map((row) => ({
mediaId: row.media_id,
sourcePath: row.source_path,
@ -1893,7 +2391,7 @@ export async function scanProject(projectId: string) {
for (const file of files) {
if (HTML_EXTENSIONS.has(getExtension(file.sourcePath))) {
pages.push(parseHtmlPage(file, await readHtmlFile(file)));
pages.push(parseHtmlPage(file, await readSourceFile(file)));
continue;
}

View File

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
function getNumericConstant(source: string, name: string) {
const match = new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`).exec(source);
return match ? Number(match[1]) : null;
}
const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8");
const yandexEvidenceSource = await readFile(new URL("../evidence/yandexEvidence.ts", import.meta.url), "utf8");
const projectRoutesSource = await readFile(new URL("../projects/routes.ts", import.meta.url), "utf8");
const appApiSource = await readFile(new URL("../../../app/src/api.ts", import.meta.url), "utf8");
const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8");
assert.ok(
(getNumericConstant(wordstatRepositorySource, "WORDSTAT_TOP_RESULTS_LIMIT") ?? 0) >= 100,
"Wordstat related/top result surface must stay wide enough for multi-intent keyword cleaning."
);
assert.ok(
wordstatRepositorySource.includes("WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT"),
"Wordstat top results must keep per-source balancing, not only global frequency sorting."
);
assert.ok(
(getNumericConstant(yandexEvidenceSource, "DEFAULT_PHRASE_LIMIT") ?? 0) >= 18,
"Yandex Evidence default phrase limit must not fall back to toy 5-phrase coverage."
);
assert.ok(
(getNumericConstant(yandexEvidenceSource, "MAX_PHRASE_LIMIT") ?? 0) >= 48 &&
!/Math\.min\(12,/.test(yandexEvidenceSource),
"Yandex Evidence runtime clamp must allow expanded multi-intent coverage."
);
assert.ok(
/\.max\(48\)/.test(projectRoutesSource),
"Yandex Evidence API must allow expanded phrase coverage beyond the old 12-phrase cap."
);
assert.ok(
!/runYandexEvidence\(project\.id,\s*5\)/.test(appSource) && !/phraseLimit\s*=\s*5/.test(appApiSource),
"Frontend must not launch the external evidence pass with the old 5-phrase limit."
);
assert.ok(
yandexEvidenceSource.includes("bucketKey") && yandexEvidenceSource.includes("candidatesByBucket"),
"Yandex Evidence phrase selection must preserve semantic/landing coverage buckets."
);
console.info("Demand coverage check passed.");

View File

@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
collapseCarouselManualSemanticBlockBoxes,
getCarouselManualBoxBase,
isCarouselLikeSemanticValue,
type SemanticBlockBoxLike
} from "../preview/semanticBlockRules.js";
function box(input: Partial<SemanticBlockBoxLike> & Pick<SemanticBlockBoxLike, "id" | "title">): SemanticBlockBoxLike {
return {
...input,
height: input.height ?? 240,
id: input.id,
left: input.left ?? 0,
targetBound: input.targetBound ?? true,
targetIds: input.targetIds ?? [],
title: input.title,
top: input.top ?? 0,
width: input.width ?? 320
};
}
assert.equal(
isCarouselLikeSemanticValue(["content.galleryItems.0.caption", "Gallery"]),
true,
"Generic gallery collections must be treated as carousel-like without NODE.DC terms."
);
assert.equal(
isCarouselLikeSemanticValue(["content.cards.0.title", "Преимущества", "card"]),
false,
"Plain card lists must not become one carousel entity only because they are indexed."
);
assert.deepEqual(getCarouselManualBoxBase(box({ id: "semantic-block:gallery:visual-2", title: "Gallery 2" })), {
id: "semantic-block:gallery",
title: "Gallery"
});
const collapsedVisualBoxes = collapseCarouselManualSemanticBlockBoxes([
box({
anchors: [{ entityType: "section", id: "anchor-a", selector: "[data-seo-target-id='a']", source: "workspace_target", targetId: "a" }],
id: "semantic-block:gallery:visual-1",
title: "Gallery 1",
left: 10,
targetIds: ["a"]
}),
box({
anchors: [{ entityType: "section", id: "anchor-b", selector: "[data-seo-target-id='b']", source: "workspace_target", targetId: "b" }],
id: "semantic-block:gallery:visual-2",
title: "Gallery 2",
left: 360,
targetIds: ["b"]
}),
box({ id: "semantic-block:gallery:visual-3", title: "Gallery 3", left: 710, targetIds: ["c"] })
]);
assert.equal(collapsedVisualBoxes.length, 1, "Split visual boxes for one carousel must collapse into one entity.");
assert.equal(collapsedVisualBoxes[0]?.id, "semantic-block:gallery");
assert.deepEqual(collapsedVisualBoxes[0]?.targetIds, ["a", "b", "c"]);
assert.equal(collapsedVisualBoxes[0]?.anchors?.length, 2, "Collapsed carousel entity must keep DOM anchors.");
assert.equal(collapsedVisualBoxes[0]?.left, 10);
assert.equal(collapsedVisualBoxes[0]?.width, 1020);
const collapsedVisualBoxesAcrossLayoutRows = collapseCarouselManualSemanticBlockBoxes([
box({ id: "semantic-block:gallery:visual-1", title: "Gallery 1", left: 10, targetIds: ["a"], top: 0 }),
box({ id: "semantic-block:gallery:visual-2", title: "Gallery 2", left: 20, targetIds: ["b"], top: 920 })
]);
assert.equal(
collapsedVisualBoxesAcrossLayoutRows.length,
1,
"A carousel with stable visual ids must stay one entity even when layout measurements land in different bands."
);
const collapsedTitleOnlyBoxes = collapseCarouselManualSemanticBlockBoxes([
box({ geometry: { height: 240, left: 0, top: 0, width: 320 }, id: "manual-1", title: "Слайдер 1", left: 0, targetIds: ["slide-1"] }),
box({ geometry: { height: 240, left: 300, top: 0, width: 320 }, id: "manual-2", title: "Слайдер 2", left: 300, targetIds: ["slide-2"] }),
box({ geometry: { height: 240, left: 600, top: 0, width: 320 }, id: "manual-3", title: "Слайдер 3", left: 600, targetIds: ["slide-3"] })
]);
assert.equal(collapsedTitleOnlyBoxes.length, 1, "Legacy title-only slider boxes must collapse after reload.");
assert.equal(collapsedTitleOnlyBoxes[0]?.id, "manual-carousel:слайдер");
assert.equal(collapsedTitleOnlyBoxes[0]?.title, "Слайдер");
const separateRows = collapseCarouselManualSemanticBlockBoxes([
box({ geometry: { height: 240, left: 0, top: 0, width: 320 }, id: "row-a", title: "Слайдер 1", top: 0, targetIds: ["a"] }),
box({ geometry: { height: 240, left: 0, top: 900, width: 320 }, id: "row-b", title: "Слайдер 1", top: 900, targetIds: ["b"] })
]);
assert.equal(separateRows.length, 2, "Different carousel rows must not collapse into one global slider.");
const titleOnlyWithoutGeometry = collapseCarouselManualSemanticBlockBoxes([
box({ id: "loose-a", title: "Слайдер 1", targetIds: ["a"] }),
box({ id: "loose-b", title: "Слайдер 2", targetIds: ["b"] })
]);
assert.equal(titleOnlyWithoutGeometry.length, 2, "Title-only carousel labels without geometry must not be globally merged.");
const plainBlocks = collapseCarouselManualSemanticBlockBoxes([
box({ id: "manual-a", title: "Тезис 1", left: 0, targetIds: ["a"] }),
box({ id: "manual-b", title: "Тезис 2", left: 300, targetIds: ["b"] })
]);
assert.equal(plainBlocks.length, 2, "Plain numbered semantic blocks must stay separate.");
const previewBridgeSource = await readFile(new URL("../preview/routes.ts", import.meta.url), "utf8");
const semanticBlockRepositorySource = await readFile(new URL("../workspace/semanticBlocks.ts", import.meta.url), "utf8");
const semanticBlockMigrationSource = await readFile(new URL("../../migrations/010_semantic_blocks.sql", import.meta.url), "utf8");
const rewriteDiffSource = await readFile(new URL("../rewrite/rewriteDiff.ts", import.meta.url), "utf8");
const visualComposerSource = await readFile(new URL("../rewrite/visualComposerVariants.ts", import.meta.url), "utf8");
const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8");
assert.ok(
!/const\s+SEMANTIC_BLOCK_EDITOR_ENABLED\s*=\s*false/.test(appSource),
"Semantic block editor must stay in the stage 07 runtime path."
);
assert.ok(
!/const\s+SEMANTIC_BLOCK_OVERRIDES_ENABLED\s*=\s*false/.test(appSource),
"Manual semantic block overrides must feed rewrite/visual-composer grouping."
);
assert.ok(
appSource.includes("postWorkspacePreviewSectionTexts(workspaceKey, {})"),
"Stage 03 must explicitly clear iframe draft text instead of sharing stage 07 patch state."
);
assert.ok(
previewBridgeSource.includes("function isSafePreviewPatchElement"),
"Preview bridge must keep a safe-patch gate before mutating iframe DOM."
);
assert.ok(
/const\s+blockedTags\s*=\s*new\s+Set\([\s\S]*"section"[\s\S]*"article"[\s\S]*"form"/.test(previewBridgeSource),
"Preview bridge must block text writes into layout containers."
);
assert.ok(
semanticBlockMigrationSource.includes("create table if not exists semantic_blocks"),
"Semantic block decisions must have durable backend storage."
);
assert.ok(
semanticBlockMigrationSource.includes("source_bindings jsonb") && semanticBlockMigrationSource.includes("geometry_cache jsonb"),
"Semantic block storage must keep source bindings and geometry, not only titles."
);
assert.ok(
semanticBlockRepositorySource.includes("saveSemanticBlocksForWorkspace") && semanticBlockRepositorySource.includes("client.release()"),
"Semantic block saves must go through an atomic backend repository transaction."
);
assert.ok(
appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"),
"Stage 07 semantic block overrides must reload from and save to backend."
);
assert.ok(
rewriteDiffSource.includes("listSemanticBlocksForPages") &&
rewriteDiffSource.includes("semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock") &&
rewriteDiffSource.includes("semanticGroupedSlotCount") &&
rewriteDiffSource.includes("buildSemanticRewriteGroups") &&
rewriteDiffSource.includes("fallbackReason: slot.semanticBlock ? null : \"no_semantic_block\"") &&
rewriteDiffSource.includes("buildSemanticGroupSourceGraph") &&
rewriteDiffSource.includes("contentFieldCount"),
"Rewrite diff must attach persisted semantic blocks and build semantic rewrite groups with source graph."
);
assert.ok(
visualComposerSource.includes("getRewriteSemanticGroupId") &&
visualComposerSource.includes("semanticBlockCount") &&
visualComposerSource.includes("persisted_semantic_block") &&
visualComposerSource.includes("semanticRewriteGroupById"),
"Visual composer must consume semantic rewrite groups, not only flat rewrite slots."
);
assert.ok(
appSource.includes("getWorkspaceSemanticGroupFromRewriteSlot") &&
appSource.includes("variants.readiness.semanticBlockCount"),
"Stage 07 UI must expose semantic block grouping and fallback slot counts."
);
console.info("Semantic block universality check passed.");

View File

@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { parseProjectSourceStructure } from "../scans/projectScanner.js";
const genericLandingModel = {
output: "public/index.html",
regions: [
{
id: "main",
label: "Main",
blocks: [
{
id: "hero-main",
anchor: "hero",
adminLabel: "Hero",
template: "hero.html",
content: {
title: "Industrial automation platform",
lead: "Connect teams, workflows and data in one operational layer."
},
editableFields: [
{
group: "Hero copy",
kind: "text",
label: "Title",
path: "content.title",
renderAs: "text"
},
{
group: "Hero copy",
kind: "textarea",
label: "Lead",
path: "content.lead",
renderAs: "text"
}
]
}
]
}
]
};
const genericStructure = parseProjectSourceStructure({
rawText: JSON.stringify(genericLandingModel),
sourcePath: "content/pages/landing.json"
});
assert.ok(genericStructure, "Generic source model must be detected without a hardcoded file name.");
assert.equal(genericStructure.sections.length, 1);
assert.equal(genericStructure.sections[0]?.contentSourcePath, "content/pages/landing.json");
assert.equal(genericStructure.sections[0]?.sourcePath, "content/pages/hero.html");
assert.equal(genericStructure.sections[0]?.outputPath, "public/index.html");
assert.equal(genericStructure.sections[0]?.previewTargetSelector, "#hero");
assert.equal(genericStructure.sections[0]?.editableFields.length, 2);
assert.equal(genericStructure.sections[0]?.editableFields[0]?.value, "Industrial automation platform");
const sectionsOnlyStructure = parseProjectSourceStructure({
rawText: JSON.stringify({
outputPath: "site/about/index.html",
sections: [
{
id: "faq",
selector: "[data-section='faq']",
template: "faq-block.html",
content: {
question: "How does it work?"
},
editableFields: [
{
kind: "text",
label: "Question",
path: "content.question"
}
]
}
]
}),
sourcePath: "data/pages/about.json"
});
assert.ok(sectionsOnlyStructure, "Root sections[] models must be detected without regions[].");
assert.equal(sectionsOnlyStructure.sections[0]?.previewTargetSelector, "[data-section='faq']");
assert.equal(sectionsOnlyStructure.sections[0]?.sourcePath, "data/pages/faq-block.html");
assert.equal(
parseProjectSourceStructure({
rawText: JSON.stringify({ scripts: ["app.js"], styles: ["app.css"] }),
sourcePath: "manifest.json"
}),
null,
"Plain JSON configs must not become page source models."
);
assert.equal(
parseProjectSourceStructure({
rawText: JSON.stringify({ sections: [{ id: "plugins", enabled: true }] }),
sourcePath: "config/modules.json"
}),
null,
"Plain sections[] configs without render/content/editable signals must not become page source models."
);
const scannerSource = await readFile(new URL("../scans/projectScanner.ts", import.meta.url), "utf8");
const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8");
const scopeFlowSource = await readFile(new URL("../../../app/src/ProjectScopeFlow.tsx", import.meta.url), "utf8");
assert.ok(!scannerSource.includes("HOME_TEMPLATE_SELECTORS"), "Scanner must not use current-site template selector maps.");
assert.ok(!scannerSource.includes("content/pages/home.json"), "Scanner must not read a fixed current-site content file.");
assert.ok(!appSource.includes("\"home_section\""), "App runtime must use source_section, not home_section.");
assert.ok(!scopeFlowSource.includes("\"home_section\""), "Scope flow must use source_section, not home_section.");
assert.ok(
!/content\.(heading|accessCard|form|legalHtml)/.test(appSource),
"Frontend semantic grouping must not branch on current-site content paths."
);
console.info("Source model universality check passed.");

View File

@ -0,0 +1,294 @@
import { createHash } from "node:crypto";
import { pool } from "../db/client.js";
import { storageProvider } from "../storage/index.js";
type DraftRow = {
id: string;
project_id: string;
page_id: string | null;
status: string;
created_at: Date;
updated_at: Date;
};
type DraftItemRow = {
id: string;
draft_id: string;
field: string;
original_snapshot_key: string | null;
working_snapshot_key: string | null;
working_text: string | null;
created_at: Date;
updated_at: Date;
};
export type ContentFieldDraftInput = {
fieldId: string | null;
fieldPath: string;
group: string | null;
kind: string;
originalText: string;
pageId: string;
renderAs: string | null;
scopeId: string;
sectionId: string;
sourcePath: string;
title: string;
workingText: string;
};
export type ContentFieldSnapshot = {
schemaVersion: "content-field-workspace.v1";
createdAt: string;
fieldId: string | null;
fieldPath: string;
group: string | null;
kind: string;
originalText: string;
pageId: string;
projectId: string;
renderAs: string | null;
scopeId: string;
sectionId: string;
sourceHint: string;
sourcePath: string;
title: string;
};
export type ContentFieldDraft = {
draftId: string;
draftItemId: string;
fieldKey: string;
fieldPath: string;
originalSnapshotKey: string | null;
originalText: string;
pageId: string;
scopeId: string;
sectionId: string;
sourcePath: string;
title: string;
updatedAt: string;
workingSnapshotKey: string | null;
workingText: string;
};
export class ContentFieldWorkspaceError extends Error {
constructor(message: string) {
super(message);
this.name = "ContentFieldWorkspaceError";
}
}
function normalizePath(value: string) {
return value.replace(/\\/g, "/").replace(/^\/+/, "");
}
export function makeContentFieldKey(sectionId: string, fieldPath: string) {
return `content_field:${sectionId}:${fieldPath}`;
}
function snapshotStorageKey(projectId: string, draftId: string, fieldKey: string) {
const digest = createHash("sha256").update(fieldKey).digest("hex").slice(0, 24);
return `projects/${projectId}/drafts/${draftId}/content-fields/${digest}-original.json`;
}
function workingStorageKey(projectId: string, draftId: string, fieldKey: string) {
const digest = createHash("sha256").update(fieldKey).digest("hex").slice(0, 24);
return `projects/${projectId}/drafts/${draftId}/content-fields/${digest}-working-${Date.now()}.txt`;
}
async function getActiveDraft(projectId: string, pageId: string) {
const result = await pool.query<DraftRow>(
`
select *
from drafts
where project_id = $1 and page_id = $2 and status = 'active'
order by updated_at desc
limit 1;
`,
[projectId, pageId]
);
return result.rows[0] ?? null;
}
async function ensureActiveDraft(projectId: string, pageId: string) {
const existingDraft = await getActiveDraft(projectId, pageId);
if (existingDraft) {
return existingDraft;
}
const result = await pool.query<DraftRow>(
`
insert into drafts (project_id, page_id, status)
values ($1, $2, 'active')
returning *;
`,
[projectId, pageId]
);
const draft = result.rows[0];
if (!draft) {
throw new ContentFieldWorkspaceError("Не удалось создать content draft.");
}
return draft;
}
async function getDraftItem(draftId: string, fieldKey: string) {
const result = await pool.query<DraftItemRow>(
`
select *
from draft_items
where draft_id = $1 and field = $2
order by updated_at desc
limit 1;
`,
[draftId, fieldKey]
);
return result.rows[0] ?? null;
}
export async function listContentFieldDraftsForPage(projectId: string, pageId: string) {
const draft = await getActiveDraft(projectId, pageId);
if (!draft) {
return [];
}
const result = await pool.query<DraftItemRow>(
`
select *
from draft_items
where draft_id = $1 and field like 'content_field:%'
order by field asc, updated_at desc;
`,
[draft.id]
);
const drafts = await Promise.all(
result.rows.map(async (item) => {
const snapshot = await readContentFieldSnapshot(item.original_snapshot_key);
return snapshot ? buildContentFieldResponse(draft, item, snapshot) : null;
})
);
return drafts.filter((item): item is ContentFieldDraft => Boolean(item));
}
export async function readContentFieldSnapshot(key: string | null) {
if (!key) {
return null;
}
try {
const rawSnapshot = await storageProvider.getObjectText(key);
return JSON.parse(rawSnapshot) as ContentFieldSnapshot;
} catch {
return null;
}
}
function buildContentFieldResponse(draft: DraftRow, item: DraftItemRow, snapshot: ContentFieldSnapshot): ContentFieldDraft {
return {
draftId: draft.id,
draftItemId: item.id,
fieldKey: item.field,
fieldPath: snapshot.fieldPath,
originalSnapshotKey: item.original_snapshot_key,
originalText: snapshot.originalText,
pageId: snapshot.pageId,
scopeId: snapshot.scopeId,
sectionId: snapshot.sectionId,
sourcePath: snapshot.sourcePath,
title: snapshot.title,
updatedAt: item.updated_at.toISOString(),
workingSnapshotKey: item.working_snapshot_key,
workingText: item.working_text ?? snapshot.originalText
};
}
export async function saveContentFieldDraft(projectId: string, input: ContentFieldDraftInput) {
const sourcePath = normalizePath(input.sourcePath);
if (!sourcePath) {
throw new ContentFieldWorkspaceError("Content field sourcePath пустой.");
}
const draft = await ensureActiveDraft(projectId, input.pageId);
const fieldKey = makeContentFieldKey(input.sectionId, input.fieldPath);
const existingItem = await getDraftItem(draft.id, fieldKey);
const existingSnapshot = existingItem ? await readContentFieldSnapshot(existingItem.original_snapshot_key) : null;
const snapshot: ContentFieldSnapshot =
existingSnapshot ??
{
createdAt: new Date().toISOString(),
fieldId: input.fieldId,
fieldPath: input.fieldPath,
group: input.group,
kind: input.kind,
originalText: input.originalText,
pageId: input.pageId,
projectId,
renderAs: input.renderAs,
schemaVersion: "content-field-workspace.v1",
scopeId: input.scopeId,
sectionId: input.sectionId,
sourceHint: `content:${input.fieldPath}`,
sourcePath,
title: input.title
};
const originalSnapshotKey = existingItem?.original_snapshot_key ?? snapshotStorageKey(projectId, draft.id, fieldKey);
const workingSnapshotKey = workingStorageKey(projectId, draft.id, fieldKey);
if (!existingItem) {
await storageProvider.putObject({
body: JSON.stringify(snapshot, null, 2),
contentType: "application/json; charset=utf-8",
key: originalSnapshotKey
});
}
await storageProvider.putObject({
body: input.workingText,
contentType: "text/plain; charset=utf-8",
key: workingSnapshotKey
});
const itemResult = existingItem
? await pool.query<DraftItemRow>(
`
update draft_items
set working_text = $1, working_snapshot_key = $2, updated_at = now()
where id = $3
returning *;
`,
[input.workingText, workingSnapshotKey, existingItem.id]
)
: await pool.query<DraftItemRow>(
`
insert into draft_items (draft_id, field, original_snapshot_key, working_snapshot_key, working_text)
values ($1, $2, $3, $4, $5)
returning *;
`,
[draft.id, fieldKey, originalSnapshotKey, workingSnapshotKey, input.workingText]
);
const item = itemResult.rows[0];
if (!item) {
throw new ContentFieldWorkspaceError("Не удалось сохранить content field draft.");
}
await pool.query(
`
update drafts
set updated_at = now()
where id = $1;
`,
[draft.id]
);
return buildContentFieldResponse(draft, item, snapshot);
}

View File

@ -54,14 +54,38 @@ type WorkspaceSnapshot = {
createdAt: string;
};
type PageWorkspaceSemanticGroup = {
confidence: number;
evidence: string[];
fieldCount: number;
fieldOrder: number;
fieldRole: string;
id: string;
source: "workspace_section";
title: string;
};
type PageWorkspaceSectionAnchor = {
entityType: "document_meta" | "dom_section" | "heading_group" | "media_container";
fingerprint: {
selector: string | null;
sourceHint: string;
text: string;
};
selector: string | null;
sourceHint: string;
};
export type PageWorkspaceSection = {
id: string;
kind: "meta" | "content_block" | "heading_group" | "media";
title: string;
selector: string | null;
anchor: PageWorkspaceSectionAnchor;
originalText: string;
workingText: string;
charCount: number;
semanticGroup: PageWorkspaceSemanticGroup | null;
summary: string;
sourceHint: string;
};
@ -109,6 +133,9 @@ const WORKSPACE_FIELD = "page_text";
const MAX_BLOCK_TEXT_LENGTH = 6_000;
const MAX_SECTIONS = 80;
const MAX_MEDIA_SECTIONS = 80;
const WORKSPACE_GROUPING_VERSION_EVIDENCE = "workspace_grouping_v3";
const MAX_SOURCE_ORDER_CLUSTER_SIZE = 12;
const MAX_CLUSTER_SECTION_CHARS = 900;
function toIsoDate(value: Date) {
return value.toISOString();
@ -202,8 +229,8 @@ function getFirstH1Text(raw: Record<string, unknown>) {
return normalizeHeadings(raw).find((heading) => heading.level === 1)?.text ?? "";
}
function countImageTags(html: string) {
return Array.from(getBodyHtml(html).matchAll(/<img\b[^>]*>/gi)).length;
function countMediaTags(html: string) {
return Array.from(getBodyHtml(html).matchAll(/<(img|video)\b[^>]*>/gi)).length;
}
function getSectionSummary(text: string) {
@ -219,9 +246,51 @@ function getSectionSummary(text: string) {
return firstMeaningfulLine.length > 180 ? `${firstMeaningfulLine.slice(0, 177)}...` : firstMeaningfulLine;
}
function makeSection(input: Omit<PageWorkspaceSection, "charCount" | "summary" | "workingText">): PageWorkspaceSection {
function getWorkspaceSectionRole(input: { id: string; kind: PageWorkspaceSection["kind"]; title: string }) {
const value = `${input.id} ${input.kind} ${input.title}`.toLocaleLowerCase("ru-RU");
if (value.includes("seo-title") || value.includes("title")) return "title";
if (value.includes("description")) return "description";
if (value.includes("h1")) return "heading";
if (input.kind === "heading_group") return "heading_group";
if (input.kind === "media") return "media";
if (input.kind === "meta") return "meta";
return "body";
}
function getSectionAnchorEntityType(kind: PageWorkspaceSection["kind"]): PageWorkspaceSectionAnchor["entityType"] {
if (kind === "meta") return "document_meta";
if (kind === "media") return "media_container";
if (kind === "heading_group") return "heading_group";
return "dom_section";
}
function getTextFingerprint(value: string) {
return normalizeWhitespace(value).slice(0, 500);
}
function getSectionAnchor(input: Pick<PageWorkspaceSection, "kind" | "originalText" | "selector" | "sourceHint">): PageWorkspaceSectionAnchor {
return {
entityType: getSectionAnchorEntityType(input.kind),
fingerprint: {
selector: input.selector,
sourceHint: input.sourceHint,
text: getTextFingerprint(input.originalText)
},
selector: input.selector,
sourceHint: input.sourceHint
};
}
type MakePageWorkspaceSectionInput = Omit<PageWorkspaceSection, "anchor" | "charCount" | "semanticGroup" | "summary" | "workingText"> & {
semanticGroup?: PageWorkspaceSemanticGroup | null;
};
function makeSection(input: MakePageWorkspaceSectionInput): PageWorkspaceSection {
return {
...input,
anchor: getSectionAnchor(input),
semanticGroup: input.semanticGroup ?? null,
workingText: input.originalText,
charCount: input.originalText.length,
summary: getSectionSummary(input.originalText)
@ -335,31 +404,33 @@ function extractHeadingSections(html: string) {
return dedupeSections(sections);
}
function extractImageAltSections(html: string) {
function extractMediaSections(html: string) {
const bodyHtml = getBodyHtml(html);
const imagePattern = /<img\b([^>]*)>/gi;
const mediaPattern = /<(img|video)\b([^>]*)>/gi;
const sections: PageWorkspaceSection[] = [];
let match: RegExpExecArray | null;
let imageIndex = 0;
let mediaIndex = 0;
while ((match = imagePattern.exec(bodyHtml)) && sections.length < MAX_MEDIA_SECTIONS) {
imageIndex += 1;
while ((match = mediaPattern.exec(bodyHtml)) && sections.length < MAX_MEDIA_SECTIONS) {
mediaIndex += 1;
const attributes = match[1] ?? "";
const source = getAttribute(attributes, "src");
const tagName = match[1] ?? "img";
const attributes = match[2] ?? "";
const source = getAttribute(attributes, "src") ?? getAttribute(attributes, "poster");
const alt = getAttribute(attributes, "alt");
const label = getAttribute(attributes, "aria-label") ?? getAttribute(attributes, "title");
const normalizedAlt = normalizeWhitespace(alt ?? "");
const filename = getDisplayFilename(source, `image-${imageIndex}`);
const filename = getDisplayFilename(source, `${tagName}-${mediaIndex}`);
sections.push(
makeSection({
id: `media-alt-${imageIndex}`,
id: `media-alt-${mediaIndex}`,
kind: "media",
title: `ALT: ${filename}`,
selector: getPreviewTargetSelector(`media-alt-${imageIndex}`),
originalText: normalizedAlt,
sourceHint: source ?? `img:nth-of-type(${imageIndex})`
title: tagName === "video" ? `VIDEO: ${filename}` : `ALT: ${filename}`,
selector: getPreviewTargetSelector(`media-alt-${mediaIndex}`),
originalText: normalizedAlt || normalizeWhitespace(label ?? ""),
sourceHint: source ?? `${tagName}:nth-of-type(${mediaIndex})`
})
);
}
@ -367,6 +438,168 @@ function extractImageAltSections(html: string) {
return sections;
}
function getSectionGroupEvidence(section: PageWorkspaceSection) {
if (section.kind === "meta") {
return [WORKSPACE_GROUPING_VERSION_EVIDENCE, "document_meta"];
}
if (section.kind === "media") {
return [WORKSPACE_GROUPING_VERSION_EVIDENCE, "media_element"];
}
if (section.id.startsWith("heading-group-")) {
return [WORKSPACE_GROUPING_VERSION_EVIDENCE, "heading_range", "source_order"];
}
return [WORKSPACE_GROUPING_VERSION_EVIDENCE, "semantic_dom_section", "source_order"];
}
function getSectionGroupConfidence(section: PageWorkspaceSection) {
if (section.kind === "meta") return 0.9;
if (section.kind === "media") return 0.74;
if (section.id.startsWith("heading-group-")) return 0.72;
return 0.84;
}
function isGenericSectionTitle(section: PageWorkspaceSection) {
const title = section.title.toLocaleLowerCase("ru-RU");
return /^(section|article|header|main|footer|aside|nav)([#.]\S+)?$/.test(title) || /^блок \d+$/.test(title);
}
function isSourceOrderClusterCandidate(section: PageWorkspaceSection) {
if (section.kind !== "content_block") {
return false;
}
if (section.charCount < 20 || section.charCount > MAX_CLUSTER_SECTION_CHARS) {
return false;
}
if (section.sourceHint === "<main>" || section.sourceHint === "<header>" || section.sourceHint === "<footer>" || section.sourceHint === "<nav>") {
return false;
}
return true;
}
function shouldClusterBySourceOrder(run: PageWorkspaceSection[]) {
if (run.length < 2) {
return false;
}
if (run.some(isGenericSectionTitle)) {
return true;
}
if (run.length >= 3) {
const averageLength = run.reduce((sum, section) => sum + section.charCount, 0) / run.length;
const sameSourceHint = new Set(run.map((section) => section.sourceHint)).size === 1;
return sameSourceHint && averageLength <= 520;
}
return false;
}
function getDefaultSemanticGroup(section: PageWorkspaceSection): PageWorkspaceSemanticGroup {
return {
confidence: getSectionGroupConfidence(section),
evidence: getSectionGroupEvidence(section),
fieldCount: 1,
fieldOrder: 0,
fieldRole: getWorkspaceSectionRole({
id: section.id,
kind: section.kind,
title: section.title
}),
id: section.kind === "media" ? "semantic-block:media-assets" : `semantic-block:${section.id}`,
source: "workspace_section",
title: section.kind === "media" ? "Медиа страницы" : section.title
};
}
function getSourceOrderClusterGroup(
run: PageWorkspaceSection[],
section: PageWorkspaceSection,
runIndex: number,
fieldOrder: number
): PageWorkspaceSemanticGroup {
const firstTitle = run[0]?.title ?? section.title;
const lastTitle = run.at(-1)?.title ?? firstTitle;
const title = normalizeWhitespace(firstTitle === lastTitle ? firstTitle : `${firstTitle} -> ${lastTitle}`).slice(0, 180);
return {
confidence: 0.58,
evidence: [
WORKSPACE_GROUPING_VERSION_EVIDENCE,
"adjacent_short_sections",
"same_source_order_run",
"requires_visual_or_manual_review"
],
fieldCount: run.length,
fieldOrder,
fieldRole: getWorkspaceSectionRole({
id: section.id,
kind: section.kind,
title: section.title
}),
id: `semantic-block:source-order-run-${runIndex}`,
source: "workspace_section",
title
};
}
function attachSemanticGroups(sections: PageWorkspaceSection[]) {
const groups = new Map<string, PageWorkspaceSemanticGroup>();
let run: PageWorkspaceSection[] = [];
let runIndex = 0;
const flushRun = () => {
if (run.length === 0) {
return;
}
runIndex += 1;
const clusterRun = shouldClusterBySourceOrder(run) ? run.slice(0, MAX_SOURCE_ORDER_CLUSTER_SIZE) : [];
run.forEach((section, index) => {
groups.set(
section.id,
clusterRun.includes(section)
? getSourceOrderClusterGroup(clusterRun, section, runIndex, index)
: getDefaultSemanticGroup(section)
);
});
run = [];
};
sections.forEach((section) => {
if (!isSourceOrderClusterCandidate(section)) {
flushRun();
groups.set(section.id, getDefaultSemanticGroup(section));
return;
}
const previous = run.at(-1);
if (
previous &&
(previous.sourceHint !== section.sourceHint || run.length >= MAX_SOURCE_ORDER_CLUSTER_SIZE)
) {
flushRun();
}
run.push(section);
});
flushRun();
return sections.map((section) => ({
...section,
semanticGroup: section.semanticGroup ?? groups.get(section.id) ?? getDefaultSemanticGroup(section)
}));
}
function buildPreviewHtml(html: string) {
const previewStyles = `
<style>
@ -375,7 +608,7 @@ function buildPreviewHtml(html: string) {
a { cursor: default !important; }
</style>
`;
let imageIndex = 0;
let mediaIndex = 0;
const targetSeeds = getPreviewTargetSeeds(html);
const withoutScripts = annotatePreviewTargets(html, targetSeeds)
.replace(/<script\b[\s\S]*?<\/script>/gi, "")
@ -383,16 +616,16 @@ function buildPreviewHtml(html: string) {
.replace(/<h([1-3])\b([^>]*)>/gi, (_match, level: string, attributes: string) => {
return `<h${level}${attributes} data-seo-label="H${level}">`;
})
.replace(/<img\b([^>]*)>/gi, (match, attributes: string) => {
imageIndex += 1;
.replace(/<(img|video)\b([^>]*)>/gi, (match, tagName: string, attributes: string) => {
mediaIndex += 1;
if (/data-seo-media-index\s*=/i.test(attributes)) {
return match;
}
const isSelfClosing = /\/\s*$/.test(attributes);
const cleanAttributes = attributes.replace(/\/\s*$/, "").trimEnd();
return `<img${cleanAttributes} data-seo-media-index="${imageIndex}"${isSelfClosing ? " /" : ""}>`;
const cleanAttributes = attributes.replace(/\/\s*$/, "").trim();
return `<${tagName}${cleanAttributes ? ` ${cleanAttributes}` : ""} data-seo-media-index="${mediaIndex}"${isSelfClosing ? " /" : ""}>`;
});
if (/<head\b[^>]*>/i.test(withoutScripts)) {
@ -429,15 +662,15 @@ function buildSections(page: PageWorkspaceSourceRow, html: string): PageWorkspac
const semanticSections = extractSemanticSections(html);
const contentSections = semanticSections.length >= 2 ? semanticSections : extractHeadingSections(html);
const mediaSections = extractImageAltSections(html);
const mediaSections = extractMediaSections(html);
return [...sections, ...contentSections, ...mediaSections];
return attachSemanticGroups([...sections, ...contentSections, ...mediaSections]);
}
function buildIssueAlignedSections(page: PageWorkspaceSourceRow, html: string): PageWorkspaceSection[] {
const semanticSections = extractSemanticSections(html);
const contentSections = semanticSections.length >= 2 ? semanticSections : extractHeadingSections(html);
const mediaSections = extractImageAltSections(html);
const mediaSections = extractMediaSections(html);
const pageAnchorSelector = contentSections[0]?.selector ?? mediaSections[0]?.selector ?? getPreviewTargetSelector("block-1");
const h1Section = contentSections.find((section) => section.title.toLocaleLowerCase().startsWith("h1:"));
const seoSections: PageWorkspaceSection[] = [
@ -467,7 +700,7 @@ function buildIssueAlignedSections(page: PageWorkspaceSourceRow, html: string):
})
];
return [...seoSections, ...contentSections, ...mediaSections];
return attachSemanticGroups([...seoSections, ...contentSections, ...mediaSections]);
}
function buildOriginalText(page: PageWorkspaceSourceRow, sections: PageWorkspaceSection[]) {
@ -662,7 +895,7 @@ function buildSnapshot(page: PageWorkspaceSourceRow, html: string): WorkspaceSna
async function getUpgradedSnapshot(page: PageWorkspaceSourceRow, item: DraftItemRow) {
const snapshot = await readOriginalSnapshot(item.original_snapshot_key);
const imageCount = Math.min(countImageTags(snapshot.originalHtml ?? ""), MAX_MEDIA_SECTIONS);
const mediaCount = Math.min(countMediaTags(snapshot.originalHtml ?? ""), MAX_MEDIA_SECTIONS);
const mediaSectionCount = snapshot.sections.filter((section) => section.kind === "media").length;
const isCurrentSnapshot =
snapshot.schemaVersion === "page-workspace.v2" &&
@ -671,7 +904,11 @@ async function getUpgradedSnapshot(page: PageWorkspaceSourceRow, item: DraftItem
snapshot.sections.some((section) => section.id === "seo-description") &&
snapshot.sections.some((section) => section.id === "seo-h1") &&
snapshot.sections.some((section) => section.kind === "content_block" || section.kind === "heading_group") &&
(!/<img\b/i.test(snapshot.originalHtml ?? "") || mediaSectionCount >= imageCount) &&
snapshot.sections.every((section) => Boolean(section.anchor?.entityType) && "fingerprint" in section.anchor) &&
snapshot.sections.every((section) =>
Boolean(section.semanticGroup?.evidence.includes(WORKSPACE_GROUPING_VERSION_EVIDENCE))
) &&
(!/<(img|video)\b/i.test(snapshot.originalHtml ?? "") || mediaSectionCount >= mediaCount) &&
snapshot.sections.every((section) => section.kind === "meta" || section.selector?.includes("data-seo-target-id"));
if (isCurrentSnapshot) {

View File

@ -90,20 +90,22 @@ export function getPreviewTargetSeeds(html: string) {
headingIndex += 1;
}
const imagePattern = /<img\b([^>]*)>/gi;
let imageMatch: RegExpExecArray | null;
let imageIndex = 0;
while ((imageMatch = imagePattern.exec(bodyHtml)) && seeds.length < 180) {
imageIndex += 1;
const mediaPattern = /<(img|video)\b([^>]*)>/gi;
let mediaMatch: RegExpExecArray | null;
let mediaIndex = 0;
while ((mediaMatch = mediaPattern.exec(bodyHtml)) && seeds.length < 180) {
mediaIndex += 1;
const attributes = imageMatch[1] ?? "";
const id = `media-alt-${imageIndex}`;
const tagName = mediaMatch[1] ?? "img";
const attributes = mediaMatch[2] ?? "";
const source = getAttribute(attributes, "src") ?? getAttribute(attributes, "poster");
const id = `media-alt-${mediaIndex}`;
seeds.push({
id,
kind: "media",
selector: getPreviewTargetSelector(id),
sourceHint: getAttribute(attributes, "src") ?? `img:nth-of-type(${imageIndex})`,
targetIndex: imageIndex
sourceHint: source ?? `${tagName}:nth-of-type(${mediaIndex})`,
targetIndex: mediaIndex
});
}
@ -116,7 +118,7 @@ export function annotatePreviewTargets(html: string, seeds: PreviewTargetSeed[])
const mediaSeeds = seeds.filter((seed) => seed.kind === "media");
let blockIndex = 0;
let headingIndex = 0;
let imageIndex = 0;
let mediaIndex = 0;
return html
.replace(/<(section|article|header|main|footer|aside|nav)\b([^>]*)>([\s\S]*?)<\/\1>/gi, (blockHtml, tagName: string, attributes: string, content: string) => {
@ -153,9 +155,9 @@ export function annotatePreviewTargets(html: string, seeds: PreviewTargetSeed[])
const openingTag = `<h${level}${attributes}>`;
return headingHtml.replace(openingTag, injectAttribute(openingTag, "data-seo-target-id", seed.id));
})
.replace(/<img\b([^>]*)>/gi, (openingTag) => {
imageIndex += 1;
const seed = mediaSeeds.find((candidate) => candidate.targetIndex === imageIndex);
.replace(/<(img|video)\b([^>]*)>/gi, (openingTag) => {
mediaIndex += 1;
const seed = mediaSeeds.find((candidate) => candidate.targetIndex === mediaIndex);
return seed ? injectAttribute(openingTag, "data-seo-target-id", seed.id) : openingTag;
});

View File

@ -0,0 +1,317 @@
import { pool } from "../db/client.js";
type JsonRecord = Record<string, unknown>;
type JsonArray = unknown[];
type SemanticBlockRow = {
id: string;
project_id: string;
scan_version_id: string | null;
page_id: string;
workspace_key: string;
scope_id: string;
block_key: string;
title: string;
role: string;
source: SemanticBlockSource;
identity: JsonRecord;
anchors: JsonArray;
captured_entities: JsonArray;
source_bindings: JsonArray;
media_context: JsonRecord;
geometry_cache: JsonRecord;
rewrite_state: JsonRecord;
target_ids: string[];
archived_at: Date | null;
created_at: Date;
updated_at: Date;
};
export type SemanticBlockSource = "auto" | "manual" | "source_object" | "visual_section" | "workspace_section";
export type SemanticBlockModel = {
id: string;
projectId: string;
scanVersionId: string | null;
pageId: string;
workspaceKey: string;
scopeId: string;
blockKey: string;
title: string;
role: string;
source: SemanticBlockSource;
identity: JsonRecord;
anchors: JsonArray;
capturedEntities: JsonArray;
sourceBindings: JsonArray;
mediaContext: JsonRecord;
geometryCache: JsonRecord;
rewriteState: JsonRecord;
targetIds: string[];
archivedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type SemanticBlockSaveInput = {
scanVersionId?: string | null;
workspaceKey: string;
scopeId: string;
blocks: Array<{
blockKey: string;
title: string;
role?: string;
source?: SemanticBlockSource;
identity?: JsonRecord;
anchors?: JsonArray;
capturedEntities?: JsonArray;
sourceBindings?: JsonArray;
mediaContext?: JsonRecord;
geometryCache?: JsonRecord;
rewriteState?: JsonRecord;
targetIds?: string[];
}>;
};
export class SemanticBlockWorkspaceError extends Error {
constructor(message: string) {
super(message);
this.name = "SemanticBlockWorkspaceError";
}
}
function cleanText(value: string, maxLength: number) {
return value.replace(/\s+/g, " ").trim().slice(0, maxLength);
}
function normalizeRecord(value: JsonRecord | undefined): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function normalizeArray(value: JsonArray | undefined): JsonArray {
return Array.isArray(value) ? value.slice(0, 200) : [];
}
function normalizeTargetIds(value: string[] | undefined) {
return Array.from(
new Set((value ?? []).map((targetId) => targetId.trim()).filter(Boolean))
).slice(0, 200);
}
function mapSemanticBlockRow(row: SemanticBlockRow): SemanticBlockModel {
return {
anchors: normalizeArray(row.anchors),
archivedAt: row.archived_at?.toISOString() ?? null,
blockKey: row.block_key,
capturedEntities: normalizeArray(row.captured_entities),
createdAt: row.created_at.toISOString(),
geometryCache: normalizeRecord(row.geometry_cache),
id: row.id,
identity: normalizeRecord(row.identity),
mediaContext: normalizeRecord(row.media_context),
pageId: row.page_id,
projectId: row.project_id,
rewriteState: normalizeRecord(row.rewrite_state),
role: row.role,
scanVersionId: row.scan_version_id,
scopeId: row.scope_id,
source: row.source,
sourceBindings: normalizeArray(row.source_bindings),
targetIds: normalizeTargetIds(row.target_ids),
title: row.title,
updatedAt: row.updated_at.toISOString(),
workspaceKey: row.workspace_key
};
}
async function assertPageBelongsToProject(projectId: string, pageId: string) {
const result = await pool.query<{ id: string }>(
`
select id
from pages
where id = $1 and project_id = $2
limit 1;
`,
[pageId, projectId]
);
if (!result.rows[0]) {
throw new SemanticBlockWorkspaceError("Страница не найдена в проекте.");
}
}
export async function listSemanticBlocksForPage(projectId: string, pageId: string) {
await assertPageBelongsToProject(projectId, pageId);
const result = await pool.query<SemanticBlockRow>(
`
select *
from semantic_blocks
where project_id = $1
and page_id = $2
and archived_at is null
order by workspace_key asc, scope_id asc, updated_at desc, block_key asc;
`,
[projectId, pageId]
);
return result.rows.map(mapSemanticBlockRow);
}
export async function listSemanticBlocksForPages(projectId: string, pageIds: string[]) {
const uniquePageIds = Array.from(new Set(pageIds.filter(Boolean)));
if (uniquePageIds.length === 0) {
return [];
}
const result = await pool.query<SemanticBlockRow>(
`
select sb.*
from semantic_blocks sb
join pages p on p.id = sb.page_id and p.project_id = sb.project_id
where sb.project_id = $1
and sb.page_id = any($2::uuid[])
and sb.archived_at is null
order by sb.page_id asc, sb.workspace_key asc, sb.scope_id asc, sb.updated_at desc, sb.block_key asc;
`,
[projectId, uniquePageIds]
);
return result.rows.map(mapSemanticBlockRow);
}
export async function saveSemanticBlocksForWorkspace(projectId: string, pageId: string, input: SemanticBlockSaveInput) {
await assertPageBelongsToProject(projectId, pageId);
const workspaceKey = cleanText(input.workspaceKey, 240);
const scopeId = cleanText(input.scopeId, 240);
if (!workspaceKey || !scopeId) {
throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks.");
}
const normalizedBlocks = input.blocks.map((block) => ({
anchors: normalizeArray(block.anchors),
blockKey: cleanText(block.blockKey, 260),
capturedEntities: normalizeArray(block.capturedEntities),
geometryCache: normalizeRecord(block.geometryCache),
identity: normalizeRecord(block.identity),
mediaContext: normalizeRecord(block.mediaContext),
rewriteState: normalizeRecord(block.rewriteState),
role: cleanText(block.role ?? "content", 80) || "content",
source: block.source ?? "manual",
sourceBindings: normalizeArray(block.sourceBindings),
targetIds: normalizeTargetIds(block.targetIds),
title: cleanText(block.title, 260) || "Semantic block"
})).filter((block) => block.blockKey);
const blockKeys = normalizedBlocks.map((block) => block.blockKey);
const client = await pool.connect();
await client.query("begin");
try {
await client.query(
`
update semantic_blocks
set archived_at = now(), updated_at = now()
where project_id = $1
and page_id = $2
and workspace_key = $3
and scope_id = $4
and archived_at is null
and not (block_key = any($5::text[]));
`,
[projectId, pageId, workspaceKey, scopeId, blockKeys]
);
for (const block of normalizedBlocks) {
await client.query(
`
insert into semantic_blocks (
project_id,
scan_version_id,
page_id,
workspace_key,
scope_id,
block_key,
title,
role,
source,
identity,
anchors,
captured_entities,
source_bindings,
media_context,
geometry_cache,
rewrite_state,
target_ids,
archived_at
)
values (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::text[], null
)
on conflict (project_id, page_id, workspace_key, scope_id, block_key)
do update set
scan_version_id = excluded.scan_version_id,
title = excluded.title,
role = excluded.role,
source = excluded.source,
identity = excluded.identity,
anchors = excluded.anchors,
captured_entities = excluded.captured_entities,
source_bindings = excluded.source_bindings,
media_context = excluded.media_context,
geometry_cache = excluded.geometry_cache,
rewrite_state = excluded.rewrite_state,
target_ids = excluded.target_ids,
archived_at = null,
updated_at = now();
`,
[
projectId,
input.scanVersionId ?? null,
pageId,
workspaceKey,
scopeId,
block.blockKey,
block.title,
block.role,
block.source,
JSON.stringify(block.identity),
JSON.stringify(block.anchors),
JSON.stringify(block.capturedEntities),
JSON.stringify(block.sourceBindings),
JSON.stringify(block.mediaContext),
JSON.stringify(block.geometryCache),
JSON.stringify(block.rewriteState),
block.targetIds
]
);
}
await client.query("commit");
} catch (error) {
await client.query("rollback");
throw error;
} finally {
client.release();
}
const result = await pool.query<SemanticBlockRow>(
`
select *
from semantic_blocks
where project_id = $1
and page_id = $2
and workspace_key = $3
and scope_id = $4
and archived_at is null
order by updated_at desc, block_key asc;
`,
[projectId, pageId, workspaceKey, scopeId]
);
return result.rows.map(mapSemanticBlockRow);
}