NODEDC_SEO/seo_mode/seo_mode/server/src/preview/semanticBlockRules.ts

163 lines
4.9 KiB
TypeScript

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
.normalize("NFKC")
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
.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 searchableValue = [
visualIdMatch?.[1],
baseTitle,
box.id,
box.title
]
.filter(Boolean)
.join(" ")
.toLocaleLowerCase("ru-RU");
// Geometry and numbered labels are presentation hints only. A stable
// :visual-N identity is the sole authority for collapsing projections.
if (!visualIdMatch || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
return null;
}
return {
id: visualIdMatch[1],
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 key = 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;
}