2024-05-07 17:38:48 -07:00
|
|
|
require "addressable/template"
|
2024-05-07 16:06:37 -07:00
|
|
|
require "async/http/internet/instance"
|
|
|
|
|
2024-10-18 17:27:15 -07:00
|
|
|
module Neopets::NCMall
|
2024-05-07 16:06:37 -07:00
|
|
|
# Share a pool of persistent connections, rather than reconnecting on
|
|
|
|
# each request. (This library does that automatically!)
|
|
|
|
INTERNET = Async::HTTP::Internet.instance
|
|
|
|
|
2024-05-10 17:39:10 -07:00
|
|
|
# Load the NC Mall home page content area, and return its useful data.
|
2024-05-07 16:06:37 -07:00
|
|
|
HOME_PAGE_URL = "https://ncmall.neopets.com/mall/ajax/home_page.phtml"
|
|
|
|
def self.load_home_page
|
2024-05-07 17:38:48 -07:00
|
|
|
load_page_by_url HOME_PAGE_URL
|
|
|
|
end
|
|
|
|
|
|
|
|
# Load the NC Mall page for a specific type and category ID.
|
|
|
|
CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new(
|
|
|
|
"https://ncmall.neopets.com/mall/ajax/load_page.phtml?lang=en{&type,cat}"
|
|
|
|
)
|
|
|
|
def self.load_page(type, cat)
|
|
|
|
load_page_by_url CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:)
|
|
|
|
end
|
|
|
|
|
2024-05-10 17:39:10 -07:00
|
|
|
# Load the NC Mall root document HTML, and extract the list of links to
|
|
|
|
# other pages ("New", "Popular", etc.)
|
|
|
|
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
|
|
|
|
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
|
|
|
|
def self.load_page_links
|
2024-09-07 12:14:12 -07:00
|
|
|
html = Sync do
|
|
|
|
INTERNET.get(ROOT_DOCUMENT_URL, [
|
2024-05-10 17:39:10 -07:00
|
|
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
2024-09-07 12:14:12 -07:00
|
|
|
]) do |response|
|
|
|
|
if response.status != 200
|
|
|
|
raise ResponseNotOK.new(response.status),
|
|
|
|
"expected status 200 but got #{response.status} (#{url})"
|
|
|
|
end
|
2024-05-10 17:39:10 -07:00
|
|
|
|
2024-09-07 12:14:12 -07:00
|
|
|
response.read
|
2024-05-10 17:39:10 -07:00
|
|
|
end
|
|
|
|
end
|
2024-09-07 12:14:12 -07:00
|
|
|
|
|
|
|
# Extract `load_items_pane` calls from the root document's HTML. (We use
|
|
|
|
# a very simplified regex, rather than actually parsing the full HTML!)
|
|
|
|
html.scan(PAGE_LINK_PATTERN).
|
|
|
|
map { |type, cat, label| {type:, cat:, label:} }.
|
|
|
|
uniq
|
2024-05-10 17:39:10 -07:00
|
|
|
end
|
|
|
|
|
2024-05-07 17:38:48 -07:00
|
|
|
private
|
|
|
|
|
|
|
|
def self.load_page_by_url(url)
|
|
|
|
Sync do
|
2024-09-07 12:14:12 -07:00
|
|
|
INTERNET.get(url, [
|
2024-05-07 16:06:37 -07:00
|
|
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
2024-09-07 12:14:12 -07:00
|
|
|
]) do |response|
|
|
|
|
if response.status != 200
|
|
|
|
raise ResponseNotOK.new(response.status),
|
|
|
|
"expected status 200 but got #{response.status} (#{url})"
|
|
|
|
end
|
2024-05-07 16:06:37 -07:00
|
|
|
|
2024-09-07 12:14:12 -07:00
|
|
|
parse_nc_page response.read
|
2024-05-07 17:38:48 -07:00
|
|
|
end
|
|
|
|
end
|
2024-05-07 16:06:37 -07:00
|
|
|
end
|
|
|
|
|
|
|
|
# Given a string of NC page data, parse the useful data out of it!
|
|
|
|
def self.parse_nc_page(nc_page_str)
|
|
|
|
begin
|
|
|
|
nc_page = JSON.parse(nc_page_str)
|
|
|
|
rescue JSON::ParserError
|
|
|
|
Rails.logger.debug "Unexpected NC page response:\n#{nc_page_str}"
|
|
|
|
raise UnexpectedResponseFormat,
|
|
|
|
"failed to parse NC page response as JSON"
|
|
|
|
end
|
|
|
|
|
|
|
|
unless nc_page.has_key? "object_data"
|
|
|
|
raise UnexpectedResponseFormat, "missing field object_data in NC page"
|
|
|
|
end
|
|
|
|
|
2024-09-27 18:27:12 -07:00
|
|
|
object_data = nc_page["object_data"]
|
|
|
|
|
2024-05-10 17:39:10 -07:00
|
|
|
# NOTE: When there's no object data, it will be an empty array instead of
|
|
|
|
# an empty hash. Weird API thing to work around!
|
2024-09-27 18:27:12 -07:00
|
|
|
object_data = {} if object_data == []
|
|
|
|
|
|
|
|
# Only the items in the `render` list are actually listed as directly for
|
|
|
|
# sale in the shop. `object_data` might contain other items that provide
|
|
|
|
# supporting information about them, but aren't actually for sale.
|
|
|
|
visible_object_data = (nc_page["render"] || []).
|
|
|
|
map { |id| object_data[id.to_s] }.
|
|
|
|
filter(&:present?)
|
2024-05-10 17:39:10 -07:00
|
|
|
|
2024-09-27 18:27:12 -07:00
|
|
|
items = visible_object_data.map do |item_info|
|
2024-05-07 16:06:37 -07:00
|
|
|
{
|
|
|
|
id: item_info["id"],
|
|
|
|
name: item_info["name"],
|
|
|
|
description: item_info["description"],
|
|
|
|
price: item_info["price"],
|
|
|
|
discount: parse_item_discount(item_info),
|
|
|
|
is_available: item_info["isAvailable"] == 1,
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
{items:}
|
|
|
|
end
|
|
|
|
|
|
|
|
# Given item info, return a hash of discount-specific info, if any.
|
|
|
|
def self.parse_item_discount(item_info)
|
|
|
|
discount_price = item_info["discountPrice"]
|
|
|
|
return nil unless discount_price.present? && discount_price > 0
|
|
|
|
|
|
|
|
{
|
|
|
|
price: discount_price,
|
2024-05-07 17:40:14 -07:00
|
|
|
begins_at: item_info["discountBegin"],
|
|
|
|
ends_at: item_info["discountEnd"],
|
2024-05-07 16:06:37 -07:00
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
class ResponseNotOK < StandardError
|
|
|
|
attr_reader :status
|
|
|
|
def initialize(status)
|
|
|
|
super
|
|
|
|
@status = status
|
|
|
|
end
|
|
|
|
end
|
|
|
|
class UnexpectedResponseFormat < StandardError;end
|
|
|
|
end
|