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:



Somewhat related: