/
xbobrov
/
Metal_Skill
Обзор
Документация
Войти
/
xbobrov
/
Metal_Skill
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/refresh-module-five.ts
365 строк
17 KB
artofmarktg-a11y
Prepare knowledge base release
09 авг 2026, 22:31
09 авг 2026, 22:31
86720a1
Код
Авторство
О чём код?
import "dotenv/config"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { prisma } from "../src/lib/db"; import { appendLessonMiniCheck } from "../src/lib/lesson-mini-checks"; import { appendLessonSalesScenario } from "../src/lib/lesson-sales-scenarios"; import { improveLessonWording } from "../src/lib/lesson-wording"; import { improveModuleFiveLessonContent, isModuleFiveChildModule, replaceClarificationPhrases, replaceLegacyClarificationMarkers, } from "../src/lib/module-five-content"; import { normalizeLessonHeadingHierarchy, removeLegacyLessonActions, repairLessonContrast, sanitizeLessonHtml, standardizeLessonPresentation, } from "../src/lib/sanitize-lesson-html"; type TrainingModule = { id: number; parent_id: number | null; order_num: number }; type TrainingLesson = { id: number; module_id: number; order_num: number; title: string; lesson_type: string; content: string; }; type TrainingData = { modules: TrainingModule[]; lessons: TrainingLesson[] }; type VideoEntry = { title: string; platform: string; description: string; url: string }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const trainingDataPath = path.resolve(__dirname, "../src/data/training-data.json"); const productParentId = 3; const orderedProductModuleIds = [14, 16, 17, 18, 10, 19, 20, 22, 15, 21]; const expectedChildModuleIds = [...orderedProductModuleIds].sort((a, b) => a - b); const legacyTestLessonTitle = "Итоговое тестирование"; const videoMaterialsLessonTitle = "Видеоматериалы"; const expectedLessonCount = 116; const legacyVideoSourceRevision = "f984e31"; function canonicalContent(lessonId: number, source: string, includeInteractions: boolean) { const correctedSource = replaceClarificationPhrases( lessonId, replaceLegacyClarificationMarkers(lessonId, source), ); const improved = improveLessonWording(lessonId, improveModuleFiveLessonContent(lessonId, correctedSource)); const interactive = includeInteractions ? appendLessonSalesScenario(lessonId, appendLessonMiniCheck(lessonId, improved)) : improved; return standardizeLessonPresentation( normalizeProductLessonHeadings( normalizeLessonHeadingHierarchy( ensureLessonTableWrappers(repairLessonContrast(removeLegacyLessonActions(interactive))), ), ), ); } function normalizeProductLessonHeadings(content: string) { return content .replace(/<h[3456]\b[^>]*>/gi, "<h2>") .replace(/<\/h[3456]>/gi, "</h2>"); } function ensureLessonTableWrappers(content: string) { return content.replace(/<table\b[\s\S]*?<\/table>/gi, (table, offset, source) => { const precedingMarkup = source.slice(Math.max(0, offset - 240), offset); const isWrapped = /<div\b[^>]*\bclass="[^"]*\blesson-table\b[^"]*"[^>]*>\s*$/i.test(precedingMarkup); return isWrapped ? table : `<div class="lesson-table">${table}</div>`; }); } function assertModuleFiveContent(lesson: TrainingLesson) { if (/\sstyle\s*=/i.test(lesson.content) || /<style\b/i.test(lesson.content)) { throw new Error(`В уроке ${lesson.id} остался встроенный стиль.`); } if (/<table\b/i.test(lesson.content) && !/<div\s+class="lesson-table">/i.test(lesson.content)) { throw new Error(`Таблица урока ${lesson.id} должна быть внутри .lesson-table.`); } if ([10, 16, 17, 18, 19].includes(lesson.module_id) && /<(?:td|th)\b[^>]*>\s*(?:<\/(?:td|th)>|(?=<(?:td|th)\b)|(?=<\/tr>))/i.test(lesson.content)) { throw new Error(`В таблице урока ${lesson.id} есть незаполненная ячейка.`); } if (/<h[134]\b/i.test(lesson.content)) { throw new Error(`В уроке ${lesson.id} нарушена иерархия заголовков.`); } if (!/\blesson-product-content\b/i.test(lesson.content)) { throw new Error(`В уроке ${lesson.id} не применён единый каркас продуктовой линейки.`); } } function escapeHtml(value: string) { return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character] || character); } function parseLegacyVideoEntries(content: string): VideoEntry[] { const rows = content.matchAll(/<tr><td><strong>([\s\S]*?)<\/strong><br><span[^>]*>([\s\S]*?)<\/span><\/td><td>([\s\S]*?)<\/td><td><a href="([^"]+)"/gi); return [...rows].map((match) => ({ title: match[1].replace(/<[^>]+>/g, "").trim(), platform: match[2].replace(/<[^>]+>/g, "").trim(), description: match[3].replace(/<[^>]+>/g, "").trim(), url: match[4].trim(), })); } function videoEmbedUrl(url: string) { try { const parsed = new URL(url); const hostname = parsed.hostname.replace(/^www\./, ""); if (hostname === "youtube.com" || hostname === "m.youtube.com") { const videoId = parsed.searchParams.get("v"); if (videoId && /^[\w-]{11}$/.test(videoId)) return `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`; } if (hostname === "youtu.be") { const videoId = parsed.pathname.slice(1); if (/^[\w-]{11}$/.test(videoId)) return `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`; } if (hostname === "rutube.ru") { const videoId = parsed.pathname.match(/^\/video\/([\w-]+)\/?$/)?.[1]; if (videoId) return `https://rutube.ru/play/embed/${videoId}`; } } catch { return null; } return null; } function targetLessonIdForVideo(moduleId: number, value: string) { const text = value.toLowerCase(); const has = (...parts: string[]) => parts.some((part) => text.includes(part)); if (moduleId === 10) return has("рез", "свар", "травлен", "пассивац", "очистк", "полиров", "пленк") ? 190 : has("кран", "арматур", "aisi 304", "aisi 316") ? 40 : 38; if (moduleId === 14) { if (has("а500", "а500с")) return 106; if (has("а3", "а400", "25г2с", "35гс")) return 105; if (has("а1", "а240", "гладк")) return 104; if (has("арматур", "вязк")) return 103; if (has("шестигран")) return 101; if (has("квадрат")) return 100; if (has("полос")) return 102; if (has("круг")) return 99; return has("вес", "метр", "тонн") ? 193 : 98; } if (moduleId === 16) { if (has("угол")) return 119; if (has("швеллер")) return 120; if (has("двутавр", "балк")) return 121; if (has("тавр", "рельс")) return 122; return has("рез", "сверл", "обработ") ? 125 : 118; } if (moduleId === 17) { if (has("горяч", "холоднокатан")) return 127; if (has("оцинк", "покрыт", "пленк")) return 130; if (has("рулон", "штрипс", "резк")) return 131; if (has("рифлен", "пвл", "настил")) return 132; return has("вес", "толщин", "формат") ? 128 : 126; } if (moduleId === 18) { if (has("профиль", "квадратн", "прямоугольн")) return 136; if (has("вгп", "электросвар", "бесшов", "кругл")) return 135; if (has("диаметр", "стенк", "ду", "вес")) return 137; if (has("нержав", "покрыт", "марка")) return 138; return has("рез", "резьб", "обработ") ? 140 : 134; } if (moduleId === 19) { if (has("р6м5", "р18", "быстрореж")) return 146; if (has("подшип")) return 147; if (has("штамп")) return 155; if (has("инструмент")) return 145; if (has("закал", "отпуск", "цемент", "термообработ")) return 151; if (has("легирован")) return 143; return 142; } if (moduleId === 20) { if (has("алюмин")) return 157; if (has("мед")) return 158; if (has("латун")) return 159; if (has("бронз")) return 160; if (has("свин")) return 166; if (has("цинк")) return 167; if (has("никел")) return 168; if (has("олов")) return 169; if (has("титан")) return 161; return 156; } if (moduleId === 22) { if (has("профнаст")) return 181; if (has("металлочереп")) return 182; if (has("фальц")) return 183; if (has("мембран", "пвх")) return 185; if (has("мягк", "рулон", "наплав")) return 184; if (has("добор", "водосток", "снег", "крепеж")) return 186; if (has("паро", "утепл", "гидро", "пирог")) return 187; if (has("расчет")) return 197; return 180; } if (moduleId === 15) { if (has("минераль", "минват", "pir", "pur", "пенополистир")) return 112; if (has("стенов", "кровельн", "холодиль", "перегород")) return 111; if (has("облицов", "клей", "замок")) return 110; if (has("толщин", "покрыт", "цвет", "размер")) return 113; if (has("добор", "крепеж", "узл")) return 114; if (has("пожар", "нагруз", "расклад")) return 198; if (has("монтаж", "хранен", "перевоз")) return 115; return 109; } if (moduleId === 21) { if (has("витая", "utp", "ftp", "lan", "интернет")) return 177; if (has("кввг", "контрольн")) return 175; if (has("кг", "пвс", "шввп", "гибк")) return 174; if (has("ввг", "силов", "nym")) return 172; if (has("маркиров")) return 171; if (has("одножиль", "многожиль", "медь", "алюмин")) return 199; return 170; } throw new Error(`Не настроено распределение видео для подраздела ${moduleId}.`); } function renderVideoCard(video: VideoEntry) { const title = escapeHtml(video.title); const platform = escapeHtml(video.platform); const description = escapeHtml(video.description); const url = escapeHtml(video.url); const embedUrl = videoEmbedUrl(video.url); const player = embedUrl ? `<div class="lesson-video-player"><iframe src="${embedUrl}" title="${title}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="allowfullscreen"></iframe></div>` : `<a class="lesson-video-link" href="${url}" target="_blank" rel="noopener noreferrer">Открыть на ${platform}</a>`; return `<article class="lesson-video-card">${player}<div class="lesson-video-card-body"><p class="lesson-video-platform">${platform}</p><p class="lesson-video-title">${title}</p><p>${description}</p>${embedUrl ? `<a class="lesson-video-link" href="${url}" target="_blank" rel="noopener noreferrer">Открыть на ${platform}</a>` : ""}</div></article>`; } function appendContextualVideos(data: TrainingData, videoMaterialsLessons: TrainingLesson[]) { const legacyData = JSON.parse(execFileSync( "git", ["show", `${legacyVideoSourceRevision}:src/data/training-data.json`], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, )) as TrainingData; const videosByLesson = new Map<number, VideoEntry[]>(); for (const videoMaterialsLesson of videoMaterialsLessons) { const originalLesson = legacyData.lessons.find((lesson) => lesson.id === videoMaterialsLesson.id); if (!originalLesson) throw new Error(`Не найден исходный список видео для урока ${videoMaterialsLesson.id}.`); const videos = parseLegacyVideoEntries(originalLesson.content); if (!videos.length) throw new Error(`В исходном списке видео урока ${videoMaterialsLesson.id} нет роликов.`); for (const video of videos) { const targetLessonId = targetLessonIdForVideo(videoMaterialsLesson.module_id, `${video.title} ${video.description}`); const grouped = videosByLesson.get(targetLessonId) || []; grouped.push(video); videosByLesson.set(targetLessonId, grouped); } } for (const [lessonId, videos] of videosByLesson) { const targetLesson = data.lessons.find((lesson) => lesson.id === lessonId); if (!targetLesson) throw new Error(`Не найден урок ${lessonId} для размещения видео.`); const section = `<section class="lesson-video-context"><h2>Видео к теме</h2><div class="lesson-video-grid">${videos.map(renderVideoCard).join("")}</div></section>`; targetLesson.content = targetLesson.content.replace(/<\/section>\s*$/i, `${section}</section>`); } } async function main() { const data = JSON.parse(fs.readFileSync(trainingDataPath, "utf8")) as TrainingData; const childModuleIds = data.modules .filter((module) => module.parent_id === productParentId) .map((module) => module.id) .sort((a, b) => a - b); if (JSON.stringify(childModuleIds) !== JSON.stringify(expectedChildModuleIds)) { throw new Error(`Неожиданный состав продуктовой линейки: ${childModuleIds.join(", ")}.`); } if (!childModuleIds.every(isModuleFiveChildModule)) { throw new Error("В продуктовой линейке найден неподдерживаемый подраздел."); } const legacyTestLessons = data.lessons.filter((lesson) => ( childModuleIds.includes(lesson.module_id) && lesson.title === legacyTestLessonTitle && lesson.lesson_type === "quiz" )); const legacyTestModuleIds = legacyTestLessons .map((lesson) => lesson.module_id) .sort((a, b) => a - b); if (legacyTestLessons.length && JSON.stringify(legacyTestModuleIds) !== JSON.stringify(expectedChildModuleIds)) { throw new Error("Не удалось однозначно найти старые страницы итогового тестирования продуктовой линейки."); } // У теста есть собственный отдельный маршрут в интерфейсе. Эти уроки были // устаревшими страницами-переходниками и дублировали его в содержании. const legacyTestLessonIds = legacyTestLessons.map((lesson) => lesson.id); data.lessons = data.lessons.filter((lesson) => !legacyTestLessonIds.includes(lesson.id)); const videoMaterialsLessons = data.lessons.filter((lesson) => ( childModuleIds.includes(lesson.module_id) && lesson.title === videoMaterialsLessonTitle && lesson.lesson_type === "theory" )); const videoMaterialsModuleIds = videoMaterialsLessons .map((lesson) => lesson.module_id) .sort((a, b) => a - b); if (videoMaterialsLessons.length && JSON.stringify(videoMaterialsModuleIds) !== JSON.stringify(expectedChildModuleIds)) { throw new Error("Не удалось однозначно найти старые уроки «Видеоматериалы» продуктовой линейки."); } if (videoMaterialsLessons.length) appendContextualVideos(data, videoMaterialsLessons); const videoMaterialsLessonIds = videoMaterialsLessons.map((lesson) => lesson.id); data.lessons = data.lessons.filter((lesson) => !videoMaterialsLessonIds.includes(lesson.id)); for (const [index, moduleId] of orderedProductModuleIds.entries()) { const module = data.modules.find((item) => item.id === moduleId); if (!module) { throw new Error(`Не найден подраздел продуктовой линейки ${moduleId}.`); } module.order_num = index + 1; } const lessons = data.lessons.filter((lesson) => childModuleIds.includes(lesson.module_id)); if (lessons.length !== expectedLessonCount) { throw new Error(`Ожидалось ${expectedLessonCount} уроков продуктовой линейки, найдено ${lessons.length}.`); } for (const lesson of lessons) { lesson.content = canonicalContent(lesson.id, lesson.content, false); assertModuleFiveContent(lesson); } fs.writeFileSync(trainingDataPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); await prisma.$transaction([ prisma.lesson.deleteMany({ where: { id: { in: legacyTestLessonIds } } }), prisma.lesson.deleteMany({ where: { id: { in: videoMaterialsLessonIds } } }), ...orderedProductModuleIds.map((moduleId, index) => prisma.module.update({ where: { id: moduleId }, data: { orderNum: index + 1 }, })), ...lessons.map((lesson) => prisma.lesson.update({ where: { id: lesson.id }, data: { orderNum: lesson.order_num, content: sanitizeLessonHtml(canonicalContent(lesson.id, lesson.content, true)), }, })), ], { timeout: 60_000 }); console.info(`Модуль 05 обновлён: ${lessons.length} уроков в исходных данных и локальной БД.`); } main() .catch((error: unknown) => { console.error(error); process.exitCode = 1; }) .finally(async () => prisma.$disconnect());