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.
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