scott_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,151 @@
1
+ # ScottUk
2
+
3
+ A simple gem to pull cinemas and performances from the [Scott Cinemas website](https://www.scottcinemas.co.uk) and spit out useful formatted info.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'scott_uk'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install scott_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,
24
+ festival suffixes, the parent and baby suffix and the film society prefix,
25
+ and naming National Theatre Live and Royal Ballet and Opera screenings in
26
+ full.
27
+
28
+ No token or setup is needed. Scott Cinemas publishes a JSON feed of each
29
+ venue's listings, documented at https://www.scottcinemas.co.uk/api. Tickets
30
+ are sold through TapOS, behind each venue's own booking pages.
31
+
32
+ ### Cinema
33
+
34
+ The website keeps the chosen cinema in a cookie and has no plain list of
35
+ venues, so the gem holds them: names, addresses and feed codes from the
36
+ website's cinema and API pages, and coordinates from its cinema finder. Cinema
37
+ methods make no requests. The four WTW cinemas in Cornwall, which share the
38
+ website's cinema menu, run a separate site and are not included.
39
+
40
+ ``` ruby
41
+ ScottUk::Cinema.all
42
+ #=> [<ScottUk::Cinema ...>, <ScottUk::Cinema ...>, ...]
43
+
44
+ cinema = ScottUk::Cinema.new('SCOBWR')
45
+ #=> <ScottUk::Cinema ...>
46
+
47
+ cinema.adr
48
+ #=> {
49
+ street_address: 'Northgate Yard',
50
+ extended_address: 'Mount Street',
51
+ locality: 'Bridgwater',
52
+ region: 'Somerset',
53
+ postal_code: 'TA6 3FQ',
54
+ country_name: 'United Kingdom'
55
+ }
56
+
57
+ cinema.brand
58
+ #=> 'Scott Cinemas'
59
+
60
+ cinema.full_name
61
+ #=> 'Scott Cinemas Bridgwater'
62
+
63
+ cinema.id
64
+ #=> 'SCOBWR'
65
+
66
+ cinema.latitude
67
+ #=> 51.12985
68
+
69
+ cinema.url
70
+ #=> 'https://bridgwater.scottcinemas.co.uk'
71
+ ```
72
+
73
+ ### Performances
74
+
75
+ `Performance.at` returns performances for an inclusive range of showing dates,
76
+ defaulting to today in London and the following 13 days. The venue's feed
77
+ holds every advertised date, so any range is one request.
78
+
79
+ ``` ruby
80
+ ScottUk::Performance.at('SCOBWR', from: Date.today, to: Date.today + 2)
81
+ #=> [<ScottUk::Performance ...>, <ScottUk::Performance ...>, ...]
82
+
83
+ performance = ScottUk::Performance.at('SCOBWR').first
84
+ #=> <ScottUk::Performance ...>
85
+
86
+ performance.film_name
87
+ #=> 'Ghosts: The Possession of Button House' # from '... (Parent and Baby Only Show)'
88
+
89
+ performance.film_year
90
+ #=> nil # from the title, e.g. 1975 for 'Barry Lyndon (1975)'
91
+
92
+ performance.dimension
93
+ #=> '2d'
94
+
95
+ performance.variant
96
+ #=> ['baby']
97
+
98
+ performance.subtitled?
99
+ #=> false
100
+
101
+ performance.festival
102
+ #=> nil # e.g. #<data Cinebase::Festival name="Exmouth Film Festival", year=2026>
103
+
104
+ performance.starting_at
105
+ #=> 2026-11-04 11:00:00 UTC
106
+
107
+ performance.booking_url
108
+ #=> 'https://bridgwater.scottcinemas.co.uk/book-now/ghosts-the-possession-parent-baby/76429'
109
+
110
+ performance.cinema_name
111
+ #=> 'Bridgwater'
112
+
113
+ performance.cinema_id
114
+ #=> 'SCOBWR'
115
+ ```
116
+
117
+ Booking links are the feed's own, to the venue's booking page, which holds
118
+ the TapOS seat selection.
119
+
120
+ Variants come from the feed's flags and fields: `arts` (event cinema:
121
+ theatre, opera, ballet, musicals, music, art and television), `baby`,
122
+ `relaxed` (autism friendly), `senior` (Silver Screen) and `subtitled`. Most
123
+ screenings offer audio description, so it is not a variant. Strobe warnings,
124
+ ticket offers and premium screens are not variants either.
125
+
126
+ ## Tests
127
+
128
+ ```sh
129
+ bundle exec rake # unit tests against fixtures
130
+ bundle exec rake live # live scottcinemas.co.uk
131
+ ```
132
+
133
+ The fixture in `test/fixtures` is Bridgwater's feed, trimmed to a few dates
134
+ with shortened descriptions. `test/support/api_shapes.rb` describes the fields
135
+ the gem reads. The unit suite checks the fixture against it and the live suite
136
+ checks every venue's real feed, held only in memory. When the feed changes,
137
+ update the shape first, then the fixture.
138
+
139
+ ## Releasing
140
+
141
+ Bump the version, merge, then run the **Release Gem** workflow in GitHub
142
+ Actions. It runs the tests, tags the version and publishes the gem through
143
+ RubyGems trusted publishing.
144
+
145
+ ## Contributing
146
+
147
+ 1. Fork it
148
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
149
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
150
+ 4. Push to the branch (`git push origin my-new-feature`)
151
+ 5. Create new Pull Request
@@ -0,0 +1,123 @@
1
+ module ScottUk
2
+ # The object representing a cinema on the Scott Cinemas website
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [String] the cinema's feed code, e.g. 'SCOBAR'
6
+
7
+ # The website keeps the chosen cinema in a cookie and has no plain venue
8
+ # list, so the gem holds the venues: names, addresses and feed codes
9
+ # from the website's cinema and API pages, coordinates from its cinema
10
+ # finder.
11
+ VENUES = {
12
+ 'SCOBAR' => {
13
+ name: 'Barnstaple', subdomain: 'barnstaple', street_address: 'Boutport Street', locality: 'Barnstaple',
14
+ region: 'Devon', postal_code: 'EX31 1SR', latitude: 51.07933, longitude: -4.05859
15
+ },
16
+ 'SCOBWR' => {
17
+ name: 'Bridgwater', subdomain: 'bridgwater', street_address: 'Northgate Yard',
18
+ extended_address: 'Mount Street', locality: 'Bridgwater', region: 'Somerset', postal_code: 'TA6 3FQ',
19
+ latitude: 51.12985, longitude: -3.006949
20
+ },
21
+ 'SCOORP' => {
22
+ name: 'Bristol, Westbury Park', subdomain: 'bristolwestburypark', street_address: 'Northumbria Drive',
23
+ extended_address: 'Westbury Park', locality: 'Bristol', postal_code: 'BS9 4HN',
24
+ latitude: 51.480392, longitude: -2.612085
25
+ },
26
+ 'SCOEXM' => {
27
+ name: 'Exmouth', subdomain: 'exmouth', street_address: 'Strand Gardens', locality: 'Exmouth',
28
+ region: 'Devon', postal_code: 'EX8 1HP', latitude: 50.619789, longitude: -3.413336
29
+ },
30
+ 'SCONEW' => {
31
+ name: 'Newton Abbot', subdomain: 'newtonabbot', street_address: 'Market Street', locality: 'Newton Abbot',
32
+ region: 'Devon', postal_code: 'TQ12 2RB', latitude: 50.530781, longitude: -3.61197
33
+ },
34
+ 'SCORAD' => {
35
+ name: 'Sidmouth', subdomain: 'sidmouth', street_address: 'Radway Place', locality: 'Sidmouth',
36
+ region: 'Devon', postal_code: 'EX10 8TL', latitude: 50.682728, longitude: -3.238649
37
+ }
38
+ }.freeze
39
+
40
+ ADDRESS_PARTS = %i(street_address extended_address locality region postal_code).freeze
41
+
42
+ # Constructor
43
+ # @param [String] id the cinema's feed code, e.g. 'SCOBAR' or 'scobar'
44
+ # @return [ScottUk::Cinema]
45
+ def initialize(id)
46
+ @id = id.to_s.upcase
47
+ end
48
+
49
+ # Return basic cinema information for all cinemas
50
+ # @return [Array<ScottUk::Cinema>]
51
+ # @example
52
+ # ScottUk::Cinema.all
53
+ # #=> [<ScottUk::Cinema>, <ScottUk::Cinema>, ...]
54
+ def self.all
55
+ VENUES.keys.map { |id| new(id) }
56
+ end
57
+
58
+ # @!method address
59
+ # Address of the cinema
60
+ # @return [Hash] of different address parts
61
+ # @see #adr
62
+
63
+ # Address of the cinema
64
+ # @return [Hash] contains :street_address, :extended_address,
65
+ # :locality, :region, :postal_code, :country_name
66
+ # @example
67
+ # cinema = ScottUk::Cinema.new('SCOBWR')
68
+ # cinema.adr
69
+ # #=> {
70
+ # street_address: 'Northgate Yard',
71
+ # extended_address: 'Mount Street',
72
+ # locality: 'Bridgwater',
73
+ # region: 'Somerset',
74
+ # postal_code: 'TA6 3FQ',
75
+ # country_name: 'United Kingdom'
76
+ # }
77
+ # @note Uses method naming as at http://microformats.org/wiki/adr
78
+ def adr
79
+ ADDRESS_PARTS.to_h { |part| [part, venue[part]] }.merge(country_name: 'United Kingdom')
80
+ end
81
+
82
+ # Brand of the cinema
83
+ # @return [String] which will always be 'Scott Cinemas'
84
+ def brand
85
+ 'Scott Cinemas'.freeze
86
+ end
87
+
88
+ # @return [Float, nil]
89
+ def latitude
90
+ venue[:latitude]
91
+ end
92
+
93
+ # @return [Float, nil]
94
+ def longitude
95
+ venue[:longitude]
96
+ end
97
+
98
+ # The name of the cinema
99
+ # @return [String]
100
+ # @example
101
+ # ScottUk::Cinema.new('SCOORP').name
102
+ # #=> 'Bristol, Westbury Park'
103
+ def name
104
+ venue[:name].to_s
105
+ end
106
+
107
+ # The url of the cinema's pages on the Scott Cinemas website
108
+ # @return [String]
109
+ # @example
110
+ # ScottUk::Cinema.new('SCOBAR').url
111
+ # #=> 'https://barnstaple.scottcinemas.co.uk'
112
+ def url
113
+ subdomain = venue[:subdomain]
114
+ subdomain ? "https://#{subdomain}.scottcinemas.co.uk" : ''
115
+ end
116
+
117
+ private
118
+
119
+ def venue
120
+ VENUES.fetch(id, {})
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScottUk
4
+ # Internal utility classes: Do not use
5
+ # @api private
6
+ module Internal
7
+ # Reads Scott Cinemas' public JSON listings feed, documented at
8
+ # https://www.scottcinemas.co.uk/api. Each venue's feed holds every
9
+ # advertised date.
10
+ class Feed
11
+ include Http
12
+
13
+ URL = 'https://www.scottcinemas.co.uk/json.php'
14
+
15
+ # @param [String] code a venue's feed code, e.g. 'SCOBAR'
16
+ # @return [Hash] the feed: 'cinema', 'films' by id, and 'schedule' of
17
+ # showtimes by local midnight, then screen, then start, each a Unix
18
+ # timestamp
19
+ def listings(code)
20
+ JSON.parse(get("#{URL}?cinema=#{CGI.escape(code)}"))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module ScottUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Requests to scottcinemas.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 "Scott 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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScottUk
4
+ # @api private
5
+ module Internal
6
+ module Parser
7
+ # Parses one showtime from the feed into performance attributes
8
+ class Showtime
9
+ # Showtime flags and the variants they mean. Others, such as 'STB'
10
+ # (strobe lighting), 'ATF' (all tickets £5), 'LUX' and 'DOL' (screen
11
+ # formats), are not variants.
12
+ FLAGS = {
13
+ 'AFS' => 'relaxed',
14
+ 'ART' => 'arts',
15
+ 'BAL' => 'arts',
16
+ 'ECA' => 'arts',
17
+ 'ETV' => 'arts',
18
+ 'MSC' => 'arts',
19
+ 'MUS' => 'arts',
20
+ 'OPE' => 'arts',
21
+ 'PBB' => 'baby',
22
+ 'SSC' => 'senior',
23
+ 'THE' => 'arts'
24
+ }.freeze
25
+
26
+ # Yes/no fields and the variants they mean. Most screenings offer
27
+ # audio description, so it does not mark one out.
28
+ FIELDS = {
29
+ 'AFS' => 'relaxed',
30
+ 'subtitles' => 'subtitled'
31
+ }.freeze
32
+
33
+ YES = 'Y'
34
+ FESTIVAL_FLAG = 'EVF'
35
+ FESTIVAL = 'Exmouth Film Festival'
36
+ THREE_D = /\A3D\z/i
37
+ LANGUAGE = /\((#{Cinebase::TitleSanitizer::LANGUAGES.join('|')})\)/i
38
+
39
+ # @param [Hash] showtime one showtime from the feed's schedule
40
+ # @param [Hash] film the feed's film for the showtime
41
+ # @param [Time] starting_at from the showtime's Unix timestamp
42
+ def initialize(showtime, film:, starting_at:)
43
+ @showtime = showtime
44
+ @film = film
45
+ @starting_at = starting_at
46
+ end
47
+
48
+ # @return [Hash] options for ScottUk::Performance.new
49
+ def to_hash
50
+ {
51
+ booking_url: @showtime['booking_url'],
52
+ dimension: @showtime['performance_type'].to_s.match?(THREE_D) ? '3d' : '2d',
53
+ festival: festival,
54
+ film_name: TitleSanitizer.new(title).sanitized,
55
+ film_year: Cinebase::TitleSanitizer.film_year(title),
56
+ language: title[LANGUAGE, 1],
57
+ starting_at: @starting_at,
58
+ variant: variant
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def flags
65
+ Array(@showtime['flags'])
66
+ end
67
+
68
+ def festival
69
+ if flags.include?(FESTIVAL_FLAG)
70
+ Cinebase::Festival.new(name: FESTIVAL, year: @starting_at.year)
71
+ else
72
+ Cinebase::Festival.from_title(title, year: @starting_at.year)
73
+ end
74
+ end
75
+
76
+ def title
77
+ @title ||= CGI.unescapeHTML(@film.fetch('name')).strip
78
+ end
79
+
80
+ def variant
81
+ from_fields = FIELDS.filter_map { |field, variant| variant if @showtime[field] == YES }
82
+ (flags.filter_map { |flag| FLAGS[flag] } + from_fields).uniq
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,36 @@
1
+ module ScottUk
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 [ScottUk::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
+ /\s*\(Parent and Baby Only Show\)/i, # parent and baby screening
22
+ /\s*(?:-\s+|\()Exmouth Film Festival\)?/i, # festival suffix
23
+ /\ASFS:\s*/ # film society prefix
24
+ ] + COMMON_REMOVE
25
+ end
26
+
27
+ # regexes and their replacements
28
+ def replace
29
+ {
30
+ /\ARBO \d{2}-\d{2}: (.*)/i => 'Royal Ballet and Opera: ',
31
+ /\ANATIONAL THEATRE LIVE: (.*)/i => 'National Theatre Live: '
32
+ }.merge(COMMON_REPLACE)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ module ScottUk
2
+ # A single screening of a film on the Scott Cinemas website
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 [String] :language (nil) audio language, e.g. 'Hindi'
25
+ # @option options [Time] :starting_at listed start time of the performance
26
+
27
+ TIME_ZONE = 'Europe/London'.freeze
28
+
29
+ # Performances within an inclusive range of showing dates, from one
30
+ # request: the venue's feed holds every advertised date.
31
+ # @param [String] cinema_id the cinema's feed code, e.g. 'SCOBAR'
32
+ # @param [Date] from first date (defaults to today in London)
33
+ # @param [Date] to last date (defaults to 13 days after from)
34
+ # @return [Array<ScottUk::Performance>]
35
+ def self.at(
36
+ cinema_id,
37
+ from: TZInfo::Timezone.get(TIME_ZONE).now.to_date,
38
+ to: from + 13
39
+ )
40
+ raise ArgumentError, 'to must not precede from' if to < from
41
+
42
+ cinema = Cinema.new(cinema_id)
43
+ if cinema.name.empty?
44
+ []
45
+ else
46
+ feed = ScottUk::Internal::Feed.new.listings(cinema.id)
47
+ performances = showtimes_between(feed, from, to).map do |showtime, film, starting_at|
48
+ parsed = ScottUk::Internal::Parser::Showtime.new(showtime, film: film, starting_at: starting_at)
49
+ new(parsed.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
50
+ end
51
+ performances.sort_by(&:starting_at)
52
+ end
53
+ end
54
+
55
+ # @!method showing_on
56
+ # The date of the screening
57
+ # @return [Date]
58
+
59
+ # @!method starting_at
60
+ # UTC time of the screening
61
+ # @return [Time]
62
+
63
+ # @!method variant
64
+ # The kinds of screening (arts, audio_described, baby, relaxed ...)
65
+ # @return <Array[String]>
66
+
67
+ # PHP sends an empty schedule, day or screen as [] rather than {}
68
+ def self.showtimes_between(feed, from, to)
69
+ films = Hash(feed['films'])
70
+ Hash(feed['schedule']).flat_map do |midnight, screens|
71
+ if (from..to).cover?(london_date(midnight))
72
+ Hash(screens).values.flat_map do |showtimes|
73
+ Hash(showtimes).filter_map do |timestamp, showtime|
74
+ film = films[showtime['film_id']]
75
+ [showtime, film, Time.at(timestamp.to_i).utc] if film
76
+ end
77
+ end
78
+ else
79
+ []
80
+ end
81
+ end
82
+ end
83
+ private_class_method :showtimes_between
84
+
85
+ def self.london_date(timestamp)
86
+ TZInfo::Timezone.get(TIME_ZONE).to_local(Time.at(timestamp.to_i).utc).to_date
87
+ end
88
+ private_class_method :london_date
89
+ end
90
+ end
@@ -0,0 +1,6 @@
1
+ # Ruby interface for https://www.scottcinemas.co.uk
2
+ # @version 1.0.0
3
+ module ScottUk
4
+ # Gem version
5
+ VERSION = '1.0.0'.freeze
6
+ end
data/lib/scott_uk.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'cgi/escape'
2
+ require 'json'
3
+
4
+ require 'cinebase'
5
+
6
+ require_relative './scott_uk/version'
7
+
8
+ require_relative './scott_uk/internal/http'
9
+ require_relative './scott_uk/internal/feed'
10
+ require_relative './scott_uk/internal/title_sanitizer'
11
+ require_relative './scott_uk/internal/parser/showtime'
12
+
13
+ require_relative './scott_uk/cinema'
14
+ require_relative './scott_uk/performance'
15
+
16
+ # scott_uk gem
17
+ module ScottUk
18
+ end