diff --git a/README.md b/README.md index ab55fd6..d57f50c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX, - **Notes** — free-form notes per model, saved as you type. - **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press / to search. - **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates". -- **Watch a MakerWorld collection** — paste a collection link (private ones work, using your MakerWorld token) and Printhive checks it on a schedule you choose, importing models added to it into a collection of the same name here, optionally AI-tagging the new ones. Each check reads the whole collection (paging through MakerWorld's own load-more call, up to ~640 models) and imports up to 15 of the new ones, spaced out, returning shortly after if more are waiting; models you remove on MakerWorld stay in your library. Each watch shows when it last ran, what it imported, and why a check failed — with a one-click token update when MakerWorld refuses your token. +- **Watch a MakerWorld collection** — paste a collection link (private ones work, using your MakerWorld token) and Printhive checks it on a schedule you choose, importing models added to it into a collection of the same name here, optionally AI-tagging the new ones. Each check reads the whole collection (paging through MakerWorld's own load-more call, up to ~640 models) and imports up to 15 of the new ones, spaced out, returning shortly after if more are waiting; models you remove on MakerWorld stay in your library. Each watch shows when it last ran, what it imported, and why a check failed — with a one-click token update when MakerWorld refuses your token. A model whose files MakerWorld's anti-bot check blocked is listed as still without files, with a "Get files" button to fetch them later. - **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images. - **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change. - **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page. diff --git a/lib/collectionWatch.js b/lib/collectionWatch.js index 9166600..b5eea6e 100644 --- a/lib/collectionWatch.js +++ b/lib/collectionWatch.js @@ -39,7 +39,8 @@ function start() { async function list() { const { rows } = await db.query(` SELECT w.*, p.name AS project_name, - (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'imported') AS imported_count, + (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count, + (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count, (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count, COALESCE(r.recent, '[]'::json) AS recent FROM watched_collections w @@ -57,7 +58,8 @@ async function list() { async function get(id) { const { rows: [watch] } = await db.query(` SELECT w.*, p.name AS project_name, - (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'imported') AS imported_count, + (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count, + (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count, (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count FROM watched_collections w LEFT JOIN projects p ON p.id = w.project_id WHERE w.id = $1`, [id]); if (!watch) return null; @@ -161,7 +163,10 @@ async function importFound(watch, collection) { } else { added += 1; fresh.push(result.model.id); - await recordItem(watch.id, design, result.model.id, 'imported', result.filesError || null); + // Imported, but MakerWorld wouldn't hand over the files: say so rather + // than letting an empty model look like a success. + await recordItem(watch.id, design, result.model.id, + result.filesError ? 'partial' : 'imported', result.filesError || null); } // A refused token means every later download fails too: stop and say so. if (result.filesErrorReason === 'makerworld_token') { diff --git a/lib/importers/makerworld.js b/lib/importers/makerworld.js index 8c02a54..3d65ca7 100644 --- a/lib/importers/makerworld.js +++ b/lib/importers/makerworld.js @@ -5,6 +5,8 @@ const flaresolverr = require('../flaresolverr'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // A model page embeds its printable "instances" in __NEXT_DATA__. Each instance // id is what the download API takes. function parseInstances(html) { @@ -27,12 +29,28 @@ function parseInstances(html) { return result; } +// Cloudflare sometimes answers a download call with its anti-bot check instead of +// the JSON — especially during a run of downloads. It comes back as a normal +// answer, so nothing below retries it on its own. +const CHALLENGE_RE = /not a robot|just a moment|captcha|attention required|cf[- ]challenge/i; +function isChallenge(answer) { + if (!answer || answer.url) return false; + return CHALLENGE_RE.test(String(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) { +// A challenge usually clears if we let FlareSolverr's browser try again after a +// pause, so give it a couple of goes before reporting a problem. +async function resolveDownload(flaresolverrUrl, instanceId, token, { retries = 2, waitMs = 8000 } = {}) { 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' }], - }); + const opts = { cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }] }; + let answer; + for (let attempt = 0; attempt <= retries; attempt++) { + if (attempt > 0) await sleep(waitMs * attempt); + answer = await flaresolverr.solveJson(flaresolverrUrl, api, opts); + if (!isChallenge(answer)) return answer; + } + return answer; } // Why a download-link answer came back without a link: { message, tokenRejected }, @@ -43,6 +61,13 @@ function downloadProblem(answer) { 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 check blocked the download. Trying again in a few minutes usually works.', + tokenRejected: false, + challenged: true, + }; + } return { message: `MakerWorld didn’t give a download link${detail ? `: ${detail}` : ''}`, tokenRejected: false }; } @@ -104,6 +129,6 @@ function parseLicense(html) { } module.exports = { - parseInstances, resolveDownload, downloadProblem, parseLicense, + parseInstances, resolveDownload, downloadProblem, isChallenge, parseLicense, parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl, }; diff --git a/public/app.js b/public/app.js index eb8d851..e54da15 100644 --- a/public/app.js +++ b/public/app.js @@ -2194,13 +2194,14 @@ function watchHtml(w) { -
${escapeHtml(when)} · every ${w.interval_hours}h · ${plural(w.imported_count, 'model')} imported${w.error_count ? ` · ${w.error_count} failed` : ''}${listed}
+
${escapeHtml(when)} · every ${w.interval_hours}h · ${plural(w.imported_count, 'model')} imported${w.partial_count ? ` · ${w.partial_count} still without files` : ''}${w.error_count ? ` · ${w.error_count} failed` : ''}${listed}
${w.last_error ? `
${escapeHtml(w.last_error)} ${w.last_error_reason === 'makerworld_token' ? '' : ''}
` : ''} ${recent.length ? `
${recent.map((i) => ` ${i.model_id ? `` - : escapeHtml(i.title || i.design_id)}${i.status === 'error' ? ` ${escapeHtml(i.error || 'failed')}` : ''}`).join('')}
` : ''} + : escapeHtml(i.title || i.design_id)}${i.error ? ` ${escapeHtml(i.error)}` : ''}${i.status === 'partial' && i.model_id + ? ` ` : ''}`).join('')}` : ''} `; } @@ -2286,6 +2287,9 @@ async function handleWatchAction(btn) { if (!confirm('Stop watching this collection?\n\nThe models it already imported are kept.')) { btn.disabled = false; return; } await getJson(`/api/watches/${id}`, { method: 'DELETE' }); await loadData(); + } else if (action === 'files') { + btn.textContent = 'Getting files…'; + await postJson(`/api/models/${Number(btn.dataset.modelId)}/download-files`); } else if (action === 'token') { promptMakerworldToken('MakerWorld didn’t accept your token when checking this collection.', () => retryWatchCheck(id), 'Save & check collection'); diff --git a/server.js b/server.js index 03f0e19..4f92e43 100644 --- a/server.js +++ b/server.js @@ -436,6 +436,9 @@ app.post('/api/models/:id/download-files', async (req, res, next) => { return res.status(200).json({ ...publicModel(model), added: 0 }); } const updated = await store.addFiles(model.id, newFiles); + // If a watched collection imported this one without its files, it's fine now. + await db.query(`UPDATE watched_collection_items SET status = 'imported', error = NULL + WHERE model_id = $1 AND status = 'partial'`, [model.id]); fingerprint.kick(); res.json({ ...publicModel(updated), added: newFiles.length }); } catch (e) { next(e); } diff --git a/test/makerworld.test.js b/test/makerworld.test.js index e54130f..113d6b7 100644 --- a/test/makerworld.test.js +++ b/test/makerworld.test.js @@ -79,3 +79,19 @@ test('collection links are recognised', () => { assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/collections/31103374'), '31103374'); assert.equal(makerworld.collectionIdFromUrl('https://makerworld.com/en/models/719274-skeleton-bookmarks'), null); }); + +test('MakerWorld’s anti-bot check is told apart from a refused token', () => { + const challenge = { code: 1, error: 'We need to confirm that you are not a robot.' }; + assert.equal(makerworld.isChallenge(challenge), true); + assert.equal(makerworld.isChallenge({ url: 'https://cdn.example/x.3mf' }), false); + assert.equal(makerworld.isChallenge({ code: 1, error: 'Please log in to download models.' }), false); + + const blocked = makerworld.downloadProblem(challenge); + assert.equal(blocked.challenged, true); + assert.equal(blocked.tokenRejected, false); + assert.match(blocked.message, /anti-bot/); + + const refused = makerworld.downloadProblem({ code: 1, error: 'Please log in to download models.' }); + assert.equal(refused.tokenRejected, true); + assert.equal(refused.challenged, undefined); +});