curzon_uk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +47 -0
- data/.github/workflows/release.yml +73 -0
- data/.gitignore +19 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +11 -0
- data/COMM-LICENSE +22 -0
- data/Gemfile +3 -0
- data/LICENSE +617 -0
- data/README.md +164 -0
- data/Rakefile +38 -0
- data/curzon_uk.gemspec +31 -0
- data/lib/curzon_uk/cinema.rb +109 -0
- data/lib/curzon_uk/internal/api_response.rb +35 -0
- data/lib/curzon_uk/internal/guest_token.rb +27 -0
- data/lib/curzon_uk/internal/parser/api/showtime.rb +62 -0
- data/lib/curzon_uk/internal/title_sanitizer.rb +32 -0
- data/lib/curzon_uk/performance.rb +82 -0
- data/lib/curzon_uk/version.rb +6 -0
- data/lib/curzon_uk.rb +30 -0
- data/test/fixtures/api/showtimes-SOH1.json +58 -0
- data/test/fixtures/api/sites.json +40 -0
- data/test/lib/curzon_uk/cinema_test.rb +84 -0
- data/test/lib/curzon_uk/internal/api_response_test.rb +52 -0
- data/test/lib/curzon_uk/internal/fixture_shape_test.rb +14 -0
- data/test/lib/curzon_uk/internal/guest_token_test.rb +91 -0
- data/test/lib/curzon_uk/internal/parser/api/showtime_test.rb +59 -0
- data/test/lib/curzon_uk/internal/title_sanitizer_test.rb +31 -0
- data/test/lib/curzon_uk/performance_test.rb +106 -0
- data/test/lib/curzon_uk/version_test.rb +7 -0
- data/test/live/api_shape_test.rb +19 -0
- data/test/live/integration_test.rb +27 -0
- data/test/live/live_helper.rb +11 -0
- data/test/package_test.rb +56 -0
- data/test/support/api_fixtures_helper.rb +17 -0
- data/test/support/api_shapes.rb +48 -0
- data/test/support/fake_api_response.rb +20 -0
- data/test/test_helper.rb +20 -0
- metadata +208 -0
data/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# CurzonUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull cinemas and performances from the Vista API behind the [Curzon website](https://www.curzon.com) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'curzon_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install curzon_uk
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
Performance titles are sanitized as much as possible, removing years, Curzon's DocHouse and Film Club strand prefixes, festival prefixes and other markers.
|
|
24
|
+
|
|
25
|
+
### Guest token
|
|
26
|
+
|
|
27
|
+
Every API call needs the public guest token the Curzon website gives each
|
|
28
|
+
visitor. Fetch one before anything else:
|
|
29
|
+
|
|
30
|
+
``` ruby
|
|
31
|
+
CurzonUk.acquire_token!
|
|
32
|
+
CurzonUk::Cinema.all
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This shells out to **`curl-impersonate`**, not ordinary system `curl`, using its
|
|
36
|
+
Chrome 150 TLS/HTTP profile, and reads `window.initialData.api.authToken` from
|
|
37
|
+
`https://www.curzon.com/`. No browser or JavaScript execution is needed.
|
|
38
|
+
|
|
39
|
+
Platform-specific gem packages include the pinned `curl-impersonate` 2.2.3
|
|
40
|
+
executable for glibc Linux (x86-64, ARM64) and macOS (Intel, Apple Silicon).
|
|
41
|
+
On other platforms, or when installing from Git, install a
|
|
42
|
+
[curl-impersonate release](https://github.com/lexiforest/curl-impersonate/releases/tag/v2.2.3)
|
|
43
|
+
yourself and put it on your `PATH` or pass its path:
|
|
44
|
+
|
|
45
|
+
``` ruby
|
|
46
|
+
CurzonUk.acquire_token!(curl: '/opt/curl-impersonate/curl-impersonate')
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or supply your own current guest JWT, without the `Bearer ` prefix:
|
|
50
|
+
|
|
51
|
+
``` ruby
|
|
52
|
+
CurzonUk.token = ENV.fetch('CURZON_TOKEN')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Tokens currently last 12 hours. The gem checks the declared expiry but does not
|
|
56
|
+
verify the signature. Missing, expired or rejected tokens raise
|
|
57
|
+
`CurzonUk::AuthenticationError`; call `CurzonUk.acquire_token!` again. Normal
|
|
58
|
+
API calls never acquire or renew a token automatically.
|
|
59
|
+
|
|
60
|
+
### Cinema
|
|
61
|
+
|
|
62
|
+
Cinema ids are Vista site ids, such as `'SOH1'` for Soho.
|
|
63
|
+
|
|
64
|
+
``` ruby
|
|
65
|
+
CurzonUk::Cinema.all
|
|
66
|
+
#=> [<CurzonUk::Cinema ...>, <CurzonUk::Cinema ...>, ...]
|
|
67
|
+
|
|
68
|
+
cinema = CurzonUk::Cinema.new('SOH1')
|
|
69
|
+
#=> <CurzonUk::Cinema ...>
|
|
70
|
+
|
|
71
|
+
cinema.adr
|
|
72
|
+
#=> {
|
|
73
|
+
street_address: '99 Shaftesbury Avenue',
|
|
74
|
+
extended_address: nil,
|
|
75
|
+
locality: 'London',
|
|
76
|
+
region: nil,
|
|
77
|
+
postal_code: 'W1D 5DY',
|
|
78
|
+
country_name: 'United Kingdom'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cinema.brand
|
|
82
|
+
#=> 'Curzon'
|
|
83
|
+
|
|
84
|
+
cinema.full_name
|
|
85
|
+
#=> 'Curzon Soho'
|
|
86
|
+
|
|
87
|
+
cinema.url
|
|
88
|
+
#=> 'https://www.curzon.com/venues/soho/'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Performances
|
|
92
|
+
|
|
93
|
+
`Performance.at` returns performances for an inclusive range of business dates,
|
|
94
|
+
defaulting to today in London and the following 13 days. It makes one request
|
|
95
|
+
per day.
|
|
96
|
+
|
|
97
|
+
``` ruby
|
|
98
|
+
CurzonUk::Performance.at('SOH1', from: Date.today, to: Date.today + 2)
|
|
99
|
+
#=> [<CurzonUk::Performance ...>, <CurzonUk::Performance ...>, ...]
|
|
100
|
+
|
|
101
|
+
performance = CurzonUk::Performance.at('SOH1').first
|
|
102
|
+
#=> <CurzonUk::Performance ...>
|
|
103
|
+
|
|
104
|
+
performance.film_name
|
|
105
|
+
#=> 'Fatherland'
|
|
106
|
+
|
|
107
|
+
performance.film_year
|
|
108
|
+
#=> nil # from the title, e.g. 2026 for 'Street Fighter (2026)'
|
|
109
|
+
|
|
110
|
+
performance.dimension
|
|
111
|
+
#=> '2d'
|
|
112
|
+
|
|
113
|
+
performance.variant
|
|
114
|
+
#=> ['q&a']
|
|
115
|
+
|
|
116
|
+
performance.subtitled?
|
|
117
|
+
#=> false # true for open captioned screenings
|
|
118
|
+
|
|
119
|
+
performance.festival
|
|
120
|
+
#=> nil # e.g. #<data Cinebase::Festival name="UKJFF", year=2026>
|
|
121
|
+
|
|
122
|
+
performance.starting_at
|
|
123
|
+
#=> 2026-10-23 10:00:00 UTC
|
|
124
|
+
|
|
125
|
+
performance.booking_url
|
|
126
|
+
#=> 'https://www.curzon.com/ticketing/seats/?showtimeId=SOH1-57865'
|
|
127
|
+
|
|
128
|
+
performance.cinema_name
|
|
129
|
+
#=> 'Soho'
|
|
130
|
+
|
|
131
|
+
performance.cinema_id
|
|
132
|
+
#=> 'SOH1'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Variants are `baby`, `q&a` and `subtitled`. Curzon doesn't tag audio
|
|
136
|
+
languages, so `language` is always `nil`.
|
|
137
|
+
|
|
138
|
+
## Tests
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
bundle exec rake # unit tests against fixtures
|
|
142
|
+
bundle exec rake live # live API, needs curl-impersonate or CURZON_TOKEN
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Fixtures in `test/fixtures/api` are hand-written: invented films and addresses
|
|
146
|
+
in the shape of Vista responses, never recorded API output.
|
|
147
|
+
`test/support/api_shapes.rb` describes the fields the gem reads. The unit suite
|
|
148
|
+
checks the fixtures against it and the live suite checks real responses, held
|
|
149
|
+
only in memory. When the API changes, update the shape first, then the fixtures.
|
|
150
|
+
|
|
151
|
+
## Releasing
|
|
152
|
+
|
|
153
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
154
|
+
Actions. It builds the source and four platform gems, publishes them through
|
|
155
|
+
RubyGems trusted publishing (no MFA code needed) and tags the release. Versions
|
|
156
|
+
already on RubyGems are skipped, so a failed run can be rerun.
|
|
157
|
+
|
|
158
|
+
## Contributing
|
|
159
|
+
|
|
160
|
+
1. Fork it
|
|
161
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
162
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
163
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
164
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env rake
|
|
2
|
+
require 'bundler/gem_tasks'
|
|
3
|
+
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
require 'cinebase/vista/package_task'
|
|
6
|
+
|
|
7
|
+
Cinebase::Vista::PackageTask.new('curzon_uk.gemspec', root: __dir__)
|
|
8
|
+
|
|
9
|
+
Rake::TestTask.new do |t|
|
|
10
|
+
t.libs << 'lib/curzon_uk'
|
|
11
|
+
t.test_files = FileList[
|
|
12
|
+
'test/lib/curzon_uk/*_test.rb',
|
|
13
|
+
'test/lib/curzon_uk/internal/*_test.rb',
|
|
14
|
+
'test/lib/curzon_uk/internal/parser/api/*_test.rb'
|
|
15
|
+
]
|
|
16
|
+
t.verbose = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Rake::TestTask.new do |t|
|
|
20
|
+
t.libs << 'lib/curzon_uk'
|
|
21
|
+
t.name = :live
|
|
22
|
+
t.test_files = FileList[
|
|
23
|
+
'test/live/*_test.rb'
|
|
24
|
+
]
|
|
25
|
+
t.verbose = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# http://erniemiller.org/2014/02/05/7-lines-every-gems-rakefile-should-have/
|
|
29
|
+
desc 'run gem console'
|
|
30
|
+
task :console do
|
|
31
|
+
require 'irb'
|
|
32
|
+
require 'irb/completion'
|
|
33
|
+
require 'curzon_uk'
|
|
34
|
+
ARGV.clear
|
|
35
|
+
IRB.start
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
task default: :test
|
data/curzon_uk.gemspec
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
3
|
+
require 'curzon_uk/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'curzon_uk'
|
|
7
|
+
spec.version = CurzonUk::VERSION
|
|
8
|
+
spec.authors = ['Andy Croll']
|
|
9
|
+
spec.email = ['andy@goodscary.com']
|
|
10
|
+
spec.description = 'Pull movie & cinema information from the Curzon Vista API'
|
|
11
|
+
spec.summary = "It's a scraper, but a nice one"
|
|
12
|
+
spec.homepage = 'https://github.com/andycroll/curzon_uk'
|
|
13
|
+
spec.licenses = %w(AGPL MIT)
|
|
14
|
+
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.files = `git ls-files`.split("\n")
|
|
18
|
+
spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
|
19
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
|
20
|
+
spec.require_paths = ['lib']
|
|
21
|
+
|
|
22
|
+
spec.add_development_dependency 'base64'
|
|
23
|
+
spec.add_development_dependency 'bundler', '>= 2.0'
|
|
24
|
+
spec.add_development_dependency 'minitest-mock'
|
|
25
|
+
spec.add_development_dependency 'minitest-reporters'
|
|
26
|
+
spec.add_development_dependency 'rake'
|
|
27
|
+
spec.add_development_dependency 'simplecov', '< 1'
|
|
28
|
+
spec.add_development_dependency 'webmock'
|
|
29
|
+
|
|
30
|
+
spec.add_runtime_dependency 'cinebase', '~> 5.4'
|
|
31
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module CurzonUk
|
|
2
|
+
# The object representing a cinema on the Curzon website
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the Vista site id of the cinema, e.g. 'ALD1'
|
|
6
|
+
|
|
7
|
+
# UK postcode
|
|
8
|
+
POSTCODE = /[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}/i.freeze
|
|
9
|
+
|
|
10
|
+
# Constructor
|
|
11
|
+
# @param [String] id the cinema id, e.g. 'ALD1' or 'ald1'
|
|
12
|
+
# @return [CurzonUk::Cinema]
|
|
13
|
+
def initialize(id)
|
|
14
|
+
@id = id.to_s.upcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Return basic cinema information for all cinemas
|
|
18
|
+
# @return [Array<CurzonUk::Cinema>]
|
|
19
|
+
# @example
|
|
20
|
+
# CurzonUk::Cinema.all
|
|
21
|
+
# #=> [<CurzonUk::Cinema>, <CurzonUk::Cinema>, ...]
|
|
22
|
+
def self.all
|
|
23
|
+
sites_hash.keys.map { |id| new(id) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @!method address
|
|
27
|
+
# Address of the cinema
|
|
28
|
+
# @return [Hash] of different address parts
|
|
29
|
+
# @see #adr
|
|
30
|
+
|
|
31
|
+
# Address of the cinema
|
|
32
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
33
|
+
# :locality, :region, :postal_code, :country_name
|
|
34
|
+
# @example
|
|
35
|
+
# cinema = CurzonUk::Cinema.new('SOH1')
|
|
36
|
+
# cinema.adr
|
|
37
|
+
# #=> {
|
|
38
|
+
# street_address: '99 Shaftesbury Avenue',
|
|
39
|
+
# extended_address: nil,
|
|
40
|
+
# locality: 'London',
|
|
41
|
+
# region: nil,
|
|
42
|
+
# postal_code: 'W1D 5DY',
|
|
43
|
+
# country_name: 'United Kingdom'
|
|
44
|
+
# }
|
|
45
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
46
|
+
def adr
|
|
47
|
+
lines = address_lines
|
|
48
|
+
if (match = lines.last&.match(/\A(.*?),?\s*(#{POSTCODE.source})\z/i))
|
|
49
|
+
lines.pop
|
|
50
|
+
lines << match[1] unless match[1].empty?
|
|
51
|
+
postal_code = match[2]
|
|
52
|
+
end
|
|
53
|
+
street_address = lines.shift
|
|
54
|
+
locality = lines.pop
|
|
55
|
+
{
|
|
56
|
+
street_address: street_address,
|
|
57
|
+
extended_address: lines.empty? ? nil : lines.join(', '),
|
|
58
|
+
locality: locality,
|
|
59
|
+
region: nil,
|
|
60
|
+
postal_code: postal_code,
|
|
61
|
+
country_name: 'United Kingdom'.freeze
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Brand of the cinema
|
|
66
|
+
# @return [String] which will always be 'Curzon'
|
|
67
|
+
def brand
|
|
68
|
+
'Curzon'.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The name of the cinema
|
|
72
|
+
# @return [String]
|
|
73
|
+
# @example
|
|
74
|
+
# CurzonUk::Cinema.new('SOH1').name
|
|
75
|
+
# #=> 'Soho'
|
|
76
|
+
def name
|
|
77
|
+
site_hash.dig('name', 'text').to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The url of the cinema on the Curzon website
|
|
81
|
+
# @return [String]
|
|
82
|
+
def url
|
|
83
|
+
"https://www.curzon.com/venues/#{name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')}/"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def self.sites_hash
|
|
88
|
+
@sites_hash ||= CurzonUk::Internal::ApiResponse.new.sites.to_h do |site|
|
|
89
|
+
[site.fetch('id'), site]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :sites_hash
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Curzon ends each line with a comma
|
|
97
|
+
def address_lines
|
|
98
|
+
address = site_hash.dig('contactDetails', 'addressV2') || {}
|
|
99
|
+
%w(line1 line2 line3 line4).filter_map do |key|
|
|
100
|
+
text = address.dig(key, 'text').to_s.strip.delete_suffix(',').strip
|
|
101
|
+
text unless text.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def site_hash
|
|
106
|
+
@site_hash ||= self.class.send(:sites_hash).fetch(id, {})
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module CurzonUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Fetches cinema and showtime data from the Vista API behind curzon.com
|
|
10
|
+
class ApiResponse
|
|
11
|
+
BASE_URL = "https://digital-api.curzon.com/ocapi/v1/"
|
|
12
|
+
|
|
13
|
+
# @return [Array<Hash>] cinemas
|
|
14
|
+
def sites
|
|
15
|
+
client.get("sites").fetch("sites")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param [String] cinema_id site id, e.g. 'ALD1'
|
|
19
|
+
# @param [Date] date business date
|
|
20
|
+
# @return [Hash] showtimes plus their related films and attributes
|
|
21
|
+
def showtimes(cinema_id, date)
|
|
22
|
+
client.get("showtimes/by-business-date/#{date.iso8601}",
|
|
23
|
+
"siteIds" => cinema_id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Curzon's API needs no Connect-Region-Code.
|
|
29
|
+
def client
|
|
30
|
+
Cinebase::Vista::Client.new(base_url: BASE_URL, region_code: nil,
|
|
31
|
+
token: CurzonUk.token)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CurzonUk
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads the public guest token from the Curzon website's page data
|
|
8
|
+
class GuestToken < Cinebase::Vista::GuestToken
|
|
9
|
+
PAGE_URL = "https://www.curzon.com/"
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Missing pages carry the same page data.
|
|
14
|
+
def accepted_statuses
|
|
15
|
+
%w[200 404]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def bundled_curl
|
|
19
|
+
File.expand_path("../../../vendor/curl-impersonate/bin/curl-impersonate", __dir__)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def extract_token(html)
|
|
23
|
+
JSON.parse(html[/window\.initialData\s*=\s*(.+?);\s*$/, 1].to_s).dig("api", "authToken")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module CurzonUk
|
|
6
|
+
# @api private
|
|
7
|
+
module Internal
|
|
8
|
+
module Parser
|
|
9
|
+
module Api
|
|
10
|
+
# Parses a single Vista showtime into performance attributes
|
|
11
|
+
class Showtime
|
|
12
|
+
BOOKING_URL = "https://www.curzon.com/ticketing/seats/?showtimeId="
|
|
13
|
+
|
|
14
|
+
# Vista attribute names
|
|
15
|
+
VARIANTS = {
|
|
16
|
+
"Baby Club" => "baby",
|
|
17
|
+
"Open Captioned" => "subtitled",
|
|
18
|
+
"Screening followed by a Q&A" => "q&a"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# @param [Hash] data one showtime
|
|
22
|
+
# @param [Hash{String => Hash}] films related films keyed by id
|
|
23
|
+
# @param [Hash{String => Hash}] attributes related attributes keyed by id
|
|
24
|
+
def initialize(data, films:, attributes:)
|
|
25
|
+
@data = data
|
|
26
|
+
@films = films
|
|
27
|
+
@attributes = attributes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash] options for CurzonUk::Performance.new
|
|
31
|
+
def to_hash
|
|
32
|
+
{
|
|
33
|
+
booking_url: BOOKING_URL + @data.fetch("id"),
|
|
34
|
+
dimension: @data["requires3dGlasses"] ? "3d" : "2d",
|
|
35
|
+
festival: Cinebase::Festival.from_title(title, year: starting_at.year),
|
|
36
|
+
film_name: TitleSanitizer.new(title).sanitized,
|
|
37
|
+
film_year: Cinebase::TitleSanitizer.film_year(title),
|
|
38
|
+
starting_at: starting_at,
|
|
39
|
+
variant: variant
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def starting_at
|
|
46
|
+
Time.iso8601(@data.dig("schedule", "startsAt")).utc
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def title
|
|
50
|
+
@films.fetch(@data.fetch("filmId")).dig("title", "text")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def variant
|
|
54
|
+
@data.fetch("attributeIds", []).filter_map do |id|
|
|
55
|
+
VARIANTS[@attributes.dig(id, "name", "text").to_s.strip]
|
|
56
|
+
end.uniq
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module CurzonUk
|
|
2
|
+
# Internal utility classes: Do not use
|
|
3
|
+
# @api private
|
|
4
|
+
module Internal
|
|
5
|
+
# Sanitize and standardize film titles
|
|
6
|
+
class TitleSanitizer < Cinebase::TitleSanitizer
|
|
7
|
+
# @!method initialize(title)
|
|
8
|
+
# Constructor
|
|
9
|
+
# @param [String] title a film title
|
|
10
|
+
# @return [CurzonUk::Internal::TitleSanitizer]
|
|
11
|
+
|
|
12
|
+
# @!method sanitized
|
|
13
|
+
# sanitized and standardized title
|
|
14
|
+
# @return [String] sanitised title
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# strings and regex to be removed
|
|
19
|
+
def remove
|
|
20
|
+
[
|
|
21
|
+
/\ADocHouse: /i, # DocHouse strand
|
|
22
|
+
/\AFilm Club: /i # Film Club strand
|
|
23
|
+
] + COMMON_REMOVE
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# regexes and their replacements
|
|
27
|
+
def replace
|
|
28
|
+
COMMON_REPLACE
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module CurzonUk
|
|
2
|
+
# The object representing a single screening of a film on the Curzon website
|
|
3
|
+
class Performance < Cinebase::Performance
|
|
4
|
+
# @!attribute [r] booking_url
|
|
5
|
+
# @return [String] the booking URL on the cinema website
|
|
6
|
+
# @!attribute [r] cinema_name
|
|
7
|
+
# @return [String] the cinema name
|
|
8
|
+
# @!attribute [r] cinema_id
|
|
9
|
+
# @return [String] the cinema id
|
|
10
|
+
# @!attribute [r] dimension
|
|
11
|
+
# @return [String] 2d or 3d
|
|
12
|
+
# @!attribute [r] film_name
|
|
13
|
+
# @return [String] the film name
|
|
14
|
+
|
|
15
|
+
# @!method initialize(options)
|
|
16
|
+
# @param [Hash] options options hash
|
|
17
|
+
# @option options [String] :booking_url (nil) buying url for the screening
|
|
18
|
+
# @option options [String] :cinema_name name of the cinema
|
|
19
|
+
# @option options [String] :cinema_id website id of the cinema
|
|
20
|
+
# @option options [String] :dimension ('2d') dimension of the screening
|
|
21
|
+
# @option options [String] :film_name name of the film
|
|
22
|
+
# @option options [Time] :starting_at listed start time of the performance
|
|
23
|
+
# @option options [Cinebase::Festival] :festival (nil) festival edition
|
|
24
|
+
# @option options [Integer] :film_year (nil) year from the film's title
|
|
25
|
+
# @option options [String] :language (nil) audio language of the screening
|
|
26
|
+
|
|
27
|
+
# Performances within an inclusive range of cinema business dates.
|
|
28
|
+
# @param [String] cinema_id id of the cinema, e.g. 'ALD1'
|
|
29
|
+
# @param [Date] from first date (defaults to today in London)
|
|
30
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
31
|
+
# @return [Array<CurzonUk::Performance>]
|
|
32
|
+
def self.at(
|
|
33
|
+
cinema_id,
|
|
34
|
+
from: TZInfo::Timezone.get('Europe/London').now.to_date,
|
|
35
|
+
to: from + 13
|
|
36
|
+
)
|
|
37
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
38
|
+
|
|
39
|
+
api = CurzonUk::Internal::ApiResponse.new
|
|
40
|
+
cinema = Cinema.new(cinema_id)
|
|
41
|
+
(from..to).flat_map do |date|
|
|
42
|
+
performances_on(api, cinema, date)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @!method showing_on
|
|
47
|
+
# The date of the screening
|
|
48
|
+
# @return [Date]
|
|
49
|
+
|
|
50
|
+
# @!method starting_at
|
|
51
|
+
# UTC time of the screening
|
|
52
|
+
# @return [Time]
|
|
53
|
+
|
|
54
|
+
# @!method variant
|
|
55
|
+
# The kinds of screening (IMAX, kids, baby, senior)
|
|
56
|
+
# @return <Array[String]>
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# private
|
|
60
|
+
|
|
61
|
+
def self.performances_on(api, cinema, date)
|
|
62
|
+
response = api.showtimes(cinema.id, date)
|
|
63
|
+
related = response.fetch('relatedData')
|
|
64
|
+
films = by_id(related.fetch('films'))
|
|
65
|
+
attributes = by_id(related.fetch('attributes'))
|
|
66
|
+
|
|
67
|
+
response.fetch('showtimes').map do |data|
|
|
68
|
+
showtime = CurzonUk::Internal::Parser::Api::Showtime.new(
|
|
69
|
+
data, films: films, attributes: attributes
|
|
70
|
+
)
|
|
71
|
+
new(showtime.to_hash.merge(cinema_id: cinema.id,
|
|
72
|
+
cinema_name: cinema.name))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
private_class_method :performances_on
|
|
76
|
+
|
|
77
|
+
def self.by_id(items)
|
|
78
|
+
items.to_h { |item| [item.fetch('id'), item] }
|
|
79
|
+
end
|
|
80
|
+
private_class_method :by_id
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/curzon_uk.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
require 'cinebase'
|
|
4
|
+
require 'cinebase/vista'
|
|
5
|
+
|
|
6
|
+
require_relative './curzon_uk/version'
|
|
7
|
+
|
|
8
|
+
require_relative './curzon_uk/internal/api_response'
|
|
9
|
+
require_relative './curzon_uk/internal/guest_token'
|
|
10
|
+
require_relative './curzon_uk/internal/title_sanitizer'
|
|
11
|
+
|
|
12
|
+
require_relative './curzon_uk/internal/parser/api/showtime'
|
|
13
|
+
|
|
14
|
+
require_relative './curzon_uk/cinema'
|
|
15
|
+
require_relative './curzon_uk/performance'
|
|
16
|
+
|
|
17
|
+
# curzon_uk gem
|
|
18
|
+
module CurzonUk
|
|
19
|
+
AuthenticationError = Cinebase::Vista::AuthenticationError
|
|
20
|
+
|
|
21
|
+
# A raw guest JWT, without the "Bearer " prefix. Never persisted by the gem.
|
|
22
|
+
class << self
|
|
23
|
+
attr_accessor :token
|
|
24
|
+
|
|
25
|
+
# Explicit acquisition/renewal; ordinary API calls never launch a subprocess.
|
|
26
|
+
def acquire_token!(curl: nil)
|
|
27
|
+
self.token = Internal::GuestToken.new(curl: curl).fetch
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"businessDate": "2026-10-24",
|
|
3
|
+
"showtimes": [
|
|
4
|
+
{
|
|
5
|
+
"id": "SOH1-1001",
|
|
6
|
+
"filmId": "FILM-1",
|
|
7
|
+
"schedule": { "startsAt": "2026-10-24T13:00:00+01:00" },
|
|
8
|
+
"attributeIds": [],
|
|
9
|
+
"requires3dGlasses": false
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "SOH1-1002",
|
|
13
|
+
"filmId": "FILM-1",
|
|
14
|
+
"schedule": { "startsAt": "2026-10-24T16:00:00+01:00" },
|
|
15
|
+
"attributeIds": ["A-CAPT"],
|
|
16
|
+
"requires3dGlasses": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "SOH1-1003",
|
|
20
|
+
"filmId": "FILM-2",
|
|
21
|
+
"schedule": { "startsAt": "2026-10-24T18:30:00+01:00" },
|
|
22
|
+
"attributeIds": ["A-DOC", "A-QA"],
|
|
23
|
+
"requires3dGlasses": false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "SOH1-1004",
|
|
27
|
+
"filmId": "FILM-3",
|
|
28
|
+
"schedule": { "startsAt": "2026-10-24T10:30:00+01:00" },
|
|
29
|
+
"attributeIds": ["A-BABY", "A-CLUB"],
|
|
30
|
+
"requires3dGlasses": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "SOH1-1005",
|
|
34
|
+
"filmId": "FILM-4",
|
|
35
|
+
"schedule": { "startsAt": "2026-10-25T00:15:00+01:00" },
|
|
36
|
+
"attributeIds": [],
|
|
37
|
+
"requires3dGlasses": false
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"relatedData": {
|
|
41
|
+
"films": [
|
|
42
|
+
{ "id": "FILM-1", "title": { "text": "The Example Film" } },
|
|
43
|
+
{ "id": "FILM-2", "title": { "text": "DocHouse: A Made Up Documentary (2025)" } },
|
|
44
|
+
{ "id": "FILM-3", "title": { "text": "Film Club: Pretend Classic" } },
|
|
45
|
+
{ "id": "FILM-4", "title": { "text": "UKJFF - Imaginary Picture" } }
|
|
46
|
+
],
|
|
47
|
+
"attributes": [
|
|
48
|
+
{ "id": "A-CAPT", "shortName": { "text": "Captioned" }, "name": { "text": "Open Captioned" } },
|
|
49
|
+
{ "id": "A-DOC", "shortName": { "text": "Dochouse" }, "name": { "text": "Programmed by Example Dochouse" } },
|
|
50
|
+
{ "id": "A-QA", "shortName": { "text": "Q&A" }, "name": { "text": "Screening followed by a Q&A" } },
|
|
51
|
+
{ "id": "A-BABY", "shortName": { "text": "Baby Club" }, "name": { "text": "Baby Club" } },
|
|
52
|
+
{ "id": "A-CLUB", "shortName": { "text": "Film Club" }, "name": { "text": "Film Club" } }
|
|
53
|
+
],
|
|
54
|
+
"sites": [
|
|
55
|
+
{ "id": "SOH1", "name": { "text": "Soho" } }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|