merlin_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 +157 -0
- data/lib/merlin_uk/cinema.rb +189 -0
- data/lib/merlin_uk/internal/http.rb +39 -0
- data/lib/merlin_uk/internal/parser/schedule.rb +102 -0
- data/lib/merlin_uk/internal/parser/showing.rb +71 -0
- data/lib/merlin_uk/internal/title_sanitizer.rb +39 -0
- data/lib/merlin_uk/internal/website.rb +20 -0
- data/lib/merlin_uk/performance.rb +72 -0
- data/lib/merlin_uk/version.rb +6 -0
- data/lib/merlin_uk.rb +19 -0
- metadata +152 -0
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# MerlinUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull cinemas and performances from the [Merlin Cinemas website](https://www.merlincinemas.co.uk) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'merlin_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install merlin_uk
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
The gem conforms to the API set down in the `cinebase` gem, [andycroll/cinebase](https://github.com/andycroll/cinebase), which provides a lot of useful base vocabulary and repetitive code for this series of cinema focussed gems.
|
|
22
|
+
|
|
23
|
+
Performance titles are sanitized as much as possible, removing years, the
|
|
24
|
+
Bring Your Own Baby prefix, film society and special screening prefixes, Q&A
|
|
25
|
+
and "at The Ritz" suffixes, and naming National Theatre Live, Royal Ballet
|
|
26
|
+
and Opera and Met Opera screenings in full.
|
|
27
|
+
|
|
28
|
+
No token or setup is needed. The website has no API: each venue's home
|
|
29
|
+
page lists its whole schedule in HTML. Tickets are sold through each venue's
|
|
30
|
+
Admit One site.
|
|
31
|
+
|
|
32
|
+
### Cinema
|
|
33
|
+
|
|
34
|
+
The website lists its venues and cinema codes in a menu but gives addresses
|
|
35
|
+
only on each venue's information page, and no coordinates, so the gem holds
|
|
36
|
+
them: names and codes from the menu, addresses from the information pages,
|
|
37
|
+
and coordinates from OpenStreetMap or, for the Ritz in Penzance, the New
|
|
38
|
+
Central in Torquay and the Strand in Rhyl, which it does not map, the
|
|
39
|
+
postcode. Cinema methods make no requests.
|
|
40
|
+
|
|
41
|
+
``` ruby
|
|
42
|
+
MerlinUk::Cinema.all
|
|
43
|
+
#=> [<MerlinUk::Cinema ...>, <MerlinUk::Cinema ...>, ...]
|
|
44
|
+
|
|
45
|
+
cinema = MerlinUk::Cinema.new('MER_REDR')
|
|
46
|
+
#=> <MerlinUk::Cinema ...>
|
|
47
|
+
|
|
48
|
+
cinema.adr
|
|
49
|
+
#=> {
|
|
50
|
+
street_address: 'Fore Street',
|
|
51
|
+
extended_address: nil,
|
|
52
|
+
locality: 'Redruth',
|
|
53
|
+
region: 'Cornwall',
|
|
54
|
+
postal_code: 'TR15 2AZ',
|
|
55
|
+
country_name: 'United Kingdom'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cinema.brand
|
|
59
|
+
#=> 'Merlin'
|
|
60
|
+
|
|
61
|
+
cinema.name
|
|
62
|
+
#=> 'Regal Cinema & Theatre, Redruth'
|
|
63
|
+
|
|
64
|
+
cinema.full_name
|
|
65
|
+
#=> 'Merlin Regal Cinema & Theatre, Redruth'
|
|
66
|
+
|
|
67
|
+
cinema.id
|
|
68
|
+
#=> 'MER_REDR'
|
|
69
|
+
|
|
70
|
+
cinema.latitude
|
|
71
|
+
#=> 50.233212
|
|
72
|
+
|
|
73
|
+
cinema.url
|
|
74
|
+
#=> 'https://redruth.merlincinemas.co.uk'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Performances
|
|
78
|
+
|
|
79
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
80
|
+
defaulting to today in London and the following 13 days. The venue's home
|
|
81
|
+
page holds every advertised date, so any range is one request.
|
|
82
|
+
|
|
83
|
+
``` ruby
|
|
84
|
+
MerlinUk::Performance.at('MER_REDR', from: Date.today, to: Date.today + 2)
|
|
85
|
+
#=> [<MerlinUk::Performance ...>, <MerlinUk::Performance ...>, ...]
|
|
86
|
+
|
|
87
|
+
performance = MerlinUk::Performance.at('MER_REDR').first
|
|
88
|
+
#=> <MerlinUk::Performance ...>
|
|
89
|
+
|
|
90
|
+
performance.film_name
|
|
91
|
+
#=> 'No Trees No Seas' # from 'No Trees No Seas with Q&A'
|
|
92
|
+
|
|
93
|
+
performance.film_year
|
|
94
|
+
#=> nil # from the title, e.g. 1960 for 'Picture House presents Psycho (1960)'
|
|
95
|
+
|
|
96
|
+
performance.dimension
|
|
97
|
+
#=> '2d'
|
|
98
|
+
|
|
99
|
+
performance.variant
|
|
100
|
+
#=> ['q&a']
|
|
101
|
+
|
|
102
|
+
performance.subtitled?
|
|
103
|
+
#=> false
|
|
104
|
+
|
|
105
|
+
performance.starting_at
|
|
106
|
+
#=> 2026-11-12 19:30:00 UTC
|
|
107
|
+
|
|
108
|
+
performance.booking_url
|
|
109
|
+
#=> 'https://merlinredruth.admit-one.eu/?p=tickets&perfCode=93005'
|
|
110
|
+
|
|
111
|
+
performance.cinema_name
|
|
112
|
+
#=> 'Regal Cinema & Theatre, Redruth'
|
|
113
|
+
|
|
114
|
+
performance.cinema_id
|
|
115
|
+
#=> 'MER_REDR'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Booking links are the website's own, to the venue's Admit One site, which
|
|
119
|
+
redirects to seat selection.
|
|
120
|
+
|
|
121
|
+
Variants come from each booking link's icons, the website's event cinema
|
|
122
|
+
marker and titles: `arts` (event cinema: theatre, opera, ballet, concerts
|
|
123
|
+
and art), `baby` (Bring Your Own Baby), `q&a`, `relaxed` (autism friendly)
|
|
124
|
+
and `subtitled`. Super Saver prices, luxury seats, private boxes and
|
|
125
|
+
licensed screenings are not variants.
|
|
126
|
+
|
|
127
|
+
Live stage shows, which the website marks with a theatre icon, are not
|
|
128
|
+
films and are left out. Mostly these are at the Regal in Redruth and the
|
|
129
|
+
Ritz in Penzance.
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
bundle exec rake # unit tests against fixtures
|
|
135
|
+
bundle exec rake live # live merlincinemas.co.uk
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The fixtures in `test/fixtures` are the Redruth and Galashiels home pages,
|
|
139
|
+
trimmed to a few films without the page's head and navigation.
|
|
140
|
+
`test/support/page_shapes.rb` describes the markup the gem reads. The unit
|
|
141
|
+
suite checks the fixtures against it and the live suite checks every
|
|
142
|
+
venue's real page, held only in memory. When the website changes, update
|
|
143
|
+
the shape first, then the fixtures.
|
|
144
|
+
|
|
145
|
+
## Releasing
|
|
146
|
+
|
|
147
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
148
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
149
|
+
RubyGems trusted publishing.
|
|
150
|
+
|
|
151
|
+
## Contributing
|
|
152
|
+
|
|
153
|
+
1. Fork it
|
|
154
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
155
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
156
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
157
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
module MerlinUk
|
|
2
|
+
# The object representing a cinema on the Merlin Cinemas website
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the website's cinema code, e.g. 'MER_REDR'
|
|
6
|
+
|
|
7
|
+
# The website lists its venues and codes in a menu but gives addresses
|
|
8
|
+
# only on each venue's information page, and no coordinates, so the gem
|
|
9
|
+
# holds them: names and codes from the menu, addresses from the
|
|
10
|
+
# information pages, coordinates from OpenStreetMap or, where it has no
|
|
11
|
+
# venue, the postcode.
|
|
12
|
+
VENUES = {
|
|
13
|
+
'MER_BODM' => {
|
|
14
|
+
name: 'Capitol Cinema, Bodmin', subdomain: 'bodmin', street_address: 'Mount Folly Square',
|
|
15
|
+
locality: 'Bodmin', region: 'Cornwall', postal_code: 'PL31 2DB', latitude: 50.470563, longitude: -4.719178
|
|
16
|
+
},
|
|
17
|
+
'MER_HELS' => {
|
|
18
|
+
name: 'Flora Cinema, Helston', subdomain: 'helston', street_address: '17 Wendron Street',
|
|
19
|
+
locality: 'Helston', region: 'Cornwall', postal_code: 'TR13 8PT', latitude: 50.101288, longitude: -5.273108
|
|
20
|
+
},
|
|
21
|
+
'MER_FALM' => {
|
|
22
|
+
name: 'Phoenix Cinema, Falmouth', subdomain: 'falmouth', street_address: '11 Berkeley Vale',
|
|
23
|
+
locality: 'Falmouth', region: 'Cornwall', postal_code: 'TR11 3PL', latitude: 50.15405, longitude: -5.074337
|
|
24
|
+
},
|
|
25
|
+
'MER_REDR' => {
|
|
26
|
+
name: 'Regal Cinema & Theatre, Redruth', subdomain: 'redruth', street_address: 'Fore Street',
|
|
27
|
+
locality: 'Redruth', region: 'Cornwall', postal_code: 'TR15 2AZ', latitude: 50.233212, longitude: -5.229744
|
|
28
|
+
},
|
|
29
|
+
'MER_STIV' => {
|
|
30
|
+
name: 'Royal Cinema, St. Ives', subdomain: 'st-ives', street_address: 'Royal Square',
|
|
31
|
+
locality: 'St Ives', region: 'Cornwall', postal_code: 'TR26 2ND', latitude: 50.211081, longitude: -5.481897
|
|
32
|
+
},
|
|
33
|
+
'MER_PENZ' => {
|
|
34
|
+
name: 'Savoy Cinema, Penzance', subdomain: 'penzance', street_address: '16 Causewayhead',
|
|
35
|
+
locality: 'Penzance', region: 'Cornwall', postal_code: 'TR18 2SN', latitude: 50.119726, longitude: -5.538722
|
|
36
|
+
},
|
|
37
|
+
'MER_RITZ' => {
|
|
38
|
+
name: 'The Ritz, Penzance', subdomain: 'ritz', street_address: '4 Queen Street',
|
|
39
|
+
locality: 'Penzance', region: 'Cornwall', postal_code: 'TR18 4BJ', latitude: 50.117616, longitude: -5.536687
|
|
40
|
+
},
|
|
41
|
+
'MER_ILFR' => {
|
|
42
|
+
name: 'Embassy Cinema, Ilfracombe', subdomain: 'ilfracombe', street_address: '134 High Street',
|
|
43
|
+
locality: 'Ilfracombe', region: 'Devon', postal_code: 'EX34 9EZ', latitude: 51.208405, longitude: -4.121911
|
|
44
|
+
},
|
|
45
|
+
'MER_KING' => {
|
|
46
|
+
name: 'Kings Cinema, Kingsbridge', subdomain: 'kingsbridge', street_address: 'Fore Street',
|
|
47
|
+
locality: 'Kingsbridge', region: 'Devon', postal_code: 'TQ7 1PP', latitude: 50.285613, longitude: -3.778093
|
|
48
|
+
},
|
|
49
|
+
'MER_OKEH' => {
|
|
50
|
+
name: 'New Carlton Cinema, Okehampton', subdomain: 'okehampton', street_address: 'Market Street',
|
|
51
|
+
locality: 'Okehampton', region: 'Devon', postal_code: 'EX20 1HN', latitude: 50.739349, longitude: -4.003581
|
|
52
|
+
},
|
|
53
|
+
'MER_TORQ' => {
|
|
54
|
+
name: 'New Central Cinema, Torquay', subdomain: 'torquay', street_address: '90-96 Union Street',
|
|
55
|
+
locality: 'Torquay', region: 'Devon', postal_code: 'TQ2 5PY', latitude: 50.467185, longitude: -3.530679
|
|
56
|
+
},
|
|
57
|
+
'MER_TIVE' => {
|
|
58
|
+
name: 'Tivoli Cinema, Tiverton', subdomain: 'tiverton', street_address: '23 Fore Street',
|
|
59
|
+
locality: 'Tiverton', region: 'Devon', postal_code: 'EX16 6LD', latitude: 50.902181, longitude: -3.486483
|
|
60
|
+
},
|
|
61
|
+
'MER_COLE' => {
|
|
62
|
+
name: 'Studio Cinema, Coleford', subdomain: 'coleford', street_address: '14 High Street',
|
|
63
|
+
locality: 'Coleford', region: 'Gloucestershire', postal_code: 'GL16 8HF',
|
|
64
|
+
latitude: 51.792524, longitude: -2.617573
|
|
65
|
+
},
|
|
66
|
+
'MER_CROM' => {
|
|
67
|
+
name: 'Regal Movieplex, Cromer', subdomain: 'cromer', street_address: '2-4 Hans Place',
|
|
68
|
+
locality: 'Cromer', region: 'Norfolk', postal_code: 'NR27 9EQ', latitude: 52.930769, longitude: 1.300493
|
|
69
|
+
},
|
|
70
|
+
'MER_REDC' => {
|
|
71
|
+
name: 'The Regent, Redcar', subdomain: 'redcar', street_address: 'Newcomen Terrace',
|
|
72
|
+
locality: 'Redcar', region: 'North Yorkshire', postal_code: 'TS10 1AU', latitude: 54.62059, longitude: -1.07025
|
|
73
|
+
},
|
|
74
|
+
'MER_WEST' => {
|
|
75
|
+
name: 'Plaza Cinema, Weston-super-Mare', subdomain: 'wsm', street_address: 'Walliscote Road',
|
|
76
|
+
locality: 'Weston-super-Mare', region: 'Somerset', postal_code: 'BS23 1US',
|
|
77
|
+
latitude: 51.346664, longitude: -2.975809
|
|
78
|
+
},
|
|
79
|
+
'MER_WELL' => {
|
|
80
|
+
name: 'Wellesley Cinema, Wellington', subdomain: 'wellington', street_address: 'Mantle Street',
|
|
81
|
+
locality: 'Wellington', region: 'Somerset', postal_code: 'TA21 8AU', latitude: 50.975938, longitude: -3.231092
|
|
82
|
+
},
|
|
83
|
+
'MER_AYRS' => {
|
|
84
|
+
name: 'Astoria Cinema, Ayr', subdomain: 'ayr', street_address: 'Burns Statue Square',
|
|
85
|
+
locality: 'Ayr', region: 'Ayrshire', postal_code: 'KA7 1UP', latitude: 55.457617, longitude: -4.628246
|
|
86
|
+
},
|
|
87
|
+
'MER_THUR' => {
|
|
88
|
+
name: 'Merlin Cinema, Thurso', subdomain: 'thurso', street_address: 'Ormlie Road',
|
|
89
|
+
locality: 'Thurso', region: 'Caithness', postal_code: 'KW14 7QU', latitude: 58.5863, longitude: -3.536282
|
|
90
|
+
},
|
|
91
|
+
'MER_GALA' => {
|
|
92
|
+
name: 'Pavilion Cinema, Galashiels', subdomain: 'galashiels', street_address: 'Market Street',
|
|
93
|
+
locality: 'Galashiels', region: 'Scottish Borders', postal_code: 'TD1 3AF',
|
|
94
|
+
latitude: 55.61554, longitude: -2.805527
|
|
95
|
+
},
|
|
96
|
+
'MER_PRES' => {
|
|
97
|
+
name: 'Scala Cinema, Prestatyn', subdomain: 'prestatyn', street_address: '47 High Street',
|
|
98
|
+
locality: 'Prestatyn', region: 'Denbighshire', postal_code: 'LL19 9AH', latitude: 53.33545, longitude: -3.404633
|
|
99
|
+
},
|
|
100
|
+
'MER_RHYL' => {
|
|
101
|
+
name: 'Strand Cinema, Rhyl', subdomain: 'rhyl', street_address: 'West Parade', extended_address: 'The Village',
|
|
102
|
+
locality: 'Rhyl', region: 'Denbighshire', postal_code: 'LL18 1HB', latitude: 53.322014, longitude: -3.492144
|
|
103
|
+
}
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
107
|
+
|
|
108
|
+
# Constructor
|
|
109
|
+
# @param [String] id the website's cinema code, e.g. 'MER_REDR' or 'mer_redr'
|
|
110
|
+
# @return [MerlinUk::Cinema]
|
|
111
|
+
def initialize(id)
|
|
112
|
+
@id = id.to_s.upcase
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Return basic cinema information for all cinemas
|
|
116
|
+
# @return [Array<MerlinUk::Cinema>]
|
|
117
|
+
# @example
|
|
118
|
+
# MerlinUk::Cinema.all
|
|
119
|
+
# #=> [<MerlinUk::Cinema>, <MerlinUk::Cinema>, ...]
|
|
120
|
+
def self.all
|
|
121
|
+
VENUES.keys.map { |id| new(id) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @!method address
|
|
125
|
+
# Address of the cinema
|
|
126
|
+
# @return [Hash] of different address parts
|
|
127
|
+
# @see #adr
|
|
128
|
+
|
|
129
|
+
# Address of the cinema
|
|
130
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
131
|
+
# :locality, :region, :postal_code, :country_name
|
|
132
|
+
# @example
|
|
133
|
+
# cinema = MerlinUk::Cinema.new('MER_RHYL')
|
|
134
|
+
# cinema.adr
|
|
135
|
+
# #=> {
|
|
136
|
+
# street_address: 'West Parade',
|
|
137
|
+
# extended_address: 'The Village',
|
|
138
|
+
# locality: 'Rhyl',
|
|
139
|
+
# region: 'Denbighshire',
|
|
140
|
+
# postal_code: 'LL18 1HB',
|
|
141
|
+
# country_name: 'United Kingdom'
|
|
142
|
+
# }
|
|
143
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
144
|
+
def adr
|
|
145
|
+
ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Brand of the cinema
|
|
149
|
+
# @return [String] which will always be 'Merlin'
|
|
150
|
+
def brand
|
|
151
|
+
'Merlin'.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [Float, nil]
|
|
155
|
+
def latitude
|
|
156
|
+
venue[:latitude]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Float, nil]
|
|
160
|
+
def longitude
|
|
161
|
+
venue[:longitude]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The name of the cinema
|
|
165
|
+
# @return [String]
|
|
166
|
+
# @example
|
|
167
|
+
# MerlinUk::Cinema.new('MER_REDR').name
|
|
168
|
+
# #=> 'Regal Cinema & Theatre, Redruth'
|
|
169
|
+
def name
|
|
170
|
+
venue[:name].to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The url of the cinema's pages on the Merlin Cinemas website
|
|
174
|
+
# @return [String]
|
|
175
|
+
# @example
|
|
176
|
+
# MerlinUk::Cinema.new('MER_REDR').url
|
|
177
|
+
# #=> 'https://redruth.merlincinemas.co.uk'
|
|
178
|
+
def url
|
|
179
|
+
subdomain = venue[:subdomain]
|
|
180
|
+
subdomain ? "https://#{subdomain}.merlincinemas.co.uk" : ''
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def venue
|
|
186
|
+
VENUES.fetch(id, {})
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module MerlinUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to merlincinemas.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
|
+
uri = URI(url)
|
|
20
|
+
attempts = 0
|
|
21
|
+
begin
|
|
22
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|
|
23
|
+
http.request(Net::HTTP::Get.new(uri))
|
|
24
|
+
end
|
|
25
|
+
rescue *TRANSIENT_ERRORS
|
|
26
|
+
attempts += 1
|
|
27
|
+
raise if attempts > RETRIES
|
|
28
|
+
|
|
29
|
+
sleep(attempts)
|
|
30
|
+
retry
|
|
31
|
+
end
|
|
32
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
33
|
+
raise "Merlin Cinemas HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
|
|
35
|
+
String.new(response.body, encoding: Encoding::UTF_8)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MerlinUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Reads the full schedule on a venue's home page: a card for each film,
|
|
8
|
+
# each with dated rows of booking links. The page also repeats the
|
|
9
|
+
# schedule day by day, which this ignores.
|
|
10
|
+
class Schedule
|
|
11
|
+
FULL = /<div data-frame="full">(.*?)(?:<div data-frame="\d|\z)/m
|
|
12
|
+
CARD = /<div class="filmCard full" data-film="[^"]*">/
|
|
13
|
+
TITLE = %r{<h2 class="overlay">([^<]*)</h2>}
|
|
14
|
+
LISTING_ICON = %r{<img src="/images/(\w+)\.png" class="live_theatre"}
|
|
15
|
+
DATE_OR_LINK = %r{<h5>([^<]*)</h5>|<a href="([^"]*perfCode=\d+[^"]*)"[^>]*>(.*?)</a>}m
|
|
16
|
+
KEY = /data-key="([^"]+)"/
|
|
17
|
+
TIME = /(\d{1,2}):(\d{2})/
|
|
18
|
+
DATE = /(\d{1,2})(?:st|nd|rd|th) (#{Date::MONTHNAMES.compact.join('|')})(?: (\d{4}))?/
|
|
19
|
+
FIRST_DAY = /<li data-date="(\d{4}-\d{2}-\d{2})"/
|
|
20
|
+
|
|
21
|
+
# The site marks live stage shows, which are not films, with this
|
|
22
|
+
# icon, and screened theatre, opera and concerts with 'event_cinema'
|
|
23
|
+
STAGE_ICON = 'theatre'
|
|
24
|
+
TIME_ZONE = 'Europe/London'
|
|
25
|
+
|
|
26
|
+
# @param [String] html a venue's home page
|
|
27
|
+
def initialize(html)
|
|
28
|
+
@html = html
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Every screening in the full schedule, leaving out live stage shows.
|
|
32
|
+
# Each once, as featured films also appear again in the list.
|
|
33
|
+
# @return [Array<MerlinUk::Internal::Parser::Showing>]
|
|
34
|
+
# @raise [RuntimeError] when the page has no full schedule
|
|
35
|
+
def showings
|
|
36
|
+
full = @html[FULL, 1]
|
|
37
|
+
if full
|
|
38
|
+
all = full.split(CARD).drop(1).flat_map do |card|
|
|
39
|
+
icon = card[LISTING_ICON, 1]
|
|
40
|
+
icon == STAGE_ICON ? [] : card_showings(card, icon)
|
|
41
|
+
end
|
|
42
|
+
all.uniq(&:booking_url)
|
|
43
|
+
else
|
|
44
|
+
raise 'Merlin Cinemas page has no full schedule'
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def card_showings(card, icon)
|
|
51
|
+
title = CGI.unescapeHTML(card[TITLE, 1].to_s).strip
|
|
52
|
+
date = nil
|
|
53
|
+
card.scan(DATE_OR_LINK).filter_map do |heading, href, link|
|
|
54
|
+
if heading
|
|
55
|
+
date = parse_date(heading)
|
|
56
|
+
nil
|
|
57
|
+
elsif date && (time = link.match(TIME))
|
|
58
|
+
Showing.new(title: title, event_cinema: !icon.nil?, starting_at: starting_at(date, time),
|
|
59
|
+
booking_url: CGI.unescapeHTML(href), keys: link.scan(KEY).flatten.uniq)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The site gives the year only for dates outside its current year
|
|
65
|
+
def parse_date(heading)
|
|
66
|
+
match = heading.match(DATE)
|
|
67
|
+
if match
|
|
68
|
+
day = match[1].to_i
|
|
69
|
+
month = Date::MONTHNAMES.index(match[2])
|
|
70
|
+
if match[3]
|
|
71
|
+
Date.new(match[3].to_i, month, day)
|
|
72
|
+
else
|
|
73
|
+
nearest_date(day, month)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def nearest_date(day, month)
|
|
79
|
+
years = (first_day.year - 1)..(first_day.year + 1)
|
|
80
|
+
dates = years.filter_map { |year| Date.new(year, month, day) if Date.valid_date?(year, month, day) }
|
|
81
|
+
dates.min_by { |date| (date - first_day).abs }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def first_day
|
|
85
|
+
@first_day ||= begin
|
|
86
|
+
iso = @html[FIRST_DAY, 1]
|
|
87
|
+
if iso
|
|
88
|
+
Date.iso8601(iso)
|
|
89
|
+
else
|
|
90
|
+
raise 'Merlin Cinemas page lists no days'
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def starting_at(date, time)
|
|
96
|
+
local = Time.utc(date.year, date.month, date.day, time[1].to_i, time[2].to_i)
|
|
97
|
+
TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MerlinUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# One screening from a venue's full schedule
|
|
8
|
+
class Showing
|
|
9
|
+
# Booking link icons and the variants they mean. Others, such as
|
|
10
|
+
# 'access', 'licensed', 'luxury', 'box' (private boxes) and 'saver'
|
|
11
|
+
# (cheap tickets), are not variants.
|
|
12
|
+
KEYS = {
|
|
13
|
+
'autism' => 'relaxed',
|
|
14
|
+
'baby' => 'baby',
|
|
15
|
+
'subtitled' => 'subtitled'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
TITLES = {
|
|
19
|
+
/\ABring Your Own Baby\b/i => 'baby',
|
|
20
|
+
/\bQ&A\b/i => 'q&a'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
THREE_D = /\b3D\b/i
|
|
24
|
+
LANGUAGE = /\((#{Cinebase::TitleSanitizer::LANGUAGES.join('|')})\)/i
|
|
25
|
+
|
|
26
|
+
# @return [String] the film's title as listed
|
|
27
|
+
attr_reader :title
|
|
28
|
+
# @return [Time] UTC start
|
|
29
|
+
attr_reader :starting_at
|
|
30
|
+
# @return [String] the venue's Admit One booking page
|
|
31
|
+
attr_reader :booking_url
|
|
32
|
+
|
|
33
|
+
# @param [String] title the film's title as listed
|
|
34
|
+
# @param [Boolean] event_cinema whether the site marks the film as
|
|
35
|
+
# event cinema: screened theatre, opera, ballet, concerts or art
|
|
36
|
+
# @param [Time] starting_at UTC start
|
|
37
|
+
# @param [String] booking_url the venue's Admit One booking page
|
|
38
|
+
# @param [Array<String>] keys the booking link's icons, e.g. 'subtitled'
|
|
39
|
+
def initialize(title:, event_cinema:, starting_at:, booking_url:, keys:)
|
|
40
|
+
@title = title
|
|
41
|
+
@event_cinema = event_cinema
|
|
42
|
+
@starting_at = starting_at
|
|
43
|
+
@booking_url = booking_url
|
|
44
|
+
@keys = keys
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash] options for MerlinUk::Performance.new
|
|
48
|
+
def to_hash
|
|
49
|
+
{
|
|
50
|
+
booking_url: @booking_url,
|
|
51
|
+
dimension: @title.match?(THREE_D) ? '3d' : '2d',
|
|
52
|
+
festival: Cinebase::Festival.from_title(@title, year: @starting_at.year),
|
|
53
|
+
film_name: TitleSanitizer.new(@title).sanitized,
|
|
54
|
+
film_year: Cinebase::TitleSanitizer.film_year(@title),
|
|
55
|
+
language: @title[LANGUAGE, 1],
|
|
56
|
+
starting_at: @starting_at,
|
|
57
|
+
variant: variant
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def variant
|
|
64
|
+
from_titles = TITLES.filter_map { |pattern, variant| variant if @title.match?(pattern) }
|
|
65
|
+
from_keys = @keys.filter_map { |key| KEYS[key] }
|
|
66
|
+
(from_keys + from_titles + (@event_cinema ? ['arts'] : [])).uniq
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module MerlinUk
|
|
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 [MerlinUk::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
|
+
/\ABring Your Own Baby(?: Screening)?:\s*/i, # parent and baby screening
|
|
22
|
+
/\A(?:PFS|Picture House) presents:?\s*/i, # film society prefix
|
|
23
|
+
/\ASpecial Screening:\s*/i, # special screening prefix
|
|
24
|
+
/\s+with\s+(?:Pre[- ]?)?(?:Recorded\s+)?Q&A\z/i, # Q&A add-on
|
|
25
|
+
/\s+at\s+the\s+Ritz\.?\z/i # Ritz Penzance suffix
|
|
26
|
+
] + COMMON_REMOVE
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# regexes and their replacements
|
|
30
|
+
def replace
|
|
31
|
+
{
|
|
32
|
+
/\ANT Live: (.*)/i => 'National Theatre Live: ',
|
|
33
|
+
/\ARBO 20\d{2}-\d{2}: MET Opera - (.*)/i => 'Met Opera: ',
|
|
34
|
+
/\ARBO 20\d{2}-\d{2}: The Royal (?:Ballet|Opera) - (.*)/i => 'Royal Ballet and Opera: '
|
|
35
|
+
}.merge(COMMON_REPLACE)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MerlinUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads venue pages from merlincinemas.co.uk. Each venue has its own
|
|
8
|
+
# subdomain, whose home page holds every advertised date in its full
|
|
9
|
+
# schedule.
|
|
10
|
+
class Website
|
|
11
|
+
include Http
|
|
12
|
+
|
|
13
|
+
# @param [String] venue_url e.g. 'https://redruth.merlincinemas.co.uk'
|
|
14
|
+
# @return [String] the venue's home page
|
|
15
|
+
def home(venue_url)
|
|
16
|
+
get("#{venue_url}/")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|