skip to content
tlj 的工程笔记

三点三刻被解析成 15:15:中文时间解析的两个坑

/ 8 min read

Table of Contents

做语音日历时,有一个绕不开的核心模块:把用户说的话转成结构化时间。

用户说”明天下午三点三刻提醒我开会”,系统需要解析出一个确定的 datetime。这件事看起来简单,真正实现后才发现,中文口语的时间表达里藏着不少例外。这篇记录其中两个有代表性的问题,以及背后的原因。

为什么没有直接用现成的库

第一反应是找现成的解析库。dateparser 这类库对英文和规整格式(2026-05-30 15:45)支持得不错,但对”三点三刻""大后天""下下周一”这类纯口语表达,要么解析不出来,要么解析错却不报错。后者风险更大:一个看起来合法的错误结果,会一路流到下游。

这是个比赛项目,时间解析是核心能力之一,我不希望把最关键的环节放进一个无法控制、出错也难定位的黑盒里。所以决定自己实现一套规则引擎:规则可控、可测试、出错能定位到具体哪一条。代价是要自己把这些例外逐个处理掉。

坑一:单位的语义被当成了数字

第一版运行后,“三点三刻”被解析成了 15:15,正确结果应该是 15:45

“刻”是传统的时间单位,一刻等于十五分钟,三刻即四十五分钟。15:15 这个错误结果,来自分钟部分的正则匹配。当时的写法大致如下(简化):

# 有问题的版本:贪心地把"三刻"里的"三"当成了分钟数
minute_pattern = r"(?P<minute>[零一二三四五六七八九十]+)?(|)?"

在”三点三刻”中,“点”后面跟着”三刻”。正则在匹配分钟时先吃到了”三”,把它当作分钟数 3,随后又看到”刻”,按”一刻 = 15 分钟”的逻辑处理。两套逻辑互相干扰,最终得到 15:15

根本原因是,我把”刻”当成了一个可有可无的后缀,而没有把它当成一个会改变前面数字含义的独立单位。“三分”里的三表示 3 分钟,“三刻”里的三表示 3 × 15 = 45 分钟,两个”三”的语义完全不同,不能用同一个分支处理。

修复方式是把”刻”作为独立单位单独计算:

# 修复:刻是独立单位,N 刻 = N × 15 分钟,与"分"分开处理
def parse_minute(text: str) -> int:
ke_match = re.search(r"(?P<n>[零一二三四五六七八九十]+)", text)
if ke_match:
return cn_to_int(ke_match.group("n")) * 15
fen_match = re.search(r"(?P<n>[零一二三四五六七八九十]+)", text)
if fen_match:
return cn_to_int(fen_match.group("n"))
return 0

“刻”优先判断,命中即返回 N × 15,不再进入”分”的逻辑。两个分支互不干扰。

坑二:相对日期的子串冲突

相对日期是另一类问题。“今天""明天""后天""大后天”,以及”下周一""下下周一”。

第一版用一个字典做映射,遍历匹配时出了问题:

RELATIVE_DAYS = {
"": 0, # "今天"中的"天"
"明天": 1,
"后天": 2,
"大后天": 3,
}

“大后天”这个词里同时包含”后天”和”天”两个 key。如果匹配时不约定顺序,先命中”后天”,就会把”大后天”误判为 +2 天。这是典型的子串冲突:短 key 是长 key 的子串,谁先命中取决于遍历顺序,而字典的顺序不应被当作可靠依赖。

修复方式是按 key 的长度降序匹配:先尝试最长的”大后天”,命中即停止,不给”后天""天”机会。

# 长 key 优先,避免"大后天"被"后天"提前命中
for key in sorted(RELATIVE_DAYS, key=len, reverse=True):
if key in text:
offset = RELATIVE_DAYS[key]
break

这条规则后来在处理”下下周”与”下周”时直接复用,是同一类子串包含问题。

真正兜住这两个问题的是测试

这两个 bug 有一个共同点:它们都不会抛异常,只是默默返回错误答案。15:15+2 天看起来都是合法结果,不针对具体用例验证就很难发现。

因此这个模块我写了 58 个单元测试,覆盖能想到的口语表达:整点、半点、刻、相对天、周内某天、跨天、上午下午的 12/24 小时换算。这些测试是纯逻辑,不依赖网络和凭证,本机几百毫秒即可跑完。

@pytest.mark.parametrize("text,expected", [
("三点三刻", (15, 45)),
("下午三点三刻", (15, 45)),
("大后天上午十点", ...),
("下下周一", ...),
])
def test_parse_time(text, expected):
assert parse(text) == expected

写测试时还有一个细节:要断言”参考日的下周一是几号”,必须先确认参考日 2026-05-29 是星期几。我先查清楚它是周五,再写断言——否则测试本身就是错的,用一个错误的预期去验证,即使通过也没有意义。

小结

中文口语时间解析的难点不在某个算法,而在于自然语言里大量”看起来规整、实际全是例外”的边角。这次的两个问题可以归纳为:

  • 同一个数字在不同单位下语义不同,单位应当作为会改写数字含义的一等公民处理,而不是可选后缀;
  • 词与词之间存在子串包含关系(大后天 ⊃ 后天 ⊃ 天),匹配顺序需要显式按长度降序,不能依赖遍历顺序;
  • 这类错误不抛异常,只能依靠穷举用例的测试网兜住。

自己实现解析器,最大的收获不是那几行修复,而是把这些例外逐个识别出来并固化进测试。下次再处理口语解析,我会更早地先列用例、再写规则。