import http from "node:http"; import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import crypto from "node:crypto"; import { spawn } from "node:child_process"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const outputsDir = path.join(__dirname, "outputs"); const publicDir = path.join(__dirname, "public"); const buildRoot = path.join(__dirname, "build"); const port = Number(process.env.PORT || 8765); const HTML_TIMEOUT_MS = 20000; const ASSET_TIMEOUT_MS = 12000; const MAX_ASSETS = 250; const mimeTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".ico": "image/x-icon", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".zip": "application/zip" }; let progressState = { active: false, buildId: null, updatedAt: null, logs: [], summary: null, awaitingApproval: false, preview: null }; const previewSessions = new Map(); const approvalWaiters = new Map(); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host}`); if (req.method === "GET" && url.pathname === "/api/status") { return sendJson(res, { ok: true, port, outputsDir }); } if (req.method === "GET" && url.pathname === "/api/progress") { return sendJson(res, { ok: true, ...progressState }); } if (req.method === "POST" && url.pathname === "/api/continue") { const body = await readJson(req); const waiter = approvalWaiters.get(body.buildId); if (!waiter) return sendJson(res, { ok: false, error: "No build is waiting for approval." }, 404); approvalWaiters.delete(body.buildId); progressState.awaitingApproval = false; progressState.preview = null; progressState.updatedAt = new Date().toISOString(); waiter(); return sendJson(res, { ok: true }); } if (req.method === "POST" && url.pathname === "/api/build") { const body = await readJson(req); const result = await buildProject(body); return sendJson(res, result); } if (req.method === "GET" && url.pathname.startsWith("/download/")) { const name = path.basename(decodeURIComponent(url.pathname.replace("/download/", ""))); const file = path.join(outputsDir, name); if (!file.startsWith(outputsDir)) return notFound(res); return sendFile(res, file, true); } if (req.method === "GET" && url.pathname.startsWith("/preview/")) { return sendPreviewFile(res, url.pathname); } // === API РЕДАКТОРА СТРАНИЦ === // GET /api/pages?buildId=... — список страниц для редактора if (req.method === "GET" && url.pathname === "/api/pages") { const buildId = url.searchParams.get("buildId"); const session = previewSessions.get(buildId); if (!session) return sendJson(res, { ok: false, error: "Build session not found" }, 404); const manifest = await getBuildManifest(buildId); const pages = (manifest?.staticPages || []).map((p) => ({ title: p.title, path: p.path, file: p.file, url: `/preview/${buildId}/pages/${encodeURIComponent(p.file)}` })); return sendJson(res, { ok: true, buildId, pages }); } // GET /api/page?buildId=...&file=... — получить HTML страницы для редактора if (req.method === "GET" && url.pathname === "/api/page") { const buildId = url.searchParams.get("buildId"); const file = url.searchParams.get("file"); const session = previewSessions.get(buildId); if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400); const filePath = path.join(session.staticDir, path.basename(file)); if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); try { let html = await fs.readFile(filePath, "utf8"); // подставить плейсхолдеры для корректного отображения в редакторе html = html.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); html = html.replaceAll("{{ACI_SITE_URL}}", "/"); return sendJson(res, { ok: true, file, content: html }); } catch { return sendJson(res, { ok: false, error: "File not found" }, 404); } } // POST /api/page — сохранить отредактированный HTML if (req.method === "POST" && url.pathname === "/api/page") { const body = await readJson(req); const { buildId, file, content } = body; const session = previewSessions.get(buildId); if (!session || !file) return sendJson(res, { ok: false, error: "Invalid request" }, 400); const filePath = path.join(session.staticDir, path.basename(file)); if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); // вернуть плейсхолдеры обратно перед сохранением let html = content; html = html.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); html = html.replaceAll("/", "{{ACI_SITE_URL}}").replaceAll("{{ACI_SITE_URL}}/", "/"); // аккуратно await fs.writeFile(filePath, html, "utf8"); // обновить gutenbergContent в manifest await updateBuildManifest(buildId, (manifest) => { const page = manifest.staticPages.find((p) => p.file === file); if (page) { const parts = splitPageHtml(html, manifest.contentSelector); page.gutenbergContent = htmlToGutenberg(parts.contentHtml); } }); return sendJson(res, { ok: true }); } const requested = url.pathname === "/" ? "/index.html" : url.pathname; const file = path.join(publicDir, decodeURIComponent(requested)); if (!file.startsWith(publicDir)) return notFound(res); return sendFile(res, file); } catch (error) { sendJson(res, { ok: false, error: error.message, stack: error.stack }, 500); } }); server.listen(port, () => { console.log(`Archive Casino WP Builder is running at http://localhost:${port}/`); }); async function buildProject(input) { const config = normalizeConfig(input); const buildId = `${Date.now()}-${slugify(config.targetDomain || "site")}`; const buildDir = path.join(buildRoot, buildId); const themeDir = path.join(buildDir, slugify(config.themeName)); const dataDir = path.join(themeDir, "data"); const staticDir = path.join(dataDir, "static-html"); const assetDir = path.join(dataDir, "archive-assets"); const logs = []; progressState = { active: true, buildId, updatedAt: new Date().toISOString(), logs: [], summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length }, awaitingApproval: false, preview: null }; const progressLogs = { push(message) { logs.push(message); setProgress(message, { summary: { pages: staticPages.length, assets: assetMap.size, casinoPages: config.casinoPages.length } }); } }; await fs.mkdir(outputsDir, { recursive: true }); await fs.mkdir(staticDir, { recursive: true }); await fs.mkdir(assetDir, { recursive: true }); await fs.mkdir(themeDir, { recursive: true }); const assetMap = new Map(); const cssTexts = []; const staticPages = []; previewSessions.set(buildId, { buildDir, staticDir, assetDir }); let shellHtmlGlobal = ""; // шапка главной страницы — пойдёт в header.php темы let footerHtmlGlobal = ""; // футер — в footer.php for (const sourceUrl of config.urls) { progressLogs.push(`Resolving ${sourceUrl}`); const snapshot = await resolveSnapshot(sourceUrl, config); progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`); const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`; const rawHtml = await fetchText(htmlUrl); let html = cleanWaybackHtml(rawHtml); html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs); html = html .replaceAll(snapshot.original, config.targetDomain ? `https://${config.targetDomain}` : snapshot.original) .replaceAll("{{ACI_SITE_URL}}", ""); // === Фаза очистки ссылок (авто-удаление 404 + внешних) === const cleaned = await cleanExternalLinks(html, config.sourceDomain, progressLogs); html = cleaned.cleanedHtml; // === Разборка на части (shell / content / footer) === const parts = splitPageHtml(html, config.contentSelector); progressLogs.push(`Split: контент через "${parts.contentSelector}", content=${parts.contentHtml.length}b, shell=${parts.shellHtml.length}b`); // запомнить shell/footer главной страницы (первой) if (!shellHtmlGlobal && parts.shellHtml) { shellHtmlGlobal = parts.shellHtml; footerHtmlGlobal = parts.footerHtml; } // === Конвертация контента в Gutenberg-блоки === const gutenbergContent = htmlToGutenberg(parts.contentHtml); const blockCount = (gutenbergContent.match(//gi, "") .replace(/]*(?:webarchive|archive_analytics|wombat|wayback|_static\/js)[^>]*>[\s\S]*?<\/script>/gi, "") .replace(/]*(?:webarchive|_static)[^>]*>/gi, "") .replace(/]+id=["']wm-ipp["'][\s\S]*?<\/div>\s*<\/div>\s*<\/div>/gi, "") .replace(/]+id=["']wm-ipp["'][\s\S]*?<\/div>/gi, "") .replace(/\s*__wm\.[^<]+/gi, ""); } function patchCasinoMenu(html, label, href) { if (new RegExp(`href=["'][^"']*${escapeRegex(href)}[^"']*["']`, "i").test(html)) return html; const link = `
  • ${escapeHtml(label)}
  • `; const menuPattern = /(<(?:div|nav)[^>]+class=["'][^"']*(?:menu|nav|navigation)[^"']*["'][^>]*>[\s\S]*?]*>[\s\S]*?)(<\/ul>)/i; if (menuPattern.test(html)) { return html.replace(menuPattern, `$1${link}$2`); } const navPattern = /()/i; if (navPattern.test(html)) { return html.replace(navPattern, (whole, navBody, close) => { if (/<\/ul>/i.test(navBody)) return `${navBody.replace(/<\/ul>/i, `${link}`)}${close}`; return `${navBody}${escapeHtml(label)}${close}`; }); } return html; } function chunkString(value, size = 7000) { const chunks = []; for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size)); return chunks; } function phpString(value) { return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; } function phpChunkArray(base64) { return `[${chunkString(base64).map(phpString).join(",")}]`; } async function listFilesRecursive(dir) { try { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...await listFilesRecursive(full)); else files.push(full); } return files; } catch { return []; } } function mimeForAssetFile(file, data) { const ext = path.extname(file).toLowerCase(); const fallback = mimeTypes[ext] || "application/octet-stream"; if (fallback === "application/octet-stream") { const sample = data.subarray(0, 256).toString("utf8").trimStart().toLowerCase(); if (sample.startsWith(" '
  • ' + escapeHtml(item.title) + '
  • ').join(""); const casinoChildren = (config.casinoPages || []).map((page) => '
  • ' + escapeHtml(page.title) + '
  • ').join(""); return staticItems + '
    • ' + casinoChildren + '
  • '; } function injectStaticCasinoAccordion(html) { const css = ''; const js = ''; if (!html.includes('aci-casino-menu-style')) html = html.replace(/<\/head>/i, css + ''); if (!html.includes('aci-casino-menu-script')) html = html.replace(/<\/body>/i, js + ''); return html; } function pruneStaticMenus(html, config) { const menu = fixedProjectMenuHtml(config); html = html.replace(/(
    \s*]*>)[\s\S]*?(<\/ul>)/i, '$1' + menu + '$2'); return injectStaticCasinoAccordion(html); } function normalizeStaticHtmlForTheme(html, config) { html = html.replace(/