depot_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 +159 -0
- data/lib/depot_uk/cinema.rb +100 -0
- data/lib/depot_uk/internal/http.rb +52 -0
- data/lib/depot_uk/internal/parser/calendar.rb +80 -0
- data/lib/depot_uk/internal/parser/showtime.rb +96 -0
- data/lib/depot_uk/internal/title_sanitizer.rb +37 -0
- data/lib/depot_uk/internal/website.rb +62 -0
- data/lib/depot_uk/performance.rb +80 -0
- data/lib/depot_uk/version.rb +6 -0
- data/lib/depot_uk.rb +18 -0
- metadata +152 -0
data/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# DepotUk
|
|
2
|
+
|
|
3
|
+
A simple gem to pull the cinema and performances from the website of
|
|
4
|
+
[Depot](https://lewesdepot.org), the independent cinema in Lewes, and spit
|
|
5
|
+
out useful formatted info.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your application's Gemfile:
|
|
10
|
+
|
|
11
|
+
gem 'depot_uk'
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
$ bundle
|
|
16
|
+
|
|
17
|
+
Or install it yourself as:
|
|
18
|
+
|
|
19
|
+
$ gem install depot_uk
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
Performance titles are sanitized as much as possible, removing the parent
|
|
26
|
+
and baby, Knitflicks and See It First prefixes, the Cinecity suffix and
|
|
27
|
+
opera and ballet labels, and naming Royal Ballet and Opera screenings in
|
|
28
|
+
full.
|
|
29
|
+
|
|
30
|
+
No token or setup is needed. The website runs WordPress with Jacro's
|
|
31
|
+
plugin, and tickets are sold through Jacro's booking site.
|
|
32
|
+
|
|
33
|
+
### Cinema
|
|
34
|
+
|
|
35
|
+
Depot is one venue, so the gem holds it: the address from the website's
|
|
36
|
+
travel page and the coordinates from OpenStreetMap. Its id is `'deplew'`,
|
|
37
|
+
the Jacro code in the website's booking links. Cinema methods make no
|
|
38
|
+
requests.
|
|
39
|
+
|
|
40
|
+
``` ruby
|
|
41
|
+
DepotUk::Cinema.all
|
|
42
|
+
#=> [<DepotUk::Cinema ...>]
|
|
43
|
+
|
|
44
|
+
cinema = DepotUk::Cinema.new('deplew')
|
|
45
|
+
#=> <DepotUk::Cinema ...>
|
|
46
|
+
|
|
47
|
+
cinema.adr
|
|
48
|
+
#=> {
|
|
49
|
+
street_address: 'Pinwell Road',
|
|
50
|
+
extended_address: nil,
|
|
51
|
+
locality: 'Lewes',
|
|
52
|
+
region: 'East Sussex',
|
|
53
|
+
postal_code: 'BN7 2JS',
|
|
54
|
+
country_name: 'United Kingdom'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cinema.brand
|
|
58
|
+
#=> 'Independent'
|
|
59
|
+
|
|
60
|
+
cinema.name
|
|
61
|
+
#=> 'Depot'
|
|
62
|
+
|
|
63
|
+
cinema.latitude
|
|
64
|
+
#=> 50.8715417
|
|
65
|
+
|
|
66
|
+
cinema.url
|
|
67
|
+
#=> 'https://lewesdepot.org'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Performances
|
|
71
|
+
|
|
72
|
+
`Performance.at` returns performances for an inclusive range of showing dates,
|
|
73
|
+
defaulting to today in London and the following 13 days.
|
|
74
|
+
|
|
75
|
+
The website's calendar holds every advertised date, so any range takes five
|
|
76
|
+
requests: the calendar, the home page for the full titles the calendar
|
|
77
|
+
shortens, and the descriptive subtitles, relaxed and autism friendly access
|
|
78
|
+
pages. A range with no performances takes one. The site is slow: expect
|
|
79
|
+
about five seconds.
|
|
80
|
+
|
|
81
|
+
``` ruby
|
|
82
|
+
DepotUk::Performance.at('deplew', from: Date.today, to: Date.today + 2)
|
|
83
|
+
#=> [<DepotUk::Performance ...>, <DepotUk::Performance ...>, ...]
|
|
84
|
+
|
|
85
|
+
performance = DepotUk::Performance.at('deplew').first
|
|
86
|
+
#=> <DepotUk::Performance ...>
|
|
87
|
+
|
|
88
|
+
performance.film_name
|
|
89
|
+
#=> 'Wicker' # from 'Parent & Baby ONLY: Wicker'
|
|
90
|
+
|
|
91
|
+
performance.film_year
|
|
92
|
+
#=> nil # from the title, e.g. 1990 for 'Misery (1990)'
|
|
93
|
+
|
|
94
|
+
performance.dimension
|
|
95
|
+
#=> '2d'
|
|
96
|
+
|
|
97
|
+
performance.variant
|
|
98
|
+
#=> ['baby', 'relaxed', 'subtitled']
|
|
99
|
+
|
|
100
|
+
performance.subtitled?
|
|
101
|
+
#=> true
|
|
102
|
+
|
|
103
|
+
performance.festival
|
|
104
|
+
#=> nil # e.g. #<data Cinebase::Festival name="Cinecity", year=2026>
|
|
105
|
+
|
|
106
|
+
performance.starting_at
|
|
107
|
+
#=> 2026-11-02 11:00:00 UTC
|
|
108
|
+
|
|
109
|
+
performance.booking_url
|
|
110
|
+
#=> 'https://lewesdepot.org/film/parent-baby-only-wicker/booknow/40631087'
|
|
111
|
+
|
|
112
|
+
performance.cinema_name
|
|
113
|
+
#=> 'Depot'
|
|
114
|
+
|
|
115
|
+
performance.cinema_id
|
|
116
|
+
#=> 'deplew'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Booking links are the website's own, to the page that holds Jacro's seat
|
|
120
|
+
selection.
|
|
121
|
+
|
|
122
|
+
Only films are returned. Films play in the numbered screens, so the gem
|
|
123
|
+
drops listings marked 'Depot event' or held in the studio: talks,
|
|
124
|
+
workshops, courses and tastings.
|
|
125
|
+
|
|
126
|
+
Variants come from the access pages and from titles: `subtitled`
|
|
127
|
+
(descriptive subtitles), `relaxed` (relaxed and autism friendly), `baby`
|
|
128
|
+
(parent and baby), `kids` (CBeebies and sensory friendly), `arts` (Royal
|
|
129
|
+
Ballet and Opera, National Theatre Live, Exhibition on Screen, musicals,
|
|
130
|
+
plays and pantomimes) and `q&a`. Films subtitled from another language are
|
|
131
|
+
not a variant.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
bundle exec rake # unit tests against fixtures
|
|
137
|
+
bundle exec rake live # live lewesdepot.org
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The fixtures in `test/fixtures` are the website's pages, trimmed to the
|
|
141
|
+
parts the gem reads: a few calendar days, the home page's film list and the
|
|
142
|
+
access pages' listings. `test/support/api_shapes.rb` describes those parts.
|
|
143
|
+
The unit suite checks the fixtures against it and the live suite checks the
|
|
144
|
+
real pages, held only in memory. When the website changes, update the shape
|
|
145
|
+
first, then the fixtures.
|
|
146
|
+
|
|
147
|
+
## Releasing
|
|
148
|
+
|
|
149
|
+
Bump the version, merge, then run the **Release Gem** workflow in GitHub
|
|
150
|
+
Actions. It runs the tests, tags the version and publishes the gem through
|
|
151
|
+
RubyGems trusted publishing.
|
|
152
|
+
|
|
153
|
+
## Contributing
|
|
154
|
+
|
|
155
|
+
1. Fork it
|
|
156
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
157
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
158
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
159
|
+
5. Create new Pull Request
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module DepotUk
|
|
2
|
+
# The object representing the Depot cinema in Lewes
|
|
3
|
+
class Cinema < Cinebase::Cinema
|
|
4
|
+
# @!attribute [r] id
|
|
5
|
+
# @return [String] the cinema's Jacro code, 'deplew'
|
|
6
|
+
|
|
7
|
+
# Depot is one venue. Its id is the Jacro code in the website's booking
|
|
8
|
+
# links. The address is from the website's travel page, the coordinates
|
|
9
|
+
# from the cinema's OpenStreetMap node 44767219.
|
|
10
|
+
VENUES = {
|
|
11
|
+
'deplew' => {
|
|
12
|
+
name: 'Depot', street_address: 'Pinwell Road', locality: 'Lewes', region: 'East Sussex',
|
|
13
|
+
postal_code: 'BN7 2JS', latitude: 50.8715417, longitude: 0.0120094
|
|
14
|
+
}
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
|
|
18
|
+
URL = 'https://lewesdepot.org'.freeze
|
|
19
|
+
|
|
20
|
+
# Constructor
|
|
21
|
+
# @param [String] id the cinema's Jacro code, 'deplew' or 'DEPLEW'
|
|
22
|
+
# @return [DepotUk::Cinema]
|
|
23
|
+
def initialize(id)
|
|
24
|
+
@id = id.to_s.downcase
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return basic cinema information for all cinemas
|
|
28
|
+
# @return [Array<DepotUk::Cinema>]
|
|
29
|
+
# @example
|
|
30
|
+
# DepotUk::Cinema.all
|
|
31
|
+
# #=> [<DepotUk::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 = DepotUk::Cinema.new('deplew')
|
|
46
|
+
# cinema.adr
|
|
47
|
+
# #=> {
|
|
48
|
+
# street_address: 'Pinwell Road',
|
|
49
|
+
# extended_address: nil,
|
|
50
|
+
# locality: 'Lewes',
|
|
51
|
+
# region: 'East Sussex',
|
|
52
|
+
# postal_code: 'BN7 2JS',
|
|
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
|
+
# DepotUk::Cinema.new('deplew').name
|
|
80
|
+
# #=> 'Depot'
|
|
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
|
+
# DepotUk::Cinema.new('deplew').url
|
|
89
|
+
# #=> 'https://lewesdepot.org'
|
|
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,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module DepotUk
|
|
6
|
+
# Internal utility classes: Do not use
|
|
7
|
+
# @api private
|
|
8
|
+
module Internal
|
|
9
|
+
# Requests to lewesdepot.org over one kept-alive connection. The site is
|
|
10
|
+
# slow, so timeouts are long.
|
|
11
|
+
module Http
|
|
12
|
+
HOST = 'lewesdepot.org'
|
|
13
|
+
TIMEOUT = 40
|
|
14
|
+
RETRIES = 2
|
|
15
|
+
USER_AGENT = 'Mozilla/5.0 (compatible; depot_uk; +https://github.com/andycroll/depot_uk)'
|
|
16
|
+
TRANSIENT_ERRORS = [
|
|
17
|
+
EOFError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Close the connection
|
|
21
|
+
# @return [nil]
|
|
22
|
+
def close
|
|
23
|
+
@connection.finish if @connection&.started?
|
|
24
|
+
@connection = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def get(path)
|
|
30
|
+
attempts = 0
|
|
31
|
+
begin
|
|
32
|
+
response = connection.request(Net::HTTP::Get.new(path, 'User-Agent' => USER_AGENT))
|
|
33
|
+
rescue *TRANSIENT_ERRORS
|
|
34
|
+
close
|
|
35
|
+
attempts += 1
|
|
36
|
+
raise if attempts > RETRIES
|
|
37
|
+
|
|
38
|
+
sleep(attempts)
|
|
39
|
+
retry
|
|
40
|
+
end
|
|
41
|
+
# Omit response bodies from errors, and never follow redirects.
|
|
42
|
+
raise "Depot HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
43
|
+
|
|
44
|
+
String.new(response.body, encoding: Encoding::UTF_8)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def connection
|
|
48
|
+
@connection ||= Net::HTTP.start(HOST, 443, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DepotUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Reads the website's calendar: every advertised date, each with its
|
|
8
|
+
# listings and their start times. Dates have no year.
|
|
9
|
+
class Calendar
|
|
10
|
+
# One performance of a film
|
|
11
|
+
Entry = Data.define(:id, :slug, :heading, :date, :starting_at)
|
|
12
|
+
|
|
13
|
+
DAY = %r{<h2>(\w{3})<br>(\d{1,2}) (\w+)</h2>}
|
|
14
|
+
LISTING = %r{
|
|
15
|
+
data-link="/film/([^"]+)"\s*><div\sclass="contents"><h3>(.*?)</h3>
|
|
16
|
+
(.*?)<div\sclass="calendar-perf-buttons">(.*?)</div>
|
|
17
|
+
}mx
|
|
18
|
+
PERFORMANCE = %r{perf-popup\.php\?id=(\d+)"[^>]*>\s*(\d{1,2}):(\d{2})\s*</a>}
|
|
19
|
+
LINE_BREAK = %r{<br\s*/?>}
|
|
20
|
+
|
|
21
|
+
# Films play in numbered screens. Talks, workshops and tastings are
|
|
22
|
+
# 'Depot event' or in the studio.
|
|
23
|
+
FILM_SCREEN = /\AScreen \d/
|
|
24
|
+
|
|
25
|
+
# A day and month falls on a different weekday in each of three
|
|
26
|
+
# neighbouring years
|
|
27
|
+
YEARS_AROUND = (-1..1)
|
|
28
|
+
TIME_ZONE = 'Europe/London'
|
|
29
|
+
|
|
30
|
+
# @param [String] html the calendar page
|
|
31
|
+
# @param [Date] near a date within a year of the listings, which sets
|
|
32
|
+
# their years
|
|
33
|
+
def initialize(html, near:)
|
|
34
|
+
@html = html
|
|
35
|
+
@near = near
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Array<DepotUk::Internal::Parser::Calendar::Entry>] film
|
|
39
|
+
# performances in calendar order
|
|
40
|
+
def entries
|
|
41
|
+
date = nil
|
|
42
|
+
@html.scan(Regexp.union(DAY, LISTING)).flat_map do |weekday, day, month, slug, heading, details, buttons|
|
|
43
|
+
if weekday
|
|
44
|
+
date = date_of(weekday, day.to_i, month)
|
|
45
|
+
[]
|
|
46
|
+
elsif date && film?(details)
|
|
47
|
+
performances(buttons, slug: slug, heading: CGI.unescapeHTML(heading).strip, date: date)
|
|
48
|
+
else
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def date_of(weekday, day, month_name)
|
|
57
|
+
month = Date::MONTHNAMES.index(month_name)
|
|
58
|
+
if month
|
|
59
|
+
YEARS_AROUND.filter_map do |offset|
|
|
60
|
+
year = @near.year + offset
|
|
61
|
+
Date.new(year, month, day) if Date.valid_date?(year, month, day)
|
|
62
|
+
end.find { |date| date.strftime('%a') == weekday }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def film?(details)
|
|
67
|
+
details.split(LINE_BREAK).any? { |line| line.strip.match?(FILM_SCREEN) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def performances(buttons, slug:, heading:, date:)
|
|
71
|
+
buttons.scan(PERFORMANCE).map do |id, hour, minute|
|
|
72
|
+
local = Time.utc(date.year, date.month, date.day, hour.to_i, minute.to_i)
|
|
73
|
+
starting_at = TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local)
|
|
74
|
+
Entry.new(id: id, slug: slug, heading: heading, date: date, starting_at: starting_at)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DepotUk
|
|
4
|
+
# @api private
|
|
5
|
+
module Internal
|
|
6
|
+
module Parser
|
|
7
|
+
# Turns one calendar entry into performance attributes
|
|
8
|
+
class Showtime
|
|
9
|
+
BOOKING_URL = 'https://lewesdepot.org/film/%<slug>s/booknow/%<id>s'
|
|
10
|
+
|
|
11
|
+
# The calendar cuts long headings at a word and adds this
|
|
12
|
+
TRUNCATION = '...'
|
|
13
|
+
|
|
14
|
+
# Titles and the variants they mean. The access pages mark
|
|
15
|
+
# subtitled and relaxed performances.
|
|
16
|
+
TITLE_VARIANTS = {
|
|
17
|
+
/\AParent (?:&|and) Baby\b/i => 'baby',
|
|
18
|
+
/\ASensory Friendly\b/i => %w(relaxed kids),
|
|
19
|
+
/\ACbeebies\b/i => 'kids',
|
|
20
|
+
/\bQ\s?&\s?A\b/i => 'q&a',
|
|
21
|
+
/\A(?:RBO|National Theatre Live|NT Live|Met Opera|Exhibition on Screen):/i => 'arts',
|
|
22
|
+
/\bThe (?:Musical|Play)\z|\bLive On Stage\z|\bPanto\b/i => 'arts'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
CINECITY = /\(Cinecity\)/i
|
|
26
|
+
CINECITY_NAME = 'Cinecity'
|
|
27
|
+
THREE_D = /\b3D\b/i
|
|
28
|
+
|
|
29
|
+
# @param [DepotUk::Internal::Parser::Calendar::Entry] entry
|
|
30
|
+
# @param [Array<String>] titles every advertised title in full
|
|
31
|
+
# @param [Array<String>] variants from the access pages
|
|
32
|
+
def initialize(entry, titles:, variants:)
|
|
33
|
+
@entry = entry
|
|
34
|
+
@titles = titles
|
|
35
|
+
@variants = variants
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Hash] options for DepotUk::Performance.new
|
|
39
|
+
def to_hash
|
|
40
|
+
{
|
|
41
|
+
booking_url: format(BOOKING_URL, slug: @entry.slug, id: @entry.id),
|
|
42
|
+
dimension: title.match?(THREE_D) ? '3d' : '2d',
|
|
43
|
+
festival: festival,
|
|
44
|
+
film_name: TitleSanitizer.new(title).sanitized,
|
|
45
|
+
film_year: Cinebase::TitleSanitizer.film_year(title),
|
|
46
|
+
starting_at: @entry.starting_at,
|
|
47
|
+
variant: variant
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# WordPress's slug for a title, e.g. 'de-gaulle-liberte'
|
|
52
|
+
# @param [String] title
|
|
53
|
+
# @return [String]
|
|
54
|
+
def self.slug(title)
|
|
55
|
+
ascii = title.unicode_normalize(:nfkd).gsub(/\p{Mn}/, '').downcase.delete("'’")
|
|
56
|
+
ascii.gsub(/[^a-z0-9]+/, '-').delete_prefix('-').delete_suffix('-')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def festival
|
|
62
|
+
year = @entry.date.year
|
|
63
|
+
if title.match?(CINECITY)
|
|
64
|
+
Cinebase::Festival.new(name: CINECITY_NAME, year: year)
|
|
65
|
+
else
|
|
66
|
+
Cinebase::Festival.from_title(title, year: year)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def title
|
|
71
|
+
@title ||= if @entry.heading.end_with?(TRUNCATION)
|
|
72
|
+
full_title(@entry.heading.delete_suffix(TRUNCATION))
|
|
73
|
+
else
|
|
74
|
+
@entry.heading
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The film's slug settles a shortened heading that starts several
|
|
79
|
+
# titles, e.g. 'Exhibition on Screen:...'
|
|
80
|
+
def full_title(start)
|
|
81
|
+
matches = @titles.select { |candidate| candidate.start_with?(start) }
|
|
82
|
+
if matches.one?
|
|
83
|
+
matches.first
|
|
84
|
+
else
|
|
85
|
+
matches.find { |candidate| self.class.slug(candidate) == @entry.slug } || start
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def variant
|
|
90
|
+
from_title = TITLE_VARIANTS.flat_map { |pattern, variants| title.match?(pattern) ? Array(variants) : [] }
|
|
91
|
+
(from_title + @variants).uniq
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module DepotUk
|
|
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 [DepotUk::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
|
+
/\AParent (?:&|and) Baby ONLY:\s*/i, # parent and baby screening
|
|
22
|
+
/\AKNITFLICKS:\s*/i, # knit-along screening
|
|
23
|
+
/\ASee It First\s*[–-]\s*/i, # preview
|
|
24
|
+
/\s*\(Cinecity\)/i, # festival suffix
|
|
25
|
+
/\s*\((?:MET )?(?:Opera|Ballet)\)\z/i # event cinema genre
|
|
26
|
+
] + COMMON_REMOVE
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# regexes and their replacements
|
|
30
|
+
def replace
|
|
31
|
+
{
|
|
32
|
+
/\ARBO: (.*)/ => 'Royal Ballet and Opera: '
|
|
33
|
+
}.merge(COMMON_REPLACE)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DepotUk
|
|
4
|
+
# Internal utility classes: Do not use
|
|
5
|
+
# @api private
|
|
6
|
+
module Internal
|
|
7
|
+
# Reads the pages of lewesdepot.org that, together, describe every
|
|
8
|
+
# advertised performance: the calendar, the home page's film list and
|
|
9
|
+
# the access pages.
|
|
10
|
+
class Website
|
|
11
|
+
include Http
|
|
12
|
+
|
|
13
|
+
CALENDAR_PATH = '/whats-on/calendar'
|
|
14
|
+
HOME_PATH = '/'
|
|
15
|
+
|
|
16
|
+
# Each access page lists every upcoming performance of its kind
|
|
17
|
+
ACCESS_PAGES = {
|
|
18
|
+
'/access/descriptive-subtitles' => 'subtitled',
|
|
19
|
+
'/access/relaxed' => 'relaxed',
|
|
20
|
+
'/access/autism-friendly' => 'relaxed'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
FILM_SELECT = %r{<select[^>]*id="film_select"[^>]*>(.*?)</select>}m
|
|
24
|
+
FILM_OPTION = %r{<option value="\d+">([^<]+)</option>}
|
|
25
|
+
PERFORMANCE_ID = /perf-popup\.php\?id=(\d+)/
|
|
26
|
+
|
|
27
|
+
# Yield a website, closing its connection afterwards
|
|
28
|
+
# @yieldparam [DepotUk::Internal::Website] website
|
|
29
|
+
# @return the block's result
|
|
30
|
+
def self.open
|
|
31
|
+
website = new
|
|
32
|
+
yield website
|
|
33
|
+
ensure
|
|
34
|
+
website.close
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] the calendar page, every advertised date
|
|
38
|
+
def calendar
|
|
39
|
+
get(CALENDAR_PATH)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The calendar shortens long titles, the home page's film list does
|
|
43
|
+
# not
|
|
44
|
+
# @return [Array<String>] every advertised title, e.g. 'Ghosts: The
|
|
45
|
+
# Possession of Button House'
|
|
46
|
+
def titles
|
|
47
|
+
films = get(HOME_PATH)[FILM_SELECT, 1].to_s
|
|
48
|
+
films.scan(FILM_OPTION).map { |(title)| CGI.unescapeHTML(title).strip }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash{String => Array<String>}] variants by performance id,
|
|
52
|
+
# e.g. { '40631087' => ['subtitled', 'relaxed'] }
|
|
53
|
+
def variants
|
|
54
|
+
ACCESS_PAGES.each_with_object({}) do |(path, variant), variants|
|
|
55
|
+
get(path).scan(PERFORMANCE_ID).each do |(id)|
|
|
56
|
+
variants[id] = (variants[id] || []) | [variant]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module DepotUk
|
|
2
|
+
# A single screening of a film at the Depot cinema
|
|
3
|
+
class Performance < Cinebase::Performance
|
|
4
|
+
# @!attribute [r] booking_url
|
|
5
|
+
# @return [String] the booking URL on the cinema website
|
|
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 from the film's title
|
|
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. The calendar
|
|
29
|
+
# holds every advertised date, so any range takes five requests: the
|
|
30
|
+
# calendar, the home page for titles the calendar shortens, and three
|
|
31
|
+
# access pages. A range with no performances takes one.
|
|
32
|
+
# @param [String] cinema_id the cinema's Jacro code, 'deplew'
|
|
33
|
+
# @param [Date] from first date (defaults to today in London)
|
|
34
|
+
# @param [Date] to last date (defaults to 13 days after from)
|
|
35
|
+
# @return [Array<DepotUk::Performance>]
|
|
36
|
+
def self.at(
|
|
37
|
+
cinema_id,
|
|
38
|
+
from: TZInfo::Timezone.get(TIME_ZONE).now.to_date,
|
|
39
|
+
to: from + 13
|
|
40
|
+
)
|
|
41
|
+
raise ArgumentError, 'to must not precede from' if to < from
|
|
42
|
+
|
|
43
|
+
cinema = Cinema.new(cinema_id)
|
|
44
|
+
if cinema.name.empty?
|
|
45
|
+
[]
|
|
46
|
+
else
|
|
47
|
+
performances = Internal::Website.open { |website| read(website, cinema, from..to) }
|
|
48
|
+
performances.sort_by(&:starting_at)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @!method showing_on
|
|
53
|
+
# The date of the screening
|
|
54
|
+
# @return [Date]
|
|
55
|
+
|
|
56
|
+
# @!method starting_at
|
|
57
|
+
# UTC time of the screening
|
|
58
|
+
# @return [Time]
|
|
59
|
+
|
|
60
|
+
# @!method variant
|
|
61
|
+
# The kinds of screening (arts, baby, kids, q&a, relaxed, subtitled)
|
|
62
|
+
# @return <Array[String]>
|
|
63
|
+
|
|
64
|
+
def self.read(website, cinema, dates)
|
|
65
|
+
entries = Internal::Parser::Calendar.new(website.calendar, near: dates.first).entries
|
|
66
|
+
entries = entries.select { |entry| dates.cover?(entry.date) }
|
|
67
|
+
if entries.empty?
|
|
68
|
+
[]
|
|
69
|
+
else
|
|
70
|
+
titles = website.titles
|
|
71
|
+
variants = website.variants
|
|
72
|
+
entries.map do |entry|
|
|
73
|
+
showtime = Internal::Parser::Showtime.new(entry, titles: titles, variants: variants.fetch(entry.id, []))
|
|
74
|
+
new(showtime.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :read
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/depot_uk.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'cgi/escape'
|
|
2
|
+
|
|
3
|
+
require 'cinebase'
|
|
4
|
+
|
|
5
|
+
require_relative './depot_uk/version'
|
|
6
|
+
|
|
7
|
+
require_relative './depot_uk/internal/http'
|
|
8
|
+
require_relative './depot_uk/internal/website'
|
|
9
|
+
require_relative './depot_uk/internal/title_sanitizer'
|
|
10
|
+
require_relative './depot_uk/internal/parser/calendar'
|
|
11
|
+
require_relative './depot_uk/internal/parser/showtime'
|
|
12
|
+
|
|
13
|
+
require_relative './depot_uk/cinema'
|
|
14
|
+
require_relative './depot_uk/performance'
|
|
15
|
+
|
|
16
|
+
# depot_uk gem
|
|
17
|
+
module DepotUk
|
|
18
|
+
end
|