gym_finder 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/gym_finder +61 -0
- data/lib/gym_finder.rb +4 -0
- data/lib/gym_finder/calendar.rb +7 -0
- data/lib/gym_finder/cli.rb +70 -0
- data/lib/gym_finder/client.rb +158 -0
- data/lib/gym_finder/gym.rb +7 -0
- data/lib/gym_finder/gyms.json +62 -0
- data/lib/gym_finder/gyms.rb +12 -0
- data/lib/gym_finder/ocr.rb +22 -0
- data/lib/gym_finder/parser.rb +59 -0
- data/lib/gym_finder/post_processor.rb +28 -0
- data/lib/gym_finder/reservation.rb +10 -0
- data/lib/gym_finder/time_table.rb +27 -0
- metadata +127 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2fb6bb856937918867e24853ade1d88c21bb96e3
|
4
|
+
data.tar.gz: 292429d65fc9e59e37cf5ff696a2f452b102cfbe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a9b737b2464ae764f09be88bb4e272c46852d6f87e2f33ca12bfd6c182c7f126630f59017671d1cab0f428d4b30967252c3239fb24169c512661f5afbd0df74f
|
7
|
+
data.tar.gz: f035457eefeacfe57cee11c4a3666508edb9c74a624d30657fe40b702fa378b425c01d54b8f22cd30c1dab4f2d34d3b28fe08ef96833c0e0902d3fc796c6a5b8
|
data/bin/gym_finder
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
require 'gym_finder/cli'
|
6
|
+
|
7
|
+
options = {
|
8
|
+
gyms: [],
|
9
|
+
courts: [],
|
10
|
+
hours: [],
|
11
|
+
weekday: false,
|
12
|
+
weekend: false,
|
13
|
+
only_available: true
|
14
|
+
}
|
15
|
+
OptionParser.new do |parser|
|
16
|
+
parser.banner = "Usage: env GYM_FINDER_USERNAME=USR GYM_FINDER_PASSWORD=PWD #{File.basename __FILE__} [options]"
|
17
|
+
|
18
|
+
parser.on('-u USERNAME', '--username USERNAME', 'defaults to env GYM_FINDER_USERNAME') do |v|
|
19
|
+
options[:username] = v
|
20
|
+
end
|
21
|
+
|
22
|
+
parser.on('-p PASSWORD', '--password PASSWORD', 'defaults to env GYM_FINDER_PASSWORD') do |v|
|
23
|
+
options[:password] = v
|
24
|
+
end
|
25
|
+
|
26
|
+
parser.on('-g name1,name2,name3', '--gyms name1,name2,name3', Array, 'gym name filter') do |names|
|
27
|
+
options[:gyms] = names
|
28
|
+
end
|
29
|
+
|
30
|
+
parser.on('-c name1,name2,name3', '--court name1,name2,name3', Array, 'court name filter') do |names|
|
31
|
+
options[:courts] = names
|
32
|
+
end
|
33
|
+
|
34
|
+
parser.on('-t h1,h2,h3', '--hours h1,h2,h3', Array, 'hour filter') do |hours|
|
35
|
+
options[:hours] = hours
|
36
|
+
end
|
37
|
+
|
38
|
+
parser.on('--[no-]weekend') do |v|
|
39
|
+
options[:weekend] = v
|
40
|
+
end
|
41
|
+
|
42
|
+
parser.on('--[no-]weekday') do |v|
|
43
|
+
options[:weekend] = v
|
44
|
+
end
|
45
|
+
|
46
|
+
parser.on('--[no-]only-available', 'show only available slots') do |v|
|
47
|
+
options[:only_available] = v
|
48
|
+
end
|
49
|
+
|
50
|
+
parser.on('--list-gyms', 'prints gyms') do
|
51
|
+
puts GymFinder::GYMS.map(&:name)
|
52
|
+
exit
|
53
|
+
end
|
54
|
+
|
55
|
+
parser.on('-h', '--help', 'prints this help') do
|
56
|
+
puts parser
|
57
|
+
exit
|
58
|
+
end
|
59
|
+
end.parse!
|
60
|
+
|
61
|
+
puts GymFinder::Cli.new(**options).perform.to_json
|
data/lib/gym_finder.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gym_finder/client'
|
4
|
+
require 'gym_finder/post_processor'
|
5
|
+
require 'set'
|
6
|
+
|
7
|
+
module GymFinder
|
8
|
+
class Cli
|
9
|
+
def initialize(
|
10
|
+
gyms: [],
|
11
|
+
courts: [],
|
12
|
+
hours: [],
|
13
|
+
weekend: false,
|
14
|
+
weekday: false,
|
15
|
+
only_available: true,
|
16
|
+
username: ENV['GYM_FINDER_USERNAME'],
|
17
|
+
password: ENV['GYM_FINDER_PASSWORD']
|
18
|
+
)
|
19
|
+
bind = binding
|
20
|
+
local_variables.each do |variable|
|
21
|
+
instance_variable_set(
|
22
|
+
"@#{variable}",
|
23
|
+
bind.local_variable_get(variable)
|
24
|
+
)
|
25
|
+
end
|
26
|
+
validate!
|
27
|
+
@client = Client.new(username: username, password: password)
|
28
|
+
@gym_filter = lambda { |gym|
|
29
|
+
gyms.empty? ? true : gyms.any? { |name| gym.name.include?(name) }
|
30
|
+
}
|
31
|
+
@court_filter = lambda { |court|
|
32
|
+
courts.empty? ? true : courts.any? { |name| court.name.include?(name) }
|
33
|
+
}
|
34
|
+
@date_filter = lambda { |date|
|
35
|
+
return true unless weekend ^ weekday
|
36
|
+
return date.sunday? || date.saturday? if weekend
|
37
|
+
return !date.sunday? && !date.sunday if weekday
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def perform
|
42
|
+
results = @client.fetch(
|
43
|
+
gym_filter: @gym_filter,
|
44
|
+
court_filter: @court_filter,
|
45
|
+
date_filter: @date_filter
|
46
|
+
)
|
47
|
+
results = PostProcessor.new(results).available.slots if @only_available
|
48
|
+
results = PostProcessor.new(results).hour_list(hour_list).slots unless @hours.empty?
|
49
|
+
results
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def validate!
|
55
|
+
if @username.nil?
|
56
|
+
warn 'env GYM_FINDER_USERNAME is not set'
|
57
|
+
exit 1
|
58
|
+
elsif @password.nil?
|
59
|
+
warn 'env GYM_FINDER_PASSWORD is not set'
|
60
|
+
exit 1
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def hour_list
|
65
|
+
@hours
|
66
|
+
.map { |time| time.split('-').map(&:to_i) }
|
67
|
+
.map! { |a| a.length == 2 ? a.first.upto(a.last - 1).to_a : a }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'eventmachine'
|
4
|
+
require 'eventmachine'
|
5
|
+
require 'em-http-request'
|
6
|
+
require 'json'
|
7
|
+
require 'uri'
|
8
|
+
require 'gym_finder/gyms'
|
9
|
+
require 'gym_finder/parser'
|
10
|
+
require 'gym_finder/ocr'
|
11
|
+
require 'time'
|
12
|
+
|
13
|
+
module GymFinder
|
14
|
+
class Client
|
15
|
+
class Error < RuntimeError; end
|
16
|
+
class CaptchaError < Error; end
|
17
|
+
class Conn
|
18
|
+
attr_accessor :cookie
|
19
|
+
def initialize
|
20
|
+
@conn = EventMachine::HttpRequest.new('https://scr.cyc.org.tw/')
|
21
|
+
@pending = 0
|
22
|
+
@processed = 0
|
23
|
+
end
|
24
|
+
|
25
|
+
def request(method, **params)
|
26
|
+
client = @conn.send(method, keepalive: true, head: { 'cookie' => @cookie }, **params)
|
27
|
+
@pending += 1
|
28
|
+
client.callback do
|
29
|
+
@processed += 1
|
30
|
+
yield client
|
31
|
+
print "\t#{(@processed.to_f / @pending * 100).round}%\r" if STDOUT.tty?
|
32
|
+
@done.call if @pending == @processed && @done
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def post(**params, &block)
|
37
|
+
request(:post, **params, &block)
|
38
|
+
end
|
39
|
+
|
40
|
+
def get(**params, &block)
|
41
|
+
request(:get, **params, &block)
|
42
|
+
end
|
43
|
+
|
44
|
+
def done(&block)
|
45
|
+
@done = block
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class Slot
|
50
|
+
attr_accessor :gym, :court, :date, :time_slot, :client
|
51
|
+
def initialize(gym:, court:, date:, time_slot:, client:)
|
52
|
+
@gym = gym
|
53
|
+
@court = court
|
54
|
+
@date = date
|
55
|
+
@time_slot = time_slot
|
56
|
+
@client = client
|
57
|
+
end
|
58
|
+
|
59
|
+
def to_json(*args)
|
60
|
+
{
|
61
|
+
gym: @gym.name,
|
62
|
+
type: @court.name,
|
63
|
+
court: @time_slot.court,
|
64
|
+
price: @time_slot.price,
|
65
|
+
status: @time_slot.status,
|
66
|
+
time: Time.new(@date.year, @date.month, @date.day, @time_slot.time).iso8601,
|
67
|
+
gym_homepage: @gym.homepage,
|
68
|
+
reservation_link: "https://#{@client.req.host}#{@client.req.path}?module=net_booking&files=booking_place&StepFlag=25&QPid=#{@time_slot.qpid}&QTime=#{@time_slot.time}&PT=#{@court.pt}&D=#{@date.strftime('%Y/%m/%d')}"
|
69
|
+
}.to_json(*args)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def initialize(
|
74
|
+
username: ENV['GYM_FINDER_USERNAME'],
|
75
|
+
password: ENV['GYM_FINDER_PASSWORD'],
|
76
|
+
retry_captcha: 3
|
77
|
+
)
|
78
|
+
@parser = Parser.new
|
79
|
+
@username = username
|
80
|
+
@password = password
|
81
|
+
@retry_captcha = retry_captcha
|
82
|
+
end
|
83
|
+
|
84
|
+
def fetch(*params)
|
85
|
+
captcha_error_count = 0
|
86
|
+
begin
|
87
|
+
_fetch(*params)
|
88
|
+
rescue CaptchaError => error
|
89
|
+
captcha_error_count += 1
|
90
|
+
raise error if captcha_error_count == @retry_captcha
|
91
|
+
retry
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def _fetch(
|
98
|
+
gym_filter: ->(_gym) { true },
|
99
|
+
court_filter: ->(_court) { true },
|
100
|
+
date_filter: ->(_date) { true }
|
101
|
+
)
|
102
|
+
captcha_error_count = 0
|
103
|
+
result = []
|
104
|
+
EM.run do
|
105
|
+
conn = Conn.new
|
106
|
+
conn.done { EM.stop }
|
107
|
+
conn.get(path: '/NewCaptcha.aspx') do |client|
|
108
|
+
ocr = Ocr.new
|
109
|
+
captcha_text = ocr.resolve(client.response)
|
110
|
+
conn.cookie = client.response_header['SET_COOKIE'][/ASP\.NET_SessionId=\w+/]
|
111
|
+
conn.post(
|
112
|
+
path: '/tp03.aspx',
|
113
|
+
query: 'module=login_page&files=login',
|
114
|
+
body: {
|
115
|
+
loginid: @username,
|
116
|
+
loginpw: @password,
|
117
|
+
Captcha_text: captcha_text
|
118
|
+
}
|
119
|
+
) do |client|
|
120
|
+
raise CaptchaError if client.response.include?('驗證碼錯誤')
|
121
|
+
GYMS.select(&gym_filter).each do |gym|
|
122
|
+
uri = URI(gym.reservation)
|
123
|
+
conn.get(path: uri.path) do |client|
|
124
|
+
@parser.parse_reservation(client.response).available_courts.select(&court_filter).each do |court|
|
125
|
+
query = "module=net_booking&files=booking_place&PT=#{court.pt}"
|
126
|
+
conn.get(path: client.req.path, query: query) do |client_calendar|
|
127
|
+
@parser.parse_calendar(client_calendar.response).available_dates.select(&date_filter).each do |date|
|
128
|
+
1.upto(3).each do |i|
|
129
|
+
query = "module=net_booking&files=booking_place&StepFlag=2&PT=#{court.pt}&D=#{date.strftime('%Y/%m/%d')}&D2=#{i}"
|
130
|
+
conn.get(path: client_calendar.req.path, query: query) do |client_time_table|
|
131
|
+
result.concat(
|
132
|
+
@parser
|
133
|
+
.parse_time_table(client_time_table.response)
|
134
|
+
.time_slots
|
135
|
+
.map do |time_slot|
|
136
|
+
Slot.new(
|
137
|
+
gym: gym,
|
138
|
+
court: court,
|
139
|
+
date: date,
|
140
|
+
time_slot: time_slot,
|
141
|
+
client: client_time_table
|
142
|
+
)
|
143
|
+
end
|
144
|
+
)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
result
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
[
|
2
|
+
{
|
3
|
+
"name": "中山運動中心",
|
4
|
+
"homepage": "http://cssc.cyc.org.tw/",
|
5
|
+
"reservation": "http://cyc.xuanen.com.tw/tp01.aspx"
|
6
|
+
},
|
7
|
+
{
|
8
|
+
"name": "南港運動中心",
|
9
|
+
"homepage": "https://ngsc.cyc.org.tw/",
|
10
|
+
"reservation": "http://cyc.xuanen.com.tw/tp02.aspx"
|
11
|
+
},
|
12
|
+
{
|
13
|
+
"name": "信義運動中心",
|
14
|
+
"homepage": "https://xysc.cyc.org.tw/",
|
15
|
+
"reservation": "http://cyc.xuanen.com.tw/tp04.aspx"
|
16
|
+
},
|
17
|
+
{
|
18
|
+
"name": "大安運動中心",
|
19
|
+
"homepage": "https://dasc.cyc.org.tw/",
|
20
|
+
"reservation": "http://cyc.xuanen.com.tw/tp03.aspx"
|
21
|
+
},
|
22
|
+
{
|
23
|
+
"name": "文山運動中心",
|
24
|
+
"homepage": "http://wssc.cyc.org.tw/",
|
25
|
+
"reservation": "http://cyc.xuanen.com.tw/tp06.aspx"
|
26
|
+
},
|
27
|
+
{
|
28
|
+
"name": "內湖運動中心",
|
29
|
+
"homepage": "https://nhsc.cyc.org.tw/",
|
30
|
+
"reservation": "https://scr.cyc.org.tw/tp12.aspx"
|
31
|
+
},
|
32
|
+
{
|
33
|
+
"name": "蘆洲國民運動中心",
|
34
|
+
"homepage": "http://lzcsc.cyc.org.tw/",
|
35
|
+
"reservation": "https://scr.cyc.org.tw/TP07.aspx"
|
36
|
+
},
|
37
|
+
{
|
38
|
+
"name": "土城國民運動中心",
|
39
|
+
"homepage": "https://tccsc.cyc.org.tw/",
|
40
|
+
"reservation": "https://scr.cyc.org.tw/tp08.aspx"
|
41
|
+
},
|
42
|
+
{
|
43
|
+
"name": "汐止國民運動中心",
|
44
|
+
"homepage": "http://xzcsc.cyc.org.tw/",
|
45
|
+
"reservation": "https://scr.cyc.org.tw/tp09.aspx"
|
46
|
+
},
|
47
|
+
{
|
48
|
+
"name": "永和國民運動中心",
|
49
|
+
"homepage": "https://yhcsc.cyc.org.tw/",
|
50
|
+
"reservation": "https://scr.cyc.org.tw/tp10.aspx"
|
51
|
+
},
|
52
|
+
{
|
53
|
+
"name": "中壢國民運動中心",
|
54
|
+
"homepage": "https://zlcsc.cyc.org.tw/",
|
55
|
+
"reservation": "https://scr.cyc.org.tw/tp15.aspx"
|
56
|
+
},
|
57
|
+
{
|
58
|
+
"name": "桃園國民運動中心",
|
59
|
+
"homepage": "https://tycsc.cyc.org.tw/",
|
60
|
+
"reservation": "https://scr.cyc.org.tw/tp13.aspx"
|
61
|
+
}
|
62
|
+
]
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'gym_finder/gym'
|
3
|
+
|
4
|
+
module GymFinder
|
5
|
+
GYMS = JSON.parse(IO.read("#{__dir__}/gyms.json")).map! do |element|
|
6
|
+
Gym.new.tap do |gym|
|
7
|
+
gym.name = element['name']
|
8
|
+
gym.homepage = element['homepage']
|
9
|
+
gym.reservation = element['reservation']
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
|
5
|
+
module GymFinder
|
6
|
+
class Ocr
|
7
|
+
def resolve(captcha)
|
8
|
+
r, w = IO.pipe
|
9
|
+
w.write captcha
|
10
|
+
w.close
|
11
|
+
r2, w2 = IO.pipe
|
12
|
+
Open3.pipeline(
|
13
|
+
['convert', '-', '-resize', '400%', '-threshold', '25%', '-'],
|
14
|
+
['tesseract', 'stdin', 'stdout', '--psm', '8', '-c', 'tessedit_char_whitelist=0123456789', err: File.open("/dev/null", "wb")],
|
15
|
+
in: r,
|
16
|
+
out: w2
|
17
|
+
)
|
18
|
+
w2.close
|
19
|
+
r2.read.strip
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nokogiri'
|
4
|
+
require 'date'
|
5
|
+
require 'gym_finder/time_table'
|
6
|
+
require 'gym_finder/calendar'
|
7
|
+
require 'gym_finder/reservation'
|
8
|
+
|
9
|
+
module GymFinder
|
10
|
+
class Parser
|
11
|
+
def parse_reservation(html)
|
12
|
+
doc = Nokogiri::HTML(html)
|
13
|
+
selector = '#ContentPlaceHolder1_button_image > table > tr > td > img[onclick*="net_booking"]'
|
14
|
+
Reservation.new.tap do |reservation|
|
15
|
+
reservation.available_courts = doc.css(selector).map do |node|
|
16
|
+
Reservation::Court.new.tap do |court|
|
17
|
+
court.name = node['alt']
|
18
|
+
court.pt = node['onclick'][/PT=(\d+)/, 1]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def parse_calendar(html)
|
25
|
+
doc = Nokogiri::HTML(html)
|
26
|
+
dates_selector = 'td[bgcolor="#87C675"]'
|
27
|
+
Calendar.new.tap do |calendar|
|
28
|
+
calendar.available_dates = doc.css(dates_selector).map do |node|
|
29
|
+
Date.strptime(node.at_css('img')['onclick'][%r{\d{4}/\d{2}/\d{2}}], '%Y/%m/%d')
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def parse_time_table(html)
|
35
|
+
doc = Nokogiri::HTML(html)
|
36
|
+
TimeTable.new.tap do |table|
|
37
|
+
table.time_slots = doc.css('#ContentPlaceHolder1_Step2_data tr:not(:first-child)').map do |row|
|
38
|
+
TimeTable::TimeSlot.new.tap do |time_slot|
|
39
|
+
time_slot.time = row.at_css('td:first-child').text[/(\d+):\d+~\d+\d+/, 1].to_i
|
40
|
+
time_slot.court = row.at_css('td:nth-child(2)').text
|
41
|
+
time_slot.price = row.at_css('td:nth-child(3)').text.to_i
|
42
|
+
img = row.at_css('td:last-child > img')
|
43
|
+
img_src = img['src']
|
44
|
+
case img_src
|
45
|
+
when 'img/sche01.png'
|
46
|
+
time_slot.status = 'available'
|
47
|
+
time_slot.qpid = img['onclick'][/Step3Action\((\d+),\d+\)/, 1]
|
48
|
+
when 'img/sche02.jpg'
|
49
|
+
time_slot.status = 'reserved'
|
50
|
+
else
|
51
|
+
warn "unknown status: #{img_src}"
|
52
|
+
'unknown'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
|
5
|
+
module GymFinder
|
6
|
+
class PostProcessor
|
7
|
+
attr_reader :slots
|
8
|
+
def initialize(slots)
|
9
|
+
@slots = slots
|
10
|
+
end
|
11
|
+
|
12
|
+
def available
|
13
|
+
PostProcessor.new(@slots.select { |slot| slot.time_slot.status == 'available' })
|
14
|
+
end
|
15
|
+
|
16
|
+
def hour_list(list)
|
17
|
+
results = []
|
18
|
+
@slots.group_by { |slot| [slot.gym, slot.date] }.each do |_, slots|
|
19
|
+
list.each do |hours|
|
20
|
+
if Set.new(hours).subset?(Set.new(slots.map { |slot| slot.time_slot.time }))
|
21
|
+
results.concat(slots.select { |slot| hours.include? slot.time_slot.time })
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
PostProcessor.new(results)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GymFinder
|
4
|
+
class TimeTable
|
5
|
+
class TimeSlot
|
6
|
+
attr_accessor :time, :court, :price, :status, :qpid
|
7
|
+
|
8
|
+
def initialize(**params)
|
9
|
+
params.each do |key, value|
|
10
|
+
send("#{key}=", value)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def ==(other_time_slot)
|
15
|
+
%i[time court price status qpid].each do |name|
|
16
|
+
return false unless send(name) == other_time_slot.send(name)
|
17
|
+
end
|
18
|
+
true
|
19
|
+
end
|
20
|
+
end
|
21
|
+
attr_accessor :time_slots
|
22
|
+
|
23
|
+
def available_time_slots
|
24
|
+
time_slots.select { |time_slot| time_slot.status == 'available' }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gym_finder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jian Weihang
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-11-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: nokogiri
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: em-http-request
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: minitest
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description:
|
84
|
+
email: tonytonyjan@gmail.com
|
85
|
+
executables:
|
86
|
+
- gym_finder
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- bin/gym_finder
|
91
|
+
- lib/gym_finder.rb
|
92
|
+
- lib/gym_finder/calendar.rb
|
93
|
+
- lib/gym_finder/cli.rb
|
94
|
+
- lib/gym_finder/client.rb
|
95
|
+
- lib/gym_finder/gym.rb
|
96
|
+
- lib/gym_finder/gyms.json
|
97
|
+
- lib/gym_finder/gyms.rb
|
98
|
+
- lib/gym_finder/ocr.rb
|
99
|
+
- lib/gym_finder/parser.rb
|
100
|
+
- lib/gym_finder/post_processor.rb
|
101
|
+
- lib/gym_finder/reservation.rb
|
102
|
+
- lib/gym_finder/time_table.rb
|
103
|
+
homepage:
|
104
|
+
licenses:
|
105
|
+
- MIT
|
106
|
+
metadata: {}
|
107
|
+
post_install_message:
|
108
|
+
rdoc_options: []
|
109
|
+
require_paths:
|
110
|
+
- lib
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
requirements: []
|
122
|
+
rubyforge_project:
|
123
|
+
rubygems_version: 2.4.5
|
124
|
+
signing_key:
|
125
|
+
specification_version: 4
|
126
|
+
summary: gym finder
|
127
|
+
test_files: []
|