// Generic page-metadata importer for sites without a usable download API // (Printables, MakerWorld, or any URL). Reads Open Graph / 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(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'") .replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /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 };