Say why MakerWorld files didn't download instead of importing silently without them

MakerWorld answers an expired or missing auth token with {"error": "Please log
in to download models."} and no download link, which was skipped without a
word, so imports and "Download files from source" just ended with no files.
Now the reason comes back: missing settings, a refused token (probably expired),
no printable files on the page, or a failed download. The import screen shows
it, and the Settings hint explains where the token comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 18:17:46 +10:00
parent f80078d7ed
commit 28a95ee330
5 changed files with 84 additions and 12 deletions

View file

@ -35,10 +35,21 @@ async function resolveDownload(flaresolverrUrl, instanceId, token) {
}); });
} }
// Why a download-link answer came back without a link, in words you can act on;
// 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 'MakerWorld didn’t accept your auth token (it has probably expired). Paste a fresh one in Settings → MakerWorld auth token, then use “Download files from source”.';
}
return `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`;
}
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License"). // Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
function parseLicense(html) { function parseLicense(html) {
const m = html.match(/"license"\s*:\s*"([^"]+)"/); const m = html.match(/"license"\s*:\s*"([^"]+)"/);
return m ? m[1] : null; return m ? m[1] : null;
} }
module.exports = { parseInstances, resolveDownload, parseLicense }; module.exports = { parseInstances, resolveDownload, downloadProblem, parseLicense };

View file

@ -2252,10 +2252,13 @@ async function handleImport(e) {
if (res.ok) { if (res.ok) {
await loadData(); await loadData();
if (data.metadataOnly) { if (data.metadataOnly) {
status.textContent = data.metadataFetched status.textContent = data.filesError
? `Imported "${data.name}", but its files couldn’t be downloaded: ${data.filesError}`
: data.metadataFetched
? `Imported "${data.name}". Opening it so you can add the downloaded files…` ? `Imported "${data.name}". Opening it so you can add the downloaded files…`
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`; : `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200); // Give a download problem time to be read before moving on.
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, data.filesError ? 6000 : 1200);
} else { } else {
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`; status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
setTimeout(() => closeModal(importModal), 900); setTimeout(() => closeModal(importModal), 900);

View file

@ -320,8 +320,10 @@
placeholder="Bearer token from your logged-in session" autocomplete="off"> placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint"> <p class="form-hint">
Status: <span class="setting-status" data-status="makerworld_token">—</span>. Status: <span class="setting-status" data-status="makerworld_token">—</span>.
Lets Printhive download files from MakerWorld using your account. Paste the bearer Lets Printhive download files from MakerWorld using your account. In a browser signed in to
token from your logged-in browser session (see instructions in chat). makerworld.com, open the developer tools (Application / Storage → Cookies → makerworld.com)
and copy the value of the <code>token</code> cookie. It expires after a while: paste a new one if
MakerWorld downloads stop working.
</p> </p>
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button> <button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
</div> </div>

View file

@ -444,6 +444,7 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
// original name is already present. // original name is already present.
async function fetchSourceFiles(model, skipNames) { async function fetchSourceFiles(model, skipNames) {
if (model.source_site === 'makerworld') { if (model.source_site === 'makerworld') {
await requireMakerworldSettings(); // before the page fetch, which fails without FlareSolverr
const html = await fetchPageHtml(model.source_url); const html = await fetchPageHtml(model.source_url);
return downloadMakerworldFiles(html, skipNames); return downloadMakerworldFiles(html, skipNames);
} }
@ -828,11 +829,13 @@ app.post('/api/import', async (req, res, next) => {
// Download the actual files where we can (MakerWorld via FlareSolverr+token, // Download the actual files where we can (MakerWorld via FlareSolverr+token,
// Printables via its public GraphQL API), and capture the license. // Printables via its public GraphQL API), and capture the license.
let files = []; let files = [];
let filesError = null; // why no files could be downloaded, shown after the import
let license = meta.license || null; let license = meta.license || null;
let commercial = null; let commercial = null;
if (detected.site === 'makerworld' && html) { if (detected.site === 'makerworld' && html) {
files = await downloadMakerworldFiles(html).catch((err) => { files = await downloadMakerworldFiles(html).catch((err) => {
console.warn('MakerWorld file download failed:', err.message); console.warn('MakerWorld file download failed:', err.message);
filesError = err.message;
return []; return [];
}); });
license = makerworld.parseLicense(html) || license; license = makerworld.parseLicense(html) || license;
@ -840,6 +843,7 @@ app.post('/api/import', async (req, res, next) => {
} else if (detected.site === 'printables' && detected.id) { } else if (detected.site === 'printables' && detected.id) {
const result = await downloadPrintablesFiles(detected.id).catch((err) => { const result = await downloadPrintablesFiles(detected.id).catch((err) => {
console.warn('Printables file download failed:', err.message); console.warn('Printables file download failed:', err.message);
filesError = err.message;
return { files: [] }; return { files: [] };
}); });
files = result.files; files = result.files;
@ -869,7 +873,7 @@ app.post('/api/import', async (req, res, next) => {
files, files,
}); });
fingerprint.kick(); fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched }); res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched, filesError });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -918,14 +922,28 @@ async function resolveCommercial(licenseName, fallback) {
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr). // Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
// skipNames: original filenames already present (so a re-download only adds new ones). // skipNames: original filenames already present (so a re-download only adds new ones).
async function downloadMakerworldFiles(html, skipNames = new Set()) { // MakerWorld downloads need both a FlareSolverr address and your auth token.
async function requireMakerworldSettings() {
const { value: token } = await resolveSetting('makerworld_token'); const { value: token } = await resolveSetting('makerworld_token');
const { value: flareUrl } = await resolveSetting('flaresolverr_url'); const { value: flareUrl } = await resolveSetting('flaresolverr_url');
if (!token || !flareUrl) return []; if (!token || !flareUrl) {
throw Object.assign(new Error('Add your MakerWorld auth token and FlareSolverr URL in Settings to download MakerWorld files.'), { status: 400 });
}
return { token, flareUrl };
}
// Downloads a MakerWorld model's files. When nothing could be downloaded it throws
// with the reason (missing settings, an expired token, …) so the import can say why.
async function downloadMakerworldFiles(html, skipNames = new Set()) {
const { token, flareUrl } = await requireMakerworldSettings();
const instances = makerworld.parseInstances(html); const instances = makerworld.parseInstances(html);
if (!instances.length) {
throw Object.assign(new Error('Couldn’t find any printable files on the MakerWorld page.'), { status: 502 });
}
ensureUploadDir(); ensureUploadDir();
const files = []; const files = [];
const problems = [];
for (let i = 0; i < instances.length; i++) { for (let i = 0; i < instances.length; i++) {
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
const inst = instances[i]; const inst = instances[i];
@ -934,18 +952,29 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
dl = await makerworld.resolveDownload(flareUrl, inst.id, token); dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
} catch (err) { } catch (err) {
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message); console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
problems.push(`Couldn’t ask MakerWorld for a download link: ${err.message}`);
continue;
}
const problem = makerworld.downloadProblem(dl);
if (problem) {
console.warn(`MakerWorld instance ${inst.id}: ${problem}`);
problems.push(problem);
continue; continue;
} }
if (!dl || !dl.url) continue;
const original = dl.name || `instance-${inst.id}.3mf`; const original = dl.name || `instance-${inst.id}.3mf`;
if (skipNames.has(original)) continue; if (skipNames.has(original)) continue;
const resp = await fetch(dl.url); const resp = await fetch(dl.url);
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; } if (!resp.ok) {
console.warn(`MakerWorld CDN download ${resp.status} for ${original}`);
problems.push(`Downloading ${original} from MakerWorld failed (HTTP ${resp.status})`);
continue;
}
const fileName = uniqueName(original); const fileName = uniqueName(original);
const filePath = path.join(UPLOAD_DIR, fileName); const filePath = path.join(UPLOAD_DIR, fileName);
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
files.push(describeFile(filePath, fileName, original)); files.push(describeFile(filePath, fileName, original));
} }
if (!files.length && problems.length) throw Object.assign(new Error(problems[0]), { status: 502 });
return files; return files;
} }

27
test/makerworld.test.js Normal file
View file

@ -0,0 +1,27 @@
// MakerWorld importer helpers (lib/importers/makerworld.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const makerworld = require('../lib/importers/makerworld');
const page = (design) =>
`<html><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { design } } })}</script></html>`;
test('printable instances and the license are read from the page data', () => {
const html = page({
license: 'Standard Digital File License',
instances: [{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' }, { id: 650089 }],
});
assert.deepEqual(makerworld.parseInstances(html), [
{ id: 650088, title: 'AMS - 0.4 Nozzle - 0.08 Layer' },
{ id: 650089, title: '' },
]);
assert.equal(makerworld.parseLicense(html), 'Standard Digital File License');
assert.deepEqual(makerworld.parseInstances('<html><title>Just a moment...</title></html>'), []);
});
test('a download answer without a link says why', () => {
assert.equal(makerworld.downloadProblem({ name: 'bookmarks.3mf', url: 'https://cdn.example/bookmarks.3mf' }), null);
assert.match(makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' }), /token \(it has probably expired\)/);
assert.equal(makerworld.downloadProblem({ code: 7, error: 'Model not found' }), 'MakerWorld didn’t give a download link: Model not found');
assert.equal(makerworld.downloadProblem(null), 'MakerWorld didn’t give a download link');
});