Import expanded Knowledge SEO content

This commit is contained in:
dcconstructions
2026-07-10 01:51:58 +03:00
parent e08ad18253
commit 2c07dc2df8
27 changed files with 3114 additions and 311 deletions
+262
View File
@@ -0,0 +1,262 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const knowledgePath = resolve(repoRoot, "content", "knowledge.json");
const args = process.argv.slice(2);
const inputPath = args.find((value) => !value.startsWith("--"));
const dryRun = args.includes("--dry-run");
if (!inputPath) {
throw new Error("Укажите путь к NODEDC_Knowledge_SEO_replacement_pack.md");
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function cleanMetaValue(value) {
const trimmed = String(value || "").trim();
return trimmed.startsWith("`") && trimmed.endsWith("`") ? trimmed.slice(1, -1).trim() : trimmed;
}
function metadataValue(section, label) {
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = section.match(new RegExp(`^\\*\\*${escapedLabel}:\\*\\*\\s*(.+?)\\s*$`, "m"));
return cleanMetaValue(match?.[1] || "");
}
function inlineMarkdown(value) {
return escapeHtml(String(value || "").trim())
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
}
function buttonShortcode(label, href) {
const name = String(label || "Открыть").replaceAll('"', "&quot;");
const link = String(href || "/").replaceAll('"', "&quot;");
return `<p>[button name="${name}" link="${link}"]</p>`;
}
function markdownToHtml(markdown) {
const lines = String(markdown || "").replaceAll("\r\n", "\n").split("\n");
const blocks = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line || line === "---") {
index += 1;
continue;
}
if (/^#\s+/.test(line)) {
index += 1;
continue;
}
const heading = /^(#{2,3})\s+(.+)$/.exec(line);
if (heading) {
const level = heading[1].length;
blocks.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
index += 1;
continue;
}
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(line);
if (link) {
blocks.push(buttonShortcode(link[1], link[2]));
index += 1;
continue;
}
const bullet = /^[-*]\s+(.+)$/.exec(line);
if (bullet) {
const items = [];
while (index < lines.length) {
const item = /^[-*]\s+(.+)$/.exec(lines[index].trim());
if (!item) break;
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
index += 1;
}
blocks.push(`<ul>${items.join("")}</ul>`);
continue;
}
const ordered = /^\d+\.\s+(.+)$/.exec(line);
if (ordered) {
const items = [];
while (index < lines.length) {
const item = /^\d+\.\s+(.+)$/.exec(lines[index].trim());
if (!item) break;
items.push(`<li>${inlineMarkdown(item[1])}</li>`);
index += 1;
}
blocks.push(`<ol>${items.join("")}</ol>`);
continue;
}
const paragraph = [];
while (index < lines.length) {
const next = lines[index].trim();
if (!next || next === "---" || /^#{1,3}\s+/.test(next) || /^[-*]\s+/.test(next) || /^\d+\.\s+/.test(next) || /^\[[^\]]+\]\([^)\s]+\)$/.test(next)) {
break;
}
paragraph.push(next);
index += 1;
}
if (paragraph.length) {
blocks.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
} else {
index += 1;
}
}
return `${blocks.join("\n")}\n`;
}
function parsePack(markdown) {
const matches = [...markdown.matchAll(/^## (\d+)\. (.+)$/gm)];
const pages = new Map();
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
const number = Number(match[1]);
if (!Number.isInteger(number) || number < 1 || number > 23) continue;
const section = markdown.slice(match.index + match[0].length, matches[index + 1]?.index || markdown.length);
const h1 = /^# (.+)$/m.exec(section);
if (!h1) throw new Error(`В SEO-паке не найден H1 для раздела ${number}`);
const bodyStart = (h1.index || 0) + h1[0].length;
const page = {
number,
title: h1[1].trim(),
id: metadataValue(section, "ID"),
slug: metadataValue(section, "Slug"),
seoTitle: metadataValue(section, "SEO title"),
seoDescription: metadataValue(section, "SEO description"),
bodyHtml: markdownToHtml(section.slice(bodyStart)),
};
if (number > 1 && (!page.id || !page.slug || !page.seoTitle || !page.seoDescription)) {
throw new Error(`В SEO-паке неполные метаданные для «${page.title}»`);
}
pages.set(number, page);
}
if (pages.size !== 23 || !pages.has(1)) {
throw new Error(`Ожидалось 23 страницы SEO-пака, найдено ${pages.size}`);
}
return pages;
}
function walk(nodes, callback) {
for (const node of nodes || []) {
callback(node);
walk(node.children, callback);
}
}
function leadingMediaParagraph(html) {
return String(html || "").match(/^\s*(<p>\s*<(?:img|video)\b[\s\S]*?<\/p>)/i)?.[1] || "";
}
function materialFromPage(page, existing) {
const media = leadingMediaParagraph(existing?.bodyHtml);
return {
...(existing || {}),
id: page.id,
title: page.title,
slug: page.slug,
enabled: true,
useContent: true,
seo: {
title: page.seoTitle,
description: page.seoDescription,
},
bodyHtml: `${media ? `${media}\n` : ""}${page.bodyHtml}`,
children: [],
};
}
const treeOrder = [
{
id: "platform-overview",
children: ["hub", "engine", "ops", "integration-layer", "artifact-event-layer"],
},
{
id: "agent-context",
children: ["document-agent", "agent-catalog"],
},
{
id: "applied-modules",
children: [
"tender-agent",
"1c-integration",
"drone-management",
"city-digital-twins",
"industrial-contour",
"enterprise-management",
"construction-contour",
"seo-module",
"procurement-contour",
"project-office",
"video-analytics",
"engineering-contour",
],
},
];
const [markdown, rawKnowledge] = await Promise.all([
readFile(resolve(inputPath), "utf8"),
readFile(knowledgePath, "utf8"),
]);
const pages = parsePack(markdown);
const knowledge = JSON.parse(rawKnowledge);
const existingNodes = new Map();
walk(knowledge.tree, (node) => existingNodes.set(node.id, node));
const rootPage = pages.get(1);
const pageById = new Map([...pages.values()].filter((page) => page.number > 1).map((page) => [page.id, page]));
const materials = new Map([...pageById].map(([id, page]) => [id, materialFromPage(page, existingNodes.get(id))]));
for (const { id, children } of treeOrder) {
const node = materials.get(id);
if (!node) throw new Error(`Не найден раздел «${id}» в SEO-паке`);
node.children = children.map((childId) => {
const child = materials.get(childId);
if (!child) throw new Error(`Не найден дочерний раздел «${childId}» в SEO-паке`);
return child;
});
}
knowledge.title = rootPage.title;
knowledge.seo = {
...(knowledge.seo || {}),
title: rootPage.seoTitle,
description: rootPage.seoDescription,
};
knowledge.settings ||= {};
knowledge.settings.description = rootPage.seoDescription;
knowledge.introHtml = rootPage.bodyHtml;
knowledge.tree = treeOrder.map(({ id }) => materials.get(id));
const summary = {
pages: pages.size,
updated: [...pageById.keys()].filter((id) => existingNodes.has(id)).length,
added: [...pageById.keys()].filter((id) => !existingNodes.has(id)).length,
tree: knowledge.tree.map((node) => ({ id: node.id, children: node.children.map((child) => child.id) })),
};
if (!dryRun) {
await writeFile(knowledgePath, `${JSON.stringify(knowledge, null, 2)}\n`);
}
console.log(`${dryRun ? "Checked" : "Imported"} Knowledge SEO pack: ${JSON.stringify(summary)}`);
+4 -4
View File
@@ -548,7 +548,7 @@ function renderHeader(searchPlaceholder) {
}
function pageTitle(record = null) {
if (!record) return knowledge.title || "База знаний";
if (!record) return knowledge.seo?.title || knowledge.title || "База знаний";
return record.node.seo?.title || record.node.title || "Материал";
}
@@ -558,7 +558,7 @@ function visibleTitle(record = null) {
}
function pageDescription(record = null) {
if (!record) return knowledge.settings?.description || "";
if (!record) return knowledge.seo?.description || knowledge.settings?.description || "";
return record.node.seo?.description || stripHtml(renderKnowledgeShortcodes(record.node.bodyHtml)).slice(0, 180);
}
@@ -723,8 +723,8 @@ async function writePage(urlPath, html) {
async function writeSeoFiles(records) {
const sitemapUrls = [
{ loc: canonicalHome(), priority: "1.0" },
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.8" },
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: record.parents.length ? "0.6" : "0.7" })),
{ loc: canonicalForPath(`/${knowledgeBasePath()}/`), priority: "0.7" },
...records.map((record) => ({ loc: canonicalForPath(record.urlPath), priority: "0.7" })),
];
const sitemapUrl = new URL("sitemap.xml", canonicalHome()).href;
const robots = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");