这段时间我在折腾数据可视化练手项目,翻来覆去找公开数据源的时候,发现NASA开放API是个特别顺手的家伙。它不像很多商业接口那样要搞复杂的OAuth授权,也不像爬普通网站那样要跟各种HTML结构搏斗,它给的是干净的JSON,文档结构清晰,题材还自带吸引力——每日天文图片、火星车照片、近地小行星监测,随便挑一个都能做出像样的数据作品。
如果你正在学Python的数据抓取和清洗,或者想找一个真实、规范、免费的API项目来做练习,NASA这套接口是性价比很高的选择。这篇文章我会从实际使用的角度,讲清楚怎么申请访问权限、如何封装请求、怎么解析三种典型接口的数据,并把我在真实操作中遇到的坑一并写出来。内容不复杂,你会用requests和pandas就能跟着做,看完基本可以直接上手。
1. 项目概述:为什么拿NASA的API练手很值
1.1 这个项目解决什么问题
单纯学Python语法很容易陷入“懂了但不知道干嘛”的状态,而纯练爬虫又总是被反爬、加密参数搞得怀疑人生。NASA开放API刚好卡在一个很好的中间位置:它是真正的RESTful接口,有规范的鉴权方式、有明确的数据结构、有现实意义的数据内容,但又不设置高门槛。
你可以借这个项目一次性练到几件事:
- 用requests调用HTTP接口,理解URL、查询参数、响应状态码这些基础概念;
- 用JSON解析嵌套数据结构,学会从一堆层级不明的字典里稳妥地取字段;
- 用pandas把接口数据转成表格,做筛选、分组、统计;
- 用matplotlib做基础可视化,让数据“说话”。
整个流程下来,等于把数据工作中最常接触的“采集—清洗—分析—呈现”四个环节都走了一遍。
1.2 接口选型:我为什么选这三个
NASA开放API的目录里有不少接口,我实际挑选了三个特点各异的作为主线,分别对应三种典型场景:
| 接口 | 数据特点 | 核心练习点 |
|---|---|---|
| APOD每日天文图片 | 单条JSON记录,包含图片链接 | 参数请求、图片二进制下载 |
| 火星探测器照片 | 批量照片数据,含多个筛选维度 | 循环分页、嵌套字段解析、DataFrame统计 |
| 近地小行星数据 | 按日期分组的对象集合 | 日期参数、多层嵌套解析、简单可视化 |
这三个接口基本覆盖了日常数据开发的大部分形态:有返回单条记录的,有返回多条记录的,还有按时间维度分组的。选它们做练习,比死磕某一个接口要值得多,因为你学的是“通用的处理思路”,而不是某个接口独有的调用方式。
2. 环境准备与请求层设计
2.1 开发环境的依赖
这个项目的依赖很少,一个Python 3.9以上的环境就够了。建议你单独建一个虚拟环境,避免和系统环境互相污染。
pip install requests pandas matplotlibrequests负责发HTTP请求,pandas负责数据整理,matplotlib负责画图。如果你后面打算做更多数据分析,再补一个numpy也不迟,但目前这三个就够用了。
2.2 理解REST API的调用套路
NASA的接口和绝大多数公开API一样,遵循同一套规则:你给我一个HTTP请求,我给你一个JSON响应。
一个基本调用长这样:
import requests url = "https://api.nasa.gov/planetary/apod" params = { "api_key": "你的API Key", "date": "2024-05-20" } resp = requests.get(url, params=params) data = resp.json()这里有个容易忽略的细节:requests.get的params参数会自动把字典拼成URL后面的查询字符串,你不需要手动拼?api_key=xxx&date=xxx。写成字典的好处是参数多了以后代码依然清晰,也方便做条件组装。
响应的JSON就是Python字典和列表的组合,直接用索引就能取值。但真实数据往往嵌套很深,所以写代码时一定要有“逐层拆解”的意识,不要一上来就写data["a"]["b"]["c"]这种长链,很容易在某一层遇到KeyError。
2.3 封装一个带重试的通用请求函数
我在实际写这个项目时,没有每个接口都单独写一遍requests调用,而是封装了一个通用函数。好处是统一处理了API Key注入、超时、重试、限流这些脏活,后面每个接口的代码都清爽很多。
import time import requests NASA_API_BASE = "https://api.nasa.gov" API_KEY = "你的API Key" def nasa_get(endpoint, params=None, retries=3, timeout=30): url = NASA_API_BASE + endpoint if params is None: params = {} params["api_key"] = API_KEY for attempt in range(retries): try: resp = requests.get(url, params=params, timeout=timeout) if resp.status_code == 429: wait = int(resp.headers.get("Retry-After", 60)) print(f"请求过于频繁,等待 {wait} 秒后重试") time.sleep(wait) continue resp.raise_for_status() return resp.json() except requests.exceptions.Timeout: if attempt < retries - 1: wait = 2 ** attempt time.sleep(wait) continue raise except requests.exceptions.RequestException as e: print(f"请求失败: {e}") if attempt < retries - 1: time.sleep(2 ** attempt) continue raise return None这个函数做了三件事:
- 自动把api_key塞进参数里,你不需要每个接口都记得带;
- 对429限流做等待重试,读取响应头里的Retry-After作为等待秒数;
- 对网络超时做指数退避重试,第一次等1秒,第二次等2秒,第三次等4秒。
指数退避是请求外部服务时很实用的策略。服务器出现临时故障时,你连续快速重试反而可能加重问题,而等待时间逐步拉长,给了服务恢复的时间,也减小了你自己的请求压力。
2.4 注意:API Key的获取和限流
API Key需要在官网申请,流程很简单:打开API页面,填一个邮箱和名字,系统会给你发一封带Key的邮件,基本是即时到账。
拿到Key后,我建议设置一个环境变量来保存,而不是直接写在代码里。你可以写个小配置模块:
import os API_KEY = os.environ.get("NASA_API_KEY", "你的备用Key")这样做的好处是代码提交到Git仓库时不会把Key泄露出去,纯属个人习惯,但长期来看是值得养成的。
另外要注意,用了demo级别Key虽然也能跑通接口,但限流比较严格。我实际用下来,连续请求几十次就可能触发429。所以批量拉数据时,循环里加点time.sleep(0.5)这类小间隔,能少很多麻烦。
3. 实战一:APOD每日天文图片抓取与归档
3.1 接口返回结构
APOD(Astronomy Picture of the Day)是NASA最经典的每日天文图片接口。请求一个日期,它会返回当天的图片信息、标题和一段说明文字。
响应结构大致是这样的:
{ "date": "2024-05-20", "title": "某张天文图片标题", "explanation": "一段很长的说明文字", "url": "https://apod.nasa.gov/apod/image/xxxx.jpg", "hdurl": "https://apod.nasa.gov/apod/image/xxxx_hd.jpg", "media_type": "image" }注意里面的media_type字段,它可能是image也可能是video,因为NASA偶尔会放视频。写抓取逻辑时必须先判断这个字段,不然直接把video链接当图片下载,会得到一堆没用的文件。
3.2 批量抓取与本地归档
我的目标是抓取一个日期范围内所有图片,保存到本地文件夹。日期范围用datetime的date对象来迭代,比手动数天数要可靠。
from datetime import date, timedelta def fetch_apod(day): data = nasa_get("/planetary/apod", {"date": day.isoformat()}) return data def download_apod_images(start_day, end_day, save_dir="apod_images"): import os os.makedirs(save_dir, exist_ok=True) current = start_day while current <= end_day: data = fetch_apod(current) if data is None: current += timedelta(days=1) continue print(f"抓取 {current}: {data.get('title')}") if data.get("media_type") == "image": url = data.get("hdurl") or data.get("url") filename = f"{current.isoformat()}.jpg" try: img_resp = requests.get(url, timeout=60) img_resp.raise_for_status() with open(os.path.join(save_dir, filename), "wb") as f: f.write(img_resp.content) print(f" 已保存 {filename}") except Exception as e: print(f" 图片下载失败: {e}") current += timedelta(days=1) time.sleep(0.5)这个循环里我特意在每次请求之间加了0.5秒的延迟。实际体验下来,如果完全不加停顿,连续请求几十次后很容易被限流,加了延迟之后,整个批量过程基本没再触发过429。
3.3 这个场景里的两个容易踩的坑
第一个坑是图片链接的域名偶尔会出问题。APOD的历史图片链接有时会指向一个已经不存在的路径,200状态码也未必能保证图片真的能显示。所以我在这里做了两层防护:先用try/except接住下载异常,同时判断media_type,从逻辑上排除非图片文件。
第二个坑是“当天”的界定。APOD的更新并不严格等于自然日零点,它通常在北京时间当天下午到夜间才更新当天的条目。如果你在清晨去抓“今天”的图片,很可能拿到的是昨天的数据。处理办法很简单:抓当前日期时,如果返回的date字段不是你要的那天,按旧数据跳过即可,不要把它硬存成当天的文件。
4. 实战二:火星探测器照片数据的筛选与分析
4.1 火星照片接口的筛选维度
火星探测器照片接口是我觉得NASA接口里最有意思的一个,它允许你按火星车、火星日、地球日期、相机类型等维度查照片。以好奇号火星车为例:
def fetch_mars_photos(sol, rover="curiosity", camera=None): endpoint = f"/mars-photos/api/v1/rovers/{rover}/photos" params = {"sol": sol} if camera: params["camera"] = camera data = nasa_get(endpoint, params) return data.get("photos", [])请求参数里的sol是火星日,指火星车在火星上度过的天数,从着陆那天算起。更常用的可能是earth_date,也就是对应的地球日期。但这两个参数不能同时传,接口文档里明确说它们是互斥的,同时传通常会报参数错误。
camera参数可以筛选特定相机,好奇号上有多个相机:导航相机、桅杆相机、化学相机等,每种拍摄的图片风格完全不同。如果你对数据进行了筛选,字段值不对会导致结果为空,这时候先检查一下相机名称缩写是否拼对。
4.2 多Sol循环拉取与数据整理
单次请求返回的照片数量是有限制的,我记得常规返回的条目通常不超过25条左右。想拿更长时间段的数据,就得按时间维度循环去拉。
我写了一个循环:遍历连续的火星日,把每张照片的关键信息抽出来,统一放到列表里,最后再用pandas转成DataFrame。
import pandas as pd def collect_mars_photos(start_sol, end_sol): all_rows = [] for sol in range(start_sol, end_sol + 1): photos = fetch_mars_photos(sol) for p in photos: all_rows.append({ "id": p["id"], "sol": p["sol"], "earth_date": p["earth_date"], "camera": p["camera"]["full_name"], "img_url": p["img_src"] }) print(f"sol {sol}: 获取到 {len(photos)} 张照片") time.sleep(0.3) return pd.DataFrame(all_rows) df = collect_mars_photos(1000, 1009)这里有个细节值得多说一句:p["camera"]本身还是一个字典,里面有name、full_name等字段。我刚上手时图省事,直接用p["camera"]["name"],后来发现输出的是缩写,像NAVCAM_LEFT这种,做图表标签时很不友好。改成full_name之后就正常多了。
遇到嵌套字段时不要靠猜,可以先print(json.dumps(photos[0], indent=2, ensure_ascii=False))把结构完整打出来,再决定取哪一层。
4.3 用DataFrame快速统计
拿到DataFrame之后,数据就开始“活”了。我通常先看一眼整体情况:
print(df.info()) print(df.groupby("camera").size())groupby输出直接告诉你哪个相机拍的照片最多。我实测下来,导航相机类的照片数量明显多于其他相机,这和火星车实际工作方式是对应的——导航相机几乎是日常持续拍摄。
如果你还想做更深入一点的验证,比如看哪些日期照片数量最多:
date_counts = df.groupby("earth_date").size().sort_values(ascending=False) print(date_counts.head(10))这种多几步的聚合操作能帮你养成一个习惯:拿到任何数据集,先做基本的分布探查,而不是急着画图。分布对了,后面的分析才有意义。
5. 实战三:近地小行星数据解析与可视化
5.1 近地天体接口的数据结构
第三个接口是近地天体数据,返回结构比前两个复杂一些,但拆解起来更锻炼数据处理能力。
def fetch_neo(start_date, end_date): params = { "start_date": start_date.isoformat(), "end_date": end_date.isoformat() } data = nasa_get("/neo/rest/v1/feed", params) return data返回结果的顶层是near_earth_objects,它是一个字典,键是日期,值是该日期所有小行星对象的列表。每个对象里有名字、预估直径、是否具有潜在威胁、接近速度等字段。
这里要注意close_approach_data是数组,不是单个字典。虽然大多数情况下它只有一个元素,但你写代码时最好按数组来处理,万一以后接口返回多条接近记录,你的代码也不会崩。
5.2 日期窗口与参数细节
这个接口有个硬性限制:一次请求最多查7天的数据,而且结束日期不能晚于当前日期。我一开始没有细看文档,直接传了一个月的日期范围,接口返回400错误,查了文档才发现问题。
解决办法很简单,把时间窗口切成7天一段,循环请求再合并结果:
from datetime import timedelta def fetch_neo_range(start_day, end_day): all_rows = [] current = start_day while current < end_day: seg_end = min(current + timedelta(days=6), end_day) data = fetch_neo(current, seg_end) for day_str, objects in data.get("near_earth_objects", {}).items(): for obj in objects: approach = obj["close_approach_data"][0] diameter = obj["estimated_diameter"]["kilometers"] all_rows.append({ "date": day_str, "name": obj["name"], "diameter_km": (diameter["estimated_diameter_min"] + diameter["estimated_diameter_max"]) / 2, "hazardous": obj["is_potentially_hazardous_asteroid"], "velocity_km_s": float(approach["relative_velocity"]["kilometers_per_second"]), "miss_distance_km": float(approach["miss_distance"]["kilometers"]) }) current = seg_end + timedelta(days=1) time.sleep(0.5) return pd.DataFrame(all_rows)这段代码里我做了两个关键动作:直径取最小和最大估值的平均值,作为该天体的代表性直径;速度字段从字符串转float,因为接口返回的数值大多是字符串类型。不做转换的话,你后续做数学运算时会直接TypeError。
5.3 半分钟画一张散点图
数据整理完之后,可视化就是临门一脚。我习惯先做一个散点图,横轴是估计直径,纵轴是相对地球的接近速度,用颜色区分是否被标记为潜在危险天体。
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(10, 6)) scatter = ax.scatter( df["diameter_km"], df["velocity_km_s"], c=df["hazardous"], cmap="coolwarm", s=(df["diameter_km"] + 0.01) * 80, alpha=0.7 ) ax.set_xlabel("Estimated Diameter (km)") ax.set_ylabel("Relative Velocity (km/s)") ax.set_title("Near-Earth Objects") plt.tight_layout() plt.savefig("neo_scatter.png", dpi=150)点的大小我按照直径映射了一下,这样小行星越大,点在图上越明显。散点图价值在于一眼就能看出是否存在“又大又快的危险分子”,如果图上出现右上角深色的大圆点,那就值得多看两眼了。
matplotlib默认情况下对中文支持不友好,图例和标签直接用英文是最省事的做法。如果你确实需要中文,可以在代码里单独设置字体,那是另一个话题,这里就不展开了。
6. 常见故障排查与性能优化实录
6.1 HTTP状态码速查与处理建议
我在用这套接口过程中,几乎把常见的错误状态码都碰了一遍,这里整理成表格,你遇到问题时可以对着查:
| 状态码 | 含义 | 常见原因 | 处理办法 |
|---|---|---|---|
| 401 | 认证失败 | api_key没传或传错 | 检查参数里是否真的带了正确的Key |
| 403 | 禁止访问 | Key无效或IP被封了一段时间 | 换Key尝试,确认请求频率 |
| 404 | 地址不存在 | endpoint拼写错误 | 核对官方文档里的路径,别漏掉前缀 |
| 400 | 参数错误 | 日期格式不对、参数互斥、日期超过范围 | 按文档逐个核验参数 |
| 429 | 请求过于频繁 | 触发限流 | 等待Retry-After,或者降低请求频率 |
| 500/503 | 服务端异常 | NASA接口临时故障 | 用指数退避重试 |
一个比较隐蔽的排查技巧是:当状态码是401或403时,先确认你的API Key是否能正常访问首页。NASA在下发Key的时候偶尔会出现邮件丢了的情况,重新申请一次也未尝不可。另一个更隐蔽的情况是,某些网络环境下请求被中间层注入了一个假页面,状态码是200但JSON解析失败,这时候用print(resp.text[:200])看下响应头部,立刻就能知道拿到的是不是真正的JSON。
6.2 限流与缓存策略
NASA接口虽然比较慷慨,但不是无限制的。尤其是在批量抓取火星照片那种需要发几百个请求的场景里,限流是必然会发生的事。
我后来给自己定了三条规矩:
- 批量请求前先估算总请求数,超过百次就拆成多批次跑;
- 每两次请求之间至少间隔0.3到0.5秒;
- 已经抓过的数据写一个简单的本地缓存,避免重复请求。
本地缓存做起来很简单,比如照片数据可以保存成JSON文件,文件名里带上查询参数。下次请求之前先看看缓存文件是否存在,存在就直接读文件,不发HTTP请求。这个做法不仅节省你的请求额度,还能大幅缩短重复运行脚本的时间。对于纯学习场景,缓存的价值甚至超过接口本身的稳定性。
6.3 接口相关的几个数据坑
除了HTTP层面的问题,数据本身的坑也不少见。我遇到的比较有代表性的有三个。
第一个是日期时区问题。接口返回的日期字段大多是UTC格式,如果你的脚本按本地时间做了日期分割,在跨时区边界时会出现数据错位。解决办法是做一个统一的parse_day函数,把字符串转成date对象时明确时区,不要依赖操作系统的默认设置。
第二个是字段缺失。近地天体接口里每个对象的字段几乎一样,但极少数历史对象可能缺少某些字段,例如相对速度为空。我的习惯是取字段用obj.get("字段名", None),不要用obj["字段名"]直接索引。前者遇到缺失只返回None,顶多让单行数据为空;后者直接抛KeyError,整个循环都会崩。
第三个是火星照片的图片链接并非永久有效。我试过跑历史照片归档时,有几个img_src返回了404。所以批量下载图片时,代码里一定要有异常捕获和失败记录,我习惯把它们写进一个failed.txt,跑完脚本再决定要不要手动重试。
6.4 后续还能怎么扩展
这个项目练完,扩展方向其实很多。我自己接着做的是把一个定时抓取脚本挂到服务器上,每天自动拉取APOD图片和当天的近地天体数据,然后推送一个总结到自己的通知渠道。整个过程中用到的技能点,从定时任务到文件管理,又额外补了一遍。
如果你对数据分析更感兴趣,可以往更重的方向走:把历史火星照片数据积累起来,分析不同火星季节下照片数量的变化;或者把近地天体数据收集半年,做一张动态的轨道图。这些想法都不需要换工具,pandas和matplotlib基本都能撑住。
7. 几点自己的后续计划
这个项目做到后面,我觉得收获最大的其实不是某个接口本身的用法,而是“如何稳妥地跟一个不完美但可用的外部服务打交道”。API会挂、限流会触发、数据会缺失、字段会变,这些才是真实工作中天天面对的问题。
如果你也跟着做了一遍,建议你在跑通这几个例子之后,回头尝试一个自己的小任务:比如把你所在城市每天的气象数据存下来,或者定期抓某个公开数据源的TOP榜。数据源是什么不重要,重要的是把“请求—清洗—存储—展示”这条链路跑熟了,以后遇到任何新的公开API,你都能用一个小时左右的时间完成从读到用的全过程。
最后再分享一个实用小习惯:所有批量抓取脚本的输出,一定不要用print糊墙式的堆在屏幕上。把关键信息写到日志文件里,或者至少用print加上时间戳,否则脚本跑几百条数据之后,你很难定位到底是哪一步出的问题。这个习惯帮我省下的排查时间,远超写日志本身花掉的那点功夫。