From e8d7f6678ddce5713f859516b2f8617e5f7a21e4 Mon Sep 17 00:00:00 2001 From: Matchu Date: Tue, 11 Oct 2022 11:13:10 -0700 Subject: [PATCH] Auto-modeling script?? It seems to be working!! How exciting!! I'm just letting it run on stuff now :3 One important issue is that Classic DTI doesn't show images for items modeled this way, because we don't download the SWFs for it. But I wanna update it to stop using AWS anyway and do the same stuff 2020 does, I think we can do that pretty sneakily! --- package.json | 1 + scripts/model-needed-items.js | 206 ++++++++++++++++++++++++++++++ src/server/load-pet-data.js | 83 ++++++++++++ src/server/modeling.js | 14 +- src/server/types/Pet.js | 37 +----- src/server/types/PetAppearance.js | 21 +++ 6 files changed, 323 insertions(+), 39 deletions(-) create mode 100644 scripts/model-needed-items.js create mode 100644 src/server/load-pet-data.js diff --git a/package.json b/package.json index 58e6693..aa2b21a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "cache-asset-manifests": "yarn run-script scripts/cache-asset-manifests.js", "delete-user": "yarn run-script scripts/delete-user.js", "export-users-to-auth0": "yarn run-script scripts/export-users-to-auth0.js", + "model-needed-items": "yarn run-script scripts/model-needed-items.js", "validate-owls-data": "yarn run-script scripts/validate-owls-data.js", "archive:create": "yarn archive:create:list-urls && yarn archive:create:download-urls && yarn archive:create:upload", "archive:create:list-urls": "yarn run-script scripts/archive/create/list-urls.js", diff --git a/scripts/model-needed-items.js b/scripts/model-needed-items.js new file mode 100644 index 0000000..585d36b --- /dev/null +++ b/scripts/model-needed-items.js @@ -0,0 +1,206 @@ +const beeline = require("honeycomb-beeline")({ + writeKey: process.env["HONEYCOMB_WRITE_KEY"], + dataset: + process.env["NODE_ENV"] === "production" + ? "Dress to Impress (2020)" + : "Dress to Impress (2020, dev)", + serviceName: "impress-2020-gql-server", +}); +import connectToDb from "../src/server/db"; +import buildLoaders from "../src/server/loaders"; +import { + loadCustomPetData, + loadNCMallPreviewImageHash, +} from "../src/server/load-pet-data"; +import { gql, loadGraphqlQuery } from "../src/server/ssr-graphql"; +import { saveModelingData } from "../src/server/modeling"; + +async function main() { + const db = await connectToDb(); + const loaders = buildLoaders(db); + const context = { db, ...loaders }; + + const { data, errors } = await loadGraphqlQuery({ + query: gql` + query ScriptModelNeededItems_GetNeededItems { + standardItems: itemsThatNeedModels { + id + name + speciesThatNeedModels { + id + name + withColor(colorId: "8") { + neopetsImageHash + } + } + } + + babyItems: itemsThatNeedModels(colorId: "6") { + id + name + speciesThatNeedModels(colorId: "6") { + id + name + withColor(colorId: "6") { + neopetsImageHash + } + } + } + + maraquanItems: itemsThatNeedModels(colorId: "44") { + id + name + speciesThatNeedModels(colorId: "44") { + id + name + withColor(colorId: "44") { + neopetsImageHash + } + } + } + + mutantItems: itemsThatNeedModels(colorId: "46") { + id + name + speciesThatNeedModels(colorId: "46") { + id + name + withColor(colorId: "46") { + neopetsImageHash + } + } + } + } + `, + }); + + if (errors) { + console.error(`Couldn't load items that need modeling:`); + for (const error of errors) { + console.error(error); + } + return 1; + } + + await modelItems(data.standardItems, context); + await modelItems(data.babyItems, context); + await modelItems(data.maraquanItems, context); + await modelItems(data.mutantItems, context); +} + +async function modelItems(items, context) { + for (const item of items) { + for (const species of item.speciesThatNeedModels) { + try { + await modelItem(item, species, context); + } catch (error) { + console.error( + `❌ [${item.name} (${item.id}) on ${species.name} (${species.id}))] ` + + `Modeling failed, skipping:\n`, + error + ); + continue; + } + console.info( + `✅ [${item.name} (${item.id}) on ${species.name} (${species.id}))] ` + + `Modeling data saved!` + ); + } + } +} + +async function modelItem(item, species, context) { + // First, use the NC Mall try-on feature to get the image hash for this + // species wearing this item. + const imageHash = await loadImageHash(item, species); + + // Next, load the detailed customization data, using the special feature + // where "@imageHash" can be looked up as if it were a pet name. + const petName = "@" + imageHash; + const customPetData = await loadCustomPetData(petName); + + // We don't have real pet metadata, but that's okay, that's only relevant for + // tagging pet appearances, and that's not what we're here to do, so the + // modeling function will skip that step. (But we do provide the pet "name" + // to save in our modeling logs!) + const petMetaData = { name: petName, mood: null, gender: null }; + + // Check whether we actually *got* modeling data back. It's possible this + // item just isn't compatible with this species! (In this case, it would be + // wise for someone to manually set the `modeling_status_hint` field on this + // item, so we skip it in the future!) + // + // NOTE: It seems like sometimes customPetData.object_asset_registry is + // an object keyed by asset ID, and sometimes it's an array? Uhhh hm. Well, + // Object.values does what we want in both cases! + const itemAssets = Object.values(customPetData.object_asset_registry); + const hasAssetsForThisItem = itemAssets.some( + (a) => String(a.obj_info_id) === item.id + ); + if (!hasAssetsForThisItem) { + throw new Error(`custom pet data did not have assets for item ${item.id}`); + } + + // Finally, model this data into the database! + await saveModelingData(customPetData, petMetaData, context); +} + +async function loadImageHash(item, species) { + const basicImageHash = species.withColor.neopetsImageHash; + try { + return await loadWithRetries( + () => loadNCMallPreviewImageHash(basicImageHash, [item.id]), + { + numAttempts: 3, + delay: 5000, + contextString: `${item.name} (${item.id}) on ${species.name} (${species.id}))`, + } + ); + } catch (error) { + console.error( + `[${item.name} (${item.id}) on ${species.name} (${species.id}))] ` + + `Loading failed too many times, giving up` + ); + throw error; + } +} + +async function loadWithRetries(fn, { numAttempts, delay, contextString }) { + if (numAttempts <= 0) { + return; + } + + try { + return await fn(); + } catch (error) { + console.error( + `[${contextString}] Error loading, will retry in ${delay}ms:\n`, + error + ); + await new Promise((resolve) => setTimeout(() => resolve(), delay)); + return await loadWithRetries(fn, { + numAttempts: numAttempts - 1, + delay: delay * 2, + }); + } +} + +async function mainWithBeeline() { + const trace = beeline.startTrace({ + name: "scripts/model-needed-items", + operation_name: "scripts/model-needed-items", + }); + + try { + await main(); + } finally { + beeline.finishTrace(trace); + } +} + +mainWithBeeline() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .then((code = 0) => process.exit(code)); diff --git a/src/server/load-pet-data.js b/src/server/load-pet-data.js new file mode 100644 index 0000000..c8db65c --- /dev/null +++ b/src/server/load-pet-data.js @@ -0,0 +1,83 @@ +import util from "util"; +import fetch from "node-fetch"; +import xmlrpc from "xmlrpc"; + +const neopetsXmlrpcClient = xmlrpc.createSecureClient({ + host: "www.neopets.com", + path: "/amfphp/xmlrpc.php", +}); +const neopetsXmlrpcCall = util + .promisify(neopetsXmlrpcClient.methodCall) + .bind(neopetsXmlrpcClient); + +export async function loadPetMetaData(petName) { + const response = await neopetsXmlrpcCall("PetService.getPet", [petName]); + return response; +} + +export async function loadCustomPetData(petName) { + try { + const response = await neopetsXmlrpcCall("CustomPetService.getViewerData", [ + petName, + ]); + return response; + } catch (error) { + // If Neopets.com fails to find valid customization data, we return null. + if ( + error.code === "AMFPHP_RUNTIME_ERROR" && + error.faultString === "Unable to find body artwork for this combination." + ) { + return null; + } else { + throw error; + } + } +} + +export async function loadNCMallPreviewImageHash(basicImageHash, itemIds) { + const query = new URLSearchParams(); + query.append("selPetsci", basicImageHash); + for (const itemId of itemIds) { + query.append("itemsList[]", itemId); + } + + // When we get rate limited, subsequent requests to the *exact* same URL + // fail. For our use case, it makes sense to cache-bust that, I think! + query.append("dti-rand", Math.random()); + + const url = `http://ncmall.neopets.com/mall/ajax/petview/getPetData.php?${query}`; + const res = await fetch(url); + if (!res.ok) { + try { + console.error( + `[loadNCMallPreviewImageHash] ${res.status} ${res.statusText}:\n` + + (await res.text()) + ); + } catch (error) { + console.error( + `[loadNCMallPreviewImageHash] could not load response text for ` + + `NC Mall preview failed request: ${error.message}` + ); + } + throw new Error( + `could not load NC Mall preview image hash: ${res.status} ${res.statusText}` + ); + } + + const dataText = await res.text(); + if (dataText.includes("trying to reload the page too quickly")) { + throw new Error(`hit the NC Mall rate limit`); + } + const data = JSON.parse(dataText); + if (data.success !== true) { + throw new Error( + `NC Mall preview returned non-success data: ${JSON.stringify(data)}` + ); + } + if (!data.newsci) { + throw new Error( + `NC Mall preview returned no newsci field: ${JSON.stringify(data)}` + ); + } + return data.newsci; +} diff --git a/src/server/modeling.js b/src/server/modeling.js index d81eda6..5cfec8f 100644 --- a/src/server/modeling.js +++ b/src/server/modeling.js @@ -16,10 +16,6 @@ * HTML5. */ async function saveModelingData(customPetData, petMetaData, context) { - if (process.env["USE_NEW_MODELING"] !== "1") { - return; - } - const modelingLogs = []; const addToModelingLogs = (entry) => { console.info("[Modeling] " + JSON.stringify(entry, null, 4)); @@ -47,6 +43,13 @@ async function savePetTypeAndStateModelingData( petMetaData, context ) { + // NOTE: When we automatically model items with "@imageHash" pet names, we + // can't load corresponding metadata. That's fine, the script is just looking + // for new item data anyway, we can skip this step altogether in that case! + if (petMetaData.mood == null || petMetaData.gender == null) { + return; + } + const { db, petTypeBySpeciesAndColorLoader, @@ -214,6 +217,9 @@ async function saveItemModelingData(customPetData, context) { async function saveSwfAssetModelingData(customPetData, context) { const { db, swfAssetByRemoteIdLoader, addToModelingLogs } = context; + // NOTE: It seems like sometimes customPetData.object_asset_registry is + // an object keyed by asset ID, and sometimes it's an array? Uhhh hm. Well, + // Object.values does what we want in both cases! const objectAssets = Object.values(customPetData.object_asset_registry); const incomingItemSwfAssets = objectAssets.map((objectAsset) => ({ type: "object", diff --git a/src/server/types/Pet.js b/src/server/types/Pet.js index 3e26cc9..8cd1f18 100644 --- a/src/server/types/Pet.js +++ b/src/server/types/Pet.js @@ -1,8 +1,7 @@ -import util from "util"; import { gql } from "apollo-server"; -import xmlrpc from "xmlrpc"; import { getPoseFromPetState } from "../util"; import { saveModelingData } from "../modeling"; +import { loadCustomPetData, loadPetMetaData } from "../load-pet-data"; const typeDefs = gql` type Pet { @@ -123,7 +122,7 @@ const resolvers = { loadPetMetaData(petName), ]); - if (customPetData != null) { + if (customPetData != null && process.env["USE_NEW_MODELING"] === "1") { await saveModelingData(customPetData, petMetaData, { db, petTypeBySpeciesAndColorLoader, @@ -139,38 +138,6 @@ const resolvers = { }, }; -const neopetsXmlrpcClient = xmlrpc.createSecureClient({ - host: "www.neopets.com", - path: "/amfphp/xmlrpc.php", -}); -const neopetsXmlrpcCall = util - .promisify(neopetsXmlrpcClient.methodCall) - .bind(neopetsXmlrpcClient); - -async function loadPetMetaData(petName) { - const response = await neopetsXmlrpcCall("PetService.getPet", [petName]); - return response; -} - -async function loadCustomPetData(petName) { - try { - const response = await neopetsXmlrpcCall("CustomPetService.getViewerData", [ - petName, - ]); - return response; - } catch (error) { - // If Neopets.com fails to find valid customization data, we return null. - if ( - error.code === "AMFPHP_RUNTIME_ERROR" && - error.faultString === "Unable to find body artwork for this combination." - ) { - return null; - } else { - throw error; - } - } -} - function getPoseFromPetData(petMetaData, petCustomData) { const moodId = petMetaData.mood; const genderId = petMetaData.gender; diff --git a/src/server/types/PetAppearance.js b/src/server/types/PetAppearance.js index 42f7f1e..0e3a553 100644 --- a/src/server/types/PetAppearance.js +++ b/src/server/types/PetAppearance.js @@ -36,6 +36,12 @@ const typeDefs = gql` switching between standard colors. """ standardBodyId: ID! + + """ + A SpeciesColorPair of this species and the given color. Null if we don't + have a record of it as a valid species-color pair on Neopets.com. + """ + withColor(colorId: ID!): SpeciesColorPair } """ @@ -211,6 +217,21 @@ const resolvers = { return petType.bodyId; }, + + withColor: async ( + { id }, + { colorId }, + { petTypeBySpeciesAndColorLoader } + ) => { + const petType = await petTypeBySpeciesAndColorLoader.load({ + speciesId: id, + colorId, + }); + if (petType == null) { + return null; + } + return { id: petType.id }; + }, }, Body: {