picturehouse_uk 3.0.14 → 5.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.
Files changed (63) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +26 -0
  3. data/.github/workflows/release.yml +26 -0
  4. data/.gitignore +1 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +44 -0
  7. data/LICENSE +617 -0
  8. data/README.md +5 -4
  9. data/Rakefile +12 -0
  10. data/lib/picturehouse_uk/cinema.rb +152 -127
  11. data/lib/picturehouse_uk/internal/api.rb +42 -0
  12. data/lib/picturehouse_uk/internal/parser/address.rb +36 -9
  13. data/lib/picturehouse_uk/internal/parser/screenings.rb +89 -95
  14. data/lib/picturehouse_uk/internal/title_sanitizer.rb +61 -65
  15. data/lib/picturehouse_uk/internal/website.rb +10 -12
  16. data/lib/picturehouse_uk/performance.rb +60 -0
  17. data/lib/picturehouse_uk/version.rb +2 -2
  18. data/lib/picturehouse_uk.rb +3 -4
  19. data/picturehouse_uk.gemspec +5 -7
  20. data/rake/fixture_creator.rb +62 -0
  21. data/test/fixtures/cinemas.html +4262 -0
  22. data/test/fixtures/duke-of-york-s-picturehouse/cinema.html +6187 -0
  23. data/test/fixtures/duke-of-york-s-picturehouse/get_movies.json +120552 -0
  24. data/test/fixtures/duke-of-york-s-picturehouse/information.html +3725 -0
  25. data/test/fixtures/duke-s-at-komedia/cinema.html +6138 -0
  26. data/test/fixtures/duke-s-at-komedia/get_movies.json +112 -0
  27. data/test/fixtures/duke-s-at-komedia/information.html +3690 -0
  28. data/test/fixtures/home.html +6236 -565
  29. data/test/fixtures/phoenix-picturehouse/cinema.html +6089 -0
  30. data/test/fixtures/phoenix-picturehouse/get_movies.json +120552 -0
  31. data/test/fixtures/phoenix-picturehouse/information.html +3630 -0
  32. data/test/lib/picturehouse_uk/cinema_test.rb +113 -142
  33. data/test/lib/picturehouse_uk/internal/api_test.rb +92 -0
  34. data/test/lib/picturehouse_uk/internal/parser/{address_parser_test.rb → address_test.rb} +11 -11
  35. data/test/lib/picturehouse_uk/internal/parser/screenings_test.rb +100 -48
  36. data/test/lib/picturehouse_uk/internal/title_sanitizer_test.rb +48 -48
  37. data/test/lib/picturehouse_uk/internal/website_test.rb +15 -31
  38. data/test/lib/picturehouse_uk/performance_test.rb +197 -0
  39. data/test/lib/picturehouse_uk/version_test.rb +1 -1
  40. data/test/live/integration_test.rb +15 -32
  41. data/test/support/fake_api.rb +16 -0
  42. data/test/support/fake_website.rb +24 -0
  43. data/test/test_helper.rb +13 -2
  44. metadata +52 -67
  45. data/.rdoc_options +0 -16
  46. data/.travis.yml +0 -5
  47. data/lib/picturehouse_uk/film.rb +0 -59
  48. data/lib/picturehouse_uk/screening.rb +0 -70
  49. data/test/fixture_updater.rb +0 -73
  50. data/test/fixtures/cinema/Duke_Of_Yorks.html +0 -2984
  51. data/test/fixtures/cinema/Dukes_At_Komedia.html +0 -5518
  52. data/test/fixtures/cinema/National_Media_Museum.html +0 -10266
  53. data/test/fixtures/cinema/Phoenix_Picturehouse.html +0 -5202
  54. data/test/fixtures/info/Duke_Of_Yorks.html +0 -549
  55. data/test/fixtures/info/Dukes_At_Komedia.html +0 -537
  56. data/test/fixtures/info/Phoenix_Picturehouse.html +0 -553
  57. data/test/fixtures/whats_on/Duke_Of_Yorks.html +0 -2737
  58. data/test/fixtures/whats_on/Dukes_At_Komedia.html +0 -5132
  59. data/test/fixtures/whats_on/National_Media_Museum.html +0 -9690
  60. data/test/fixtures/whats_on/Phoenix_Picturehouse.html +0 -4916
  61. data/test/lib/picturehouse_uk/film_test.rb +0 -141
  62. data/test/lib/picturehouse_uk/screening_test.rb +0 -181
  63. /data/{LICENSE.txt → COMM-LICENSE} +0 -0
@@ -1,59 +1,43 @@
1
1
  module PicturehouseUk
2
2
  # The object representing a cinema on the Picturehouse UK website
3
- class Cinema
3
+ class Cinema < Cinebase::Cinema
4
4
  # address css
5
- ADDRESS_CSS = '.static-content #contact-us + p:first'
6
- # cinema link css
7
- CINEMA_LINKS_CSS = '.footer .col-sm-3 option + option'
8
-
9
- # @return [String] the brand of the cinema
10
- attr_reader :brand
11
- # @return [String] the id of the cinema on the Picturehouse website
12
- attr_reader :id
13
- # @return [String] the name of the cinema
14
- attr_reader :name
15
- # @return [String] the slug of the cinema
16
- attr_reader :slug
17
- # @return [String] the url of the cinema on the Picturehouse website
18
- attr_reader :url
19
-
20
- # @param [Hash] options id, name and url of the cinemas
21
- # @return [PicturehouseUk::Cinema]
22
- def initialize(options)
23
- @brand = 'Picturehouse'
24
- @id = options[:id]
25
- @name = options[:name]
26
- @slug = @name.downcase.gsub(/[^0-9a-z ]/, '').gsub(/\s+/, '-')
27
- @url = if options[:url][0] == '/'
28
- "http://www.picturehouses.com#{options[:url]}"
29
- else
30
- options[:url]
31
- end
32
- end
5
+ ADDRESS_CSS = '.cinemaAdrass:not(.openingTime)'.freeze
6
+ # cinema link css on the /cinema page
7
+ CINEMA_LINKS_CSS = 'a[href*="/cinema/"]'.freeze
8
+
9
+ # @!attribute [r] id
10
+ # @return [Integer] the numeric id of the cinema on the Cineworld website
11
+
12
+ # @!method initialize(options)
13
+ # Constructor
14
+ # @param [String] id the cinema id of the cinema in capitalized snake case
15
+ # @return [PicturehouseUk::Cinema]
33
16
 
34
17
  # Return basic cinema information for all cinemas
35
18
  # @return [Array<PicturehouseUk::Cinema>]
36
19
  # @example
37
20
  # PicturehouseUk::Cinema.all
38
- # # => [<PicturehouseUK::Cinema ...>, <PicturehouseUK::Cinema ...>, ...]
21
+ # #=> [<PicturehouseUk::Cinema>, <PicturehouseUk::Cinema>, ...]
39
22
  def self.all
40
- cinema_links.map { |link| new_from_link(link) }
23
+ cinema_hash.keys.map { |id| new(id) }
41
24
  end
42
25
 
43
- # Find a single cinema
44
- # @param [String] id the cinema id as used on the picturehouses.co.uk website
45
- # @return [PicturehouseUk::Cinema, nil]
46
- # @example
47
- # PicturehouseUk::Cinema.find('Dukes_At_Komedia')
48
- # # => <PicturehouseUK::Cinema ...>
49
- def self.find(id)
50
- all.find { |cinema| cinema.id == id }
26
+ # @api private
27
+ def self.cinema_hash
28
+ @cinema_hash ||= ListParser.new(cinema_links).to_hash
51
29
  end
52
30
 
31
+ # @!method address
32
+ # Address of the cinema
33
+ # @return [Hash] of different address parts
34
+ # @see #adr
35
+
53
36
  # Address of the cinema
54
- # @return [Hash] of different address parts
37
+ # @return [Hash] contains :street_address, :extended_address,
38
+ # :locality, :postal_code, :country
55
39
  # @example
56
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
40
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
57
41
  # cinema.adr
58
42
  # #=> {
59
43
  # street_address: '44-47 Gardner Street',
@@ -67,122 +51,163 @@ module PicturehouseUk
67
51
  def adr
68
52
  PicturehouseUk::Internal::Parser::Address.new(address_node.to_s).address
69
53
  end
70
- alias_method :address, :adr
71
-
72
- # The second address line of of the cinema
73
- # @return [String, nil]
74
- # @example
75
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
76
- # cinema.extended_address
77
- # #=> 'North Laine'
78
- # @note Uses method naming as at http://microformats.org/wiki/adr
79
- def extended_address
80
- address[:extended_address]
81
- end
82
54
 
83
- # Films with showings scheduled at this cinema
84
- # @return [Array<PicturehouseUk::Film>]
55
+ # Brand of the cinema
56
+ # @return [String] which will always be 'Picturehouse'
85
57
  # @example
86
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
87
- # cinema.films
88
- # # => [<PicturehouseUk::Film ...>, <PicturehouseUk::Film ...>, ...]
89
- def films
90
- PicturehouseUk::Film.at(@id)
91
- end
58
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
59
+ # cinema.brand
60
+ # #=> 'Picturehouse'
61
+ def brand
62
+ 'Picturehouse'.freeze
63
+ end
64
+
65
+ # @!method country_name
66
+ # Country of the cinema
67
+ # @return [String] which will always be 'United Kingdom'
68
+ # @example
69
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
70
+ # cinema.country_name
71
+ # #=> 'United Kingdom'
72
+
73
+ # @!method extended_address
74
+ # The second address line of the cinema
75
+ # @return [String]
76
+ # @example
77
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
78
+ # cinema.extended_address
79
+ # #=> 'North Laine'
92
80
 
93
81
  # The name of the cinema (might include brand)
94
82
  # @return [String]
95
83
  # @example
96
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
84
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
97
85
  # cinema.full_name
98
86
  # #=> "Duke's At Komedia"
99
87
  def full_name
100
88
  name
101
89
  end
102
90
 
103
- # The locality (town) of the cinema
104
- # @return [String]
105
- # @example
106
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
107
- # cinema.locality
108
- # #=> 'Brighton'
109
- # @note Uses the standard method naming as at http://microformats.org/wiki/adr
110
- def locality
111
- address[:locality]
112
- end
91
+ # @!method locality
92
+ # The locality (town) of the cinema
93
+ # @return [String]
94
+ # @example
95
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
96
+ # cinema.locality
97
+ # #=> 'Brighton'
113
98
 
114
- # Post code of the cinema
99
+ # The name of the cinema
115
100
  # @return [String]
116
101
  # @example
117
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
118
- # cinema.postal_code
119
- # #=> 'BN1 1UN'
120
- # @note Uses the standard method naming as at http://microformats.org/wiki/adr
121
- def postal_code
122
- address[:postal_code]
123
- end
124
-
125
- # The region (county) of the cinema
102
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
103
+ # cinema.name
104
+ # #=> "Duke's At Komedia"
105
+ def name
106
+ self.class.cinema_hash.fetch(id, {})[:name]
107
+ end
108
+
109
+ # @!method postal_code
110
+ # Post code of the cinema
111
+ # @return [String]
112
+ # @example
113
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
114
+ # cinema.postal_code
115
+ # #=> 'BN1 1UN'
116
+
117
+ # @!method region
118
+ # The region (county) of the cinema if provided
119
+ # @return [String]
120
+ # @example
121
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
122
+ # cinema.region
123
+ # #=> 'East Sussex'
124
+
125
+ # @!method slug
126
+ # The URL-able slug of the cinema
127
+ # @return [String]
128
+ # @example
129
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
130
+ # cinema.slug
131
+ # #=> 'dukes-at-komedia'
132
+
133
+ # @!method street_address
134
+ # The street address of the cinema
135
+ # @return [String]
136
+ # @example
137
+ # cinema = PicturehouseUk::Cinema.new('Dukes_At_Komedia')
138
+ # cinema.street_address
139
+ # #=> '44-47 Gardner Street'
140
+
141
+ # The url of the cinema on the Picturehouse website
126
142
  # @return [String]
127
- # @example
128
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
129
- # cinema.region
130
- # #=> 'East Sussex'
131
- # @note Uses the standard method naming as at http://microformats.org/wiki/adr
132
- def region
133
- address[:region]
134
- end
135
-
136
- # All planned screenings
137
- # @return [Array<PicturehouseUk::Screening>]
138
- # @example
139
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
140
- # cinema.screenings
141
- # # => [<PicturehouseUk::Screening ...>, <PicturehouseUk::Screening ...>]
142
- def screenings
143
- PicturehouseUk::Screening.at(@id)
144
- end
145
-
146
- # The street adress of the cinema
147
- # @return a String
148
- # @example
149
- # cinema = PicturehouseUk::Cinema.find('Dukes_At_Komedia')
150
- # cinema.street_address
151
- # #=> '44-47 Gardner Street'
152
- # @note Uses the standard method naming as at http://microformats.org/wiki/adr
153
- def street_address
154
- address[:street_address]
143
+ def url
144
+ "http://www.picturehouses.com#/cinema/#{id}"
155
145
  end
156
146
 
157
147
  private
158
148
 
159
149
  def self.cinema_links
160
- home_doc.css(CINEMA_LINKS_CSS)
161
- end
162
-
163
- def self.home_doc
164
- @home_doc ||= Nokogiri::HTML(website.home)
150
+ cinemas_doc.css(CINEMA_LINKS_CSS)
165
151
  end
152
+ private_class_method :cinema_links
166
153
 
167
- def self.website
168
- @website ||= PicturehouseUk::Internal::Website.new
169
- end
170
-
171
- def self.new_from_link(link)
172
- url = link.get_attribute('data-href')
173
- name = link.children.first.to_s.split(' — ')[1]
174
-
175
- new(id: url.match(%r{/cinema/(.+)$})[1],
176
- name: name,
177
- url: url)
154
+ def self.cinemas_doc
155
+ @cinemas_doc ||=
156
+ Nokogiri::HTML(PicturehouseUk::Internal::Website.new.cinemas)
178
157
  end
158
+ private_class_method :cinemas_doc
179
159
 
180
160
  def address_node
181
161
  @address_node ||= info_doc.css(ADDRESS_CSS)
182
162
  end
183
163
 
184
164
  def info_doc
185
- @info_doc ||= Nokogiri::HTML(self.class.website.info(id))
165
+ @info_doc ||=
166
+ Nokogiri::HTML(PicturehouseUk::Internal::Website.new.information(id))
167
+ end
168
+
169
+ # @api private
170
+ # Utility class to parse the links from the cinemas page
171
+ class ListParser
172
+ def initialize(nodes)
173
+ @nodes = nodes
174
+ end
175
+
176
+ def to_hash
177
+ @nodes.each_with_object({}) do |node, result|
178
+ cinema_id = id(node)
179
+ cinema_name = name(node)
180
+ # Skip links that don't have valid cinema data
181
+ next unless cinema_id && cinema_name
182
+ # Skip duplicate entries
183
+ next if result.key?(cinema_id)
184
+
185
+ result[cinema_id] = { name: cinema_name, url: url(node) }
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def id(node)
192
+ href = url(node)
193
+ return nil unless href
194
+ match = href.match(%r{/cinema/([^/?#]+)})
195
+ match ? match[1] : nil
196
+ end
197
+
198
+ def name(node)
199
+ # Cinema name is in a <p> tag within the link
200
+ p_tag = node.css('p').first
201
+ return nil unless p_tag
202
+ # Get text and remove the location span
203
+ p_tag.children.find { |child| child.text? }&.text&.strip
204
+ end
205
+
206
+ def url(node)
207
+ href = node.get_attribute('href')
208
+ # Convert relative URLs to absolute if needed
209
+ href&.start_with?('http') ? href : "https://www.picturehouses.com#{href}"
210
+ end
186
211
  end
187
212
  end
188
213
  end
@@ -0,0 +1,42 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'openssl'
4
+
5
+ module PicturehouseUk
6
+ # @api private
7
+ module Internal
8
+ # Fetches JSON data from the Picturehouse API
9
+ class Api
10
+ API_BASE = 'https://www.picturehouses.com'.freeze
11
+ API_ENDPOINT = '/api/get-movies-ajax'.freeze
12
+
13
+ # Fetch movie data for a cinema
14
+ # @param cinema_id [String] the cinema ID
15
+ # @param date [String] the date in YYYY-MM-DD format, or 'show_all_dates'
16
+ # @return [Hash] parsed JSON response
17
+ def get_movies(cinema_id, date = 'show_all_dates')
18
+ uri = URI("#{API_BASE}#{API_ENDPOINT}")
19
+
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = true
22
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
23
+
24
+ request = Net::HTTP::Post.new(uri.path)
25
+ request.set_form_data(
26
+ 'start_date' => date,
27
+ 'cinema_id' => cinema_id,
28
+ 'filters' => []
29
+ )
30
+
31
+ response = http.request(request)
32
+
33
+ return {} unless response.is_a?(Net::HTTPSuccess)
34
+
35
+ JSON.parse(response.body)
36
+ rescue StandardError => e
37
+ warn "Failed to fetch movies for cinema #{cinema_id}: #{e.message}"
38
+ {}
39
+ end
40
+ end
41
+ end
42
+ end
@@ -15,23 +15,50 @@ module PicturehouseUk
15
15
  # @note Uses the address naming from http://microformats.org/wiki/adr
16
16
  def address
17
17
  {
18
- street_address: array[1],
19
- extended_address: array.length > 5 ? array[2] : nil,
18
+ street_address: address_lines[0],
19
+ extended_address: extended_address,
20
20
  locality: town,
21
- region: array[-2] == town ? nil : array[-2],
22
- postal_code: array[-1],
23
- country: 'United Kingdom'
21
+ region: region,
22
+ postal_code: postal_code,
23
+ country_name: 'United Kingdom'.freeze
24
24
  }
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- def town
30
- @town ||= array[0].to_s.split(', ')[-1]
29
+ def array
30
+ @array ||= begin
31
+ # Split on <br> tags first, then strip all HTML tags from each part
32
+ @html.split(/<br\s*\/?>/).map do |part|
33
+ # Strip all HTML tags and clean up whitespace
34
+ Nokogiri::HTML::DocumentFragment.parse(part).text.strip
35
+ end.reject(&:empty?)
36
+ end
31
37
  end
32
38
 
33
- def array
34
- @array ||= Array(@html.gsub(/\<.?p.?\>/, '').split('<br>'))
39
+ # Skip the first element (cinema name) and return address lines
40
+ def address_lines
41
+ @address_lines ||= array[1..-1] || []
42
+ end
43
+
44
+ def extended_address
45
+ address_lines.length > 4 ? address_lines[1] : nil
46
+ end
47
+
48
+ def postal_code
49
+ address_lines[-1]
50
+ end
51
+
52
+ def region
53
+ # If there are 4+ address lines (street, town, region, postcode),
54
+ # the region is at -2, otherwise nil
55
+ address_lines.length >= 4 ? address_lines[-2] : nil
56
+ end
57
+
58
+ def town
59
+ # Town is always the second-to-last item (before postcode)
60
+ # unless there's a region, then it's third-to-last
61
+ @town ||= address_lines.length >= 4 ? address_lines[-3] : address_lines[-2]
35
62
  end
36
63
  end
37
64
  end
@@ -3,127 +3,121 @@ module PicturehouseUk
3
3
  module Internal
4
4
  # @api private
5
5
  module Parser
6
- # Parses screenings page into an array of hashes for an individual cinema
7
- Screenings = Struct.new(:cinema_id) do
8
- # css for a day of films & screenings
9
- LISTINGS = '.listings li:not(.dark)'
10
- DATE = '.nav-collapse.collapse'
6
+ # Parses JSON API response into an array of screening hashes
7
+ class Screenings
8
+ def initialize(cinema_id)
9
+ @cinema_id = cinema_id
10
+ end
11
11
 
12
- # parse the cinema page into an array of screenings attributes
12
+ # Parse the JSON response into an array of screening attributes
13
13
  # @return [Array<Hash>]
14
14
  def to_a
15
- doc.css(LISTINGS).flat_map do |node|
16
- FilmWithShowtimes.new(node, date_from_html(node.css(DATE).to_s)).to_a
17
- end
15
+ return [] unless json_data['response'] == 'success'
16
+ return [] unless json_data['movies']
17
+
18
+ json_data['movies'].flat_map do |movie|
19
+ parse_movie(movie)
20
+ end.compact
18
21
  end
19
22
 
20
23
  private
21
24
 
22
- def date_from_html(html)
23
- if !!html.match(/listings-further-ahead-today/)
24
- Date.now
25
- else
26
- html.match(/listings-further-ahead-(\d{4})(\d{2})(\d{2})/) do |m|
27
- Date.new(m[1].to_i, m[2].to_i, m[3].to_i)
28
- end
29
- end
30
- end
25
+ def parse_movie(movie)
26
+ return [] if movie['show_times'].nil? || movie['show_times'].empty?
31
27
 
32
- def doc
33
- @doc ||= Nokogiri::HTML(page)
28
+ movie['show_times'].map do |timing|
29
+ parse_timing(movie, timing)
30
+ end.compact
34
31
  end
35
32
 
36
- def page
37
- @page ||= PicturehouseUk::Internal::Website.new.whats_on(cinema_id)
33
+ def parse_timing(movie, timing)
34
+ # Skip sold out or unavailable screenings
35
+ return nil if timing['SoldoutStatus'] == 2
36
+ return nil unless timing['date_f'] && timing['time']
37
+
38
+ # Skip advance booking restrictions if not advertised
39
+ if movie['ABgtToday'] == true && movie['AdvertiseAdvanceBookingDate'] == false
40
+ return nil
41
+ end
42
+
43
+ {
44
+ film_name: sanitize_title(movie['Title']),
45
+ dimension: determine_dimension(movie['Title'], timing),
46
+ variant: extract_variants(timing),
47
+ booking_url: booking_url(timing),
48
+ starting_at: parse_datetime(timing['date_f'], timing['time'])
49
+ }
38
50
  end
39
- end
40
- end
41
51
 
42
- FilmWithShowtimes = Struct.new(:node, :date) do
43
- # film name css
44
- NAME = '.top-mg-sm a'
45
- # variants css
46
- VARIANTS = '.film-times .col-xs-10'
52
+ def sanitize_title(title)
53
+ TitleSanitizer.new(title).sanitized
54
+ end
47
55
 
48
- # The film name
49
- # @return [String]
50
- def name
51
- TitleSanitizer.new(raw_name).sanitized
52
- end
56
+ def determine_dimension(title, timing)
57
+ # Check title for 3D indicator
58
+ return '3d' if title =~ /3d/i
53
59
 
54
- # Showings hashes
55
- # @return [Array<Hash>]
56
- def to_a
57
- Array(node.css(VARIANTS)).flat_map do |variant|
58
- Variant.new(variant, date).to_a.map do |hash|
59
- {
60
- film_name: name,
61
- dimension: dimension
62
- }.merge(hash)
60
+ # Check timing attributes for 3D
61
+ if timing['SessionAttributesNames']
62
+ return '3d' if timing['SessionAttributesNames'].any? { |attr| attr =~ /3d/i }
63
63
  end
64
+
65
+ '2d'
64
66
  end
65
- end
66
67
 
67
- private
68
+ def extract_variants(timing)
69
+ return [] unless timing['SessionAttributesNames']
68
70
 
69
- def dimension
70
- raw_name.match(/3d/i) ? '3d' : '2d'
71
- end
71
+ variants = []
72
72
 
73
- def raw_name
74
- @raw_name ||= node.css(NAME).children.first.to_s
75
- end
76
- end
73
+ timing['SessionAttributesNames'].each do |attribute|
74
+ # Map known attributes to variant types
75
+ variants << 'baby' if attribute =~ /big scream/i
76
+ variants << 'imax' if attribute =~ /imax/i
77
+ variants << 'kids' if attribute =~ /kids|toddler/i
78
+ variants << 'arts' if attribute =~ /nt live|screen arts|rbo|roh|met opera/i
79
+ variants << 'senior' if attribute =~ /silver screen/i
80
+ end
77
81
 
78
- # variants can have multiple screenings
79
- Variant = Struct.new(:node, :date) do
80
- SHOWTIMES = '.btn'
81
- VARIANT = '.film-type-desc'
82
- TRANSLATOR = {
83
- 'Big Scream' => 'baby',
84
- 'IMAX' => 'imax',
85
- "Kids' Club" => 'kids',
86
- 'NT Live' => 'arts',
87
- 'Screen Arts' => 'arts',
88
- 'Silver Screen' => 'senior'
89
- }
90
-
91
- # Variant arrays
92
- # @return [Array<Hash>]
93
- def to_a
94
- node.css(SHOWTIMES).map do |node|
95
- { variant: variant }.merge(Showtime.new(node, date).to_hash)
82
+ variants.uniq.sort
96
83
  end
97
- end
98
-
99
- private
100
84
 
101
- def variant
102
- @variant ||= TRANSLATOR.select do |k, _|
103
- variant_text.include?(k)
104
- end.values.uniq
105
- end
85
+ def booking_url(timing)
86
+ return nil unless timing['SessionId']
106
87
 
107
- def variant_text
108
- @variant_text ||= node.css(VARIANT).to_s
109
- end
110
- end
88
+ # Vista booking URL format
89
+ "https://web.picturehouses.com/order/showtimes/#{@cinema_id}-#{timing['SessionId']}/seats"
90
+ end
111
91
 
112
- # parse an individual screening node
113
- Showtime = Struct.new(:node, :date) do
114
- def to_hash
115
- {
116
- booking_url: node['href'],
117
- time: time
118
- }
119
- end
92
+ def parse_datetime(date_str, time_str)
93
+ # date_str format: "2024-10-30"
94
+ # time_str format: "19:30"
95
+ return nil unless date_str && time_str
96
+
97
+ date_parts = date_str.split('-').map(&:to_i)
98
+ time_parts = time_str.split(':').map(&:to_i)
99
+
100
+ # Create Time object in local timezone, then convert to UTC
101
+ Time.new(
102
+ date_parts[0], # year
103
+ date_parts[1], # month
104
+ date_parts[2], # day
105
+ time_parts[0], # hour
106
+ time_parts[1], # minute
107
+ 0, # second
108
+ '+00:00' # UTC timezone
109
+ )
110
+ rescue StandardError => e
111
+ warn "Failed to parse datetime from #{date_str} #{time_str}: #{e.message}"
112
+ nil
113
+ end
120
114
 
121
- private
115
+ def json_data
116
+ @json_data ||= api_client.get_movies(@cinema_id)
117
+ end
122
118
 
123
- def time
124
- @time ||= begin
125
- hour, min = node.text.split('.').map(&:to_i)
126
- date.to_time + (hour * 60 + min) * 60
119
+ def api_client
120
+ @api_client ||= Api.new
127
121
  end
128
122
  end
129
123
  end