unDraw 插画搜索接口过一段时间就失效的完整解决方案

unDraw 插画搜索接口「过一段时间就失效」的完整解决方案

适用项目:AI 代码生成系统


一、问题描述

AI 应用需要按关键词自动搜索 unDraw 的开源插画(SVG),用于生成页面时的美化装饰。通过浏览器 F12 抓包,发现搜索功能背后调用了这样一个接口:

text
复制代码
https://undraw.co/_next/data/9SMsYpCjXCftNdh3cu_8Q/search/travel.json?term=travel

返回的是 JSON(pageProps.initialResults 数组),结构清晰、开箱即用。于是把它硬编码进代码:

java
复制代码
private static final String UNDRAW_API_URL = "https://undraw.co/_next/data/9SMsYpCjXCftNdh3cu_8Q/search/%s.json?term=%s";

结果:接口过一段时间就失效(404),换一个新抓到的地址也只能撑一段时间,周而复始。


二、根因剖析

1. 这不是普通 API,而是 Next.js 的「数据路由」

unDraw 是基于 Next.js(Pages Router)构建的站点。对于 SSG / ISR / SSR 页面,Next.js 会额外暴露一套数据接口:

text
复制代码
https://站点/_next/data/{buildId}/页面路径.json?query参数

其中:

  • {buildId}每次构建(部署新版本、内容更新、CI 重新跑)都会重新生成的随机字符串
  • 该接口是 Next.js 框架内部机制,不是站点对外承诺的公开 API,随时可能因架构升级(如迁移到 App Router)而整体消失。

顺带一提:用 F12 抓到的 URL 里还有一个小坑——关键词同时出现在路径段query 里,两处都要传。

2. 为什么「过一段时间」失效

时间线和站点部署周期强相关:unDraw 一旦发布新版本 → 生成新 buildId → 旧 buildId 对应的全部数据路由立即 404。

结论:任何硬编码 buildId 的做法都是治标不治本。

3. 两个伴生问题(容易被误判成「接口失效」)

问题表现原因
关键词未 URL 编码关键词含中文/空格时直接失败路径段与 query 都要求编码,且路径段中不允许 +(需转 %20
无浏览器 UA偶发 403/被风控无头 HTTP 请求特征明显,容易被 CDN 拦截

三、方案设计与选型

方案思路优点缺点结论
A. 解析搜索页 HTML请求 https://undraw.co/search?term=x,正则抽取内嵌的 __NEXT_DATA__ JSON,直接拿 initialResults完全不需要 buildId;一次请求搞定依赖服务端渲染结构;站点改版(如切 App Router)后需适配优质兜底
B. 动态获取 buildId先从首页 HTML 抓当前 buildId(缓存 30 分钟),请求 404 时自动强制刷新重试保留轻量 JSON 接口;改动小;可自愈依赖页面结构;抓取 buildId 多一次请求采用
C. 图片转存自有 COS搜索结果下载 SVG → 转存腾讯云 COS → 返回自托管 URL彻底摆脱 undraw 图床/站点可达性/改版影响,URL 长期稳定有存储与流量成本;首次转存耗时增加采用(长期治理)
D. 静态兜底清单内置热门关键词 → 稳定直链映射极端不可用时仍有图可用覆盖范围有限可选

最终组合拳:B(保证搜索可用)+ C(保证图片长期可用)+ 配套加固(编码 / UA / 缓存 / 降级)。

架构决策点

  • 图片最终是给用户浏览器加载的(<img src>),服务器抓取只是为了 AI 生成时挑选资源。因此「搜索接口稳定性」和「图片 URL 长期有效性」是两个独立问题,要分别治理——这正是采用 B + C 双方案的原因。
  • 转存开销:SVG 单张仅几十 KB,用 Java 21 虚拟线程并发下载 + 上传,整体耗时 ≈ 最慢一张,代价可忽略。

四、核心实现(Java 21 + Spring Boot + Hutool + Caffeine + 腾讯云 COS)

完整源码见上述仓库路径,以下为核心机制与关键代码。

1. buildId 动态获取 + 缓存(30 分钟)

抓取首页 HTML,用正则提取 __NEXT_DATA__ 里的 buildId

java
复制代码
private synchronized String getBuildId(boolean forceRefresh) { long now = System.currentTimeMillis(); if (!forceRefresh && StrUtil.isNotBlank(cachedBuildId) && now - buildIdCachedAtMillis < BUILD_ID_TTL_MILLIS) { return cachedBuildId; // 命中缓存 } try (HttpResponse response = HttpRequest.get(UNDRAW_HOME_URL) .header("User-Agent", USER_AGENT) .timeout(REQUEST_TIMEOUT_MILLIS) .execute()) { if (response.isOk()) { String buildId = ReUtil.get("\"buildId\"\\s*:\\s*\"([^\"]+)\"", response.body(), 1); if (StrUtil.isNotBlank(buildId)) { cachedBuildId = buildId; buildIdCachedAtMillis = now; return buildId; } } } catch (Exception e) { log.warn("获取 undraw buildId 异常: {}", e.getMessage()); } return null; // 返回 null → 走失败降级 }

synchronized 防止多线程并发刷新打爆首页;buildId 是低频变化值,30 分钟 TTL 足够。

2. 请求失败自动「刷新 buildId 重试一次」

java
复制代码
private JSONObject fetchSearchData(String encodedQuery) { // 第一枪:用缓存中的 buildId JSONObject data = requestSearchData(cachedBuildId, encodedQuery); if (data != null) { return data; } // 第二枪:大概率 buildId 过期(undraw 刚发布新版本),强制刷新后再试 log.warn("undraw 数据路由请求失败,尝试刷新 buildId 后重试"); String freshBuildId = getBuildId(true); if (StrUtil.isBlank(freshBuildId)) { return null; } return requestSearchData(freshBuildId, encodedQuery); }

这是整套方案里最核心的「自愈」设计:即使 undraw 在 TTL 窗口内重新部署,也只会多浪费一次请求,而不会让功能长时间失效。

3. 关键词编码(修复中文/空格 404)

java
复制代码
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8).replace("+", "%20");

注意:URLEncoder 把空格编码为 +,而路径段中 + 是字面字符,必须替换为 %20

4. 结果解析(与原生接口结构一致)

java
复制代码
JSONObject pageProps = data.getJSONObject("pageProps"); JSONArray initialResults = pageProps.getJSONArray("initialResults"); // 遍历取 title / media,封装为 ImageResource(category=ILLUSTRATION)

5. COS 自托管:虚拟线程并发转存 + 单张失败降级

java
复制代码
private List<ImageResource> mirrorToCos(List<ImageResource> imageList) { if (!isCosConfigured()) { // 未配置 COS → 直接返回原直链 return imageList; } try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { for (ImageResource image : imageList) { executor.submit(() -> mirrorSingleToCos(image)); } } // try-with-resources:close() 会等待全部任务完成 return imageList; } private void mirrorSingleToCos(ImageResource image) { String originUrl = image.getUrl(); File tempFile = null; try { // 1) 下载 SVG byte[] imageBytes = HttpRequest.get(originUrl) .header("User-Agent", USER_AGENT) .timeout(REQUEST_TIMEOUT_MILLIS).execute().bodyBytes(); // 2) 写临时文件 → 上传 COS tempFile = File.createTempFile("undraw-", ".svg"); FileUtil.writeBytes(imageBytes, tempFile); String cosKey = "/illustrations/" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM")) + "/" + UUID.randomUUID() + ".svg"; String cosUrl = cosManager.uploadFile(cosKey, tempFile); if (StrUtil.isNotBlank(cosUrl)) { image.setUrl(cosUrl); // 3) 替换为自托管地址 } } catch (Exception e) { log.warn("插画转存 COS 失败,保留原直链: {}, 原因: {}", originUrl, e.getMessage()); } finally { if (tempFile != null && tempFile.exists()) { FileUtil.del(tempFile); // 清理临时文件 } } }

要点:

  • 逐张独立降级:某一张下载/上传失败只影响它自己,其余图片照常转存;
  • 配置预检cos.client.host 为空视为未配置,直接跳过转存——避免拼出 null/illustrations/... 假 URL,也让无 COS 环境(如单元测试)能正常返回直链;
  • key 按 yyyyMM 归档,方便定期清理。

6. 整体调用链

text
复制代码
searchIllustrations(keyword) ├─ 命中 Caffeine 缓存(6h)? → 直接返回 ├─ URL 编码关键词 ├─ [第 1 枪] 数据路由请求(缓存的 buildId) │ └─ 失败 → [第 2 枪] 强制刷新 buildId 后重试 │ └─ 仍失败 → 返回空列表(warn 日志) ├─ 解析 pageProps.initialResults(最多 12 张) └─ 虚拟线程并发:逐张下载 → 转存 COS(失败保留原直链)

7. 配套加固清单

做法目的
关键词编码URLEncoder + +%20中文/空格关键词可用
User-Agent全链路携带浏览器 UA降低 CDN 风控概率
结果缓存Caffeine,6 小时 / 200 条不重复打 undraw、不重复上传 COS
buildId 缓存volatile + 30 分钟 TTL减少首页抓取频率
超时控制单请求 10s快速失败不拖垮 AI 调用
失败降级搜索失败→空列表;转存失败→原直链任何单点故障不影响主流程
清理临时文件 finally 删除;COS 按月归档不产生垃圾

五、验证方法

  1. 正常搜索:UndrawIllustrationToolTest#testSearchIllustrations(需能访问 undraw);
    • 未配置 COS → 日志「未配置腾讯云 COS,插画保留 undraw 原直链」,返回 https://images.undraw.co/...
    • 已配置 COS → 返回 https://{桶域名}/illustrations/202609/xxx.svg
  2. 自愈演练:把 cachedBuildId 手动改成错误值(或等 30 分钟过期),再次调用,观察日志出现「尝试刷新 buildId 后重试」并成功返回。
  3. 中文关键词回归:搜索「旅行」「happy birthday」,确认不再 404。

六、经验总结(可迁移到其他站点)

  1. 识别框架再动手:看到 /_next/data//_next/static/{id}/_buildManifest.js__NEXT_DATA____next_f 等特征,即可判断是 Next.js,且知道 buildId 是「一次性」的。同类思路适用于任何带随机部署标识的站点(如 Vite 的 hash 资源、Webpack 的 chunk hash)。
  2. 抓内部接口 = 承接框架债:浏览器抓到的私有接口不受稳定性承诺保护,必须做「动态获取标识 + 缓存 + 失效自愈」或「解析服务端渲染 HTML」双保险。
  3. 图片/静态资源与接口分开治理:接口失效影响「搜索」,图床失效影响「展示」,两者都要有降级预案;终极方案永远是自托管(自有对象存储/CDN)。
  4. 工具类代码的健壮性标准:AI Agent 场景下工具可能被高频并发调用,超时、缓存、逐项降级、幂等都是基本要求,任何一个第三方依赖抖动都不应让主流程失败。

七、参考资料

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP