light_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,150 @@
1
+ # LightUk
2
+
3
+ A simple gem to pull cinemas and performances from the [Light cinemas website](https://thelight.co.uk) and spit out useful formatted info.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'light_uk'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install light_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 language
24
+ versions, years, anniversary restorations, Q&A add-ons and festival prefixes.
25
+
26
+ No token or setup is needed. Each venue has its own site, such as
27
+ `https://sheffield.thelight.co.uk`, built by Glitterfish. Every venue's home
28
+ page lists all the venues and shows its own address.
29
+
30
+ ### Cinema
31
+
32
+ The venue list is cached for an hour. The first call to an address method reads
33
+ the cinema's home page. The website gives no coordinates, so `latitude` and
34
+ `longitude` are `nil`.
35
+
36
+ ``` ruby
37
+ LightUk::Cinema.all
38
+ #=> [<LightUk::Cinema ...>, <LightUk::Cinema ...>, ...]
39
+
40
+ cinema = LightUk::Cinema.new(3)
41
+ #=> <LightUk::Cinema ...>
42
+
43
+ cinema.adr
44
+ #=> {
45
+ street_address: 'Cambridge Leisure Park',
46
+ extended_address: 'Clifton Way',
47
+ locality: 'Cambridge',
48
+ region: nil,
49
+ postal_code: 'CB1 7DY',
50
+ country_name: 'United Kingdom'
51
+ }
52
+
53
+ cinema.brand
54
+ #=> 'Light'
55
+
56
+ cinema.full_name
57
+ #=> 'Light Cambridge'
58
+
59
+ cinema.id
60
+ #=> '3'
61
+
62
+ cinema.url
63
+ #=> 'https://cambridge.thelight.co.uk'
64
+ ```
65
+
66
+ ### Performances
67
+
68
+ `Performance.at` returns performances for an inclusive range of showing dates,
69
+ defaulting to today in London and the following 13 days. The venue's guide only
70
+ covers a fortnight, so it also reads the coming soon page, which holds every
71
+ session of films that have not opened yet, however far ahead. That is two
72
+ requests for all of the cinema's advertised dates.
73
+
74
+ ``` ruby
75
+ LightUk::Performance.at(3, from: Date.today, to: Date.today + 2)
76
+ #=> [<LightUk::Performance ...>, <LightUk::Performance ...>, ...]
77
+
78
+ performance = LightUk::Performance.at(3).first
79
+ #=> <LightUk::Performance ...>
80
+
81
+ performance.film_name
82
+ #=> 'Ramayana: Rise of a Legend'
83
+
84
+ performance.language
85
+ #=> 'Hindi' # from '(Hindi)' in the title
86
+
87
+ performance.film_year
88
+ #=> nil # from the title, e.g. 1996 for 'From Dusk Till Dawn (1996)'
89
+
90
+ performance.dimension
91
+ #=> '3d'
92
+
93
+ performance.variant
94
+ #=> ['imax']
95
+
96
+ performance.subtitled?
97
+ #=> false
98
+
99
+ performance.festival
100
+ #=> nil # e.g. #<data Cinebase::Festival name="CFF", year=2026>
101
+
102
+ performance.starting_at
103
+ #=> 2026-11-08 19:50:00 UTC
104
+
105
+ performance.booking_url
106
+ #=> 'https://cambridge.thelight.co.uk/#!book/cinema/83478'
107
+
108
+ performance.cinema_name
109
+ #=> 'Cambridge'
110
+
111
+ performance.cinema_id
112
+ #=> '3'
113
+ ```
114
+
115
+ Booking links open the booking panel on the venue's home page. Sessions that
116
+ have started have no booking link.
117
+
118
+ Variants are `arts`, `audio_described`, `baby`, `imax`, `kids`, `q&a`,
119
+ `relaxed`, `senior` and `subtitled`.
120
+
121
+ ## Tests
122
+
123
+ ```sh
124
+ bundle exec rake # unit tests against fixtures
125
+ bundle exec rake live # live thelight.co.uk
126
+ ```
127
+
128
+ Fixtures in `test/fixtures` are pages captured from the Cambridge venue's
129
+ website and trimmed to a few films. `test/support/api_shapes.rb` describes the
130
+ fields the gem reads. The unit suite checks the fixtures against it and the
131
+ live suite checks real responses, held only in memory. When the website
132
+ changes, update the shape first, then the fixtures.
133
+
134
+ The website's certificate chains to Sectigo's Public Server Authentication Root
135
+ R46. Trust stores without it, such as macOS's `/etc/ssl/cert.pem`, fail to
136
+ verify it; point `SSL_CERT_FILE` at a current Mozilla bundle instead.
137
+
138
+ ## Releasing
139
+
140
+ Bump the version, merge, then run the **Release Gem** workflow in GitHub
141
+ Actions. It runs the tests, tags the version and publishes the gem through
142
+ RubyGems trusted publishing.
143
+
144
+ ## Contributing
145
+
146
+ 1. Fork it
147
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
148
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
149
+ 4. Push to the branch (`git push origin my-new-feature`)
150
+ 5. Create new Pull Request
@@ -0,0 +1,112 @@
1
+ module LightUk
2
+ # The object representing a cinema on the Light website
3
+ class Cinema < Cinebase::Cinema
4
+ # @!attribute [r] id
5
+ # @return [String] the Light id of the cinema, e.g. '7'
6
+
7
+ # Worker processes outlive a day's listings, so refetch the venue list
8
+ CACHE_SECONDS = 3600
9
+
10
+ # UK postcode, the last part of an address
11
+ POSTCODE = /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/i.freeze
12
+
13
+ # Constructor
14
+ # @param [Integer, String] id the cinema id of the format 7/'7'
15
+ # @return [LightUk::Cinema]
16
+ def initialize(id)
17
+ @id = id.to_s
18
+ end
19
+
20
+ # Return basic cinema information for all cinemas
21
+ # @return [Array<LightUk::Cinema>]
22
+ # @example
23
+ # LightUk::Cinema.all
24
+ # #=> [<LightUk::Cinema>, <LightUk::Cinema>, ...]
25
+ def self.all
26
+ sites_hash.keys.map { |id| new(id) }
27
+ end
28
+
29
+ # @!method address
30
+ # Address of the cinema
31
+ # @return [Hash] of different address parts
32
+ # @see #adr
33
+
34
+ # Address of the cinema, read from the footer of its home page
35
+ # @return [Hash] contains :street_address, :extended_address,
36
+ # :locality, :region, :postal_code, :country_name
37
+ # @example
38
+ # cinema = LightUk::Cinema.new(3)
39
+ # cinema.adr
40
+ # #=> {
41
+ # street_address: 'Cambridge Leisure Park',
42
+ # extended_address: 'Clifton Way',
43
+ # locality: 'Cambridge',
44
+ # region: nil,
45
+ # postal_code: 'CB1 7DY',
46
+ # country_name: 'United Kingdom'
47
+ # }
48
+ # @note Uses method naming as at http://microformats.org/wiki/adr
49
+ def adr
50
+ parts = address_parts
51
+ postal_code = parts.pop if parts.last&.match?(POSTCODE)
52
+ # Some addresses leave out the town, which every venue is named after
53
+ town_at = parts.index(name) || parts.size
54
+ lines = parts.first(town_at)
55
+ region = parts.drop(town_at + 1)
56
+ {
57
+ street_address: lines.shift,
58
+ extended_address: lines.empty? ? nil : lines.join(', '),
59
+ locality: name,
60
+ region: region.empty? ? nil : region.join(', '),
61
+ postal_code: postal_code,
62
+ country_name: 'United Kingdom'.freeze
63
+ }
64
+ end
65
+
66
+ # Brand of the cinema
67
+ # @return [String] which will always be 'Light'
68
+ def brand
69
+ 'Light'.freeze
70
+ end
71
+
72
+ # The name of the cinema
73
+ # @return [String]
74
+ # @example
75
+ # LightUk::Cinema.new(7).name
76
+ # #=> 'Sheffield'
77
+ def name
78
+ site['title'].to_s
79
+ end
80
+
81
+ # The url of the cinema's home page on the Light website
82
+ # @return [String]
83
+ # @example
84
+ # LightUk::Cinema.new(7).url
85
+ # #=> 'https://sheffield.thelight.co.uk'
86
+ def url
87
+ site['url'].to_s
88
+ end
89
+
90
+ # @api private
91
+ def self.sites_hash
92
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+ if @sites_hash.nil? || now - @fetched_at > CACHE_SECONDS
94
+ @fetched_at = now
95
+ @sites_hash = LightUk::Internal::Website.new.sites.to_h { |site| [site.fetch('id').to_s, site] }
96
+ end
97
+ @sites_hash
98
+ end
99
+ private_class_method :sites_hash
100
+
101
+ private
102
+
103
+ def address_parts
104
+ @address ||= LightUk::Internal::Website.new.address(url).to_s
105
+ @address.split(',').map(&:strip).reject(&:empty?)
106
+ end
107
+
108
+ def site
109
+ self.class.send(:sites_hash).fetch(id, {})
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module LightUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Requests to thelight.co.uk
10
+ module Http
11
+ private
12
+
13
+ def get(url)
14
+ uri = URI(url)
15
+ request = Net::HTTP::Get.new(uri)
16
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
17
+ open_timeout: 10, read_timeout: 30) do |http|
18
+ http.request(request)
19
+ end
20
+ # Omit response bodies from errors, and never follow redirects.
21
+ raise "Light HTTP error #{response.code}" unless response.is_a?(Net::HTTPSuccess)
22
+
23
+ String.new(response.body, encoding: Encoding::UTF_8)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LightUk
6
+ # @api private
7
+ module Internal
8
+ module Parser
9
+ # Reads the films and sessions embedded in a venue's coming soon page
10
+ class ComingSoon
11
+ # Each film's markup starts with its programme id
12
+ FILM = /<div data-prog="(\d+)" class="prog">/
13
+ TITLE = %r{<h2><a href="[^"]*">(.*?)</a></h2>}m
14
+
15
+ # @param [String] html the coming soon page
16
+ def initialize(html)
17
+ @html = html
18
+ end
19
+
20
+ # @return [Array<Hash>] films in the shape of the miniguide's Schedule
21
+ def films
22
+ @html.split(FILM).drop(1).each_slice(2).filter_map do |id, markup|
23
+ title = markup[TITLE, 1]
24
+ dates = markup[/ScheduleBrowser\(\{selector: '#prog-#{id}'.*?, data:(\[.*?\])\}\)\}\);<\/script>/m, 1]
25
+ if title && dates
26
+ { "ID" => id.to_i, "Title" => title, "Dates" => JSON.parse(dates) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LightUk
4
+ # @api private
5
+ module Internal
6
+ module Parser
7
+ # Parses a single Light session into performance attributes
8
+ class Session
9
+ # Booking opens in a panel on the venue's home page
10
+ BOOKING_PATH = "/#!book/cinema/"
11
+
12
+ # Light collection titles
13
+ VARIANTS = {
14
+ "Audio Description" => "audio_described",
15
+ "Baby-friendly screenings" => "baby",
16
+ "Captioned for hard of hearing" => "subtitled",
17
+ "Dementia-friendly screenings" => "relaxed",
18
+ "Family Specials" => "kids",
19
+ "IMAX" => "imax",
20
+ "Museums & Galleries on Screen" => "arts",
21
+ "Music" => "arts",
22
+ "Musicals" => "arts",
23
+ "Opera & Dance" => "arts",
24
+ "Q&A" => "q&a",
25
+ "Relaxed Screenings" => "relaxed",
26
+ "Silver Screen" => "senior",
27
+ "Theatre" => "arts",
28
+ "Toddler Time" => "kids"
29
+ }.freeze
30
+
31
+ # FormatDisplay, e.g. '2D', '3D', 'IMAX' or 'IMAX 3D'
32
+ THREE_D = /\b3D\b/i
33
+ IMAX = /\bIMAX\b/i
34
+
35
+ LANGUAGE = /\((#{Cinebase::TitleSanitizer::LANGUAGES.join('|')})\)/i
36
+ TIME_ZONE = "Europe/London"
37
+
38
+ # @param [Hash] session one session
39
+ # @param [Hash] film the film the session shows
40
+ # @param [Hash] date the listings date the session is on
41
+ # @param [String] venue_url the venue's home page
42
+ def initialize(session, film:, date:, venue_url:)
43
+ @session = session
44
+ @film = film
45
+ @date = date
46
+ @venue_url = venue_url
47
+ end
48
+
49
+ # @return [Hash] options for LightUk::Performance.new
50
+ def to_hash
51
+ {
52
+ booking_url: booking_url,
53
+ dimension: format.match?(THREE_D) ? "3d" : "2d",
54
+ festival: Cinebase::Festival.from_title(title, year: starting_at.year),
55
+ film_name: TitleSanitizer.new(title).sanitized,
56
+ film_year: Cinebase::TitleSanitizer.film_year(title),
57
+ language: title[LANGUAGE, 1],
58
+ starting_at: starting_at,
59
+ variant: variant
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ # Sessions that have started have no booking id
66
+ def booking_url
67
+ id = @session["BOID"]
68
+ "#{@venue_url.chomp('/')}#{BOOKING_PATH}#{id}" if id
69
+ end
70
+
71
+ def format
72
+ @session["FormatDisplay"].to_s
73
+ end
74
+
75
+ # Dates are keys such as '20261031' and times are London time such as '18.15'
76
+ def starting_at
77
+ @starting_at ||= begin
78
+ year, month, day = @date.fetch("Key").unpack("a4a2a2").map(&:to_i)
79
+ hour, minute = @session.fetch("Display").scan(/\d+/).map(&:to_i)
80
+ local = Time.utc(year, month, day, hour, minute)
81
+ TZInfo::Timezone.get(TIME_ZONE).local_to_utc(local) { |periods| periods.first }.utc
82
+ end
83
+ end
84
+
85
+ def title
86
+ @title ||= CGI.unescapeHTML(@film.fetch("Title"))
87
+ end
88
+
89
+ def variant
90
+ variants = @session.fetch("Collections", []).filter_map { |collection| VARIANTS[collection["Title"]] }
91
+ variants << "imax" if format.match?(IMAX)
92
+ variants.uniq
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,38 @@
1
+ module LightUk
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 [LightUk::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
+ /\ACrafty Cinema:\s*/i, # craft activity before the film
22
+ /\s*\(\d+(st|nd|rd|th) Anniversary [^)]*\)/i, # anniversary restoration
23
+ /\s*\(4K Restoration\)/i, # restoration
24
+ /\s*\(Encore\)/i, # encore broadcast
25
+ /\s*\+\s*Mulled Wine\b.*\z/i # seasonal extras
26
+ ] + COMMON_REMOVE
27
+ end
28
+
29
+ # regexes and their replacements
30
+ def replace
31
+ {
32
+ /EXHIBITION ON SCREEN:\s*(.*)/i => 'Exhibition on Screen: ',
33
+ /(?:NT|National Theatre) Live:\s*(.*)/ => 'National Theatre Live: '
34
+ }.merge(COMMON_REPLACE)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LightUk
6
+ # Internal utility classes: Do not use
7
+ # @api private
8
+ module Internal
9
+ # Reads venues and listings from the Light websites, one per venue
10
+ class Website
11
+ include Http
12
+
13
+ # Every venue's home page lists all the venues
14
+ SITES_URL = "https://sheffield.thelight.co.uk/"
15
+ MINIGUIDE_PATH = "/resource/services/miniguide/data.ashx"
16
+ COMING_SOON_PATH = "/cinema/comingsoon"
17
+
18
+ # @return [Array<Hash>] venues, e.g. { 'id' => 7, 'title' => 'Sheffield',
19
+ # 'url' => 'https://sheffield.thelight.co.uk' }
20
+ def sites
21
+ JSON.parse(get(SITES_URL)[/var __sites = (\[.*?\]);/, 1] || "[]")
22
+ end
23
+
24
+ # @param [String] url a venue's home page
25
+ # @return [String, nil] the address in the page footer, e.g.
26
+ # 'The Moor, Sheffield, S1 4PF'
27
+ def address(url)
28
+ address = get(url)[%r{<p class="address">(.*?)</p>}m, 1]
29
+ CGI.unescapeHTML(address).strip if address
30
+ end
31
+
32
+ # The guide behind the venue's listings, which covers a fortnight
33
+ # @param [String] url a venue's home page
34
+ # @return [Array<Hash>] films with their sessions on each date
35
+ def miniguide(url)
36
+ script = get(URI.join(url, MINIGUIDE_PATH).to_s)
37
+ JSON.parse(script[/var __gfminiguidedata = (\{.*\});/m, 1] || "{}").fetch("Schedule", [])
38
+ end
39
+
40
+ # Every session of films not yet showing, however far ahead
41
+ # @param [String] url a venue's home page
42
+ # @return [Array<Hash>] films in the shape of #miniguide
43
+ def coming_soon(url)
44
+ Parser::ComingSoon.new(get(URI.join(url, COMING_SOON_PATH).to_s)).films
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,77 @@
1
+ module LightUk
2
+ # A single screening of a film on the Light 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
+ # Performances within an inclusive range of showing dates, from two
28
+ # requests for all of the cinema's advertised dates: the next fortnight's
29
+ # guide and the coming soon page.
30
+ # @param [Integer, String] cinema_id id of the cinema
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<LightUk::Performance>]
34
+ def self.at(
35
+ cinema_id,
36
+ from: TZInfo::Timezone.get('Europe/London').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
+ website = LightUk::Internal::Website.new
43
+ films = website.miniguide(cinema.url) + website.coming_soon(cinema.url)
44
+ performances = sessions_between(films, from, to).map do |film, date, session|
45
+ parsed = LightUk::Internal::Parser::Session.new(session, film: film, date: date, venue_url: cinema.url)
46
+ new(parsed.to_hash.merge(cinema_id: cinema.id, cinema_name: cinema.name))
47
+ end
48
+ performances.sort_by(&:starting_at)
49
+ end
50
+
51
+ # @!method showing_on
52
+ # The date of the screening
53
+ # @return [Date]
54
+
55
+ # @!method starting_at
56
+ # UTC time of the screening
57
+ # @return [Time]
58
+
59
+ # @!method variant
60
+ # The kinds of screening (arts, audio_described, kids, subtitled ...)
61
+ # @return <Array[String]>
62
+
63
+ # private
64
+
65
+ # The coming soon page repeats the sessions of films that open within the
66
+ # guide's fortnight, and sessions that have started have no booking id.
67
+ def self.sessions_between(films, from, to)
68
+ sessions = films.flat_map do |film|
69
+ film.fetch('Dates', [])
70
+ .select { |date| (from..to).cover?(Date.iso8601(date.fetch('Key'))) }
71
+ .flat_map { |date| date.fetch('Sessions', []).map { |session| [film, date, session] } }
72
+ end
73
+ sessions.uniq { |film, date, session| session['BOID'] || [film['ID'], date['Key'], session['Display']] }
74
+ end
75
+ private_class_method :sessions_between
76
+ end
77
+ end
@@ -0,0 +1,6 @@
1
+ # Ruby interface for https://thelight.co.uk
2
+ # @version 1.0.0
3
+ module LightUk
4
+ # Gem version
5
+ VERSION = '1.0.0'.freeze
6
+ end
data/lib/light_uk.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'cgi/escape'
2
+ require 'json'
3
+
4
+ require 'cinebase'
5
+
6
+ require_relative './light_uk/version'
7
+
8
+ require_relative './light_uk/internal/http'
9
+ require_relative './light_uk/internal/website'
10
+ require_relative './light_uk/internal/title_sanitizer'
11
+
12
+ require_relative './light_uk/internal/parser/coming_soon'
13
+ require_relative './light_uk/internal/parser/session'
14
+
15
+ require_relative './light_uk/cinema'
16
+ require_relative './light_uk/performance'
17
+
18
+ # light_uk gem
19
+ module LightUk
20
+ end