diff --git a/app/services/neopets/nc_mall.rb b/app/services/neopets/nc_mall.rb index 0e284b08..0961fbb9 100644 --- a/app/services/neopets/nc_mall.rb +++ b/app/services/neopets/nc_mall.rb @@ -120,6 +120,39 @@ module Neopets::NCMall end end + # Load "exclusive" styles from tab 3 of the Styling Studio. Unlike tabs 1 + # and 2, these are not species-specific—there's just one request that + # returns all exclusives. + def self.load_exclusives(neologin:) + Sync do + DTIRequests.post( + STYLING_STUDIO_URL, + [ + ["Content-Type", "application/x-www-form-urlencoded"], + ["Cookie", "neologin=#{neologin}"], + ["X-Requested-With", "XMLHttpRequest"], + ], + {tab: 3, mode: "getTab", key: "StylingStudioTab", value: 3}.to_query, + ) do |response| + if response.status != 200 + raise ResponseNotOK.new(response.status), + "expected status 200 but got #{response.status} (#{STYLING_STUDIO_URL})" + end + + begin + data = JSON.parse(response.read).deep_symbolize_keys + + # Like styles, exclusives is a hash keyed by ID (or an empty + # array when there are none). + data.fetch(:exclusives).to_h.values. + map { |s| s.slice(:oii, :name, :image) } + rescue JSON::ParserError, KeyError + raise UnexpectedResponseFormat + end + end + end + end + # Generate a new image hash for a pet wearing specific items. Takes a base # pet sci (species/color image hash) and optional item IDs, and returns a # response containing the combined image hash in the :newsci field. diff --git a/lib/tasks/neopets/import/styling_studio.rake b/lib/tasks/neopets/import/styling_studio.rake index cd81319b..16e9a5fa 100644 --- a/lib/tasks/neopets/import/styling_studio.rake +++ b/lib/tasks/neopets/import/styling_studio.rake @@ -33,12 +33,34 @@ namespace "neopets:import" do end print "\n" - style_ids = styles_by_species_id.values.flatten(1).map { |s| s[:oii] } + # Load exclusive styles from tab 3 (not species-specific). + print "Loading exclusives…" + begin + exclusives = Neopets::NCMall.load_exclusives( + neologin: Neologin.cookie, + ) + rescue => error + puts "\n⚠️ Error loading exclusives, skipping: #{error.message}" + Sentry.capture_exception(error, + tags: { task: "neopets:import:styling_studio" }) + exclusives = nil + end + puts " #{exclusives&.size || 0} loaded" + + all_styles = styles_by_species_id.values.flatten(1) + all_styles += exclusives unless exclusives.nil? + style_ids = all_styles.map { |s| s[:oii] } style_records_by_id = AltStyle.where(id: style_ids).to_h { |as| [as.id, as] } - all_species.each do |species| - styles = styles_by_species_id[species.id] + # Build a list of groups to process: one per species, plus exclusives. + groups = all_species.map { |sp| + {label: sp.human_name, styles: styles_by_species_id[sp.id]} + } + groups << {label: "Exclusives", styles: exclusives} + + groups.each do |group| + styles = group[:styles] next if styles.nil? counts = {changed: 0, unchanged: 0, skipped: 0} @@ -96,7 +118,7 @@ namespace "neopets:import" do record.save! end - puts "#{species.human_name}: #{counts[:changed]} changed, " + + puts "#{group[:label]}: #{counts[:changed]} changed, " + "#{counts[:unchanged]} unchanged, #{counts[:skipped]} skipped" end rescue => e diff --git a/spec/services/nc_mall_spec.rb b/spec/services/nc_mall_spec.rb index f33181dc..583a04cb 100644 --- a/spec/services/nc_mall_spec.rb +++ b/spec/services/nc_mall_spec.rb @@ -213,4 +213,72 @@ RSpec.describe Neopets::NCMall, type: :model do expect { styles }.to raise_error(Neopets::NCMall::UnexpectedResponseFormat) end end + + describe ".load_exclusives" do + def stub_exclusives_request + stub_request(:post, "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php"). + with( + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + "Cookie": "neologin=STUB_NEOLOGIN", + "User-Agent": Rails.configuration.user_agent_for_neopets, + }, + body: "key=StylingStudioTab&mode=getTab&tab=3&value=3", + ) + end + + subject(:exclusives) do + Neopets::NCMall.load_exclusives(neologin: "STUB_NEOLOGIN") + end + + it "loads exclusive styles from tab 3" do + stub_exclusives_request.to_return( + body: '{"success":true,"exclusives":{"95639":{"name":"Treasured Roberta","image":"https:\/\/images.neopets.com\/items\/1c2h6d7fdn.gif","oii":95639},"95640":{"name":"Treasured Lisha","image":"https:\/\/images.neopets.com\/items\/0ocf7m2daj.gif","oii":95640}},"styleMeter":{"num":0,"max":15,"isReady":false},"mode":"getTab"}' + ) + + expect(exclusives).to contain_exactly( + { + oii: 95639, + name: "Treasured Roberta", + image: "https://images.neopets.com/items/1c2h6d7fdn.gif", + }, + { + oii: 95640, + name: "Treasured Lisha", + image: "https://images.neopets.com/items/0ocf7m2daj.gif", + }, + ) + end + + it "handles empty exclusives" do + stub_exclusives_request.to_return( + body: '{"success":true,"exclusives":[],"styleMeter":{"num":0,"max":15,"isReady":false},"mode":"getTab"}' + ) + + expect(exclusives).to be_empty + end + + it "raises an error if the request returns a non-200 status" do + stub_exclusives_request.to_return(status: 400) + + expect { exclusives }.to raise_error(Neopets::NCMall::ResponseNotOK) + end + + it "raises an error if the request returns a non-JSON response" do + stub_exclusives_request.to_return( + body: "Oops, this request failed for some weird reason!", + ) + + expect { exclusives }.to raise_error(Neopets::NCMall::UnexpectedResponseFormat) + end + + it "raises an error if the request returns unexpected JSON" do + stub_exclusives_request.to_return( + body: '{"success": false}', + ) + + expect { exclusives }.to raise_error(Neopets::NCMall::UnexpectedResponseFormat) + end + end end