1. 为什么盯上TimeandDate:静态历法页的抓取价值分析
做天文数据采集这个需求,最初并不是为了炫技,而是被一个很现实的问题逼的:我想做一套二十四节气提醒服务,需要未来五年内每个节气精确到分钟的北京时间。翻遍了手头的API,要么收费,要么返回结果只精确到天,要么干脆数据源是爬来的还不稳定。后来我注意到 TimeandDate 这类老牌天文历法网站,页面不复杂,数据覆盖年份跨度极大,而且最重要的是——它走的是静态HTML渲染,不需要执行复杂的JavaScript就能拿到完整数据,这对爬虫来说简直是教科书级的目标。
先说清楚为什么静态历法页比接口更适合自己动手:
- 接口数据确实规范,但通常需要申请密钥,有配额限制,商用还要付费。
- 动态页面(Ajax/JS渲染)虽然也能爬,但要处理XHR请求、可能带签名参数,维护成本高。
- 静态页面(HTML直接包含数据)只需要按URL请求,解析HTML表格就行,几乎没有隐藏逻辑。
TimeandDate 的节气页面结构我观察过:它没有把整个网站包成SPA,节气表格是直接渲染在HTML里的,连表格分页都没有,一年的数据一次性给到。这意味着用最简单的 requests + lxml 就能搞定。
还有一个很容易被忽视的点:抓取静态页面的合规门槛更低。没有绕过登录、没有破解加密参数、没有高并发压力,纯粹是读取公开数据。如果你只是为个人项目采集,在合理频率下爬取,道义上和技术上都说得过去。爬虫这行当,挑软柿子捏不丢人,丢人的是拿无数代理去怼一个公告栏。
当然,选择这个站点还有一个专业层面原因:TimeandDate 的节气数据不仅包含日期,还精确到小时分钟,并且标注了时区,这对需要做时间换算的后续处理非常友好。对比国内一些日历站,很多只给"2024年3月20日 11:06" 这种不带时区的文本,而国际站点的标准化程度通常更高。
2. 动手前拆解:URL规律、请求头与时区陷阱
2.1 摸清URL参数
TimeandDate 的节气数据页面路径很有规律。以某年为例,访问时节气列表的URL大致长这样:
https://www.timeanddate.com/calendar/seasons.html?year=2024&n=123year参数不用多说,n参数表示地区/时区。这个n是站点内部的城市编号,会影响页面显示哪个时区的时间。我一开始没注意,以为抓到的就是UTC,后来和北京时间对比发现差了8小时,才意识到n控制的是展示时区。
如果要抓取"北京时间"的节气时刻,最省事的办法不是去反推n,而是直接抓默认时区数据,然后在本地程序里做时区换算。更省事的是在请求参数里显式带上n=236这类代表北京地区的编号(具体编号可以通过进页面下拉框看URL变化)。不过为了稳妥,我更推荐"抓原始+本地换算"的做法,因为页面一旦改了地区编号映射,爬虫就废了。
2.2 请求头伪装是基本盘
静态页面虽然好抓,但完全不设置请求头直接裸奔,被拒的概率不小。我自己用的标准请求头如下:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.timeanddate.com/calendar/", "Connection": "keep-alive", }不需要伪装得太复杂,但User-Agent一定不能是爬虫库的默认UA。有些网站会对默认UA直接返回403,TimeandDate 也不例外。另外,加Referer可以让请求看起来更自然,这是很多新手忽略的细节。
2.3 时区信息比想象中复杂
进入页面后,你会看到类似Mar 20, 2024 at 03:06 EDT的文本。这里EDT是美东夏令时,和UTC相差4小时。如果不做时区归一化,后面所有数据都是混乱的。所以我在解析时不会直接把字符串转成datetime,而是先把时区缩写提取出来,用pytz或zoneinfo做映射,转成UTC再转成目标时区。
这一步直接决定了"高精度"三个字能不能落地。节气时间如果准时区偏了,那连"当天"都可能搞错,更别提精确到分钟了。
3. 页面解析实战:从HTML表格中精准抽取节气字段
3.1 下载页面并用lxml解析
用requests把HTML拿回来后,我习惯先保存一份到本地做离线分析,避免反复请求网站。然后交给lxml.html解析:
import requests from lxml import html resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() doc = html.fromstring(resp.text)这里有个细节:页面编码通常是UTF-8,但保存到本地时最好resp.encoding = resp.apparent_encoding或者直接resp.encoding = "utf-8",防止Windows下用记事本打开乱码(别笑,我排查过半天)。
3.2 找到节气表格的位置
TimeandDate 的节气表格在页面中相对固定,浏览器F12看一眼就知道。通常是一个<table>,每一行是一个节气,列包括:节气名称、日期、时间、时区。我使用XPath定位:
# 找到包含 "Equinox" 或 "Solstice" 的表格行 rows = doc.xpath("//table[contains(@class, 'table--calendar')]//tr"):具体表格class可能会变,所以更稳妥的做法是先打印所有table的class,再动态匹配。我习惯打印前几个表格的text_content(),人工核对结构后再写定位。
3.3 解析行数据的完整示例
下面是一段简化的解析代码,配合注释看:
def parse_seasons(doc): seasons = [] # 通常节气行的第一个单元格不包含td而是th,或者td有较特殊的class rows = doc.xpath("//tr[td[contains(@class, 'season')] or th[contains(@class, 'season')]]") for row in rows: cells = row.xpath("./td | ./th") if len(cells) < 4: continue name = cells[0].text_content().strip() date_text = cells[1].text_content().strip() time_text = cells[2].text_content().strip() timezone_text = cells[3].text_content().strip() if not (name and date_text): continue seasons.append({ "name": name, "date": date_text, "time": time_text, "timezone": timezone_text, }) return seasons注意:不要用依赖列下标的绝对XPath,因为网站在不同年份可能插入其他列。我是先通过表头筛选,再按内容特征匹配,这样数据源微调时能少踩坑。
3.4 把文本组合成标准日期时间
拿到date和time后,我们会得到"Mar 20, 2024"和"03:06"这样的碎片。把它们拼成完整字符串,再按 "Mar" 这种英文月份缩写映射成数字月。我写了一个小函数:
from datetime import datetime MONTH_MAP = {m: i+1 for i, m in enumerate( ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])} def parse_date_time(date_text, time_text): # date_text like "Mar 20, 2024" month_str, day_str, year_str = date_text.replace(",", "").split() month = MONTH_MAP[month_str] day = int(day_str) year = int(year_str) hour, minute = map(int, time_text.split(":")) return datetime(year, month, day, hour, minute)这里有个容易忽略的点:节气页面显示的时间可能是当地时区的时间,所以你最终要结合时区信息做localize和转换。
4. 高精度的门道:时区归一化与跨源校验
4.1 时区归一化的标准姿势
我们目标是中国标准时间(CST,UTC+8),所以对抓到的每一个节气时刻,需要知道它来自哪个时区,再转换成UTC,再从UTC转换为北京时间。比如EDT是America/New_York的夏令时,CET是欧洲中部时间,等等。我用zoneinfo来做映射,因为它不需要额外安装包(Python 3.9+)。
from zoneinfo import ZoneInfo from datetime import datetime TIMEZONE_ALIASES = { "EDT": "America/New_York", "EST": "America/New_York", "CET": "Europe/Berlin", "CEST": "Europe/Berlin", "GMT": "Etc/GMT", "UTC": "Etc/UTC", "JST": "Asia/Tokyo", # 其他时区按需补充 } def to_beijing(dt, tz_alias): tz = ZoneInfo(TIMEZONE_ALIASES.get(tz_alias, "Etc/UTC")) localized = dt.replace(tzinfo=tz) beijing_tz = ZoneInfo("Asia/Shanghai") return localized.astimezone(beijing_tz)如果你不想维护时区缩写映射表,也可以直接用pytz的country_timezones猜测,但那会引入不确定性,不如手动维护一份常用映射来得稳。
4.2 跨源校验:不能光信一个站
爬虫最怕的是数据源自己出错,尤其节气这种需要高精度的数据。我拿 TimeandDate 抓到的数据,和两个独立数据源做过对比:
- 香港天文台的节气时间表
- 开源天文库
skyfield通过天文算法计算的节气时刻
skyfield需要下载行星历表,但计算精度非常高。我做校验时发现,TimeandDate 的数据与skyfield计算结果误差基本在1分钟以内。这里要注意,节气时刻本身是一个"瞬间",不同数据源由于舍入方式、时区基准不同,差一两分钟可以接受。
校验代码不复杂:
from skyfield.api import load, Topos from skyfield.almanac import find_discrete # 省略具体天文计算步骤,核心思路: # 1. 用TimeandDate的年份,计算该年太阳黄经为0/90/180/270度的时刻 # 2. 转换到北京时间 # 3. 与抓取结果对比实际对比时,我发现有些年份会有1分钟偏差,原因是页面显示的时间被四舍五入了。所以如果你要的是"精确到秒"的数据,静态网站并不是最佳选择,应该用天文库直接算;如果你要的是"分钟级"且覆盖面广,它足够用了。
4.3 二十四节气的完整性处理
TimeandDate 的seasons.html页面通常只包含春分、夏至、秋分、冬至这四个天文节点,并不是完整的二十四节气。这是个重要区别!我之前差点忽略了。它确实主要展示四季分界,而不是中国传统二十四节气。
那完整的二十四节气怎么办?标题里明确说"二十四节气天文数据采集器",所以不能只爬那个seasons页面。解决办法有两条路:
- 用
skyfield直接算完整二十四节气,把 TimeandDate 作为校验参照物。 - 爬 TimeandDate 其他日历页面,有些页面会列出节气,但可能不是每年都有。
我的选择是:用天文库计算出完整的二十四节气(春分、夏至、秋分、冬至只是其中四个),然后用 TimeandDate 的数据做交叉验证。这样既利用了静态页面的快速验证,又保证了数据的完整性。如果你不想引入天文库,也可以考虑抓取香港天文台的节气数据,但那属于另一个数据源,不在本文展开。
5. 数据导出:写出规范可用的xlsx文件
5.1 用pandas还是openpyxl?
数据量不大,用pandas加上openpyxl引擎导出最方便。pandas的DataFrame.to_excel一行代码就能写入。如果不想为了一个简单功能引入pandas的依赖,也可以用纯openpyxl操作单元格。我推荐 p 因为后续可能要做数据清洗和统计,pandas 顺手一些。
如果你在只有标准库的环境里跑,纯openpyxl也可以,工作量无非是手动写表头、逐行写值、调列宽。
5.2 导出一张清爽的节气表
字段设计上我最终定了这几列:
| 字段名 | 示例 | 说明 |
|---|---|---|
| season_name | 春分 | 节气中文名 |
| season_en | March Equinox | 英文名 |
| event_time_utc | 2024-03-20 03:06:20+00:00 | 标准UTC |
| event_time_beijing | 2024-03-20 11:06:20+08:00 | 北京时间 |
| source | TimeandDate | 数据来源 |
| verified | True | 是否与天文库校验通过 |
导出代码:
import pandas as pd df = pd.DataFrame(records) df["event_time_utc"] = pd.to_datetime(df["event_time_utc"]) df["event_time_beijing"] = pd.to_datetime(df["event_time_beijing"]) with pd.ExcelWriter("solar_terms_2024.xlsx", engine="openpyxl", datetime_format="YYYY-MM-DD HH:MM:SS") as writer: df.to_excel(writer, index=False, sheet_name="2024节气")5.3 批量抓取多年数据时的文件组织
如果一次抓10年的数据,我建议别把所有年份塞进一张表,而是每个年份一个sheet,或者一个sheet里带年份字段。前者适合Excel筛选,后者适合pandas统一处理。
我的做法是:抓完所有年份后,全部concat到一个DataFrame,加一列year,导出单一sheet。这样后续做时间序列分析、绘图都方便。同时保留一个rawsheet存原始文本,方便回溯排查。
6. 爬虫稳定性与反爬规避经验
6.1 频率控制与随机化
TimeandDate 这类网站没有很强的反爬机制,但频繁请求同样容易触发限流。我抓10年数据时,在每两个请求之间加了time.sleep(random.uniform(1, 3)),总耗时还不到一分钟。这个频率已经足够温和了。千万不要并发去抓,这种静态页面串行抓10年也就几十个请求,并发完全是自找封禁。
6.2 超时重试与页面结构变化
网络请求没有百分百成功的道理,我写了个带重试的下载函数:
import time import requests def fetch_html(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.text else: time.sleep(2 + attempt) except requests.RequestException: time.sleep(3) raise RuntimeError(f"Failed to fetch {url}")这个函数虽然简单,但能扛住偶发的网络抖动。页面结构变化时,解析函数会返回空列表或解析出异常数据,我专门写了个校验:节气数量必须>=24(如果用天文库算的),时间范围必须合法,否则就会报错。这样一旦网站改版,爬虫会立刻失败,而不是默默给出错误数据。
6.3 识别不同年份的页面差异
不同年份的节气列表可能有微调,比如增加某些列或调整顺序。我的解析代码不依赖固定列数,而是按内容特征匹配。比如用date_text里面是否包含年份、time_text是否匹配\d{2}:\d{2}作为边界条件。
还有一点:英文月份名在不同语言版本里不一样。如果你把站点的语言切换到中文,页面上的节气名就变成"春分"、"秋分"之类,数值没变但月份名会是"3月20日"。所以我抓取时固定用?lang=en或直接抓默认英文版,从源头统一格式。
6.4 关于robots.txt和道德边界
TimeandDate 的robots.txt对爬虫有约束,我记得它允许部分路径,但最好先去看一眼。我个人的态度是:只抓少量页面、不用于商业再分发、不高频请求,这是爬虫玩家的基本素养。如果你要把数据做成付费产品,请务必换成官方API或购买授权,别在爬虫上动商业脑筋。
7. 完整代码串联与扩展思路
7.1 一个可运行的迷你版本
下面我把从请求到导出的核心逻辑串成一个完整脚本。你可以直接把它作为骨架,再按需扩展。
import random import re import time from datetime import datetime from zoneinfo import ZoneInfo import pandas as pd import requests from lxml import html HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.timeanddate.com/calendar/", } TIMEZONE_ALIASES = { "EDT": "America/New_York", "EST": "America/New_York", "CET": "Europe/Berlin", "CEST": "Europe/Berlin", "GMT": "Etc/GMT", "UTC": "Etc/UTC", } MONTH_MAP = {m: i+1 for i, m in enumerate( ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])} def fetch_html(url, retries=3): for i in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text except requests.RequestException: pass time.sleep(2 + i) raise RuntimeError(f"fetch failed: {url}") def parse_date_time(date_text, time_text): cleaned_date = date_text.replace(",", " ").strip() parts = cleaned_date.split() if len(parts) != 3: raise ValueError(f"unexpected date: {date_text}") month = MONTH_MAP[parts[0]] day = int(parts[1]) year = int(parts[2]) hour, minute = map(int, time_text.split(":")) return datetime(year, month, day, hour, minute) def parse_page(html_text): doc = html.fromstring(html_text) seasons = [] rows = doc.xpath("//tr[td[contains(@class, 'season')] or th[contains(@class, 'season')]]") for row in rows: cells = row.xpath("./td | ./th") texts = [c.text_content().strip() for c in cells] if len(texts) < 4: continue name = texts[0] date_text = texts[1] time_text = texts[2] tz_text = texts[3] if not re.search(r"\d{2}:\d{2}", time_text): continue try: raw_dt = parse_date_time(date_text, time_text) except ValueError: continue tz_name = TIMEZONE_ALIASES.get(tz_text, "Etc/UTC") local_tz = ZoneInfo(tz_name) beijing_tz = ZoneInfo("Asia/Shanghai") localized = raw_dt.replace(tzinfo=local_tz) beijing_time = localized.astimezone(beijing_tz) utc_time = localized.astimezone(ZoneInfo("Etc/UTC")) seasons.append({ "season_name": name, "event_time_utc": utc_time, "event_time_beijing": beijing_time, "timezone_source": tz_text, }) return seasons def main(): all_records = [] for year in range(2020, 2026): url = f"https://www.timeanddate.com/calendar/seasons.html?year={year}" html_text = fetch_html(url) records = parse_page(html_text) for r in records: r["year"] = year all_records.extend(records) print(f"{year}: got {len(records)} records") time.sleep(random.uniform(1, 2)) df = pd.DataFrame(all_records) df.to_excel("solar_terms.xlsx", index=False, sheet_name="seasons") print("done") if __name__ == "__main__": main()这个脚本抓的是四季分界数据。若完整二十四节气,需要叠加天文计算逻辑,篇幅有限,但核心思路一致。
7.2 扩展思路:让采集器更有价值
只抓一堆节气时间放在Excel里,其实发挥不了多少价值。我的后续扩展方向:
- 自动生成提醒:把xlsx导入日历应用,或者用
schedule库在节气前N天推送消息。 - 关联其他天文数据:同一网站还有日出日落、月相数据,扩展解析函数即可。
- 绘制节气时间变化曲线:用
matplotlib画二十四节气在一年中的分布,能看到冬至日最早、夏至日最晚等规律。
我自己后来做了个节气推送机器人,每天检查距离下一个节气还有多少天多少小时,提前24小时提醒。数据就是这套采集器跑出来的,用着很踏实。
7.3 最后说点实在的
如果你只是为了拿节气数据,其实没必要非要爬虫。直接用skyfield或astral算更精准。爬虫方案最大的价值在于:当你需要"快速验证一个网站的数据能否用于生产"时,这套流程能帮你建立从页面解析、清洗、校验到导出的完整链路。同样的思路可以迁移到任何静态数据表页面。
另外,别忘了在代码里保留数据来源标记和抓取日期,这是专业数据工作者和随便玩玩的人之间的区别。至少我每次跑完脚本,都会在输出目录放一个README.txt,记录数据源URL、抓取时间和解析脚本版本。等几个月后再看数据,你会感激这个习惯。
我踩过的最大坑就是:刚开始爬的时候没做时区归一化,结果2023年的节气表在Excel里显示的时间和手机日历差了整整8小时,排查了一个多小时才发现是EDT的问题。所以,拿到任何带时区缩写的原始数据,第一件事就是存UTC副本,这是所有时间类爬虫的保命法则。