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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/COMM-LICENSE +22 -0
- data/LICENSE +617 -0
- data/README.md +171 -0
- data/lib/reel_uk/cinema.rb +201 -0
- data/lib/reel_uk/internal/http.rb +49 -0
- data/lib/reel_uk/internal/listings.rb +44 -0
- data/lib/reel_uk/internal/parser/coming_soon.rb +30 -0
- data/lib/reel_uk/internal/parser/showtimes.rb +46 -0
- data/lib/reel_uk/internal/parser/whats_on.rb +62 -0
- data/lib/reel_uk/internal/title_sanitizer.rb +37 -0
- data/lib/reel_uk/internal/website.rb +51 -0
- data/lib/reel_uk/performance.rb +129 -0
- data/lib/reel_uk/version.rb +6 -0
- data/lib/reel_uk.rb +23 -0
- metadata +154 -0
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
|