cineworld_uk 4.1.0-arm64-darwin
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/.agents/setup +51 -0
- data/.github/workflows/test.yml +43 -0
- data/.gitignore +18 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +225 -0
- data/COMM-LICENSE +22 -0
- data/Gemfile +4 -0
- data/LICENSE +617 -0
- data/README.md +225 -0
- data/Rakefile +59 -0
- data/cineworld_uk.gemspec +30 -0
- data/lib/cineworld_uk/cinema.rb +168 -0
- data/lib/cineworld_uk/internal/api_response.rb +116 -0
- data/lib/cineworld_uk/internal/guest_token.rb +73 -0
- data/lib/cineworld_uk/internal/parser/api/cinema_address.rb +81 -0
- data/lib/cineworld_uk/internal/parser/api/film.rb +48 -0
- data/lib/cineworld_uk/internal/parser/api/performance.rb +41 -0
- data/lib/cineworld_uk/internal/title_sanitizer.rb +68 -0
- data/lib/cineworld_uk/performance.rb +104 -0
- data/lib/cineworld_uk/version.rb +6 -0
- data/lib/cineworld_uk.rb +31 -0
- data/tasks/package.rake +84 -0
- data/test/fixtures/api/cinema-list.json +1 -0
- data/test/fixtures/api/dates-14.json +1 -0
- data/test/fixtures/api/performances-tomorrow-14.json +1 -0
- data/test/fixtures/api/vista-showtimes-brighton.json +1 -0
- data/test/lib/cineworld_uk/cinema_test.rb +249 -0
- data/test/lib/cineworld_uk/internal/api_response_test.rb +207 -0
- data/test/lib/cineworld_uk/internal/parser/api/cinema_address_test.rb +94 -0
- data/test/lib/cineworld_uk/internal/parser/api/film_test.rb +160 -0
- data/test/lib/cineworld_uk/internal/parser/api/performance_test.rb +62 -0
- data/test/lib/cineworld_uk/internal/title_sanitizer_test.rb +153 -0
- data/test/lib/cineworld_uk/performance_test.rb +183 -0
- data/test/lib/cineworld_uk/version_test.rb +7 -0
- data/test/live/integration_test.rb +51 -0
- data/test/package_test.rb +49 -0
- data/test/support/fixture_reader.rb +32 -0
- data/test/test_helper.rb +12 -0
- data/vendor/curl-impersonate/SOURCE +5 -0
- data/vendor/curl-impersonate/bin/curl-impersonate +0 -0
- data/vendor/curl-impersonate/licenses/LICENSE +21 -0
- data/vendor/curl-impersonate/licenses/LICENSE_BORINGSSL +238 -0
- data/vendor/curl-impersonate/licenses/LICENSE_BROTLI +19 -0
- data/vendor/curl-impersonate/licenses/LICENSE_CARES +24 -0
- data/vendor/curl-impersonate/licenses/LICENSE_CURL +22 -0
- data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP2 +23 -0
- data/vendor/curl-impersonate/licenses/LICENSE_NGHTTP3 +22 -0
- data/vendor/curl-impersonate/licenses/LICENSE_NGTCP2 +22 -0
- data/vendor/curl-impersonate/licenses/LICENSE_ZLIB +22 -0
- data/vendor/curl-impersonate/licenses/LICENSE_ZSTD +30 -0
- metadata +233 -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
|
+
[](https://badge.fury.io/rb/cineworld_uk)
|
|
6
|
+
[](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
|