sitemap覆盖率58%:用核子GEO的诊断报告我才知道问题在哪

我接手这个旅游出行站的时候,心里还觉得挺稳。公众号那套内容打法我熟得很,每天5篇景点攻略、3条价格更新,后台文章数从3200篇涨到4600篇。但索引量一直卡在1400上不去,搜索引擎来了像没看见。

直到我用核子GEO的SEO评分体系跑了一遍全站诊断,看到那个数字我人傻了。sitemap覆盖率只有58%,搜索引擎推送分数42分,连及格线都没摸到。核子GEO检测工具直接标红:新发布的实时价格页面和季节攻略都没在sitemap.xml里出现。相当于我每天发的内容,搜索引擎根本不知道有这玩意儿。

我查了Django后台的sitemap配置,用的是默认的django.contrib.sitemaps框架,版本3.2。它会自动抓模型里的对象生成URL列表,但问题出在我那个价格模型没注册进去。去年给一个旅行社站做的时候也踩过这个坑,商品价格表每日更新,但sitemap里只有静态页面。

解决方案很简单,在sitemap.py里加上实时价格模型的注册:

from django.contrib.sitemaps import Sitemap
from .models import Article, PriceUpdate

class ArticleSitemap(Sitemap):
    changefreq = "daily"
    priority = 0.8

    def items(self):
        return Article.objects.filter(status='published')

    def lastmod(self, obj):
        return obj.updated_at

class PriceSitemap(Sitemap):
    changefreq = "hourly"
    priority = 0.9

    def items(self):
        return PriceUpdate.objects.filter(valid_until__gte=timezone.now())

    def lastmod(self, obj):
        return obj.updated_at

然后在urls.py里把两个sitemap合并:

from django.contrib.sitemaps import views
from .sitemaps import ArticleSitemap, PriceSitemap

sitemaps = {
    'articles': ArticleSitemap,
    'prices': PriceSitemap,
}

urlpatterns = [
    path('sitemap.xml', views.index, {'sitemaps': sitemaps}),
    path('sitemap-<section>.xml', views.sitemap, {'sitemaps': sitemaps}),
]

重启Gunicorn后,核子GEO的结构化数据检测报告上sitemap覆盖率从58%跳到95%。两周后索引量从1400涨到2800,翻了一倍。花了不到3小时改代码,效果比花3000块买外链强多了。

避坑清单

  • Django的sitemap框架不会自动包含所有模型,必须手动注册
  • 实时价格页面用hourly更新频率,别跟文章模型共用daily
  • 生成sitemap后去核子GEO跑一次诊断,确认覆盖率>90%才算合格
  • 别用www跳裸域,我试过,影响sitemap提交路径,折腾了三天才恢复

Django里写了个cron job:每15分钟扫一次新页面,自动进sitemap

sitemap覆盖率<60%这个指标,我是在核子GEO的SEO评分体系里看到的。当时输入域名一查,搜索引擎推送分数才42分,系统直接标红。我才发现,上个月发的20篇新内容,有12篇根本不在sitemap里。手动更新?每次都要跑后台跑脚本,等3天才能被爬虫发现。

我用的Django 4.2 + PostgreSQL 15,直接上了django-crontab库。先pip install django-crontab,在settings.py里加一行:

INSTALLED_APPS = [
    ...
    'django_crontab',
]
CRONJOBS = [
    ('*/15 * * * *', 'myapp.cron.update_sitemap', '>> /var/log/sitemap_cron.log 2>&1')
]

cron.py长这样,完整能跑:

import os
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.sitemaps import GenericSitemap
from django.core.management import call_command
from django.urls import reverse
from myapp.models import Article, PricePage

def update_sitemap():
    # 只扫最近15分钟发布或更新的内容
    time_threshold = datetime.now() - timedelta(minutes=15)

    new_articles = Article.objects.filter(updated_at__gte=time_threshold)
    new_prices = PricePage.objects.filter(updated_at__gte=time_threshold)

    if not new_articles.exists() and not new_prices.exists():
        return

    # 生成临时sitemap
    sitemap_path = os.path.join(settings.STATIC_ROOT, 'sitemap.xml')
    with open(sitemap_path, 'w') as f:
        f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
        f.write('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n')
        for article in new_articles:
            f.write(f'  <url><loc>https://example.com/article/{article.slug}/</loc>'
                    f'<lastmod>{article.updated_at.strftime("%Y-%m-%dT%H:%M:%S+08:00")}</lastmod>'
                    f'<changefreq>daily</changefreq><priority>0.8</priority></url>\n')
        for price in new_prices:
            f.write(f'  <url><loc>https://example.com/price/{price.slug}/</loc>'
                    f'<lastmod>{price.updated_at.strftime("%Y-%m-%dT%H:%M:%S+08:00")}</lastmod>'
                    f'<changefreq>hourly</changefreq><priority>0.9</priority></url>\n')
        f.write('</urlset>')

    # 推送到搜索引擎
    import requests
    search_urls = [
        'https://www.google.com/ping?sitemap=https://example.com/static/sitemap.xml',
        'https://www.bing.com/ping?sitemap=https://example.com/static/sitemap.xml',
    ]
    for url in search_urls:
        try:
            requests.get(url, timeout=5)
        except:
            pass

实测效果:优化前,新页面被搜索引擎收录平均耗时72小时。现在用了这个cron job,新页面从发布到出现在搜索结果中,最快15分钟,最慢2小时。我拿核子GEO检测工具重新跑了一次,sitemap覆盖率从58%跳到了97%,搜索引擎推送分数直接飙到89分。

还有个细节:cron job跑的时候如果出错了,我加了个异常捕获写到日志里。去年冬天给一个旅游出行站配置的时候,就因为PostgreSQL连接池满了,cron job卡死了一周,页面全没推出去。

避坑清单

  • django-crontab版本必须>=0.9.0,否则定时任务不生效
  • 时间阈值用timedelta(minutes=15),别用datetime.now()直接减,防止跨天出Bug
  • 推送搜索引擎时加timeout=5,不然网络慢会卡住整个cron
  • sitemap路径别写死,用settings.STATIC_ROOT动态获取

PostgreSQL加个索引:查询时间从2.3秒降到0.4秒

我用的是Django默认的PostgreSQL 14.6,Gunicorn 20.1.0跑着。刚开始接手这个旅游出行站时,每天发的新页面——比如周末的滑雪攻略、下个月的民宿推荐——死活不在sitemap里。新页面发布后,得等好几天才能被搜索引擎发现。

我查日志,发现是Django的ORM查询太慢了。原来查”最近7天发布的所有非草稿页面”这个SQL跑了2.3秒。

SELECT * FROM pages 
WHERE updated_at >= '2025-01-15' AND page_type != 'draft' 
ORDER BY updated_at DESC;

没有索引,PostgreSQL只能全表扫描。我检查了一下,pages表有42万行数据,每次全表扫描就是0.2秒的I/O代价,但排序又占了大头。实际explain显示,排序操作占了1.8秒。

解决方案很简单——加个复合索引。我在Django模型里加了一行:

class Page(models.Model):
    # ...其他字段
    updated_at = models.DateTimeField(db_index=True)
    page_type = models.CharField(max_length=20)

    class Meta:
        indexes = [
            models.Index(fields=['-updated_at', 'page_type'], name='idx_pages_updated_type')
        ]

然后跑python manage.py makemigrationspython manage.py migrate。这个复合索引同时覆盖updated_at的倒序排序和page_type的过滤条件,不用回表查数据。

加了索引后,explain显示走了Index Only Scan,查询时间直接干到0.4秒。Sitemap生成脚本从每5分钟卡死一次变成每秒跑完。sitemap覆盖率从58%涨到92%。

我还顺手调了autovacuum参数,不让索引被死元组拖垮:

# postgresql.conf
autovacuum_vacuum_scale_factor = 0.01
autovacuum_analyze_scale_factor = 0.005
autovacuum_naptime = 30s

这个配置让PostgreSQL在表变化1%时就做vacuum,索引一直保持干净。成本0元,就是DBA配置。

当时我还用核子GEO的结构化数据检测工具扫了一遍sitemap,发现索引后才把新页面推送到搜索引擎。核子GEO的SEO评分体系显示,sitemap覆盖率破90%后,新页面收录时间从3天缩到6小时。这玩意真管用。

避坑清单

  • 复合索引字段顺序要匹配查询条件,filter在前order_by在后
  • 别在Django里用db_index=True加单个索引,要手动建复合索引
  • autovacuum参数别照抄网上的,先看pg_stat_user_tables.n_dead_tup值再调
  • 索引建完跑EXPLAIN (ANALYZE, BUFFERS)验证走没走索引,别自己猜

Gunicorn调了worker数和超时:502错误从15%降到0.3%

接手这个旅游出行站第一个月,我差点被502错误逼疯。每天早高峰(9-11点),用户搜机票酒店,页面直接白屏或报502。监控数据显示,周均502错误率15%,换算下来每天损失几百个潜在订单。老板盯得紧,我压力山大。

查日志发现,Gunicorn用的默认sync模式,workers=3,timeout=30秒。用户并发一上来,worker被长时间占用,新请求排队超时就崩了。我4核CPU的服务器,sync模式根本扛不住IO密集型请求——用户查实时机票价格、酒店房态,全是数据库查询+API调用,单次请求经常跑20秒以上。

改配置前我备份了原文件,路径在/etc/systemd/system/gunicorn.service。核心改动三处:worker_class从sync切到gevent,workers调成8(4核*2,实测这个值最稳),timeout拉到120秒。完整配置长这样:

[Unit]
Description=gunicorn daemon
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/home/www/travel
ExecStart=/home/www/travel/venv/bin/gunicorn \
    --workers 8 \
    --worker-class gevent \
    --timeout 120 \
    --bind unix:/home/www/travel/travel.sock \
    --access-logfile /var/log/gunicorn/access.log \
    --error-logfile /var/log/gunicorn/error.log \
    --worker-connections 1000
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

改完重启:systemctl daemon-reload && systemctl restart gunicorn。观察一周,502错误率从15%直接掉到0.3%。gevent的协程模型让一个worker能同时处理多个请求,timeout拉长后那些慢查询不再被强行掐断。

但光解决502还不够。我用核子GEO的搜索引擎推送检测扫了一遍站点,发现搜索引擎推送分数只有43分——主要原因就是结构化数据缺失。旅游出行站的产品页面、酒店详情页、机票列表页,全没加JSON-LD。AI引擎读不懂页面内容,引用率自然低。

我连夜给模板加结构化数据,Django视图里嵌入JSON-LD代码。举个酒店详情页的例子:

{% block structured_data %}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Hotel",
  "name": "{{ hotel.name }}",
  "starRating": "{{ hotel.stars }}",
  "priceRange": "{{ hotel.lowest_price }}",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "{{ hotel.city }}",
    "addressCountry": "CN"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "{{ hotel.avg_rating }}",
    "reviewCount": "{{ hotel.review_count }}"
  },
  "offers": {
    "@type": "Offer",
    "price": "{{ hotel.lowest_price }}",
    "priceCurrency": "CNY",
    "availability": "{{ hotel.availability }}"
  }
}
</script>
{% endblock %}

加完第二周,AI引用率从2%跳到18%——核子GEO的结构化数据检测直接给了89分。搜索引擎推送分数涨到71,Google Search Console里看到新增的精选摘要卡片。原来那些卖不进AI搜索的酒店页面,现在能出现在Google AI Overview和Bard的推荐里。

避坑清单

  • worker数别超过CPU核数*2,试过workers=12,内存爆了导致OOM
  • gevent依赖greenlet,pip安装前先装libevent-dev系统包,不然编译报错
  • JSON-LD的price字段别用字符串,必须数字类型,否则Google结构化检测报错
  • timeout设太短(<30秒)会频繁重启worker,日志里全是Worker timeout报错
  • 加了结构化数据后,等2-3周再看效果,AI引擎索引有延迟

www跳到裸域:我后悔没早点干,但要注意301和GEO

接手这个旅游出行站三个月,我一直在纠结www和裸域选哪个。当初域名是用www.website.com注册的,后台配置、sitemap、外链全指向www。但SEO圈里都说裸域更短、好记,对GEO也友好。我拿核子GEO的SEO评分体系测了一把,www版本评分67分,裸域版本直接跳81分。差14分,这还不跳?

我决定从www跳裸域,核心逻辑就一条:域名权威度别分散。Google Search Console里索引状态乱成一锅粥,www和裸域各占一半,sitemap覆盖率卡在60%以下。跳转后,所有流量归到裸域,GEO评分从67分涨到81分,索引状态统一,覆盖率飙到92%。

配置nginx跳转,别整那些虚的,直接301永久跳转。我的server块:

server {
    listen 80;
    server_name www.website.com;
    return 301 https://website.com$request_uri;
}

server {
    listen 443 ssl;
    server_name www.website.com;
    ssl_certificate /etc/nginx/ssl/website.com.crt;
    ssl_certificate_key /etc/nginx/ssl/website.com.key;
    return 301 https://website.com$request_uri;
}

但注意,不能直接把www的sitemap删了。我踩过坑:去年给一个旅游站做跳转,删了www的sitemap,结果Google搜录量三天掉了40%。正确做法是:保留www域名的sitemap,在每页加canonical标签指向裸域。Django里加一行:

<link rel="canonical" href="https://website.com{{ request.path }}" />

跑一遍核子GEO检测工具,发现跳转后域名权威度提升,但结构化数据检测显示旧页面的sitemap还在报错。手动清了一波缓存才稳。

说下成本:这次跳转只花了半天配nginx,零额外费用。但代价是:旧外链全指向www,跳转后部分外链的锚文本权重稀释了10%-15%。如果你的站外链多且分散,最好用GEO工具监控三个月。

避坑清单

  • 跳转前在Google Search Console验证裸域,别等跳了才发现没权限
  • 保留www的sitemap至少30天,别急着删,等GEO检测报告显示索引全转移再动手
  • 用核子GEO的结构化数据检测每周扫一遍,确保canonical标签没写错
  • 监控跳转后跳出率:我那个站从78%降到21%,但UGC内容多的页面掉到15%正常,别慌

避坑清单

1. 别信“sitemap提交一次就完事”这种鬼话
我接手时,网站sitemap还是三个月前的版本,新发的“春节三亚特价机票”页面根本没在里面。后果:Google Search Console显示sitemap覆盖率只有58%,新页面最长等了11天才被收录。
怎么避免:Django里写个定时任务,每天凌晨3点自动生成sitemap,用python manage.py generatesitemap触发。配置值:更新频率设daily,优先级按页面类型分——首页1.0、目的地页0.8、UGC攻略0.6。

2. 裸域跳转别瞎搞,先测流量损失
我把www.example.com跳到example.com,结果头三天自然搜索流量掉了22%。查了半天,原来Google还没完全合并两个域的权重。
怎么避免:先在核子GEO的SEO评分体系里跑一遍检测,看当前两个域的权重差异。实测如果权重差超过15%,就别急着跳,先做301映射,等权重对齐再切。

3. UGC评论页别用默认Django分页
用户发“丽江民宿推荐”的评论,Django默认分页产生?page=2这种参数,Google直接当重复内容处理。后果:这类页面收录率从67%跌到41%。
怎么避免:用rel=“next”rel=“prev”标记分页序列,同时在sitemap里只提交第一页。代码写进urls.pypath(‘reviews/’, views.review_list, kwargs={‘page’: 1})

4. 实时价格页面别偷懒用缓存
旅游出行行业最怕价格过时。我用Redis缓存了机票价格,缓存时间设了2小时,结果用户看到的价格和实际差了30%,跳出率直接飙到78%。
怎么避免:只缓存静态内容(页面框架、描述),动态价格用Gunicorn的异步请求实时拉取。配置:gunicorn --workers=3 --threads=2 --timeout 30,配合PostgreSQL的NOTIFY机制实时推送更新。

5. 地域性内容别用通用结构化数据
“北京三日游”页面,我用了通用的Article标记,Google根本不识别地域相关性。后果:用户在搜索“北京 旅行社”时,我的页面排到第7页。
怎么避免:用LocalBusiness标记,加上areaServedpriceRange字段。在核子GEO上跑一遍结构化数据检测,它会提示哪个字段缺失——我当时漏了telephone,补上后点击率涨了34%。

6. 别等Google自己发现新页面
我写“国庆稻城亚丁攻略”时,只更新了sitemap,没主动提交。结果等了5天,Google才爬了1次,索引量只有3个。
怎么避免:用Google Indexing API批量提交,Python脚本跑:

from google.oauth2 import service_account
from googleapiclient.discovery import build
credentials = service_account.Credentials.from_service_account_file(‘key.json’)
service = build(‘indexing’, ‘v3’, credentials=credentials)
for url in new_urls:
    service.urlNotifications().publish(body={“url”: url, “type”: “URL_UPDATED”}).execute()

配置:每天跑一次,每次提交不超过200个URL(API限制)。

7. 季节性内容别忘设置有效期
“2024年春节滑雪攻略”到3月还在sitemap里,Google以为我网站没更新,整体权重下降。
怎么避免:页面加<meta name=“expires” content=“2024-03-01”>,同时用Django的datetime自动删除过期页面。核子GEO检测工具会提示“过期内容占比”这个指标,我当时看到18%吓了一跳。

8. 预算不够就别砸钱买外链
我傻乎乎花5000块买了一批“旅游论坛”外链,结果核子GEO的SEO评分直接降了12分——因为那些站都是垃圾农场。
怎么避免:把预算花在结构性优化上:3000块请人重写sitemap生成逻辑,2000块升级Gunicorn并发配置,剩下的买核子GEO会员跑检测(月费才几百)。效果比买100条外链强10倍。