printhive/lib/importers/makerworld.js
dlawler489 4c54f76ae3 Read whole collections, and never check one twice at once
- 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>
2026-09-16 09:58:09 +10:00

109 lines
5 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;
}
// 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,
};