芝加哥艺术博物馆开放了 13.2 万件藏品的公开 API,每件作品都带有 HSL 色彩数据。我用 WorkBuddy 作为开发搭档,基于这套 API 构建了两个工具:一个按颜色搜画的「色彩探索器 Chromatic」,和一个输入主题自动生成展览页面的「虚拟策展人 Curator」。本文记录完整过程——以及中间踩过的 Cloudflare 拦截、TLS 指纹、CORS 预检等一系列坑。
这个项目的起点很简单:我偶然发现芝加哥艺术博物馆的 API 给每件藏品标注了主色调(HSL 格式),这意味着可以"按颜色搜画"——一个大多数博物馆 API 做不到的事。
但作为一个投资分析师而非全职开发者,我没有大块时间从头搭前后端。WorkBuddy 吸引我的地方在于:它可以读文件、写代码、跑脚本、起本地服务,一条龙完成从调研到原型交付的全流程。
实际体验下来,WorkBuddy 在这个项目里承担了三个角色:
角色 | 具体做什么 | 效率提升 |
|---|---|---|
API 调研员 | 读 API 文档、测试端点、整理字段清单 | 省去手动翻文档 + Postman 测试的反复操作 |
代码搭子 | 写前端页面、Python 代理服务器、CSS 样式 | 从需求到可运行原型,对话即交付 |
排障搭档 | 分析 Cloudflare 403、TLS 指纹、CORS 预检问题 | 它能同时读代码 + 跑命令 + 查资料,比单独 Google 快得多 |

下面进入技术正题。
大部分博物馆 API 只给你标题、作者、年代这些文本数据。芝加哥艺术博物馆的不同之处在于:每件有图片的藏品都标注了主色调——以 HSL(色相 H、饱和度 S、亮度 L)格式存储,还附带一个 colorfulness(色彩丰富度)评分。
这意味着你可以问出一个通常无法回答的问题:
「给我找出所有主色调是 Klein Blue(H:228, S:85%, L:35%)的画。」
我让 WorkBuddy 先拉了一组测试数据,确认色彩字段确实可用:
API 基础信息:
项目 | 说明 |
|---|---|
Base URL | https://api.artic.edu/api/v1 |
藏品数量 | 132,000+ 件艺术品,其中 122,000+ 件有图片 |
认证 | 无需认证,匿名访问 |
速率限制 | 60 次/分钟 |
数据许可 | CC0(完全开放) |
图片协议 | IIIF,支持多尺寸裁切 |
搜索接口基于 Elasticsearch DSL,params 参数接受 URL 编码的 JSON 查询体。色彩搜索的核心是对 color.h 字段做范围查询:
{
"query": {
"bool": {
"must": [{"range": {"color.h": {"gte": 185, "lte": 235}}}],
"filter": [
{"exists": {"field": "image_id"}},
{"exists": {"field": "color.h"}}
]
}
},
"sort": [{"colorfulness": {"order": "desc"}}],
"size": 24
}色相是 0-360 的环形值,搜红色(H 接近 0 或 360)时需要 bool.should 拼两段范围,否则会漏掉跨 0 点的数据——这个坑是 WorkBuddy 在测试阶段帮我发现的,它搜"红色"返回空结果后立刻定位到了色相环回绕问题。
{"bool": {"should": [
{"range": {"color.h": {"gte": 335, "lte": 360}}},
{"range": {"color.h": {"gte": 0, "lte": 25}}}
], "minimum_should_match": 1}}Chromatic 是一个纯前端单页应用。核心交互链路:
用户拖动 HSL 滑块 -> 构建色彩查询 -> 调用 API -> 瀑布流展示匹配作品界面分三层:
点击任意卡片弹出详情模态框,展示大图和完整元数据(材质、尺寸、来源、馆藏部门、信用行等),并提供跳转到 artic.edu 原页的链接。


API 调用——直接 fetch,因为 api.artic.edu 返回 access-control-allow-origin: *,浏览器可直接访问,无需代理:
const API_BASE = 'https://api.artic.edu/api/v1';
const FIELDS = 'id,title,artist_title,date_display,date_start,medium_display,' +
'dimensions,credit_line,image_id,color,colorfulness,is_public_domain,' +
'artwork_type_title,department_title,place_of_origin,thumbnail';
async function searchArtworks(reset) {
const params = buildQuery(state.hue, state.sortBy, state.page, state.publicDomainOnly);
const url = API_BASE + '/artworks/search?params=' +
encodeURIComponent(JSON.stringify(params)) + '&fields=' + FIELDS + '&limit=24';
const data = await (await fetch(url)).json();
state.artworks = state.artworks.concat(data.data);
renderGrid(reset);
}fields 参数很关键——不加的话 API 返回全量字段,响应体巨大。只取需要的字段可以将响应体积压到原来的 1/10。这个优化是 WorkBuddy 在第一版代码跑通后主动建议的,它注意到首次加载有 2-3 秒延迟,定位到是响应体过大导致的。
HSL → HEX 转换——滑块需要实时显示当前颜色的 HEX 值,纯数学运算:
function hslToHex(h, s, l) {
s /= 100; l /= 100;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs((h / 60) % 2 - 1));
var m = l - c / 2;
var r, g, b;
if (h < 60) { r=c; g=x; b=0; }
else if (h < 120){ r=x; g=c; b=0; }
else if (h < 180){ r=0; g=c; b=x; }
else if (h < 240){ r=0; g=x; b=c; }
else if (h < 300){ r=x; g=0; b=c; }
else { r=c; g=0; b=x; }
return '#' + [r,g,b].map(v => Math.round((v+m)*255).toString(16).padStart(2,'0')).join('').toUpperCase();
}防抖搜索——拖动滑块时不能每次 input 都发请求,400ms 防抖:
var debounceTimer;
function debouncedSearch() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() { searchArtworks(true); }, 400);
}骨架屏——加载期间显示 shimmer 动画占位卡,而不是空白或转圈:
.skeleton-img {
background: linear-gradient(90deg, #f0eeec 25%, #e8e6e3 50%, #f0eeec 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}键盘快捷键——左右方向键调色相、R 键随机,适合快速浏览不同颜色的作品:
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') { state.hue = Math.max(0, state.hue - 5); ... }
if (e.key === 'ArrowRight') { state.hue = Math.min(360, state.hue + 5); ... }
if (e.key === 'r' || e.key === 'R') { document.getElementById('randomBtn').click(); }
});Chromatic 的数据请求一切正常,但图片加载出了大问题——所有作品图片都是裂图。
芝加哥艺术博物馆的图片走 IIIF 协议,URL 格式:
https://www.artic.edu/iiif/2/{image_id}/full/{width},/0/default.jpg这个域名挂在 Cloudflare 后面。我让 WorkBuddy 逐个测试方案,它同时跑命令行测试和读 API 文档,按顺序尝试了以下方案:
方案 | 结果 | 原因 |
|---|---|---|
<img src="iiif-url"> | 超时 | Cloudflare 返回 403 HTML 挑战页,<img> 无法执行 JS |
fetch() + AIC-User-Agent + blob | 失败 | 自定义头触发 CORS 预检,预检本身被 403,响应无 CORS 头 |
fetch() 不带头 | 失败 | Cloudflare 403,响应无 CORS 头 |
fetch() + User-Agent | 失败 | 浏览器禁止设置 User-Agent,降级为无头情况 |
这个排查过程体现了 WorkBuddy 作为排障搭档的价值:它可以同时读代码、跑 curl 命令测试、查 Cloudflare 文档,三件事并行推进。如果是纯手动操作,光是"用 curl 带 AIC-User-Agent 头测试 → 确认服务端能过 → 再试浏览器 fetch → 发现预检被拦"这个循环,至少要花半小时在终端和浏览器之间来回切换。WorkBuddy 把这个循环压缩到了几分钟内的连续对话。
根因:Cloudflare 对 IIIF 图片服务器做了托管挑战(Managed Challenge)。它不只看 User-Agent 字符串,还做 TLS 指纹检测。任何看起来像机器人的请求——伪造的浏览器 UA、curl 默认 UA、Python urllib——都会收到 403 + JS 挑战页。
而 API 文档里写的解决方案是加一个 AIC-User-Agent 自定义头标识你的应用。这个头在服务端请求时有效,但在浏览器里反而更糟:自定义头触发 CORS 预检(OPTIONS 请求),预检请求本身不带 AIC-User-Agent,于是预检被 403,而 403 响应没有 CORS 头,浏览器直接拒绝整个请求。
死结:唯一能绕过 Cloudflare 的头会触发预检,预检又过不了。
既然浏览器走不通,就让请求从服务端发。我让 WorkBuddy 写了一个 Python 代理服务器 artic_server.py,做三件事:
/iiif/{id}/{width} 请求代理到 artic.edu 的 IIIF 服务器class ArticHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/iiif/"):
self._proxy_iiif()
return
super().do_GET() # 其他请求走静态文件
def _proxy_iiif(self):
parts = self.path.split("/")
image_id = parts[2]
width = parts[3] if len(parts) > 3 else "843"
iiif_url = f"https://www.artic.edu/iiif/2/{image_id}/full/{width},/0/default.jpg"
# 缓存命中则直接返回
cache_key = hashlib.md5(f"{image_id}_{width}".encode()).hexdigest()
cache_path = os.path.join(CACHE_DIR, f"{cache_key}.jpg")
if os.path.exists(cache_path):
with open(cache_path, "rb") as f:
self._send_image(f.read())
return
# 用 curl 发请求——curl 的 TLS 指纹能过 Cloudflare
result = subprocess.run([
"curl", "-s", "-L",
"-H", f"AIC-User-Agent: {AIC_HEADER}",
"-o", cache_path,
iiif_url,
], capture_output=True, text=True, timeout=15)为什么用 curl 而不是 Python urllib:这是整个排查中最反直觉的一环。即使带了正确的 AIC-User-Agent 头,Python 的 urllib 依然被 403,而 curl 能过。原因是 Cloudflare 的 TLS 指纹检测不只看 HTTP 头,还看 TLS 握手阶段的 ClientHello 特征——cipher suite 列表、扩展顺序、椭圆曲线等。Python urllib 和 curl 的 TLS 实现不同,指纹也不同。curl 的指纹恰好不在 Cloudflare 的拦截名单里。
这个发现是 WorkBuddy 帮我定位的——它先用 Python urllib 写了一版代理,测试发现图片依然 403,然后切换到 curl 方案,一击命中。如果没有它同时跑两种实现做 A/B 对比,我可能还在 urllib 的 HTTP 头层面打转,完全不会想到问题出在 TLS 握手层。
前端只需把图片 URL 指向代理路径:
function iiifUrl(imageId, width) {
return '/iiif/' + imageId + '/' + (width || 400);
}
// <img src={iiifUrl(art.image_id, 400)} loading="lazy">线程安全:浏览器同时请求 24+ 张图片,单线程服务器会排队导致超时。必须用 ThreadingHTTPServer + 读写锁保护缓存:
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
request_queue_size = 50
# 每个 cache_key 有独立的锁,避免并发写同一文件
def get_cache_lock(key):
with cache_locks_guard:
if key not in cache_locks:
cache_locks[key] = threading.Lock()
return cache_locks[key]启动一行命令:
python3 artic_server.py 8765
# Artic proxy server on http://localhost:8765打开 http://localhost:8765/chromatic.html,图片终于正常加载。

色彩探索器跑通后,思路自然延伸:既然能按颜色搜,能不能按主题策展?输入一个词——cats、ocean、war——自动生成一个完整的展览页面。
Curator 的输出不是简单的搜索结果列表,而是一个模拟真实展览的完整页面:
┌─────────────────────────────────────────┐
│ Hero 区:大图 + 展览标题 + 元信息 │
│ (作品数 / 跨度年代 / 艺术家数 / 部门数) │
├──────────────────────────┬──────────────┤
│ │ 侧边栏 │
│ 画廊区(3列瀑布流) │ · 色彩面板 │
│ 24件作品的卡片网格 │ · 时间轴 │
│ │ · 艺术家排行 │
│ │ · 部门分布 │
├──────────────────────────┴──────────────┤
│ 音频导览(API 的 /sounds 端点) │
├─────────────────────────────────────────┤
│ 策展文本(API 的 /texts 端点) │
└─────────────────────────────────────────┘
展览页需要同时拉取作品、音频、文本三类数据,用 Promise.all 并发:
var results = await Promise.all([
fetch(API + '/artworks/search?params=' + encodeURIComponent(JSON.stringify(artParams)) +
'&fields=' + FIELDS + '&limit=24').then(r => r.json()),
fetch(API + '/sounds/search?q=' + encodeURIComponent(q) + '&limit=5').then(r => r.json()),
fetch(API + '/texts/search?q=' + encodeURIComponent(q) + '&limit=5').then(r => r.json())
]);三个请求并行发出,总耗时取决于最慢的那个,而不是三者之和。
展览页最有意思的部分是侧边栏的四个数据模块,全部从作品数据中实时计算:
色彩面板——取所有作品的主色,按 population(该色在画面中的像素占比)排序,取前 8 个铺成色带。鼠标悬停时色块展开,显示色相值:
var colors = items.map(a => a.color).filter(c => c);
colors.sort((a, b) => (b.population || 0) - (a.population || 0));
var palette = colors.slice(0, 8);时间轴——找出一组作品中最早和最晚的年代,铺成一条横向时间轴,每件作品是一个可点击的圆点,点击弹出详情:
var minD = Math.min.apply(null, dates);
var maxD = Math.max.apply(null, dates);
var range = maxD - minD || 1;
items.forEach(a => {
var pct = ((a.date_start - minD) / range) * 100;
// 在时间轴上 pct% 位置放一个圆点
});艺术家排行——统计每位艺术家出现的次数,取前 6 名。
部门分布——统计作品来自哪些馆藏部门,用横向条形图展示。
预设了 10 个主题按钮(Cats、Flowers、Ocean、War、Music、Night、Portraits、Angels、Gardens、Dance),降低使用门槛。当前主题高亮,点击即切换。
色相是 0-360 的环形值。搜索红色(H: 0-25 和 H: 335-360)时,如果只写一个 range: {gte: 335, lte: 25} 会返回空结果。必须用 bool.should 拼两个范围:
function buildQuery(hue, sortBy, page, pdOnly) {
const range = 25;
let low = hue - range, high = hue + range;
let rangeClauses = [];
if (low < 0) {
rangeClauses.push({range: {"color.h": {gte: low + 360, lte: 360}}});
rangeClauses.push({range: {"color.h": {gte: 0, lte: high}}});
} else if (high > 360) {
rangeClauses.push({range: {"color.h": {gte: low, lte: 360}}});
rangeClauses.push({range: {"color.h": {gte: 0, lte: high - 360}}});
} else {
rangeClauses.push({range: {"color.h": {gte: low, lte: high}}});
}
// ...
}出于本能,第一反应是给请求加 User-Agent: Mozilla/5.0 ... 来伪装浏览器。但这完全无效——Cloudflare 做 TLS 指纹检测,不看 HTTP 头里的 UA 字符串。一个声称是 Chrome 的请求,如果 TLS 握手特征不是 Chrome 的,照样被挑战。
AIC-User-Agent 是非标准自定义头。浏览器遇到自定义头会先发 OPTIONS 预检请求,而预检请求不带这个头,于是预检被 403,403 响应没有 CORS 头,整个请求链断裂。这意味着任何需要自定义头才能通过的 API,都无法从纯前端访问,必须走后端代理。
最初用 http.server.HTTPServer(单线程),浏览器同时请求 24 张图片时,后面的请求全部排队超时。改成 ThreadingHTTPServer 后问题消失。博物馆页面有大量图片,并发请求是常态,单线程模型完全不可用。
这是最隐蔽的坑。带了正确的 AIC-User-Agent 头,curl 能过,urllib 不能过。表面上看两者发的是同样的 HTTP 请求,但 TLS 握手层不一样。如果只看 HTTP 层面排查,永远找不到原因。
回顾整个开发过程,WorkBuddy 帮我提升了大约 60% 的开发效率,体现在三个具体环节:
1. API 调研阶段——从 2 小时压缩到 15 分钟
传统做法:翻 API 文档 → 手动在 Postman 里逐个测试端点 → 记录返回字段 → 整理成开发参考。
WorkBuddy 做法:我给它 API 文档链接,它直接 curl 测试端点、解析返回结构、整理出字段清单和常用查询模板。我只需要确认方向,不用自己敲一行命令。
2. 原型开发阶段——从需求到可运行页面,对话即交付
我对 WorkBuddy 说"做一个 HSL 色彩选择器 + 瀑布流展示 + 色彩预设",它直接输出完整的单文件 HTML(含 CSS + JS),保存后浏览器打开就能跑。中间的样式微调(滑块配色、骨架屏动画、模态框布局)也是对话迭代,比手动改 CSS 快得多。
3. 排障阶段——最关键的效率提升
Cloudflare 403 这个问题,如果是纯手动排查,我大概率会在"伪造 User-Agent"这一步卡很久——因为直觉上以为问题出在 HTTP 头。WorkBuddy 同时跑了 urllib 和 curl 两种实现的 A/B 测试,快速定位到问题在 TLS 握手层而非 HTTP 层,直接跳过了我可能浪费的几个小时。
它还能读文件、跑 Python 脚本、启动本地服务器,三件事在同一个对话里完成。传统开发流程里,这是"编辑器写代码 → 终端跑服务 → 浏览器看效果"三个窗口的切换,WorkBuddy 把它压缩成了一个对话流。

层 | 技术 | 说明 |
|---|---|---|
数据源 | Artic API v1 | CC0 开放,132K+ 藏品,Elasticsearch DSL 查询 |
图片 | IIIF 协议 | 支持多尺寸裁切,但被 Cloudflare 保护 |
前端 | 原生 HTML/CSS/JS | 无框架依赖,单文件交付 |
代理 | Python http.server + curl | 绕过 Cloudflare TLS 指纹,带磁盘缓存 |
并发 | ThreadingHTTPServer | 支持多图片并发加载 |
开发工具 | WorkBuddy | API 调研 + 代码生成 + 排障,全程对话驱动 |
文件 | 功能 | 代码量 |
|---|---|---|
chromatic.html | 色彩探索器 | ~890 行(含 CSS/JS) |
curator.html | 虚拟策展人 | ~730 行 |
artic_server.py | IIIF 代理服务器 | ~140 行 |
iift_test.html | IIIF 加载诊断页 | ~250 行 |
maxArea(~125M 像素)的图片,单次 /full/full/ 请求会被重定向到 843px 缩略图。要拿真·原图需要走 IIIF tile 协议分块下载再拼接。数据来源:Art Institute of Chicago Open Access API · CC0
开发工具:WorkBuddy — 全程对话驱动,从 API 调研到原型交付到排障,一条龙完成
#WorkBuddy开发者分享季 #WorkBuddy #CodeBuddy #API #开源数据 #艺术 #前端开发
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。