做应用原型或者仪表盘界面的时候我经常需要一批电池图标。手机电量从0%到100%、充电中、低电量、省电模式配上不同颜色和边框一套下来轻轻松松上百个。以前我的做法是找素材站一个个右键另存为先是手指酸再是图多了以后文件要么叫battery-1、battery-2这种毫无辨识度的名字要么干脆带着一长串参数乱码。直到我花了一个晚上写出一套基于JavaScript的批量下载脚本这个工作才彻底结束。这里说的“基于JavaScript的电池图标批量下载”本质上是个前端自动化素材采集的小项目。用一段JS脚本把一个图标站里的电池相关素材一次性抓下来自动命名、自动排序、自动归到一个文件夹里。它解决的不只是“下载”这个动作而是把“找图、定位资源、保存、整理”一整个链路全部串起来。这篇内容适合这三拨人一是要做原型或者UI稿但不想耗在图标收集上的设计师二是写爬虫或数据采集但不想碰重型框架的前端开发者三是单纯想给自己的素材库填充一大批高质量图标的自媒体或者开发者。不要被“爬虫”“采集”这类词吓到。这个项目不需要安装重量级框架不需要申请任何平台的授权纯JavaScript就能跑而且从浏览器开发者工具到本地Node脚本都有可行路径。下面我按需求拆解、原理、实操、排查四个阶段完整讲一遍代码可以直接“抄作业”。1. 需求拆解为什么要用JavaScript做批量下载1.1 电池图标的高频场景与素材来源先说清楚一个背景电池图标不是冷门素材。凡是涉及设备状态、手机电量、能耗监测、智能硬件App、仪表盘、图标主题的界面几乎都离不开它。一套完整的基础电池图标通常包含满电、80%、50%、20%、低电量、充电中、未充电等多个状态再叠加配色和风格差异数量很快就膨胀到几十上百个。这个体量本身就是天然的“批量下载”需求场景。素材来源通常是两类。一类是专门收录图标的设计资源站这些站点页面结构相对统一图标以卡片形式排列图片地址和标题信息都比较集中是脚本方案最乐于处理的目标另一类是类似图标字体服务这类以字体方式交付的素材它们不走图片下载逻辑而是选好图标后生成字体文件这种场景属于另一种方案不在本文讨论范围内。本文主要针对第一类即以图片卡片形式展示的图标资源站。对于这类资源站我们真正需要搞清楚的事情只有三件图标真实地址放在哪里、标题文字放在哪里、页面有没有做懒加载。这三件事搞明白后面所有脚本都是同一套思路换几个选择器而已。1.2 方案对比浏览器脚本、油猴脚本还是Node脚本围绕“批量下载”这个目标实际上有三套路径工程实践上各有适用场景。方案运行环境核心优势典型限制浏览器控制台脚本浏览器开发者工具零依赖直接复制粘贴即可运行适合一次性下载多文件下载会被浏览器拦截页面关闭即失效油猴脚本浏览器扩展可保存复用绕过多文件下载限制支持触发式执行需要安装扩展对跨域资源处理需要额外配置Node脚本本地运行稳定性最高支持并发控制、断点重试可批量处理大量文件需要安装Node环境对动态渲染页面需要无头浏览器兜底我的选择原则是这样的几十个以内、一次性任务直接用浏览器控制台脚本几百个、需要反复执行或者要把下载任务交给别人复用就写成一个Node脚本。油猴脚本属于两者之间的折中方案如果你不抗拒浏览器扩展也可以选它后面我会提到一个它更友好之处。为什么选JavaScript而不是其他语言很简单素材在网页里而浏览器本身就是最擅长和网页打交道的环境。用JavaScript直接跑在页面上天然拿到当前页面的DOM结构、请求头、Cookie不需要额外维护一套登录态这是其他语言没法比的便利。所以这个项目的核心不是“写爬虫”而是“在正确的位置执行正确的脚本”。2. 批量下载的核心原理与关键技术2.1 资源定位图标真实地址藏在哪这是整个项目里最容易踩坑的环节。很多图标站在页面结构上做了“懒加载”处理就是页面先渲染出一个占位图等用户滚动到附近时才真正去请求高清图。体现在DOM里真实图片地址往往不在img标签的src属性上而是写在data-src或者data-original这类自定义属性里。如果你直接按img.src去下载只能拿到一张又小又模糊的占位图甚至拿到一个空的占位地址。拿一个生活里的场景类比就像你点了一份外卖页面上显示的是餐品封面但真正给你送到手里的东西需要点进详情页才能看到。懒加载占位图就是那个“封面”data-src里的地址才是你要的“餐品本体”。这里有个经验拿到一个站点后先用浏览器开发者工具的检查器点开一个图标卡片确认三个信息——真实地址在哪个属性里、标题文字在哪个元素里、卡片容器有没有可用的data-id或者data-type之类的分类标识。把这三个点摸清楚脚本的可靠性能提高一大半。图标站为了做SEO通常还会在页面里嵌一份JSON数据或者把列表信息放在全局变量里如果能直接解析这份结构化数据效率会更高不用担心某个属性名写错导致的解析失败。2.2 批量下载的实现机制从一次下载到N次下载单张图片下载在浏览器里就是发一个请求拿到资源然后触发保存动作。批量下载的关键不在于“怎么下一次”而在于“怎么稳定地下N次”。浏览器端用得最多的是“Blob中转”方案先用fetch把图片资源请求回来转换成Blob二进制对象然后通过URL.createObjectURL生成一个临时链接程序化地创建a标签指定download属性为文件名再触发点击完成下载。这么做的原因比较现实如果直接把a标签的href指向原始图片地址浏览器大概率会打开预览而不是下载而且跨域资源无法通过download属性指定重命名。Blob方式把图片数据完全拿在手里文件的命名权、保存路径、后续处理全部可控。批量侧要处理的两个核心问题是并发控制和节奏控制。并发太高服务器会对你的访问做限制节奏太快浏览器会把你连续触发的下载动作判定为自动化行为直接弹拦截。所以我的做法是把下载任务排成一个队列一次只跑少量请求每个完成后间隔一小段时间再跑下一个。视觉上慢一点但整体失败率会大幅度降低。这里有个小技巧在Node脚本里设置并发数为3、每个任务之间加300毫秒左右的间隔实测下来是一个比较不容易出事的节奏。2.3 命名与格式批量任务里最容易被忽视的细节批量下载做完后还有一个隐形工程命名。如果按原始URL保存你会得到一批类似“icon-abc123.png?id123”这种文件完全没有语义。等下次要找一个“充电中”的图标只能把所有图打开看一遍比手动保存还难受。合理的命名策略是从卡片里提取语义信息组合文件名比如“battery-charging-black.png”“battery-low-red.png”把状态、颜色、风格全部编码进文件名。这样下载完成的一瞬间你的素材库就已经整理好了后续做图标管理、做主题包、做设计稿引用都能省出大量检索时间。这个道理跟整理代码仓库一样变量名取得好后续维护成本立刻降一半。格式问题也要提前想清楚。同一套图标站在不同列表页展示时可能一部分是PNG静态图一部分是SVG矢量图。如果脚本里写死了取PNG遇到SVG就只能跳过或者拿错图。我建议在代码里先根据URL扩展名做一次判断再决定保存的格式并且在命名时把扩展名跟实际内容严格对应。一个简单的思路是把“下载”和“命名”拆成两个独立逻辑一个负责对准格式一个负责拼好名字后续扩展起来会非常顺手。3. 实操从浏览器控制台到本地Node脚本3.1 浏览器控制台下的快速方案第一步先打开目标图标的列表页按F12进入开发者工具切到Console面板。在Console里跑脚本之前先手动检查一下页面上每张图标卡片的容器类名、图片真实地址所在属性、标题元素的选择器然后替换到下面的代码里。// 1. 获取所有图标卡片容器 const cards document.querySelectorAll(.icon-item); // 2. 提取真实地址与名称信息 const list []; cards.forEach((card, index) { const img card.querySelector(img); const src img.getAttribute(data-src) || img.getAttribute(data-original) || img.src; const name ( card.querySelector(.icon-title)?.textContent.trim() || card.querySelector(.icon-name)?.textContent.trim() || battery- index ); list.push({ src, name }); }); // 3. 逐个执行下载控制节奏 async function downloadOne({ src, name }) { try { const res await fetch(src); const blob await res.blob(); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download ${name}.png; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() URL.revokeObjectURL(url), 2000); } catch (e) { console.warn(下载失败, name, e); } } (async () { for (const item of list) { await downloadOne(item); await new Promise(r setTimeout(r, 400)); } })();这段脚本有几点需要解释清楚。fetch能否成功取决于当前页面和目标图片域名之间是否有CORS限制。在同一站点内绝大多数情况没问题如果图片在CDN域名下而这个域名没有开放跨域控制台会看到跨域报错这时候就只能退回Node方案用服务端请求去绕过浏览器的跨域限制。代码里的选择器要根据实际页面结构替换比如类名从.icon-item换成.battery-card图片地址属性从data-src换成data-original都是常见操作。控制台脚本还有一个绕不开的体验问题当连续触发下载多个文件时浏览器会询问“是否允许此网站下载多个文件”。你需要手动点一下允许如果不允许后续下载会被全部拦截。小批量够用大批量还是建议走Node方案。如果你习惯用油猴脚本可以把这段逻辑放进下载回调里它能在一定程度上绕过多文件下载的交互限制一次任务结束直接收文件这是油猴脚本在这个场景下的明显优势。3.2 本地Node脚本的稳定方案Node方案适合两类场景一是数量大、需要长时间跑的任务二是浏览器控制台处理不了的跨域资源和访问限制。我的习惯是先用请求库抓取列表页HTML用解析库把图标信息和图片地址从HTML里摘出来然后以流式写入的方式把图片落盘。const axios require(axios); const cheerio require(cheerio); const fs require(fs); const path require(path); const BASE https://example.com/battery-icons; const SAVE_DIR ./battery_set; if (!fs.existsSync(SAVE_DIR)) { fs.mkdirSync(SAVE_DIR, { recursive: true }); } // 第一步抓取列表页解析图标信息 async function parsePage(url) { const { data } await axios.get(url, { headers: { User-Agent: Mozilla/5.0 ... } }); const $ cheerio.load(data); const list []; $(.icon-item).each((i, el) { const img $(el).find(img); const src img.attr(data-src) || img.attr(src); const name $(el).find(.icon-title).text().trim() || battery-${i}; if (src) list.push({ src: https://example.com src, name }); }); return list; } // 第二步单文件下载流式写入 async function downloadOne(item) { const res await axios.get(item.src, { headers: { Referer: BASE }, responseType: stream }); const ext path.extname(new URL(item.src).pathname) || .png; const filePath path.join(SAVE_DIR, ${item.name}${ext}); const writer fs.createWriteStream(filePath); res.data.pipe(writer); return new Promise((resolve, reject) { writer.on(finish, resolve); writer.on(error, reject); }); } // 第三步并发限制队列 async function runWithLimit(list, limit 3) { let cursor 0; async function worker() { while (cursor list.length) { const item list[cursor]; try { await downloadOne(item); console.log(已下载 item.name); } catch (e) { console.warn(失败 item.name, e.message); } await new Promise(r setTimeout(r, 300)); } } const workers Array.from({ length: limit }, () worker()); await Promise.all(workers); } (async () { const list await parsePage(BASE); console.log(解析到图标数量 list.length); await runWithLimit(list, 3); })();这段代码把解析、下载、并发三个环节拆成了独立函数方便按需调整。其中User-Agent和Referer是两个关键请求头很多图标源站会校验Referer不带的话返回403的可能性很大User-Agent如果不设置一些服务端会当成脚本请求直接拒绝。responseType设成stream可以让下载过程以流式方式写盘内存占用低下载大图或者几百张批量任务时不会把本地运行内存吃满。并发数量我一般压到3个。并发调高看起来会快但大多数素材站点并没有给这么高的并发做准备很容易触发限流甚至封掉临时访问权限。慢一点没关系能完整跑完才是关键。跑完以后你得到的文件夹里就是一组命名规范、格式统一的电池图标从数量到质量都是一次性到位。3.3 动态渲染站点的无头浏览器兜底方案如果目标站点的图标列表是纯JavaScript动态渲染出来的比如页面先给空壳数据通过接口异步加载后再填充DOM上面两种方案都会失效因为抓取HTML时页面还没有图标节点。这种场景就得用无头浏览器方案兜底启动一个无头浏览器打开页面等待页面完成动态渲染再到渲染后的DOM里取值。无头浏览器本质上是一个没有界面的浏览器实例它能完整执行页面上的所有JavaScript就像用户正常打开网页一样。无头浏览器方案通常需要额外安装浏览器内核相关依赖运行速度也比纯请求慢不少所以我只建议在没办法解析静态HTML时才启用。实现思路大同小异加载页面、等待网络空闲或目标节点出现、抓取列表、逐个下载逻辑和前面的Node脚本一致。区别仅在“数据怎么拿到手”这个环节。如果遇到这种站点我会先尽量找一找它有没有数据接口很多SPA站点其实都暴露了内部接口直接请求接口拿JSON比跑无头浏览器轻量得多。4. 常见问题与排查技巧实录4.1 高频问题速查表症状原因解决方案下载下来的图片全是占位透明小图取到了懒加载占位地址真实地址在data-src等属性里强制读取data-src或者data-original不要用img.srcfetch跨域报错图片所在域名未开放CORS改用Node脚本从服务端请求或借助无头浏览器请求返回403缺少User-Agent或Referer被服务器识别为异常请求在请求头上带上常用浏览器的User-Agent和所在页面Referer下载过程中文件损坏responseType未设成stream文本方式写入导致二进制损坏responseType改为stream使用流式写入文件名带乱码或空名解析的标题字段中没有有效内容或者页面编码不匹配加一层textContent.trim()兜底必要时使用默认命名规则浏览器一直弹“允许下载多个文件”浏览器安全策略拦截批量下载小批量手动确认大批量改为Node脚本脚本跑到一半停住单个请求挂起等待超时时间过长给请求设置timeout增加失败重试逻辑这张表基本覆盖了这个项目从头到尾可能碰到的主要问题。每一条都对应真实存在的解决路径遇到对应现象时可以参考快速对照。4.2 几个独家避坑心得第一个心得是“先看数据再看页面”。在写任何下载逻辑之前先打开开发者工具的Network面板刷新一遍列表页看页面真正发出的图片请求长什么样。有时候一个页面的HTML里根本没有图片地址而是通过一个接口返回JSON这个JSON里面可能直接包含所有图标的名称、标签和URL。直接解析这个接口比解析DOM更稳也比观察选择器更省时间。我现在的习惯是先刷新页面看请求再返回去写脚本成功率几乎翻倍。第二个心得是“批量任务必须分阶段验证”。别一口气把200个图全部跑完再检查那样一旦命名规则错了就要全部重来。我的做法是先跑前五个项检查下载结果是不是完整的图片、命名规则是不是符合预期、文件格式是否正确确认无误后再放开全部任务。这个小流程看似多花五分钟实际省掉的是整个上午。用前面Node代码来说就是把runWithLimit的limit参数临时调成1list先slice(0, 5)跑完看结果再决定是否全量执行。第三个心得是关于站点规则的。批量下载素材本质上是在消耗目标站点的带宽和资源做这件事之前一定要看一眼站点的使用条款别拿别人免费提供的资源去做不当用途也别高频重刷页面。保持正常下载节奏、控制并发数量、尊重禁止访问的路径是这类脚本创作者最基本的自觉。有一次我没有控制好节奏一个任务把对方站点访问得很慢后来发现对方在维护一个很薄弱的文件服务那种体验并不好。从那以后我的所有下载脚本里都默认内置了延迟和并发上限。现在再接到图标类素材采集的需求我已经形成一套固定流程先打开页面判断资源藏在静态HTML还是动态渲染再按规模选浏览器控制台还是Node脚本下载完之后按语义命名归好文件夹。整个过程用JavaScript从头到尾跑通既没有引入重型工具也没有花费额外精力去维护环境。批量下载这件事最难的地方其实不在技术而在方案的选择和执行节奏的控制只要把这两点想清楚剩下的就是让脚本安安静静地把图标一张张搬进你的素材库。
阅读完成 · 觉得有帮助?