Skip to content

Instantly share code, notes, and snippets.

@duzun
Last active August 11, 2018 00:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save duzun/234bd3ca69b243bb32bb to your computer and use it in GitHub Desktop.
Save duzun/234bd3ca69b243bb32bb to your computer and use it in GitHub Desktop.
Compresses images using on-line services.
#!/bin/sh
# https://gist.github.com/duzun/234bd3ca69b243bb32bb
basedir=`dirname "$0"`
optimagurl=https://duzun.me/optimag.js
optimagjs=$basedir/optimag.js
case `uname` in
*CYGWIN*) optimagjs=`cygpath -w "$optimagjs"`;;
*MINGW64*) optimagjs=`cygpath -w "$optimagjs"`;;
esac
if [ ! -f "$optimagjs" ]; then
curl $optimagurl > $optimagjs
fi
if [ ! -f "$optimagjs" ]; then
wget $optimagurl -O $optimagjs
fi
if [ ! -x "$optimagjs" ]; then
chmod +x $optimagjs
fi
node "$optimagjs" $@
ret=$?
exit $ret
@ECHO off
REM https://gist.github.com/duzun/234bd3ca69b243bb32bb
set optimagurl=https://duzun.me/optimag.js
if not exist %~dp0\optimag.js (
curl %optimagurl% > %~dp0\optimag.js
)
if not exist %~dp0\optimag.js (
wget %optimagurl% -O %~dp0\optimag.js
)
node %~dp0\optimag.js %*
#!/usr/bin/env node
/**
* Compresses images using on-line services.
*
* Usage:
* node optimag.js [-t|-k|-c] source-pic.png [compressed-pic.png]
*
* Auto-update:
* node optimag.js -u [<url-to-optimag-source.js>]
*
* Install:
* wget -O /usr/local/lib/optimag.js https://duzun.me/optimag.js && chmod +x /usr/local/lib/optimag.js && ln -si /usr/local/lib/optimag.js /usr/local/bin/optimag
* or
* curl https://duzun.me/optimag.js > /usr/local/lib/optimag.js && chmod +x /usr/local/lib/optimag.js && ln -si /usr/local/lib/optimag.js /usr/local/bin/optimag
*
*
* Online services:
* https://tinypng.com/
* https://kraken.io/web-interface
* https://compressor.io/compress
*
* @author Dumitru Uzun (DUzun.Me)
* @version 2.2.2
*/
var VERSION = '2.2.2';
// For PNG tinypng & kraken are good, compressor and sometimes might be good
// For JPG tinypng is the best, kraken helps a little bit, compressor is useless
// compressor: Doesn't display errors, max 10Mb
// kraken: max 1Mb, supports CMYK
var https = require('https');
var http = require('http');
var fs = require('fs');
var url = require('url');
var path = require('path');
var zlib = require('zlib');
var log = console.log.bind(console);
var TB = "\t";
var CR = "\r";
var LF = "\n";
var CRLF = CR+LF;
var update_url = 'https://duzun.me/optimag.js';
tinypng.id = 'tinypng';
tinypng.url = 'https://tinypng.com/web/shrink';
tinypng.fileExts = ['.jpg', '.jpeg', '.png']
kraken.id = 'kraken';
kraken.url = 'https://kraken.io/uploader';
kraken.Referer = 'https://kraken.io/web-interface';
kraken.maxSize = 1024*1024;
tinypng.fileExts = ['.jpg', '.jpeg', '.png']
compressor.id = 'compressor';
compressor.url = 'https://compressor.io/server/Lossless.php';
compressor.Referer = 'https://compressor.io/compress';
compressor.maxSize = 10*1024*1024;
tinypng.fileExts = ['.jpg', '.jpeg', '.png']
if ( !module.parent ) {
var processor = tinypng;
var src;
var dest;
for(var i=2, l=process.argv.length; i<l; i++) {
var a = process.argv[i];
switch(a) {
case '--update':
case '-u': {
autoUpdate(process.argv[i+1] || update_url);
i = l;
processor = false;
} break;
case '-v': {
log('v' + VERSION);
processor = false;
} break;
case '-t': {
processor = tinypng;
} break;
case '-k': {
processor = kraken;
} break;
case '-c': {
processor = compressor;
} break;
default: {
if ( !src ) {
src = a;
}
else {
dest = a;
}
}
}
}
if ( !src ) src = '.';
if ( !dest ) dest = src;
if ( processor ) {
log('Processor: ' + (processor.id || processor.name));
processor(src, dest, function (err, info, contents, resp) {
if ( err ) {
console.error(err);
process.exit(1);
}
else {
if ( info ) {
var ratio = info.processedSize ? info.outputSize / info.processedSize : 1;
log(['Totals:'
, 'ratio ' + fmtPercent(ratio || 1)
, 'saved ' + fmtSize(info.saved || 0)
, 'processed ' + fmtSize(info.processedSize) + '/' + fmtSize(info.totalSize) + ' in ' + fmtSize(info.filesCount) + ' files'
].join(LF+TB));
}
process.exit(0);
}
});
}
}
else {
module.exports.tinypng = tinypng;
module.exports.kraken = kraken;
module.exports.compressor = compressor;
}
// - Compressors -----------------------------------------------------------
function tinypng(src, dest, cb, isRec) {
var _src = path.resolve(src);
var _dest = src != dest ? path.resolve(dest) : _src;
var isOtherDest = _src != _dest;
var srcStat = fs.statSync(_src);
if ( srcStat.isDirectory() ) {
// Don't go deeper then one level
if ( isRec ) {
cb();
}
else {
processDir(_src, _dest, tinypng, cb);
}
return ;
}
// Browser state object
var state = {};
var ext = path.extname(src).replace(/^\.+/, '');
ext = {jpg:'jpeg'}[ext] || ext;
var data = fs.readFileSync(_src);
var fileSize = data.length;
out('"' + path.basename(_src) + '"' + TB + fmtSize(fileSize) + ' >>t');
// log('t<- ' + fmtSize(fileSize) + ' of "'+path.basename(_src)+'"');
var req = request({
url: tinypng.url
, data: data
, headers: {
"Content-Type" : "image/" + ext
, "Pragma" : "no-cache"
, "Cache-Control" : "no-cache"
, "Connection" : "close"
}
}, function (err, data, resp, state) {
if ( err ) { cb(err); return; }
next(data.toString('utf-8'), resp);
}, state);
function next(json, res) {
var obj = JSON.parse(json);
if ( !obj.output ) {
var err = new Error(obj.message);
err.error = obj.error;
err.message = obj.message;
out('>x '+err.error + ':' + err.message, true);
// log(err.error + ':', err.message);
cb(err);
}
else {
obj.saved = obj.input.size - obj.output.size;
// log('Ratio:'
// , obj.output
// ? obj.output.size + ':' + obj.input.size +
// ' = ' + obj.output.ratio +
// ' < ' + fmtSize(obj.saved)
// : obj
// );
if ( obj.output.ratio < 1 ) {
var url = obj.output.url;
out('>> '+fmtSize(obj.output.size));
if ( isOtherDest ) {
out(' to "' + path.basename(_dest) + '"');
}
else {
if ( obj.output ) {
out(' = '+fmtPercent(obj.output.ratio));
out(' < '+fmtSize(obj.saved));
}
}
// log('t-> '+fmtSize(obj.output.size)+' to "' + path.basename(_dest) + '"');
var req = request({
url: obj.output.url
, filename: _dest
, filesize: obj.output.size
}, function (err, data, resp, state) {
out(' .', true);
cb(err, obj, data, resp);
}, state);
}
else {
// log('No compression');
out('>| < 0b');
if ( isOtherDest ) {
out(', original to "' + path.basename(_dest) + '"')
// log('Writing original file to "'+path.basename(_dest)+'"');
fs.writeFile(_dest, data, function (err) {
out(' .', true);
cb(err, obj, data);
});
}
else {
out(' .', true);
cb(null, obj)
}
}
}
}
return req;
}
function kraken(src, dest, cb, isRec) {
var _src = path.resolve(src);
var _dest = src == dest ? _src : path.resolve(dest);
var srcStat = fs.statSync(_src);
if ( srcStat.isDirectory() ) {
// Don't go deeper then one level
if ( isRec ) {
cb();
}
else {
processDir(_src, _dest, kraken, cb);
}
return ;
}
var ext = path.extname(src).replace(/^\.+/, '');
ext = {jpg:'jpeg'}[ext] || ext;
var boundary = '----WebKitFormBoundary' + randStr(16);
var beginData = '--' + boundary + CRLF +
'Content-Disposition: form-data; name="files"; filename="'+path.basename(_src)+'"' + CRLF +
"Content-Type: image/" + ext + CRLF + CRLF
;
var endData = CRLF + '--' + boundary + CRLF +
'Content-Disposition: form-data; name="lossy"' + CRLF + CRLF +
'false' +
CRLF + '--' + boundary + '--' + CRLF;
var data = fs.readFileSync(_src);
var fileSize = data.length;
data = Buffer.concat([new Buffer(beginData, 'utf8'), data, new Buffer(endData, 'utf8')]);
// Browser state object
var state = {};
log('k<- ' + fmtSize(fileSize) + ' of "'+path.basename(_src)+'"');
var req = request({
url: kraken.url
, data: data
, headers: {
"Content-Type" : "multipart/form-data; boundary=" + boundary
, "Referer" : kraken.Referer
, "Pragma" : "no-cache"
, "Cache-Control" : "no-cache"
// , "Connection" : "close"
}
}, function (err, data, resp, state) {
if ( err ) { cb(err); return; }
next(data.toString('utf-8'), resp);
}, state);
function next(json, res) {
var _obj = JSON.parse(json);
if ( !_obj.originalSize || _obj.status == 'error' ) {
var err = new Error(_obj.err);
err.error = _obj.status;
err.message = _obj.err;
log(err.error + ':', err.message);
cb(err);
}
else {
// Convert response to something similar to kraken's response
var obj = {input:{}, output:{}};
obj.input.size = _obj.originalSize;
obj.output.size = _obj.krakedSize;
obj.output.ratio = Math.round(obj.output.size / obj.input.size * 1e4) / 1e4;
obj.output.url = _obj.url;
obj.saved = obj.input.size - obj.output.size;
log('Ratio:'
, obj.output
? obj.output.size + ':' + obj.input.size +
' = ' + obj.output.ratio +
' < ' + fmtSize(obj.saved)
: obj
);
if ( obj.output.ratio < 1 ) {
log('k-> '+fmtSize(obj.output.size)+' to "' + path.basename(_dest) + '"');
var req = request({
url: obj.output.url
, filename: _dest
, filesize: obj.output.size
, headers: {
"Referer": kraken.Referer
, "Pragma" : "no-cache"
, "Cache-Control" : "no-cache"
}
}, function (err, data, resp, state) {
cb(err, obj, data, resp);
}, state);
}
else {
log('No compression');
if ( _src != _dest ) {
log('Writing original file to "'+path.basename(_dest)+'"');
fs.writeFile(_dest, data, function (err) {
cb(err, obj, data);
});
}
else {
cb(null, obj)
}
}
}
}
return req;
}
function compressor(src, dest, cb, isRec) {
var _src = path.resolve(src);
var _dest = src == dest ? _src : path.resolve(dest);
var srcStat = fs.statSync(_src);
if ( srcStat.isDirectory() ) {
// Don't go deeper then one level
if ( isRec ) {
cb();
}
else {
processDir(_src, _dest, compressor, cb);
}
return ;
}
var ext = path.extname(src).replace(/^\.+/, '');
ext = {jpg:'jpeg'}[ext] || ext;
var boundary = '----WebKitFormBoundary' + randStr(16);
var beginData = '--' + boundary + CRLF +
'Content-Disposition: form-data; name="files[]"; filename="'+path.basename(_src)+'"' + CRLF +
"Content-Type: image/" + ext + CRLF + CRLF
;
var endData = CRLF + '--' + boundary + '--' + CRLF;
var data = fs.readFileSync(_src);
var fileSize = data.length;
data = Buffer.concat([new Buffer(beginData, 'utf8'), data, new Buffer(endData, 'utf8')]);
// Browser state object
var state = {};
log('c<- ' + fmtSize(fileSize) + ' of "'+path.basename(_src)+'"');
var req = request({
url: compressor.url
, data: data
, headers: {
"Content-Type" : "multipart/form-data; boundary=" + boundary
, "Pragma" : "no-cache"
, "Cache-Control" : "no-cache"
, "Referer" : compressor.Referer
, "Accept" : "application/json, text/javascript, */*; q=0.01"
, "X-Requested-With" : "XMLHttpRequest"
// , "Connection" : "close"
}
}, function (err, data, resp, state) {
if ( err ) { cb(err); return; }
next(data.toString('utf-8'), resp);
}, state);
function next(json, res) {
var _obj = JSON.parse(json);
var _files = _obj.files;
var _file = _files[0];
// Convert response to something similar to compressor's response
var obj = {input:{}, output:{}};
obj.input.size = _file.size;
obj.input.name = _file.name;
obj.output.size = _file.sizeAfter;
obj.output.ratio = Math.round(obj.output.size / obj.input.size * 1e4) / 1e4;
obj.output.url = _file.url;
if ( !obj.output.size || _obj.error ) {
var err = new Error(_obj.error);
err.error = _obj.error;
err.message = _obj.message;
log(err.error + ':', err.message, _obj);
cb(err);
}
else {
obj.saved = obj.input.size - obj.output.size;
log('Ratio:'
, obj.output
? obj.output.size + ':' + obj.input.size +
' = ' + obj.output.ratio +
' < ' + fmtSize(obj.saved)
: obj
);
if ( obj.output.ratio < 1 ) {
log('c-> '+fmtSize(obj.output.size)+' to "' + path.basename(_dest) + '"');
var req = request({
url: obj.output.url
, filename: _dest
, filesize: obj.output.size
, headers: {
"Referer": compressor.Referer
}
}, function (err, data, resp, state) {
cb(err, obj, data, resp);
}, state);
}
else {
log('No compression');
if ( _src != _dest ) {
log('Writing original file to "'+path.basename(_dest)+'"');
fs.writeFile(_dest, data, function (err) {
cb(err, obj, data);
});
}
else {
cb(null, obj)
}
}
}
}
return req;
}
// - Auto-update -----------------------------------------------------------
function autoUpdate(url, cb) {
fs.realpath(process.argv[1], function (err, filename) {
if ( err ) if ( cb ) cb(err); else throw err;
request({
url: url
// , filename: filename
}, function (err, data, resp, state) {
if ( err ) if ( cb ) cb(err); else throw err;
var _str = data.toString('utf8');
var _ver = _str.match(/\*\s+\@version\s+([0-9]+\.[0-9]+\.[0-9]+)/i);
if ( !_ver ) {
var err = new Error("Can't find version tag in the received file :-(");
if ( cb ) cb(err); else throw err;
}
_ver = _ver[1];
if ( VERSION == _ver ) {
if ( cb ) {
cb(null, {version: VERSION, filename: filename, remote_ver: _ver});
}
else {
log("No update available, version " + VERSION);
}
}
else fs.writeFile(filename, data, function (err) {
if ( cb ) {
cb(err, {version: _ver, filename: filename, data: data});
}
else {
log('New version ' + _ver + ' in ' + filename);
}
});
});
});
}
// - Helpers -----------------------------------------------------------
function processDir(dirName, dest, processor, cb) {
fs.readdir(dirName, function (err, files) {
var fileExts = processor.fileExts || ['.jpg', '.jpeg', '.png'];
files = files
.filter(function (f) {
var e = path.extname(f).toLowerCase();
var isImg = ~fileExts.indexOf(e);
return isImg;
})
// Sort files by size - bigger files first:
.map(function (f) {
var fn = path.join(dirName, f);
var stat = fs.statSync(fn);
return {n:fn, s:stat.size};
})
;
if ( processor.maxSize ) {
var maxSize = processor.maxSize;
files = files
.filter(function (o) { return o.s <= maxSize; })
;
}
var processedSize = 0;
var outputSize = 0;
var savedSize = 0;
var totalSize = 0;
files = files
.sort(function (a, b) {return b.s - a.s;})
.map(function (o) {
totalSize += o.s;
return o.n;
})
;
var idx = 0;
function log_report() {
var progress = (idx / files.length + processedSize / totalSize) / 2;
var ratio = processedSize ? outputSize / processedSize : 1;
log([
fmtPercent(progress)
, idx + '/' + files.length
, lpad(fmtSize(processedSize), 7) + '/' + lpad(fmtSize(totalSize), 7)
, 'ratio ' + fmtPercent(ratio)
, 'saved ' + fmtSize(savedSize)
].join(TB));
}
;(function process(err, obj) {
if ( err && err.error == 'TooManyRequests' ) {
out('Retrying in ');
count_back(3, function () {
out(LF);
log_report();
var fn = files[idx-1];
processor(fn, path.join(dest, path.basename(fn)), process, true);
});
return;
}
if ( obj ) {
if ( obj.saved ) savedSize += obj.saved;
if ( obj.output.size ) outputSize += obj.output.size;
if ( obj.input.size ) processedSize += obj.input.size;
}
var fn = files[idx];
if ( idx >= files.length ) {
if ( obj ) {
obj.saved = savedSize;
obj.savedSize = savedSize;
obj.outputSize = outputSize;
obj.processedSize = processedSize;
obj.totalSize = totalSize;
obj.filesCount = files.length;
}
cb.apply(this, arguments);
return;
}
idx++;
// log((savedSize ? 'Saved ' + fmtSize(savedSize) + ' in ' : 'Processing ') + idx + ' of ' + files.length);
log_report();
processor(fn, path.join(dest, path.basename(fn)), process, true);
}
());
});
}
/**
* Make an http(s) request.
*
* Defaults to GET if no .data, and POST for .data.
* Can have .cookie.
* The .data is automatically .write()en.
* If .filename present, downloads contents to it.
* Additionally .filesize can be checked before writing to .filename.
*
* @param (object|string)opt - url or options object
* @param (function)cb(err, data, resp, state)
* @param (object)state - to store cookie so far
*
* @return (Request) .end()ed
*/
function request(opt, cb, state) {
// Swap cb with state, if state is the cb:
if ( typeof state == 'function' && typeof cb != 'function' ) {
var t = cb;
cb = state;
state = t;
}
if ( typeof opt == 'string' ) opt = { url: opt };
var protocol;
if ( opt.url ) {
var _purl = url.parse(opt.url);
opt.hostname = _purl.hostname
opt.path = _purl.path
protocol = _purl.protocol;
if ( !('port' in opt) ) opt.port = protocol == 'https:' ? 443 : 80;
}
else {
protocol = opt.port == 443 ? 'https:' : 'http:';
}
// Default headers
var _headers = {
// "User-Agent" : "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36"
"User-Agent" : "Mozilla/5.0 (Windows NT 6.3)"
, "Accept-Encoding" : "gzip, deflate"
, "Accept-Language" : "en-US,en;q=0.8"
, "Accept" : "*/*"
};
_headers['Origin'] = protocol + '//' + (opt.hostname || opt.host);
_headers['Referer'] = _headers['Origin'] + '/';
var data = opt.data;
if ( 'data' in opt ) {
if ( !opt.method ) opt.method = 'POST';
// Default method for data requests is GET
_headers['Content-Length'] = data && data.length || 0;
}
// Default method is GET
if ( !opt.method ) opt.method = 'GET';
var cookie = state && state.cookie;
// opt.cookie can overwrite state.cookie;
if ( 'cookie' in opt ) {
cookie = opt.cookie;
}
if ( !isEmptyObject(cookie) ) {
_headers['Cookie'] = cookies2header(cookie);
}
// Headers in opt.headers are more important then the dynamic ones
if ( opt.headers ) {
Object.keys(opt.headers).forEach(function (n) {
_headers[n] = opt.headers[n];
});
}
opt.headers = _headers;
// File download
var filename = opt.filename;
var filesize = opt.filesize; // expected filesize
// Remove special options:
delete opt.url;
delete opt.data;
delete opt.cookie;
delete opt.filename;
delete opt.filesize;
var hlib = protocol == 'https:' ? https : http;
var req = hlib.request(opt, function(resp) {
// log('STATUS: ' + resp.statusCode);
if ( state ) {
state.cookie = getCookies(resp, state.cookie);
}
// File download request:
if ( filename ) {
if ( resp.statusCode == 200 ) {
onHttpResponse(resp, function (err, data) {
// Check filesize
if ( !err && filesize != undefined && filesize != data.length ) {
err = new Error('Data doesn\'t have the expected size of "'+filesize+'"');
err.expectedSize = filesize;
err.actualSize = data.length;
}
if ( err ) {
cb(err, data, resp, state);
}
else {
fs.writeFile(filename, data, function (err) {
cb(err, data, resp, state);
});
}
});
}
else {
var err = new Error('Can\'t download file: ' + resp.statusMessage);
err.code = resp.statusCode;
err.status = resp.statusMessage;
cb(err, null, resp, state);
}
}
// Data request:
else {
onHttpResponse(resp, function (err, data) {
cb(err, data, resp, state);
});
}
});
req.on('error', function (err) {
cb(err, null, null, state);
});
if ( data ) req.write(data);
req.end();
return req;
}
function onHttpResponse(resp, cb) {
var body = [];
resp.on('data', function (chunk) { body.push(chunk); });
resp.on('end', function () {
body = Buffer.concat(body);
var encoding = resp.headers['content-encoding'];
if (encoding && encoding.indexOf('gzip') >= 0) {
zlib.gunzip(body, function(err, dezipped) {
if ( err ) {
cb(err, body, resp);
return;
}
cb(null, dezipped, resp);
});
}
else {
cb(null, body, resp);
}
});
}
function getCookies(resp, list) {
if ( !list ) list = {};
var cooks = resp.headers['set-cookie'];
if ( cooks ) {
cooks.forEach(function (c) {
var t = c.split(';');
var n = t[0].split('=');
list[n[0]] = n[1];
});
}
return list;
}
function cookies2header(list) {
if ( list ) {
return Object.keys(list)
.map(function (n) {
var v = list[n];
return n + '=' + v;
})
.join('; ')
;
}
}
function out(str, lf) {
process.stdout.write(str);
if ( lf ) process.stdout.write(LF);
}
function count_back(sec, cb) {
var _a = [];
(function _to_(sec) {
var msg = sec.toFixed(0) + ' sec';
out(msg);
setTimeout(function () {
_a.length = msg.length + 1;
out(_a.join('\x08'));
if ( sec <= 1 ) {
cb && cb();
}
else {
_to_(sec-1);
}
}, Math.min(1, sec) * 1e3);
}(parseFloat(sec)));
}
/**
* Generate a random alphanumeric string of given length.
*/
function randStr(len) {
if ( len == undefined ) len = 16;
var ret = [];
for(var i = 0, c; i < len; i++) {
c = Math.floor(Math.random() * 62) + 48;
if ( 57 < c ) c += 7;
if ( 90 < c ) c += 6;
ret.push(c);
}
return String.fromCharCode.apply(String, ret);
}
function isEmptyObject(obj) {
if ( obj ) for ( var i in obj ) if ( Object.prototype.hasOwnProperty.call(obj, i) ) return false;
return true;
}
function fmtSize(size) {
var ret = Number(size)
, u = 'b'
;
if ( ret > 1048576 ) {
ret = (ret / 1048576).toFixed(2);
u = 'Mb';
}
else
if ( ret > 1024 ) {
ret = (ret / 1024).toFixed(2);
u = 'Kb';
}
return ret + u;
}
function fmtPercent(abs) {
return lpad((abs*100).toFixed(2), 6) + '%';
}
function lpad(str, size, chr) {
str = String(str);
var len = str.length;
var dif = size - len;
if ( dif > 0 ) {
if ( !chr ) chr = ' ';
var a = [];
a.length = dif + 1;
str = a.join(chr) + str;
}
return str;
}
Copy link

ghost commented Jul 24, 2015

Please note that the tinypng.url ('https://tinypng.com/web/shrink') is not the official endpoint for using the TinyPNG/TinyJPG API.

Read https://tinypng.com/developers for more information on our official API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment