feat: add SEO rewrite workspace contracts
This commit is contained in:
parent
4b46936d0b
commit
f9b240dbd1
|
|
@ -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()}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
: "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"
|
||||
? "Кластер уже имеет сильную локальную evidence. Дальше не расширять вслепую, а проверять спрос и конкурентов."
|
||||
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) ??
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
phrase: item.phrase,
|
||||
priority: (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low",
|
||||
reason: "High demand candidate from keyword cleaning.",
|
||||
source: "top_demand",
|
||||
targetPath: item.targetPath
|
||||
});
|
||||
const priority = (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low";
|
||||
|
||||
addCandidate(
|
||||
{
|
||||
phrase: item.phrase,
|
||||
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({
|
||||
phrase: item.phrase,
|
||||
priority: item.priority,
|
||||
reason: item.normalization?.reviewReason ?? "Approved market seed.",
|
||||
source: "market_seed",
|
||||
targetPath: null
|
||||
});
|
||||
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>) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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\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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/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 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;
|
||||
}
|
||||
|
||||
function isSafeCssIdentifier(value: string | null) {
|
||||
return Boolean(value && /^[a-zA-Z][\w:-]*$/.test(value));
|
||||
}
|
||||
|
||||
function selectorFromAnchor(anchor: string | null) {
|
||||
if (!anchor || !/^[a-zA-Z][\w:-]*$/.test(anchor)) {
|
||||
return isSafeCssIdentifier(anchor) ? `#${anchor}` : 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;
|
||||
}
|
||||
|
||||
return `#${anchor}`;
|
||||
const scopedAnchorId = `${section.id}-${section.anchor}`;
|
||||
|
||||
return htmlIds.has(scopedAnchorId) ? `#${scopedAnchorId}` : null;
|
||||
}
|
||||
|
||||
function selectorForHomeBlock(block: HomeBlockStructure) {
|
||||
return block.previewTargetSelector ?? HOME_TEMPLATE_SELECTORS[normalizeTemplateName(block.template)] ?? selectorFromAnchor(block.anchor);
|
||||
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 parseHomeStructure(rawText: string | null): HomeStructure | null {
|
||||
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;
|
||||
}
|
||||
|
||||
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(homeStructure: HomeStructure | null, pages: ProjectScanSummary["pages"]) {
|
||||
if (!homeStructure) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
if (file.size > 2_000_000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fs.readFile(path.join(rootPath, normalizedRequestedPath), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
if (/^(package-lock|package|tsconfig|jsconfig|manifest|project-index)\.json$/i.test(filename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
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 (source.source_type === "browser_folder") {
|
||||
const importPrefix = getStringConfig(source.source_config, "importPrefix");
|
||||
const rootName = getStringConfig(source.source_config, "rootName");
|
||||
if (sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!importPrefix) {
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const normalizedPrefix = importPrefix.endsWith("/") ? importPrefix : `${importPrefix}/`;
|
||||
const candidates = [
|
||||
normalizedRequestedPath,
|
||||
rootName ? `${normalizePath(rootName)}/${normalizedRequestedPath}` : null
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const mergedStructure = mergeProjectSourceStructures(structures);
|
||||
const htmlIdsByPath = await buildHtmlIdMap(files);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
@ -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.");
|
||||
|
|
@ -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.");
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue