Compare commits
No commits in common. "d5fa67d9e7a59c89d62901ba8deb37d28edefd8b" and "bbbaf8fa7dd05990d30963941842da503aada4b1" have entirely different histories.
d5fa67d9e7
...
bbbaf8fa7d
8 changed files with 17 additions and 144 deletions
|
|
@ -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 <kbd>/</kbd> 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. A check imports each model's page — name, description, picture, licence — but not its files, so a large collection arrives quickly; models are listed as still without files and you fetch the ones you want with "Get files". MakerWorld refuses downloads fired off in quick succession (a few seconds apart, as a check would), but spacing them out is fine — 67 models in a row downloaded about 20 seconds apart without a single refusal. After a run of downloads MakerWorld may also ask you to prove you're not a robot: the Watched page says so and Printhive stops downloading — checks still import new models, whose files wait — until you solve it on MakerWorld and press "I've solved it".
|
||||
- **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 check imports each model's page — name, description, picture, licence — but not its files, so a large collection arrives quickly; models are listed as still without files and you fetch the ones you want with "Get files". MakerWorld refuses downloads fired off in quick succession (a few seconds apart, as a check would), but spacing them out is fine — 67 models in a row downloaded about 20 seconds apart without a single refusal.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
// The site is read gently: a few models per check, spaced out.
|
||||
const db = require('./db');
|
||||
const store = require('./store');
|
||||
const downloadBlock = require('./downloadBlock');
|
||||
const makerworld = require('./importers/makerworld');
|
||||
|
||||
const IMPORT_LIMIT = 15; // models per check, so a big collection trickles in
|
||||
|
|
@ -185,12 +184,9 @@ async function importFound(watch, collection) {
|
|||
if (!live || !live.enabled) break;
|
||||
const url = makerworld.designUrl(design);
|
||||
try {
|
||||
// While MakerWorld is waiting for you to prove you're not a robot, the
|
||||
// check still brings in the models — their files wait for you.
|
||||
const withFiles = Boolean(watch.fetch_files) && !downloadBlock.isBlocked('makerworld');
|
||||
const result = await deps.importFromUrl(url, {
|
||||
projects: watch.project_id ? [watch.project_id] : [],
|
||||
withFiles, // off by default: MakerWorld refuses quick bursts
|
||||
withFiles: Boolean(watch.fetch_files), // off by default: MakerWorld refuses quick bursts
|
||||
});
|
||||
if (result.existing) {
|
||||
if (watch.project_id) await store.addModelToProject(result.model.id, watch.project_id);
|
||||
|
|
@ -200,29 +196,28 @@ async function importFound(watch, collection) {
|
|||
fresh.push(result.model.id);
|
||||
// Without files (or when they couldn't be fetched) the model is only
|
||||
// 'partial' — "Get files" fills it in later.
|
||||
const gotFiles = withFiles && !result.filesError;
|
||||
const gotFiles = watch.fetch_files && !result.filesError;
|
||||
await recordItem(watch.id, design, result.model.id, gotFiles ? 'imported' : 'partial',
|
||||
result.filesError || null);
|
||||
}
|
||||
// Refused downloads mean we're asking too fast. Stop the batch and come
|
||||
// back shortly, rather than collecting refusals for the whole of it.
|
||||
if (withFiles && result.filesError) {
|
||||
if (watch.fetch_files && result.filesError) {
|
||||
fileMisses += 1;
|
||||
if (fileMisses >= 2 && result.filesErrorReason !== 'makerworld_token') { stoppedEarly = true; break; }
|
||||
} else if (!result.filesError) {
|
||||
fileMisses = 0;
|
||||
}
|
||||
// A refused token, or a check only you can solve, means every later
|
||||
// download fails too: stop and say so.
|
||||
if (['makerworld_token', 'makerworld_challenge'].includes(result.filesErrorReason)) {
|
||||
problem = httpError(result.filesError, 502, result.filesErrorReason);
|
||||
// A refused token means every later download fails too: stop and say so.
|
||||
if (result.filesErrorReason === 'makerworld_token') {
|
||||
problem = httpError(result.filesError, 502, 'makerworld_token');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
await recordItem(watch.id, design, null, 'error', err.message);
|
||||
if (['makerworld_token', 'makerworld_challenge'].includes(err.reason)) { problem = err; break; }
|
||||
if (err.reason === 'makerworld_token') { problem = err; break; }
|
||||
}
|
||||
await sleep(withFiles ? BETWEEN_DOWNLOADS_MS : BETWEEN_IMPORTS_MS);
|
||||
await sleep(watch.fetch_files ? BETWEEN_DOWNLOADS_MS : BETWEEN_IMPORTS_MS);
|
||||
}
|
||||
|
||||
const more = pending.length > todo.length || stoppedEarly;
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
// When a site blocks downloads until you prove you're not a robot.
|
||||
//
|
||||
// MakerWorld does this after a few downloads in a row. Only you can clear it —
|
||||
// by solving the check in your own browser — so Printhive stops asking until
|
||||
// you say you have. Attempts while blocked are wasted and may prolong it.
|
||||
//
|
||||
// Held in memory: a restart clears it, and the next attempt finds out again.
|
||||
const blocks = new Map(); // site → { since, message }
|
||||
|
||||
const DEFAULT = 'MakerWorld is asking you to confirm you’re not a robot before it will allow more downloads.';
|
||||
|
||||
function block(site, message = DEFAULT) {
|
||||
if (!blocks.has(site)) blocks.set(site, { since: new Date(), message });
|
||||
return blocks.get(site);
|
||||
}
|
||||
|
||||
// Called when you say you've solved it, and whenever a download succeeds.
|
||||
function clear(site) {
|
||||
return blocks.delete(site);
|
||||
}
|
||||
|
||||
const isBlocked = (site) => blocks.has(site);
|
||||
|
||||
function state(site) {
|
||||
const b = blocks.get(site);
|
||||
return b ? { blocked: true, since: b.since, message: b.message } : { blocked: false };
|
||||
}
|
||||
|
||||
module.exports = { block, clear, isBlocked, state, DEFAULT };
|
||||
|
|
@ -51,9 +51,7 @@ async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
|
|||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Keep a little of what came back: a site answering with a page instead of
|
||||
// JSON is usually asking the visitor to prove they're human.
|
||||
throw Object.assign(new Error('FlareSolverr did not return parseable JSON.'), { raw: text.slice(0, 300) });
|
||||
throw new Error('FlareSolverr did not return parseable JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,13 +31,10 @@ function parseInstances(html) {
|
|||
// couple of files in a row. It isn't Cloudflare — FlareSolverr reports no
|
||||
// challenge, and an unauthenticated call still gets a normal reply — so retrying
|
||||
// doesn't clear it; only asking for fewer files does.
|
||||
const CHALLENGE_RE = /not a robot|just a moment|captcha|attention required|cf[- ]challenge|verify (?:you|your|that)|verification (?:required|needed)|security check|select in this order/i;
|
||||
|
||||
const isChallengeText = (text) => CHALLENGE_RE.test(String(text || ''));
|
||||
|
||||
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 isChallengeText(answer.error || answer.message || answer.msg);
|
||||
return CHALLENGE_RE.test(String(answer.error || answer.message || answer.msg || ''));
|
||||
}
|
||||
|
||||
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
|
||||
|
|
@ -124,6 +121,6 @@ function parseLicense(html) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
parseInstances, resolveDownload, downloadProblem, isChallenge, isChallengeText, parseLicense,
|
||||
parseInstances, resolveDownload, downloadProblem, isChallenge, parseLicense,
|
||||
parseCollection, parseCollectionDesigns, collectionIdFromUrl, collectionDesignsUrl, designUrl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3145,7 +3145,6 @@ function watchPanelHtml() {
|
|||
const intervals = [1, 3, 6, 12, 24, 48]
|
||||
.map((h) => `<option value="${h}"${h === 6 ? ' selected' : ''}>${h === 1 ? 'every hour' : `every ${h} hours`}</option>`).join('');
|
||||
return `
|
||||
<div id="downloadBlock" class="ai-callout is-warning" hidden></div>
|
||||
<section class="ai-card">
|
||||
<div class="ai-card-head"><h3>Watch a MakerWorld collection</h3></div>
|
||||
<p class="form-hint">Printhive checks the collection and imports models added to it, into a collection of the same
|
||||
|
|
@ -3192,8 +3191,7 @@ function watchHtml(w) {
|
|||
</div>
|
||||
<div class="watch-meta">${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}</div>
|
||||
${w.last_error ? `<div class="ai-run-error">${escapeHtml(w.last_error)}
|
||||
${w.last_error_reason === 'makerworld_token' ? '<button type="button" class="btn btn-primary btn-sm" data-watch="token">Update token</button>' : ''}
|
||||
${w.last_error_reason === 'makerworld_challenge' ? '<a href="https://makerworld.com" target="_blank" rel="noopener">Open MakerWorld to solve it</a>' : ''}</div>` : ''}
|
||||
${w.last_error_reason === 'makerworld_token' ? '<button type="button" class="btn btn-primary btn-sm" data-watch="token">Update token</button>' : ''}</div>` : ''}
|
||||
${watchNotices.has(w.id) ? `<div class="ai-run-error">${escapeHtml(watchNotices.get(w.id))}</div>` : ''}
|
||||
${recent.length ? `<div class="watch-recent">${recent.map((i) => `
|
||||
<span>${i.model_id
|
||||
|
|
@ -3213,34 +3211,7 @@ async function renderWatchPanel() {
|
|||
fileGrid.className = 'ai-panel';
|
||||
resultCount.textContent = '';
|
||||
if (!document.getElementById('watchForm')) fileGrid.innerHTML = watchPanelHtml();
|
||||
await Promise.all([refreshWatches(), refreshDownloadBlock()]);
|
||||
}
|
||||
|
||||
// MakerWorld asks you to prove you're not a robot after a few downloads. Only
|
||||
// you can answer it, so Printhive stops downloading until you say you have.
|
||||
async function refreshDownloadBlock() {
|
||||
const box = document.getElementById('downloadBlock');
|
||||
if (!box) return;
|
||||
let state;
|
||||
try {
|
||||
state = await getJson('/api/downloads/block');
|
||||
} catch {
|
||||
return; // leave whatever's shown; the next refresh tries again
|
||||
}
|
||||
if (!state.blocked) { box.hidden = true; box.innerHTML = ''; return; }
|
||||
box.hidden = false;
|
||||
box.innerHTML = `<strong>MakerWorld has paused downloads.</strong>
|
||||
${escapeHtml(state.message)} Checks still bring in new models — their files wait for you.
|
||||
<a href="https://makerworld.com" target="_blank" rel="noopener">Open MakerWorld</a>, solve the check, then:
|
||||
<button type="button" class="btn btn-primary btn-sm" id="clearBlockBtn">I’ve solved it</button>`;
|
||||
document.getElementById('clearBlockBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await postJson('/api/downloads/block/clear');
|
||||
await refreshDownloadBlock();
|
||||
} catch (err) {
|
||||
box.innerHTML += `<span class="ai-run-error">${escapeHtml(err.message)}</span>`;
|
||||
}
|
||||
});
|
||||
await refreshWatches();
|
||||
}
|
||||
|
||||
async function refreshWatches() {
|
||||
|
|
@ -3261,7 +3232,6 @@ async function refreshWatches() {
|
|||
: '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>';
|
||||
// Keep the page current even between checks: one may start from the schedule,
|
||||
// another tab, or another browser. refreshWatches stops itself when you leave.
|
||||
refreshDownloadBlock();
|
||||
const checking = watches.some((w) => w.last_status === 'checking');
|
||||
watchPoll = setTimeout(refreshWatches, checking ? 5000 : 20000);
|
||||
}
|
||||
|
|
|
|||
28
server.js
28
server.js
|
|
@ -36,7 +36,6 @@ const spoolSync = require('./lib/spoolSync');
|
|||
|
||||
const printerLive = require('./lib/printerLive');
|
||||
const printerCamera = require('./lib/printerCamera');
|
||||
const downloadBlock = require('./lib/downloadBlock');
|
||||
|
||||
// What a printer has loaded → its card and the Filament page.
|
||||
async function syncTrays(printerId, trays) {
|
||||
|
|
@ -1172,12 +1171,6 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
|||
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
||||
} catch (err) {
|
||||
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
||||
// A page where JSON was expected is usually the "prove you're human" check.
|
||||
if (makerworld.isChallengeText(err.raw)) {
|
||||
problems.push({ message: downloadBlock.DEFAULT, challenged: true });
|
||||
downloadBlock.block('makerworld');
|
||||
break;
|
||||
}
|
||||
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1185,8 +1178,6 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
|||
if (problem) {
|
||||
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
|
||||
problems.push(problem);
|
||||
// Only you can clear this one, so stop rather than collect refusals.
|
||||
if (problem.challenged) { downloadBlock.block('makerworld', problem.message); break; }
|
||||
continue;
|
||||
}
|
||||
const original = dl.name || `instance-${inst.id}.3mf`;
|
||||
|
|
@ -1202,14 +1193,11 @@ async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
|||
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
||||
files.push(describeFile(filePath, fileName, original));
|
||||
downloadBlock.clear('makerworld'); // downloads are going through again
|
||||
}
|
||||
if (!files.length && problems.length) {
|
||||
const refused = problems.find((p) => p.tokenRejected);
|
||||
const challenged = problems.find((p) => p.challenged);
|
||||
const worst = refused || challenged || problems[0];
|
||||
throw Object.assign(new Error(worst.message),
|
||||
{ status: 502, reason: refused ? 'makerworld_token' : challenged ? 'makerworld_challenge' : undefined });
|
||||
throw Object.assign(new Error((refused || problems[0]).message),
|
||||
{ status: 502, reason: refused ? 'makerworld_token' : undefined });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
|
@ -1545,18 +1533,6 @@ async function fetchMakerworldCollection(url) {
|
|||
return { ...collection, designs: [...byId.values()] };
|
||||
}
|
||||
|
||||
// Whether a site is waiting for you to prove you're not a robot, and the
|
||||
// button that says you have. Clearing it only lets Printhive try again.
|
||||
app.get('/api/downloads/block', (req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(downloadBlock.state('makerworld'));
|
||||
});
|
||||
|
||||
app.post('/api/downloads/block/clear', (req, res) => {
|
||||
downloadBlock.clear('makerworld');
|
||||
res.json({ message: 'Printhive will try MakerWorld downloads again.' });
|
||||
});
|
||||
|
||||
app.get('/api/watches', async (req, res, next) => {
|
||||
try { res.json(await collectionWatch.list()); } catch (e) { next(e); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const block = require('../lib/downloadBlock');
|
||||
const makerworld = require('../lib/importers/makerworld');
|
||||
|
||||
test('a blocked site stays blocked until it’s cleared', () => {
|
||||
assert.equal(block.isBlocked('makerworld'), false);
|
||||
block.block('makerworld', 'Prove you’re not a robot.');
|
||||
assert.equal(block.isBlocked('makerworld'), true);
|
||||
const state = block.state('makerworld');
|
||||
assert.equal(state.blocked, true);
|
||||
assert.equal(state.message, 'Prove you’re not a robot.');
|
||||
assert.ok(state.since instanceof Date);
|
||||
// Blocking again keeps the original message and time.
|
||||
const again = block.block('makerworld', 'something else');
|
||||
assert.equal(again.message, 'Prove you’re not a robot.');
|
||||
block.clear('makerworld');
|
||||
assert.deepEqual(block.state('makerworld'), { blocked: false });
|
||||
});
|
||||
|
||||
test('MakerWorld’s anti-bot answers are recognised, ordinary refusals aren’t', () => {
|
||||
for (const text of ['Please verify you are not a robot', 'Just a moment…', 'Select in this order',
|
||||
'Security check required', 'Verification required']) {
|
||||
assert.equal(makerworld.isChallengeText(text), true, text);
|
||||
}
|
||||
for (const text of ['Please log in to download models.', 'Model not found', '']) {
|
||||
assert.equal(makerworld.isChallengeText(text), false, text);
|
||||
}
|
||||
// An answer carrying a link is never a challenge.
|
||||
assert.equal(makerworld.isChallenge({ url: 'https://cdn/x.3mf', message: 'captcha' }), false);
|
||||
const problem = makerworld.downloadProblem({ error: 'Please verify you are not a robot' });
|
||||
assert.equal(problem.challenged, true);
|
||||
assert.equal(makerworld.downloadProblem({ error: 'Please log in to download models.' }).tokenRejected, true);
|
||||
});
|
||||
Loading…
Reference in a new issue