reel_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.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # ReelUk
2
+
3
+ A simple gem to pull cinemas and performances from the [Reel Cinemas website](https://reel-cinemas.co.uk) and spit out useful formatted info.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'reel_uk'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install reel_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 subtitled, 3D
24
+ and premium screen copies, preview prefixes, anniversaries and years, and
25
+ naming National Theatre Live and Royal Ballet and Opera screenings in full.
26
+
27
+ No token or setup is needed. Reel is moving from Admit One's A1Web website,
28
+ `reelcinemas.co.uk`, to a WordPress website, `reel-cinemas.co.uk`, built on
29
+ Jacro's cinema plugin with TapOS ticketing. Most venues have moved. The cinema
30
+ menu on the new home page links each venue's pages on whichever website it
31
+ uses, and the gem reads each venue from there: the new website itself, or the
32
+ old one through cinebase's shared `Cinebase::A1web` support. A venue that
33
+ moves keeps its id, its path on both websites.
34
+
35
+ ### Cinema
36
+
37
+ The venue list is cached for an hour. Reading it takes one request for the home
38
+ page. Venue pages give one-line addresses and no coordinates, so the gem holds
39
+ them: addresses from the page footers and coordinates from OpenStreetMap,
40
+ apart from Burnley's, which is not mapped and comes from its postcode.
41
+
42
+ ``` ruby
43
+ ReelUk::Cinema.all
44
+ #=> [<ReelUk::Cinema ...>, <ReelUk::Cinema ...>, ...]
45
+
46
+ cinema = ReelUk::Cinema.new('borehamwood')
47
+ #=> <ReelUk::Cinema ...>
48
+
49
+ cinema.adr
50
+ #=> {
51
+ street_address: 'Metropolis Centre',
52
+ extended_address: '84 Shenley Road',
53
+ locality: 'Borehamwood',
54
+ region: 'Hertfordshire',
55
+ postal_code: 'WD6 1EH',
56
+ country_name: 'United Kingdom'
57
+ }
58
+
59
+ cinema.brand
60
+ #=> 'Reel'
61
+
62
+ cinema.full_name
63
+ #=> 'Reel Borehamwood'
64
+
65
+ cinema.id
66
+ #=> 'borehamwood'
67
+
68
+ cinema.latitude
69
+ #=> 51.6549265
70
+
71
+ cinema.url
72
+ #=> 'https://reel-cinemas.co.uk/borehamwood'
73
+
74
+ cinema.a1web?
75
+ #=> false # true for venues still on reelcinemas.co.uk
76
+ ```
77
+
78
+ ### Performances
79
+
80
+ `Performance.at` returns performances for an inclusive range of showing dates,
81
+ defaulting to today in London and the following 13 days. It always reads every
82
+ advertised date.
83
+
84
+ On the WordPress website the what's on page covers three weeks and the coming
85
+ soon page shows only a film's first few showtimes. The gem reads both, then
86
+ asks the quick booking menu for every showtime of each film on either page:
87
+ three requests and one more for each film, about forty for a venue, which
88
+ takes half a minute. On the old A1Web website the what's on page holds every
89
+ date, so it is one request.
90
+
91
+ ``` ruby
92
+ ReelUk::Performance.at('chorley', from: Date.today, to: Date.today + 2)
93
+ #=> [<ReelUk::Performance ...>, <ReelUk::Performance ...>, ...]
94
+
95
+ performance = ReelUk::Performance.at('chorley').detect(&:subtitled?)
96
+ #=> <ReelUk::Performance ...>
97
+
98
+ performance.film_name
99
+ #=> 'Wicker' # from 'Wicker - Subtitled Screening'
100
+
101
+ performance.film_year
102
+ #=> nil # from the title, e.g. 1981 for 'The Evil Dead (1981)'
103
+
104
+ performance.language
105
+ #=> nil # e.g. 'Hindi' from 'Ramayana: Rise of a Legend: 3D (Hindi)'
106
+
107
+ performance.dimension
108
+ #=> '2d'
109
+
110
+ performance.variant
111
+ #=> ['subtitled']
112
+
113
+ performance.subtitled?
114
+ #=> true
115
+
116
+ performance.starting_at
117
+ #=> 2026-11-03 17:15:00 UTC
118
+
119
+ performance.booking_url
120
+ #=> 'https://reel-cinemas.co.uk/chorley/booknow/34324/'
121
+
122
+ performance.cinema_name
123
+ #=> 'Chorley'
124
+
125
+ performance.cinema_id
126
+ #=> 'chorley'
127
+ ```
128
+
129
+ Booking links on the WordPress website open the TapOS booking for the
130
+ showtime in the venue's pages. Venues on the old website link to their Admit
131
+ One ticketing site, such as
132
+ `https://reelhull.admit-one.co.uk/tickets/?perfCode=34037`, which redirects to
133
+ seat selection; the old website's own booking links only work in a browser
134
+ that has just visited the venue's pages.
135
+
136
+ Variants are `arts` (event cinema, from A1Web's 'Event Cinema' heading or, on
137
+ the WordPress website, from titles such as 'RBO' and 'NT Live'), `baby`,
138
+ `imax`, `kids`, `relaxed`, `senior` and `subtitled`. Showtime labels such as
139
+ 'Senior' are only on the WordPress website's what's on page, so showtimes
140
+ beyond its three weeks have none until they come into range. 'Movie Madness'
141
+ marks cheap tickets and is not a variant.
142
+
143
+ ## Tests
144
+
145
+ ```sh
146
+ bundle exec rake # unit tests against fixtures
147
+ bundle exec rake live # live reel-cinemas.co.uk and reelcinemas.co.uk, several minutes
148
+ ```
149
+
150
+ Fixtures in `test/fixtures` are captured from both websites and trimmed: the
151
+ home page's cinema menu, Chorley's quick booking menu, what's on page and
152
+ coming soon page with a few films on a few dates, the quick booking menu's
153
+ showtimes for three films, and Hull's A1Web what's on page with a few films on
154
+ a few dates. `test/support/api_shapes.rb` describes the parts of the pages the
155
+ gem reads. The unit suite checks the fixtures against it and the live suite
156
+ checks real responses, held only in memory. When the websites change, update
157
+ the shape first, then the fixtures.
158
+
159
+ ## Releasing
160
+
161
+ Bump the version, merge, then run the **Release Gem** workflow in GitHub
162
+ Actions. It runs the tests, tags the version and publishes the gem through
163
+ RubyGems trusted publishing.
164
+
165
+ ## Contributing
166
+
167
+ 1. Fork it
168
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
169
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
170
+ 4. Push to the branch (`git push origin my-new-feature`)
171
+ 5. Create new Pull Request
@@ -0,0 +1,201 @@
1
+ module ReelUk
2
+ # The object representing a cinema on the Reel Cinemas website
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [String] the venue's path on the website, e.g. 'chorley'
6
+
7
+ # Worker processes outlive a day's listings, so refetch the venue list
8
+ CACHE_SECONDS = 3600
9
+
10
+ # Most venues have moved to Reel's WordPress website. The rest are still
11
+ # on its old Admit One A1Web website, which the cinema menu links.
12
+ VENUE = %r{<option value="(https://(?:reel-cinemas|reelcinemas)\.co\.uk/([a-z-]+))/?">([^<]+)</option>}
13
+ A1WEB_HOST = 'reelcinemas.co.uk'
14
+ A1WEB_WEBSITE = 'https://reelcinemas.co.uk'
15
+ # Menu names such as 'Fareham - Hampshire' add the county
16
+ COUNTY = /\s+-\s+.*\z/
17
+
18
+ # Venue pages give one-line addresses and no coordinates. Addresses come
19
+ # from those page footers, coordinates from OpenStreetMap, apart from
20
+ # Burnley's, from its postcode.
21
+ LOCATIONS = {
22
+ 'bishop-auckland' => {
23
+ street_address: 'Bishop Auckland Retail and Leisure Park', locality: 'Bishop Auckland',
24
+ postal_code: 'DL14 9AA', latitude: 54.6383991, longitude: -1.6885187
25
+ },
26
+ 'blackburn' => {
27
+ street_address: 'Nab Lane', locality: 'Blackburn', region: 'Lancashire', postal_code: 'BB2 1LN',
28
+ latitude: 53.7483439, longitude: -2.4868526
29
+ },
30
+ 'borehamwood' => {
31
+ street_address: 'Metropolis Centre', extended_address: '84 Shenley Road', locality: 'Borehamwood',
32
+ region: 'Hertfordshire', postal_code: 'WD6 1EH', latitude: 51.6549265, longitude: -0.2747628
33
+ },
34
+ 'bridgnorth' => {
35
+ street_address: 'Whitburn Street', locality: 'Bridgnorth', region: 'Shropshire', postal_code: 'WV16 4QP',
36
+ latitude: 52.535652, longitude: -2.4222158
37
+ },
38
+ 'burnley' => {
39
+ street_address: 'Unit 1, Pioneer Place', extended_address: 'Curzon Street', locality: 'Burnley',
40
+ postal_code: 'BB11 1FB', latitude: 53.791753, longitude: -2.245629
41
+ },
42
+ 'chippenham' => {
43
+ street_address: 'Marshfield Road', locality: 'Chippenham', region: 'Wiltshire', postal_code: 'SN15 1JR',
44
+ latitude: 51.4618773, longitude: -2.1207797
45
+ },
46
+ 'chorley' => {
47
+ street_address: 'Unit 6, Market Walk Extension', extended_address: 'Union Street', locality: 'Chorley',
48
+ postal_code: 'PR7 1FD', latitude: 53.6539814, longitude: -2.628561
49
+ },
50
+ 'fareham-hampshire' => {
51
+ street_address: '3 Vannes Parade', extended_address: 'Market Quay', locality: 'Fareham',
52
+ region: 'Hampshire', postal_code: 'PO16 0BX', latitude: 50.8519189, longitude: -1.1784328
53
+ },
54
+ 'farnham-surrey' => {
55
+ street_address: '8 Old Market Place', locality: 'Farnham', region: 'Surrey', postal_code: 'GU9 7SF',
56
+ latitude: 51.2154964, longitude: -0.7948056
57
+ },
58
+ 'hull' => {
59
+ street_address: 'St Stephens Shopping Centre', extended_address: '110 Ferensway', locality: 'Hull',
60
+ region: 'North Humberside', postal_code: 'HU2 8LN', latitude: 53.7450491, longitude: -0.3474325
61
+ },
62
+ 'morecambe' => {
63
+ street_address: 'Central Drive', locality: 'Morecambe', postal_code: 'LA4 4DW',
64
+ latitude: 54.0707779, longitude: -2.8743043
65
+ },
66
+ 'port-talbot' => {
67
+ street_address: 'Hollywood Park', locality: 'Port Talbot', postal_code: 'SA12 6QW',
68
+ latitude: 51.5926478, longitude: -3.8123333
69
+ },
70
+ 'quinton' => {
71
+ street_address: 'Hagley Road West', locality: 'Halesowen', region: 'West Midlands', postal_code: 'B62 9AS',
72
+ latitude: 52.4625982, longitude: -2.0141104
73
+ },
74
+ 'rochdale' => {
75
+ street_address: 'Unit 22, Rochdale Riverside', extended_address: 'Riverside Walk', locality: 'Rochdale',
76
+ postal_code: 'OL16 1BE', latitude: 53.6178417, longitude: -2.1558455
77
+ },
78
+ 'wakefield' => {
79
+ street_address: 'Unit 104-107, The Ridings Shopping Centre', extended_address: 'Almshouse Lane',
80
+ locality: 'Wakefield', postal_code: 'WF1 1DS', latitude: 53.6816652, longitude: -1.4960074
81
+ },
82
+ 'widnes' => {
83
+ street_address: 'Unit 4, Venture Fields', extended_address: 'Earle Road', locality: 'Widnes',
84
+ region: 'Cheshire', postal_code: 'WA8 0GY', latitude: 53.3624648, longitude: -2.723302
85
+ }
86
+ }.freeze
87
+
88
+ ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
89
+
90
+ # Constructor
91
+ # @param [String] id the venue's path on the website, e.g. 'chorley'
92
+ # @return [ReelUk::Cinema]
93
+ def initialize(id)
94
+ @id = id.to_s.downcase
95
+ end
96
+
97
+ # Return basic cinema information for all cinemas, from the cinema menu
98
+ # on the home page
99
+ # @return [Array<ReelUk::Cinema>]
100
+ # @example
101
+ # ReelUk::Cinema.all
102
+ # #=> [<ReelUk::Cinema>, <ReelUk::Cinema>, ...]
103
+ def self.all
104
+ venues_hash.keys.map { |id| new(id) }
105
+ end
106
+
107
+ # @return [Cinebase::A1web::Client] for venues still on the old website
108
+ def self.a1web_client
109
+ Cinebase::A1web::Client.new(base_url: A1WEB_WEBSITE)
110
+ end
111
+
112
+ # @!method address
113
+ # Address of the cinema
114
+ # @return [Hash] of different address parts
115
+ # @see #adr
116
+
117
+ # Address of the cinema
118
+ # @return [Hash] contains :street_address, :extended_address,
119
+ # :locality, :region, :postal_code, :country_name
120
+ # @example
121
+ # cinema = ReelUk::Cinema.new('borehamwood')
122
+ # cinema.adr
123
+ # #=> {
124
+ # street_address: 'Metropolis Centre',
125
+ # extended_address: '84 Shenley Road',
126
+ # locality: 'Borehamwood',
127
+ # region: 'Hertfordshire',
128
+ # postal_code: 'WD6 1EH',
129
+ # country_name: 'United Kingdom'
130
+ # }
131
+ # @note Uses method naming as at http://microformats.org/wiki/adr
132
+ def adr
133
+ ADDRESS_PARTS.to_h { |part| [part, location[part]] }.merge(country_name: 'United Kingdom')
134
+ end
135
+
136
+ # @return [Boolean] whether the venue is still on the old A1Web website
137
+ def a1web?
138
+ URI(url).host == A1WEB_HOST
139
+ end
140
+
141
+ # Brand of the cinema
142
+ # @return [String] which will always be 'Reel'
143
+ def brand
144
+ 'Reel'.freeze
145
+ end
146
+
147
+ # @return [Float, nil]
148
+ def latitude
149
+ location[:latitude]
150
+ end
151
+
152
+ # @return [Float, nil]
153
+ def longitude
154
+ location[:longitude]
155
+ end
156
+
157
+ # The name of the cinema
158
+ # @return [String]
159
+ # @example
160
+ # ReelUk::Cinema.new('fareham-hampshire').name
161
+ # #=> 'Fareham'
162
+ def name
163
+ venue[:name].to_s
164
+ end
165
+
166
+ # The url of the cinema's pages on the Reel website
167
+ # @return [String]
168
+ # @example
169
+ # ReelUk::Cinema.new('chorley').url
170
+ # #=> 'https://reel-cinemas.co.uk/chorley'
171
+ # ReelUk::Cinema.new('hull').url
172
+ # #=> 'https://reelcinemas.co.uk/hull'
173
+ def url
174
+ venue[:url].to_s
175
+ end
176
+
177
+ # @api private
178
+ def self.venues_hash
179
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
+ if @venues_hash.nil? || now - @fetched_at > CACHE_SECONDS
181
+ @fetched_at = now
182
+ html = ReelUk::Internal::Website.new.home
183
+ @venues_hash = html.scan(VENUE).to_h do |url, id, name|
184
+ [id, { url: url, name: CGI.unescapeHTML(name).sub(COUNTY, '').strip }]
185
+ end
186
+ end
187
+ @venues_hash
188
+ end
189
+ private_class_method :venues_hash
190
+
191
+ private
192
+
193
+ def location
194
+ LOCATIONS.fetch(id, {})
195
+ end
196
+
197
+ def venue
198
+ self.class.send(:venues_hash).fetch(id, {})
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module ReelUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Requests to reel-cinemas.co.uk
10
+ module Http
11
+ RETRIES = 2
12
+ TRANSIENT_ERRORS = [
13
+ EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
14
+ ].freeze
15
+
16
+ private
17
+
18
+ def get(url)
19
+ perform(Net::HTTP::Get.new(URI(url)))
20
+ end
21
+
22
+ def post(url, form)
23
+ post = Net::HTTP::Post.new(URI(url))
24
+ post.set_form_data(form)
25
+ perform(post)
26
+ end
27
+
28
+ def perform(request)
29
+ uri = request.uri
30
+ attempts = 0
31
+ begin
32
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
33
+ http.request(request)
34
+ end
35
+ rescue *TRANSIENT_ERRORS
36
+ attempts += 1
37
+ raise if attempts > RETRIES
38
+
39
+ sleep(attempts)
40
+ retry
41
+ end
42
+ # Omit response bodies from errors, and never follow redirects.
43
+ raise "Reel HTTP error #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
44
+
45
+ String.new(response.body, encoding: Encoding::UTF_8)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReelUk
4
+ # @api private
5
+ module Internal
6
+ # Every showtime a venue on Reel's WordPress website has on sale. The
7
+ # what's on page covers three weeks and the coming soon page only a
8
+ # film's first few showtimes, so the quick booking menu's request for
9
+ # each film on either page fills in the rest, one request per film.
10
+ # Labels such as 'Senior' are only on the what's on page.
11
+ class Listings
12
+ LOCATION = /attr_loc="(\d+)"/
13
+
14
+ # @param [String] venue_url e.g. 'https://reel-cinemas.co.uk/chorley'
15
+ # @param [ReelUk::Internal::Website] website
16
+ def initialize(venue_url, website: Website.new)
17
+ @venue_url = venue_url
18
+ @website = website
19
+ end
20
+
21
+ # @return [Array<Cinebase::A1web::Showing>] each showtime once
22
+ def showings
23
+ whats_on = Parser::WhatsOn.new(@website.whats_on(@venue_url), venue_url: @venue_url)
24
+ films = Parser::ComingSoon.new(@website.coming_soon(@venue_url)).films.merge(whats_on.films)
25
+ (whats_on.showings + scheduled(films)).uniq(&:booking_url)
26
+ end
27
+
28
+ private
29
+
30
+ # Without a location id, only the what's on page's showtimes are known
31
+ def scheduled(films)
32
+ location = @website.venue(@venue_url)[LOCATION, 1]
33
+ if location
34
+ films.flat_map do |film_id, title|
35
+ html = @website.showtimes(film_id, location)
36
+ Parser::Showtimes.new(html, film_id: film_id, title: title, venue_url: @venue_url).showings
37
+ end
38
+ else
39
+ []
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReelUk
4
+ # @api private
5
+ module Internal
6
+ module Parser
7
+ # Reads the films on a venue's coming soon page, which shows only
8
+ # their first few showtimes
9
+ class ComingSoon
10
+ FILM_BLOCK = '<div class="grid-column">'
11
+ FILM = %r{<h2><a href="[^"]*/film/(\d+)/[^"]*">(.*?)</a></h2>}m
12
+ BOOKABLE = %r{/booknow/\d+}
13
+
14
+ # @param [String] html a coming soon page
15
+ def initialize(html)
16
+ @html = html
17
+ end
18
+
19
+ # @return [Hash{String => String}] titles by Jacro film id, for films
20
+ # with showtimes on sale
21
+ def films
22
+ @html.split(FILM_BLOCK).drop(1).filter_map do |markup|
23
+ film = markup.match(FILM)
24
+ [film[1], CGI.unescapeHTML(film[2]).strip] if film && markup.match?(BOOKABLE)
25
+ end.to_h
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReelUk
4
+ # @api private
5
+ module Internal
6
+ module Parser
7
+ # Reads the quick booking menu's options for one film at one venue,
8
+ # such as '16 Dec 2026 @ 09:30', which name every showtime on sale
9
+ class Showtimes
10
+ OPTION = %r{<option value="[^"]*/booknow/(\d+)/?">(\d{1,2} [A-Z][a-z]{2} \d{4}) @ (\d{1,2}):(\d{2})</option>}
11
+ TIME_ZONE = 'Europe/London'
12
+
13
+ # @param [String] html the menu's options
14
+ # @param [String] film_id Jacro's film id
15
+ # @param [String] title the film's title
16
+ # @param [String] venue_url e.g. 'https://reel-cinemas.co.uk/chorley'
17
+ def initialize(html, film_id:, title:, venue_url:)
18
+ @html = html
19
+ @film_id = film_id
20
+ @title = title
21
+ @venue_url = venue_url
22
+ end
23
+
24
+ # Options link through a 'reel-chorley' path that redirects, so
25
+ # booking links take the what's on page's form
26
+ # @return [Array<Cinebase::A1web::Showing>] without tags
27
+ def showings
28
+ @html.scan(OPTION).map do |booking_id, day, hour, minute|
29
+ date = Date.strptime(day, '%d %b %Y')
30
+ Cinebase::A1web::Showing.new(
31
+ film_id: @film_id, title: @title, date: date, starting_at: starting_at(date, hour, minute),
32
+ booking_url: "#{@venue_url}/booknow/#{booking_id}/", tags: []
33
+ )
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def starting_at(date, hour, minute)
40
+ local = Time.utc(date.year, date.month, date.day, hour.to_i, minute.to_i)
41
+ TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReelUk
4
+ # @api private
5
+ module Internal
6
+ module Parser
7
+ # Reads the films and showtimes on a venue's what's on page, which
8
+ # covers the next three weeks. Each film has a block for each date,
9
+ # with every showtime twice, for large and small screens.
10
+ class WhatsOn
11
+ BLOCK = /<div class="nowshowing_film_box_outer[^"]*?nowshowingslider_(\d{8})">/
12
+ FILM = %r{<h2><a href="[^"]*/film/(\d+)/[^"]*">(.*?)</a></h2>}m
13
+ SHOWTIME = %r{
14
+ <a\shref="[^"]*/booknow/(\d+)/?"[^>]*>\s*(\d{1,2}:\d{2})\s*</a>
15
+ (?:<div\sclass="show_time_label">([^<]*)</div>)?
16
+ }x
17
+ TIME_ZONE = 'Europe/London'
18
+
19
+ # @param [String] html a what's on page
20
+ # @param [String] venue_url e.g. 'https://reel-cinemas.co.uk/chorley'
21
+ def initialize(html, venue_url:)
22
+ @html = html
23
+ @venue_url = venue_url
24
+ end
25
+
26
+ # @return [Hash{String => String}] titles by Jacro film id
27
+ def films
28
+ blocks.to_h { |_date, film_id, title, _markup| [film_id, title] }
29
+ end
30
+
31
+ # @return [Array<Cinebase::A1web::Showing>] each showtime once, with
32
+ # labels such as 'Senior' as tags
33
+ def showings
34
+ all = blocks.flat_map do |date, film_id, title, markup|
35
+ markup.scan(SHOWTIME).map do |booking_id, time, label|
36
+ Cinebase::A1web::Showing.new(
37
+ film_id: film_id, title: title, date: date, starting_at: starting_at(date, time),
38
+ booking_url: "#{@venue_url}/booknow/#{booking_id}/", tags: [label].compact
39
+ )
40
+ end
41
+ end
42
+ all.uniq(&:booking_url)
43
+ end
44
+
45
+ private
46
+
47
+ def blocks
48
+ @blocks ||= @html.split(BLOCK).drop(1).each_slice(2).filter_map do |key, markup|
49
+ film = markup.to_s.match(FILM)
50
+ [Date.strptime(key, '%Y%m%d'), film[1], CGI.unescapeHTML(film[2]).strip, markup] if film
51
+ end
52
+ end
53
+
54
+ def starting_at(date, time)
55
+ hour, minute = time.split(':').map(&:to_i)
56
+ local = Time.utc(date.year, date.month, date.day, hour, minute)
57
+ TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ module ReelUk
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 [ReelUk::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+-\s+Subtitled(?: Screening)?\z/i, # subtitled copy
22
+ /(?::|\s+-)?\s+3D\b/i, # 3D copy, e.g. ': 3D' or ' - 3D'
23
+ /\s+I Infinity Vision\z/i, # premium screen copy
24
+ /\ASee It First\s+-\s+/i # preview
25
+ ] + COMMON_REMOVE
26
+ end
27
+
28
+ # regexes and their replacements
29
+ def replace
30
+ {
31
+ /\ANT Live: (.*)/ => 'National Theatre Live: ',
32
+ /\ARBO 20\d{2}-(?:20)?\d{2}: (.*)/ => 'Royal Ballet and Opera: '
33
+ }.merge(COMMON_REPLACE)
34
+ end
35
+ end
36
+ end
37
+ end