
前阵子有人问我拿 Python 做万年历查询到底能玩出什么花。第一反应是calendar.monthcalendar输出一个公历月历但这只解决了最基本的需求一旦出现农历、节气、干支、传统节日这些元素标准库就全部抓瞎了。可话说回来只要目标是“Python 万年历查询”你就不能只做一个公历月历农历和节气才是中文语境下“万年历”的灵魂。这个项目很适合三类人刚学完 Python 基础、想找一个综合练手题的同学需要给内部工具加上日历功能的开发者还有纯粹对历法计算感兴趣、想搞清楚“农历到底怎么算”的爱好者。它不像爬虫项目那样依赖网络也不像算法题那样脱离实际做完之后你能同时收获日期处理、命令行工具设计、文本排版、数据驱动编程这几块经验。下面我从需求拆解开始把这套工具的设计思路、核心算法、完整实现和踩坑记录一次讲清楚。1. 需求拆解与技术选型先搞清楚“万年历查询”到底在查什么1.1 公历月历、农历信息与节日信息分别属于不同计算体系写代码之前先把需求列清楚。一个完整的万年历查询页面通常要回答几个问题今天或指定日期是公历几月几日、星期几对应的农历日期是什么有没有闰月当天有没有二十四节气当天是什么传统节日或公历节日这一年的干支纪年、生肖是什么。这些信息不是同一套东西。公历日期是纯数学规则一年 365 天或 366 天闰年每 4 年一次、百年例外、四百年又恢复规则固定程序员闭着眼睛都能写。星期本质上也是一个“求余数”的过程蔡勒公式、基姆拉尔森公式都能解决。但农历不一样农历的月份长度由月相决定一个朔望月约 29.53 天所以大月 30 天、小月 29 天怎么排列不是按“逢单月大、逢双月小”这种简单规则来的而是由天文观测推算出来的。闰月设置更复杂需要满足“无中气则置闰”的规则。节气也属于天文计算体系二十四节气本质上是太阳在黄道上的二十四个位置落到日期上不固定前后会差一两天。因此想做出一个像样的万年历查询程序第一步就是承认这是一个“多数据源拼装”问题不是一个简单的算法题。1.2 技术方案取舍标准库打底农历部分交给成熟开源库我见过不少人一上来就想自己实现农历算法把“抄数据表”和“写算法”对立起来。如果你只是为了练手完全可以自己维护一份从 1900 年到 2100 年的农历数据表然后写查表转换。但如果你是做一个要长期用的工具我非常不建议从零造轮子。农历数据的权威来源是天文台每年发布的《中国天文年历》以及历书数据这里面牵扯到太阳、月亮位置计算精度要求高普通开发者自己算根本不可行。更现实的路径是用一份结构化的农历数据表或者直接调用成熟的开源农历库。我最后采用的是“标准库 第三方农历库”的组合calendar负责公历月历矩阵、闰年判断datetime负责日期对象、星期计算、字符串格式化第三方农历库负责公历转农历、干支、生肖、节气、闰月信息节日和排版逻辑自己写因为这部分非常“主观”不同地区对节日的定义不一样。这个选型的核心逻辑是把“容易验证”的部分公历握在自己手里把“需要专业知识”的部分农历、节气交给验证过的数据源把“业务相关”的部分节日、显示规则留在业务层。这样项目结构清晰后期也容易替换底层农历库。1.3 分层结构数据、计算、展示三层分离按照上面的选型我把代码组织成三层。数据层负责获取每一天的原始信息包括公历、农历、干支、节气计算层负责判断节日、处理闰月、补充“第几个星期几”这类移动节日展示层只管排版输出不掺和任何历法运算。用一个简单的表格说明层级职责示例函数数据层获取公历和农历原始数据build_month_grid()、collect_day_info()计算层节日判断、闰月处理、生肖计算get_festival()、is_leap_month()展示层格式化输出、按周渲染、中文对齐render_week()、pad()这样做的好处非常明显。第一你可以单独测试每一层比如先把农历库的返回值打印出来核对再去做排版。第二如果以后想把命令行界面换成 Web 界面只需要替换展示层数据层和计算层完全不用动。第三也是最关键的万年历的“准确性”问题只集中在数据层排查范围被大大缩小了。2. 历法核心细节从星期、闰月到节气这些知识点决定了代码怎么写2.1 公历日期的长度与星期计算公历部分没什么玄学但有些细节值得写下来。判断闰年的标准写法是def is_leap_year(year): return (year % 4 0 and year % 100 ! 0) or (year % 400 0)很多人只知道“四年一闰”忘记百年和四百年的规则。比如 1900 年不是闰年2000 年才是闰年。这个判断是整个公历日期计算的地基错了后面全错。星期计算我建议直接用datetime.date.weekday()返回 0 代表周一6 代表周日。但如果你想真正理解星期的原理可以看一眼蔡勒公式def zeller(year, month, day): if month 3: month 12 year - 1 k year % 100 j year // 100 h (day (13 * (month 1)) // 5 k k // 4 j // 4 5 * j) % 7 return (h 5) % 7 # 0 表示周一这个公式本质上是在算“日期与某个已知星期之间的差值再取模”。理解了这一点你就会发现所有星期公式都逃脱不了取模运算区别只在于基准点选在哪里。实际项目中我还是会用标准库但我会把蔡勒公式留在注释里算是给未来看代码的人留一个“为什么星期可以这样算”的提示。2.2 农历不是“阴历”大小月与闰月怎么来很多初学者把农历叫“阴历”这其实不准确。真正的纯阴历只看月相一年 12 个朔望月约 354 天比回归年少了 11 天用不了几年月份就会和季节对不上。而农历为了让春节保持在冬末春初引入了二十四节气作为“阳历骨架”又通过闰月把阴历部分拉回正轨。所以准确说法是“阴阳合历”。农历一个月是 29 天还是 30 天取决于“朔”的时刻落在哪一天。所谓“朔”就是月亮运行到太阳和地球之间的时刻这一天定为初一。两次朔之间的间隔就是一个月约 29.53 天因此大小月交替出现但并不是固定 1、3、5 月大。闰月则是为了保证农历年与节气年同步当某个月里没有“中气”二十四节气里排在奇数位的十二个节气时这个月就会变成上一个月的闰月。这就是“无中气则置闰”。这套规则说起来简单真正写代码就麻烦了。你需要知道未来每个月的朔时刻和中气时刻这已经超出“能用一句 Python 表达式写完”的范畴。所以我不会在业务层手搓这个逻辑而是依赖农历库已经做好的数据表。只要知道底层原理你在排查“为什么某个闰月要这样放”的时候就不会一头雾水。2.3 干支纪元、生肖与二十四节气的关系干支纪年和生肖也是万年历常要展示的内容。这里有一个特别容易踩的坑干支纪年的起点到底是大年初一还是立春历法书上的看法并不完全统一。民俗上很多人习惯按农历正月初一算生肖更替比如 2024 年 2 月 10 日之后出生的孩子属龙。但命理学的干支纪年则是按立春为界立春之前还是上一年的干支。不同的农历库默认行为也不一样有的给getYearInGanZhi()有的给getYearInGanZhiByLiChun()如果你不做区分很容易出现“大年初一到立春之间显示错了生肖”的尴尬。我的建议是在项目里明确写清楚你采用哪种规则并在用户界面里注明。你不需要评价谁对谁错只需要把规则固定下来否则同一个日期在两个版本里显示不一样用户就会认为你的工具 bug 很多。二十四节气的日期计算同样属于天文范畴。一个节气在公历几月几日基本固定比如立春常在 2 月 4 日前后清明常在 4 月 5 日前后但具体到某一年的时刻会有波动。专业做法是用天文算法计算太阳黄经普通做法是查表。农历库通常已经把节气按日期整理成了字符串取用即可。2.4 节日算法固定日期、农历日期与“第几个星期几”三类节日的计算能直接反映一个开发者对问题分类的能力。我把节日分成三大类固定公历日期元旦 1 月 1 日、劳动节 5 月 1 日、国庆节 10 月 1 日固定农历日期春节 正月初一、元宵节 正月十五、端午节 五月初五、中秋节 八月十五移动公历节日母亲节是 5 月第二个星期日父亲节是 6 月第三个星期日感恩节是 11 月第四个星期四。前两类最简单写条件判断就行。第三类需要一点小逻辑def nth_weekday(year, month, weekday, n): # weekday: 0 是周一6 是周日n 表示第几个 first_day datetime.date(year, month, 1) first_weekday first_day.weekday() delta (weekday - first_weekday) % 7 target_day 1 delta (n - 1) * 7 return datetime.date(year, month, target_day)这个函数的思路是先找到这个月第一个目标星期几落在哪一天然后加上(n - 1) * 7。注意(weekday - first_weekday) % 7这个取模很关键它能自动处理“目标星期在当前月第一天之前”的情况。把节日分成三类之后你的节日判断函数会非常清晰。不用在一个if里面塞满几十个条件而是把三种规则分别处理最后合并结果。3. 实操实现命令行版 Python 万年历查询工具3.1 环境准备与工程结构我用的环境是 Python 3.10 以上版本除了标准库只需要安装一个农历库。以我用过的 lunar_python 为例安装命令是pip install lunar-python这个库的 API 设计得很容易上手先用Solar.fromYMD()构造一个公历日期对象再调用getLunar()得到农历对象。建议新建一个项目目录里面至少拆出两个文件lunar_calendar.py放核心逻辑cli.py放命令行入口。如果你嫌两个文件麻烦最简单的方式是先在一个文件里写完跑通再拆模块。我就是先写完一个密密麻麻的单文件脚本跑通所有功能后再慢慢拆分的。3.2 生成公历月历网格公历月历的“骨架”用calendar.Calendar生成最方便import calendar def build_month_grid(year, month): cal calendar.Calendar(firstweekday0) # 周一作为一周开头 weeks cal.monthdayscalendar(year, month) return weeksmonthdayscalendar返回的是一个二维列表每行是一个星期共 28 到 31 个日期。不在当前月份的日期用 0 填充。这个 0 非常有用它让你在渲染时不用额外判断“这个格子是不是空的”直接拿 0 去跳过就行。你可能会有疑问为什么不用monthrange加循环自己拼因为Calendar已经替你处理了跨月、周起始日、补零这些细节。少一行代码不是重点重点是这部分已经被标准库测试过不需要你重复造轮子。3.3 给每一天补上农历、干支、节气与节日标签月历网格有了接下来要做“信息叠加”。对每一天逐个查农历数据组装成一个“标签列表”from lunar_python import Solar def collect_day_info(year, month, day): if day 0: return None solar Solar.fromYMD(year, month, day) lunar solar.getLunar() # 农历库通常用负数表示闰月这里做一次兼容处理 is_leap lunar.getMonth() 0 lunar_month abs(lunar.getMonth()) lunar_day lunar.getDay() tags [] if is_leap: tags.append(闰 str(lunar_month) 月) festival get_festival(year, month, day, lunar_month, lunar_day, lunar) if festival: tags.append(festival) jieqi lunar.getJieQi() if jieqi: tags.append(jieqi) return { day: day, lunar_month: lunar_month, lunar_day: lunar_day, is_leap: is_leap, tags: tags, gz_year: lunar.getYearInGanZhi(), shengxiao: lunar.getYearShengXiao(), }注意lunar.getMonth()在不同库里返回负数还是正数没有统一标准。我的做法是做一个封装函数把“是否闰月”判断隔离在数据层。这样以后换库只需要改这一层上层展示逻辑不受影响。节气获取也不要直接裸调。我建议再包装一层把空字符串、None、空数组统一处理成空值避免展示层写一堆防御代码。3.4 中文对齐与表格输出的处理这个环节看着不起眼但非常容易翻车。Python 的len()对中文按一个字符计算但在终端里一个中文字符通常占两个英文字符的宽度。如果直接用str.ljust()对齐中文数字混排的日历会歪七扭八。我写了一个按“显示宽度”补齐字符串的小工具def display_width(s): return sum(2 if ord(ch) 0x7F else 1 for ch in s) def pad_to_width(s, width): space width - display_width(s) if space 0: space 0 return s * space这个函数把所有非 ASCII 字符按宽度 2 计算ASCII 字符按宽度 1 计算。渲染每一个格子的时候先算出这一格里最多能放几行字然后逐行补齐。实测下来在常见的等宽字体终端里中文对齐效果已经足够好了。如果你还想再讲究一点可以把“农历日”单独放一行缩写成“初一、初二、初三”这种中文数字而不是输出“1、2、3”。这样占用宽度更可控也更像真正的万年历。3.5 命令行入口与年份批量查询命令行入口很简单核心是参数解析import sys def main(): args sys.argv[1:] if len(args) 2: year int(args[0]) month int(args[1]) print_calendar(year, month) elif len(args) 1: year int(args[0]) for month in range(1, 13): print_calendar(year, month) print() else: today datetime.date.today() print_calendar(today.year, today.month) if __name__ __main__: main()如果你传一个参数就打印整年 12 个月的日历传两个参数就打印指定月份不传参数就打印当前月份。这个设计符合“万年历查询”这个直觉用户想知道哪年哪月就直接敲两个参数用户想看全年就只给年份。整年打印的时候月份之间加一个空行分隔即可。如果是在 Windows 终端里跑中文编码最好手动设置一下sys.stdout.reconfigure(encodingutf-8)否则容易遇到UnicodeEncodeError。4. 常见问题与排查技巧实录4.1 闰月信息丢失或显示错误我在第一次跑通时发现农历六月之后出现了一个“闰六月初一”但显示成了普通的“六月初一”。原因就是我没有处理负数月份直接把lunar.getMonth()拿去拼字符串了。排查步骤很简单先打印原始返回值看它到底返回了什么。如果你用的是负数表示闰月的库就把负数取绝对值然后在前面加“闰”字。同时还要注意闰月里的初一、十五这些日子的农历日期也许是正常的但“月”本身要特殊标记否则后续的节日判断、干支计算都可能错位。经验是永远不要去猜库的返回值格式打印出来看。把原始数据作为外层函数的输入把所有“方言”统一成自己的内部结构。4.2 节气判断踩到时间边界节气判断的坑比较隐蔽。有些节气具体发生时刻是在某一天深夜 23 点之后甚至跨到第二天的零点附近。如果你只按“日期等于节气日”来判断某些年份会差一天。这个问题在纯日期展示型工具里不太明显但如果你的工具被用来做“今日宜忌”这种强提示功能就会有人拿着权威日历来找你核对。处理办法是优先使用库提供的“精确节气时刻”接口而不是只拿日期字符串。如果你的需求确实只需要“当天是否节气”那么至少要在发布前选择一两年手工核验几个边界节气比如立春、冬至因为这两个日期的争议出现概率最高。4.3 第三方库版本差异导致 API 变化我最初参考的一份代码用旧版 API升级到新版之后发现原来的Lunar.fromSolar()调用变成了要先构建Solar对象再调getLunar()。这种升级导致我花了不少时间排查。第三方库的迭代频率虽然不算高但不能假设 API 永远不变。我在项目里加了一个简单的“适配层”把所有第三方调用都收进一个文件。换库或者升级时只需要修改适配层里的函数展示层完全不受影响。这套思路在任何依赖第三方生态的项目里都值得推广把不可控的依赖关在笼子里你的核心逻辑才能稳定。4.4 常见问题速查表现象可能原因处理方式农历月份显示为负数库用负数表示闰月取绝对值并单独标记闰月生肖从元旦就变了直接拿了公历年份用农历正月初一或立春作为生肖转换点节气个别日期对不上只比较日期未用精确时刻改用精确节气接口并手工核验边界中文日历对不齐使用len()计算字符串宽度用显示宽度函数补齐UnicodeEncodeError终端默认编码不支持中文设置 stdout 使用 UTF-8 编码月份开头出现奇怪的空白格monthdayscalendar补零没有处理把 0 当成空单元格跳过即可这些坑不是一次性踩到的有些是在不同年份的日期上反复验证时才暴露出来。我最后养成了一个习惯每年随便挑几个日期用权威日历网站交叉核对一遍尤其是二月、闰月、春节前后这三个区域。另外再提醒一个细节农历数据覆盖范围因库而异。有些库只覆盖 1900 到 2100 年超出区间会报错或返回空数据。如果你要做“真正万年历”最好在入口处做一次年份范围校验给用户一个明确提示而不是让程序抛出一个看不懂的异常。最后再说一点个人体会这个项目做完我最深的感觉是万年历查询的代码量其实不大真正值钱的是对历法规则的敬畏。公历随便写几行就能转起来农历和节气却需要数据支撑。很多看似“很简单”的需求背后都有一整套前人花了大量时间验证的数据模型。你在接一个需求的时候先别急着写第一行代码花十分钟把“哪些部分靠规则、哪些部分靠数据、哪些部分靠业务约定”想清楚后面能省出好几个晚上。如果你还打算继续扩展可以考虑三个方向一是增加公历农历互转输入农历日期反查公历二是做月相展示把每月十五、初一标记出来三是把命令行工具包成一个小型 Web 服务让别人通过接口也能查。每一步都不难但每一步都会让你对整个历法系统的理解更深一层。