rubybikes 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/CONTRIBUTING.md +33 -0
  4. data/LICENSE +662 -0
  5. data/NOTICE +10 -0
  6. data/PROVIDERS.md +123 -0
  7. data/README.md +108 -0
  8. data/RELEASING.md +44 -0
  9. data/data/ATTRIBUTION.txt +290 -0
  10. data/data/geojson/ch_cantons.json +53 -0
  11. data/data/geojson/japan_prefectures.json +49 -0
  12. data/data/geojson/penang.json +63 -0
  13. data/data/graphql/charichari.graphql +12 -0
  14. data/data/graphql/ganxeta.graphql +22 -0
  15. data/data/graphql/otp.graphql +68 -0
  16. data/data/providers.json +1 -0
  17. data/data/pybikes/aksu.json +49 -0
  18. data/data/pybikes/ambici.json +166 -0
  19. data/data/pybikes/baksi.json +41 -0
  20. data/data/pybikes/batumvelo.json +19 -0
  21. data/data/pybikes/bcycle.json +326 -0
  22. data/data/pybikes/beryl.json +195 -0
  23. data/data/pybikes/bicicard.json +205 -0
  24. data/data/pybikes/bicicas.json +20 -0
  25. data/data/pybikes/bicielx.json +19 -0
  26. data/data/pybikes/biciferrolterra.json +21 -0
  27. data/data/pybikes/bicimad.json +16 -0
  28. data/data/pybikes/bicincitta.json +1551 -0
  29. data/data/pybikes/bicing.json +37 -0
  30. data/data/pybikes/bikeshare_ie.json +46 -0
  31. data/data/pybikes/bikeu.json +17 -0
  32. data/data/pybikes/bixi.json +21 -0
  33. data/data/pybikes/bizkaibizi.json +105 -0
  34. data/data/pybikes/careem.json +40 -0
  35. data/data/pybikes/charichari.json +106 -0
  36. data/data/pybikes/chartered_bike.json +86 -0
  37. data/data/pybikes/clujbike.json +17 -0
  38. data/data/pybikes/cyclocity.json +211 -0
  39. data/data/pybikes/cyclopolis.json +78 -0
  40. data/data/pybikes/cykleo.json +39 -0
  41. data/data/pybikes/decobike.json +18 -0
  42. data/data/pybikes/derp.json +114 -0
  43. data/data/pybikes/deutschebahn.json +97 -0
  44. data/data/pybikes/digitransit.json +123 -0
  45. data/data/pybikes/docomo.json +215 -0
  46. data/data/pybikes/easybike.json +160 -0
  47. data/data/pybikes/ecovelo.json +307 -0
  48. data/data/pybikes/emovity.json +17 -0
  49. data/data/pybikes/encicla.json +17 -0
  50. data/data/pybikes/fifteen.json +50 -0
  51. data/data/pybikes/fsm.json +16 -0
  52. data/data/pybikes/ganxeta.json +20 -0
  53. data/data/pybikes/gbfs.json +1578 -0
  54. data/data/pybikes/gira.json +20 -0
  55. data/data/pybikes/gyorbike.json +19 -0
  56. data/data/pybikes/hellocycling.json +248 -0
  57. data/data/pybikes/joco.json +22 -0
  58. data/data/pybikes/keolis.json +22 -0
  59. data/data/pybikes/kolumbus.json +18 -0
  60. data/data/pybikes/lastenvelo.json +30 -0
  61. data/data/pybikes/linkbike.json +17 -0
  62. data/data/pybikes/meiob.json +18 -0
  63. data/data/pybikes/mobhis.json +43 -0
  64. data/data/pybikes/movus.json +72 -0
  65. data/data/pybikes/nextbike.json +2443 -0
  66. data/data/pybikes/nextbike_gbfs.json +5 -0
  67. data/data/pybikes/nubija.json +18 -0
  68. data/data/pybikes/open_source_bike_share.json +28 -0
  69. data/data/pybikes/open_source_bike_share_v2.json +17 -0
  70. data/data/pybikes/openmove.json +20 -0
  71. data/data/pybikes/ov_fiets.json +20 -0
  72. data/data/pybikes/pedalada.json +66 -0
  73. data/data/pybikes/publibike.json +127 -0
  74. data/data/pybikes/qrobici.json +20 -0
  75. data/data/pybikes/roovee.json +245 -0
  76. data/data/pybikes/samba.json +52 -0
  77. data/data/pybikes/sejong.json +19 -0
  78. data/data/pybikes/seoulbike.json +18 -0
  79. data/data/pybikes/sharedmobility.json +63 -0
  80. data/data/pybikes/slovnaft_bajk.json +21 -0
  81. data/data/pybikes/smartbike.json +28 -0
  82. data/data/pybikes/smoove.json +26 -0
  83. data/data/pybikes/soltrafego.json +39 -0
  84. data/data/pybikes/tashu.json +20 -0
  85. data/data/pybikes/urbansharing.json +22 -0
  86. data/data/pybikes/velib.json +23 -0
  87. data/data/pybikes/velobike_kz.json +17 -0
  88. data/data/pybikes/velok.json +16 -0
  89. data/data/pybikes/veloway.json +20 -0
  90. data/data/pybikes/weelo.json +45 -0
  91. data/data/pybikes/wegoshare.json +141 -0
  92. data/data/pybikes/xian.json +19 -0
  93. data/data/pybikes/yelovelo.json +22 -0
  94. data/data/pybikes/youbike.json +173 -0
  95. data/data/upstream.json +7 -0
  96. data/lib/rubybikes/engine.rb +3 -0
  97. data/lib/rubybikes/native/authenticated.rb +95 -0
  98. data/lib/rubybikes/native/engine.rb +103 -0
  99. data/lib/rubybikes/native/gbfs.rb +169 -0
  100. data/lib/rubybikes/native/geometry.rb +53 -0
  101. data/lib/rubybikes/native/graphql.rb +135 -0
  102. data/lib/rubybikes/native/html_clients.rb +313 -0
  103. data/lib/rubybikes/native/json_providers.rb +174 -0
  104. data/lib/rubybikes/native/more_json.rb +126 -0
  105. data/lib/rubybikes/native/nextbike.rb +50 -0
  106. data/lib/rubybikes/native/provider.rb +84 -0
  107. data/lib/rubybikes/native/remaining_clients.rb +102 -0
  108. data/lib/rubybikes/native/response_cache.rb +78 -0
  109. data/lib/rubybikes/native/transport.rb +163 -0
  110. data/lib/rubybikes/native/xml_details.rb +159 -0
  111. data/lib/rubybikes/native.rb +30 -0
  112. data/lib/rubybikes/version.rb +5 -0
  113. data/lib/rubybikes.rb +201 -0
  114. data/sig/external.rbs +30 -0
  115. data/sig/manifest.yaml +8 -0
  116. data/sig/native.rbs +230 -0
  117. data/sig/rubybikes.rbs +143 -0
  118. metadata +230 -0
@@ -0,0 +1,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