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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Native adaptations of PyBikes clients, AGPL-3.0; see NOTICE for attribution.
|
|
4
|
+
class RubyBikes
|
|
5
|
+
module Native
|
|
6
|
+
class JsonProviders
|
|
7
|
+
def update_baksi
|
|
8
|
+
JSON.parse(html_capture(request(p('feed_url')), /var all = (\[.*\]);/)).each do |s|
|
|
9
|
+
uid, name = s[0].split('-', 2)
|
|
10
|
+
station(name, s[5], s[6], number(s[2][/\d+/]), number(s[3][/\d+/]), { 'uid' => uid, 'online' => s[1].include?('Aktif') })
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update_chartered_bike
|
|
15
|
+
data = json('https://api.charteredbike.in/api/v1/stations/get-all-station',
|
|
16
|
+
params: { 'lat' => 21.1940305, 'long' => 72.825347, 'version' => '1.9.2', 'buildNumber' => 283 }, headers: { 'User-Agent' => 'okhttp/3.12.1',
|
|
17
|
+
'Accept' => 'application/json, text/plain, */*' })
|
|
18
|
+
raise ProviderError, 'Provider returned unsuccessful status' unless data['status'] == 200
|
|
19
|
+
|
|
20
|
+
data.fetch('data').each do |s|
|
|
21
|
+
station(s['stationName'], s['latitude'], s['longitude'], number(s['bikesAvailable'] || 0), number(s['freeRacks']),
|
|
22
|
+
{ 'uid' => s['stationId'], 'ebikes' => number(s['ebikesAvailable'] || 0), 'number' => number(s['stationNumber']), 'online' => s['active'] })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update_clujbike
|
|
27
|
+
JSON.parse(html_capture(request(p('feed_url')), /var stations = (\[.*?\]);/)).each do |s|
|
|
28
|
+
station(s['StationName'], s['Latitude'], s['Longitude'], number(s['OcuppiedSpots']), number(s['EmptyDoors']),
|
|
29
|
+
{ 'address' => s['Address'], 'status' => s['Status'] == 'Offline' ? 'offline' : 'online' })
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update_cykleo
|
|
34
|
+
json('https://portail.cykleo.fr/pu/stations/availability', params: { 'organization_id' => p('organization') },
|
|
35
|
+
headers: { 'Referer' => 'https://portail.cykleo.fr/',
|
|
36
|
+
'Content-Type' => 'application/json; charset=utf-8' }).each do |s|
|
|
37
|
+
info = s['station']['assetStation']
|
|
38
|
+
normal, electric = s.fetch('availableClassicBikeCount'), s.fetch('availableElectricBikeCount')
|
|
39
|
+
station(info['commercialName'], info['coordinate']['y'], info['coordinate']['x'], normal + electric, s['availableDockCount'],
|
|
40
|
+
{ 'uid' => s['id'], 'number' => info['commercialNumber'], 'ebikes' => electric, 'normal_bikes' => normal,
|
|
41
|
+
'online' => s['station']['status'] == 'IN_SERVICE', 'status' => s['station']['status'] })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_easybike
|
|
46
|
+
if spec['class'] == 'EasyBikeNew'
|
|
47
|
+
data = json("https://#{p('city_uid')}.easybike.gr:8001/imet/v2/availability", headers: { 'Authorization' => 'Basic aW1ldDIwMTkwNTAxOkomZ2UjdXBQbUBNbT81aGI0eTZZ' })
|
|
48
|
+
data.fetch('stations').each { |s| station(s['label'], s['lat'], s['lng'], number(s['available']), number(s['spaces']), { 'uid' => s['station_id'] }) }
|
|
49
|
+
else
|
|
50
|
+
stations = json("http://reseller.easybike.gr/#{p('city_uid')}/api.php").fetch('stations')
|
|
51
|
+
raise ProviderError, 'Provider currently publishes no station feed' if stations.nil?
|
|
52
|
+
|
|
53
|
+
stations.each do |s|
|
|
54
|
+
station(s['description'], s['lat'], s['lng'], number(s['free_bikes']), number(s['free_spaces']), { 'slots' => number(s['total_spaces']) })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_open_source_bike_share
|
|
60
|
+
json(p('feed_url')).each do |s|
|
|
61
|
+
bikes = truthy?(s['bikecount']) ? s['bikecount'] : s.fetch('bikeCount')
|
|
62
|
+
station(s['standName'], s['lat'] || s['latitude'], s['lon'] || s['longitude'], number(bikes), integer(s['slotcount']),
|
|
63
|
+
{ 'uid' => number(s['standId']), 'photo' => s['standPhoto'], 'description' => s['standDescription'] })
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_smoove
|
|
68
|
+
json(p('feed_url')).fetch('response').each do |s|
|
|
69
|
+
station(s['name'], s['coordinates'][0], s['coordinates'][1], number(s['avl_bikes']), number(s['free_slots']), { 'uid' => s['nid'], 'slots' => number(s['total_slots']) })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def update_veloway
|
|
74
|
+
json(p('feed_url')).fetch('stand').each do |s|
|
|
75
|
+
lat, lon = Float(s['lat']), Float(s['lng'])
|
|
76
|
+
next if lat.zero? && lon.zero?
|
|
77
|
+
|
|
78
|
+
# @type var extra: RubyBikes::object
|
|
79
|
+
extra = { 'uid' => number(s['id']), 'slots' => number(s['tc']), 'slots_available' => number(s['ac']), 'online' => number(s['disp']) == 1 }
|
|
80
|
+
extra['address'] = URI.decode_www_form_component(s['wcom'].to_s) if truthy?(s['wcom'])
|
|
81
|
+
station(URI.decode_www_form_component(s['name'].to_s), lat, lon, number(s['ab']), number(s['ap']), extra)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_wegoshare
|
|
86
|
+
json("#{p('endpoint')}/cxf/am/station/map/search", method: 'POST', data: JSON.generate('isPublic' => true, 'limit' => -1),
|
|
87
|
+
headers: { 'Content-Type' => 'application/json; charset=utf-8' }).fetch('results').each do |s|
|
|
88
|
+
next if s['stationStatus'] == 'PLANNED'
|
|
89
|
+
|
|
90
|
+
station(s['name'], s['areaCentroid']['latitude'], s['areaCentroid']['longitude'], number(s['totalLockedCycleCount']), number(s['freeDocksCount']),
|
|
91
|
+
{ 'uid' => s['id'], 'description' => s['description'], 'address' => s['address'], 'online' => s['stationStatus'] == 'OPEN',
|
|
92
|
+
'last_update' => s['lastUpdateCycleCountAt'],
|
|
93
|
+
'photo' => s.key?('photoId') ? "#{p('endpoint')}/cxf/fm/files/#{s['photoId']}" : nil })
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def update_yelovelo
|
|
98
|
+
json('https://opendata.agglo-larochelle.fr/d4c/api/records/2.0/downloadfile/format=json&resource_id=1f124bea-d55f-457f-9eab-b7877d803435').each do |item|
|
|
99
|
+
s = item.fetch('fields')
|
|
100
|
+
station(s['station_nom'], s['station_latitude'], s['station_longitude'], number(s['velos_disponibles']), number(s['accroches_libres']),
|
|
101
|
+
{ 'slots' => s['nombre_emplacements'] })
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def update_youbike
|
|
106
|
+
taiwan = spec['class'] == 'YouBikeTw'
|
|
107
|
+
data = taiwan ? json('https://apis.youbike.com.tw/json/station-yb2.json') : json(p('feed_url')).fetch('retVal')
|
|
108
|
+
data.each do |s|
|
|
109
|
+
next if taiwan && s['area_code'] != p('uid')
|
|
110
|
+
next if s['lat'] == '' || s['lng'] == ''
|
|
111
|
+
|
|
112
|
+
language = taiwan ? 'tw' : 'cn'
|
|
113
|
+
# @type var extra: RubyBikes::object
|
|
114
|
+
extra = { 'uid' => s['station_no'], 'district' => s["district_#{language}"], 'address' => s["address_#{language}"], 'slots' => s['parking_spaces'],
|
|
115
|
+
'last_updated' => s['updated_at'], 'online' => s['status'] == 1, 'youbike_version' => s['type'],
|
|
116
|
+
'en' => { 'name' => s['name_en'], 'district' => s['district_en'], 'address' => s['address_en'] } }
|
|
117
|
+
if taiwan
|
|
118
|
+
extra['normal_bikes'] = s['available_spaces_detail']['yb1'] + s['available_spaces_detail']['yb2']
|
|
119
|
+
extra['ebikes'] = s['available_spaces_detail']['eyb']
|
|
120
|
+
end
|
|
121
|
+
station(s["name_#{language}"], s['lat'], s['lng'], number(s['available_spaces']), number(s['empty_spaces']), extra)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from PyBikes nextbike.py, Copyright eskerda and contributors, AGPL-3.0.
|
|
4
|
+
class RubyBikes
|
|
5
|
+
module Native
|
|
6
|
+
class Nextbike < Provider
|
|
7
|
+
def initialize(spec, parameters, key: nil)
|
|
8
|
+
super
|
|
9
|
+
@parameters.fetch('domain')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update
|
|
13
|
+
url = "https://#{p('hostname', 'maps.nextbike.net')}/maps/nextbike-live.xml"
|
|
14
|
+
document = Nokogiri::XML(request(url, params: { 'domains' => p('domain') })) { |config| config.strict.nonet }
|
|
15
|
+
places = document.xpath('/markers/country//city//place')
|
|
16
|
+
places = places.select { |place| place.ancestors('city').first['uid'] == p('city_uid').to_s } if p('city_uid')
|
|
17
|
+
raise ProviderError, 'Provider returned no places for this network' if places.empty?
|
|
18
|
+
|
|
19
|
+
places.each do |place|
|
|
20
|
+
latitude, longitude = Float(place['lat']), Float(place['lng'])
|
|
21
|
+
next if p('bbox') && !Geometry.contains?(p('bbox'), latitude, longitude)
|
|
22
|
+
|
|
23
|
+
if place['spot'] == '1'
|
|
24
|
+
extra = { 'uid' => place['uid'], 'virtual' => place['place_type'] == '17' }
|
|
25
|
+
extra['number'] = place['number'] if place['number']
|
|
26
|
+
bike_types = place['bike_types']
|
|
27
|
+
counts = if bike_types && !bike_types.strip.empty?
|
|
28
|
+
JSON.parse(bike_types).values
|
|
29
|
+
else
|
|
30
|
+
[place['bikes']]
|
|
31
|
+
end
|
|
32
|
+
extra['bikes_approximate'] = true if counts.any? { |count| count.to_s.end_with?('+') }
|
|
33
|
+
bikes = counts.sum { |count| number(count.to_s.delete_suffix('+')) }
|
|
34
|
+
free = nil
|
|
35
|
+
if place['bike_racks']
|
|
36
|
+
extra['slots'] = number(place['bike_racks'])
|
|
37
|
+
free = place['free_racks'] ? number(place['free_racks']) : extra['slots'] - bikes
|
|
38
|
+
end
|
|
39
|
+
extra['bike_uids'] = place['bike_numbers'].split(',').reject(&:empty?) if place['bike_numbers']
|
|
40
|
+
station(place['name'], latitude, longitude, bikes, free, extra)
|
|
41
|
+
end
|
|
42
|
+
if place['bike'] == '1'
|
|
43
|
+
bike = place.at_xpath('./bike')
|
|
44
|
+
vehicle(latitude, longitude, { 'uid' => bike['number'], 'online' => bike['active'] == '1' })
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/md5'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'csv'
|
|
6
|
+
require 'nokogiri'
|
|
7
|
+
|
|
8
|
+
class RubyBikes
|
|
9
|
+
module Native
|
|
10
|
+
class Provider
|
|
11
|
+
attr_reader :tag, :meta, :spec, :parameters
|
|
12
|
+
|
|
13
|
+
def initialize(spec, parameters, key: nil)
|
|
14
|
+
@spec = spec
|
|
15
|
+
@parameters = Marshal.load(Marshal.dump(parameters))
|
|
16
|
+
@tag = @parameters.fetch('tag')
|
|
17
|
+
defaults = Marshal.load(Marshal.dump(spec.fetch('meta')))
|
|
18
|
+
@meta = { 'name' => nil, 'city' => nil, 'country' => nil, 'latitude' => nil, 'longitude' => nil, 'company' => nil }.merge(defaults).merge(@parameters.fetch('meta'))
|
|
19
|
+
@meta['name'] ||= @meta['system'] || spec.fetch('class')
|
|
20
|
+
@key = key
|
|
21
|
+
raise APIKeyNotAvailableError, 'This provider requires an API key' if spec['authed'] && !truthy?(key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def snapshot(transport)
|
|
25
|
+
@transport = transport
|
|
26
|
+
@stations, @vehicles = [], []
|
|
27
|
+
update
|
|
28
|
+
{ 'tag' => tag, 'meta' => meta, 'stations' => @stations, 'vehicles' => @vehicles }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def station(name, latitude, longitude, bikes = nil, free = nil, extra = {})
|
|
32
|
+
latitude, longitude = Float(latitude), Float(longitude)
|
|
33
|
+
bounds = parameters['bbox']
|
|
34
|
+
return if bounds && !Geometry.contains?(bounds, latitude, longitude)
|
|
35
|
+
|
|
36
|
+
id = Digest::MD5.hexdigest("#{(latitude * 1e6).to_i},#{(longitude * 1e6).to_i}")
|
|
37
|
+
@stations << { 'id' => id, 'name' => name, 'latitude' => latitude, 'longitude' => longitude,
|
|
38
|
+
'bikes' => bikes, 'free' => free, 'extra' => extra, 'timestamp' => Time.now.utc.iso8601(6) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vehicle(latitude, longitude, extra = {}, kind = 'bike')
|
|
42
|
+
latitude, longitude = Float(latitude), Float(longitude)
|
|
43
|
+
return if parameters['bbox'] && !Geometry.contains?(parameters['bbox'], latitude, longitude)
|
|
44
|
+
|
|
45
|
+
# @type var components: Array[String | Integer]
|
|
46
|
+
components = [tag]
|
|
47
|
+
if truthy?(extra['uid'])
|
|
48
|
+
components << extra['uid']
|
|
49
|
+
else
|
|
50
|
+
components.push((latitude * 1e6).to_i, (longitude * 1e6).to_i, kind)
|
|
51
|
+
end
|
|
52
|
+
@vehicles << { 'id' => Digest::MD5.hexdigest(components.join(',')), 'latitude' => latitude,
|
|
53
|
+
'longitude' => longitude, 'kind' => kind, 'extra' => extra, 'timestamp' => Time.now.utc.iso8601(6) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def request(url, **options) = @transport.request(url, **options)
|
|
57
|
+
def json(url, **options) = JSON.parse(request(url, **options))
|
|
58
|
+
|
|
59
|
+
def xml(url, **options)
|
|
60
|
+
Nokogiri::XML(request(url, **options)) { |config| config.nonet } # rubocop:disable Style/SymbolProc -- Preserve the typed parser configuration block.
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def html(url, **options) = Nokogiri::HTML(request(url, **options))
|
|
64
|
+
def p(name, fallback = nil) = parameters.fetch(name, fallback)
|
|
65
|
+
def number(value) = value.is_a?(String) ? Integer(value, 10) : Integer(value)
|
|
66
|
+
def list(value) = Array(value)
|
|
67
|
+
def integer(value) = value.nil? ? nil : number(value)
|
|
68
|
+
|
|
69
|
+
def truthy?(value)
|
|
70
|
+
# @type var empty_list: Array[untyped]
|
|
71
|
+
empty_list = []
|
|
72
|
+
# @type var empty_object: RubyBikes::object
|
|
73
|
+
empty_object = {}
|
|
74
|
+
value && value != 0 && value != '' && value != empty_list && value != empty_object
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def iso_date(value)
|
|
78
|
+
value.is_a?(Numeric) ? Time.at(value).utc.iso8601 : Time.iso8601(value).utc.iso8601
|
|
79
|
+
rescue ArgumentError, TypeError, RangeError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adapted from PyBikes cyclopolis.py and keolis.py.
|
|
4
|
+
# Copyright (C) eskerda, Eduardo Mucelli Rezende Oliveira and contributors.
|
|
5
|
+
# Distributed under the AGPL-3.0 license; see LICENSE.
|
|
6
|
+
|
|
7
|
+
class RubyBikes
|
|
8
|
+
module Native
|
|
9
|
+
class JsonProviders
|
|
10
|
+
def update_cyclopolis
|
|
11
|
+
return update_cyclopolis_api if spec.fetch('class') == 'CyclopolisApi'
|
|
12
|
+
|
|
13
|
+
body = request(p('feed_url'))
|
|
14
|
+
coordinate_pattern = if p('mapstyle') == 'google'
|
|
15
|
+
/latLng:\[(-?\d+\.\d+).*?(-?\d+\.\d+)\]/m
|
|
16
|
+
else
|
|
17
|
+
/"lat":\s?"(-?\d+\.\d+).*?"lon":\s?"(-?\d+\.\d+)/m
|
|
18
|
+
end
|
|
19
|
+
coordinates = body.scan(coordinate_pattern)
|
|
20
|
+
descriptions = body.scan(%r{data"?:.*?<span.*?>(.*?)<\\?/span>}m).flatten
|
|
21
|
+
coordinates.zip(descriptions).each do |(latitude, longitude), description|
|
|
22
|
+
fields = description.gsub(%r{<\\?/?b>}, '').split(%r{<br.?/?>})
|
|
23
|
+
# @type var extra: RubyBikes::object
|
|
24
|
+
extra = {}
|
|
25
|
+
if fields.size == 4
|
|
26
|
+
name, raw_bikes, raw_free, status = fields
|
|
27
|
+
else
|
|
28
|
+
name, raw_bikes, raw_slots, raw_free, status = fields
|
|
29
|
+
extra['slots'] = number(raw_slots.split(':').last)
|
|
30
|
+
end
|
|
31
|
+
bikes = cyclopolis_count(raw_bikes.split(': ').last)
|
|
32
|
+
free = cyclopolis_count(raw_free.split(':').last)
|
|
33
|
+
extra['closed'] = true if status == 'offline'
|
|
34
|
+
station(name, latitude, longitude, bikes, free, extra)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update_cyclopolis_api
|
|
39
|
+
json(p('feed_url')).each do |item|
|
|
40
|
+
bikes = item.fetch('vehicle_types_available').sum { |type| type.fetch('count') }
|
|
41
|
+
free = item.fetch('vehicle_docks_available').sum { |type| type.fetch('count') }
|
|
42
|
+
renting, returning = item.values_at('is_renting', 'is_returning')
|
|
43
|
+
extra = { 'uid' => item['id'], 'is_renting' => renting, 'is_returning' => returning,
|
|
44
|
+
'online' => truthy?(renting) ? renting : returning }
|
|
45
|
+
coordinates = item.fetch('location').fetch('coordinates')
|
|
46
|
+
station(item['name'], coordinates[1], coordinates[0], bikes, free, extra)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_keolis
|
|
51
|
+
case spec.fetch('class')
|
|
52
|
+
when 'KeolisIlevia'
|
|
53
|
+
url = "https://data.lillemetropole.fr/data/ogcapi/collections/#{p('dataset')}/items"
|
|
54
|
+
json(url, params: { 'f' => 'json', 'limit' => -1 }).fetch('records').each do |fields|
|
|
55
|
+
extra = { 'status' => fields['etat'], 'uid' => fields['@id'].to_s,
|
|
56
|
+
'city' => fields['commune'], 'address' => fields['adresse'],
|
|
57
|
+
'last_update' => fields['date_modification'],
|
|
58
|
+
'online' => fields['etat_connexion'] == 'CONNECTÉ',
|
|
59
|
+
'payment-terminal' => fields['type'] == 'AVEC TPE' }
|
|
60
|
+
station(fields['nom'], fields['y'], fields['x'], number(fields['nb_velos_dispo']),
|
|
61
|
+
number(fields['nb_places_dispo']), extra)
|
|
62
|
+
end
|
|
63
|
+
when 'KeolisSTAR'
|
|
64
|
+
@meta['source'] = @meta['source'].gsub('{dataset}', p('dataset').to_s) if @meta['source']
|
|
65
|
+
data = json('https://data.explore.star.fr/api/records/1.0/search/',
|
|
66
|
+
params: { 'dataset' => p('dataset'), 'rows' => -1 })
|
|
67
|
+
data.fetch('records').each do |record|
|
|
68
|
+
fields = record.fetch('fields')
|
|
69
|
+
extra = { 'slots' => fields['nombreemplacementsactuels'], 'status' => fields['etat'],
|
|
70
|
+
'uid' => fields['idstation'].to_s, 'last_update' => fields['lastupdate'],
|
|
71
|
+
'online' => fields['etat'] == 'En fonctionnement', 'payment-terminal' => true }
|
|
72
|
+
coordinates = fields.fetch('coordonnees')
|
|
73
|
+
station(fields['nom'], coordinates[0], coordinates[1], number(fields['nombrevelosdisponibles']),
|
|
74
|
+
number(fields['nombreemplacementsdisponibles']), extra)
|
|
75
|
+
end
|
|
76
|
+
when 'VCub'
|
|
77
|
+
json(p('feed_url')).fetch('lists').each do |fields|
|
|
78
|
+
ebikes = number(fields['nbElectricBikeAvailable'])
|
|
79
|
+
manual_bikes = number(fields['nbBikeAvailable'])
|
|
80
|
+
bikes, free = ebikes + manual_bikes, number(fields['nbPlaceAvailable'])
|
|
81
|
+
extra = { 'uid' => fields['id'].to_s, 'last_update' => fields['updatedAt'],
|
|
82
|
+
'address' => fields['address'], 'city' => fields['city'],
|
|
83
|
+
'online' => fields['connexionState'] == 'CONNECTEE', 'slots' => bikes + free,
|
|
84
|
+
'ebikes' => ebikes, 'normal_bikes' => manual_bikes,
|
|
85
|
+
'has_ebikes' => fields.key?('nbElectricBikeAvailable') }
|
|
86
|
+
station(fields['name'], fields['latitude'], fields['longitude'], bikes, free, extra)
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
raise NoSystemFoundError, 'Unknown Keolis client'
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def cyclopolis_count(value)
|
|
96
|
+
number(value)
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class RubyBikes
|
|
4
|
+
module Native
|
|
5
|
+
# A bounded response cache. Expiry uses the caller's monotonic clock;
|
|
6
|
+
# reads do not extend a response's lifetime.
|
|
7
|
+
class ResponseCache
|
|
8
|
+
Entry = Struct.new(:response, :stored_at, :bytes, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(ttl: 60, max_bytes: 32 * 1024 * 1024, max_entries: 128)
|
|
11
|
+
raise ArgumentError, 'cache TTL must be positive and finite' unless ttl.positive? && ttl.finite?
|
|
12
|
+
raise ArgumentError, 'cache byte limit must be positive' unless max_bytes.positive?
|
|
13
|
+
raise ArgumentError, 'cache entry limit must be positive' unless max_entries.positive?
|
|
14
|
+
|
|
15
|
+
@ttl, @max_bytes, @max_entries = ttl, max_bytes, max_entries
|
|
16
|
+
@entries = {}
|
|
17
|
+
@bytes = 0
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(key, now)
|
|
22
|
+
atomic do
|
|
23
|
+
expire(now)
|
|
24
|
+
@entries[key]&.response
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def put(key, response, now)
|
|
29
|
+
size = response.body.bytesize
|
|
30
|
+
if size > @max_bytes
|
|
31
|
+
atomic do
|
|
32
|
+
expire(now)
|
|
33
|
+
previous = @entries.delete(key)
|
|
34
|
+
@bytes -= previous.bytes if previous
|
|
35
|
+
end
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
# The transport exposes mutable configuration and last_response, so
|
|
39
|
+
# neither the caller's cache key nor response should back stored data.
|
|
40
|
+
saved_key = Marshal.load(Marshal.dump(key))
|
|
41
|
+
saved_response = response.dup
|
|
42
|
+
saved_response.body = response.body.dup.freeze
|
|
43
|
+
saved_response.headers = response.headers.transform_values { |value| value.dup.freeze }.freeze
|
|
44
|
+
saved_response.freeze
|
|
45
|
+
atomic do
|
|
46
|
+
expire(now)
|
|
47
|
+
previous = @entries.delete(key)
|
|
48
|
+
@bytes -= previous.bytes if previous
|
|
49
|
+
while @entries.length >= @max_entries || @bytes + size > @max_bytes
|
|
50
|
+
oldest = @entries.shift
|
|
51
|
+
@bytes -= oldest[1].bytes if oldest
|
|
52
|
+
end
|
|
53
|
+
@entries[saved_key] = Entry.new(response: saved_response, stored_at: now, bytes: size)
|
|
54
|
+
@bytes += size
|
|
55
|
+
end
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def atomic(&block)
|
|
62
|
+
# Defer asynchronous timer delivery only while updating accounting.
|
|
63
|
+
# Synchronous errors still propagate; no network I/O runs here.
|
|
64
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
65
|
+
@mutex.synchronize(&block)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expire(now)
|
|
70
|
+
@entries.delete_if do |_, entry|
|
|
71
|
+
expired = now - entry.stored_at >= @ttl
|
|
72
|
+
@bytes -= entry.bytes if expired
|
|
73
|
+
expired
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
require 'http/cookie_jar'
|
|
8
|
+
|
|
9
|
+
class RubyBikes
|
|
10
|
+
module Native
|
|
11
|
+
class Transport
|
|
12
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
13
|
+
attr_reader :headers, :last_response
|
|
14
|
+
|
|
15
|
+
def initialize(timeout: 60, headers: {}, proxy: nil, cooldowns: {}, cache: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
16
|
+
@clock = clock
|
|
17
|
+
seconds = Float(timeout, exception: true)
|
|
18
|
+
raise ArgumentError, 'timeout must be positive' unless seconds&.positive? && seconds.finite?
|
|
19
|
+
|
|
20
|
+
@deadline = @clock.call + seconds
|
|
21
|
+
@headers = { 'User-Agent' => "RubyBikes/#{RubyBikes::VERSION}" }.merge(headers)
|
|
22
|
+
@proxy = proxy && URI(proxy)
|
|
23
|
+
@cooldowns = cooldowns
|
|
24
|
+
@cookies = HTTP::CookieJar.new
|
|
25
|
+
@cache = cache || ResponseCache.new
|
|
26
|
+
@deadline_active = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remaining
|
|
30
|
+
seconds = @deadline - @clock.call
|
|
31
|
+
raise TimeoutError, 'Provider update exceeded its deadline' unless seconds.positive?
|
|
32
|
+
|
|
33
|
+
seconds
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def within_deadline
|
|
37
|
+
return yield if @deadline_active
|
|
38
|
+
|
|
39
|
+
@deadline_active = true
|
|
40
|
+
begin
|
|
41
|
+
Timeout.timeout(remaining) { yield } # rubocop:disable Style/ExplicitBlockArgument -- The timer passes an argument; provider blocks take none.
|
|
42
|
+
rescue Timeout::Error
|
|
43
|
+
raise TimeoutError, 'Provider update exceeded its deadline'
|
|
44
|
+
ensure
|
|
45
|
+
@deadline_active = false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def request(url, method: 'GET', params: nil, data: nil, headers: {}, raw: false, skip_cache: false, default_encoding: 'UTF-8')
|
|
50
|
+
within_deadline do
|
|
51
|
+
perform_request(url, method: method, params: params, data: data, headers: headers, raw: raw, skip_cache: skip_cache, default_encoding: default_encoding)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def perform_request(url, method:, params:, data:, headers:, raw:, skip_cache:, default_encoding:)
|
|
58
|
+
remaining
|
|
59
|
+
uri = transport_uri(url)
|
|
60
|
+
uri.query = [uri.query, params && URI.encode_www_form(params)].compact.reject(&:empty?).join('&')
|
|
61
|
+
uri.query = nil if uri.query == ''
|
|
62
|
+
host = uri.host
|
|
63
|
+
raise ArgumentError, 'Provider URL must include a host' unless host
|
|
64
|
+
|
|
65
|
+
until_time = @cooldowns.fetch(host, 0.0)
|
|
66
|
+
raise RateLimitedError.new('Provider rate limit reached', details: { 'host' => uri.host, 'retry_after' => until_time - Time.now.to_f }) if until_time > Time.now.to_f
|
|
67
|
+
|
|
68
|
+
combined = @headers.merge(headers)
|
|
69
|
+
cookies = @cookies.cookies(uri)
|
|
70
|
+
combined['Cookie'] = cookies.map { |cookie| "#{cookie.name}=#{cookie.value}" }.join('; ') unless cookies.empty?
|
|
71
|
+
cache_key = [method, uri.to_s, data, combined]
|
|
72
|
+
cached = @cache.get(cache_key, @clock.call) if method == 'GET' && !skip_cache
|
|
73
|
+
if cached
|
|
74
|
+
@last_response = cached
|
|
75
|
+
return decode(@last_response, raw, default_encoding)
|
|
76
|
+
end
|
|
77
|
+
response = fetch(uri, method, data, combined, 5)
|
|
78
|
+
@last_response = response
|
|
79
|
+
if response.status == 429
|
|
80
|
+
value = response.headers.fetch('retry-after', '300')
|
|
81
|
+
delay = begin
|
|
82
|
+
Float(value)
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
begin
|
|
85
|
+
Time.httpdate(value).to_f - Time.now.to_f
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
300
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
delay = [[delay, 1].max, 86_400].min # rubocop:disable Style/ComparableClamp -- Preserve rejection of non-comparable Retry-After values.
|
|
91
|
+
@cooldowns[host] = Time.now.to_f + delay
|
|
92
|
+
raise RateLimitedError.new('Provider rate limit reached', details: { 'host' => uri.host, 'retry_after' => delay })
|
|
93
|
+
end
|
|
94
|
+
@cache.put(cache_key, response, @clock.call) if method == 'GET' && response.status.between?(200, 299) && !skip_cache
|
|
95
|
+
decode(response, raw, default_encoding)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode(response, raw, default_encoding)
|
|
99
|
+
body = response.body
|
|
100
|
+
if raw
|
|
101
|
+
body.b
|
|
102
|
+
else
|
|
103
|
+
body.dup.force_encoding(response.headers.fetch('content-type', '')[/charset\s*=\s*"?([^;"\s]+)/i, 1] || default_encoding).encode('UTF-8', invalid: :replace,
|
|
104
|
+
undef: :replace)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def transport_uri(url)
|
|
109
|
+
uri = URI(url)
|
|
110
|
+
raise ArgumentError, 'Provider URL must use HTTP or HTTPS' unless uri.is_a?(URI::HTTP)
|
|
111
|
+
|
|
112
|
+
uri
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def fetch(uri, method, data, headers, redirects)
|
|
116
|
+
proxy = @proxy
|
|
117
|
+
http_class = if proxy
|
|
118
|
+
proxy_host = proxy.host
|
|
119
|
+
raise ArgumentError, 'Proxy URL must include a host' unless proxy_host
|
|
120
|
+
|
|
121
|
+
Net::HTTP::Proxy(proxy_host, proxy.port, proxy.user, proxy.password)
|
|
122
|
+
else
|
|
123
|
+
Net::HTTP
|
|
124
|
+
end
|
|
125
|
+
host = uri.host
|
|
126
|
+
raise ArgumentError, 'Provider URL must include a host' unless host
|
|
127
|
+
|
|
128
|
+
http = http_class.new(host, uri.port)
|
|
129
|
+
http.max_retries = 0
|
|
130
|
+
http.use_ssl = uri.scheme == 'https'
|
|
131
|
+
http.open_timeout = [5, remaining].min
|
|
132
|
+
http.read_timeout = [20, remaining].min
|
|
133
|
+
http.write_timeout = [20, remaining].min
|
|
134
|
+
# Net::HTTP accepts its generic request base class at this boundary.
|
|
135
|
+
# @type var request: untyped
|
|
136
|
+
request = Net::HTTPGenericRequest.new(method, !data.nil?, method != 'HEAD', uri.request_uri, headers)
|
|
137
|
+
if data.is_a?(Hash)
|
|
138
|
+
request.set_form_data(data)
|
|
139
|
+
elsif data
|
|
140
|
+
request.body = data
|
|
141
|
+
end
|
|
142
|
+
response = http.start { |connection| connection.request(request) }
|
|
143
|
+
response.get_fields('set-cookie')&.each { |cookie| @cookies.parse(cookie, uri) }
|
|
144
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
145
|
+
raise ProviderError, 'Provider exceeded the redirect limit' if redirects.zero?
|
|
146
|
+
|
|
147
|
+
target = transport_uri(URI.join(uri.to_s, response.fetch('location')).to_s)
|
|
148
|
+
forwarded = headers.dup
|
|
149
|
+
forwarded.reject! { |key, _| %w[authorization cookie].include?(key.downcase) } if [target.scheme, target.host, target.port] != [uri.scheme, uri.host, uri.port]
|
|
150
|
+
cookies = @cookies.cookies(target)
|
|
151
|
+
forwarded['Cookie'] = cookies.map { |cookie| "#{cookie.name}=#{cookie.value}" }.join('; ') unless cookies.empty?
|
|
152
|
+
redirect_method = if (%w[302 303].include?(response.code) && method != 'HEAD') || (response.code == '301' && method == 'POST')
|
|
153
|
+
'GET'
|
|
154
|
+
else
|
|
155
|
+
method
|
|
156
|
+
end
|
|
157
|
+
return fetch(target, redirect_method, redirect_method == method ? data : nil, forwarded, redirects - 1)
|
|
158
|
+
end
|
|
159
|
+
Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body || '')
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|