rio_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 +13 -0
- data/COMM-LICENSE +22 -0
- data/LICENSE +617 -0
- data/README.md +169 -0
- data/lib/rio_uk/cinema.rb +99 -0
- data/lib/rio_uk/internal/http.rb +39 -0
- data/lib/rio_uk/internal/parser/event.rb +127 -0
- data/lib/rio_uk/internal/title_sanitizer.rb +116 -0
- data/lib/rio_uk/internal/website.rb +31 -0
- data/lib/rio_uk/performance.rb +66 -0
- data/lib/rio_uk/version.rb +6 -0
- data/lib/rio_uk.rb +18 -0
- metadata +151 -0
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# RioUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull the cinema and its performances from the [Rio Cinema website](https://riocinema.org.uk) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'rio_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install rio_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
|
+
No token or setup is needed. The website runs on Savoy Systems' ticketing.
|
|
24
|
+
Every page of it carries the whole programme as one line of JSON, every event
|
|
25
|
+
on sale with every advertised performance, so one request to the what's on
|
|
26
|
+
page reads it all. There is no lighter feed and no compression; the page is
|
|
27
|
+
about 850 KB.
|
|
28
|
+
|
|
29
|
+
### Cinema
|
|
30
|
+
|
|
31
|
+
The Rio is one venue, keyed `rio`, the name of its Savoy site. The gem holds
|
|
32
|
+
its details: the address from the website's footer and coordinates from
|
|
33
|
+
OpenStreetMap. Cinema methods make no requests.
|
|
34
|
+
|
|
35
|
+
``` ruby
|
|
36
|
+
RioUk::Cinema.all
|
|
37
|
+
#=> [<RioUk::Cinema ...>]
|
|
38
|
+
|
|
39
|
+
cinema = RioUk::Cinema.new('rio')
|
|
40
|
+
#=> <RioUk::Cinema ...>
|
|
41
|
+
|
|
42
|
+
cinema.adr
|
|
43
|
+
#=> {
|
|
44
|
+
street_address: '107 Kingsland High Street',
|
|
45
|
+
extended_address: nil,
|
|
46
|
+
locality: 'London',
|
|
47
|
+
region: nil,
|
|
48
|
+
postal_code: 'E8 2PB',
|
|
49
|
+
country_name: 'United Kingdom'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cinema.brand
|
|
53
|
+
#=> 'Independent'
|
|
54
|
+
|
|
55
|
+
cinema.name
|
|
56
|
+
#=> 'Rio Cinema'
|
|
57
|
+
|
|
58
|
+
cinema.id
|
|
59
|
+
#=> 'rio'
|
|
60
|
+
|
|
61
|
+
cinema.latitude
|
|
62
|
+
#=> 51.5496199
|
|
63
|
+
|
|
64
|
+
cinema.url
|
|
65
|
+
#=> 'https://riocinema.org.uk'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Performances
|
|
69
|
+
|
|
70
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
71
|
+
defaulting to today in London and the following 13 days. Any range is one
|
|
72
|
+
request.
|
|
73
|
+
|
|
74
|
+
``` ruby
|
|
75
|
+
RioUk::Performance.at('rio', from: Date.today, to: Date.today + 365)
|
|
76
|
+
#=> [<RioUk::Performance ...>, <RioUk::Performance ...>, ...]
|
|
77
|
+
|
|
78
|
+
performance = RioUk::Performance.at('rio').first
|
|
79
|
+
#=> <RioUk::Performance ...>
|
|
80
|
+
|
|
81
|
+
performance.film_name
|
|
82
|
+
#=> 'Dao' # from "Black History Month: Alain Gomis' DAO"
|
|
83
|
+
|
|
84
|
+
performance.film_year
|
|
85
|
+
#=> nil
|
|
86
|
+
|
|
87
|
+
performance.dimension
|
|
88
|
+
#=> '2d'
|
|
89
|
+
|
|
90
|
+
performance.variant
|
|
91
|
+
#=> ['q&a']
|
|
92
|
+
|
|
93
|
+
performance.starting_at
|
|
94
|
+
#=> 2026-11-01 13:30:00 UTC
|
|
95
|
+
|
|
96
|
+
performance.booking_url
|
|
97
|
+
#=> 'https://riocinema.org.uk/Rio.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_2884892.TcsSection_1299'
|
|
98
|
+
|
|
99
|
+
performance.cinema_name
|
|
100
|
+
#=> 'Rio Cinema'
|
|
101
|
+
|
|
102
|
+
performance.cinema_id
|
|
103
|
+
#=> 'rio'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Booking links open Savoy's ticket selection for the performance. A few special
|
|
107
|
+
events sell through a partner, such as the Japanese Film Club, and link there.
|
|
108
|
+
Sold out performances are listed with no booking link.
|
|
109
|
+
|
|
110
|
+
Variants come from the Rio's performance flags and from titles: `baby` (Carers
|
|
111
|
+
+ Baby), `senior` (Classic Matinee, for the over 60s), `kids` (Family Flicks
|
|
112
|
+
and Hackney Children's Film Fest), `subtitled` (Hard of Hearing), `q&a`,
|
|
113
|
+
`relaxed`, `35mm`, `70mm` and `sing_along`. Pink Palace, Rio Forever, special
|
|
114
|
+
event and no ads flags are not variants.
|
|
115
|
+
|
|
116
|
+
Film years come from the listing's year, or a year in the title. Double bills
|
|
117
|
+
('1974 / 1976') and programmes ('Various') have none.
|
|
118
|
+
|
|
119
|
+
Only films are kept: events whose type names a film or feature and that have a
|
|
120
|
+
director or a year. Gigs and parties listed as films, such as 'Velcro Flats
|
|
121
|
+
presents POZI', have neither.
|
|
122
|
+
|
|
123
|
+
Titles are cleaned up. The Rio writes the film in capitals and everything else
|
|
124
|
+
in mixed case:
|
|
125
|
+
|
|
126
|
+
- Strands, clubs, festivals and presenters before a colon go: 'Pink Palace: A
|
|
127
|
+
SINGLE MAN', 'JAPANESE FILM CLUB: YOUR NAME', 'Loose Willis presents: THE
|
|
128
|
+
DEVILS'.
|
|
129
|
+
- A mixed case prefix before a film in capitals goes: 'Category H: MALATESTA’S
|
|
130
|
+
CARNIVAL OF BLOOD + FINAL DESTINATION 3'.
|
|
131
|
+
- Mixed case extras after a film in capitals go: 'PAN'S LABYRINTH on 35mm',
|
|
132
|
+
'WE, THE HATED (Just Stop Oil doc) + Q&A', '... with Smut Press'.
|
|
133
|
+
- A director's name in front goes: "Alain Gomis' DAO" becomes 'Dao'.
|
|
134
|
+
- Capitals become title case: 'SHAUN THE SHEEP: THE BEAST OF MOSSY BOTTOM'
|
|
135
|
+
becomes 'Shaun the Sheep: The Beast of Mossy Bottom'. Words with digits,
|
|
136
|
+
Roman numerals and words without vowels, such as 'M3GAN', 'II' and 'RRR',
|
|
137
|
+
keep their capitals.
|
|
138
|
+
- Event cinema names stay and are standardised: 'NT Live: HAMLET' becomes
|
|
139
|
+
'National Theatre Live: Hamlet', 'RBO 2026-27: SWAN LAKE' becomes 'Royal
|
|
140
|
+
Ballet and Opera: Swan Lake'.
|
|
141
|
+
|
|
142
|
+
Double bills keep both films: 'Flesh Gordon + Gums'.
|
|
143
|
+
|
|
144
|
+
## Tests
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
bundle exec rake # unit tests against fixtures
|
|
148
|
+
LIVE=1 bundle exec rake live # live riocinema.org.uk
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The fixture in `test/fixtures` is the what's on page, trimmed to its programme
|
|
152
|
+
of a few events with short synopses. `test/support/api_shapes.rb` describes
|
|
153
|
+
the JSON the gem reads. The unit suite checks the fixture against it and the
|
|
154
|
+
live suite checks the real page, held only in memory. When the page changes,
|
|
155
|
+
update the shape first, then the fixture.
|
|
156
|
+
|
|
157
|
+
## Releasing
|
|
158
|
+
|
|
159
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
160
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
161
|
+
RubyGems trusted publishing.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
1. Fork it
|
|
166
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
167
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
168
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
169
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module RioUk
|
|
2
|
+
# The object representing the Rio Cinema in Dalston, London
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the cinema's id, 'rio'
|
|
6
|
+
|
|
7
|
+
# The Rio is one venue. Its id is the name of its Savoy site, as in
|
|
8
|
+
# riocinema.org.uk/Rio.dll.
|
|
9
|
+
VENUES = {
|
|
10
|
+
'rio' => {
|
|
11
|
+
name: 'Rio Cinema', street_address: '107 Kingsland High Street', locality: 'London',
|
|
12
|
+
postal_code: 'E8 2PB', latitude: 51.5496199, longitude: -0.0755805
|
|
13
|
+
}
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
17
|
+
URL = 'https://riocinema.org.uk'.freeze
|
|
18
|
+
|
|
19
|
+
# Constructor
|
|
20
|
+
# @param [String] id the cinema's id, 'rio' or 'Rio'
|
|
21
|
+
# @return [RioUk::Cinema]
|
|
22
|
+
def initialize(id)
|
|
23
|
+
@id = id.to_s.downcase
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Return basic cinema information for all cinemas
|
|
27
|
+
# @return [Array<RioUk::Cinema>] the one cinema
|
|
28
|
+
# @example
|
|
29
|
+
# RioUk::Cinema.all
|
|
30
|
+
# #=> [<RioUk::Cinema>]
|
|
31
|
+
def self.all
|
|
32
|
+
VENUES.keys.map { |id| new(id) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @!method address
|
|
36
|
+
# Address of the cinema
|
|
37
|
+
# @return [Hash] of different address parts
|
|
38
|
+
# @see #adr
|
|
39
|
+
|
|
40
|
+
# Address of the cinema
|
|
41
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
42
|
+
# :locality, :region, :postal_code, :country_name
|
|
43
|
+
# @example
|
|
44
|
+
# cinema = RioUk::Cinema.new('rio')
|
|
45
|
+
# cinema.adr
|
|
46
|
+
# #=> {
|
|
47
|
+
# street_address: '107 Kingsland High Street',
|
|
48
|
+
# extended_address: nil,
|
|
49
|
+
# locality: 'London',
|
|
50
|
+
# region: nil,
|
|
51
|
+
# postal_code: 'E8 2PB',
|
|
52
|
+
# country_name: 'United Kingdom'
|
|
53
|
+
# }
|
|
54
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
55
|
+
def adr
|
|
56
|
+
ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Brand of the cinema
|
|
60
|
+
# @return [String] which will always be 'Independent'
|
|
61
|
+
def brand
|
|
62
|
+
'Independent'.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Float, nil]
|
|
66
|
+
def latitude
|
|
67
|
+
venue[:latitude]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Float, nil]
|
|
71
|
+
def longitude
|
|
72
|
+
venue[:longitude]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The name of the cinema
|
|
76
|
+
# @return [String]
|
|
77
|
+
# @example
|
|
78
|
+
# RioUk::Cinema.new('rio').name
|
|
79
|
+
# #=> 'Rio Cinema'
|
|
80
|
+
def name
|
|
81
|
+
venue[:name].to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The url of the cinema's website
|
|
85
|
+
# @return [String]
|
|
86
|
+
# @example
|
|
87
|
+
# RioUk::Cinema.new('rio').url
|
|
88
|
+
# #=> 'https://riocinema.org.uk'
|
|
89
|
+
def url
|
|
90
|
+
venue.empty? ? '' : URL
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def venue
|
|
96
|
+
VENUES.fetch(id, {})
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module RioUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to riocinema.org.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 "Rio Cinema 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,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RioUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Reads one Savoy event, a film or other listing with its performances
|
|
8
|
+
class Event
|
|
9
|
+
# TypeDescription of films, e.g. 'Film' or 'Main Feature'. Other
|
|
10
|
+
# types are live events.
|
|
11
|
+
FILM_TYPE = /\b(?:film|feature)s?\b/i
|
|
12
|
+
|
|
13
|
+
# The Rio's performance flags and the variants they mean. PP (Pink
|
|
14
|
+
# Palace), RF (Rio Forever), SP (special event) and NoAds are strands
|
|
15
|
+
# and promises, not variants.
|
|
16
|
+
FLAGS = {
|
|
17
|
+
'CB' => 'baby', # Carers + Baby
|
|
18
|
+
'CM' => 'senior', # Classic Matinee, for the over 60s
|
|
19
|
+
'FF' => 'kids', # Family Flicks
|
|
20
|
+
'HC' => 'kids', # Hackney Children's Film Fest
|
|
21
|
+
'HoH' => 'subtitled', # Hard of Hearing
|
|
22
|
+
'QA' => 'q&a',
|
|
23
|
+
'RS' => 'relaxed'
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
TITLE_VARIANTS = {
|
|
27
|
+
/\b35mm\b/i => '35mm',
|
|
28
|
+
/\b70mm\b/i => '70mm',
|
|
29
|
+
/\bQ\s?[&+]\s?A\b/i => 'q&a',
|
|
30
|
+
/\bSing[- ]?A[- ]?Long\b/i => 'sing_along'
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
YES = 'Y'
|
|
34
|
+
THREE_D = /\b3D\b/i
|
|
35
|
+
YEAR = /\A\d{4}\z/
|
|
36
|
+
TIME_ZONE = 'Europe/London'
|
|
37
|
+
|
|
38
|
+
# @param [Hash] event one of the website's Savoy events
|
|
39
|
+
def initialize(event)
|
|
40
|
+
@event = event
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Films have a director or a year. Gigs and parties listed as films
|
|
44
|
+
# have neither.
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def film?
|
|
47
|
+
@event['TypeDescription'].to_s.match?(FILM_TYPE) && !(director.empty? && year_field.empty?)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param [Range<Date>] dates listings dates to keep
|
|
51
|
+
# @return [Array<Hash>] options for RioUk::Performance.new
|
|
52
|
+
def performances(dates)
|
|
53
|
+
@event.fetch('Performances', []).filter_map do |performance|
|
|
54
|
+
date = Date.iso8601(performance.fetch('StartDate'))
|
|
55
|
+
attributes(performance, date) if dates.cover?(date)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def attributes(performance, date)
|
|
62
|
+
starting_at = starting_at(performance, date)
|
|
63
|
+
{
|
|
64
|
+
booking_url: booking_url(performance),
|
|
65
|
+
dimension: [title, format].any? { |text| text.match?(THREE_D) } ? '3d' : '2d',
|
|
66
|
+
festival: Cinebase::Festival.from_title(title, year: starting_at.year),
|
|
67
|
+
film_name: film_name,
|
|
68
|
+
film_year: film_year,
|
|
69
|
+
starting_at: starting_at,
|
|
70
|
+
variant: variant(performance)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def booking_url(performance)
|
|
75
|
+
if performance['IsSoldOut'] == YES || !performance['IsOpenForSale']
|
|
76
|
+
nil
|
|
77
|
+
else
|
|
78
|
+
Website.url(performance.fetch('URL'))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def director
|
|
83
|
+
@event['Director'].to_s.strip
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def film_name
|
|
87
|
+
@film_name ||= TitleSanitizer.new(title, director: director, strands: strands).sanitized
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Double bills give two years, e.g. '1974 / 1976', and programmes of
|
|
91
|
+
# shorts 'Various'
|
|
92
|
+
def film_year
|
|
93
|
+
year_field.match?(YEAR) ? year_field.to_i : Cinebase::TitleSanitizer.film_year(title)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format
|
|
97
|
+
@event.fetch('Tags', []).map { |tag| tag['Format'].to_s }.join(' ')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Times are London time, e.g. '1830'
|
|
101
|
+
def starting_at(performance, date)
|
|
102
|
+
hour, minute = performance.fetch('StartTime').unpack('a2a2').map(&:to_i)
|
|
103
|
+
local = Time.utc(date.year, date.month, date.day, hour, minute)
|
|
104
|
+
TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def strands
|
|
108
|
+
@event.fetch('Seasons', []).map { |season| CGI.unescapeHTML(season['SeasonName'].to_s) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def title
|
|
112
|
+
@title ||= CGI.unescapeHTML(@event.fetch('Title')).squeeze(' ').strip
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def variant(performance)
|
|
116
|
+
from_flags = FLAGS.filter_map { |flag, variant| variant if performance[flag] == YES }
|
|
117
|
+
from_title = TITLE_VARIANTS.filter_map { |pattern, variant| variant if title.match?(pattern) }
|
|
118
|
+
(from_flags + from_title).uniq
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def year_field
|
|
122
|
+
@event['Year'].to_s.strip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module RioUk
|
|
2
|
+
# Internal utility classes: Do not use
|
|
3
|
+
# @api private
|
|
4
|
+
module Internal
|
|
5
|
+
# Sanitize and standardize film titles. The Rio writes the film in
|
|
6
|
+
# capitals and its strand, presenter and extras in mixed case, e.g.
|
|
7
|
+
# 'Little White Lies: PAN'S LABYRINTH on 35mm'.
|
|
8
|
+
class TitleSanitizer < Cinebase::TitleSanitizer
|
|
9
|
+
# Event cinema names to keep in front of the film
|
|
10
|
+
EVENT_CINEMA = /\A(?:NT Live|National Theatre Live|RBO|Royal Ballet|Met Opera|Exhibition on Screen)\b[^:]*:\s*/i
|
|
11
|
+
# A strand or presenter that names a club, festival or presenter
|
|
12
|
+
PRESENTER = /\b(?:club|fest|festival|kino|presents?)\b/i
|
|
13
|
+
SMALL_WORDS = %w(a an and as at but by for from in nor of on or the to v vs via with).freeze
|
|
14
|
+
# Lone Roman numerals, e.g. 'II', stay in capitals
|
|
15
|
+
ROMAN = /\A(?=[IVXLC])C{0,3}(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})\z/
|
|
16
|
+
VOWEL = /[AEIOUYÀ-ÖØ-Ý]/
|
|
17
|
+
# The first two-letter word, which says whether the title is in capitals
|
|
18
|
+
FIRST_WORD = /[\p{L}\p{N}'’-]*\p{L}[\p{L}\p{N}'’-]*\p{L}[\p{L}\p{N}'’-]*/
|
|
19
|
+
|
|
20
|
+
# Constructor
|
|
21
|
+
# @param [String] title a film title, without HTML entities
|
|
22
|
+
# @param [String] director the event's director, whose name some
|
|
23
|
+
# titles lead with, e.g. "Alain Gomis' DAO"
|
|
24
|
+
# @param [Array<String>] strands the event's season names, e.g.
|
|
25
|
+
# 'Pink Palace'
|
|
26
|
+
# @return [RioUk::Internal::TitleSanitizer]
|
|
27
|
+
def initialize(title, director: '', strands: [])
|
|
28
|
+
super(title)
|
|
29
|
+
@director = director
|
|
30
|
+
@strands = strands
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# sanitized and standardized title
|
|
34
|
+
# @return [String] sanitised title
|
|
35
|
+
def sanitized
|
|
36
|
+
@sanitized ||= begin
|
|
37
|
+
listed = without_director(title.squeeze(' ').strip)
|
|
38
|
+
event_cinema = listed[EVENT_CINEMA].to_s
|
|
39
|
+
film = without_strand(listed.delete_prefix(event_cinema))
|
|
40
|
+
film = titlecase(leading_capitals(film)) if capitals?(film)
|
|
41
|
+
film = remove.reduce(event_cinema + film) { |text, pattern| text.gsub(pattern, '') }
|
|
42
|
+
replace.reduce(film) { |text, (pattern, prefix)| text.gsub(pattern) { prefix + $1 } }.squeeze(' ').strip
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# strings and regex to be removed
|
|
49
|
+
def remove
|
|
50
|
+
[/[\s:,+-]+\z/] + COMMON_REMOVE
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# regexes and their replacements
|
|
54
|
+
def replace
|
|
55
|
+
{
|
|
56
|
+
/\A(?:NT|National Theatre) Live:\s*(.*)/i => 'National Theatre Live: ',
|
|
57
|
+
/\ARBO (?:Cinema Season )?20\d{2}-\d{2}:\s*(.*)/i => 'Royal Ballet and Opera: ',
|
|
58
|
+
/\AExhibition on Screen:\s*(.*)/i => 'Exhibition on Screen: '
|
|
59
|
+
}.merge(COMMON_REPLACE)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def capitals?(text)
|
|
63
|
+
word = text[FIRST_WORD]
|
|
64
|
+
!word.nil? && !word.match?(/\p{Ll}/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mixed case words after the film are extras, e.g. 'on 35mm'
|
|
68
|
+
def leading_capitals(text)
|
|
69
|
+
text.split(' ').take_while { |word| !word.match?(/\p{Ll}/) }.join(' ')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def without_director(text)
|
|
73
|
+
if @director.empty?
|
|
74
|
+
text
|
|
75
|
+
else
|
|
76
|
+
text.sub(/#{Regexp.escape(@director)}['’]s?\s+(?=\S)/i, '')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The first prefix before a colon goes when it is one of the event's
|
|
81
|
+
# strands, names a club, festival or presenter, or is in mixed case in
|
|
82
|
+
# front of a film in capitals
|
|
83
|
+
def without_strand(text)
|
|
84
|
+
prefix, film = text.split(/:\s+/, 2)
|
|
85
|
+
if film.nil?
|
|
86
|
+
text.sub(/\A.+?\s+presents\s+/i, '')
|
|
87
|
+
elsif strand?(prefix) || prefix.match?(PRESENTER) || (prefix.match?(/\p{Ll}/) && capitals?(film))
|
|
88
|
+
film
|
|
89
|
+
else
|
|
90
|
+
text
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def strand?(prefix)
|
|
95
|
+
@strands.any? { |strand| prefix.downcase.start_with?(strand.downcase) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Words start with a capital, except small words inside the title
|
|
99
|
+
def titlecase(text)
|
|
100
|
+
text.gsub(/[\p{L}\p{N}'’]+/) do |word|
|
|
101
|
+
titlecase_word(word, first: $`.match?(/\A[^\p{L}\p{N}]*\z|[:(–-]\s*\z/))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def titlecase_word(word, first:)
|
|
106
|
+
if !first && SMALL_WORDS.include?(word.downcase)
|
|
107
|
+
word.downcase
|
|
108
|
+
elsif word.match?(/\p{N}|\p{Ll}/) || word.length < 2 || word.match?(ROMAN) || !word.match?(VOWEL)
|
|
109
|
+
word
|
|
110
|
+
else
|
|
111
|
+
word.capitalize
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RioUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads the Rio Cinema's website, run on Savoy Systems' ticketing. No
|
|
8
|
+
# token is needed.
|
|
9
|
+
class Website
|
|
10
|
+
include Http
|
|
11
|
+
|
|
12
|
+
WHATS_ON = 'https://riocinema.org.uk/Rio.dll/WhatsOn'
|
|
13
|
+
# Savoy pages carry the whole programme as one line of JSON
|
|
14
|
+
EVENTS = /^var Events =\s*(\{.*\})\s*$/
|
|
15
|
+
|
|
16
|
+
# Every event on sale with every advertised performance, however far
|
|
17
|
+
# ahead, including sold out ones
|
|
18
|
+
# @return [Array<Hash>] Savoy events, each with its 'Performances'
|
|
19
|
+
def events
|
|
20
|
+
JSON.parse(get(WHATS_ON)[EVENTS, 1] || '{}').fetch('Events', [])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param [String] path a Savoy link relative to the what's on page,
|
|
24
|
+
# e.g. 'Booking?Booking=...'
|
|
25
|
+
# @return [String] the absolute url
|
|
26
|
+
def self.url(path)
|
|
27
|
+
URI.join(WHATS_ON, path).to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module RioUk
|
|
2
|
+
# A single screening of a film on the Rio Cinema website
|
|
3
|
+
class Performance < Cinebase::Performance
|
|
4
|
+
# @!attribute [r] booking_url
|
|
5
|
+
# @return [String, nil] the booking URL, nil when sold out
|
|
6
|
+
# @!attribute [r] cinema_name
|
|
7
|
+
# @return [String] the cinema name
|
|
8
|
+
# @!attribute [r] cinema_id
|
|
9
|
+
# @return [String] the cinema id
|
|
10
|
+
# @!attribute [r] dimension
|
|
11
|
+
# @return [String] 2d or 3d
|
|
12
|
+
# @!attribute [r] film_name
|
|
13
|
+
# @return [String] the film name
|
|
14
|
+
|
|
15
|
+
# @!method initialize(options)
|
|
16
|
+
# @param [Hash] options options hash
|
|
17
|
+
# @option options [String] :booking_url (nil) buying url for the screening
|
|
18
|
+
# @option options [String] :cinema_name name of the cinema
|
|
19
|
+
# @option options [String] :cinema_id website id of the cinema
|
|
20
|
+
# @option options [String] :dimension ('2d') dimension of the screening
|
|
21
|
+
# @option options [Cinebase::Festival] :festival (nil) festival edition
|
|
22
|
+
# @option options [String] :film_name name of the film
|
|
23
|
+
# @option options [Integer] :film_year (nil) year of the film
|
|
24
|
+
# @option options [Time] :starting_at listed start time of the performance
|
|
25
|
+
|
|
26
|
+
TIME_ZONE = 'Europe/London'.freeze
|
|
27
|
+
|
|
28
|
+
# Performances within an inclusive range of showing dates, from one
|
|
29
|
+
# request: the what's on page holds every advertised date.
|
|
30
|
+
# @param [String] cinema_id the cinema's id, 'rio'
|
|
31
|
+
# @param [Date] from first date (defaults to today in London)
|
|
32
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
33
|
+
# @return [Array<RioUk::Performance>]
|
|
34
|
+
def self.at(
|
|
35
|
+
cinema_id,
|
|
36
|
+
from: TZInfo::Timezone.get(TIME_ZONE).now.to_date,
|
|
37
|
+
to: from + 13
|
|
38
|
+
)
|
|
39
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
40
|
+
|
|
41
|
+
cinema = Cinema.new(cinema_id)
|
|
42
|
+
if cinema.name.empty?
|
|
43
|
+
[]
|
|
44
|
+
else
|
|
45
|
+
events = RioUk::Internal::Website.new.events.map { |event| RioUk::Internal::Parser::Event.new(event) }
|
|
46
|
+
performances = events.select(&:film?).flat_map { |event| event.performances(from..to) }.map do |attributes|
|
|
47
|
+
new(attributes.merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
48
|
+
end
|
|
49
|
+
performances.sort_by(&:starting_at)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @!method showing_on
|
|
54
|
+
# The date of the screening
|
|
55
|
+
# @return [Date]
|
|
56
|
+
|
|
57
|
+
# @!method starting_at
|
|
58
|
+
# UTC time of the screening
|
|
59
|
+
# @return [Time]
|
|
60
|
+
|
|
61
|
+
# @!method variant
|
|
62
|
+
# The kinds of screening (35mm, baby, kids, q&a, relaxed, senior,
|
|
63
|
+
# subtitled ...)
|
|
64
|
+
# @return <Array[String]>
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/rio_uk.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
require 'cinebase'
|
|
5
|
+
|
|
6
|
+
require_relative './rio_uk/version'
|
|
7
|
+
|
|
8
|
+
require_relative './rio_uk/internal/http'
|
|
9
|
+
require_relative './rio_uk/internal/website'
|
|
10
|
+
require_relative './rio_uk/internal/title_sanitizer'
|
|
11
|
+
require_relative './rio_uk/internal/parser/event'
|
|
12
|
+
|
|
13
|
+
require_relative './rio_uk/cinema'
|
|
14
|
+
require_relative './rio_uk/performance'
|
|
15
|
+
|
|
16
|
+
# rio_uk gem
|
|
17
|
+
module RioUk
|
|
18
|
+
end
|