基于Node.js的图标批量下载工具:从手动收集到自动化流水线

发布时间:2026/10/17 17:16:37
基于Node.js的图标批量下载工具:从手动收集到自动化流水线 做设计开发的时候最烦的不是写功能而是收集资源。最近在做一个设备管理类的后台项目需要一整套电池图标不同电量档位、充电状态、省电模式、有线无线混合粗算下来少说五六十个图标。一开始想找设计师要结果人家排期一周起想从网上下载一个页面一个页面地右击另存为手指都快抽筋了而且下载下来的文件名乱七八糟还得一个个改。于是我决定自己写一个基于 JavaScript 的图标批量下载工具把废旧时间换成喝咖啡的时间。这篇文章就分享一下这个“电池图标批量下载器”的开发思路和完整落地过程。用一句话概括在 Node.js 环境里写一个脚本从公开图库拉取批量电池图标自动分类、命名、去重并转换成多倍尺寸的 PNG 供项目直接使用。适合所有被重复性下载工作折磨的前端、客户端开发和 UI 工程师参考。我自己实测下来五六十个图标从手工下载的“半小时起步”压缩到了几十秒文件名还干净得像处女座亲手整理过一样。1. 电池图标为什么需要批量下载1.1 一个典型的开发需求场景先还原一下需求是怎么来的。那套设备管理后台的界面里首页要展示一批“在线设备”每个设备旁边要有一个电池电量指示条查下来需要这些状态的图标电量0%、25%、50%、75%、100%至少要五档考虑到低电量告警可能还要单加一档“低于 10%”状态充电中、放电中正常、省电模式、电池异常、未检测到电池风格页面上有亮色背景区域也有暗色图表卡片所以同一套图标最好有填充版和线框版两种 visual weight尺寸给前端用的图标基本都是 1x、2x、3x 三套 PNG按 16、24、32 的基准尺寸向外扩。把这些条件做个排列组合需求直接奔着四五十个文件去。如果算上后续迭代可能用到的“无线充电”“已充满”这类特殊状态轻松突破七十个。这时候如果不批量处理在图标网站上手动搜索、下载、改名、导尺寸没有一整个下午根本下不来。1.2 手抄式下载的三个痛点手动下载看着简单但真做起来有硬伤。第一是命名混乱。从图库网站另存为得到的文件名往往是随机字符串比如5f3a2c9e1b8d.svg或者download-132.svg。直接扔进项目要么依赖母命令强行加 alias要么忘掉这是什么东西。几周后回来看自己都会怀疑“这个 5f3a2c9e 到底是什么”。第二是重复劳动。同一套图在网站上可能是 16、24、32、48、64 多个尺寸但 PNG 下载回来就是一堆相似文件。你会在“到底保留哪个”上浪费大量时间最后往往全留下——项目体积就这么膨胀起来的。第三是精准匹配难。网站上预览图容易有版权提示、水印、背景网格真正下载下来才发现 SVG 内部带有不需要的元素还得手工清理。一条条核对非常考验耐心。这类任务只干一次还好要是以后做第二个、第三个项目还要用同款图标那就纯粹是灾难。写个批量下载脚本本质上是把这段重复劳动一次性封存。2. 技术方案选型与整体设计2.1 为什么是 JavaScript 而不是 Python 或 Shell做批量下载很多人第一反应是 Pythonrequests 加 BeautifulSoup 确实生态丰富。但这次我有三个理由非 JavaScript 不可。一是项目本身是前端后台团队里大家都能读写 JS交付后别人接替维护的成本低。二是新写的自动化脚本可以直接复用项目中已装的 Node 依赖比如sharp做图像处理不用引入 Python 运行环境。三是在调试阶段我可以把下载逻辑和浏览器里的实际渲染结果对照着调保持技术栈统一排查时省一份心智负担。当然如果只是批量拉文件不改格式Shell 的curl 循环也可以。但这套流程还要做名称映射、倍图生成、内容去重用 Shell 写条件判断和字符串处理会很痛苦。JavaScript 的数组、对象方法让这些操作非常顺手天然适合做“数据结构 流程编排”的活。2.2 落地环境与依赖准备我用的是Node.js 18版本因为 18 内置了全局fetch可以少引一个请求库脚本更轻。本机没有 Node 的话直接去官网下 LTS 版本即可不需要额外编译。实际用到的第三方依赖只有四个包名作用为什么选它sharpSVG 转 PNG、缩放多倍图底层是 libvips处理 SVG 极其快且无需额外安装 GraphicsMagickp-limit控制并发下载数量原生 Promise 无法限制并发这个库极小且稳定fs-extra文件操作增强ensureDir、copy这些方法好用省去大量手写递归iconv-lite处理历史遗留的 GBK 编码页面有些图库站点响应头是 UTF-8内容却混着 GBK 字符防乱码要兜底项目初始化非常简单mkdir battery-icon-downloader cd battery-icon-downloader npm init -y npm install sharp p-limit fs-extra iconv-lite2.3 下载源的选择策略这一步很多人会纠结到底从哪里批量拿 SVG我踩过几次坑之后推荐首选提供“直接复制路径”功能的公开 SVG 图库而不是去爬整个网页。原因有两点一是稳定性。公开图库比如以 SVG 文件命名的开源图标集 CDN通常有稳定的 URL 规则只要找到命名规律批量拼地址就能下载。二是版权相对省心。开源图标集的 license 一般允许商用但要仔细看条款有些要求署名有些要求修改后以相同方式共享不能直接抄。我这次用的图标源是某个开源图标仓库的纯 SVG 子集文件名也是battery-{档位}-{状态}.svg这种有规律的命名。下面给出 URL 模式的抽象表示具体域名需要你自己替换成可用的图库地址https://your-icon-host.example/cdn/icons/battery-{level}-{status}.svg例如https://your-icon-host.example/cdn/icons/battery-100-charging.svg https://your-icon-host.example/cdn/icons/battery-50-saver.svg选定源之后批量下载的整个基调就定了核心是“拼地址、限并发、做校验、改名字、转格式”。这五个动作消耗了本文后续百分之八十的代码量。3. 核心模块拆解与关键代码实现3.1 数据清单设计下载前先把要什么列清楚很多人写下载脚本上来就写循环这是个坏习惯。我习惯先声明一份“需要哪些图标”的清单数据。效果有两层一是改需求时只维护清单不用动核心逻辑二是后续对接 CI 或 UI 稿配置时清单可以直接导出成 JSON 给设计系统用。我的清单设计大致长这样// icon-manifest.js const batteryLevels [0, 25, 50, 75, 100, 10]; // 10 是额外低电量告警档 const batteryStates [ { id: idle, label: normal }, // 放电/普通 { id: charging, label: charging }, // 充电 { id: saver, label: saver }, // 省电模式 { id: error, label: error }, // 电池异常 ]; module.exports { batteryLevels, batteryStates };然后用这个清单生成真正的 URL 列表const { batteryLevels, batteryStates } require(./icon-manifest); const ICON_BASE_URL https://your-icon-host.example/cdn/icons; function buildDownloadList() { const tasks []; for (const level of batteryLevels) { for (const state of batteryStates) { const fileName battery-${level}-${state.label}.svg; tasks.push({ url: ${ICON_BASE_URL}/${fileName}, fileName, level, state: state.label, }); } } return tasks; }为什么要单独抽象出buildDownloadList而不是直接写双层循环去下载因为下载完之后的重命名、去重、倍图处理全都要用到level和state这两个字段。把它们提前结构化后面的过滤、排序、统计代码会干净很多。3.2 并发下载器不能三秒打爆图标服务器下载看起来就是fetch套循环其实最大的坑是并发控制。如果一次性把几十个请求全发出去本地网卡没问题但对方服务器很可能直接给你返回 403 或者把你 IP 封掉。真实项目里尤其后端接口不是自家控制时一定要克制。p-limit的用法很简单建立一个并发数为 5 的限流器然后把每个下载任务包进限流器执行const pLimit require(p-limit); const limit pLimit(5); // 同时最多 5 个下载请求 const files []; async function downloadOne(task) { const response await fetch(task.url, { headers: { User-Agent: Mozilla/5.0 (compatible; IconBatch/1.0) }, signal: AbortSignal.timeout(15000), // 15 秒超时 }); if (!response.ok) { throw new Error(${task.url} - HTTP ${response.status}); } const buffer Buffer.from(await response.arrayBuffer()); const filePath path.join(DOWNLOAD_DIR, task.fileName); await fse.outputFile(filePath, buffer); return filePath; } async function downloadAll(taskList) { const results await Promise.allSettled( taskList.map((task) limit(() downloadOne(task))) ); return results; }这里有几个细节值得说明。AbortSignal.timeout(15)是 Node 18 里内置的请求超时方案比setTimeout手动清理请求要简洁。15 秒看起来长但在弱网环境下 SVG 文件本身只有几 KB其实非常够用。如果超过 15 秒还在传输绝大多数情况是连接被劫持或服务器卡死继续等只会更糟。Promise.allSettled而不是Promise.all的关键在于下载任务单条失败不应该拖垮整批任务。比如某个图标在源仓库里恰好缺失404我们希望记录失败原因继续下载其它图标最后统一生成报告而不是让脚本中途崩溃。3.3 重试机制一次失败不代表永久失败网络请求总有偶发超时。遇到 5xx、网络抖动直接放弃有点亏。我加了一层小重试思路比较简单每个任务最多尝试 3 次重试间隔按 500ms、2000ms、5000ms 指数退避。这里所谓“指数退避”就是每次失败后等待时间翻倍和 4 倍、8 倍的激进策略相比这套更平稳不让下一次请求恰好在服务器抖动期撞上去。顺便说一句不是所有错误都该重试。比如 HTTP 404 —— 源文件根本不存在重试一百次也白搭。此时应该记录失败原因直接跳过。我在代码里对错误类型做了区分const RETRYABLE_CODES new Set([408, 429, 500, 502, 503, 504]); async function downloadWithRetry(task, retries 3) { for (let attempt 1; attempt retries; attempt) { try { return await downloadOne(task); } catch (err) { const isRetryable RETRYABLE_CODES.has(err.statusCode); if (!isRetryable || attempt retries) throw err; const delay 500 * 2 ** (attempt - 1); await sleep(delay); } } }这个“可重试错误码”的筛选逻辑非常重要。很多时候我们把网络脚本写成“凡失败必重试”结果一只不存在的文件导致脚本多花了几分钟纯粹浪费时间。3.4 SVG 转 PNG 与多倍图生成下载下来的battery-100-charging.svg是矢量图前端可以直接用 SVG但很多桌面客户端和移动端图标组件只吃 PNG。所以需要批量转换。我在转换环节用sharp原因是它对 SVG 的渲染非常细致可以指定背景透明、密度density和缩放倍数一步到位const sharp require(sharp); async function convertToPng(svgPath, outputDir, size) { const outputName path.basename(svgPath, .svg) .png; const outputPath path.join(outputDir, outputName); await sharp(svgPath, { density: 300 }) .resize({ width: size, height: size, fit: contain }) .png({ compressionLevel: 9 }) .toFile(outputPath); return outputPath; }这里的density: 300是我非常推荐加上的一个参数。SVG 本身是矢量但 sharp 在光栅化时内部有默认 DPI如果不把 density 调高小尺寸图标边缘容易发虚。调高 density 再 resize 到目标尺寸相当于先渲染出超采样图片再缩小锯齿不明显。实测下来同样一个 24px 的 PNG用 density 300 的边缘明显比默认值更平滑。多倍图生成就是一个尺寸映射循环const scaleFactor { 1x: 24, 2x: 48, 3x: 72, }; for (const [label, size] of Object.entries(scaleFactor)) { const outDir path.join(OUTPUT_DIR, png/${label}); await fse.ensureDir(outDir); await convertToPng(filePath, outDir, size); }3.5 画幅校验不要让空文件流入项目下载最隐蔽的坑是“下载成功但内容不对”。比如某些 CDN 对 404 会返回一个 HTML 错误页但 HTTP 状态码是 200又比如 SVG 文件本身内容是空的或者只有一行!-- placeholder --。如果直接拿去转换sharp 可能生成一张全透明空图合入工程后眼睛却看不见排查起来非常浪费时间。所以下载完成之后我加了一道“校验关卡”。第一层是文件大小。小于 200 字节的 SVG 大概率异常直接删除并计入失败清单。第二层是SVG 标签检查。必须包含svg标签且存在path或circle之类的图形节点否则判定无效。第三层是sharp 实际渲染检查。尝试渲染成 PNG如果结果尺寸是 0×0说明 SVG 内部结构有问题。function validateSvg(filePath) { const content fs.readFileSync(filePath, utf8); if (content.length 200) return { ok: false, reason: file-too-small }; if (!content.includes(svg) || !/(path|circle|rect|polygon)/.test(content)) { return { ok: false, reason: invalid-svg-body }; } return { ok: true }; }这道校验在批量场景里尤其重要——它把“质量检查”从人眼判断变成了程序判断哪怕后面图片量增长到几百个也不会漏掉任何一个异常文件。4. 交付目录与命名规范4.1 输出目录结构设计一次完整的批量下载跑完后我期望看到的目录是这样的battery-icon-downloader/ ├── downloads/ │ ├── battery-0-normal.svg │ ├── battery-25-normal.svg │ ├── battery-100-charging.svg │ └── ... ├── output/ │ ├── svg/ │ │ ├── battery-0-normal.svg │ │ └── ... │ ├── png/ │ │ ├── 1x/ │ │ │ ├── battery-0-normal.png │ │ │ └── ... │ │ ├── 2x/ │ │ │ └── ... │ │ └── 3x/ │ │ └── ... │ └── manifest.json └── failed.log这里我有意区分了downloads/和output/svg/。downloads/是原始素材保留 URL 原名万一后续需要回溯可以找到源头output/是经过命名规范化、校验、转换的最终产物。这个区分在返工时特别好用——设计师改了命名规则我只要重新跑一遍整理流程不用重新下载。4.2 命名规范从源头消除歧义文件名本身就是一份元数据。我定的规则是小写字母 连字符battery-{电量}-{状态}.svg比如battery-0-normal.svg battery-25-charging.svg battery-10-error.svg这样在 IDE 里按文件名排序电量档位从低到高一目了然前端代码里引用时也能直接从业务状态映射到对应文件名不需要额外的翻译表。有人喜欢用下划线或者驼峰比如battery_100_Charging在跨平台项目里容易出问题。Windows、macOS、Linux 对大小写敏感程度不一样团队协作时最容易翻车。全小写 连字符是最稳妥的通用规范CDN 上一般也只推荐这种命名方式。4.3 去重策略同样内容的图标只留一份批量下载过程中很可能遇到两个不同 URL 但内容几乎完全相同的文件。不处理的话产物体积白白膨胀。我用的去重方案比较简单但有效按文件内容的 MD5 指纹去重。下载完扫描downloads/目录中的所有 SVG计算哈希const crypto require(crypto); function calcMd5(filePath) { const content fs.readFileSync(filePath); return crypto.createHash(md5).update(content).digest(hex); }同一份 SVG 如果内容和另一个文件名不同但 hash 相同判定为重复文件仅保留命名更规范的那个。不过要注意MD5 去重只能处理字节级完全相同的重复。如果两个 SVG 内容等价但元素顺序不同长度不同则不会判重。更好一点的方案是把 SVG 的 path 数据用库做归一化后再哈希比如清洗标签属性、统一小数位这次改动成本较高我认为对这次使用场景来说 md5 已经够用了。输出manifest.json时我同时把去重信息写进去{ battery-100-charging.svg: { source: battery-100-charging.svg, md5: ab3f19c4..., duplicates: [battery-100-charging-copy-2.svg] } }这个清单对后续接设计系统、审查交付情况都很方便。5. 全流程整合与实战运行5.1 把下载、校验、转换串成一个管道前面各模块是零件现在把它们组装成一个完整脚本。我推荐用一个pipeline.js统一调度而不是把这些逻辑散在入口文件里// pipeline.js const path require(path); const fse require(fs-extra); const { buildDownloadList } require(./icon-manifest); const { downloadAll } require(./downloader); const { validateSvg } require(./validator); const { convertToPng } require(./converter); const { calcMd5, writeManifest } require(./utils); async function main() { const taskList buildDownloadList(); const DOWNLOAD_DIR path.join(__dirname, downloads); const OUTPUT_DIR path.join(__dirname, output); const failedLog []; await fse.ensureDir(DOWNLOAD_DIR); await fse.ensureDir(OUTPUT_DIR); // 第一步批量下载 const results await downloadAll(taskList); const successFiles []; results.forEach((res, index) { if (res.status fulfilled) { successFiles.push(res.value); } else { failedLog.push({ url: taskList[index].url, reason: res.reason.message }); } }); // 第二步校验去重 const validFiles []; const hashSet new Set(); for (const file of successFiles) { const check validateSvg(file); if (!check.ok) { failedLog.push({ file, reason: check.reason }); continue; } const hash calcMd5(file); if (hashSet.has(hash)) { failedLog.push({ file, reason: duplicate-content }); continue; } hashSet.add(hash); validFiles.push(file); } // 第三步整理并转换 const svgOutputDir path.join(OUTPUT_DIR, svg); const png1xDir path.join(OUTPUT_DIR, png/1x); const png2xDir path.join(OUTPUT_DIR, png/2x); const png3xDir path.join(OUTPUT_DIR, png/3x); await fse.ensureDir(svgOutputDir); for (const file of validFiles) { // 规范重命名 const normalized normalizeFileName(file); // 具体逻辑见后文 await fse.copy(file, path.join(svgOutputDir, normalized)); // 生成多倍图 await convertToPng(file, png1xDir, 24); await convertToPng(file, png2xDir, 48); await convertToPng(file, png3xDir, 72); } // 第四步输出报告 await writeManifest(validFiles, OUTPUT_DIR); fse.writeFileSync(failed.log, JSON.stringify(failedLog, null, 2)); console.log(完成成功 ${validFiles.length} 个失败 ${failedLog.length} 个); } main().catch(err { console.error(管线执行中断, err); process.exit(1); });5.2 命名规整函数用元数据反推文件名normalizeFileName是个容易被人忽略但很关键的模块。因为源 URL 的文件名可能带后缀版本号比如battery-100-charging-2a01.svg或者特殊前缀ic_battery_100_charging。我们希望最终产物统一成battery-100-charging.svg。这种情况下我直接利用taskList里面已经存好的level和state字段来构造文件名而不是解析原始文件名。任务列表是结构化数据下载前就已经知道每个文件代表什么。规整函数写起来就很简单function normalizeFileName(file) { const task taskLookup[path.basename(file)]; if (!task) return path.basename(file); return battery-${task.level}-${task.state.label}.svg; }这对“为什么提前要有一份清单”给出第二个理由清单不仅仅是待办列表还是文件命名的唯一数据源。有了它任何下载回来的文件都能映射回自己的语义。5.3 运行输出与实际效果运行命令就一行node pipeline.js看到控制台输出“完成成功 62 个失败 2 个”的时候我就可以去output/png/2x目录里直接拖出图片压缩进项目。整个流程从零开始到拿到可用的图标大概 40 秒。如果只有网络快的地儿20 秒就能完成。这里的让脚本重跑能力也非常重要。第一次跑失败的两个文件我在failed.log里看到原因是“URL 拼写不规范导致的 404”。修改了battery-level参数表之后重新执行node pipeline.js这次是完全幂等的——已经成功下载的文件会被重写但最终产物是一致的。所谓“幂等”就是说不管跑多少遍只要输入不变输出保持一致不会产生额外垃圾文件。5.4 把这些能力暴露给非技术同事考虑到团队里可能有不会敲命令的 UI 同学我顺手包了一层简单的命令行交互。用readline让用户输入要下载的电量档位和状态集合比如? 电量档位逗号分隔: 0, 10, 25, 50, 75, 100 ? 状态列表逗号分隔: normal, charging, saver, error收集完参数后自动调buildDownloadList()。这样设计师拿到工具不用打开icon-manifest.js也能直接改配置下载自己那套图标。6. 常见问题与排查实录6.1 高频异常速查表实际跑脚本过程中最容易遇到的几个问题整理成一张速查表异常现象可能原因处理方式所有请求都返回 403目标服务器有防盗链检查补充User-Agent与Referer头或检查是否被限流下载成功但sharp转出来全透明SVG 只有占位注释图形为空启用 SVG 内容校验删除占位文件并人工核实部分文件下载 404图库版本更新导致路径变更检查清单中的档位与状态是否符合新规则控制台输出乱码页面/文件名是 GBK 编码用 iconv-lite 解码或统一转成 UTF-8脚本跑到一半内存暴涨并发设置过高降低pLimit的并发数比如从 10 改为 3文件名大小写与引用不一致源文件本身就是大写命名规整为全小写并在清单中做映射6.2 一个真实排查案例我遇到过一次非常有意思的问题。所有图标下载都很顺利但生成的 PNG 放到后台界面上有接近三分之一是“带灰色底纹”的。肉眼看到的是图标边缘有一层淡淡的阴影放到白色背景下很突兀放到暗色背景下又看不见了。排查过程大概是这样的先怀疑是 sharp 转换参数不对反复调整背景透明度没有效果然后怀疑是源 SVG 本身带样式用文本编辑器打开downloads/battery-0-normal.svg一看发现源 SVG 里自带一个fill-opacity不为 0 的背景矩形层和一条描边 shadow在网页预览时由于是白色页面看不出来但一旦渲染到透明背景就露馅。解决方案也很粗暴在 validator 里增加一个 DOM 级清洗流程如果检测到 SVG 根节点下第一层存在rect或path但是颜色接近白色就自动从 SVG 内容中剥离该节点再输出到干净产物目录。这算是非常“脏活累活”的经验但恰恰是这类细节决定了交付图能不能直接进设计系统。6.3 后续扩展方向如果这套工具不只是给自己的项目用而是想沉淀成团队的通用脚本可以再扩展几块能力一是把来源从“固定图标库”扩展成“任意符合规则的 URL”。维护一份 adapter 清单比如“该图库的 URL 规则是什么、需要哪些请求头、命名映射怎么拿”。这样切换资源供应商时只加 adapter核心管线完全不用动。二是支持自动生成 SVG Sprite。前端切换图标时合并成雪碧图可以减少 HTTP 请求。用svg-sprite库可以把output/svg下的所有图标合并成一个battery-icons.svg并输出对应的符号引用symbol信息。三是把失败重试策略做成可配置。某些内网环境下重试次数和超时时间需要单独调做成config.js里的字段比写死在函数里灵活得多。写在最后的体会这套批量下载工具做完之后我的体会是一切“找一个文件、改一个名、导一张图”的手工重复动作都值得用脚本封装起来。表面上看写脚本花了一两个小时好像不如直接复制粘贴来得快。但批量任务的真正成本从来不在单次操作而在重复次数和出错后的返工。我把同样思路扩展到团队里其他几个资源下载场景比如天气图标、设备状态图标都被证明非常划算。另外也想提醒一句批量下载一定要克制。控制并发、做好重试、校验内容是对目标服务器负责也是对自己的交付质量负责。脚本跑完几秒钟很简单难的是下载下来的每一个文件都敢直接合进项目里。如果你正准备做类似的资源批量收集别一上来就写复杂的爬虫解析先把“清单”“下载”“校验”“命名”“转换”这五个环节的边界理清楚照着这个框架走很快就能跑出一条稳定的流水线。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询