cdnget 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |o|
4
+ o.name = "cdnget"
5
+ o.version = '$Release: 0.1.0 $'.split()[1]
6
+ o.authors = ["makoto kuwata"]
7
+ o.email = ["kwa@kuwata-lab.com"]
8
+
9
+ o.summary = "Utility to download files from CDNJS, jsDelivr or Google."
10
+ o.description = "Utility to download files from CDNJS, jsDelivr or Google."
11
+ o.homepage = "https://github.com/kwatch/cdnget"
12
+ o.license = "MIT"
13
+
14
+ o.files = Dir[*%w[
15
+ README.md MIT-LICENSE Rakefile cdnget.gemspec
16
+ lib/**/*.rb
17
+ bin/cdnget
18
+ test/**/*_test.rb
19
+ ]]
20
+ o.bindir = "bin"
21
+ o.executables = ["cdnget"]
22
+ o.require_paths = ["lib"]
23
+
24
+ o.required_ruby_version = '>= 2.0'
25
+
26
+ o.add_development_dependency "minitest", "~> 5.4"
27
+ o.add_development_dependency "minitest-ok", "~> 1.0"
28
+ end
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- coding: utf-8 -*-
3
+
4
+ ##
5
+ ## Download files from CDN (CDNJS, Google, jsDelivr).
6
+ ##
7
+ ## - CDNJS (https://cdnjs.com/)
8
+ ## - Google (https://developers.google.com/speed/libraries/)
9
+ ## - jsDelivr (https://www.jsdelivr.com/)
10
+ ##
11
+ ## Example:
12
+ ## $ cdnget # list public CDN
13
+ ## $ cdnget [-q] cdnjs # list libraries
14
+ ## $ cdnget [-q] cdnjs jquery # list versions
15
+ ## $ cdnget [-q] cdnjs jquery 2.2.0 # list files
16
+ ## $ cdnget [-q] cdnjs jquery 2.2.0 /tmp # download files
17
+ ##
18
+
19
+ require 'open-uri'
20
+ require 'json'
21
+ require 'fileutils'
22
+
23
+
24
+ module CDNGet
25
+
26
+
27
+ RELEASE = '$Release: 0.1.0 $'.split()[1]
28
+
29
+ CLASSES = []
30
+
31
+
32
+ class Base
33
+
34
+ def self.inherited(klass)
35
+ CLASSES << klass
36
+ end
37
+
38
+ def list()
39
+ raise NotImplementedError.new("#{self.class.name}#list(): not implemented yet.")
40
+ end
41
+
42
+ def find(library)
43
+ raise NotImplementedError.new("#{self.class.name}#find(): not implemented yet.")
44
+ end
45
+
46
+ def get(library, version)
47
+ raise NotImplementedError.new("#{self.class.name}#get(): not implemented yet.")
48
+ end
49
+
50
+ def download(library, version, basedir=".", quiet: false)
51
+ validate(library, version)
52
+ File.exist?(basedir) or
53
+ raise CommandError.new("#{basedir}: not exist.")
54
+ File.directory?(basedir) or
55
+ raise CommandError.new("#{basedir}: not a directory.")
56
+ target_dir = File.join(basedir, library, version)
57
+ d = get(library, version)
58
+ d[:files].each do |file|
59
+ filepath = File.join(target_dir, file)
60
+ dirpath = File.dirname(filepath)
61
+ print "#{filepath} ..." unless quiet
62
+ url = File.join(d[:baseurl], file) # not use URI.join!
63
+ content = fetch(url)
64
+ content = content.force_encoding('ascii-8bit')
65
+ print " Done (#{format_integer(content.bytesize)} byte)" unless quiet
66
+ FileUtils.mkdir_p(dirpath) unless File.exist?(dirpath)
67
+ unchanged = File.exist?(filepath) && File.read(filepath, mode: 'rb') == content
68
+ if unchanged
69
+ print " (Unchanged)" unless quiet
70
+ else
71
+ File.open(filepath, 'wb') {|f| f.write(content) }
72
+ end
73
+ puts() unless quiet
74
+ end
75
+ nil
76
+ end
77
+
78
+ protected
79
+
80
+ def fetch(url, library=nil)
81
+ begin
82
+ html = open(url, 'rb') {|f| f.read() }
83
+ return html
84
+ rescue OpenURI::HTTPError => ex
85
+ raise CommandError.new("GET #{url} : #{ex.message}")
86
+ end
87
+ end
88
+
89
+ def validate(library, version)
90
+ if library
91
+ library =~ /\A[-.\w]+\z/ or
92
+ raise ArgumentError.new("#{library.inspect}: Unexpected library name.")
93
+ end
94
+ if version
95
+ version =~ /\A\d+(\.\d+)+([-.\w]+)?/ or
96
+ raise ArgumentError.new("#{version.inspect}: Unexpected version number.")
97
+ end
98
+ end
99
+
100
+ def format_integer(value)
101
+ return value.to_s.reverse.scan(/..?.?/).collect {|s| s.reverse }.reverse.join(',')
102
+ end
103
+
104
+ end
105
+
106
+
107
+ class HttpError < StandardError
108
+ end
109
+
110
+
111
+ class CDNJS < Base # TODO: use jsdelivr api
112
+ CODE = "cdnjs"
113
+ SITE_URL = 'https://cdnjs.com/'
114
+
115
+ def fetch(url, library=nil)
116
+ begin
117
+ html = open(url, 'rb') {|f| f.read() }
118
+ if library
119
+ html =~ /<h1\b.*?>(.*?)<\/h1>/ or
120
+ raise CommandError.new("#{library}: Library not found.")
121
+ library == $1.strip() or
122
+ raise CommandError.new("#{library}: Library not found (maybe '#{$1.strip()}'?).")
123
+ end
124
+ return html
125
+ rescue OpenURI::HTTPError => ex
126
+ raise HttpError.new("GET #{url} : #{ex.message}")
127
+ end
128
+ end
129
+ protected :fetch
130
+
131
+ def list
132
+ libs = []
133
+ html = fetch("https://cdnjs.com/libraries")
134
+ html.scan(/<td\b(.*?)<\/td>/m) do |str,|
135
+ name = desc = nil
136
+ name = $1 if str =~ /href="\/libraries\/([-.\w]+)">\s*\1\s*<\/a>/
137
+ desc = $1.strip if str =~ /<div style="display: none;">(.*?)<\/div>/m
138
+ libs << {name: name, desc: desc} if name
139
+ end
140
+ return libs.sort_by {|d| d[:name] }.uniq
141
+ end
142
+
143
+ def find(library)
144
+ validate(library, nil)
145
+ html = fetch("https://cdnjs.com/libraries/#{library}", library)
146
+ versions = []
147
+ html.scan(/<option value="([^"]+)" *(?:selected)?>/) do |ver,|
148
+ versions << ver
149
+ end
150
+ desc = tags = nil
151
+ if html =~ /<p>(.*?)<\/p>\s*<em>(.*?)<\/em>/
152
+ desc = $1
153
+ tags = $2
154
+ end
155
+ return {
156
+ name: library,
157
+ desc: desc,
158
+ tags: tags,
159
+ versions: versions,
160
+ }
161
+ end
162
+
163
+ def get(library, version)
164
+ validate(library, version)
165
+ html = fetch("https://cdnjs.com/libraries/#{library}/#{version}", library)
166
+ baseurl = "https://cdnjs.cloudflare.com/ajax/libs/#{library}/#{version}"
167
+ urls = []
168
+ files = []
169
+ rexp = %r`>(#{Regexp.escape(baseurl)}/([^<]+))<\/p>`
170
+ html.scan(%r`>(#{Regexp.escape(baseurl)}/([^<]+))<\/p>`) do |url, file|
171
+ urls << url .gsub(/&#x2F;/, '/')
172
+ files << file.gsub(/&#x2F;/, '/')
173
+ end
174
+ return {
175
+ name: library,
176
+ version: version,
177
+ urls: urls,
178
+ files: files,
179
+ baseurl: baseurl,
180
+ }
181
+ end
182
+
183
+ end
184
+
185
+
186
+ class JSDelivr < Base
187
+ CODE = "jsdelivr"
188
+ SITE_URL = "https://www.jsdelivr.com/"
189
+ API_URL = "https://api.jsdelivr.com/v1/jsdelivr/libraries"
190
+
191
+ def list
192
+ json = fetch("#{API_URL}?fields=name,description,homepage")
193
+ arr = JSON.load(json)
194
+ return arr.collect {|d|
195
+ {name: d["name"], desc: d["description"], site: d["homepage"] }
196
+ }
197
+ end
198
+
199
+ def find(library)
200
+ validate(library, nil)
201
+ json = fetch("#{API_URL}?name=#{library}&fields=name,description,homepage,versions")
202
+ arr = JSON.load(json)
203
+ d = arr.first or
204
+ raise CommandError.new("#{library}: Library not found.")
205
+ return {
206
+ name: d['name'],
207
+ desc: d['description'],
208
+ site: d['homepage'],
209
+ versions: d['versions'],
210
+ }
211
+ end
212
+
213
+ def get(library, version)
214
+ validate(library, version)
215
+ baseurl = "https://cdn.jsdelivr.net/#{library}/#{version}"
216
+ url = "#{API_URL}/#{library}/#{version}"
217
+ json = fetch("#{API_URL}/#{library}/#{version}")
218
+ files = JSON.load(json)
219
+ ! files.empty? or
220
+ raise CommandError.new("#{library}: Library not found.")
221
+ urls = files.collect {|x| "#{baseurl}/#{x}" }
222
+ return {
223
+ name: library,
224
+ version: version,
225
+ urls: urls,
226
+ files: files,
227
+ baseurl: baseurl,
228
+ }
229
+ end
230
+
231
+ end
232
+
233
+
234
+ class GoogleCDN < Base # TODO: use jsdelivr api
235
+ CODE = "google"
236
+ SITE_URL = 'https://developers.google.com/speed/libraries/'
237
+
238
+ def list
239
+ libs = []
240
+ html = fetch("https://developers.google.com/speed/libraries/")
241
+ rexp = %r`"https://ajax\.googleapis\.com/ajax/libs/([^/]+)/([^/]+)/([^"]+)"`
242
+ html.scan(rexp) do |lib, ver, file|
243
+ libs << {name: lib, desc: "latest version: #{ver}" }
244
+ end
245
+ return libs.sort_by {|d| d[:name] }.uniq
246
+ end
247
+
248
+ def find(library)
249
+ validate(library, nil)
250
+ html = fetch("https://developers.google.com/speed/libraries/")
251
+ rexp = %r`"https://ajax\.googleapis\.com/ajax/libs/#{library}/`
252
+ site_url = nil
253
+ versions = []
254
+ urls = []
255
+ found = false
256
+ html.scan(/<h3\b.*?>.*?<\/h3>\s*<dl>(.*?)<\/dl>/m) do |text,|
257
+ if text =~ rexp
258
+ found = true
259
+ if text =~ /<dt>.*?snippet:<\/dt>\s*<dd>(.*?)<\/dd>/m
260
+ s = $1
261
+ s.scan(/\b(?:src|href)="([^"]*?)"/) do |href,|
262
+ urls << href
263
+ end
264
+ end
265
+ if text =~ /<dt>site:<\/dt>\s*<dd>(.*?)<\/dd>/m
266
+ s = $1
267
+ if s =~ %r`href="([^"]+)"`
268
+ site_url = $1
269
+ end
270
+ end
271
+ text.scan(/<dt>(?:stable |unstable )?versions:<\/dt>\s*<dd\b.*?>(.*?)<\/dd>/m) do
272
+ s = $1
273
+ vers = s.split(/,/).collect {|x| x.strip() }
274
+ versions.concat(vers)
275
+ end
276
+ break
277
+ end
278
+ end
279
+ found or
280
+ raise CommandError.new("#{library}: Library not found.")
281
+ return {
282
+ name: library,
283
+ site: site_url,
284
+ urls: urls,
285
+ versions: versions,
286
+ }
287
+ end
288
+
289
+ def get(library, version)
290
+ validate(library, version)
291
+ d = find(library)
292
+ d[:versions].find(version) or
293
+ raise CommandError.new("#{version}: No such version of #{library}.")
294
+ urls = d[:urls]
295
+ if urls
296
+ rexp = /(\/libs\/#{library})\/[^\/]+/
297
+ urls = urls.collect {|x| x.gsub(rexp, "\\1/#{version}") }
298
+ end
299
+ baseurl = "https://ajax.googleapis.com/ajax/libs/#{library}/#{version}"
300
+ files = urls ? urls.collect {|x| x[baseurl.length..-1] } : nil
301
+ return {
302
+ name: d[:name],
303
+ site: d[:site],
304
+ urls: urls,
305
+ files: files,
306
+ baseurl: baseurl,
307
+ version: version,
308
+ }
309
+ end
310
+
311
+ end
312
+
313
+
314
+ #class JQueryCDN < Base
315
+ # CODE = "jquery"
316
+ # SITE_URL = 'https://code.jquery.com/'
317
+ #end
318
+
319
+
320
+ #class ASPNetCDN < Base
321
+ # CODE = "aspnet"
322
+ # SITE_URL = 'https://www.asp.net/ajax/cdn/'
323
+ #end
324
+
325
+
326
+ class CommandError < StandardError
327
+ end
328
+
329
+
330
+ class Main
331
+
332
+ def initialize(script=nil)
333
+ @script = script || File.basename($0)
334
+ end
335
+
336
+ def help_message
337
+ script = @script
338
+ return <<END
339
+ #{script} -- download files from public CDN
340
+
341
+ Usage: #{script} [options] [CDN] [library] [version] [directory]
342
+
343
+ Options:
344
+ -h, --help : help
345
+ -v, --version : version
346
+ -q, --quiet : minimal output
347
+
348
+ Example:
349
+ $ #{script} # list public CDN
350
+ $ #{script} [-q] cdnjs # list libraries
351
+ $ #{script} [-q] cdnjs jquery # list versions
352
+ $ #{script} [-q] cdnjs jquery 2.2.0 # list files
353
+ $ #{script} [-q] cdnjs jquery 2.2.0 /tmp # download files
354
+ END
355
+ end
356
+
357
+ def self.main(args=nil)
358
+ args ||= ARGV
359
+ s = self.new().run(*args)
360
+ puts s if s
361
+ exit 0
362
+ rescue CommandError => ex
363
+ $stderr.puts ex.message
364
+ exit 1
365
+ end
366
+
367
+ def run(*args)
368
+ cmdopts = parse_cmdopts(args, "hvq", ["help", "version", "quiet"])
369
+ return help_message() if cmdopts['h'] || cmdopts['help']
370
+ return RELEASE + "\n" if cmdopts['v'] || cmdopts['version']
371
+ @quiet = cmdopts['quiet'] || cmdopts['q']
372
+ #
373
+ validate(args[1], args[2])
374
+ #
375
+ case args.length
376
+ when 0
377
+ return do_list_cdns()
378
+ when 1
379
+ cdn_code = args[0]
380
+ return do_list_libraries(cdn_code)
381
+ when 2
382
+ cdn_code, library = args
383
+ if library.include?('*')
384
+ return do_search_libraries(cdn_code, library)
385
+ else
386
+ return do_find_library(cdn_code, library)
387
+ end
388
+ when 3
389
+ cdn_code, library, version = args
390
+ return do_get_library(cdn_code, library, version)
391
+ when 4
392
+ cdn_code, library, version, basedir = args
393
+ do_download_library(cdn_code, library, version, basedir)
394
+ return ""
395
+ else
396
+ raise CommandError.new("'#{args[4]}': Too many arguments.")
397
+ end
398
+ end
399
+
400
+ def validate(library, version)
401
+ if library && ! library.include?('*')
402
+ library =~ /\A[-.\w]+\z/ or
403
+ raise CommandError.new("#{library}: Unexpected library name.")
404
+ end
405
+ if version
406
+ version =~ /\A[-.\w]+\z/ or
407
+ raise CommandError.new("#{version}: Unexpected version number.")
408
+ end
409
+ end
410
+
411
+ def parse_cmdopts(cmdargs, short_opts, long_opts)
412
+ cmdopts = {}
413
+ while cmdargs[0] && cmdargs[0].start_with?('-')
414
+ cmdarg = cmdargs.shift
415
+ if cmdarg == '--'
416
+ break
417
+ elsif cmdarg.start_with?('--')
418
+ cmdarg =~ /\A--(\w[-\w]+)(=.*?)?/ or
419
+ raise CommandError.new("#{cmdarg}: invalid command option.")
420
+ name = $1
421
+ value = $2 ? $2[1..-1] : true
422
+ long_opts.include?(name) or
423
+ raise CommandError.new("#{cmdarg}: unknown command option.")
424
+ cmdopts[name] = value
425
+ elsif cmdarg.start_with?('-')
426
+ cmdarg[1..-1].each_char do |c|
427
+ short_opts.include?(c) or
428
+ raise CommandError.new("-#{c}: unknown command option.")
429
+ cmdopts[c] = true
430
+ end
431
+ else
432
+ raise "unreachable"
433
+ end
434
+ end
435
+ return cmdopts
436
+ end
437
+
438
+ def find_cdn(cdn_code)
439
+ klass = CLASSES.find {|c| c::CODE == cdn_code } or
440
+ raise CommandError.new("#{cdn_code}: no such CDN.")
441
+ return klass.new
442
+ end
443
+
444
+ def render_list(list)
445
+ if @quiet
446
+ return list.collect {|d| "#{d[:name]}\n" }.join()
447
+ else
448
+ return list.collect {|d| "%-20s # %s\n" % [d[:name], d[:desc]] }.join()
449
+ end
450
+ end
451
+
452
+ def do_list_cdns
453
+ if @quiet
454
+ return CLASSES.map {|c| "#{c::CODE}\n" }.join()
455
+ else
456
+ return CLASSES.map {|c| "%-10s # %s\n" % [c::CODE, c::SITE_URL] }.join()
457
+ end
458
+ end
459
+
460
+ def do_list_libraries(cdn_code)
461
+ cdn = find_cdn(cdn_code)
462
+ return render_list(cdn.list)
463
+ end
464
+
465
+ def do_search_libraries(cdn_code, pattern)
466
+ cdn = find_cdn(cdn_code)
467
+ rexp_str = pattern.split('*', -1).collect {|x| Regexp.escape(x) }.join('.*')
468
+ rexp = Regexp.compile("\\A#{rexp_str}\\z", Regexp::IGNORECASE)
469
+ return render_list(cdn.list.select {|a| a[:name] =~ rexp })
470
+ end
471
+
472
+ def do_find_library(cdn_code, library)
473
+ cdn = find_cdn(cdn_code)
474
+ d = cdn.find(library)
475
+ s = ""
476
+ if @quiet
477
+ d[:versions].each do |ver|
478
+ s << "#{ver}\n"
479
+ end if d[:versions]
480
+ else
481
+ s << "name: #{d[:name]}\n"
482
+ s << "desc: #{d[:desc]}\n" if d[:desc]
483
+ s << "tags: #{d[:tags]}\n" if d[:tags]
484
+ s << "site: #{d[:site]}\n" if d[:site]
485
+ s << "snippet: |\n" << d[:snippet].gsub(/^/, ' ') if d[:snippet]
486
+ s << "versions:\n"
487
+ d[:versions].each do |ver|
488
+ s << " - #{ver}\n"
489
+ end if d[:versions]
490
+ end
491
+ return s
492
+ end
493
+
494
+ def do_get_library(cdn_code, library, version)
495
+ cdn = find_cdn(cdn_code)
496
+ d = cdn.get(library, version)
497
+ s = ""
498
+ if @quiet
499
+ d[:urls].each do |url|
500
+ s << "#{url}\n"
501
+ end if d[:urls]
502
+ else
503
+ s << "name: #{d[:name]}\n"
504
+ s << "version: #{d[:version]}\n"
505
+ s << "tags: #{d[:tags]}\n" if d[:tags]
506
+ s << "site: #{d[:site]}\n" if d[:site]
507
+ s << "snippet: |\n" << d[:snippet].gsub(/^/, ' ') if d[:snippet]
508
+ s << "urls:\n" if d[:urls]
509
+ d[:urls].each do |url|
510
+ s << " - #{url}\n"
511
+ end if d[:urls]
512
+ end
513
+ return s
514
+ end
515
+
516
+ def do_download_library(cdn_code, library, version, basedir)
517
+ cdn = find_cdn(cdn_code)
518
+ cdn.download(library, version, basedir, quiet: @quiet)
519
+ return nil
520
+ end
521
+
522
+ end
523
+
524
+
525
+ end
526
+
527
+
528
+ if __FILE__ == $0
529
+ CDNGet::Main.main()
530
+ end