为了压缩markdown笔记图片等用途,寻找了图片批量压缩的方法。虽然实际方法肯定很多,但是以下仅涉及了3个方法。
1.使用TinyPNG网站
-
TinyPNG是国外的一个无需注册即可免费使用的图片压缩网站,限制是每月免费压缩500张,同时单次限制上传20张。
-
通过点击以下链接可申请API密钥,使用Nodejs开发使用。
-
项目中输入命令安装
pnpm i tinify
即可使用,以下是简单代码实现 -
测试后感觉限制不少,同时可能网络原因完成压缩用时一般。
import tinify from 'tinify';
import fs from "fs";
import path from "path";
import config from './config.js';
tinify.key = config.api_key;
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.svg'];
const folderPath = './';
// 递归遍历文件夹
const getImagesInFolder = (folderPath, images = []) => {
const files = fs.readdirSync(folderPath);
files.forEach((file) => {
const filePath = path.resolve(folderPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
getImagesInFolder(filePath, images);
} else if (isImageFile(file)) {
images.push(filePath);
}
});
return images;
};
const isImageFile = (filename) => {
const ext = path.extname(filename).toLowerCase();
return imageExtensions.includes(ext);
};
const zipImage = (path) => {
fs.readFile(path, (err, sourceData) => {
if (err) {
throw err;
}
tinify.fromBuffer(sourceData).toBuffer((err, resultData) => {
if (err) {
throw err;
}
fs.writeFile(path, resultData, (err => {
if (err) {
throw err;
}
}));
});
});
};
const imagePaths = getImagesInFolder(folderPath);
console.log('All image paths:', imagePaths);
imagePaths.forEach(item => zipImage(item));
2.Sharpjs
- Sharp是nodejs中的一个常用图像处理包,功能也非常多,其功能之一就是用来压缩图片。
- npm网站:sharp
- 项目中需安装
pnpm i sharp
,我还额外安装了chalk库便于打印显示,就是一个简单的展示效果。 - 以下是简单代码实现,选择了压缩成webp,同时将压缩结果存放到运行脚本目录下的output文件夹中,结构和原来的目录相同。手动检查后,可以把output内的内容粘贴到原目录覆盖。
- 测试后感觉速度还是可以的。
import sharp from 'sharp';
import fs from "fs";
import path from "path";
import chalk from "chalk";
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.svg'];
const outputFolder = './output';
const RunFolder = './';
// 递归遍历文件夹
const getImagesInFolder = (folderPath, images = []) => {
const files = fs.readdirSync(folderPath);
files.forEach((file) => {
const filePath = path.resolve(folderPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
getImagesInFolder(filePath, images);
} else if (isImageFile(file)) {
images.push(filePath);
}
});
return images;
};
const isImageFile = (filename) => {
const ext = path.extname(filename).toLowerCase();
return imageExtensions.includes(ext);
};
const imageSizeFormat = (imageSize) => {
if (imageSize < 1024) {
return `${imageSize} B`;
} else if (imageSize < 1048576) {
const sizeInKB = (imageSize / 1024).toFixed(2);
return `${sizeInKB} KB`;
} else {
const sizeInMB = (imageSize / 1048576).toFixed(2);
return `${sizeInMB} MB`;
}
};
// 获取文件大小
const getImageSize = (filePath) => {
const stats = fs.statSync(filePath);
const fileSizeInBytes = stats.size;
return fileSizeInBytes;
};
// 计算百分比变化
const calculatePercentageChange = (oldSize, newSize) => {
const percentageChange = ((newSize - oldSize) / oldSize) * 100;
if (percentageChange > 0)
return "↑" + percentageChange.toFixed(2) + "%";
else
return "↓" + (-percentageChange.toFixed(2)) + "%";
};
const sharpComp = (inputPath) => {
// 计算输出路径,没有则进行创建
const outputFolderPath = path.join(outputFolder, path.dirname(path.relative(RunFolder, inputPath)));
const outputPath = path.join(outputFolderPath, path.basename(inputPath));
if (!fs.existsSync(outputFolderPath)) {
fs.mkdirSync(outputFolderPath, { recursive: true });
}
sharp(inputPath).webp({ quality: 75, effort: 5 }).toFile(outputPath, (err) => {
if (err) {
console.error(err);
}
const oldImageSize = getImageSize(inputPath);
const newImageSize = getImageSize(outputPath);
const compPercent = calculatePercentageChange(oldImageSize, newImageSize);
console.log(chalk.cyan(inputPath) + ": " + chalk.yellow(imageSizeFormat(oldImageSize)));
console.log("├─ " + chalk.cyan(outputPath) + ": " + chalk.yellow(imageSizeFormat(newImageSize)));
if (compPercent.startsWith('↓')) {
console.log("└─ " + chalk.green(`[${compPercent}]`) + "\n");
} else if (compPercent.startsWith('↑')) {
console.log("└─ " + chalk.red(`[${compPercent}]`) + "\n");
} else {
console.log("└─ " + `[${compPercent}]` + "\n");
}
});
};
const startCompImage = () => {
const imagePaths = getImagesInFolder(RunFolder);
console.log('all image paths:', imagePaths);
imagePaths.forEach(item => sharpComp(item));
};
startCompImage();
3.Squoosh网站+Selenium
-
Squoosh是Google开发的图像压缩工具。支持多种压缩格式,同时自定义选项也非常多。而且压缩比也比较高。测试了它的AVIF格式感觉挺不错。
- 官网:Squoosh
-
它还有一个API库@squoosh/lib和一个cli库@squoosh/cli。但是根据这两个项目npm上的介绍,目前已经不再维护了。我简单测试了这两个库的使用,结果是遇到了一些bug,不想再折腾了。
-
如果这两个库确实无法使用,因为官方网站还是可以使用,所以也可以使用selenium自动化操作。
-
以下是使用nodejs的selenium库操作的简单代码实现,项目需安装
pnpm i chromedriver selenium-webdriver chalk
。当然使用python肯定也是可以的。- 这里使用了谷歌浏览器驱动,并且是npm安装形式,如果本地已经安装过应该是不需要再安装这个chromedriver了。
- 还是选择了压缩成webp
- 同时为了获取Squoosh网站生成的图片,选择了注入脚本执行请求blob链接转成base64获取结果,最后再转换成buffer写入到指定路径。当然也可以直接获取下载按钮模拟点击下载,会方便一些。
- Stack Overflow对这种方式的解释:How to download an image with Python 3/Selenium if the URL begins with “blob:”?
-
测试后,压缩效果和sharp差不多,而且因为使用到了selenium,可能会不稳定,并且在使用代理的情况下速度也不快。
import 'chromedriver';
import { Builder, By, until } from 'selenium-webdriver';
import fs from 'fs';
import path from "path";
import chalk from 'chalk';
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'process';
const imageExtensions = ['.jpg', '.jpeg', '.png', '.bmp', '.svg'];
const outputFolder = './output';
const RunFolder = './';
const rl = readline.createInterface({ input, output });
const driver = new Builder().forBrowser('chrome').build();
// blob图片链接转base64编码脚本
const injectScript = function (blobUrl) {
console.log('arguments', arguments);
var uri = arguments[0];
var callback = arguments[arguments.length - 1];
var toBase64 = function (buffer) {
for (var r, n = new Uint8Array(buffer), t = n.length, a = new Uint8Array(4 * Math.ceil(t / 3)), i = new Uint8Array(64), o = 0, c = 0; 64 > c; ++c)
i[c] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(c); for (c = 0; t - t % 3 > c; c += 3, o += 4)r = n[c] << 16 | n[c + 1] << 8 | n[c + 2], a[o] = i[r >> 18], a[o + 1] = i[r >> 12 & 63], a[o + 2] = i[r >> 6 & 63], a[o + 3] = i[63 & r]; return t % 3 === 1 ? (r = n[t - 1], a[o] = i[r >> 2], a[o + 1] = i[r << 4 & 63], a[o + 2] = 61, a[o + 3] = 61) : t % 3 === 2 && (r = (n[t - 2] << 8) + n[t - 1], a[o] = i[r >> 10], a[o + 1] = i[r >> 4 & 63], a[o + 2] = i[r << 2 & 63], a[o + 3] = 61), new TextDecoder("ascii").decode(a);
};
var xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
xhr.onload = function () { callback(toBase64(xhr.response)); };
xhr.onerror = function () { callback(xhr.status); };
xhr.open('GET', uri);
xhr.send();
};
const chromeAutoOperation = async (inputPath) => {
await driver.get('https://squoosh.app');
await driver.findElement(By.className("_hide_vzxu7_18")).sendKeys(inputPath);
await driver.wait(until.elementsLocated(By.className("_builtin-select_1onzk_5")), 5000);
const selects = await driver.findElements(By.className("_builtin-select_1onzk_5"));
await selects[1].sendKeys("WebP");
try {
await driver.wait(until.elementsLocated(By.className("_spinner-container_18q42_141")), 1000);
const load = await driver.findElement(By.className("_spinner-container_18q42_141"));
await driver.wait(until.stalenessOf(load));
} catch (error) {
// if (!(error instanceof TimeoutError))
// throw error;
// if (!(error instanceof NoSuchElementError))
// throw error;
}
// 获取blob链接
await driver.wait(until.elementsLocated(By.xpath('//*[@id="app"]/div/file-drop/div/div[4]/div[2]/a')), 1000);
const link = await driver.findElement(By.xpath('//*[@id="app"]/div/file-drop/div/div[4]/div[2]/a'));
let blobLink = null;
const timeout = 2000;
const startTime = Date.now();
while (blobLink === null && (Date.now() - startTime) < timeout) {
blobLink = await link.getAttribute("href");
}
if (blobLink === null) throw new Error('blob link not found');
// 获取图片大小等文本
const oldImageSize = await driver.findElement(By.xpath('//*[@id="app"]/div/file-drop/div/div[3]/div[2]/div[2]/div/div[1]/div')).getText();
const newImageSize = await driver.findElement(By.xpath('//*[@id="app"]/div/file-drop/div/div[4]/div[2]/div[2]/div/div[1]/div')).getText();
let compPercent = await driver.findElement(By.xpath('//*[@id="app"]/div/file-drop/div/div[4]/div[2]/div[2]/div/div[2]/div')).getText();
compPercent = compPercent.replace(/\n/g, '');
// 注入脚本请求获得图片buffer
const base64Res = await driver.executeAsyncScript(injectScript, blobLink);
if (typeof base64Res === 'number') throw new Error('request failed, code: ' + base64Res);
const buffer = Buffer.from(base64Res, 'base64');
// 计算输出路径,没有则进行创建
const outputFolderPath = path.join(outputFolder, path.dirname(path.relative(RunFolder, inputPath)));
const outputPath = path.join(outputFolderPath, path.basename(inputPath));
if (!fs.existsSync(outputFolderPath)) {
fs.mkdirSync(outputFolderPath, { recursive: true });
}
// 写入图片并打印结果
fs.writeFile(outputPath, buffer, function (err) {
if (err) { console.log(err); }
console.log(chalk.cyan(inputPath) + ": " + chalk.yellow(oldImageSize));
console.log("├─ " + chalk.cyan(outputPath) + ": " + chalk.yellow(newImageSize));
if (compPercent.startsWith('↓')) {
console.log("└─ " + chalk.green(`[${compPercent}]`) + "\n");
} else if (compPercent.startsWith('↑')) {
console.log("└─ " + chalk.red(`[${compPercent}]`) + "\n");
} else {
console.log("└─ " + `[${compPercent}]` + "\n");
}
});
};
// 递归遍历文件夹
const getImagesInFolder = (folderPath, images = []) => {
const files = fs.readdirSync(folderPath);
files.forEach((file) => {
const filePath = path.resolve(folderPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
getImagesInFolder(filePath, images);
} else if (isImageFile(file)) {
images.push(filePath);
}
});
return images;
};
const isImageFile = (filename) => {
const ext = path.extname(filename).toLowerCase();
return imageExtensions.includes(ext);
};
const startCompImage = async () => {
const imagePaths = getImagesInFolder(RunFolder);
console.log('all image paths:', imagePaths);
for (const inputPath of imagePaths) {
try {
await chromeAutoOperation(inputPath);
} catch (error) {
console.error(chalk.red(`processing image with path ${inputPath} failed\n`), error);
}
}
};
startCompImage();
rl.on("close", async () => {
await driver.close();
process.exit(0);
});
- 总结:还是使用sharpjs方案简单方便