fix(import): support HTML-wrapped clone furnidata and skip empty icon sources
Leet serves its furnidata as HTML with the JSON embedded in a <pre> block, and Cloudflare blocks Node's direct fetch. Extract the JSON payload from the HTML (direct or via the FlareSolverr fallback) before giving up on a Cloudflare challenge. Also skip the standalone icon download when a clone source has no iconBaseUrl configured (Habbo, Hubba, Fresh, Kyzegs, RidgeRP), which previously produced invalid relative URLs like /xxx_icon.png and a flood of download errors before falling back to extracting the icon from the .nitro bundle.
This commit is contained in:
1 parent
172941c542
commit
1851653afb
2 files changed
+115
-1
No files matched your search
@@ -12,6 +12,7 @@ const {
|
|||||||
selectLimit,
|
selectLimit,
|
||||||
fsUnlink,
|
fsUnlink,
|
||||||
fsReadFile,
|
fsReadFile,
|
||||||
|
fetchWithFlareSolver,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
|
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
|
||||||
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
|
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
|
||||||
@@ -20,9 +21,11 @@ const {
|
|||||||
selectLimit: vi.fn<AnyFn>(async () => []),
|
selectLimit: vi.fn<AnyFn>(async () => []),
|
||||||
fsUnlink: vi.fn<AnyFn>(async () => {}),
|
fsUnlink: vi.fn<AnyFn>(async () => {}),
|
||||||
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
|
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from("NITRO")),
|
||||||
|
fetchWithFlareSolver: vi.fn<AnyFn>(async () => ""),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/import/core/download", () => ({ downloadFile }));
|
vi.mock("@/lib/services/import/core/download", () => ({ downloadFile }));
|
||||||
|
vi.mock("@/lib/services/flare-solver", () => ({ fetchWithFlareSolver }));
|
||||||
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
|
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
|
||||||
vi.mock("@/lib/services/furni-import", () => ({
|
vi.mock("@/lib/services/furni-import", () => ({
|
||||||
ensureDirectories: vi.fn<AnyFn>(async () => {}),
|
ensureDirectories: vi.fn<AnyFn>(async () => {}),
|
||||||
@@ -72,6 +75,7 @@ vi.mock("@/lib/db", () => ({
|
|||||||
import {
|
import {
|
||||||
__resetItemsBaseAllocatorForTests,
|
__resetItemsBaseAllocatorForTests,
|
||||||
cloneSingleFurni,
|
cloneSingleFurni,
|
||||||
|
fetchSourceFurnidata,
|
||||||
parseFurnidata,
|
parseFurnidata,
|
||||||
} from "./clone-import";
|
} from "./clone-import";
|
||||||
|
|
||||||
@@ -194,3 +198,84 @@ describe("clone-import", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchSourceFurnidata", () => {
|
||||||
|
const FD = {
|
||||||
|
roomitemtypes: { furnitype: [{ classname: "bc_sofa" }] },
|
||||||
|
wallitemtypes: { furnitype: [{ classname: "wall_x" }] },
|
||||||
|
};
|
||||||
|
const JSON_BODY = JSON.stringify(FD);
|
||||||
|
const HTML_WRAPPED = `<html><head><meta charset="utf-8"></head><body><pre>${JSON_BODY}</pre></body></html>`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchWithFlareSolver.mockReset();
|
||||||
|
fetchWithFlareSolver.mockResolvedValue("");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a plain JSON furnidata directly", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON_BODY, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const list = await fetchSourceFurnidata("https://a.test/fd.json", 1);
|
||||||
|
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
|
||||||
|
expect(fetchWithFlareSolver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts JSON from an HTML-wrapped furnidata (e.g. Leet via FlareSolverr)", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response("<html>challenge</html>", {
|
||||||
|
status: 403,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fetchWithFlareSolver.mockResolvedValue(HTML_WRAPPED);
|
||||||
|
const list = await fetchSourceFurnidata("https://b.test/fd.json", 1);
|
||||||
|
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
|
||||||
|
expect(fetchWithFlareSolver).toHaveBeenCalledWith("https://b.test/fd.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to FlareSolverr on non-JSON content-type even when status is 200", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(HTML_WRAPPED, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fetchWithFlareSolver.mockResolvedValue(HTML_WRAPPED);
|
||||||
|
const list = await fetchSourceFurnidata("https://c.test/fd.json", 1);
|
||||||
|
expect(list.map((e) => e.classname)).toEqual(["bc_sofa", "wall_x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when HTML has no embedded JSON payload", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response("<html>challenge</html>", {
|
||||||
|
status: 403,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fetchWithFlareSolver.mockResolvedValue("<html>still a challenge</html>");
|
||||||
|
await expect(
|
||||||
|
fetchSourceFurnidata("https://d.test/fd.json", 1),
|
||||||
|
).rejects.toThrow(/Cloudflare challenge/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,24 @@ export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] {
|
|||||||
const cache = new Map<string, { list: SourceFurni[]; ts: number }>();
|
const cache = new Map<string, { list: SourceFurni[]; ts: number }>();
|
||||||
const TTL = 5 * 60 * 1000;
|
const TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some sources serve their furnidata wrapped in an HTML document (e.g. Leet's
|
||||||
|
* JSON is embedded in a `<pre>` block inside the page, often surfaced by the
|
||||||
|
* FlareSolverr fallback). Extract the JSON payload before giving up.
|
||||||
|
*/
|
||||||
|
function extractJsonFromHtml(body: string): string | null {
|
||||||
|
const pre = body.match(/<pre[^>]*>([\s\S]*?)<\/pre>/);
|
||||||
|
const candidate = pre?.[1] ?? body;
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
|
||||||
|
try {
|
||||||
|
JSON.parse(trimmed);
|
||||||
|
return trimmed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSourceFurnidata(
|
export async function fetchSourceFurnidata(
|
||||||
url: string,
|
url: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
@@ -115,10 +133,14 @@ export async function fetchSourceFurnidata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.trimStart().startsWith("<")) {
|
if (body.trimStart().startsWith("<")) {
|
||||||
|
const extracted = extractJsonFromHtml(body);
|
||||||
|
if (!extracted) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Source ${url} is behind a Cloudflare challenge that could not be bypassed. Start FlareSolverr and check its logs.`,
|
`Source ${url} is behind a Cloudflare challenge that could not be bypassed. Start FlareSolverr and check its logs.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
body = extracted;
|
||||||
|
}
|
||||||
let list: SourceFurni[];
|
let list: SourceFurni[];
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(body);
|
const json = JSON.parse(body);
|
||||||
@@ -253,7 +275,9 @@ export async function cloneSingleFurni(params: {
|
|||||||
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
|
logger.warn("[clone-import] invalid .nitro bundle for", { classname });
|
||||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||||
}
|
}
|
||||||
const iconDl = await downloadFile(
|
let iconDl: { ok: boolean; size: number };
|
||||||
|
if (source.iconBaseUrl) {
|
||||||
|
iconDl = await downloadFile(
|
||||||
`${source.iconBaseUrl}/${classname}_icon.png`,
|
`${source.iconBaseUrl}/${classname}_icon.png`,
|
||||||
iconPath,
|
iconPath,
|
||||||
{
|
{
|
||||||
@@ -261,6 +285,11 @@ export async function cloneSingleFurni(params: {
|
|||||||
validate: "png",
|
validate: "png",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// No standalone icons on this source (e.g. Habbo/Hubba) — fall through to
|
||||||
|
// extracting the catalog icon from the .nitro bundle we just downloaded.
|
||||||
|
iconDl = { ok: false, size: 0 };
|
||||||
|
}
|
||||||
if (!iconDl.ok) {
|
if (!iconDl.ok) {
|
||||||
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
|
||||||
// extract the catalog icon from the bundle we just downloaded.
|
// extract the catalog icon from the bundle we just downloaded.
|
||||||
|
|||||||
Reference in new issue
Block a user