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,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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "repository": "https://github.com/eskerda/pybikes",
3
+ "revision": "b8681d2344d2ccc61d7b99b089a8cdba167a169e",
4
+ "protocol": 1,
5
+ "baseline_clients": 85,
6
+ "baseline_networks": 815
7
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'native' unless defined?(RubyBikes::Native::Engine)
@@ -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