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 按月归档 | 不产生垃圾 |
五、验证方法
- 正常搜索:
UndrawIllustrationToolTest#testSearchIllustrations(需能访问 undraw);- 未配置 COS → 日志「未配置腾讯云 COS,插画保留 undraw 原直链」,返回
https://images.undraw.co/...; - 已配置 COS → 返回
https://{桶域名}/illustrations/202609/xxx.svg。
- 未配置 COS → 日志「未配置腾讯云 COS,插画保留 undraw 原直链」,返回
- 自愈演练:把
cachedBuildId手动改成错误值(或等 30 分钟过期),再次调用,观察日志出现「尝试刷新 buildId 后重试」并成功返回。 - 中文关键词回归:搜索「旅行」「happy birthday」,确认不再 404。
六、经验总结(可迁移到其他站点)
- 识别框架再动手:看到
/_next/data/、/_next/static/{id}/_buildManifest.js、__NEXT_DATA__、__next_f等特征,即可判断是 Next.js,且知道 buildId 是「一次性」的。同类思路适用于任何带随机部署标识的站点(如 Vite 的 hash 资源、Webpack 的 chunk hash)。 - 抓内部接口 = 承接框架债:浏览器抓到的私有接口不受稳定性承诺保护,必须做「动态获取标识 + 缓存 + 失效自愈」或「解析服务端渲染 HTML」双保险。
- 图片/静态资源与接口分开治理:接口失效影响「搜索」,图床失效影响「展示」,两者都要有降级预案;终极方案永远是自托管(自有对象存储/CDN)。
- 工具类代码的健壮性标准:AI Agent 场景下工具可能被高频并发调用,超时、缓存、逐项降级、幂等都是基本要求,任何一个第三方依赖抖动都不应让主流程失败。
七、参考资料
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
