omniplex_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 +10 -0
- data/COMM-LICENSE +22 -0
- data/LICENSE +617 -0
- data/README.md +168 -0
- data/lib/omniplex_uk/cinema.rb +172 -0
- data/lib/omniplex_uk/internal/client.rb +108 -0
- data/lib/omniplex_uk/internal/listings.rb +100 -0
- data/lib/omniplex_uk/internal/title_sanitizer.rb +43 -0
- data/lib/omniplex_uk/performance.rb +131 -0
- data/lib/omniplex_uk/version.rb +6 -0
- data/lib/omniplex_uk.rb +15 -0
- metadata +150 -0
data/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# OmniplexUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull cinemas and performances from the [Omniplex Cinemas website](https://www.omniplexcinemas.co.uk) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'omniplex_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install omniplex_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 classics,
|
|
24
|
+
encore and anniversary markers, language versions and years, and giving
|
|
25
|
+
Royal Ballet and Opera, Met Opera and National Theatre Live broadcasts the
|
|
26
|
+
standard prefixes.
|
|
27
|
+
|
|
28
|
+
No token or setup is needed. Omniplex's UK website runs on Admit One, but
|
|
29
|
+
with its own templates rather than the A1Web pages that cinebase's
|
|
30
|
+
`Cinebase::A1web` reads, so the gem has its own client. The gem covers the UK
|
|
31
|
+
venues only: Great Britain and Northern Ireland. The Republic of Ireland
|
|
32
|
+
venues are on omniplex.ie.
|
|
33
|
+
|
|
34
|
+
### Cinema
|
|
35
|
+
|
|
36
|
+
The venue list is cached for an hour. Ids are the website's site codes, such
|
|
37
|
+
as `OMP_KENN`. The gem holds each venue's address and coordinates, from the
|
|
38
|
+
address block and directions link on its listings page. High Wycombe and
|
|
39
|
+
Sutton have neither, so theirs come from OpenStreetMap.
|
|
40
|
+
|
|
41
|
+
``` ruby
|
|
42
|
+
OmniplexUk::Cinema.all
|
|
43
|
+
#=> [<OmniplexUk::Cinema ...>, <OmniplexUk::Cinema ...>, ...]
|
|
44
|
+
|
|
45
|
+
cinema = OmniplexUk::Cinema.new('OMP_KENN')
|
|
46
|
+
#=> <OmniplexUk::Cinema ...>
|
|
47
|
+
|
|
48
|
+
cinema.adr
|
|
49
|
+
#=> {
|
|
50
|
+
street_address: 'Kennedy Centre',
|
|
51
|
+
extended_address: '564-568 Falls Road',
|
|
52
|
+
locality: 'Belfast',
|
|
53
|
+
region: 'County Antrim',
|
|
54
|
+
postal_code: 'BT11 9AB',
|
|
55
|
+
country_name: 'United Kingdom'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cinema.brand
|
|
59
|
+
#=> 'Omniplex'
|
|
60
|
+
|
|
61
|
+
cinema.full_name
|
|
62
|
+
#=> 'Omniplex Belfast - Kennedy Centre'
|
|
63
|
+
|
|
64
|
+
cinema.id
|
|
65
|
+
#=> 'OMP_KENN'
|
|
66
|
+
|
|
67
|
+
cinema.latitude
|
|
68
|
+
#=> 54.58149
|
|
69
|
+
|
|
70
|
+
cinema.url
|
|
71
|
+
#=> 'https://www.omniplexcinemas.co.uk/cinema/showtimes?sitecode=OMP_KENN'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Performances
|
|
75
|
+
|
|
76
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
77
|
+
defaulting to today in London and the following 13 days. The website lists one
|
|
78
|
+
day per page. The first page also lists every date the venue advertises, so
|
|
79
|
+
the gem then reads only those dates in the range, one after another over one
|
|
80
|
+
connection. A year's range for one venue is about 45 to 60 requests.
|
|
81
|
+
|
|
82
|
+
``` ruby
|
|
83
|
+
OmniplexUk::Performance.at('OMP_SUND', from: Date.today, to: Date.today + 2)
|
|
84
|
+
#=> [<OmniplexUk::Performance ...>, <OmniplexUk::Performance ...>, ...]
|
|
85
|
+
|
|
86
|
+
performance = OmniplexUk::Performance.at('OMP_SUND').first
|
|
87
|
+
#=> <OmniplexUk::Performance ...>
|
|
88
|
+
|
|
89
|
+
performance.film_name
|
|
90
|
+
#=> 'Clayface'
|
|
91
|
+
|
|
92
|
+
performance.language
|
|
93
|
+
#=> nil # e.g. 'Hindi' from '(Hindi)' in the title
|
|
94
|
+
|
|
95
|
+
performance.film_year
|
|
96
|
+
#=> nil # from the title, e.g. 1996 for 'Scream (1996) (Omniplex Classics)'
|
|
97
|
+
|
|
98
|
+
performance.dimension
|
|
99
|
+
#=> '2d'
|
|
100
|
+
|
|
101
|
+
performance.variant
|
|
102
|
+
#=> ['maxx']
|
|
103
|
+
|
|
104
|
+
performance.subtitled?
|
|
105
|
+
#=> false
|
|
106
|
+
|
|
107
|
+
performance.starting_at
|
|
108
|
+
#=> 2026-11-01 16:20:00 UTC
|
|
109
|
+
|
|
110
|
+
performance.booking_url
|
|
111
|
+
#=> 'https://www.omniplexcinemas.co.uk/booking/seats/sunderland/44797'
|
|
112
|
+
|
|
113
|
+
performance.cinema_name
|
|
114
|
+
#=> 'Sunderland'
|
|
115
|
+
|
|
116
|
+
performance.cinema_id
|
|
117
|
+
#=> 'OMP_SUND'
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Booking links go straight to seat selection for the performance.
|
|
121
|
+
|
|
122
|
+
Variants come from the hall icons and tags on each showtime, and the film's
|
|
123
|
+
Event genre:
|
|
124
|
+
|
|
125
|
+
| Website | Variant |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| Event genre, LIVE broadcasts | `arts` |
|
|
128
|
+
| Audio Described | `audio_described` |
|
|
129
|
+
| Avenue | `avenue` |
|
|
130
|
+
| D-BOX | `dbox` |
|
|
131
|
+
| 4D E-Motion | `4d` |
|
|
132
|
+
| Infinity Vision, Infinity Vision 3D | `infinity_vision` |
|
|
133
|
+
| MAXX | `maxx` |
|
|
134
|
+
| Seniors Club | `senior` |
|
|
135
|
+
| Sensory Friendly | `relaxed` |
|
|
136
|
+
| Sofa Bed | `sofabed` |
|
|
137
|
+
| Subtitled | `subtitled` |
|
|
138
|
+
|
|
139
|
+
Infinity Vision 3D showings are also `3d`.
|
|
140
|
+
|
|
141
|
+
## Tests
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
bundle exec rake # unit tests against fixtures
|
|
145
|
+
bundle exec rake live # live omniplexcinemas.co.uk
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Fixtures in `test/fixtures` are pages captured from omniplexcinemas.co.uk,
|
|
149
|
+
trimmed to a few films and showtimes, with the Sunderland calendar cut to the
|
|
150
|
+
dates captured. They keep the site's mix of UTF-8 and Windows-1252 bytes.
|
|
151
|
+
`test/support/api_shapes.rb` describes the parts of the pages the gem reads.
|
|
152
|
+
The unit suite checks the fixtures against it and the live suite checks real
|
|
153
|
+
responses, held only in memory. When the website changes, update the shape
|
|
154
|
+
first, then the fixtures.
|
|
155
|
+
|
|
156
|
+
## Releasing
|
|
157
|
+
|
|
158
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
159
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
160
|
+
RubyGems trusted publishing.
|
|
161
|
+
|
|
162
|
+
## Contributing
|
|
163
|
+
|
|
164
|
+
1. Fork it
|
|
165
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
166
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
167
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
168
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
module OmniplexUk
|
|
2
|
+
# The object representing a cinema on the Omniplex Cinemas website
|
|
3
|
+
class Cinema < Cinebase::A1web::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the Admit One site code of the cinema, e.g. 'OMP_KENN'
|
|
6
|
+
|
|
7
|
+
# @!method initialize(id)
|
|
8
|
+
# @param [String] id the cinema id, e.g. 'OMP_KENN' or 'omp_kenn'
|
|
9
|
+
# @return [OmniplexUk::Cinema]
|
|
10
|
+
|
|
11
|
+
# @!method self.all
|
|
12
|
+
# Return basic cinema information for the UK cinemas: Great Britain
|
|
13
|
+
# and Northern Ireland, not the Republic of Ireland
|
|
14
|
+
# @return [Array<OmniplexUk::Cinema>]
|
|
15
|
+
# @example
|
|
16
|
+
# OmniplexUk::Cinema.all
|
|
17
|
+
# #=> [<OmniplexUk::Cinema>, <OmniplexUk::Cinema>, ...]
|
|
18
|
+
|
|
19
|
+
# @!method adr
|
|
20
|
+
# Address of the cinema
|
|
21
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
22
|
+
# :locality, :region, :postal_code, :country_name
|
|
23
|
+
# @example
|
|
24
|
+
# cinema = OmniplexUk::Cinema.new('OMP_KENN')
|
|
25
|
+
# cinema.adr
|
|
26
|
+
# #=> {
|
|
27
|
+
# street_address: 'Kennedy Centre',
|
|
28
|
+
# extended_address: '564-568 Falls Road',
|
|
29
|
+
# locality: 'Belfast',
|
|
30
|
+
# region: 'County Antrim',
|
|
31
|
+
# postal_code: 'BT11 9AB',
|
|
32
|
+
# country_name: 'United Kingdom'
|
|
33
|
+
# }
|
|
34
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
35
|
+
|
|
36
|
+
# @!method latitude
|
|
37
|
+
# @return [Float, nil]
|
|
38
|
+
|
|
39
|
+
# @!method longitude
|
|
40
|
+
# @return [Float, nil]
|
|
41
|
+
|
|
42
|
+
# @!method name
|
|
43
|
+
# The name of the cinema
|
|
44
|
+
# @return [String]
|
|
45
|
+
# @example
|
|
46
|
+
# OmniplexUk::Cinema.new('OMP_KENN').name
|
|
47
|
+
# #=> 'Belfast - Kennedy Centre'
|
|
48
|
+
|
|
49
|
+
# @!method url
|
|
50
|
+
# The cinema's listings page, which also selects it on the website
|
|
51
|
+
# @return [String]
|
|
52
|
+
# @example
|
|
53
|
+
# OmniplexUk::Cinema.new('OMP_KENN').url
|
|
54
|
+
# #=> 'https://www.omniplexcinemas.co.uk/cinema/showtimes?sitecode=OMP_KENN'
|
|
55
|
+
|
|
56
|
+
# Addresses and coordinates from the address block and directions link
|
|
57
|
+
# on each venue's listings page, except High Wycombe and Sutton, which
|
|
58
|
+
# have neither and come from OpenStreetMap.
|
|
59
|
+
LOCATIONS = {
|
|
60
|
+
'OMP_ANTR' => {
|
|
61
|
+
street_address: 'Junction One Retail Park', locality: 'Antrim', region: 'County Antrim',
|
|
62
|
+
postal_code: 'BT41 4LL', latitude: 54.7297, longitude: -6.2311
|
|
63
|
+
},
|
|
64
|
+
'OMP_BANB' => {
|
|
65
|
+
street_address: 'Unit 4-12, The Outlet', locality: 'Banbridge', region: 'County Down',
|
|
66
|
+
postal_code: 'BT32 4GJ', latitude: 54.3326344, longitude: -6.2783001
|
|
67
|
+
},
|
|
68
|
+
'OMP_BANG' => {
|
|
69
|
+
street_address: 'Valentines Road', locality: 'Bangor', region: 'County Down',
|
|
70
|
+
postal_code: 'BT20 4JH', latitude: 54.6548005, longitude: -5.6631795
|
|
71
|
+
},
|
|
72
|
+
'OMP_BIRM' => {
|
|
73
|
+
street_address: 'Great Park', extended_address: 'Rubery', locality: 'Birmingham',
|
|
74
|
+
region: 'West Midlands', postal_code: 'B45 9JL', latitude: 52.39882, longitude: -2.00826
|
|
75
|
+
},
|
|
76
|
+
'OMP_CARR' => {
|
|
77
|
+
street_address: 'Unit 3-4, Rodgers Quay', locality: 'Carrickfergus', region: 'County Antrim',
|
|
78
|
+
postal_code: 'BT38 8BE', latitude: 54.7123617, longitude: -5.8125379
|
|
79
|
+
},
|
|
80
|
+
'OMP_CLYD' => {
|
|
81
|
+
street_address: '23 Britannia Way', locality: 'Clydebank', region: 'West Dunbartonshire',
|
|
82
|
+
postal_code: 'G81 2RZ', latitude: 55.9015, longitude: -4.4056
|
|
83
|
+
},
|
|
84
|
+
'OMP_CRAI' => {
|
|
85
|
+
street_address: 'Highfield Heights', locality: 'Craigavon', region: 'County Armagh',
|
|
86
|
+
postal_code: 'BT64 3AE', latitude: 54.4492, longitude: -6.3985
|
|
87
|
+
},
|
|
88
|
+
'OMP_DERR' => {
|
|
89
|
+
street_address: 'Quayside Centre', extended_address: 'Strand Road', locality: 'Derry',
|
|
90
|
+
region: 'County Londonderry', postal_code: 'BT48 7PX', latitude: 55.0002376, longitude: -7.3215562
|
|
91
|
+
},
|
|
92
|
+
'OMP_DOWN' => {
|
|
93
|
+
street_address: '5 Owenbeg Avenue', locality: 'Downpatrick', region: 'County Down',
|
|
94
|
+
postal_code: 'BT30 6FJ', latitude: 54.3237579, longitude: -5.7139260
|
|
95
|
+
},
|
|
96
|
+
'OMP_DUDO' => {
|
|
97
|
+
street_address: 'Entertainment Village', extended_address: 'Dundonald', locality: 'Belfast',
|
|
98
|
+
region: 'County Down', postal_code: 'BT16 1YG', latitude: 54.5874, longitude: -5.8189
|
|
99
|
+
},
|
|
100
|
+
'OMP_DUGA' => {
|
|
101
|
+
street_address: 'Oaks Retail Park', extended_address: 'Oaks Road', locality: 'Dungannon',
|
|
102
|
+
region: 'County Tyrone', postal_code: 'BT71 4AR', latitude: 54.5125018, longitude: -6.7616527
|
|
103
|
+
},
|
|
104
|
+
'OMP_GLAS' => {
|
|
105
|
+
street_address: '7 Renfrew Street', locality: 'Glasgow', region: 'Glasgow City',
|
|
106
|
+
postal_code: 'G2 3AB', latitude: 55.864976, longitude: -4.254477
|
|
107
|
+
},
|
|
108
|
+
'OMP_HIWY' => {
|
|
109
|
+
street_address: 'Crest Road', locality: 'High Wycombe', region: 'Buckinghamshire',
|
|
110
|
+
postal_code: 'HP11 1UA', latitude: 51.6131257, longitude: -0.7719008
|
|
111
|
+
},
|
|
112
|
+
'OMP_IPSW' => {
|
|
113
|
+
street_address: '1 Buttermarket Shopping Centre', locality: 'Ipswich', region: 'Suffolk',
|
|
114
|
+
postal_code: 'IP1 1DT', latitude: 52.0575, longitude: 1.1550
|
|
115
|
+
},
|
|
116
|
+
'OMP_KENN' => {
|
|
117
|
+
street_address: 'Kennedy Centre', extended_address: '564-568 Falls Road', locality: 'Belfast',
|
|
118
|
+
region: 'County Antrim', postal_code: 'BT11 9AB', latitude: 54.58149, longitude: -5.97779
|
|
119
|
+
},
|
|
120
|
+
'OMP_LARN' => {
|
|
121
|
+
street_address: 'Larne Leisure Park', extended_address: 'Redlands Road', locality: 'Larne',
|
|
122
|
+
region: 'County Antrim', postal_code: 'BT40 1FD', latitude: 54.8470005, longitude: -5.8102719
|
|
123
|
+
},
|
|
124
|
+
'OMP_LISB' => {
|
|
125
|
+
street_address: 'Lisburn Leisure Park', extended_address: 'Governors Road', locality: 'Lisburn',
|
|
126
|
+
region: 'County Antrim', postal_code: 'BT28 1LP', latitude: 54.507481, longitude: -6.051616
|
|
127
|
+
},
|
|
128
|
+
'OMP_NEWR' => {
|
|
129
|
+
street_address: 'The Quays Shopping Centre', locality: 'Newry', region: 'County Down',
|
|
130
|
+
postal_code: 'BT35 8QS', latitude: 54.1688, longitude: -6.3401
|
|
131
|
+
},
|
|
132
|
+
'OMP_OMAG' => {
|
|
133
|
+
street_address: 'Kevlin Road', locality: 'Omagh', region: 'County Tyrone',
|
|
134
|
+
postal_code: 'BT78 1JL', latitude: 54.5965088, longitude: -7.3006357
|
|
135
|
+
},
|
|
136
|
+
'OMP_SUND' => {
|
|
137
|
+
street_address: '4 Lambton Street', locality: 'Sunderland', region: 'Tyne and Wear',
|
|
138
|
+
postal_code: 'SR1 1TP', latitude: 54.9082446, longitude: -1.3800217
|
|
139
|
+
},
|
|
140
|
+
'OMP_SUTT' => {
|
|
141
|
+
street_address: 'St Nicholas Centre', extended_address: 'St Nicholas Way', locality: 'Sutton',
|
|
142
|
+
region: 'Greater London', postal_code: 'SM1 1AY', latitude: 51.3647655, longitude: -0.1953102
|
|
143
|
+
},
|
|
144
|
+
'OMP_WIGA' => {
|
|
145
|
+
street_address: 'Unit 2, Robin Leisure Park', locality: 'Wigan', region: 'Greater Manchester',
|
|
146
|
+
postal_code: 'WN5 0UJ', latitude: 53.54603, longitude: -2.65797
|
|
147
|
+
}
|
|
148
|
+
}.freeze
|
|
149
|
+
|
|
150
|
+
# @return [String]
|
|
151
|
+
def self.website
|
|
152
|
+
Internal::Client::WEBSITE
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @api private
|
|
156
|
+
# @return [OmniplexUk::Internal::Client]
|
|
157
|
+
def self.client
|
|
158
|
+
Internal::Client.new
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @api private
|
|
162
|
+
def self.locations
|
|
163
|
+
LOCATIONS
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Brand of the cinema
|
|
167
|
+
# @return [String] which will always be 'Omniplex'
|
|
168
|
+
def brand
|
|
169
|
+
'Omniplex'.freeze
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
|
|
4
|
+
module OmniplexUk
|
|
5
|
+
# Internal utility classes: Do not use
|
|
6
|
+
# @api private
|
|
7
|
+
module Internal
|
|
8
|
+
# Reads the venue list and daily listings from omniplexcinemas.co.uk,
|
|
9
|
+
# Omniplex's Admit One site. A venue's listings take one request per
|
|
10
|
+
# day, so requests share one kept-alive connection until #close.
|
|
11
|
+
class Client
|
|
12
|
+
WEBSITE = 'https://www.omniplexcinemas.co.uk'.freeze
|
|
13
|
+
SHOWTIMES_PATH = '/cinema/showtimes'.freeze
|
|
14
|
+
|
|
15
|
+
# The home page's site picker groups venues by region. The Ireland
|
|
16
|
+
# region is the Republic, served by omniplex.ie.
|
|
17
|
+
REGION_OR_VENUE = /data-region="(\w+)"|data-name="([^"]*)" data-sitecode="(\w+)"/
|
|
18
|
+
UK_REGIONS = %w(England NorthernIreland Scotland Wales).freeze
|
|
19
|
+
|
|
20
|
+
# Pages claim ISO-8859-1 but mix UTF-8 with Windows-1252 apostrophes
|
|
21
|
+
LEGACY_ENCODING = Encoding::Windows_1252
|
|
22
|
+
RETRIES = 2
|
|
23
|
+
TRANSIENT_ERRORS = [
|
|
24
|
+
EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# UK venues in the home page's site picker
|
|
28
|
+
# @return [Array<Hash>] with :url, :name and :code, e.g.
|
|
29
|
+
# { url: 'https://www.omniplexcinemas.co.uk/cinema/showtimes?sitecode=OMP_KENN',
|
|
30
|
+
# name: 'Belfast - Kennedy Centre', code: 'OMP_KENN' }
|
|
31
|
+
def venues
|
|
32
|
+
region = nil
|
|
33
|
+
venues = get('/').scan(REGION_OR_VENUE).filter_map do |group, name, code|
|
|
34
|
+
if group
|
|
35
|
+
region = group
|
|
36
|
+
nil
|
|
37
|
+
elsif UK_REGIONS.include?(region)
|
|
38
|
+
{ url: showtimes_url(code), name: CGI.unescapeHTML(name).strip, code: code }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
venues.uniq { |venue| venue[:code] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One day's listings for a venue, or its default day's without a date.
|
|
45
|
+
# Each page also lists every advertised date.
|
|
46
|
+
# @param [String] site_code e.g. 'OMP_KENN'
|
|
47
|
+
# @param [Date, nil] date
|
|
48
|
+
# @return [String]
|
|
49
|
+
def showtimes(site_code, date = nil)
|
|
50
|
+
get(showtimes_path(site_code, date))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param [String] site_code e.g. 'OMP_KENN'
|
|
54
|
+
# @return [String] the venue's listings page; it also selects the venue
|
|
55
|
+
# on the website for later visits
|
|
56
|
+
def showtimes_url(site_code)
|
|
57
|
+
WEBSITE + showtimes_path(site_code)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
@http.finish if @http&.started?
|
|
62
|
+
@http = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def showtimes_path(site_code, date = nil)
|
|
68
|
+
query = { sitecode: site_code }
|
|
69
|
+
query.merge!(action: 'processFilters', filterDate: date.iso8601) if date
|
|
70
|
+
"#{SHOWTIMES_PATH}?#{URI.encode_www_form(query)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def get(path)
|
|
74
|
+
attempts = 0
|
|
75
|
+
begin
|
|
76
|
+
response = http.request(Net::HTTP::Get.new(path))
|
|
77
|
+
rescue *TRANSIENT_ERRORS
|
|
78
|
+
close
|
|
79
|
+
attempts += 1
|
|
80
|
+
raise if attempts > RETRIES
|
|
81
|
+
|
|
82
|
+
sleep(attempts)
|
|
83
|
+
retry
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
87
|
+
decode(response.body)
|
|
88
|
+
else
|
|
89
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
90
|
+
raise "HTTP error #{response.code} from #{URI(WEBSITE).host}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def http
|
|
95
|
+
@http ||= begin
|
|
96
|
+
uri = URI(WEBSITE)
|
|
97
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def decode(body)
|
|
102
|
+
body.dup.force_encoding(Encoding::UTF_8).scrub do |bytes|
|
|
103
|
+
bytes.force_encoding(LEGACY_ENCODING).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
require 'date'
|
|
3
|
+
|
|
4
|
+
module OmniplexUk
|
|
5
|
+
# Internal utility classes: Do not use
|
|
6
|
+
# @api private
|
|
7
|
+
module Internal
|
|
8
|
+
# Reads one day's showtimes, and the venue's advertised dates, from a
|
|
9
|
+
# listings page.
|
|
10
|
+
class Listings
|
|
11
|
+
# The calendar's dates are local midnights in milliseconds, so dates
|
|
12
|
+
# in British Summer Time fall at 23:00 UTC the day before
|
|
13
|
+
ADVERTISED_DATES = /allowedDatesTimestamps = \[([\d,\s]*)\]/
|
|
14
|
+
PAGE_DATE = /var defaultDate = "(\d{4}-\d{2}-\d{2})"/
|
|
15
|
+
FILM = %r{
|
|
16
|
+
<p\sclass="font-medium\stext-lg[^"]*">\s*<a\s[^>]*>(.*?)</a>\s*</p>
|
|
17
|
+
\s*<img\s[^>]*id="favourite([^"]+)"
|
|
18
|
+
}mx
|
|
19
|
+
GENRES = %r{/images/ratings/[^"]*"[^>]*>\s*<p[^>]*>([^<]*)</p>}
|
|
20
|
+
SHOWTIME = %r{<a aria-label="[^"]*? at (\d{1,2}):(\d{2}) in [^"]*" href="([^"]*)"[^>]*>(.*?)</a>}m
|
|
21
|
+
ICON = %r{<img src="[^"]*/([\w-]+)\.(?:png|svg)"}
|
|
22
|
+
TAG = %r{<span class="text-xs[^"]*">([^<]*)</span>}
|
|
23
|
+
|
|
24
|
+
# Hall icons on a showtime, named as the website names them
|
|
25
|
+
ICONS = {
|
|
26
|
+
'4d_emotion' => '4D E-Motion',
|
|
27
|
+
'avenue' => 'Avenue',
|
|
28
|
+
'infinity3d_icon' => 'Infinity Vision 3D',
|
|
29
|
+
'infinity_icon' => 'Infinity Vision',
|
|
30
|
+
'omniplex_mobile-app-2025_svgs_audio-described-alt' => 'Audio Described',
|
|
31
|
+
'omniplex_mobile-app-2025_svgs_dbox-orange' => 'D-BOX',
|
|
32
|
+
'omniplex_mobile-app-2025_svgs_maxx' => 'MAXX',
|
|
33
|
+
'sofabed' => 'Sofa Bed'
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Films whose genres include it are live and recorded events
|
|
37
|
+
EVENT_GENRE = 'Event'.freeze
|
|
38
|
+
MILLISECONDS = 1000
|
|
39
|
+
TIME_ZONE = 'Europe/London'.freeze
|
|
40
|
+
|
|
41
|
+
# @return [Date, nil] the day this page lists
|
|
42
|
+
attr_reader :date
|
|
43
|
+
|
|
44
|
+
# @param [String] html a listings page
|
|
45
|
+
def initialize(html)
|
|
46
|
+
@html = html
|
|
47
|
+
page_date = html[PAGE_DATE, 1]
|
|
48
|
+
@date = Date.iso8601(page_date) if page_date
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<Date>] every date the venue advertises, in order
|
|
52
|
+
def dates
|
|
53
|
+
timestamps = @html[ADVERTISED_DATES, 1].to_s.split(',')
|
|
54
|
+
timestamps.map { |milliseconds| local_date(milliseconds.to_i / MILLISECONDS) }.uniq.sort
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Array<Cinebase::A1web::Showing>]
|
|
58
|
+
def showings
|
|
59
|
+
if date
|
|
60
|
+
@html.split(FILM).drop(1).each_slice(3).flat_map do |title, film_id, markup|
|
|
61
|
+
film_showings(text(title), film_id, markup.to_s)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def film_showings(title, film_id, markup)
|
|
71
|
+
genres = markup[GENRES, 1].to_s.split(',').map(&:strip)
|
|
72
|
+
film_tags = genres.include?(EVENT_GENRE) ? [EVENT_GENRE] : []
|
|
73
|
+
markup.scan(SHOWTIME).map do |hour, minute, href, box|
|
|
74
|
+
Cinebase::A1web::Showing.new(
|
|
75
|
+
film_id: film_id, title: title, date: date, starting_at: starting_at(hour.to_i, minute.to_i),
|
|
76
|
+
booking_url: CGI.unescapeHTML(href), tags: film_tags + tags(box)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tags(box)
|
|
82
|
+
icons = box.scan(ICON).flatten.filter_map { |icon| ICONS[icon] }
|
|
83
|
+
icons + box.scan(TAG).flatten.map { |tag| text(tag) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def local_date(seconds)
|
|
87
|
+
TZInfo::Timezone.get(TIME_ZONE).to_local(Time.at(seconds).utc).to_date
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def starting_at(hour, minute)
|
|
91
|
+
local = Time.utc(date.year, date.month, date.day, hour, minute)
|
|
92
|
+
TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def text(html)
|
|
96
|
+
CGI.unescapeHTML(html.gsub(/<[^>]*>/, ' ')).gsub(/\s+/, ' ').strip
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module OmniplexUk
|
|
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
|
+
# The website loses some apostrophes, e.g. 'Andre Rieu?s'
|
|
8
|
+
LOST_APOSTROPHE = /(\w)\?(s)\b/
|
|
9
|
+
|
|
10
|
+
# @!method initialize(title)
|
|
11
|
+
# Constructor
|
|
12
|
+
# @param [String] title a film title
|
|
13
|
+
# @return [OmniplexUk::Internal::TitleSanitizer]
|
|
14
|
+
|
|
15
|
+
# sanitized and standardized title
|
|
16
|
+
# @return [String] sanitised title
|
|
17
|
+
def sanitized
|
|
18
|
+
@restored ||= super.gsub(LOST_APOSTROPHE, "\\1'\\2")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# strings and regex to be removed
|
|
24
|
+
def remove
|
|
25
|
+
[
|
|
26
|
+
/\s*\(Omniplex Classics\)/i, # classics season
|
|
27
|
+
/\s*\(Sunrise Screening\)/i, # early screening
|
|
28
|
+
/\s*\(Poland\)/, # Polish film
|
|
29
|
+
/\s+Encore\z/i # encore screening
|
|
30
|
+
] + COMMON_REMOVE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# regexes and their replacements
|
|
34
|
+
def replace
|
|
35
|
+
{
|
|
36
|
+
/\A(.*?)\s+-\s*LIVE\s*-\s*The Royal (?:Ballet|Opera) \d{2}-\d{2}\z/ => 'Royal Ballet and Opera: ',
|
|
37
|
+
/\A(.*?)\s+-\s+The Met Opera \d{2}-\d{2}\z/ => 'Met Opera: ',
|
|
38
|
+
/\ANT Live:\s*(.*)/ => 'National Theatre Live: '
|
|
39
|
+
}.merge(COMMON_REPLACE)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|