huayang/frontend/scripts/sync-fastimg-assets.mjs
2026-05-07 18:24:22 +08:00

314 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(/&quot;|&#\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)
})