Google Earth Engine ASSETS批量删除与复制脚本实践
发布时间:2026/11/5 7:24:29 锦皓数字建站

删除和复制 Google Earth Engine 里的 ASSETS看起来是一件很简单的小事直到你在网页端对着一个存了三百多个中间产物的目录一个一个点删除点到手抖。我最早接手一个共享项目时就是这么干的那会儿还觉得“反正就几十个”结果清理到一半浏览器崩了页面刷新回来删到哪个了都不知道只能从头再数一遍。后来我把这套流程彻底脚本化JavaScript 和 Python 两套都写过也包括同事只装了 Python 环境的场景。这篇分享的是真正把“删除指定 ASSETS 里的文件信息”和“复制指定 ASSETS 里的文件信息”做成可重复执行脚本的经验包含递归列目录、批量删除、跨路径复制、并发控制和幂等处理的完整写法。适合已经会在 Code Editor 里跑几行代码、但一遇到批量操作就回到手动点击的读者也适合想把清理和迁移工作交给一个定时脚本的人。1. GEE 的 ASSETS 不是网盘路径、类型与权限决定了能不能动很多人第一次接触这个体系会下意识把它当成云盘有目录有文件能拖能拽能右键删除。等你真写脚本的时候就会发现这个直觉会让你连着踩好几个坑。搞清楚它的节点模型后面的删除和复制才有依据。1.1 资产 ID 的层级结构与 projects 前缀每一个资产都由一个全局唯一的asset id定位形式是projects/项目名/assets/后续路径。这个前缀不是装饰它是权限边界。你在 Code Editor 左侧看到的目录树显示的是users/xxx/...或者projects/xxx/assets/...但真正传给 API 的必须是完整的、带前缀的字符串。自己拼路径时最常见的错误就是把显示名当成了 id比如把my-folder/sub/img直接传进去接口会告诉你资产不存在而实际上它存在只是少了一段前缀。路径里的分隔符只有/。别用反斜杠别带尾部斜杠也别在路径里塞空格。空格在技术上也许能过但你在 shell 脚本、日志、CSV 清单之间来回倒手的时候一个空格就足以让某一行的解析错位。我现在的习惯是新建资产时就把命名规范定死只允许小写字母、数字、下划线和短横线中间产物统一加前缀tmp_这样后面按前缀筛选删除非常省事。还有一点容易忽略folder 和 image collection 不是一回事。folder 是纯容器里面的东西彼此独立image collection 是一个整体资产它下面挂着的 image 属于它的一部分。这个区别直接决定删除策略第 2 章会展开。1.2 五类节点它们的删除语义完全不同写脚本之前先得能判断手里这个东西是什么类型。getAsset返回的对象里有type字段常见的取值有这么几类type 取值含义删除时是否需要递归FOLDER纯目录容器需要先清空子节点IMAGE单幅影像不需要直接删IMAGE_COLLECTION影像集合不需要删除时其下 image 一并消失TABLE矢量表 / FeatureCollection不需要CLASSIFIER等派生资产训练产物不需要我第一次写递归删除时把 image collection 也当目录处理了挨个去删它下面的 image结果删到一半报错因为集合本身已经被前一个操作带走了。后来才明白只有 FOLDER 需要递归其它类型都是原子操作。这个认知能让你少写一半代码。判断类型不要靠猜路径。有人喜欢用“路径最后一段没有扩展名就是目录”这在你自己建的目录里勉强成立碰上别人共享过来的资产就完全失效。老老实实调一次getAsset多花一次网络往返换来的是不会误删。1.3 三道权限闸门不是所有资产你都能删动手之前必须明确一件事你只能删自己有权写的东西。具体的限制大致分三层。第一层是 root。公共目录下的资产是只读的任何删除和复制写入操作都会被拒绝这个不用试试了也是报错。第二层是项目权限。你在某个项目里有 Writer 或 Owner 角色才能删这个项目下的资产。共享给你的资产如果对方只给了 Reader你同样删不掉。团队协作里经常出现“这个目录看起来在我的列表里但我删不了”的情况八成就是权限没给到写。第三层是资产本身的 ACL。单个资产可以被单独授权所以理论上可能出现“目录能删、里面某个文件删不掉”的诡异状态。真遇到这种别急着怀疑脚本先用网页端对那一个资产试试手删能立刻区分是代码问题还是权限问题。提示任何批量删除脚本在正式跑之前都应该先跑一次“干跑”dry run把将要删除的清单打印出来或者写成文件人工扫一眼。这一步花两分钟能救回几个月的成果。2. 删除为什么“删目录”这件事必须自己写递归网页端删除目录时如果目录非空通常会提示你先清空。这个限制在 API 层面是一样的deleteAsset只认单个资产 id遇到非空 FOLDER 直接报错。所以所谓“删除指定 ASSETS 里的文件信息”本质上是“列出目录下所有节点 → 自底向上逐个删除 → 最后删空目录”这一串动作。2.1 deleteAsset 的真实行为与异步陷阱JavaScript 侧的调用形式是ee.data.deleteAsset(assetId, callback)。这里有个特别容易被忽略的点它是异步的。如果你不传 callback在 Code Editor 的交互环境里它表现为同步返回Code Editor 内部有一套同步 RPC 机制看起来一切正常但一旦你把同样的代码搬到 Node 环境或者包进一个批量循环里行为就可能变成“发起请求后立刻继续下一行”于是你在一秒内发出几百个删除请求然后被限流打成一片红色报错。我早期的写法就是无脑 for 循环跑二十个资产没事跑两百个就开始间歇性失败。原因不是权限也不是路径写错纯粹是请求发得太密。正确的姿势有两个方向一是用回调串行化上一个删完再发下一个二是控制节奏每 N 个请求插一个小延迟并给失败项做退避重试。Python 侧是ee.data.delete_asset(asset_id)同步阻塞没有回调概念。表面上看省心但如果你把它放进大规模循环里串行执行会非常慢这时候需要自己上线程池同时又要压住并发数避免触发配额。2.2 手写递归删除的 JavaScript 实现先把“列全部子节点”这一步封装好。listAssets是分页的一页默认返回数量有限目录大了必然要翻页翻页靠nextPageToken// 递归列出一个 folder 下的所有直接子节点自动翻页 function listChildren(parentId) { var result []; var token null; do { var resp ee.data.listAssets({ parent: parentId, pageSize: 1000, pageToken: token }); result result.concat(resp.assets || []); token resp.nextPageToken; } while (token); return result; }这段在 Code Editor 里可以直接跑返回的是数组。注意resp.assets里每一项都有id和type这两个字段就是后面所有逻辑的输入。有了它递归删除就是一个很直白的后序遍历function deleteNode(assetId, dryRun, log) { var info ee.data.getAsset(assetId); log.push((dryRun ? [DRY] : [DEL] ) assetId ( info.type )); if (info.type FOLDER) { var children listChildren(assetId); children.forEach(function (child) { deleteNode(child.id, dryRun, log); // 先把子节点清干净 }); } if (!dryRun) { ee.data.deleteAsset(assetId); // 最后删自己 } } // 用法 var log []; deleteNode(projects/你的项目名/assets/tmp_2023_batch, true, log); print(log.join(\n));后序的含义很明确先处理子节点再处理自己。反过来做删到父目录那一刻就会被“非空”挡下来。这段代码在跑真实删除时最大的风险是速度。ee.data.deleteAsset在交互环境里同步返回一轮几百个调用会拉长执行时间也容易中途被打断。生产环境里我会把它改成排队形式先把整棵树的 id 全收集到一个数组再按批执行每批之间留间隔并把成功项写进一个本地清单。2.3 Python 版递归删除与幂等处理Python 侧的逻辑一模一样只是命名风格换了。这里先给一个最朴素的版本import ee ee.Initialize(project你的项目名) def list_children(parent_id): items, token [], None while True: resp ee.data.list_assets( parentparent_id, page_size1000, page_tokentoken ) items.extend(resp.get(assets, [])) token resp.get(nextPageToken) if not token: break return items def delete_node(asset_id, dry_runTrue, logNone): log log if log is not None else [] info ee.data.get_asset(asset_id) log.append(f{[DRY] if dry_run else [DEL]} {asset_id} ({info[type]})) if info[type] FOLDER: for child in list_children(asset_id): delete_node(child[id], dry_run, log) if not dry_run: try: ee.data.delete_asset(asset_id) except ee.EEException as e: # 已经不存在就跳过保证脚本可重复执行 if not found in str(e).lower(): log.append(f[SKIP] {asset_id}) else: raise return log关键点在try/except那一段。批量脚本一定会遇到“目标已经不在了”的情况原因可能是一次中断后重跑也可能是别人同时在清理。不做幂等处理整个任务就会卡在一个已经不存在的资产上后面的全部不执行。判断方式我一般用错误信息里是否包含 not found 之类的关键字不同客户端版本措辞可能略有差异实际用的时候把真实异常打印一次看清楚再匹配。一个真实的踩坑Python 的分页字段名是nextPageToken不是next_page_token。我第一次写的时候很自然地按 Python 习惯取了后者拿到的永远是None于是脚本只处理了第一页就“圆满完成”了还打了日志说成功删除 998 个。后来对着账才发现目录里还剩一大半。这个坑非常隐蔽因为不报错只是悄悄漏数据。2.4 删除前的清单快照这一份表可能比脚本更重要我现在所有涉及删除的脚本第一步都是把清单落盘第二步才是执行。清单里至少包含三列完整 asset id、类型、父目录。如果是影像集合再加一列影像数量方便事后判断删掉的东西量级对不对。这么做的直接收益有三个。第一你可以把清单交给同事交叉检查尤其是删公共目录的时候多一双眼睛基本能拦住误操作。第二执行中断后有据可查知道哪些已经处理过。第三也是最实际的一点——如果删错了你能拿着这份表明确知道自己失去了什么至少能判断要不要重建。清单格式我一般用 CSV因为人能直接看脚本也能直接读。写文件用 Python 侧比较方便JS 侧在 Code Editor 里可以先print出来再手动存或者干脆把收集逻辑写成导出任务。注意删除操作没有回收站。删掉就是删掉这一点和本地文件系统完全不同不要指望“删了再恢复”。3. 复制copyAsset 的适用边界比大多数人以为的要窄删除是减法复制是搬运。搬运看起来更安全实际上坑更多因为复制涉及源、目标、命名和实际数据传输四个环节每个环节都能出问题。3.1 copyAsset 能复制什么、不能复制什么JavaScript 是ee.data.copyAsset(sourceId, destinationId, overwrite, callback)Python 是ee.data.copy_asset(source_id, destination_id, overwriteFalse)。这两个函数都只对具体资产生效也就是 IMAGE、IMAGE_COLLECTION、TABLE 这类。FOLDER 不能直接复制。这一点是新手最容易卡住的地方他们对着一个目录调用 copy期待整个目录搬过去结果收到一个类型错误。正确的做法仍然是递归在目标位置先建同构的目录结构然后逐个资产复制。所以第 2 章的递归框架在这里可以复用只是把“删除”换成“创建目录 复制”。另外一个限制是跨来源。公共目录里的资产可以作为源被读取和复制到自己名下但不能作为目标被写入。这个方向性一定要分清否则会看到“权限不足”却找不到原因。3.2 同名冲突与 overwrite 参数的版本差异目标位置已存在同名资产时行为取决于客户端版本。较新的版本支持显式传overwrite为真时覆盖为假时报错。较老的版本没有这个位置参数函数签名是(sourceId, destinationId, callback)。我踩过的坑是本地 Python 环境版本偏旧代码里写了overwriteTrue结果第三个位置参数被当成回调函数传进去了报了一个“callback is not callable”之类的错误。看报错完全想不到是版本问题。解决办法很朴素先打印一下函数签名确认或者直接用关键字参数传多数版本会给出更明确的类型错误提示。在批量场景里我更倾向于不覆盖。理由很实际如果目标已存在有两种可能——我上次已经复制成功了重复执行或者目标里有别人放的东西不能覆盖。这两种情况都不应该盲目覆盖。所以我一般先getAsset探一下目标是否存在存在就跳过并记录不存在才复制。这个判断加上之后脚本就变成了幂等的可以随便重跑。3.3 集合复制后的 id 重写与引用失效复制一个 image collection里面的每一幅 image 都会生成新的 id指向新集合。这意味着任何写死了旧 image id 的脚本、图表、导出任务都会失效。这一点在迁移工作流时特别致命因为集合本身复制过去了看起来一切正常直到你跑下游流程才发现取不到数据。我的处理方式是复制完成后立刻在新集合上跑一次最小验证比如取第一幅影像的 id 并打印再读一下集合的影像数量和源集合比对。两边数量不一致就说明复制没完成或者中途出过错。这个检查写起来只要几行但能把很多隐性问题在源头拦下来。src ee.ImageCollection(projects/旧项目/assets/某个集合) dst ee.ImageCollection(projects/新项目/assets/某个集合) print(src size:, src.size().getInfo()) print(dst size:, dst.size().getInfo()) print(first id:, dst.first().id().getInfo())注意size()和getInfo()都是真实计算大集合上会慢别在几千幅的集合上随手调。这种校验放在中小规模集合上做足够大规模集合用资产元数据里的数量字段比对更快。3.4 大批量复制的配额与限速复制真实数据是有成本的。一个影像集合可能包含大量影像每幅都要搬复制几百个这种集合时间和配额消耗都很可观。几个实操层面的经验并发不能拉满。我试过一次开二十个线程同时复制前半段很顺后半段开始大量失败报的都是限流类的错误。后来把并发压到四到六个配合失败重试整体反而更快因为没有返工。失败的项要能单独重跑。做法是在清单里加一列状态每次执行后更新。中断之后只挑状态为失败或未执行的继续不要从头再来——从头来一遍意味着前面已经成功的项又要被探测一次时间全浪费在判断“已存在跳过”上。大集合分批复制。如果单个集合特别大可以按时间范围或条带切分复制成多个小集合最后再用合并的方式组织。这样每批的失败影响面小重试成本也低。4. JavaScript 与 Python 两套写法对照camelCase 与 snake_case 之间的暗礁同一个功能有两个语言版本看起来是便利实际用起来是两套细节。我见过太多人把 JS 代码里改个下划线就贴进 Python然后对着莫名其妙的报错排查半天。这部分把关键差异集中列出来。4.1 函数名对照表功能JavaScriptPython列出子资产ee.data.listAssets({parent, pageSize, pageToken})ee.data.list_assets(parent..., page_size..., page_token...)获取资产元数据ee.data.getAsset(id)ee.data.get_asset(id)删除资产ee.data.deleteAsset(id, callback)ee.data.delete_asset(id)复制资产ee.data.copyAsset(src, dst, overwrite, callback)ee.data.copy_asset(src, dst, overwriteFalse)创建目录ee.data.createFolder(path)ee.data.create_folder(path)重命名/移动ee.data.renameAsset(src, dst)ee.data.rename_asset(src, dst)表里能看出规律Python 全部是 snake_caseJS 全部是 camelCase。但参数名不是简单换下划线——JS 里listAssets接收的是一个配置对象键名是parent、pageSize、pageTokenPython 里是三个独立的关键字参数名字是parent、page_size、page_token。而返回结果里的分页字段两边都是nextPageToken保持原样。这个不对称是最容易写错的地方。4.2 回调、同步返回与阻塞模型的差异JS 侧的ee.data函数大多接受一个可选的 callback。在 Code Editor 里不传 callback 时它走内部的同步机制表现得像同步函数可以直接拿返回值。这套机制只存在于交互式编辑环境里一旦代码离开那个环境就必须按异步方式处理。Python 侧没有这个问题所有调用都是阻塞的拿到返回值再往下走。代价是慢好处是逻辑直白。所以我在 Python 里的批量逻辑一般是这样组织的收集阶段串行快都是元数据查询执行阶段用有限并发慢都是真实写入。还有一个细节Python 里要在开头ee.Initialize()并且指定项目名。不指定项目名在某些环境里能跑但配额和权限归属会变得不明确容易在跨项目操作时出问题。JS 侧在 Code Editor 里默认已经初始化好了切到 Node 环境同样需要显式初始化。4.3 分页在两边的取法差异这一点值得单独拎出来说因为它是“脚本跑成功了但只处理了一部分”这类问题的头号来源。JS 版var token null; do { var resp ee.data.listAssets({parent: parentId, pageSize: 1000, pageToken: token}); // 处理 resp.assets token resp.nextPageToken; } while (token);Python 版token None while True: resp ee.data.list_assets(parentparent_id, page_size1000, page_tokentoken) # 处理 resp[assets] token resp.get(nextPageToken) if not token: break注意 Python 里我用了.get()因为当结果只剩最后一页时某些版本返回的字典里干脆没有这个键而不是给一个 None。直接resp[nextPageToken]会抛 KeyError。这种边界情况在实际运行里出现频率不低尤其是一个目录刚好被 1000 整除的时候。4.4 环境初始化与凭据的差别在浏览器编辑环境里基本不需要管凭据登录状态本身就带着授权。Python 侧在本地跑第一次要完成一次授权流程之后会缓存在本地。多项目场景下要显式指定项目否则默认项目可能不是你想要的会出现“明明有权限却报没有权限”的怪现象。我在同事机器上遇到过这种情况脚本在 A 项目下能正常列出资产在 B 项目下全部失败。查了半天是默认项目没切过去。定位方法很简单先打印一下当前初始化用的项目名一眼就能看出来。5. 把脚本做到能处理上万个资产的程度写一个能删十个资产的脚本跟写一个能删一万个资产、中途断了还能接着跑的脚本是两件事。这一章讲的是从“能跑”到“敢在正式目录上用”的那段路。5.1 清单驱动先枚举再执行两阶段分离最稳的结构是两阶段。第一阶段只做枚举从一个根目录出发递归收集所有节点写成一份带状态列的 CSV。第二阶段只做执行读取 CSV按状态筛选逐条处理处理完回写状态。这样做的好处是执行阶段的逻辑变得极简单——它不用再关心目录结构只面对扁平的列表。而且清单生成一次之后可以反复用改逻辑、改并发、改重试策略都不需要重新枚举。清单的列我固定用这几项asset_id、type、parent、depth、status、error。depth是为删除排序用的删除时必须由深到浅深度的降序排列就是天然的执行顺序。有个细节递归枚举时深层次目录很容易混进大量中间产物比如某个任务的临时切片。我的做法是先按命名规则筛一遍把带tmp_前缀的单独标出来执行阶段优先处理这些。真正需要谨慎的是那些名字看起来很正式的资产它们通常要人工确认。5.2 并发控制与退避重试并发数的经验值元数据查询可以放大一点八到十问题不大真实写操作删除、复制压到四到六。这个区间是我在不同项目、不同规模下反复试出来的再高收益就很小失败率明显上升。重试策略要用指数退避不要用固定间隔硬顶。第一次失败等一秒第二次两秒第三次四秒最多试三四次。超过次数就标记为失败写进清单等人工介入。无脑重试会放大限流让原本只是慢的任务变成彻底失败。import time import random def with_retry(fn, max_try4, base1.0): for i in range(max_try): try: return fn() except ee.EEException as e: if i max_try - 1: raise # 加一点随机抖动避免多个线程同时重试撞在一起 time.sleep(base * (2 ** i) random.random())这段逻辑看起来不起眼但它是让万级任务跑完的关键。我第一次没做重试跑两千个删除最后成功了大概八成剩下两成全是限流报错还得人工挑出来重跑反而更麻烦。5.3 断点续跑与状态回写状态回写的时机很重要。我一开始是全部跑完再一次性写文件结果中途断掉前面几小时的进度全丢了。改成每处理一条就更新内存状态每二十条落一次盘。这样最坏情况只丢二十条的记录而那二十条重新探测一下就能知道状态存在即未完成不存在即已完成代价很小。对于删除判断“是否已完成”非常简单目标不存在就是完成了。所以删除任务天然幂等重跑一次没有任何危害。对于复制判断要复杂一点目标存在并不一定意味着复制完整一个中断的复制可能留下半个集合。这种情况我建议把失败的复制项直接删掉目标再重来而不是指望它能续传。5.4 校验怎么确认复制真的成功了复制完成后的校验步骤绝对不能省。我的做法是分两层。第一层是元数据层比对源和目标的类型、影像数量或要素数量。数量对不上就标记异常。这一层很快适合全量做。第二层是数据层随机抽一到两幅影像各取一个小范围的统计值比如均值或者是波段数和源对比。这一层慢只对关键资产做。抽样检查能抓到一类很隐蔽的问题——数量对得上但内容不对比如源和目标其实指错了地方。还有一种情况值得留个心眼复制过程中源被修改了。这在共享项目里不是不可能复制出来的结果和源对不上。遇到数量或统计值不一致又找不到别的原因可以回头确认一下源集合的更新时间。5.5 一个容易被忽略的旁路命令行工具在浏览器里写 JavaScript 和在本地写 Python 之外还有一条路——官方的命令行工具支持列出、复制、移动、删除这类常见操作其中列目录还带递归选项。它的好处是不用写循环一行命令就能看完整棵树缺点是细粒度控制不如 API比如你要按类型筛选、按前缀跳过、做退避重试还是得自己写。我的实际用法是混合的日常小规模清理直接用命令行几秒钟搞定需要可追溯、可重复、带校验的批量任务才上脚本。命令行那一步通常用来快速核对目录规模和结构相当于给脚本的枚举结果做个交叉验证。6. 只有真跑过才会知道的几个细节这一章收集的是一些零散但确实让人卡住过的点写出来省得别人再走一遍。名字里的特殊字符。路径里带空格、括号、中文的资产在脚本里原样传递一般是没问题的但一旦经过 shell 或者 CSV就会出问题。CSV 里的逗号如果不加引号整行会错位导致删错对象。我现在所有清单都用csv模块或 pandas 写不手拼字符串就是为了避开这个。误删之后能补救到哪一步。结论很干脆删掉的资产本身无法恢复。但有两件事可以做。一是如果你之前在别的路径复制过一份那还能拿回来二是如果删除的是一个 image collection 而里面的影像曾经被单独引用过那些引用也一并失效了。所以我在清理前凡是不确定以后还会不会用到的东西先复制到一个归档目录再删。归档目录的命名固定方便定期清理。共享资产的只读属性。别人共享给你的资产即使出现在你的列表里你也不能删、不能覆盖复制。这类资产在执行阶段会被拒绝脚本要把这种失败和限流失败区分开——前者重试再多次也没用应该直接标记为“跳过”并从后续重试列表里剔除否则你的重试机制会一直在一个永远不可能成功的项上空转白白拖慢整体进度。根目录级别的操作要单独对待。项目 assets 根节点本身是删不掉的脚本如果从根开始递归最后一步会必然失败。处理办法是在删除逻辑里加一层判断遇到根节点直接跳过。这个 bug 通常在你把脚本从子目录切到根目录跑的时候才暴露出来。复制跨项目时的写入目标。目标必须是一个你确实有写权限的项目路径。我遇到过一次失败排查很久最后发现目标路径里的项目名拼错了一个字母接口自作聪明地新建了一个同名目录结构然后把数据写了进去直到下一次跑才因为找不到资产才发现。所以复制之后的那次get_asset探测不只是校验也是防止这类“看起来成功”的假象。我个人在实际操作中的体会是这套东西的技术难度并不高难的是让脚本在出问题时能停下来、能说清楚停在哪、能安全地接着跑。真正花时间的地方不是那几十行 API 调用而是清单、状态、重试、校验这些看起来像“附加功能”的部分。如果你只打算清理一次、规模也就几十个资产手点和脚本区别不大但只要开始出现“这个目录里有上千个中间产物每季度要清一次”的场景把上面这套骨架搭起来后面每一次都是几分钟的事。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。