首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >我用 WorkBuddy + 艺术馆公开 API,做了一个按颜色搜画的工具

我用 WorkBuddy + 艺术馆公开 API,做了一个按颜色搜画的工具

原创
作者头像
用户12616881
发布2026-07-11 05:35:27
发布2026-07-11 05:35:27
550
举报

芝加哥艺术博物馆开放了 13.2 万件藏品的公开 API,每件作品都带有 HSL 色彩数据。我用 WorkBuddy 作为开发搭档,基于这套 API 构建了两个工具:一个按颜色搜画的「色彩探索器 Chromatic」,和一个输入主题自动生成展览页面的「虚拟策展人 Curator」。本文记录完整过程——以及中间踩过的 Cloudflare 拦截、TLS 指纹、CORS 预检等一系列坑。

零、为什么用 WorkBuddy 做这个

这个项目的起点很简单:我偶然发现芝加哥艺术博物馆的 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 字段做范围查询:

代码语言:javascript
复制
{
  "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 在测试阶段帮我发现的,它搜"红色"返回空结果后立刻定位到了色相环回绕问题。

代码语言:txt
复制
{"bool": {"should": [
  {"range": {"color.h": {"gte": 335, "lte": 360}}},
  {"range": {"color.h": {"gte": 0, "lte": 25}}}
], "minimum_should_match": 1}}

二、色彩探索器:Chromatic

设计思路

Chromatic 是一个纯前端单页应用。核心交互链路:

代码语言:txt
复制
用户拖动 HSL 滑块 -> 构建色彩查询 -> 调用 API -> 瀑布流展示匹配作品

界面分三层:

  • 色彩选择区:HSL 三轴滑块 + 10 个艺术史预设色(Rothko Red、Van Gogh Yellow、Klein Blue 等)+ 随机按钮
  • 控制栏:排序方式(色彩丰富度 / 最新 / 最古)+ 公有领域过滤
  • 作品网格:4 列瀑布流,每张卡片显示图片、标题、作者、年代、主色色块和色彩丰富度评分

点击任意卡片弹出详情模态框,展示大图和完整元数据(材质、尺寸、来源、馆藏部门、信用行等),并提供跳转到 artic.edu 原页的链接。

关键实现

API 调用——直接 fetch,因为 api.artic.edu 返回 access-control-allow-origin: *,浏览器可直接访问,无需代理:

代码语言:txt
复制
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 值,纯数学运算:

代码语言:txt
复制
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 防抖:

代码语言:txt
复制
var debounceTimer;
function debouncedSearch() {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(function() { searchArtworks(true); }, 400);
}

骨架屏——加载期间显示 shimmer 动画占位卡,而不是空白或转圈:

代码语言:txt
复制
.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 键随机,适合快速浏览不同颜色的作品:

代码语言:txt
复制
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(); }
});

三、撞墙:图片加载与 Cloudflare

Chromatic 的数据请求一切正常,但图片加载出了大问题——所有作品图片都是裂图。

排查过程

芝加哥艺术博物馆的图片走 IIIF 协议,URL 格式:

代码语言:txt
复制
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,做三件事:

  1. 托管静态 HTML/JS 文件
  2. 把浏览器的 /iiif/{id}/{width} 请求代理到 artic.edu 的 IIIF 服务器
  3. 把图片缓存到磁盘,避免重复请求
代码语言:txt
复制
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 指向代理路径:

代码语言:txt
复制
function iiifUrl(imageId, width) {
  return '/iiif/' + imageId + '/' + (width || 400);
}
// <img src={iiifUrl(art.image_id, 400)} loading="lazy">

线程安全:浏览器同时请求 24+ 张图片,单线程服务器会排队导致超时。必须用 ThreadingHTTPServer + 读写锁保护缓存:

代码语言:txt
复制
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]

启动一行命令:

代码语言:txt
复制
python3 artic_server.py 8765
# Artic proxy server on http://localhost:8765

打开 http://localhost:8765/chromatic.html,图片终于正常加载。

四、虚拟策展人:Curator

色彩探索器跑通后,思路自然延伸:既然能按颜色搜,能不能按主题策展?输入一个词——cats、ocean、war——自动生成一个完整的展览页面。

展览页结构

Curator 的输出不是简单的搜索结果列表,而是一个模拟真实展览的完整页面:

代码语言:txt
复制
┌─────────────────────────────────────────┐
│  Hero 区:大图 + 展览标题 + 元信息       │
│  (作品数 / 跨度年代 / 艺术家数 / 部门数)  │
├──────────────────────────┬──────────────┤
│                          │  侧边栏       │
│  画廊区(3列瀑布流)       │  · 色彩面板   │
│  24件作品的卡片网格        │  · 时间轴     │
│                          │  · 艺术家排行  │
│                          │  · 部门分布   │
├──────────────────────────┴──────────────┤
│  音频导览(API 的 /sounds 端点)          │
├─────────────────────────────────────────┤
│  策展文本(API 的 /texts 端点)           │
└─────────────────────────────────────────┘

并发请求

展览页需要同时拉取作品、音频、文本三类数据,用 Promise.all 并发:

代码语言:txt
复制
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 个铺成色带。鼠标悬停时色块展开,显示色相值:

代码语言:txt
复制
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);

时间轴——找出一组作品中最早和最晚的年代,铺成一条横向时间轴,每件作品是一个可点击的圆点,点击弹出详情:

代码语言:txt
复制
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),降低使用门槛。当前主题高亮,点击即切换。

五、踩坑清单与经验

坑 1:色相环回绕

色相是 0-360 的环形值。搜索红色(H: 0-25 和 H: 335-360)时,如果只写一个 range: {gte: 335, lte: 25} 会返回空结果。必须用 bool.should 拼两个范围:

代码语言:txt
复制
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}}});
  }
  // ...
}

坑 2:伪造浏览器 UA 无效

出于本能,第一反应是给请求加 User-Agent: Mozilla/5.0 ... 来伪装浏览器。但这完全无效——Cloudflare 做 TLS 指纹检测,不看 HTTP 头里的 UA 字符串。一个声称是 Chrome 的请求,如果 TLS 握手特征不是 Chrome 的,照样被挑战。

坑 3:CORS 预检陷阱

AIC-User-Agent 是非标准自定义头。浏览器遇到自定义头会先发 OPTIONS 预检请求,而预检请求不带这个头,于是预检被 403,403 响应没有 CORS 头,整个请求链断裂。这意味着任何需要自定义头才能通过的 API,都无法从纯前端访问,必须走后端代理。

坑 4:单线程服务器并发超时

最初用 http.server.HTTPServer(单线程),浏览器同时请求 24 张图片时,后面的请求全部排队超时。改成 ThreadingHTTPServer 后问题消失。博物馆页面有大量图片,并发请求是常态,单线程模型完全不可用。

坑 5:Python urllib 过不了 Cloudflare

这是最隐蔽的坑。带了正确的 AIC-User-Agent 头,curl 能过,urllib 不能过。表面上看两者发的是同样的 HTTP 请求,但 TLS 握手层不一样。如果只看 HTTP 层面排查,永远找不到原因。

六、WorkBuddy 在这个项目中的真实价值

回顾整个开发过程,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 行

九、后续可做

  1. 全量图片镜像:API 有 122,000+ 张可下载图片,按 IIIF 平均 2.3MB/张估算,全量约 250GB。60 req/min 的限制下,顺序下载约需 34 小时。可行性已验证,按需推进。
  2. IIIF 分块下载:超过 maxArea(~125M 像素)的图片,单次 /full/full/ 请求会被重定向到 843px 缩略图。要拿真·原图需要走 IIIF tile 协议分块下载再拼接。
  3. 主题智能推荐:当前 Curator 是纯文本搜索,可以加入语义分析——输入"孤独"自动关联到 nocturne、solitude、isolation 等关键词,再分别搜索后合并去重。

数据来源Art Institute of Chicago Open Access API · CC0

开发工具:WorkBuddy — 全程对话驱动,从 API 调研到原型交付到排障,一条龙完成

#WorkBuddy开发者分享季 #WorkBuddy #CodeBuddy #API #开源数据 #艺术 #前端开发

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 零、为什么用 WorkBuddy 做这个
  • 一、发现:一个被忽视的色彩宝库
  • 二、色彩探索器:Chromatic
    • 设计思路
    • 关键实现
  • 三、撞墙:图片加载与 Cloudflare
    • 排查过程
    • 破局:本地代理服务器
  • 四、虚拟策展人:Curator
    • 展览页结构
    • 并发请求
    • 侧边栏数据可视化
    • 主题快捷入口
  • 五、踩坑清单与经验
    • 坑 1:色相环回绕
    • 坑 2:伪造浏览器 UA 无效
    • 坑 3:CORS 预检陷阱
    • 坑 4:单线程服务器并发超时
    • 坑 5:Python urllib 过不了 Cloudflare
  • 六、WorkBuddy 在这个项目中的真实价值
  • 七、技术栈总结
  • 八、项目文件
  • 九、后续可做
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档