本文介绍基于脚本,快速、批量下载 Earthdata 中遥感影像数据的方法。

最近,需要下载 MODIS 的 GPP 数据,时间跨度从 2000 年到 2024 年,时间分辨率为 8 天,覆盖全球陆地范围。数据来源选择的是 NASA LP DAAC 提供的 MOD17A2HGF v6.1 产品——这是 MOD17A2H 的 Gap-Filled(间隙填充)版本,在年末阶段对 FPAR/LAI 输入质量较差的像元进行了清洁处理,能有效消除云污染导致的伪异常,适合做长时序分析。
之前的几篇文章中,我们多次介绍过不同的遥感影像批量下载方法,包括基于浏览器插件、本地下载器、谷歌地球引擎GEE平台等;但是,一直都没介绍过基于脚本的下载方法——而基于脚本下载,可能反而是最简单、最快捷的方法。
因此,这篇文章记录一下用 Python 批量下载这批数据的思路和完整代码——只要是需要批量下载 Earthdata 数据的,都可以参考本文思路。大家可以直接将本文发给 Agent,让 AI 一键部署本文所需的环境与脚本,真的就是点点鼠标就能批量下载了。
本文以 MOD17A2HGF 数据为例来介绍(但 Earthdata 中的其他数据都可以用本文的方法)。MOD17A2HGF 是 Terra 卫星 MODIS 传感器生产的全球陆地总初级生产力(GPP)产品。

这一产品的主要参数如下:
项目 | 内容 |
|---|---|
产品名 | MOD17A2HGF v6.1 |
时间分辨率 | 8 天合成 |
空间分辨率 | 500 m(原生分辨率) |
数据格式 | HDF4(.hdf) |
空间覆盖 | 全球陆地,约 286~326 个 MODIS 瓦片/周期 |
数据时段 | 2000-02-18 至今 |
下载来源 | NASA LP DAAC(earthaccess API) |
全球完整下载一套(2000-2024)大约需要 1136 个 8 天周期,原始 HDF 文件总量约 1000 GB 左右(实际下载完毕后,发现数据量其实远超这个数值)。单靠手动从网页点击下载显然不现实,所以用脚本来做。

主要依赖两个库:
pip install earthaccess tqdm
earthaccess 是 NASA 官方出品的 Python 库,专门用于搜索和下载 Earthdata(包括 MODIS 在内的所有 NASA 数据)。tqdm 用于显示下载进度条。
另外需要注册一个 NASA Earthdata 账号,地址是 https://urs.earthdata.nasa.gov,注册完成后在账号页面给 LP DAAC Data Pool 这个应用授权,否则下载会报 401 错误。

认证方面,推荐使用 .netrc 文件方式,在 Windows 上对应的文件路径是 C:\Users\<用户名>\_netrc,内容格式如下:
machine urs.earthdata.nasa.gov
login 你的用户名
password 你的密码
这种方式最稳定,不依赖 Token API,在有代理软件的 Windows 环境下也能正常工作。如果系统装了 Clash、V2Ray 等工具,即使其是"关闭"状态,Windows 系统代理设置有时仍然生效,会导致 HTTPS 连接被拦截。解决方案是在脚本里显式设置 NO_PROXY="*",让 Python 的请求绕过系统代理。
下载脚本的整体逻辑不复杂,核心是三步:
第一步,生成 MODIS 8 天周期列表。MODIS 的 8 天合成不是任意的 8 天,而是从每年第 1 天(1 月 1 日)开始,每 8 天一个周期,全年固定 46 个周期(最后一个周期可能不足 8 天)。所以需要先把起止日期对齐到 MODIS 的标准周期边界,再逐一生成周期列表。
第二步,按周期搜索并下载颗粒。用 earthaccess.search_data() 搜索指定时间范围内的数据颗粒(Granule),每个颗粒对应一个 MODIS 瓦片的 HDF 文件。全球范围每个周期大约有 290 个颗粒。搜索完成后,用多线程并发下载,默认 8 个线程,实测速度稳定在每个周期 3~5 分钟,全部下载完大约需要 3 天左右。
第三步,断点续传与完整性校验。下载过程中用一个 JSON 文件记录已完成的周期。每次完成一个周期后,会对比搜索到的颗粒数和本地文件数,如果下载成功率达到 85% 以上,则认为该周期完成并写入进度文件。下次运行时加 --resume 参数即可从断点继续,不会重复下载已完成的周期。
此外,每个颗粒的下载支持最多 3 次指数退避重试,下载时先写入 .tmp 临时文件,完成后重命名,防止意外中断导致的不完整文件被当作有效文件跳过。
#!/usr/bin/env python3
"""
MODIS MOD17A2HGF v6.1 GPP 全球下载脚本(纯下载版)
=====================================================
仅从 NASA LP DAAC 下载 HDF 文件,按 8 天周期组织目录存储。
下载完成后,使用本地 ArcPy 将 HDF 批量转为 GeoTIFF。
使用方法:
python lpdaac_gpp_download_only.py # 默认下载 2000-2024
python lpdaac_gpp_download_only.py --resume # 断点续传
python lpdaac_gpp_download_only.py --workers 12 # 调整线程数
python lpdaac_gpp_download_only.py --dry-run # 仅统计,不下载
依赖:
pip install earthaccess tqdm
"""
import argparse
import json
import logging
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path
import earthaccess
from tqdm import tqdm
# ============================================================
# 日志配置
# ============================================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
# ============================================================
# 常量
# ============================================================
MODIS_PRODUCT = "MOD17A2HGF"
MODIS_VERSION = "061"
MODIS_TERRA_START = datetime(2000, 2, 18)
DEFAULT_RAW_DIR = r"F:\MODIS_GPP\raw_hdf"
PROGRESS_FILE = r"F:\MODIS_GPP\gpp_download_progress.json"
LOG_FILE = r"F:\MODIS_GPP\gpp_download.log"
EXPECTED_TILES_MIN = 250# 低于此数量认为下载不完整
# ============================================================
# 辅助函数
# ============================================================
def generate_8day_periods(start_date, end_date):
"""生成 MODIS 8 天合成周期列表,对齐到标准周期边界。"""
periods = []
year = start_date.year
doy = start_date.timetuple().tm_yday
modis_doy = ((doy - 1) // 8) * 8 + 1
current = datetime(year, 1, 1) + timedelta(days=modis_doy - 1)
if current < MODIS_TERRA_START:
current = MODIS_TERRA_START
while current <= end_date:
period_end = current + timedelta(days=7)
periods.append((current, period_end))
current = period_end + timedelta(days=1)
return periods
def authenticate():
"""NASA Earthdata 认证,优先级:.netrc > 环境变量 > 交互式。
同时设置 NO_PROXY,防止 Windows 系统代理干扰 HTTPS 连接。
"""
os.environ["NO_PROXY"] = "*"
os.environ["no_proxy"] = "*"
for strategy in ("netrc", "environment", "interactive"):
try:
earthaccess.login(strategy=strategy)
logger.info(f"[OK] 使用 {strategy} 认证成功")
return
except Exception as e:
logger.debug(f"{strategy} 认证失败: {e}")
logger.error("[FAIL] 所有认证方式均失败,请检查 ~/.netrc 或环境变量配置")
sys.exit(1)
def download_single_granule(granule, output_dir, max_retries=3):
"""下载单个数据颗粒,支持重试(指数退避)。"""
for attempt in range(max_retries):
try:
links = granule.data_links()
ifnot links:
return (granule, None, False)
url = links[0]
filename = os.path.basename(url)
local_path = os.path.join(output_dir, filename)
# 已存在且大小合理(> 100 KB)则直接跳过
if os.path.exists(local_path) and os.path.getsize(local_path) > 102400:
return (granule, local_path, True)
session = earthaccess.get_requests_https_session()
response = session.get(url, stream=True, timeout=120)
response.raise_for_status()
# 先写 .tmp,完成后重命名,防止中断产生不完整文件
temp_path = local_path + ".tmp"
with open(temp_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
os.rename(temp_path, local_path)
return (granule, local_path, True)
except Exception as e:
if attempt < max_retries - 1:
wait = 2 ** attempt
time.sleep(wait)
else:
logger.warning(f" 下载失败 {os.path.basename(url)}: {e}")
return (granule, None, False)
return (granule, None, False)
def download_granules_parallel(granules, output_dir, max_workers=8):
"""多线程并发下载颗粒列表。"""
os.makedirs(output_dir, exist_ok=True)
downloaded, failed = [], 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(download_single_granule, g, output_dir): g
for g in granules}
with tqdm(total=len(futures), desc=" 下载瓦片", unit="瓦片",
ncols=80, leave=False) as pbar:
for future in as_completed(futures):
granule, path, success = future.result()
if success and path:
downloaded.append(path)
else:
failed += 1
pbar.update(1)
pbar.set_postfix(ok=len(downloaded), fail=failed)
logger.info(f" 下载完成: {len(downloaded)} 成功, {failed} 失败")
return downloaded, failed
def count_hdf_files(directory):
ifnot os.path.exists(directory):
return0
return len([f for f in os.listdir(directory) if f.lower().endswith(".hdf")])
def load_progress(progress_file):
if os.path.exists(progress_file):
with open(progress_file, "r") as f:
return json.load(f)
return {"completed_periods": [], "start_time": datetime.now().isoformat()}
def save_progress(progress, progress_file):
os.makedirs(os.path.dirname(progress_file), exist_ok=True)
with open(progress_file, "w") as f:
json.dump(progress, f, indent=2)
# ============================================================
# 主流程
# ============================================================
def main():
parser = argparse.ArgumentParser(
description="MODIS MOD17A2HGF v6.1 GPP 全球 HDF 批量下载",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--start", default="2000-02-18", help="起始日期 YYYY-MM-DD")
parser.add_argument("--end", default="2024-12-31", help="结束日期 YYYY-MM-DD")
parser.add_argument("--raw-dir", default=DEFAULT_RAW_DIR, help="HDF 存储目录")
parser.add_argument("--workers", type=int, default=8, help="并行线程数(默认 8)")
parser.add_argument("--resume", action="store_true", help="从上次中断处恢复")
parser.add_argument("--dry-run", action="store_true", help="仅统计,不下载")
parser.add_argument("--force", action="store_true", help="强制重新下载已完成周期")
args = parser.parse_args()
os.makedirs(args.raw_dir, exist_ok=True)
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
fh = logging.FileHandler(LOG_FILE, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
start_date = datetime.strptime(args.start, "%Y-%m-%d")
end_date = datetime.strptime(args.end, "%Y-%m-%d")
logger.info("=" * 60)
logger.info("MODIS MOD17A2HGF v6.1 GPP 全球 HDF 下载(纯下载版)")
logger.info("=" * 60)
authenticate()
periods = generate_8day_periods(start_date, end_date)
total_gb = len(periods) * 290 * 2.2 / 1024
logger.info(f"总 8 天周期数: {len(periods)}")
logger.info(f"时间范围: {periods[0][0]:%Y-%m-%d} ~ {periods[-1][1]:%Y-%m-%d}")
logger.info(f"并行线程数: {args.workers}")
logger.info(f"预估总量: ~{total_gb:.0f} GB")
if args.dry_run:
completed = sum(
1for ps, _ in periods
if count_hdf_files(os.path.join(args.raw_dir, ps.strftime("%Y-%m-%d")))
>= EXPECTED_TILES_MIN
)
logger.info(f"本地已有完整周期: {completed}/{len(periods)}")
return
progress = load_progress(PROGRESS_FILE) if args.resume else {
"completed_periods": [], "start_time": datetime.now().isoformat()
}
completed = set(progress.get("completed_periods", []))
if completed and args.resume:
logger.info(f"恢复模式: 已完成 {len(completed)}/{len(periods)} 个周期")
total_downloaded, total_failed = 0, 0
start_time = time.time()
for i, (period_start, period_end) in enumerate(periods):
period_key = period_start.strftime("%Y-%m-%d")
period_dir = os.path.join(args.raw_dir, period_key)
ifnot args.force and (period_key in completed or
count_hdf_files(period_dir) >= EXPECTED_TILES_MIN):
if period_key notin completed:
completed.add(period_key)
progress["completed_periods"] = sorted(completed)
save_progress(progress, PROGRESS_FILE)
continue
elapsed = time.time() - start_time
if total_downloaded > 0:
avg = elapsed / total_downloaded
eta_h = avg * (len(periods) - len(completed)) / 3600
eta_str = f" (ETA: {eta_h:.1f}h)"
else:
eta_str = ""
logger.info(f"\n--- 周期 {i+1}/{len(periods)}: "
f"{period_start:%Y-%m-%d} ~ {period_end:%Y-%m-%d}{eta_str} ---")
try:
granules = earthaccess.search_data(
short_name=MODIS_PRODUCT, version=MODIS_VERSION,
temporal=(period_start, period_end),
)
except Exception as e:
logger.error(f" 搜索颗粒失败: {e}")
total_failed += 1
continue
ifnot granules:
logger.warning(" 未找到颗粒,跳过")
completed.add(period_key)
progress["completed_periods"] = sorted(completed)
save_progress(progress, PROGRESS_FILE)
continue
logger.info(f" 找到 {len(granules)} 个颗粒,使用 {args.workers} 线程下载...")
os.makedirs(period_dir, exist_ok=True)
downloaded, failed_count = download_granules_parallel(
granules, period_dir, max_workers=args.workers
)
if len(downloaded) >= len(granules) * 0.85:
logger.info(f" [OK] 周期 {period_key} 完成 ({len(downloaded)}/{len(granules)})")
completed.add(period_key)
progress["completed_periods"] = sorted(completed)
save_progress(progress, PROGRESS_FILE)
total_downloaded += 1
else:
logger.warning(f" [WARN] 周期 {period_key} 不完整 "
f"({len(downloaded)}/{len(granules)}),下次运行将自动补全")
total_failed += 1
if (i + 1) % 10 == 0:
speed = total_downloaded / ((time.time() - start_time) / 3600)
logger.info(f" >>> 进度: {len(completed)}/{len(periods)}, "
f"速度: {speed:.1f} 周期/小时")
# 最终汇总
total_hours = (time.time() - start_time) / 3600
logger.info("\n" + "=" * 60)
logger.info(f"下载完成! 总耗时: {total_hours:.1f} 小时")
logger.info(f"成功: {len(completed)}/{len(periods)} 个周期")
logger.info(f"HDF 文件位置: {os.path.abspath(args.raw_dir)}")
logger.info("=" * 60)
if __name__ == "__main__":
main()
脚本中几个容易需要根据实际情况调整的变量如下:
参数 | 默认值 | 说明 |
|---|---|---|
DEFAULT_RAW_DIR | F:\MODIS_GPP\raw_hdf | HDF 文件存储目录,按需修改到有足够空间的磁盘 |
--start / --end | 2000-02-18 / 2024-12-31 | 下载的时间范围 |
--workers | 8 | 并行下载线程数,NASA 服务器一般限制约 10~15 个并发,不建议超过 15 |
EXPECTED_TILES_MIN | 250 | 认为一个周期"下载完整"所需的最低文件数,全球约 290 个瓦片,设 250 作为容错阈值 |
命令行用法如下:
# 完整下载 2000-2024(首次运行)
python lpdaac_gpp_download_only.py
# 断点续传(中途中断后恢复)
python lpdaac_gpp_download_only.py --resume
# 先检查本地已有多少,不实际下载
python lpdaac_gpp_download_only.py --dry-run
# 调整线程数为 12,加快下载
python lpdaac_gpp_download_only.py --workers 12
# 自定义时间范围
python lpdaac_gpp_download_only.py --start 2010-01-01 --end 2020-12-31
关于 MOD17A2HGF 与 MOD17A2H 的选择:如果做长时序分析,建议优先选 GF 版本。标准版 MOD17A2H 在年末几个周期会因为 FPAR/LAI 质量差而出现明显的低估异常,在时序曲线上表现为突然的负值或极低值,用 GF 版本可以规避这个问题。
关于 Windows 代理干扰问题:这个问题比较隐蔽。即便在 Clash 或 V2Ray 里点击了"关闭系统代理",Windows 注册表里的代理设置有时候不会立即清除,导致 Python 的 requests 库仍然走代理,在代理不稳定或没有开启 TUN 模式时会出现 SSL 握手失败(SSLEOFError)。解决方法是在脚本里显式设置 os.environ["NO_PROXY"] = "*" 和 os.environ["no_proxy"] = "*",强制让所有请求绕过代理直连。
关于 earthaccess v0.18 认证接口变更:早期版本的 earthaccess.login() 支持 strategy="password" 直接传用户名密码,v0.18 之后这个策略被移除,改成了 strategy="netrc"(读取 .netrc 文件)、strategy="environment"(读取环境变量)和 strategy="interactive"(交互式引导)三种方式。如果用老版本代码会报 ValueError: Invalid strategy,换用上面的方式就好。
至此,大功告成。
欢迎关注:疯狂学习GIS