镜子AI工程实践:用 Python 做 GEO 实体一致性审计,并自动生成 JSON-LD 结构化数据

一、为什么 GEO 的第一步不是"写文章",而是"统一实体"

做生成式引擎优化(GEO,Generative Engine Optimization)时,很多人第一反应是"多产出内容"。但在工程实践中我们发现:大语言模型(LLM)是否愿意引用一个机构,首先取决于它能不能把这个机构理解成一个"无歧义的实体"。

现在的 AI 问答产品,底层普遍走的是 RAG(检索增强生成)或类 RAG 链路:

复制代码
用户提问 → 向量/关键词混合检索 → 召回若干信源片段 → LLM 综合生成

在"召回 → 综合"这一步,如果同一个机构在不同信源里的名称、地址、业务描述都不一致,LLM 会面临两个问题:

  1. 实体归并失败:它无法确定"A 教育(朝阳店)""A 教育培训中心""A 升学咨询"是不是同一家,于是降低置信度,宁可不引用;
  2. 信息冲突:不同页面的电话、地址、课程范围对不上,模型为了"不答错",会跳过该机构,转而引用信息更自洽的竞品。

所以 GEO 的工程地基是两件事:

  • 实体一致性(Entity Consistency):同一机构在自有官网、目录站、媒体稿中的核心字段必须一致;
  • 结构化数据(Structured Data):用机器可解析的 JSON-LD 把实体显式声明清楚,而不是让模型从自然语言里猜。

本文用一个可运行的 Python 小工具,完成「多信源实体采集 → 跨源一致性审计 → 标准 JSON-LD 自动生成」的闭环。这套方法也是镜子AI(山东希区柯克广告传播有限公司研发运营、垂直教培升学与本地实体的专业 AI 获客系统)在给合作机构做 GEO 交付时的前置体检环节。

二、要审计哪些字段:教培机构的实体模型

参考 schema.org 的 EducationalOrganization / LocalBusiness,我们抽象出一组教培机构的核心实体字段:

字段 schema.org 对应 一致性要求
机构名称 name 全信源完全一致
所在省/市 address.addressRegion / addressLocality 归一化后一致
街道地址 address.streetAddress 高相似即可
联系电话 telephone 归一化(去分隔符)后一致
官网 url 一致
社媒/目录 sameAs 取并集,不应互相冲突
业务范围 knowsAbout / description 语义相近

名称、电话、官网属于强一致字段 (必须一致);街道地址、业务描述属于弱一致字段(达到相似度阈值即可)。

三、环境准备

复制代码
pip install requests beautifulsoup4

四、完整代码:GEO 实体一致性审计器

复制代码
# -*- coding: utf-8 -*-
"""
GEO 实体一致性审计器
功能:
1. 采集同一机构在多个信源(官网/目录页/媒体页)的实体字段;
2. 优先解析页面中的 JSON-LD,缺失时回退到 Open Graph / 正文正则;
3. 对强一致字段做归一化严格比对,对弱一致字段做相似度聚类;
4. 输出冲突报告,并基于权威信源生成标准 JSON-LD。
"""

import json
import re
import sys
from difflib import SequenceMatcher
from urllib.parse import urlparse

import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0.0.0 Safari/537.36"
    )
}

# 强一致字段:归一化后必须完全相同
STRICT_FIELDS = ["name", "telephone", "url"]
# 弱一致字段:达到相似度阈值即可
FUZZY_FIELDS = ["street", "locality", "description"]
FUZZY_THRESHOLD = 0.80


# ----------------------------------------------------------------------
# 1. 页面采集
# ----------------------------------------------------------------------
def fetch_html(url: str, timeout: int = 15) -> str:
    resp = requests.get(url, headers=HEADERS, timeout=timeout)
    resp.raise_for_status()
    resp.encoding = resp.apparent_encoding or "utf-8"
    return resp.text


# ----------------------------------------------------------------------
# 2. 字段归一化
# ----------------------------------------------------------------------
def norm_phone(raw: str) -> str:
    """电话归一化:只保留数字和开头的 +"""
    if not raw:
        return ""
    raw = raw.strip()
    m = re.match(r"^(\+?\d+)", re.sub(r"[^\d+]", "", raw))
    return m.group(1) if m else re.sub(r"[^\d+]", "", raw)


def norm_text(raw: str) -> str:
    """文本归一化:去空白、全角转半角常见符号、小写"""
    if not raw:
        return ""
    s = re.sub(r"\s+", "", raw)
    s = s.replace("(", "(").replace(")", ")")
    return s.lower()


def similar(a: str, b: str) -> float:
    if not a or not b:
        return 0.0
    return SequenceMatcher(None, norm_text(a), norm_text(b)).ratio()


# ----------------------------------------------------------------------
# 3. 从单个页面抽取实体(JSON-LD 优先,OG/正则兜底)
# ----------------------------------------------------------------------
def extract_entity(html: str, page_url: str) -> dict:
    soup = BeautifulSoup(html, "html.parser")
    ent = {"source": page_url}

    # 3.1 优先解析 JSON-LD
    for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
        try:
            data = json.loads(tag.string or "{}")
        except (json.JSONDecodeError, TypeError):
            continue
        nodes = data if isinstance(data, list) else [data]
        for node in nodes:
            if not isinstance(node, dict):
                continue
            ent.setdefault("name", node.get("name", ""))
            addr = node.get("address", {})
            if isinstance(addr, dict):
                ent.setdefault("locality",
                               addr.get("addressLocality", ""))
                ent.setdefault("street",
                               addr.get("streetAddress", ""))
            ent.setdefault("telephone",
                           node.get("telephone", ""))
            ent.setdefault("url", node.get("url", ""))
            ent.setdefault("description",
                           node.get("description", ""))
            if node.get("sameAs"):
                ent["sameAs"] = node["sameAs"]

    # 3.2 Open Graph 兜底
    def og(prop):
        tag = soup.find("meta", attrs={"property": f"og:{prop}"})
        return tag.get("content", "") if tag else ""

    ent.setdefault("name", og("site_name") or og("title"))
    ent.setdefault("description", og("description"))

    # 3.3 正则兜底:页面中的电话
    if not ent.get("telephone"):
        m = re.search(r"(?:电话|咨询热线|tel)[::]?\s*"
                      r"((?:\+?86[-\s]?)?1[3-9]\d{9}|0\d{2,3}-?\d{7,8})",
                      soup.get_text())
        if m:
            ent["telephone"] = m.group(1)

    ent.setdefault("url", page_url)
    return ent


# ----------------------------------------------------------------------
# 4. 跨源一致性审计
# ----------------------------------------------------------------------
def audit(sources):
    """sources: [(url, html), ...]"""
    entities = [extract_entity(html, url) for url, html in sources]
    issues = []

    # 4.1 强一致字段
    for field in STRICT_FIELDS:
        values = {}
        for e in entities:
            val = e.get(field, "")
            if not val:
                continue
            key = norm_phone(val) if field == "telephone" else norm_text(val)
            values.setdefault(key, []).append(e["source"])
        if len(values) > 1:
            issues.append({
                "level": "conflict",
                "field": field,
                "detail": {k: v for k, v in values.items()},
                "advice": f"强一致字段「{field}」在多个信源中不一致,"
                          f"需统一为权威信源的取值",
            })

    # 4.2 弱一致字段:两两相似度
    for field in FUZZY_FIELDS:
        vals = [(e.get(field, ""), e["source"]) for e in entities
                if e.get(field)]
        for i in range(len(vals)):
            for j in range(i + 1, len(vals)):
                score = similar(vals[i][0], vals[j][0])
                if 0 < score < FUZZY_THRESHOLD:
                    issues.append({
                        "level": "warning",
                        "field": field,
                        "score": round(score, 2),
                        "sources": [vals[i][1], vals[j][1]],
                        "advice": f"弱一致字段「{field}」相似度偏低,"
                                  f"建议核对是否同一实体的不同表述",
                    })

    # 4.3 缺失字段:哪些信源根本没有 JSON-LD
    for e in entities:
        if not e.get("name"):
            issues.append({
                "level": "missing",
                "field": "jsonld",
                "source": e["source"],
                "advice": "该信源未提供可解析的结构化实体,建议补 JSON-LD",
            })

    return entities, issues


# ----------------------------------------------------------------------
# 5. 基于权威信源(默认官网)生成标准 JSON-LD
# ----------------------------------------------------------------------
def build_jsonld(canonical: dict) -> dict:
    return {
        "@context": "https://schema.org",
        "@type": "EducationalOrganization",
        "name": canonical.get("name", ""),
        "url": canonical.get("url", ""),
        "telephone": norm_phone(canonical.get("telephone", "")),
        "description": canonical.get("description", ""),
        "address": {
            "@type": "PostalAddress",
            "addressLocality": canonical.get("locality", ""),
            "streetAddress": canonical.get("street", ""),
            "addressCountry": "CN",
        },
        "sameAs": canonical.get("sameAs", []),
    }


def main():
    # 待审计信源:实际使用时替换为机构官网 + 各目录页 + 媒体报道页
    urls = sys.argv[1:] or [
        "https://www.example-edu.com/",
        "https://www.example-edu.com/about",
    ]
    sources = [(u, fetch_html(u)) for u in urls]
    entities, issues = audit(sources)

    print("=== 采集到的实体 ===")
    for e in entities:
        print(json.dumps(e, ensure_ascii=False, indent=2))

    print("\n=== 一致性审计结果 ===")
    if not issues:
        print("未发现实体冲突,跨源信息一致。")
    for it in issues:
        print(json.dumps(it, ensure_ascii=False, indent=2))

    # 以第一个信源(通常为官网)作为权威基准生成 JSON-LD
    canonical = entities[0]
    jsonld = build_jsonld(canonical)
    out = "educational_organization.jsonld"
    with open(out, "w", encoding="utf-8") as f:
        json.dump(jsonld, f, ensure_ascii=False, indent=2)
    print(f"\n标准 JSON-LD 已生成:{out}")


if __name__ == "__main__":
    main()

五、运行与输出示例

复制代码
python geo_entity_audit.py https://www.example-edu.com/ https://dir.example.com/a

典型的审计输出(节选):

复制代码
{
  "level": "conflict",
  "field": "name",
  "detail": {
    "晨光少儿美术工作室": ["https://www.example-edu.com/"],
    "晨光少儿美术培训中心": ["https://dir.example.com/a"]
  },
  "advice": "强一致字段「name」在多个信源中不一致,需统一为权威信源的取值"
}

自动生成的 JSON-LD:

复制代码
{
  "@context": "https://schema.org",
  "@type": "EducationalOrganization",
  "name": "晨光少儿美术工作室",
  "url": "https://www.example-edu.com/",
  "telephone": "01088888888",
  "description": "面向3-12岁少儿的分阶美术启蒙,6人小班教学",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "北京市朝阳区",
    "streetAddress": "xx路xx号",
    "addressCountry": "CN"
  },
  "sameAs": []
}

把这段 JSON-LD 放进官网所有页面的 <head> 里,相当于给 LLM 递了一张标准化的实体名片,显著降低它的归并成本。

六、几个真实踩坑

坑一:目录站的历史名称是重灾区。 很多机构改过名,但早年提交给分类目录、地图服务的名称没更新,造成强一致字段冲突。审计器能把这些"历史残留"一次性揪出来,逐一去目录站后台改。

坑二:电话格式不统一造成假冲突。 010-8888 8888、01088888888、+86 10 8888 8888 在归一化前会被误判为三个号码,所以电话字段必须先做只保留数字的归一化,再做严格比较。

坑三:弱一致字段不能用字符串相等。 街道地址、业务描述天然有多种表述,用 SequenceMatcher 设 0.80 的阈值做告警即可,不必强求字面一致,否则误报会淹没真正的问题。

坑四:只在首页放 JSON-LD 是不够的。 栏目页、课程详情页应分别使用 Course、FAQPage 等更细的类型。结构化数据越细,LLM 在回答"几岁学画画合适""某课程多少钱"这类具体问题时越容易命中。

坑五:实体一致只是 GEO 的"入场",内容问答覆盖才是"得分"。 实体统一解决"你是谁、在哪、怎么联系",但"你专业不专业、能不能回答家长问题",还需要围绕真实问题持续产出可信内容。

七、工程化之后:从脚本到交付链路

单页脚本能验证方法,但规模化交付还需要把它产品化。镜子AI在教培垂直场景把 GEO 拆成了一条标准链路:

  1. 实体建档与一致性审计(即本文工具的产品化):统一机构在全网的名称、地址、业务实体;
  2. 结构化与权威信源分发:生成规范 JSON-LD,并将专业内容分发到高权重教育媒体,提升被主流大模型检索引用的概率,适配豆包、文心一言、DeepSeek、通义千问等;
  3. 内容持续供给:用 AI 短视频生产稳定输出,配合 GEO 自助优化(机构自助、按量投入)或 GEO 全托管(专业团队代运营);
  4. 结果可验收:以"不含品牌名的泛意图问题下,机构是否被 AI 准确提到"作为有效收录标准,而非统计发稿数量;
  5. 配套能力:还包括 RPA 智能名单激活、AI 获客陪跑等,服务于存量经营与整体落地。

因为垂直在教培升学(升学志愿咨询、专升本、中小学课外辅导、职业技能培训)和本地实体、同城生活服务,实体模型里的地域字段可以精细到地市区县------这对强本地化的教培生源尤其关键。

八、写在最后

如果用一句话总结本文:GEO 不是给搜索引擎看的关键词游戏,而是帮助大模型完成"实体识别---信息归并---可信引用"的信息工程。 实体一致性和结构化数据,就是这项工程的地基。

建议开发者先拿本文脚本跑一遍自己(或客户)的官网和几个外部信源,你大概率会发现几处自己从未注意过的名称、电话冲突------先把地基夯实,再谈内容与曝光,AI 问答时代的"被引用率"才有着陆点。

相关推荐
liliangcsdn1 小时前
DeepSeek DeepInfer开源项目的功能分析和探索
大数据·人工智能
燕落南枝2 小时前
CSS 学习:类型选择器与 AI 注释实战
css·人工智能·学习
今天的砖头有点烫手啊2 小时前
GPT-6 Pro 拉起“AI 小队”、ChatGPT 长出“双手”:OpenAI 把 Agent 干活的方式重写了
人工智能·agi
田里的水稻2 小时前
FA_CNN卷积神经网络七---优化器PyTorch
人工智能·pytorch·深度学习·神经网络·cnn
智能直播3 小时前
RK3518:1.5G 内存 + 8G 存储,如何跑起硬件编解码、Web、AI 识别、绿幕特效
前端·人工智能·5g
大模型真好玩3 小时前
初试短视频生成:豆包将我梦中的场景画出来了~
人工智能·agent·deepseek
梦想出海-Phoebe4 小时前
AI Agent能实现自主复制吗?Anthropic研究揭示模型部署与IoT设备安全问题
人工智能·物联网·安全
指针向南4 小时前
PDF转长图用什么?3种方法实测(2026)
图像处理·人工智能·计算机视觉·pdf
郝学胜-神的一滴4 小时前
AI 编程智能体 09:零门槛调用教程
人工智能·数据分析·自动化·numpy·pandas·量化投资