Vue SPA 博客改为静态 HTML 输出解决爬虫无法索引文章内容

问题背景

基于 Vue 3 + Vite 的 SPA 博客,文章内容在构建时渲染为 HTML 字符串,存储为 JSON 文件(public/data/posts/{slug}.json)。运行时通过 fetch() 加载 JSON,再用 v-html 注入到弹窗组件中。

这套方案对搜索引擎爬虫和 AI 索引不友好:

GitHub Pages 是纯静态托管,没有后端服务器,SSR 走不通。最终方案:构建时直接生成独立的 HTML 文件,每篇文章一个目录。

目录结构

改造前:

public/data/posts/esp32s3-esp-nn-sin-demo.json   ← { "content": "<html>..." }
public/blog/嵌入式 & Linux/esp32s3-esp-nn-sin-demo/img.jpg

改造后:

public/posts/esp32s3-esp-nn-sin-demo/
  index.html          ← 完整 HTML 页面(meta 标签 + 正文 + 样式引用)
  img.jpg             ← 图片和 HTML 同目录

posts.json 索引文件保留,首页卡片列表仍依赖它加载文章元数据。slug 字段从 嵌入式 & Linux/esp32s3-esp-nn-sin-demo 改为 esp32s3-esp-nn-sin-demo,URL 不再包含分类路径。

构建流程

scripts/generate-data.js 负责整个流程:

  1. 扫描 src/content/blog/ 下的 Markdown 文件
  2. 解析 frontmatter,提取数学公式,用 marked + Shiki 渲染为 HTML
  3. 修复图片路径,复制图片到 public/posts/{postDir}/
  4. 调用 post-html-template.js 生成完整 HTML 页面
  5. 生成 posts.json 索引和 sitemap.xml

公开文章和加密文章都输出为 HTML。加密文章的 HTML 内嵌 <script type="application/json" id="encrypted-data"> 存储密文,页面自带解密 UI 和内联的 Web Crypto 解密脚本。

package.json 构建脚本:

node scripts/generate-data.js && vite build && node scripts/generate-data.js --post-build

第三步 --post-buildpublic/posts/ 重新复制到 dist/posts/,覆盖 Vite 构建时改写过的版本。

关键设计决策

扁平 URL 结构

所有文章放在 posts/ 根目录下,不按分类建子目录。分类仅用于首页筛选和侧边栏展示,不进入 URL。好处是改分类名不会导致已分享的链接失效。

图片用绝对路径

HTML 中图片使用 /posts/{postDir}/img.jpg 而非相对路径 img.jpg。原因是 SPA 弹窗通过 fetch() 获取 HTML 后用 innerHTML 注入首页 DOM,浏览器会以当前页面 URL 为基准解析相对路径,导致图片地址错误。

CSS 样式和 KaTeX CSS 用相对路径 ../post-styles.css,因为它们在 <head> 中引用,直接由浏览器加载,不存在 DOM 注入的上下文问题。

Vite dev 服务器中间件

Vite dev 服务器把 /posts/xxx/ 当作 SPA 路由,返回 index.html 而不是实际的文章 HTML 文件。通过 configureServer 钩子添加中间件拦截请求:

function postsMiddleware() {
  return {
    name: 'posts-middleware',
    configureServer(server) {
      server.middlewares.use((req, res, next) => {
        if (req.url?.startsWith('/posts/') && !req.url.includes('.')) {
          const urlPath = decodeURIComponent(req.url).replace(/\/$/, '')
          const filePath = resolve(__dirname, 'public' + urlPath + '/index.html')
          if (fs.existsSync(filePath)) {
            res.setHeader('Content-Type', 'text/html')
            res.end(fs.readFileSync(filePath, 'utf-8'))
            return
          }
        }
        next()
      })
    }
  }
}

直接访问文章 URL 的处理

文章 HTML 内嵌一段 JS:

if (window.top === window) {
  window.location.replace('/?article={slug}');
}

浏览器直接访问文章 URL 时,先加载 HTML 页面,脚本检测到是顶层窗口(非 SPA 弹窗的 fetch),自动跳转到首页并携带 ?article= 参数。首页的 checkUrlForArticle() 读取该参数,自动打开对应文章弹窗,URL 替换为 /posts/{slug}

踩过的坑

Vite 构建改写 public 下 HTML 的路径

vite build 会处理 public/ 目录下的 HTML 文件,把 src="img.jpg" 解析为 src="/posts/嵌入式 & Linux/xxx/img.jpg" 绝对路径。解决办法是构建脚本在 Vite 完成后执行 --post-build,用未被改写的版本覆盖 dist/posts/

dev 服务器缓存旧数据

npm run dev 启动的 generate-data.js --watch 进程使用 Node.js 模块缓存,修改构建脚本代码后不会自动生效。需要杀掉旧进程重新启动 dev 服务器。Vite 本身也有进程级缓存,即使文件已更新,正在运行的 Vite 进程仍返回旧内容。

SPA 弹窗中相对图片路径解析错误

最初图片用相对路径 img.jpg,在独立访问时正常,但在 SPA �弹窗中加载失败。原因是 innerHTML 注入后浏览器以当前页面 URL(//posts/xxx)为基准解析相对路径,而不是以文章 HTML 所在目录为基准。改为绝对路径 /posts/{postDir}/img.jpg 后解决。

最终效果

本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。