Measured against the live site: FlareSolverr reports no challenge on that endpoint and an unauthenticated call gets a normal reply in 3s, so this is MakerWorld limiting an account to a couple of downloads in a row, not a Cloudflare page. Two models retried three times each still came back empty — the retry only made every failure a minute slower. Keeps the classification, so a refusal is named for what it is and the model is flagged as still without files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.7 KiB
JavaScript
126 lines
5.7 KiB
JavaScript
// 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');
|
||
|
||
// 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;
|
||
}
|
||
|
||
// MakerWorld answers with an anti-bot message once an account has asked for a
|
||
// couple of files in a row. It isn't Cloudflare — FlareSolverr reports no
|
||
// challenge, and an unauthenticated call still gets a normal reply — so retrying
|
||
// doesn't clear it; only asking for fewer files does.
|
||
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).
|
||
async function resolveDownload(flaresolverrUrl, instanceId, token) {
|
||
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
|
||
return flaresolverr.solveJson(flaresolverrUrl, api, {
|
||
cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }],
|
||
});
|
||
}
|
||
|
||
// 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 limit blocked the download — it only allows a couple in a row.',
|
||
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,
|
||
};
|