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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/CONTRIBUTING.md +33 -0
- data/LICENSE +662 -0
- data/NOTICE +10 -0
- data/PROVIDERS.md +123 -0
- data/README.md +108 -0
- data/RELEASING.md +44 -0
- data/data/ATTRIBUTION.txt +290 -0
- data/data/geojson/ch_cantons.json +53 -0
- data/data/geojson/japan_prefectures.json +49 -0
- data/data/geojson/penang.json +63 -0
- data/data/graphql/charichari.graphql +12 -0
- data/data/graphql/ganxeta.graphql +22 -0
- data/data/graphql/otp.graphql +68 -0
- data/data/providers.json +1 -0
- data/data/pybikes/aksu.json +49 -0
- data/data/pybikes/ambici.json +166 -0
- data/data/pybikes/baksi.json +41 -0
- data/data/pybikes/batumvelo.json +19 -0
- data/data/pybikes/bcycle.json +326 -0
- data/data/pybikes/beryl.json +195 -0
- data/data/pybikes/bicicard.json +205 -0
- data/data/pybikes/bicicas.json +20 -0
- data/data/pybikes/bicielx.json +19 -0
- data/data/pybikes/biciferrolterra.json +21 -0
- data/data/pybikes/bicimad.json +16 -0
- data/data/pybikes/bicincitta.json +1551 -0
- data/data/pybikes/bicing.json +37 -0
- data/data/pybikes/bikeshare_ie.json +46 -0
- data/data/pybikes/bikeu.json +17 -0
- data/data/pybikes/bixi.json +21 -0
- data/data/pybikes/bizkaibizi.json +105 -0
- data/data/pybikes/careem.json +40 -0
- data/data/pybikes/charichari.json +106 -0
- data/data/pybikes/chartered_bike.json +86 -0
- data/data/pybikes/clujbike.json +17 -0
- data/data/pybikes/cyclocity.json +211 -0
- data/data/pybikes/cyclopolis.json +78 -0
- data/data/pybikes/cykleo.json +39 -0
- data/data/pybikes/decobike.json +18 -0
- data/data/pybikes/derp.json +114 -0
- data/data/pybikes/deutschebahn.json +97 -0
- data/data/pybikes/digitransit.json +123 -0
- data/data/pybikes/docomo.json +215 -0
- data/data/pybikes/easybike.json +160 -0
- data/data/pybikes/ecovelo.json +307 -0
- data/data/pybikes/emovity.json +17 -0
- data/data/pybikes/encicla.json +17 -0
- data/data/pybikes/fifteen.json +50 -0
- data/data/pybikes/fsm.json +16 -0
- data/data/pybikes/ganxeta.json +20 -0
- data/data/pybikes/gbfs.json +1578 -0
- data/data/pybikes/gira.json +20 -0
- data/data/pybikes/gyorbike.json +19 -0
- data/data/pybikes/hellocycling.json +248 -0
- data/data/pybikes/joco.json +22 -0
- data/data/pybikes/keolis.json +22 -0
- data/data/pybikes/kolumbus.json +18 -0
- data/data/pybikes/lastenvelo.json +30 -0
- data/data/pybikes/linkbike.json +17 -0
- data/data/pybikes/meiob.json +18 -0
- data/data/pybikes/mobhis.json +43 -0
- data/data/pybikes/movus.json +72 -0
- data/data/pybikes/nextbike.json +2443 -0
- data/data/pybikes/nextbike_gbfs.json +5 -0
- data/data/pybikes/nubija.json +18 -0
- data/data/pybikes/open_source_bike_share.json +28 -0
- data/data/pybikes/open_source_bike_share_v2.json +17 -0
- data/data/pybikes/openmove.json +20 -0
- data/data/pybikes/ov_fiets.json +20 -0
- data/data/pybikes/pedalada.json +66 -0
- data/data/pybikes/publibike.json +127 -0
- data/data/pybikes/qrobici.json +20 -0
- data/data/pybikes/roovee.json +245 -0
- data/data/pybikes/samba.json +52 -0
- data/data/pybikes/sejong.json +19 -0
- data/data/pybikes/seoulbike.json +18 -0
- data/data/pybikes/sharedmobility.json +63 -0
- data/data/pybikes/slovnaft_bajk.json +21 -0
- data/data/pybikes/smartbike.json +28 -0
- data/data/pybikes/smoove.json +26 -0
- data/data/pybikes/soltrafego.json +39 -0
- data/data/pybikes/tashu.json +20 -0
- data/data/pybikes/urbansharing.json +22 -0
- data/data/pybikes/velib.json +23 -0
- data/data/pybikes/velobike_kz.json +17 -0
- data/data/pybikes/velok.json +16 -0
- data/data/pybikes/veloway.json +20 -0
- data/data/pybikes/weelo.json +45 -0
- data/data/pybikes/wegoshare.json +141 -0
- data/data/pybikes/xian.json +19 -0
- data/data/pybikes/yelovelo.json +22 -0
- data/data/pybikes/youbike.json +173 -0
- data/data/upstream.json +7 -0
- data/lib/rubybikes/engine.rb +3 -0
- data/lib/rubybikes/native/authenticated.rb +95 -0
- data/lib/rubybikes/native/engine.rb +103 -0
- data/lib/rubybikes/native/gbfs.rb +169 -0
- data/lib/rubybikes/native/geometry.rb +53 -0
- data/lib/rubybikes/native/graphql.rb +135 -0
- data/lib/rubybikes/native/html_clients.rb +313 -0
- data/lib/rubybikes/native/json_providers.rb +174 -0
- data/lib/rubybikes/native/more_json.rb +126 -0
- data/lib/rubybikes/native/nextbike.rb +50 -0
- data/lib/rubybikes/native/provider.rb +84 -0
- data/lib/rubybikes/native/remaining_clients.rb +102 -0
- data/lib/rubybikes/native/response_cache.rb +78 -0
- data/lib/rubybikes/native/transport.rb +163 -0
- data/lib/rubybikes/native/xml_details.rb +159 -0
- data/lib/rubybikes/native.rb +30 -0
- data/lib/rubybikes/version.rb +5 -0
- data/lib/rubybikes.rb +201 -0
- data/sig/external.rbs +30 -0
- data/sig/manifest.yaml +8 -0
- data/sig/native.rbs +230 -0
- data/sig/rubybikes.rbs +143 -0
- 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
|