Revisions with an IMVU parent store its public info (image, category path, gender) from IMVU's product API, looked up after registering and in a periodic pass. Product views gain parent, image and categories; GET /products takes category and limit. The website shows the image on cards and product pages. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d57674d90c
commit
6668b55117
15 files changed
+2026
-29
No files matched your search
+56
-4
@@ -1,6 +1,6 @@
|
||||
// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
|
||||
// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends
|
||||
// on it for nothing else.
|
||||
// rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
|
||||
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
|
||||
|
||||
export type ImvuUser = {
|
||||
id: number;
|
||||
@@ -25,6 +25,13 @@ export type ImvuRoom = {
|
||||
expired: boolean;
|
||||
};
|
||||
|
||||
/** An IMVU product's public info, as T5 products derived from it show it. */
|
||||
export type ImvuProduct = {
|
||||
image: string | null; // https, on *.imvu.com
|
||||
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
|
||||
gender: string; // "female", "male", or "" (either)
|
||||
};
|
||||
|
||||
export class NoSuchImvuUser extends Error {
|
||||
constructor() {
|
||||
super("no such IMVU account");
|
||||
@@ -65,6 +72,20 @@ export class ImvuClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
|
||||
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
|
||||
*/
|
||||
async product(id: number): Promise<ImvuProduct | null> {
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
try {
|
||||
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
|
||||
} catch (e) {
|
||||
if (e instanceof NoSuchImvuUser) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async wait() {
|
||||
const now = Date.now();
|
||||
const at = Math.max(now, this.next);
|
||||
@@ -72,7 +93,8 @@ export class ImvuClient {
|
||||
if (at > now) await Bun.sleep(at - now);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<unknown> {
|
||||
/** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
|
||||
private async get(path: string, missing: number[] = []): Promise<unknown> {
|
||||
await this.wait();
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -83,7 +105,7 @@ export class ImvuClient {
|
||||
} catch (e) {
|
||||
throw new ImvuUnavailable(String(e));
|
||||
}
|
||||
if (response.status === 404) throw new NoSuchImvuUser();
|
||||
if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
|
||||
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
@@ -115,6 +137,36 @@ export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undef
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
|
||||
export function imvuImage(v: unknown): string | null {
|
||||
if (typeof v !== "string" || v.length > 1000) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(v.startsWith("//") ? "https:" + v : v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
||||
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
|
||||
const d = entry?.[1].data;
|
||||
if (!d) return null;
|
||||
const path = Array.isArray(d.category_path) ? d.category_path : [];
|
||||
const categories = path.slice(0, 10).flatMap((c: unknown) => {
|
||||
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
|
||||
const n = Number(id);
|
||||
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
|
||||
return [{ id: n, name: name.trim().slice(0, 100) }];
|
||||
});
|
||||
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
|
||||
return { image: imvuImage(d.product_image), categories, gender };
|
||||
}
|
||||
|
||||
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
|
||||
export function parse(answer: unknown): ImvuUser {
|
||||
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
|
||||
|
||||
Reference in new issue
Block a user