#!/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...]') 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")