gym_finder 1.0.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 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: []