前言
最近接手了一个用 Vue CLI 5 构建的在线工具箱站点(下文以 3qtools.cn 为例),遇到了一个很典型、也很要命的问题:
主域名之前是有收录的,但后来在百度上搜不到了;用关键词搜出来的,全是子域名站点。
一开始按常规思路排查------robots、死链、服务器、外链,查了一圈都没问题。最后用百度蜘蛛的身份实测才发现,真正的病根藏在一个很多人都会忽略的地方:整个主站对搜索引擎来说,就是一张 3KB 的空白壳。
这篇文章完整记录了从"诊断"到"抢救"的全过程,包含 6 个致命报错的排查思路、预渲染的落地细节、sitemap 的深度优化、Nginx 的一行关键配置,以及百度搜索资源平台的实际操作。全部内容均为本次真实项目的一手踩坑记录,代码可直接参考。
阅读前提:本文假设你的站点也是 Vue/React 单页应用(SPA),且遇到了收录差、蜘蛛抓不到内容的问题。如果你的站是纯静态 HTML,那本文大部分内容不适用。
目录
- 一、背景:主站被"除名"了
- 二、诊断:用蜘蛛的眼睛看自己的站
- 三、根因:四个叠加的致命问题
- 四、方案选型:三条路怎么选
- [五、构建期预渲染落地(含 6 个致命坑)](#五、构建期预渲染落地(含 6 个致命坑) "#%E4%BA%94%E6%9E%84%E5%BB%BA%E6%9C%9F%E9%A2%84%E6%B8%B2%E6%9F%93%E8%90%BD%E5%9C%B0%E5%90%AB-6-%E4%B8%AA%E8%87%B4%E5%91%BD%E5%9D%91")
- 六、预渲染产物验证方法论
- [七、sitemap.xml 深度优化](#七、sitemap.xml 深度优化 "#%E4%B8%83sitemapxml-%E6%B7%B1%E5%BA%A6%E4%BC%98%E5%8C%96")
- [八、Nginx 一行配置消除 250 条 301](#八、Nginx 一行配置消除 250 条 301 "#%E5%85%ABnginx-%E4%B8%80%E8%A1%8C%E9%85%8D%E7%BD%AE%E6%B6%88%E9%99%A4-250-%E6%9D%A1-301")
- 九、百度搜索资源平台实操
- [十、www 与非 www:站点版本的选择](#十、www 与非 www:站点版本的选择 "#%E5%8D%81www-%E4%B8%8E%E9%9D%9E-www%E7%AB%99%E7%82%B9%E7%89%88%E6%9C%AC%E7%9A%84%E9%80%89%E6%8B%A9")
- 十一、进阶:异步接口页面的预渲染
- 十二、成果数据
- 十三、完整避坑清单
- 十四、总结
一、背景:主站被"除名"了
站点情况:
| 项目 | 说明 |
|---|---|
| 技术栈 | 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 分钟内完成):
- 百度搜
site:主域名,看首页位置和收录条数 - 百度搜
site:子域名,对比两边收录量级 - 用搜狗 / 必应 / 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固化:
bashnpm 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'))
})
两个高频错误:
- 事件名两边不一致 ------
vue.config.js里的renderAfterDocumentEvent值和main.js里派发的事件名必须逐字符一致 (大小写、连字符都算)。很多坑都栽在render-event写成renderEvent上。 - 看不到自己打的日志 ------ 插件会把页面所有 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%) |
两个理由必须关掉它:
- 体积 ------ map 通常是同源代码 JS 的 2
3 倍,本项目18MBchunk-pinyin.js6MB,光它一个的 map 就可能 12 - 安全 ------ 如果带
.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 处一并优化:
- 移除
/all(与/重复内容) <loc>做 XML 转义(URL 里出现&会让整个 sitemap 解析失败)- 日期用本地时区补零(
toISOString()按 UTC 算,东八区凌晨构建会差一天) - 写盘前自检(格式非法直接抛错停下)
- 重复地址自动合并
- 账本自动清理(已下线 URL 的记录移除)
- 网址拼接统一(去掉结尾斜杠风险,避免拼出
//)
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 地址的后果:
- 双倍消耗抓取配额 ------ 蜘蛛每抓一条要发两次请求
- 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万条 |
三条铁律:
- 不要反复重试推送 ------ 连续超配额会被判异常,可能下调配额甚至短期冻结
- 不要一次性全量提交几百条 ------ 会被判垃圾提交
- 已提交过的 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 操作步骤
用户中心→站点管理→添加站点→ 填入真实主版本域名- 选
HTML 标签验证,把平台给的meta写进public/index.html的<head> - 主站验证通过后,用**"快捷批量添加子站点"**把 www 和子域名一次挂进来(之后无需分别验证)
- 在新站点下提交 sitemap
- 旧的错误版本站点保留 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(推荐):数据本地化
这类古籍/清单数据本身就是静态的(曹操的诗不会每周新增),根本不需要走接口。
做法:
- 把数据提取成本地 JSON/JS 文件(
src/data/literature/caocao.js等) - 组件改为直接读本地数据,
mounted里不再发请求
js
import caocaoPoems from '@/data/literature/caocao.js'
export default {
data() {
return {
poemList: caocaoPoems, // 直接用本地数据
total: caocaoPoems.length
}
}
}
- 把这些页面加进
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 问题,本质是"内容可见性"问题------不是加关键词,而是先让蜘蛛能拿到内容。
具体到本次项目,关键动作只有五个:
- 做预渲染 ------ 让蜘蛛不烧 JS 配额就能拿到完整 HTML(治本)
- 修首页空壳 ------ 重定向 + 落盘路径冲突,是"只有首页空"的元凶
- 优化 sitemap ------ 补上唯一起作用的
lastmod,且用指纹账本避免谎报 - 改 Nginx 一行 ------
$uri/index.html前置,消除 250 条 301 - 正确提交 ------ 抓取诊断 + sitemap + 分批 API 提交,不浪费配额
最后想强调一点 :这个站的底子其实是好的(robots、sitemap、静态教程页都做得很规范),但首页空壳这一个点,把 287 个页面的机会全锁死了。
SEO 优化有时候不是"做得不够多",而是"有一个关键点没通"。 找到那个点,比堆一百个优化项都管用。
参考
- Vue CLI 官方文档 -
productionSourceMap/ 现代模式 - 百度搜索资源平台官方帮助文档
prerender-spa-plugin源码(v3.4.0)- Google 官方文档 - Dynamic Rendering
如果这篇文章帮你定位到了问题,欢迎点赞 👍 收藏 ⭐ 关注,也欢迎在评论区交流你踩过的坑。
声明:本文为原创实战记录,文中代码均已在真实项目中验证。转载请注明出处。