165 lines
4.9 KiB
Ruby
165 lines
4.9 KiB
Ruby
#!/usr/bin/env ruby
|
|
|
|
require 'base64'
|
|
require 'fileutils'
|
|
require 'json'
|
|
require 'net/http'
|
|
require 'pathname'
|
|
require 'tmpdir'
|
|
require 'uri'
|
|
|
|
SUPPORTED_EXTENSIONS = %w[.png .jpg .jpeg .webp].freeze
|
|
|
|
def format_bytes(bytes)
|
|
format('%.2f MB', bytes.to_f / 1024 / 1024)
|
|
end
|
|
|
|
def collect_files(target)
|
|
if File.file?(target)
|
|
return SUPPORTED_EXTENSIONS.include?(File.extname(target).downcase) ? [target] : []
|
|
end
|
|
|
|
return [] unless Dir.exist?(target)
|
|
|
|
Dir.glob(File.join(target, '**', '*'))
|
|
.select { |path| File.file?(path) }
|
|
.select { |path| SUPPORTED_EXTENSIONS.include?(File.extname(path).downcase) }
|
|
end
|
|
|
|
def build_http(uri)
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = uri.scheme == 'https'
|
|
http.read_timeout = 120
|
|
http.open_timeout = 30
|
|
http
|
|
end
|
|
|
|
def tinypng_shrink(api_key, file_path)
|
|
uri = URI('https://api.tinify.com/shrink')
|
|
request = Net::HTTP::Post.new(uri)
|
|
request['Authorization'] = "Basic #{Base64.strict_encode64("api:#{api_key}")}"
|
|
request['Content-Type'] = 'application/octet-stream'
|
|
request.body = File.binread(file_path)
|
|
|
|
response = build_http(uri).request(request)
|
|
unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
|
|
message = begin
|
|
body = JSON.parse(response.body)
|
|
body['message'] || response.body
|
|
rescue StandardError
|
|
response.body
|
|
end
|
|
raise "Shrink failed (#{response.code}): #{message}"
|
|
end
|
|
|
|
JSON.parse(response.body)
|
|
end
|
|
|
|
def download_file(url, destination)
|
|
uri = URI(url)
|
|
request = Net::HTTP::Get.new(uri)
|
|
response = build_http(uri).request(request)
|
|
|
|
unless response.is_a?(Net::HTTPSuccess)
|
|
raise "Download failed (#{response.code}): #{response.body}"
|
|
end
|
|
|
|
File.binwrite(destination, response.body)
|
|
end
|
|
|
|
def compress_file(api_key, file_path)
|
|
original_size = File.size(file_path)
|
|
shrink_result = tinypng_shrink(api_key, file_path)
|
|
output = shrink_result.fetch('output')
|
|
temp_path = File.join(Dir.tmpdir, "#{File.basename(file_path)}.tinypng_tmp")
|
|
|
|
download_file(output.fetch('url'), temp_path)
|
|
compressed_size = File.size(temp_path)
|
|
FileUtils.mv(temp_path, file_path, force: true)
|
|
|
|
{
|
|
filePath: file_path,
|
|
originalSize: original_size,
|
|
compressedSize: compressed_size,
|
|
savedBytes: original_size - compressed_size,
|
|
ratio: output['ratio']
|
|
}
|
|
ensure
|
|
FileUtils.rm_f(temp_path) if defined?(temp_path) && temp_path && File.exist?(temp_path)
|
|
end
|
|
|
|
api_key = ENV['TINYPNG_API_KEY']
|
|
abort('Missing TINYPNG_API_KEY environment variable') unless api_key && !api_key.empty?
|
|
|
|
retry_failed = ARGV.delete('--retry-failed')
|
|
targets = ARGV
|
|
abort('Usage: tinypng_batch.rb <dir> [dir...]') if targets.empty?
|
|
|
|
progress_path = File.expand_path('tinypng-progress.json')
|
|
existing_progress = if File.exist?(progress_path)
|
|
JSON.parse(File.read(progress_path), symbolize_names: true)
|
|
else
|
|
{}
|
|
end
|
|
|
|
results = Array(existing_progress[:results])
|
|
failures = Array(existing_progress[:failures])
|
|
processed_paths = results.map { |item| item[:filePath] }
|
|
processed_paths += failures.map { |item| item[:filePath] } unless retry_failed
|
|
|
|
target_files = targets.flat_map { |target| collect_files(File.expand_path(target)) }.uniq
|
|
files = target_files.reject { |path| processed_paths.include?(path) }
|
|
.sort_by { |path| -File.size(path) }
|
|
|
|
persist_progress = lambda do
|
|
File.write(
|
|
progress_path,
|
|
JSON.pretty_generate(
|
|
{
|
|
results: results,
|
|
failures: failures
|
|
}
|
|
) + "\n"
|
|
)
|
|
end
|
|
|
|
files.each_with_index do |file_path, index|
|
|
begin
|
|
result = compress_file(api_key, file_path)
|
|
results << result
|
|
persist_progress.call
|
|
puts [
|
|
'OK',
|
|
index + 1,
|
|
files.length,
|
|
result[:savedBytes],
|
|
result[:originalSize],
|
|
result[:compressedSize],
|
|
Pathname.new(file_path).relative_path_from(Pathname.pwd)
|
|
].join("\t")
|
|
rescue StandardError => error
|
|
failures << { filePath: file_path, message: error.message }
|
|
persist_progress.call
|
|
puts ['ERR', index + 1, files.length, Pathname.new(file_path).relative_path_from(Pathname.pwd), error.message].join("\t")
|
|
end
|
|
end
|
|
|
|
summary = {
|
|
totalFiles: target_files.length,
|
|
trackedFiles: results.length + failures.length,
|
|
compressedFiles: results.length,
|
|
failedFiles: failures.length,
|
|
originalBytes: results.sum { |item| item[:originalSize] },
|
|
compressedBytes: results.sum { |item| item[:compressedSize] },
|
|
savedBytes: results.sum { |item| item[:savedBytes] },
|
|
failures: failures,
|
|
topSavings: results.sort_by { |item| -item[:savedBytes] }.first(30)
|
|
}
|
|
|
|
report_path = File.expand_path('tinypng-report.json')
|
|
File.write(report_path, JSON.pretty_generate(summary) + "\n")
|
|
|
|
puts ['SUMMARY', summary[:totalFiles], summary[:trackedFiles], summary[:compressedFiles], summary[:failedFiles], summary[:originalBytes], summary[:compressedBytes], summary[:savedBytes]].join("\t")
|
|
puts ['REPORT', report_path].join("\t")
|
|
puts ['HUMAN', format_bytes(summary[:originalBytes]), format_bytes(summary[:compressedBytes]), format_bytes(summary[:savedBytes])].join("\t")
|