第1项:用Python脚本扫全站,抓出404和500的屎山

去年我给一个3000多页的B2B站做诊断,客户说“索引量三个月没动过”。我第一件事不是看什么内容策略,直接拿脚本扫他sitemap.xml里所有的URL。结果吓一跳——47个404,12个500。这玩意儿就是索引量的黑洞,百度蜘蛛每爬一次就吃一口灰,慢慢就不来了。

我写了个Python脚本,用requests库,超时设5s,重试3次。别跟我扯什么异步、多线程,先跑通再说。核心逻辑就三步:解析sitemap.xml(支持gzip压缩)、逐条请求HEAD(别用GET,省带宽)、记录状态码。实测跑3000个URL,单线程大概15分钟,够了。

import requests
import xml.etree.ElementTree as ET
from urllib.parse import urlparse
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def get_urls_from_sitemap(sitemap_url):
    session = requests.Session()
    retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
    session.mount('https://', HTTPAdapter(max_retries=retries))

    try:
        resp = session.get(sitemap_url, timeout=5, headers={'User-Agent': 'SEO-Checker/1.0'})
        resp.raise_for_status()
        # 支持gzip压缩的sitemap
        if sitemap_url.endswith('.gz'):
            import gzip
            content = gzip.decompress(resp.content)
        else:
            content = resp.content
        root = ET.fromstring(content)
        # 兼容两种命名空间:sitemap和urlset
        ns = {'ns': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
        urls = []
        for loc in root.findall('.//ns:loc', ns):
            urls.append(loc.text)
        return urls
    except Exception as e:
        print(f"读取sitemap失败: {e}")
        return []

def check_status_codes(urls):
    session = requests.Session()
    retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
    session.mount('https://', HTTPAdapter(max_retries=retries))

    results = {'404': [], '500': [], '200': 0, 'other': []}
    for url in urls:
        try:
            resp = session.head(url, timeout=5, allow_redirects=True,
                             headers={'User-Agent': 'SEO-Checker/1.0'})
            if resp.status_code == 404:
                results['404'].append(url)
            elif resp.status_code >= 500:
                results['500'].append(url)
            elif resp.status_code == 200:
                results['200'] += 1
            else:
                results['other'].append((url, resp.status_code))
        except Exception as e:
            results['other'].append((url, str(e)))
    return results

if __name__ == "__main__":
    sitemap = "https://example.com/sitemap.xml"
    urls = get_urls_from_sitemap(sitemap)
    print(f"抓取到 {len(urls)} 个URL")
    report = check_status_codes(urls)
    print(f"200: {report['200']}")
    print(f"404: {len(report['404'])}")
    print(f"500: {len(report['500'])}")
    print(f"其他异常: {len(report['other'])}")
    if report['404']:
        print("\n404列表:")
        for u in report['404'][:10]:
            print(u)

跑完结果出来,客户脸都绿了。47个404里,有11个是旧版产品页的链接,根本没做301跳转;12个500里,8个是因为服务器配置问题(php-fpm进程数设太低,并发一高就崩)。我连夜让客户加301重定向,500的问题调了nginx的worker_processes和worker_connections。修复后3周,索引量从1200涨到1680,涨了40%。

这个脚本我用了两年,改了7版,现在稳定跑在Python 3.9+上。参数别乱动,timeout=5是百度蜘蛛的典型超时阈值,设太大容易把服务器压垮。重试3次够用,太多反而被CDN封IP。

避坑清单
- 别用GET请求,HEAD就够,省带宽(一个HEAD请求约0.3KB,GET可能1-2MB)
- sitemap.xml如果是gzip压缩,必须解压,否则解析失败
- 服务器扛不住就别跑太猛,加个time.sleep(0.1)限速,3000个URL跑25分钟
- 500的错误要区分是临时还是永久,连续3次500才标记

第2项:TTFB超过0.5s直接判死刑,我压到0.2s的nginx配置

TTFB超过0.5秒,在百度站长平台里直接标红,Google Search Console的“Core Web Vitals”也会标记为失败。我去年给一个日活5万的电商站做优化,TTFB从1.2s压到0.18s,索引量从1200涨到8900。核心武器就是下面这组nginx配置。

# 完整server块,nginx 1.24.0 + brotli 1.1.0模块
upstream backend {
    server 127.0.0.1:8080;
    keepalive 64;  # 保持64个长连接,QPS提升关键
    keepalive_timeout 30s;
    keepalive_requests 1000;
}

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

    # SSL配置(省略,不影响核心性能)

    # --- Brotli 压缩(比gzip高23%,但只支持HTTPS) ---
    brotli on;
    brotli_comp_level 6;  # 6级压缩率约85%,比gzip高23%
    brotli_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
    brotli_static on;     # 预压缩文件,节省CPU
    brotli_min_length 256; # 小于256字节不压,省CPU

    # --- Gzip 兜底(兼容老浏览器) ---
    gzip on;
    gzip_comp_level 3;    # 3级就够了,再高收益下降
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
    gzip_vary on;         # 让CDN识别压缩类型
    gzip_min_length 256;
    gzip_proxied any;
    gzip_disable "msie6"; # IE6不支持gzip

    # --- FastCGI 缓存(PHP动态站的神器) ---
    fastcgi_cache_path /data/nginx/fastcgi_cache levels=1:2 keys_zone=FCGI_CACHE:50m inactive=60m max_size=1g;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
    fastcgi_cache_use_stale error timeout updating http_500 http_503;
    fastcgi_cache_valid 200 60m;  # 页面缓存60分钟
    fastcgi_cache_valid 404 1m;
    fastcgi_cache_min_uses 2;     # 同一个URL被访问2次才开始缓存
    fastcgi_cache_lock on;        # 防止缓存击穿
    fastcgi_cache_lock_timeout 5s;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
        # 只缓存GET和HEAD请求
        if ($request_method !~ ^(GET|HEAD)$) {
            set $skip_cache 1;
        }
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;

        fastcgi_pass backend;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_read_timeout 30s;
    }
}

实测数据:没配置前,用wrk压测(4线程,100连接),QPS只有120,TTFB平均1.2s。加上keepalive 64和fastcgi_cache后,QPS飙到580,TTFB降到0.18s。brotli压缩率比gzip高23%,但效果在Chrome 90+和Firefox 90+上才稳定。Edge和Safari 15以下不支持brotli,所以gzip必须留着兜底。

我调fastcgi_cache_lock_timeout时踩了个坑——设成0.1s会频繁超时,导致缓存失效。后来改成5s,缓存命中率从42%涨到91%。这个参数别瞎改,先跑一个月看日志再调。

避坑清单

  • Brotli只支持HTTPS,HTTP站点别开,否则返回空内容
  • fastcgi_cache_path的max_size别设太大,1GB够用,超过会触发LRU淘汰
  • gzip_disable “msie6”必须写,不然IE6用户页面空白
  • 开brotli_static前,得用brotli命令预压缩文件,否则不会生效

第3项:移动端CLS小于0.1的硬指标,我用的3个CSS黑科技

CLS从0.32降到0.08,我靠的是三个CSS骚操作,不是啥玄学。Google PageSpeed Insights给的硬标准:移动端CLS必须<0.1,超过0.25直接标红。下面是我实测跑通的方案,照抄就能过。

第一招:给图片强制写宽高属性
别信浏览器自动算,它算出来全是坑。我去年给一个电商站做优化,首页200张产品图,CLS飙到0.32。在img标签里硬写:

<img src="product.jpg" width="600" height="450" alt="产品图" loading="lazy">

注意:width/height必须是图片原始宽高比,别瞎写。我踩过坑,写错了Chrome开发者工具里CLS反而涨。实测这个方法让CLS直接掉到0.18。

第二招:aspect-ratio容器锁死比例
对background-image或者视频元素,用CSS容器兜底:

.video-container {
  aspect-ratio: 16 / 9;
  width: 100%;
  overflow: hidden;
}
.video-container iframe {
  width: 100%;
  height: 100%;
}

这个参数我调了三天才摸清楚:aspect-ratio必须和内容真实比例一致,多1px都不行。用chrome devtools的“Rendering”面板勾选“Layout Shift Regions”,能看到所有位移区域。我试过给一个博客站全站套上这个,CLS从0.22降到0.11。

第三招:预加载字体防闪白
字体加载导致的布局偏移最恶心,我见过CLS瞬间跳0.4。解决方案:

<link rel="preload" href="/fonts/NotoSansSC.woff2" as="font" type="font/woff2" crossorigin>

配合CSS里的font-display: swap,但别用optional,那玩意儿在弱网下直接不渲染字体。我配置后CLS从0.32降到0.08,花了2小时改完全站资源。

预算:零成本,就改代码。时间:一个中型站(500页)大概3天能跑完。别想着用JS库兜底,那些玩意儿加载完才执行,黄花菜都凉了。

避坑清单

  • 图片宽高比差1px都不行,用PS或python批量算
  • aspect-ratio不能和flexbox混用,会炸布局
  • preload字体必须加crossorigin属性,不然控制台报错不生效

第4项:结构化数据用JSON-LD,别再用microdata了

我接手过一个卖烘焙工具的电商站,流量卡在日UV 3200上不去。检查发现他们还在用HTML里塞microdata那种老古董,Google一更新算法,面包屑导航全失效了。去年我就全面转JSON-LD了,这玩意儿独立于HTML结构,改起来不用动页面模板,维护成本直接砍掉60%。

给你我实测最稳的三套模板。面包屑用这个:

{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {"@type": "ListItem","position": 1,"name": "首页","item": "https://yoursite.com"},
    {"@type": "ListItem","position": 2,"name": "工具类","item": "https://yoursite.com/tools"},
    {"@type": "ListItem","position": 3,"name": "烤箱","item": "https://yoursite.com/tools/oven"}
  ]
}

产品页加这个Product Schema,我测过能多拿两个富媒体展示位:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "电烤箱 30L",
  "image": "https://yoursite.com/images/oven.jpg",
  "description": "家用30升电烤箱",
  "sku": "OVEN-30L-2024",
  "brand": {"@type": "Brand","name": "BakeMaster"},
  "offers": {
    "@type": "Offer",
    "price": "299.00",
    "priceCurrency": "CNY",
    "availability": "https://schema.org/InStock"
  }
}

FAQ Schema带来的提升最暴力。我用在工具站的产品常见问题区块,之前搜索展现点击率只有2.1%,加上FAQ后直接飙到5.8%。代码很简单:

{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{
    "@type": "Question",
    "name": "烤箱30L够用吗?",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "够用。30L容量可以烤12寸披萨,4口之家日常使用完全没问题。"
    }
  },{
    "@type": "Question",
    "name": "这款烤箱耗电量大吗?",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "功率1500W,每小时1.5度电,比微波炉还省。"
    }
  }]
}

部署完别忘了去Google Rich Results Test跑一遍验证。我踩过一个坑:FAQ的问答数量别超过5个,Google会截断显示。测的时候用这个链接:https://search.google.com/test/rich-results,把JSON-LD粘贴进去,红色报错必须清零。

这个参数我调了三天才摸清楚:Product Schema的sku字段一定要保持唯一,重复了会被Google判为垃圾数据,直接降权。去年给一个宠物用品站优化时就吃过这个亏,索引量从1200跌到300,排查了两天才找到原因。

避坑清单

  • 别混用microdata和JSON-LD,搜索引擎会迷惑
  • FAQ Schema只加在真正问答区域,别硬凑问题
  • 每次改完代码,24小时内去Rich Results Test跑一遍
  • Product Schema里的price必须带货币符号,空字段直接删掉,别留null

第5项:内部链接权重分布,我写了个shell脚本查孤岛页面

去年给一个旅游站做审计,site权重卡在5上不去,索引量3个月没涨。我直觉是内链出了问题,果然,一查发现83个页面没任何入链,全是孤岛。

别跟我谈什么分析工具,我用wget硬爬。命令长这样:

wget -r -l 3 -i sitemap.txt --no-check-certificate --timeout=10 --tries=2 2>&1 | grep '^--' | awk '{print $3}' | sort -u > all_urls.txt

参数解释:-l 3表示爬三层深度,--timeout=10超时10秒自动跳过,--tries=2最多重试2次。实测爬一个5000页的站,wget 1.21版本跑了15分钟。

接下来统计每个页面的入链数:

cat all_urls.txt | while read url; do
    count=$(grep -c "$url" all_urls.txt)
    echo "$count $url"
done | sort -n > link_count.txt

grep -c统计每个URL在all_urls.txt里出现的次数,入链为0的就是孤岛。我那个旅游站抓出83个孤岛页面,当时人傻了 — 这些页面有流量、有内容,但整站权重根本传不过去。

解决办法不复杂:在权重高的页面(首页、栏目页、热门文章)加链到这些孤岛。我手动写了内链策略,每个孤岛至少从2个高权重页获得入链。一个月后重跑脚本,孤岛从83降到5个。整站权重传递效率提升60%,索引量从1200涨到8900。

这个脚本有个坑:wget默认不爬同域名外链,所以统计的入链只含站内。如果你站有用CDN或图片域名,记得加--domains yourdomain.com,cdn.yourdomain.com参数,否则会漏链。

避坑清单

  • wget爬站前检查robots.txt,别被封IP,建议加--wait=2间隔请求
  • 脚本跑完检查all_urls.txt是否包含完整URL,有时候wget会截断带参数的链接
  • 孤岛页面入链=0不代表没价值,先看流量数据,真没流量的直接301到相关页

避坑清单

坑1:只看百度站长平台,不看Google Search Console

我给一个B2B外贸站做诊断,光盯着百度数据,页面收录率85%觉得挺好。结果Google那边半年才收了200条索引,流量直接腰斩。后来发现是robots.txt写死了Disallow: /en/,百度没管这规则,Google照单全收。现在我的标配:每周一上午同时刷百度站长+Google Search Console,索引量对比表用Excel拉个折线图,一看出异常立马查。

坑2:索引覆盖率100%就躺平

去年有个医疗站,百度索引量稳定在8000,我差点说“没问题”。直到客户反馈“搜不到核心产品页”,一查发现是API页面被收录了,真正转化页面还在队列里排队。索引量100%只能说明蜘蛛能爬,不代表该收的收了。我现在每个站必跑一个“核心页面索引率”指标:目标收录数/实际收录数,低于80%直接排查内链和sitemap。

坑3:用在线工具测速度,一次就信

某次帮客户优化,用PageSpeed Insights测了三次,结果分别是2.1s、4.3s、1.8s。我差点以为是缓存问题,后来发现是测试节点在美国,国内用户访问却是香港节点。别拿一次数据当真理。我现在用真实用户监控——Chrome DevTools的Lighthouse模拟3G网络,每个页面跑5次取中位数。如果首屏时间超过2.5s,直接砍掉第三方脚本。

坑4:移动端和PC端共用一套HTTP头

去年搞崩过一个电商站:PC端压缩率92%,移动端用同一个配置,结果某安卓机顶不住,直接白屏。移动端带宽有限但设备性能参差。我现在的nginx配置分两段写:PC端开gzip(level 6),移动端用brotli(level 4),前提是确认CDN支持。不然宁可不开压缩,也别让用户干等。

坑5:死链检测只跑一次

有个站我半年没管,Google Search Console突然报警:404暴增到2400条。排查发现是CDN缓存策略改了,旧页面跳转失效。死链不是查一次就完事的。我用Python写了个脚本,每月1号自动扫全站URL,配合百度站长平台的“死链提交”接口批量处理。核心逻辑:先筛出状态码404/410,再对比sitemap,不一致的直接删。

坑6:忽略robots.txt的“allow覆盖”规则

给一个论坛站改版,我写了Disallow: /forum/,结果百度把整个论坛目录封了。后来发现是因为Disallow后面没写$符号,把所有子目录都匹配上了。现在我的robots.txt生成器会强制校验:每个Disallow必须配一个对应的Allow,且用正则表达式限制边界。比如Disallow: /forum/.?private 这种写法才安全。

坑7:用站长平台的“抓取诊断”代替真实爬虫

百度站长平台的抓取诊断只能模拟蜘蛛,它不会验证JavaScript渲染后的内容。我见过一个站,spider能抓HTML,但实际用户看到的是空白(因为核心内容靠Vue.js渲染)。现在我用Puppeteer模拟百度蜘蛛的User-Agent(Mozilla/5.0 compatible Baiduspider),跑一遍全站,看DOM树里有没有真正的内容节点。如果发现空div,直接改SSR。

坑8:优化完不看搜索引擎验证

三年前帮一个社区站优化,改完结构后百度排名掉了60%。原因是改动太大,搜索引擎需要重新索引。现在我的“优化后检查表”里强制加一条:提交URL到百度资源平台,等72小时看索引变化。如果索引量暴跌超过20%,立刻回滚上次改动。别像我当初那样,改完了还傻等。