rubybikes 0.3.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.
Files changed (118) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +33 -0
  4. data/LICENSE +662 -0
  5. data/NOTICE +10 -0
  6. data/PROVIDERS.md +123 -0
  7. data/README.md +108 -0
  8. data/RELEASING.md +44 -0
  9. data/data/ATTRIBUTION.txt +290 -0
  10. data/data/geojson/ch_cantons.json +53 -0
  11. data/data/geojson/japan_prefectures.json +49 -0
  12. data/data/geojson/penang.json +63 -0
  13. data/data/graphql/charichari.graphql +12 -0
  14. data/data/graphql/ganxeta.graphql +22 -0
  15. data/data/graphql/otp.graphql +68 -0
  16. data/data/providers.json +1 -0
  17. data/data/pybikes/aksu.json +49 -0
  18. data/data/pybikes/ambici.json +166 -0
  19. data/data/pybikes/baksi.json +41 -0
  20. data/data/pybikes/batumvelo.json +19 -0
  21. data/data/pybikes/bcycle.json +326 -0
  22. data/data/pybikes/beryl.json +195 -0
  23. data/data/pybikes/bicicard.json +205 -0
  24. data/data/pybikes/bicicas.json +20 -0
  25. data/data/pybikes/bicielx.json +19 -0
  26. data/data/pybikes/biciferrolterra.json +21 -0
  27. data/data/pybikes/bicimad.json +16 -0
  28. data/data/pybikes/bicincitta.json +1551 -0
  29. data/data/pybikes/bicing.json +37 -0
  30. data/data/pybikes/bikeshare_ie.json +46 -0
  31. data/data/pybikes/bikeu.json +17 -0
  32. data/data/pybikes/bixi.json +21 -0
  33. data/data/pybikes/bizkaibizi.json +105 -0
  34. data/data/pybikes/careem.json +40 -0
  35. data/data/pybikes/charichari.json +106 -0
  36. data/data/pybikes/chartered_bike.json +86 -0
  37. data/data/pybikes/clujbike.json +17 -0
  38. data/data/pybikes/cyclocity.json +211 -0
  39. data/data/pybikes/cyclopolis.json +78 -0
  40. data/data/pybikes/cykleo.json +39 -0
  41. data/data/pybikes/decobike.json +18 -0
  42. data/data/pybikes/derp.json +114 -0
  43. data/data/pybikes/deutschebahn.json +97 -0
  44. data/data/pybikes/digitransit.json +123 -0
  45. data/data/pybikes/docomo.json +215 -0
  46. data/data/pybikes/easybike.json +160 -0
  47. data/data/pybikes/ecovelo.json +307 -0
  48. data/data/pybikes/emovity.json +17 -0
  49. data/data/pybikes/encicla.json +17 -0
  50. data/data/pybikes/fifteen.json +50 -0
  51. data/data/pybikes/fsm.json +16 -0
  52. data/data/pybikes/ganxeta.json +20 -0
  53. data/data/pybikes/gbfs.json +1578 -0
  54. data/data/pybikes/gira.json +20 -0
  55. data/data/pybikes/gyorbike.json +19 -0
  56. data/data/pybikes/hellocycling.json +248 -0
  57. data/data/pybikes/joco.json +22 -0
  58. data/data/pybikes/keolis.json +22 -0
  59. data/data/pybikes/kolumbus.json +18 -0
  60. data/data/pybikes/lastenvelo.json +30 -0
  61. data/data/pybikes/linkbike.json +17 -0
  62. data/data/pybikes/meiob.json +18 -0
  63. data/data/pybikes/mobhis.json +43 -0
  64. data/data/pybikes/movus.json +72 -0
  65. data/data/pybikes/nextbike.json +2443 -0
  66. data/data/pybikes/nextbike_gbfs.json +5 -0
  67. data/data/pybikes/nubija.json +18 -0
  68. data/data/pybikes/open_source_bike_share.json +28 -0
  69. data/data/pybikes/open_source_bike_share_v2.json +17 -0
  70. data/data/pybikes/openmove.json +20 -0
  71. data/data/pybikes/ov_fiets.json +20 -0
  72. data/data/pybikes/pedalada.json +66 -0
  73. data/data/pybikes/publibike.json +127 -0
  74. data/data/pybikes/qrobici.json +20 -0
  75. data/data/pybikes/roovee.json +245 -0
  76. data/data/pybikes/samba.json +52 -0
  77. data/data/pybikes/sejong.json +19 -0
  78. data/data/pybikes/seoulbike.json +18 -0
  79. data/data/pybikes/sharedmobility.json +63 -0
  80. data/data/pybikes/slovnaft_bajk.json +21 -0
  81. data/data/pybikes/smartbike.json +28 -0
  82. data/data/pybikes/smoove.json +26 -0
  83. data/data/pybikes/soltrafego.json +39 -0
  84. data/data/pybikes/tashu.json +20 -0
  85. data/data/pybikes/urbansharing.json +22 -0
  86. data/data/pybikes/velib.json +23 -0
  87. data/data/pybikes/velobike_kz.json +17 -0
  88. data/data/pybikes/velok.json +16 -0
  89. data/data/pybikes/veloway.json +20 -0
  90. data/data/pybikes/weelo.json +45 -0
  91. data/data/pybikes/wegoshare.json +141 -0
  92. data/data/pybikes/xian.json +19 -0
  93. data/data/pybikes/yelovelo.json +22 -0
  94. data/data/pybikes/youbike.json +173 -0
  95. data/data/upstream.json +7 -0
  96. data/lib/rubybikes/engine.rb +3 -0
  97. data/lib/rubybikes/native/authenticated.rb +95 -0
  98. data/lib/rubybikes/native/engine.rb +103 -0
  99. data/lib/rubybikes/native/gbfs.rb +169 -0
  100. data/lib/rubybikes/native/geometry.rb +53 -0
  101. data/lib/rubybikes/native/graphql.rb +135 -0
  102. data/lib/rubybikes/native/html_clients.rb +313 -0
  103. data/lib/rubybikes/native/json_providers.rb +174 -0
  104. data/lib/rubybikes/native/more_json.rb +126 -0
  105. data/lib/rubybikes/native/nextbike.rb +50 -0
  106. data/lib/rubybikes/native/provider.rb +84 -0
  107. data/lib/rubybikes/native/remaining_clients.rb +102 -0
  108. data/lib/rubybikes/native/response_cache.rb +78 -0
  109. data/lib/rubybikes/native/transport.rb +163 -0
  110. data/lib/rubybikes/native/xml_details.rb +159 -0
  111. data/lib/rubybikes/native.rb +30 -0
  112. data/lib/rubybikes/version.rb +5 -0
  113. data/lib/rubybikes.rb +201 -0
  114. data/sig/external.rbs +30 -0
  115. data/sig/manifest.yaml +8 -0
  116. data/sig/native.rbs +230 -0
  117. data/sig/rubybikes.rbs +143 -0
  118. metadata +230 -0
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted from PyBikes OTP, Charichari, Ganxeta and UrbanSharing; AGPL-3.0.
4
+ class RubyBikes
5
+ module Native
6
+ class JsonProviders
7
+ def graphql(url, name, variables = {}, operation: nil)
8
+ payload = { 'query' => File.read(File.join(Native::DATA, 'graphql', "#{name}.graphql")), 'variables' => variables }
9
+ payload['operationName'] = operation if operation
10
+ json(url, method: 'POST', headers: { 'Content-Type' => 'application/json; charset=utf-8' }, data: JSON.generate(payload)).fetch('data')
11
+ end
12
+
13
+ def update_charichari
14
+ graphql('https://charichari.bike/api/graphql', 'charichari', { 'regionId' => p('region_id') }, operation: 'listPorts').fetch('ports').each do |s|
15
+ station(s['title'], s['location']['latitude'], s['location']['longitude'], 0, number(s['capacity']), { 'uid' => s['id'], 'address' => s['address'] })
16
+ end
17
+ end
18
+
19
+ def update_ganxeta
20
+ graphql('https://bks-api.ganxeta.cat/rms-bike-sharing-real-time/graphql', 'ganxeta', {}, operation: 'StationsRealtime').fetch('get_data_real_time').each do |s|
21
+ station(s['station_name'], s['latitude'], s['longitude'], number(s['bikes_available']), number(s['racks_available']),
22
+ { 'uid' => s['station_id'], 'address' => s['station_address'], 'slots' => number(s['racks_total']), 'number' => s['station_external_id'] })
23
+ end
24
+ end
25
+
26
+ def update_otp
27
+ data = graphql(p('feed_url'), 'otp')
28
+ data.fetch('vehicleRentalStations').each do |s|
29
+ # @type var extra: RubyBikes::object
30
+ extra = { 'uid' => s['stationId'], 'online' => s['operative'] }
31
+ extra['slots'] = s['capacity'] unless s['capacity'].nil?
32
+ extra['rental_uris'] = s['rentalUris'] if truthy?(s['rentalUris'])
33
+ s['availableVehicles']['byType'].each do |type|
34
+ field = otp_type(type.fetch('vehicleType')).first
35
+ extra[field] = extra.fetch(field, 0) + type.fetch('count') if field
36
+ end
37
+ free = s['availableSpaces']['total']
38
+ station(s['name'], s['lat'], s['lon'], s['availableVehicles']['total'], free == 2_147_483_647 ? 32 : free, extra)
39
+ end
40
+ data.fetch('rentalVehicles').each do |s|
41
+ # @type var extra: RubyBikes::object
42
+ extra = { 'uid' => s['vehicleId'], 'online' => s['operative'] }
43
+ extra['rental_uris'] = s['rentalUris'] if truthy?(s['rentalUris'])
44
+ extra['battery'] = Float(s['fuel']['percent']) if truthy?(s['fuel']['percent'])
45
+ vehicle(s['lat'], s['lon'], extra, otp_type(s.fetch('vehicleType')).last)
46
+ end
47
+ end
48
+
49
+ def update_digitransit
50
+ @parameters['feed_url'] ||= 'https://api.digitransit.fi/routing/v2/finland/gtfs/v1'
51
+ @transport.headers['digitransit-subscription-key'] = @key
52
+ update_otp
53
+ end
54
+
55
+ def otp_type(type)
56
+ case [type['formFactor'], type['propulsionType']]
57
+ when %w[BICYCLE HUMAN] then %w[normal_bikes bike]
58
+ when %w[BICYCLE ELECTRIC_ASSIST] then %w[ebikes ebike]
59
+ when %w[CARGO_BICYCLE HUMAN] then %w[cargo bike]
60
+ when %w[CARGO_BICYCLE ELECTRIC_ASSIST] then %w[ecargo bike]
61
+ else [nil, 'bike']
62
+ end
63
+ end
64
+
65
+ def update_urbansharing
66
+ params = { 'operationName' => 'DockGroups', 'variables' => JSON.generate('systemId' => tag),
67
+ 'extensions' => JSON.generate('persistedQuery' => { 'version' => 1, 'sha256Hash' => '6afd0a33ab1a81d87dd31830e4e3f3da6f9e0881a9a07427b59bf1dec6fd692e' }) }
68
+ json('https://core.urbansharing.com/public/api/v1/graphql', params: params).fetch('data').fetch('dockGroups').each do |s|
69
+ station(s['title'], s['coord']['lat'], s['coord']['lng'], number(s['availabilityInfo']['availableVehicles']), number(s['availabilityInfo']['availableDocks']),
70
+ { 'uid' => s['id'], 'address' => s['subTitle'], 'online' => s['state'] == 'active' })
71
+ end
72
+ end
73
+
74
+ def update_roovee
75
+ data = json('https://api.roovee.eu/public/bikesAndZones', params: { 'tenant' => p('tenant') })
76
+ data.fetch('zones').each do |s|
77
+ next unless s['type'] == 'preferredBikeReturnZone'
78
+
79
+ station(s['name'], s['areaCenter']['lat'], s['areaCenter']['lng'], 0, nil, { 'virtual' => true })
80
+ end
81
+ data.fetch('bikes').each do |s|
82
+ vehicle(s['location']['lat'], s['location']['lng'], { 'uid' => s['bikeNumber'], 'online' => s['isAvailable'] }, s['type'] == 'Elektryczny' ? 'ebike' : 'bike')
83
+ end
84
+ end
85
+
86
+ def update_bicing
87
+ json("#{p('endpoint')}/get-stations").fetch('stations').each do |s|
88
+ begin
89
+ lat, lon = Float(s['latitude']), Float(s['longitude'])
90
+ rescue ArgumentError, TypeError
91
+ next
92
+ end
93
+ # @type var extra: RubyBikes::object
94
+ extra = { 'uid' => number(s['id']), 'online' => s['status'] == 1 }
95
+ extra['normal_bikes'] = number(s['mechanical_bikes']) if s.key?('mechanical_bikes')
96
+ extra.merge!('has_ebikes' => true, 'ebikes' => number(s['electrical_bikes'])) if s.key?('electrical_bikes')
97
+ station(s['streetName'], lat, lon, number(s['bikes']), number(s['slots']), extra)
98
+ end
99
+ end
100
+
101
+ def update_velok
102
+ json('https://api.tfl.lu/v1/BikePoint').fetch('features').each do |s|
103
+ info = s.fetch('properties')
104
+ next unless info.fetch('id').include?('velok')
105
+
106
+ station(info['name'], s['geometry']['coordinates'][1], s['geometry']['coordinates'][0], info['available_bikes'] + info['available_ebikes'], info['available_docks'],
107
+ { 'uid' => info['id'].split(':').last, 'address' => info['address'], 'photo' => info['photo'], 'slots' => info['docks'], 'online' => info['open'],
108
+ 'ebikes' => info['available_ebikes'] })
109
+ end
110
+ end
111
+
112
+ def update_publibike
113
+ cantons = Array(p('canton'))
114
+ regions = JSON.parse(File.read(File.join(Native::DATA, 'geojson/ch_cantons.json'))).fetch('features').select { |f| f['geometry'] && cantons.include?(f['properties']['NAME']) }
115
+ data = json('https://rest.publibike.ch/v1/public/all/stations')
116
+ data.fetch('publibike').fetch('stations').each do |s|
117
+ next unless regions.empty? || regions.any? { |f| Geometry.contains?(f, Float(s['latitude']), Float(s['longitude'])) }
118
+
119
+ bikes = s.fetch('vehicles').size
120
+ station(s['name'], s['latitude'], s['longitude'], bikes, s['capacity'] - bikes,
121
+ { 'uid' => s['id'], 'address' => s['address'], 'zip' => s['zip'], 'city' => s['city'], 'slots' => s['capacity'], 'ebikes' => s['vehicles'].count do |v|
122
+ v['type']['id'] == 2
123
+ end })
124
+ end
125
+ data.fetch('velospot').fetch('responseData').each do |s|
126
+ next unless regions.empty? || regions.any? { |f| Geometry.contains?(f, Float(s['lat']), Float(s['lng'])) }
127
+
128
+ station(s['station_name'], s['lat'], s['lng'], number(s['totalBike']), 0,
129
+ { 'uid' => s['station_id'], 'address' => s['station_address'], 'ebikes' => number(s['totalElectricalBike']),
130
+ 'normal_bikes' => number(s['totalNonElectricalBike']) })
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Native adaptations of PyBikes HTML clients. Original work Copyright eskerda,
4
+ # Martín González Gómez, Eduardo Mucelli Rezende Oliveira, thesebas and iomartin.
5
+ # Distributed under the AGPL-3.0 license; see LICENSE.
6
+ require 'strscan'
7
+
8
+ class RubyBikes
9
+ module Native
10
+ # A data-only reader for the array/tuple literals published by Samba.
11
+ # Identifiers, calls, interpolation and executable expressions are rejected.
12
+ class DataLiteral
13
+ def self.parse(source)
14
+ raise ProviderError, 'Provider literal is too large' if source.bytesize > 2 * 1024 * 1024
15
+
16
+ new(source).parse
17
+ end
18
+
19
+ def initialize(source)
20
+ @scanner = StringScanner.new(source)
21
+ end
22
+
23
+ def parse
24
+ result = value(0)
25
+ whitespace
26
+ raise ProviderError, 'Invalid provider data literal' unless @scanner.eos?
27
+
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def whitespace
34
+ @scanner.skip(/\s*/)
35
+ end
36
+
37
+ def value(depth)
38
+ raise ProviderError, 'Provider literal is too deeply nested' if depth > 64
39
+
40
+ whitespace
41
+ opening = @scanner.peek(1)
42
+ return sequence(depth, opening == '[' ? ']' : ')') if ['[', '('].include?(opening)
43
+ return string if ["'", '"'].include?(opening)
44
+
45
+ number = @scanner.scan(/[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)
46
+ return number.match?(/[.eE]/) ? Float(number) : Integer(number, 10) if number
47
+
48
+ token = @scanner.scan(/(?:True|False|None|true|false|null)\b/)
49
+ return { 'True' => true, 'true' => true, 'False' => false, 'false' => false, 'None' => nil, 'null' => nil }.fetch(token) if token
50
+
51
+ raise ProviderError, 'Invalid provider data literal'
52
+ end
53
+
54
+ def sequence(depth, closing)
55
+ @scanner.getch
56
+ # @type var items: Array[RubyBikes::json]
57
+ items = []
58
+ whitespace
59
+ return items if @scanner.scan(Regexp.new(Regexp.escape(closing)))
60
+
61
+ loop do
62
+ items << value(depth + 1)
63
+ whitespace
64
+ return items if @scanner.scan(Regexp.new(Regexp.escape(closing)))
65
+ unless @scanner.scan(/,/) # rubocop:disable Style/RedundantRegexpArgument -- Ruby 3.1 compatibility.
66
+ raise ProviderError,
67
+ 'Invalid provider data literal'
68
+ end
69
+
70
+ whitespace
71
+ return items if @scanner.scan(Regexp.new(Regexp.escape(closing)))
72
+ end
73
+ end
74
+
75
+ def string
76
+ quote = @scanner.getch
77
+ result = +''
78
+ until @scanner.eos?
79
+ character = @scanner.getch
80
+ return result if character == quote
81
+
82
+ if character == '\\'
83
+ escaped = @scanner.getch
84
+ raise ProviderError, 'Invalid provider string literal' unless escaped
85
+
86
+ character = case escaped
87
+ when 'n' then "\n"
88
+ when 'r' then "\r"
89
+ when 't' then "\t"
90
+ when 'b' then "\b"
91
+ when 'f' then "\f"
92
+ when 'v' then "\v"
93
+ when 'a' then "\a"
94
+ when '\\', "'", '"', '/' then escaped
95
+ when "\n" then ''
96
+ when 'x', 'u', 'U'
97
+ width = { 'x' => 2, 'u' => 4, 'U' => 8 }.fetch(escaped)
98
+ hexadecimal = @scanner.scan(/[0-9a-fA-F]{#{width}}/)
99
+ raise ProviderError, 'Invalid provider string escape' unless hexadecimal
100
+
101
+ [hexadecimal.to_i(16)].pack('U')
102
+ else "\\#{escaped}"
103
+ end
104
+ end
105
+ raise ProviderError, 'Invalid provider string literal' unless character
106
+
107
+ result << character
108
+ end
109
+ raise ProviderError, 'Unterminated provider string literal'
110
+ end
111
+ end
112
+
113
+ class JsonProviders
114
+ EMOVITY_USER_AGENT = 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4572.0 Safari/537.36'
115
+ SAMBA_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/31.0.1650.63 Chrome/31.0.1650.63 Safari/537.36'
116
+
117
+ def update_bicielx
118
+ raw = request('https://gestion.bicielx.es/mapa_prestamo.php')
119
+ html_scan(raw, %r{var circle = .*?</div>"\)}m).each do |marker|
120
+ name = html_capture(marker, %r{'>(.*?)</h2}m)
121
+ coordinates = html_capture(marker, /\[([^\]]+)\]/).split(',')
122
+ availability = html_match(marker, /libres: (\d+).*disponibles: (\d+)/m)
123
+ uid = html_capture(marker, /"([^"]+)"/)
124
+ station(name, coordinates.fetch(0), coordinates.fetch(1), number(availability[2]), number(availability[1]), { 'uid' => number(uid) })
125
+ end
126
+ end
127
+
128
+ def update_biciferrolterra
129
+ raw = request(p('url'))
130
+ coordinates = html_scan(raw, /new GMarker\([\s\S]*?\)\s*;/).uniq
131
+ availability = html_scan(raw, /GEvent.addListener\(marker_subguri[\s\S]*?\)\s*;/)
132
+ coordinates.zip(availability).each do |coords, info|
133
+ raise ProviderError, 'Provider station availability is missing' unless info
134
+
135
+ lat, lon = html_scan(coords, /-?\d+\.\d+/).map { |number| Float(number) }
136
+ bikes = number(html_capture(info, /Bicicletas dispoñibles: (-?\d+)/))
137
+ free = number(html_capture(info, /Postos libres: (-?\d+)/))
138
+ station(html_capture(coords, /title:'(.*?)'/), lat, lon, bikes, free,
139
+ { 'slots' => bikes + free, 'schedule' => html_capture(info, %r{Horario: ([^<]+)</}) })
140
+ end
141
+ end
142
+
143
+ def update_bikeu
144
+ raw = request(p('url'))
145
+ data = JSON.parse(html_capture(raw, /setConfig\('StationsData',(\[.*\])\);/))
146
+ data.each do |info|
147
+ bikes = number(info.fetch('TotalAvailableBikes'))
148
+ station(info.fetch('Name'), info.fetch('Latitude'), info.fetch('Longitude'), bikes,
149
+ number(info.fetch('TotalLocks')) - bikes, { 'uid' => info.fetch('id') })
150
+ end
151
+ end
152
+
153
+ def update_emovity
154
+ @transport.headers['User-Agent'] = EMOVITY_USER_AGENT
155
+ raw = request(p('feed_url'))
156
+ html_scan(raw, /addMarker\((-?\d+[.,]\d+),(-?\d+[.,]\d+),(\d+),(\d+),'(.*)'\);/).each do |lat, lon, bikes, free, popup|
157
+ name = Nokogiri::HTML.fragment(popup).xpath('.//div/text()').first&.text
158
+ raise ProviderError, 'Provider station name is missing' unless name
159
+
160
+ station(name, lat.tr(',', '.'), lon.tr(',', '.'), number(bikes), number(free),
161
+ { 'uid' => html_capture(name, /(\d+)\s*-/) })
162
+ end
163
+ end
164
+
165
+ def update_gyorbike
166
+ raw = request('https://www.gyorbike.hu/en/stations')
167
+ document = Nokogiri::HTML(raw)
168
+ markers = JSON.parse(html_capture(raw, /gMap.markerData=(.*);gMap.listenerParams/))
169
+ rows = document.xpath('//table[@id="cmeStationInfo"]//tr[@id]').to_h { |row| [row['id'].sub(/\Atr-/, ''), row] }
170
+ markers.each do |marker|
171
+ row = rows[marker.fetch('station_num').to_s]
172
+ next unless row
173
+
174
+ name = html_text(row, '[data-title="Station name"] .inner').split('Offline').first.to_s.strip
175
+ normal = gyorbike_count(row, 'Standard bike')
176
+ electric = gyorbike_count(row, 'E-bike')
177
+ station(name, marker.fetch('lat'), marker.fetch('lng'), normal + electric, gyorbike_count(row, 'Available dock'),
178
+ { 'uid' => marker.fetch('station_num'), 'slots' => marker.fetch('docks'), 'normal_bikes' => normal,
179
+ 'ebikes' => electric, 'online' => !row['class'].to_s.include?('offline') })
180
+ end
181
+ end
182
+
183
+ def update_meiob
184
+ html(p('endpoint')).css('.bikes > .bike').each do |item|
185
+ names = html_text(item, '.stationlon + .border-right-blue > h2').split(' - ')
186
+ name = names.length == 2 ? names[1] : names[0]
187
+ availability = item.css('.bike__available').map(&:text)
188
+ addresses = item.css('.bike__distance__title').map(&:text)
189
+ station(name, html_text(item, '.stationlat'), html_text(item, '.stationlon'), number(availability.fetch(0)), number(availability.fetch(1)),
190
+ { 'address' => addresses.fetch(0), 'city' => addresses.fetch(1) })
191
+ end
192
+ end
193
+
194
+ def update_mobhis
195
+ raw = request(p('feed_url'))
196
+ coordinates = html_scan(raw, /var marker = L\.marker\(\[(-?\d+\.\d+),\s*(-?\d+\.\d+)\]/)
197
+ popups = html_scan(raw, /marker.bindPopup\("(.*)"\)/).flatten
198
+ coordinates.zip(popups).each do |(lat, lon), popup|
199
+ raise ProviderError, 'Provider station popup is missing' unless popup
200
+
201
+ online = !popup.include?('Estação offline')
202
+ unless online
203
+ station(nil, lat, lon, 0, 0, { 'online' => false })
204
+ next
205
+ end
206
+ texts = Nokogiri::HTML.fragment(popup).xpath('.//text()').map(&:text)
207
+ name = texts.shift
208
+ remaining = texts.join(' ')
209
+ bikes = html_scan(remaining, /(\d+) bikes/).flatten.map { |number| number(number) }
210
+ spaces = html_scan(remaining, /(\d+) vagas/).flatten.map { |number| number(number) }
211
+ extra = { 'online' => true }
212
+ extra.merge!('kid_bikes' => bikes[1], 'kid_slots' => spaces[1]) if bikes.length > 1 && spaces.length > 1
213
+ station(name, lat, lon, bikes.fetch(0), spaces.fetch(0), extra)
214
+ end
215
+ end
216
+
217
+ def update_movus
218
+ raw = request(p('feed_url'))
219
+ match = raw.match(/var misPuntos = \[(.*?)\];/m)
220
+ markers = if match
221
+ JSON.parse("[#{match[1].to_s.sub(/,\s*\z/, '')}]")
222
+ else
223
+ # Current MIBISI pages publish station records through the
224
+ # JSON endpoint referenced by their map script.
225
+ document = Nokogiri::HTML(raw)
226
+ script = document.at_css('script[src$="assets/js/map.js"], script[src$="/map.js"]')
227
+ raise ProviderError, 'Provider page is missing its station map' unless script
228
+
229
+ script_url = URI.join(p('feed_url'), script['src']).to_s
230
+ endpoint = html_capture(request(script_url), /fetch\(\s*['"]([^'"]+)['"]\s*\)/)
231
+ json(URI.join(p('feed_url'), endpoint).to_s).map do |record|
232
+ [record.fetch('nombre'), record.fetch('lat'), record.fetch('lng'), nil, record.fetch('html')]
233
+ end
234
+ end
235
+ markers.each do |name, lat, lon, _unused, info|
236
+ next unless truthy?(lat) && truthy?(lon)
237
+
238
+ if info.match?(/Actualiz[aá]ndose/i)
239
+ station(name, lat, lon, 0)
240
+ elsif p('ebikes')
241
+ counts = html_match(info, /mecánicas:\s*(\d+).*eléctricas:\s*(\d+).*libres:\s*(\d+)/m)
242
+ electric = number(counts[2])
243
+ station(name, lat, lon, number(counts[1]) + electric, number(counts[3]), { 'ebikes' => electric })
244
+ else
245
+ counts = html_match(info, /Totales\s*=\s*(\d+).*disponibles\s*=\s*(\d+).*libres\s*=\s*(-?\d+)/m)
246
+ station(name, lat, lon, number(counts[2]), [number(counts[3]), 0].max, { 'slots' => number(counts[1]) })
247
+ end
248
+ end
249
+ end
250
+
251
+ def update_samba
252
+ @transport.headers['User-Agent'] = SAMBA_USER_AGENT
253
+ raw = request(p('url'))
254
+ literal = html_capture(raw, /var\s+beaches\s*=\s*(\[.*?\])\s*;/m)
255
+ data = DataLiteral.parse(literal)
256
+ raise ProviderError, 'Provider stations must be a list' unless data.is_a?(Array)
257
+
258
+ data.each do |data|
259
+ # @type var data: untyped
260
+ operation = data.fetch(6)
261
+ status = if %w[EI EM].include?(operation)
262
+ 'maintenance/implementation'
263
+ elsif data.fetch(5) == 'A' && operation == 'EO'
264
+ 'open'
265
+ else
266
+ 'closed'
267
+ end
268
+ station(data.fetch(0), data.fetch(1), data.fetch(2), number(data.fetch(8)), number(data.fetch(9)),
269
+ { 'address' => data.fetch(4), 'description' => data.fetch(3), 'status' => status })
270
+ end
271
+ end
272
+
273
+ def update_velobike_kz
274
+ html(p('feed_url')).xpath('//table[contains(@class, "table-stations")]//tbody//tr').each do |row|
275
+ extra = { 'online' => row.xpath('.//td[span]').empty?, 'slots' => velobike_number(row['data-total-slots']),
276
+ 'address' => html_text(row, 'td.address').strip, 'uid' => html_text(row, 'th.code').strip }
277
+ station(html_text(row, 'td.name').strip, row['data-latitude'], row['data-longitude'],
278
+ velobike_number(html_text(row, 'td.avl-bikes').strip), velobike_number(html_text(row, 'td.free-slots').strip), extra)
279
+ end
280
+ end
281
+
282
+ private
283
+
284
+ def html_scan(source, pattern) = source.scan(pattern)
285
+
286
+ def html_match(source, pattern)
287
+ source.match(pattern) || raise(ProviderError, 'Provider page is missing required station data')
288
+ end
289
+
290
+ def html_capture(source, pattern)
291
+ html_match(source, pattern)[1] || raise(ProviderError, 'Provider page is missing a capture')
292
+ end
293
+
294
+ def html_text(node, selector)
295
+ element = node.at_css(selector)
296
+ raise ProviderError, 'Provider page is missing a required station field' unless element
297
+
298
+ element.text
299
+ end
300
+
301
+ def gyorbike_count(row, title)
302
+ value = html_text(row, "[data-title=\"#{title}\"] .inner")
303
+ value.include?('--') ? 0 : number(value)
304
+ end
305
+
306
+ def velobike_number(value)
307
+ number(value)
308
+ rescue ArgumentError
309
+ 0
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Native ports of PyBikes clients, Copyright eskerda, Martín González Gómez,
4
+ # Eduardo Mucelli Rezende Oliveira and contributors. Distributed under AGPL-3.0.
5
+ class RubyBikes
6
+ module Native
7
+ class JsonProviders < Provider
8
+ def update
9
+ send("update_#{spec.fetch('module')}")
10
+ end
11
+
12
+ def update_encicla
13
+ json('https://webapp.metropol.gov.co/wsencicla/api/Disponibilidad/GetDisponibilidadMapas').each do |s|
14
+ next unless number(s.fetch('cdo')).zero?
15
+
16
+ station(s['name'], s['lat'], s['lon'], s['bikes'], s['capacity'] - s['bikes'],
17
+ { 'uid' => s['id'], 'slots' => s['capacity'], 'address' => s['address'].to_s.strip,
18
+ 'description' => s['description'], 'online' => s['closed'] == '0' })
19
+ end
20
+ end
21
+
22
+ def update_fifteen
23
+ data = json(p('feed_url'))
24
+ if data.is_a?(Hash)
25
+ raise ProviderError, 'Provider returned unsuccessful status' unless data['statusCode'] == 200
26
+
27
+ data = data.fetch('data').fetch('stations')
28
+ end
29
+ data.each do |s|
30
+ station(s['label'], s['coordinates'][1], s['coordinates'][0], number(s['availableBikes']), number(s['availableSlots']),
31
+ { 'uid' => s['id'], 'online' => !s['isClosed'] })
32
+ end
33
+ end
34
+
35
+ def update_gira
36
+ json(p('feed_url')).fetch('features').each do |s|
37
+ info = s.fetch('properties')
38
+ bikes = number(info['num_bicicletas'])
39
+ station(info['desig_comercial'], s['geometry']['coordinates'][0][1], s['geometry']['coordinates'][0][0], bikes, number(info['num_docas']) - bikes,
40
+ { 'uid' => info['id_expl'], 'slots' => info['num_docas'], 'status' => info['estado'], 'online' => info['estado'] == 'active' })
41
+ end
42
+ end
43
+
44
+ def update_joco
45
+ json(p('feed_url')).each do |s|
46
+ bikes = number(s['slots']['withAvailableVehicle'])
47
+ station(s['name'], s['info']['position']['coordinates'][1], s['info']['position']['coordinates'][0], bikes, number(s['slots']['free']),
48
+ { 'uid' => s['id'], 'has_ebikes' => true, 'ebikes' => bikes, 'address' => s['info']['address'], 'slots' => number(s['slots']['total']),
49
+ 'online' => s['state'] == 'OPEN' })
50
+ end
51
+ end
52
+
53
+ def update_kolumbus
54
+ json('https://kolumbus-sanntidsskjerm-backend-prod.azurewebsites.net/entur/city-bikes').each do |s|
55
+ bikes, slots = number(s['available']), number(s['capacity'])
56
+ station(s['name'], s['latitude'], s['longitude'], bikes, [slots - bikes, 0].max,
57
+ { 'has_ebikes' => true, 'slots' => slots, 'uid' => s['id'], 'number' => s['id'][/Station:(\d+)$/, 1]&.to_i })
58
+ end
59
+ end
60
+
61
+ def update_linkbike
62
+ @parameters['bbox'] = JSON.parse(File.read(File.join(Native::DATA, 'geojson/penang.json')))
63
+ json('https://www.linkbike.my/linkbike_api/api_update_stationinfo.php').each do |s|
64
+ bikes, slots = number(s['availbike']), number(s['capacity'])
65
+ station(s['name'], s['lat'], s['lng'], bikes, slots - bikes, { 'uid' => s['StId'], 'slots' => slots })
66
+ end
67
+ end
68
+
69
+ def update_openmove
70
+ json('https://otp.opendatahub.com/otp/routers/openmove/bike_rental').fetch('stations').each do |s|
71
+ next if s['isFloatingBike'] || !s['networks'].include?(p('network'))
72
+
73
+ station(s['name'], s['y'], s['x'], number(s['bikesAvailable']), number(s['spacesAvailable']),
74
+ { 'uid' => s['id'].delete('"'), 'last_update' => s['lastReportedEpochSeconds'], 'online' => s['realTimeData'] })
75
+ end
76
+ end
77
+
78
+ def update_qrobici
79
+ json('https://qrobici.mx/mobile/getStations').fetch('estaciones').each do |s|
80
+ station(s['T_NOMBRE'], s['T_LATITUD'], s['T_LONGITUD'], number(s['BICICLETAS_DISPONIBLES']), number(s['ESPACIOS_DISPONIBLES']),
81
+ { 'uid' => s['id'], 'address' => s['T_REFERENCIA'], 'slots' => s['E_NO_SLOTS'], 'status' => s['ESTATUS'], 'online' => s['ESTATUS'] == 'En línea',
82
+ 'last_update' => s['FH_FECHA_ULTIMA_CONEXION'] })
83
+ end
84
+ end
85
+
86
+ def update_sejong
87
+ json(p('feed_url')).fetch('data').fetch('sbike_station').each do |s|
88
+ station(s['station_name'], s['y_pos'], s['x_pos'], number(s['bike_parking']), nil, { 'uid' => s['station_id'], 'address' => s['addr'] })
89
+ end
90
+ end
91
+
92
+ def update_seoulbike
93
+ json(p('feed_url'), method: 'POST', data: { 'stationGrpSeq' => 'ALL' }).fetch('realtimeList').each do |s|
94
+ kids, slots = number(s['parkingELECBikeCnt']), number(s['rackTotCnt'])
95
+ bikes = number(s['parkingQRBikeCnt']) + kids
96
+ station(s['stationName'], s['stationLatitude'], s['stationLongitude'], bikes, [slots - bikes, 0].max,
97
+ { 'kid_bikes' => kids, 'slots' => slots, 'uid' => s['stationId'] })
98
+ end
99
+ end
100
+
101
+ def update_slovnaft_bajk
102
+ json('https://gate.slovnaftbajk.sk/AppGate2.php', method: 'POST', data: JSON.generate('Cmd' => 'GetAllStationInfo', 'Area' => 'BA')).fetch('Info').each do |s|
103
+ next unless s['test_station'] == '0'
104
+
105
+ station(s['Name'], s['GpsLat'], s['GpsLon'], number(s['BikeNum']), number(s['DockNum']),
106
+ { 'uid' => s['Station'], 'ebikes' => number(s['eBikeNum']), 'online' => s['active'] == '1' })
107
+ end
108
+ end
109
+
110
+ def update_smartbike
111
+ information = json("#{p('endpoint')}/station_list.json")
112
+ statuses = json("#{p('endpoint')}/station_status_list.json").to_h { |s| [s.fetch('id'), s] }
113
+ information.each do |s|
114
+ status = statuses.fetch(s.fetch('id'))
115
+ station(s['name'], s['location']['lat'], s['location']['lon'], number(status['availability']['bikes']), number(status['availability']['slots']),
116
+ { 'status' => status['status'], 'uid' => number(s['id']), 'address' => s['address'] })
117
+ end
118
+ end
119
+
120
+ def update_soltrafego
121
+ json("https://bikesharing.soltrafego.pt/index.php/adminapi/dashboard_data/#{p('uid')}").fetch('stations').each do |s|
122
+ station(s['name'], s['lat'], s['lon'], number(s['online_docked']), number(s['online_free']),
123
+ { 'uid' => s['id'], 'address' => s['address'], 'last_update' => s['updated_on'], 'slots' => s['docks'].size, 'online' => s['active'] == '1' })
124
+ end
125
+ end
126
+
127
+ def update_tashu
128
+ raw = request(p('endpoint'))
129
+ JSON.parse(html_capture(raw, /var station_json = JSON.parse\('(.*)'\);/)).each do |s|
130
+ station(s['name'], s['x_pos'], s['y_pos'], number(s['parking_count']), nil, { 'uid' => s['id'], 'address' => s['address'] })
131
+ end
132
+ end
133
+
134
+ def update_batumvelo
135
+ json('https://batumvelo.ge/api/client/public/parking', headers: { 'Accept' => 'application/json' }).each do |s|
136
+ station(s['name'], s['lat'], s['lng'], 0, 0, { 'online' => s['type'] == 'ALLOW' })
137
+ end
138
+ json('https://batumvelo.ge/api/client/public/available', headers: { 'Accept' => 'application/json' }).each do |s|
139
+ vehicle(s['lat'], s['lng'])
140
+ end
141
+ end
142
+
143
+ def update_aksu
144
+ raw = request(p('endpoint'))
145
+ JSON.parse(html_capture(raw, /var ibike = (.*)/)).fetch('station').each do |s|
146
+ bikes, slots = number(s['availBike']), number(s['capacity'])
147
+ station(s['name'], s['lat'], s['lng'], bikes, slots - bikes, { 'uid' => s['id'], 'address' => s['address'], 'slots' => slots })
148
+ end
149
+ end
150
+
151
+ def update_bicicas
152
+ json('https://ws2.bicicas.es/bench_status_map').fetch('features').each do |s|
153
+ info = s.fetch('properties')
154
+ anchors = info.fetch('anchors')
155
+ free = anchors.count { |a| a['bicycle'].nil? && a.fetch('incidents').empty? }
156
+ ebikes = anchors.count { |a| truthy?(a['is_electric']) }
157
+ station(info['name'], s['geometry']['coordinates'][1], s['geometry']['coordinates'][0], info['bikes_available'], free,
158
+ { 'has_ebikes' => ebikes.positive?, 'ebikes' => ebikes, 'slots' => anchors.size, 'last_seen' => info['last_seen'],
159
+ 'number_loans' => number(info['number_loans']), 'incidents' => info['incidents'], 'online' => info['online'] })
160
+ end
161
+ end
162
+
163
+ def update_lastenvelo
164
+ # @type var rows: untyped
165
+ rows = CSV.parse(request(p('feed_url')), headers: true)
166
+ rows.each do |row|
167
+ _utc, human, uid, lat, lon, status, _returned, name, subname, info, booking = row.fields
168
+ station([name, subname].reject { |v| v.nil? || v.empty? }.join(' - '), lat, lon, status == 'available' ? 1 : 0, nil,
169
+ { 'uid' => uid, 'status' => status, 'rental_uris' => { 'web' => booking }, 'last_updated' => human, 'bike_type' => info })
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end