前两天朋友甩给我一条日程标题:2026年09月20日星期日。她说想在那天安排一场家庭聚会,可翻了好几个App,有的显示周日,有的显示周一,她差点把行程定错。我一边吐槽她该换工具,一边意识到这种困惑其实特别常见——很多人拿到一串写着年月日和星期几的中文文本,第一反应就是上网再查一遍,而不是自己动手验证。
作为一个常年和日期、定时任务打交道的开发者,我决定把“2026年09月20日星期日”当成一个完整样本,写一套从中文日期解析、星期校验、农历节气查询,到倒计时和日历提醒的小工具。整个过程里踩了不少坑,也沉淀出很多可以直接复用的经验。这篇分享就是完整的复盘,希望对那些正在写爬虫日期抽取、日历应用、提醒服务,或者单纯想搞懂“这个日期到底是不是周日”的朋友有点帮助。
1. 一个平平无奇的日期标题,为什么要专门写个工具处理它
1.1 搜索“2026年09月20日星期日”的人,背后都有哪些诉求
先说结论:这种中文日期文本,比大部分人想象中常见得多。网站标题、活动海报、爬虫抓回来的页面正文、别人随手发来的日程截图,到处都是类似格式。我细拆了一下,围绕“2026年09月20日星期日”这个标题,真实需求其实能分成四类。
第一类是最朴素的:确认这是星期几。很多人不确定某个未来日期落在周几,得反复去查日历,尤其当这个日期要用来定婚礼、订场地、约会议的时候,星期错了后面全乱。第二类是想知道还有多少天,也就是倒计时。目标日期可能是生日、纪念日、考试日、项目上线日,人天然会对“距离那一天还有多久”产生兴趣。第三类更中国化一点:查农历、节气、宜忌,看看这个日子适不适合办酒席、搬家、签合同。第四类是程序员的视角:批量解析这类文本,把“2026年09月20日星期日”转成结构化数据,存进数据库或者生成日历事件。
这些需求单独看都不难,但放在一起就变成一个完整的小工程:文本解析、日期校验、星期计算、农历转换、倒计时、日历导出、自动提醒,每一环都有细节。我决定把它们全部串成一条流水线。
1.2 把需求翻译成技术模块和输出物
动手之前,我把需求映射成了下面这组技术模块,后面所有代码都是按这个结构写的。
| 用户疑问 | 对应技术模块 | 主要依赖/方法 |
|---|---|---|
| 这是星期几? | 日期解析 + 星期校验 | Pythondatetime、蔡勒公式 |
| 是农历几月几号? | 公历转农历 | lunar_python或sxtwl |
| 有什么节气、宜忌? | 农历库扩展查询 | 农历库自带的节气、黄历接口 |
| 还有几天到? | 倒计时计算 | datetime.date+zoneinfo |
| 怎么存进日历/自动提醒? | 日历事件 + 推送 | ICS 文件、飞书Webhook、cron |
整个处理流程是这样走的:输入一行中文日期文本 → 正则抽取年月日和星期字段 → 用datetime校验合法性 → 计算星期并和文本里的“星期日”比对 → 附加农历、节气、宜忌 → 算出倒计时天数 → 导出 ICS 日历文件或触发提醒。每个环节都有可复现的代码,下面逐个说。
2. 中文日期文本解析:从字符串校验到星期比对
2.1 正则表达式搞定“2026年09月20日星期日”
解析的第一步是让程序读懂这串话。看似简单,实际写正则时你会遇到各种变体:“2026年09月20日星期日”“2026-09-20 周日”“2026年9月20号星期天”,甚至还有“二〇二六年九月二十日”这种纯中文写法。
我先给一个兼容度比较高的解析函数,核心是两组正则:一组处理阿拉伯数字加中文年月日,一组处理纯中文数字。代码里我保留了两个分支,优先级先按阿拉伯数字来。
import re from datetime import date CN_NUMS = { "零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, } def cn_num_to_int(text): num = 0 for ch in text: if ch in CN_NUMS: num = num * 10 + CN_NUMS[ch] return num if num else None def parse_chinese_date(text): year = month = day = None week_text = None # 第一优先:阿拉伯数字格式,例如 2026年09月20日星期日 m = re.search( r"(\d{4})\s*[年.\-/]\s*(\d{1,2})\s*[月.\-/]\s*(\d{1,2})" r"\s*[日号]?\s*(星期[一二三四五六日天]|周[一二三四五六日天]|礼拜[一二三四五六日天])?", text ) if m: year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3)) week_text = m.group(4) else: # 第二优先:纯中文数字,例如 二〇二六年九月二十日星期日 m2 = re.match( r"([〇零一二三四五六七八九两]{4})年" r"([〇零一二三四五六七八九两]{1,2})月" r"([〇零一二三四五六七八九两]{1,3})\s*[日号]?" r"\s*(星期[一二三四五六日天]|周[一二三四五六日天]|礼拜[一二三四五六日天])?", text ) if m2: year = cn_num_to_int(m2.group(1)) month = cn_num_to_int(m2.group(2)) day = cn_num_to_int(m2.group(3)) week_text = m2.group(4) if year is None: raise ValueError(f"无法识别的日期格式: {text}") try: d = date(year, month, day) except ValueError as e: raise ValueError(f"非法日期: {year}-{month:02d}-{day:02d}") from e return d, week_text这个函数里我故意让正则允许“2026-09-20”这种带横线或点号的写法,也允许中间有全角空格。实测下来,网页标题里最常见的几种格式都能命中,基本够用。
2.2 合法性校验:2月30日不会出现在任何年份
很多人拿到“2026年09月20日”只会做正则提取,提取完直接组装字符串,不校验。这是一个很隐蔽的坑。
举个例子,“2026-02-30”这种文本,正则能完美匹配出 year=2026, month=2, day=30,但 2026 年 2 月根本没有 30 号。如果不做校验,后面的星期计算、倒计时计算全部会拿一个不存在的日期做运算,轻则报错,重则静默产生错误结果。
上面的代码里我用了date(year, month, day)来构造日期,Python 的datetime会在遇到非法日期时抛出ValueError,然后我把它包装成带原始输入信息的异常传出去。这样就完成了“格式可解析”和“日期真实存在”两层校验。
顺带说一句,正则匹配时我只匹配月前后各一段数字,不会出现把“2026-09-20”拆成 month=20、day=9 这种错误。日期文本处理里月份和日期顺序搞反是很常见的 bug,固定字段顺序能少掉一半的坑。
2.3 星期字段核验:“星期日”和程序算出来的周日对不对得上
标题里带了“星期日”,那程序必须回答一个问题:这个“星期日”是题目自带的说法,还是真的对应 2026-09-20 这个日期?
我会先把中文星期映射成数字:星期一对应 0,星期二对应 1,依此类推,星期日对应 6。然后用d.weekday()算出真实星期,两个值一比对就知道题目文本有没有写错。
WEEK_MAP = { "一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6, } def verify_week(d, week_text): if not week_text: return None # 取最后一个字,兼容“周日”“星期日”“星期天” w = WEEK_MAP[week_text[-1]] actual = d.weekday() if w != actual: return f"星期字段不匹配: 文本说{week_text},实际是星期{'一二三四五六日'[actual]}" return f"星期校验通过: {week_text}"拿“2026年09月20日星期日”跑一遍:date(2026, 9, 20).weekday()返回 6,对应星期日,和文本一致。这也算是顺手验证了标题本身没写错。
3. 星期计算的底层原理:蔡勒公式前因后果与标准库源码
3.1 用Python标准库一行算星期,但它内部做了什么
日常开发里根本不用自己写星期算法,date.weekday()一行就出结果。比如date(2026, 9, 20).weekday()会返回 6,代表星期日;date.isoweekday()会返回 7,也是星期日。
但“会用”和“懂它”是两回事。Python 的date类型基于“投影格里高利历”,也就是说它把现行公历规则往前无限投影,从公元 1 年 1 月 1 日开始连续编号,再根据编号取模算出星期。对于 1582 年之后实际使用的日期,这套算法没有任何问题;对于 1582 年 10 月 15 日之前的日期,严格历史意义上会有偏差,不过日常开发不会碰到那个量级。
标准库最大的价值不是算法本身,而是它把所有边界都处理好了:闰年、月末、世纪年、日期格式化。所以我的建议是:只要目标平台有可靠的日期库,优先用标准库,不要自己造轮子。自己写算法,是为了理解原理、做跨语言移植,或者应付“不能用 datetime 但必须算星期”的极端环境。
3.2 蔡勒公式:手写星期计算的经典解法
如果确实要手写,蔡勒公式(Zeller's congruence)是最经典的方案,专门用于格里高利历。它的核心思路是把年月日映射到一个模 7 的数,然后映射成星期几。
公式我用的这个版本:
def zeller_weekday(year, month, day): # 1月、2月视为上一年的13月、14月 if month < 3: month += 12 year -= 1 K = year % 100 # 年份后两位 J = year // 100 # 年份前两位 # h: 0=Saturday, 1=Sunday, ..., 6=Friday h = (day + (13 * (month + 1)) // 5 + K + K // 4 + J // 4 + 5 * J) % 7 week_names = [ "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" ] # 转成 Python weekday 约定:0=Monday ... 6=Sunday weekday = (h + 5) % 7 return week_names[h], weekday验证一下“2026年09月20日星期日”:
- 9 月不需要调整,year=2026, month=9, day=20。
- K = 2026 % 100 = 26,J = 2026 // 100 = 20。
- h = (20 + (13 * 10) // 5 + 26 + 26 // 4 + 20 // 4 + 5 * 20) % 7 = (20 + 26 + 26 + 6 + 5 + 100) % 7 = 183 % 7 = 1。
h=1 对应 Sunday,(h + 5) % 7 = 6,和date.weekday()结果一致。这个公式跑出来的结果可以用标准库做交叉验证,两个独立来源互相印证,基本稳了。
3.3 边界情况:1582年、1月2月、闰年百年规则
蔡勒公式最容易翻车的三个点,我依次说清楚。
第一,1月2月的特判。公式里把 1 月当上一年 13 月、2 月当上一年 14 月,原因是为了让月份参与计算时保持某种规律,否则结果会偏。写代码时这个判断必须在最前面。
第二,闰年百年规则。能被 4 整除的年份是闰年,但整百年份必须能被 400 整除才算闰年。所以 2000 年是闰年,1900 年不是闰年,2100 年也不是闰年。测试用例里一定要带一个 1900-02-29 的非法用例,很多新手在这里翻车。
第三,格里高利历的适用范围。蔡勒公式默认采用的是格里高利历规则,如果拿去算 1582 年 10 月之前的历史日期,理论上会有偏移。真碰到这种需求,别硬用公式,去找历史的儒略历算法。日常业务里 99.9% 的场景都落在现代日期范围内,不必过度担心。
4. 农历、节气与黄历信息:给日期附上“人文上下文”
4.1 为什么公历解析还不够
公历日期是客观的、线性的,但人对日期的感知往往是“文化”的。“2026年09月20日”在公历里就是普通一天,但如果要问“农历几月几号”“是不是某个节气”“宜不宜搬家”,光靠公历算不出来。
中国用户很常见的需求是:生日按农历过、长辈挑吉日要看黄历、节日要看二十四节气。所以我想让这个工具不只输出一个干巴巴的 ISO 日期,而是把农历、节气、宜忌一起带出来。这一步本身不复杂,难点在选对农历库。
4.2 两个主流Python农历库的选型对比
我用过的农历库主要有两个,各有优缺点。
lunar_python是纯 Python 实现,API 是中文风格,直接调用Solar.fromYmd()然后getLunar()就能拿到农历对象,宜忌、节气、干支都有现成方法,写业务代码非常顺手。适合做 Web 服务、脚本工具这种开发效率优先的场景。
sxtwl基于寿星天文历的数据,底层是 C++,处理速度快,数据覆盖面大,适合高并发或者要遍历几千年的场景。但它的 API 更底层,字段名偏“天文历”风格,上手成本高一点。
| 对比项 | lunar_python | sxtwl |
|---|---|---|
| 语言 | 纯 Python | C++ 扩展 |
| 中文API | 友好 | 一般 |
| 性能 | 够用 | 更优 |
| 文档 | 中文文档较清晰 | 需要看 README |
| 适合场景 | 业务脚本、Web服务 | 高并发、大规模计算 |
如果只是给自己做个提醒小工具,我推荐lunar_python。安装命令:
pip install lunar-python4.3 实际接入:公历转农历、节气判断与宜忌输出
用lunar_python把“2026-09-20”转成农历,核心代码很短:
from lunar_python import Solar solar = Solar.fromYmd(2026, 9, 20) lunar = solar.getLunar() print("干支年:", lunar.getYearInGanZhi()) print("农历月:", lunar.getMonthInChinese()) print("农历日:", lunar.getDayInChinese()) print("宜:", lunar.getDayYi()) print("忌:", lunar.getDayJi())注意一点:不同版本的农历库,同一日期的农历输出可能差一天。这不是库写错了,而是农历的“时区边界”和“历算数据版本”在不同实现里可能有差别。所以我在这里不贴死运行结果,建议你本地跑一次,以实际输出为准,这个习惯在历法数据处理里非常重要。
节气判断时,日期没落在节气当天,接口返回空值是正常的。黄历宜忌的数据来源是传统通书,属于民俗约定,我只把它当展示数据用,不构成任何决策建议。如果要做严肃的择日参考,还是得找专门的正版历书数据源。
5. 从倒计时到日历事件:把目标日期接入自动化提醒
5.1 倒计时计算的两个时区陷阱
算“还有多少天到2026年09月20日”看起来只是一次减法,但时区问题能让你悄悄差出一天。
最直观的错误写法是这样:
from datetime import date days_left = (date(2026, 9, 20) - date.today()).days这段代码在本地跑没问题,但部署到云服务器或云函数后,date.today()取的是服务器时区的日期。如果服务器是 UTC 时间,而你在北京时间下午 8 点之后运行,UTC 还是同一天,倒计时就会感觉“变慢了”;反过来,UTC 已经跨天而本地还没跨天时,又会“提前一天”。
正确做法是明确指定目标时区,用zoneinfo拿到本地时间,再取日期:
from datetime import date, datetime from zoneinfo import ZoneInfo today = datetime.now(ZoneInfo("Asia/Shanghai")).date() target = date(2026, 9, 20) days_left = (target - today).days print(f"距离 {target.isoformat()} 还有 {days_left} 天")这种按天粒度的计算,核心原则是“先定时区,再取日期,最后做差”。顺序反了,结果就是错的。
5.2 生成ICS日历事件:别人可以直接导入日历
倒计时算出来只是第一步,真正方便的是把这个日期变成一个标准的日历事件文件,这样同事、家人可以直接导入 Outlook、Google Calendar 或苹果日历。
ICS 文件本质是纯文本,自己拼也不难。我写了一个很简洁的生成函数:
from datetime import date, datetime, timedelta def make_ics(event_title, start_date, end_date=None): if end_date is None: end_date = start_date + timedelta(days=1) dt = start_date.strftime("%Y%m%d") dt_end = end_date.strftime("%Y%m%d") lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//date-title-tool//CN//", "BEGIN:VEVENT", f"UID:{start_date.isoformat()}@date-title-tool", f"DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}", f"DTSTART;VALUE=DATE:{dt}", f"DTEND;VALUE=DATE:{dt_end}", f"SUMMARY:{event_title}", "END:VEVENT", "END:VCALENDAR", ] return "\r\n".join(lines) ics_content = make_ics( "家庭聚会", date(2026, 9, 20) ) with open("2026-09-20-family.ics", "w", encoding="utf-8") as f: f.write(ics_content)注意DTEND我设置成了“次日”。全天的日历事件结束日期在 ICS 协议里必须用排他性日期写法,也就是说结束那天本身不算事件日。这个细节很多人第一次写都会搞错,导致日历上事件多显示一天。
5.3 接入飞书/邮件/定时任务
ICS 文件解决的是“存进日历”的问题,但你还得让工具在合适的时间主动提醒。我的方案是 cron 定时任务 + 消息推送。
先写一个提醒脚本,逻辑是每天检查一次,如果目标日期在 3 天以内就发一条通知:
from datetime import date, datetime from zoneinfo import ZoneInfo import requests def notify(title, text): # 替换成你自己的飞书机器人 Webhook webhook = "https://open.feishu.cn/open-apis/bot/v2/hook/你的密钥" requests.post(webhook, json={ "msg_type": "text", "content": {"text": f"{title}\n{text}"} }) target = date(2026, 9, 20) today = datetime.now(ZoneInfo("Asia/Shanghai")).date() days_left = (target - today).days if 0 <= days_left <= 3: notify("日期提醒", f"距离 2026年09月20日 还有 {days_left} 天") elif days_left < 0: notify("日期提醒", "目标日期已过")然后加一条 crontab,每天早上 9 点跑一次:
0 9 * * * cd /opt/date-title-tool && python remind.py >> /var/log/date-title.log 2>&1邮件提醒同理,把requests.post换成smtplib发信就行。这里不展开全部代码,核心思路和飞书一样:定时触发、条件判断、推送结果。
6. 部署、测试与踩坑记录:我这套小工具翻过的车
6.1 容器时区导致倒计时全部延误一天
第一次把这个工具部署到 Docker 容器时,我遇到了一个非常隐蔽的 bug:所有倒计时都差了一天。
排查了很久才发现,容器默认时区是 UTC,而容器里跑的业务逻辑全是按中国时区设计的。datetime.now()在容器里拿到的是 UTC 时间,计算出来的“今天”和宿主机上的“今天”不一致,导致目标日期在晚上 8 点之后会提前触发或者延后触发。
解决办法很简单,在 Dockerfile 里显式设置时区环境变量:
ENV TZ=Asia/Shanghai或者运行时挂载时间配置。这个教训后来被我写进了团队的部署规范里:凡是涉及“今天”逻辑的应用,启动时必须明确时区,不能依赖宿主机默认值。
6.2 农历库版本锁定:今天能用,明天依赖更新就崩
农历库的 API 变化比我想象中频繁。lunar_python在不同版本里,某些方法名和返回类型都有过调整;sxtwl也一样,安装包的小版本更新也可能带来行为差异。
我遇到过最尴尬的情况是:昨天还正常运行的脚本,今天一更新依赖,getDayYi()直接报AttributeError。后来我把所有依赖版本锁死,才彻底避免这类问题。
pip freeze > requirements.txt部署时用pip install -r requirements.txt安装,而不是每次都装最新版。如果哪天确实要升级,先跑一遍完整测试用例再上生产。
6.3 测试用例固化日期边界问题
日期处理最容易出bug,所以我给这套工具写了一批测试用例,专门覆盖正常日期、非法日期、闰年、中文数字格式。
| 输入 | 期望结果 |
|---|---|
2026年09月20日星期日 | 合法,2026-09-20,weekday=6 |
2026-09-20 | 合法,星期日 |
2026-02-30 | 抛异常,非法日期 |
2024-02-29 | 合法,星期四 |
1900-02-29 | 抛异常,1900不是闰年 |
2026年9月20号周日 | 归一化后同样是9月20日星期日 |
对应的 pytest 测试长这样:
import pytest from date_title import parse_chinese_date def test_parse_valid(): d, week_text = parse_chinese_date("2026年09月20日星期日") assert d.isoformat() == "2026-09-20" assert d.weekday() == 6 assert week_text == "星期日" def test_invalid_feb_30(): with pytest.raises(ValueError): parse_chinese_date("2026-02-30") def test_leap_year(): d, _ = parse_chinese_date("2024-02-29") assert d.weekday() == 3这些用例看着简单,但每一条都曾经是我踩过的真实坑。把边界固化下来之后,后面改正则、换农历库都不怕回归。
最后再分享一点个人体会:日期这种数据,看起来是技术领域里最基础的一层,但真正把它做成产品功能时,你会发现它同时牵扯着历法、时区、文化和通信协议。如果你只是偶尔想知道“2026年09月20日”是星期几,搜索引擎就够了;但如果你要把日期当成工具的一部分,那文本解析、时区处理、边界用例这三件事,越早安排妥当越好。我写完这套小工具,最大的收获不是代码,而是终于明白了为什么日期库会成为每个语言的标准配置。