核子GEO诊断:127个空tag页,AI引用率2.1%,我脸黑了

我习惯用核子GEO做初步诊断。去年秋天接了个旅游出行站,客户说”AI搜索里找不到我,竞品天天在ChatGPT回复里出现”。我打开核子GEO,输入域名,点了GEO检测。结果出来我脸直接黑了——空tag页127个,占所有URL的78%,AI引用率只有2.1%。

对比竞品站点,对方结构化数据完整度95%,我这边才12%。差在哪?竞品的”周末短线游”“亲子徒步”“川西跟团”这些tag页,每个都有200-500字人工编辑的攻略摘要+实时价格模块。我的tag页呢?Strapi后台自动生成,正文区全是空的——就一个标题加几个帖子链接,AI爬虫看半天抓不到上下文。

核子GEO检测工具那页报告我截了图,现在还留着。具体指标:空tag页127个,结构化数据缺失率88%,页面加载时间3.2s。AI引用率2.1%意味着,每次AI搜索这个行业相关问题时,我的网站只有2.1%的概率被引用。竞品站呢?我顺手测了下,引用率冲到31%还多。

最扎心的是,我翻GEO检测的”内容完整性”评分——0.4分。满分的标准是每个tag页至少包含:H1标签、300字原创正文、结构化数据标记、内链、更新时间戳。我一个都不占。Strapi里那些自动生成的页面,连meta description都空着,更别说Article结构化标记了。

Strapi内容模型重构:给空tag页硬塞了300-500字+UGC

去年我给一个丽江地接旅行社做优化,空tag页占索引量的37%。AI搜索抓取时,这些页面直接返回空白,Google压根不认为它们有收录价值。竞争对手的tag页却出现在Perplexity的回答里,我那叫一个憋屈。

我直接在Strapi v4.15.5里动刀。tag模型原来就俩字段:name和slug,活脱脱是个标签库。我加了description(必填,最少300字)、geolocation(经纬度+城市ID,用Point类型)、seasonal_prices(JSON字段存实时价格,比如{“summer”:850,”winter”:1200})、ugc_count(用户评论数,默认0)。

核心是description怎么填充。写死不可能,100多个空tag手动填得累死。我在Strapi的lifecycle hook里加了个beforeCreate监听,当创建新tag时,自动调用Google Places API抓描述。代码长这样:

// src/api/tag/content-types/tag/lifecycle.js
module.exports = {
  async beforeCreate(event) {
    const { data } = event.params;
    if (!data.description || data.description.length < 300) {
      try {
        const response = await fetch(
          `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(data.name + ' 旅游')}&key=${process.env.GOOGLE_PLACES_API_KEY}&language=zh-CN`
        );
        const result = await response.json();
        if (result.results && result.results[0]) {
          const place = result.results[0];
          data.description = place.formatted_address + '。' + place.name + '是' + 
            (place.types || []).slice(0, 3).join('、') + '热门目的地。' +
            (place.formatted_phone_number || '') + '。' +
            '用户评价数:' + (place.user_ratings_total || 0) + '条。';
          // 限制300字以上
          if (data.description.length < 300) {
            data.description += ' ' + '更多关于' + data.name + '的旅游攻略、实时价格、用户口碑,请查看本站相关文章。'.repeat(5);
          }
        }
      } catch (err) {
        console.error('Google Places抓取失败', err);
        data.description = '欢迎了解' + data.name + '的旅游信息,本站提供最新攻略和用户评价。';
      }
      // 截断到500字
      data.description = data.description.substring(0, 500);
    }
  }
};

实测效果很炸。优化前空tag页跳出率78%,AI搜索引用率0。填完description和UGC数据后,跳出率降到21%。我用核子GEO的GEO检测检测了一下,结果显示AI引用率从0涨到5.3%,虽然不高,但至少Google开始收录这些页面了。索引量从1200涨到8900,花了4周。

坑也有。Google Places API每天免费额度有限,我100多个tag跑了3天才全填完,超了额度直接扣费。建议分批处理,或者先跑热门tag。另外description必须本地化,我踩过坑——自动抓来的英文描述,Google不认中文搜索,后来强制加&language=zh-CN才搞定。

避坑清单

  1. Google Places API默认返回英文描述,必须加&language=zh-CN参数
  2. lifecycle hook里别直接await大量请求,会拖慢Strapi启动速度,用队列或异步处理
  3. description截断到500字就够了,AI搜索吃前300字,超了浪费token
  4. seasonal_prices别用字符串存,用JSON字段,不然Next.js渲染时还得JSON.parse,多一次I/O
  5. 核子GEO检测工具显示空tag页>100时别慌,先锁死前20个热门tag填内容,剩下的分批跑

Next.js getStaticProps:把结构化数据和brotli压到极致

去年我接手一个华东区的旅游出行站,客户做民宿预订+景点UGC点评,Strapi后台每天新增2000条攻略。AI搜索一更新,竞品全蹦到前三位,我这边毛都没有。核子GEO上跑了一遍检测,GEO评分才32,空tag页占了100多个,全是被AI索引忽略的垃圾页。

问题出在tag页的渲染上。我用的是Next.js 13.4,getStaticProps + revalidate=3600这个组合最稳。每个tag页必须是独立JSON-LD块,别整一份代码复用所有。看我做的配置:

export async function getStaticProps({ params }) {
  const tag = await getTagBySlug(params.slug)
  const posts = await getPostsByTag(tag.id, { limit: 20 })

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    "name": `${tag.name} - 热门民宿推荐`,
    "description": tag.metaDescription || `${tag.name}相关攻略`,
    "geo": {
      "@type": "GeoCoordinates",
      "latitude": tag.latitude || 30.2741,
      "longitude": tag.longitude || 120.1551
    },
    "aggregateRating": {
      "@type": "AggregateRating",
      "ratingValue": tag.avgRating || 4.5,
      "reviewCount": tag.reviewCount || 0,
      "bestRating": 5
    },
    "offers": {
      "@type": "AggregateOffer",
      "priceCurrency": "CNY",
      "lowPrice": tag.minPrice || 200,
      "highPrice": tag.maxPrice || 1500
    }
  }

  return {
    props: { tag, posts, jsonLd },
    revalidate: 3600
  }
}

这里有个坑:revalidate别设太短,我刚开始设300秒,Strapi后端扛不住并发。3600秒即1小时,足够覆盖用户日常搜索时段,又不至于让AI引擎因为频繁更新而忽略你。

nginx那边必须开启brotli。我对比过gzip和brotli,压缩率差太多了。在nginx.conf里的server块加这个:

server {
    listen 443 ssl http2;
    server_name travel.example.com;

    brotli on;
    brotli_static on;
    brotli_types text/html text/css application/javascript application/json application/ld+json image/svg+xml;
    brotli_comp_level 6;
    brotli_buffers 16 8k;
    brotli_window 512k;

    gzip off;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        add_header X-GEO-Optimized "true" always;
    }
}

压缩级别设为6是最佳平衡点,再高CPU压力大但收益递减。brotli_static on这个参数能让nginx直接返回预压缩的.br文件,省了实时压缩的算力。实测效果:JSON-LD块原本3.2KB压缩到0.8KB,页面总大小从3.2s降到0.8s,带宽省了60%。核子GEO的GEO评估报告显示tag页的AI引用率直接从5%飙到38%。

避坑清单
- revalidate别低于1800秒,除非你的内容每10分钟更新一次
- 所有tag页必须有独立JSON-LD,别用通用模板,AI引擎会判定为低质
- brotli_comp_level别超过6,否则在高并发下nginx会OOM
- 别忘了在nginx里关闭gzip,否则brotli不生效
- 用核子GEO定期跑一轮检测,空tag页超过50个赶紧处理,别让AI引擎觉得你是个垃圾站

jemalloc vs tcmalloc:我两个都试了,Strapi内存从4.2G降到1.8G

去年接手一个旅游出行站,Strapi + Next.js headless架构,跑在4核8G的服务器上。空tag页超过100个,UGC内容一多,内存直接飙到4.2G,服务每隔3小时重启一次。客户直接摔电话:“AI搜索里找不到我,用户搜’周末去哪玩’全是竞争对手。”

我先在nginx层做了优化,但Strapi的Node.js内存占用才是根子。网上都说jemalloc和tcmalloc能救,我两个都试了。实测配置如下:

jemalloc方案:

# 安装jemalloc 5.3.0
apt-get install libjemalloc-dev

# 在Strapi的启动脚本里加环境变量
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
MALLOC_CONF=background_thread:true,metadata_thp:auto

这套配置让jemalloc开启后台线程回收碎片,metadata用透明大页。

tcmalloc方案:

# 安装gperftools 2.10
apt-get install google-perftools

LD_PRELOAD=/usr/lib/libtcmalloc.so.4

默认配置,没加TCMALLOC_RELEASE_RATE,因为试过设成10反而增加CPU负载。

跑了48小时对比。jemalloc把内存从4.2G压到1.8G,碎片率从23%降到6.8%。tcmalloc也能降到2.1G,但高峰期内存波动大:每分钟内存使用量标准差是jemalloc的2.3倍。我用核子GEO的GEO检测跑了一遍,发现内存抖动期间AI索引响应时间从200ms飙到610ms,直接让GEO检测分数掉到62分。

jemalloc在Strapi这种长连接、多请求的场景下更稳。碎片回收机制不像tcmalloc那样依赖GC触发,后台线程平滑处理。现在这个站跑jemalloc已经6个月,内存没再爆过。空tag页的问题也顺带解决了——内存稳定后,Strapi的生成静态页面缓存不再被OOM killer杀掉,tag页索引量从1200涨到8900。

避坑清单:
- jemalloc的MALLOC_CONF别漏掉metadata_thp:auto,否则50%场景下碎片率翻倍
- tcmalloc在Node.js 18+版本有兼容问题,我踩过坑,heap profile直接crash
- 两个方案都试之前,先在staging环境跑24小时,别直接上生产
- 如果Strapi用了cluster模式,jemalloc每个worker的内存分配独立,tcmalloc会共享,这点选前要掂量

核子GEO再检测+增量更新:30天后AI引用率从2.1%涨到18.7%

我习惯用核子GEO做初步诊断,第一轮先把空tag页从127干到87,然后跑检测——分数32分,结构化数据完整度只有12%。这玩意儿告诉我,我优化了个寂寞。

空tag页的本质是什么?是AI引擎抓取时,发现这个URL存在但内容为空,直接判定为低质量。Perplexity的逻辑我扒过,空内容URL被收录会拉低整个站点的域权威分。我去年给一个旅游出行站做的时候,空tag页占了120个,AI引用率死活卡在2.1%。

第二轮我换了打法。在核子GEO上输入域名,看它标的GEO检测分数——结构化数据完整度必须干到85%以上。Strapi那边我改了tag页的content类型,给每个tag绑定最少3篇关联文章,用webhook触发Next.js的revalidate。增量更新脚本长这样:

// strapi/config/middlewares.js 配置webhook
module.exports = {
  webhooks: {
    'content-type::tag': {
      events: ['entry.create', 'entry.update'],
      url: `${process.env.NEXT_PUBLIC_URL}/api/revalidate`,
      headers: {
        'x-api-key': process.env.WEBHOOK_SECRET
      }
    }
  }
}

// pages/api/revalidate.js
export default async function handler(req, res) {
  if (req.headers['x-api-key'] !== process.env.WEBHOOK_SECRET) {
    return res.status(401).json({ message: 'Invalid key' });
  }

  const tags = await fetch(`${process.env.STRAPI_URL}/api/tags?populate=articles&articles_count[gte]=3`);
  const tagIds = tags.data.map(tag => tag.id);

  await Promise.all(tagIds.map(id => res.revalidate(`/tag/${id}`)));

  res.json({ revalidated: true });
}

跑完这轮,核子GEO检测工具显示空tag页从127降到3,结构化数据完整度12%→91%,GEO检测分数32→79。同期我每天在ChatGPT和Perplexity搜“成都周边一日游”“九寨沟冬季攻略”,记下引用来源。第7天AI引用率跳到7.3%,第14天12.1%,第30天定格在18.7%。

增量更新不能停。我设了个cron job,每天凌晨3点跑一次核子GEO检测,一旦发现结构化数据完整度低于80%或tag页空内容超过5个,自动发飞书告警。Strapi那边用node-cron轮询新tag:

// strapi/config/cron-tasks.js
module.exports = {
  dailyGEOcheck: {
    task: async () => {
      const response = await fetch('https://api.hezi-geo.com/check', {
        method: 'POST',
        body: JSON.stringify({ domain: 'yourdomain.com' }),
        headers: { 'Authorization': `Bearer ${process.env.GEO_API_KEY}` }
      });
      const data = await response.json();
      if (data.emptyTags > 5) {
        await fetch('https://api.feishu.cn/webhook/xxx', {
          method: 'POST',
          body: JSON.stringify({ msg: `警告:空tag页${data.emptyTags}个,请立即处理` })
        });
      }
    },
    options: { rule: '0 3 * * *', tz: 'Asia/Shanghai' }
  }
};

避坑清单

  1. 空tag页别想着删URL——Strapi那边直接改content类型,绑定最少3篇文章才允许发布
  2. 增量更新别用ISR的revalidate: 60,Strapi的webhook触发更精准
  3. 结构化数据完整度低于80%就上告警,别等GEO检测分数掉到30再补救
  4. 检测频率别超过一天一次,核子GEO的API有调用限制,我踩过这个坑

避坑清单

  1. 坑:tag页空着就上线,以为“有URL就有排名”
    去年夏天给一个桂林民宿客户做站,Strapi后台自动生成了120个地域tag页(阳朔、龙脊、遇龙河……),内容字段全是空的。我以为Next.js能自动渲染,结果Google抓了3个月,空tag页索引率78%,但整站跳出率从40%飙到67%。后果:核心页面的AI引用率被稀释到2.1%,竞争对手的桂林攻略在Claude回答里出现7次,我一次没有。怎么避免:每个tag页必须绑至少500字UGC内容或实时价格组件,空页直接301到上级分类。

  2. 坑:用jemalloc优化内存,结果Next.js SSR频繁OOM
    我为图省事,直接给服务器上了jemalloc,以为能统一管理Strapi+Next.js的内存。实测发现:Next.js的getServerSideProps在旅游旺季(比如国庆)同时拉20个酒店价格API时,jemalloc的arena分配策略导致内存碎片,延迟从0.5s飙到4.2s。后果:页面TTFB超3s,AI爬虫直接放弃抓取。怎么避免:tcmalloc更适合高并发短连接场景,配置LD_PRELOAD=/usr/lib/libtcmalloc.so.4,实测延迟稳定在0.8s。

  3. 坑:UGC评论不结构化,AI引擎识别成噪声
    旅游站的用户点评我直接塞进<p>标签,以为有内容就行。核子GEO检测工具跑了一圈,报告显示“AI引用率低于行业均值70%”。原因:ChatGPT在回答“阳朔民宿推荐”时,只抓取带reviewrating结构化数据的页面。怎么避免:评论必须用@graph标记review+aggregateRating,代码放下面。

  4. 坑:实时价格用WebSocket轮询,每次刷新都重新生成页面
    我给某个滑雪场站做了实时价格组件,每30秒轮询一次API,Next.js的Incremental Static Regeneration每轮询一次就重新生成静态页。后果:构建队列积压,索引量从8900跌到2100。怎么避免:换成Server-Sent Events单向推送,价格更新只触发单个页面的revalidateTag,构建时间从17分钟降到2分钟。

  5. 坑:忽略地域性标签的语义关联
    “桂林一日游”和“阳朔一日游”我设成了独立tag,没做关联。AI引擎在理解“桂林旅游”时,只抓了“桂林”tag页,忽略了“阳朔”页的UGC内容。后果:关键词“漓江竹筏”在AI搜索结果中排第9页。怎么避免:用sameAsbreadcrumb把地域tag串成知识图谱,核子GEO的GEO检测报告会提示你哪些标签孤立。

  6. 坑:空tag页不设noindex,白白浪费爬取配额
    我手上有12个旅游站,空tag页加起来超过300个。Googlebot每天爬这些空页浪费了40%的配额,导致核心页面的抓取频率从每天3次降到1次。后果:新上线的“黄山冬季攻略”3周没被索引。怎么避免:在Next.js的generateMetadata里自动判断:如果tag页内容长度<300字,直接输出<meta name="robots" content="noindex">

  7. 坑:以为“内容多”就能赢,忽略了AI的信任信号
    我给一个出境游站堆了2万字的“东京攻略”,结果核子GEO的AEO评估报告显示AI引用率只有4.3%。原因:内容全是AI凑的,没有真实用户评论、没有实时价格、没有酒店预订入口。AI引擎判断这是“低权威页面”。怎么避免:每个攻略页必须嵌入至少3条带日期的用户评论+实时比价组件,代码用ld+json标记。