Compare commits

...

2 commits

Author SHA1 Message Date
a4dd680445 Use improved NC trade value text in item page 2025-06-27 15:30:36 -07:00
10708de615 Migrate from Owls NC values to Lebron
Owls has retired, and a new team have taken up the mantle! Exciting!
2025-06-27 15:26:38 -07:00
11 changed files with 159 additions and 116 deletions

View file

@ -107,10 +107,10 @@
a a
color: inherit color: inherit
.owls-info-link .nc-trade-guide-info-link
cursor: help cursor: help
.owls-info-label .nc-trade-guide-info-label
text-decoration-line: underline text-decoration-line: underline
text-decoration-style: dotted text-decoration-style: dotted

View file

@ -145,7 +145,7 @@ class ItemsController < ApplicationController
# For Dyeworks items whose base is currently in the NC Mall, preload their # 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 # 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]) Item.preload_nc_trade_values(@items[:dyeworks])
# Start loading the NC trade values for the non-Mall NC items. # Start loading the NC trade values for the non-Mall NC items.

View file

@ -151,7 +151,7 @@ module ItemsHelper
end end
def nc_trade_value_updated_at_text(nc_trade_value) 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 # Render both "[X] [days] ago", and also the exact date, only including the
# year if it's not this same year. # 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") :
nc_trade_value.updated_at.strftime("%b %-d, %Y") 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 end
NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{ NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{
@ -184,7 +184,7 @@ module ItemsHelper
# nicely for our use case. # nicely for our use case.
def nc_trade_value_estimate_text(nc_trade_value) def nc_trade_value_estimate_text(nc_trade_value)
match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN) match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN)
return nc_trade_value if match.nil? return nc_trade_value.value_text if match.nil?
match => {single:, low:, high:} match => {single:, low:, high:}
if single.present? if single.present?
@ -192,7 +192,7 @@ module ItemsHelper
elsif low.present? && high.present? elsif low.present? && high.present?
"#{low}#{high} capsules" "#{low}#{high} capsules"
else else
nc_trade_value nc_trade_value.value_text
end end
end end

View file

@ -118,12 +118,11 @@ class Item < ApplicationRecord
return @nc_trade_value if @nc_trade_value_loaded return @nc_trade_value if @nc_trade_value_loaded
@nc_trade_value = begin @nc_trade_value = begin
Rails.logger.debug "Item #{id} (#{name}) <lookup>" LebronNCValues.find_by_name(name)
OwlsValueGuide.find_by_name(name) rescue LebronNCValues::NotFound => error
rescue OwlsValueGuide::NotFound => error
Rails.logger.debug("No NC trade value listed for #{name} (#{id})") Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
nil nil
rescue OwlsValueGuide::NetworkError => error rescue LebronNCValues::NetworkError => error
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}") Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
nil nil
end end

View file

@ -5,7 +5,7 @@ class Item
end end
# Whether this is a Dyeworks item whose base item can currently be purchased # 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? def dyeworks_buyable?
dyeworks_base_buyable? && dyeworks_dyeable? dyeworks_base_buyable? && dyeworks_dyeable?
end end
@ -18,14 +18,14 @@ class Item
end end
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now, # 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? def dyeworks_dyeable?
dyeworks_permanent? || dyeworks_limited_active? dyeworks_permanent? || dyeworks_limited_active?
end end
# Whether this is one of the few Dyeworks items that can be dyed in the NC # 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 # Mall at any time, rather than as part of a limited-time event. (Lebron
# this, not us!) # tracks this, not us!)
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
def dyeworks_permanent? def dyeworks_permanent?
return false if nc_trade_value.nil? return false if nc_trade_value.nil?
@ -33,11 +33,11 @@ class Item
end end
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right # 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 # 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 # 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? def dyeworks_limited_active?
return false unless dyeworks_limited? return false unless dyeworks_limited?
return true if dyeworks_limited_final_date.nil? 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 # 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 # limited-time event. (This may return true even if the end date has
# passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!) # passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!)
DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i
def dyeworks_limited? def dyeworks_limited?
return false if nc_trade_value.nil? return false if nc_trade_value.nil?
nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN) 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 # 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 # 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 = DYEWORKS_LIMITED_FINAL_DATE_PATTERN =
/Dyeable\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i /Dyeworks\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
def dyeworks_limited_final_date def dyeworks_limited_final_date
return nil unless dyeworks_limited? return nil unless dyeworks_limited?

View file

@ -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

View file

@ -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

View file

@ -33,9 +33,11 @@
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item) = 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) = link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
- if item.nc_trade_value - if item.nc_trade_value
= link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text), %span{
"https://www.neopets.com/~owls",
title: nc_trade_value_updated_at_text(item.nc_trade_value) title: nc_trade_value_updated_at_text(item.nc_trade_value)
}
= t 'items.show.resources.lebron',
value: nc_trade_value_estimate_text(item.nc_trade_value)
- unless item.nc? - unless item.nc?
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item) = 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) = link_to t('items.show.resources.trading_post'), trading_post_url_for(item)

View file

@ -92,7 +92,7 @@
title: "This recipe is NOT currently scheduled to be removed " + title: "This recipe is NOT currently scheduled to be removed " +
"from Dyeworks. It might not stay forever, but it's also " + "from Dyeworks. It might not stay forever, but it's also " +
"not part of a known limited-time event, like most " + "not part of a known limited-time event, like most " +
"Dyeworks items are. (Thanks Owls team!)" "Dyeworks items are. (Thanks Lebron team!)"
} }
(Always available) (Always available)
- elsif item.dyeworks_limited_final_date.present? - elsif item.dyeworks_limited_final_date.present?
@ -100,14 +100,14 @@
title: "This recipe is part of a limited-time Dyeworks " + title: "This recipe is part of a limited-time Dyeworks " +
"event. The last day you can dye this is " + "event. The last day you can dye this is " +
"#{item.dyeworks_limited_final_date.to_fs(:long)}. " + "#{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)}) (Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)})
- elsif item.dyeworks_limited? - elsif item.dyeworks_limited?
%span.dyeworks-timeframe{ %span.dyeworks-timeframe{
title: "This recipe is part of a limited-time Dyeworks " + title: "This recipe is part of a limited-time Dyeworks " +
"event, and is scheduled to be removed from the NC Mall " + "event, and is scheduled to be removed from the NC Mall " +
"soon. (Thanks Owls team!)" "soon. (Thanks Lebron team!)"
} }
(Limited-time) (Limited-time)
@ -210,11 +210,9 @@
- if @items[:other_nc].any?(&:nc_trade_value) - if @items[:other_nc].any?(&:nc_trade_value)
:markdown :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 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! community. We've loaded their info here for you, too! Thanks, Lebron team!
[owls]: https://www.neopets.com/~owls
%table.item-list{"data-complexity": complexity_for(@items[:other_nc])} %table.item-list{"data-complexity": complexity_for(@items[:other_nc])}
%thead %thead
@ -227,17 +225,17 @@
- content_for :subtitle, flush: true do - content_for :subtitle, flush: true do
- if item.nc_trade_value.present? - if item.nc_trade_value.present?
- if nc_trade_value_is_estimate(item.nc_trade_value) - if nc_trade_value_is_estimate(item.nc_trade_value)
= link_to "https://www.neopets.com/~owls", = content_tag :span,
class: "owls-info-link", target: "_blank", class: "nc-trade-guide-info-link",
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 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.owls-info-label [Owls] %span.nc-trade-guide-info-label [Lebron]
Estimated value: Estimated value:
= nc_trade_value_estimate_text(item.nc_trade_value) = nc_trade_value_estimate_text(item.nc_trade_value)
- else - else
= link_to "https://www.neopets.com/~owls", = content_tag :span,
class: "owls-info-link", target: "_blank", class: "nc-trade-guide-info-link",
title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do title: "Lebron 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] %span.nc-trade-guide-info-label [Lebron]
Trade info: Trade info:
#{item.nc_trade_value.value_text} #{item.nc_trade_value.value_text}

View file

@ -310,7 +310,7 @@ en:
resources: resources:
jn_items: JN Items jn_items: JN Items
impress_2020: DTI 2020 impress_2020: DTI 2020
owls: "Owls: %{value}" lebron: "Lebron: %{value}"
shop_wizard: Shop Wizard shop_wizard: Shop Wizard
trading_post: Trades trading_post: Trades
auction_genie: Auctions auction_genie: Auctions

View file

@ -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