├── .gitignore ├── LICENSE ├── README.md ├── album_downloader.rb ├── albums.yaml ├── audio_downloader.rb ├── fileid_decoder.rb ├── free_album_downloader.rb ├── params_decryptor.rb ├── shuffle.js ├── uid_token.png └── xmly /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | *.bridgesupport 21 | build-iPhoneOS/ 22 | build-iPhoneSimulator/ 23 | 24 | ## Specific to RubyMotion (use of CocoaPods): 25 | # 26 | # We recommend against adding the Pods directory to your .gitignore. However 27 | # you should judge for yourself, the pros and cons are mentioned at: 28 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 29 | # 30 | # vendor/Pods/ 31 | 32 | ## Documentation cache and generated files: 33 | /.yardoc/ 34 | /_yardoc/ 35 | /doc/ 36 | /rdoc/ 37 | 38 | ## Environment normalization: 39 | /.bundle/ 40 | /vendor/bundle 41 | /lib/bundler/man/ 42 | 43 | # for a library or gem, you might want to ignore these files since the code is 44 | # intended to run in multiple environments; otherwise, check them in: 45 | # Gemfile.lock 46 | # .ruby-version 47 | # .ruby-gemset 48 | 49 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 50 | .rvmrc 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### 依赖环境 2 | 3 | 本工具依赖于 `Ruby` 执行环境。 4 | 5 | `Linux` 或 `OS X` 环境,可以通过 `RVM` 或者 `rbenv` 来安装 `Ruby`,当然,如果只是临时使用,也可以用系统自带的。 6 | 7 | #### RVM 8 | 9 | `RVM` [官方地址](http://rvm.io/)。 10 | 11 | ```bash 12 | # 安装 13 | gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB 14 | curl -sSL https://get.rvm.io | bash -s stable 15 | 16 | rvm reload 17 | rvm list known 18 | rvm install 2.5.1 19 | rvm use 2.5.1 —default 20 | 21 | # 更新 22 | rvm get stable 23 | ``` 24 | 25 | #### rbenv 26 | 27 | `rbenv` [官方地址](https://github.com/rbenv/rbenv),下面以 `OS X` 平台来演示如何安装 `Ruby`。 28 | 29 | 30 | ```bash 31 | # 安装 32 | brew update 33 | brew install rbenv ruby-build 34 | 35 | rbenv init 36 | rbenv versions 37 | rbenv install 2.5.1 38 | 39 | # 更新 40 | brew upgrade rbenv ruby-build 41 | ``` 42 | 43 | 了解更多 `Ruby` 相关知识,可以参考 [Ruby 编程手札](https://github.com/jameszhan/notes-ruby)。 44 | 45 | #### 本工具依赖的 `gem` 46 | 47 | ```bash 48 | gem install nokogiri 49 | gem install faraday 50 | gem install thor 51 | ``` 52 | 53 | ### 下载喜马拉雅付费音频 54 | 55 | > 本工具只能下载你本人已经付费过的喜马拉雅专辑,也就是指定的 `uid` 已经购买了该专辑。 56 | 57 | #### 如何获取你的 `uid` 和 `token` 58 | 59 | 在电脑 `Chrome` 上随便打开一个喜马拉雅付费音频,并打开开发者工具(Developer Tools),点击音频播放按钮, 60 | 在网络 `XHR` 中锁定检查支付的请求 `URL`,该 `URL` 以 `https://mpay.ximalaya.com/mobile/track/pay/` 开始。 61 | 此 `URL` 就包含你想要的 `uid` 和 `token`。 62 | 63 | > 注意:VIP 和 自购是不同的 `token` 64 | 65 | ![uid_token.png](./uid_token.png) 66 | 67 | ```bash 68 | export XMLY_UID=YOUR_UID 69 | export XMLY_TOKEN=YOUR_TOKEN 70 | ``` 71 | 72 | #### 激活工具 73 | 74 | ```bash 75 | chmod +x xmly 76 | ./xmly 77 | 78 | # Commands: 79 | # xmly album # xmly album 4417201 keji /tmp 80 | # xmly download # xmly download 26903700,27251627 /tmp 81 | # xmly help [COMMAND] # Describe available commands or one specific command 82 | ``` 83 | 84 | ### 示例演示 85 | 86 | #### 下载专辑 87 | 88 | 下载[卓老板聊科技第二季](https://www.ximalaya.com/keji/4417201/)。 89 | 90 | ```bash 91 | mkdir ~/keji4417201 92 | ./xmly album 4417201 keji ~/keji4417201 93 | ``` 94 | 95 | #### 批量下载多个音频 96 | 97 | 这个命令适合某些音频下载失败时候使用,音频 `ID` 可以从专辑下载的失败日志中拿到。 98 | 99 | 音频ID | 名称 | URL 100 | --- | --- | --- 101 | 45213979 | 发现天使粒子是怎么回事 | https://www.ximalaya.com/keji/4417201/45213979 102 | 37492676 | 认识人类的语言和手势 | https://www.ximalaya.com/keji/4417201/37492676 103 | 38739420 | 绘画和音乐是大脑发育的副产品 | https://www.ximalaya.com/keji/4417201/38739420 104 | 105 | ```bash 106 | ./xmly download 45213979,37492676,38739420 /tmp 107 | ``` 108 | -------------------------------------------------------------------------------- /album_downloader.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | require 'faraday' 3 | require 'json' 4 | require_relative 'audio_downloader' 5 | 6 | class AlbumDownloader 7 | 8 | def initialize(album_id, category, uid, token, storage_dir='/tmp') 9 | @album_id = album_id 10 | @category = category 11 | @storage_dir = storage_dir 12 | @uid = uid 13 | @token = token 14 | @success_audios = [] 15 | @failure_audios = [] 16 | end 17 | 18 | def download 19 | album_url = "/#{@category}/#{@album_id}/" 20 | doc = get_album_page album_url 21 | batch_download(doc) 22 | input = doc.at('div.rC5T.pagination input[type="number"]') 23 | if input and input.attr('max') 24 | max = input.attr('max') 25 | total_page = max.respond_to?(:to_i) ? max.to_i : max.value.to_i 26 | if total_page > 1 27 | total_page.downto(2) do |page| 28 | doc = get_album_page "#{album_url}p#{page}/" 29 | batch_download(doc) 30 | end 31 | end 32 | end 33 | ensure 34 | open "#{@storage_dir}/#{@category}_#{@album_id}_success.log", 'w:UTF-8' do |io| 35 | @success_audios.each do |audio| 36 | io << audio 37 | io << "\n" 38 | end 39 | end 40 | open "#{@storage_dir}/#{@category}_#{@album_id}_failure.log", 'w:UTF-8' do |io| 41 | @failure_audios.each do |audio| 42 | io << audio 43 | io << "\n" 44 | end 45 | end 46 | end 47 | 48 | private 49 | def batch_download(doc) 50 | doc.css('ul.rC5T li').each do |li| 51 | link = li.at('div.text.rC5T a') 52 | href = link.attr('href') 53 | audio_id = href.split('/')[-1] 54 | audio_downloader.download(audio_id, @storage_dir) do |audio, success| 55 | if success 56 | @success_audios << audio 57 | else 58 | @failure_audios << audio 59 | end 60 | end 61 | end 62 | end 63 | 64 | def get_album_page(album_url) 65 | response = connection.get do |request| 66 | request.url album_url 67 | request.headers['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8' 68 | request.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.59 Safari/537.36' 69 | end 70 | Nokogiri::HTML(response.body) 71 | end 72 | 73 | def connection 74 | @connection ||= Faraday.new(url: 'https://www.ximalaya.com') do |faraday| 75 | faraday.request :url_encoded # form-encode POST params 76 | faraday.response :logger # log requests to STDOUT 77 | faraday.adapter Faraday.default_adapter # make requests with Net::HTTP 78 | end 79 | end 80 | 81 | def audio_downloader 82 | @audio_downloader ||= AudioDownloader.new(@uid, @token) 83 | end 84 | 85 | end 86 | 87 | if __FILE__ == $0 88 | downloader = AlbumDownloader.new(4417201, 'keji', ENV['XMLY_UID'], ENV['XMLY_TOKEN']) 89 | downloader.download 90 | end 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /albums.yaml: -------------------------------------------------------------------------------- 1 | - 2 | id: 16579688 3 | title: 马思纯为你演绎悬疑经典 4 | category: yule 5 | author: 马思纯 6 | - 7 | id: 14520718 8 | title: 易中天说禅 9 | category: renwen 10 | author: 易中天 11 | - 12 | id: 16816483 13 | category: ertong 14 | title: 启蒙英语看动物世界 15 | author: ChrisLeeson -------------------------------------------------------------------------------- /audio_downloader.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'logger' 3 | require_relative 'fileid_decoder' 4 | require_relative 'params_decryptor' 5 | 6 | class AudioDownloader 7 | 8 | LOGGER = Logger.new(STDOUT) 9 | ILLEGAL_FILENAME_CHARS = %r([|/?*:"<>\\]) 10 | 11 | def initialize(uid, token) 12 | @decryptor = ParamsDecryptor.new 13 | @uid = uid 14 | @token = token 15 | end 16 | 17 | def batch_download(audio_ids, dir) 18 | audio_ids.each do |audio_id| 19 | download(audio_id, dir) 20 | end 21 | end 22 | 23 | def download(audio_id, dir) 24 | audio_desc = get_audio_desc(audio_id) 25 | audio = get_audio_resp(audio_desc) 26 | total_length = audio_desc['totalLength'] 27 | content_length = audio.headers['content-length'] 28 | filename = sanitize_file_name(audio_desc['title']) 29 | if total_length and content_length and total_length == content_length.to_i 30 | open "/#{dir}/#{filename}.m4a", 'wb' do |io| 31 | io << audio.body 32 | end 33 | LOGGER.info("[#{audio_id}]_#{filename} with length #{total_length} download successful.") 34 | yield "#{audio_id}_#{filename}", true if block_given? 35 | else 36 | if audio.status == 206 37 | processed_length = content_length.to_i 38 | open "/#{dir}/#{filename}.m4a", 'wb' do |io| 39 | io << audio.body 40 | while processed_length < total_length 41 | partial_audio = get_audio_resp(audio_desc) do |req| 42 | req.headers['Range'] = "bytes=#{processed_length}-#{total_length - 1}" 43 | end 44 | partial_content_length = partial_audio.headers['content-length'].to_i 45 | processed_length += partial_content_length 46 | io << partial_audio.body 47 | end 48 | end 49 | LOGGER.info("[#{audio_id}]_#{filename} with length #{processed_length} download successful.") 50 | yield "#{audio_id}_#{filename}", true if block_given? 51 | else 52 | LOGGER.error("[#{audio_id}] download failure with response (#{audio.status}, #{audio.headers}).") 53 | yield "#{audio_id}_#{filename}", false if block_given? 54 | end 55 | end 56 | rescue Exception => e 57 | LOGGER.error("[#{audio_id}] download failure with error (#{e.message}).") 58 | yield "#{audio_id}_#{e.message}", false if block_given? 59 | end 60 | 61 | private 62 | 63 | def get_audio_resp(audio_desc) 64 | decoder = FileidDecoder.new(audio_desc['seed']) 65 | url = "#{audio_desc['domain']}/download/#{audio_desc['apiVersion']}/#{decoder.decode(audio_desc['fileId'])}" 66 | params = @decryptor.decrypt(audio_desc['ep']).split('-') 67 | connection.get do |req| 68 | req.url url, { 69 | sign: params[1], 70 | buy_key: params[0], 71 | token: params[2], 72 | timestamp: params[3], 73 | duration: audio_desc['duration'] 74 | } 75 | req.headers['Accept-Encoding'] = 'identity;q=1, *;q=0' 76 | req.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.59 Safari/537.36' 77 | yield req if block_given? 78 | end 79 | end 80 | 81 | def get_audio_desc(audio_id) 82 | resp = mpay_connection.get do |req| 83 | req.url "/mobile/track/pay/#{audio_id}", { 84 | device: 'pc', 85 | uid: @uid, 86 | token: @token, 87 | isBackend: false 88 | } 89 | req.headers['Accept'] = 'application/json, text/javascript, */*; q=0.01' 90 | req.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.59 Safari/537.36' 91 | req.headers['Host'] = 'mpay.ximalaya.com' 92 | end 93 | JSON.parse(resp.body) 94 | end 95 | 96 | def mpay_connection 97 | @mpay_connection ||= Faraday.new(url: 'https://mpay.ximalaya.com') do |faraday| 98 | faraday.request :url_encoded # form-encode POST params 99 | faraday.response :logger # log requests to STDOUT 100 | faraday.adapter Faraday.default_adapter # make requests with Net::HTTP 101 | end 102 | end 103 | 104 | def connection 105 | @connection ||= Faraday.new(url: 'http://audio.pay.xmcdn.com') do |faraday| 106 | faraday.request :url_encoded 107 | faraday.response :logger 108 | faraday.adapter Faraday.default_adapter 109 | end 110 | end 111 | 112 | def sanitize_file_name(name) 113 | name.gsub(ILLEGAL_FILENAME_CHARS, '_').gsub(/\s/, '') 114 | end 115 | 116 | end -------------------------------------------------------------------------------- /fileid_decoder.rb: -------------------------------------------------------------------------------- 1 | # cg_fun: function(t) { 2 | # var t = t.split("*"), e = "", i = 0; 3 | # for (i = 0; i < t.length - 1; i++) 4 | # e += this._cgStr.charAt(t[i]); 5 | # return e 6 | # } 7 | # 8 | # cg_hun: function() { 9 | # this._cgStr = ""; 10 | # var t = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/\\:._-1234567890" 11 | # , e = t.length 12 | # , i = 0; 13 | # 14 | # for (i = 0; i < e; i++) { 15 | # var o = this.ran() * t.length, n = parseInt(o); 16 | # this._cgStr += t.charAt(n), 17 | # t = t.split(t.charAt(n)).join("") 18 | # } 19 | # } 20 | # 21 | # ran: function() { 22 | # return this._randomSeed = (211 * this._randomSeed + 30031) % 65536, this._randomSeed / 65536 23 | # } 24 | 25 | class FileidDecoder 26 | 27 | def initialize(seed) 28 | @random_seed = seed 29 | end 30 | 31 | def decode(str) 32 | @words ||= gen_dict 33 | ids = str.split("*").map(&:to_i) 34 | str = '' 35 | i = 0 36 | while i < ids.length do 37 | str << @words[ids[i]] 38 | i += 1 39 | end 40 | str 41 | end 42 | 43 | def gen_dict 44 | target_str = '' 45 | t = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/\\:._-1234567890" 46 | l = t.length 47 | i = 0 48 | while i < l do 49 | n = (self.next_seed * t.length).to_i 50 | target_char = t[n] 51 | target_str << target_char 52 | t = t.split(target_char).join('') 53 | i += 1 54 | end 55 | target_str 56 | end 57 | 58 | def next_seed 59 | @random_seed = (211 * @random_seed + 30031) % 65536 60 | @random_seed / 65536.0 61 | end 62 | 63 | end 64 | 65 | if __FILE__ == $0 66 | decoder = FileidDecoder.new(5663) 67 | filename = decoder.decode('51*60*8*53*1*30*42*43*38*38*42*38*23*42*52*23*42*14*26*51*41*50*30*25*40*36*54*19*35*64*7*65*18*48*47*36*31*36*17*60*56*35*57*66*23*62*23*49*20*21*10*') 68 | puts filename 69 | end 70 | -------------------------------------------------------------------------------- /free_album_downloader.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'logger' 3 | require 'faraday' 4 | require 'json' 5 | require 'fileutils' 6 | 7 | class FreeAlbumDownloader 8 | 9 | LOGGER = Logger.new(STDOUT) 10 | ILLEGAL_FILENAME_CHARS = %r([|/?*:"'<>\\]) 11 | 12 | def initialize(album_id, storage_dir = '/tmp') 13 | @album_id = album_id 14 | @storage_dir = storage_dir 15 | @success_audios = [] 16 | @failure_audios = [] 17 | end 18 | 19 | def download 20 | FileUtils.mkdir_p(@storage_dir) unless Dir.exists?(@storage_dir) 21 | Dir.chdir(@storage_dir) 22 | need_break = false 23 | (1...100).each do |page| 24 | album_url = "/revision/play/album?albumId=#{@album_id}&pageNum=#{page}&pageSize=100&sort=0" 25 | response = connection.get do |request| 26 | request.url album_url 27 | request.headers['Accept'] = '*/*' 28 | request.headers['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36' 29 | end 30 | body = JSON.parse(response.body) 31 | if body['ret'] == 200 and body['data'] and body['data']['tracksAudioPlay'] 32 | audios = body['data']['tracksAudioPlay'] 33 | batch_download(audios) 34 | need_break = true unless body['data']['hasMore'] 35 | else 36 | LOGGER.error("Invalid body #{body} for album #{@album_id}") 37 | end 38 | break if need_break 39 | end 40 | end 41 | 42 | private 43 | def batch_download(audios) 44 | threads = [] 45 | audios.each do |audio| 46 | threads << Thread.start { download_audio(audio) } 47 | if threads.length % 5 == 0 48 | threads.each(&:join) 49 | threads.clear 50 | end 51 | end 52 | threads.each(&:join) 53 | end 54 | 55 | def download_audio(audio) 56 | index = audio['index'] || 0 57 | name = sanitize_file_name(audio['trackName']) 58 | url = audio['src'] 59 | if name and url 60 | ext = File.extname(url) 61 | filename = '%03d.%s%s' % [index, name, ext] 62 | if File.exists?(filename) 63 | puts "#{filename} have already downloaded!" 64 | else 65 | `wget #{url} --output-document='#{filename}'` 66 | end 67 | end 68 | end 69 | 70 | def sanitize_file_name(name) 71 | name.gsub(ILLEGAL_FILENAME_CHARS, '_').gsub(/\s/, '') 72 | end 73 | 74 | def connection 75 | @connection ||= Faraday.new(url: 'https://www.ximalaya.com') do |faraday| 76 | faraday.request :url_encoded # form-encode POST params 77 | faraday.response :logger # log requests to STDOUT 78 | faraday.adapter Faraday.default_adapter # make requests with Net::HTTP 79 | end 80 | end 81 | end 82 | 83 | if __FILE__ == $0 84 | downloader = FreeAlbumDownloader.new(18835582, '/tmp/test') 85 | downloader.download 86 | end -------------------------------------------------------------------------------- /params_decryptor.rb: -------------------------------------------------------------------------------- 1 | CHAR_MAP = [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 2 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 3 | 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 4 | 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 5 | 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1]; 6 | 7 | class ParamsDecryptor 8 | 9 | def initialize(seed = 'xkt3a41psizxrh9l') 10 | @seed = seed 11 | end 12 | 13 | def decrypt(ep) 14 | do_decrypt(@seed, pre_decrypt(ep)) 15 | end 16 | 17 | private 18 | def pre_decrypt(ep) 19 | return '' if ep.nil? || ep.empty? 20 | n = ep.length 21 | i = 0 22 | s = '' 23 | while i < n 24 | begin 25 | e = CHAR_MAP[255 & ep[i].ord] 26 | i += 1 27 | end while i < n and e == -1 28 | break if -1 == e 29 | 30 | begin 31 | f = CHAR_MAP[255 & ep[i].ord] 32 | i += 1 33 | end while i < n and -1 == f 34 | break if -1 == f 35 | 36 | s << (e << 2 | (48 & f) >> 4).chr 37 | 38 | begin 39 | return s if 61 == (e = 255 & ep[i].ord) 40 | i += 1 41 | e = CHAR_MAP[e] 42 | end while i < n and -1 == e 43 | break if -1 == e 44 | 45 | s << ((15 & f) << 4 | (60 & e) >> 2).chr 46 | 47 | begin 48 | return s if 61 == (f = 255 & ep[i].ord) 49 | i += 1 50 | f = CHAR_MAP[f] 51 | end while i < n && -1 == f 52 | break if -1 == f 53 | 54 | s << ((3 & e) << 6 | f).chr 55 | end 56 | s 57 | end 58 | 59 | def do_decrypt(key, ep) 60 | chars = [] 61 | (0...256).each do |i| 62 | chars[i] = i 63 | end 64 | n = 0 65 | (0...256).each do |i| 66 | n = (n + chars[i] + key[i % key.length].ord) % 256 67 | chars[i], chars[n] = chars[n], chars[i] 68 | end 69 | 70 | str = '' 71 | s = n = i = 0 72 | while i < ep.length 73 | s = (s + 1) % 256 74 | n = (n + chars[s]) % 256 75 | chars[n], chars[s] = chars[s], chars[n] 76 | str << (ep[i].ord ^ chars[(chars[s] + chars[n]) % 256]).chr 77 | i += 1 78 | end 79 | str 80 | end 81 | 82 | end 83 | 84 | 85 | if __FILE__ == $0 86 | decryptor = ParamsDecryptor.new 87 | encrypt_params = '3kFqaox2SndSj6gJPoocsAtdUxUghSLGTowfeV+0DX6qnbmF3q+Kmu9b0f6P1KJrXuV013EEeqdi0vL3wAMW3rwVOylUHb6iWNzDuDxcqRKro+RYnTkRM6gvcTKBAUOReczeQshNrmE8/fT4631Ye4C0DIkeiohLnqpn+1X8VUzh8Bk='; 88 | decrypt_params = decryptor.decrypt(encrypt_params) 89 | puts decrypt_params 90 | end 91 | -------------------------------------------------------------------------------- /shuffle.js: -------------------------------------------------------------------------------- 1 | function shuffle(origin, array) { 2 | var target = [], i, j, c; 3 | for (i = 0; i < origin.length; i++) { 4 | c = 'a' <= origin[i] && 'z' >= origin[i] ? origin[i].charCodeAt(0) - 97 : origin[i] - 0 + 26; 5 | for (j = 0; j < 36; j++) { 6 | if (array[j] === c) { 7 | c = j; 8 | break; 9 | } 10 | } 11 | target[i] = 25 < c ? c - 26 : String.fromCharCode(c + 97); 12 | } 13 | return target.join(''); 14 | } 15 | 16 | function decrypt(ep, key, array) { 17 | var new_key = shuffle(key, array); 18 | console.log(new_key); 19 | return do_decrypt(new_key, pre_decrypt(ep)); 20 | } 21 | 22 | function pre_decrypt(ep) { 23 | if (!ep) { 24 | return ''; 25 | } 26 | var n = ep.length, 27 | i, 28 | e, 29 | f, 30 | s = '', 31 | table = [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 32 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 33 | 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 34 | 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35 | 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1]; 36 | 37 | for (i = 0; i < n;) { 38 | do { 39 | e = table[255 & ep.charCodeAt(i++)]; 40 | } while (i < n && -1 === e); 41 | if (-1 === e) break; 42 | 43 | do { 44 | f = table[255 & ep.charCodeAt(i++)]; 45 | } while (i < n && -1 === f); 46 | if (-1 === f) break; 47 | 48 | s += String.fromCharCode(e << 2 | (48 & f) >> 4); 49 | do { 50 | if (61 === (e = 255 & ep.charCodeAt(i++))) { 51 | return s; 52 | } 53 | e = table[e]; 54 | } while (i < n && -1 === e); 55 | if (-1 === e) break; 56 | 57 | s += String.fromCharCode((15 & f) << 4 | (60 & e) >> 2); 58 | do { 59 | if (61 === (f = 255 & ep.charCodeAt(i++))) { 60 | return s; 61 | } 62 | f = table[f]; 63 | } while (i < n && -1 === f); 64 | if (-1 === f) break; 65 | 66 | s += String.fromCharCode((3 & e) << 6 | f); 67 | } 68 | return s; 69 | } 70 | 71 | 72 | function do_decrypt(key, ep) { 73 | var str = '', 74 | chars = [], 75 | i, 76 | n, 77 | t, 78 | j, 79 | s; 80 | for (i = 0; i < 256; i++) { 81 | chars[i] = i; 82 | } 83 | for (n = 0, i = 0; i < 256; i++) { 84 | n = (n + chars[i] + key.charCodeAt(i % key.length)) % 256; 85 | t = chars[i]; 86 | chars[i] = chars[n]; 87 | chars[n] = t; 88 | } 89 | for (s = n = 0, j = 0; j < ep.length; j++) { 90 | s = (s + 1) % 256; 91 | n = (n + chars[s]) % 256; 92 | t = chars[s]; 93 | chars[s] = chars[n]; 94 | chars[n] = t; 95 | str += String.fromCharCode(ep.charCodeAt(j) ^ chars[(chars[s] + chars[n]) % 256]); 96 | } 97 | return str; 98 | } 99 | 100 | if (require.main === module) { 101 | s = "dg3utf1k6yxdwi09"; 102 | a = [19, 1, 4, 7, 30, 14, 28, 8, 24, 17, 6, 35, 34, 16, 9, 10, 13, 22, 32, 29, 31, 21, 18, 3, 2, 23, 25, 27, 11, 20, 5, 15, 12, 0, 33, 26]; 103 | encrypt_params = '3kFqaox2SndSj6gJPoocsAtdUxUghSLGTowfeV+0DX6qnbmF3q+Kmu9b0f6P1KJrXuV013EEeqdi0vL3wAMW3rwVOylUHb6iWNzDuDxcqRKro+RYnTkRM6gvcTKBAUOReczeQshNrmE8/fT4631Ye4C0DIkeiohLnqpn+1X8VUzh8Bk='; 104 | 105 | console.log(decrypt(encrypt_params, s, a)); 106 | } 107 | 108 | -------------------------------------------------------------------------------- /uid_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jameszhan/xmly-download/5b132af552aabc3d2325a0f58f0966ef4ced80e1/uid_token.png -------------------------------------------------------------------------------- /xmly: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'thor' 3 | require 'yaml' 4 | require_relative 'album_downloader' 5 | require_relative 'audio_downloader' 6 | require_relative 'free_album_downloader' 7 | 8 | class Xmly < Thor 9 | 10 | desc 'free ', 'Download free album to dir' 11 | def free(album_id, storage_dir='/tmp') 12 | downloader = FreeAlbumDownloader.new(album_id, storage_dir) 13 | downloader.download 14 | end 15 | 16 | desc 'frees .yaml ', 'Download free albums' 17 | def frees(albums_config_file, storage_dir='/tmp') 18 | album_configs = YAML.load_file(albums_config_file) 19 | album_configs.each do |album| 20 | say "Begin to download album #{album}", :green 21 | target_dir = "#{storage_dir}/#{album['category']}/#{album['title']}" 22 | downloader = FreeAlbumDownloader.new(album['id'], target_dir) 23 | downloader.download 24 | say "Finish to download album #{album}", :yellow 25 | end 26 | end 27 | 28 | desc 'album ', 'xmly album 4417201 keji /tmp' 29 | def album(album_id, category, storage_dir='/tmp') 30 | downloader = AlbumDownloader.new(album_id, category, ENV['XMLY_UID'], ENV['XMLY_TOKEN'], storage_dir) 31 | downloader.download 32 | end 33 | 34 | desc 'albums .yaml ', 'xmly albums albums.yml /tmp' 35 | def albums(albums_config_file, storage_dir='/tmp') 36 | album_configs = YAML.load_file(albums_config_file) 37 | album_configs.each do |album| 38 | say "Begin to download album #{album}", :green 39 | target_dir = "#{storage_dir}/#{album['title']}" 40 | Dir.mkdir(target_dir) unless Dir.exist?(target_dir) 41 | downloader = AlbumDownloader.new(album['id'], album['category'], ENV['XMLY_UID'], ENV['XMLY_TOKEN'], target_dir) 42 | downloader.download 43 | say "Finish to download album #{album}", :yellow 44 | end 45 | end 46 | 47 | desc 'download ', 'xmly download 26903700,27251627 /tmp' 48 | def download(audio_ids, storage_dir='/tmp') 49 | downloader = AudioDownloader.new(ENV['XMLY_UID'], ENV['XMLY_TOKEN']) 50 | downloader.batch_download(audio_ids.split(',').map(&:strip), storage_dir) 51 | end 52 | 53 | end 54 | 55 | Xmly.start(ARGV) 56 | 57 | --------------------------------------------------------------------------------