import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '..') const publicDir = path.join(rootDir, 'public') const publicAssetsDir = path.join(publicDir, 'assets') /** * 本地化外链静态资源(与 App.vue 中 rewriteLegacyStylesheetText 规则对齐): * - 归档 omo-oss + thefastimg/thefastfile * - 商城 presale.jhtsoft.com * - 阿里 cdn *.alicdn.com * * 用法:npm run sync:fastimg * 仅重写已下载:npm run sync:fastimg -- --rewrite-only */ const CDN_URL_RE = /https?:\/\/omo-oss-(?:image|file)\d*\.thefast(?:img|file)\.com\/[^\s"'<>)\]}]+/gi const PRESALE_URL_RE = /https?:\/\/(?:www\.)?presale\.jhtsoft\.com\/[^\s"'<>)\]}]+/gi const ALICDN_URL_RE = /https?:\/\/[^\s"'<>)\]}]*\.alicdn\.com\/[^\s"'<>)\]}]+/gi const DEFAULT_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Referer: 'https://www.haihofoods.com/', Accept: '*/*' } function headersForUrl(url) { let u try { u = new URL(url) } catch { return DEFAULT_HEADERS } if (/presale\.jhtsoft\.com$/i.test(u.hostname)) { return { ...DEFAULT_HEADERS, Referer: 'https://haihofoods.cutmall.cn/', Origin: 'https://haihofoods.cutmall.cn' } } if (/\.alicdn\.com$/i.test(u.hostname)) { return { ...DEFAULT_HEADERS, Referer: 'https://www.taobao.com/' } } return DEFAULT_HEADERS } function walkFiles(dir, extensions) { const out = [] if (!fs.existsSync(dir)) return out for (const name of fs.readdirSync(dir)) { const full = path.join(dir, name) const st = fs.statSync(full) if (st.isDirectory()) out.push(...walkFiles(full, extensions)) else if (extensions.some((e) => name.endsWith(e))) out.push(full) } return out } function normalizeCollectedUrl(raw) { let u = raw.trim().replace(/[,;]+$/, '') while (/[.,;:]$/.test(u)) u = u.slice(0, -1) return u } const CDN_HOST_OK = /^omo-oss-(?:image|file)\d*\.thefast(?:img|file)\.com$/i const PRESALE_HOST_OK = /^(?:www\.)?presale\.jhtsoft\.com$/i const ALICDN_HOST_OK = /\.alicdn\.com$/i function sanitizeGenericAssetUrl(raw, hostOk) { let s = normalizeCollectedUrl(raw) const cut = s.search(/"|&#\d+;|[\s"'<>]/) if (cut !== -1) s = s.slice(0, cut) while (/[.,;:]$/.test(s)) s = s.slice(0, -1) try { const u = new URL(s) if (typeof hostOk === 'function') { if (!hostOk(u.hostname)) return null } else if (!hostOk.test(u.hostname)) { return null } const last = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '') if (!/\.(png|jpe?g|gif|webp|svg|ico|ttf|woff2?|eot)$/i.test(last)) return null u.hash = '' return u.href } catch { return null } } function sanitizeCdnArchivedUrl(raw) { return sanitizeGenericAssetUrl(raw, CDN_HOST_OK) } function sanitizePresaleUrl(raw) { return sanitizeGenericAssetUrl(raw, PRESALE_HOST_OK) } function sanitizeAlicdnUrl(raw) { return sanitizeGenericAssetUrl(raw, (h) => ALICDN_HOST_OK.test(h)) } function pathnameHasAsset(pathname) { const parts = pathname.split('/').filter(Boolean) return parts.length > 0 } function dedupeByPath(urls) { const pick = new Map() for (const url of urls) { let u try { u = new URL(url) } catch { continue } const key = `${u.hostname.toLowerCase()}${u.pathname}${u.search}` const cur = pick.get(key) if (!cur || (cur.startsWith('http:') && url.startsWith('https:'))) pick.set(key, url) } return [...pick.values()] } function pathKey(url) { const u = new URL(url) return `${u.hostname.toLowerCase()}${u.pathname}${u.search}` } function suggestedFilename(url) { const u = new URL(url) const last = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '') if (!last) return null const safe = last.replace(/[^a-zA-Z0-9._-]+/g, '_') return safe || null } function uniqueDestPath(preferredName, occupied) { let name = preferredName let n = 0 while (occupied.has(name)) { n += 1 const ext = path.extname(preferredName) const base = ext ? preferredName.slice(0, -ext.length) : preferredName name = `${base}-${n}${ext || ''}` } occupied.add(name) return name } async function downloadOne(url, destFsPath) { const res = await fetch(url, { redirect: 'follow', headers: headersForUrl(url) }) if (!res.ok) { throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`) } const buf = Buffer.from(await res.arrayBuffer()) if (!buf.length) { throw new Error(`Empty body — ${url}`) } fs.mkdirSync(path.dirname(destFsPath), { recursive: true }) fs.writeFileSync(destFsPath, buf) } function collectFromRegex(text, re, sanitize) { const cleanedSet = new Set() re.lastIndex = 0 let m while ((m = re.exec(text)) !== null) { const cleaned = sanitize(m[0]) if (!cleaned) continue try { if (!pathnameHasAsset(new URL(cleaned).pathname)) continue } catch { continue } cleanedSet.add(cleaned) } return cleanedSet } function replaceBothProtocols(text, fromUrl, toPath) { let out = text.split(fromUrl).join(toPath) try { const u = new URL(fromUrl) const altProto = u.protocol === 'https:' ? 'http:' : 'https:' const alt = `${altProto}//${u.host}${u.pathname}${u.search}` if (alt !== fromUrl) out = out.split(alt).join(toPath) } catch { /* ignore */ } return out } async function main() { const dryRun = process.argv.includes('--dry-run') const skipDownload = process.argv.includes('--rewrite-only') const sourceFiles = [ ...walkFiles(publicDir, ['.css']), ...walkFiles(path.join(rootDir, 'src', 'generated'), ['.html', '.txt', '.json']) ] const allFound = new Set() for (const file of sourceFiles) { const text = fs.readFileSync(file, 'utf8') for (const s of collectFromRegex(text, CDN_URL_RE, sanitizeCdnArchivedUrl)) allFound.add(s) for (const s of collectFromRegex(text, PRESALE_URL_RE, sanitizePresaleUrl)) allFound.add(s) for (const s of collectFromRegex(text, ALICDN_URL_RE, sanitizeAlicdnUrl)) allFound.add(s) } const fetchUrls = dedupeByPath([...allFound]).sort() console.log( `Found ${allFound.size} remote asset URL hits (${fetchUrls.length} unique URLs to fetch).` ) const keyToPublicPath = new Map() const occupiedNames = new Set( fs.existsSync(publicAssetsDir) ? fs.readdirSync(publicAssetsDir).filter((n) => fs.statSync(path.join(publicAssetsDir, n)).isFile()) : [] ) function isRasterExt(name) { return /\.(png|jpe?g|gif|webp)$/i.test(name) } for (const url of fetchUrls) { const key = pathKey(url) let fname = suggestedFilename(url) if (!fname) { const h = crypto.createHash('sha256').update(url).digest('hex').slice(0, 16) fname = `asset-${h}.bin` } fname = uniqueDestPath(fname, occupiedNames) const publicPath = `/assets/${fname}` if (dryRun) { keyToPublicPath.set(key, publicPath) continue } if (skipDownload) { const fsPath = path.join(publicAssetsDir, fname) if (!fs.existsSync(fsPath)) { console.warn(`[skip-download] missing file, run without --rewrite-only: ${fname}`) } else { keyToPublicPath.set(key, publicPath) } continue } const dest = path.join(publicAssetsDir, fname) if (fs.existsSync(dest) && fs.statSync(dest).size > 0) { console.log(`OK (exists) ${fname}`) keyToPublicPath.set(key, publicPath) continue } process.stdout.write(`GET ${url.slice(0, 80)}… → ${fname} `) try { await downloadOne(url, dest) console.log('ok') keyToPublicPath.set(key, publicPath) } catch (e) { console.log(`FAIL (${e.message})`) if (isRasterExt(fname)) { console.warn(` → use /assets/no-img.svg for ${key}`) keyToPublicPath.set(key, '/assets/no-img.svg') } else { console.warn(` → skip (font/other); URL left unchanged in sources`) } } } if (dryRun) { console.log('Dry run: no files written.') return } const urlToPublicPath = new Map() for (const u of allFound) { try { if (!pathnameHasAsset(new URL(u).pathname)) continue const pub = keyToPublicPath.get(pathKey(u)) if (pub) urlToPublicPath.set(u, pub) } catch { /* ignore */ } } let touched = 0 for (const file of sourceFiles) { let text = fs.readFileSync(file, 'utf8') const original = text const replacements = [...urlToPublicPath.entries()].sort((a, b) => b[0].length - a[0].length) for (const [absUrl, pubPath] of replacements) { text = replaceBothProtocols(text, absUrl, pubPath) } if (text !== original) { fs.writeFileSync(file, text, 'utf8') touched += 1 console.log(`Rewrote ${path.relative(rootDir, file)}`) } } console.log(`Done. Updated ${touched} files.`) } main().catch((e) => { console.error(e) process.exit(1) })