chatapp3-flutter/scripts/tinypng_batch.rb
2026-04-13 15:25:55 +08:00

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")