fix(依赖解析): 修复CSS依赖路径对上级目录的处理并增强CDN回退支持

- 修正CSS依赖路径中`..`上级目录的解析,确保文件正确落盘
- 针对Font Awesome的`../webfonts`场景进行特殊处理,迁移旧版本误存文件
- 增强CDN回退逻辑以支持`@scope`包名格式
- 更新.gitignore添加cache目录,修改seed.txt测试用例
- 更新README文档说明相关改进
This commit is contained in:
2025-12-12 17:14:13 +08:00
parent d47f125cea
commit fd30f9b81e
4 changed files with 67 additions and 9 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ node_modules
dist/ dist/
build/ build/
*.log *.log
cache

View File

@@ -145,6 +145,7 @@
- 当抓取 `CSS` 文件时,会自动解析其中的 `url(...)` 引用,并尝试下载相对路径的依赖(如字体、图片等),统一保存到 `cache/css/...` 对应目录下,保持与源路径相同的层级结构。 - 当抓取 `CSS` 文件时,会自动解析其中的 `url(...)` 引用,并尝试下载相对路径的依赖(如字体、图片等),统一保存到 `cache/css/...` 对应目录下,保持与源路径相同的层级结构。
- 这样,形如 `@font-face { src: url(fonts/element-icons.woff) }` 的引用将会在本地落盘为:`/css/.../fonts/element-icons.woff`,无需跨域请求第三方源。 - 这样,形如 `@font-face { src: url(fonts/element-icons.woff) }` 的引用将会在本地落盘为:`/css/.../fonts/element-icons.woff`,无需跨域请求第三方源。
- 失败的依赖抓取会被静默跳过,不影响主 `CSS` 的可用性。 - 失败的依赖抓取会被静默跳过,不影响主 `CSS` 的可用性。
- 针对使用 `../webfonts/...` 的场景(如 Font Awesome已修正对上级目录的处理确保依赖文件最终位于与 `css/` 同级的 `webfonts/` 目录;旧版本误存于 `css/webfonts/` 的文件会在后续抓取时自动迁移到正确位置。
## 去重策略 ## 去重策略
@@ -186,4 +187,5 @@
- 增强缓存管理分页加载2050/页)、按名称/类型/更新时间过滤、按名称/大小/时间排序、轻量虚拟滚动与懒加载、元数据解析(库名/版本/扩展名/类别) - 增强缓存管理分页加载2050/页)、按名称/类型/更新时间过滤、按名称/大小/时间排序、轻量虚拟滚动与懒加载、元数据解析(库名/版本/扩展名/类别)
- 修复分段筛选:切换 `CSS/JS/全部` 时重置分页并重新加载 - 修复分段筛选:切换 `CSS/JS/全部` 时重置分页并重新加载
- 静态缓存优化:`/` 与 HTML 响应禁用缓存;为首页 CSS/JS 增加版本参数以避免浏览器缓存旧样式与脚本 - 静态缓存优化:`/` 与 HTML 响应禁用缓存;为首页 CSS/JS 增加版本参数以避免浏览器缓存旧样式与脚本
- 页面视觉细节:为 `header` 与 `main` 增加间距≥30px背景设置 `background-attachment: fixed` 并覆盖视窗(居中、等比、无重复) - 页面视觉细节:为 `header` 与 `main` 增加间距≥30px背景设置 `background-attachment: fixed` 并覆盖视窗(居中、等比、无重复)
- 修复CSS 依赖路径对 `..` 上级目录的正确解析与落盘(兼容 Font Awesome 的 `../webfonts`);增强 CDN 回退匹配支持 `@scope` 包名jsDelivr / unpkg

View File

@@ -7,4 +7,12 @@ https://cdn.jsdmirror.com/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js
https://cdn.tailwindcss.com https://cdn.tailwindcss.com
# Element UI 2.15.13 样式(用于验证字体依赖自动抓取) # Element UI 2.15.13 样式(用于验证字体依赖自动抓取)
https://cdn.jsdmirror.com/npm/element-ui@2.15.13/lib/theme-chalk/index.css https://dist.jicelue.com/css/npm/element-ui@2.15.13/lib/theme-chalk/index.css
# Font Awesome Free 6验证 webfonts 自动抓取)
https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css
https://s4.zstatic.net/ajax/libs/font-awesome/5.15.4/css/all.min.css
https://s4.zstatic.net/ajax/libs/font-awesome/5.15.4/css/v4-shims.min.css
https://s4.zstatic.net/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css

View File

@@ -195,17 +195,22 @@ async function fetchCssDependencies(baseUrl, cssBuf) {
} }
for (const rel of refs) { for (const rel of refs) {
// 归一化相对路径,计算本地写入位置 // 归一化相对路径,保留 .. 以正确定位到上级目录;去掉查询与哈希
const relSafe = rel.replace(/\\+/g, '/').replace(/^\/+/, '') const relNorm = rel.replace(/\\+/g, '/').replace(/^\s+|\s+$/g, '')
const relParts = relSafe.split('/').filter(p => p && p !== '..') const noQuery = relNorm.split('#')[0].split('?')[0]
const localPath = path.join(targetDir, ...relParts) const localPathCandidate = path.join(targetDir, noQuery)
const localPath = path.normalize(localPathCandidate)
const localDir = path.dirname(localPath) const localDir = path.dirname(localPath)
// 防越权:确保写入路径仍在 CSS_DIR 根内
const rootResolved = path.resolve(CSS_DIR)
const resolved = path.resolve(localPath)
if (!resolved.startsWith(rootResolved)) continue
if (fs.existsSync(localPath)) continue if (fs.existsSync(localPath)) continue
if (!fs.existsSync(localDir)) fs.mkdirSync(localDir, { recursive: true }) if (!fs.existsSync(localDir)) fs.mkdirSync(localDir, { recursive: true })
// 依次尝试:原始源、回退源 // 依次尝试:原始源、回退源
const primary = new URL(rel, baseUrl).toString() const primary = new URL(relNorm, baseUrl).toString()
const candidates = [primary, ...buildFallbacks(primary)] const candidates = [primary, ...buildScopedFallbacks(primary)]
let saved = false let saved = false
for (const c of candidates) { for (const c of candidates) {
try { try {
@@ -220,6 +225,48 @@ async function fetchCssDependencies(baseUrl, cssBuf) {
// no-op // no-op
} }
} }
// 针对旧版本误保存在 packageRoot/css/webfonts 的情况,统一迁移到 packageRoot/webfonts
try {
const packageRoot = path.dirname(targetDir)
const wrongDir = path.join(packageRoot, 'css', 'webfonts')
const correctDir = path.join(packageRoot, 'webfonts')
if (fs.existsSync(wrongDir)) {
if (!fs.existsSync(correctDir)) fs.mkdirSync(correctDir, { recursive: true })
const entries = fs.readdirSync(wrongDir, { withFileTypes: true })
for (const e of entries) {
if (!e.isFile()) continue
const src = path.join(wrongDir, e.name)
const dst = path.join(correctDir, e.name)
if (!fs.existsSync(dst)) fs.renameSync(src, dst)
}
}
} catch {}
}
/**
* 针对 npm/@scope 包名的CDN回退构造jsDelivr/unpkg
* 兼容路径:/npm/(css/)?[@scope/]<name>@<version>/...
* @param {string} absUrl 绝对URL
* @returns {string[]}
*/
function buildScopedFallbacks(absUrl) {
try {
const u = new URL(absUrl)
const m = u.pathname.match(/\/(?:css\/)?npm\/(?:@([^/]+)\/)?([^/@]+)@([^/]+)\/(.+)/)
if (m) {
const scope = m[1]
const name = m[2]
const version = m[3]
const rest = m[4]
const pkg = scope ? `@${scope}/${name}@${version}` : `${name}@${version}`
return [
`https://cdn.jsdelivr.net/npm/${pkg}/${rest}`,
`https://unpkg.com/${pkg}/${rest}`
]
}
} catch {}
return []
} }
/** /**