cineworld_uk 4.1.0-x86_64-linux-gnu

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.agents/setup +51 -0
  3. data/.github/workflows/test.yml +43 -0
  4. data/.gitignore +18 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +225 -0
  7. data/COMM-LICENSE +22 -0
  8. data/Gemfile +4 -0
  9. data/LICENSE +617 -0
  10. data/README.md +225 -0
  11. data/Rakefile +59 -0
  12. data/cineworld_uk.gemspec +30 -0
  13. data/lib/cineworld_uk/cinema.rb +168 -0
  14. data/lib/cineworld_uk/internal/api_response.rb +116 -0
  15. data/lib/cineworld_uk/internal/guest_token.rb +73 -0
  16. data/lib/cineworld_uk/internal/parser/api/cinema_address.rb +81 -0
  17. data/lib/cineworld_uk/internal/parser/api/film.rb +48 -0
  18. data/lib/cineworld_uk/internal/parser/api/performance.rb +41 -0
  19. data/lib/cineworld_uk/internal/title_sanitizer.rb +68 -0
  20. data/lib/cineworld_uk/performance.rb +104 -0
  21. data/lib/cineworld_uk/version.rb +6 -0
  22. data/lib/cineworld_uk.rb +31 -0
  23. data/tasks/package.rake +84 -0
  24. data/test/fixtures/api/cinema-list.json +1 -0
  25. data/test/fixtures/api/dates-14.json +1 -0
  26. data/test/fixtures/api/performances-tomorrow-14.json +1 -0
  27. data/test/fixtures/api/vista-showtimes-brighton.json +1 -0
  28. data/test/lib/cineworld_uk/cinema_test.rb +249 -0
  29. data/test/lib/cineworld_uk/internal/api_response_test.rb +207 -0
  30. data/test/lib/cineworld_uk/internal/parser/api/cinema_address_test.rb +94 -0
  31. data/test/lib/cineworld_uk/internal/parser/api/film_test.rb +160 -0
  32. data/test/lib/cineworld_uk/internal/parser/api/performance_test.rb +62 -0
  33. data/test/lib/cineworld_uk/internal/title_sanitizer_test.rb +153 -0
  34. data/test/lib/cineworld_uk/performance_test.rb +183 -0
  35. data/test/lib/cineworld_uk/version_test.rb +7 -0
  36. data/test/live/integration_test.rb +51 -0
  37. data/test/package_test.rb +49 -0
  38. data/test/support/fixture_reader.rb +32 -0
  39. data/test/test_helper.rb +12 -0
  40. data/vendor/curl-impersonate/SOURCE +5 -0
  41. data/vendor/curl-impersonate/bin/curl-impersonate +0 -0
  42. data/vendor/curl-impersonate/licenses/LICENSE +21 -0
  43. data/vendor/curl-impersonate/licenses/LICENSE_BORINGSSL +238 -0
  44. data/vendor/curl-impersonate/licenses/LICENSE_BROTLI +19 -0
  45. data/vendor/curl-impersonate/licenses/LICENSE_CARES +24 -0
  46. data/vendor/curl-impersonate/licenses/LICENSE_CURL +22 -0
  47. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_GPL2 +339 -0
  48. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_GPL3 +35 -0
  49. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_LGPL3 +165 -0
  50. data/vendor/curl-impersonate/licenses/LICENSE_LIBIDN2_UNICODE +91 -0
  51. data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP2 +23 -0
  52. data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP3 +22 -0
  53. data/vendor/curl-impersonate/licenses/LICENSE_NGTCP2 +22 -0
  54. data/vendor/curl-impersonate/licenses/LICENSE_ZLIB +22 -0
  55. data/vendor/curl-impersonate/licenses/LICENSE_ZSTD +30 -0
  56. metadata +237 -0
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # CineworldUk
2
+
3
+ A gem to retrieve [Cineworld UK](https://www.cineworld.co.uk) cinemas and showtimes using the Vista API.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/cineworld_uk.svg)](https://badge.fury.io/rb/cineworld_uk)
6
+ [![Tests](https://github.com/andycroll/cineworld_uk/actions/workflows/.travis.yml/badge.svg)](https://github.com/andycroll/cineworld_uk/actions)
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ gem 'cineworld_uk'
13
+
14
+ And then execute:
15
+
16
+ $ bundle
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install cineworld_uk
21
+
22
+ ## Usage
23
+
24
+ The gem conforms to the API set down in the `cinebase` gem, [andycroll/cinebase](https://github.com/andycroll/cinebase), which provides a lot of useful base vocabulary and repetitive code for this series of cinema focussed gems.
25
+
26
+ Performance titles are sanitized as much as possible, removing 'screening type' information and 'dimension' as well as standardising all the theatre/cultural event naming (NT Live, Royal Opera House etc).
27
+
28
+ ### Authentication
29
+
30
+ Acquire a guest JWT explicitly before retrieving data:
31
+
32
+ ```ruby
33
+ require 'cineworld_uk'
34
+
35
+ CineworldUk.acquire_token!
36
+ CineworldUk::Cinema.all
37
+ ```
38
+
39
+ This shells out to **`curl-impersonate`**, not ordinary system `curl`, using its
40
+ Chrome 150 TLS/HTTP profile. No Python, Chrome, JavaScript execution or browser
41
+ cookies are needed. Platform-specific gem packages include the pinned
42
+ `curl-impersonate` 2.2.3 executable and its upstream licence notices. RubyGems
43
+ and Bundler automatically select the matching package for:
44
+
45
+ - glibc Linux: x86-64 and ARM64 (`x86_64-linux-gnu`, `aarch64-linux-gnu`)
46
+ - macOS: Intel and Apple Silicon (`x86_64-darwin`, `arm64-darwin`)
47
+
48
+ The executable runs directly from the installed gem. There are no separate
49
+ installation commands, PATH changes, or runtime downloads on these platforms.
50
+
51
+ When installing from Git, forcing the source (`ruby`) gem, or using another
52
+ platform (including Alpine/musl and Windows), install a compatible
53
+ [curl-impersonate release](https://github.com/lexiforest/curl-impersonate/releases/tag/v2.2.3)
54
+ with `--impersonate chrome150` support yourself. For an x86-64 Linux checkout:
55
+
56
+ ```sh
57
+ mkdir -p "$HOME/.local/bin"
58
+ curl -fL https://github.com/lexiforest/curl-impersonate/releases/download/v2.2.3/curl-impersonate-v2.2.3.x86_64-linux-gnu.tar.gz \
59
+ | tar -xz -C "$HOME/.local/bin" curl-impersonate
60
+ export PATH="$HOME/.local/bin:$PATH"
61
+ ```
62
+
63
+ Other platforms have separate assets on the release page. You can also specify
64
+ the executable path:
65
+
66
+ ```ruby
67
+ CineworldUk.acquire_token!(curl: '/opt/curl-impersonate/curl-impersonate')
68
+ ```
69
+
70
+ The method returns the token and sets `CineworldUk.token` in memory. It reads
71
+ `__NEXT_DATA__.props.pageProps.environment.gasToken` from the ticketing site's
72
+ root page, even when the page displays a 404. It checks the declared expiry,
73
+ but decoding the JWT does not verify its signature. A blocked page, expired or
74
+ missing token, missing executable, or failed command raises
75
+ `CineworldUk::AuthenticationError` and leaves any previous token unchanged.
76
+ Requests time out after 30 seconds. Certificate verification remains enabled;
77
+ redirects are not followed, and subprocess arguments are passed without a shell.
78
+ Cloudflare may still block requests on other networks or after website changes.
79
+
80
+ Alternatively, supply your own current guest JWT without the `Bearer ` prefix;
81
+ this requires no external executable:
82
+
83
+ ```ruby
84
+ CineworldUk.token = ENV.fetch('CINEWORLD_TOKEN')
85
+ ```
86
+
87
+ The gem sends `Authorization: Bearer ...` and `Connect-Region-Code: GB` to
88
+ `digital-api.cineworld.co.uk`. Normal API calls never acquire or renew a token
89
+ automatically.
90
+
91
+ Missing tokens and HTTP 401 responses raise `CineworldUk::AuthenticationError`.
92
+ Call `CineworldUk.acquire_token!` again, or set `CineworldUk.token` with a fresh
93
+ token, before retrying. Tokens are never persisted by the gem. Other HTTP
94
+ failures raise an error rather than returning an empty schedule. Redirects are
95
+ not followed, so credentials are not forwarded to another host.
96
+
97
+ There is no verified fixed token lifetime. Its declared lifetime is `exp - iat`
98
+ seconds. The website reloads to renew after 90% of that interval. To inspect
99
+ your token locally without printing it (decoding does not verify its signature):
100
+
101
+ ```ruby
102
+ require 'base64'
103
+ payload = CineworldUk.token.split('.').fetch(1)
104
+ claims = JSON.parse(Base64.urlsafe_decode64(payload))
105
+ puts "Lifetime: #{claims.fetch('exp') - claims.fetch('iat')} seconds"
106
+ puts "Expires: #{Time.at(claims.fetch('exp')).utc}"
107
+ ```
108
+
109
+ ### Cinema
110
+
111
+ ``` ruby
112
+ CineworldUk::Cinema.all
113
+ #=> [<CineworldUK::Cinema ...>, <CineworldUK::Cinema ...>, ...]
114
+
115
+ cinema = CineworldUk::Cinema.new(14)
116
+ #=> <CineworldUK::Cinema ...>
117
+
118
+ cinema.adr
119
+ #=> {
120
+ street_address: 'Brighton Marina Village',
121
+ extended_address: nil,
122
+ locality: 'Brighton',
123
+ region: 'East Sussex',
124
+ postal_code: 'BN2 5UF',
125
+ country: 'United Kingdom'
126
+ }
127
+
128
+ cinema.brand
129
+ #=> 'Cineworld'
130
+
131
+ cinema.full_name
132
+ #=> 'Cineworld Brighton'
133
+
134
+ cinema.id
135
+ #=> 14
136
+
137
+ cinema.postal_code
138
+ #=> 'BN2 5UF'
139
+ ```
140
+
141
+ ### Performances
142
+
143
+ `at` accepts a numeric Vista cinema ID (`14`, `'14'` or `'014'` for Brighton),
144
+ not a Boxoffice ID such as `X079B`. Dates are inclusive **business dates**.
145
+ Without dates, it fetches today in London and the following 13 days; it does not
146
+ discover every future screening date.
147
+
148
+ ```ruby
149
+ day = Date.new(2026, 10, 14)
150
+ performances = CineworldUk::Performance.at(14, from: day, to: day)
151
+ ```
152
+
153
+ This makes **one HTTP request per cinema per requested day**, including empty
154
+ days. Cinema names, film titles and attributes come from the same response—no
155
+ extra cinema or film lookups are needed. `Cinema.all` separately fetches and
156
+ caches the cinema list in memory, shared by name and address lookups. Performance
157
+ responses are not cached across calls, so reuse the returned array when possible.
158
+
159
+ Advertised session times (`startsAt`), not feature start times (`filmStartsAt`),
160
+ are converted to UTC using the offsets supplied by Vista. Recognized session
161
+ attributes populate the existing dimension and variant fields; unknown attributes
162
+ are ignored. No seat-availability or booking operations are performed.
163
+
164
+ ``` ruby
165
+ CineworldUk::Performance.at(14)
166
+ #=> [<CineworldUK::Performance ...>, <CineworldUK::Performance ...>, ...]
167
+
168
+ performance = performances.first
169
+ #=> <CineworldUK::Performance ...>
170
+
171
+ performance.film_name
172
+ #=> 'Other Mommy'
173
+
174
+ performance.dimension
175
+ #=> '2d'
176
+
177
+ performance.variant
178
+ #=> ['audio_described']
179
+
180
+ performance.starting_at
181
+ #=> 2026-10-14 14:40:00 UTC
182
+
183
+ performance.showing_on
184
+ #=> Date.new(2026, 10, 14)
185
+
186
+ performance.booking_url
187
+ #=> "https://web.cineworld.co.uk/order/showtimes/014-165507/seats"
188
+
189
+ performance.cinema_name
190
+ #=> 'Brighton'
191
+
192
+ performance.cinema_id
193
+ #=> 14
194
+ ```
195
+
196
+ ## Building release packages
197
+
198
+ ```sh
199
+ bundle exec rake build # Portable source gem; external curl-impersonate required
200
+ bundle exec rake package:all # Four platform gems with bundled executables
201
+ ```
202
+
203
+ To build and check just one platform on a matching host:
204
+
205
+ ```sh
206
+ bundle exec rake 'package:native[x86_64-linux-gnu]'
207
+ CINEWORLD_PACKAGE=pkg/cineworld_uk-4.1.0-x86_64-linux-gnu.gem bundle exec ruby test/package_test.rb
208
+ ```
209
+
210
+ Native packaging downloads pinned upstream archives at **build time**, verifies
211
+ their SHA-256 digests, and includes the executable, licence files and source
212
+ provenance. Archives and built gems stay in ignored `pkg/`, not Git. CI builds
213
+ and installation-tests each platform on its corresponding runner and saves the
214
+ gems as workflow artifacts; it does not publish them. Publish the source and
215
+ platform gems together for automatic platform selection to work for users.
216
+
217
+ ## Contributing
218
+
219
+ 1. Fork it
220
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
221
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
222
+ 4. Push to the branch (`git push origin my-new-feature`)
223
+ 5. Create new Pull Request
224
+
225
+ Note that contributors assign all rights to the owner, Andy Croll ([github](http://github.com/andycroll)), of this gem.
data/Rakefile ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ require 'rake/testtask'
5
+ load File.expand_path('tasks/package.rake', __dir__)
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << 'lib/cineworld_uk'
9
+ t.test_files = FileList[
10
+ 'test/lib/cineworld_uk/*_test.rb',
11
+ 'test/lib/cineworld_uk/internal/*_test.rb',
12
+ 'test/lib/cineworld_uk/internal/parser/api/*_test.rb'
13
+ ]
14
+ t.verbose = true
15
+ end
16
+
17
+ Rake::TestTask.new do |t|
18
+ t.libs << 'lib/cineworld_uk'
19
+ t.name = :live
20
+ t.test_files = FileList[
21
+ 'test/live/*_test.rb'
22
+ ]
23
+ t.verbose = true
24
+ end
25
+
26
+ desc 'console'
27
+ # http://erniemiller.org/2014/02/05/7-lines-every-gems-rakefile-should-have/
28
+ task :console do
29
+ require 'irb'
30
+ require 'irb/completion'
31
+ require 'cineworld_uk'
32
+ ARGV.clear
33
+ IRB.start
34
+ end
35
+
36
+ desc 'Recreate test fixtures'
37
+ task :fixtures do
38
+ require 'cineworld_uk'
39
+ CineworldUk.token = ENV.fetch("CINEWORLD_TOKEN")
40
+
41
+ def js_fixture(name)
42
+ File.expand_path("../test/fixtures/#{name}.json", __FILE__)
43
+ end
44
+
45
+ api = CineworldUk::Internal::ApiResponse.new
46
+
47
+ File.open(js_fixture('api/cinema-list'), 'w') do |file|
48
+ puts '* API Cinema List'
49
+ file.write api.cinema_list
50
+ end
51
+
52
+ File.open(js_fixture('api/performances-tomorrow-14'), 'w') do |file|
53
+ puts '* API Performances in Brighton Tomorrow'
54
+ file.write api.performances(14, Date.today + 1)
55
+ end
56
+
57
+ end
58
+
59
+ task :default => :test
@@ -0,0 +1,30 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'cineworld_uk/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'cineworld_uk'
7
+ spec.version = CineworldUk::VERSION
8
+ spec.authors = ['Andy Croll']
9
+ spec.email = ['andy@goodscary.com']
10
+ spec.description = 'Pull movie & cinema information from the Cineworld iOS API'
11
+ spec.summary = "It's a scraper, but a nice one"
12
+ spec.homepage = 'https://github.com/andycroll/cineworld_uk'
13
+ spec.licenses = %w(AGPL MIT)
14
+
15
+ spec.files = `git ls-files`.split("\n")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler', '>= 2.0'
21
+ spec.add_development_dependency 'minitest-mock'
22
+ spec.add_development_dependency 'minitest-reporters'
23
+ spec.add_development_dependency 'rake'
24
+ spec.add_development_dependency 'simplecov', '< 1'
25
+ spec.add_development_dependency 'webmock'
26
+
27
+ spec.add_runtime_dependency 'base64', '~> 0.3'
28
+ spec.add_runtime_dependency 'cinebase', '~> 5.0'
29
+ spec.add_runtime_dependency 'nokogiri', '>= 1.11'
30
+ end
@@ -0,0 +1,168 @@
1
+ module CineworldUk
2
+ # The object representing a cinema on the Cineworld UK website
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [Integer] the numeric id of the cinema on the Cineworld website
6
+
7
+ # Constructor
8
+ # @param [Integer, String] id cinema id
9
+ # @return [CineworldUk::Cinema]
10
+ def initialize(id)
11
+ @id = id.to_i
12
+ end
13
+
14
+ # Return basic cinema information for all cinemas
15
+ # @return [Array<CineworldUk::Cinema>]
16
+ # @example
17
+ # CineworldUk::Cinema.all
18
+ # #=> [<CineworldUk::Cinema>, <CineworldUk::Cinema>, ...]
19
+ def self.all
20
+ id_names_hash.map { |id, _| new id }
21
+ end
22
+
23
+ # @api private
24
+ # called from instance methods
25
+ # @return [Hash<Integer => String>]
26
+ def self.id_names_hash
27
+ @id_names_hash ||= cinema_list_json.each_with_object({}) do |hash, result|
28
+ name = hash['displayName']
29
+ id_key = hash['id'].to_i
30
+ result[id_key] = name.gsub('London - ', '').gsub(' - ', ': ') if name
31
+ end
32
+ end
33
+
34
+ # @!method address
35
+ # Address of the cinema
36
+ # @return [Hash] of different address parts
37
+ # @see #adr
38
+
39
+ # Address of the cinema
40
+ # @return [Hash] of different address parts
41
+ # @example
42
+ # cinema = CineworldUk::Cinema.new(3)
43
+ # cinema.adr
44
+ # #=> {
45
+ # street_address: '44-47 Gardner Street',
46
+ # extended_address: 'North Laine',
47
+ # locality: 'Brighton',
48
+ # postal_code: 'BN1 1UN',
49
+ # country_name: 'United Kingdom'
50
+ # }
51
+ # @note Uses the standard method naming as at http://microformats.org/wiki/adr
52
+ def adr
53
+ CineworldUk::Internal::Parser::Api::CinemaAddress.new(
54
+ @id, self.class.send(:cinema_list_json)
55
+ ).to_hash
56
+ end
57
+
58
+ # Brand of the cinema
59
+ # @return [String] which will always be 'Cineworld'
60
+ # @example
61
+ # cinema = CineworldUk::Cinema.new(3)
62
+ # cinema.brand
63
+ # #=> 'Cineworld'
64
+ def brand
65
+ 'Cineworld'.freeze
66
+ end
67
+
68
+ # @!method country_name
69
+ # Country of the cinema
70
+ # @return [String] which will always be 'United Kingdom'
71
+ # @example
72
+ # cinema = CineworldUk::Cinema.new(3)
73
+ # cinema.country_name
74
+ # #=> 'United Kingdom'
75
+
76
+ # @!method extended_address
77
+ # The second address line of the cinema
78
+ # @return [String]
79
+ # @example
80
+ # cinema = CineworldUk::Cinema.new(10)
81
+ # cinema.extended_address
82
+ # #=> 'Chelsea'
83
+ #
84
+ # cinema = CineworldUk::Cinema.new(3)
85
+ # cinema.extended_address
86
+ # #=> ''
87
+
88
+ # @!method full_name
89
+ # The name of the cinema including the brand
90
+ # @return [String]
91
+ # @example
92
+ # cinema = CineworldUk::Cinema.new(88)
93
+ # cinema.full_name
94
+ # #=> 'Cineworld Glasgow: IMAX at GSC'
95
+
96
+ # @!method locality
97
+ # The locality (town) of the cinema
98
+ # @return [String]
99
+ # @example
100
+ # cinema = CineworldUk::Cinema.new(3)
101
+ # cinema.locality
102
+ # #=> 'Brighton'
103
+
104
+ # The name of the cinema
105
+ # @return [String]
106
+ # @example
107
+ # cinema = CineworldUk::Cinema.new(3)
108
+ # cinema.name
109
+ # #=> 'Brighton'
110
+ def name
111
+ @name ||= self.class.id_names_hash[id]
112
+ end
113
+
114
+ # @!method postal_code
115
+ # Post code of the cinema
116
+ # @return [String]
117
+ # @example
118
+ # cinema = CineworldUk::Cinema.new(3)
119
+ # cinema.postal_code
120
+ # #=> 'BN2 5UF'
121
+
122
+ # @!method region
123
+ # The region (county) of the cinema if provided
124
+ # @return [String]
125
+ # @example
126
+ # cinema = CineworldUk::Cinema.new(3)
127
+ # cinema.region
128
+ # #=> 'East Sussex'
129
+
130
+ # @!method slug
131
+ # The URL-able slug of the cinema
132
+ # @return [String]
133
+ # @example
134
+ # cinema = CineworldUk::Cinema.new(3)
135
+ # cinema.slug
136
+ # #=> 'odeon-brighton'
137
+
138
+ # @!method street_address
139
+ # The street address of the cinema
140
+ # @return [String]
141
+ # @example
142
+ # cinema = CineworldUk::Cinema.new(3)
143
+ # cinema.street_address
144
+ # #=> 'Brighton Marina'
145
+ # @note Uses the standard method naming as at http://microformats.org/wiki/adr
146
+
147
+ # The url of the cinema on the Cineworld website
148
+ # @return [String]
149
+ def url
150
+ "http://www.cineworld.co.uk/cinemas/#{@id}/information"
151
+ end
152
+
153
+ # private
154
+
155
+ # @api private
156
+ def self.api
157
+ @api ||= CineworldUk::Internal::ApiResponse.new
158
+ end
159
+ private_class_method :api
160
+
161
+ # @api private
162
+ def self.cinema_list_json
163
+ @cinema_list_json ||=
164
+ JSON.parse(api.cinema_list)['body']['cinemas']
165
+ end
166
+ private_class_method :cinema_list_json
167
+ end
168
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "date"
6
+ require "time"
7
+
8
+ module CineworldUk
9
+ module Internal
10
+ # Adapts Vista responses to the parsers' cinema/film/event vocabulary.
11
+ class ApiResponse
12
+ BASE_URL = "https://digital-api.cineworld.co.uk/ocapi/v1/"
13
+ ATTRIBUTES = {
14
+ "3D" => "3d", "IMAX" => "imax", "AD" => "audio-described",
15
+ "Subtitled" => "subbed", "Subtitles" => "subbed",
16
+ "Autism Friendly" => "autism-friendly",
17
+ "Movies for Juniors" => "junior",
18
+ "Unlimited Screening" => "unlimited-screening", "Q&A" => "qa"
19
+ }.freeze
20
+
21
+ def cinema_list
22
+ cinemas = fetch("sites").fetch("sites").map do |site|
23
+ address = site.fetch("contactDetails", {}).fetch("addressV2", {})
24
+ {
25
+ "id" => site.fetch("id"),
26
+ "displayName" => site.fetch("name").fetch("text"),
27
+ "addressInfo" => {
28
+ "address1" => address.dig("line1", "text"),
29
+ "city" => address.dig("line2", "text"),
30
+ "state" => address.dig("line3", "text"),
31
+ "postalCode" => address.dig("line4", "text")
32
+ }
33
+ }
34
+ end
35
+ JSON.generate("body" => { "cinemas" => cinemas })
36
+ end
37
+
38
+ def performances(cinema_id, date)
39
+ site_id = cinema_id.to_s.rjust(3, "0")
40
+ response = fetch("showtimes/by-business-date/#{date.iso8601}",
41
+ "siteIds" => site_id)
42
+ related = response.fetch("relatedData")
43
+ films = related.fetch("films").to_h { |film| [film.fetch("id"), film] }
44
+ attributes = related.fetch("attributes").to_h do |attr|
45
+ [attr.fetch("id"), attr]
46
+ end
47
+ normalized_films = []
48
+ events = response.fetch("showtimes").map do |showtime|
49
+ film = films.fetch(showtime.fetch("filmId"))
50
+ attrs = showtime.fetch("attributeIds", []).filter_map do |id|
51
+ attribute = attributes.fetch(id)
52
+ ATTRIBUTES[attribute.dig("shortName", "text")] ||
53
+ ATTRIBUTES[attribute.dig("name", "text")]
54
+ end
55
+ attrs << "3d" if showtime["requires3dGlasses"]
56
+ # Formats belong to a session, not to the film as a whole.
57
+ id = showtime.fetch("id")
58
+ normalized_films << {
59
+ "id" => id,
60
+ "name" => film.fetch("title").fetch("text"),
61
+ "attributeIds" => attrs
62
+ }
63
+ starts_at = showtime.fetch("schedule").fetch("startsAt")
64
+ {
65
+ "filmId" => id, "attributeIds" => attrs,
66
+ "eventDateTime" => Time.iso8601(starts_at).utc.iso8601,
67
+ "bookingLink" =>
68
+ "https://web.cineworld.co.uk/order/showtimes/#{id}/seats"
69
+ }
70
+ end
71
+ site = related.fetch("sites").detect do |item|
72
+ item.fetch("id") == site_id
73
+ end
74
+ JSON.generate(
75
+ "body" => {
76
+ "films" => normalized_films, "events" => events,
77
+ "cinemaName" => site&.dig("name", "text")
78
+ }
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def fetch(path, query = {})
85
+ token = CineworldUk.token
86
+ if !token.is_a?(String) || token.strip.empty?
87
+ raise AuthenticationError,
88
+ "Set CineworldUk.token to a current guest JWT " \
89
+ "before fetching data"
90
+ end
91
+ uri = URI(BASE_URL + path)
92
+ uri.query = URI.encode_www_form(query) unless query.empty?
93
+ request = Net::HTTP::Get.new(uri)
94
+ request["Authorization"] = "Bearer #{token}"
95
+ request["Connect-Region-Code"] = "GB"
96
+ request["Accept"] = "application/json"
97
+ response = Net::HTTP.start(
98
+ uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30
99
+ ) do |http|
100
+ http.request(request)
101
+ end
102
+ case response
103
+ when Net::HTTPSuccess
104
+ JSON.parse(response.body)
105
+ when Net::HTTPUnauthorized
106
+ raise AuthenticationError,
107
+ "Vista rejected the guest token; " \
108
+ "supply a fresh token with CineworldUk.token="
109
+ else
110
+ # Omit response bodies and never redirect credentials to another host.
111
+ raise "Vista HTTP error #{response.code}"
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "nokogiri"
6
+ require "open3"
7
+
8
+ module CineworldUk
9
+ module Internal
10
+ # Retrieves the public guest token without running a browser.
11
+ class GuestToken
12
+ def initialize(curl:)
13
+ @curl = curl || default_curl
14
+ end
15
+
16
+ def fetch
17
+ token = extract_token(request_page)
18
+ validate_expiry!(token)
19
+ token
20
+ rescue JSON::ParserError, ArgumentError, IndexError, TypeError, NoMethodError
21
+ raise AuthenticationError, "Guest token page data is missing or invalid", cause: nil
22
+ end
23
+
24
+ private
25
+
26
+ def default_curl
27
+ bundled = File.expand_path("../../../vendor/curl-impersonate/bin/curl-impersonate", __dir__)
28
+ File.executable?(bundled) ? bundled : "curl-impersonate"
29
+ end
30
+
31
+ def curl_command
32
+ [@curl, "--disable", "--impersonate", "chrome150",
33
+ "--compressed", "--silent", "--show-error",
34
+ "--connect-timeout", "10", "--max-time", "30",
35
+ "--write-out", "\n%{http_code}", "https://web.cineworld.co.uk/"]
36
+ end
37
+
38
+ def request_page
39
+ output, _stderr, result = Open3.capture3(*curl_command)
40
+ unless result.success?
41
+ raise AuthenticationError,
42
+ "Guest token request failed (curl exit #{result.exitstatus})"
43
+ end
44
+
45
+ html, _, status = output.rpartition("\n")
46
+ unless %w[200 404].include?(status)
47
+ raise AuthenticationError, "Guest token page returned HTTP #{status}"
48
+ end
49
+ html
50
+ rescue Errno::ENOENT
51
+ raise AuthenticationError,
52
+ "Install curl-impersonate or pass its executable path with curl:"
53
+ end
54
+
55
+ def extract_token(html)
56
+ script = Nokogiri::HTML(html).at_css("script#__NEXT_DATA__")
57
+ data = JSON.parse(script ? script.content : "")
58
+ token = data.dig("props", "pageProps", "environment", "gasToken")
59
+ unless token.is_a?(String) && !token.empty?
60
+ raise AuthenticationError, "Guest token is missing from the page"
61
+ end
62
+ token
63
+ end
64
+
65
+ def validate_expiry!(token)
66
+ claims = JSON.parse(Base64.urlsafe_decode64(token.split(".").fetch(1)))
67
+ if Integer(claims.fetch("exp")) <= Time.now.to_i
68
+ raise AuthenticationError, "Guest token supplied by the page has expired"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end