fix(import): support HTML-wrapped clone furnidata and skip empty icon sources
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 42s

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:
openhands committed 2026-08-05 12:10:02 +02:00
1 parent 172941c542
commit 1851653afb
2 files changed
+125 -11

No files matched your search

+85
View File
@@ -12,6 +12,7 @@ const {
selectLimit,
fsUnlink,
fsReadFile,
fetchWithFlareSolver,
} = vi.hoisted(() => ({
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
@@ -20,9 +21,11 @@ const {
selectLimit: vi.fn<AnyFn>(async () => []),
fsUnlink: vi.fn<AnyFn>(async () => {}),
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/flare-solver", () => ({ fetchWithFlareSolver }));
vi.mock("@/lib/services/furni-data", () => ({ appendFurniEntry }));
vi.mock("@/lib/services/furni-import", () => ({
ensureDirectories: vi.fn<AnyFn>(async () => {}),
@@ -72,6 +75,7 @@ vi.mock("@/lib/db", () => ({
import {
__resetItemsBaseAllocatorForTests,
cloneSingleFurni,
fetchSourceFurnidata,
parseFurnidata,
} 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/);
});
});