printhive/lib/importers/makerworld.js
dlawler489 a65c9d047f Don't retry MakerWorld's anti-bot refusal
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>
2026-09-16 12:53:36 +10:00

126 lines
5.7 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');
// 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,
};