Downloading CleanShot images with Ruby
In my opinion CleanShot is one of the best screenshot tools for macOS by 2023. I'm using it pretty much every day.
I'm going to integrate CleanShot with my notes publication script to embed screenshots to Markdown pages.
My desired workflow is to plug CleanShot short URL into my notes using Markdown image markup, letting a script to extract actual image URL and do further processing.
Here is a quick example on how to download image file from CleanShot URL:
require "http"
require "cgi"
cleanshot_url = "https://share.cleanshot.com/35WJDTGz"
# Fetch direct image URL
direct_image_url = HTTP.get("#{cleanshot_url}+").headers["Location"]
# Extract image file name from the CleanShot query string
query = URI.parse(direct_image_url).query
param = CGI.parse(query).fetch("response-content-disposition").first
file_name = CGI.unescape(param.split("=", 2).last)
# Download the image to a file
image_contents = HTTP.get(direct_image_url).body.to_s
File.open(file_name, "wb") { _1.write(image_contents) }
(Yes, it is possible to avoid third party dependency here by using Ruby's native Net::HTTP
instead of the http
gem, but I don't enjoy pain.)
Let's add some errors handling and refactor it into a service class:
require "http"
require "cgi"
class CleanshotDownloader
attr_reader :cleanshot_url
def initialize(cleanshot_url, file_name: nil)
@cleanshot_url = cleanshot_url
@file_name = file_name
end
def download
response = HTTP.get(direct_image_url)
raise "error downloading image" unless response.status.success?
File.open(file_name, "wb") { _1.write(response.body.to_s) }
file_name
end
private
def direct_image_url
@direct_image_url ||= begin
response = HTTP.get("#{cleanshot_url}+")
raise "error getting image URL" unless response.status.redirect?
response.headers["Location"]
end
end
def file_name
@file_name ||= begin
query = URI.parse(direct_image_url).query
param = CGI.parse(query).fetch("response-content-disposition").first
CGI.unescape(param.split("=", 2).last)
end
end
end
Usage example:
CleanshotDownloader.new("https://share.cleanshot.com/35WJDTGz").download
# => "CleanShot 2023-08-06 at 15.27.35.jpeg"
And a spec with fancy WebMock stubs:
RSpec.describe CleanshotDownloader do
subject(:service_call) { described_class.new(cleanshot_url).download }
let(:cleanshot_url) { "https://share.cleanshot.com/80085" }
let(:direct_image_url) do
"https://media.cleanshot.cloud/media/1334/itkYgfyMGKeaUT.jpeg?" \
"response-content-disposition=attachment%3Bfilename%3DCleanShot" \
"%25202023-08-06%2520at%252015.27.35.jpeg&Expires=1691367016"
end
let(:image_contents) { file_fixture("banana.jpg").read }
context "with explicit file name" do
subject(:service_call) { described_class.new(cleanshot_url, file_name: file_name).download }
let(:file_name) { Tempfile.new(described_class.name) }
before do
stub_cleanshot_url.to_return(status: 302, headers: {"Location" => direct_image_url})
stub_direct_image_url.to_return(headers: {"Content-Type" => "image/jpeg"}, body: image_contents)
service_call
end
it { expect(File.read(file_name)).to eq(image_contents) }
end
context "with implicit file name" do
before do
stub_cleanshot_url.to_return(status: 302, headers: {"Location" => direct_image_url})
stub_direct_image_url.to_return(headers: {"Content-Type" => "image/jpeg"}, body: image_contents)
end
it { expect(service_call).to eq("CleanShot 2023-08-06 at 15.27.35.jpeg") }
end
context "with redirect error" do
before do
stub_cleanshot_url.to_return(status: 500)
end
it { expect { service_call }.to raise_error("error getting image URL") }
end
context "with image downloading error" do
before do
stub_cleanshot_url.to_return(status: 302, headers: {"Location" => direct_image_url})
stub_direct_image_url.to_return(status: 500)
end
it { expect { service_call }.to raise_error("error downloading image") }
end
def stub_cleanshot_url
stub_request(:get, "#{cleanshot_url}+")
end
def stub_direct_image_url
stub_request(:get, direct_image_url)
end
end
References:
- CleanShot: https://cleanshot.com
- Markdown reference (CommonMark): https://commonmark.org/help/
Somewhat related: