gym_finder 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GymFinder
4
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GymFinder
4
+ class Calendar
5
+ attr_accessor :available_dates
6
+ end
7
+ end
@@ -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,7 @@
1
+ require 'json'
2
+
3
+ module GymFinder
4
+ class Gym
5
+ attr_accessor :name, :homepage, :reservation
6
+ end
7
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GymFinder
4
+ class Reservation
5
+ class Court
6
+ attr_accessor :name, :pt
7
+ end
8
+ attr_accessor :available_courts
9
+ end
10
+ 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: []