节假日营业时间写错半小时的代价:openingHoursSpecification 落地实战

2026-09-19 09:59:46 9 次浏览
结构化数据Schema.orgJSON-LDPython本地生活服务GEO

适用读者:连锁门店与本地服务业的技术负责人、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 里的每条记录都带生效区间,解析器会拿当前日期去比对 validFromvalidThrough。落在区间内,这一条就生效。

第三步,优先级裁决。例外优先于常规,这是这套设计的核心约定。只要某天命中了例外,当天的常规时段就不再参与判定,而不是取两者的交集或并集。

第四步,时段匹配。取门店所在地的当前本地时间(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,假期用 specialOpeningHoursSpecificationopeningHours 只在无法结构化时作为补充文本,不要单独依赖它。

时区:字段里没有时区,这是设计使然

openscloses 的类型是 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 而不是 Mon1,也可以写完整 URL 形式。七天都要被覆盖到。时间相同的日子可以像上面这样合并进同一个 dayOfWeek 数组,减少重复;但不能漏掉某一天,漏掉会被解析器理解为那天不营业,而不是沿用相邻日子的时段。

假期第一条用了数组形式的 dayOfWeek,一次性覆盖三天,适合三天一致的整段假期。validFromvalidThrough 都是闭区间,两端日期都包含在内。

第二条是闭店日写法:openscloses 同为 00:00,表示当天全天休息,这是主流解析器约定的表达方式,不要留空、不要写 23:59,也不要直接删掉这一天。第三条演示了不写 dayOfWeek 的整段写法,表示区间内每天都是这个时段。

二十四小时营业的门店写法是七天都写 opens00:00closes23: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 及以上,仅用标准库 jsondatetimecollections。数据源在示例里用列表字典模拟,接真实库时把两个列表换成数据库查询结果即可,字段结构保持一致。

# -*- 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)

这段脚本解决的是重复性劳动。常态表与例外表都由门店系统导出,脚本每天跑一次,产物直接注入页面模板。假期调整只改例外表,不动模板。

有一处需要留意:同一天多段的情况,上面的实现把 openscloses 写成了数组。这种并列数组的写法在部分解析器上支持不完整。稳妥的做法是多段拆成多条记录,每条一条时段,牺牲一点体积换取兼容性。取舍取决于你的解析器目标,如果只面向支持度较好的平台,数组形式更紧凑。

五、校验脚本:把坑拦在上线前

生成之后必须校验。人工生成时最容易犯的几类错误是:时间没补零、跨午夜没拆、例外区间缺失或倒挂、闭店日写成 23:59、七天的记录缺了一天。下面这份脚本把这些都查一遍,发现问题就返回非零退出码,可以直接挂到持续集成里。

依赖与环境:Python 3.9 及以上,仅用标准库 redatetimesys。输入为上一节 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

闭店日为什么不能靠「不写」表达。解析器的回落逻辑是:这一天没有例外,就去看常规时段;常规时段里也没有,才判定休息。所以不写等于把判定权交给了常规时段,门店当天明明休息,回答却是照常营业。openscloses 同为 00:00 是被普遍接受的显式休息表达,它不是一个长度为零的区间,而是一个约定信号。

validThrough 的闭区间语义也常被误解。写 2026-02-152026-02-17,表示三天都生效,不是两天。跨年的假期要把区间写全,不能只写一个起始日。

八、几个流传较广的误区

改页面文字就够了。不对。页面文字是给人看的,结构化数据是给机器看的。两者分属不同通道,改了前者不影响后者。正确做法是让页面文字由同一份数据渲染出来,改一处两处同步。

openingHoursSpecification 里加 validFrom 也能表达假期。不对。常规时段属性表达的是常态,带区间反而会让解析器困惑。假期必须放在 specialOpeningHoursSpecification 里。

例外覆盖是时段的合并。不对。命中例外后当天常规时段整体失效,例外里没覆盖的时段就是休息,不会自动回落到常规。

时间要按 UTC 写。不对。字段没有时区槽位,写 UTC 反而制造偏差。按门店墙上时间写,把时区的可推断性交给地址与可见文本。

写完就不用管了。不对。节假日表每年都要更新,validThrough 过了就失效。把这个表的维护纳入运营日程,脚本每天生成,校验每天跑。

九、把流程固化下来

最小可行的闭环是四步。门店系统维护常态表与例外表。脚本每天定时生成 JSON-LD 片段。发布流水线里跑校验脚本,失败就阻断。上线后按周抽样验证,把助手回答与门店真实状态做比对,发现偏差回查数据。

这套东西的技术含量不高,难点在流程纪律:让营业时间的权威来源是门店数据库,而不是某个人工维护的页面。只要还有一个环节允许手工填时间,漏改就只是时间问题。

你们门店的营业时间数据是怎么同步的,有没有踩过跨午夜或闭店日的坑,评论区聊聊具体的场景和解法。

参考与延伸

openingHoursSpecification, specialOpeningHoursSpecification, JSON-LD, LocalBusiness 结构化数据, 节假日营业时间, 跨午夜时段解析, GEO, AI优化AIO

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