vk_music 3.1.7 → 4.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.
- checksums.yaml +4 -4
- data/.env.example +3 -0
- data/.github/workflows/ruby.yml +35 -0
- data/.gitignore +6 -0
- data/.rspec +1 -0
- data/.rubocop.yml +56 -0
- data/Gemfile +38 -10
- data/Gemfile.lock +124 -70
- data/LICENSE.txt +0 -0
- data/README.md +121 -94
- data/Rakefile +15 -22
- data/bin/console +18 -24
- data/lib/vk_music.rb +32 -18
- data/lib/vk_music/audio.rb +112 -187
- data/lib/vk_music/client.rb +193 -677
- data/lib/vk_music/playlist.rb +44 -97
- data/lib/vk_music/request.rb +13 -0
- data/lib/vk_music/request/artist.rb +24 -0
- data/lib/vk_music/request/audios_reload.rb +29 -0
- data/lib/vk_music/request/base.rb +75 -0
- data/lib/vk_music/request/login.rb +35 -0
- data/lib/vk_music/request/my_page.rb +21 -0
- data/lib/vk_music/request/playlist.rb +31 -0
- data/lib/vk_music/request/playlist_section.rb +35 -0
- data/lib/vk_music/request/post.rb +22 -0
- data/lib/vk_music/request/profile.rb +24 -0
- data/lib/vk_music/request/search.rb +34 -0
- data/lib/vk_music/request/wall_section.rb +34 -0
- data/lib/vk_music/utility.rb +8 -78
- data/lib/vk_music/utility/artist_loader.rb +17 -0
- data/lib/vk_music/utility/artist_url_parser.rb +22 -0
- data/lib/vk_music/utility/audio_data_parser.rb +37 -0
- data/lib/vk_music/utility/audio_items_parser.rb +18 -0
- data/lib/vk_music/utility/audio_node_parser.rb +59 -0
- data/lib/vk_music/utility/audios_from_ids_loader.rb +21 -0
- data/lib/vk_music/utility/audios_ids_getter.rb +25 -0
- data/lib/vk_music/utility/audios_loader.rb +37 -0
- data/lib/vk_music/utility/data_type_guesser.rb +48 -0
- data/lib/vk_music/utility/duration_parser.rb +17 -0
- data/lib/vk_music/utility/last_profile_post_loader.rb +26 -0
- data/lib/vk_music/utility/link_decoder.rb +107 -0
- data/lib/vk_music/utility/node_text_children_reader.rb +14 -0
- data/lib/vk_music/utility/playlist_loader.rb +30 -0
- data/lib/vk_music/utility/playlist_node_parser.rb +21 -0
- data/lib/vk_music/utility/playlist_section_loader.rb +29 -0
- data/lib/vk_music/utility/playlist_url_parser.rb +32 -0
- data/lib/vk_music/utility/post_loader.rb +23 -0
- data/lib/vk_music/utility/post_url_parser.rb +24 -0
- data/lib/vk_music/utility/profile_id_resolver.rb +58 -0
- data/lib/vk_music/utility/wall_loader.rb +25 -0
- data/lib/vk_music/version.rb +7 -5
- data/lib/vk_music/web_parser.rb +9 -0
- data/lib/vk_music/web_parser/artist.rb +16 -0
- data/lib/vk_music/web_parser/audios_reload.rb +20 -0
- data/lib/vk_music/web_parser/base.rb +27 -0
- data/lib/vk_music/web_parser/login.rb +13 -0
- data/lib/vk_music/web_parser/my_page.rb +19 -0
- data/lib/vk_music/web_parser/playlist.rb +33 -0
- data/lib/vk_music/web_parser/playlist_section.rb +53 -0
- data/lib/vk_music/web_parser/post.rb +15 -0
- data/lib/vk_music/web_parser/profile.rb +33 -0
- data/lib/vk_music/web_parser/search.rb +56 -0
- data/lib/vk_music/web_parser/wall_section.rb +53 -0
- data/vk_music.gemspec +36 -40
- metadata +63 -77
- data/.travis.yml +0 -7
- data/bin/setup +0 -8
- data/lib/vk_music/constants.rb +0 -78
- data/lib/vk_music/exceptions.rb +0 -21
- data/lib/vk_music/link_decoder.rb +0 -102
- data/lib/vk_music/utility/log.rb +0 -51
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Turn human readable track length to its size in seconds
|
6
|
+
module DurationParser
|
7
|
+
# @param str [String] string in format "HH:MM:SS" or something alike (+/d++ Regex selector is used)
|
8
|
+
# @return [Integer] amount of seconds
|
9
|
+
def self.call(str)
|
10
|
+
str.scan(/\d+/)
|
11
|
+
.map(&:to_i)
|
12
|
+
.reverse
|
13
|
+
.each_with_index.reduce(0) { |acc, (count, position)| acc + count * 60**position }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'profile_id_resolver'
|
4
|
+
|
5
|
+
module VkMusic
|
6
|
+
module Utility
|
7
|
+
# Get user or group id from url
|
8
|
+
module LastProfilePostLoader
|
9
|
+
# vk.com url regex
|
10
|
+
VK_PATH = ProfileIdResolver::VK_PATH
|
11
|
+
private_constant :VK_PATH
|
12
|
+
|
13
|
+
# @param agent [Mechanize]
|
14
|
+
# @param url [String] URL to profile page
|
15
|
+
# @return [Array(owner_id?, post_id?)]
|
16
|
+
def self.call(agent, url: nil, owner_id: nil)
|
17
|
+
path = url&.match(VK_PATH)&.captures&.first
|
18
|
+
request = VkMusic::Request::Profile.new(profile_id: owner_id, profile_custom_path: path)
|
19
|
+
request.call(agent)
|
20
|
+
[request.id, request.last_post_id]
|
21
|
+
rescue Mechanize::ResponseCodeError
|
22
|
+
[nil, nil]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Link decoding utilities
|
6
|
+
module LinkDecoder
|
7
|
+
# JS code which creates function to unmask audio URL.
|
8
|
+
JS_CODE = <<~HEREDOC
|
9
|
+
function vk_unmask_link(link, vk_id) {
|
10
|
+
|
11
|
+
// Utility functions to unmask
|
12
|
+
|
13
|
+
var audioUnmaskSource = function (encrypted) {
|
14
|
+
if (encrypted.indexOf('audio_api_unavailable') != -1) {
|
15
|
+
var parts = encrypted.split('?extra=')[1].split('#');
|
16
|
+
|
17
|
+
var handled_anchor = '' === parts[1] ? '' : handler(parts[1]);
|
18
|
+
|
19
|
+
var handled_part = handler(parts[0]);
|
20
|
+
|
21
|
+
if (typeof handled_anchor != 'string' || !handled_part) {
|
22
|
+
// if (typeof handled_anchor != 'string') console.warn('Handled_anchor type: ' + typeof handled_anchor);
|
23
|
+
// if (!handled_part) console.warn('Handled_part: ' + handled_part);
|
24
|
+
return encrypted;
|
25
|
+
}
|
26
|
+
|
27
|
+
handled_anchor = handled_anchor ? handled_anchor.split(String.fromCharCode(9)) : [];
|
28
|
+
|
29
|
+
for (var func_key, splited_anchor, l = handled_anchor.length; l--;) {
|
30
|
+
splited_anchor = handled_anchor[l].split(String.fromCharCode(11));
|
31
|
+
func_key = splited_anchor.splice(0, 1, handled_part)[0];
|
32
|
+
if (!utility_object[func_key]) {
|
33
|
+
// console.warn('Was unable to find key: ' + func_key);
|
34
|
+
return encrypted;
|
35
|
+
}
|
36
|
+
handled_part = utility_object[func_key].apply(null, splited_anchor)
|
37
|
+
}
|
38
|
+
|
39
|
+
if (handled_part && 'http' === handled_part.substr(0, 4)) return handled_part;
|
40
|
+
// else console.warn('Failed unmasking: ' + handled_part);
|
41
|
+
} else {
|
42
|
+
// console.warn('Bad link: ' + encrypted);
|
43
|
+
}
|
44
|
+
return encrypted;
|
45
|
+
}
|
46
|
+
|
47
|
+
var handler = function (part) {
|
48
|
+
if (!part || part.length % 4 == 1) return !1;
|
49
|
+
for (var t, i, o = 0, s = 0, a = ''; i = part.charAt(s++);) {
|
50
|
+
i = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/='.indexOf(i)
|
51
|
+
~i && (t = o % 4 ? 64 * t + i : i, o++ % 4) && (a += String.fromCharCode(255 & t >> (-2 * o & 6)));
|
52
|
+
}
|
53
|
+
return a;
|
54
|
+
}
|
55
|
+
|
56
|
+
var utility_object = {
|
57
|
+
i: function(e, t) {
|
58
|
+
return utility_object.s(e, t ^ vk_id);
|
59
|
+
},
|
60
|
+
s: function(e, t) {
|
61
|
+
var n = e.length;
|
62
|
+
if (n) {
|
63
|
+
var i = r_func(e, t),
|
64
|
+
o = 0;
|
65
|
+
for (e = e.split(''); ++o < n;)
|
66
|
+
e[o] = e.splice(i[n - 1 - o], 1, e[o])[0];
|
67
|
+
e = e.join('')
|
68
|
+
}
|
69
|
+
return e;
|
70
|
+
}
|
71
|
+
};
|
72
|
+
|
73
|
+
var r_func = function (e, t) {
|
74
|
+
var n = e.length,
|
75
|
+
i = [];
|
76
|
+
if (n) {
|
77
|
+
var o = n;
|
78
|
+
for (t = Math.abs(t); o--;)
|
79
|
+
t = (n * (o + 1) ^ t + o) % n,
|
80
|
+
i[o] = t;
|
81
|
+
}
|
82
|
+
return i;
|
83
|
+
}
|
84
|
+
|
85
|
+
return audioUnmaskSource(link);
|
86
|
+
}
|
87
|
+
HEREDOC
|
88
|
+
private_constant :JS_CODE
|
89
|
+
|
90
|
+
# JS context with unmasking link
|
91
|
+
@@js_context = ExecJS.compile(JS_CODE)
|
92
|
+
|
93
|
+
# Unmask audio download URL
|
94
|
+
# @param link [String] encoded link to audio. Usually looks like
|
95
|
+
# "https://m.vk.com/mp3/audio_api_unavailable.mp3?extra=...".
|
96
|
+
# @param client_id [Integer] ID of user which got this link. ID is required for decoding.
|
97
|
+
# @return [String?] audio download URL, which can be used only from current IP.
|
98
|
+
def self.call(link, client_id)
|
99
|
+
VkMusic.log.debug('LinkDecoder') { "Unmasking link `#{link}` with client id #{client_id}" }
|
100
|
+
@@js_context.call('vk_unmask_link', link, client_id)
|
101
|
+
rescue StandardError => e
|
102
|
+
VkMusic.log.warn('LinkDecoder') { "Failed to decode link `#{link}`: #{e.full_message}" }
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Read inner of text-childrens of +Nokogiri::XML::Node+ node
|
6
|
+
module NodeTextChildrenReader
|
7
|
+
# @param node [Nokogiri::XML::Node]
|
8
|
+
# @return [String]
|
9
|
+
def self.call(node)
|
10
|
+
node.children.select(&:text?).map(&:text).join.strip
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Load playlist audios
|
6
|
+
module PlaylistLoader
|
7
|
+
# @param agent [Mechanize]
|
8
|
+
# @param client_id [Integer]
|
9
|
+
# @param owner_id [Integer]
|
10
|
+
# @param playlist_id [Integer]
|
11
|
+
# @param access_hash [String, nil]
|
12
|
+
# @param up_to [Integer]
|
13
|
+
# @return [Playlist?]
|
14
|
+
def self.call(agent, client_id, owner_id, playlist_id, access_hash, up_to)
|
15
|
+
page = Request::Playlist.new(owner_id, playlist_id, access_hash, client_id)
|
16
|
+
page.call(agent)
|
17
|
+
audios = page.audios
|
18
|
+
return if audios.nil? || audios.empty?
|
19
|
+
|
20
|
+
up_to = page.real_size if up_to > page.real_size
|
21
|
+
|
22
|
+
rest = PlaylistSectionLoader.call(agent, client_id, owner_id, playlist_id, access_hash,
|
23
|
+
audios.size, up_to - audios.size)
|
24
|
+
audios.concat(rest)
|
25
|
+
Playlist.new(audios, id: playlist_id, owner_id: owner_id, access_hash: access_hash,
|
26
|
+
title: page.title, subtitle: page.subtitle, real_size: page.real_size)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Read inner of text-childrens of +Nokogiri::XML::Node+ node
|
6
|
+
module PlaylistNodeParser
|
7
|
+
# @param node [Nokogiri::XML::Node]
|
8
|
+
# @return [Playlist]
|
9
|
+
def self.call(node)
|
10
|
+
url = node.at_css('.audioPlaylists__itemLink').attribute('href').value
|
11
|
+
owner_id, id, access_hash = PlaylistUrlParser.call(url)
|
12
|
+
|
13
|
+
Playlist.new([],
|
14
|
+
id: id, owner_id: owner_id, access_hash: access_hash,
|
15
|
+
title: node.at_css('.audioPlaylists__itemTitle').content,
|
16
|
+
subtitle: node.at_css('.audioPlaylists__itemSubtitle').content,
|
17
|
+
real_size: nil)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Load sections into playlist
|
6
|
+
module PlaylistSectionLoader
|
7
|
+
# @param agent [Mechanize]
|
8
|
+
# @param client_id [Integer]
|
9
|
+
# @param owner_id [Integer]
|
10
|
+
# @param playlist_id [Integer]
|
11
|
+
# @param access_hash [String, nil]
|
12
|
+
# @param offset [Integer]
|
13
|
+
# @param up_to [Integer]
|
14
|
+
# @return [Array<Audio>]
|
15
|
+
def self.call(agent, client_id, owner_id, playlist_id, access_hash, offset, up_to)
|
16
|
+
audios = []
|
17
|
+
|
18
|
+
while audios.size < up_to
|
19
|
+
section = Request::PlaylistSection.new(owner_id, playlist_id, access_hash, offset + audios.size, client_id)
|
20
|
+
section.call(agent)
|
21
|
+
audios.concat(section.audios)
|
22
|
+
break if section.audios.empty? || !section.more?
|
23
|
+
end
|
24
|
+
|
25
|
+
audios.first(up_to)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Read inner of text-childrens of +Nokogiri::XML::Node+ node
|
6
|
+
module PlaylistUrlParser
|
7
|
+
# Regular expression to parse playlist link. Oh my, it is so big
|
8
|
+
VK_PLAYLIST_URL_POSTFIX = %r{
|
9
|
+
.* # Garbage
|
10
|
+
(?:audio_playlist|album/|playlist/) # Start of ids
|
11
|
+
(-?\d+)_(\d+) # Ids themself
|
12
|
+
(?:(?:(?:.*(?=&access_hash=)&access_hash=)|/|%2F|_)([\da-z]+))? # Access hash
|
13
|
+
}x.freeze
|
14
|
+
public_constant :VK_PLAYLIST_URL_POSTFIX
|
15
|
+
|
16
|
+
# @param url [String]
|
17
|
+
# @return [Array(Integer?, Integer?, String?)] playlist data array:
|
18
|
+
# +[owner_id, playlist_id, access_hash]+
|
19
|
+
def self.call(url)
|
20
|
+
owner_id, playlist_id, access_hash = url.match(VK_PLAYLIST_URL_POSTFIX).captures
|
21
|
+
|
22
|
+
owner_id = Integer(owner_id, 10)
|
23
|
+
playlist_id = Integer(playlist_id, 10)
|
24
|
+
access_hash = nil if access_hash&.empty?
|
25
|
+
|
26
|
+
[owner_id, playlist_id, access_hash]
|
27
|
+
rescue StandardError
|
28
|
+
[nil, nil, nil]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Load wall audios
|
6
|
+
module PostLoader
|
7
|
+
# @param agent [Mechanize]
|
8
|
+
# @param client_id [Integer]
|
9
|
+
# @param owner_id [Integer]
|
10
|
+
# @param post_id [Integer]
|
11
|
+
# @return [Array<Audio>]
|
12
|
+
def self.call(agent, client_id, owner_id, post_id)
|
13
|
+
page = Request::Post.new(owner_id, post_id, client_id)
|
14
|
+
page.call(agent)
|
15
|
+
urlles_audios = page.audios
|
16
|
+
|
17
|
+
wall_audios = WallLoader.call(agent, client_id, owner_id, post_id).audios
|
18
|
+
|
19
|
+
urlles_audios.map { |urlles| wall_audios.find { |audio| audio.like?(urlles) } }.compact
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Load wall audios
|
6
|
+
module PostUrlParser
|
7
|
+
# Regex for post URL
|
8
|
+
POST_POSTFIX = /.*wall(-?\d+)_(\d+)/.freeze
|
9
|
+
public_constant :POST_POSTFIX
|
10
|
+
|
11
|
+
# @param url [String]
|
12
|
+
# @return [Array(owner_id?, post_id?)]
|
13
|
+
def self.call(url)
|
14
|
+
matches = url.match(POST_POSTFIX)&.captures
|
15
|
+
return [nil, nil] unless matches && matches.size == 2
|
16
|
+
|
17
|
+
owner_id = Integer(matches[0], 10)
|
18
|
+
post_id = Integer(matches[1], 10)
|
19
|
+
|
20
|
+
[owner_id, post_id]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Get user or group id from url
|
6
|
+
module ProfileIdResolver
|
7
|
+
# vk.com url regex
|
8
|
+
VK_PATH = %r{(?:https?://)?(?:vk\.com/)?([^/?&]+)}.freeze
|
9
|
+
public_constant :VK_PATH
|
10
|
+
|
11
|
+
# audios list page
|
12
|
+
AUDIOS_PATH = /audios(-?\d+)/.freeze
|
13
|
+
public_constant :AUDIOS_PATH
|
14
|
+
|
15
|
+
# vk.com user path regex
|
16
|
+
USER_PATH = /id(\d+)/.freeze
|
17
|
+
public_constant :USER_PATH
|
18
|
+
|
19
|
+
# vk.com user club regex
|
20
|
+
CLUB_PATH = /(?:club|group|public|event)(\d+)/.freeze
|
21
|
+
public_constant :CLUB_PATH
|
22
|
+
|
23
|
+
class << self
|
24
|
+
# @param agent [Mechanize]
|
25
|
+
# @param url [String] URL to profile page
|
26
|
+
# @return [Integer?] ID of profile or +nil+ if not a profile page
|
27
|
+
def call(agent, url)
|
28
|
+
path = url.match(VK_PATH)&.captures&.first
|
29
|
+
return unless path
|
30
|
+
|
31
|
+
direct_match = direct_match(path)
|
32
|
+
return direct_match if direct_match
|
33
|
+
|
34
|
+
request = VkMusic::Request::Profile.new(profile_custom_path: path)
|
35
|
+
request.call(agent)
|
36
|
+
request.id
|
37
|
+
rescue Mechanize::ResponseCodeError
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def direct_match(path)
|
44
|
+
audios_match = path.match(AUDIOS_PATH)
|
45
|
+
return Integer(audios_match.captures.first, 10) if audios_match
|
46
|
+
|
47
|
+
user_match = path.match(USER_PATH)
|
48
|
+
return Integer(user_match.captures.first, 10) if user_match
|
49
|
+
|
50
|
+
club_match = path.match(CLUB_PATH)
|
51
|
+
return -1 * Integer(club_match.captures.first, 10) if club_match
|
52
|
+
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
module Utility
|
5
|
+
# Load wall audios
|
6
|
+
module WallLoader
|
7
|
+
# @param agent [Mechanize]
|
8
|
+
# @param client_id [Integer]
|
9
|
+
# @param owner_id [Integer]
|
10
|
+
# @param post_id [Integer]
|
11
|
+
# @param up_to [Integer]
|
12
|
+
# @return [Playlist?]
|
13
|
+
def self.call(agent, client_id, owner_id, post_id)
|
14
|
+
page = Request::WallSection.new(owner_id, post_id, client_id)
|
15
|
+
page.call(agent)
|
16
|
+
audios = page.audios
|
17
|
+
return if audios.nil? || audios.empty?
|
18
|
+
|
19
|
+
Playlist.new(audios, id: 0, owner_id: owner_id, access_hash: '',
|
20
|
+
title: page.title, subtitle: page.subtitle,
|
21
|
+
real_size: audios.size)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/vk_music/version.rb
CHANGED
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VkMusic
|
4
|
+
# Parses out any data from page received by {Request} objects
|
5
|
+
module WebParser; end
|
6
|
+
end
|
7
|
+
|
8
|
+
require_relative 'web_parser/base'
|
9
|
+
Dir[File.join(__dir__, 'web_parser', '*.rb')].each { |file| require_relative file }
|