☰
Python从零实现中国农历与节日工具库:压缩表、节气与闰月处理
2026/10/16 16:45:48 网站建设 项目流程

简介:这份资源是面向Python开发者、日历应用与传统文化工具开发者的中国农历与节日实现工具库,适合需要处理公历农历转换、传统节日识别、二十四节气计算等场景的中级学习者。压缩包共123个文件,约918KB,以72个py源码文件为核心,辅以30个md文档、6张png示意图,以及yml、yaml、csv、toml、cfg等配置与数据文件,结构完整,便于直接阅读源码与查阅说明。资源围绕农历转换、农历信息查询、节日计算、节气计算、API设计、兼容性测试与社区维护等模块展开,csv数据文件可用于节日与节气数据校验,md文档则记录了开发与变更说明。目前已有162人学习下载,读者可借此掌握农历库的调用方式与实现思路,快速集成到日历、提醒、文化教育等应用中,同时理解开源库的目录组织与测试维护方法。

1. 农历转换这件事,为什么自己写一遍比调库更值

每年一到春节、中秋、端午前后,后台总会冒出同一类需求:把公历日期转成农历、算出当天对应的节日、判断今年是不是闰月、给活动页挂一个「距离除夕还有 X 天」的倒计时。很多开发者第一反应是找个现成库 pip 装上就完事,结果真到线上才发现——节气算错一天、闰月判断翻车、节日表里混进了洋节、跨年边界直接抛异常。这类问题不是库不行,而是农历本身是一套「天文历法 + 查表」的混合系统,你不理解它的数据结构,就永远只能当黑匣子用。

这篇笔记讲的就是用 Python 从零搭一个中国农历与节日实现工具库:核心是农历与公历的双向转换、二十四节气、传统节日映射,以及闰月处理。适合两类人——一类是要在业务里做日期展示、活动倒计时、日历组件的后端或前端工程师;另一类是刚接触历法计算、想搞明白「为什么农历不能纯公式算」的新手。我会把数据表怎么组织、转换算法怎么落地、参数怎么调、坑在哪,一步步拆开,代码可以直接抄去改。

2. 农历数据到底怎么存:一张压缩表撑起两百年

农历转换的难点从来不是算法,而是数据。公历是纯数学的,闰年规则一句话说清;农历是「朔望月 + 回归年」的阴阳合历,每个月长度 29 或 30 天,闰月插在哪一年、插在哪个月,全靠天文观测结果,没有简洁公式能推。所以工程上的通行做法是:把一段年份的农历信息预先编码成整数表,运行时查表 + 位运算解码。

2.1 为什么是「压缩整数表」而不是 JSON

常见做法是给每一年分配一个十六进制整数,用二进制位段存这一年的关键信息。以 1900 到 2100 这个常用区间为例,每年一个整数,200 个整数就能覆盖两百年,体积小到可以直接内嵌进源码,不依赖外部文件。相比每年存一个 JSON 对象(闰月月份、每月大小、节气时刻),整数表读取快、无 IO、便于版本管理。

一个典型的编码方案是这样分配的:低位 4 位存闰月月份(0 表示无闰月),接着 12 或 13 位存每个月的天数(1 表示 30 天,0 表示 29 天),高位存该年正月初一对应的公历日期偏移或节气基准。不同实现位段划分略有差异,但思路一致——把「查表」变成「位运算」。

# 农历年份压缩表(示意,仅展示结构与解码逻辑,非完整两百年数据) # 每个整数编码一年的农历信息: # 低 4 位 : 闰月月份,0 表示该年无闰月 # 中间 13 位: 从正月起每个月的天数,1=30天,0=29天 # 高位 : 该年正月初一距基准日的天数偏移 LUNAR_INFO = [ 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, # 仅示例若干年 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, ] def leap_month(year_index): """返回该年闰月月份,0 表示无闰月""" return LUNAR_INFO[year_index] & 0xf def leap_days(year_index): """闰月天数:闰月为 30 天返回 30,否则 29""" if leap_month(year_index) == 0: return 0 return 30 if (LUNAR_INFO[year_index] & 0x10000) else 29 def month_days(year_index, month): """返回某农历月的天数,month 从 1 开始""" return 30 if (LUNAR_INFO[year_index] & (0x10000 >> month)) else 29

这段代码的关键在三个位运算:& 0xf取出低 4 位拿闰月月份;& 0x10000判断闰月本身是 29 还是 30 天;0x10000 >> month逐位右移,判断第 month 个月是 30 天还是 29 天。参数上,year_index是相对基准年(比如 1900)的偏移,不是公历年份本身,这一点新手最容易搞混——传 2024 进去会直接越界。

提示:压缩表的年份区间一旦确定就不能随意扩展,因为高位偏移是相对基准日累加的。要扩到 2100 年以后,得重新生成整张表并校准基准日。

2.2 基准日与偏移量:所有转换的锚点

有了每年的月长信息,还需要一个绝对锚点才能和公历对上。通常选一个已知的农历正月初一对应的公历日期作为基准,比如 1900 年 1 月 31 日是农历 1900 年正月初一。之后所有计算都围绕「距基准日多少天」展开:公历转农历,先算目标公历距基准日的天数,再逐年减去农历年的总天数,定位到具体年份,再逐月减,定位到月和日。

from datetime import date BASE_DATE = date(1900, 1, 31) # 农历1900年正月初一 def lunar_year_days(year_index): """计算某农历年的总天数""" total = 0 for m in range(1, 13): total += month_days(year_index, m) total += leap_days(year_index) # 有闰月则加上闰月天数 return total def solar_to_lunar(solar: date): """公历转农历,返回 (农历年, 月, 日, 是否闰月)""" offset = (solar - BASE_DATE).days year_index = 0 # 逐年扣减,定位农历年 while year_index < len(LUNAR_INFO): days = lunar_year_days(year_index) if offset < days: break offset -= days year_index += 1 # 逐月扣减,定位农历月 leap = leap_month(year_index) is_leap = False month = 1 while month <= 12: # 闰月插在 leap 月之后,需要单独判断 if leap and month == leap + 1 and not is_leap: days = leap_days(year_index) is_leap = True else: days = month_days(year_index, month) if offset < days: break offset -= days if is_leap: is_leap = False month += 1 else: month += 1 day = offset + 1 return 1900 + year_index, month, day, is_leap

逻辑说明:外层 while 逐年扣减,offset落到哪一年就停;内层 while 逐月扣减,遇到闰月时先处理闰月再回到正常月份序列。参数offset是距基准日的整数天数,is_leap标记当前定位到的是不是闰月。这里有个容易翻车的点——闰月的插入位置是「闰 X 月」表示 X 月之后的那个重复月,所以判断条件是month == leap + 1,而不是month == leap。写错这一处,闰年所有日期整体偏移一个月。

2.3 反向转换:农历转公历的累加法

反向转换思路对称:从基准日开始,先加上目标农历年之前所有年的天数,再加上目标月之前所有月的天数,最后加上日偏移,得到距基准日总天数,再换算成公历日期。

from datetime import timedelta def lunar_to_solar(lunar_year, lunar_month, lunar_day, is_leap=False): """农历转公历""" year_index = lunar_year - 1900 total = 0 # 累加之前所有农历年的天数 for i in range(year_index): total += lunar_year_days(i) # 累加目标年之前所有月的天数 leap = leap_month(year_index) for m in range(1, lunar_month): total += month_days(year_index, m) if leap and m == leap: total += leap_days(year_index) # 闰月在 m 月之后,补上 # 如果目标就是闰月本身,还要加上正常月的天数 if is_leap and leap == lunar_month: total += month_days(year_index, lunar_month) total += lunar_day - 1 return BASE_DATE + timedelta(days=total)

参数is_leap决定目标日期落在闰月还是正常月。注意累加逻辑里,闰月天数是在遍历到m == leap时补进去的,因为闰月排在正常 leap 月之后。如果目标本身就是闰月,还要额外加上正常 leap 月的天数。这两处顺序错了,转换结果会差 29 或 30 天,属于典型的「差一天」血泪经验。

3. 二十四节气与节日映射:别用固定日期硬编码

农历转换跑通之后,下一个需求几乎必然是节气和节日。很多人图省事,直接把「清明 4 月 5 日」「冬至 12 月 22 日」写死,结果遇到闰年或特殊年份就翻车。节气和节日是两套不同的机制,得分开处理。

3.1 节气:用天文近似公式而非查表

二十四节气本质是太阳在黄道上的视位置每走 15 度一个节点,属于天文计算。工程上常用一个近似公式:以某个基准年的节气时刻为起点,加上回归年长度的累积偏移,再对 24 取模。常见做法是用一个「节气分钟偏移表」配合年份修正。

# 二十四节气名称,从立春开始 SOLAR_TERMS = [ "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", "立夏", "小满", "芒种", "夏至", "小暑", "大暑", "立秋", "处暑", "白露", "秋分", "寒露", "霜降", "立冬", "小雪", "大雪", "冬至", "小寒", "大寒", ] # 以 1900 年为基准的节气分钟偏移(示意,实际需完整 24 项) TERM_MINUTES = [0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, 173149, 195551, 218072, 240693, 263343, 285989, 308563, 331033, 353350, 375494, 397447, 419210, 440795, 462224, 483532, 504758] def solar_term_date(year, term_index): """计算某年某节气的公历日期,term_index 从 0 开始""" # 基准:1900年1月6日2时5分 对应小寒附近,此处用简化偏移 base = date(1900, 1, 6) minutes = TERM_MINUTES[term_index] + 525948.76 * (year - 1900) return base + timedelta(minutes=minutes)

逻辑说明:TERM_MINUTES存的是每个节气相对基准时刻的分钟偏移,525948.76是回归年长度(分钟)。参数term_index对应节气序号,year是公历年份。这个近似公式在 1900 到 2100 区间误差通常在一天以内,够业务用;如果要精确到分钟,得引入更复杂的太阳黄经计算。注意base的选取要和偏移表配套,换一套表就得换基准,否则整体偏移。

3.2 传统节日:分「农历固定」和「公历固定」两类

节日映射的坑在于不能一刀切。春节、端午、中秋、重阳、除夕是农历固定日期;元旦、劳动节、国庆是公历固定日期;清明既是节气又是节日,得走节气计算;母亲节、父亲节是「某月第几个星期几」,属于规则型。用一个统一的节日表,按类型分派计算逻辑,才不会漏。

# 节日定义:类型 + 参数 FESTIVALS = [ {"name": "春节", "type": "lunar", "month": 1, "day": 1}, {"name": "元宵节", "type": "lunar", "month": 1, "day": 15}, {"name": "端午节", "type": "lunar", "month": 5, "day": 5}, {"name": "中秋节", "type": "lunar", "month": 8, "day": 15}, {"name": "重阳节", "type": "lunar", "month": 9, "day": 9}, {"name": "元旦", "type": "solar", "month": 1, "day": 1}, {"name": "劳动节", "type": "solar", "month": 5, "day": 1}, {"name": "国庆节", "type": "solar", "month": 10, "day": 1}, {"name": "清明节", "type": "term", "term_index": 4}, ] def get_festival(solar: date): """返回某公历日期对应的传统节日名,无则返回 None""" ly, lm, ld, is_leap = solar_to_lunar(solar) for f in FESTIVALS: if f["type"] == "lunar": # 闰月不算节日,避免闰五月初五被误判为端午 if not is_leap and lm == f["month"] and ld == f["day"]: return f["name"] elif f["type"] == "solar": if solar.month == f["month"] and solar.day == f["day"]: return f["name"] elif f["type"] == "term": if solar == solar_term_date(solar.year, f["term_index"]): return f["name"] return None

参数说明:type决定走哪条分支,lunar用农历月日匹配,solar用公历月日,term用节气序号。这里特意加了not is_leap判断——闰五月初五不是端午节,这是很多人忽略的边界。除夕更特殊,它是「农历腊月最后一天」,可能是二十九也可能是三十,得单独算,不能写死腊月三十。

3.3 除夕与倒计时的正确算法

除夕的日期取决于腊月是大月还是小月。正确做法是先定位到农历腊月,取该月天数,最后一天就是除夕。

def chuxi_date(solar_year): """计算某公历年的除夕对应公历日期""" # 除夕落在该公历年的农历腊月,需先找到对应农历年 ly, _, _, _ = solar_to_lunar(date(solar_year, 12, 31)) year_index = ly - 1900 # 腊月天数 days = month_days(year_index, 12) # 腊月最后一天即除夕 return lunar_to_solar(ly, 12, days)

逻辑说明:先通过年末日期反推农历年,再取该年腊月天数,最后一天就是除夕。参数solar_year是公历年份。倒计时则用(chuxi_date(year) - date.today()).days,注意跨年时目标年份要取下一个春节所在的公历年,否则会出现负数。

4. 避坑与排查:农历实现里最容易翻车的五处

这一章全是踩过的坑,按「现象 → 原因 → 解决」写,照着排查能省不少时间。

4.1 转换结果整体差一天

现象:公历转农历,所有日期都比预期少一天或多一天。原因:基准日选错,或者offset计算时用了(solar - BASE_DATE).days但基准日本身没算进去。解决:确认基准日对应的农历日期,用几个已知日期(如 2024 年春节是 2 月 10 日)反测,校准BASE_DATE和offset的加减。

4.2 闰月年份日期错乱

现象:闰年时,闰月之后的月份全部偏移。原因:闰月插入位置判断写成month == leap而非month == leap + 1,或者累加时漏加闰月天数。解决:用有闰月的年份(如 2023 年闰二月)测试,打印每月天数序列,确认闰月出现在正确位置。

4.3 节气日期偏差超过一天

现象:清明、冬至等节气算出来和日历差一两天。原因:近似公式的回归年系数不准,或基准时刻选取有误。解决:换用更精确的太阳黄经算法,或直接采用权威节气表做查表修正,业务场景下查表更稳。

4.4 节日表混入非传统节日

现象:情人节、圣诞节被算进「中国节日」。原因:节日表定义时没区分文化来源。解决:把节日表按「传统节日」「公历纪念日」「规则型节日」分类,传统节日只保留农历和节气来源的,避免语义混淆。

4.5 年份越界直接抛异常

现象:传入 1899 或 2101 的日期,程序崩溃或返回错误结果。原因:压缩表区间固定,越界后索引失效。解决:在入口处做年份校验,越界时明确抛ValueError并提示支持区间,而不是静默返回错误值。

注意:农历库的测试用例一定要覆盖闰年、闰月、跨年、节气边界这四类,普通日期测不出问题。

5. 进阶技巧:把工具库做成可验证、可扩展的形态

前面几章把核心转换和节日映射跑通了,但一个能长期用的工具库,还得解决「怎么验证结果对不对」和「怎么扩展到更多年份」这两个问题。这一章讲两个具体技巧。

第一个技巧是用已知锚点做回归测试。农历虽然复杂,但有很多公开的权威对照日期,比如每年春节的公历日期、几个固定节气的日期。把这些锚点整理成测试用例,每次改代码跑一遍,比人工核对靠谱得多。

# 回归测试锚点:公历日期 -> 期望农历 ANCHORS = [ (date(2024, 2, 10), (2024, 1, 1, False)), # 2024春节 (date(2023, 1, 22), (2023, 1, 1, False)), # 2023春节 (date(2023, 3, 22), (2023, 2, 1, True)), # 2023闰二月初一 (date(2025, 1, 29), (2025, 1, 1, False)), # 2025春节 ] def run_regression(): for solar, expected in ANCHORS: result = solar_to_lunar(solar) assert result == expected, f"{solar} 期望 {expected},实际 {result}" print("全部锚点通过")

逻辑说明:ANCHORS存的是「公历日期 → 期望农历元组」,run_regression逐个断言。参数上,期望元组是(年, 月, 日, 是否闰月)。这套测试能快速暴露基准日、闰月、边界三类问题。我一般会在每次调整压缩表或转换逻辑后先跑它,比翻日历快。

第二个技巧是给工具库加一层缓存。农历转换里lunar_year_days会被反复调用,逐年累加在批量转换时开销明显。用functools.lru_cache把年天数、月天数缓存起来,批量处理几千个日期时能快好几倍。

from functools import lru_cache @lru_cache(maxsize=None) def lunar_year_days_cached(year_index): total = 0 for m in range(1, 13): total += month_days(year_index, m) total += leap_days(year_index) return total

参数maxsize=None表示不限缓存条数,因为年份区间有限(两百年也就 200 条),不会撑爆内存。注意缓存函数必须是纯函数——只依赖year_index,不依赖外部可变状态,否则缓存会返回脏数据。

扩展年份区间时,别急着改代码,先确认新数据表的基准日是否和现有逻辑一致。如果新表换了基准,所有偏移计算都要跟着调,这时候回归测试就是你的后悔药。我自己的习惯是:任何涉及日期表的改动,先跑锚点测试,再跑一遍全年 365 天的往返转换(公历转农历再转回公历,看是否等于原日期),两个都过才提交。往返一致性测试能抓出绝大多数边界 bug,比单测几个日期管用。

最后说个选型上的判断:如果你的业务只需要展示农历日期和几个大节日,用现成库完全够,没必要自己造;但如果涉及节气精确计算、闰月业务规则、跨两百年以上的日期处理,自己掌握这套数据结构和算法,改起来才不被动。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询