vue_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 +21 -0
- data/.github/workflows/release.yml +19 -0
- data/.gitignore +18 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +10 -0
- data/COMM-LICENSE +22 -0
- data/Gemfile +3 -0
- data/LICENSE +617 -0
- data/README.md +141 -0
- data/Rakefile +35 -0
- data/lib/vue_uk/cinema.rb +115 -0
- data/lib/vue_uk/internal/api_response.rb +51 -0
- data/lib/vue_uk/internal/http.rb +42 -0
- data/lib/vue_uk/internal/parser/api/showtime.rb +73 -0
- data/lib/vue_uk/internal/title_sanitizer.rb +31 -0
- data/lib/vue_uk/internal/website.rb +22 -0
- data/lib/vue_uk/performance.rb +70 -0
- data/lib/vue_uk/version.rb +6 -0
- data/lib/vue_uk.rb +19 -0
- data/test/fixtures/api/cinemas.json +42 -0
- data/test/fixtures/api/films-10051.json +103 -0
- data/test/fixtures/website/cinema-10051.html +8 -0
- data/test/lib/vue_uk/cinema_test.rb +110 -0
- data/test/lib/vue_uk/internal/api_response_test.rb +68 -0
- data/test/lib/vue_uk/internal/fixture_shape_test.rb +18 -0
- data/test/lib/vue_uk/internal/parser/api/showtime_test.rb +44 -0
- data/test/lib/vue_uk/internal/title_sanitizer_test.rb +30 -0
- data/test/lib/vue_uk/internal/website_test.rb +22 -0
- data/test/lib/vue_uk/performance_test.rb +108 -0
- data/test/lib/vue_uk/version_test.rb +7 -0
- data/test/live/api_shape_test.rb +17 -0
- data/test/live/integration_test.rb +25 -0
- data/test/live/live_helper.rb +9 -0
- data/test/support/api_shapes.rb +38 -0
- data/test/support/fakes.rb +31 -0
- data/test/support/fixtures_helper.rb +21 -0
- data/test/test_helper.rb +20 -0
- data/vue_uk.gemspec +30 -0
- metadata +195 -0
data/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# VueUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull cinemas and performances from the API behind the [Vue cinemas website](https://www.myvue.com) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'vue_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install vue_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 language versions, years, screening dates and festival prefixes.
|
|
24
|
+
|
|
25
|
+
No token or setup is needed. The API answers uncached requests only within the
|
|
26
|
+
guest session the website starts for every visitor, so the gem starts one with
|
|
27
|
+
`POST /api/microservice/auth/token` and sends its cookies, starting a fresh one
|
|
28
|
+
once if the API rejects it.
|
|
29
|
+
|
|
30
|
+
### Cinema
|
|
31
|
+
|
|
32
|
+
Only UK cinemas are returned; Vue's euro-priced cinemas are left out. The API
|
|
33
|
+
has no addresses, so the first call to an address method reads the cinema's page
|
|
34
|
+
on myvue.com.
|
|
35
|
+
|
|
36
|
+
``` ruby
|
|
37
|
+
VueUk::Cinema.all
|
|
38
|
+
#=> [<VueUk::Cinema ...>, <VueUk::Cinema ...>, ...]
|
|
39
|
+
|
|
40
|
+
cinema = VueUk::Cinema.new(10051)
|
|
41
|
+
#=> <VueUk::Cinema ...>
|
|
42
|
+
|
|
43
|
+
cinema.adr
|
|
44
|
+
#=> {
|
|
45
|
+
street_address: 'The Viaduct',
|
|
46
|
+
extended_address: 'Hyndburn Road',
|
|
47
|
+
locality: 'Accrington',
|
|
48
|
+
region: nil,
|
|
49
|
+
postal_code: 'BB5 1QF',
|
|
50
|
+
country_name: 'United Kingdom'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cinema.brand
|
|
54
|
+
#=> 'Vue'
|
|
55
|
+
|
|
56
|
+
cinema.full_name
|
|
57
|
+
#=> 'Vue Accrington'
|
|
58
|
+
|
|
59
|
+
cinema.id
|
|
60
|
+
#=> '10051'
|
|
61
|
+
|
|
62
|
+
cinema.url
|
|
63
|
+
#=> 'https://www.myvue.com/cinema/accrington'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Performances
|
|
67
|
+
|
|
68
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
69
|
+
defaulting to today in London and the following 13 days. It makes one request
|
|
70
|
+
for all of the cinema's advertised dates and keeps those in the range.
|
|
71
|
+
|
|
72
|
+
``` ruby
|
|
73
|
+
VueUk::Performance.at(10051, from: Date.today, to: Date.today + 2)
|
|
74
|
+
#=> [<VueUk::Performance ...>, <VueUk::Performance ...>, ...]
|
|
75
|
+
|
|
76
|
+
performance = VueUk::Performance.at(10051).first
|
|
77
|
+
#=> <VueUk::Performance ...>
|
|
78
|
+
|
|
79
|
+
performance.film_name
|
|
80
|
+
#=> 'Clayface'
|
|
81
|
+
|
|
82
|
+
performance.language
|
|
83
|
+
#=> 'English' # Vue tags English audio too
|
|
84
|
+
|
|
85
|
+
performance.film_year
|
|
86
|
+
#=> nil # from the title, e.g. 2026 for 'Street Fighter (2026)'
|
|
87
|
+
|
|
88
|
+
performance.dimension
|
|
89
|
+
#=> '2d'
|
|
90
|
+
|
|
91
|
+
performance.variant
|
|
92
|
+
#=> ['audio_described']
|
|
93
|
+
|
|
94
|
+
performance.subtitled?
|
|
95
|
+
#=> false
|
|
96
|
+
|
|
97
|
+
performance.festival
|
|
98
|
+
#=> nil # e.g. #<data Cinebase::Festival name="CFF", year=2026>
|
|
99
|
+
|
|
100
|
+
performance.starting_at
|
|
101
|
+
#=> 2026-10-23 16:00:00 UTC
|
|
102
|
+
|
|
103
|
+
performance.booking_url
|
|
104
|
+
#=> 'https://www.myvue.com/book-tickets/summary/10051/HO00020288/139988'
|
|
105
|
+
|
|
106
|
+
performance.cinema_name
|
|
107
|
+
#=> 'Accrington'
|
|
108
|
+
|
|
109
|
+
performance.cinema_id
|
|
110
|
+
#=> '10051'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Variants are `arts`, `audio_described`, `autism_friendly`, `kids` and
|
|
114
|
+
`subtitled`.
|
|
115
|
+
|
|
116
|
+
## Tests
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bundle exec rake # unit tests against fixtures
|
|
120
|
+
bundle exec rake live # live myvue.com
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Fixtures in `test/fixtures` are hand-written: invented films and addresses in
|
|
124
|
+
the shape of Vue's responses, never recorded output. `test/support/api_shapes.rb`
|
|
125
|
+
describes the fields the gem reads. The unit suite checks the fixtures against
|
|
126
|
+
it and the live suite checks real responses, held only in memory. When the API
|
|
127
|
+
changes, update the shape first, then the fixtures.
|
|
128
|
+
|
|
129
|
+
## Releasing
|
|
130
|
+
|
|
131
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
132
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
133
|
+
RubyGems trusted publishing.
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
1. Fork it
|
|
138
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
139
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
140
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
141
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env rake
|
|
2
|
+
require 'bundler/gem_tasks'
|
|
3
|
+
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new do |t|
|
|
7
|
+
t.libs << 'lib/vue_uk'
|
|
8
|
+
t.test_files = FileList[
|
|
9
|
+
'test/lib/vue_uk/*_test.rb',
|
|
10
|
+
'test/lib/vue_uk/internal/*_test.rb',
|
|
11
|
+
'test/lib/vue_uk/internal/parser/api/*_test.rb'
|
|
12
|
+
]
|
|
13
|
+
t.verbose = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Rake::TestTask.new do |t|
|
|
17
|
+
t.libs << 'lib/vue_uk'
|
|
18
|
+
t.name = :live
|
|
19
|
+
t.test_files = FileList[
|
|
20
|
+
'test/live/*_test.rb'
|
|
21
|
+
]
|
|
22
|
+
t.verbose = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# http://erniemiller.org/2014/02/05/7-lines-every-gems-rakefile-should-have/
|
|
26
|
+
desc 'run gem console'
|
|
27
|
+
task :console do
|
|
28
|
+
require 'irb'
|
|
29
|
+
require 'irb/completion'
|
|
30
|
+
require 'vue_uk'
|
|
31
|
+
ARGV.clear
|
|
32
|
+
IRB.start
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
task default: :test
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module VueUk
|
|
2
|
+
# The object representing a cinema on the Vue website
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the Vue id of the cinema, e.g. '10051'
|
|
6
|
+
|
|
7
|
+
# UK postcode, the last line of an address
|
|
8
|
+
POSTCODE = /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/i.freeze
|
|
9
|
+
|
|
10
|
+
# A county and postcode on one line, e.g. 'Essex CO4 5UP'
|
|
11
|
+
REGION_AND_POSTCODE = /\A(.+?)\s+([A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2})\z/i.freeze
|
|
12
|
+
|
|
13
|
+
# Map coordinates some cinemas list in place of a postcode
|
|
14
|
+
COORDINATES = /\A-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?\z/.freeze
|
|
15
|
+
|
|
16
|
+
# Constructor
|
|
17
|
+
# @param [Integer, String] id the cinema id of the format 10051/'10051'
|
|
18
|
+
# @return [VueUk::Cinema]
|
|
19
|
+
def initialize(id)
|
|
20
|
+
@id = id.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Return basic cinema information for all UK cinemas
|
|
24
|
+
# @return [Array<VueUk::Cinema>]
|
|
25
|
+
# @example
|
|
26
|
+
# VueUk::Cinema.all
|
|
27
|
+
# #=> [<VueUk::Cinema>, <VueUk::Cinema>, ...]
|
|
28
|
+
def self.all
|
|
29
|
+
cinemas_hash.keys.map { |id| new(id) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @!method address
|
|
33
|
+
# Address of the cinema
|
|
34
|
+
# @return [Hash] of different address parts
|
|
35
|
+
# @see #adr
|
|
36
|
+
|
|
37
|
+
# Address of the cinema, read from its page on the Vue website
|
|
38
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
39
|
+
# :locality, :region, :postal_code, :country_name
|
|
40
|
+
# @example
|
|
41
|
+
# cinema = VueUk::Cinema.new(10051)
|
|
42
|
+
# cinema.adr
|
|
43
|
+
# #=> {
|
|
44
|
+
# street_address: 'The Viaduct',
|
|
45
|
+
# extended_address: 'Hyndburn Road',
|
|
46
|
+
# locality: 'Accrington',
|
|
47
|
+
# region: nil,
|
|
48
|
+
# postal_code: 'BB5 1QF',
|
|
49
|
+
# country_name: 'United Kingdom'
|
|
50
|
+
# }
|
|
51
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
52
|
+
def adr
|
|
53
|
+
lines = address_lines
|
|
54
|
+
if lines.last&.match?(POSTCODE)
|
|
55
|
+
postal_code = lines.pop
|
|
56
|
+
elsif (match = lines.last&.match(REGION_AND_POSTCODE))
|
|
57
|
+
lines.pop
|
|
58
|
+
region, postal_code = match.captures
|
|
59
|
+
end
|
|
60
|
+
street_address = lines.shift
|
|
61
|
+
locality = lines.pop
|
|
62
|
+
{
|
|
63
|
+
street_address: street_address,
|
|
64
|
+
extended_address: lines.empty? ? nil : lines.join(', '),
|
|
65
|
+
locality: locality,
|
|
66
|
+
region: region,
|
|
67
|
+
postal_code: postal_code,
|
|
68
|
+
country_name: 'United Kingdom'.freeze
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Brand of the cinema
|
|
73
|
+
# @return [String] which will always be 'Vue'
|
|
74
|
+
def brand
|
|
75
|
+
'Vue'.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The name of the cinema
|
|
79
|
+
# @return [String]
|
|
80
|
+
# @example
|
|
81
|
+
# VueUk::Cinema.new(10051).name
|
|
82
|
+
# #=> 'Accrington'
|
|
83
|
+
def name
|
|
84
|
+
cinema_hash['cinemaName'].to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The url of the cinema on the Vue website
|
|
88
|
+
# @return [String]
|
|
89
|
+
def url
|
|
90
|
+
cinema_hash['whatsOnUrl'].to_s.delete_suffix('/whats-on')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @api private
|
|
94
|
+
# Leaves out Vue's euro-priced cinemas outside the UK.
|
|
95
|
+
def self.cinemas_hash
|
|
96
|
+
@cinemas_hash ||= VueUk::Internal::ApiResponse.new.cinemas
|
|
97
|
+
.select { |cinema| cinema['venueCurrency'] == 'GBP' }
|
|
98
|
+
.to_h { |cinema| [cinema.fetch('cinemaId'), cinema] }
|
|
99
|
+
end
|
|
100
|
+
private_class_method :cinemas_hash
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def address_lines
|
|
105
|
+
@page ||= VueUk::Internal::Website.new.cinema(cinema_hash.fetch('whatsOnUrl'))
|
|
106
|
+
@page.dig('cinemaAddress', 'value').to_s.split(/\r?\n/)
|
|
107
|
+
.map { |line| line.strip.sub(/,\s*(UK|United Kingdom)\z/i, '') }
|
|
108
|
+
.reject { |line| line.empty? || line.match?(COORDINATES) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cinema_hash
|
|
112
|
+
@cinema_hash ||= self.class.send(:cinemas_hash).fetch(id, {})
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module VueUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Fetches cinema and showing data from the API behind myvue.com
|
|
10
|
+
class ApiResponse
|
|
11
|
+
include Http
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://www.myvue.com/api/microservice/"
|
|
14
|
+
# The query the website itself sends; embargo level 3 is public listings
|
|
15
|
+
FILM_QUERY = {
|
|
16
|
+
"minEmbargoLevel" => 3, "includesSession" => true, "includeSessionAttributes" => true
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# @return [Array<Hash>] cinemas
|
|
20
|
+
def cinemas
|
|
21
|
+
fetch("showings/cinemas").fetch("result").flat_map { |group| group.fetch("cinemas") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param [String] cinema_id e.g. '10051'
|
|
25
|
+
# @return [Array<Hash>] films with their sessions on every advertised date
|
|
26
|
+
def films(cinema_id)
|
|
27
|
+
fetch("showings/cinemas/#{cinema_id}/films", FILM_QUERY).fetch("result")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Uncached responses need the guest session the website starts for
|
|
33
|
+
# every visitor; start a fresh one once if it is rejected.
|
|
34
|
+
def fetch(path, query = {})
|
|
35
|
+
url = BASE_URL + path
|
|
36
|
+
url += "?#{URI.encode_www_form(query)}" unless query.empty?
|
|
37
|
+
retried = false
|
|
38
|
+
begin
|
|
39
|
+
@session ||= post_for_cookies("#{BASE_URL}auth/token", accept: "application/json")
|
|
40
|
+
JSON.parse(get(url, accept: "application/json", cookie: @session))
|
|
41
|
+
rescue Unauthorized
|
|
42
|
+
raise if retried
|
|
43
|
+
|
|
44
|
+
retried = true
|
|
45
|
+
@session = nil
|
|
46
|
+
retry
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module VueUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to myvue.com
|
|
10
|
+
module Http
|
|
11
|
+
class Unauthorized < StandardError; end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def get(url, accept:, cookie: nil)
|
|
16
|
+
request = Net::HTTP::Get.new(URI(url))
|
|
17
|
+
request["Cookie"] = cookie if cookie
|
|
18
|
+
perform(request, accept: accept).body
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] the response's cookies as a Cookie header value
|
|
22
|
+
def post_for_cookies(url, accept:)
|
|
23
|
+
response = perform(Net::HTTP::Post.new(URI(url)), accept: accept)
|
|
24
|
+
response.get_fields("Set-Cookie").to_a.map { |cookie| cookie.split(";").first }.join("; ")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def perform(request, accept:)
|
|
28
|
+
request["Accept"] = accept
|
|
29
|
+
uri = request.uri
|
|
30
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
|
31
|
+
open_timeout: 10, read_timeout: 30) do |http|
|
|
32
|
+
http.request(request)
|
|
33
|
+
end
|
|
34
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
35
|
+
raise Unauthorized, "Vue HTTP error 401" if response.is_a?(Net::HTTPUnauthorized)
|
|
36
|
+
raise "Vue HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
37
|
+
|
|
38
|
+
response
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module VueUk
|
|
6
|
+
# @api private
|
|
7
|
+
module Internal
|
|
8
|
+
module Parser
|
|
9
|
+
module Api
|
|
10
|
+
# Parses a single Vue session into performance attributes
|
|
11
|
+
class Showtime
|
|
12
|
+
BOOKING_HOST = "https://www.myvue.com"
|
|
13
|
+
|
|
14
|
+
# Vue attribute names
|
|
15
|
+
VARIANTS = {
|
|
16
|
+
"Audio Description (AD)" => "audio_described",
|
|
17
|
+
"Autism Friendly" => "autism_friendly",
|
|
18
|
+
"Big Screen Events" => "arts",
|
|
19
|
+
"Big Screen Music" => "arts",
|
|
20
|
+
"Big Screen Musicals" => "arts",
|
|
21
|
+
"Big Shorts" => "kids",
|
|
22
|
+
"Event" => "arts",
|
|
23
|
+
"Mighty Mornings" => "kids",
|
|
24
|
+
"Open Captioned" => "subtitled"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Vue tags English audio too, unlike the other chains
|
|
28
|
+
LANGUAGES = (Cinebase::TitleSanitizer::LANGUAGES + ["English"]).freeze
|
|
29
|
+
|
|
30
|
+
# @param [Hash] session one session
|
|
31
|
+
# @param [Hash] film the film the session shows
|
|
32
|
+
def initialize(session, film:)
|
|
33
|
+
@session = session
|
|
34
|
+
@film = film
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash] options for VueUk::Performance.new
|
|
38
|
+
def to_hash
|
|
39
|
+
{
|
|
40
|
+
booking_url: booking_url,
|
|
41
|
+
dimension: attribute_names.any? { |name| name.match?(/\b3D\b/i) } ? "3d" : "2d",
|
|
42
|
+
festival: Cinebase::Festival.from_title(title, year: starting_at.year),
|
|
43
|
+
film_name: TitleSanitizer.new(title).sanitized,
|
|
44
|
+
film_year: Cinebase::TitleSanitizer.film_year(title),
|
|
45
|
+
language: attribute_names.find { |name| LANGUAGES.include?(name) },
|
|
46
|
+
starting_at: starting_at,
|
|
47
|
+
variant: attribute_names.filter_map { |name| VARIANTS[name] }.uniq
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def attribute_names
|
|
54
|
+
@attribute_names ||= @session.fetch("attributes", []).map { |attribute| attribute["name"].to_s.strip }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def booking_url
|
|
58
|
+
path = @session["bookingUrl"]
|
|
59
|
+
URI.join(BOOKING_HOST, path).to_s if path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def starting_at
|
|
63
|
+
Time.iso8601(@session.fetch("showTimeWithTimeZone")).utc
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def title
|
|
67
|
+
@film.fetch("filmTitle")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module VueUk
|
|
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 [VueUk::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
|
+
/\s*\(\d{1,2} [A-Z][a-z]+ \d{4}\)/ # screening date
|
|
22
|
+
] + COMMON_REMOVE
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# regexes and their replacements
|
|
26
|
+
def replace
|
|
27
|
+
COMMON_REPLACE
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module VueUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Reads cinema details the API doesn't give from a cinema's page data
|
|
10
|
+
class Website
|
|
11
|
+
include Http
|
|
12
|
+
|
|
13
|
+
# @param [String] url a cinema's what's on page
|
|
14
|
+
# @return [Hash] the page's cinema details, e.g. its cinemaAddress
|
|
15
|
+
def cinema(url)
|
|
16
|
+
html = get(url, accept: "text/html")
|
|
17
|
+
script = html[%r{<script id="__NEXT_DATA__"[^>]*>(.*?)</script>}m, 1].to_s
|
|
18
|
+
JSON.parse(script).dig("props", "pageProps", "layoutData", "sitecore", "context", "cinema") || {}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module VueUk
|
|
2
|
+
# A single screening of a film on the Vue 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 [Cinebase::Festival] :festival (nil) festival edition
|
|
22
|
+
# @option options [String] :film_name name of the film
|
|
23
|
+
# @option options [Integer] :film_year (nil) year from the film's title
|
|
24
|
+
# @option options [String] :language (nil) audio language, e.g. 'English'
|
|
25
|
+
# @option options [Time] :starting_at listed start time of the performance
|
|
26
|
+
|
|
27
|
+
# Performances within an inclusive range of showing dates, from one request
|
|
28
|
+
# for all of the cinema's advertised dates.
|
|
29
|
+
# @param [Integer, String] cinema_id id of the cinema
|
|
30
|
+
# @param [Date] from first date (defaults to today in London)
|
|
31
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
32
|
+
# @return [Array<VueUk::Performance>]
|
|
33
|
+
def self.at(
|
|
34
|
+
cinema_id,
|
|
35
|
+
from: TZInfo::Timezone.get('Europe/London').now.to_date,
|
|
36
|
+
to: from + 13
|
|
37
|
+
)
|
|
38
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
39
|
+
|
|
40
|
+
cinema = Cinema.new(cinema_id)
|
|
41
|
+
VueUk::Internal::ApiResponse.new.films(cinema.id).flat_map do |film|
|
|
42
|
+
sessions_between(film, from, to).map do |session|
|
|
43
|
+
showtime = VueUk::Internal::Parser::Api::Showtime.new(session, film: film)
|
|
44
|
+
new(showtime.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @!method showing_on
|
|
50
|
+
# The date of the screening
|
|
51
|
+
# @return [Date]
|
|
52
|
+
|
|
53
|
+
# @!method starting_at
|
|
54
|
+
# UTC time of the screening
|
|
55
|
+
# @return [Time]
|
|
56
|
+
|
|
57
|
+
# @!method variant
|
|
58
|
+
# The kinds of screening (arts, audio_described, kids, subtitled ...)
|
|
59
|
+
# @return <Array[String]>
|
|
60
|
+
|
|
61
|
+
# private
|
|
62
|
+
|
|
63
|
+
def self.sessions_between(film, from, to)
|
|
64
|
+
film.fetch('showingGroups', [])
|
|
65
|
+
.select { |group| (from..to).cover?(Date.iso8601(group.fetch('date')[0, 10])) }
|
|
66
|
+
.flat_map { |group| group.fetch('sessions', []) }
|
|
67
|
+
end
|
|
68
|
+
private_class_method :sessions_between
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/vue_uk.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
require 'cinebase'
|
|
4
|
+
|
|
5
|
+
require_relative './vue_uk/version'
|
|
6
|
+
|
|
7
|
+
require_relative './vue_uk/internal/http'
|
|
8
|
+
require_relative './vue_uk/internal/api_response'
|
|
9
|
+
require_relative './vue_uk/internal/website'
|
|
10
|
+
require_relative './vue_uk/internal/title_sanitizer'
|
|
11
|
+
|
|
12
|
+
require_relative './vue_uk/internal/parser/api/showtime'
|
|
13
|
+
|
|
14
|
+
require_relative './vue_uk/cinema'
|
|
15
|
+
require_relative './vue_uk/performance'
|
|
16
|
+
|
|
17
|
+
# vue_uk gem
|
|
18
|
+
module VueUk
|
|
19
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"result": [
|
|
3
|
+
{
|
|
4
|
+
"alpha": "A",
|
|
5
|
+
"cinemas": [
|
|
6
|
+
{
|
|
7
|
+
"cinemaId": "10051",
|
|
8
|
+
"cinemaName": "Accrington",
|
|
9
|
+
"fullName": "Accrington - Example Viaduct",
|
|
10
|
+
"venueCurrency": "GBP",
|
|
11
|
+
"whatsOnUrl": "https://www.myvue.com/cinema/accrington/whats-on"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"alpha": "B",
|
|
17
|
+
"cinemas": [
|
|
18
|
+
{
|
|
19
|
+
"cinemaId": "10015",
|
|
20
|
+
"cinemaName": "Birmingham (Star City)",
|
|
21
|
+
"fullName": "Birmingham - Example Park",
|
|
22
|
+
"venueCurrency": "GBP",
|
|
23
|
+
"whatsOnUrl": "https://www.myvue.com/cinema/birmingham-star-city/whats-on"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"alpha": "E",
|
|
29
|
+
"cinemas": [
|
|
30
|
+
{
|
|
31
|
+
"cinemaId": "90001",
|
|
32
|
+
"cinemaName": "Example Euro",
|
|
33
|
+
"fullName": "Example Euro - Elsewhere",
|
|
34
|
+
"venueCurrency": "EUR",
|
|
35
|
+
"whatsOnUrl": "https://www.myvue.com/cinema/example-euro/whats-on"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"responseCode": 0,
|
|
41
|
+
"errorMessage": null
|
|
42
|
+
}
|