Vue SPA 站百度收录“全军覆没“?一次预渲染抢救的完整实战(2026 踩坑版)

前言

最近接手了一个用 Vue CLI 5 构建的在线工具箱站点(下文以 3qtools.cn 为例),遇到了一个很典型、也很要命的问题:

主域名之前是有收录的,但后来在百度上搜不到了;用关键词搜出来的,全是子域名站点。

一开始按常规思路排查------robots、死链、服务器、外链,查了一圈都没问题。最后用百度蜘蛛的身份实测才发现,真正的病根藏在一个很多人都会忽略的地方:整个主站对搜索引擎来说,就是一张 3KB 的空白壳。

这篇文章完整记录了从"诊断"到"抢救"的全过程,包含 6 个致命报错的排查思路、预渲染的落地细节、sitemap 的深度优化、Nginx 的一行关键配置,以及百度搜索资源平台的实际操作。全部内容均为本次真实项目的一手踩坑记录,代码可直接参考。

阅读前提:本文假设你的站点也是 Vue/React 单页应用(SPA),且遇到了收录差、蜘蛛抓不到内容的问题。如果你的站是纯静态 HTML,那本文大部分内容不适用。


目录


一、背景:主站被"除名"了

站点情况:

项目 说明
技术栈 Vue 2 + Vue CLI 5 + Vue Router(history 模式)
站点规模 主域约 318 条 URL(223 个工具页 + 64 个教程页 + 分类页等)
部署 Nginx 1.18 + Ubuntu,纯静态部署
特殊结构 主域外还有 5 个子域名(ai、gift、giftbox、happy、demo)

现象:

  • site:主域名 收录量只有约 10 条(收录率 ≈ 3%)
  • 关键词搜索结果里,出现的全是子域名站点
  • 主域首页在 site: 结果里排在最后一位

这里要先建立一个关键认知,否则会一直误判:

百度对二级域名(子域名)的判定是"独立站点" ------ 独立的抓取配额、独立的索引库、独立的权重档案。主域攒的信任度不会自动传给子域。所以"子站能搜到、主站搜不到",本质是两套独立账本,百度把展示机会给了它认为更值得的那一摊。


二、诊断:用蜘蛛的眼睛看自己的站

2.1 三个必须做的自查动作

排查收录问题,第一步不是改东西,而是先分类。三类问题的处理动作完全相反:

现象组合 问题性质 处理方向
site:主域 首页沉底 主域降权 修主域质量信号
site:主域 首页正常,但关键词给的是子域 百度做了版本选择 做规范化,收回权重
搜狗/必应/360 也搜不到主域 站点级技术故障 先修服务器/robots/证书

三个自查动作(10 分钟内完成):

  1. 百度搜 site:主域名,看首页位置和收录条数
  2. 百度搜 site:子域名,对比两边收录量级
  3. 用搜狗 / 必应 / 360 分别搜同一关键词做交叉验证

2.2 实测:用蜘蛛 UA 访问自己的站

这是最关键的一步。用 curl 模拟百度蜘蛛访问,看它到底拿到什么:

bash 复制代码
curl -s -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" \
  https://3qtools.cn/ | wc -c

实测结果(抢救前):

检查项 实测值 判断
首页 HTML 字节数 3029 ❌ 空壳
#app 内 DOM 空 ❌
H1 标签 0 个 ❌
内链(<a href>) 0 条 ❌ 蜘蛛无法爬下去
站点地图 URL 数 318 条 ✅
robots.txt 规则清晰,声明了 sitemap ✅
HTTP → HTTPS 正确 301 ✅

也就是说:robots、sitemap、证书、状态码这些都做得很好(规范度超过大部分个人站长),唯独首页是一张白纸。

2.3 一个容易误判的关键细节

实测中还发现一个反直觉的现象:

  • 百度确实执行了 JS,也渲染成功了(搜索结果里部分工具页显示的是个性化标题)
  • 但 318 个页面里,只有极少数被成功渲染抓取

结论:问题不是"百度不支持 JS",而是------

百度支持 JS 渲染,但渲染配额有限。它只能挑极少数高价值页面去渲染,剩下 300 多条永远排不上队。


三、根因:四个叠加的致命问题

3.1 根因一:主域是纯前端渲染 SPA,完全没做爬虫预渲染 ★★★★★

用 Baiduspider、Googlebot 和普通 UA 分别访问,返回的全是同一份 3029 字节空壳:

  • 没有正文 → 无法判断页面主题
  • 没有任何 HTML 内链 → 无法发现 223 个工具页和 64 个教程页
  • 只能靠 sitemap 硬发现 URL,然后烧 JS 渲染配额逐个渲染

3.2 根因二:子域名把内容和权重全部分流 ★★★★★

5 个子域名各占一个独立索引库,主域只剩一个空壳首页。

另外还发现 gift 和 giftbox 两个子域题材高度重叠(都是年会大屏抽奖),属于典型的自我竞争。

3.3 根因三:www 与非 www 双活,没有 301 ★★★

实测两个域名都返回 200、content-length 相同、etag 相同------同一份内容挂在两个地址上。百度无法判断哪个是官方版本,权重被劈成两半。

3.4 根因四:教程页是"沉睡资产" ★★★★

64 个教程页配置全对、正文约 1.6 万字、canonical 正确,却一条都没被收录。

原因还是根因一:首页空壳 → 蜘蛛进不来;这些 URL 在 sitemap 里排位靠后 → 配额轮不到。

现成的好内容,只差一个入口。


四、方案选型:三条路怎么选

针对 SPA 的 SEO 问题,主流有三条路:

方案 原理 改动范围 服务器成本 推荐度
构建期预渲染 构建时用无头浏览器把路由渲染成静态 HTML 前端构建配置 无(纯静态) ⭐⭐⭐⭐⭐
Nginx 动态预渲染 识别蜘蛛 UA,转发到本地渲染服务 Nginx + 一个 Node 服务 需常驻服务 ⭐⭐⭐⭐
SSR(Nuxt) 服务端实时渲染 全量重构 高 ⭐⭐(长期)

选型建议:

  • 内容更新不频繁、页面数量可控(几百个以内)→ 构建期预渲染,最省心,产物纯静态,和现有部署方式完全兼容
  • 页面数量极多、内容更新频繁 → Nginx 动态预渲染
  • 交互复杂、内容实时性要求高 → 才考虑 SSR

本项目选择构建期预渲染,理由:工具站内容更新频率低(一周加 1~2 个工具),页面总数可控,且不想引入常驻 Node 服务。


五、构建期预渲染落地(含 6 个致命坑)

5.1 基础配置

安装(注意 puppeteer 的镜像问题):

bash 复制代码
export PUPPETEER_DOWNLOAD_HOST=https://npmmirror.com/mirrors
npm install prerender-spa-plugin@3.4.0 --save-dev

vue.config.js 核心配置:

js 复制代码
const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')
const PuppeteerRenderer = PrerenderSPAPlugin.PuppeteerRenderer

module.exports = {
  publicPath: '/',              // 必须是根路径,不能是 './'
  productionSourceMap: false,   // 生产关闭 source map(重要,见 5.8)

  configureWebpack: {
    plugins: [
      new PrerenderSPAPlugin({
        staticDir: path.join(__dirname, 'dist'),
        indexPath: path.join(__dirname, 'dist', 'index.html'),
        routes: ['/', '/image', /* ... */],
        renderer: new PuppeteerRenderer({
          renderAfterDocumentEvent: 'custom-render-trigger',
          renderAfterTime: 8000,          // 兜底,防止单条路由卡死
          headless: true,
          maxConcurrentRoutes: 2,         // 不要用默认的 0(不限并发)
          args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
        }),
      }),
    ],
  },
}

5.2 【坑 1】Unable to prerender all routes! ------ 插件把真凶吞了

现象:构建报错,终端只有一行:

css 复制代码
[prerender-spa-plugin] Unable to prerender all routes!

原因 :翻源码发现,这个插件把真实错误丢掉了 。es6/index.js 第 143--149 行:

js 复制代码
.catch(err => {
  PrerendererInstance.destroy()
  const msg = '[prerender-spa-plugin] Unable to prerender all routes!'
  console.error(msg)                        // ← 只打印这句
  compilation.errors.push(new Error(msg))
  done()
})

注意:catch 抓到 err 之后,从头到尾一次都没用过它。 所以这条报错的信息量为 0,不是你没找对地方。

定位方法:临时打补丁,把真实错误打出来。

js 复制代码
// 在编译入口 mock outputFileSystem 后直接复现:
// 你会发现 compilation.errors 里其实是 1 条,但内容就是那句通用文案

结论 :真实的失败原因藏在 4 类里------mkdirp 兼容 / 事件超时 / 内存不足 / 路径错误。必须让它把真凶吐出来才能继续。

5.3 【坑 2】webpack 5 移除了 compilerFS.mkdirp

这是本项目最核心的一个坑。

同文件第 58--62 行:

js 复制代码
const mkdirp = function (dir, opts) {
  return new Promise((resolve, reject) => {
    compilerFS.mkdirp(dir, opts, (err, made) => err === null ? resolve(made) : reject(err))
  })          // ↑ webpack5 的 outputFileSystem 是 graceful-fs,已经没有这个方法
}

webpack 5 的 compiler.outputFileSystem 不再提供 mkdirp,这一行直接抛:

vbnet 复制代码
TypeError: compilerFS.mkdirp is not a function

关键点:这一步发生在**"所有路由都渲染成功、准备把 HTML 写进 dist"的阶段**。也就是说------

页面很可能已经渲染成功了,是倒在最后一步存盘上。

识别特征 :构建日志里所有路由都打了 ✔,但最后仍报 Unable to prerender all routes。

修复:改写成兼容写法。

js 复制代码
const fs = require('fs')
const mkdirp = function (dir, opts) {
  return new Promise((resolve, reject) => {
    try {
      fs.mkdirSync(dir, { recursive: true })
      resolve(dir)
    } catch (err) {
      reject(err)
    }
  })
}

⚠️ 重要提醒 :直接改 node_modules 的补丁会被 npm install / npm ci 冲掉。必须用 patch-package 固化:

bash 复制代码
npm i -D patch-package
npx patch-package prerender-spa-plugin

然后在 package.json 里加 "postinstall": "patch-package"。

5.4 【坑 3】Vue CLI 5 默认开启 modern 模式,插件被跑了两遍

识别特征 :构建日志里出现 Building legacy bundle for production...。

Vue CLI 官方文档明确:从 v5.0.0-beta.0 起,vue-cli-service build 会根据 browserslist 自动产出两套 bundle ,一次 build 会顺序跑两遍 webpack(modern → legacy)。

而预渲染插件的 afterEmit 钩子会在两个阶段各触发一次,两轮渲染互相覆盖、互相干扰。

修复 :configureWebpack 写成函数,只在其中一个阶段执行插件:

js 复制代码
configureWebpack: () => {
  // ⚠️ 这两个环境变量只能在函数内部读取,写在文件顶层拿到的是 undefined
  const isModern = process.env.VUE_CLI_MODERN_BUILD

  if (isModern) {
    return {}   // modern 阶段跳过预渲染
  }

  return {
    plugins: [ /* PrerenderSPAPlugin ... */ ],
  }
}

5.5 【坑 4】main.js 事件未派发 / 触发时机太早

识别特征 :构建成功,但 dist/index.html 还是 3KB 空壳。

插件默认不会等 Vue 渲染完就抓 HTML,必须手动触发事件。

Vue 2 写法(注意要等两帧 + 留出接口时间):

js 复制代码
new Vue({
  router,
  store,
  render: h => h(App),
  async mounted() {
    await this.$nextTick()
    await this.$nextTick()
    await new Promise(resolve => setTimeout(resolve, 1000))  // 给异步数据留时间
    document.dispatchEvent(new Event('custom-render-trigger'))
  }
}).$mount('#app')

Vue 3 写法:

js 复制代码
router.isReady().then(async () => {
  app.mount('#app')
  await nextTick()
  document.dispatchEvent(new Event('custom-render-trigger'))
})

两个高频错误:

  1. 事件名两边不一致 ------ vue.config.js 里的 renderAfterDocumentEvent 值和 main.js 里派发的事件名必须逐字符一致 (大小写、连字符都算)。很多坑都栽在 render-event 写成 renderEvent 上。
  2. 看不到自己打的日志 ------ 插件会把页面所有 console 消息(含 log)交给你的 consoleHandler,但如果你的 handler 只放行 error/warning,那你自己打的 console.log 就被过滤掉了。排查时先把 handler 改成全类型打印:
js 复制代码
consoleHandler(route, msg) {
  console.log(`  [浏览器 ${msg.type()}] ${route} → ${msg.text()}`)
}

还有一个隐蔽的坑 :renderAfterTime 会掩盖"事件没触发"这件事------事件和兜底时间在抢同一个 Promise,谁先到谁说了算。所以调试时要把兜底拉长、超时调小,让"事件没触发"能直接报错暴露出来。

5.6 【坑 5】首页重定向,dist/index.html 永远是空壳

识别特征 :其它页面都有内容,唯独首页是 3036 字节空壳。

根因 :预渲染落盘用的不是配置里的路由,而是页面跳转后的最终地址 。源码 Renderer.js:

js 复制代码
const result = {
  originalRoute: route,                                  // 原始路由 '/'
  route: yield page.evaluate('window.location.pathname'), // 重定向后变成 '/all'
  html: yield page.content(),
}

配合落盘逻辑 route + '/index.html',如果 / 被 router 重定向到 /all,内容就写进了 dist/all/index.html,而 dist/index.html 从头到尾都是 webpack 生成的原始空壳。

修复(两种):

方案 A(推荐):把重定向去掉,直接让 / 渲染目标组件,并把 / 放进预渲染路由列表:

js 复制代码
// router/index.js
{ path: '/',    name: 'index', component: () => import('@/views/AllTools.vue') },

方案 B:构建后补一步,把 /all 的内容复制到根目录 index.html(只并 #app 内容,保留 script 标签)。

为什么"只有首页"会反复变空 :webpack 永远不会往 dist/image/index.html 这类路径写文件,那些路径只有预渲染插件写过,所以产物留下了。只有 index.html 这个路径会被 webpack 写两次------如果插件所在阶段不是最后输出的那个阶段,就会被覆盖回空壳。

5.7 【坑 6】带 .html 的路由生成了同名目录(EISDIR)

现象:

vbnet 复制代码
ERROR  Error: EISDIR: illegal operation on a directory, open 'D:\...\dist\about.html'

打开 dist 目录,发现 about.html 和 sitemap.html 居然显示成文件夹。

根因 :插件对输出路径的算法是无条件 追加 /index.html(源码第 110 行),没有任何后缀判断:

bash 复制代码
路由 /about.html  →  dist/about.html/index.html   ← 生成了一个名字带点的目录!

而 public/about.html 这个静态文件又会被 webpack 复制到 dist/about.html(文件)。一个要文件、一个要目录,必然冲突。

修复(方案 A,推荐) :路径里带后缀的,一律不进预渲染列表。

js 复制代码
const PRERENDER_EXCLUDE = [
  /\.html?$/i,                                                  // about.html / sitemap.html / 教程页
  /\.(xml|json|txt|ico|png|jpe?g|gif|svg|webp|css|js|map)$/i,   // 其它静态资源
  /^\/api\//,                                                   // 接口
  /^\/all\/?$/,                                                 // 与 "/" 同组件,重复内容
  /^\/sign-in\/?$/,                                             // 登录页
]
const shouldPrerender = (p) => !PRERENDER_EXCLUDE.some((re) => re.test(p))

这些文件本来就是纯静态 HTML,放在 public/ 里就够了 ,webpack 会原样复制到 dist/,URL 不变、收录不受影响。它们压根不是 Vue 页面,本来就不需要预渲染。

如果路由列表是从 sitemap 自动读的,过滤必须作用在读取函数的返回值上:

js 复制代码
function readRoutesFromSitemap() {
  const fs = require('fs')
  const file = path.join(__dirname, 'public', 'sitemap.xml')
  if (!fs.existsSync(file)) return []
  const xml = fs.readFileSync(file, 'utf8')
  return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)]
    .map((m) => { try { return new URL(m[1]).pathname } catch (e) { return null } })
    .filter(Boolean)
}

// 关键:过滤 + 归一化 + 去重
const normalizeRoute = (p) => p.replace(/\/+$/, '') || '/'
const PRERENDER_ROUTES = [...new Set(readRoutesFromSitemap().map(normalizeRoute))]
  .filter(shouldPrerender)

顺手加一道自检 ,防止 public/sitemap.xml 不存在时静默返回空列表、"构建成功但一条都没预渲染":

js 复制代码
if (PRERENDER_ROUTES.length === 0) {
  throw new Error('[prerender] 路由列表为空,请检查 public/sitemap.xml 是否存在')
}

5.8 附带一个易忽略项:productionSourceMap: false

Vue CLI 的 productionSourceMap 默认是 true ,会在生产构建里生成 .map 文件。

实测对比(同一份产物):

关闭 source map 开启 source map
总体积 10.34 MB 44.34 MB
Source Map 占比 0 31.00 MB(69.9%)

两个理由必须关掉它:

  1. 体积 ------ map 通常是同源代码 JS 的 23 倍,本项目 chunk-pinyin.js 6MB,光它一个的 map 就可能 1218MB
  2. 安全 ------ 如果带 .map 的产物部署上线,任何人下载后能把混淆代码还原成你的 .vue 源码

验证线上是否泄露:

bash 复制代码
curl -I https://你的域名/js/app.xxxx.js.map
# 返回 200 就是泄露了

如果是这种情况,Nginx 里补一条:

nginx 复制代码
location ~* \.map$ {
    return 404;
}

六、预渲染产物验证方法论

构建成功 ≠ 预渲染成功。 这类插件最容易骗人的地方,是"构建成功"但产出的是"成功生成的一堆空壳"。

6.1 三层验证法

第 1 层:文件层 ------ 看产物结构

bash 复制代码
dir /s /b dist\*.html

每条路由应该对应一个目录 + index.html。

第 2 层:内容层 ------ 看有没有真内容(最关键)

bash 复制代码
wc -c dist/index.html
grep -oE '<h1[^>]*>[^<]+</h1>' dist/index.html
grep -oE 'href="/tool-detail/[^"]+"' dist/index.html | wc -l

判据:首页应该远大于 3029 字节(通常 20KB+),且能抓到 H1 和内链。

第 3 层:HTTP 层 ------ 起本地服务,模拟蜘蛛访问

bash 复制代码
curl -s -A "Baiduspider" http://127.0.0.1:8080/ | wc -c

6.2 一个必须澄清的误区:F12 看到的是假的

这是本文最想强调的一点。

查看方式 看到的内容 能否判断预渲染
Ctrl+U 查看网页源代码 服务器返回的原始 HTML ✅ 唯一正确方式
curl 同上,最干净 ✅
F12 → Network → index.html → Response 响应体原文 ✅
❌ F12 → Elements Vue 重建后的 DOM ❌ 永远显示 JS 渲染结果

为什么? 因为预渲染产出的 HTML 里同时装了两样东西:

html 复制代码
<div id="app">...预渲染写进来的静态快照...</div>
<script src="/js/chunk-vendors.js"></script>

浏览器的处理时间线:

时刻 发生的事
T1 拉取 index.html(静态文件,秒回)
T2 解析 HTML → 首屏立刻画出静态快照
T3 下载并执行 chunk-vendors.js / app.js
T4 Vue 启动,执行 $mount('#app')
T5 $mount 把 #app 现有内容清空,用虚拟 DOM 重新渲染
T6 你看到的最终画面 = JS 渲染结果

第 5 步是关键 :Vue 2 的 $mount(el) 默认行为就是"接管容器并重建"。

静态内容确实到达了浏览器、也确实被画出来了,只是随后被 Vue 重渲染覆盖。 你看到的"JS 动态渲染",是覆盖之后的结果。

更直观的招 :F12 → F1(Settings)→ 勾选 Disable JavaScript → 刷新页面。这时你看到的就是百度蜘蛛第一眼看到的样子。

6.3 一个更隐蔽的问题:内容雷同检测

光看字节数还不够。曾经遇到过 15 个分类页字节数全都是 10232、一个不差的情况------这不是巧合,说明这些页面的主体内容是一样的(通常是依赖接口数据的区域渲染为空)。

检测方法 :提取每个页面 #app 内的内容算指纹,比对是否有多页相同。

js 复制代码
const crypto = require('crypto')
const appContent = extractAppContent(html)   // 深度配对计数提取
const fingerprint = crypto.createHash('md5').update(appContent).digest('hex').slice(0, 10)

判定:出现 2 个及以上相同指纹 = 内容雷同,需要修。

⚠️ 提取 #app 内容时,不要用"从 <div id="app"> 往后找第一个 <script>"这种办法。 很多项目的 <script> 全部在 <head> 里(#app 之后一个都没有),这种写法会命中 -1,把 appLen 误判为 0。必须用深度配对计数 (逐层数 <div> / </div>),不依赖 <script> 位置。


七、sitemap.xml 深度优化

7.1 先纠一个误区:priority 和 changefreq 已经不起作用

这两个字段是 2005 年 sitemap 协议刚发布时设计的,主流搜索引擎已公开表示不参与排名 。实测发现站点里 318 条 URL 都填了这两个字段,但真正起作用的 <lastmod> 一条都没有。

7.2 但 lastmod 有个大坑

不能每次构建都把全站刷成"今天"。

一旦引擎发现这个字段每天在变、但页面内容其实没变,会把该字段的可信度归零------此后你真有内容更新,也不再被采信。有公开案例显示,站点因每天全量刷新 lastmod,三周内索引覆盖率从 89% 掉到 17%。

7.3 方案:lastmod 内容指纹账本

原理:

xml 复制代码
每条 URL 记录两个值 → { 内容指纹, 最后修改日期 }

每次构建:
  指纹没变 → 沿用旧日期(不谎报更新)
  指纹变了 → 推进到构建当天(如实反映)
  找不到内容来源 → 干脆不输出 <lastmod>(宁缺勿假)

指纹直接取该页正文真正的来源,精度很高:

页面类型 指纹来源
工具详情页 该工具的数据对象
分类页 该分类下的工具清单
教程页 该 HTML 文件的正文字节
首页 全站工具 + 教程 + 分类清单

关键 :教程页的指纹必须取文件内容,不能取文件的 mtime。

git 不保存 mtime。 全新克隆一次仓库,所有文件的 mtime 都会变成"克隆那一刻",导致 63 篇教程的 lastmod 集体被判为"今天更新",和"每天全量刷新"是一个后果。

用文件正文字节的 sha1 则免疫:重新克隆、改文件权限、改换行符都不会误触。

实测验证:

用例 结果
二次构建产物逐字节一致(内容没变时 lastmod 不刷新) ✅
只改一个工具名 → 恰好 2 条推进(该工具 + 首页) ✅
只改一篇教程正文 → 恰好 1 条推进,且是正确的那条 ✅
只改文件 mtime(正文一字未动)→ lastmod 不推进 ✅

其他 7 处一并优化:

  1. 移除 /all(与 / 重复内容)
  2. <loc> 做 XML 转义(URL 里出现 & 会让整个 sitemap 解析失败)
  3. 日期用本地时区补零(toISOString() 按 UTC 算,东八区凌晨构建会差一天)
  4. 写盘前自检(格式非法直接抛错停下)
  5. 重复地址自动合并
  6. 账本自动清理(已下线 URL 的记录移除)
  7. 网址拼接统一(去掉结尾斜杠风险,避免拼出 //)

7.4 一个必须注意的点

指纹账本文件(如 sitemap-lastmod.json)必须提交进 git。

这是整套机制的唯一前提。如果账本丢失(全新克隆没带上、或误加进 .gitignore),下次构建会把全部 URL 当成"首次收录",全站日期又变成同一天。

7.5 教程页数据源:改为扫描目录

如果教程页是 public/tutorials/*.html 这样的静态文件,直接从目录扫描即可,新增/改名/删除自动跟随,不用手动维护教程清单。

同时加一道硬校验:目录不存在时直接抛错中止,而不是静默扫到 0 个文件------否则整批教程地址会一次性从 sitemap 里消失,而构建照样"成功"。


八、Nginx 一行配置消除 250 条 301

8.1 现象:78.9% 的 URL 返回 301

预渲染产物是目录结构 (/image/index.html)。而 Nginx 这句:

nginx 复制代码
try_files $uri $uri/ /index.html;

当 $uri/ 命中目录、而原始 URI 又不以 / 结尾时,Nginx 会自动 301 跳到带斜杠的地址。

实测统计(317 条 URL):

erlang 复制代码
⚠️  301   250 条  (78.9%)   ← /tool-detail/* 223 条 + 分类页 15 条 + 其它 12 条
✅ 200     67 条  (21.1%)

8.2 为什么不能就这么提交

百度官方明确要求:"如链接存在跳转关系,直接提交跳转后的链接。"

提交 301 地址的后果:

  1. 双倍消耗抓取配额 ------ 蜘蛛每抓一条要发两次请求
  2. 301 地址通常不进索引 ------ 收录变慢,严重的直接不收

8.3 修复:把 $uri/index.html 插到 $uri/ 前面

nginx 复制代码
location / {
    # 原来:try_files $uri $uri/ /index.html;
    # 改成:
    try_files $uri $uri/index.html $uri/ /index.html;
}

原理 :访问 /image 时直接内部命中 /image/index.html 返回内容,不产生任何跳转。

实测效果:

markdown 复制代码
URL                          原配置        新配置
----------------------------------------------------
/                            ✅ 200       ✅ 200
/tool-detail/image-compress  ⚠️  301      ✅ 200
/image                       ⚠️  301      ✅ 200
/literature-catalog/shijing  ⚠️  301      ✅ 200
/image/                      ✅ 200       ✅ 200
----------------------------------------------------
301 条数:原配置 3 条 → 新配置 0 条

生效与验证:

bash 复制代码
nginx -t && nginx -s reload
curl -s -o /dev/null -w "%{http_code}" https://3qtools.cn/image
# 预期:200

8.4 顺带查出的软 404 问题

改完之后,还发现一个更隐蔽的问题:不存在的地址返回 200 + 首页内容。

bash 复制代码
/foo/bar/baz                        → 200(首页内容)
/tool-detail/not-exist-tool-xyz     → 200(首页内容)
/categories/image(旧地址)          → 200(首页内容)

这是 try_files ... /index.html 兜底的副作用:它对所有未匹配路径都返回 200。旧地址会变成"内容与首页完全相同的页面",被判为重复内容,继续占用配额。

修复分两步:

第一步(零风险):老地址 301 + 登录页 noindex

nginx 复制代码
location = /all   { return 301 https://3qtools.cn/; }
location = /index { return 301 https://3qtools.cn/; }
location = /home  { return 301 https://3qtools.cn/; }

location = /sign-in {
    add_header X-Robots-Tag "noindex, nofollow" always;
    try_files $uri $uri/index.html /index.html;
}

第二步(确认所有合法路由都有实体文件后):把兜底改成 404

nginx 复制代码
location / {
    try_files $uri $uri/index.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html { internal; }

九、百度搜索资源平台实操

前置条件:站点已部署、蜘蛛能拿到完整 HTML。在站点还是空壳的阶段去提交,等于把百度叫来看一间空房子。

9.1 抓取诊断(每周约 70 次配额)

路径:站点管理 → 抓取诊断

操作:填 URL → 点抓取 → 看四项指标

项目 合格标准
HTTP 状态码 200
抓取耗时 建议 < 3 秒
页面内容 能看到标题、正文、内链
抓取失败原因 无

抓取失败对号入座:

报错 原因 处置
连接超时 服务器过载 / 网络不稳 查带宽、看负载
抓取超时 页面下载过慢 优化响应时间,上 CDN
403 访问被拒绝 CDN/WAF 拦了百度蜘蛛 放行 Baiduspider 的 IP
404 URL 失效 修链接或提交死链
5xx 后端异常 查 Nginx 日志

关于"抓取中"一直不结束 :这是中间态,不代表出错。抓取诊断是共享资源池,高峰期排队正常。超过 30 分钟仍是"抓取中",直接重新提交即可(不要连续反复抓同一 URL)。

9.2 sitemap 提交

路径:资源提交 → 普通收录 → sitemap 标签

提交前先自检文件:

平台要求 检查方法
地址可访问 curl -I https://你的域名/sitemap.xml
文件 < 10MB 看文件大小
URL ≤ 50,000 条 数 <loc> 数量
不能是索引型 sitemap 根节点必须是 <urlset>,不能是 <sitemapindex>

索引型 sitemap 直接拒收 ,而且存在时还会阻塞新文件提交。必须先删掉索引型才能提交。

关于配额 :截图里如果显示 今日提交上限:0 条,说明平台没给你今天这个站的额度,这是新站接入后的常见情况,不是你文件的问题。

9.3 API / 手动提交

{"error":400,"message":"over quota"} 的含义 :不是代码写错,也不是站点被封------就是今天的主动推送配额用完了。

百度普通收录的配额是按站点质量动态给的:

站点状态 典型每日配额
刚验证的新站 0 ~ 10 条
有基础收录、抓取正常的站 几十 ~ 几百条
高权重老站 几万 ~ 10万条

三条铁律:

  1. 不要反复重试推送 ------ 连续超配额会被判异常,可能下调配额甚至短期冻结
  2. 不要一次性全量提交几百条 ------ 会被判垃圾提交
  3. 已提交过的 URL 不要再推 ------ 长期重复提交会被下调配额,甚至收回 API 权限

配额低时的替代通道(都不占主动推送配额):

通道 是否占配额 说明
robots.txt 声明 Sitemap ❌ 蜘蛛抓 robots 时会自行读取并拉取
内链爬取 ❌ 内链越丰富,蜘蛛爬得越全
抓取诊断 ❌ 每周 70 次,相当于手动通知蜘蛛

重要认知 :sitemap 提交 只是一个"通知"动作,不影响被收录 。只要 robots.txt 里声明了 Sitemap 地址,蜘蛛本来就会自己来读------这一条不需要任何配额。

9.4 提交节奏建议

阶段 动作
第 1 天 提交 sitemap + 抓取诊断 3~5 个核心页
第 1 周 每天按剩余配额推送(有多少推多少),优先枢纽页
第 2 周 配额通常涨到 10~20 条/天,继续小批量推
第 3~4 周 索引量爬升后,配额逐步提升,按 10 条/天节奏走

枢纽层优先:先把首页 + 分类页 + 教程索引提交(这批构成全站内链骨架),再交长尾工具页。


十、www 与非 www:站点版本的选择

10.1 先查清自己站的真实主版本

用蜘蛛 UA 测四个地址:

请求地址 状态 说明
https://你的域名/ 200 真实主版本
https://www.你的域名/ 301 跳转入口
http://你的域名/ 301
http://www.你的域名/ 301

三个旁证 :robots 里声明的 sitemap 地址、sitemap 里的 <loc>、页面上验证标签的归属------三者指向哪个版本,哪个就是主版本。

10.2 关键认知:站点域名"不能改",只能新增

百度搜索资源平台只有"添加 / 删除"站点,没有"编辑站点域名"这个入口。

所以如果平台上添加错了版本(比如加了 www,但真实主版本是非 www),动作是新增一个正确的站点,而不是改现有的。

百度官方原话:

"网站的链接也许会使用 www 和非 www 两种网址,建议添加用户能够真实访问到的网址。"

10.3 操作步骤

  1. 用户中心 → 站点管理 → 添加站点 → 填入真实主版本域名
  2. 选 HTML 标签验证,把平台给的 meta 写进 public/index.html 的 <head>
  3. 主站验证通过后,用**"快捷批量添加子站点"**把 www 和子域名一次挂进来(之后无需分别验证)
  4. 在新站点下提交 sitemap
  5. 旧的错误版本站点保留 1~2 周观察,再决定是否删除

⚠️ 不要为了"统一"反过来把 www 定为主版本,除非你愿意把 sitemap、robots、内链、301 方向、已提交 URL 全部重做一遍。


十一、进阶:异步接口页面的预渲染

11.1 问题

有些页面的列表数据是异步接口加载的,例如文学目录页:

bash 复制代码
GET https://你的域名/api/search?type=caocao&page=1&pageSize=80

返回 26 篇诗歌后前端才渲染列表。

预渲染时的困境:

  • 接口在构建环境打不到(本地没有后端)→ net::ERR_FAILED
  • skipThirdPartyRequests: true 会把接口直接 abort → 列表空白
  • pageHandler 时机不对 → 拦不住任何东西

实测结果 :9 个文学目录页全部是空心骨架------要么列表缺失(#app 内仅 24 字符空容器),要么直接回退到首页内容。

11.2 方案 A(推荐):数据本地化

这类古籍/清单数据本身就是静态的(曹操的诗不会每周新增),根本不需要走接口。

做法:

  1. 把数据提取成本地 JSON/JS 文件(src/data/literature/caocao.js 等)
  2. 组件改为直接读本地数据,mounted 里不再发请求
js 复制代码
import caocaoPoems from '@/data/literature/caocao.js'

export default {
  data() {
    return {
      poemList: caocaoPoems,   // 直接用本地数据
      total: caocaoPoems.length
    }
  }
}
  1. 把这些页面加进 PRERENDER_ROUTES,重新构建

好处:预渲染 100% 成功、首屏速度暴涨、不占服务器接口压力、蜘蛛 100% 抓到所有条目和链接。

11.3 方案 B:用插件的 server.proxy 反代接口

如果页面数据必须走接口,prerender-spa-plugin 原生支持 server 配置(基于 express + http-proxy-middleware),可以在预渲染时把接口代理到真实后端:

js 复制代码
new PrerenderSPAPlugin({
  staticDir: path.join(__dirname, 'dist'),
  indexPath: path.join(__dirname, 'dist', 'index.html'),
  routes: [/* ... */],

  server: {
    proxy: {
      '/api': {
        target: 'https://你的线上域名',
        changeOrigin: true,
        secure: false,
      },
    },
  },

  renderer: new PuppeteerRenderer({
    // ⚠️ 注意:不要开 skipThirdPartyRequests,它会把接口也 abort 掉
    renderAfterDocumentEvent: 'custom-render-trigger',
    renderAfterTime: 15000,
    timeout: 60000,
  }),
})

配套把 main.js 里派发事件前的等待拉长,给接口留返回时间:

js 复制代码
await new Promise(resolve => setTimeout(resolve, 2000))
document.dispatchEvent(new Event('custom-render-trigger'))

方案 A 与 B 怎么选:数据本身是静态的 → 选 A(一劳永逸);数据必须实时从后端取 → 选 B(但要接受预渲染变慢、构建依赖后端可用)。


十二、成果数据

12.1 抢救前后对比

指标 抢救前 抢救后
首页 HTML 字节数 3029 33843
首页 H1 标签 0 1
首页内链 0 条 84 条
蜘蛛 UA 访问 拿到空壳 拿到完整渲染 HTML
sitemap URL 数 318(含 /all) 317
sitemap <lastmod> 0 条 315 条
URL 200 直出率 ------ 317 / 317(100%)
301 跳转数 250 条 0 条
www → 非 www 双活无 301 正确 301
内容雷同页面 15 个分类页字节数全相同 0 组
source map 泄露 存在 已关闭

12.2 实测的服务器性能

地址 状态 首字节 总耗时 大小
/ 200 0.13s 0.156s 33828 B
/image 200 0.13s 0.158s 21836 B
/tool-detail/image-compress 200 0.17s 0.197s 16124 B

完整浏览器渲染(无头 Chromium):页面加载完成 1774 ms,网络空闲 476 ms,H1 1 个,站内链接 80 条。


十三、完整避坑清单

13.1 预渲染相关

  • publicPath 必须是 '/',不能是 './'(否则预渲染后资源 404)
  • routes 里不能写动态路由(/detail/:id),必须展开成真实地址
  • 带 .html 后缀的路径不要放进预渲染列表(会生成同名目录)
  • main.js 必须派发事件,事件名两边逐字符一致
  • 调试时把 headless 设为 false,能亲眼看到浏览器里的渲染过程
  • maxConcurrentRoutes 不要用默认的 0(不限并发),建议 2~5
  • productionSourceMap: false,避免体积膨胀 + 源码泄露
  • node_modules 补丁要用 patch-package 固化,否则 npm i 后失效
  • 每次构建前先 rmdir /s /q dist,避免历史残留干扰

13.2 验证相关

  • 用 Ctrl+U 或 curl 验证,不要用 F12 Elements
  • 检查 #app 内容时用深度配对计数 ,不要用 <script> 位置猜边界
  • 做内容指纹比对,防止多页内容雷同
  • 构建成功 ≠ 预渲染成功,必须验证产物字节数

13.3 sitemap 相关

  • lastmod 不能每次构建全量刷新
  • 教程页指纹取文件内容,不取 mtime(git 不保存 mtime)
  • 账本文件必须提交进 git
  • <loc> 要做 XML 转义
  • 日期用本地时区补零
  • 目录缺失要抛错中止,不能静默返回空列表

13.4 提交相关

  • 提交前先确认所有 URL 是 200 直出(不要提交 301 地址)
  • sitemap 不能是索引型
  • 不要反复重试超配额的提交
  • 不要一次性全量提交几百条
  • 已提交过的 URL 不要重复提交
  • 只提交真实主版本的站点

13.5 心态相关

  • 不要一上来就换域名或全站改版(单点问题会被改成全站问题)
  • 恢复期不要狂加外链"救"排名
  • 给恢复留时间:索引库更新有延迟,一般 2~4 周 看到变化,结构性调整 1~2 个月
  • 不要花钱买"快速收录""包收录"服务(平台已下线快速收录,且提交不保证收录)

十四、总结

回顾整个抢救过程,最核心的一条认知是:

SPA 站的 SEO 问题,本质是"内容可见性"问题------不是加关键词,而是先让蜘蛛能拿到内容。

具体到本次项目,关键动作只有五个:

  1. 做预渲染 ------ 让蜘蛛不烧 JS 配额就能拿到完整 HTML(治本)
  2. 修首页空壳 ------ 重定向 + 落盘路径冲突,是"只有首页空"的元凶
  3. 优化 sitemap ------ 补上唯一起作用的 lastmod,且用指纹账本避免谎报
  4. 改 Nginx 一行 ------ $uri/index.html 前置,消除 250 条 301
  5. 正确提交 ------ 抓取诊断 + sitemap + 分批 API 提交,不浪费配额

最后想强调一点 :这个站的底子其实是好的(robots、sitemap、静态教程页都做得很规范),但首页空壳这一个点,把 287 个页面的机会全锁死了。

SEO 优化有时候不是"做得不够多",而是"有一个关键点没通"。 找到那个点,比堆一百个优化项都管用。


参考

  • Vue CLI 官方文档 - productionSourceMap / 现代模式
  • 百度搜索资源平台官方帮助文档
  • prerender-spa-plugin 源码(v3.4.0)
  • Google 官方文档 - Dynamic Rendering

如果这篇文章帮你定位到了问题,欢迎点赞 👍 收藏 ⭐ 关注,也欢迎在评论区交流你踩过的坑。

声明:本文为原创实战记录,文中代码均已在真实项目中验证。转载请注明出处。

相关推荐
一 乐12 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
wengad15 小时前
从 wangEditor 迁到 wangEditor-next
前端·javascript·vue.js
FYKJ_201018 小时前
django电影推荐系统55066-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
qq_3168377518 小时前
阿里云百炼 fun-asr-realtime uniapp vue3 调用
javascript·vue.js·uni-app
李剑一19 小时前
AntDesign老版本没有月份和年份区间选择组件?我封装了一个完美版,附源码!
前端·vue.js
计算机毕设定制辅导-无忧学长20 小时前
《基于Spring Boot的减脂轻食购物网站》
java·vue.js·spring boot·后端·减脂轻食购物网站
一 乐21 小时前
博客管理系统|基于springboot + vue博客管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕设
lyz2468591 天前
vue3页面因参数未声明而报错的常犯错误
前端·javascript·vue.js