Bash
In a previous post I implemented a very simple LocalSend client for my Linux desktop.
The Bash script was simple and it did exactly what I needed it to do. Here it is:
The script
1
2
3
4
5
6
7
8
9
10
11
12
BASE_URL=http://192.168.1.149:53317/api/localsend/v2
SESSION_ID=5a9606ab-f683-4045-8f72-5a6a2ace9c39
curl -X POST --silent $BASE_URL/prepare-download?sessionId=$SESSION_ID |
jq -c ".files[] | {id, fileName}" |
while read -r FILE_JSON
do
FILE_ID=$(echo $FILE_JSON | jq -r .id)
FILE_NAME=$(echo $FILE_JSON | jq -r .fileName)
wget -O $FILE_NAME "${BASE_URL}/download?sessionId=${SESSION_ID}&fileId=${FILE_ID}"
done
It still required some manual steps though.
BASE_URLto be inserted manuallySESSION_IDneeds to be extracted via browser and inserted manually- Files are downloaded in the same directory where the was run script from. You have to navigate to where you want the files to be downloaded.
Ruby
As stated in my previous post, LocalSend web page assumes JavaScript support. cURL cannot really help me with that.
In a recent issue of Ruby Weekly, this is what I came across:
Tanakai: A Web Scraping Framework for Ruby — Kimurai was a scraping framework that seemingly hibernated a few years ago, but Tanakai is an up-to-date fork that supports Ruby 3+, Cuprite, and lets you work with headless browsers to scrape and interact with modern sites, even those rendered with JavaScript.
Perfect! 👌😍
MVP
Here’s the solution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
require 'tanakai'
# Crawls LocalSend's "share via link"
class LocalSendSpider < Tanakai::Base
PHONE_APP_ACCEPT_TIMEOUT = 10
@name = 'local_send_spider'
@engine = :selenium_firefox
def parse(_response = nil, url:, **)
logger.info('Count down for approval on Smartphone side')
PHONE_APP_ACCEPT_TIMEOUT.times do |n|
sleep 1
logger.info(PHONE_APP_ACCEPT_TIMEOUT - n)
break unless browser.current_response.xpath('/html/body/div/a').empty?
end
logger.info('Fetching file links...')
browser.current_response.xpath('/html/body/div/a').map do |nokogiri_node|
file_path = nokogiri_node[:href]
file_name = nokogiri_node.xpath('div[@class = "file-name-cell"]').text.strip
logger.warn("Downloading file #{file_name}")
system(%(wget -O #{file_name} "#{url + file_path}"))
end
logger.info('Done!')
end
end
LocalSendSpider.parse!(:parse, url: 'http://192.168.1.247:53317')
It should be pretty easy to follow:
- Navigate to some URL with Selenium Firefox
- Wait for “approval” from the smartphone app
- Find file links and names with some help from Nokogiri
- Download each file with
wget
Disregard the #parse and #parse! methods. That’s part of Tanakai’s admittedly clunky API.
Here’s what the output looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ bundle exec ruby local_send.rb
... DEBUG -- local_send_spider: BrowserBuilder (selenium_firefox): created browser instance
... DEBUG -- local_send_spider: BrowserBuilder (selenium_firefox): enabled native headless_mode
... INFO -- local_send_spider: Browser: started get request to: http://192.168.1.247:53317
... INFO -- local_send_spider: Browser: finished get request to: http://192.168.1.247:53317
... DEBUG -- local_send_spider: Browser: driver.current_memory: 725158
... INFO -- local_send_spider: Count down for approval on Smartphone side
... INFO -- local_send_spider: 10
... INFO -- local_send_spider: 9
... INFO -- local_send_spider: 8
... INFO -- local_send_spider: 7
... INFO -- local_send_spider: 6
... INFO -- local_send_spider: 5
... INFO -- local_send_spider: Fetching file links...
... WARN -- local_send_spider: Downloading file IMG_20231029_120332_010.jpg
--2023-11-01 22:16:45-- http://192.168.1.247:53317/api/localsend/v2/download?sessionId=cec94145-8f75-4dd1-bbce-2e627957b88a&fileId=890b04dc-9537-499a-925b-9671b86b1309
Connecting to 192.168.1.247:53317... connected.
HTTP request sent, awaiting response... 200 OK
Length: 9445328 (9.0M) [application/octet-stream]
Saving to: ‘IMG_20231029_120332_010.jpg’
IMG_20231029_120332_010.jpg 100%[======================================================>] 9.01M 4.38MB/s in 2.1s
2023-11-01 22:16:47 (4.38 MB/s) - ‘IMG_20231029_120332_010.jpg’ saved [9445328/9445328]
... INFO -- local_send_spider: Done!
... INFO -- local_send_spider: Browser: driver selenium_firefox has been destroyed
And that’s it! Simple, right?
In terms of functionality, this is better than the Bash script from previous post. There’s no need to extract the SESSION_ID from the browser; that’s the benefit of supporting JavaScript. The only manual step here is to paste in the URL where the files can be downloaded.
Everything else stays the same; files are downloaded to the folder from where this script was run, and file names are preserved with the download.
PRs
I always use the latest version of Ruby. At the time of writing, that’s Ruby 3.2.2. There were two issues I needed to resolve before I could use Tanakai, so I opened respective pull requests that got merged:
That’s good. Now I can continue using Tanakai and improve my script.
Improvements
I want to create a proper CLI tool for downloading files from LocalSend phone app.
Specifically:
URLshould be supplied via command line argumentSESSION_IDshould be hidden from the user’s perspective- Download folder should be specified via command line argument
- Both
URLand the download folder should be subject to validation - When something goes wrong, there should be a clear error message describing what went wrong
- Other
CLI
I want to pass arguments via the command line. There are a couple of options to do that:
- I could use
ARGVdirectly and parse it manually. But I won’t do that. No need to reinvent the wheel. - Use Ruby standard library’s OptionParser
- Use a gem
In the “CLI option parsers” category on The Ruby Toolbox I can find a couple of good candidates:
I won’t go with any of them though. OptionParser will do the job of simple command line option parsing perfectly well. I’ve also used it in the past and am familiar with it.
OptionParser it is then!
OptionParser
OptionParser is a class for command-line option analysis.
Features
- The argument specification and the code to handle it are written in the same place.
- It can output an option summary; you don’t need to maintain this string separately.
- Optional and mandatory arguments are specified very gracefully.
- Arguments can be automatically converted to a specified class.
- Arguments can be restricted to a certain set.
All of these features are demonstrated in the examples below. See make_switch for full documentation.
The implementation is pretty straight forward. Note that both the --url and --download-path are optional - denoted by the square brackets [value]. I’m using Ruby’s immutable Data class in order to store the actual values. They’re great for defining simple value-like objects and are convenient to use for pattern matching. I’ll use pattern matching for the validation purposes below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
require 'optparse'
require 'optparse/uri'
module CmdLineArgs
Missing = Data.define
CmdArgs = Data.define(:uri, :download_path)
end
## Parse CLI flags
class ArgParser
include CmdLineArgs
LOCAL_SEND_DEFAULT_PORT = 53_317
def self.call(options)
new.call(options)
end
def call(options)
parse(options)
end
private
def parse(_options)
args = { uri: Missing.new, download_path: Missing.new }
opt_parser = OptionParser.new do |parser|
parser.banner = <<~BANNER
Download all files from LocalSend\'s "share via link" <URL> to <DOWNLOAD_PATH>
Usage: ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir
BANNER
parser.separator('')
parser.set_summary_width(40)
parser.on('-u', '--url [URL]', URI) do |uri|
if uri
uri.port = LOCAL_SEND_DEFAULT_PORT
args[:uri] = uri
end
end
parser.on('-d', '--download-path [DOWNLOAD_PATH]', String) do |download_path|
args[:download_path] = File.expand_path(download_path) if download_path
end
parser.on('-h', '--help', 'Prints this help') do
puts parser
exit
end
end
opt_parser.parse!
CmdArgs.new(**args)
end
end
ArgParser.call(ARGV)
1
2
3
4
5
6
7
8
$ bundle exec ruby local_send.rb --help
Download all files from LocalSend's "share via link" <URL> to <DOWNLOAD_PATH>
Usage: ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir
-u, --url [URL]
-d, --download-path [DOWNLOAD_PATH]
-h, --help Prints this help
Validation
Ruby’s pattern matching is perfect for the task at hand. It’s very easy to describe different error modes and handle them accordingly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Validate ARGV
class ArgValidator
include CmdLineArgs
attr_reader :parsed_options
def initialize(parsed_options)
@parsed_options = parsed_options
end
def when_valid
case parsed_options
in CmdArgs(uri: Missing, download_path: Missing)
raise StandardError, 'Missing URL and DOWNLOAD_PATH'
in CmdArgs(uri: Missing, download_path: _)
raise StandardError, 'Missing URL'
in CmdArgs(uri: _, download_path: Missing)
raise StandardError, 'Missing DOWNLOAD_PATH'
in CmdArgs(_, download_path) unless File.directory?(download_path)
raise StandardError, 'DOWNLOAD_PATH is not a directory or DOWNLOAD_PATH does not exist'
in CmdArgs(URI::HTTP | URI::HTTPS => uri, String => download_path)
yield uri.to_s, download_path
in CmdArgs(URI::Generic => uri, _)
raise StandardError, "Invalid URL: #{uri}"
end
end
end
ArgValidator.new(ArgParser.call(ARGV)).when_valid do |url, download_path|
# do something with url and download_path
end
File Download
I’ve provided two different downloaders here. One is written in pure Ruby, the other uses wget.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
require 'open-uri'
require 'fileutils'
# Should be included in all download hander classes
module DownloaderInterface
def call
raise NotImplementedError
end
def full_file_download_path
raise NotImplementedError
end
end
# Downloads file to a specified path from URL
class RubyDownloader
include DownloaderInterface
attr_reader :uri, :file_name, :download_path
def initialize(uri, file_name, download_path)
@uri = uri
@file_name = file_name
@download_path = download_path
end
def call
File.open(full_file_download_path, 'w') { _1.write(OpenURI.open_uri(uri).read) }
end
def full_file_download_path
File.join(download_path, file_name)
end
end
# Downloads file to a specified path from URL via wget
class WgetDownloader
include DownloaderInterface
attr_reader :uri, :file_name, :download_path
def initialize(uri, file_name, download_path)
@uri = uri
@file_name = file_name
@download_path = download_path
end
def call
system(%(wget -O #{full_file_download_path} "#{uri}"))
end
def full_file_download_path
File.join(download_path, file_name)
end
end
Usage
And here’s how to put everything together:
1
2
3
4
5
6
7
8
9
10
ArgValidator.new(ArgParser.call(ARGV)).when_valid do |url, download_path|
LocalSendSpider.parse!(
:parse,
url: url,
data: {
download_path: download_path,
download_handler: WgetDownloader # or use RubyDownloader
}
)
end
And here’s how to use it from the command line:
1
$ bundle exec ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir