princecharles_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 +173 -0
- data/lib/princecharles_uk/cinema.rb +100 -0
- data/lib/princecharles_uk/internal/http.rb +39 -0
- data/lib/princecharles_uk/internal/parser/whats_on.rb +96 -0
- data/lib/princecharles_uk/internal/title_sanitizer.rb +41 -0
- data/lib/princecharles_uk/internal/website.rb +21 -0
- data/lib/princecharles_uk/performance.rb +129 -0
- data/lib/princecharles_uk/version.rb +6 -0
- data/lib/princecharles_uk.rb +17 -0
- metadata +151 -0
data/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# PrincecharlesUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull the cinema and its performances from the [Prince Charles Cinema website](https://princecharlescinema.com) and spit out useful formatted info.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
gem 'princecharles_uk'
|
|
10
|
+
|
|
11
|
+
And then execute:
|
|
12
|
+
|
|
13
|
+
$ bundle
|
|
14
|
+
|
|
15
|
+
Or install it yourself as:
|
|
16
|
+
|
|
17
|
+
$ gem install princecharles_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 WordPress with Jacro's
|
|
24
|
+
cinema plugin, and tickets are sold through Jacro's TapOS. Its what's on page
|
|
25
|
+
lists every film on sale with every advertised showtime, months ahead, so one
|
|
26
|
+
request reads the whole programme.
|
|
27
|
+
|
|
28
|
+
### Cinema
|
|
29
|
+
|
|
30
|
+
The Prince Charles Cinema has one venue, keyed by its Jacro site code,
|
|
31
|
+
`PRILON`. The gem holds its details: the address from the website's location
|
|
32
|
+
page and coordinates from OpenStreetMap. Cinema methods make no requests.
|
|
33
|
+
|
|
34
|
+
``` ruby
|
|
35
|
+
PrincecharlesUk::Cinema.all
|
|
36
|
+
#=> [<PrincecharlesUk::Cinema ...>]
|
|
37
|
+
|
|
38
|
+
cinema = PrincecharlesUk::Cinema.new('PRILON')
|
|
39
|
+
#=> <PrincecharlesUk::Cinema ...>
|
|
40
|
+
|
|
41
|
+
cinema.adr
|
|
42
|
+
#=> {
|
|
43
|
+
street_address: '7 Leicester Place',
|
|
44
|
+
extended_address: nil,
|
|
45
|
+
locality: 'London',
|
|
46
|
+
region: nil,
|
|
47
|
+
postal_code: 'WC2H 7BY',
|
|
48
|
+
country_name: 'United Kingdom'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
cinema.brand
|
|
52
|
+
#=> 'Independent'
|
|
53
|
+
|
|
54
|
+
cinema.name
|
|
55
|
+
#=> 'Prince Charles Cinema'
|
|
56
|
+
|
|
57
|
+
cinema.id
|
|
58
|
+
#=> 'PRILON'
|
|
59
|
+
|
|
60
|
+
cinema.latitude
|
|
61
|
+
#=> 51.5114907
|
|
62
|
+
|
|
63
|
+
cinema.url
|
|
64
|
+
#=> 'https://princecharlescinema.com'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Performances
|
|
68
|
+
|
|
69
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
70
|
+
defaulting to today in London and the following 13 days. Any range is one
|
|
71
|
+
request.
|
|
72
|
+
|
|
73
|
+
``` ruby
|
|
74
|
+
PrincecharlesUk::Performance.at('PRILON', from: Date.today, to: Date.today + 365)
|
|
75
|
+
#=> [<PrincecharlesUk::Performance ...>, <PrincecharlesUk::Performance ...>, ...]
|
|
76
|
+
|
|
77
|
+
performance = PrincecharlesUk::Performance.at('PRILON', from: Date.today, to: Date.today + 365).last
|
|
78
|
+
#=> <PrincecharlesUk::Performance ...>
|
|
79
|
+
|
|
80
|
+
performance.film_name
|
|
81
|
+
#=> 'The Rocky Horror Picture Show' # from '... : Sing Along'
|
|
82
|
+
|
|
83
|
+
performance.film_year
|
|
84
|
+
#=> 1975
|
|
85
|
+
|
|
86
|
+
performance.dimension
|
|
87
|
+
#=> '2d'
|
|
88
|
+
|
|
89
|
+
performance.variant
|
|
90
|
+
#=> ['sing_along']
|
|
91
|
+
|
|
92
|
+
performance.subtitled?
|
|
93
|
+
#=> false
|
|
94
|
+
|
|
95
|
+
performance.festival
|
|
96
|
+
#=> nil
|
|
97
|
+
|
|
98
|
+
performance.starting_at
|
|
99
|
+
#=> 2027-06-25 19:30:00 UTC
|
|
100
|
+
|
|
101
|
+
performance.booking_url
|
|
102
|
+
#=> 'https://princecharlescinema.com/prince-charles-cinema/booknow/31628659/'
|
|
103
|
+
|
|
104
|
+
performance.cinema_name
|
|
105
|
+
#=> 'Prince Charles Cinema'
|
|
106
|
+
|
|
107
|
+
performance.cinema_id
|
|
108
|
+
#=> 'PRILON'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Booking links are the website's own booking pages, which hold the TapOS seat
|
|
112
|
+
selection. Sold out screenings are listed with no booking link.
|
|
113
|
+
|
|
114
|
+
Date headings such as 'Friday 25th June' give no year. The gem takes the year
|
|
115
|
+
in which that date falls on that weekday.
|
|
116
|
+
|
|
117
|
+
Variants come from each showtime's tags and from titles: `35mm`, `70mm`,
|
|
118
|
+
`subtitled` (SUB and HOH, hard of hearing, screenings), `q&a`, `sing_along`
|
|
119
|
+
and `arts` (films with a live score). 4K, £1 member tickets, introductions,
|
|
120
|
+
previews and final shows are not variants.
|
|
121
|
+
|
|
122
|
+
Film years come from a year in the title, such as 'Godzilla (1954)' or
|
|
123
|
+
'The Mummy [1999]', or else from the listing. Placeholder years before 1888,
|
|
124
|
+
which the listing gives to some events such as 'Mystery Movie', are dropped.
|
|
125
|
+
|
|
126
|
+
Titles are cleaned up:
|
|
127
|
+
|
|
128
|
+
- Original titles, cuts and notes in square brackets go: 'Rashomon [Rashômon]'
|
|
129
|
+
becomes 'Rashomon', 'The New World [Extended Cut]' becomes 'The New World'.
|
|
130
|
+
- A final parenthesis goes: 'Daisies (Sedmikrásky)' becomes 'Daisies', and
|
|
131
|
+
'Ten Years of Monkey Tennis (with Peter Serafinowicz)' loses its guest.
|
|
132
|
+
- Anniversaries, restorations and touring prints go: 'Amelie - 25th
|
|
133
|
+
Anniversary', 'Wake in Fright - 4K Restoration', 'Short Cuts : 35mm Roadshow
|
|
134
|
+
Tour'.
|
|
135
|
+
- Sing-along names go: 'The Rocky Horror Picture Show: Sing Along' and
|
|
136
|
+
'Sing-A-Long-A Rocky Horror Picture Show'.
|
|
137
|
+
- Q&A add-ons go: 'Dead Souls + Alex Cox Q&A' becomes 'Dead Souls'.
|
|
138
|
+
- Presenters go: 'Funeral Parade presents "The Long Day Closes"' becomes 'The
|
|
139
|
+
Long Day Closes'.
|
|
140
|
+
- Spaced colons close up: '2001 : A Space Odyssey' becomes '2001: A Space
|
|
141
|
+
Odyssey'.
|
|
142
|
+
|
|
143
|
+
Director's and final cuts named after a colon keep their names. Double bills,
|
|
144
|
+
marathons and all-nighters keep the cinema's names, such as 'Studio Ghibli
|
|
145
|
+
All-Nighter' and 'Evangelion: Death (True)² + The End of Evangelion: Double
|
|
146
|
+
Feature'.
|
|
147
|
+
|
|
148
|
+
## Tests
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
bundle exec rake # unit tests against fixtures
|
|
152
|
+
LIVE=1 bundle exec rake live # live princecharlescinema.com
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The fixture in `test/fixtures` is the what's on page, trimmed to a few films
|
|
156
|
+
with shortened descriptions and no posters. `test/support/api_shapes.rb`
|
|
157
|
+
describes the markup the gem reads. The unit suite checks the fixture against
|
|
158
|
+
it and the live suite checks the real page, held only in memory. When the page
|
|
159
|
+
changes, update the shape first, then the fixture.
|
|
160
|
+
|
|
161
|
+
## Releasing
|
|
162
|
+
|
|
163
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
164
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
165
|
+
RubyGems trusted publishing.
|
|
166
|
+
|
|
167
|
+
## Contributing
|
|
168
|
+
|
|
169
|
+
1. Fork it
|
|
170
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
171
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
172
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
173
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module PrincecharlesUk
|
|
2
|
+
# The object representing the Prince Charles Cinema, London
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the cinema's Jacro site code, 'PRILON'
|
|
6
|
+
|
|
7
|
+
# The cinema has one venue. Its Jacro site code names it in ticketing
|
|
8
|
+
# links. The address is from the website's location page, coordinates
|
|
9
|
+
# from OpenStreetMap.
|
|
10
|
+
VENUES = {
|
|
11
|
+
'PRILON' => {
|
|
12
|
+
name: 'Prince Charles Cinema', street_address: '7 Leicester Place', locality: 'London',
|
|
13
|
+
postal_code: 'WC2H 7BY', latitude: 51.5114907, longitude: -0.1302137
|
|
14
|
+
}
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
18
|
+
URL = 'https://princecharlescinema.com'.freeze
|
|
19
|
+
|
|
20
|
+
# Constructor
|
|
21
|
+
# @param [String] id the cinema's Jacro site code, 'PRILON' or 'prilon'
|
|
22
|
+
# @return [PrincecharlesUk::Cinema]
|
|
23
|
+
def initialize(id)
|
|
24
|
+
@id = id.to_s.upcase
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return basic cinema information for all cinemas
|
|
28
|
+
# @return [Array<PrincecharlesUk::Cinema>] the one cinema
|
|
29
|
+
# @example
|
|
30
|
+
# PrincecharlesUk::Cinema.all
|
|
31
|
+
# #=> [<PrincecharlesUk::Cinema>]
|
|
32
|
+
def self.all
|
|
33
|
+
VENUES.keys.map { |id| new(id) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @!method address
|
|
37
|
+
# Address of the cinema
|
|
38
|
+
# @return [Hash] of different address parts
|
|
39
|
+
# @see #adr
|
|
40
|
+
|
|
41
|
+
# Address of the cinema
|
|
42
|
+
# @return [Hash] contains :street_address, :extended_address,
|
|
43
|
+
# :locality, :region, :postal_code, :country_name
|
|
44
|
+
# @example
|
|
45
|
+
# cinema = PrincecharlesUk::Cinema.new('PRILON')
|
|
46
|
+
# cinema.adr
|
|
47
|
+
# #=> {
|
|
48
|
+
# street_address: '7 Leicester Place',
|
|
49
|
+
# extended_address: nil,
|
|
50
|
+
# locality: 'London',
|
|
51
|
+
# region: nil,
|
|
52
|
+
# postal_code: 'WC2H 7BY',
|
|
53
|
+
# country_name: 'United Kingdom'
|
|
54
|
+
# }
|
|
55
|
+
# @note Uses method naming as at http://microformats.org/wiki/adr
|
|
56
|
+
def adr
|
|
57
|
+
ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Brand of the cinema
|
|
61
|
+
# @return [String] which will always be 'Independent'
|
|
62
|
+
def brand
|
|
63
|
+
'Independent'.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Float, nil]
|
|
67
|
+
def latitude
|
|
68
|
+
venue[:latitude]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Float, nil]
|
|
72
|
+
def longitude
|
|
73
|
+
venue[:longitude]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The name of the cinema
|
|
77
|
+
# @return [String]
|
|
78
|
+
# @example
|
|
79
|
+
# PrincecharlesUk::Cinema.new('PRILON').name
|
|
80
|
+
# #=> 'Prince Charles Cinema'
|
|
81
|
+
def name
|
|
82
|
+
venue[:name].to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The url of the cinema's website
|
|
86
|
+
# @return [String]
|
|
87
|
+
# @example
|
|
88
|
+
# PrincecharlesUk::Cinema.new('PRILON').url
|
|
89
|
+
# #=> 'https://princecharlescinema.com'
|
|
90
|
+
def url
|
|
91
|
+
venue.empty? ? '' : URL
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def venue
|
|
97
|
+
VENUES.fetch(id, {})
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module PrincecharlesUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to princecharlescinema.com
|
|
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 "Prince Charles 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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrincecharlesUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# One showtime from the what's on page
|
|
8
|
+
Showing = Data.define(:title, :year, :date, :starting_at, :tags, :booking_url)
|
|
9
|
+
|
|
10
|
+
# Parses the what's on page: one block per film, each with its
|
|
11
|
+
# showtimes under date headings such as 'Friday 25th June'
|
|
12
|
+
class WhatsOn
|
|
13
|
+
FILM = /<div class="jacro-event movie-tabs row/
|
|
14
|
+
TITLE = /class="liveeventtitle"[^>]*>([^<]+)</
|
|
15
|
+
# The first detail is the film's year when the listing gives one
|
|
16
|
+
DETAILS = %r{<div class="running-time">\s*<span>([^<]*)</span>}
|
|
17
|
+
YEAR = /\A\d{4}\z/
|
|
18
|
+
SHOWTIMES = %r{<ul class="performance-list-items">(.*?)</ul>}m
|
|
19
|
+
HEADING_OR_SHOWTIME = %r{<div class="heading">([^<]+)</div>|<li class="([^"]*)">(.*?)</li>}m
|
|
20
|
+
HEADING = /\A(\w+) (\d{1,2})(?:st|nd|rd|th) (\w+)\z/
|
|
21
|
+
TIME = %r{<span class="time">\s*(\d{1,2}):(\d{2})\s*([ap]m)\s*</span>}i
|
|
22
|
+
# Sold out showtimes have no link
|
|
23
|
+
BOOKING = %r{href="(https://princecharlescinema\.com/[\w-]+/booknow/\d+)/?"}
|
|
24
|
+
TIME_ZONE = 'Europe/London'
|
|
25
|
+
HOURS_TO_NOON = 12
|
|
26
|
+
|
|
27
|
+
# @param [String] html the what's on page
|
|
28
|
+
# @param [Date] today in London, to place headings, which give no
|
|
29
|
+
# year, in the right year
|
|
30
|
+
def initialize(html, today:)
|
|
31
|
+
@html = html
|
|
32
|
+
@today = today
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<PrincecharlesUk::Internal::Parser::Showing>]
|
|
36
|
+
def showings
|
|
37
|
+
@html.split(FILM).drop(1).flat_map { |film| film_showings(film) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def film_showings(film)
|
|
43
|
+
title = CGI.unescapeHTML(film[TITLE, 1].to_s).strip
|
|
44
|
+
year = film[DETAILS, 1].to_s.strip
|
|
45
|
+
year = year.match?(YEAR) ? year.to_i : nil
|
|
46
|
+
date = nil
|
|
47
|
+
film[SHOWTIMES, 1].to_s.scan(HEADING_OR_SHOWTIME).filter_map do |heading, tags, showtime|
|
|
48
|
+
if heading
|
|
49
|
+
date = date(heading.strip)
|
|
50
|
+
nil
|
|
51
|
+
elsif date && showtime.match?(TIME)
|
|
52
|
+
showing(title, year, date, tags, showtime)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def showing(title, year, date, tags, showtime)
|
|
58
|
+
Showing.new(
|
|
59
|
+
title: title,
|
|
60
|
+
year: year,
|
|
61
|
+
date: date,
|
|
62
|
+
starting_at: starting_at(date, showtime),
|
|
63
|
+
tags: tags.split,
|
|
64
|
+
booking_url: booking_url(showtime)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def booking_url(showtime)
|
|
69
|
+
url = showtime[BOOKING, 1]
|
|
70
|
+
url ? "#{url}/" : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The year whose date falls on the heading's weekday, or failing
|
|
74
|
+
# that the first on or after today
|
|
75
|
+
def date(heading)
|
|
76
|
+
weekday, day, name = heading.match(HEADING)&.captures
|
|
77
|
+
month = name && Date::MONTHNAMES.index(name)
|
|
78
|
+
if month
|
|
79
|
+
dates = (@today.year - 1..@today.year + 1).filter_map do |year|
|
|
80
|
+
Date.new(year, month, day.to_i) if Date.valid_date?(year, month, day.to_i)
|
|
81
|
+
end
|
|
82
|
+
dates.find { |date| Date::DAYNAMES[date.wday] == weekday } || dates.find { |date| date >= @today }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def starting_at(date, showtime)
|
|
87
|
+
hour, minute, meridiem = showtime.match(TIME).captures
|
|
88
|
+
hour = hour.to_i % HOURS_TO_NOON
|
|
89
|
+
hour += HOURS_TO_NOON if meridiem.casecmp?('pm')
|
|
90
|
+
local = Time.utc(date.year, date.month, date.day, hour, minute.to_i)
|
|
91
|
+
TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module PrincecharlesUk
|
|
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 [PrincecharlesUk::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
|
+
/\A.+?\s+presents\s+/i, # presenter, e.g. 'Funeral Parade presents'
|
|
22
|
+
/\ASing-A-Long-A\s+/i, # sing-along prefix
|
|
23
|
+
/:\s*Sing[- ]?A[- ]?Long\z/i, # sing-along suffix
|
|
24
|
+
/\s+-\s+\d+(?:st|nd|rd|th) Anniversary\z/i, # anniversary re-release
|
|
25
|
+
/\s+-\s+4K Restoration\z/i, # restoration
|
|
26
|
+
/\s*:\s*35mm Roadshow Tour\z/i, # touring print
|
|
27
|
+
/\s*\[[^\]]*\]\z/, # original title, cut or year, e.g. '[Rashômon]'
|
|
28
|
+
/\s*\([^)]*\)\z/ # original title, guest or year, e.g. '(Sedmikrásky)'
|
|
29
|
+
] + COMMON_REMOVE
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# regexes and their replacements
|
|
33
|
+
def replace
|
|
34
|
+
{
|
|
35
|
+
/\A"(.*)"\z/ => '', # quotes left by a presenter
|
|
36
|
+
/\s+:\s+(.*)/ => ': ' # spaced colon, e.g. '2001 : A Space Odyssey'
|
|
37
|
+
}.merge(COMMON_REPLACE)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PrincecharlesUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads the Prince Charles Cinema's WordPress website, built on Jacro's
|
|
8
|
+
# cinema plugin. No token is needed.
|
|
9
|
+
class Website
|
|
10
|
+
include Http
|
|
11
|
+
|
|
12
|
+
WHATS_ON = 'https://princecharlescinema.com/whats-on/'
|
|
13
|
+
|
|
14
|
+
# @return [String] every film on sale with every advertised showtime,
|
|
15
|
+
# however far ahead, including sold out ones
|
|
16
|
+
def whats_on
|
|
17
|
+
get(WHATS_ON)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module PrincecharlesUk
|
|
2
|
+
# A single screening of a film on the Prince Charles 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
|
+
# Showtime tags and the variants they mean. HOH (hard of hearing)
|
|
27
|
+
# screenings are subtitled. Others, such as '4k', '1-mem' (£1 member
|
|
28
|
+
# tickets), 'intro', 'preview' and 'final-show', are not variants.
|
|
29
|
+
TAGS = {
|
|
30
|
+
'35mm' => '35mm',
|
|
31
|
+
'70mm' => '70mm',
|
|
32
|
+
'hoh' => 'subtitled',
|
|
33
|
+
'live-score' => 'arts',
|
|
34
|
+
'q-a' => 'q&a',
|
|
35
|
+
'sing-along' => 'sing_along',
|
|
36
|
+
'sub' => 'subtitled'
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Variants some titles name without a tag
|
|
40
|
+
TITLE_VARIANTS = {
|
|
41
|
+
/\b35mm\b/i => '35mm',
|
|
42
|
+
/\b70mm\b/i => '70mm',
|
|
43
|
+
/\bQ&A\b/i => 'q&a',
|
|
44
|
+
/\bSing[- ]?A[- ]?Long\b/i => 'sing_along'
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
THREE_D = /\b3D\b/i
|
|
48
|
+
# Bracketed years in titles, such as 'The Mummy [1999]'
|
|
49
|
+
TITLE_YEAR = /[(\[](1[89]\d{2}|20\d{2})[)\]]\s*\z/
|
|
50
|
+
# Listings give placeholder years to some events, e.g. 1878
|
|
51
|
+
FIRST_FILM_YEAR = 1888
|
|
52
|
+
TIME_ZONE = 'Europe/London'.freeze
|
|
53
|
+
|
|
54
|
+
# Performances within an inclusive range of showing dates, from one
|
|
55
|
+
# request: the what's on page holds every advertised date.
|
|
56
|
+
# @param [String] cinema_id the cinema's Jacro site code, 'PRILON'
|
|
57
|
+
# @param [Date] from first date (defaults to today in London)
|
|
58
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
59
|
+
# @return [Array<PrincecharlesUk::Performance>]
|
|
60
|
+
def self.at(
|
|
61
|
+
cinema_id,
|
|
62
|
+
from: today,
|
|
63
|
+
to: from + 13
|
|
64
|
+
)
|
|
65
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
66
|
+
|
|
67
|
+
cinema = Cinema.new(cinema_id)
|
|
68
|
+
if cinema.name.empty?
|
|
69
|
+
[]
|
|
70
|
+
else
|
|
71
|
+
performances = showings.select { |showing| (from..to).cover?(showing.date) }.map do |showing|
|
|
72
|
+
new(attributes(showing).merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
73
|
+
end
|
|
74
|
+
performances.sort_by(&:starting_at)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @!method showing_on
|
|
79
|
+
# The date of the screening
|
|
80
|
+
# @return [Date]
|
|
81
|
+
|
|
82
|
+
# @!method starting_at
|
|
83
|
+
# UTC time of the screening
|
|
84
|
+
# @return [Time]
|
|
85
|
+
|
|
86
|
+
# @!method variant
|
|
87
|
+
# The kinds of screening (35mm, 70mm, q&a, sing_along, subtitled ...)
|
|
88
|
+
# @return <Array[String]>
|
|
89
|
+
|
|
90
|
+
def self.today
|
|
91
|
+
TZInfo::Timezone.get(TIME_ZONE).now.to_date
|
|
92
|
+
end
|
|
93
|
+
private_class_method :today
|
|
94
|
+
|
|
95
|
+
def self.showings
|
|
96
|
+
html = PrincecharlesUk::Internal::Website.new.whats_on
|
|
97
|
+
PrincecharlesUk::Internal::Parser::WhatsOn.new(html, today: today).showings
|
|
98
|
+
end
|
|
99
|
+
private_class_method :showings
|
|
100
|
+
|
|
101
|
+
def self.attributes(showing)
|
|
102
|
+
title = showing.title
|
|
103
|
+
{
|
|
104
|
+
booking_url: showing.booking_url,
|
|
105
|
+
dimension: [title, *showing.tags].any? { |text| text.match?(THREE_D) } ? '3d' : '2d',
|
|
106
|
+
festival: Cinebase::Festival.from_title(title, year: showing.date.year),
|
|
107
|
+
film_name: PrincecharlesUk::Internal::TitleSanitizer.new(title).sanitized,
|
|
108
|
+
film_year: film_year(showing),
|
|
109
|
+
starting_at: showing.starting_at,
|
|
110
|
+
variant: variant(showing)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
private_class_method :attributes
|
|
114
|
+
|
|
115
|
+
# A year in the title first, as it picks out one version of a film
|
|
116
|
+
def self.film_year(showing)
|
|
117
|
+
year = showing.title[TITLE_YEAR, 1]&.to_i || showing.year
|
|
118
|
+
year if year && year >= FIRST_FILM_YEAR
|
|
119
|
+
end
|
|
120
|
+
private_class_method :film_year
|
|
121
|
+
|
|
122
|
+
def self.variant(showing)
|
|
123
|
+
from_tags = showing.tags.filter_map { |tag| TAGS[tag] }
|
|
124
|
+
from_title = TITLE_VARIANTS.filter_map { |pattern, variant| variant if showing.title.match?(pattern) }
|
|
125
|
+
(from_tags + from_title).uniq
|
|
126
|
+
end
|
|
127
|
+
private_class_method :variant
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
|
|
3
|
+
require 'cinebase'
|
|
4
|
+
|
|
5
|
+
require_relative './princecharles_uk/version'
|
|
6
|
+
|
|
7
|
+
require_relative './princecharles_uk/internal/http'
|
|
8
|
+
require_relative './princecharles_uk/internal/website'
|
|
9
|
+
require_relative './princecharles_uk/internal/title_sanitizer'
|
|
10
|
+
require_relative './princecharles_uk/internal/parser/whats_on'
|
|
11
|
+
|
|
12
|
+
require_relative './princecharles_uk/cinema'
|
|
13
|
+
require_relative './princecharles_uk/performance'
|
|
14
|
+
|
|
15
|
+
# princecharles_uk gem
|
|
16
|
+
module PrincecharlesUk
|
|
17
|
+
end
|