diff --git a/app/assets/stylesheets/items/sources.sass b/app/assets/stylesheets/items/sources.sass index 1927b5b4..1f265ef7 100644 --- a/app/assets/stylesheets/items/sources.sass +++ b/app/assets/stylesheets/items/sources.sass @@ -107,10 +107,10 @@ a color: inherit - .owls-info-link + .nc-trade-guide-info-link cursor: help - .owls-info-label + .nc-trade-guide-info-label text-decoration-line: underline text-decoration-style: dotted diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 80ee4109..18fbb19e 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -145,7 +145,7 @@ class ItemsController < ApplicationController # For Dyeworks items whose base is currently in the NC Mall, preload their # trade values. We'll use this to determine which ones are fully buyable rn - # (because Owls tracks this data and we don't). + # (because our NC values guide tracks this data and we don't). Item.preload_nc_trade_values(@items[:dyeworks]) # Start loading the NC trade values for the non-Mall NC items. diff --git a/app/helpers/items_helper.rb b/app/helpers/items_helper.rb index 329ad4bf..30388d81 100644 --- a/app/helpers/items_helper.rb +++ b/app/helpers/items_helper.rb @@ -151,7 +151,7 @@ module ItemsHelper end def nc_trade_value_updated_at_text(nc_trade_value) - return nil if nc_trade_value.updated_at.nil? + return "NC trade value" if nc_trade_value.updated_at.nil? # Render both "[X] [days] ago", and also the exact date, only including the # year if it's not this same year. @@ -160,7 +160,7 @@ module ItemsHelper nc_trade_value.updated_at.strftime("%b %-d") : nc_trade_value.updated_at.strftime("%b %-d, %Y") - "Last updated: #{date_str} (#{time_ago_str} ago)" + "NC trade value—Last updated: #{date_str} (#{time_ago_str} ago)" end NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{ diff --git a/app/models/item.rb b/app/models/item.rb index f6d55344..ba56435d 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -118,12 +118,11 @@ class Item < ApplicationRecord return @nc_trade_value if @nc_trade_value_loaded @nc_trade_value = begin - Rails.logger.debug "Item #{id} (#{name}) " - OwlsValueGuide.find_by_name(name) - rescue OwlsValueGuide::NotFound => error + LebronNCValues.find_by_name(name) + rescue LebronNCValues::NotFound => error Rails.logger.debug("No NC trade value listed for #{name} (#{id})") nil - rescue OwlsValueGuide::NetworkError => error + rescue LebronNCValues::NetworkError => error Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}") nil end diff --git a/app/models/item/dyeworks.rb b/app/models/item/dyeworks.rb index 79a7b9de..e4590c9b 100644 --- a/app/models/item/dyeworks.rb +++ b/app/models/item/dyeworks.rb @@ -5,7 +5,7 @@ class Item end # Whether this is a Dyeworks item whose base item can currently be purchased - # in the NC Mall, then dyed via Dyeworks. (Owls tracks this last part!) + # in the NC Mall, then dyed via Dyeworks. (Lebron tracks this last part!) def dyeworks_buyable? dyeworks_base_buyable? && dyeworks_dyeable? end @@ -18,14 +18,14 @@ class Item end # Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now, - # either at any time or as a limited-time event. (Owls tracks this, not us!) + # either at any time or as a limited-time event. (Lebron tracks this, not us!) def dyeworks_dyeable? dyeworks_permanent? || dyeworks_limited_active? end # Whether this is one of the few Dyeworks items that can be dyed in the NC - # Mall at any time, rather than as part of a limited-time event. (Owls tracks - # this, not us!) + # Mall at any time, rather than as part of a limited-time event. (Lebron + # tracks this, not us!) DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i def dyeworks_permanent? return false if nc_trade_value.nil? @@ -33,11 +33,11 @@ class Item end # Whether this is a Dyeworks item that can be dyed in the NC Mall ~right - # now, as part of a limited-time event. (Owls tracks this, not us!) + # now, as part of a limited-time event. (Lebron tracks this, not us!) # # If we aren't sure of the final date, this will still return `true`, on # the assumption it *is* dyeable right now and we just don't understand the - # details of what Owls told us. + # details of what Lebron told us. def dyeworks_limited_active? return false unless dyeworks_limited? return true if dyeworks_limited_final_date.nil? @@ -51,8 +51,8 @@ class Item # Whether this is a Dyeworks item that can only be dyed as part of a # limited-time event. (This may return true even if the end date has - # passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!) - DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i + # passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!) + DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i def dyeworks_limited? return false if nc_trade_value.nil? nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN) @@ -60,9 +60,9 @@ class Item # If this is a limited-time Dyeworks item, this is the date we think the # event will end on. Even if `dyeworks_limited?` returns true, this could - # still be `nil`, if we fail to parse this. (Owls tracks this, not us!) + # still be `nil`, if we fail to parse this. (Lebron tracks this, not us!) DYEWORKS_LIMITED_FINAL_DATE_PATTERN = - /Dyeable\s*Thru\s*(?[a-z]+)\s*(?[0-9]+)/i + /Dyeworks\s*Thru\s*(?[a-z]+)\s*(?[0-9]+)/i def dyeworks_limited_final_date return nil unless dyeworks_limited? diff --git a/app/services/lebron_nc_values.rb b/app/services/lebron_nc_values.rb new file mode 100644 index 00000000..62abf0b8 --- /dev/null +++ b/app/services/lebron_nc_values.rb @@ -0,0 +1,50 @@ +module LebronNCValues + # NOTE: While we still have `updated_at` infra lying around from the Owls + # days, the current JSON file doesn't tell us that info, so it's always + # `nil` for now. + Value = Struct.new(:value_text, :updated_at) + + class Error < StandardError;end + class NetworkError < Error;end + class NotFound < Error;end + + class << self + def find_by_name(name) + value_text = all_values[name.downcase] + raise NotFound if value_text.nil? || value_text.strip == '-' + + Value.new(value_text, nil) + end + + private + + def all_values + Rails.cache.fetch('LebronNCValues.load_all_values', expires_in: 1.day) do + Sync { load_all_values } + end + end + + ALL_VALUES_URL = "https://lebron-values.netlify.app/item_values.json" + def load_all_values + begin + DTIRequests.get(ALL_VALUES_URL) do |response| + if response.status != 200 + raise NetworkError, + "Lebron returned status code #{response.status} (expected 200)" + end + + begin + JSON.parse(response.read) + rescue JSON::ParserError => error + raise NetworkError, + "Lebron returned unsupported data format: #{error.message}" + end + end + rescue Async::TimeoutError => error + raise NetworkError, "Lebron timed out: #{error.message}" + rescue SocketError => error + raise NetworkError, "Could not connected to Lebron: #{error.message}" + end + end + end +end diff --git a/app/services/owls_value_guide.rb b/app/services/owls_value_guide.rb deleted file mode 100644 index 5c57e122..00000000 --- a/app/services/owls_value_guide.rb +++ /dev/null @@ -1,77 +0,0 @@ -module OwlsValueGuide - include HTTParty - - ITEMDATA_URL_TEMPLATE = Addressable::Template.new( - "https://neo-owls.net/itemdata/{item_name}" - ) - - def self.find_by_name(item_name) - # Load the itemdata, pulling from the Rails cache if possible. - cache_key = "OwlsValueGuide/itemdata/#{item_name}" - data = Rails.cache.fetch(cache_key, expires_in: 1.day) do - load_itemdata(item_name) - end - - if data == :not_found - raise NotFound - end - - # Owls has records of some items that it explicitly marks as having no - # listed value. We don't care about that distinction, just return nil! - return nil if data['owls_value'].blank? - - Value.new(data['owls_value'], parse_last_updated(data['last_updated'])) - end - - Value = Struct.new(:value_text, :updated_at) - - class Error < StandardError;end - class NetworkError < Error;end - class NotFound < Error;end - - private - - def self.load_itemdata(item_name) - Rails.logger.info "[OwlsValueGuide] Loading value for #{item_name.inspect}" - - url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name) - begin - res = get(url, headers: { - "User-Agent" => Rails.configuration.user_agent_for_neopets, - }) - rescue StandardError => error - raise NetworkError, "Couldn't connect to Owls: #{error.message}" - end - - if res.code == 404 - # Instead of raising immediately, return `:not_found` to save this - # result in the cache, then raise *after* we exit the cache block. That - # way, we won't make repeat requests for items we have that Owls - # doesn't. - return :not_found - end - - if res.code != 200 - raise NetworkError, "Owls returned status code #{res.code} (expected 200)" - end - - begin - res.parsed_response - rescue HTTParty::Error => error - raise NetworkError, "Owls returned unsupported data format: #{error.message}" - end - end - - def self.parse_last_updated(date_str) - return nil if date_str.blank? - - begin - Date.strptime(date_str, '%Y-%m-%d') - rescue Date::Error - Rails.logger.error( - "[OwlsValueGuide] unexpected last_updated format: #{date_str.inspect}" - ) - return nil - end - end -end diff --git a/app/views/items/_item_header.html.haml b/app/views/items/_item_header.html.haml index f9bdb1d8..42e3f53e 100644 --- a/app/views/items/_item_header.html.haml +++ b/app/views/items/_item_header.html.haml @@ -33,9 +33,10 @@ = link_to t('items.show.resources.jn_items'), jn_items_url_for(item) = link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item) - if item.nc_trade_value - = link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text), - "https://www.neopets.com/~owls", + %span{ title: nc_trade_value_updated_at_text(item.nc_trade_value) + } + = t('items.show.resources.lebron', value: item.nc_trade_value.value_text) - unless item.nc? = link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item) = link_to t('items.show.resources.trading_post'), trading_post_url_for(item) diff --git a/app/views/items/sources.html.haml b/app/views/items/sources.html.haml index 557aa24f..04906b2c 100644 --- a/app/views/items/sources.html.haml +++ b/app/views/items/sources.html.haml @@ -92,7 +92,7 @@ title: "This recipe is NOT currently scheduled to be removed " + "from Dyeworks. It might not stay forever, but it's also " + "not part of a known limited-time event, like most " + - "Dyeworks items are. (Thanks Owls team!)" + "Dyeworks items are. (Thanks Lebron team!)" } (Always available) - elsif item.dyeworks_limited_final_date.present? @@ -100,14 +100,14 @@ title: "This recipe is part of a limited-time Dyeworks " + "event. The last day you can dye this is " + "#{item.dyeworks_limited_final_date.to_fs(:long)}. " + - "(Thanks Owls team!)" + "(Thanks Lebron team!)" } (Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)}) - elsif item.dyeworks_limited? %span.dyeworks-timeframe{ title: "This recipe is part of a limited-time Dyeworks " + "event, and is scheduled to be removed from the NC Mall " + - "soon. (Thanks Owls team!)" + "soon. (Thanks Lebron team!)" } (Limited-time) @@ -210,11 +210,9 @@ - if @items[:other_nc].any?(&:nc_trade_value) :markdown - The [Owls Value Guide][owls] often has more details about how to get + The "Lebron NC Values" team keep track of the details about how to get these items, and how much they're usually worth in the NC Trading - community. We've loaded their info here for you, too! Thanks, Owls team! - - [owls]: https://www.neopets.com/~owls + community. We've loaded their info here for you, too! Thanks, Lebron team! %table.item-list{"data-complexity": complexity_for(@items[:other_nc])} %thead @@ -227,17 +225,17 @@ - content_for :subtitle, flush: true do - if item.nc_trade_value.present? - if nc_trade_value_is_estimate(item.nc_trade_value) - = link_to "https://www.neopets.com/~owls", - class: "owls-info-link", target: "_blank", - title: 'Owls keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do - %span.owls-info-label [Owls] + = content_tag :span, + class: "nc-trade-guide-info-link", + title: 'Lebron keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do + %span.nc-trade-guide-info-label [Lebron] Estimated value: = nc_trade_value_estimate_text(item.nc_trade_value) - else - = link_to "https://www.neopets.com/~owls", - class: "owls-info-link", target: "_blank", - title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do - %span.owls-info-label [Owls] + = content_tag :span, + class: "nc-trade-guide-info-link", + title: "Lebron keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do + %span.nc-trade-guide-info-label [Lebron] Trade info: #{item.nc_trade_value.value_text} diff --git a/config/locales/en.yml b/config/locales/en.yml index de1e1483..e3104434 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -310,7 +310,7 @@ en: resources: jn_items: JN Items impress_2020: DTI 2020 - owls: "Owls: %{value}" + lebron: "Lebron: %{value}" shop_wizard: Shop Wizard trading_post: Trades auction_genie: Auctions diff --git a/spec/services/lebron_nc_values_spec.rb b/spec/services/lebron_nc_values_spec.rb new file mode 100644 index 00000000..cfdb4b20 --- /dev/null +++ b/spec/services/lebron_nc_values_spec.rb @@ -0,0 +1,71 @@ +require 'webmock/rspec' +require_relative '../rails_helper' + +RSpec.describe LebronNCValues, type: :model do + describe ".find_by_name" do + def stub_page_request + stub_request(:get, "https://lebron-values.netlify.app/item_values.json"). + with( + headers: { + "User-Agent": Rails.configuration.user_agent_for_neopets, + }, + ) + end + + context "when loading" do + before do + stub_page_request.to_return( + body: '{"10th birthday balloon garland": "-", "1 large neocola with ice": "1-2", "air faerie bubble necklace": "Permanent Buyable"}' + ) + end + + context "a standard item value" do + subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") } + it("loads value text as-is") { expect(value[:value_text]).to eq("1-2") } + end + + context "a more complex item value" do + subject(:value) { LebronNCValues.find_by_name("Air Faerie Bubble Necklace") } + it("loads value text as-is") { expect(value[:value_text]).to eq("Permanent Buyable") } + end + + context "a completely unknown item value" do + subject(:value) { LebronNCValues.find_by_name("Floating Negg Faerie Doll") } + it("raises NotFound") { expect { value }.to raise_error(LebronNCValues::NotFound) } + end + + context "a known blank item value" do + subject(:value) { LebronNCValues.find_by_name("10th Birthday Balloon Garland") } + it("raises NotFound") { expect { value }.to raise_error(LebronNCValues::NotFound) } + end + end + + context "when the request fails" do + before { stub_page_request.to_return(status: 404, body: "{}") } + + subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") } + it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) } + end + + context "when the response is malformed" do + before { stub_page_request.to_return(body: "Hello, world!") } + + subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") } + it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) } + end + + context "when the request times out" do + before { stub_page_request.to_timeout } + + subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") } + it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) } + end + + context "when the request fails to connect" do + before { stub_page_request.to_raise(Socket::ResolutionError) } + + subject(:value) { LebronNCValues.find_by_name("1 Large Neocola with Ice") } + it("raises NetworkError") { expect { value }.to raise_error(LebronNCValues::NetworkError) } + end + end +end