Migrate from Owls NC values to Lebron

Owls has retired, and a new team have taken up the mantle! Exciting!
This commit is contained in:
Emi Matchu 2025-06-27 15:18:29 -07:00
parent 0e57a76ce6
commit 10708de615
11 changed files with 156 additions and 114 deletions

View file

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

View file

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

View file

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

View file

@ -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}) <lookup>"
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

View file

@ -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*(?<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
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,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)

View file

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

View file

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

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