cineworld_uk 3.1.0 → 4.1.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 (48) hide show
  1. checksums.yaml +5 -5
  2. data/.agents/setup +51 -0
  3. data/.github/workflows/test.yml +43 -0
  4. data/.gitignore +1 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +21 -0
  7. data/README.md +136 -16
  8. data/Rakefile +5 -37
  9. data/cineworld_uk.gemspec +7 -4
  10. data/lib/cineworld_uk/cinema.rb +7 -4
  11. data/lib/cineworld_uk/internal/api_response.rb +99 -56
  12. data/lib/cineworld_uk/internal/guest_token.rb +73 -0
  13. data/lib/cineworld_uk/internal/parser/api/cinema_address.rb +24 -24
  14. data/lib/cineworld_uk/internal/parser/api/film.rb +10 -8
  15. data/lib/cineworld_uk/internal/parser/api/performance.rb +6 -11
  16. data/lib/cineworld_uk/performance.rb +42 -23
  17. data/lib/cineworld_uk/version.rb +2 -2
  18. data/lib/cineworld_uk.rb +12 -2
  19. data/tasks/package.rake +84 -0
  20. data/test/fixtures/api/cinema-list.json +1 -1
  21. data/test/fixtures/api/dates-14.json +1 -0
  22. data/test/fixtures/api/performances-tomorrow-14.json +1 -0
  23. data/test/fixtures/api/vista-showtimes-brighton.json +1 -0
  24. data/test/lib/cineworld_uk/cinema_test.rb +47 -47
  25. data/test/lib/cineworld_uk/internal/api_response_test.rb +177 -58
  26. data/test/lib/cineworld_uk/internal/parser/api/cinema_address_test.rb +14 -14
  27. data/test/lib/cineworld_uk/internal/parser/api/film_test.rb +43 -55
  28. data/test/lib/cineworld_uk/internal/parser/api/performance_test.rb +11 -12
  29. data/test/lib/cineworld_uk/internal/title_sanitizer_test.rb +18 -18
  30. data/test/lib/cineworld_uk/performance_test.rb +31 -32
  31. data/test/lib/cineworld_uk/version_test.rb +1 -1
  32. data/test/live/integration_test.rb +9 -9
  33. data/test/package_test.rb +49 -0
  34. data/test/support/fixture_reader.rb +2 -14
  35. data/test/test_helper.rb +3 -2
  36. metadata +65 -36
  37. data/.travis.yml +0 -10
  38. data/lib/cineworld_uk/internal/parser/api/film_lookup.rb +0 -38
  39. data/lib/cineworld_uk/internal/parser/api/performances_by_day.rb +0 -54
  40. data/test/fixtures/api/cinema-detail-10.json +0 -1
  41. data/test/fixtures/api/cinema-detail-21.json +0 -1
  42. data/test/fixtures/api/cinema-detail-3.json +0 -1
  43. data/test/fixtures/api/cinema-detail-96.json +0 -1
  44. data/test/fixtures/api/dates-3.json +0 -1
  45. data/test/fixtures/api/film-list-comingsoon.json +0 -1
  46. data/test/fixtures/api/film-list.json +0 -1
  47. data/test/fixtures/api/performances-tomorrow-3.json +0 -1
  48. data/test/lib/cineworld_uk/internal/parser/api/film_lookup_test.rb +0 -31
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "nokogiri"
6
+ require "open3"
7
+
8
+ module CineworldUk
9
+ module Internal
10
+ # Retrieves the public guest token without running a browser.
11
+ class GuestToken
12
+ def initialize(curl:)
13
+ @curl = curl || default_curl
14
+ end
15
+
16
+ def fetch
17
+ token = extract_token(request_page)
18
+ validate_expiry!(token)
19
+ token
20
+ rescue JSON::ParserError, ArgumentError, IndexError, TypeError, NoMethodError
21
+ raise AuthenticationError, "Guest token page data is missing or invalid", cause: nil
22
+ end
23
+
24
+ private
25
+
26
+ def default_curl
27
+ bundled = File.expand_path("../../../vendor/curl-impersonate/bin/curl-impersonate", __dir__)
28
+ File.executable?(bundled) ? bundled : "curl-impersonate"
29
+ end
30
+
31
+ def curl_command
32
+ [@curl, "--disable", "--impersonate", "chrome150",
33
+ "--compressed", "--silent", "--show-error",
34
+ "--connect-timeout", "10", "--max-time", "30",
35
+ "--write-out", "\n%{http_code}", "https://web.cineworld.co.uk/"]
36
+ end
37
+
38
+ def request_page
39
+ output, _stderr, result = Open3.capture3(*curl_command)
40
+ unless result.success?
41
+ raise AuthenticationError,
42
+ "Guest token request failed (curl exit #{result.exitstatus})"
43
+ end
44
+
45
+ html, _, status = output.rpartition("\n")
46
+ unless %w[200 404].include?(status)
47
+ raise AuthenticationError, "Guest token page returned HTTP #{status}"
48
+ end
49
+ html
50
+ rescue Errno::ENOENT
51
+ raise AuthenticationError,
52
+ "Install curl-impersonate or pass its executable path with curl:"
53
+ end
54
+
55
+ def extract_token(html)
56
+ script = Nokogiri::HTML(html).at_css("script#__NEXT_DATA__")
57
+ data = JSON.parse(script ? script.content : "")
58
+ token = data.dig("props", "pageProps", "environment", "gasToken")
59
+ unless token.is_a?(String) && !token.empty?
60
+ raise AuthenticationError, "Guest token is missing from the page"
61
+ end
62
+ token
63
+ end
64
+
65
+ def validate_expiry!(token)
66
+ claims = JSON.parse(Base64.urlsafe_decode64(token.split(".").fetch(1)))
67
+ if Integer(claims.fetch("exp")) <= Time.now.to_i
68
+ raise AuthenticationError, "Guest token supplied by the page has expired"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -7,8 +7,9 @@ module CineworldUk
7
7
  class CinemaAddress
8
8
  # @param [Integer] id the cinema id
9
9
  # @return [CineworldUk::Internal::Parser::Api::CinemaAddress]
10
- def initialize(id)
10
+ def initialize(id, cinemas = nil)
11
11
  @id = id
12
+ @cinemas = cinemas
12
13
  end
13
14
 
14
15
  # @return [Hash] contains :street_address, :extended_address,
@@ -27,52 +28,51 @@ module CineworldUk
27
28
 
28
29
  private
29
30
 
30
- def adr_array
31
- @adr_array ||=
32
- cinema_detail_hash['address'].split(',').map(&:strip).compact
31
+ def address_info
32
+ @address_info ||= cinema_hash['addressInfo'] || {}
33
33
  end
34
34
 
35
- def cinema_detail_hash
36
- @cinema_detail_hash ||= JSON.parse(cinema_detail_response)['cinema']
35
+ def cinema_hash
36
+ @cinema_hash ||= begin
37
+ cinemas = @cinemas ||
38
+ JSON.parse(cinema_list_response)["body"]["cinemas"]
39
+ formatted_id = @id.to_s.rjust(3, '0')
40
+ cinemas.find { |c| c['id'] == formatted_id } || {}
41
+ end
37
42
  end
38
43
 
39
- def cinema_detail_response
40
- @cinema_detail_response ||=
41
- CineworldUk::Internal::ApiResponse.new.cinema_detail(@id)
42
- end
43
-
44
- def ext_array
45
- @ext_array ||= adr_array[1..-1]
44
+ def cinema_list_response
45
+ @cinema_list_response ||=
46
+ CineworldUk::Internal::ApiResponse.new.cinema_list
46
47
  end
47
48
 
48
49
  def extended_address
49
- return nil if ext_array.count == 1
50
- london? ? nil : ext_array[0]
51
- end
50
+ addr2 = address_info['address2']
51
+ addr3 = address_info['address3']
52
52
 
53
- def final
54
- @final ||= ext_array.last
53
+ return nil if london?
54
+ addr2 || addr3
55
55
  end
56
56
 
57
57
  def locality
58
- return ext_array[0] if ext_array.count == 1
59
- london? ? ext_array[0] : ext_array[1]
58
+ address_info['city']
60
59
  end
61
60
 
62
61
  def london?
63
- final == 'London'
62
+ address_info['city'] == 'London' ||
63
+ cinema_hash['displayName']&.start_with?('London')
64
64
  end
65
65
 
66
66
  def postal_code
67
- cinema_detail_hash['postcode']
67
+ address_info['postalCode']
68
68
  end
69
69
 
70
70
  def region
71
- 'London' if london?
71
+ address_info['state'] || ('London' if london?)
72
72
  end
73
73
 
74
74
  def street_address
75
- adr_array[0]
75
+ address_info['address1']
76
76
  end
77
77
  end
78
78
  end
@@ -12,31 +12,33 @@ module CineworldUk
12
12
  # @return [ CineworldUk::Internal::Parser::Api::Film]
13
13
  def initialize(data)
14
14
  @data = data
15
- @id = @data['edi']
15
+ @id = @data['id']
16
16
  end
17
17
 
18
18
  # Do you need your 3D glasses?
19
19
  # @return [String] either '2d' or '3d'
20
20
  def dimension
21
- @data['format'] =~ /3D/i ? '3d' : '2d'
21
+ @data['attributeIds']&.include?('3d') ? '3d' : '2d'
22
22
  end
23
23
 
24
24
  # Sanitized film name
25
25
  # @return [String]
26
26
  def name
27
- TitleSanitizer.new(@data['originalTitle'] || '').sanitized
27
+ title = @data['name'] || ''
28
+ TitleSanitizer.new(title).sanitized
28
29
  end
29
30
 
30
31
  # List of strings representing different kinds of performance, such
31
32
  # as autism, kids, imax, members or q&a
32
33
  # @return [Array<String>] or an empty array
33
34
  def variant
35
+ attrs = @data['attributeIds'] || []
34
36
  [
35
- @data['title'] =~ /Autism Friendly/i ? 'autism_friendly' : nil,
36
- @data.fetch('format', '') =~ /IMAX/i ? 'imax' : nil,
37
- @data['title'] =~ /Movies for Juniors/i ? 'kids' : nil,
38
- @data['title'] =~ /Unlimited Screening/i ? 'members' : nil,
39
- @data['title'] =~ /Q (and|&) A/i ? 'q&a' : nil
37
+ attrs.include?('autism-friendly') ? 'autism_friendly' : nil,
38
+ attrs.include?('imax') ? 'imax' : nil,
39
+ attrs.include?('junior') ? 'kids' : nil,
40
+ attrs.include?('unlimited-screening') ? 'members' : nil,
41
+ attrs.include?('qa') ? 'q&a' : nil
40
42
  ].compact
41
43
  end
42
44
  end
@@ -13,32 +13,27 @@ module CineworldUk
13
13
 
14
14
  # @return [String] direct booking url
15
15
  def booking_url
16
- "http://www.cineworld.co.uk#{@data['url']}"
16
+ @data['bookingLink']
17
17
  end
18
18
 
19
19
  # @return [Integer] id for film lookup
20
20
  def film_id
21
- @data['film']
21
+ @data['filmId']
22
22
  end
23
23
 
24
24
  # @return [DateTime] in local time
25
25
  def starting_at
26
- Time.strptime(time_str, '%Y%m%d %H:%S')
26
+ Time.parse(@data['eventDateTime'])
27
27
  end
28
28
 
29
29
  # @return [Array<String>] includes audio described & subtitled
30
30
  def variant
31
+ attrs = @data['attributeIds'] || []
31
32
  [
32
- @data['ad'] ? 'audio_described' : nil,
33
- @data['subtitled'] ? 'subtitled' : nil
33
+ attrs.include?('audio-described') ? 'audio_described' : nil,
34
+ attrs.include?('subbed') ? 'subtitled' : nil
34
35
  ].compact
35
36
  end
36
-
37
- private
38
-
39
- def time_str
40
- "#{@data['date']} #{@data['time']}"
41
- end
42
37
  end
43
38
  end
44
39
  end
@@ -22,14 +22,22 @@ module CineworldUk
22
22
  # @option options [String] :film_name name of the film
23
23
  # @option options [Time] :starting_at listed start time of the performance
24
24
 
25
- # All currently listed films showing at a cinema
25
+ # Performances within an inclusive range of cinema business dates.
26
26
  # @param [Integer] cinema_id id of the cinema on the website
27
- # @return [Array<CineworldUk::Screening>]
28
- def self.at(cinema_id)
27
+ # @param [Date] from first date (defaults to today in London)
28
+ # @param [Date] to last date (defaults to 13 days after from)
29
+ # @return [Array<CineworldUk::Performance>]
30
+ def self.at(
31
+ cinema_id,
32
+ from: TZInfo::Timezone.get("Europe/London").now.to_date,
33
+ to: from + 13
34
+ )
29
35
  cinema_id = cinema_id.to_i
30
- dates(cinema_id).flat_map do |date|
36
+ raise ArgumentError, "to must not precede from" if to < from
37
+
38
+ (from..to).flat_map do |date|
31
39
  performances_on(cinema_id, date).flat_map do |p|
32
- new cinema_hash(cinema_id).merge(p)
40
+ new({ cinema_id: cinema_id }.merge(p))
33
41
  end
34
42
  end
35
43
  end
@@ -55,31 +63,42 @@ module CineworldUk
55
63
  private_class_method :api
56
64
 
57
65
  # @api private
58
- def self.cinema_hash(cinema_id)
59
- {
60
- cinema_id: cinema_id,
61
- cinema_name: CineworldUk::Cinema.new(cinema_id).name
62
- }
63
- end
64
- private_class_method :cinema_hash
66
+ def self.performances_on(cinema_id, date)
67
+ response = JSON.parse(api.performances(cinema_id, date))
68
+ films_hash = build_films_hash(response['body']['films'])
69
+ events = response['body']['events']
65
70
 
66
- # @api private
67
- def self.dates(cinema_id)
68
- @dates ||= JSON.parse(api.dates(cinema_id))['dates'].map do |str|
69
- Date.strptime(str, '%Y%m%d')
71
+ events.map do |event_data|
72
+ performance = Internal::Parser::Api::Performance.new(event_data)
73
+ film = films_hash[performance.film_id]
74
+
75
+ {
76
+ cinema_name: response["body"]["cinemaName"] ||
77
+ CineworldUk::Cinema.new(cinema_id).name,
78
+ booking_url: performance.booking_url,
79
+ dimension: film.dimension,
80
+ film_name: film.name,
81
+ starting_at: utc(performance.starting_at),
82
+ variant: (performance.variant + film.variant).sort
83
+ }
70
84
  end
71
85
  end
72
- private_class_method :dates
86
+ private_class_method :performances_on
73
87
 
74
88
  # @api private
75
- def self.films
76
- @films ||= Internal::Parser::Api::FilmLookup.new.to_hash
89
+ def self.build_films_hash(films_array)
90
+ films_array.each_with_object({}) do |film_data, hash|
91
+ film = Internal::Parser::Api::Film.new(film_data)
92
+ hash[film_data['id']] = film
93
+ end
77
94
  end
78
- private_class_method :films
95
+ private_class_method :build_films_hash
79
96
 
80
- def self.performances_on(cinema_id, date)
81
- Internal::Parser::Api::PerformancesByDay.new(cinema_id, date, films).to_a
97
+ # @api private
98
+ def self.utc(time)
99
+ return time if time.utc?
100
+ TZInfo::Timezone.get('Europe/London').local_to_utc(time)
82
101
  end
83
- private_class_method :performances_on
102
+ private_class_method :utc
84
103
  end
85
104
  end
@@ -1,6 +1,6 @@
1
1
  # Ruby interface for http://www.cineworld.co.uk
2
- # @version 3.0.4
2
+ # @version 4.1.0
3
3
  module CineworldUk
4
4
  # Gem version
5
- VERSION = '3.1.0'.freeze
5
+ VERSION = "4.1.0".freeze
6
6
  end
data/lib/cineworld_uk.rb CHANGED
@@ -5,10 +5,9 @@ require 'cinebase'
5
5
  require_relative './cineworld_uk/version'
6
6
 
7
7
  require_relative './cineworld_uk/internal/api_response'
8
+ require_relative './cineworld_uk/internal/guest_token'
8
9
  require_relative './cineworld_uk/internal/parser/api/cinema_address'
9
10
  require_relative './cineworld_uk/internal/parser/api/film'
10
- require_relative './cineworld_uk/internal/parser/api/film_lookup'
11
- require_relative './cineworld_uk/internal/parser/api/performances_by_day'
12
11
  require_relative './cineworld_uk/internal/parser/api/performance'
13
12
 
14
13
  require_relative './cineworld_uk/internal/title_sanitizer'
@@ -18,4 +17,15 @@ require_relative './cineworld_uk/performance'
18
17
 
19
18
  # cineworld_uk gem
20
19
  module CineworldUk
20
+ class AuthenticationError < StandardError; end
21
+
22
+ # A raw guest JWT, without the "Bearer " prefix. Never persisted by the gem.
23
+ class << self
24
+ attr_accessor :token
25
+
26
+ # Explicit acquisition/renewal; ordinary API calls never launch a subprocess.
27
+ def acquire_token!(curl: nil)
28
+ self.token = Internal::GuestToken.new(curl: curl).fetch
29
+ end
30
+ end
21
31
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "rubygems/package"
6
+ require "tmpdir"
7
+ require "zlib"
8
+
9
+ namespace :package do
10
+ # SHA-256 digests from the upstream v2.2.3 release assets.
11
+ releases = {
12
+ "x86_64-linux-gnu" => ["x86_64-linux-gnu", "ca7c8aae49e33260d3a2400db16c7737aaf04692d606a843502056b42990f989"],
13
+ "aarch64-linux-gnu" => ["aarch64-linux-gnu", "5e71795b971f32be8f3d7785895f4dceb232f04414f5a067db266432a5b08657"],
14
+ "arm64-darwin" => ["arm64-macos", "2569f4139460fcb301484d37938de91b1c220efd15487ee22a9db554262062fe"],
15
+ "x86_64-darwin" => ["x86_64-macos", "4686806d59abea93866a917c3025a049ef3d7a3240de742ad66bd1a4d9890dc7"]
16
+ }
17
+
18
+ desc "Build a gem with bundled curl-impersonate 2.2.3 for the given platform"
19
+ task :native, [:platform] do |_task, args|
20
+ platform = args.fetch(:platform)
21
+ target, digest = releases.fetch(platform)
22
+ root = File.expand_path("..", __dir__)
23
+ asset = "curl-impersonate-v2.2.3.#{target}.tar.gz"
24
+ url = "https://github.com/lexiforest/curl-impersonate/releases/download/v2.2.3/#{asset}"
25
+ archive = File.join(root, "pkg", "downloads", asset)
26
+ FileUtils.mkdir_p(File.dirname(archive))
27
+ unless File.exist?(archive)
28
+ sh "curl", "--fail", "--location", "--retry", "3", "--output", archive, url
29
+ end
30
+ abort "SHA-256 mismatch for #{archive}; delete it and retry" unless Digest::SHA256.file(archive).hexdigest == digest
31
+
32
+ spec = Gem::Specification.load(File.join(root, "cineworld_uk.gemspec")).dup
33
+ Dir.mktmpdir("cineworld-package") do |stage|
34
+ spec.files.each do |file|
35
+ destination = File.join(stage, file)
36
+ FileUtils.mkdir_p(File.dirname(destination))
37
+ FileUtils.cp(File.join(root, file), destination)
38
+ end
39
+
40
+ vendor = File.join(stage, "vendor", "curl-impersonate")
41
+ Zlib::GzipReader.open(archive) do |gzip|
42
+ Gem::Package::TarReader.new(gzip) do |tar|
43
+ tar.each do |entry|
44
+ next unless entry.file?
45
+
46
+ name = entry.full_name
47
+ relative = if name == "curl-impersonate"
48
+ "bin/curl-impersonate"
49
+ elsif name.start_with?("LICENSE") && File.basename(name) == name
50
+ "licenses/#{name}"
51
+ end
52
+ next unless relative
53
+
54
+ destination = File.join(vendor, relative)
55
+ FileUtils.mkdir_p(File.dirname(destination))
56
+ File.binwrite(destination, entry.read)
57
+ end
58
+ end
59
+ end
60
+ File.chmod(0o755, File.join(vendor, "bin", "curl-impersonate"))
61
+ File.write(File.join(vendor, "SOURCE"), <<~SOURCE)
62
+ curl-impersonate 2.2.3
63
+ Binary: #{url}
64
+ SHA-256: #{digest}
65
+ Source and build instructions: https://github.com/lexiforest/curl-impersonate/tree/v2.2.3
66
+ Redistribution notices: licenses/
67
+ SOURCE
68
+
69
+ Dir.chdir(stage) do
70
+ spec.platform = Gem::Platform.new(platform)
71
+ spec.files += Dir.glob("vendor/**/*").select { |file| File.file?(file) }
72
+ Gem::Package.build(spec, false, false, File.join(root, "pkg", spec.file_name))
73
+ end
74
+ end
75
+ end
76
+
77
+ desc "Build all supported platform gems with bundled curl-impersonate"
78
+ task :all do
79
+ releases.each_key do |platform|
80
+ Rake::Task["package:native"].reenable
81
+ Rake::Task["package:native"].invoke(platform)
82
+ end
83
+ end
84
+ end