#!/usr/bin/env node import process from "node:process"; const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com"); const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/; const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"]; const expectedBuildSha = normalizeBuildSha(process.env.PUBLIC_EXPECTED_BUILD_SHA); const forbiddenFullBuildSha = normalizeBuildSha(process.env.PUBLIC_FORBIDDEN_BUILD_SHA, { keepFull: true }); function normalizeBaseUrl(value) { return String(value).replace(/\/+$/, ""); } function normalizeBuildSha(value, { keepFull = false } = {}) { const normalized = String(value ?? "").trim().toLowerCase(); if (!/^[0-9a-f]{7,64}$/.test(normalized)) { return null; } return keepFull ? normalized : normalized.slice(0, 12); } function toAssetPath(value) { if (value.startsWith("/")) { return value; } return `/assets/${value.replace(/^\.\//, "")}`; } async function fetchText(path) { const response = await fetch(`${baseUrl}${path}`); if (!response.ok) { throw new Error(`GET ${path} failed: ${response.status}`); } return response.text(); } async function collectAssets() { const seen = new Set(); const queue = []; const sources = new Map(); const html = await fetchText("/"); for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) { const assetPath = toAssetPath(match[1]); if (!seen.has(assetPath)) { queue.push(assetPath); seen.add(assetPath); } } for (let index = 0; index < queue.length; index += 1) { const assetPath = queue[index]; const source = await fetchText(assetPath); sources.set(assetPath, source); for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) { const nextPath = toAssetPath(match[1]); if (!seen.has(nextPath)) { queue.push(nextPath); seen.add(nextPath); } } } return { assets: Array.from(seen).sort(), sources, }; } const { assets, sources } = await collectAssets(); const findings = []; let expectedBuildShaFound = expectedBuildSha === null; let forbiddenFullBuildShaFound = false; for (const assetPath of assets) { const source = sources.get(assetPath) ?? ""; if (expectedBuildSha && source.toLowerCase().includes(expectedBuildSha)) { expectedBuildShaFound = true; } if ( forbiddenFullBuildSha && forbiddenFullBuildSha !== expectedBuildSha && source.toLowerCase().includes(forbiddenFullBuildSha) ) { forbiddenFullBuildShaFound = true; } if (!focusedAssetPattern.test(assetPath)) { continue; } for (const term of forbiddenTerms) { if (source.includes(term)) { findings.push({ assetPath, category: "forbidden_copy", term }); } } } if (!expectedBuildShaFound) { findings.push({ assetPath: "public-bundle", category: "build_sha_missing" }); } if (forbiddenFullBuildShaFound) { findings.push({ assetPath: "public-bundle", category: "full_build_sha_exposed" }); } const buildMarkerStatus = expectedBuildSha === null ? "NOT_CHECKED" : expectedBuildShaFound && !forbiddenFullBuildShaFound ? "PASS" : "FAIL"; console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} buildMarker=${buildMarkerStatus} findings=${findings.length}`); for (const finding of findings) { console.log(`ERROR ${finding.assetPath} category=${finding.category}`); } if (findings.length) { process.exitCode = 1; }