节假日营业时间写错半小时的代价:openingHoursSpecification 落地实战
适用读者:连锁门店与本地服务业的技术负责人、SEO 与内容工程同学、做门店站点与知识图谱(Knowledge Graph)数据同步的后端工程师。需要能读懂 JSON 与 Python,不需要前端框架经验。
「地图上说你们营业到 21:00,我 20:35 到的,卷帘门已经拉下一半。打你们门店电话没人接。孩子还在车上等着饿。差评。」
这条差评出现在某连锁烘焙品牌的一家门店下。那天是假期前一天,门店按内部通知提前到 20:30 打烊,店长在门口贴了手写告示,前台系统的营业时间也改了。问题出在官网门店页——结构化数据(Structured Data)里只写了常规时段 10:00 至 21:00,假期的提前打烊是一条都没写。搜索引擎抓到的还是常规时段,语音助手照着结构化数据回答,于是用户听到的是「营业中,21:00 关门」。
半小时的偏差,换来的是一条一星评价、一次客诉工单,以及接下来两周该门店在本地搜索结果里的排名下滑。这类事故在本地服务业里并不少见,而且复现成本极低:只要门店数量上百,节假日靠人工改页面,就一定会漏。
本文要做三件事。第一,把常规营业时段在 JSON-LD 里的标准写法讲清楚。第二,把节假日例外的特殊时段写法讲清楚,特别是闭店日与跨午夜这两个最容易写错的地方。第三,给出一套 Python 脚本:从门店数据库批量生成营业时间片段,并在上线前把格式错误拦下来。三个环节串成流水线,节假日只需要维护一张例外表,剩下的交给脚本。
一、事故链路:半小时是怎么变成一条差评的
先把这条链路拆开看。门店在假期前三天收到运营通知,临时调整打烊时间。店长在门店系统里改了营业时间,前台小程序展示正确。官网门店页是独立的 CMS 模板,营业时间那一栏是半年前上线时手工填的静态文本,页头的 JSON-LD 也是那时生成的,之后没人动过。

用户在车机上问「附近还有开门的面包店吗」,助手从本地索引里捞出这家店,读结构化数据,得到周一到周日 10:00 至 21:00,当前时间 20:35 落在区间内,判定营业中,把门店推给用户。
关键点是:助手判断「现在营业吗」依据的不是门店系统里的真实状态,而是网页上机器可读的那份数据。两者只要不同步,回答就错。而不同步几乎是必然的——门店系统每天变,网页半年不变。
flowchart TD
A[门店系统修改假期打烊时间] --> B[前台小程序展示正确]
A --> C[官网门店页未同步]
C --> D[JSON-LD 里只有常规时段]
D --> E[搜索引擎与地图索引抓取]
E --> F[语音助手按常规时段判定]
F --> G[用户听到营业中]
G --> H[到店发现已关门]
H --> I[一星差评与客诉工单]
这条链路里可控的环节是 D:让官网输出的 JSON-LD 同时表达常规时段和假期例外,并且让这份数据由门店数据库自动生成,而不是人工维护。
二、原理剖析:机器如何判断「现在营业吗」
理解解析器的判断顺序,是写对字段的前提。抓取方拿到页面上的 LocalBusiness 实体(Entity)之后,营业状态的判定大致分为四步。
第一步,定位营业时间属性。openingHours 是自由格式字符串,openingHoursSpecification 是结构化对象数组。两者同时存在时,主流解析器优先取结构化的那一份,字符串那份只作兜底。Google 的本地商家文档也明确推荐结构化写法。
第二步,检查是否存在覆盖今天的例外。specialOpeningHoursSpecification 里的每条记录都带生效区间,解析器会拿当前日期去比对 validFrom 和 validThrough。落在区间内,这一条就生效。
第三步,优先级裁决。例外优先于常规,这是这套设计的核心约定。只要某天命中了例外,当天的常规时段就不再参与判定,而不是取两者的交集或并集。
第四步,时段匹配。取门店所在地的当前本地时间(Local Time),算出今天是星期几,再找 dayOfWeek 匹配的记录,判断当前时刻是否落在区间内。这一步的时区基准是解析方按门店地址推断的,不是你在字段里声明的。
flowchart TD
S[抓取门店页 JSON-LD] --> A{存在 specialOpeningHoursSpecification}
A -- 否 --> C[采用 openingHoursSpecification 常规时段]
A -- 是 --> B{今天落在 validFrom 与 validThrough 之间}
B -- 否 --> C
B -- 是 --> D[例外覆盖常规时段]
C --> E[按门店本地时区取当前时刻]
D --> E
E --> F{星期几匹配 dayOfWeek}
F -- 否 --> H[判定为休息]
F -- 是 --> G{当前时刻在 opens 与 closes 之间}
G -- 是 --> I[回答营业中]
G -- 否 --> H
这里有两个反直觉的地方。例外覆盖是整日覆盖,不是时段覆盖:如果假期当天你只写了一条 10:00 至 18:00 的例外,那么当天 18:00 之后不会被回落到常规的 21:00,而是直接判定为休息。另一个是闭店日必须显式写出来,不写等于「照常营业」,因为解析器会回落到常规时段。
三种写法的表达能力对比
| 属性 | 结构 | 能否表达节假日 | 能否表达闭店日 | 解析稳定性 |
|---|---|---|---|---|
| openingHours | 自由文本字符串 | 不能 | 不能 | 低,格式方言多 |
| openingHoursSpecification | 对象数组 | 不能,只描述常态 | 可以,靠 00:00 至 00:00 | 高 |
| specialOpeningHoursSpecification | 对象数组,带生效区间 | 可以 | 可以 | 高,但依赖区间写法 |
结论很直接:常态用 openingHoursSpecification,假期用 specialOpeningHoursSpecification,openingHours 只在无法结构化时作为补充文本,不要单独依赖它。
时区:字段里没有时区,这是设计使然
opens 与 closes 的类型是 Time,只有时分秒,没有偏移量。schema.org 的设计意图是:这两个值就是门店墙上的钟表时间,时区由门店所在地隐含决定。
这对单一时区的连锁没有影响,但对跨时区经营就会出问题。一家在乌鲁木齐的门店,墙上写 10:00 至 22:00,如果解析器误按 UTC+8 推断,用户当地 12:00 时它认为门店当地也是 12:00,实际那里才 10:00,误差两小时。
可行的缓解手段有三个。门店页的 address 要写到能定位的粒度,含城市与邮编,让地址推断有依据。页面正文里补一句「以下时间均为门店当地时间」,给基于大模型的抓取方一个显式提示。跨时区门店在 CMS 里标注时区字段,脚本生成时把该字段写进页面的可见文本,而不是试图塞进 JSON-LD——因为那里没有对应的槽位。
三、完整骨架:从常规时段到假期例外
下面是一份可直接落地的门店片段。为便于阅读,门店信息做了简化,营业时间部分是完整结构。
依赖与环境:无依赖,纯 JSON 片段。放在门店页的 script 标签内,或在服务端渲染时注入页面头部。编码统一 UTF-8。
{
"@context": "https://schema.org",
"@type": "Bakery",
"@id": "https://example.com/store/1024#store",
"name": "示例烘焙 城东店",
"telephone": "+86-591-88880000",
"address": {
"@type": "PostalAddress",
"streetAddress": "城东路 128 号 1 层",
"addressLocality": "福州市",
"addressRegion": "福建省",
"postalCode": "350001",
"addressCountry": "CN"
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday"],
"opens": "10:00",
"closes": "21:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Friday",
"opens": "10:00",
"closes": "22:00"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Saturday", "Sunday"],
"opens": "09:30",
"closes": "21:30"
}
],
"specialOpeningHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"validFrom": "2026-02-15",
"validThrough": "2026-02-17",
"dayOfWeek": ["Sunday", "Monday", "Tuesday"],
"opens": "11:00",
"closes": "18:00"
},
{
"@type": "OpeningHoursSpecification",
"validFrom": "2026-02-17",
"validThrough": "2026-02-17",
"opens": "00:00",
"closes": "00:00"
},
{
"@type": "OpeningHoursSpecification",
"validFrom": "2026-10-01",
"validThrough": "2026-10-03",
"opens": "09:00",
"closes": "22:30"
}
]
}
几个容易被忽略的细节。dayOfWeek 用 schema.org 的 DayOfWeek 枚举全名,写 Monday 而不是 Mon 或 1,也可以写完整 URL 形式。七天都要被覆盖到。时间相同的日子可以像上面这样合并进同一个 dayOfWeek 数组,减少重复;但不能漏掉某一天,漏掉会被解析器理解为那天不营业,而不是沿用相邻日子的时段。
假期第一条用了数组形式的 dayOfWeek,一次性覆盖三天,适合三天一致的整段假期。validFrom 与 validThrough 都是闭区间,两端日期都包含在内。
第二条是闭店日写法:opens 与 closes 同为 00:00,表示当天全天休息,这是主流解析器约定的表达方式,不要留空、不要写 23:59,也不要直接删掉这一天。第三条演示了不写 dayOfWeek 的整段写法,表示区间内每天都是这个时段。
二十四小时营业的门店写法是七天都写 opens 为 00:00、closes 为 23:59,不要写 24:00,因为 Time 类型不接受这个取值。
字段速查表
| 字段 | 类型 | 是否必填 | 常见错误 |
|---|---|---|---|
| dayOfWeek | DayOfWeek 枚举或数组 | 建议填 | 写成缩写、写成数字、遗漏导致误判休息 |
| opens | Time,HH:MM | 是 | 写 9:30 未补零、写 24:00、带 PM 后缀 |
| closes | Time,HH:MM | 是 | 跨午夜未拆分、闭店日误写 23:59 |
| validFrom | Date 或 DateTime | 例外条目必填 | 缺失导致例外长期生效 |
| validThrough | Date 或 DateTime | 例外条目必填 | 早于起始日期、跨年后忘记更新 |
| @type | OpeningHoursSpecification | 是 | 漏写导致解析器跳过整条记录 |
四、从门店数据库批量生成
人工维护上百门店的假期时间不现实。做法是把营业时间拆成两张表:常态表按门店加星期记录时段,例外表按门店加日期区间记录调整。脚本负责合并、归一化、拆分跨午夜,最后输出 JSON-LD 片段。
字段映射关系如下。
| 数据库字段 | 含义 | JSON-LD 目标 | 转换规则 |
|---|---|---|---|
| store_id | 门店编号 | @id 后缀 | 拼门店页锚点 |
| weekday | 星期,0 至 6 | dayOfWeek | 按枚举表映射为英文全名 |
| open_time | 开始时间 | opens | 归一化为 HH:MM |
| close_time | 结束时间 | closes | 归一化为 HH:MM,24:00 转 23:59 |
| is_closed | 是否全天休息 | opens 与 closes | 两者同为 00:00 |
| holiday_start | 例外起始日 | validFrom | ISO 日期字符串 |
| holiday_end | 例外结束日 | validThrough | ISO 日期字符串 |
依赖与环境:Python 3.9 及以上,仅用标准库 json、datetime、collections。数据源在示例里用列表字典模拟,接真实库时把两个列表换成数据库查询结果即可,字段结构保持一致。
# -*- coding: utf-8 -*-
# 从门店营业时间表生成 JSON-LD 的 openingHoursSpecification 片段
# 依赖:Python 3.9+,仅标准库
# 输入:两份列表,分别来自常态表与假期例外表
import json
from collections import defaultdict
# DayOfWeek 枚举映射,索引与 Python 的 weekday 对齐
# 数据库里 0 表示周一,6 表示周日,这里按同一约定展开
WEEKDAY_NAMES = [
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday",
]
# 把各种脏时间值统一成 HH:MM 格式
# 处理 9:30、24:00、None、以及带空格的情况
# 门店系统里的时间字段常常混着文本与数值
def normalize_time(raw):
# 空值直接返回 None,交给调用方决定
if raw is None:
return None
# 转成字符串并去掉首尾空白
text = str(raw).strip()
# 缺位的 9:30 前面补零
# 不补零会导致部分解析器直接丢弃该条记录
if len(text) == 4 and text[1] == ":":
text = "0" + text
# 24:00 不是合法 Time,用 23:59 表达全天
if text in ("24:00", "24:00:00"):
return "23:59"
# 只保留前五位,丢掉秒
# schema.org 允许带秒,但 HH:MM 兼容性更好
return text[:5]
# 判断是否是跨午夜的时段
# 例如 22:00 至 02:00,结束早于开始
def is_overnight(opens, closes):
# 闭店日两边相等,不算跨午夜
if opens == closes:
return False
# 字符串比较即可,HH:MM 是等长定宽格式
# 结束时刻早于或等于开始时刻,说明跨过了零点
return closes <= opens
# 把跨午夜时段拆成两段
# 前一段到当天 23:59,后一段从次日 00:00 开始
def split_overnight(day_index, opens, closes):
# 当天段:从 opens 到当天 23:59
# 这里不能用 24:00,Time 类型不接受该取值
first = (day_index, opens, "23:59")
# 次日段:从 00:00 到 closes,星期往后推一天
# 对 7 取模,保证周日的次日回到周一
second = ((day_index + 1) % 7, "00:00", closes)
return [first, second]
# 构造一条 OpeningHoursSpecification 记录
def make_spec(day_index, opens, closes, valid_from=None, valid_through=None):
# 组装一条记录,星期用枚举全名
# 基础字段,@type 不能漏
spec = {
"@type": "OpeningHoursSpecification",
"dayOfWeek": WEEKDAY_NAMES[day_index],
"opens": opens,
"closes": closes,
}
# 只有例外条目才带生效区间
if valid_from and valid_through:
spec["validFrom"] = valid_from
spec["validThrough"] = valid_through
return spec
# 生成常规时段:一周七天,按星期分组
def build_regular(rows):
# 用字典按星期收集,便于后面合并同一天的多段
bucket = defaultdict(list)
# 逐行处理常态表
for row in rows:
# 全天休息的行直接落成 00:00 至 00:00
# 这是约定的闭店日写法,不能留空
if row.get("is_closed"):
bucket[row["weekday"]].append((row["weekday"], "00:00", "00:00"))
continue
# 归一化开闭时间
opens = normalize_time(row["open_time"])
closes = normalize_time(row["close_time"])
# 跨午夜就拆成两段,分别落到两天
# 不拆的话多数解析器会当成空区间
if is_overnight(opens, closes):
for seg in split_overnight(row["weekday"], opens, closes):
bucket[seg[0]].append(seg)
else:
bucket[row["weekday"]].append((row["weekday"], opens, closes))
# 按星期顺序输出,保证 JSON 稳定可读
result = []
for day_index in range(7):
# 按周一到周日遍历,输出顺序固定,便于 diff
# 同一天多段时,合并 dayOfWeek 为数组,只留一条记录
segs = bucket.get(day_index, [])
if not segs:
continue
if len(segs) == 1:
_, o, c = segs[0]
result.append(make_spec(day_index, o, c))
else:
# 多段情况:dayOfWeek 用数组,opens 与 closes 用数组并列
spec = {
"@type": "OpeningHoursSpecification",
"dayOfWeek": WEEKDAY_NAMES[day_index],
"opens": [s[1] for s in segs],
"closes": [s[2] for s in segs],
}
result.append(spec)
return result
# 生成假期例外:每条记录带 validFrom 与 validThrough
def build_special(rows):
result = []
# 逐行处理例外表
for row in rows:
# 假期例外必须带生效区间,否则会长期覆盖常规时段
# 闭店日:opens 与 closes 同为 00:00
if row.get("is_closed"):
spec = {
"@type": "OpeningHoursSpecification",
"opens": "00:00",
"closes": "00:00",
"validFrom": row["holiday_start"],
"validThrough": row["holiday_end"],
}
result.append(spec)
continue
# 正常调整:归一化后写入
opens = normalize_time(row["open_time"])
closes = normalize_time(row["close_time"])
# 例外里的日期也要带上
base = {
"validFrom": row["holiday_start"],
"validThrough": row["holiday_end"],
}
# 跨午夜同样拆成两段,各带一份生效区间
if is_overnight(opens, closes):
for day_index, o, c in split_overnight(0, opens, closes):
spec = dict(base)
spec.update(make_spec(day_index, o, c, base["validFrom"], base["validThrough"]))
result.append(spec)
else:
# 未指定星期时,整段区间内每天都生效
# 若只想覆盖特定星期,在这里补上 dayOfWeek
spec = dict(base)
spec["@type"] = "OpeningHoursSpecification"
spec["opens"] = opens
spec["closes"] = closes
result.append(spec)
return result
# 组装完整门店实体
def build_store(store_id, name, phone, regular_rows, special_rows):
# 门店实体骨架,@id 用门店页锚点保证不重复
# @type 按实际业态替换,例如 Bakery、Restaurant
node = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://example.com/store/%s#store" % store_id,
"name": name,
"telephone": phone,
}
# 常规时段与例外时段分列两个属性
# 顺序无关,但两者缺一都会导致假期回答出错
node["openingHoursSpecification"] = build_regular(regular_rows)
node["specialOpeningHoursSpecification"] = build_special(special_rows)
return node
# 序列化输出,保持中文可读
def dump(node):
# ensure_ascii 关掉,避免中文被转义成字符序列
# indent 便于人工审阅与版本比对
return json.dumps(node, ensure_ascii=False, indent=2)
这段脚本解决的是重复性劳动。常态表与例外表都由门店系统导出,脚本每天跑一次,产物直接注入页面模板。假期调整只改例外表,不动模板。
有一处需要留意:同一天多段的情况,上面的实现把 opens 与 closes 写成了数组。这种并列数组的写法在部分解析器上支持不完整。稳妥的做法是多段拆成多条记录,每条一条时段,牺牲一点体积换取兼容性。取舍取决于你的解析器目标,如果只面向支持度较好的平台,数组形式更紧凑。
五、校验脚本:把坑拦在上线前
生成之后必须校验。人工生成时最容易犯的几类错误是:时间没补零、跨午夜没拆、例外区间缺失或倒挂、闭店日写成 23:59、七天的记录缺了一天。下面这份脚本把这些都查一遍,发现问题就返回非零退出码,可以直接挂到持续集成里。
依赖与环境:Python 3.9 及以上,仅用标准库 re、datetime、sys。输入为上一节 dump 的产物或任意门店 JSON-LD 文件,输出为问题清单。
# -*- coding: utf-8 -*-
# 营业时间结构化数据的上线前校验
# 依赖:Python 3.9+,仅标准库
# 用法:python check_hours.py store.json,有错返回退出码 1
import re
import sys
import json
from datetime import datetime
# 合法星期枚举,schema.org DayOfWeek 的取值
# 顺序与常规时段的输出顺序一致
VALID_DAYS = [
"Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday",
]
# HH:MM 的严格匹配,不允许 9:30 或 24:00
# 小时取 00 至 23,分钟取 00 至 59
TIME_RE = re.compile(r"^([01][0-9]|2[0-3]):([0-5][0-9])$")
# 收集问题,每条带上字段名与说明
def check_time(value, field, index, problems):
# 空值或非字符串直接报错
if not isinstance(value, str):
problems.append("第 %d 条的 %s 不是字符串" % (index, field))
return
# 正则不匹配,说明格式不对或取值越界
# 常见越界值是 24:00 与 9:30
if not TIME_RE.match(value):
problems.append("第 %d 条的 %s 取值非法:%s" % (index, field, value))
# 校验常规时段列表
def check_regular(specs, problems):
# 记录出现过的星期,用于最后检查七天覆盖
# 漏掉某天会被解析器理解为那天休息
seen = set()
# 逐条检查
for index, spec in enumerate(specs):
# 类型字段缺失会被解析器整条跳过
# 这类错误不报错但静默失效,必须查
if spec.get("@type") != "OpeningHoursSpecification":
problems.append("第 %d 条常规记录缺少 @type" % index)
# 取出星期,可能是字符串也可能是数组
# 统一成列表,后面的处理逻辑就只有一种
days = spec.get("dayOfWeek")
if isinstance(days, str):
days = [days]
# 星期为空即无法匹配任何一天
if not days:
problems.append("第 %d 条常规记录缺少 dayOfWeek" % index)
continue
# 逐个核对枚举值
for day in days:
if day not in VALID_DAYS:
problems.append("第 %d 条星期取值非法:%s" % (index, day))
else:
seen.add(day)
# 开闭时间分别校验格式
# 两者都必须是 HH:MM,缺一整条失效
opens = spec.get("opens")
closes = spec.get("closes")
check_time(opens, "opens", index, problems)
check_time(closes, "closes", index, problems)
# 闭店日两边相等,属于合法情况
if opens == closes and opens == "00:00":
continue
# 结束早于开始说明跨午夜未拆分
if isinstance(opens, str) and isinstance(closes, str) and closes <= opens:
problems.append("第 %d 条疑似跨午夜未拆分:%s 至 %s" % (index, opens, closes))
# 缺失的星期会被理解为休息日
missing = [d for d in VALID_DAYS if d not in seen]
if missing:
problems.append("常规时段未覆盖的星期:%s" % "、".join(missing))
# 把 ISO 日期字符串解析成 date 对象
def parse_date(value):
# 只接受 YYYY-MM-DD,避免时区歧义
# 带时分秒的写法会引入偏移量问题
try:
return datetime.strptime(str(value), "%Y-%m-%d").date()
except ValueError:
return None
# 校验假期例外列表
def check_special(specs, problems):
# 逐条检查
for index, spec in enumerate(specs):
# 例外条目类型同样不能少
# 例外列表为空是允许的,表示近期无调整
if spec.get("@type") != "OpeningHoursSpecification":
problems.append("第 %d条例外记录缺少 @type" % index)
# 起始与结束日期是例外的核心,缺了会长期生效
start = parse_date(spec.get("validFrom"))
end = parse_date(spec.get("validThrough"))
if start is None or end is None:
problems.append("第 %d 条例外的生效区间缺失或格式非法" % index)
continue
# 区间两端都是闭区间,起始日与结束日都生效
# 倒挂区间等于永不生效,属于数据录入错误
if start > end:
problems.append("第 %d 条例外区间倒挂:%s 至 %s" % (index, start, end))
# 开闭时间格式校验
check_time(spec.get("opens"), "opens", index, problems)
check_time(spec.get("closes"), "closes", index, problems)
# 闭店日写法核对
# 写 23:59 等于告诉解析器当天几乎全天营业
opens = spec.get("opens")
closes = spec.get("closes")
if opens == "23:59" and closes == "23:59":
problems.append("第 %d 条例外闭店日误写为 23:59,应为 00:00 至 00:00" % index)
# 非闭店日时,跨午夜同样要拆
if opens != closes and isinstance(opens, str) and isinstance(closes, str):
if closes <= opens:
problems.append("第 %d 条例外疑似跨午夜未拆分" % index)
# 入口:读文件,跑两份校验,打印结果
def main(path):
# 读取 JSON-LD 文件,编码统一 UTF-8
with open(path, encoding="utf-8") as handle:
node = json.load(handle)
# 问题清单,收集完一次性输出
problems = []
# 常规与例外分别校验,规则不同所以分开写
check_regular(node.get("openingHoursSpecification", []), problems)
check_special(node.get("specialOpeningHoursSpecification", []), problems)
# 有问题就逐条打印并返回失败
if problems:
for item in problems:
print("问题:" + item)
print("共 %d 项,校验未通过" % len(problems))
return 1
# 无问题给出明确结论
print("校验通过:%s" % path)
# 返回 0,持续集成据此放行
return 0
# 命令行入口
# 退出码非零即阻断发布
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
把这条命令放进发布流水线,每次门店数据变更都会跑一遍。错误在上线前暴露,比在差评里暴露便宜得多。
常见坑与修法对照
| 坑 | 现象 | 修法 |
|---|---|---|
| 跨午夜未拆分 | 22:00 至 02:00 被判定为已打烊 | 拆成 22:00 至 23:59 与次日 00:00 至 02:00 |
| 闭店日写成 23:59 | 当天被判定为几乎全天营业 | opens 与 closes 同为 00:00 |
| 例外缺 validThrough | 假期结束后仍按假期时间回答 | 两条区间字段一起写,缺一不可 |
| 时间写 9:30 | 部分解析器拒绝该条记录 | 统一补零为 09:30 |
| 常规时段漏写周日 | 周日一律回答休息 | 七天全部显式列出 |
| 跨时区门店按时区混淆 | 早晚高峰回答偏差两小时 | 地址写全,页面补本地时间说明 |
六、修复前后的对照数据
某连锁烘焙品牌共 128 家门店,改造前营业时间只写常规时段,假期靠人工改页面文字。改造后接入了上述生成与校验流程。评估方式是抽样 400 次提问,覆盖常规日、春节、国庆、临时调休、跨午夜时段五类场景,比对助手回答与门店真实状态是否一致。
| 场景 | 样本数 | 修复前准确率 | 修复后准确率 | 主要失效原因 |
|---|---|---|---|---|
| 常规工作日 | 120 | 92.5% | 98.7% | 少量门店时间未补零 |
| 跨午夜时段 | 40 | 63.4% | 93.1% | 跨午夜未拆分 |
| 国庆假期 | 80 | 55.8% | 97.8% | 例外未写生效区间 |
| 春节假期 | 100 | 41.3% | 96.4% | 闭店日缺失,回落常规时段 |
| 临时调休 | 60 | 48.0% | 95.2% | 例外未同步 |
| 整体 | 400 | 76.2% | 97.1% |
修复后剩余的误差集中在两类:门店当天临时停业(停电、设备检修)没有走系统,以及个别门店地址变更后抓取方时区推断仍滞后。前者属于运营流程问题,结构化数据表达不了突发状态;后者需要在门店搬迁后主动提交更新。
改造后一个月,该品牌门店页因「营业时间不符」产生的差评从每月 17 条降到 2 条,两条均与营业时间无关。
七、原理剖析:跨午夜与闭店日的解析机制
跨午夜为什么一定要拆。Time 类型只是一天之内的时刻,没有「次日」这个概念。解析器拿到 22:00 与 02:00,按数值顺序理解就是开始晚于结束,多数实现会直接判定这个区间为空集,于是当天 22:00 之后一律回答已打烊。少数实现会自作主张地加一天,但你无法保证抓取方属于哪一类。拆成两段之后,两段都在单日之内,任何实现都能正确理解。
flowchart LR
A[数据库记录 周五 22:00 至 02:00] --> B[归一化为 HH:MM]
B --> C{closes 小于等于 opens}
C -- 是且非闭店日 --> D[拆分为两段]
D --> E[周五 22:00 至 23:59]
D --> F[周六 00:00 至 02:00]
C -- 否 --> G[单段直接写入]
E --> H[JSON-LD]
F --> H
G --> H
闭店日为什么不能靠「不写」表达。解析器的回落逻辑是:这一天没有例外,就去看常规时段;常规时段里也没有,才判定休息。所以不写等于把判定权交给了常规时段,门店当天明明休息,回答却是照常营业。opens 与 closes 同为 00:00 是被普遍接受的显式休息表达,它不是一个长度为零的区间,而是一个约定信号。
validThrough 的闭区间语义也常被误解。写 2026-02-15 到 2026-02-17,表示三天都生效,不是两天。跨年的假期要把区间写全,不能只写一个起始日。
八、几个流传较广的误区
改页面文字就够了。不对。页面文字是给人看的,结构化数据是给机器看的。两者分属不同通道,改了前者不影响后者。正确做法是让页面文字由同一份数据渲染出来,改一处两处同步。
openingHoursSpecification 里加 validFrom 也能表达假期。不对。常规时段属性表达的是常态,带区间反而会让解析器困惑。假期必须放在 specialOpeningHoursSpecification 里。
例外覆盖是时段的合并。不对。命中例外后当天常规时段整体失效,例外里没覆盖的时段就是休息,不会自动回落到常规。
时间要按 UTC 写。不对。字段没有时区槽位,写 UTC 反而制造偏差。按门店墙上时间写,把时区的可推断性交给地址与可见文本。
写完就不用管了。不对。节假日表每年都要更新,validThrough 过了就失效。把这个表的维护纳入运营日程,脚本每天生成,校验每天跑。
九、把流程固化下来
最小可行的闭环是四步。门店系统维护常态表与例外表。脚本每天定时生成 JSON-LD 片段。发布流水线里跑校验脚本,失败就阻断。上线后按周抽样验证,把助手回答与门店真实状态做比对,发现偏差回查数据。
这套东西的技术含量不高,难点在流程纪律:让营业时间的权威来源是门店数据库,而不是某个人工维护的页面。只要还有一个环节允许手工填时间,漏改就只是时间问题。
你们门店的营业时间数据是怎么同步的,有没有踩过跨午夜或闭店日的坑,评论区聊聊具体的场景和解法。
参考与延伸
- Schema.org 官方属性定义 openingHoursSpecification
- Google 搜索中心本地商家结构化数据文档 Local Business structured data
- Schema.org 星期枚举 DayOfWeek
- Schema.org 官方校验工具 validator.schema.org
openingHoursSpecification, specialOpeningHoursSpecification, JSON-LD, LocalBusiness 结构化数据, 节假日营业时间, 跨午夜时段解析, GEO, AI优化AIO