NODEDC_SITE/tools/import-knowledge-seo-pack.mjs

263 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}`);