feat: MakerWorld komplett auf Bookmarklet-Ansatz umgestellt (kein Puppeteer/Login mehr)

This commit is contained in:
2026-07-11 09:28:15 +02:00
parent 9f1dd4e3b8
commit 142eda070c
4 changed files with 154 additions and 674 deletions

View File

@@ -10,8 +10,6 @@
"multer": "^1.4.5-lts.1",
"ws": "^8.17.1",
"puppeteer-core": "^22.0.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"mqtt": "^5.10.1"
}
}

View File

@@ -613,13 +613,24 @@ db.exec(`
)
`);
// MakerWorld-Zugangsdaten — pro Nutzer eigener Account (1:1), Passwort verschlüsselt
// MakerWorld: Empfangs-Token pro Nutzer für den Bookmarklet-Ansatz (kein
// Login/Cookie-Handling mehr nötig — der Nutzer schickt seine eigenen, schon
// im Browser geladenen Daten per Klick auf ein Lesezeichen an DickenDock)
db.exec(`
CREATE TABLE IF NOT EXISTS makerworld_accounts (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
username TEXT NOT NULL,
password_enc TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Zuletzt per Bookmarklet empfangene Statistik — persistiert, damit ein
// Container-Neustart die zuletzt bekannten Zahlen nicht verwirft
db.exec(`
CREATE TABLE IF NOT EXISTS makerworld_stats (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
`);

View File

@@ -1,84 +1,55 @@
const express = require('express');
const fs = require('fs');
const crypto = require('crypto');
const db = require('../../db');
const { encrypt, decrypt } = require('../../crypto-helper');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── MakerWorld Creator Center Statistik — pro Nutzer eigener Account ────────
// Jeder Nutzer hinterlegt seine EIGENEN MakerWorld-Zugangsdaten und sieht nur
// seine eigenen Creator-Center-Zahlen. Dafür bekommt jeder Nutzer ein eigenes,
// persistentes Chromium-Profil (eigene Cookies/Session, komplett getrennt von
// den anderen Nutzern), das unter /data liegt und Container-Neustarts übersteht.
// ── MakerWorld Creator Center Statistik — Bookmarklet-Ansatz ─────────────────
// Nach mehreren gescheiterten Versuchen (Cloudflare blockt sowohl reinen
// fetch() als auch stealth-gepatchtes Puppeteer-Chromium, vermutlich per
// TLS-Fingerprinting) gibt es hier bewusst KEINE Server-seitige Automatisierung
// mehr. Stattdessen: der Nutzer öffnet die Statistikseite ganz normal in seinem
// eigenen, echten, eingeloggten Browser und klickt auf ein Lesezeichen
// ("Bookmarklet"). Das liest den bereits im Browser geladenen __NEXT_DATA__-
// Block direkt aus der Seite (kein Netzwerk-Request an makerworld.com nötig,
// keine Cloudflare-Prüfung möglich, da es der echte Browser des Nutzers ist)
// und schickt nur die Statistikdaten an diesen Endpunkt.
//
// Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares
// JS-Challenge (cf_clearance) selbstständig — kein manuelles Cookie-Kopieren
// im Normalfall. Nur wenn die eigentliche Login-Session abläuft und MakerWorld
// einen E-Mail-Verifizierungscode verlangt, pausiert der Vorgang und wartet auf
// Eingabe des Codes über die App (POST /verify-code).
//
// HINWEIS: Die Login-Formular-Selektoren sind best-effort (Bambu Lab nutzt
// einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup nicht
// vorab bekannt ist). Schlägt der automatische Login fehl, gibt es eine klare
// Fehlermeldung statt eines stillen Hängers — dann bitte einen HAR-Export
// des echten Login-Vorgangs schicken, um die Selektoren zu fixen.
// Authentifizierung: das Bookmarklet läuft im Kontext von makerworld.com
// (fremde Origin) und hat daher keinen Zugriff auf den DickenDock-Login-Token.
// Stattdessen bekommt jeder Nutzer ein eigenes, zufälliges Ingest-Token, das
// fest im Bookmarklet-Code steht — das ersetzt hier die reguläre Anmeldung.
const DATA_URL = 'https://makerworld.com/de/my/data-overview/model';
const PROFILE_BASE_DIR = '/data/makerworld-profiles'; // + /{userId}
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 Minuten zwischen automatischen Neuabrufen
const LOGIN_CODE_TIMEOUT_MS = 5 * 60 * 1000; // 5 Minuten Zeit, den E-Mail-Code einzugeben
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const cacheByUser = new Map(); // userId -> { data, fetchedAt, error }
const pendingLoginByUser = new Map(); // userId -> { browser, page, codeField, startedAt }
const queueByUser = new Map(); // userId -> Promise (Serialisierung je Nutzer)
const activeBrowserByUser = new Map(); // userId -> zuletzt gestarteter Browser (für Force-Kill bei Timeout)
const cacheByUser = new Map(); // userId -> { data, fetchedAt } (Lazy-Load aus DB)
function getCache(userId) {
if (!cacheByUser.has(userId)) cacheByUser.set(userId, { data: null, fetchedAt: 0, error: null });
if (!cacheByUser.has(userId)) {
const row = db.prepare('SELECT data_json, fetched_at FROM makerworld_stats WHERE user_id=?').get(userId);
cacheByUser.set(userId, row
? { data: JSON.parse(row.data_json), fetchedAt: row.fetched_at }
: { data: null, fetchedAt: 0 });
}
return cacheByUser.get(userId);
}
// Jeder Nutzer bekommt seine eigene Operations-Queue — verschiedene Nutzer
// können parallel laufen, aber nie zwei Vorgänge gleichzeitig auf demselben
// Profil (Chromium sperrt das Profilverzeichnis sonst gegenseitig aus)
function serialized(userId, fn) {
const prev = queueByUser.get(userId) || Promise.resolve();
const run = prev.then(fn, fn);
queueByUser.set(userId, run.catch(() => {}));
return run;
}
// ── Zugangsdaten (pro Nutzer) ─────────────────────────────────────────────────
function getCredentials(userId) {
const row = db.prepare('SELECT username, password_enc FROM makerworld_accounts WHERE user_id=?').get(userId);
if (!row) return null;
const password = decrypt(row.password_enc);
if (!password) return null;
return { username: row.username, password };
}
function saveCredentials(userId, username, password) {
function persistCache(userId, data, fetchedAt) {
db.prepare(`
INSERT OR REPLACE INTO makerworld_accounts (user_id, username, password_enc)
INSERT OR REPLACE INTO makerworld_stats (user_id, data_json, fetched_at)
VALUES (?, ?, ?)
`).run(userId, username, encrypt(password));
`).run(userId, JSON.stringify(data), fetchedAt);
}
// ── __NEXT_DATA__ Extraktion (über Tag-Grenzen, kein Regex auf den JSON-Inhalt) ─
function extractNextData(html) {
const marker = '<script id="__NEXT_DATA__"';
const markerIdx = html.indexOf(marker);
if (markerIdx === -1) return null;
const tagEnd = html.indexOf('>', markerIdx) + 1;
if (tagEnd === 0) return null;
const scriptEnd = html.indexOf('</script>', tagEnd);
if (scriptEnd === -1) return null;
try { return JSON.parse(html.slice(tagEnd, scriptEnd)); }
catch { return null; }
function getOrCreateToken(userId) {
let row = db.prepare('SELECT token FROM makerworld_ingest_tokens WHERE user_id=?').get(userId);
if (!row) {
const token = crypto.randomBytes(24).toString('hex');
db.prepare('INSERT INTO makerworld_ingest_tokens (user_id, token) VALUES (?, ?)').run(userId, token);
row = { token };
}
return row.token;
}
function mapStatisticalData(parsed) {
const statisticalData = parsed?.props?.pageProps?.statisticalData;
// Formt den vom Bookmarklet gesendeten statisticalData-Ausschnitt in unser Format
function mapStatisticalData(statisticalData) {
if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
const s = statisticalData.summary;
return {
@@ -110,427 +81,57 @@ function mapStatisticalData(parsed) {
};
}
// Diagnose/Fallback: Abruf OHNE Browser, nur reiner HTTP-fetch() mit dem
// übergebenen Cookie-String. Testet, ob ein reiner Cookie-Replay reicht, oder
// ob Cloudflare zusätzlich am TLS-Fingerprint der Verbindung erkennt, dass es
// kein "echter" Browser ist (dann hilft auch kein Puppeteer-Cookie-Inject).
async function tryRawFetch(userId, cookieString) {
try {
const res = await fetch(DATA_URL, {
headers: {
'Cookie': cookieString,
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'de,en-US;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate',
},
});
console.log(`[MakerWorld:${userId}] Raw-Fetch-Test: HTTP ${res.status}`);
if (!res.ok) return null;
const html = await res.text();
const parsed = extractNextData(html);
console.log(`[MakerWorld:${userId}] Raw-Fetch-Test: __NEXT_DATA__ ${parsed ? 'gefunden ✓' : 'nicht gefunden (vermutlich Just-a-moment-Seite)'}`);
return parsed;
} catch (e) {
console.log(`[MakerWorld:${userId}] Raw-Fetch-Test fehlgeschlagen: ${e.message}`);
return null;
}
}
// ── Routes ────────────────────────────────────────────────────────────────────
// ── Browser-Helfer ────────────────────────────────────────────────────────────
// Nach einem abgebrochenen Vorgang (Container-Neustart mitten im Betrieb,
// Absturz o.ä.) kann Chromium veraltete Sperrdateien im Profil hinterlassen —
// dann hängt der nächste Start ewig, statt einen Fehler zu werfen. Vor jedem
// Start räumen wir das defensiv weg.
function cleanupStaleLocks(profileDir) {
for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
try { fs.rmSync(`${profileDir}/${name}`, { force: true }); } catch {}
}
}
async function launchBrowser(userId) {
let puppeteer;
try {
// puppeteer-extra + Stealth-Plugin patcht die üblichen Merkmale, an denen
// Cloudflare & Co. headless Chromium erkennen (navigator.webdriver, fehlende
// Plugins/MimeTypes, iframe-contentWindow-Check, ...). Ohne das blieb die
// Seite bei "Just a moment..." hängen, auch nach langem Warten.
const { addExtra } = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const puppeteerCore = require('puppeteer-core');
puppeteer = addExtra(puppeteerCore);
puppeteer.use(StealthPlugin());
} catch {
throw new Error('puppeteer-core / puppeteer-extra nicht installiert');
}
const profileDir = `${PROFILE_BASE_DIR}/${userId}`;
fs.mkdirSync(profileDir, { recursive: true });
cleanupStaleLocks(profileDir);
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
userDataDir: profileDir,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
headless: true,
});
activeBrowserByUser.set(userId, browser);
return browser;
}
function forceKillBrowser(userId) {
const b = activeBrowserByUser.get(userId);
if (!b) return;
try { b.process()?.kill('SIGKILL'); } catch {}
}
// Harte Zeitgrenze um einen kompletten Browser-Vorgang — falls irgendwo (Launch,
// Navigation, Screenshot, Close) etwas hängt, wird der Prozess hart beendet statt
// die komplette Warteschlange dieses Nutzers für immer zu blockieren.
function withHardTimeout(promise, ms, onTimeout) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(async () => {
try { await onTimeout?.(); } catch {}
reject(new Error(`Zeitüberschreitung nach ${Math.round(ms / 1000)}s — Vorgang wurde abgebrochen`));
}, ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function getNextDataFromPage(page) {
const html = await page.content();
return extractNextData(html);
}
async function isCloudflareChallenge(page) {
const title = await page.title().catch(() => '');
return /just a moment|checking your browser|attention required|einen moment|überprüfung|einen augenblick|verifizierung ihres browsers/i.test(title);
}
// Speichert einen Screenshot + die aktuelle URL/Titel zur Fehlerdiagnose
async function saveDebugScreenshot(userId, page) {
const dir = `${PROFILE_BASE_DIR}/${userId}`;
fs.mkdirSync(dir, { recursive: true });
await page.screenshot({ path: `${dir}/last-failure.png`, fullPage: false });
fs.writeFileSync(`${dir}/last-failure.json`, JSON.stringify({
url: page.url(), title: await page.title().catch(() => '?'), at: new Date().toISOString(),
}));
}
function isLoginUrl(url) {
return /bambulab\.com\/[^/]*\/?sign-in|makerworld\.com\/[a-z]{2}\/sign-in/i.test(url || '');
}
async function findFirstSelector(page, selectors) {
for (const sel of selectors) {
const el = await page.$(sel).catch(() => null);
if (el) return sel;
}
return null;
}
// Best-effort Login-Formular ausfüllen. Wirft einen Error mit .needsCode=true,
// falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — Browser/
// Page bleiben dann bewusst OFFEN in pendingLoginByUser.
async function attemptLogin(userId, browser, page) {
const creds = getCredentials(userId);
if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten hinterlegt');
const emailField = await findFirstSelector(page, [
'input[type="email"]', 'input[name="account"]', 'input[name="username"]', 'input[type="text"]',
]);
const pwField = await findFirstSelector(page, ['input[type="password"]']);
if (!emailField || !pwField) {
await saveDebugScreenshot(userId, page).catch(() => {});
throw new Error(`Login-Formular nicht gefunden (Selektoren evtl. veraltet) — Seite: "${await page.title().catch(()=>'?')}" (${page.url()})`);
}
await page.type(emailField, creds.username, { delay: 25 });
await page.type(pwField, creds.password, { delay: 25 });
const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
await new Promise(r => setTimeout(r, 3000));
let parsed = await getNextDataFromPage(page);
if (parsed) return parsed;
if (!page.url().includes('data-overview')) {
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
parsed = await getNextDataFromPage(page);
if (parsed) return parsed;
}
const codeField = await findFirstSelector(page, [
'input[name="code"]', 'input[autocomplete="one-time-code"]', 'input[type="tel"]',
]);
if (codeField) {
const startedAt = Date.now();
pendingLoginByUser.set(userId, { browser, page, codeField, startedAt });
setTimeout(() => {
const p = pendingLoginByUser.get(userId);
if (p && p.startedAt === startedAt) {
p.browser.close().catch(() => {});
pendingLoginByUser.delete(userId);
}
}, LOGIN_CODE_TIMEOUT_MS);
const err = new Error('Verifizierungscode per E-Mail erforderlich');
err.needsCode = true;
throw err;
}
await saveDebugScreenshot(userId, page).catch(() => {});
throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout');
}
async function fetchStatsViaBrowserInner(userId, forceRefresh) {
const cache = getCache(userId);
if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) {
console.log(`[MakerWorld:${userId}] Cache-Treffer, kein Neuabruf (forceRefresh=false)`);
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
}
if (pendingLoginByUser.has(userId)) {
console.log(`[MakerWorld:${userId}] Noch ein offener Login-Vorgang (wartet auf Code) — Abruf übersprungen`);
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
}
let browser;
let page;
try {
console.log(`[MakerWorld:${userId}] Abruf gestartet (forceRefresh=${forceRefresh})`);
browser = await launchBrowser(userId);
console.log(`[MakerWorld:${userId}] Browser gestartet, öffne Seite`);
page = await browser.newPage();
await page.setUserAgent(USER_AGENT);
await page.setViewport({ width: 1366, height: 900 });
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 45000 });
console.log(`[MakerWorld:${userId}] Seite geladen: "${await page.title().catch(()=>'?')}" (${page.url()})`);
// Bis zu 20 Sekunden Zeit geben, falls Cloudflare eine automatische
// Managed-Challenge zeigt (löst sich bei echtem Chromium meist selbst)
let cfWaited = 0;
for (let i = 0; i < 5 && await isCloudflareChallenge(page); i++) {
cfWaited += 4;
console.log(`[MakerWorld:${userId}] Cloudflare-Challenge erkannt, warte weiter (${cfWaited}s)...`);
await new Promise(r => setTimeout(r, 4000));
}
let parsed = await getNextDataFromPage(page);
console.log(`[MakerWorld:${userId}] __NEXT_DATA__ ${parsed ? 'gefunden' : 'NICHT gefunden'}`);
if (!parsed && isLoginUrl(page.url())) {
console.log(`[MakerWorld:${userId}] Login-Seite erkannt (${page.url()}), versuche automatischen Login`);
parsed = await attemptLogin(userId, browser, page); // wirft ggf. needsCode-Error
}
if (!parsed) {
const debugTitle = await page.title().catch(() => '?');
const debugUrl = page.url();
console.log(`[MakerWorld:${userId}] Fehlschlag — Seite: "${debugTitle}" (${debugUrl})`);
await saveDebugScreenshot(userId, page).catch(() => {});
throw new Error(`__NEXT_DATA__ nicht gefunden — Seite: "${debugTitle}" (${debugUrl}). Debug-Screenshot verfügbar über GET /debug-screenshot`);
}
const data = mapStatisticalData(parsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
await browser.close();
console.log(`[MakerWorld:${userId}] Erfolgreich: ${data.downloads} Downloads, ${data.likes} Likes`);
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
} catch (e) {
if (e.needsCode) {
console.log(`[MakerWorld:${userId}] Code-Eingabe erforderlich, Browser bleibt offen`);
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
}
console.error(`[MakerWorld:${userId}] Fehler: ${e.message}`);
if (browser) await browser.close().catch(() => {});
cache.error = e.message;
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
}
}
async function submitLoginCodeInner(userId, code) {
const cache = getCache(userId);
const pending = pendingLoginByUser.get(userId);
if (!pending) {
return { data: cache.data, error: 'Kein offener Login-Vorgang', fetchedAt: cache.fetchedAt, needsCode: false };
}
const { browser, page, codeField } = pending;
try {
await page.type(codeField, code, { delay: 25 });
const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
await new Promise(r => setTimeout(r, 3000));
let parsed = await getNextDataFromPage(page);
if (!parsed && !page.url().includes('data-overview')) {
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
parsed = await getNextDataFromPage(page);
}
if (parsed) {
const data = mapStatisticalData(parsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
pendingLoginByUser.delete(userId);
await browser.close();
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
}
// Code vermutlich falsch — Vorgang bleibt offen für einen weiteren Versuch
return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true };
} catch (e) {
await browser.close().catch(() => {});
pendingLoginByUser.delete(userId);
cache.error = e.message;
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
}
}
async function injectCookiesInner(userId, cookieString) {
console.log(`[MakerWorld:${userId}] Cookie-Injection gestartet (${cookieString.length} Zeichen)`);
const cookies = cookieString.split(';').map(s => s.trim()).filter(Boolean).map(p => {
const idx = p.indexOf('=');
return { name: p.slice(0, idx), value: p.slice(idx + 1), domain: '.makerworld.com', path: '/' };
});
console.log(`[MakerWorld:${userId}] ${cookies.length} Cookies geparst: ${cookies.map(c=>c.name).join(', ')}`);
const browser = await launchBrowser(userId);
console.log(`[MakerWorld:${userId}] Browser für Cookie-Injection gestartet`);
try {
const page = await browser.newPage();
await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch((e) => {
console.log(`[MakerWorld:${userId}] Navigation vor Cookie-Set fehlgeschlagen (ignoriert): ${e.message}`);
});
await page.setCookie(...cookies);
console.log(`[MakerWorld:${userId}] Cookies gesetzt, schließe Browser`);
} finally {
await browser.close();
}
}
const fetchStatsViaBrowser = (userId, forceRefresh) =>
serialized(userId, () => withHardTimeout(fetchStatsViaBrowserInner(userId, forceRefresh), 60000, () => forceKillBrowser(userId)));
const submitLoginCode = (userId, code) =>
serialized(userId, () => withHardTimeout(submitLoginCodeInner(userId, code), 60000, () => forceKillBrowser(userId)));
const injectCookies = (userId, cookieString) =>
serialized(userId, () => withHardTimeout(injectCookiesInner(userId, cookieString), 45000, () => forceKillBrowser(userId)));
// ── Routes (alle nur "authenticate" — jeder verwaltet ausschließlich sich selbst) ──
router.get('/stats', authenticate, async (req, res) => {
const userId = req.user.id;
const cache = getCache(userId);
const configured = !!getCredentials(userId) || !!cache.fetchedAt;
if (!configured) {
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
}
try {
const result = await fetchStatsViaBrowser(userId, false);
res.json({ configured: true, ...result });
} catch (e) {
cache.error = e.message;
res.json({ configured: true, data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
// GET /stats eigene, zuletzt per Bookmarklet empfangene Daten
router.get('/stats', authenticate, (req, res) => {
const cache = getCache(req.user.id);
res.json({ configured: !!cache.fetchedAt, data: cache.data, fetchedAt: cache.fetchedAt || null });
});
router.post('/refresh', authenticate, async (req, res) => {
const userId = req.user.id;
const cache = getCache(userId);
// GET /ingest-token eigenes (ggf. neu erzeugtes) Token + Server-Origin, für den Bookmarklet-Code
router.get('/ingest-token', authenticate, (req, res) => {
const token = getOrCreateToken(req.user.id);
res.json({ token, origin: `${req.protocol}://${req.get('host')}` });
});
// POST /ingest-token/regenerate altes Token ungültig machen, neues erzeugen
// (z.B. falls der Bookmarklet-Code mal versehentlich geteilt wurde)
router.post('/ingest-token/regenerate', authenticate, (req, res) => {
const token = crypto.randomBytes(24).toString('hex');
db.prepare('INSERT OR REPLACE INTO makerworld_ingest_tokens (user_id, token) VALUES (?, ?)').run(req.user.id, token);
res.json({ token, origin: `${req.protocol}://${req.get('host')}` });
});
// OPTIONS/POST /ingest vom Bookmarklet aus makerworld.com aufgerufen (fremde
// Origin!) — daher bewusst OHNE "authenticate"-Middleware, das Ingest-Token im
// Body übernimmt die Rolle der Authentifizierung. CORS wird hier gezielt nur
// für diese eine Route geöffnet, nicht global für die App.
router.options('/ingest', (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.sendStatus(204);
});
router.post('/ingest', (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
const { token, statisticalData } = req.body || {};
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'Token fehlt' });
const row = db.prepare('SELECT user_id FROM makerworld_ingest_tokens WHERE token=?').get(token);
if (!row) return res.status(403).json({ error: 'Ungültiges Token' });
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result);
const data = mapStatisticalData(statisticalData);
const fetchedAt = Date.now();
const cache = getCache(row.user_id);
cache.data = data; cache.fetchedAt = fetchedAt;
persistCache(row.user_id, data, fetchedAt);
console.log(`[MakerWorld:${row.user_id}] Daten per Bookmarklet empfangen: ${data.downloads} Downloads, ${data.likes} Likes`);
res.json({ ok: true });
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
router.post('/verify-code', authenticate, async (req, res) => {
const { code } = req.body;
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
const userId = req.user.id;
const cache = getCache(userId);
try {
const result = await submitLoginCode(userId, code.trim());
res.json(result);
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
router.get('/login-status', authenticate, (req, res) => {
const userId = req.user.id;
const cache = getCache(userId);
const creds = getCredentials(userId);
const debugPath = `${PROFILE_BASE_DIR}/${userId}/last-failure.json`;
let debugInfo = null;
try { debugInfo = JSON.parse(fs.readFileSync(debugPath, 'utf8')); } catch {}
res.json({
configured: !!creds,
username: creds?.username || null,
lastFetchedAt: cache.fetchedAt || null,
lastError: cache.error || null,
needsCode: pendingLoginByUser.has(userId),
debugScreenshot: debugInfo,
});
});
// Letzter Fehl-Screenshot (Diagnose) — nur der eigene, als data:-URI (CSP erlaubt
// kein blob:/externes Laden, data: URIs sind bereits über img-src freigegeben)
router.get('/debug-screenshot', authenticate, (req, res) => {
const path = `${PROFILE_BASE_DIR}/${req.user.id}/last-failure.png`;
if (!fs.existsSync(path)) return res.status(404).json({ error: 'Kein Debug-Screenshot vorhanden' });
const base64 = fs.readFileSync(path).toString('base64');
res.json({ image: `data:image/png;base64,${base64}` });
});
router.post('/credentials', authenticate, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
const userId = req.user.id;
saveCredentials(userId, username.trim(), password);
const cache = getCache(userId);
try {
const result = await fetchStatsViaBrowser(userId, true);
res.json(result);
} catch (e) {
cache.error = e.message;
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
}
});
// Eigene Zugangsdaten entfernen (Profil/Cookies bleiben unangetastet liegen)
router.delete('/credentials', authenticate, (req, res) => {
db.prepare('DELETE FROM makerworld_accounts WHERE user_id=?').run(req.user.id);
res.json({ ok: true });
});
// Manueller Fallback: Cookie-String direkt ins eigene Profil einspielen
router.post('/cookie', authenticate, async (req, res) => {
const { cookie } = req.body;
const userId = req.user.id;
console.log(`[MakerWorld:${userId}] POST /cookie empfangen (${cookie?.length || 0} Zeichen)`);
if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) {
return res.status(400).json({ error: 'Ungültiger Cookie-String' });
}
const cache = getCache(userId);
// Erst den günstigen Weg testen: reiner HTTP-fetch() ohne Browser
const rawParsed = await tryRawFetch(userId, cookie.trim());
if (rawParsed) {
try {
const data = mapStatisticalData(rawParsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
console.log(`[MakerWorld:${userId}] Raw-Fetch erfolgreich, Puppeteer nicht nötig`);
return res.json({ data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false, method: 'raw-fetch' });
} catch (e) {
console.log(`[MakerWorld:${userId}] Raw-Fetch lieferte Seite, aber Mapping fehlgeschlagen: ${e.message}`);
}
}
// Fallback: über Puppeteer ins persistente Profil einspielen
try {
await injectCookies(userId, cookie.trim());
const result = await fetchStatsViaBrowser(userId, true);
console.log(`[MakerWorld:${userId}] POST /cookie fertig (Puppeteer-Fallback): needsCode=${result.needsCode}, error=${result.error||'-'}`);
res.json({ ...result, method: 'puppeteer' });
} catch (e) {
console.error(`[MakerWorld:${userId}] POST /cookie Exception: ${e.message}`);
res.status(500).json({ error: e.message });
res.status(400).json({ error: e.message });
}
});

View File

@@ -199,114 +199,38 @@ function OrdersInPeriod({ fromDate, toDate }) {
// MakerWorld Creator-Center Statistik
function MakerworldTab({ toast }) {
const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode }
const [state, setState] = useState(null); // { configured, data, fetchedAt }
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [tokenInfo, setTokenInfo] = useState(null); // { token, origin }
const [regenerating, setRegenerating] = useState(false);
const [showModels, setShowModels] = useState(false);
const [loginStatus, setLoginStatus] = useState(null);
const [credForm, setCredForm] = useState({ username:'', password:'' });
const [savingCreds, setSavingCreds] = useState(false);
const [codeInput, setCodeInput] = useState('');
const [submittingCode, setSubmittingCode] = useState(false);
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const [showCookieFallback, setShowCookieFallback] = useState(false);
const [debugImgUrl, setDebugImgUrl] = useState(null);
const [loadingScreenshot, setLoadingScreenshot] = useState(false);
const load = () => {
setLoading(true);
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
};
const loadLoginStatus = () => {
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
const loadToken = () => {
api('/tools/makerworld/ingest-token').then(setTokenInfo).catch(()=>{});
};
useEffect(() => { load(); loadLoginStatus(); }, []);
useEffect(() => { load(); loadToken(); }, []);
// Solange auf einen Code gewartet wird, alle paar Sekunden pollen
useEffect(() => {
if (!state?.needsCode) return;
const iv = setInterval(() => { load(); loadLoginStatus(); }, 5000);
return () => clearInterval(iv);
}, [state?.needsCode]);
const refresh = async () => {
setRefreshing(true);
const regenerateToken = async () => {
if (!window.confirm('Neues Bookmarklet erzeugen? Das alte funktioniert danach nicht mehr — du musst es neu ins Lesezeichen legen.')) return;
setRegenerating(true);
try {
const r = await api('/tools/makerworld/refresh', { method:'POST' });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.('MakerWorld verlangt einen E-Mail-Verifizierungscode');
else if (r.error) toast?.(r.error,'error');
else toast?.('Aktualisiert');
const r = await api('/tools/makerworld/ingest-token/regenerate', { method:'POST' });
setTokenInfo(r);
toast?.('Neues Bookmarklet erzeugt — bitte neu einrichten');
} catch(e) { toast?.(e.message,'error'); }
finally { setRefreshing(false); }
finally { setRegenerating(false); }
};
const saveCredentials = async () => {
if (!credForm.username.trim() || !credForm.password) return;
setSavingCreds(true);
try {
const r = await api('/tools/makerworld/credentials', { body:credForm });
setCredForm({ username:'', password:'' });
setState(p => ({ ...p, ...r, configured:true }));
loadLoginStatus();
if (r.needsCode) toast?.('Zugangsdaten gespeichert — E-Mail-Code erforderlich');
else if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Zugangsdaten gespeichert, Abruf erfolgreich');
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCreds(false); }
};
const bookmarkletCode = tokenInfo ? `javascript:(function(){try{var el=document.getElementById('__NEXT_DATA__');if(!el){alert('MakerWorld: __NEXT_DATA__ nicht gefunden. Bist du auf der Statistikseite (/my/data-overview/model)?');return;}var nd=JSON.parse(el.textContent);var sd=nd.props&&nd.props.pageProps&&nd.props.pageProps.statisticalData;if(!sd||!sd.summary){alert('MakerWorld: Keine Statistikdaten auf dieser Seite gefunden.');return;}fetch('${tokenInfo.origin}/api/tools/makerworld/ingest',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${tokenInfo.token}',statisticalData:sd})}).then(function(r){return r.json();}).then(function(j){alert(j.ok?'DickenDock aktualisiert ✓':('Fehler: '+(j.error||'unbekannt')));}).catch(function(e){alert('Fehler: '+e.message);});}catch(e){alert('Fehler: '+e.message);}})();` : '';
const removeCredentials = async () => {
try {
await api('/tools/makerworld/credentials', { method:'DELETE' });
toast?.('Zugangsdaten entfernt');
setState({ configured:false, data:null, error:null, fetchedAt:null, needsCode:false });
loadLoginStatus();
} catch(e) { toast?.(e.message,'error'); }
};
const viewDebugScreenshot = async () => {
setLoadingScreenshot(true);
try {
const r = await api('/tools/makerworld/debug-screenshot');
if (!r.image) { toast?.('Kein Debug-Screenshot vorhanden','error'); return; }
setDebugImgUrl(r.image); // bereits eine data:-URI, CSP-konform (kein blob:)
} catch(e) { toast?.(e.message,'error'); }
finally { setLoadingScreenshot(false); }
};
const submitCode = async () => {
if (!codeInput.trim()) return;
setSubmittingCode(true);
try {
const r = await api('/tools/makerworld/verify-code', { body:{ code:codeInput.trim() } });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.(r.error || 'Code nicht akzeptiert, nochmal versuchen','error');
else if (r.error) toast?.(r.error,'error');
else { toast?.('Login erfolgreich'); setCodeInput(''); }
} catch(e) { toast?.(e.message,'error'); }
finally { setSubmittingCode(false); }
};
const saveCookie = async () => {
if (!cookieInput.trim()) return;
setSavingCookie(true);
try {
const r = await api('/tools/makerworld/cookie', { body:{ cookie: cookieInput } });
setCookieInput('');
setState(p => ({ ...p, ...r, configured:true }));
if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Cookie übernommen, Abruf erfolgreich');
loadLoginStatus();
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCookie(false); }
const copyBookmarklet = async () => {
try { await navigator.clipboard.writeText(bookmarkletCode); toast?.('Bookmarklet-Code kopiert'); }
catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); }
};
if (loading) return <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>;
@@ -318,108 +242,54 @@ function MakerworldTab({ toast }) {
const min = Math.round((Date.now()-ts)/60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
return `vor ${Math.round(min/60)} Std.`;
if (min < 1440) return `vor ${Math.round(min/60)} Std.`;
return `vor ${Math.round(min/1440)} Tag(en)`;
};
return (
<div>
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
{state?.needsCode && (
<div style={{...S.card,borderColor:'rgba(255,230,109,0.4)',background:'rgba(255,230,109,0.08)'}}>
<div style={{...S.head,color:'#ffe66d'}}>📧 VERIFIZIERUNGSCODE ERFORDERLICH</div>
<div style={{...S.sub,marginBottom:8}}>
MakerWorld hat einen Code an deine E-Mail-Adresse geschickt. Bitte hier eintragen
(5 Minuten Zeit, danach muss der Login-Versuch neu gestartet werden).
</div>
<div style={{display:'flex',gap:8}}>
<input value={codeInput} onChange={e=>setCodeInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&submitCode()}
placeholder="Code aus E-Mail" style={{...S.inp,flex:1}}/>
<button onClick={submitCode} disabled={submittingCode||!codeInput.trim()}
style={{...S.btn('#ffe66d'),opacity:(submittingCode||!codeInput.trim())?0.5:1,whiteSpace:'nowrap'}}>
{submittingCode?'…':'✓ Bestätigen'}
</button>
</div>
<div style={S.card}>
<div style={S.head}>EINRICHTUNG (EINMALIG)</div>
<div style={{...S.sub,marginBottom:10}}>
Kein Login, kein Passwort nötig nur ein Lesezeichen. Öffne die Seite{' '}
<code style={{color:'#4ecdc4'}}>makerworld.com/de/my/data-overview/model</code>{' '}
in deinem eigenen Browser (wo du eh eingeloggt bist), klicke dann auf das Lesezeichen
die aktuell auf der Seite geladenen Zahlen werden direkt an DickenDock geschickt.
</div>
)}
{/* Admin: Zugangsdaten */}
{!state?.needsCode && (
<div style={S.card}>
<div style={S.head}>MEINE MAKERWORLD-ZUGANGSDATEN</div>
{loginStatus && (
<div style={{...S.sub,marginBottom:10}}>
{loginStatus.configured
? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}`
: 'Noch keine Zugangsdaten hinterlegt.'}
{loginStatus.lastError && <div style={{color:'#f87171',marginTop:4}}> {loginStatus.lastError}</div>}
</div>
)}
<div style={{...S.sub,marginBottom:8}}>
Wird verschlüsselt gespeichert und nur für deinen eigenen Abruf genutzt andere Nutzer sehen
weder deine Zugangsdaten noch deine Zahlen. Ein echter Browser mit dauerhaftem Profil hält die
Session am Laufen bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch,
erscheint hier automatisch die Code-Eingabe.
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}>
<input value={credForm.username} onChange={e=>setCredForm(p=>({...p,username:e.target.value}))}
placeholder="E-Mail / Benutzername" style={{...S.inp,flex:1,minWidth:160}}/>
<input type="password" value={credForm.password} onChange={e=>setCredForm(p=>({...p,password:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&saveCredentials()}
placeholder="Passwort" style={{...S.inp,flex:1,minWidth:160}}/>
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<button onClick={saveCredentials} disabled={savingCreds||!credForm.username.trim()||!credForm.password}
style={{...S.btn('#4ade80'),opacity:(savingCreds||!credForm.username.trim()||!credForm.password)?0.5:1}}>
{savingCreds?'…':'💾 Speichern & testen'}
</button>
<button onClick={refresh} disabled={refreshing}
style={{...S.btn('#4ecdc4'),opacity:refreshing?0.5:1}}>
{refreshing?'…':'🔄 Neu abrufen'}
</button>
<button onClick={()=>setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
</button>
{loginStatus?.configured && (
<button onClick={removeCredentials} style={{...S.btn('#f87171')}}>
🗑 Zugangsdaten entfernen
</button>
)}
</div>
{showCookieFallback && (
<div style={{marginTop:14,paddingTop:14,borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{...S.sub,marginBottom:8}}>
Nur falls der automatische Login nicht funktioniert: kompletten Cookie-Header aus einem
eingeloggten Browser-Tab kopieren (Entwicklertools Netzwerk Request an
/de/my/data-overview/model "Cookie"-Header) und hier einfügen.
</div>
<textarea value={cookieInput} onChange={e=>setCookieInput(e.target.value)}
placeholder="lang=de; cf_clearance=...; token=...; ..."
rows={3} style={{...S.inp,resize:'vertical',fontSize:10,marginBottom:8}}/>
<button onClick={saveCookie} disabled={savingCookie||!cookieInput.trim()}
style={{...S.btn('#4ecdc4'),opacity:(savingCookie||!cookieInput.trim())?0.5:1}}>
{savingCookie?'…':'💾 Cookie übernehmen & testen'}
</button>
</div>
)}
<div style={{...S.sub,marginBottom:6,fontWeight:700,color:'rgba(255,255,255,0.6)'}}>Am PC:</div>
<div style={{...S.sub,marginBottom:10}}>
Lesezeichenleiste anzeigen, dann diesen Link dorthin ziehen:
</div>
<div style={{marginBottom:14}}>
<a href={bookmarkletCode} onClick={e=>e.preventDefault()} draggable="true"
style={{...S.btn('#4ecdc4'),display:'inline-block',cursor:'grab',textDecoration:'none'}}>
📌 DickenDock Sync
</a>
</div>
)}
{state?.error && (
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}>
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11,marginBottom:8}}>
{state.error}{d ? ' — zeige zuletzt bekannten Stand.' : ''}
</div>
<button onClick={viewDebugScreenshot} disabled={loadingScreenshot}
style={{...S.btn('#888888'),opacity:loadingScreenshot?0.5:1}}>
{loadingScreenshot?'…':'🖼 Debug-Screenshot ansehen'}
<div style={{...S.sub,marginBottom:6,fontWeight:700,color:'rgba(255,255,255,0.6)'}}>Am Handy:</div>
<div style={{...S.sub,marginBottom:10}}>
Erst irgendeine Seite als Lesezeichen speichern, dann dieses Lesezeichen bearbeiten und
als Namen z.B. "DickenDock Sync" eintragen als URL den kopierten Code einfügen (ersetzt
die bisherige URL komplett):
</div>
<button onClick={copyBookmarklet} style={{...S.btn('#4ecdc4')}}>
📋 Bookmarklet-Code kopieren
</button>
<div style={{display:'flex',gap:8,marginTop:14,paddingTop:14,borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<button onClick={regenerateToken} disabled={regenerating} style={{...S.btn('#f87171'),opacity:regenerating?0.5:1}}>
{regenerating?'…':'🔄 Bookmarklet zurücksetzen'}
</button>
{debugImgUrl && (
<div style={{marginTop:10}}>
<img src={debugImgUrl} alt="Debug-Screenshot" style={{width:'100%',borderRadius:8,border:'1px solid rgba(255,255,255,0.1)'}}/>
</div>
)}
</div>
</div>
{!state?.configured && (
<div style={S.card}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
Noch keine Daten empfangen Lesezeichen einrichten und einmal anklicken.
</div>
</div>
)}