门店 GEO 架构改造:改一次营业时间要动 40 个页面的中央化与批量输出方案

2026-09-24 01:25:18 4 次浏览
GEO本地服务ASP.NET CoreLocalBusiness架构方案.NET 8

适用读者:负责连锁门店官网/小程序官网站的 .NET 后端与全栈工程师,以及在做 GEO(Generative Engine Optimization,生成式引擎优化)落地的技术负责人。

去年腊月二十八,我们接到一个加盟商投诉:顾客按某 AI 搜索引擎给出的答案开车去了门店,结果是闭店日。追下去发现问题出在官网某个门店页上——春节特别营业时间只改了 CMS 里那一页的富文本,而页脚和侧栏还挂着渲染死的静态时间块,AI 爬虫把两处矛盾的信息一起抓走了,模型自己"猜"了一个。这事儿之后我们下决心把 40 个门店页的营业时间全部收口,方案落地跑了一个多月,这篇把架构、代码和踩坑一次讲清。

一、问题:营业时间散落在 40 个页面里

连锁服务品牌(我们是汽修 + 洗美这个业态)的官网结构很典型:一个门店列表页,加每个门店一个详情页,一共 40 个门店页。历史原因,营业时间的维护入口有四个:

门店定位针连向中央数据库与时间表

  • CMS 富文本里的"门店介绍"段落;
  • 每个页面模板里的静态侧栏组件;
  • 页脚一个全局的"营业时间说明"(其实按区域写死了几套);
  • 小程序端另有一份内嵌 JSON。

改造前我拉过一次清单,40 个门店页里,营业时间出现的 DOM 位置多达 6 处,任何一处过期都可能造成 AI 引擎读到错误答案。更麻烦的是,这些内容全部是给"人"看的排版文字,AI 爬虫抓走之后只能靠语言模型自己从中文段落里抽时间规则,抽错的概率不低——我们抽查了 12 家门店在三家 AI 引擎的回答,4 家存在时间口径错误或含糊。

从 GEO 的视角看,这不是文案问题,是架构问题。生成式引擎引用一个门店,本质上是把网页内容对齐到"实体(Entity)"再生成答案,对齐的可靠性高度依赖机器可读的结构化信号。营业时间这种字段级事实,就应该用 Schema.org 的 LocalBusiness + OpeningHoursSpecification(营业时间规范)来声明,而不是埋在散文里等人去猜。

改造前后我们内部验收时做过一张对比表,这里直接放出来:

维度 改造前 改造后
营业时间维护入口 4 个,分散在 CMS/模板/页脚/小程序 1 个(运营后台单表)
单店修改涉及页面数 1-6 处不等,靠人记 0 处,改库即生效
结构化数据 无 JSON-LD,纯散文 每页自动注入 LocalBusiness JSON-LD
节假日时间 各页手写,易漏易错 specialOpeningHoursSpecification 统一配置
AI 引擎回答准确率抽查 12 家中 4 家有错 复测 3 轮未发现口径错误
一致性校验 人工抽查 每日定时任务 + 阻断告警

二、总体架构:单表为源,渲染时统一注入

目标架构一句话:营业时间是数据,不是内容。运营后台只维护一张门店营业时间表,官网渲染层在每个门店页输出时,从这张表生成 LocalBusiness JSON-LD 动态注入 <head>,页面正文里的"可见时间"也由同一数据源渲染。AI 爬虫无论抓 JSON-LD 还是抓正文,读到的都是同一份事实。

flowchart LR
    A[运营后台<br/>营业时间单表] --> B[(门店主数据库<br/>opening_hours 表)]
    B --> C[ASP.NET Core<br/>门店页渲染服务]
    C --> D[正文时间块<br/>人类可读]
    C --> E[LocalBusiness JSON-LD<br/>机器可读]
    D --> F[页面 HTML]
    E --> F
    F --> G[AI 爬虫抓取]
    F --> H[一致性校验任务<br/>每日比对]
    H -->|不一致| I[告警并阻断发布]

几个关键设计决策:

  1. 单一事实来源(Single Source of Truth)。所有时间表达只允许从数据库表 store_opening_hours 读,模板里不允许再出现任何硬编码时间。为了让老页面迁移可控,我们在第 2 周先做了一个"影子模式":新逻辑照常输出,但旧静态块不删,用校验脚本比对两者是否一致,跑了一周全绿才切换。
  2. 渲染时生成,不做静态预处理。40 个页面的量级远不需要预生成,Razor 渲染时查一次缓存即可。门店时间变更走后台保存事件主动失效缓存,延迟控制在秒级。
  3. 节假日独立建模。春节、国庆这种特殊日期用 specialOpeningHoursSpecification 表达,和常规 openingHours 分开两张小表,避免运营每次节假日都要去改常规时间再改回来。

三、数据模型与 JSON-LD 生成

先讲机制层面为什么这么映射。Schema.org 的 LocalBusiness 实体下,openingHoursSpecification 数组描述"每周循环的常规时间",每个元素由 dayOfWeek + opens + closes 构成;而 specialOpeningHoursSpecification 是同一实体上的另一个数组,元素多了 validity 范围(validFrom / validThrough)和 closes: "00:00" 或 opens/closes 同值表示"当天闭店"。生成式引擎和传统爬虫对这两个数组的解析逻辑是明确的:特殊日期覆盖常规规则。这意味着只要我们把春节的 2 月 16 日到 2 月 20 日写成一段 special 声明,就不需要(也不应该)去动任何常规记录。

还有个容易被忽略的点:时间值必须用 HH:MM 的 24 小时制字符串,时区信息靠 availabilityStarts 之类的字段并不适用,实践里靠站点级 sitemap 时区声明和页面上的时区上下文兜底。国内站点就统一北京时间,别引入不必要的复杂度。

3.1 数据表与实体

.NET 8 + EF Core 8 + ASP.NET Core MVC(Razor)。表设计我贴一下核心实体,注释写得比较密,方便直接抄:

// 依赖:.NET 8 / EF Core 8(Microsoft.EntityFrameworkCore.SqlServer 8.0.x)
// 文件:Entities/StoreOpeningHours.cs
// 说明:常规营业时间按"门店 × 星期"逐条建模,分时段就多插几行
public class StoreOpeningHours
{
    public int Id { get; set; }

    // 关联门店主表的外键,查询时永远按门店聚合
    public int StoreId { get; set; }

    // 星期几,一周一条或多条(分时段场景)
    public DayOfWeek DayOfWeek { get; set; }

    // .NET 6+ 的 TimeOnly,序列化后天然就是 HH:mm
    public TimeOnly Opens { get; set; }

    // 24 小时制,从源头避免上午/下午歧义
    public TimeOnly Closes { get; set; }

    // 当天休息置 true,输出 JSON-LD 时直接跳过该条
    public bool IsClosed { get; set; }
}

// 特殊时段实体:节假日/临时调停业都走这张表,不碰常规数据
public class StoreSpecialHours
{
    public int Id { get; set; }

    // 同上,指向门店
    public int StoreId { get; set; }

    // 特殊时段起始日(含),对应 schema.org 的 validFrom
    public DateOnly ValidFrom { get; set; }

    // 特殊时段结束日(含),对应 validThrough
    public DateOnly ValidThrough { get; set; }

    // 整段闭店(如春节休市)标记
    public bool IsClosed { get; set; }

    // 非整段闭店时的开始时间,可空
    public TimeOnly? Opens { get; set; }

    // 非整段闭店时的结束时间,可空
    public TimeOnly? Closes { get; set; }

    // 运营备注,如"春节假期",仅供内部,不进 JSON-LD
    public string? Note { get; set; }
}

两个实体对应两张表,都带 StoreId 索引。TimeOnly/DateOnly 在 EF Core 8 + SQL Server 下映射很顺,不需要额外转换器。

3.2 Razor 页面统一注入 JSON-LD

注入逻辑封装成一个 PartialView + 一个 builder 类,任何门店页 _Layout 里一行 @await Html.PartialAsync("_LocalBusinessJsonLd", Model.Store) 搞定。builder 是关键:

// 依赖:System.Text.Json(.NET 8 内置),无需第三方包
// 文件:Services/LocalBusinessJsonBuilder.cs
// 说明:JSON-LD 组装的单一入口,正文与校验任务都复用它
public static class LocalBusinessJsonBuilder
{
    // 序列化选项:忽略 null 字段,避免输出 "closes": null 干扰爬虫解析
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

        // 中文门店名不转义成 \uXXXX,保持原文可读
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    public static string Build(Store store,
        IReadOnlyList<StoreOpeningHours> weekly,
        IReadOnlyList<StoreSpecialHours> specials,
        DateOnly today)
    {
        // 常规时间:先剔除当天休息的记录
        var openingSpecs = weekly
            .Where(w => !w.IsClosed)
            .Select(w => new Dictionary<string, object>
            {
                // 每条对应一个 OpeningHoursSpecification 节点
                ["@type"] = "OpeningHoursSpecification",

                // 星期映射成 schema.org 规定的 URL 枚举形式
                ["dayOfWeek"] = MapDay(w.DayOfWeek),

                // 强制 HH:mm 的 24 小时制格式
                ["opens"] = w.Opens.ToString("HH:mm"),
                ["closes"] = w.Closes.ToString("HH:mm")
            }).ToList();

        // 特殊时间:只保留"未过期且未来 90 天内"的记录
        // 历史节假日声明对 AI 引擎没有引用价值,留着反而制造噪声
        var specialSpecs = specials
            .Where(s => s.ValidThrough >= today &&
                        s.ValidFrom <= today.AddDays(90))
            .Select(s => (Dictionary<string, object>)BuildSpecial(s)).ToList();

        var obj = new Dictionary<string, object>
        {
            // 固定指向 https://schema.org 词表
            ["@context"] = "https://schema.org",

            // 有细分子类型就用细分的,信息量比裸 LocalBusiness 高
            ["@type"] = store.IsAutoRepair ? "AutoRepair" : "LocalBusiness",
            ["name"] = store.Name,
            ["telephone"] = store.Phone,

            // PostalAddress 子结构:实体对齐的重要指纹字段
            ["address"] = new Dictionary<string, object>
            {
                ["@type"] = "PostalAddress",
                ["streetAddress"] = store.Address,
                ["addressLocality"] = store.City,
                ["addressCountry"] = "CN"
            },

            // 经纬度:帮助 AI 引擎把页面归到正确的门店实体上
            ["geo"] = new Dictionary<string, object>
            {
                ["@type"] = "GeoCoordinates",
                ["latitude"] = store.Lat,
                ["longitude"] = store.Lng
            },
            ["openingHoursSpecification"] = openingSpecs,
            ["specialOpeningHoursSpecification"] = specialSpecs
        };

        // 门店名来自内部后台受控内容,可接受宽松转义
        return JsonSerializer.Serialize(obj, JsonOpts);
    }

    private static Dictionary<string, object> BuildSpecial(StoreSpecialHours s)
    {
        // 整段闭店:opens 与 closes 同值是 schema.org 认可的闭店表达
        var opens = s.IsClosed ? "00:00" : s.Opens!.Value.ToString("HH:mm");
        var closes = s.IsClosed ? "00:00" : s.Closes!.Value.ToString("HH:mm");

        // 别自造 "closed": true 字段,解析器只认规范写法
        return new Dictionary<string, object>
        {
            ["@type"] = "OpeningHoursSpecification",
            ["opens"] = opens,
            ["closes"] = closes,

            // 日期必须是 ISO 8601 的 yyyy-MM-dd
            ["validFrom"] = s.ValidFrom.ToString("yyyy-MM-dd"),
            ["validThrough"] = s.ValidThrough.ToString("yyyy-MM-dd")
        };
    }

    // DayOfWeek 枚举转 schema.org 的 URL 形式,如 https://schema.org/Monday
    private static string MapDay(DayOfWeek d) => $"https://schema.org/{d.ToString()}";
}

页面侧就是一段 script 标签:

<!-- 依赖:ASP.NET Core MVC Razor 视图,无额外前端依赖 -->
<!-- 文件:Views/Shared/_LocalBusinessJsonLd.cshtml -->
<!-- 用法:门店页 _Layout 中一行 PartialAsync 即可完成注入 -->
@model Store
@{
    // 从视图注入的服务里取缓存好的时间数据,避免每个页面各自查询
    var hours = await storeHoursService.GetAsync(Model.Id);
}
<!-- type="application/ld+json" 是爬虫与 AI 爬虫共同识别的结构化数据载体 -->
<script type="application/ld+json">
    <!-- 注意:JSON-LD 内容必须来自服务端白名单字段构建 -->
    <!-- 任何用户富文本输入都不允许进入这条输出通道 -->
@Html.Raw(@Model.JsonLd)
</script>

正文可见时间块也从同一个 hours 对象渲染。这里有个血泪教训:第 1 版我们把 JSON-LD 序列化后直接 Html.Raw 塞进正文附近,结果运营在富文本里贴了一段带 <script> 的内容引发转义告警——后来统一改成只从服务端白名单字段构建,任何用户输入不进 JSON-LD 通道。

四、specialOpeningHoursSpecification:节假日的正确打开方式

节假日是连锁门店 GEO 出错的重灾区,值得单独拎一节讲。

运营侧的录入规则我们定得很死:每到一个法定节假日前 30 天,运营后台会给所有门店推一条"待确认假期时间"任务,门店负责人确认是整段闭店、缩短营业还是正常营业,确认后写进 store_special_hours。整段闭店就一条记录覆盖全假期;缩短营业的,按天拆记录。这套流程在第 3 周上线,当年春节 40 家门店共录入 63 条特殊时段记录,全部经双人复核。

不同场景对应的录入方式与输出形态,列成表更直观:

场景 录入方式 特殊记录条数 JSON-LD 输出形态
整段闭店(春节休市) 一条记录覆盖整个假期 1 条 opens/closes 同值 00:00 + validFrom/validThrough
缩短营业(假期后半段恢复半天) 按天逐日拆分录入 每天一条 每条独立 opens/closes 与日期范围
正常营业 一键确认沿用常规时段 0 条 不产生 special 输出,常规规则直接生效
临时停业(台风、设备检修) 当日补录,保存即失效缓存 1 条 依靠校验任务在下一轮 02:00 前完成比对

机器侧的映射规则前面代码里已经体现,这里补充三个细节:

  • 有效期过滤。过期的特殊时段要从 JSON-LD 里删掉,否则模型可能拿去年春节的闭店声明来回答今年的问题。我们按"validThrough 不早于今天"过滤,同时校验任务里会专门扫"存量过期 special 记录超过 30 天未清理"的门店。
  • 闭店表达。schema.org 对"当天闭店"的官方建议是 opens 和 closes 同值(或都为 00:00)。别自己发明 "closed": true 这种字段,AI 引擎的解析器只认规范写法。
  • 正文要有人话版。JSON-LD 给机器,正文里的"2 月 16 日至 20 日春节休市,2 月 21 日起恢复正常营业"给人和语言模型兜底,两处必须同源生成——这正是下一节一致性校验存在的理由。
sequenceDiagram
    participant Ops as 运营后台
    participant DB as 门店数据库
    participant Web as 渲染服务
    participant Check as 一致性校验任务
    participant AI as AI 引擎爬虫
    Ops->>DB: 录入/确认节假日特殊时段
    DB-->>Web: 缓存失效通知
    Web->>DB: 渲染门店页时拉取常规+特殊时段
    Web->>AI: 输出正文时间块 + LocalBusiness JSON-LD
    Check->>DB: 每日 02:00 拉取全量数据
    Check->>Web: 抓取线上页面正文与 JSON-LD
    Check-->>Check: 比对三源:库/正文/JSON-LD
    Check->>Ops: 不一致则告警并阻断该页发布

五、一致性校验:让错误活不过 24 小时

机制上,营业时间的引用链条是"数据库 → 渲染输出 → AI 爬虫抓取 → 模型生成答案",前两环可以完全程序化校验,第三环之后的错误我们控制不了,所以目标定为:错误在前两环的存活时间不超过一个校验周期

校验任务是个 .NET Worker Service(BackgroundService),每天凌晨 2 点跑,干三件事:

  1. 库内自检:常规时间有没有跨零点没写跨越标记、special 时段有没有日期倒挂、有没有过期超 30 天未清理的记录;
  2. 线上比对:对 40 个门店页各发一次 HTTP 请求,解析出正文时间块文本和 JSON-LD,与数据库重新计算出的期望值逐字段比对;
  3. 格式校验:JSON-LD 时间字段必须匹配 HH:mm 正则,日期必须匹配 yyyy-MM-dd
// 依赖:.NET 8 Worker Service 模板(Microsoft.NET.Sdk.Worker)+ HttpClientFactory
// 文件:Workers/HoursConsistencyWorker.cs
// 说明:每日全量校验"数据库 / 正文 / JSON-LD"三源一致性
public class HoursConsistencyWorker : BackgroundService
{
    // 每轮校验创建 Scope 取 DbContext,Worker 本身是单例
    private readonly IServiceProvider _sp;

    // 抓线上页面用,配了超时与重试策略
    private readonly IHttpClientFactory _http;

    private readonly ILogger<HoursConsistencyWorker> _logger;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // 定时循环:每天 02:00 触发一次全量校验
        // 40 个页面量级下全量跑不到 1 分钟,暂不需要事件驱动
        while (!ct.IsCancellationRequested)
        {
            await RunAllStoresAsync(ct);

            // 睡到明天 02:00,误差忽略不计
            var next = DateTime.Today.AddDays(1).AddHours(2);
            await Task.Delay(next - DateTime.Now, ct);
        }
    }

    private async Task RunAllStoresAsync(CancellationToken ct)
    {
        using var scope = _sp.CreateScope();

        // 只校验已发布门店,草稿页不进比对流程
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var stores = await db.Stores.Where(s => s.IsPublished).ToListAsync(ct);

        // 逐店校验;40 家规模串行完全够用
        foreach (var store in stores)
        {
            // 第一步:库内自检,规则式校验(日期倒挂/跨天未标记等)
            var dbIssues = DbSelfCheck(store, db);

            // 第二步:抓线上页,拆出正文时间块与 JSON-LD 两个视图
            var html = await FetchPublishedPageAsync(store.Slug, ct);
            var (visibleText, jsonLd) = PageParser.Extract(html);

            // 第三步:期望值按同一 builder 重新计算,保证口径同源
            var expectedJson = LocalBusinessJsonBuilder.Build(
                store, await db.GetWeeklyAsync(store.Id),
                await db.GetSpecialsAsync(store.Id), DateOnly.FromDateTime(DateTime.Today));

            var issues = dbIssues.ToList();

            // 不一致说明渲染层用了旧缓存或旧模板
            if (jsonLd != expectedJson)
                issues.Add("JSON-LD 与数据库期望值不一致");

            // 不一致说明页面上还有硬编码时间残留
            if (!HoursTextMatcher.Matches(visibleText, expectedJson))
                issues.Add("正文时间文本与结构化数据不一致");

            if (issues.Count > 0)
            {
                // 告警并阻断:该门店页移出发布队列,直到人工处理
                await PublishBlockAsync(store.Id, issues, ct);
                _logger.LogWarning("门店 {Store} 校验失败:{Issues}", store.Id, string.Join("; ", issues));
            }
        }
    }
}

这套任务上线后的第 2 周,真的抓到过一次:某门店页脚的旧静态块没删干净,正文比 JSON-LD 少了一段周六下午的时段,告警里报的就是"正文时间文本与结构化数据不一致"。人工删掉残留、复跑全绿,全程十几分钟。没有这个任务,那块残留很可能又会变成一次 AI 回答错误。

六、原理/机制剖析:为什么结构化时间数据能改变 AI 的回答

这一节把机制讲透,方便你向业务方解释这套改造值不值。

生成式引擎回答"附近哪家门店今天开门",链路大致是:爬取 → 抽取 → 实体对齐(Entity Resolution,把网页信号归并到"这个品牌的这家门店"这个实体上)→ 索引入检索库 → 生成时召回并引用。每一步对信息的"结构化程度"都很敏感:

  • 抽取层:JSON-LD 是自描述的键值结构,解析器不需要理解中文语义就能抽出 opens/closes;而散文时间需要语言模型做开放式抽取,"上午九点到晚上六点"这种表达还涉及歧义归一。抽取步骤越简单,错误率越低。
  • 对齐层:LocalBusiness 的 name、telephone、address、geo 一组字段共同构成实体的"指纹",帮助引擎确认"这个页面说的是 A 品牌在 B 路的那家店",而不是把同品牌两家店的时间张冠李戴。我们改造时特意把所有门店页的 geotelephone 补齐了,这是零成本提升对齐质量的动作。
  • 冲突消解层:当页面同时存在"正文散文"和"JSON-LD"两份时间信号且口径一致时,模型置信度高;口径冲突时,不同引擎的策略不同——有的信任结构化数据,有的倾向正文,有的干脆给含糊答案。冲突本身就是风险,中央化的意义在于从源头消灭冲突,而不是赌引擎的消解策略

还有一个反直觉的观察:给结构化数据不等于 AI 一定引用你。引用还受品牌在该实体上的第三方信源(地图、点评平台)一致性影响。官网的 LocalBusiness 是你完全可控的那一环,把它做到零错误,是把可控变量的方差压到最小。

七、误区澄清与收尾

三个常见误区顺手澄清一下:

  • "把时间写进页面就够了"。不够。散文化时间在抽取环节损耗大,且多处副本必然漂移。字段级事实请走结构化声明,正文只是机器可读数据的"人话翻译"。
  • "节假日只改后台就行"。后台是源头,但过期 special 记录清理、正文残留扫描这类事,没有定时校验就是靠运气。机制化的校验任务才是这事儿能长期省事的根本。
  • "AI 引擎马上就会按新数据回答"。爬虫重新抓取有周期,模型侧的索引刷新更慢。改完数据后我们观察到部分引擎 1-2 周才更新口径,别因为第二天没生效就怀疑方案。

趋势上看,随着 AI 搜索在本地服务决策里的权重上升,门店级事实数据(时间、电话、位置、服务项目)的"中央化 + 结构化输出"会从可选项变成基础设施。这套方案的表结构对 40 家门店绰绰有余,扩展到几百家的瓶颈会出在校验任务的抓取频率和告警处理人效上,到时候值得把校验改成事件驱动(保存即校验单店)而不是每日全量。

如果你也在做连锁门店的 GEO 改造,欢迎评论区聊聊你们的节假日数据流程和校验策略。

参考与延伸

  • schema.org LocalBusiness 类型定义:https://schema.org/LocalBusiness
  • OpeningHoursSpecification / specialOpeningHoursSpecification 属性说明:https://schema.org/OpeningHoursSpecification
  • Google Search Central 本地商家结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/local-business
  • llmstxt.org(面向 AI 引擎的站点级说明文件规范):https://llmstxt.org

GEO|AI优化AIO|LocalBusiness|specialOpeningHoursSpecification|JSON-LD|连锁门店|架构方案|.NET 8

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。