最近想给练手的Python脚本找点像样的真实数据,翻来翻去发现还是NASA的公开API最合口味。注册一个免费Key,就能拿到每日天文照片、火星车拍摄记录、近地小行星轨道这类真实数据,而且请求和返回全是标准JSON结构,拿来练requests、JSON解析和pandas清洗再合适不过。这篇文章我把从读取到处理的完整过程捋一遍,包括怎么申请密钥、怎么读懂接口文档、怎么把嵌套字段拍平成一张表,最后还会专门讲几个文档里不会写但实际很容易踩的坑。如果你想把API数据真正落到Excel或DataFrame里,而不是停在“打印返回结果”这一步,这篇应该能帮你少走不少冤枉路。
1. 为什么选NASA公开接口当练手数据源
1.1 一套免费、真实、标准REST风格的天文数据
NASA的开放数据平台提供的是一整套按REST风格组织的HTTP接口,对个人开发者完全免费,只需要用邮箱申请一个API Key就能调用。它和网上那些“模拟接口”“假数据集”最大的区别在于:返回的是真实观测数据,具有真实数据特有的脏、乱、缺,这反而正是练习数据处理时最值钱的部分。
我当时大致对比了平台上的几个常用接口,适用场景很不一样:
| 接口 | 返回内容 | 典型字段 | 适合练习的点 |
|---|---|---|---|
| APOD | 每日天文图片 | date, title, url, explanation | 参数最少,返回结构最简单 |
| Mars Photos | 火星车拍摄照片元数据 | sol, camera, img_src, rover | 列表返回、枚举参数 |
| NEO Feed | 近地小行星飞掠数据 | near_earth_objects, diameter, velocity | 典型嵌套JSON,清洗练习好素材 |
| EPIC | 地球影像 | image, date, caption | 可做后续扩展 |
我自己走过一遍之后最大的感受是:真实接口的返回值里,字段缺失、类型不一致、结构变化都是常态。比如同一批近地天体数据中,某些小行星的“相对速度”字段就是没有,某些日期分组下可能一条记录都没有。这些在造假数据里永远碰不到,但在真实API里几乎每次都会遇到。把这些问题一个个解决掉,你对Python数据结构、异常处理、pandas的熟练度都会上一个台阶。
1.2 接口选型顺序:从APOD到火星车再到近地天体
很多新手拿到这个平台后第一反应是“我直接去调最复杂的接口”,结果被三层嵌套JSON直接劝退。我的建议是先按难度顺序来,建立正反馈之后再逐步深入。
第一个接口首选APOD,因为它的返回结构最简单:一个日期对应一个字典,直接就能看到“请求→响应→字段”这条完整链路。跑通之后你会立刻明白API Key在哪个位置、params参数怎么传、resp.json()到底把什么变成了Python对象。等这步熟练了,再去碰火星车照片的列表返回,最后挑战近地天体那种“字典套列表、列表套字典”的多层嵌套结构,就不容易懵。
如果一上来就调NEO Feed,你会发现光是搞明白数据结构就要花半天,而且很容易把“接口请求成功”和“数据真正可用”两件事混在一起。我见过不少人在这一步卡住,最后以为是自己代码写错,实际上是结构和预期对不上。所以接口选型顺序不是小事,它直接决定了你的学习曲线是平缓还是陡峭。
2. 申请密钥与读文档的准备工作
2.1 注册与拿Key时要注意的三个细节
NASA的开放平台注册是自助式的,流程不复杂:打开官网,找到生成API Key的入口,填一个邮箱和一段用途说明,就能拿到一串Key。虽然快,但有几个细节容易被忽略。
第一,用途说明不要只写“test”,尽量写具体一点,比如“用于个人学习Python数据处理”“用于开发一个每日天文图片展示项目”。平台虽然不会人工审核每一单,但规范填写能避免后续使用中遇到不必要的限制。
第二,Key生成后立刻复制保存,不要只留在页面上一关了之。我自己的习惯是单独建一个.env文件放好,不放进任何代码文件里。
第三,如果后面用同一个Key在多个项目里跑,建议为不同用途分别注册不同的Key。这样某个项目触发了限流或者Key泄露需要重置,其他项目不会跟着遭殃。这个经验是我踩过一次坑之后才总结出来的,一开始图省事全部用一个Key,结果一个批量任务把配额打满,其他脚本全部跟着报429。
2.2 读文档的正确姿势:先参数,后返回示例
NASA的接口文档字段非常多,如果你从头通读,很容易陷入细节出不来。我后来摸索出来的方法是只看三样东西:接口的Endpoint、参数表、“Example Response”示例,顺序不能反。
先确认Endpoint,这是请求的URL前缀。然后看参数表,重点关注哪些参数必填、哪些是枚举值、哪些参数之间存在互斥关系。比如火星车照片接口里,sol(火星日)和earth_date(地球日期)二选一即可,如果两个同时传,部分版本会报参数冲突;再比如camera参数不是随便填的,必须用文档规定的相机缩写,比如MAST、NAVCAM、FHAZ。
最后才看返回示例,而且要多看几遍。示例的作用是告诉你字段名是img_src而不是image_url,是is_potentially_hazardous_asteroid这种长命名而不是is_dangerous。这些命名细节在写解析代码时特别容易错,如果只凭感觉猜,猜错一个字段名整个解析就白写了。
2.3 把Key放进环境变量而不是代码里
把API Key写死在.py文件里是我见过最常见的安全隐患。这里说的不只是“别提交到仓库”,还包括本地脚本本身:万一脚本被拷贝、截屏、分享,Key就跟着泄露了。
我推荐的方式是放在环境变量里,配合.env文件管理。先在终端里做一次:
export NASA_API_KEY="你的key"然后Python脚本里这样读取:
import os API_KEY = os.getenv("NASA_API_KEY") if not API_KEY: raise RuntimeError("请先设置 NASA_API_KEY 环境变量")如果项目里已经装了python-dotenv,也可以在项目根目录建一个.env文件:
NASA_API_KEY=你的key然后在代码最上面加两行:
from dotenv import load_dotenv load_dotenv()这样做的好处很明显:脚本本身不包含任何密钥信息,换机器部署时只需要复制.env文件,代码库永远是干净的。如果你已经不小心把Key提交给了代码托管平台,不要以为删掉文件就没事了,应该立刻去后台把这个Key作废重置。
3. 把第一个真实请求跑起来:APOD与火星车照片
3.1 APOD:一天一张宇宙照片的完整请求代码
APOD(Astronomy Picture of the Day)应该是NASA所有接口里最“温柔”的一个,它每天返回一张天文图片和一段解释文字。请求代码非常简单:
import os import requests API_KEY = os.getenv("NASA_API_KEY") ENDPOINT = "https://api.nasa.gov/planetary/apod" params = { "api_key": API_KEY, "date": "2024-12-01" } resp = requests.get(ENDPOINT, params=params, timeout=10) resp.raise_for_status() data = resp.json() print(data)这里有两个习惯值得从一开始就养成。第一,参数一定要通过params字典传,而不是手动拼URL。虽然这种写法多打几行字,但好处是如果参数里含有&、=这类特殊字符,requests会自动帮我做URL编码,不会导致请求格式错误。第二,resp.raise_for_status()必须养成条件反射,它能帮你在HTTP 404、429、500这类状态码出现时立刻抛异常,而不是拿到一个错误页面然后默默吞掉。
返回的data字典里主要这几个字段:date、title、url、hdurl、explanation。需要图片链接就取url或hdurl,需要文字说明就取explanation。我还习惯把每次的原始返回用json.dumps(data, ensure_ascii=False, indent=2)打印出来看一眼,确认字段名和类型跟文档一致,再开始写正式处理逻辑。
3.2 火星车照片:带枚举参数的查询与空列表判断
跑通APOD之后,可以升级到火星车照片接口。这个接口的Endpoint是:
ENDPOINT = "https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/photos" params = { "api_key": API_KEY, "sol": 1000, "camera": "MAST" } resp = requests.get(ENDPOINT, params=params, timeout=10) resp.raise_for_status() data = resp.json() photos = data.get("photos", [])这里返回的核心是一个photos数组,数组里的每个元素是一张照片的元数据,包括图片地址img_src、拍摄的相机camera、所属火星车rover等。
这个接口值得专门练一个习惯:永远先判断返回的列表是不是空的。NASA的火星车不是每一天的每一个相机都有照片,有些sol值下搜索结果是空列表,这是正常现象,不是请求出错。如果你一上来就写for photo in photos然后取photo["img_src"],当photos为空时什么都不会发生,代码也不会报错,但后续逻辑可能就静默跳过了,容易埋下隐患。
我处理时会先打印len(photos),再取第一张照片的完整JSON和所有字段名,确认当前搜索条件下的真实结构。这样能及时发现“这个相机在某个sol下没数据”,而不是让程序黑盒运行。
3.3 用循环拉取一个月的数据
单条请求跑通后,很自然的需求是“我想拉一个月的历史数据”。我就拿APOD做了个简单循环:
import time import json import os import requests API_KEY = os.getenv("NASA_API_KEY") ENDPOINT = "https://api.nasa.gov/planetary/apod" OUTPUT_DIR = "./apod_cache" os.makedirs(OUTPUT_DIR, exist_ok=True) start = "2024-11-01" end = "2024-11-30" # 生成日期列表 from datetime import datetime, timedelta d = datetime.strptime(start, "%Y-%m-%d") dates = [] while d <= datetime.strptime(end, "%Y-%m-%d"): dates.append(d.strftime("%Y-%m-%d")) d += timedelta(days=1) for date in dates: params = {"api_key": API_KEY, "date": date} try: resp = requests.get(ENDPOINT, params=params, timeout=10) resp.raise_for_status() data = resp.json() file_path = os.path.join(OUTPUT_DIR, f"{date}.json") with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) print(f"已保存 {date}") except requests.RequestException as e: print(f"请求失败 {date}: {e}") time.sleep(1)这段代码里最关键的是time.sleep(1)。NASA接口有明确的访问频率限制,连续快速请求很容易触发429状态码。每两次请求之间留出1秒间隔,既稳妥又不至于太慢。同时我把每次返回都存成独立的JSON文件,这样后面无论做分析还是重新处理,都不需要再发一遍请求,省下的配额可以用来做更多尝试。
到这里,“读取NASA数据”这条链路已经闭环了:单个请求、条件查询、批量循环拉取都能跑。但读取到的原始JSON离“能用的分析数据”还有一步,那就是处理好嵌套结构,这也是整个任务里最有技术含量的一部分。
4. 处理嵌套JSON:把近地天体数据变成一张规整表
4.1 near_earth_objects的返回结构到底长什么样
近地天体Feed接口返回的是某个时间段内所有接近地球的小行星记录,它的结构比APOD和火星车照片复杂得多。直接用请求拉下来之后,最外层的near_earth_objects是一个字典,键是日期字符串,值是该日期下所有小行星组成的列表。每颗小行星又是一个大字典,里面有name、estimated_diameter、close_approach_data等字段。
问题在于estimated_diameter本身还是字典,下面又按kilometers、meters、miles等单位各自嵌套了一层;close_approach_data则是一个列表,列表里可能有多次飞掠记录。这种“字典套列表、列表套字典”的结构,在真实业务数据里非常常见,比如订单、物流轨迹、用户事件都会有类似的层级。
我写代码前习惯先用标准库pprint把原始返回打印成结构化的文本,或者干脆把一整个响应保存到本地文件再仔细观察。这一步绝对不能省,处理嵌套JSON最大的成本就是“你以为的结构”和“实际结构”之间的偏差。
4.2 字段展开:从字典套列表到一行一条记录
我们的目标是把这种嵌套结构转换成一行一条记录的平铺列表,每列是一个标量字段。近地天体数据的展开逻辑如下:
import itertools rows = [] for date, objects in data["near_earth_objects"].items(): for obj in objects: rows.append({ "date": date, "name": obj.get("name"), "diameter_km": obj.get("estimated_diameter", {}) .get("kilometers", {}) .get("estimated_diameter_max"), "is_hazardous": obj.get("is_potentially_hazardous_asteroid"), "velocity_kph": obj.get("close_approach_data", [{}])[0] .get("relative_velocity", {}) .get("kilometers_per_hour"), "miss_distance_km": obj.get("close_approach_data", [{}])[0] .get("miss_distance", {}) .get("kilometers") })这里有几个要点值得细说。
第一,取嵌套字段一定要用.get()而不是直接["key"],并且每层都要给一个合适的默认值。例如close_approach_data可能不存在,我给它默认[{}],这样再往下一层取.get("relative_velocity")时不会立刻抛IndexError或KeyError。
第二,estimated_diameter内部再套一层,必须连续两次.get()。这个链条写起来长,但换来的是代码在这种半结构化的真实数据面前足够扛揍。
第三,注意到这里取的是estimated_diameter_max而不是estimated_diameter_min,两者数值不同,选哪个完全取决于你的分析目标。是先确定口径再写代码,否则换口径的时候又要改一遍逻辑。
4.3 类型转换、缺失值处理与CSV导出
拿到rows列表后,接下来的常规操作是用pandas把它包成DataFrame,再做类型清洗:
import pandas as pd df = pd.DataFrame(rows) print(df.head()) print(df.dtypes)真实API数据经过上面的展开之后,字段类型几乎一定是不对的:diameter_km会变成字符串,is_hazardous是布尔值,日期是ISO格式字符串。手动转换一下:
df["date"] = pd.to_datetime(df["date"]) df["diameter_km"] = pd.to_numeric(df["diameter_km"], errors="coerce") df["velocity_kph"] = pd.to_numeric(df["velocity_kph"], errors="coerce")errors="coerce"的意思是:遇到无法解析的值就变成NaN,而不是整个报错退出。这一步非常重要,因为真实数据里总会混入个别空字符串、异常符号,如果不处理,一行脏数据就能让整段分析代码崩掉。
转换完成后可以做很多分析,比如看看本周有多少颗小行星被标记为“潜在危险天体”,或者统计直径中位数。最后导出:
df.to_csv("neo_2025_0101_0107.csv", index=False, encoding="utf-8-sig")加上utf-8-sig是为了让生成的CSV在Excel里打开时中文不乱码,这个细节是给非程序员同事用的,省得每次都要手动改编码。
5. 在限流边界上工作:并发、缓存与增量抓取
5.1 限流规则与幂等请求是这一切的前提
处理完单个时间段的近地天体数据之后,你很容易产生一个想法:把过去一年的数据全都拉下来慢慢分析。但这个需求背后隐藏着一个绕不开的现实,就是NASA接口有访问频率限制。
限流的具体数值在API文档里有明确说明,不同接口可能还不一样,所以不要依赖第三方博文里的数字,直接看申请页面上写的Rate Limits。一旦超过限制,接口会返回HTTP 429,raise_for_status()会直接抛异常。
限流这件事想明白之后,你会发现一个非常友好的特性:NASA的这两个数据接口是纯查询接口,同样的参数请求,返回内容永远一样。这就意味着请求是幂等的,幂等请求是实现缓存和增量的前提:既然重复请求拿到的数据一模一样,我为什么还要重复发请求浪费配额呢?我后来的策略非常简单:每拿到一条数据就先落盘保存,下次调试代码时先从本地读,真的没有缓存了再发请求。
5.2 用线程池控制并发,给请求加退避重试
单线程循环拉数据虽然稳,但效率确实低。比如拉一年的APOD数据,每天1秒间隔就是365秒,可以接受;但如果某个接口一天要拉几百条,就需要更快的方案。NASA限流是按时间窗口算的,不是按并发连接数算的,所以在配额允许的窗口内,用少量并发能显著提速,不会增加多少风险。
我用concurrent.futures实现了一个简单的并发拉取:
from concurrent.futures import ThreadPoolExecutor, as_completed import time def fetch_one(date): params = {"api_key": API_KEY, "date": date} for attempt in range(3): try: resp = requests.get(APOD_ENDPOINT, params=params, timeout=10) resp.raise_for_status() return date, resp.json() except requests.RequestException: time.sleep(2 ** attempt) # 简单退避 return date, None dates = [...] # 要拉取的日期列表 results = [] with ThreadPoolExecutor(max_workers=4) as pool: future_map = {pool.submit(fetch_one, d): d for d in dates} for future in as_completed(future_map): date, data = future.result() if data is not None: results.append((date, data)) print(f"完成 {date}")几点经验:max_workers=4就够了,不要因为想跑得更快就开到十几个,并发太高不仅更容易触发429,还会让日志乱成一团;2 ** attempt这种指数退避是应对429的基本策略,第一次失败等1秒,第二次等2秒,第三次等4秒;每个请求都要加超时时间,不然一个请求挂在网络黑洞里,整个脚本都会卡死。
5.3 磁盘缓存:实现“只抓缺的,不抓重复的”
上面的并发方案有一个问题:如果中途断网或者代码跑挂了,第二天重新运行,所有已下载的数据又要重新拉一遍,白白浪费额度。解决办法是加一层磁盘缓存。
核心逻辑其实只有三句话:请求前先看本地有没有这个文件,有就直接读;没有才发请求;发完请求立刻把结果写进磁盘。我用APOD举例:
import os import json CACHE_DIR = "./apod_cache" os.makedirs(CACHE_DIR, exist_ok=True) def get_apod(date): path = os.path.join(CACHE_DIR, f"{date}.json") if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) params = {"api_key": API_KEY, "date": date} resp = requests.get(APOD_ENDPOINT, params=params, timeout=10) resp.raise_for_status() data = resp.json() with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) return data有了这个函数之后,再跑一遍历史数据就变成了增量抓取:本地已有缓存的天数直接秒开,只有缺失的天数才会真正占用网络请求。我当时用这个方式把近地天体数据往前补了半年,中间断了好几次网,每一次重跑都从断点继续,不用重新来。
缓存的目录建议按接口分开,比如./cache/apod/、./cache/mars/、./cache/neo/,避免不同接口的同名文件互相覆盖。文件名里带上日期参数,比如2024-12-01.json,方便人眼判断哪些日期已经接近齐了。
6. 文档之外的真实踩坑记录
6.1 返回示例会骗人:字段缺值和变化才是常态
NASA接口文档里的“Example Response”只是某一时刻的样本,不代表所有时间段的数据都长那样。我实际拉近地天体数据时发现,有的记录里close_approach_data这个字段的值存在,但内部的relative_velocity缺失;有的记录里日期分组下没有任何小行星;甚至同一个接口在跨版本更新后,字段名都可能微调。
所以我的建议是:不要把文档当作唯一标准,而是把“实际抓到的第一条数据”当作第一手参考资料。写解析代码之前,先跑下面这段代码:
import json raw = json.dumps(data, ensure_ascii=False, indent=2) with open("sample_response.json", "w", encoding="utf-8") as f: f.write(raw) top_keys = list(data.keys()) print("最外层字段:", top_keys)把样本保存下来之后,你可以随时翻看,比反复回翻网页文档高效得多。后面每次脚本改版,我都先对比新旧两份样本的字段差异,再决定解析逻辑怎么兼容。这种习惯能替你挡掉大量“明明写在文档里,但真实数据里没有”的隐形坑。
6.2 密钥泄漏:一个仓库扫描事故的教训
我自己第一次写NASA数据脚本的时候,图省事,直接把API Key写在脚本里,然后顺手把整个项目推到了代码托管平台。没过多久就收到平台的密钥扫描预警,提示检测到疑似API凭据。那次虽然只是个人学习用的小仓库,但处理起来仍然很麻烦:撤销旧Key、重新生成新Key、清理提交历史。
从那以后我立了一条规矩:任何脚本里的密钥必须全部改为环境变量读取,并且在上传之前用工具检查一遍代码中是否包含API_KEY、SECRET、PASSWORD这类高危关键词。密钥一旦暴露,不管是不是学习项目,都应该第一时间按“已泄露”处理,而不是“应该没人会看到”。宁可多花五分钟走正规流程,也不要赌一个小概率事件。
6.3 UTC时间与本地时间的8小时错位
NASA的日期时间字段大多是UTC标准时间,格式类似2024-12-01T05:00:00Z。如果你在本地时区直接比较时间,会发现“今天的数据”总比预期少,或者日期边界偏移了8小时。
处理方式是把字符串解析成带时区的时间对象,再转换成自己的目标时区。简单示例:
from datetime import datetime utc_str = "2024-12-01T05:00:00Z" # 手动去掉 Z,替换成 +00:00 utc_time = datetime.fromisoformat(utc_str.replace("Z", "+00:00")) local_time = utc_time.astimezone() print(local_time)astimezone()在系统时区正确设置的前提下会自动转换,不需要我手动加8小时。这个处理看起来很小,但在做日期维度聚合、增量更新、前后端联调时,如果忽略它,很可能出现“数据少了一天”的诡异问题,排查起来相当费时间。
写到这里,如果你正准备上手这套接口,我最想叮嘱的一句话是:把第一次请求成功的原始返回完整保存下来。无论后续写解析还是做清洗,有一份真实的样本JSON在手,几乎所有排查都变得有据可依。数据链路越往后越复杂,但只要你手里有一份可信的“现场证据”,每一步都能走得踏实。