必应三个月零曝光,我排查出 5 个 Nuxt SSR 的 SEO 坑

一个跑在 Nuxt 3 SSR 上的 AI 工具导航站,上线几个月,必应几乎不给曝光。我去后台把数据拉出来,发现根本不是"内容不够好"的问题,而是四个技术层面的坑。

这篇把排查过程和 5 处改动完整写出来,所有数字都是站长后台和 curl 实测的,可以自己复现验证。(文中的 URL、服务器路径和端口均已脱敏处理,实际问题站点是 www.heooo.com )

一、先把基线数字拿到手

不要凭感觉优化 SEO。第一步永远是量化。

我用的是必应站长后台的「搜索性能」和「站点管理器」,加上 curl 抓自己的 sitemap。拿到的基线是这样:

指标 实测值 判断
已索引 URL 1,746 收录量还行
sitemap 提交 URL 757 覆盖率极低
站内可索引页 约 6,600 ---
近 3 个月展示量 3,000 约 33 次/天
近 3 个月点击 67 CTR 2.2%
有曝光的查询词 323 个 全是工具名长尾
「导航」类查询词曝光 0 个 核心问题

最后一行是关键:这个站叫"AI导航站",但三个月里,必应对任何包含"导航"的查询都没给过它一次曝光。

所以问题不是"排名低",是"压根没进候选池"。

二、改动 1:软 404 是收录杀手

最先发现的是这个。我随手编了几个不存在的 URL 测试:

bash 复制代码
curl -o /dev/null -s -w '%{http_code}\n' https://example.com/categories/doesnotexist-xyz
# 200   ← 空壳页,正文只有 325 个字符
curl -o /dev/null -s -w '%{http_code}\n' https://example.com/tags/zzz
# 200   ← 360 个字符
curl -o /dev/null -s -w '%{http_code}\n' 'https://example.com/tools?page=999'
# 200   ← 分页越界,844 个字符
curl -o /dev/null -s -w '%{http_code}\n' https://example.com/tools/999999.html
# 301   ← 不存在的工具,301 到列表页

四类 URL,全部没有返回 404。这意味着爬虫可以无限抓取"看起来正常、实际没内容"的页面。

软 404 的真实危害不是浪费配额,而是稀释站点质量评分。 搜索引擎评估站点时会算"有效页面占比",你塞进去几百个空壳页,等于主动拉低自己的平均分。

修复方式很简单------数据拿不到就直接抛 404:

ts 复制代码
// pages/categories/[slug].vue
const { data: categoryData } = await useAsyncData(`category-${route.params.slug}`, async () => {
  return await getCategoryTools(route.params.slug, { page: 1, page_size: 20 })
})

// 关键:分类不存在时返回真 404,而不是渲染空页面
if (!(categoryData.value as any)?.category?.id) {
  throw createError({ statusCode: 404, statusMessage: '分类不存在', fatal: true })
}

注意 fatal: true。在 Nuxt 里,如果只 return 一个空状态,SSR 依然会输出 200 的 HTML;必须抛错才会让响应码变成 404。

分页越界同理:

ts 复制代码
const totalPages = Math.max(1, Math.ceil(total.value / pageSize.value))
if (page.value > totalPages && total.value > 0) {
  throw createError({ statusCode: 404, statusMessage: '页码超出范围', fatal: true })
}

三、改动 2:sitemap 的 lastmod 不能造假

这个坑更隐蔽。抓自己的 sitemap 看:

bash 复制代码
curl -s https://example.com/sitemap-tools.xml | grep -o '<lastmod>[^<]*' | sort | uniq -c
# 528 条 URL,全部是 2026-10-01

528 个工具页,lastmod 全是同一天。原因写在代码里:

ts 复制代码
// ❌ 反模式:给一个兜底日期
const FALLBACK_DATE = '2026-10-01'
const toDate = (v: any) => {
  if (!v) return FALLBACK_DATE
  const t = new Date(v)
  return isNaN(t.getTime()) ? FALLBACK_DATE : t.toISOString().split('T')[0]
}

这段代码的出发点是"让 sitemap 格式合法",但实际效果是向搜索引擎声明"本站从那天起就没更新过"。

而且它还掩盖了一个真实 bug:后端的列表接口根本没返回 updated_at 字段,所以每条都走了兜底。

修复分两步。

第一步,后端补字段(FastAPI):

python 复制代码
"updated_at": tool.updated_at.isoformat() if tool.updated_at else None,

第二步,前端不再造假------拿不到就不输出这个标签,而不是填一个假日期:

ts 复制代码
const toDateOrNull = (v: any): string | null => {
  if (!v) return null
  const t = new Date(v)
  return isNaN(t.getTime()) ? null : t.toISOString().split('T')[0]
}

const urls = tools.map((tool: any) => {
  const d = toDateOrNull(tool.updated_at)
  return {
    loc: `/tools/${tool.id}.html`,
    priority: '0.8',
    changefreq: 'weekly',
    ...(d ? { lastmod: d } : {}),   // 有值才输出
  }
})

这里还有个容易漏的点:sitemap 生成函数必须支持可选字段 。如果它无条件拼 <lastmod>${u.lastmod}</lastmod>,你会得到 <lastmod>undefined</lastmod>,这种非法值会导致整份 sitemap 被拒收。

ts 复制代码
// 修正后
const body = urls.map(u => {
  const parts = [`    <loc>${abs(u.loc)}</loc>`]
  if (u.lastmod) parts.push(`    <lastmod>${u.lastmod}</lastmod>`)
  if (u.changefreq) parts.push(`    <changefreq>${u.changefreq}</changefreq>`)
  if (u.priority) parts.push(`    <priority>${u.priority}</priority>`)
  return `  <url>\n${parts.join('\n')}\n  </url>`
}).join('\n')

改完再测:

bash 复制代码
curl -s https://example.com/sitemap-tools.xml | grep -o '<lastmod>[^<]*' | sort -u | wc -l
# 7   ← 从 1 个日期变成 7 个真实日期

四、改动 3:sitemap 覆盖率只有 11%

757 条提交,站内约 6,600 个可索引页。少提交的 5,800 多个页面,爬虫只能靠内链自己发现,效率极低。

排查发现漏得最离谱的几类:

  • 资讯列表页 /news 本身不在 sitemap 里(staticPages 数组忘记加了)
  • 7 个工具箱页面、/recommend 等全部漏掉
  • 资讯 5,899 篇,sitemap 里只有 5 条

这里有个取舍原则:sitemap 只放"你希望被收录"的页面,以及 robots 设为 index 的页面 。我同时把 5 个内容过薄的页面(排行榜子页、赞助页等)设成了 noindex,这些就绝对不能出现在 sitemap 里------两者不一致会降低 sitemap 的可信度。

ts 复制代码
const staticPages = [
  { loc: '/', priority: '1.0', changefreq: 'daily' },
  { loc: '/tools', priority: '0.9', changefreq: 'daily' },
  { loc: '/news', priority: '0.9', changefreq: 'daily' },   // 之前漏了
  { loc: '/sitemap', priority: '0.7', changefreq: 'weekly' },
  // ...
]

五、改动 4:把 query 参数页变成路径化可收录页

站内有个工具对比功能,原来的 URL 是:

ini 复制代码
/tools/compare?ids=1122,638

这种地址有两个问题:

  1. query 组合无法被稳定收录,搜索引擎不知道有多少个"有效组合"
  2. 同一个对比有 ids=1122,638 和 ids=638,1122 两种写法,天然重复

我把它改成路径化:

bash 复制代码
/tools/compare/638-vs-1122

三个配套动作缺一不可。

第一,新页面必须 SSR 渲染,不能靠客户端取数:

ts 复制代码
const { data } = await useAsyncData(`compare-${ids.join('-')}`, async () => {
  const results = await Promise.all(ids.map(id => getToolDetail(id).catch(() => null)))
  return results.filter(Boolean)
}, { server: true, lazy: false })

第二,顺序归一化 + 301,保证一组对比只有一个规范地址:

ts 复制代码
const sortedIds = [...ids].sort((a, b) => a - b)
if (sortedIds.some((v, i) => v !== ids[i])) {
  const evt = useRequestEvent()
  if (evt) {
    const { sendRedirect } = await import('h3')
    await sendRedirect(evt, `/tools/compare/${sortedIds.join('-vs-')}`, 301)
  }
}

第三,canonical 自指 + 进 sitemap:

ts 复制代码
useHead({
  title: `${a.name} vs ${b.name} 对比:定价、功能与适用场景`,
  link: [{ rel: 'canonical', href: `${siteUrl}/tools/compare/${sortedIds.join('-vs-')}` }],
  meta: [{ name: 'robots', content: 'index, follow' }],
})

最后按分类生成对比页 sitemap(每个分类取热度前 12 个工具两两组合,避免 C(528,2)=139,128 的爆炸):

scss 复制代码
每分类 C(12,2) = 66 条 × 14 个分类 ≈ 819 条

这类页面是差异化资产------同行的导航站基本只有列表页,没有成对对比页。

六、改动 5:noindex 要用"质量信号",不要用"UX 信号"

站上有 5,899 篇资讯(采集 + AI 改写)。清理低质页时,我最初写的判定是:

ts 复制代码
// ❌ 有问题:用"有没有封面图"当质量信号
if (!cover) return true

上线前我把数据拉出来算了一遍影响面:

复制代码
总篇数 5,925,有封面的只有 1,822 篇

按这个规则,69% 的正常文章会被 noindex。封面是 UX 元素,不是搜索质量信号------搜索引擎根本不在乎你的文章有没有配图。

改成真正的内容信号:

ts 复制代码
const isLowQuality = computed(() => {
  const n: any = news.value
  const summary = String(n.summary || '').replace(/<[^>]+>/g, '').trim()
  const plain = String(n.content || '').replace(/<[^>]+>/g, '').replace(/\s+/g, '')
  const pub = n.published_at ? new Date(n.published_at) : null
  const oldDays = pub ? (Date.now() - pub.getTime()) / 86400000 : 0

  if (summary.length < 40) return true      // 摘要过短
  if (plain.length < 800) return true       // 正文过薄
  if (oldDays > 180) return true            // 时效性内容已过期
  return false
})

实测影响面:1,920 / 5,925 ≈ 32%。这个比例是合理的------命中真正薄和老的内容,而不是误伤。

七、两个反直觉的发现

1. 结构化数据不是胜负手

我对标的那个站排第一,我把它首页和详情页的 JSON-LD 全抓了一遍------0 个 。而我这边工具页有 SoftwareApplication + HowTo + FAQPage + BreadcrumbList,分类页有 CollectionPage + ItemList,资讯页有 NewsArticle。

结论:schema 是加分项,不是决定项。别指望加几个 JSON-LD 就能翻身。

2. 内链不是越多越好

为了提升内链密度,我给工具详情页加了一堆区块:相关分类、热门标签、热门工具卡片......内链从 63 涨到 164。

结果页面下半部分全是导航块,观感很差,而且这些链接大多和当前工具的关系不强,等于稀释了真正相关的链接权重。

后来砍到 81:保留面包屑 + 同分类工具 + 一排相关分类。质量比数量重要。

八、顺手踩到的运维坑:部署"假成功"

改完要部署,脚本里用 pkill -f "/opt/app/server/start.js" 停旧进程。第一次部署成功了,第二次显示 FRONT_UP,但线上还是旧页面。

原因:新进程是用相对路径启动的(cd /path && node server/start.js),进程 cmdline 里只有 server/start.js,绝对路径匹配不到,旧进程压根没被杀掉。新进程因为端口被占用启动失败,脚本却只检查"端口有没有在监听"------旧进程还在听,于是判定成功。

改成按端口精确定位:

bash 复制代码
PID=$(ss -ltnp | grep ':3000' | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)
[ -n "$PID" ] && kill "$PID" && sleep 3
ss -ltn | grep -q ':3000' && echo "端口仍被占用" || echo "已释放"

教训:部署脚本的"成功"判断必须是内容验证,不能是端口/进程状态验证。

九、怎么验证你自己改完的效果

不用等搜索引擎,改完立刻能测:

bash 复制代码
# 1. 软 404 是否消除(期望全部 404)
for u in /categories/xxx /tags/zzz '/tools?page=999' /tools/999999.html; do
  echo -n "$u => "; curl -o /dev/null -s -w '%{http_code}\n' "https://example.com$u"
done

# 2. lastmod 是否有真实多样性(期望 > 1)
curl -s https://example.com/sitemap-tools.xml | grep -o '<lastmod>[^<]*' | sort -u | wc -l

# 3. sitemap 是否包含所有子 sitemap
curl -s https://example.com/sitemap.xml | grep -c '<sitemap>'

# 4. 参数页是否 301 到规范地址
curl -o /dev/null -s -w '%{http_code} -> %{redirect_url}\n' 'https://example.com/tools/compare?ids=1122,638'

然后每周看必应后台两个数:有曝光的查询词数量 、目标关键词是否开始出现曝光。

十、小结

这次改动没有一行是"SEO 玄学",全是工程问题:

  1. 软 404 → 真 404,别让空壳页稀释站点质量分
  2. sitemap 的 lastmod 宁可缺字段,也不要造假日期
  3. 覆盖率补齐,并且和 robots 保持一致
  4. query 参数页路径化 + 301 归一化 + canonical,三件套缺一不可
  5. noindex 判定用内容质量信号,不要用 UX 信号

真正难的从来不是"加什么",而是先量化、再改一处、再验证一处 。大多数站点的问题,用四五个 curl 就能定位。


本文所有数字与命令均来自真实站点改造过程;URL、服务器路径与端口均已脱敏。

相关推荐
jerrywus2 天前
你还在手动seo? 那你可以试试claude-seo skill
agent·claude·seo
薛定谔的猫-菜鸟程序员3 天前
从 SEO 到 GEO:生成式引擎优化的原理、证据与骗局
人工智能·seo·geo
SEO_juper6 天前
Go 站点地图生成器:从首页爬全站,一键生成 sitemap.xml
xml·开发语言·人工智能·golang·node.js·seo·外贸独立站
拓海SEO外贸8 天前
四款核心 SEO 工具的使用方法:GSC、GA4、Ahrefs、Semrush 保姆级教程
搜索引擎·seo·跨境电商·独立站搭建·seo优化
SEO_juper8 天前
Node.js 页面主题聚焦度检测:title 和正文各说各的,AI 也读不懂
开发语言·人工智能·node.js·seo·外贸独立站
seowmt12 天前
GEO效果该什么时候看数据:从立项到复盘的时间线复盘
算法·ip·seo·geo·faq
SEO_juper12 天前
Node.js 全站死链检测工具:从 sitemap 出发一次找全 404
开发语言·人工智能·python·node.js·seo·外贸独立站
m0_6829065313 天前
工作台后端编排链路自检 10-17 11:45:56
ai·大模型·seo·geo·结构化数据·indexnow
GlobalTradercrb16 天前
独立站SEO先写博客还是先优化产品页?看站点阶段
数据库·人工智能·google·pdf·go·seo