用Python完成网站SEO与GEO基础巡检:检查Sitemap、Canonical和JSON-LD

简介: 本文使用Python编写网站SEO与GEO基础巡检脚本,自动检查Sitemap、页面状态、标题描述、Canonical、robots标签及JSON-LD结构化数据,并将结果导出为CSV,帮助开发者快速发现网站中的常见技术问题。

网站上线后,SEO检查经常依靠人工逐页查看。当网站页面数量增加时,人工检查容易遗漏一些基础问题,例如:

页面无法正常访问;
标题或页面描述为空;
页面没有主标题;
规范地址缺失;
页面被设置为不参与索引;
没有配置JSON-LD结构化数据;
Sitemap中存在失效页面。

本文使用Python编写一个基础巡检工具,自动读取网站的robots.txt和sitemap.xml,并批量检查页面中的常见SEO与GEO基础信息。

这套工具主要用于技术检查,不分析关键词排名,也不代表页面一定会被搜索系统收录或引用。

一、SEO与GEO巡检需要检查什么

SEO技术检查主要关注页面能不能被正常访问、抓取和理解。

本文将GEO基础检查理解为以下几个方面:

页面主题能不能通过标题和正文清楚表达;
企业、文章或产品信息能不能结构化呈现;
JSON-LD中的信息与页面正文能不能保持一致;
不同页面中的名称和内容描述有没有明显冲突。

因此,脚本除了检查传统SEO标签,还会读取页面中的JSON-LD数量和类型。

二、准备Python运行环境

本文使用Python 3,需要安装两个第三方库:

pip install requests beautifulsoup4

新建一个Python文件:

seo_geo_audit.py

然后将下面的代码复制到文件中。

三、完整巡检代码
import csv
import json
import sys
import time
import xml.etree.ElementTree as ET
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

TIMEOUT_SECONDS = 10
MAX_PAGES = 20

def normalize_site(site: str) -> str:
"""统一站点地址格式。"""
site = site.strip()

if not site.startswith(("http://", "https://")):
    site = "https://" + site

parsed = urlparse(site)

if not parsed.netloc:
    raise ValueError("站点地址格式不正确")

return f"{parsed.scheme}://{parsed.netloc}"

def fetch_page(url: str):
"""请求页面并统计响应时间。"""
start_time = time.perf_counter()

response = requests.get(
    url,
    timeout=TIMEOUT_SECONDS
)

elapsed_ms = int(
    (time.perf_counter() - start_time) * 1000
)

response.raise_for_status()

return response, elapsed_ms

def find_sitemap_urls(site: str) -> list[str]:
"""从robots.txt中查找Sitemap地址。"""
robots_url = urljoin(site + "/", "robots.txt")
sitemap_urls = []

try:
    response, _ = fetch_page(robots_url)

    for line in response.text.splitlines():
        line = line.strip()

        if line.lower().startswith("sitemap:"):
            sitemap_url = line.split(":", 1)[1].strip()

            if sitemap_url:
                sitemap_urls.append(sitemap_url)

except requests.RequestException:
    pass

if not sitemap_urls:
    sitemap_urls.append(
        urljoin(site + "/", "sitemap.xml")
    )

return list(dict.fromkeys(sitemap_urls))

def get_xml_tag_name(tag: str) -> str:
"""移除XML命名空间。"""
return tag.rsplit("}", 1)[-1]

def parse_sitemap(
sitemap_url: str,
visited: set[str] | None = None
) -> list[str]:
"""解析普通Sitemap和Sitemap索引。"""
if visited is None:
visited = set()

if sitemap_url in visited:
    return []

visited.add(sitemap_url)

try:
    response, _ = fetch_page(sitemap_url)
    root = ET.fromstring(response.content)

except (
    requests.RequestException,
    ET.ParseError
):
    return []

root_name = get_xml_tag_name(root.tag)

locations = []

for node in root.iter():
    if (
        get_xml_tag_name(node.tag) == "loc"
        and node.text
    ):
        locations.append(node.text.strip())

if root_name == "sitemapindex":
    page_urls = []

    for child_sitemap in locations[:20]:
        page_urls.extend(
            parse_sitemap(
                child_sitemap,
                visited
            )
        )

    return page_urls

if root_name == "urlset":
    return locations

return []

def collect_jsonld_types(
soup: BeautifulSoup
) -> tuple[int, list[str]]:
"""读取JSON-LD数量和数据类型。"""
blocks = soup.find_all(
"script",
attrs={"type": "application/ld+json"}
)

type_names = []

def walk_json(value):
    if isinstance(value, dict):
        current_type = value.get("@type")

        if isinstance(current_type, str):
            type_names.append(current_type)

        elif isinstance(current_type, list):
            type_names.extend(
                str(item)
                for item in current_type
            )

        for child in value.values():
            walk_json(child)

    elif isinstance(value, list):
        for child in value:
            walk_json(child)

for block in blocks:
    raw_text = block.string or block.get_text()

    if not raw_text.strip():
        continue

    try:
        parsed_data = json.loads(raw_text)
        walk_json(parsed_data)

    except json.JSONDecodeError:
        continue

return len(blocks), sorted(set(type_names))

def audit_page(url: str) -> dict:
"""检查单个页面的基础信息。"""
result = {
"url": url,
"status_code": "",
"response_ms": "",
"title": "",
"title_length": 0,
"description": "",
"description_length": 0,
"h1_count": 0,
"canonical": "",
"robots_value": "",
"jsonld_count": 0,
"jsonld_types": "",
"text_chars": 0,
"error": ""
}

try:
    response, elapsed_ms = fetch_page(url)

    result["status_code"] = response.status_code
    result["response_ms"] = elapsed_ms

    soup = BeautifulSoup(
        response.text,
        "html.parser"
    )

    if soup.title:
        title = soup.title.get_text(
            " ",
            strip=True
        )

        result["title"] = title
        result["title_length"] = len(title)

    description_tag = soup.find(
        "meta",
        attrs={
            "name": lambda value:
            value
            and value.lower() == "description"
        }
    )

    if description_tag:
        description = description_tag.get(
            "content",
            ""
        ).strip()

        result["description"] = description
        result["description_length"] = len(
            description
        )

    result["h1_count"] = len(
        soup.find_all("h1")
    )

    canonical_tag = soup.find(
        "link",
        rel=lambda value:
        value and "canonical" in value
    )

    if canonical_tag:
        result["canonical"] = canonical_tag.get(
            "href",
            ""
        ).strip()

    robots_tag = soup.find(
        "meta",
        attrs={
            "name": lambda value:
            value
            and value.lower() == "robots"
        }
    )

    if robots_tag:
        result["robots_value"] = robots_tag.get(
            "content",
            ""
        ).strip()

    jsonld_count, jsonld_types = (
        collect_jsonld_types(soup)
    )

    result["jsonld_count"] = jsonld_count
    result["jsonld_types"] = "|".join(
        jsonld_types
    )

    for node in soup(
        ["script", "style", "noscript"]
    ):
        node.decompose()

    page_text = "".join(
        soup.stripped_strings
    )

    result["text_chars"] = len(page_text)

except requests.RequestException as error:
    result["error"] = str(error)

return result

def save_csv(
results: list[dict],
filename: str
) -> None:
"""将巡检结果保存为CSV文件。"""
if not results:
raise ValueError("没有可保存的巡检结果")

with open(
    filename,
    "w",
    newline="",
    encoding="utf-8-sig"
) as file:
    writer = csv.DictWriter(
        file,
        fieldnames=results[0].keys()
    )

    writer.writeheader()
    writer.writerows(results)

def main() -> None:
if len(sys.argv) < 2:
print(
"使用方法:python seo_geo_audit.py "
"https://example.com"
)
return

try:
    site = normalize_site(sys.argv[1])

except ValueError as error:
    print(error)
    return

sitemap_urls = find_sitemap_urls(site)
page_urls = []

for sitemap_url in sitemap_urls:
    page_urls.extend(
        parse_sitemap(sitemap_url)
    )

page_urls = list(dict.fromkeys(page_urls))

if not page_urls:
    print("没有读取到Sitemap,改为检查首页。")
    page_urls = [site + "/"]

selected_urls = page_urls[:MAX_PAGES]

print(
    f"发现{len(page_urls)}个页面,"
    f"本次检查{len(selected_urls)}个页面。"
)

results = []

for index, url in enumerate(
    selected_urls,
    start=1
):
    print(
        f"[{index}/{len(selected_urls)}] "
        f"{url}"
    )

    results.append(
        audit_page(url)
    )

output_file = "seo_geo_audit.csv"

save_csv(results, output_file)

print(
    f"检查完成,结果已保存到:"
    f"{output_file}"
)

if name == "main":
main()
四、运行巡检脚本

打开命令行,进入Python文件所在目录,执行:

python seo_geo_audit.py https://example.com

将示例地址替换成需要检查的网站地址。

运行后,程序会依次完成以下工作:

读取robots.txt;
尝试查找Sitemap地址;
没有找到时读取sitemap.xml;
提取Sitemap中的页面地址;
检查前20个页面;
生成seo_geo_audit.csv文件。

为了避免一次请求过多页面,示例代码只检查前20个页面。

需要增加检查数量时,可以修改:

MAX_PAGES = 20

例如改为:

MAX_PAGES = 50
五、怎样查看CSV巡检结果

脚本执行完成后,会在当前目录生成:

seo_geo_audit.csv

可以使用表格软件打开该文件。

status_code

表示页面返回状态。

正常页面通常会返回200。

如果出现404,说明页面不存在或地址已经失效;如果出现500,可能是程序或服务运行异常。

response_ms

表示页面请求所用时间,单位为毫秒。

该数据会受到网络环境、页面大小和服务性能影响,适合同类型页面之间进行比较。

title和title_length

记录页面标题及标题长度。

标题为空时,需要检查页面有没有正确设置title标签。

description

记录页面中的description内容。

description可以用来概括页面主题,方便搜索系统和用户快速理解页面内容。

h1_count

记录页面中的H1数量。

数量为0时,需要检查页面主标题是否使用了正确标签。

数量较多时,需要检查页面标题层级是否清楚。

canonical

记录页面中的规范地址。

当网站存在多个相似地址时,可以通过规范地址说明主要页面版本。

robots_value

记录页面中的robots设置。

如果发现noindex,需要确认该页面是不是有意不参与索引。

jsonld_count

记录页面中JSON-LD代码块的数量。

数量为0不代表页面一定存在问题,但说明页面没有使用JSON-LD结构化描述。

jsonld_types

记录页面中出现的数据类型,例如:

Article
Organization
BreadcrumbList
Product
FAQPage

通过这个字段,可以检查文章页、企业页和产品页有没有使用对应的数据类型。

text_chars

统计删除脚本和样式后的正文字符数量。

该数据不能直接代表内容质量,但可以帮助发现正文为空、页面内容没有正常加载等问题。

六、为文章页面增加JSON-LD

如果文章页面没有配置JSON-LD,可以根据页面真实内容添加Article数据。

示例代码如下:

结构化数据中的内容需要与页面中实际展示的信息保持一致。

不能在JSON-LD中加入页面上没有展示的产品、评价、服务范围或其他内容。

七、从GEO角度继续进行人工检查

自动巡检只能发现部分技术问题,还需要人工检查内容表达。

页面主题是否明确

文章标题、H1和正文内容应该围绕同一个主题展开。

不要出现标题写SEO,正文却大篇幅介绍无关内容的情况。

基础信息是否统一

网站名称、企业名称、产品名称和作者名称应该保持相对统一。

不同页面使用多种互相冲突的名称,会增加理解难度。

内容是否回答具体问题

技术文章需要说明:

问题背景;
操作步骤;
使用代码;
检查方法;
运行结果;
注意事项。

只介绍概念而没有实际过程,内容参考价值会比较有限。

JSON-LD是否与正文一致

JSON-LD是对页面内容的结构化说明,不能代替正文。

结构化数据中的标题、作者、发布日期和主体名称,应当能够在页面中找到对应信息。

重要页面能否直接访问

公开文章、产品说明和帮助文档应当能够正常打开。

如果页面无法访问,搜索系统和普通用户都无法读取相关内容。

八、后续可以增加哪些功能

当前脚本属于基础版本,还可以继续增加以下功能:

检查图片alt属性;
检查页面内部链接;
检查重复标题;
检查重复description;
检查重定向情况;
检查Open Graph标签;
检查分页页面;
检查JSON-LD格式错误;
生成HTML可视化报告;
定期运行并保存历史结果。

也可以将脚本放到云主机上,通过计划任务定期运行,对网站新增页面和状态变化进行持续检查。

总结

SEO与GEO不只是内容发布工作,也包含网站结构、页面状态、规范地址和结构化数据等技术环节。

本文的Python脚本可以自动读取Sitemap,并批量检查页面状态、标题、描述、H1、规范地址和JSON-LD信息。

脚本不能代替完整的网站分析,但可以用于网站上线检查、改版验收和日常维护,帮助开发人员快速发现容易遗漏的基础问题。

相关文章
|
2天前
|
人工智能 JSON 安全
|
2天前
|
云安全 人工智能 安全
|
4天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
560 20
|
3天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
457 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
|
2天前
|
人工智能 测试技术 语音技术
Qwen-Audio-3.0-TTS 正式发布!AI 语音从 “能说话” 升级到 “会带情绪表达”
阿里云发布Qwen-Audio-3.0-TTS语音合成大模型,支持细粒度标签控制(如[gasp][angry])、freestyle自由风格、16种语言及20种方言,声学鲁棒性强。含Flash(首包延时300ms)和Plus(全球榜单冠军)双版本,已在百炼平台开放调用。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
492 0
|
9天前
|
缓存 UED 开发者
Codex109天重置23次,明天还要再送一次
Codex近109天完成23次额度重置,7月14日将迎来第24次。Tibo高频响应用户反馈:优化GPT-5.6高消耗问题、补发失效福利、调整重置时间——形成“反馈→回应→修复→补偿”正向闭环,彰显以用户为中心的产品哲学。(239字)
837 12
|
2天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
596 0
|
13天前
|
存储 人工智能 JSON
Qwen 本地部署搭配 ComfyUI 生成 AI 漫剧完整实操指南(小白零基础可落地,零成本无限生成+角色一致性天花板)
2026全网最优本地漫剧流水线:零成本、离线运行、角色统一、低配(8G显卡)可跑。融合Qwen本地大模型+ComfyUI双引擎,实现剧本生成→分镜绘图→动态成片全自动,隐私安全、无审核限流,新手30分钟上手,日更无忧。(239字)

热门文章

最新文章