314 lines
9.0 KiB
JavaScript
314 lines
9.0 KiB
JavaScript
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)
|
||
})
|