/* eslint-env node */ /** * 多品牌静态首页生成器(构建后处理) * ============================================================ * 目的: * 一套 React 产物(build/)服务于 sites_config 中的 N 个品牌站点。 * 首页 index.html 里的 SEO/分享元信息(、description、og:* 等) * 原本是 %%占位符%%,靠 load.m.js 在浏览器里运行时按 hostname 改写。 * 但爬虫 / 分享卡片抓取时往往不执行 JS,拿到的还是占位符。 * * 本脚本在 `craco build` + hash-loadm 之后运行: * 1) 从 public/js/load.m.js 提取 sites_config / regions / HOST_ALIAS(单一数据源,不复制配置)。 * 2) 以 build/index.html(已含带 hash 的资源引用)为模板, * 逐站点把 %%占位符%% 替换成该品牌的真实内容,写出 build/<folder>/index.html。 * 3) 顺带生成配套的 CloudFront Function(host → folder,运行时 cloudfront-js-2.0), * 写到项目根 cloudfront-host-to-folder.js,逻辑与 load.m.js 的 getSiteConfig() * 路由保持一致,保证「静态首页」与「运行时」选到同一个品牌。 * * folder(文件夹名)= sites_config 的 key = 域名主名(second-level label)。 * 例:mintpayouts.com → folder "mintpayouts";usslot777.com → 走 HOST_ALIAS → "default"。 * * 用法:node scripts/generate-sites.js (已挂到 package.json 的 build 脚本) */ const fs = require("fs"); const path = require("path"); const vm = require("vm"); const ROOT = path.resolve(__dirname, ".."); // 配置单一数据源:public/js/load.m.js(不在别处复制一份)。 const LOADM = path.join(ROOT, "public", "js", "load.m.js"); // 模板与输出都在 build/:它已被 craco/hash 处理过(带 hash 的 load.m.js / main.js / css)。 const TEMPLATE = path.join(ROOT, "build", "index.html"); const OUT_DIR = path.join(ROOT, "build"); // CloudFront Function 生成路径(在项目根,不在 build/ 内,避免被下次 build 清空)。 const CF_OUT = path.join(ROOT, "cloudfront-host-to-folder.js"); // 与 load.m.js applyPageMeta() 保持一致的标题后缀(运行时也用这个,避免标题闪烁)。 const TITLE_SUFFIX = "Online Slots & Real Money Games"; /* ───────────────────────── 1. 提取 load.m.js 中的配置 ───────────────────────── */ /** * sites_config / regions / themes / res_path 都是 IIFE 内的私有 const,无法 require。 * 但从 `const res_path` 到 `const getPathPrefix`(第一个用到 window/document 的函数)之间, * 全是纯数据声明(res_path、各主题对象、regions、DEFAULT_REGION、sites_config), * 不依赖任何浏览器 API。整段在禁用动态代码生成、无 Node 全局对象的隔离上下文中求值, * 避免构建时把配置文件当作拥有 Node 权限的任意代码执行。 */ function extractConfig(src) { const start = src.indexOf("const res_path"); const end = src.indexOf("const getPathPrefix"); if (start === -1 || end === -1 || end <= start) { throw new Error("[generate-sites] 无法在 load.m.js 中定位配置数据块(res_path … getPathPrefix)"); } const block = src.slice(start, end); const result = evaluateData( block + "\n;({ res_path, sites_config, regions, DEFAULT_REGION });", "config" ); if (!result || typeof result.sites_config !== "object" || typeof result.regions !== "object") { throw new Error("[generate-sites] 配置数据结构无效"); } return result; } function evaluateData(source, label) { const context = vm.createContext(Object.create(null), { name: `generate-sites-${label}`, codeGeneration: { strings: false, wasm: false }, }); const result = vm.runInContext(`"use strict";\n${source}`, context, { timeout: 1000 }); // Strip cross-realm prototypes and retain JSON-compatible configuration data only. return JSON.parse(JSON.stringify(result)); } /** * HOST_ALIAS 在 getSiteConfig() 内部,单独用正则切出对象字面量再求值。 * 该字面量没有嵌套大括号,第一个 `}` 即结束。允许内部含注释/尾逗号。 */ function extractHostAlias(src) { const m = src.match(/const\s+HOST_ALIAS\s*=\s*(\{[\s\S]*?\})\s*;/); if (!m) return {}; try { const result = evaluateData(`(${m[1]});`, "host-alias"); return result && typeof result === "object" ? result : {}; } catch (e) { console.warn("[generate-sites] 解析 HOST_ALIAS 失败,按空表处理:" + e.message); return {}; } } /* ───────────────────────── 2. 占位符替换 ───────────────────────── */ const escAttr = (s) => String(s == null ? "" : s) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); function buildReplacements(key, conf, regions, DEFAULT_REGION, domainFor) { const region = regions[conf.region || DEFAULT_REGION] || regions[DEFAULT_REGION] || {}; const suffix = region.titleSuffix || TITLE_SUFFIX; // 运行时 applyPageMeta 用固定后缀;如需按区域可改 region.titleSuffix const fullTitle = `${conf.title} | ${suffix}`; const domain = domainFor(key); const origin = `https://${domain}`; const ogImage = conf.icon ? origin + conf.icon : ""; // theme-color:用各主题共有的深色背景(与首屏 loading 背景一致),运行时 setThemeColor 也会接管。 const themeColor = (conf.theme && (conf.theme.bodyBgColor || conf.theme.mainBgColor)) || "#0d1016"; return { "%%BRAND_TITLE%%": escAttr(fullTitle), "%%BRAND_DESCRIPTION%%": escAttr(conf.subtitle), "%%THEME_COLOR%%": escAttr(themeColor), "%%BRAND_NAME%%": escAttr(conf.title), "%%BRAND_OG_TITLE%%": escAttr(fullTitle), "%%BRAND_OG_DESCRIPTION%%": escAttr(conf.subtitle), "%%BRAND_OG_IMAGE%%": escAttr(ogImage), "%%BRAND_URL%%": escAttr(origin + "/"), "%%FAV_ICO%%": escAttr(conf.favicon || "/resources/favicons/favicon.ico"), }; } function applyReplacements(template, map) { let html = template; for (const token in map) { html = html.split(token).join(map[token]); } return html; } /* ───────────────────────── 3. CloudFront Function 生成 ───────────────────────── */ /** * 生成 host → folder 的 CloudFront Function(viewer-request,运行时 cloudfront-js-2.0)。 * 逻辑对齐 load.m.js getSiteConfig():匹配完整域名或任意子域名下的 HOST_ALIAS, * 再从主域名方向查找品牌标签 → 校验 folder 是否存在 → 否则 default。 * 仅改写「文档请求」("/"、扩展名为空的 SPA 路由、index.html);静态资源(带扩展名)保持原路径,从根目录回源。 */ function generateCloudFrontFn(folders, hostAlias) { const foldersMap = {}; folders.forEach((f) => (foldersMap[f] = true)); const foldersJson = JSON.stringify(foldersMap, null, 2); const aliasJson = JSON.stringify(hostAlias, null, 2); return `/* eslint-disable */ /** * CloudFront Function (viewer-request) —— host → 品牌文件夹路由 * ============================================================ * 由 scripts/generate-sites.js 自动生成,请勿手改(改 sites_config 后重跑 npm run build)。 * * 作用:按访问域名把「文档请求」改写到 /<folder>/index.html,命中对应品牌的静态首页。 * 静态资源(带扩展名,如 .js/.css/.webp/.ico)原样透传,从桶根目录回源。 * 回源到 S3 前先把 URI 里连续的 // 合并为 /(等价 nginx merge_slashes on), * 避免 //a//b.js 这类多斜杠路径在 S3 里 key 不匹配而 404(S3 不做斜杠归一)。 * 运行时:cloudfront-js-2.0。 * 关联:与 public/js/load.m.js 的 getSiteConfig() 路由保持一致。 */ var FOLDERS = ${foldersJson}; var HOST_ALIAS = ${aliasJson}; function pickFolder(rawHost) { var host = (rawHost || "").toLowerCase(); var colon = host.indexOf(":"); if (colon !== -1) host = host.slice(0, colon); host = host.replace(/\\.$/, ""); // 别名同时匹配裸域名和任意层级子域名,例如 m.ukslot777.com。 for (var aliasHost in HOST_ALIAS) { if (host === aliasHost || host.endsWith("." + aliasHost)) { return FOLDERS[HOST_ALIAS[aliasHost]] === true ? HOST_ALIAS[aliasHost] : "default"; } } // 从主域名方向查找品牌标签,例如 app.m.blazingreels777.com → blazingreels777。 var parts = host.split("."); for (var i = parts.length - 1; i >= 0; i--) { if (FOLDERS[parts[i]] === true) return parts[i]; } return "default"; } // nginx merge_slashes:把路径里 2 个及以上连续的 / 压成 1 个(保留开头的单个 /)。 function mergeSlashes(uri) { return uri.replace(/\\/{2,}/g, "/"); } function isDocument(uri) { var lastSeg = uri.slice(uri.lastIndexOf("/") + 1); // 文档:目录请求(结尾 /)、无扩展名的 SPA 路由、或显式 index.html return lastSeg === "" || lastSeg === "index.html" || !lastSeg.includes("."); } function handler(event) { var request = event.request; var host = request.headers["host"] ? request.headers["host"].value : ""; // 先做斜杠归一,再判定文档/资源;无论改写与否,回源到 S3 的 URI 都是归一后的。 request.uri = mergeSlashes(request.uri); if (isDocument(request.uri)) { request.uri = "/" + pickFolder(host) + "/index.html"; } return request; } `; } /* ───────────────────────── 4. 主流程 ───────────────────────── */ function main() { if (!fs.existsSync(LOADM)) { console.error("[generate-sites] 找不到 " + LOADM); process.exit(1); } if (!fs.existsSync(TEMPLATE)) { console.warn("[generate-sites] 跳过:未找到模板 " + TEMPLATE + "(请先执行 craco build)"); process.exit(0); } const src = fs.readFileSync(LOADM, "utf8"); const { sites_config, regions, DEFAULT_REGION } = extractConfig(src); const hostAlias = extractHostAlias(src); const template = fs.readFileSync(TEMPLATE, "utf8"); // folder → 规范域名:优先取 HOST_ALIAS 的反向映射,否则默认 <key>.com(og:url / og:image 用)。 const reverseAlias = {}; for (const domain in hostAlias) { const folder = hostAlias[domain]; if (!reverseAlias[folder]) reverseAlias[folder] = domain; } const domainFor = (key) => reverseAlias[key] || key + ".com"; const keys = Object.keys(sites_config); let count = 0; for (const key of keys) { const conf = sites_config[key]; const map = buildReplacements(key, conf, regions, DEFAULT_REGION, domainFor); const html = applyReplacements(template, map); const dir = path.join(OUT_DIR, key); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "index.html"), html); count++; } // 生成 CloudFront Function(host → folder) const cf = generateCloudFrontFn(keys, hostAlias); fs.writeFileSync(CF_OUT, cf); console.log( "[generate-sites] 已生成 " + count + " 个品牌首页 -> build/<folder>/index.html;CloudFront Function -> " + path.relative(ROOT, CF_OUT) ); } main();