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:
parent
f80078d7ed
commit
28a95ee330
5 changed files with 84 additions and 12 deletions
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
39
server.js
39
server.js
|
|
@ -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
27
test/makerworld.test.js
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue