三点三刻被解析成 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 是星期几。我先查清楚它是周五,再写断言——否则测试本身就是错的,用一个错误的预期去验证,即使通过也没有意义。
小结
中文口语时间解析的难点不在某个算法,而在于自然语言里大量”看起来规整、实际全是例外”的边角。这次的两个问题可以归纳为:
- 同一个数字在不同单位下语义不同,单位应当作为会改写数字含义的一等公民处理,而不是可选后缀;
- 词与词之间存在子串包含关系(大后天 ⊃ 后天 ⊃ 天),匹配顺序需要显式按长度降序,不能依赖遍历顺序;
- 这类错误不抛异常,只能依靠穷举用例的测试网兜住。
自己实现解析器,最大的收获不是那几行修复,而是把这些例外逐个识别出来并固化进测试。下次再处理口语解析,我会更早地先列用例、再写规则。