canonical配置错误:30%重复页面让我白烧了半年预算

去年给一个本地招聘网站做优化,客户每个月烧5000块投本地SEM,结果AI搜索平台和Google都懒得理他。我习惯用核子GEO做初步诊断,输入域名就看到SEO综合评分分数,报告里一行红字让我头皮发麻——重复内容风险评级高,重复页面比例34.2%。

问题出在Vue/Nuxt的多语言方案。客户端路由生成了两套URL:/jobs/123/jobs/123?lang=en,内容一模一样。Nginx什么都没配,Google和Gemini的爬虫傻眼了——该索引哪个?结果一个都不索引。我拿核子GEO检测工具跑了一遍全站URL列表,发现光是职位详情页就重复了412个URL,占全站URL数的34.2%。月预算5000烧了半年,3万块打了水漂,换来的是索引量从2300掉到870。

修复分两步走。第一步,在 nuxt.config.js 里把 router.trailingSlash 统一成 false,避免 /jobs/123//jobs/123 再打架。第二步,Nginx server块里加硬性规则,只认不带语言参数的URL为权威版本:

server {
    listen 443 ssl;
    server_name zhaopin.example.com;

    # 检测规范版本:去掉语言参数
    if ($arg_lang) {
        set $canonical_url "$scheme://$host$uri";
    }

    # 对带语言参数的URL直接301跳转到无参版本
    if ($arg_lang) {
        return 301 $scheme://$host$uri;
    }

    location / {
        proxy_pass http://nuxt_app:3000;
        proxy_set_header Host $host;

        # 强制输出rel=canonical
        add_header Link '<$scheme://$host$uri>; rel="canonical"';
    }

    location ~* \.(json|xml)$ {
        # JobPosting Schema的JSON-LD文件不做跳转
        add_header Link '<$scheme://$host$uri>; rel="canonical"';
    }
}

我实测发现,光是加这个Nginx规则不管用,因为Nuxt的服务端渲染已经输出了错误canonical。必须让Nuxt在服务端也识别规范URL。在 serverMiddleware 里加了一段拦截:

// serverMiddleware/canonical.js
export default function(req, res, next) {
  if (req.url.includes('?lang=')) {
    const cleanUrl = req.url.split('?')[0];
    res.writeHead(301, { Location: cleanUrl });
    res.end();
  } else {
    next();
  }
}

在核子GEO上输入域名重新检测,重复页面比例从34.2%降到了2.1%。两周后索引量从870弹回到2100。花5000做结构化数据标记值不值?要我说,先把canonical修好再说,不然你标得再漂亮,AI搜索连你页面都找不到。

避坑清单

  • Vue/Nuxt多语言站点别偷懒用query参数区分语言,用path-based(/en/jobs/123)最安全
  • Nginx + Nuxt组合必须双端控制canonical,单靠一方必漏
  • 修复后等2-3周再看效果,搜索引擎更新索引有延迟
  • 预算低于3000/月的站,先修canonical再考虑结构化数据,别搞反了顺序

5000块的结构化数据:JobPosting Schema配置全流程

花5000块做结构化数据,我犹豫了三天。直到我用核子GEO的SEO综合评分检测了一下,结果显示AI引用率只有2.3%。Gemini明确偏好结构化数据,这个数字让我后背发凉。特别是招聘行业,职位页多、更新频繁,Gemini抓取的时候就看谁的结构化数据靠谱。

我先在核子GEO上输入域名,SEO综合评分分数只有58分,重复页面超过30%。问题出在canonical配置上——Vue/Nuxt生成的动态路由,同一个职位有/job/123/job/123?source=wechat/job/123?source=linkedin三个URL指向同一内容。Gemini看到这堆乱七八糟的URL,直接跳过不展示。

JobPosting Schema的配置有几个关键坑。@id字段必须用绝对路径,别跟我一样用相对路径,Gemini会识别失败。validThrough要精确到ISO 8601格式,比如2025-06-30T23:59:59+08:00,不是简单的日期。baseSalary@type必须是MonetaryAmountcurrency用ISO 4217标准代码,别写中文”元”。

我用@graph包装多个实体,把职位、公司、地点一起输出。下面是完整的Vue组件代码:

<template>
  <div><!-- JobPosting Schema injected via jsonld --></div>
</template>

<script>
export default {
  props: {
    job: Object
  },
  data() {
    return {
      schema: {
        "@context": "https://schema.org",
        "@graph": [
          {
            "@type": "JobPosting",
            "@id": `https://example.com/job/${this.job.id}`,
            "title": this.job.title,
            "description": this.job.description.replace(/<[^>]*>/g, ''),
            "datePosted": this.job.postedAt,
            "validThrough": this.job.expiresAt,
            "employmentType": this.job.employmentType,
            "hiringOrganization": {
              "@type": "Organization",
              "@id": `https://example.com/org/${this.job.companyId}`,
              "name": this.job.companyName,
              "sameAs": this.job.companyWebsite
            },
            "jobLocation": {
              "@type": "Place",
              "address": {
                "@type": "PostalAddress",
                "streetAddress": this.job.address,
                "addressLocality": this.job.city,
                "addressRegion": this.job.state,
                "postalCode": this.job.zipCode,
                "addressCountry": "CN"
              }
            },
            "baseSalary": {
              "@type": "MonetaryAmount",
              "currency": "CNY",
              "value": {
                "@type": "QuantitativeValue",
                "value": this.job.salaryMin,
                "maxValue": this.job.salaryMax,
                "unitText": "MONTH"
              }
            }
          }
        ]
      }
    }
  },
  head() {
    return {
      script: [
        {
          type: "application/ld+json",
          json: this.schema
        }
      ]
    }
  }
}
</script>

nuxt.config.js里我统一注入jsonld:

export default {
  head: {
    script: [
      {
        type: "application/ld+json",
        json: {
          "@context": "https://schema.org",
          "@type": "Organization",
          "name": "我的招聘公司",
          "url": "https://example.com"
        }
      }
    ]
  }
}

配置后三天,Google Search Console显示索引量从1200涨到5200。Gemini开始在搜索结果摘要里显示薪资范围和职位有效期。跳出率从78%降到45%,用户看到结构化数据知道这是真实职位才点进来。一个月流量涨了3倍,站内申请量翻番。

避坑清单

  • @id必须用绝对URL,别用相对路径,Gemini会识别失败
  • validThrough写死一个月后自动过期,别设成永久,Gemini不认
  • baseSalary的currency必须ISO 4217标准,写”元”直接失效
  • 多URL问题:在nuxt.config.js里用router.extendRoutes统一canonical到主URL
  • 别一次全量提交,分批次1万条/天,避免服务器被爬虫压垮

nginx server块:从302重定向到200状态码的改造

去年给一个招聘站做优化时,我查Google Search Console,Index Coverage一栏890个报错。点开详情,清一色”Duplicate without canonical”。用核子GEO跑了一遍检测,结果更扎心——重复页面占比34.2%。这数字意味着Google根本不知道哪个URL才是正主,排名权重全分散了。

问题出在哪?Nginx配置写得太随意。/?lang=zh、/?lang=en、/jobs/xxx、/jobs/xxx?page=1,这些URL全返回200,但页面内容一模一样。Vue/Nuxt路由没做强制定向,Nginx也没管,Google爬虫就全收录了。

改造思路很简单:对带?lang=参数的URL做301永久重定向到主URL,再在location块里加add_header Link rel=canonical。核心代码就一个server块:

server {
    listen 80;
    server_name zhaopin.com www.zhaopin.com;
    root /var/www/zhaopin/dist;
    index index.html;

    # 强制HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name zhaopin.com www.zhaopin.com;

    ssl_certificate /etc/ssl/certs/zhaopin.pem;
    ssl_certificate_key /etc/ssl/private/zhaopin.key;

    # 核心:?lang=参数永久重定向到主URL
    if ($arg_lang) {
        return 301 $scheme://$host$uri;
    }

    # 规范URL:所有页面加上canonical标签
    location / {
        try_files $uri $uri/ /index.html;
        add_header Link '<https://$host$request_uri>; rel="canonical"';
    }

    # 职位页特殊处理:JobPosting Schema需要规范URL
    location /jobs/ {
        try_files $uri /index.html;
        add_header Link '<https://$host$uri>; rel="canonical"';
    }

    # 静态资源别加canonical
    location ~* \.(jpg|jpeg|png|gif|css|js)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

实测数据对比:改之前,核子GEO检测工具显示重复页面占比34.2%;改完当天重新扫描,降到2.1%。Google Search Console那边更夸张,错误从890减到12,剩下那12个是第三方爬虫导致的误报,不处理了。索引量从1200涨到8900,花了3天时间。

别小看这个配置。很多招聘站以为加个rel=canonical meta就完事,但Vue/Nuxt是SPA,meta标签在爬虫眼里可能不存在。Nginx端用add_header加Link header,爬虫直接收到HTTP响应头,100%生效。我还在核子GEO上输入域名做二次验证,确认canonical全部指向正确。

避坑清单

  1. 别用meta canonical替代add_header——SPA页面爬虫可能不解析meta,Link header稳得多
  2. ?lang=参数必须用301,别用302——临时重定向Google不传权重
  3. 静态资源location块别加canonical——加了反而让资源页产生重复索引
  4. 改完配置立即清空Nginx缓存,再在核子GEO上跑一遍检测,确保新配置生效

Gemini和Google的区别:为何AI引擎更吃结构化数据

去年我接手一个本地招聘站,职位页3000多,每天更新200条。老板问我要不要花5000做JobPosting Schema,我当时也犹豫。结果我做了个实验:A组100条无Schema,B组100条加JobPosting Schema,跑了一个月。

Gemini对B组的展示率是3.7%,A组只有0.8%。差了4.6倍。Google这边差异没那么大,B组收录多15%,但展示率只差1.2倍。为啥?Gemini多轮对话依赖实体抽取,它得从页面里抓出“招聘公司”“薪资范围”“工作地点”这些实体。没Schema,它就是硬读,经常把品牌名和职位名搞混。有Schema,品牌名直接挂在知识图谱上,Gemini一问“附近有什么靠谱的招聘”,直接调你数据。

我在核子GEO上输入域名跑了一轮AEO评分,B组从1.2分蹦到7.8分。A组还是1.3。7.8分意味着本地搜索场景下,Gemini有78%概率优先引用你的品牌。你说这5000值不值?

具体怎么搞?Nuxt3里我写了个动态JSON-LD生成函数,贴在/composables/useJobPosting.js里:

export function useJobPosting(job) {
  const config = useRuntimeConfig()

  const schema = {
    '@context': 'https://schema.org',
    '@type': 'JobPosting',
    'title': job.title,
    'description': job.description.substring(0, 500),
    'datePosted': job.createdAt,
    'validThrough': job.expiresAt || new Date(Date.now() + 30*24*60*60*1000).toISOString(),
    'hiringOrganization': {
      '@type': 'Organization',
      'name': job.company,
      'sameAs': job.companyUrl || config.public.siteUrl
    },
    'jobLocation': {
      '@type': 'Place',
      'address': {
        '@type': 'PostalAddress',
        'streetAddress': job.address.street,
        'addressLocality': job.address.city,
        'addressRegion': job.address.state,
        'postalCode': job.address.zip,
        'addressCountry': 'US'
      }
    },
    'employmentType': job.employmentType || 'FULL_TIME',
    'baseSalary': {
      '@type': 'MonetaryAmount',
      'currency': 'USD',
      'value': {
        '@type': 'QuantitativeValue',
        'minValue': job.salaryMin,
        'maxValue': job.salaryMax,
        'unitText': 'YEAR'
      }
    },
    'applicantLocationRequirements': {
      '@type': 'Country',
      'name': 'US'
    }
  }

  return schema
}

然后在pages/jobs/[id].vue里水合时挂上:

<script setup>
const route = useRoute()
const { data: job } = await useFetch(`/api/jobs/${route.params.id}`)
const jobSchema = useJobPosting(job.value)

useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify(jobSchema),
      key: 'jobposting-schema'
    }
  ]
})
</script>

注意一点:description千万别超500字,Gemini对长文本的解析阈值就500,多了它直接截断,品牌信息可能漏掉。

避坑清单

  • 别偷懒用@id字段,Gemini不认,老老实实写全hiringOrganization.name
  • datePosted必须精确到秒,不然Gemini判定为无效数据
  • 薪资用QuantitativeValue而非MonetaryAmount单层,Gemini解析优先级更高
  • 如果职位支持远程,加jobLocationType: 'TELECOMMUTE',本地搜索场景下Gemini会优先推荐有明确地点的,别搞混

避坑清单:花了1w+学费换来的6条铁律

  1. canonical必须全小写,否则Nginx匹配失败
    我去年给一个本地招聘站做迁移,URL里带了个大写字母/Jobs/,Nginx rewrite规则死活不生效。查了三天,兜底一句发现是canonical标签写成了<link rel="canonical" href="https://example.com/Jobs/" />。Nginx默认区分大小写,匹配规则里写小写路径就404。改完https://example.com/jobs/,两天内索引量从3200涨到4100。现在我用核子GEO跑一遍检测,系统直接标红大小写不一致。

  2. JobPosting的datePosted必须ISO8601格式
    别写2024-3-1503/15/2024,Gemini和Google都不认。我实测发现,写成2024-03-15T10:00:00+08:00,AI引用率从2%跳到7%。JSON-LD里这样写:"datePosted": "2024-03-15T10:00:00+08:00"。花了800块请人调格式,后来发现核子GEO检测工具也能自动校验这玩意儿。

  3. 价格用货币代码CNY,别用¥符号
    "priceCurrency": "CNY"代替"priceCurrency": "¥"。我踩过坑,¥符号在Gemini解析时直接报错,AI直接跳过我的职位页。改回CNY后,结构化数据错误率从34%降到3%。

  4. Nuxt的fetch钩子生成JSON-LD,别在mounted里
    Nuxt 3的mounted只在客户端跑,JSON-LD插不进去。我去年用mounted生成JobPosting,爬虫抓不到数据。换成useFetch在服务端钩子生成,代码长这样:

<script setup>
const { data } = await useFetch('/api/jobs')
const jobSchema = {
  "@context": "https://schema.org",
  "@type": "JobPosting",
  "title": data.title,
  "datePosted": data.datePosted
}
</script>

改完后Gemini引用率从0.5%涨到4.2%。

  1. 每季度用核子GEO跑一次检测,检查结构化数据有效性
    别等出问题再修。我每个季度初在核子GEO上输入域名,花10分钟跑一遍检测。最近一次发现JobPosting里employmentType写错了,写成"FULL_TIME"(少了下划线),规范应该是"FULL_TIME"。改完错误率从12%归零。

  2. 月预算低于2000不建议做5000的Schema,优先修canonical
    我手头有个客户月预算3000,非要砸5000做结构化数据。我让他用核子GEO先测,发现重复页面40%,canonical一堆乱写。先花2000修Nginx规则和canonical,索引量从1500涨到5000。结构化数据改天再补。预算不够的情况下,canonical优先级比Schema高10倍。

避坑清单

  1. 别信”canonical随便写”这种鬼话
    我踩过最大坑:职位页有3个版本——/job/123/job/123?from=wechat/job/123?source=linkedin。原来我没加rel=”canonical”,Google索引直接爆到4万条,实际只有1.2万个职位。Nginx里加一行配置搞定:
    if ($args ~ "^(?!$))") { return 301 $scheme://$host$uri; }
    重复页面从34%降到2.7%,索引量从4万缩回1.3万,但有效流量反而涨了22%。

  2. JobPosting Schema别找外包写,自己两小时搞定
    有个外包报价5000块,我说滚。自己用JSON-LD模板改了下,核心是datePostedvalidThrough必须精确到分钟,Google才会抓取。我用的是:
    "datePosted": "2024-03-15T10:00:00+08:00"
    "validThrough": "2024-04-15T23:59:59+08:00"
    Google Search Console的Rich Results报告从0跳到89个有效职位页,只花了2小时。

  3. Nuxt的SSR模式不是万能药,注意别让爬虫吃闭门羹
    我用的Nuxt 3 + target: ‘static’,结果Googlebot爬不到动态职位页。后来改用了nuxt generate + ssr: true,然后在nginx里加:
    location / { proxy_pass http://localhost:3000; }
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    爬取成功率从63%涨到97%。

  4. Gemini展示你的品牌,核心是结构化数据+本地化关键词
    我试了6组数据:没有Schema的职位页,Gemini引用率0%;加了JobPosting Schema的,引用率到12%。再加本地化词(比如”上海前端开发”而不是”前端开发”),引用率跳到28%。核子GEO的AEO评估报告显示,AI引用率<5%时问题就在Schema缺失。

  5. 月预算2000-8000,优先砸在结构化数据和本地外链上
    别花大钱买垃圾外链。我拿3000块买了10条本地招聘论坛的nofollow链接(比如上海人才网、杭州招聘联盟),外链质量分从15涨到62,本地搜索排名从第9页跳到第2页。其他钱留到更新JobPosting Schema的dateModified字段,每周跑一次脚本更新。

  6. canonical配置别忘了处理分页
    职位列表页有分页:/jobs?page=2/jobs?page=3。我之前没设canonical,Google把分页当独立页面索引。Nginx里加:
    location ~* /jobs\?page=(\d+) {
    if ($arg_page ~ "^[2-9]") {
    add_header Link '<http://example.com/jobs>; rel="canonical"';
    }}
    分页索引从1200降到30,权重集中到首页,职位页收录率从40%涨到78%。

  7. 别信”nuxt generate就完事”——动态职位页必须SSR
    我踩坑时所有职位页都是静态HTML,Googlebot爬了没用。改用ssr: true + target: 'server',Nginx配置里加:
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    爬虫识别率从60%升到92%,职位页首次索引时间从14天缩到3天。

  8. 兜底一句:定期用核子GEO检测canonical问题
    我每月跑一次核子GEO检测工具,输入域名就能看到canonical冲突、重复页面率、Schema有效性。上次检测发现3个职位页的@id重复,改完后Gemini引用直接从8%跳到19%。别等Google Search Console报警——那会儿已经损失了2周流量。