fix: maker world importing

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-07 17:15:15 +02:00
1 parent 9d9ac6b0ba
commit 476895f72f
3 files changed
+275 -6

No files matched your search

+7 -3
View File
@@ -472,7 +472,7 @@ function makerworldCoverUrlFromNextData(data: unknown): string | null {
return null;
}
function decodeHtmlEntities(value: string): string {
export function decodeHtmlEntities(value: string): string {
return value
.replace(/ /g, " ")
.replace(/&lt;/g, "<")
@@ -485,7 +485,7 @@ function decodeHtmlEntities(value: string): string {
/** MakerWorld's design summary is a small HTML fragment (paragraphs, links, embedded figures).
* Converts it to plain text for Print.notes: turns block-level closing tags into line breaks,
* strips every remaining tag, decodes entities, and collapses the resulting whitespace. */
function htmlToPlainText(html: string): string | null {
export function htmlToPlainText(html: string): string | null {
const withBreaks = html
.replace(/<\s*(br|\/p|\/li|\/div|\/h[1-6])\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "");
@@ -522,10 +522,14 @@ export type ImportedPageMetadata = {
description: string | null;
creator: string | null;
previewImageUrl: string | null;
/** A known-clean filename for the resolved download, when the resolver already has one
* (e.g. from the MakerWorld cloud API's own response) rather than needing to guess one from
* the download URL's path or a Content-Disposition header. */
filename: string | null;
};
export function emptyImportedPageMetadata(): ImportedPageMetadata {
return { title: null, tags: [], description: null, creator: null, previewImageUrl: null };
return { title: null, tags: [], description: null, creator: null, previewImageUrl: null, filename: null };
}
/** Best-effort metadata for a landing page, used to fill in the Print when the caller didn't
+46 -3
View File
@@ -29,6 +29,13 @@ import {
type ImportCookies,
type ImportedPageMetadata,
} from "./importResolvers";
import {
extractMakerworldBearerToken,
MakerworldAuthError,
MakerworldCaptchaError,
parseMakerworldModelUrl,
resolveMakerworldViaCloudApi,
} from "./makerworldCloudApi";
import { createPrint, type PrintMetaInput } from "./printCreation";
import { plateThumbExists, saveThumbFromBytes } from "./printService";
import type { Plate, Print } from "@prisma/client";
@@ -125,6 +132,29 @@ async function fetchWithGuard(url: string, headers: Record<string, string>): Pro
export type OpenImportResult = { response: Response; finalUrl: string; meta: ImportedPageMetadata };
type MakerworldCloudShortcut = { downloadUrl: string; meta: ImportedPageMetadata };
/** Attempts the api.bambulab.com resolution path for a MakerWorld model URL. Returns null
* for anything that should fall back to the existing page-scraping resolver (not a model
* URL, no usable token, or an unexpected upstream shape); rethrows the two failures worth
* telling the user about directly (expired session, CAPTCHA challenge) as HttpErrors instead
* of silently falling through to a resolver that would just fail the same way again. */
async function tryMakerworldCloudApi(url: string, body: ImportRequestBody): Promise<MakerworldCloudShortcut | null> {
const parsed = parseMakerworldModelUrl(url);
if (!parsed) return null;
const bearerToken = extractMakerworldBearerToken(resolveMakerworldCookie(body));
if (!bearerToken) return null;
try {
const resolved = await resolveMakerworldViaCloudApi(parsed.designId, parsed.requestedInstanceId, bearerToken);
return resolved;
} catch (err) {
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
if (err instanceof MakerworldAuthError) throw new HttpError(401, err.message);
throw err;
}
}
/**
* Fetches `url`, following HTML "landing pages" recursively (MakerWorld/Printables/Thingiverse
* page scraping, or generic <a href>/JSON link sniffing) until it lands on the actual model file
@@ -143,13 +173,25 @@ export async function openImportResponse(
if (depth > 3) throw new HttpError(400, "Too many redirects while resolving download link");
const validatedUrl = await validateRemoteUrl(url);
const headers: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
let host = "";
try {
host = (new URL(validatedUrl).hostname || "").toLowerCase();
} catch {
host = "";
}
// MakerWorld model pages: try resolving straight through api.bambulab.com first (no
// Cloudflare, no cookie-gated web session, no HTML scraping -- see makerworldCloudApi.ts).
// Only at the top of the chain, so a URL this already resolved down to (e.g. the signed S3
// download link) doesn't get reinterpreted as a fresh model page on the recursive call.
if (depth === 0 && host.endsWith("makerworld.com")) {
const cloudResolved = await tryMakerworldCloudApi(validatedUrl, body);
if (cloudResolved) {
return openImportResponse(cloudResolved.downloadUrl, body, validatedUrl, depth + 1, cloudResolved.meta);
}
}
const headers: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
let makerworldCookie: string | null = null;
let thingiverseCookie: string | null = null;
if (host.endsWith("makerworld.com")) {
@@ -186,6 +228,7 @@ export async function openImportResponse(
description: extracted.description ?? inheritedMeta.description,
creator: extracted.creator ?? inheritedMeta.creator,
previewImageUrl: extracted.previewImageUrl ?? inheritedMeta.previewImageUrl,
filename: extracted.filename ?? inheritedMeta.filename,
};
if (pageHost.endsWith("makerworld.com")) {
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
@@ -253,7 +296,7 @@ export async function downloadImportToTemp(
await response.body?.cancel().catch(() => undefined);
throw new HttpError(413, "Imported file exceeds size limit");
}
const filename = buildImportFilename(finalUrl, response.headers, body.filename);
const filename = buildImportFilename(finalUrl, response.headers, body.filename ?? meta.filename);
const mime = mimeFromContentType(response.headers.get("content-type"), filename);
const suffix = path.extname(filename) || "";
const tempPath = path.join(os.tmpdir(), `printstash-import-${crypto.randomBytes(8).toString("hex")}${suffix}`);
@@ -274,7 +317,7 @@ export async function inspectImportLink(
): Promise<{ filename: string; mime: string; is_zip: boolean; title: string | null }> {
const { response, finalUrl, meta } = await openImportResponse(url, body);
await response.body?.cancel().catch(() => undefined);
const filename = buildImportFilename(finalUrl, response.headers, body.filename);
const filename = buildImportFilename(finalUrl, response.headers, body.filename ?? meta.filename);
const mime = mimeFromContentType(response.headers.get("content-type"), filename);
const isZip = path.extname(filename).toLowerCase() === ".zip";
return { filename, mime, is_zip: isZip, title: meta.title };
+222
View File
@@ -0,0 +1,222 @@
import { IMPORT_BROWSER_USER_AGENT, IMPORT_TIMEOUT_SECONDS } from "../config";
import { decodeHtmlEntities, htmlToPlainText, type ImportedPageMetadata } from "./importResolvers";
// MakerWorld's own website (makerworld.com) sits behind Cloudflare bot management and a
// separate Geetest CAPTCHA on its download-resolution endpoints. api.bambulab.com is the
// same Bambu Cloud backend the official apps (Bambu Studio, Bambu Handy) talk to -- same
// account, same bearer token -- but isn't behind either defense. Confirmed against live
// traffic; the same finding is independently documented by other reverse-engineering
// projects (kloshi-io/makerworld-api-reverse, maziggy/bambuddy, Pr0zak/YASTL).
const DESIGN_API_BASE = "https://api.bambulab.com/v1/design-service";
const PROFILE_DOWNLOAD_BASE = "https://api.bambulab.com/v1/iot-service/api/user/profile";
const CLOUD_API_TIMEOUT_MS = IMPORT_TIMEOUT_SECONDS * 1000;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function cloudApiHeaders(bearerToken: string): Record<string, string> {
return {
"User-Agent": IMPORT_BROWSER_USER_AGENT,
Accept: "application/json,text/plain,*/*",
Authorization: `Bearer ${bearerToken}`,
Referer: "https://makerworld.com/",
};
}
async function fetchCloudJson(
url: string,
bearerToken: string,
): Promise<{ status: number; data: unknown } | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
try {
const res = await fetch(url, { headers: cloudApiHeaders(bearerToken), redirect: "follow", signal: controller.signal });
const text = await res.text();
let data: unknown = null;
if (text.trim()) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
return { status: res.status, data };
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
/** Users paste the raw browser `Cookie:` header into Settings (unchanged UX) -- this pulls
* just the `token` value out of it to use as a Bearer credential against api.bambulab.com,
* or accepts a bare pasted token directly (no `;`/`=`/whitespace) as a convenience. */
export function extractMakerworldBearerToken(rawCookieOrToken: string | null | undefined): string | null {
const raw = (rawCookieOrToken || "").trim();
if (!raw) return null;
const match = raw.match(/(?:^|;\s*)token=([^;]+)/);
if (match) {
const value = match[1].trim();
if (value) return value;
}
if (!raw.includes(";") && !raw.includes("=") && !/\s/.test(raw)) {
return raw;
}
return null;
}
export function parseMakerworldModelUrl(url: string): { designId: string; requestedInstanceId: string | null } | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
const designMatch = parsed.pathname.match(/\/models?\/(\d+)/i);
if (!designMatch) return null;
const hashMatch = parsed.hash.match(/profileid-(\d+)/i);
return { designId: designMatch[1], requestedInstanceId: hashMatch ? hashMatch[1] : null };
}
// MakerWorld's anti-abuse layer (distinct from the Cloudflare edge) answers a flagged
// request with a well-formed JSON body naming a captchaId -- sometimes under HTTP 418,
// sometimes (as observed live) under a 200. It's account/IP-scoped, self-clears after a
// few hours of quiet traffic, and cannot be solved without a real browser. Detecting it by
// shape (not exact wording) and reporting it clearly, instead of letting it fall through
// as "no downloadable file found", mirrors maziggy/bambuddy's handling of the same
// upstream behavior (they hit and documented this independently, as issue #2790).
function isCaptchaChallenge(data: unknown): boolean {
if (!isRecord(data)) return false;
const haystack = Object.entries(data)
.map(([key, value]) => `${key} ${typeof value === "string" ? value : ""}`)
.join(" ")
.toLowerCase();
return haystack.includes("captchaid") || haystack.includes("captcha") || haystack.includes("robot");
}
export const MAKERWORLD_CAPTCHA_MESSAGE =
"MakerWorld is challenging this account with a CAPTCHA before it will hand over a download link. " +
"This can't be solved automatically. Open the model on makerworld.com and click Download there " +
"once -- that usually clears it -- then retry the import.";
// Once we've seen the challenge, stop sending more automated requests for a while instead
// of retrying into a deepening block (the exact mistake that extends these in practice).
const CAPTCHA_COOLOFF_MS = 5 * 60 * 1000;
let captchaBlockedUntil = 0;
export function makerworldCaptchaCooloffActive(): boolean {
return Date.now() < captchaBlockedUntil;
}
function noteCaptchaChallenge(): void {
captchaBlockedUntil = Date.now() + CAPTCHA_COOLOFF_MS;
}
export class MakerworldCaptchaError extends Error {
constructor() {
super(MAKERWORLD_CAPTCHA_MESSAGE);
this.name = "MakerworldCaptchaError";
}
}
export class MakerworldAuthError extends Error {
constructor() {
super("Your MakerWorld session has expired or was rejected. Update the cookie in Settings and try again.");
this.name = "MakerworldAuthError";
}
}
export type MakerworldCloudResolution = {
downloadUrl: string;
meta: ImportedPageMetadata;
};
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
/**
* Resolves a MakerWorld design to a real, directly-downloadable (signed S3) URL entirely
* through api.bambulab.com -- no Cloudflare, no cookie-gated web session, no HTML scraping.
* Returns null for anything that should fall back to the existing page-scraping resolver
* (missing/malformed data); throws MakerworldCaptchaError/MakerworldAuthError for the two
* failure shapes worth telling the user about specifically.
*/
export async function resolveMakerworldViaCloudApi(
designId: string,
requestedInstanceId: string | null,
bearerToken: string,
): Promise<MakerworldCloudResolution | null> {
if (makerworldCaptchaCooloffActive()) throw new MakerworldCaptchaError();
const designResult = await fetchCloudJson(`${DESIGN_API_BASE}/design/${designId}`, bearerToken);
if (!designResult) return null;
if (isCaptchaChallenge(designResult.data)) {
noteCaptchaChallenge();
throw new MakerworldCaptchaError();
}
if (designResult.status === 401 || designResult.status === 403) throw new MakerworldAuthError();
if (designResult.status !== 200 || !isRecord(designResult.data)) return null;
const design = designResult.data;
const modelId = typeof design.modelId === "string" && design.modelId.trim() ? design.modelId.trim() : null;
if (!modelId) return null;
const instances = Array.isArray(design.instances) ? design.instances.filter(isRecord) : [];
const defaultInstanceId = design.defaultInstanceId != null ? String(design.defaultInstanceId) : null;
let selected: Record<string, unknown> | null = null;
if (requestedInstanceId) {
selected = instances.find((inst) => String(inst.id) === requestedInstanceId) ?? null;
}
if (!selected && defaultInstanceId) {
selected = instances.find((inst) => String(inst.id) === defaultInstanceId) ?? null;
}
if (!selected) {
selected = instances[0] ?? null;
}
if (!selected) return null;
const profileId = selected.profileId != null ? String(selected.profileId) : null;
if (!profileId) return null;
const downloadResult = await fetchCloudJson(
`${PROFILE_DOWNLOAD_BASE}/${profileId}?model_id=${encodeURIComponent(modelId)}`,
bearerToken,
);
if (!downloadResult) return null;
if (isCaptchaChallenge(downloadResult.data)) {
noteCaptchaChallenge();
throw new MakerworldCaptchaError();
}
if (downloadResult.status === 401 || downloadResult.status === 403) throw new MakerworldAuthError();
if (downloadResult.status !== 200 || !isRecord(downloadResult.data)) return null;
const body = downloadResult.data;
if (body.message !== "success" || typeof body.url !== "string" || !body.url.trim()) return null;
const tags = Array.isArray(design.tags)
? design.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0).map((tag) => tag.trim())
: [];
const summary = typeof design.summary === "string" ? design.summary : null;
const creator = isRecord(design.designCreator) ? pickString(design.designCreator, ["nickName", "name", "handle"]) : null;
const previewImageUrl = pickString(design, ["coverUrl", "coverPortrait", "coverLandscape"]);
const title = pickString(design, ["title"]);
return {
downloadUrl: body.url,
meta: {
title: title ? decodeHtmlEntities(title) : null,
tags,
description: summary ? htmlToPlainText(summary) : null,
creator,
previewImageUrl,
filename: pickString(body, ["filename"]),
},
};
}