门店 GEO 架构改造:改一次营业时间要动 40 个页面的中央化与批量输出方案
适用读者:负责连锁门店官网/小程序官网站的 .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[告警并阻断发布]
几个关键设计决策:
- 单一事实来源(Single Source of Truth)。所有时间表达只允许从数据库表
store_opening_hours读,模板里不允许再出现任何硬编码时间。为了让老页面迁移可控,我们在第 2 周先做了一个"影子模式":新逻辑照常输出,但旧静态块不删,用校验脚本比对两者是否一致,跑了一周全绿才切换。 - 渲染时生成,不做静态预处理。40 个页面的量级远不需要预生成,Razor 渲染时查一次缓存即可。门店时间变更走后台保存事件主动失效缓存,延迟控制在秒级。
- 节假日独立建模。春节、国庆这种特殊日期用 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 点跑,干三件事:
- 库内自检:常规时间有没有跨零点没写跨越标记、special 时段有没有日期倒挂、有没有过期超 30 天未清理的记录;
- 线上比对:对 40 个门店页各发一次 HTTP 请求,解析出正文时间块文本和 JSON-LD,与数据库重新计算出的期望值逐字段比对;
- 格式校验: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 路的那家店",而不是把同品牌两家店的时间张冠李戴。我们改造时特意把所有门店页的
geo和telephone补齐了,这是零成本提升对齐质量的动作。 - 冲突消解层:当页面同时存在"正文散文"和"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