dominosjp 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1a8eff1dcad6edc6236af5a90239ecd2afd0cdc3
4
+ data.tar.gz: 3ca1d4888bb591523b1dbe3921e71ff6c3ec811f
5
+ SHA512:
6
+ metadata.gz: 8c63d14703893d31846c4d2691b8c73ea49b5cda9ca69fa937cb40beff10e99a42820d562c8d0f7c50d15a47ff2e76f7796b73a9741f557e3c861dd913596793
7
+ data.tar.gz: 53dae35002494984757b89e65dbfda652ed6dfb26e0f9132741ee6948560076b33d5e7e8c84c297b80ec557e8c391678274b3ba307b09604984ea41e95e9149b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Mahdi Bchetnia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # DominosJP πŸ•πŸ›΅πŸ‡―πŸ‡΅
2
+ πŸ•Domino's Pizza Japan CLI πŸ•
3
+
4
+ ![](https://i.imgur.com/CRaTrSE.jpg)
5
+
6
+ ### Requirements
7
+
8
+ - A [Domino's Pizza Japan](https://www.dominos.jp/eng/) account that you've ordered with at least once in the past
9
+
10
+ ### Install
11
+
12
+ ```bash
13
+ $ gem install dominosjp
14
+ ```
15
+
16
+ ### Usage
17
+
18
+ ```bash
19
+ $ dominosjp
20
+ ```
21
+ and follow the instructions
22
+
23
+ ### Features
24
+
25
+ - Order a pizza in less than a minute
26
+ - Save your preferences for even faster ordering
27
+ - Finds the best coupon for your order from your coupon box, by calculating the real value
28
+
29
+ ### Preferences
30
+
31
+ Not really pizza-related, but by providing default values in a `.dominosjp.yml` file in your home directory you can skip most steps. See [.dominosjp.yml](blob/master/.dominosjp.yml) for examples.
32
+
33
+ Note: All keys are optional. It even allows for partial credit card info.
34
+
35
+ ### Limitations/TODO
36
+
37
+ (Prefixed by a difficulty estimation from 1 to 5)
38
+
39
+ - (1) Allow for paying via cash (credit card-only now)
40
+ - (5) Allow for selecting pizza toppings
41
+ - (4) Allow for selecting pizza size/cut type/number of slices
42
+ - (2) Allow for selecting sides (not only pizzas), (5) maybe even the special menu
43
+ - (5) Extra: Pizza Tracking via the CLI, and hopefully automate the Mystery Deal
44
+
45
+ ### Contact
46
+
47
+ [@inket](https://github.com/inket) / [@inket](https://twitter.com/inket) on Twitter / [mahdi.jp](https://mahdi.jp)
data/bin/dominosjp ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/dominosjp"
5
+
6
+ begin
7
+ dominos = DominosJP.new
8
+ dominos.login
9
+ dominos.order
10
+ rescue Interrupt
11
+ puts "Stopped by user"
12
+ end
data/lib/dominosjp.rb ADDED
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ require "colorize"
3
+ require "highline"
4
+ require "inquirer"
5
+ require "nokogiri"
6
+
7
+ require_relative "request"
8
+ require_relative "preferences"
9
+ require_relative "order_address"
10
+ require_relative "order_coupon"
11
+ require_relative "order_information"
12
+ require_relative "order_payment"
13
+ require_relative "order_review"
14
+ require_relative "pizza"
15
+ require_relative "pizza_selector"
16
+
17
+ class DominosJP
18
+ attr_accessor :order_address, :order_information
19
+ attr_accessor :order_review
20
+ attr_accessor :order_coupon, :order_payment
21
+
22
+ def initialize(login_details = {})
23
+ @email = login_details[:email] || Preferences.instance.email
24
+ @password = login_details[:password]
25
+
26
+ self.order_address = OrderAddress.new
27
+ self.order_information = OrderInformation.new
28
+ self.order_review = OrderReview.new
29
+ self.order_coupon = OrderCoupon.new
30
+ self.order_payment = OrderPayment.new
31
+ end
32
+
33
+ def login
34
+ @email ||= Ask.input("Email")
35
+ @password ||= HighLine.new.ask("Password: ") { |q| q.echo = "*" }
36
+
37
+ Request.post(
38
+ "https://order.dominos.jp/eng/login/login/",
39
+ { "emailAccount" => @email, "webPwd" => @password },
40
+ expect: :redirect, failure: "Couldn't log in successfully"
41
+ )
42
+ end
43
+
44
+ def order
45
+ order_address.input
46
+ order_address.validate
47
+
48
+ order_information.input
49
+ order_information.validate
50
+ order_information.display
51
+ order_information.confirm
52
+
53
+ PizzaSelector.select_pizzas
54
+ # TODO: allow selecting sides
55
+
56
+ order_review.display
57
+
58
+ order_coupon.total_price_without_tax = order_review.total_price_without_tax
59
+ order_coupon.input
60
+ order_coupon.validate
61
+
62
+ order_review.display
63
+
64
+ order_payment.default_name = order_information.name
65
+ order_payment.input
66
+ order_payment.validate
67
+ order_payment.display
68
+
69
+ order_review.page = order_payment.page
70
+ order_review.display
71
+
72
+ order_payment.confirm
73
+ end
74
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ class OrderAddress
3
+ attr_accessor :address
4
+
5
+ def input
6
+ response = Request.get("https://order.dominos.jp/eng/receipt/",
7
+ expect: :ok, failure: "Couldn't get order types page")
8
+
9
+ addresses = Addresses.from(response.body)
10
+ index = Ask.list "Choose an address", addresses.selection_list
11
+ self.address = addresses[index]
12
+ end
13
+
14
+ def validate
15
+ raise "Missing attributes" unless address
16
+
17
+ # Get the default parameters and add in the delivery address
18
+ params = default_params.merge("todokeSeq" => address.id)
19
+
20
+ Request.post("https://order.dominos.jp/eng/receipt/setReceipt", params,
21
+ expect: :redirect, to: "https://order.dominos.jp/eng/receipt/input/",
22
+ failure: "Couldn't set the delivery address")
23
+ end
24
+
25
+ private
26
+
27
+ def default_params
28
+ {
29
+ # Receipt method: 1=delivery, 3=pickup
30
+ "receiptMethod" => "1",
31
+ # Rest is untouched
32
+ "tenpoC" => "",
33
+ "jushoC" => "",
34
+ "kokyakuJushoBanchi" => "",
35
+ "banchiCheckBox" => "",
36
+ "buildNm" => "",
37
+ "buildCheckBox" => "",
38
+ "todokeShortNm" => "",
39
+ "kigyoNm" => "",
40
+ "bushoNm" => "",
41
+ "naisen" => "",
42
+ "targetYmd" => nil,
43
+ "targetYmdhm" => nil,
44
+ "gpsPinpointF" => false
45
+ }
46
+ end
47
+ end
48
+
49
+ class Addresses < Array
50
+ def self.from(source)
51
+ doc = Nokogiri::HTML(source)
52
+
53
+ Addresses.new(
54
+ doc.css(".l-section.m-addressSelect .addressSelect_content").map { |el| Address.new(el) }
55
+ )
56
+ end
57
+
58
+ def selection_list
59
+ map(&:list_item)
60
+ end
61
+ end
62
+
63
+ class Address
64
+ attr_accessor :id, :label, :address, :estimation
65
+
66
+ def initialize(element)
67
+ self.label = element.css(".addressSelect_labelName").text.strip
68
+ self.address = element.css(".addressSelect_information_address").text.strip
69
+ self.estimation = element.css(".time_content_text").text.strip
70
+ self.id = element.css("input[name=todokeSeq]").first["value"].strip
71
+ end
72
+
73
+ def list_item
74
+ [label, address, estimation].join("\n ")
75
+ end
76
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ class OrderCoupon
3
+ attr_accessor :total_price_without_tax
4
+ attr_accessor :add_coupon
5
+ attr_accessor :coupon
6
+
7
+ def input
8
+ self.add_coupon = Ask.confirm "Add a coupon?"
9
+ return unless add_coupon
10
+
11
+ response = Request.get("https://order.dominos.jp/eng/coupon/use/",
12
+ expect: :ok, failure: "Couldn't get coupons list")
13
+
14
+ coupons = Coupons.from(response.body, total_price_without_tax)
15
+ selected_coupon_index = Ask.list "Choose a coupon", coupons.selection_list
16
+ self.coupon = coupons[selected_coupon_index]
17
+ end
18
+
19
+ def validate
20
+ return unless add_coupon
21
+
22
+ unless coupon.usable?
23
+ puts "This coupon cannot be used."
24
+ return
25
+ end
26
+
27
+ Request.post("https://order.dominos.jp/eng/webapi/sides/setUserCoupon/", coupon.params,
28
+ expect: :ok, failure: "Couldn't add coupon")
29
+ end
30
+ end
31
+
32
+ class Coupons < Array
33
+ def self.from(source, total_price_without_tax)
34
+ doc = Nokogiri::HTML(source)
35
+ coupons = doc.css("li").map { |item| Coupon.new(item, total_price_without_tax) }
36
+
37
+ # Sort coupons by real value, expiration date while deranking those that cannot be used (error)
38
+ coupons = [coupons.reject(&:error), coupons.select(&:error)].map do |coups|
39
+ coups.group_by(&:real_value).sort.reverse.map do |_real_value, same_value_coupons|
40
+ same_value_coupons.sort_by(&:expiry)
41
+ end
42
+ end.flatten
43
+
44
+ Coupons.new(coupons)
45
+ end
46
+
47
+ def selection_list
48
+ map(&:list_item)
49
+ end
50
+ end
51
+
52
+ class Coupon
53
+ attr_accessor :name, :expiry, :error, :couponcd, :couponseq, :expires_soon, :real_value
54
+
55
+ def initialize(item, total_price_without_tax)
56
+ name_element_text = item.css("h4").text
57
+ coupon_name = name_element_text.sub("\\", "Β₯").sub("Expires soon", "")
58
+ expires_soon = name_element_text.include?("Expires soon") ? "Expires soon" : ""
59
+
60
+ coupon_link = item.css(".jso-userCuponUse").first || {}
61
+
62
+ yen_value = coupon_name.scan(/Β₯(\d+)/).flatten.first.to_i
63
+ percent_value = coupon_name.scan(/(\d+)%/).flatten.first.to_i
64
+
65
+ if yen_value != 0
66
+ real_value = yen_value * 1.08 # 8% tax
67
+ elsif percent_value != 0
68
+ real_value = (total_price_without_tax / (100 / percent_value)) * 1.08 # 8% tax
69
+ end
70
+
71
+ error = item.css(".m-input__error").text
72
+ error = error && error.strip != "" ? "\n #{error}" : nil
73
+
74
+ self.name = coupon_name
75
+ self.expiry = item.css(".m-entryPeriod").text.scan(/\d{4}-\d{2}-\d{2}/).first
76
+ self.error = error
77
+ self.couponcd = coupon_link["couponcd"]
78
+ self.couponseq = coupon_link["couponseq"]
79
+ self.expires_soon = expires_soon
80
+ self.real_value = real_value.to_i
81
+ end
82
+
83
+ def usable?
84
+ couponcd && couponseq
85
+ end
86
+
87
+ def params
88
+ { couponcd: couponcd, couponseq: couponseq }.compact
89
+ end
90
+
91
+ def list_item
92
+ "#{name.colorize(:blue)} (-Β₯#{real_value.to_s.colorize(:green)}) "\
93
+ "#{expires_soon.colorize(:yellow)} #{expiry} #{error.to_s.colorize(:red)}".strip
94
+ end
95
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ class OrderInformation
3
+ attr_accessor :name, :phone_number
4
+
5
+ def input
6
+ response = Request.get("https://order.dominos.jp/eng/receipt/input/",
7
+ expect: :ok, failure: "Couldn't get information input page")
8
+
9
+ saved_name = Name.from(response.body)
10
+ phone_numbers = PhoneNumbers.from(response.body)
11
+
12
+ self.name = Preferences.instance.name || Ask.input("Name", default: saved_name)
13
+
14
+ self.phone_number = phone_numbers.find_number(Preferences.instance.phone_number)
15
+ unless phone_number
16
+ phone_number_index = Ask.list "Phone Number", phone_numbers.selection_list
17
+ self.phone_number = phone_numbers[phone_number_index]
18
+ end
19
+
20
+ @first_response = response
21
+ end
22
+
23
+ def validate
24
+ raise "Missing attributes" unless name && phone_number
25
+
26
+ # Get the default parameters and add in the client name and phone number
27
+ params = default_params.merge(
28
+ "kokyakuNm" => name,
29
+ "telSeq" => phone_number.value
30
+ )
31
+
32
+ @second_response = Request.post("https://order.dominos.jp/eng/receipt/confirm", params,
33
+ expect: :ok, failure: "Couldn't set your information") do |resp|
34
+ resp.body.include?("Order Type, Day&Time and Your Store")
35
+ end
36
+ end
37
+
38
+ def display
39
+ doc = Nokogiri::HTML(@second_response.body)
40
+ info = doc.css(".m-input__heading").map(&:text).
41
+ zip(doc.css(".section_content_table_td").map(&:text)).to_h
42
+ @page_params = doc.css("input[type=hidden]").map do |input|
43
+ [input["name"], input["value"]]
44
+ end.to_h
45
+
46
+ info.each { |title, value| puts "#{title.colorize(:blue)}: #{value}" }
47
+ end
48
+
49
+ def confirm
50
+ raise "Stopped by user" unless Ask.confirm "Continue?"
51
+
52
+ Request.post("https://order.dominos.jp/eng/receipt/complete", @page_params,
53
+ expect: :redirect, to: "https://order.dominos.jp/eng/menu/",
54
+ failure: "Couldn't validate your information")
55
+ end
56
+
57
+ private
58
+
59
+ def default_params
60
+ kokyaku_input = Nokogiri::HTML(@first_response.body).css("input[name=kokyakuC]").first
61
+ raise "Couldn't find client information" unless kokyaku_input
62
+
63
+ {
64
+ "kokyakuC" => kokyaku_input["value"],
65
+ # Rest is untouched
66
+ "errorMessage" => "",
67
+ "receiptMethod" => "1", # Receipt method again...
68
+ "deleteTelSeq" => "",
69
+ "telNoRadio" => "0",
70
+ "telNo" => ""
71
+ }
72
+ end
73
+ end
74
+
75
+ class Name
76
+ def self.from(source)
77
+ doc = Nokogiri::HTML(source)
78
+ input = doc.css("input[name=kokyakuNm]").first
79
+ raise "Couldn't get name field from information input page" unless input
80
+
81
+ input["value"]
82
+ end
83
+ end
84
+
85
+ class PhoneNumbers < Array
86
+ def self.from(source)
87
+ doc = Nokogiri::HTML(source)
88
+ numbers = doc.css("select[name=telSeq] > option").map { |option| PhoneNumber.new(option) }
89
+
90
+ if numbers.empty?
91
+ raise "Couldn't find any saved phone numbers in the information input page"
92
+ end
93
+
94
+ PhoneNumbers.new(numbers)
95
+ end
96
+
97
+ def find_number(number)
98
+ detect { |phone_number| phone_number.number == number }
99
+ end
100
+
101
+ def selection_list
102
+ map(&:list_item)
103
+ end
104
+ end
105
+
106
+ class PhoneNumber
107
+ attr_accessor :number, :value
108
+
109
+ def initialize(option)
110
+ self.number = option.text
111
+ self.value = option["value"]
112
+ end
113
+
114
+ def list_item
115
+ number
116
+ end
117
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+ require "credit_card_validations"
3
+ require "credit_card_validations/string"
4
+
5
+ class OrderPayment
6
+ attr_accessor :default_name
7
+ attr_accessor :note
8
+ attr_accessor :last_review
9
+ attr_accessor :page
10
+
11
+ def input
12
+ Request.get("https://order.dominos.jp/eng/regi/",
13
+ expect: :ok, failure: "Couldn't get payment page")
14
+
15
+ puts
16
+ puts
17
+ puts "#{"Payment information".colorize(:blue)} (you will be able to review your order later)"
18
+
19
+ @credit_card = Preferences.instance.credit_card || CreditCard.new
20
+ @credit_card.input(default_name)
21
+
22
+ self.note = Preferences.instance.note ||
23
+ Ask.input("Any special requests? (not food preparation requests)")
24
+ end
25
+
26
+ def validate
27
+ params = default_params.merge("bikoText" => note).merge(@credit_card.params)
28
+ response = Request.post("https://order.dominos.jp/eng/regi/confirm", params,
29
+ expect: :ok, failure: "Couldn't submit payment information")
30
+ doc = Nokogiri::HTML(response.body)
31
+
32
+ token_input = doc.css("input[name='org.apache.struts.taglib.html.TOKEN']").first
33
+ raise "Couldn't get token for order validation" unless token_input && token_input["value"]
34
+
35
+ @insert_params = doc.css("input").map { |input| [input["name"], input["value"]] }.to_h
36
+
37
+ self.last_review = OrderLastReview.new(doc)
38
+ self.page = response.body
39
+ end
40
+
41
+ def display
42
+ puts last_review
43
+ end
44
+
45
+ def confirm
46
+ puts
47
+
48
+ unless Ask.confirm "Place order?"
49
+ puts "Stopped by user"
50
+ return
51
+ end
52
+
53
+ Request.post("https://order.dominos.jp/eng/regi/insert", @insert_params,
54
+ expect: :redirect, to: %r{\Ahttps://order\.dominos\.jp/eng/regi/complete/\?},
55
+ failure: "Order couldn't be placed for some reason :(")
56
+
57
+ puts
58
+ puts "Success!"
59
+ puts "Be sure to check the Domino's Pizza website in your browser "\
60
+ "to track your order status via the Pizza Tracker, and win a Mystery Deal coupon"
61
+ end
62
+
63
+ private
64
+
65
+ def default_params
66
+ {
67
+ "inquiryRiyoDStr" => "undefined",
68
+ "inquiryCardComCd" => "undefined",
69
+ "inquiryCardBrand" => "undefined",
70
+ "inquiryCreditCardNoXXX" => "undefined",
71
+ "inquiryGoodThruMonth" => "undefined",
72
+ "inquiryGoodThruYear" => "undefined",
73
+ "creditCard" => "undefined",
74
+ "receiptK" => "0",
75
+ "exteriorPayment" => "4",
76
+ "reuseCreditDiv" => "1",
77
+ "rakutenPayment" => "1",
78
+ "isDisplayMailmagaArea" => "true",
79
+ "isProvisionalKokyakuModalView" => "false",
80
+ "isProvisionalKokyaku" => "false"
81
+ }
82
+ end
83
+ end
84
+
85
+ class OrderLastReview
86
+ attr_accessor :doc
87
+
88
+ def initialize(doc)
89
+ self.doc = doc
90
+ end
91
+
92
+ def to_s
93
+ sections = doc.css(".l-section").map do |section|
94
+ next unless section.css(".m-heading__caption").count.positive?
95
+
96
+ section_name = section.css(".m-heading__caption").text.strip.gsub(/\s+/, " ").colorize(:green)
97
+ rows = section.css("tr").map do |row|
98
+ th = row.css(".m-input__heading").first || row.css("th").first
99
+ th_text = th.text.strip.gsub(/\s+/, " ").colorize(:blue)
100
+ td_text = row.css(".section_content_table_td").text.
101
+ gsub(/ +/, " ").gsub(/\t+/, "").gsub(/(?:\r\n)+/, "\r\n").strip
102
+
103
+ "#{th_text}\n#{td_text}"
104
+ end
105
+
106
+ "\n#{section_name}\n#{rows.join("\n")}"
107
+ end
108
+
109
+ sections.join("\n")
110
+ end
111
+ end
112
+
113
+ class CreditCard
114
+ attr_accessor :number, :cvv
115
+ attr_accessor :expiration_date
116
+ attr_accessor :name_on_card
117
+
118
+ VALUES = {
119
+ visa: "00200",
120
+ mastercard: "00300",
121
+ jcb: "00500",
122
+ amex: "00400",
123
+ diners: "00100",
124
+ nicos: "00600"
125
+ }.freeze
126
+
127
+ def initialize(config = {})
128
+ info = config.map { |key, value| [(key.to_sym rescue key), value.to_s] }.to_h
129
+
130
+ self.number = info[:number] || ""
131
+ self.cvv = info[:cvv]
132
+ self.expiration_date = info[:expiration_date]
133
+ self.name_on_card = info[:name]
134
+ end
135
+
136
+ def input(default_name = nil)
137
+ loop do
138
+ until number.valid_credit_card_brand?(:visa, :mastercard, :jcb, :amex, :diners)
139
+ puts "Invalid card number" unless number == ""
140
+ self.number = Ask.input "Credit Card Number"
141
+ end
142
+
143
+ unless number.credit_card_brand == :diners
144
+ self.cvv ||= HighLine.new.ask("CVV: ") { |q| q.echo = "*" }
145
+ end
146
+
147
+ expiration_month, expiration_year = (expiration_date || "").split("/")
148
+ while !(1..12).cover?(expiration_month.to_i) || !(17..31).cover?(expiration_year.to_i)
149
+ self.expiration_date = Ask.input "Expiration Date (mm/yy)"
150
+ expiration_month, expiration_year = expiration_date.split("/")
151
+ end
152
+
153
+ self.name_on_card ||= Ask.input "Name on Card", default: default_name
154
+
155
+ break if valid?
156
+ end
157
+ end
158
+
159
+ def params
160
+ {
161
+ "existCreditCardF" => "",
162
+ "reuseCredit_check" => "1", # Seems to be 1 but it doesn't save the CC info
163
+ "cardComCd" => VALUES[number.credit_card_brand],
164
+ "creditCardNo" => number,
165
+ "creditCardSecurityCode" => cvv,
166
+ "creditCardSignature" => name_on_card,
167
+ "goodThruMonth" => expiration_date.split("/").first.rjust(2, "0"),
168
+ "goodThruYear" => expiration_date.split("/").last
169
+ }
170
+ end
171
+
172
+ def valid?
173
+ response = Request.post(
174
+ "https://order.dominos.jp/eng/webapi/regi/validate/creditCard/", params,
175
+ expect: :ok, failure: "Couldn't validate credit card info"
176
+ )
177
+
178
+ result = JSON.parse(response.body)
179
+ if result["errorDetails"]
180
+ puts result
181
+ return false
182
+ end
183
+
184
+ true
185
+ end
186
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ class OrderReview
3
+ attr_accessor :page
4
+ attr_accessor :total_price, :total_price_without_tax
5
+
6
+ def display
7
+ puts
8
+ puts
9
+ puts "Review Your Order".colorize(:red)
10
+
11
+ source = page || default_page
12
+
13
+ # Order items
14
+ puts OrderItems.from(source)
15
+ puts CouponItems.from(source)
16
+
17
+ # General information
18
+ puts retrieve_prices(source)
19
+ end
20
+
21
+ private
22
+
23
+ def retrieve_prices(source)
24
+ doc = Nokogiri::HTML(source)
25
+ total_price_element = doc.css(".totalPrice_taxin")
26
+ total_price_title = total_price_element.css("dt").text.strip.gsub(/\s+/, " ")
27
+ total_price_string = total_price_element.css("dd").text.strip.gsub(/\s+/, " ")
28
+
29
+ self.total_price = total_price_string.delete(",").scan(/Β₯(\d+)/).flatten.first.to_i
30
+ self.total_price_without_tax = total_price / 108 * 100 # 8% tax
31
+
32
+ "\n#{total_price_title}: #{total_price_string.colorize(:red)}\n"\
33
+ "#{doc.css(".totalPrice_tax").text}"
34
+ end
35
+
36
+ def default_page
37
+ Request.get("https://order.dominos.jp/eng/pizza/search/",
38
+ expect: :ok, failure: "Couldn't get pizza list page").body
39
+ end
40
+ end
41
+
42
+ class OrderItems < Array
43
+ def self.from(source)
44
+ doc = Nokogiri::HTML(source)
45
+ order_items = doc.css(".m-side_orderItems li").map { |item| OrderItem.new(item) }
46
+ OrderItems.new(order_items)
47
+ end
48
+
49
+ def to_s
50
+ map(&:to_s).join("\n")
51
+ end
52
+ end
53
+
54
+ class OrderItem
55
+ attr_accessor :name, :details
56
+
57
+ def initialize(item)
58
+ self.name = item.css(".orderItems_item_name").first.text.strip.gsub(/\s+/, " ")
59
+ # TODO: Get toppings list
60
+
61
+ details_element = item.css(".orderItems_item_detail")
62
+ details_dt = details_element.css("dt").map { |t| t.text.strip.gsub(/\s+/, " ") }
63
+ details_dd = details_element.css("dd").map { |t| t.text.strip.gsub(/\s+/, " ") }
64
+
65
+ self.details = details_dt.zip(details_dd).to_h
66
+ details["Price"] = item.css(".orderItems_item_price").text.strip.gsub(/\s+/, " ")
67
+ end
68
+
69
+ def to_s
70
+ deets = details.map { |key, value| " #{key}: #{value}" }.join("\n")
71
+ "#{name.colorize(:blue)}\n#{deets}"
72
+ end
73
+ end
74
+
75
+ class CouponItems < Array
76
+ def self.from(source)
77
+ doc = Nokogiri::HTML(source)
78
+ coupon_items = doc.css(".m-side_useCoupon li").map { |item| CouponItem.new(item) }
79
+
80
+ CouponItems.new(coupon_items)
81
+ end
82
+
83
+ def to_s
84
+ return unless count.positive?
85
+ "\nCoupons".colorize(:green)
86
+ end
87
+ end
88
+
89
+ class CouponItem
90
+ attr_accessor :name, :value
91
+
92
+ def initialize(item)
93
+ self.name = item.css(".useCoupon_coupons_name").text.strip.gsub(/\s+/, " ").sub("\\", "Β₯")
94
+ self.value = item.css("dd span").text.strip
95
+ end
96
+
97
+ def to_s
98
+ " #{name} #{value.colorize(:green)}"
99
+ end
100
+ end
data/lib/pizza.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ class Pizzas < Array
3
+ def self.from(source)
4
+ doc = Nokogiri::HTML(source)
5
+
6
+ Pizzas.new(
7
+ doc.css(".jso-dataLayerProductClick").map { |el| Pizza.new(el) }.select(&:valid?)
8
+ )
9
+ end
10
+
11
+ def selection_list
12
+ map(&:list_item)
13
+ end
14
+ end
15
+
16
+ class Pizza
17
+ attr_accessor :id, :category_id, :url, :name, :description, :allergen_warning
18
+ attr_accessor :size, :crust
19
+
20
+ def initialize(element)
21
+ return unless element["iname"]
22
+
23
+ link = element["href"]
24
+ parts = link.split("/")
25
+ pizza_id = parts.pop
26
+ category_id = parts.pop
27
+ # some_other_number = parts.pop # TODO: figure out what this is
28
+
29
+ description = element.css(".menu_itemList_item_text").first
30
+ description = description.text if description
31
+
32
+ allergen_warning = element.css(".js-menuSetHeight_allergen").first
33
+ allergen_warning = allergen_warning.text if allergen_warning
34
+
35
+ self.url = "https://order.dominos.jp#{link}"
36
+ self.id = pizza_id # shohinC1
37
+ self.category_id = category_id # categoryC
38
+ self.name = element["iname"]
39
+ self.description = description
40
+ self.allergen_warning = allergen_warning
41
+ end
42
+
43
+ def valid?
44
+ url != nil
45
+ end
46
+
47
+ def available_sizes
48
+ @available_sizes ||=
49
+ detail_page_content.css("#detail_selectSize .m-input__radio").map do |option|
50
+ Pizza::Size.new(option)
51
+ end
52
+ end
53
+
54
+ def available_crusts
55
+ @available_crusts ||=
56
+ detail_page_content.css("#detail_selectCrust .m-input__radio").map do |option|
57
+ Pizza::Crust.new(option)
58
+ end
59
+ end
60
+
61
+ def params
62
+ {
63
+ "shohinC1" => id,
64
+ "categoryC" => category_id,
65
+ "sizeC" => size.value,
66
+ "crustC" => crust.value
67
+ }
68
+ end
69
+
70
+ def list_item
71
+ allergen = allergen_warning || ""
72
+
73
+ "#{name.colorize(:blue)} "\
74
+ "#{allergen.strip.colorize(:yellow)}\n "\
75
+ "#{description.gsub(",", ", ").gsub(")", ") ")}\n"
76
+ end
77
+
78
+ private
79
+
80
+ def detail_page_content
81
+ @detail_page_content ||= Nokogiri::HTML(
82
+ Request.get(url, expect: :ok, failure: "Couldn't open pizza detail page").body
83
+ )
84
+ end
85
+ end
86
+
87
+ class Pizza
88
+ class Size
89
+ attr_accessor :text, :value
90
+
91
+ def initialize(option)
92
+ self.text = option.text.strip
93
+ self.value = option.css("input[name=sizeC]").first["value"]
94
+ end
95
+
96
+ def list_item
97
+ text.gsub(/\s+/, " ").sub("δΊΊ /", "δΊΊ/").sub("cm ", "cm\n ").sub(" Β₯", "\n Β₯").strip
98
+ end
99
+ end
100
+
101
+ class Crust
102
+ attr_accessor :text, :value
103
+
104
+ def initialize(option)
105
+ self.text = option.css(".caption_radio").first.text.strip
106
+ self.value = option.css("input[name=crustC]").first["value"]
107
+ end
108
+
109
+ def list_item
110
+ text.gsub(/\s+/, " ").strip
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ class PizzaSelector
3
+ def self.select_pizzas
4
+ response = Request.get("https://order.dominos.jp/eng/pizza/search/",
5
+ expect: :ok, failure: "Couldn't get pizza list page")
6
+
7
+ pizzas = Pizzas.from(response.body)
8
+
9
+ cli = HighLine.new
10
+ choices = pizzas.selection_list
11
+
12
+ loop do
13
+ puts "-" * 42
14
+ cli.choose do |menu|
15
+ menu.prompt = "Add a pizza via number:"
16
+ menu.choices(*(choices + ["Cancel"])) do |choice|
17
+ index = choices.index(choice)
18
+
19
+ if index && index < choices.count
20
+ selected_pizza = pizzas[index]
21
+ add_pizza(customize_pizza(selected_pizza))
22
+ end
23
+ end
24
+ menu.default = "Cancel"
25
+ end
26
+
27
+ break unless Ask.confirm "Add another pizza?"
28
+ end
29
+ end
30
+
31
+ def self.customize_pizza(pizza)
32
+ puts "#{"β†’".colorize(:green)} #{pizza.name.colorize(:blue)}"
33
+
34
+ # TODO: Allow toppings selection
35
+
36
+ # Choosing the size
37
+ selected_size_index = Ask.list "Choose the size", pizza.available_sizes.map(&:list_item)
38
+ pizza.size = pizza.available_sizes[selected_size_index]
39
+
40
+ # Choosing the crust
41
+ selected_crust_index = Ask.list "Choose the crust", pizza.available_crusts.map(&:list_item)
42
+ pizza.crust = pizza.available_crusts[selected_crust_index]
43
+
44
+ pizza
45
+ end
46
+
47
+ def self.add_pizza(pizza)
48
+ params = pizza.params
49
+ params = params.merge(
50
+ "pageId" => "PIZZA_DETAIL",
51
+ # TODO: Allow cut type, number of slices and quantity selection
52
+ "cutTypeC" => 1, # Type of cut: 1=Round Cut
53
+ "cutSu" => 8, # Number of slices
54
+ "figure" => 1 # Quantity
55
+ )
56
+
57
+ response = Request.post(
58
+ "https://order.dominos.jp/eng/cart/add/pizza/", params,
59
+ expect: :redirect, to: %r{\Ahttps?://order\.dominos\.jp/eng/cart/added/\z},
60
+ failure: "Couldn't add the pizza you selected"
61
+ )
62
+
63
+ # For some reason we need to GET this URL otherwise it doesn't count as added <_<
64
+ Request.get(response["Location"],
65
+ expect: :redirect, to: "https://order.dominos.jp/eng/cart/",
66
+ failure: "Couldn't add the pizza you selected")
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ require "yaml"
3
+
4
+ class Preferences
5
+ include Singleton
6
+
7
+ attr_accessor :email, :name, :phone_number, :credit_card, :note
8
+
9
+ def initialize
10
+ preferences_path = File.join(Dir.home, ".dominosjp.yml")
11
+ return unless File.exist?(preferences_path)
12
+
13
+ prefs = YAML.safe_load(File.read(preferences_path)).map { |k, v| [(k.to_sym rescue k), v] }.to_h
14
+
15
+ self.email = prefs[:email] if prefs[:email]
16
+ self.name = prefs[:name] if prefs[:name]
17
+ self.phone_number = prefs[:phone_number] if prefs[:phone_number]
18
+ self.credit_card = CreditCard.new(prefs[:credit_card]) if prefs[:credit_card]
19
+ self.note = prefs[:note] if prefs[:note]
20
+ end
21
+ end
data/lib/request.rb ADDED
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+ require "http-cookie"
4
+ require "singleton"
5
+
6
+ class Request
7
+ include Singleton
8
+
9
+ def self.get(url, options = {})
10
+ request = Net::HTTP::Get.new(URI(url))
11
+
12
+ Request.instance.perform(
13
+ request,
14
+ block_given? ? options.merge(proc: proc { |res| yield(res) }) : options
15
+ )
16
+ end
17
+
18
+ def self.post(url, form_data, options = {})
19
+ request = Net::HTTP::Post.new(URI(url))
20
+ request.set_form_data(form_data)
21
+
22
+ Request.instance.perform(
23
+ request,
24
+ block_given? ? options.merge(proc: proc { |res| yield(res) }) : options
25
+ )
26
+ end
27
+
28
+ def initialize
29
+ @base_uri = URI("https://order.dominos.jp/eng/")
30
+ @http = Net::HTTP.start(@base_uri.host, @base_uri.port, use_ssl: true)
31
+ @jar = HTTP::CookieJar.new
32
+ end
33
+
34
+ def perform(request, options)
35
+ request["Cookie"] = cookies_value
36
+ response = @http.request(request)
37
+
38
+ save_cookies(response)
39
+ parse_options(options, response)
40
+
41
+ response
42
+ end
43
+
44
+ private
45
+
46
+ def cookies_value
47
+ HTTP::Cookie.cookie_value(@jar.cookies(@base_uri))
48
+ end
49
+
50
+ def save_cookies(response)
51
+ response.get_fields("Set-Cookie").each do |value|
52
+ @jar.parse(value, @base_uri)
53
+ end
54
+ end
55
+
56
+ def parse_options(options, response)
57
+ validate_status(options, response) if options[:expect]
58
+ validate_block(options, response) if options[:proc]
59
+ end
60
+
61
+ def validate_status(options, response)
62
+ expectation, redirect, failure = options.values_at(:expect, :to, :failure)
63
+ expectation = { ok: 200, redirect: 302 }[expectation] if expectation.is_a?(Symbol)
64
+
65
+ correct_status = (response.code.to_i == expectation)
66
+ correct_location = redirect.nil? ||
67
+ (redirect == response["Location"]) ||
68
+ (redirect.is_a?(Regexp) && redirect.match(response["Location"]))
69
+
70
+ unless correct_status && correct_location
71
+ failure_message =
72
+ failure ||
73
+ "Expected a different server response. "\
74
+ "(expected: #{options} / actual: #{response.code}[#{response["Location"]}])"
75
+
76
+ puts failure_message.colorize(:red)
77
+ raise failure_message
78
+ end
79
+ end
80
+
81
+ def validate_block(options, response)
82
+ expectation, failure = options.values_at(:proc, :failure)
83
+
84
+ unless expectation.call(response)
85
+ failure_message = failure || "Expected a different server response. "
86
+
87
+ puts failure_message.colorize(:red)
88
+ raise failure_message
89
+ end
90
+ end
91
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ class DominosJP
3
+ VERSION = "0.1.0"
4
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dominosjp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mahdi Bchetnia
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-02-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: colorize
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: credit_card_validations
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: highline
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: http-cookie
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: inquirer
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: nokogiri
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.7'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.7'
97
+ description: "A ruby gem for ordering Domino's Pizza in Japan via CLI \U0001F355"
98
+ email:
99
+ - injekter@gmail.com
100
+ executables:
101
+ - dominosjp
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - LICENSE
106
+ - README.md
107
+ - bin/dominosjp
108
+ - lib/dominosjp.rb
109
+ - lib/order_address.rb
110
+ - lib/order_coupon.rb
111
+ - lib/order_information.rb
112
+ - lib/order_payment.rb
113
+ - lib/order_review.rb
114
+ - lib/pizza.rb
115
+ - lib/pizza_selector.rb
116
+ - lib/preferences.rb
117
+ - lib/request.rb
118
+ - lib/version.rb
119
+ homepage: https://github.com/inket/dominosjp
120
+ licenses:
121
+ - MIT
122
+ metadata: {}
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: 1.3.6
137
+ requirements: []
138
+ rubyforge_project: dominosjp
139
+ rubygems_version: 2.5.2
140
+ signing_key:
141
+ specification_version: 4
142
+ summary: "Order Domino's Pizza Japan via CLI \U0001F355"
143
+ test_files: []