The challenge was detected but treated as an ordinary download failure, so the message didn't say that only you can clear it. Now it stops the run, is remembered centrally, and watches skip downloads while it stands — metadata still imports, and models stay partial for "Get files". The Watched page shows a banner with a link to MakerWorld and an "I've solved it" button; a successful download clears it too. Detection also covers a challenge page returned where JSON was expected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.9 KiB
JavaScript
129 lines
5.9 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|verify (?:you|your|that)|verification (?:required|needed)|security check|select in this order/i;
|
||
|
||
const isChallengeText = (text) => CHALLENGE_RE.test(String(text || ''));
|
||
|
||
function isChallenge(answer) {
|
||
if (!answer || answer.url) return false;
|
||
return isChallengeText(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, isChallengeText, parseLicense,
|
||
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
|
||
};
|