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"; import sharp from "sharp"; 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 host = process.env.HOST || "0.0.0.0"; const HTML_TIMEOUT_MS = 20000; const ASSET_TIMEOUT_MS = 12000; const MAX_ASSETS = 250; const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions"; const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-flash"; const EDGE_PATH = process.env.EDGE_PATH || "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; const APP_USERNAME = process.env.APP_USERNAME || "admin"; const APP_PASSWORD = process.env.APP_PASSWORD || ""; const AI_RATE_LIMIT_PER_HOUR = Math.max(1, Number(process.env.AI_RATE_LIMIT_PER_HOUR || 20)); 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, files: [] }; const previewSessions = new Map(); const approvalWaiters = new Map(); const assetDownloadPromises = new Map(); const aiRateLimits = new Map(); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host}`); if (!authorizeRequest(req, res)) return; 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") { if (progressState.active) { return sendJson(res, { ok: false, error: "A build is already running or waiting for preview approval.", buildId: progressState.buildId }, 409); } const body = await readJson(req); try { const result = await buildProject(body); return sendJson(res, result); } catch (error) { progressState.active = false; progressState.awaitingApproval = false; progressState.updatedAt = new Date().toISOString(); progressState.error = error.message; throw error; } } 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); // БЕРЁМ из session.staticPages (доступно сразу после скачивания) const staticPages = session.staticPages || []; const pages = 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 pageInfo = (session.staticPages || []).find((p) => p.file === file); 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"); const parts = splitPageHtml(html, pageInfo?.contentSelector); let content = parts.contentHtml || html; content = content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); return sendJson(res, { ok: true, file, content, protectedLayout: true, contentSelector: pageInfo?.contentSelector || parts.contentSelector }); } 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); let savedContent = unwrapGutenbergHtmlBlock(String(content || "")); savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); const validationError = validateEditableHtmlFragment(savedContent); if (validationError) return sendJson(res, { ok: false, error: validationError }, 400); const pageInfo = (session.staticPages || []).find((p) => p.file === file); if (!pageInfo) return sendJson(res, { ok: false, error: "Page metadata not found" }, 404); const filePath = path.join(session.staticDir, path.basename(file)); if (!filePath.startsWith(session.staticDir)) return sendJson(res, { ok: false, error: "Invalid path" }, 400); let fullHtml; try { fullHtml = await fs.readFile(filePath, "utf8"); } catch { return sendJson(res, { ok: false, error: "Static HTML file not found" }, 404); } const replacement = replaceEditableContentRegion(fullHtml, pageInfo.contentSelector, savedContent); if (!replacement.ok) return sendJson(res, { ok: false, error: replacement.error }, 400); await fs.writeFile(filePath, replacement.html, "utf8"); const gutenbergContent = htmlToGutenberg(savedContent); if (session.staticPages) { const page = session.staticPages.find((p) => p.file === file); if (page) page.gutenbergContent = gutenbergContent; } try { await updateBuildManifest(buildId, (manifest) => { const page = manifest.staticPages.find((p) => p.file === file); if (page) page.gutenbergContent = gutenbergContent; }); } catch {} return sendJson(res, { ok: true, protectedLayout: true, previewUrl: `/preview/${buildId}/pages/${encodeURIComponent(file)}?v=${Date.now()}` }); } if (req.method === "GET" && url.pathname === "/api/footer") { const buildId = url.searchParams.get("buildId"); const session = previewSessions.get(buildId); const firstPage = session?.staticPages?.[0]; if (!session || !firstPage) return sendJson(res, { ok: false, error: "Build session not found" }, 404); try { const filePath = path.join(session.staticDir, path.basename(firstPage.file)); const html = await fs.readFile(filePath, "utf8"); const footer = extractEditableFooterContent(html); if (!footer.ok) return sendJson(res, { ok: false, error: footer.error }, 400); const content = footer.content.replaceAll("{{ACI_ASSET_URL}}", `/preview/${buildId}/assets`); return sendJson(res, { ok: true, content }); } catch { return sendJson(res, { ok: false, error: "Footer source page not found" }, 404); } } if (req.method === "POST" && url.pathname === "/api/footer") { const body = await readJson(req); const { buildId, content } = body; const session = previewSessions.get(buildId); if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build session not found" }, 404); let savedContent = unwrapGutenbergHtmlBlock(String(content || "")); savedContent = savedContent.replaceAll(`/preview/${buildId}/assets`, "{{ACI_ASSET_URL}}"); const validationError = validateEditableHtmlFragment(savedContent); if (validationError) return sendJson(res, { ok: false, error: validationError }, 400); const targets = [ ...(session.staticPages || []).map((page) => path.join(session.staticDir, path.basename(page.file))), ...(session.casinoPreviewPages || []).map((page) => path.join(session.casinoPreviewDir, path.basename(page.file))) ]; try { for (const filePath of targets) { const html = await fs.readFile(filePath, "utf8"); const replaced = replaceEditableFooterRegion(html, savedContent); if (!replaced.ok) throw new Error(replaced.error); await fs.writeFile(filePath, replaced.html, "utf8"); } session.footerHtml = savedContent; return sendJson(res, { ok: true, previewVersion: Date.now() }); } catch (error) { return sendJson(res, { ok: false, error: error.message }, 400); } } if (req.method === "GET" && url.pathname === "/api/ai/status") { const buildId = url.searchParams.get("buildId"); const session = buildId ? previewSessions.get(buildId) : null; return sendJson(res, { ok: true, configured: Boolean(process.env.DEEPSEEK_API_KEY), model: DEEPSEEK_MODEL, available: Boolean(session?.staticPages?.length), canUndo: Boolean(session?.ai?.undoStack?.length) }); } if (req.method === "POST" && url.pathname === "/api/ai/chat") { const rateLimit = consumeAiRateLimit(req); if (!rateLimit.ok) { res.setHeader("Retry-After", String(rateLimit.retryAfter)); return sendJson(res, { ok: false, error: `AI request limit reached. Try again in ${Math.ceil(rateLimit.retryAfter / 60)} minutes.` }, 429); } const body = await readJson(req); const session = previewSessions.get(body.buildId); if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); if (!process.env.DEEPSEEK_API_KEY) return sendJson(res, { ok: false, error: "DEEPSEEK_API_KEY is not configured." }, 503); const message = String(body.message || "").trim(); if (!message) return sendJson(res, { ok: false, error: "Write a request for AI Review." }, 400); const result = await createAiReviewProposal(body.buildId, session, message, body.activePath); return sendJson(res, { ok: true, ...result }); } if (req.method === "POST" && url.pathname === "/api/ai/apply") { const body = await readJson(req); const session = previewSessions.get(body.buildId); if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); const result = await applyAiProposal(body.buildId, session, String(body.proposalId || "")); return sendJson(res, { ok: true, ...result }); } if (req.method === "POST" && url.pathname === "/api/ai/undo") { const body = await readJson(req); const session = previewSessions.get(body.buildId); if (!session?.staticPages?.length) return sendJson(res, { ok: false, error: "Build preview is not ready." }, 409); const result = await undoAiProposal(session); return sendJson(res, { ok: true, ...result }); } 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, ...(APP_PASSWORD ? {} : { stack: error.stack }) }, 500); } }); const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isMainModule) { console.log(`Starting Archive Casino WP Builder on ${host}:${port}...`); server.listen(port, host, () => { console.log(`Archive Casino WP Builder is running at http://${host}:${port}/`); console.log(APP_PASSWORD ? `Password protection is enabled for user ${APP_USERNAME}.` : "Password protection is disabled."); }); } function authorizeRequest(req, res) { if (!APP_PASSWORD) return true; const header = String(req.headers.authorization || ""); if (header.startsWith("Basic ")) { try { const credentials = Buffer.from(header.slice(6), "base64").toString("utf8"); const separator = credentials.indexOf(":"); const username = separator >= 0 ? credentials.slice(0, separator) : ""; const password = separator >= 0 ? credentials.slice(separator + 1) : ""; if (safeEqual(username, APP_USERNAME) && safeEqual(password, APP_PASSWORD)) return true; } catch {} } res.writeHead(401, { "WWW-Authenticate": 'Basic realm="Archive Casino Builder", charset="UTF-8"', "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }); res.end("Authorization required."); return false; } function safeEqual(left, right) { const leftBuffer = Buffer.from(String(left)); const rightBuffer = Buffer.from(String(right)); if (leftBuffer.length !== rightBuffer.length) return false; return crypto.timingSafeEqual(leftBuffer, rightBuffer); } function consumeAiRateLimit(req) { const now = Date.now(); const windowMs = 60 * 60 * 1000; const forwarded = String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || ""); const clientId = forwarded.split(",")[0].trim() || req.socket.remoteAddress || "unknown"; const current = aiRateLimits.get(clientId); const entry = !current || now - current.startedAt >= windowMs ? { startedAt: now, count: 0 } : current; if (entry.count >= AI_RATE_LIMIT_PER_HOUR) { return { ok: false, retryAfter: Math.max(1, Math.ceil((entry.startedAt + windowMs - now) / 1000)) }; } entry.count += 1; aiRateLimits.set(clientId, entry); if (aiRateLimits.size > 500) { for (const [key, value] of aiRateLimits) { if (now - value.startedAt >= windowMs) aiRateLimits.delete(key); } } return { ok: true }; } 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 casinoPreviewDir = path.join(buildDir, "casino-preview"); const aiReviewDir = path.join(buildDir, "ai-review"); const logs = []; progressState = { active: true, buildId, updatedAt: new Date().toISOString(), logs: [], summary: { pages: 0, assets: 0, casinoPages: config.casinoPages.length }, awaitingApproval: false, preview: null, files: [] }; 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(casinoPreviewDir, { recursive: true }); await fs.mkdir(aiReviewDir, { recursive: true }); await fs.mkdir(themeDir, { recursive: true }); const assetMap = new Map(); const cssTexts = []; const staticPages = []; const failedPages = []; previewSessions.set(buildId, { buildDir, staticDir, assetDir, casinoPreviewDir, aiReviewDir, config, ai: { messages: [], proposals: new Map(), undoStack: [] } }); if (config.crawlDomain) { config.urls = await discoverArchiveUrls(config, progressLogs); progressLogs.push(`Domain discovery selected ${config.urls.length} page(s) for this build.`); } let shellHtmlGlobal = ""; // шапка главной страницы — пойдёт в header.php темы let footerHtmlGlobal = ""; // футер — в footer.php for (const sourceUrl of config.urls) { progressLogs.push(`Resolving ${sourceUrl}`); let snapshot; try { snapshot = await resolveSnapshot(sourceUrl, config); } catch (resolveErr) { progressLogs.push(`⚠ SKIP: не удалось найти снимок для ${sourceUrl} (${resolveErr.message}) — пропускаем`); failedPages.push({ url: sourceUrl, reason: resolveErr.message }); continue; } progressLogs.push(`Using snapshot ${snapshot.timestamp} for ${snapshot.original}`); const htmlUrl = `https://web.archive.org/web/${snapshot.timestamp}id_/${snapshot.original}`; let rawHtml; try { rawHtml = await fetchText(htmlUrl); } catch (fetchErr) { progressLogs.push(`⚠ SKIP: не удалось скачать ${snapshot.original} (${fetchErr.message}) — пропускаем`); failedPages.push({ url: sourceUrl, reason: fetchErr.message }); continue; } let html = cleanWaybackHtml(rawHtml); html = await rewriteHtmlAssets(html, snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs); if (!config._reservationAssetChecked && /(?:\?rezervace|Rezervace|rozm[ií]stěn[ií] stolů)/i.test(html)) { config._reservationAssetChecked = true; config._reservationAssetFile = await tryDownloadReservationPlan( snapshot.original, snapshot.timestamp, assetDir, assetMap, cssTexts, progressLogs ); } html = rewriteInternalSiteUrls(html, config.sourceDomain, config.targetDomain) .replaceAll("{{ACI_SITE_URL}}", ""); // === Фаза очистки ссылок (авто-удаление 404 + внешних) === const cleaned = await cleanExternalLinks(html, config.targetDomain || config.sourceDomain, progressLogs); html = cleaned.cleanedHtml; html = normalizeStaticHtmlForTheme(html, config); html = applySafeTextReplacements(html, config.textReplacements); if (config.textReplacements.length) { progressLogs.push(`Applied ${config.textReplacements.length} safe text replacement(s) without changing HTML/CSS structure.`); } const preparedContent = ensureEditableContentWrapper(html, config.contentSelector); html = preparedContent.html; const markedFooter = ensureEditableFooterMarkers(html); if (!markedFooter.ok) throw new Error(`Cannot prepare editable footer for ${snapshot.original}: ${markedFooter.error}`); html = markedFooter.html; // === Разборка на части (shell / content / footer) === const parts = splitPageHtml(html, preparedContent.selector); 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(/]*id=["']aci-casino-menu-style["'][^>]*>[\s\S]*?<\/style>/i, css); else html = html.replace(/<\/head>/i, css + ''); if (html.includes('aci-casino-menu-script')) html = html.replace(/]*id=["']aci-casino-menu-script["'][^>]*>[\s\S]*?<\/script>/i, js); else html = html.replace(/<\/body>/i, js + ''); return html; } function removeUnavailableAssetReference(html, item) { const raw = escapeRegex(item.raw); if (item.attr.toLowerCase() === "src") { html = html.replace(new RegExp(`]*\\bsrc=(["'])${raw}\\1[^>]*>`, "gi"), ""); html = html.replace(new RegExp(`]*\\bsrc=(["'])${raw}\\1[^>]*>[\\s\\S]*?<\\/script\\s*>`, "gi"), ""); return html.replace(new RegExp(`\\bsrc=(["'])${raw}\\1`, "gi"), ""); } html = html.replace(new RegExp(`]*\\bhref=(["'])${raw}\\1[^>]*>`, "gi"), ""); return html.replace(new RegExp(`\\bhref=(["'])${raw}\\1`, "gi"), 'href="#"'); } function pruneStaticMenus(html, config) { const menu = fixedProjectMenuHtml(config); const replaced = replaceEditableContentRegion(html, "menu", `
      ${menu}
    `); if (replaced.ok) html = replaced.html; return injectStaticCasinoAccordion(html); } function removeCommercialArtifacts(html, config) { const allowedHosts = new Set([config.sourceDomain, config.targetDomain] .filter(Boolean) .map((host) => String(host).replace(/^https?:\/\//i, "").replace(/^www\./i, "").split("/")[0].toLowerCase())); html = html .replace(/]*name=["']author["'][^>]*(?:jirout|reklamn[ií]\s+agentura)[^>]*\/?\s*>/gi, "") .replace(/]*>[\s\S]*?<\/script>/gi, (script) => /(?:google-analytics\.com|googletagmanager\.com|GoogleAnalyticsObject|\b_gaq\b)/i.test(script) ? "" : script ) .replace(/]*>\s*]*class=["'][^"']*nadpis[^"']*["'][^>]*>\s*Facebook\s*<\/p>[\s\S]*?<\/li>/gi, "") .replace(/]*>[\s\S]*?<\/p>/gi, (paragraph) => /(?:jirout|reklamn[ií]\s+agentura)/i.test(paragraph) ? "" : paragraph ) .replace(/]*>[\s\S]*?<\/iframe>/gi, "") .replace(/]*\/?\s*>/gi, "") .replace(/href=["'][^"']*mailto:\s*([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})[^"']*["']/gi, 'href="mailto:$1"'); return html.replace(/]*)\bhref=["'](https?:\/\/[^"']+)["']([^>]*)>([\s\S]*?)<\/a>/gi, (whole, pre, href, post, inner) => { try { const host = new URL(href).hostname.replace(/^www\./i, "").toLowerCase(); if ([...allowedHosts].some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) return whole; } catch {} return inner; }); } function injectReservationDetails(html, config) { if (!/(?:\?rezervace|Pl[aá]nek rozm[ií]stěn[ií] stolů)/i.test(html)) return html; html = html.replace(/href=["'](?:[^"']*\?)?rezervace(?:#[^"']*)?["']/gi, 'href="#rezervace-plan"'); if (html.includes('id="rezervace-plan"')) return html; const image = config._reservationAssetFile ? `Rozmístění stolů v restauraci` : ""; const css = ''; const section = `
    ×

    Rozmístění stolů v restauraci

    Pro rezervování stolu prosím použijte telefon 606 045 035.

    ${image}
    `; if (!html.includes('aci-reservation-style')) html = html.replace(/<\/head>/i, css + ''); return html.replace(/<\/body>/i, section + ''); } function normalizeStaticHtmlForTheme(html, config) { html = html.replace(/