- A collection page lists only its first ~20 models. Checks now page through the same load-more call the page uses (offset + a shared seed, 64 at a time, up to ~640 models), so a watch sees the whole collection. - A private collection read without a valid token comes back as a placeholder (id -1, no models), which was being treated as an empty collection. It now reports the token problem instead. - Adding a watch starts a check, and "Check now" could start a second one alongside it, importing models twice. One check per collection at a time now, and a check interrupted by a restart no longer looks like it's still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
5 KiB
JavaScript
109 lines
5 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;
|
||
}
|
||
|
||
// 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 };
|
||
}
|
||
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, parseLicense,
|
||
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
|
||
};
|