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,173 @@
|
|
|
1
|
+
{
|
|
2
|
+
"system": "youbike",
|
|
3
|
+
"class": {
|
|
4
|
+
"YouBikeTw": {
|
|
5
|
+
"instances":[
|
|
6
|
+
{
|
|
7
|
+
"tag": "youbike-new-taipei",
|
|
8
|
+
"uid": "05",
|
|
9
|
+
"meta": {
|
|
10
|
+
"city": "新北市 New Taipei",
|
|
11
|
+
"country": "TW",
|
|
12
|
+
"latitude": 25.0169826,
|
|
13
|
+
"longitude": 121.4627868
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"tag": "youbike-taipei",
|
|
18
|
+
"uid": "00",
|
|
19
|
+
"meta": {
|
|
20
|
+
"city": "台北市 Taipei City",
|
|
21
|
+
"country": "TW",
|
|
22
|
+
"latitude": 25.0329636,
|
|
23
|
+
"longitude": 121.5654268
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"tag": "youbike-taoyuan",
|
|
28
|
+
"uid": "07",
|
|
29
|
+
"meta": {
|
|
30
|
+
"city": "桃園市 Taoyuan City",
|
|
31
|
+
"country": "TW",
|
|
32
|
+
"latitude": 24.9936281,
|
|
33
|
+
"longitude": 121.3009798
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"tag": "youbike-taichung",
|
|
38
|
+
"uid": "01",
|
|
39
|
+
"meta": {
|
|
40
|
+
"city": "臺中市 Taichung City",
|
|
41
|
+
"country": "TW",
|
|
42
|
+
"latitude": 24.1477358,
|
|
43
|
+
"longitude": 120.6736482
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"tag": "youbike-hsinchu-county",
|
|
48
|
+
"uid": "0B",
|
|
49
|
+
"meta": {
|
|
50
|
+
"city": "新竹縣 Hsinchu County",
|
|
51
|
+
"country": "TW",
|
|
52
|
+
"latitude": 24.8269,
|
|
53
|
+
"longitude": 121.0129
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"tag": "youbike-hsinchu-city",
|
|
58
|
+
"uid": "09",
|
|
59
|
+
"meta": {
|
|
60
|
+
"city": "新竹市 Hsinchu City",
|
|
61
|
+
"country": "TW",
|
|
62
|
+
"latitude": 24.8018,
|
|
63
|
+
"longitude": 120.9714
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"tag": "youbike-hsinchu-science-park",
|
|
68
|
+
"uid": "10",
|
|
69
|
+
"meta": {
|
|
70
|
+
"city": "新竹科學工業園區 Hsinchu Science Park",
|
|
71
|
+
"country": "TW",
|
|
72
|
+
"latitude": 24.7818,
|
|
73
|
+
"longitude": 121.0050
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"tag": "youbike-miaoli-county",
|
|
78
|
+
"uid": "0A",
|
|
79
|
+
"meta": {
|
|
80
|
+
"city": "台灣 Miaoli County",
|
|
81
|
+
"country": "TW",
|
|
82
|
+
"latitude": 24.5648,
|
|
83
|
+
"longitude": 120.8185
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"tag": "youbike-chiayi-city",
|
|
88
|
+
"uid": "08",
|
|
89
|
+
"meta": {
|
|
90
|
+
"city": "台灣 Chiayi City",
|
|
91
|
+
"country": "TW",
|
|
92
|
+
"latitude": 23.4797,
|
|
93
|
+
"longitude": 120.4397
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"tag": "youbike-tainan-city",
|
|
98
|
+
"uid": "13",
|
|
99
|
+
"meta": {
|
|
100
|
+
"city": "臺南市 Tainan City",
|
|
101
|
+
"country": "TW",
|
|
102
|
+
"latitude": 22.9923,
|
|
103
|
+
"longitude": 120.1850
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"tag": "youbike-kaohsiung-city",
|
|
108
|
+
"uid": "12",
|
|
109
|
+
"meta": {
|
|
110
|
+
"city": "高雄市 Kaohsiung City",
|
|
111
|
+
"country": "TW",
|
|
112
|
+
"latitude": 22.6314,
|
|
113
|
+
"longitude": 120.3018
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"tag": "youbike-pingtung-county",
|
|
118
|
+
"uid": "14",
|
|
119
|
+
"meta": {
|
|
120
|
+
"city": "屏東縣 Pingtung County",
|
|
121
|
+
"country": "TW",
|
|
122
|
+
"latitude": 22.6830,
|
|
123
|
+
"longitude": 120.4879
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"tag": "youbike-chiayi-county",
|
|
128
|
+
"uid": "11",
|
|
129
|
+
"meta": {
|
|
130
|
+
"city": "嘉義縣 Chiayi County",
|
|
131
|
+
"country": "TW",
|
|
132
|
+
"latitude": 23.4592,
|
|
133
|
+
"longitude": 120.2927
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"tag": "youbike-taitung-county",
|
|
138
|
+
"uid": "15",
|
|
139
|
+
"meta": {
|
|
140
|
+
"city": "臺東縣 Taitung County",
|
|
141
|
+
"country": "TW",
|
|
142
|
+
"latitude": 22.7557,
|
|
143
|
+
"longitude": 121.1503
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"tag": "youbike-guangfu-township",
|
|
148
|
+
"uid": "16",
|
|
149
|
+
"meta": {
|
|
150
|
+
"city": "光復鄉 Guangfu Township",
|
|
151
|
+
"country": "TW",
|
|
152
|
+
"latitude": 23.6661,
|
|
153
|
+
"longitude": 121.4215
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
"YouBikeCn": {
|
|
159
|
+
"instances": [
|
|
160
|
+
{
|
|
161
|
+
"tag": "youbike-putian-city",
|
|
162
|
+
"meta": {
|
|
163
|
+
"city": "莆田市 (Putian City)",
|
|
164
|
+
"country": "CN",
|
|
165
|
+
"latitude": 25.4504,
|
|
166
|
+
"longitude": 119.0126
|
|
167
|
+
},
|
|
168
|
+
"feed_url": "https://apis.ptyoubike.com/api/front/station/all?lang=cn"
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
data/data/upstream.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from PyBikes authenticated clients; AGPL-3.0, original attribution in NOTICE.
|
|
4
|
+
class RubyBikes
|
|
5
|
+
module Native
|
|
6
|
+
class JsonProviders
|
|
7
|
+
def update_bicimad
|
|
8
|
+
url = 'https://openapi.emtmadrid.es/v2/transport/bicimad/stations/'
|
|
9
|
+
raw = request(url)
|
|
10
|
+
if @transport.last_response.status == 401
|
|
11
|
+
auth = json('https://openapi.emtmadrid.es/v2/mobilitylabs/user/login/', headers: { 'passkey' => @key.fetch('passkey'), 'x-clientid' => @key.fetch('clientid') })
|
|
12
|
+
@transport.headers['accesstoken'] = auth.fetch('data').first.fetch('accessToken')
|
|
13
|
+
raw = request(url, skip_cache: true)
|
|
14
|
+
end
|
|
15
|
+
JSON.parse(raw).fetch('data').each do |s|
|
|
16
|
+
online = s['activate'] == 1 && s['no_available'] == 0 # rubocop:disable Style/NumericPredicate -- JSON fields may contain non-numeric values.
|
|
17
|
+
station(s['name'], s['geometry']['coordinates'][1], s['geometry']['coordinates'][0], number(s['dock_bikes']), number(s['free_bases']),
|
|
18
|
+
{ 'number' => s['number'], 'uid' => s['id'], 'address' => s['address'], 'online' => online,
|
|
19
|
+
'slots' => number(s['total_bases']), 'light' => %w[green red yellow gray].fetch(number(s['light'])) })
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update_bikeshare_ie
|
|
24
|
+
headers = {
|
|
25
|
+
'Authorization' => "Basic #{@key}",
|
|
26
|
+
'User-Agent' => 'Dalvik/2.1.0 (Linux; U; Android 11; ROCINANTE FIRE Build/9001'
|
|
27
|
+
}
|
|
28
|
+
json('https://apps.bikeshare.ie/rbs/resources/appapi/station/data/list',
|
|
29
|
+
method: 'POST', data: { 'schemeId' => p('system_id').to_s }, headers: headers).fetch('data').each do |s|
|
|
30
|
+
station(s['name'], s['latitude'], s['longitude'], number(s['bikesAvailable']), number(s['docksAvailable']),
|
|
31
|
+
{ 'uid' => s['stationId'], 'slots' => number(s['docksCount']), 'name_ie' => s['nameIrish'] })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update_weelo
|
|
36
|
+
@transport.headers['User-Agent'] = 'okhttp/4.9.1'
|
|
37
|
+
Array(p('city_ids')).each do |city|
|
|
38
|
+
url = 'https://api.weelo.it/resources/stations'
|
|
39
|
+
params = { 'idCity' => city, 'serviceType' => 1 }
|
|
40
|
+
raw = request(url, params: params)
|
|
41
|
+
if @transport.last_response.status == 401
|
|
42
|
+
token = json('https://api.weelo.it/connect/token', method: 'POST', data: @key.merge('grant_type' => 'client_credentials')).fetch('access_token')
|
|
43
|
+
@transport.headers['Authorization'] = "Bearer #{token}"
|
|
44
|
+
raw = request(url, params: params, skip_cache: true)
|
|
45
|
+
end
|
|
46
|
+
JSON.parse(raw).each do |s|
|
|
47
|
+
normal, electric = s.fetch('countMuscularBikesAvailable'), s.fetch('countAssistedBikesAvailable')
|
|
48
|
+
station(s['name'], s['latitude'], s['longitude'], normal + electric, s['countFreePlacesAvailable'],
|
|
49
|
+
{ 'uid' => s['idStation'], 'address' => s['address'], 'has_ebikes' => true, 'ebikes' => electric,
|
|
50
|
+
'normal_bikes' => normal, 'status' => s['state'], 'slots' => s['totalPlaces'] })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def update_nubija
|
|
56
|
+
json('https://ar.nubija.com:55789/v1/station/list/user', headers: { 'app-token' => @key[8...-8], 'User-Agent' => 'okhttp/3.14.9' }).fetch('station').each do |s|
|
|
57
|
+
next unless s['area'] == p('area')
|
|
58
|
+
|
|
59
|
+
station(s['station_cd'], s['x_pos'], s['y_pos'], 0, number(s['parking_count']),
|
|
60
|
+
{ 'uid' => s['id'], 'number' => number(s['tmno']), 'address' => s['address'] })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update_bicicard
|
|
65
|
+
headers = { 'User-Agent' => 'Dalvik/2.1.0 (Linux; U; Android 11; ROCINANTE FIRE Build/9001', 'DeviceModel' => 'ROCINANTE FIRE' }
|
|
66
|
+
cert = lower_keys(json("#{p('endpoint')}/api/certificado", headers: headers))
|
|
67
|
+
headers['Thumbprint'] = cert.fetch('thumbprint')
|
|
68
|
+
lower_keys(json("#{p('endpoint')}/apiapp/SBancada/Estado/TodosSimple", headers: headers, skip_cache: true)).each do |s|
|
|
69
|
+
normal, electric = s.fetch('numerobicicletasnormales'), s.fetch('numerobicicletaselectricas')
|
|
70
|
+
station(s['nombre'], s['latitud'], s['longitud'], normal + electric, s['numerocandadoslibres'],
|
|
71
|
+
{ 'uid' => s['id'], 'normal_bikes' => normal, 'ebikes' => electric, 'slots' => s['numerocandados'], 'online' => s['ispuestoonline'] })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def update_ov_fiets
|
|
76
|
+
params = { 'minLat' => 50.2876, 'minLng' => 2.7109, 'maxLat' => 53.8857, 'maxLng' => 7.9541, 'zoomLevel' => 9, 'limit' => 500,
|
|
77
|
+
'filterTypes' => ['OV_FIETS'], 'appVersionCapabilities' => %w[DLF-Tier DLS-Check DLA-GreenWheels], 'enableSubfilters' => true }
|
|
78
|
+
json('https://gateway.apiportal.ns.nl/places-api/v2/nearbyme', params: params,
|
|
79
|
+
headers: { 'X-MmVehicleExtra' => 'false',
|
|
80
|
+
'Ocp-Apim-Subscription-Key' => @key }).fetch('locations').each do |s|
|
|
81
|
+
bikes = s['description']['subDescription'][/\d+/].to_i
|
|
82
|
+
station(s['description']['full'], s['lat'], s['lng'], bikes, nil, { 'uid' => s['id']['value'][/.*-(\w+)\s*$/, 1] })
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def lower_keys(value)
|
|
87
|
+
case value
|
|
88
|
+
when Hash then value.to_h { |key, item| [key.downcase, lower_keys(item)] }
|
|
89
|
+
when Array then value.map { |item| lower_keys(item) }
|
|
90
|
+
else value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RubyBikes
|
|
4
|
+
module Native
|
|
5
|
+
class Engine
|
|
6
|
+
MANIFEST = JSON.parse(File.read(File.join(DATA, 'upstream.json'))).freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :timeout
|
|
9
|
+
|
|
10
|
+
def initialize(timeout: 60)
|
|
11
|
+
@timeout = Float(timeout)
|
|
12
|
+
raise ArgumentError, 'timeout must be positive' unless @timeout.positive? && @timeout.finite?
|
|
13
|
+
|
|
14
|
+
@providers = JSON.parse(File.read(File.join(DATA, 'providers.json')))
|
|
15
|
+
@definitions = {}
|
|
16
|
+
Dir[File.join(DATA, 'pybikes/*.json')].each do |file|
|
|
17
|
+
schema = JSON.parse(File.read(file))
|
|
18
|
+
classes = schema['class'].is_a?(Hash) ? schema['class'] : { schema['class'] => schema }
|
|
19
|
+
classes.each do |name, group|
|
|
20
|
+
catalog_instances(group['instances']).each do |params|
|
|
21
|
+
@definitions[params.fetch('tag')] = { 'module' => schema.fetch('system'), 'class' => name, 'parameters' => params }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@cooldowns = {}
|
|
27
|
+
@systems = {}
|
|
28
|
+
@response_cache = ResponseCache.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request(operation, timeout: @timeout, **arguments)
|
|
32
|
+
result = @mutex.synchronize do
|
|
33
|
+
case operation
|
|
34
|
+
when 'version'
|
|
35
|
+
{ 'implementation' => 'ruby', 'version' => RubyBikes::VERSION, 'revision' => MANIFEST.fetch('revision'), 'protocol' => 1 }
|
|
36
|
+
when 'providers'
|
|
37
|
+
{ 'providers' => @providers.map { |spec| spec.except('meta', 'gbfs', 'required_parameters') } }
|
|
38
|
+
when 'catalog'
|
|
39
|
+
networks = @definitions.map do |tag, definition|
|
|
40
|
+
spec = find_spec(definition)
|
|
41
|
+
{ 'tag' => tag, 'module' => spec['module'], 'class' => spec['class'], 'label' => spec['label'],
|
|
42
|
+
'authed' => spec['authed'], 'sync' => spec['sync'], 'unifeed' => spec['unifeed'],
|
|
43
|
+
'endpoint_host' => URI(definition['parameters']['feed_url'] || definition['parameters']['endpoint'] || '').host,
|
|
44
|
+
'meta' => spec.fetch('meta').merge(definition['parameters'].fetch('meta')) }
|
|
45
|
+
end
|
|
46
|
+
{ 'networks' => networks.sort_by { |row| row['tag'] } }
|
|
47
|
+
when 'describe', 'update'
|
|
48
|
+
definition = arguments[:definition] || @definitions[arguments.fetch(:tag)]
|
|
49
|
+
raise NoSystemFoundError, 'Unknown system tag' unless definition
|
|
50
|
+
|
|
51
|
+
spec = find_spec(definition)
|
|
52
|
+
params = definition.fetch('parameters').merge('tag' => arguments.fetch(:tag))
|
|
53
|
+
missing = spec.fetch('required_parameters').reject { |name| params.key?(name) }
|
|
54
|
+
raise ProviderError, "Missing provider parameters: #{missing.join(', ')}" unless missing.empty?
|
|
55
|
+
|
|
56
|
+
klass = if spec['gbfs']
|
|
57
|
+
Gbfs
|
|
58
|
+
elsif %w[Nextbike Bizkaibizi].include?(spec['class'])
|
|
59
|
+
Nextbike
|
|
60
|
+
else
|
|
61
|
+
JsonProviders
|
|
62
|
+
end
|
|
63
|
+
key = JSON.parse(JSON.generate(arguments[:key]))
|
|
64
|
+
identity = JSON.generate([arguments.fetch(:tag), key, definition])
|
|
65
|
+
provider = @systems[identity] ||= klass.new(spec, params, key: key)
|
|
66
|
+
if operation == 'describe'
|
|
67
|
+
{ 'tag' => provider.tag, 'meta' => provider.meta, 'module' => spec['module'], 'class' => spec['class'],
|
|
68
|
+
'authed' => spec['authed'], 'sync' => spec['sync'], 'unifeed' => spec['unifeed'] }
|
|
69
|
+
else
|
|
70
|
+
options = JSON.parse(JSON.generate(arguments[:scraper] || {})).transform_keys(&:to_sym)
|
|
71
|
+
cooldowns = @cooldowns.merge(options.delete(:cooldowns) || {})
|
|
72
|
+
begin
|
|
73
|
+
cache = spec['unifeed'] && !spec['authed'] ? @response_cache : nil
|
|
74
|
+
transport = Transport.new(timeout: timeout, cooldowns: cooldowns, cache: cache, **options)
|
|
75
|
+
transport.within_deadline { provider.snapshot(transport) }
|
|
76
|
+
ensure
|
|
77
|
+
@cooldowns.merge!(cooldowns)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
raise ArgumentError, 'Unknown engine operation'
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
JSON.parse(JSON.generate(result))
|
|
85
|
+
rescue Error
|
|
86
|
+
raise
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
raise ProviderError, "Provider update failed (#{e.class.name})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close; end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def catalog_instances(value) = Array(value)
|
|
96
|
+
|
|
97
|
+
def find_spec(definition)
|
|
98
|
+
@providers.find { |spec| spec['module'] == definition['module'] && spec['class'] == definition['class'] } ||
|
|
99
|
+
raise(NoSystemFoundError, 'Unknown provider class')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from PyBikes gbfs.py, Copyright eskerda and contributors, AGPL-3.0.
|
|
4
|
+
class RubyBikes
|
|
5
|
+
module Native
|
|
6
|
+
class Gbfs < Provider
|
|
7
|
+
def initialize(spec, parameters, key: nil)
|
|
8
|
+
super
|
|
9
|
+
@feed_url = case spec.fetch('class')
|
|
10
|
+
when 'BCycle'
|
|
11
|
+
@meta['company'] = @parameters['meta']['company'] + spec.fetch('meta').fetch('company') if @parameters.fetch('meta').key?('company')
|
|
12
|
+
"https://gbfs.bcycle.com/#{@parameters.fetch('uid')}/gbfs.json"
|
|
13
|
+
when 'Cyclocity' then "https://api.cyclocity.fr/contracts/#{@parameters.fetch('contract').downcase}/gbfs/gbfs.json"
|
|
14
|
+
when 'Ecovelo' then "https://api.gbfs.ecovelo.mobi/#{@parameters.fetch('dataset')}/gbfs.json"
|
|
15
|
+
when 'Careem' then 'https://dubai.publicbikesystem.net/customer/gbfs/v2/en/gbfs.json'
|
|
16
|
+
when 'Ambici' then 'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/gbfs.json'
|
|
17
|
+
when 'NextbikeGbfs' then "https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_#{@parameters.fetch('domain')}/gbfs.json"
|
|
18
|
+
when 'DB', 'Callabike'
|
|
19
|
+
domain = spec['class'] == 'Callabike' ? 'CallABike' : @parameters.fetch('provider')
|
|
20
|
+
"https://apis.deutschebahn.com/db-api-marketplace/apis/shared-mobility-gbfs/v2/de/#{domain}/gbfs"
|
|
21
|
+
when 'HelloCycling', 'Docomo'
|
|
22
|
+
name = spec['class'] == 'Docomo' ? 'docomo-cycle' : 'hellocycling'
|
|
23
|
+
features = JSON.parse(File.read(File.join(Native::DATA, 'geojson/japan_prefectures.json'))).fetch('features')
|
|
24
|
+
feature = features.find { |item| item['properties']['id'] == @parameters.fetch('uid') }
|
|
25
|
+
raise ArgumentError, 'Unknown Japanese prefecture' unless feature
|
|
26
|
+
|
|
27
|
+
@parameters['bbox'] = feature.fetch('geometry')
|
|
28
|
+
"https://api-public.odpt.org/api/v4/gbfs/#{name}/gbfs.json"
|
|
29
|
+
else @parameters.fetch('feed_url')
|
|
30
|
+
end
|
|
31
|
+
@meta['gbfs_href'] = @feed_url
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update
|
|
35
|
+
case spec.fetch('class')
|
|
36
|
+
when 'DB', 'Callabike'
|
|
37
|
+
@transport.headers.merge!('DB-Client-Id' => @key.fetch('client_id'), 'DB-Api-Key' => @key.fetch('client_secret'), 'Accept' => 'application/json')
|
|
38
|
+
when 'SharedMobility'
|
|
39
|
+
@transport.headers['Authorization'] = @key
|
|
40
|
+
end
|
|
41
|
+
args = p('append_feed_args_to_req') ? URI.decode_www_form(URI(@feed_url).query || '') : nil
|
|
42
|
+
feeds = discover_feeds(args)
|
|
43
|
+
information = json(feeds.fetch('station_information'), params: args).fetch('data').fetch('stations')
|
|
44
|
+
statuses = json(feeds.fetch('station_status'), params: args).fetch('data').fetch('stations').to_h { |item| [item.fetch('station_id'), item] }
|
|
45
|
+
# @type var types: Hash[String, untyped]
|
|
46
|
+
types = if feeds['vehicle_types']
|
|
47
|
+
json(feeds['vehicle_types'], params: args).fetch('data').fetch('vehicle_types', list(nil)).to_h { |item| [item.fetch('vehicle_type_id'), item] }
|
|
48
|
+
else
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
information.to_h { |item| [item.fetch('station_id'), item] }.each_value do |item|
|
|
52
|
+
next unless statuses.key?(item['station_id'])
|
|
53
|
+
next if p('region_id') && item['region_id'] != p('region_id')
|
|
54
|
+
|
|
55
|
+
item = item.merge(statuses.fetch(item['station_id']).compact)
|
|
56
|
+
next unless truthy?(item.fetch('is_installed'))
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
parse_gbfs_station(item, types)
|
|
60
|
+
rescue RubyBikes::Error
|
|
61
|
+
raise
|
|
62
|
+
rescue StandardError
|
|
63
|
+
raise unless p('ignore_errors')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
vehicles_feed = feeds['free_bike_status'] || feeds['vehicle_status']
|
|
67
|
+
return unless vehicles_feed
|
|
68
|
+
|
|
69
|
+
data = json(vehicles_feed, params: args).fetch('data')
|
|
70
|
+
list(data['bikes'] || data['vehicles']).each do |item|
|
|
71
|
+
next if item.key?('station_id')
|
|
72
|
+
|
|
73
|
+
kind = if item['vehicle_type_id']
|
|
74
|
+
type = types[item['vehicle_type_id']]
|
|
75
|
+
next unless type
|
|
76
|
+
|
|
77
|
+
vehicle_kind(type).last
|
|
78
|
+
else
|
|
79
|
+
'bike'
|
|
80
|
+
end
|
|
81
|
+
next unless kind
|
|
82
|
+
|
|
83
|
+
kind = { 'electric_bike' => 'ebike', 'electric_scooter' => 'scooter' }.fetch(item['type'], kind)
|
|
84
|
+
extra = { 'uid' => item['bike_id'] || item['vehicle_id'], 'online' => !truthy?(item.fetch('is_disabled')) }
|
|
85
|
+
extra['battery'] = Float(item['current_fuel_percent']) if item.key?('current_fuel_percent')
|
|
86
|
+
vehicle(item.fetch('lat'), item.fetch('lon'), extra, kind)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def vehicle_kind(type)
|
|
91
|
+
form, power = type.values_at('form_factor', 'propulsion_type')
|
|
92
|
+
return %w[scooters scooter] if form.is_a?(String) && form.include?('scooter') && power == 'electric'
|
|
93
|
+
return %w[cargo bike] if form == 'cargo_bicycle' && power == 'human'
|
|
94
|
+
return %w[ecargo ebike] if form == 'cargo_bicycle' && power == 'electric_assist'
|
|
95
|
+
return %w[normal_bikes bike] if form == 'bicycle' && power == 'human'
|
|
96
|
+
return %w[ebikes ebike] if form == 'bicycle' && %w[electric_assist electric].include?(power)
|
|
97
|
+
|
|
98
|
+
[nil, nil]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def discover_feeds(args)
|
|
104
|
+
feeds = %w[station_information station_status vehicle_types free_bike_status vehicle_status].to_h { |name| [name, p(name)] }.compact
|
|
105
|
+
if feeds.empty?
|
|
106
|
+
raw = request(@feed_url, params: args)
|
|
107
|
+
if @transport.last_response.status >= 400
|
|
108
|
+
raise ProviderError, 'Provider authorization failed' if @transport.last_response.status == 401
|
|
109
|
+
|
|
110
|
+
feeds = %w[station_information station_status].to_h { |name| [name, URI.join(@feed_url, "#{name}.json").to_s] }
|
|
111
|
+
else
|
|
112
|
+
data = JSON.parse(raw).fetch('data')
|
|
113
|
+
data = data['en'] || data.values.last if data.is_a?(Hash) && !data.key?('feeds')
|
|
114
|
+
data = data.fetch('feeds') if data.is_a?(Hash)
|
|
115
|
+
feeds = data.to_h { |feed| [feed.fetch('name'), feed.fetch('url')] }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
feeds.transform_values { |url| p('force_https') ? url.sub(/^http:/, 'https:') : url }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_gbfs_station(item, types)
|
|
122
|
+
name = item['name'].is_a?(Array) ? item['name'].first.fetch('text') : item['name']
|
|
123
|
+
bikes = integer(item.key?('num_bikes_available') ? item['num_bikes_available'] : item['num_vehicles_available'])
|
|
124
|
+
extra = { 'uid' => item['station_id'], 'renting' => item.fetch('is_renting'), 'returning' => item.fetch('is_returning'),
|
|
125
|
+
'virtual' => item.fetch('is_virtual_station', false) }
|
|
126
|
+
extra['last_updated'] = iso_date(item['last_reported']) if item.key?('last_reported')
|
|
127
|
+
%w[address post_code altitude].each { |field| extra[field] = item[field] if item.key?(field) }
|
|
128
|
+
extra['rental_uris'] = item['rental_uris'].slice('android', 'ios', 'web') if item['rental_uris'].is_a?(Hash)
|
|
129
|
+
extra['slots'] = item['capacity'] if item.key?('capacity')
|
|
130
|
+
if item.key?('rental_methods')
|
|
131
|
+
extra['payment'] = item['rental_methods'].map(&:downcase)
|
|
132
|
+
extra['payment-terminal'] = extra['payment'].include?('creditcard')
|
|
133
|
+
end
|
|
134
|
+
extra.merge!('has_ebikes' => true, 'ebikes' => integer(item['num_ebikes_available'])) if item.key?('num_ebikes_available')
|
|
135
|
+
available_types = item['num_bikes_available_types']
|
|
136
|
+
if available_types.is_a?(Hash) && available_types.key?('ebike')
|
|
137
|
+
extra.merge!('has_ebikes' => true, 'ebikes' => integer(available_types['ebike']), 'normal_bikes' => integer(available_types.fetch('mechanical')))
|
|
138
|
+
end
|
|
139
|
+
list(item['vehicle_types_available']).each do |available|
|
|
140
|
+
type = types[available['vehicle_type_id']]
|
|
141
|
+
next unless type
|
|
142
|
+
|
|
143
|
+
field, = vehicle_kind(type)
|
|
144
|
+
next unless field
|
|
145
|
+
|
|
146
|
+
extra[field] = extra.fetch(field, 0) + available.fetch('count')
|
|
147
|
+
extra["has_#{field}"] = true unless field == 'normal_bikes'
|
|
148
|
+
end
|
|
149
|
+
bikes = extra.fetch('normal_bikes', 0) + extra.fetch('ebikes', 0) if item.key?('vehicle_types_available') && (!bikes || bikes.zero?)
|
|
150
|
+
case spec['class']
|
|
151
|
+
when 'Velib'
|
|
152
|
+
extra['uid'] = item.fetch('stationCode')
|
|
153
|
+
extra['station_id'] = item.fetch('station_id')
|
|
154
|
+
extra['banking'] = extra.fetch('payment', []).include?('creditcard')
|
|
155
|
+
extra['payment-terminal'] = extra['banking']
|
|
156
|
+
list(available_types).each do |type|
|
|
157
|
+
next unless type.is_a?(Hash)
|
|
158
|
+
|
|
159
|
+
extra['ebikes'] = number(type['ebike']) if type.key?('ebike')
|
|
160
|
+
extra['normal_bikes'] = number(type['mechanical']) if type.key?('mechanical')
|
|
161
|
+
end
|
|
162
|
+
when 'BCycle'
|
|
163
|
+
extra.merge!('has_ebikes' => true, 'ebikes' => number(available_types.fetch('electric')), 'normal_bikes' => number(available_types.fetch('classic')))
|
|
164
|
+
end
|
|
165
|
+
station(name, item.fetch('lat'), item.fetch('lon'), bikes, integer(item['num_docks_available']), extra)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RubyBikes
|
|
4
|
+
module Native
|
|
5
|
+
module Geometry
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def contains?(bounds, latitude, longitude)
|
|
9
|
+
return true unless bounds && !bounds.empty?
|
|
10
|
+
|
|
11
|
+
if bounds.is_a?(Array)
|
|
12
|
+
unless bounds.length == 4 || (bounds.length == 2 && bounds.all? { |point| point.is_a?(Array) && point.length == 2 })
|
|
13
|
+
raise ArgumentError, 'Bounds must contain four coordinates or two latitude/longitude pairs'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
west, south, east, north = bounds.length == 4 ? bounds : [bounds[1][1], bounds[1][0], bounds[0][1], bounds[0][0]]
|
|
17
|
+
horizontal = [Float(west, exception: true), Float(east, exception: true)].sort
|
|
18
|
+
vertical = [Float(south, exception: true), Float(north, exception: true)].sort
|
|
19
|
+
west, east = horizontal.fetch(0), horizontal.fetch(1)
|
|
20
|
+
south, north = vertical.fetch(0), vertical.fetch(1)
|
|
21
|
+
return west < longitude && longitude < east && south < latitude && latitude < north
|
|
22
|
+
end
|
|
23
|
+
geometry = bounds['geometry'] || bounds
|
|
24
|
+
case geometry.fetch('type')
|
|
25
|
+
when 'FeatureCollection'
|
|
26
|
+
geometry.fetch('features').any? { |feature| contains?(feature, latitude, longitude) }
|
|
27
|
+
when 'Polygon'
|
|
28
|
+
polygon?(geometry.fetch('coordinates'), longitude, latitude)
|
|
29
|
+
when 'MultiPolygon'
|
|
30
|
+
geometry.fetch('coordinates').any? { |polygon| polygon?(polygon, longitude, latitude) }
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, 'Unsupported geographic bounds'
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def polygon?(rings, x, y)
|
|
37
|
+
ring(rings.first, x, y) == :inside && rings.drop(1).all? { |hole| ring(hole, x, y) == :outside }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ring(points, x, y)
|
|
41
|
+
inside = false
|
|
42
|
+
points.each_with_index do |(ax, ay), index|
|
|
43
|
+
bx, by = points[(index + 1) % points.size]
|
|
44
|
+
cross = ((x - ax) * (by - ay)) - ((y - ay) * (bx - ax))
|
|
45
|
+
return :boundary if cross.zero? && x.between?(*[ax, bx].minmax) && y.between?(*[ay, by].minmax)
|
|
46
|
+
|
|
47
|
+
inside = !inside if (ay > y) != (by > y) && x < ((bx - ax) * (y - ay) / (by - ay).to_f) + ax
|
|
48
|
+
end
|
|
49
|
+
inside ? :inside : :outside
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|