rockauto_api 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
+ SHA256:
3
+ metadata.gz: eff8195831ca08f029eb1128c3fbdc9a8bc5c6441a41d3eba61b2279add15d46
4
+ data.tar.gz: e961fb95855b0e0d7433ae5c9122ae3a755763e4a5ad99ce22bc0966759ebd9b
5
+ SHA512:
6
+ metadata.gz: e5b8f220ea632303e38d8a34a9ef4bddc5481b2d2c618bc73de1bd286af05cfe8e6ad0fddf621c311cb175b38735a1bb7f04ba0edb3a192a015dbc574622a8d4
7
+ data.tar.gz: '078556f85c37110c00410c72602a46757c6e8d0fb344f74f1f3d8c14d3e5f0ad6a6870715d6ced9e3c060fe56d6dae593d41460b921aec8e3e64e8362d36ac2c'
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ class Cache
5
+ def initialize(store: nil)
6
+ @store = store
7
+ @memory = {}
8
+ end
9
+
10
+ def fetch(key, ttl: 3600)
11
+ if @store
12
+ @store.fetch(key, expires_in: ttl) { yield }
13
+ else
14
+ @memory[key] ||= yield
15
+ end
16
+ end
17
+
18
+ def delete(key)
19
+ if @store
20
+ @store.delete(key)
21
+ else
22
+ @memory.delete(key)
23
+ end
24
+ end
25
+
26
+ def clear
27
+ if @store
28
+ @store.clear
29
+ else
30
+ @memory.clear
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ class Client
5
+ CATALOG_API_URL = "https://www.rockauto.com/catalog/catalogapi.php"
6
+ PARTSEARCH_URL = "https://www.rockauto.com/en/partsearch/"
7
+ ORDERSTATUS_URL = "https://www.rockauto.com/orderstatus/"
8
+ BASE_URL = "https://www.rockauto.com"
9
+
10
+ MOBILE_HEADERS = {
11
+ "User-Agent" => "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
12
+ "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
13
+ "Accept-Language" => "en-US,en;q=0.9",
14
+ "Sec-Fetch-Site" => "same-origin",
15
+ "Sec-Fetch-Mode" => "navigate",
16
+ "Sec-Fetch-Dest" => "document"
17
+ }.freeze
18
+
19
+ DESKTOP_HEADERS = {
20
+ "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36",
21
+ "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
22
+ "Accept-Language" => "en-US,en;q=0.9",
23
+ "Sec-Ch-Ua" => '"Chromium";v="139", "Not;A=Brand";v="99"',
24
+ "Sec-Fetch-Site" => "same-origin",
25
+ "Sec-Fetch-Mode" => "navigate",
26
+ "Sec-Fetch-Dest" => "document"
27
+ }.freeze
28
+
29
+ INITIAL_COOKIES = {
30
+ "idlist" => "0",
31
+ "mkt_US" => "true",
32
+ "mkt_CA" => "false",
33
+ "mkt_MX" => "false",
34
+ "year_2005" => "true",
35
+ "ck" => "1"
36
+ }.freeze
37
+
38
+ attr_reader :cache, :authenticated
39
+
40
+ def initialize(mobile: nil, cache: nil)
41
+ mobile = RockautoApi.configuration&.default_mobile if mobile.nil?
42
+ headers = mobile ? MOBILE_HEADERS : DESKTOP_HEADERS
43
+
44
+ @conn = Faraday.new(url: BASE_URL) do |f|
45
+ f.request :url_encoded
46
+ f.use :cookie_jar
47
+ f.adapter Faraday.default_adapter
48
+ f.options.timeout = RockautoApi.configuration&.request_timeout || 30
49
+ headers.each { |k, v| f.headers[k] = v }
50
+ end
51
+
52
+ set_initial_cookies
53
+ @cache = cache || RockautoApi.configuration&.cache || RockautoApi::Cache.new
54
+ @session_initialized = false
55
+ @nck_token = nil
56
+ @jnck_token = nil
57
+ @authenticated = false
58
+ end
59
+
60
+ def set_initial_cookies
61
+ INITIAL_COOKIES.each do |name, value|
62
+ @conn.headers["Cookie"] = "#{@conn.headers['Cookie']}; #{name}=#{value}" unless @conn.headers["Cookie"].to_s.include?("#{name}=")
63
+ end
64
+ end
65
+
66
+ def init_session!
67
+ return if @session_initialized
68
+
69
+ resp = @conn.get("/")
70
+ @nck_token = Parsers::HtmlHelpers.extract_javascript_variable(resp.body, "_nck")
71
+ @jnck_token = @nck_token ? CGI.escape(@nck_token) : nil
72
+ @session_initialized = true
73
+ end
74
+
75
+ def call_catalog_api(function, payload)
76
+ init_session!
77
+
78
+ data = {
79
+ "func" => function,
80
+ "payload" => payload.is_a?(String) ? payload : payload.to_json,
81
+ "api_json_request" => "1",
82
+ "sctchecked" => "1",
83
+ "scbeenloaded" => "false",
84
+ "curCartGroupID" => ""
85
+ }
86
+ data["_jnck"] = @jnck_token if @jnck_token
87
+
88
+ resp = Faraday.new(url: BASE_URL) do |f|
89
+ f.request :url_encoded
90
+ f.use :cookie_jar
91
+ f.adapter Faraday.default_adapter
92
+ f.options.timeout = RockautoApi.configuration&.request_timeout || 30
93
+ f.headers["X-Requested-With"] = "XMLHttpRequest"
94
+ f.headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
95
+ f.headers["User-Agent"] = MOBILE_HEADERS["User-Agent"]
96
+ f.headers["Referer"] = "#{BASE_URL}/"
97
+ @conn.headers["Cookie"].to_s.split(";").each do |cookie|
98
+ name, val = cookie.strip.split("=", 2)
99
+ f.headers["Cookie"] = "#{f.headers['Cookie']}; #{name}=#{val}" if name && val
100
+ end
101
+ end.post("catalog/catalogapi.php", data)
102
+
103
+ JSON.parse(resp.body)
104
+ rescue Faraday::Error => e
105
+ raise NetworkError, "API request failed: #{e.message}"
106
+ end
107
+
108
+ def post_with_csrf(url, form_data)
109
+ page_resp = @conn.get(url)
110
+ nck = Parsers::HtmlHelpers.extract_csrf_token(page_resp.body)
111
+ form_data["_nck"] = nck if nck
112
+ resp = @conn.post(url, form_data)
113
+ resp.body
114
+ end
115
+
116
+ def simulate_navigation_context(make: nil, year: nil)
117
+ return unless make || year
118
+
119
+ if make
120
+ @conn.headers["Referer"] = "#{BASE_URL}/en/catalog/"
121
+ end
122
+
123
+ if year
124
+ @conn.headers["Cookie"] = "#{@conn.headers['Cookie']}; year_#{year}=true"
125
+ end
126
+
127
+ if make
128
+ payload = {
129
+ "jsn" => {
130
+ "make" => make,
131
+ "nodetype" => "make",
132
+ "loaded" => false,
133
+ "expand_after_load" => true,
134
+ "fetching" => true,
135
+ "max_group_index" => 363,
136
+ "mkt_US" => true,
137
+ "mkt_CA" => false,
138
+ "mkt_MX" => false
139
+ }
140
+ }
141
+ call_catalog_api("navnode_fetch", payload)
142
+ end
143
+ end
144
+
145
+ def get(path)
146
+ resp = @conn.get(path)
147
+ resp.body
148
+ rescue Faraday::Error => e
149
+ raise NetworkError, "GET #{path} failed: #{e.message}"
150
+ end
151
+
152
+ def post(path, body = nil)
153
+ resp = @conn.post(path, body)
154
+ resp.body
155
+ rescue Faraday::Error => e
156
+ raise NetworkError, "POST #{path} failed: #{e.message}"
157
+ end
158
+
159
+ include Endpoints::Vehicles
160
+ include Endpoints::PartCategories
161
+ include Endpoints::PartSearch
162
+ include Endpoints::Fitment
163
+ include Endpoints::Tools
164
+ include Endpoints::Orders
165
+ include Endpoints::Account
166
+ end
167
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ class Configuration
5
+ attr_accessor :default_mobile, :cache, :request_timeout, :user_agent, :credentials
6
+
7
+ def initialize
8
+ @default_mobile = true
9
+ @cache = nil
10
+ @request_timeout = 30
11
+ @user_agent = nil
12
+ @credentials = nil
13
+ end
14
+
15
+ def email
16
+ credentials&.fetch(:email, nil)
17
+ end
18
+
19
+ def password
20
+ credentials&.fetch(:password, nil)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module Account
6
+ LOGIN_URL = "https://www.rockauto.com/catalog/catalogapi.php"
7
+ PROFILE_URL = "/en/profile/"
8
+ ORDER_HISTORY_URL = "/en/orderhistory/"
9
+
10
+ def login(email, password)
11
+ payload = {
12
+ "jsn" => {
13
+ "email" => email,
14
+ "password" => password,
15
+ "keep_me_logged_in" => false
16
+ }
17
+ }
18
+
19
+ response = call_catalog_api("login", payload)
20
+ @authenticated = response["success"] == true || response.dig("response", "success") == true
21
+ @authenticated
22
+ rescue NetworkError
23
+ @authenticated = false
24
+ end
25
+
26
+ def logout
27
+ response = call_catalog_api("logout", {})
28
+ @authenticated = false
29
+ true
30
+ rescue StandardError
31
+ @authenticated = false
32
+ true
33
+ end
34
+
35
+ def authenticated?
36
+ @authenticated
37
+ end
38
+
39
+ def get_saved_addresses
40
+ require_authentication!
41
+ html = get(PROFILE_URL)
42
+ doc = Nokogiri::HTML(html)
43
+
44
+ addresses = doc.css("table tr").map { |row|
45
+ cells = row.css("td").map { |c| c.text.strip }
46
+ next nil if cells.size < 5
47
+
48
+ Models::SavedAddress.new(
49
+ name: cells[0],
50
+ full_name: cells[1],
51
+ address_line1: cells[2],
52
+ city: cells[3],
53
+ state: cells[4],
54
+ postal_code: cells[5],
55
+ country: cells[6] || "US",
56
+ phone: cells[7]
57
+ )
58
+ }.compact
59
+
60
+ Models::SavedAddressesResult.new(
61
+ addresses: addresses,
62
+ count: addresses.size,
63
+ has_default: addresses.any? { |a| a.name&.downcase&.include?("default") }
64
+ )
65
+ end
66
+
67
+ def get_saved_vehicles
68
+ require_authentication!
69
+ html = get(PROFILE_URL)
70
+ doc = Nokogiri::HTML(html)
71
+
72
+ vehicles = doc.css("a[href*='carcode']").map { |a|
73
+ href = a["href"]
74
+ text = a.text.strip
75
+ match = href&.match(/carcode=(\d+)/)
76
+
77
+ parts = text.split(" ")
78
+ year = parts[0].to_i
79
+
80
+ Models::SavedVehicle.new(
81
+ year: year,
82
+ make: parts[1] || "",
83
+ model: parts[2] || "",
84
+ engine: parts[3..]&.join(" "),
85
+ carcode: match&.captures&.first,
86
+ display_name: text,
87
+ catalog_url: Parsers::HtmlHelpers.make_absolute_url(href)
88
+ )
89
+ }.compact
90
+
91
+ Models::SavedVehiclesResult.new(
92
+ vehicles: vehicles,
93
+ count: vehicles.size
94
+ )
95
+ end
96
+
97
+ def get_account_activity
98
+ addresses = get_saved_addresses
99
+ vehicles = get_saved_vehicles
100
+
101
+ Models::AccountActivityResult.new(
102
+ saved_addresses: addresses,
103
+ saved_vehicles: vehicles,
104
+ order_history: nil,
105
+ has_discount_codes: false,
106
+ has_store_credit: false,
107
+ has_alerts: false
108
+ )
109
+ end
110
+
111
+ def get_order_history(filter_params = {})
112
+ require_authentication!
113
+ html = get(ORDER_HISTORY_URL)
114
+ doc = Nokogiri::HTML(html)
115
+
116
+ orders = doc.css("table tr").map { |row|
117
+ cells = row.css("td").map { |c| c.text.strip }
118
+ next nil if cells.size < 3
119
+ next nil if cells[0].match?(/\A\s*(?:Order|Date|Status)\s*\z/i)
120
+
121
+ Models::OrderHistoryItem.new(
122
+ order_number: cells[0] || "",
123
+ date: cells[1],
124
+ status: cells[2],
125
+ total: cells[3],
126
+ vehicle: cells[4],
127
+ order_url: nil
128
+ )
129
+ }.compact
130
+
131
+ Models::OrderHistoryResult.new(
132
+ orders: orders,
133
+ count: orders.size,
134
+ filter_applied: filter_params.empty? ? nil : filter_params.to_s,
135
+ search_time: Time.now.iso8601
136
+ )
137
+ end
138
+
139
+ def add_external_order(email_or_phone, order_number)
140
+ require_authentication!
141
+ form_data = {
142
+ "email" => email_or_phone.include?("@") ? email_or_phone : "",
143
+ "phone" => email_or_phone.include?("@") ? "" : email_or_phone,
144
+ "order_number" => order_number
145
+ }
146
+ post_with_csrf("/en/orderhistory/addorder/", form_data)
147
+ true
148
+ rescue StandardError
149
+ false
150
+ end
151
+
152
+ private
153
+
154
+ def require_authentication!
155
+ raise AuthenticationError, "Not authenticated. Call login(email, password) first." unless @authenticated
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module Fitment
6
+ def get_fitment_for_part(listing_data)
7
+ return default_result("", "") unless listing_data.is_a?(Hash) && !listing_data.empty?
8
+
9
+ car_data = listing_data["car"] || listing_data[:car] || {}
10
+ supp_data = listing_data["supplemental"] || listing_data[:supplemental] || {}
11
+
12
+ part_number = supp_data["partnumber"] || supp_data[:partnumber] || ""
13
+ brand = supp_data["catalogname"] || supp_data[:catalogname] || ""
14
+
15
+ cache_key = "rockauto:fitment:#{part_number}:#{brand}"
16
+
17
+ cache.fetch(cache_key, ttl: 604800) do
18
+ payload = {
19
+ "partData" => {
20
+ "groupindex" => listing_data["groupindex"] || listing_data[:groupindex] || "0",
21
+ "listing_data_essential" => {
22
+ "groupindex" => listing_data["groupindex"] || listing_data[:groupindex] || "0",
23
+ "carcode" => car_data["carcode"] || car_data[:carcode] || 0,
24
+ "parttype" => car_data["parttype"] || car_data[:parttype] || "",
25
+ "partkey" => car_data["partkey"] || car_data[:partkey] || ""
26
+ },
27
+ "listing_data_supplemental" => {
28
+ "partnumber" => part_number,
29
+ "catalogname" => brand,
30
+ "belongstolisting" => "2",
31
+ "sortgroup" => 0,
32
+ "sortgrouptext" => "",
33
+ "paramdesc" => "",
34
+ "showhide" => {}
35
+ },
36
+ "OptKey" => listing_data["optkey"] || listing_data[:optkey] || ""
37
+ }
38
+ }
39
+
40
+ response = call_catalog_api("getbuyersguide", payload)
41
+ html = response.dig("buyersguidepieces", "body")
42
+
43
+ Parsers::FitmentParser.parse(html, part_number: part_number, brand: brand)
44
+ end
45
+ rescue NetworkError, ParseError
46
+ default_result(part_number, brand)
47
+ end
48
+
49
+ private
50
+
51
+ def default_result(part_number, brand)
52
+ Models::BuyersGuideResult.new(
53
+ part_number: part_number,
54
+ brand: brand,
55
+ fitments: [],
56
+ count: 0
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module Orders
6
+ def lookup_order_status(email_or_phone, order_number)
7
+ form_data = {
8
+ "email" => email_or_phone.include?("@") ? email_or_phone : "",
9
+ "phone" => email_or_phone.include?("@") ? "" : email_or_phone,
10
+ "order_number" => order_number
11
+ }
12
+
13
+ html = post_with_csrf("/orderstatus/", form_data)
14
+ parsed = Parsers::OrderParser.parse(html)
15
+
16
+ if parsed[:order_number]
17
+ Models::OrderStatusResult.new(
18
+ success: true,
19
+ order: Models::OrderStatus.new(**parsed),
20
+ error: nil
21
+ )
22
+ else
23
+ Models::OrderStatusResult.new(
24
+ success: false,
25
+ order: nil,
26
+ error: Models::OrderStatusError.new(
27
+ error_type: "not_found",
28
+ message: "Order not found",
29
+ order_number: order_number,
30
+ suggestions: ["Check the order number", "Try searching by email instead"]
31
+ )
32
+ )
33
+ end
34
+ end
35
+
36
+ def request_order_list(method, contact)
37
+ case method.to_s
38
+ when "email"
39
+ post_with_csrf("/orderstatus/", { "send_email" => "1", "email" => contact })
40
+ when "sms"
41
+ post_with_csrf("/orderstatus/", { "send_sms" => "1", "phone" => contact })
42
+ else
43
+ false
44
+ end
45
+ true
46
+ rescue StandardError
47
+ false
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module PartCategories
6
+ def get_part_categories(make, year, model, carcode)
7
+ payload = {
8
+ "jsn" => {
9
+ "make" => make,
10
+ "year" => year.to_s,
11
+ "model" => model,
12
+ "carcode" => carcode,
13
+ "nodetype" => "model",
14
+ "loaded" => false,
15
+ "expand_after_load" => true,
16
+ "fetching" => true,
17
+ "max_group_index" => 0,
18
+ "mkt_US" => true,
19
+ "mkt_CA" => false,
20
+ "mkt_MX" => false
21
+ }
22
+ }
23
+
24
+ response = call_catalog_api("navnode_fetch", payload)
25
+ html = response.dig("html_fill_sections", "navchildren[]") || ""
26
+
27
+ categories = parse_categories_from_html(html)
28
+
29
+ Models::VehiclePartCategories.new(
30
+ make: make,
31
+ year: year,
32
+ model: model,
33
+ carcode: carcode,
34
+ categories: categories,
35
+ count: categories.size
36
+ )
37
+ end
38
+
39
+ def get_parts_by_category(make, year, model, carcode, category_group_name)
40
+ categories_result = get_part_categories(make, year, model, carcode)
41
+ category = categories_result.categories.find { |c| c.group_name == category_group_name }
42
+
43
+ return Models::VehiclePartsResult.new(
44
+ make: make, year: year, model: model, carcode: carcode,
45
+ category: category_group_name, parts: [], count: 0
46
+ ) unless category&.href
47
+
48
+ html = get(category.href)
49
+ doc = Nokogiri::HTML(html)
50
+ parts = parse_parts_from_table(doc)
51
+
52
+ Models::VehiclePartsResult.new(
53
+ make: make, year: year, model: model, carcode: carcode,
54
+ category: category_group_name, parts: parts, count: parts.size
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def parse_categories_from_html(html)
61
+ doc = Nokogiri::HTML(html)
62
+ doc.css("a").map { |a|
63
+ text = a.text.strip
64
+ href = a["href"]
65
+ next nil if text.empty? || href.nil?
66
+ Models::PartCategory.new(
67
+ name: text,
68
+ group_name: text,
69
+ href: Parsers::HtmlHelpers.make_absolute_url(href)
70
+ )
71
+ }.compact
72
+ end
73
+
74
+ def parse_parts_from_table(doc)
75
+ doc.css("table tr").map { |row|
76
+ cells = row.css("td")
77
+ next nil if cells.size < 3
78
+ Parsers::PartExtractor.extract_from_row(row)
79
+ }.compact
80
+ end
81
+ end
82
+ end
83
+ end