generate-sites.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. /* eslint-env node */
  2. /**
  3. * 多品牌静态首页生成器(构建后处理)
  4. * ============================================================
  5. * 目的:
  6. * 一套 React 产物(build/)服务于 sites_config 中的 N 个品牌站点。
  7. * 首页 index.html 里的 SEO/分享元信息(<title>、description、og:* 等)
  8. * 原本是 %%占位符%%,靠 load.m.js 在浏览器里运行时按 hostname 改写。
  9. * 但爬虫 / 分享卡片抓取时往往不执行 JS,拿到的还是占位符。
  10. *
  11. * 本脚本在 `craco build` + hash-loadm 之后运行:
  12. * 1) 从 public/js/load.m.js 提取 sites_config / regions / HOST_ALIAS(单一数据源,不复制配置)。
  13. * 2) 以 build/index.html(已含带 hash 的资源引用)为模板,
  14. * 逐站点把 %%占位符%% 替换成该品牌的真实内容,写出 build/<folder>/index.html。
  15. * 3) 顺带生成配套的 CloudFront Function(host → folder,运行时 cloudfront-js-2.0),
  16. * 写到项目根 cloudfront-host-to-folder.js,逻辑与 load.m.js 的 getSiteConfig()
  17. * 路由保持一致,保证「静态首页」与「运行时」选到同一个品牌。
  18. *
  19. * folder(文件夹名)= sites_config 的 key = 域名主名(second-level label)。
  20. * 例:mintpayouts.com → folder "mintpayouts";usslot777.com → 走 HOST_ALIAS → "default"。
  21. *
  22. * 用法:node scripts/generate-sites.js (已挂到 package.json 的 build 脚本)
  23. */
  24. const fs = require("fs");
  25. const path = require("path");
  26. const vm = require("vm");
  27. const ROOT = path.resolve(__dirname, "..");
  28. // 配置单一数据源:public/js/load.m.js(不在别处复制一份)。
  29. const LOADM = path.join(ROOT, "public", "js", "load.m.js");
  30. // 模板与输出都在 build/:它已被 craco/hash 处理过(带 hash 的 load.m.js / main.js / css)。
  31. const TEMPLATE = path.join(ROOT, "build", "index.html");
  32. const OUT_DIR = path.join(ROOT, "build");
  33. // CloudFront Function 生成路径(在项目根,不在 build/ 内,避免被下次 build 清空)。
  34. const CF_OUT = path.join(ROOT, "cloudfront-host-to-folder.js");
  35. // 与 load.m.js applyPageMeta() 保持一致的标题后缀(运行时也用这个,避免标题闪烁)。
  36. const TITLE_SUFFIX = "Online Slots & Real Money Games";
  37. /* ───────────────────────── 1. 提取 load.m.js 中的配置 ───────────────────────── */
  38. /**
  39. * sites_config / regions / themes / res_path 都是 IIFE 内的私有 const,无法 require。
  40. * 但从 `const res_path` 到 `const getPathPrefix`(第一个用到 window/document 的函数)之间,
  41. * 全是纯数据声明(res_path、各主题对象、regions、DEFAULT_REGION、sites_config),
  42. * 不依赖任何浏览器 API。整段在禁用动态代码生成、无 Node 全局对象的隔离上下文中求值,
  43. * 避免构建时把配置文件当作拥有 Node 权限的任意代码执行。
  44. */
  45. function extractConfig(src) {
  46. const start = src.indexOf("const res_path");
  47. const end = src.indexOf("const getPathPrefix");
  48. if (start === -1 || end === -1 || end <= start) {
  49. throw new Error("[generate-sites] 无法在 load.m.js 中定位配置数据块(res_path … getPathPrefix)");
  50. }
  51. const block = src.slice(start, end);
  52. const result = evaluateData(
  53. block + "\n;({ res_path, sites_config, regions, DEFAULT_REGION });",
  54. "config"
  55. );
  56. if (!result || typeof result.sites_config !== "object" || typeof result.regions !== "object") {
  57. throw new Error("[generate-sites] 配置数据结构无效");
  58. }
  59. return result;
  60. }
  61. function evaluateData(source, label) {
  62. const context = vm.createContext(Object.create(null), {
  63. name: `generate-sites-${label}`,
  64. codeGeneration: { strings: false, wasm: false },
  65. });
  66. const result = vm.runInContext(`"use strict";\n${source}`, context, { timeout: 1000 });
  67. // Strip cross-realm prototypes and retain JSON-compatible configuration data only.
  68. return JSON.parse(JSON.stringify(result));
  69. }
  70. /**
  71. * HOST_ALIAS 在 getSiteConfig() 内部,单独用正则切出对象字面量再求值。
  72. * 该字面量没有嵌套大括号,第一个 `}` 即结束。允许内部含注释/尾逗号。
  73. */
  74. function extractHostAlias(src) {
  75. const m = src.match(/const\s+HOST_ALIAS\s*=\s*(\{[\s\S]*?\})\s*;/);
  76. if (!m) return {};
  77. try {
  78. const result = evaluateData(`(${m[1]});`, "host-alias");
  79. return result && typeof result === "object" ? result : {};
  80. } catch (e) {
  81. console.warn("[generate-sites] 解析 HOST_ALIAS 失败,按空表处理:" + e.message);
  82. return {};
  83. }
  84. }
  85. /* ───────────────────────── 2. 占位符替换 ───────────────────────── */
  86. const escAttr = (s) =>
  87. String(s == null ? "" : s)
  88. .replace(/&/g, "&amp;")
  89. .replace(/</g, "&lt;")
  90. .replace(/>/g, "&gt;")
  91. .replace(/"/g, "&quot;");
  92. function buildReplacements(key, conf, regions, DEFAULT_REGION, domainFor) {
  93. const region = regions[conf.region || DEFAULT_REGION] || regions[DEFAULT_REGION] || {};
  94. const suffix = region.titleSuffix || TITLE_SUFFIX; // 运行时 applyPageMeta 用固定后缀;如需按区域可改 region.titleSuffix
  95. const fullTitle = `${conf.title} | ${suffix}`;
  96. const domain = domainFor(key);
  97. const origin = `https://${domain}`;
  98. const ogImage = conf.icon ? origin + conf.icon : "";
  99. // theme-color:用各主题共有的深色背景(与首屏 loading 背景一致),运行时 setThemeColor 也会接管。
  100. const themeColor = (conf.theme && (conf.theme.bodyBgColor || conf.theme.mainBgColor)) || "#0d1016";
  101. return {
  102. "%%BRAND_TITLE%%": escAttr(fullTitle),
  103. "%%BRAND_DESCRIPTION%%": escAttr(conf.subtitle),
  104. "%%THEME_COLOR%%": escAttr(themeColor),
  105. "%%BRAND_NAME%%": escAttr(conf.title),
  106. "%%BRAND_OG_TITLE%%": escAttr(fullTitle),
  107. "%%BRAND_OG_DESCRIPTION%%": escAttr(conf.subtitle),
  108. "%%BRAND_OG_IMAGE%%": escAttr(ogImage),
  109. "%%BRAND_URL%%": escAttr(origin + "/"),
  110. "%%FAV_ICO%%": escAttr(conf.favicon || "/resources/favicons/favicon.ico"),
  111. };
  112. }
  113. function applyReplacements(template, map) {
  114. let html = template;
  115. for (const token in map) {
  116. html = html.split(token).join(map[token]);
  117. }
  118. return html;
  119. }
  120. /* ───────────────────────── 3. CloudFront Function 生成 ───────────────────────── */
  121. /**
  122. * 生成 host → folder 的 CloudFront Function(viewer-request,运行时 cloudfront-js-2.0)。
  123. * 逻辑对齐 load.m.js getSiteConfig():去 www → HOST_ALIAS → 否则取首段标签 → 校验 folder 是否存在 → 否则 default。
  124. * 仅改写「文档请求」("/"、扩展名为空的 SPA 路由、index.html);静态资源(带扩展名)保持原路径,从根目录回源。
  125. */
  126. function generateCloudFrontFn(folders, hostAlias) {
  127. const foldersMap = {};
  128. folders.forEach((f) => (foldersMap[f] = true));
  129. const foldersJson = JSON.stringify(foldersMap, null, 2);
  130. const aliasJson = JSON.stringify(hostAlias, null, 2);
  131. return `/* eslint-disable */
  132. /**
  133. * CloudFront Function (viewer-request) —— host → 品牌文件夹路由
  134. * ============================================================
  135. * 由 scripts/generate-sites.js 自动生成,请勿手改(改 sites_config 后重跑 npm run build)。
  136. *
  137. * 作用:按访问域名把「文档请求」改写到 /<folder>/index.html,命中对应品牌的静态首页。
  138. * 静态资源(带扩展名,如 .js/.css/.webp/.ico)原样透传,从桶根目录回源。
  139. * 回源到 S3 前先把 URI 里连续的 // 合并为 /(等价 nginx merge_slashes on),
  140. * 避免 //a//b.js 这类多斜杠路径在 S3 里 key 不匹配而 404(S3 不做斜杠归一)。
  141. * 运行时:cloudfront-js-2.0。
  142. * 关联:与 public/js/load.m.js 的 getSiteConfig() 路由保持一致。
  143. */
  144. var FOLDERS = ${foldersJson};
  145. var HOST_ALIAS = ${aliasJson};
  146. function pickFolder(rawHost) {
  147. var host = (rawHost || "").toLowerCase();
  148. if (host.startsWith("www.")) host = host.slice(4);
  149. var colon = host.indexOf(":");
  150. if (colon !== -1) host = host.slice(0, colon);
  151. // 首段标签对应 load.m.js 的 getSitePrefix();整域名例外走 HOST_ALIAS。
  152. var folder = HOST_ALIAS[host] || host.split(".")[0];
  153. return FOLDERS[folder] ? folder : "default";
  154. }
  155. // nginx merge_slashes:把路径里 2 个及以上连续的 / 压成 1 个(保留开头的单个 /)。
  156. function mergeSlashes(uri) {
  157. return uri.replace(/\\/{2,}/g, "/");
  158. }
  159. function isDocument(uri) {
  160. var lastSeg = uri.slice(uri.lastIndexOf("/") + 1);
  161. // 文档:目录请求(结尾 /)、无扩展名的 SPA 路由、或显式 index.html
  162. return lastSeg === "" || lastSeg === "index.html" || !lastSeg.includes(".");
  163. }
  164. function handler(event) {
  165. var request = event.request;
  166. var host = request.headers["host"] ? request.headers["host"].value : "";
  167. // 先做斜杠归一,再判定文档/资源;无论改写与否,回源到 S3 的 URI 都是归一后的。
  168. request.uri = mergeSlashes(request.uri);
  169. if (isDocument(request.uri)) {
  170. request.uri = "/" + pickFolder(host) + "/index.html";
  171. }
  172. return request;
  173. }
  174. `;
  175. }
  176. /* ───────────────────────── 4. 主流程 ───────────────────────── */
  177. function main() {
  178. if (!fs.existsSync(LOADM)) {
  179. console.error("[generate-sites] 找不到 " + LOADM);
  180. process.exit(1);
  181. }
  182. if (!fs.existsSync(TEMPLATE)) {
  183. console.warn("[generate-sites] 跳过:未找到模板 " + TEMPLATE + "(请先执行 craco build)");
  184. process.exit(0);
  185. }
  186. const src = fs.readFileSync(LOADM, "utf8");
  187. const { sites_config, regions, DEFAULT_REGION } = extractConfig(src);
  188. const hostAlias = extractHostAlias(src);
  189. const template = fs.readFileSync(TEMPLATE, "utf8");
  190. // folder → 规范域名:优先取 HOST_ALIAS 的反向映射,否则默认 <key>.com(og:url / og:image 用)。
  191. const reverseAlias = {};
  192. for (const domain in hostAlias) {
  193. const folder = hostAlias[domain];
  194. if (!reverseAlias[folder]) reverseAlias[folder] = domain;
  195. }
  196. const domainFor = (key) => reverseAlias[key] || key + ".com";
  197. const keys = Object.keys(sites_config);
  198. let count = 0;
  199. for (const key of keys) {
  200. const conf = sites_config[key];
  201. const map = buildReplacements(key, conf, regions, DEFAULT_REGION, domainFor);
  202. const html = applyReplacements(template, map);
  203. const dir = path.join(OUT_DIR, key);
  204. fs.mkdirSync(dir, { recursive: true });
  205. fs.writeFileSync(path.join(dir, "index.html"), html);
  206. count++;
  207. }
  208. // 生成 CloudFront Function(host → folder)
  209. const cf = generateCloudFrontFn(keys, hostAlias);
  210. fs.writeFileSync(CF_OUT, cf);
  211. console.log(
  212. "[generate-sites] 已生成 " +
  213. count +
  214. " 个品牌首页 -> build/<folder>/index.html;CloudFront Function -> " +
  215. path.relative(ROOT, CF_OUT)
  216. );
  217. }
  218. main();