printhive/lib/importers/makerworld.js
dlawler489 a313e0e224 Retry MakerWorld's anti-bot check, and flag imports that got no files
A run of downloads gets challenged by Cloudflare partway through: the answer
comes back as an ordinary reply, so nothing retried it and the model landed
with only its metadata — looking, on the watch page, like a success.

resolveDownload now recognises a challenge and gives FlareSolverr's browser a
couple more goes; what still fails is recorded as 'partial' and shown with its
reason and a "Get files" button, which clears the flag once the files arrive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:34:04 +10:00

134 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// MakerWorld file downloader. MakerWorld is behind Cloudflare and its downloads
// are auth-gated, so we go through FlareSolverr (solves Cloudflare) carrying the
// user's `token` cookie (authenticates). The download API returns a short-lived
// signed CDN URL, which we then fetch directly.
const flaresolverr = require('../flaresolverr');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// A model page embeds its printable "instances" in __NEXT_DATA__. Each instance
// id is what the download API takes.
function parseInstances(html) {
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return [];
let data;
try { data = JSON.parse(m[1]); } catch { return []; }
let result = [];
(function walk(node) {
if (result.length || !node || typeof node !== 'object') return;
if (Array.isArray(node.instances) && node.instances.length && typeof node.instances[0].id === 'number') {
result = node.instances
.filter((x) => x && typeof x.id === 'number')
.map((x) => ({ id: x.id, title: x.title || '' }));
return;
}
for (const key of Object.keys(node)) { walk(node[key]); if (result.length) return; }
})(data);
return result;
}
// Cloudflare sometimes answers a download call with its anti-bot check instead of
// the JSON — especially during a run of downloads. It comes back as a normal
// answer, so nothing below retries it on its own.
const CHALLENGE_RE = /not a robot|just a moment|captcha|attention required|cf[- ]challenge/i;
function isChallenge(answer) {
if (!answer || answer.url) return false;
return CHALLENGE_RE.test(String(answer.error || answer.message || answer.msg || ''));
}
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
// A challenge usually clears if we let FlareSolverr's browser try again after a
// pause, so give it a couple of goes before reporting a problem.
async function resolveDownload(flaresolverrUrl, instanceId, token, { retries = 2, waitMs = 8000 } = {}) {
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
const opts = { cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }] };
let answer;
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) await sleep(waitMs * attempt);
answer = await flaresolverr.solveJson(flaresolverrUrl, api, opts);
if (!isChallenge(answer)) return answer;
}
return answer;
}
// Why a download-link answer came back without a link: { message, tokenRejected },
// or null when it has one. Not logged in: {"code":1,"error":"Please log in to download models."}
function downloadProblem(answer) {
if (answer && answer.url) return null;
const detail = String((answer && (answer.error || answer.message || answer.msg)) || '').trim();
if (/log ?in|sign ?in|unauthori[sz]ed|token|expired/i.test(detail)) {
return { message: 'MakerWorld didn’t accept your auth token (it has probably expired).', tokenRejected: true };
}
if (isChallenge(answer)) {
return {
message: 'MakerWorld’s anti-bot check blocked the download. Trying again in a few minutes usually works.',
tokenRejected: false,
challenged: true,
};
}
return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false };
}
// The collection id in a MakerWorld collection link, or null.
function collectionIdFromUrl(url) {
const m = String(url || '').match(/makerworld\.com\/(?:[a-z-]{2,5}\/)?collections\/(\d+)/i);
return m ? m[1] : null;
}
// Where a collection page loads the rest of its models from (its own "load more"
// call). A stable `seed` keeps the pages from overlapping.
const collectionDesignsUrl = (collectionId, { offset = 0, limit = 64, seed } = {}) =>
`https://makerworld.com/api/v1/design-service/favorites/${collectionId}/designs?offset=${offset}&limit=${limit}`
+ (seed === undefined || seed === null ? '' : `&seed=${seed}`);
// That call's answer -> { designs, total, seed }.
function parseCollectionDesigns(json) {
const hits = (json && json.hits) || [];
return {
designs: hits
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' })),
total: Number(json && json.total) || 0,
seed: json ? json.seed : undefined,
};
}
const designUrl = (design) => `https://makerworld.com/en/models/${design.id}${design.slug ? `-${design.slug}` : ''}`;
// A collection page -> { id, title, total, designs: [{ id, slug, title }] }, or null
// when the page holds no collection (a private one read without a signed-in token).
// `total` is how many models the collection holds; the page may list fewer.
function parseCollection(html) {
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
let props;
try { props = JSON.parse(m[1]).props.pageProps; } catch { return null; }
// A collection you can't see (private, read without a signed-in token) comes back
// as a placeholder: id -1, no title, no models.
const id = Number(props && props.favorite && props.favorite.id);
if (!Number.isFinite(id) || id <= 0) return null;
const hits = (props.favoriteDesigns && props.favoriteDesigns.hits) || [];
const designs = hits
.filter((d) => d && (typeof d.id === 'number' || typeof d.id === 'string'))
.map((d) => ({ id: String(d.id), slug: d.slug || '', title: d.title || '' }));
const total = Number((props.favoriteDesigns && props.favoriteDesigns.total) ?? props.favorite.designCnt);
return {
id: String(id),
title: props.favorite.title || '',
total: Number.isFinite(total) ? total : designs.length,
designs,
};
}
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
function parseLicense(html) {
const m = html.match(/"license"\s*:\s*"([^"]+)"/);
return m ? m[1] : null;
}
module.exports = {
parseInstances, resolveDownload, downloadProblem, isChallenge, parseLicense,
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
};