printhive/lib/importers/generic.js
dlawler489 a37db15a3f Initial commit: Printhive 3D-print file library
Self-hosted Express + Postgres app for organizing STL/3MF/OBJ/etc. with:
- Model/file library, tags, collections, print settings
- URL import: Thingiverse (API), Printables (GraphQL), MakerWorld
  (FlareSolverr + token), cubee3d/other (metadata + manual files)
- In-browser 3D viewer; 3MF rendered via Python/trimesh GLB conversion
- Auto thumbnails, license/commercial-use tracking, in-app editing
- Settings (API tokens / FlareSolverr) stored in DB with .env fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:15:17 +10:00

78 lines
3.5 KiB
JavaScript

// Generic page-metadata importer for sites without a usable download API
// (Printables, MakerWorld, or any URL). Reads Open Graph / <title> tags so we
// can create a model with name, description, and a preview image; the actual
// model files are attached by the user afterwards.
function detectSite(url) {
let host;
try {
const parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol)) return null;
host = parsed.hostname.replace(/^www\./, '');
} catch {
return null;
}
if (host.includes('thingiverse.com')) return { site: 'thingiverse' };
if (host.includes('printables.com')) return { site: 'printables', id: (url.match(/\/model\/(\d+)/) || [])[1] || null };
if (host.includes('makerworld.com')) return { site: 'makerworld', id: (url.match(/\/models\/(\d+)/) || [])[1] || null };
if (host.includes('cubee3d.com')) {
let slug = null;
try { slug = new URL(url).pathname.split('/').filter(Boolean).pop() || null; } catch { /* ignore */ }
return { site: 'cubee3d', id: slug };
}
return { site: 'web', id: null };
}
function decodeEntities(str) {
return String(str)
.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ');
}
function metaContent(html, attr, value) {
// Matches <meta {attr}="{value}" ... content="..."> in either attribute order.
const a = html.match(new RegExp(`<meta[^>]+${attr}=["']${value}["'][^>]+content=["']([^"']*)["']`, 'i'));
if (a) return decodeEntities(a[1]);
const b = html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${value}["']`, 'i'));
return b ? decodeEntities(b[1]) : null;
}
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36';
// A page is a Cloudflare interstitial (not the real content) when it looks like this.
function isCloudflareChallenge(html) {
return /just a moment|cf-browser-verification|challenge-platform|cf_chl_opt/i.test(html || '');
}
async function fetchHtml(url) {
const res = await fetch(url, { headers: { 'User-Agent': BROWSER_UA, Accept: 'text/html' } });
const html = await res.text();
if (!res.ok && !html) throw new Error(`Could not fetch page (HTTP ${res.status}).`);
return { ok: res.ok, status: res.status, html };
}
// Extract Open Graph / title metadata from raw HTML.
function parseMetadata(html, url) {
const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1];
return {
title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null),
description: metaContent(html, 'property', 'og:description') || metaContent(html, 'name', 'description'),
image: metaContent(html, 'property', 'og:image'),
author: metaContent(html, 'name', 'author') || metaContent(html, 'property', 'og:site_name'),
};
}
// Best-effort human name from a URL slug, e.g.
// /models/98765-cool-widget -> "Cool widget". Used when a page can't be read.
function nameFromUrl(url) {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
const words = decodeURIComponent(seg).replace(/^\d+-?/, '').replace(/[-_]+/g, ' ').trim();
if (!words) return new URL(url).hostname.replace(/^www\./, '');
return words.charAt(0).toUpperCase() + words.slice(1);
} catch {
return url;
}
}
module.exports = { detectSite, fetchHtml, parseMetadata, isCloudflareChallenge, nameFromUrl };