这是「开源项目介绍」系列第三篇,写的是我最近用得最多的一个小工具:让 agent 自己去网上搜图、下载、然后把图直接贴在对话里——我不用再打开浏览器右键另存。

仓库:github.com/fomalhaut1998/dsh-image-hunt · npm:dsh-image-hunt

它只做公开页面的检索与下载:不登录、不绕付费墙、不破解任何站点。图片版权归原作者,MIT 只覆盖代码。

一、起因:图和话,从来不在一个地方

我写文章要配图,做 PPT 要背景图,做界面要参考图。以前的流程是固定的五步:

  1. 打开浏览器,在百度或 Bing 里敲关键词;
  2. 一张张翻,右键,另存为;
  3. 到下载目录里改名,从“微信截图 2026-08-31 (3).jpg”改成看得懂的名字;
  4. 拖进项目的 images/ 目录;
  5. 回到会话里,把路径告诉 agent,它才能读。

问题不在哪一步难,而在这五步必须由我亲手做完。agent 能读图、能改图、能把图排进 HTML,唯独“去网上找一张图”这一段断了,因为它在浏览器外面。

有一次我为了三张“秋天街道”的图,折腾了二十分钟。图存下来了,名字是乱的,其中一张后来发现是 400×300 的小图,放大就糊。那一刻我想,这件事应该由 agent 自己干:我把需求说清楚,它去找、去下载、去校验尺寸,最后把图摆在对话里让我挑。

这个插件就是那件事。

二、它其实只做三件事

三个工具,边界切得很清楚:

工具干什么关键参数
image_hunt搜 + 下 + 显示,一条龙query 必填;count 默认 8(1–40);engine 默认 auto;dir 默认 images
image_search只搜,不落盘无 count、无 dir,结果截前 20 条
image_download已知直链,批量下载urls 1–30 条;prefix 默认 img-;referer 可选

我平时只用第一个。剩下两个是我在调试时拆出来的:想知道“到底哪个源返回了什么”就用 image_search,想把某个网页上的图搬下来就用 image_download。

第一个工具还带了几个只对特定源生效的参数——minPixels 卡最短边、noAI 只要非 AI 作品(仅 Pixiv)、collection 和 color 只对 Iconify 图标库有意义。参数不生效的时候它不报错,只是忽略,这比报错更符合实际使用习惯。

三、14 个源,7 个别名

engine 字段能填 21 种值:14 个真实源,加 7 个别名。

先说源,都是我一个个试出来能用的:

源类型一次实测的候选数特点
百度图片综合 · 中文60中文语义最强,返回的 JSON 最脏
Bing 图片综合 · 国际70原图多,国际站不通会自动退 cn
360 图片综合 · 中文259量大速度快,质量参差
堆糖插画 · 头像47手账、壁纸、可爱风
花瓣设计 · 灵感40设计师采集,成套图多
DuckDuckGo国外 · 聚合99走代理,聚合 Bing 与 Google 结果
Yandex国外 · 原图60Pinterest 那类高清原图多
OpenverseCC 授权库60免 key,偏可商用
Wikimedia公有领域40素材、地图、纪实照片
Pixiv二次元82需要 Referer,有 AI 标记
Wallhaven二次元壁纸48–96分辨率高,标签规整
Konachan二次元44老牌 booru
Safebooru二次元100全年龄向 booru
Iconify图标40输出 SVG,可改色

表里的候选数是某一次实测的数字,不是保证值。同一个词今天搜和明天搜,结果条数会变。

再说别名,别名解决的是“我不想记 14 个名字”:

别名展开成什么
auto百度 + 360 + Bing,日常最稳的一档
all13 个照片源全开,一次能捞 270–371 条
both百度 + 必应
intlDuckDuckGo + Yandex + Bing
ccOpenverse + Wikimedia,找能商用的图
animePixiv + Wallhaven + Konachan
moeanime 再加 Safebooru

三种写法都行:单个源 id、一个别名、或者 sources 数组直接覆盖 engine。数组写法适合“我就要这两家的图”这种明确需求,比如 sources: ["huaban", "duitang"]。

四、一次 image_hunt 的完整路径

dsh-image-hunt 数据流:从一句话到一张图

一句话进来,走完六步:

  1. 查源注册表。engine 和 sources 归一成一个源列表,每个源带一个 foreign 标记,决定待会儿走不走代理。
  2. 并行抓取。每个源一个 Promise,谁也不等谁。单源超时 20–35 秒,一个源挂了只往 errors 里记一条,其它源照常返回——这条是整个插件可用性的地基,早先写成串行的时候,一个慢源能把整次搜索拖死。
  3. 轮转合并。如果按源顺序拼接,百度 60 条会把后面的源全挤到第 60 名之后。改成按源轮转交错,第一名来自百度、第二名来自 Bing、第三名来自 360,翻两页就能看到各家风格。
  4. 去重与过滤。按原图直链去重,再按 minPixels 卡最短边,不合格的条目单独记进 skipped,不吞掉。
  5. 下载校验。单张上限 MAX_BYTES = 40 MB,超了跳过;格式不靠后缀,靠文件头。
  6. 落盘 + 登记。写进 <dir>/<关键词>/,同时把每一张登记成会话附件(attachment),于是图会直接出现在对话里。

第 6 步的“登记”是这个插件和普通下载脚本最大的区别。它调用 ctx.attachments.saveImage,把文件变成当前会话可寻址的附件——不是给一个磁盘路径让我自己去翻。

五、代理只给国外源

dsh-image-hunt 的代理策略与三个坑

国内源直连,国外源走本机代理。策略看着简单,写起来有三个坑:

坑一:自己造的 socket 会被默认 Agent 忽略。 隧道连好之后,我把 socket 塞进 http.request 的 createConnection,以为请求会顺着走,结果它绕开隧道直连去了。原因是 Node 的隐式默认 Agent 不认你传进去的 socket。最后是 new Agent({ keepAlive: false, maxSockets: 1 }) 覆写 createConnection 才收住。

坑二:CONNECT 的响应里会带 bytes。 代理回的 200 Connection Established 后面可能紧跟第一段 TLS 握手字节。要一直累积到 \r\n\r\n 才算读完响应头,剩下的字节 unshift 回 socket,否则握手失败,报错还特别难懂。

坑三:IPv6 空耗。 直连兜底时强制 IPv4。不强制的话,只有 AAAA 记录的域名会先等一轮超时再回退,一次搜索白等十秒。

代理地址的解析顺序是 IMAGE_HUNT_PROXY → HTTPS_PROXY → HTTP_PROXY,默认 http://127.0.0.1:7890;写 off、none、0、false 都是关掉代理。代理真不通的时候,熔断 60 秒,然后回退直连——大概率也是失败,但会把 code 和 addrs 记进 proxyErrors(只留最近 20 条),让我知道是超时还是被拒。

六、五个真实的坑

① 百度的 JSON 有两处“非法”。 字符串里会出现裸的 TAB 控制字符,还会把单引号转义成 \'——两种都不是合法 JSON。我的处理是三级降级:先原样 JSON.parse;失败就剥掉 [\u0000-\u001f] 控制字符再试;再失败就把 \' 修回 '。三级都不行,走 baiduRows() 的正则兜底,一行一行扒字段。

② booru 只认 ASCII tag。 搜“宫崎骏”喂给 Konachan,结果稳定为空。内置了一张 TAG_MAP:宫崎骏、吉卜力 → ghibli,龙猫 → totoro,千与千寻 → spirited_away,魔女宅急便 → kiki,初音未来 → hatsune_miku。翻译不过来就退化成原词,让上游返回空——比瞎猜一个 tag 老实。

③ Pixiv 不给 Referer 就 403。 原图在 i.pximg.net,下载必须带 Referer: https://www.pixiv.net/。另外 ai_type 这个参数在服务端不生效,客户端自己按 aiType(1 = 非 AI,2 = AI)过滤。

④ 后缀会撒谎。 Safebooru 的 file_url 常以 .jpg 结尾,实际文件是 PNG。sniffExt 按字节头判断:\xFF\xD8\xFF 是 JPEG,\x89PNG 是 PNG,RIFF....WEBP 是 WebP。按后缀存,后面拿去做缩略图会炸。

⑤ 尺寸也会撒谎。 Yandex 报 2844×1600,文件打开是 2730×1536;Wikimedia 报的是 2000×1333 的缩略图,实际原图 3000×2000。所以 imgSize 自己读头部取真实像素,接口给的尺寸另存成 metaWidth / metaHeight——保留,但不信。

七、落盘长什么样

1
2
3
4
5
6
images/
└── 禾木冬景/
├── 01.jpg
├── 02.jpg
├── 03.jpg
└── meta.json

meta.json 是数组,每一项对应一张图:

1
2
3
4
5
6
7
8
9
10
11
{
"url": "https://example.com/original/abc.jpg",
"page": "https://example.com/detail/abc",
"title": "禾木的冬天",
"site": "花瓣",
"license": "unknown",
"width": 1800,
"height": 1200,
"metaWidth": 2000,
"metaHeight": 1333
}

字段不多,但每一个都有来处:url 是原图直链,page 是来源页(想追溯就点这个),site 是中文站名,license 只在 Openverse、Wikimedia 这类有明确授权字段的源才有值,其余写 unknown——不猜授权。

输出的 files[].path 永远是工作区相对路径,safeJoin 把一切输出收口在会话 cwd 里,想往 ../ 写会直接抛错。好处是路径能直接喂给 read_image、拼进 HTML、或者交给 present 挂成文件卡片。

八、命令行也能跑

不想进会话的时候,有 cli.mjs:

1
2
node cli.mjs "禾木 冬天" --n 4 --min-pixels 1200 --engine anime --noai \
--sources huaban,duitang --out images/still-life

参数和工具调用一一对应:--n 是 count,--min-pixels 是 minPixels,--noai 是 noAI,--out 是落盘目录。最后一行输出 JSON,方便管道接给别的脚本。package.json 里挂了 bin,所以全局装完可以直接 dsh-image-hunt "关键词"。

九、三种装法

1
2
3
4
5
6
7
8
# 1. 从 npm
dsh plugin --profile web add dsh-image-hunt

# 2. 直接从 GitHub
dsh plugin --profile web add github:fomalhaut1998/dsh-image-hunt

# 3. 从本地目录(改代码时用)
dsh plugin --profile web link "F:\DSH Projects\Untitled\plugins\dsh-image-hunt"

装完必须重启 DSH。插件的工具是在宿主启动时注册的,热加载不到。

十、搜不到的那些,我也都试过

写插件最花时间的不是把能用的接上,是搞清楚哪些接不上、为什么。这几家我试完放弃了:

目标实测结果结论
Google 图片tbm=isch 只回约 92 KB 的 JS 壳,AF_initDataCallback 与 encrypted-tbn 计数都是 0要跑无头浏览器,不值
搜狗图片napi 直接返回 {"status":1,"info":"forbid"}接口关了
X(Twitter)未登录只给壳页,登录要过风控放弃
Instagram同上,且图片有 CDN 签名放弃

这也是为什么源列表里国内三家、国外四家、二次元四家——能把公开接口用明白的,就这么多。

十一、边界与合规

说清楚它不做什么:

  • 不绕登录、不绕付费墙、不破解任何站点。 所有请求都打在公开页面上,得到的也是公开页面能看到的东西。
  • 不保证授权。 license 字段只在源自己提供时才写,其余是 unknown。要商用请回来源页确认,这个插件替不了你。
  • 不保证可复现。 图源会变、会限流、会改接口。今天 60 条,明天可能 12 条。
  • 不处理 NSFW 过滤。 四个二次元源里 Safebooru 是全年龄向,Pixiv 与 Konachan 需要自己判断;noAI 只是过滤 AI 作品,不是内容分级。

MIT 只覆盖这份代码。图片版权归原作者,下载下来的每一张都该当作“引用”,不是“拥有”。


写这个插件之前,我从来没认真想过“一张图是怎么被找到的”。写完才知道,中间隔着的不是一行 HTTP 请求,是一堆格式撒谎、接口返祖、代理绕路和编码错位。

它现在是我写文章时的固定流程:帮我找 3 张秋天的街道,要横图、最短边 1200 以上。剩下的它自己做,我只需要从对话里挑一张。