大数跨境
0
0

下载Sentinel系列卫星.SAFE标准格式下的单个文件

下载Sentinel系列卫星.SAFE标准格式下的单个文件 Angela的外贸日常
2025-10-14
22

前言

下半年出于工作变动,Endless的更新频次犹如人的心情般漂浮不定。原本计划分享近两年来在光学卫星辐射校正方向的一些研究心得,实际就是,忙忙忙.......  现在做了个产品经理的角色,也算是新体验,每天与开发打架,你质疑我,我质疑你。

需求场景

言归正传,在某个系统开发中,需要获取每个Sentinel-2数据成像时其太阳及卫星角度参数。在标准的.SAFE格式下,其卫星成像时的角度参数存放在GRANULE/L1C_XXXXXX_XXXXXXXXXXXXXX下的MTD_TL.xml文件中。那么如何在无须下载整个产品.SAFE格式数据下批量获取呢?

图1 MTD_TL.xml文件位置

算法实现逻辑

涉及这种产品级的访问,当然优先考虑OData ,但距离接触OData已是两年前,因此我选择去看OData API官方文档寻找我想要的答案,能不能直接通过API访问至产品内部的各级文件?有Listing product nodes章节这就好办了。

OData API官方文档:https://documentation.dataspace.copernicus.eu/APIs/OData.html#query-by-geographic-criteria

问题:为什么不通过GEE这类云计算平台获取?

回答:该MTD_TL.xml文件中的角度参数只有官方.SAFE标准格式中有,里面存放着每个波段、每个传感器焦平面成像的角度参数。


图2 OData API接口

通过输入ROI坐标、成像时间、卫星名称、云量等参数访问至产品级;

问题:为什么访问查询至产品时,只保留第一个产品?

回答:因为对于我们查询的ROI,该地点可能会位于两景影像重叠处,这就意味着有两个返回,为了方便处理,直接取列表的第一个产品。


图3  取第一个产品

对于一个产品,其API响应的JSON数据体所包含16个属性:

图4 JSON数据体

其各属性解释如下:

字段名
说明
@odata.mediaContentType application/octet-stream
表示该资源是一个二进制文件流(即整个 .SAFE 文件)
Id 184e8dbe-cd72-477f-a555-ae55ad2ab95d
产品唯一标识符(UUID)
Name S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE
文件名,遵循命名规范(见下文详解)
ContentType application/octet-stream
内容类型:二进制流
ContentLength 603,498,308
 字节 (~575.5 MB)
文件大小
OriginDate 2025-05-03T12:26:34Z
数据在归档系统中生成的时间
PublicationDate 2025-05-04T10:32:37.407Z
数据发布可供下载的时间
ModificationDate 2025-05-04T10:32:37.407Z
最后修改时间(通常与发布一致)
Online True
是否在线,可直接下载(若为 False 则需“归档恢复”)
EvictionDate 9999-12-31T23:59:59.999Z
永久保留标志(远未来日期表示不会被删除)
S3Path /eodata/Sentinel-2/MSI/L2A/2025/05/03/...
在对象存储(如 AWS S3)中的路径
Checksum
包含 MD5 和 BLAKE3 校验值
用于验证文件完整性
ContentDate
开始和结束时间均为 2025-05-03T07:56:31.025Z
数据采集时间(瞬时成像)
Footprint
WKT 格式的多边形地理范围
描述该影像覆盖的地球表面区域(SRID=4326 是 WGS84 经纬度坐标系)

在上一步API响应的JSON数据体中获取到了产品ID,即产品唯一标识符,我们用该产品ID与odata固定API拼接,然后进行requests请求;

产品ID:184e8dbe-cd72-477f-a555-ae55ad2ab95d

固定请求API:url = f"https://download.dataspace.copernicus.eu/odata/v1/Products({product_uuid})/Nodes"


'https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes'

.SAFE 产品的目录结构元数据响应

结果返回的是一个 Sentinel-2 .SAFE 产品的目录结构元数据响应,表示该产品是一个“容器”(文件夹),包含多个子节点(即内部的文件和子文件夹);

{'result': [{'ChildrenNumber'8'ContentLength'0'Id''S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE''Name''S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes'}}]}
result
查询结果数组(通常包含一个或多个产品)
ChildrenNumber
: 8
表示这个 .SAFE 文件夹内有 8 个直接子项(可能是文件夹或文件)
ContentLength
: 0
因为这是一个目录(容器),不是实际文件,所以大小为 0
Id
 / Name
目录名称,即 .SAFE 包名
Nodes.uri
可访问该目录下所有子节点的 OData API 端点 URL

具体的举例,即访问至产品的目录层级,对应的是S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE这个目录,现在我们需要利用在响应头中Nodes.uri获取下一级,即文件夹下八个文件的url;

图5  产品目录层级

.SAFE 产品的目录内结构元数据响应

'Nodes': {'uri': 'https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes'}

one_namedir= data['result'][0]['Nodes']['uri']
    resp1 = requests.get(one_namedir, headers=headers)
    data1 = resp1.json()

响应头返回如下内容:

{'result': [{'ChildrenNumber'6'ContentLength'0'Id''HTML''Name''HTML''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(HTML)/Nodes'}}, {'ChildrenNumber'0'ContentLength'18903'Id''INSPIRE.xml''Name''INSPIRE.xml''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(INSPIRE.xml)/Nodes'}}, {'ChildrenNumber'0'ContentLength'58791'Id''MTD_MSIL2A.xml''Name''MTD_MSIL2A.xml''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(MTD_MSIL2A.xml)/Nodes'}}, {'ChildrenNumber'0'ContentLength'3461'Id''S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713-ql.jpg''Name''S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713-ql.jpg''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713-ql.jpg)/Nodes'}}, {'ChildrenNumber'0'ContentLength'69144'Id''manifest.safe''Name''manifest.safe''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(manifest.safe)/Nodes'}}, {'ChildrenNumber'3'ContentLength'0'Id''rep_info''Name''rep_info''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(rep_info)/Nodes'}}, {'ChildrenNumber'1'ContentLength'0'Id''DATASTRIP''Name''DATASTRIP''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(DATASTRIP)/Nodes'}}, {'ChildrenNumber'1'ContentLength'0'Id''GRANULE''Name''GRANULE''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes'}}]}

上述一大串东西的就是目录下八个文件/文件夹层级的url,即我们进入产品的第二层,产品内部的八个文件/文件夹的url;

图6 产品目录下的八个文件/文件夹层级
图7 示例产品的构成

因为本次算法的是获取MTD_TL.xml文件,因此我们需要继续访问GRANULE文件夹,即使用GRANULE的url;

{'ChildrenNumber'1'ContentLength'0'Id''GRANULE''Name''GRANULE''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes'}}
    two_dir = next((item['Nodes']['uri'for item in data1['result'if item['Id'] == 'GRANULE'), None)
    resp2 = requests.get(two_dir, headers=headers)
    data2 = resp2.json()

GRANULE文件夹结构元数据响应

响应头返回如下内容:

{'result': [{'ChildrenNumber'4'ContentLength'0'Id''L2A_T37QBG_A003437_20250503T081037''Name''L2A_T37QBG_A003437_20250503T081037''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes'}}]}

返回的是GRANULE文件夹下一个存放影像数据文件夹,即L2A_T37QBG_A003437_20250503T081037目录;

图8 示例存放影像数据文件夹

访问至GRANULE 目录下的一个Granule子目录的元数据信息。这是 .SAFE 包中实际影像数据的存放位置;

其标准格式为:

L2A_T37QBG_A003437_20250503T081037/

├── IMG_DATA/           # 实际影像波段(.jp2 格式)

├── AUX_DATA/           # 辅助数据(如大气模型)

├── QI_DATA/            # 质量信息(如云掩膜、雪掩膜)

├── MTD_TL.xml          # 像元级元数据文件

pr_dir = data2['result'][0]['Nodes']['uri']
    resp3 = requests.get(pr_dir, headers=headers)
    data3 = resp3.json()

L2A_xxxxxx_xxxxxxx_xxxxxxxxxxxxxxxxx文件夹内结构元数据响应

响应头返回如下内容:

{'result': [{'ChildrenNumber'2'ContentLength'0'Id''AUX_DATA''Name''AUX_DATA''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes(AUX_DATA)/Nodes'}}, {'ChildrenNumber'3'ContentLength'0'Id''IMG_DATA''Name''IMG_DATA''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes(IMG_DATA)/Nodes'}}, {'ChildrenNumber'0'ContentLength'368363'Id''MTD_TL.xml''Name''MTD_TL.xml''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes(MTD_TL.xml)/Nodes'}}, {'ChildrenNumber'37'ContentLength'0'Id''QI_DATA''Name''QI_DATA''Nodes': {'uri''https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes(QI_DATA)/Nodes'}}]}

图9 响应头的四个url

分别对应四个文件/文件夹的URL,对于我们需要的MTD_TLxml,已经访问至文件本身,即可通过其提供的URL进行下载;

图10 MTD_TLxml所在的文件夹

MTD_TL.xml完整URL

MTD_TL = next((item['Nodes']['uri'] for item in data3['result'] if item['Id'] == 'MTD_TL.xml'), None)
MTD_TL ='https://download.dataspace.copernicus.eu/odata/v1/Products(184e8dbe-cd72-477f-a555-ae55ad2ab95d)/Nodes(S2C_MSIL2A_20250503T075631_N0511_R035_T37QBG_20250503T113713.SAFE)/Nodes(GRANULE)/Nodes(L2A_T37QBG_A003437_20250503T081037)/Nodes(MTD_TL.xml)/Nodes'

已经获取到文件本身URL,直接通过API拼接即可完成下载。

图11  实际运行效果

完整代码

"""
该脚本为批量输入ROI的.geojson文件,下载Sentinel-2产品多层级下的单个文件
OData API官方文档
https://documentation.dataspace.copernicus.eu/APIs/OData.html#query-by-geographic-criteria

Access token官方文档
https://documentation.dataspace.copernicus.eu/APIs/Token.html



# 响应头所包含的属性
 "@odata.context": "$metadata#Products",
  "value": [
    {
      "@odata.mediaContentType": "application/octet-stream",
      "Id": "b30f2bbf-7f02-494a-b3f4-3ea11cd3d14d",
      "Name": "S3A_REP1_AUX_POEORB_POD__20161216T083833_V20160226T215943_20160227T235943_DGNS.EOF",
      "ContentType": "application/octet-stream",
      "ContentLength": 4410152,
      "OriginDate": "2021-04-17T09:08:14.628Z",
      "PublicationDate": "2023-10-25T16:30:52.016Z",
      "ModificationDate": "2023-10-25T18:03:45.644Z",
      "Online": true,
      "EvictionDate": "",
      "S3Path": "/eodata/Sentinel-3/AUX/AUX_POEORB/2016/02/26/S3A_REP1_AUX_POEORB_POD__20161216T083833_V20160226T215943_20160227T235943_DGNS.EOF",
      "Checksum": [
        {
          "Value": "32d1b4ee5cf9255ac0536d658255c1fe",
          "Algorithm": "MD5",
          "ChecksumDate": "2023-10-25T18:03:44.760291Z"
        },
        {
          "Value": "56a78e26853a6b69021cc17c7cf495c4473ecb415e857a3bf62aa55beb60d5cd",
          "Algorithm": "BLAKE3",
          "ChecksumDate": "2023-10-25T18:03:44.779812Z"
        }
      ],
      "ContentDate": {
        "Start": "2016-02-26T21:59:43.000Z",
        "End": "2016-02-27T23:59:43.000Z"
      },
      "Footprint": null,
      "GeoFootprint": null
    },

"""




import os
import sys
import json
import hashlib
import requests
import tqdm
from osgeo import ogr, gdal
import pandas as pd
import re
from datetime import datetime, timedelta

# -------------------- 配置参数 --------------------
EMAIL ="longchxxuo@163.com"
PASSWORD =  "xxxxxxxxxx"
OUTPUT_DIR = r"C:\Users\xxxx\Desktop\下载Sentinel-2单个系数\xml"
INPUT_DIR  = r"C:\Users\xxxxx\Desktop\下载Sentinel-2单个系数\roi"   # 存放geojson文件的文件夹

SATELLITE_NAME = "SENTINEL-2"
PRODUCT_TYPE = "MSIL2A"
CLOUD_COVER = 100

# -------------------- 工具函数 --------------------
def GetFileMd5(filename):
    ifnot os.path.isfile(filename):
        returnNone
    myHash = hashlib.md5()
    with open(filename, 'rb'as f:
        whileTrue:
            b = f.read(8096)
            ifnot b:
                break
            myHash.update(b)
    return myHash.hexdigest()


def bbox(xmin, xmax, ymin, ymax):
    returnf"{xmin} {ymin}{xmax} {ymin}{xmax} {ymax}{xmin} {ymax}{xmin} {ymin}"


def read_area_interest(insterest_path):
    """
    读取兴趣区域文件(geojson / shp),输出 bbox 坐标字符串
    """

    if insterest_path.endswith(".geojson"):
        with open(insterest_path, "r", encoding="utf-8"as f:
            data = json.load(f)
        geom_type = data["features"][0]["geometry"]["type"]
        coords = []
        if geom_type == "Polygon":
            coords = data["features"][0]["geometry"]["coordinates"][0]
        elif geom_type == "MultiPolygon":
            for poly in data["features"][0]["geometry"]["coordinates"]:
                coords.extend(poly[0])
        else:
            raise ValueError(f"不支持的几何类型: {geom_type}")
        lons = [c[0for c in coords]
        lats = [c[1for c in coords]
        return bbox(str(min(lons)), str(max(lons)), str(min(lats)), str(max(lats)))

    elif insterest_path.endswith(".shp"):
        gdal.SetConfigOption("GDAL_FILENAME_IS_UTF8""NO")
        ogr.RegisterAll()
        dataset = ogr.Open(insterest_path)
        if dataset isNone:
            raise IOError(f"无法打开 {insterest_path}")
        layer = dataset.GetLayerByIndex(0)
        if layer isNone:
            raise IOError(f"无法读取 {insterest_path} 图层")
        xmin, xmax, ymin, ymax = layer.GetExtent()
        return bbox(str(xmin), str(xmax), str(ymin), str(ymax))
    else:
        raise ValueError(f"无法读取该文件 {insterest_path},仅支持 .geojson / .shp")


# -------------------- Copernicus Access Token --------------------
def get_access_token(username: str, password: str) -> str:
    data = {
        "client_id""cdse-public",
        "username": username,
        "password": password,
        "grant_type""password",
    }
    r = requests.post(
        "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token",
        data=data
    )
    r.raise_for_status()
    return r.json()["access_token"]


# -------------------- Sentinel 产品搜索 --------------------
def Sentinel_api(satellite, area_interest, startDate, endDate, cloudCover=None, ProductType=None):
    base_url = "https://catalogue.dataspace.copernicus.eu/odata/v1/Products?$filter="

    satellite_collection = f"Collection/Name eq '{satellite}'"
    roi = f"OData.CSC.Intersects(area=geography'SRID=4326;POLYGON(({area_interest}))')"
    time_range = f"ContentDate/Start gt {startDate}T00:00:00.000Z and ContentDate/Start lt {endDate}T00:00:00.000Z"
    search_lim = "&$top=1000"
    expand_assets = "&$expand=Assets"

    if ProductType:
        attr_product = f"contains(Name,'{ProductType}')"
    else:
        attr_product = "true"

    if cloudCover:
        cloud_cover_filter = f"Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value le {cloudCover})"
        request_url = f"{base_url}{attr_product} and {cloud_cover_filter} and {roi} and {satellite_collection} and {time_range}{search_lim}{expand_assets}"
    else:
        request_url = f"{base_url}{attr_product} and {roi} and {satellite_collection} and {time_range}{search_lim}{expand_assets}"

    resp = requests.get(request_url)
    resp.raise_for_status()
    data = resp.json()
    df = pd.DataFrame.from_dict(data['value'])
    return df


# -------------------- 递归列出节点 --------------------
def list_nodes(product_uuid, access_token):
    headers = {"Authorization"f"Bearer {access_token}"}
    url = f"https://download.dataspace.copernicus.eu/odata/v1/Products({product_uuid})/Nodes"
    resp = requests.get(url, headers=headers)
    resp.raise_for_status()
    data = resp.json()
    one_namedir= data['result'][0]['Nodes']['uri']
    resp1 = requests.get(one_namedir, headers=headers)
    data1 = resp1.json()

    two_dir = next((item['Nodes']['uri'for item in data1['result'if item['Id'] == 'GRANULE'), None)
    resp2 = requests.get(two_dir, headers=headers)
    data2 = resp2.json()

    pr_dir = data2['result'][0]['Nodes']['uri']
    resp3 = requests.get(pr_dir, headers=headers)
    data3 = resp3.json()
    MTD_TL = next((item['Nodes']['uri'for item in data3['result'if item['Id'] == 'MTD_TL.xml'), None)
    return MTD_TL


# -------------------- 下载文件 --------------------
def download_file(file_path, url, access_token, overwrite=False):
    os.makedirs(os.path.dirname(file_path), exist_ok=True)
    headers = {"Authorization"f"Bearer {access_token}"}
    mode = "wb"
    downloaded_bytes = 0

    if os.path.exists(file_path) andnot overwrite:
        print(f"文件已存在,跳过: {file_path}")
        return

    with requests.get(url, headers=headers, stream=Trueas r:
        total_size = int(r.headers.get("Content-Length"0)) + downloaded_bytes
        with tqdm.tqdm(total=total_size, unit='B', unit_scale=True, unit_divisor=1024,
                       desc=os.path.basename(file_path), initial=downloaded_bytes) as pbar:
            with open(file_path, mode) as f:
                for chunk in r.iter_content(chunk_size=1024):
                    if chunk:
                        f.write(chunk)
                        pbar.update(len(chunk))


# -------------------- 主程序 --------------------
if __name__ == "__main__":
    geojson_files = [f for f in os.listdir(INPUT_DIR) if f.endswith(".geojson")]
    # 获取 xml 下载链接
    access_token = get_access_token(EMAIL, PASSWORD)
    for geojson in geojson_files:
        geojson_path = os.path.join(INPUT_DIR, geojson)
        print(f"\n>>> 处理文件: {geojson_path}")

        # 从文件名提取日期
        date_str = geojson.split("_")[0]  # 例如 "20250130"
        date_obj = datetime.strptime(date_str, "%Y%m%d")
        START_DATE = date_obj.strftime("%Y-%m-%d")
        END_DATE   = (date_obj + timedelta(days=1)).strftime("%Y-%m-%d")

        # 读取ROI
        roi_bbox = read_area_interest(geojson_path)

        # 查询产品
        df_products = Sentinel_api(SATELLITE_NAME, roi_bbox, START_DATE, END_DATE,
                                   cloudCover=CLOUD_COVER, ProductType=PRODUCT_TYPE)

        if df_products.empty:
            print("未查询到数据,跳过。")
            continue

        # 只保留第一个产品
        row = df_products.iloc[0]
        product_uuid = row['Id']
        product_name = row['Name']
        print(f"选定产品: {product_name} | UUID: {product_uuid}")


        node_url = list_nodes(product_uuid, access_token)

        matches = re.findall(r'\((.*?)\)', node_url)
        if len(matches) < 5:
            print("未能解析节点URL,跳过。")
            continue
        product_uuid, safe_name, granule, granule_id, file_name = matches

        xml_name = product_name[:-5] + '.xml'
        local_path = os.path.join(OUTPUT_DIR, xml_name)

        url_part1 = "https://zipper.dataspace.copernicus.eu/odata/v1/Products%28"
        url_part2 = "/$value"
        data_url = url_part1 + product_uuid + '%29/Nodes%28' + safe_name + '%29/Nodes%28GRANULE%29/Nodes%28' + granule_id + '%29/Nodes%28MTD_TL.xml%29' + url_part2

        print(f"下载URL: {data_url}")
        download_file(local_path, data_url, access_token)

【声明】内容源于网络
0
0
Angela的外贸日常
跨境分享间 | 长期积累专业经验
内容 45910
粉丝 1
Angela的外贸日常 跨境分享间 | 长期积累专业经验
总阅读260.9k
粉丝1
内容45.9k