feat: MakerWorld Creator-Center Statistik-Tab (Cookie-basierter HTML-Fetch, __NEXT_DATA__-Extraktion)
This commit is contained in:
356
backend/src/tools/makerworld/routes.js
Normal file
356
backend/src/tools/makerworld/routes.js
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const db = require('../../db');
|
||||||
|
const { encrypt, decrypt } = require('../../crypto-helper');
|
||||||
|
const { authenticate } = require('../../middleware/auth');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── MakerWorld Creator Center Statistik ──────────────────────────────────────
|
||||||
|
// Strategie: echtes (headless) Chromium mit PERSISTENTEM Profil unter /data
|
||||||
|
// (überlebt Container-Neustarts, da /data bereits als Volume gemountet ist).
|
||||||
|
// Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares
|
||||||
|
// JS-Challenge (cf_clearance) selbstständig — dafür ist kein manuelles
|
||||||
|
// Cookie-Kopieren mehr nötig. Nur wenn die eigentliche Login-Session (nicht
|
||||||
|
// nur Cloudflare) abgelaufen ist, wird automatisch neu eingeloggt; verlangt
|
||||||
|
// MakerWorld dabei einen E-Mail-Verifizierungscode, wird der Vorgang pausiert
|
||||||
|
// 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 wir
|
||||||
|
// nicht vorab kennen). Falls der automatische Login fehlschlägt, meldet die
|
||||||
|
// App einen klaren Fehler statt stillschweigend hängen zu bleiben — dann bitte
|
||||||
|
// einen HAR-Export des echten Login-Vorgangs schicken, um die Selektoren zu fixen.
|
||||||
|
|
||||||
|
const DATA_URL = 'https://makerworld.com/de/my/data-overview/model';
|
||||||
|
const PROFILE_DIR = '/data/makerworld-profile';
|
||||||
|
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 cache = { data: null, fetchedAt: 0, error: null };
|
||||||
|
let pendingLogin = null; // { browser, page, codeField, startedAt } während auf E-Mail-Code gewartet wird
|
||||||
|
|
||||||
|
// Alle Browser-Operationen seriell ausführen (gleiche Profil-Datei darf nicht
|
||||||
|
// von zwei Chromium-Prozessen gleichzeitig verwendet werden)
|
||||||
|
let opQueue = Promise.resolve();
|
||||||
|
function serialized(fn) {
|
||||||
|
const run = opQueue.then(fn, fn);
|
||||||
|
opQueue = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zugangsdaten ──────────────────────────────────────────────────────────────
|
||||||
|
function getCredentials() {
|
||||||
|
const username = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_username'").get()?.value;
|
||||||
|
const passEnc = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_password_enc'").get()?.value;
|
||||||
|
if (!username || !passEnc) return null;
|
||||||
|
const password = decrypt(passEnc);
|
||||||
|
if (!password) return null;
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
function saveCredentials(username, password) {
|
||||||
|
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_username', ?)").run(username);
|
||||||
|
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_password_enc', ?)").run(encrypt(password));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── __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 mapStatisticalData(parsed) {
|
||||||
|
const statisticalData = parsed?.props?.pageProps?.statisticalData;
|
||||||
|
if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
|
||||||
|
const s = statisticalData.summary;
|
||||||
|
return {
|
||||||
|
downloads: s.download ?? 0,
|
||||||
|
prints: s.print ?? 0,
|
||||||
|
likes: s.like ?? 0,
|
||||||
|
collections: s.collect ?? 0,
|
||||||
|
boosts: s.boost ?? 0,
|
||||||
|
followers: s.follower ?? 0,
|
||||||
|
views: s.view ?? 0,
|
||||||
|
impressions: s.impression ?? 0,
|
||||||
|
creatorPoints: s.point ?? 0,
|
||||||
|
modelPoints: s.pointFromModel ?? 0,
|
||||||
|
instructionPoints: s.pointFromInst ?? 0,
|
||||||
|
models: (statisticalData.statisticalList || []).map(m => ({
|
||||||
|
id: m.designId,
|
||||||
|
title: m.title || m.titleTranslated || '',
|
||||||
|
cover: m.cover || '',
|
||||||
|
publishedAt: m.publishTime || '',
|
||||||
|
downloads: m.download ?? 0,
|
||||||
|
prints: m.print ?? 0,
|
||||||
|
likes: m.like ?? 0,
|
||||||
|
collections: m.collect ?? 0,
|
||||||
|
boosts: m.boost ?? 0,
|
||||||
|
views: m.view ?? 0,
|
||||||
|
impressions: m.impression ?? 0,
|
||||||
|
points: m.point ?? 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Browser-Helfer ────────────────────────────────────────────────────────────
|
||||||
|
async function launchBrowser() {
|
||||||
|
let puppeteer;
|
||||||
|
try { puppeteer = require('puppeteer-core'); }
|
||||||
|
catch { throw new Error('puppeteer-core nicht installiert'); }
|
||||||
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||||||
|
return puppeteer.launch({
|
||||||
|
executablePath: '/usr/bin/chromium-browser',
|
||||||
|
userDataDir: PROFILE_DIR,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||||
|
headless: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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/i.test(title);
|
||||||
|
}
|
||||||
|
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 — der
|
||||||
|
// Browser/die Page bleiben dann bewusst OFFEN in `pendingLogin`.
|
||||||
|
async function attemptLogin(browser, page) {
|
||||||
|
const creds = getCredentials();
|
||||||
|
if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten im Admin-Bereich 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) {
|
||||||
|
throw new Error('Login-Formular nicht gefunden (Selektoren evtl. veraltet) — bitte HAR der Login-Seite schicken, dann passe ich das an');
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Direkt erfolgreich (bekanntes Gerät, kein Code nötig)?
|
||||||
|
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();
|
||||||
|
pendingLogin = { browser, page, codeField, startedAt };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pendingLogin && pendingLogin.startedAt === startedAt) {
|
||||||
|
pendingLogin.browser.close().catch(() => {});
|
||||||
|
pendingLogin = null;
|
||||||
|
}
|
||||||
|
}, LOGIN_CODE_TIMEOUT_MS);
|
||||||
|
const err = new Error('Verifizierungscode per E-Mail erforderlich');
|
||||||
|
err.needsCode = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatsViaBrowserInner(forceRefresh) {
|
||||||
|
if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) {
|
||||||
|
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
|
||||||
|
}
|
||||||
|
if (pendingLogin) {
|
||||||
|
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await launchBrowser();
|
||||||
|
const 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 });
|
||||||
|
|
||||||
|
for (let i = 0; i < 3 && await isCloudflareChallenge(page); i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 4000));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = await getNextDataFromPage(page);
|
||||||
|
|
||||||
|
if (!parsed && isLoginUrl(page.url())) {
|
||||||
|
parsed = await attemptLogin(browser, page); // wirft ggf. needsCode-Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error('__NEXT_DATA__ nicht gefunden (weder Daten noch erkennbare Login-/Challenge-Seite) — Seitenlayout evtl. geändert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = mapStatisticalData(parsed);
|
||||||
|
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
|
||||||
|
await browser.close();
|
||||||
|
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
|
||||||
|
} catch (e) {
|
||||||
|
if (e.needsCode) {
|
||||||
|
// Browser/Page bewusst NICHT schließen — liegt in pendingLogin bereit
|
||||||
|
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
|
||||||
|
}
|
||||||
|
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(code) {
|
||||||
|
if (!pendingLogin) {
|
||||||
|
return { data: cache.data, error: 'Kein offener Login-Vorgang', fetchedAt: cache.fetchedAt, needsCode: false };
|
||||||
|
}
|
||||||
|
const { browser, page, codeField } = pendingLogin;
|
||||||
|
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;
|
||||||
|
pendingLogin = null;
|
||||||
|
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
|
||||||
|
// (solange das Zeitfenster noch läuft)
|
||||||
|
return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true };
|
||||||
|
} catch (e) {
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
pendingLogin = null;
|
||||||
|
cache.error = e.message;
|
||||||
|
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manuellen Cookie-String (Fallback) ins persistente Profil einspielen
|
||||||
|
async function injectCookiesInner(cookieString) {
|
||||||
|
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: '/' };
|
||||||
|
});
|
||||||
|
const browser = await launchBrowser();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
|
||||||
|
await page.setCookie(...cookies);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStatsViaBrowser = (forceRefresh) => serialized(() => fetchStatsViaBrowserInner(forceRefresh));
|
||||||
|
const submitLoginCode = (code) => serialized(() => submitLoginCodeInner(code));
|
||||||
|
const injectCookies = (cookieString) => serialized(() => injectCookiesInner(cookieString));
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /stats – für alle eingeloggten Nutzer (reine Anzeige)
|
||||||
|
router.get('/stats', authenticate, async (req, res) => {
|
||||||
|
const configured = !!getCredentials() || !!cache.fetchedAt;
|
||||||
|
if (!configured) {
|
||||||
|
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
|
||||||
|
}
|
||||||
|
const result = await fetchStatsViaBrowser(false);
|
||||||
|
res.json({ configured: true, ...result });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /refresh – erzwungener Neu-Abruf (Admin)
|
||||||
|
router.post('/refresh', authenticate, async (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const result = await fetchStatsViaBrowser(true);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /verify-code – E-Mail-Verifizierungscode eingeben (Admin)
|
||||||
|
router.post('/verify-code', authenticate, async (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const { code } = req.body;
|
||||||
|
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
|
||||||
|
const result = await submitLoginCode(code.trim());
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /login-status – Diagnose für Admin (Passwort wird nie zurückgegeben)
|
||||||
|
router.get('/login-status', authenticate, (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const creds = getCredentials();
|
||||||
|
res.json({
|
||||||
|
configured: !!creds,
|
||||||
|
username: creds?.username || null,
|
||||||
|
lastFetchedAt: cache.fetchedAt || null,
|
||||||
|
lastError: cache.error || null,
|
||||||
|
needsCode: !!pendingLogin,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /credentials – MakerWorld-Zugangsdaten speichern (verschlüsselt) + Testabruf (Admin)
|
||||||
|
router.post('/credentials', authenticate, async (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
|
||||||
|
saveCredentials(username.trim(), password);
|
||||||
|
const result = await fetchStatsViaBrowser(true);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /cookie – manueller Fallback: Cookie-String direkt ins Profil einspielen (Admin)
|
||||||
|
router.post('/cookie', authenticate, async (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const { cookie } = req.body;
|
||||||
|
if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiger Cookie-String' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await injectCookies(cookie.trim());
|
||||||
|
const result = await fetchStatsViaBrowser(true);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3933,7 +3933,7 @@ export default function App() {
|
|||||||
<main ref={mainRef} style={mainStyle}>
|
<main ref={mainRef} style={mainStyle}>
|
||||||
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>}
|
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>}
|
||||||
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user} nav={devToolsNav}/>}
|
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user} nav={devToolsNav}/>}
|
||||||
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})} {...(activeTool.id==='media' ? {initTab:mediaInitTab, onInitTabConsumed:()=>setMediaInitTab(null)} : {})}/>}
|
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} user={user} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})} {...(activeTool.id==='media' ? {initTab:mediaInitTab, onInitTabConsumed:()=>setMediaInitTab(null)} : {})}/>}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{mobile && (
|
{mobile && (
|
||||||
|
|||||||
@@ -197,7 +197,254 @@ function OrdersInPeriod({ fromDate, toDate }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Statistik({ mobile }) {
|
// MakerWorld Creator-Center Statistik
|
||||||
|
function MakerworldTab({ isAdmin, toast }) {
|
||||||
|
const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode }
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = 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 load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
|
||||||
|
};
|
||||||
|
const loadLoginStatus = () => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); loadLoginStatus(); }, []);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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');
|
||||||
|
} catch(e) { toast?.(e.message,'error'); }
|
||||||
|
finally { setRefreshing(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 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); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>;
|
||||||
|
|
||||||
|
const d = state?.data;
|
||||||
|
const fmtInt = v => (v ?? 0).toLocaleString('de-DE');
|
||||||
|
const fmtAgo = ts => {
|
||||||
|
if (!ts) return '—';
|
||||||
|
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.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
|
||||||
|
{isAdmin && 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin: Zugangsdaten */}
|
||||||
|
{isAdmin && !state?.needsCode && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>MAKERWORLD-LOGIN (ADMIN)</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. 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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!state?.configured && !isAdmin && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
|
||||||
|
MakerWorld-Anbindung ist noch nicht eingerichtet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state?.error && d && (
|
||||||
|
<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}}>
|
||||||
|
⚠ Letzter Abruf fehlgeschlagen ({state.error}) — zeige zuletzt bekannten Stand.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{d && (
|
||||||
|
<>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.head}>CREATOR CENTER {state.fetchedAt ? `· ${fmtAgo(state.fetchedAt)}` : ''}</div>
|
||||||
|
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
|
||||||
|
<KPI label="Downloads" value={fmtInt(d.downloads)} color="#4ecdc4"/>
|
||||||
|
<KPI label="Prints" value={fmtInt(d.prints)} color="#4ecdc4"/>
|
||||||
|
<KPI label="Likes" value={fmtInt(d.likes)} color="#ff6b9d"/>
|
||||||
|
<KPI label="Collections" value={fmtInt(d.collections)} color="#ff6b9d"/>
|
||||||
|
<KPI label="Boosts" value={fmtInt(d.boosts)} color="#ffe66d"/>
|
||||||
|
<KPI label="Follower" value={fmtInt(d.followers)} color="#ffe66d"/>
|
||||||
|
</div>
|
||||||
|
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
|
||||||
|
<KPI label="Views" value={fmtInt(d.views)} color="#60a5fa"/>
|
||||||
|
<KPI label="Impressions" value={fmtInt(d.impressions)} color="#60a5fa"/>
|
||||||
|
<KPI label="Creator Points" value={fmtInt(d.creatorPoints)} color="#c084fc"/>
|
||||||
|
<KPI label="Model Points" value={fmtInt(d.modelPoints)} color="#c084fc" sub="aus Modellen"/>
|
||||||
|
<KPI label="MakerLab Points" value={fmtInt(d.instructionPoints)} color="#c084fc" sub="aus Anleitungen"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.models?.length > 0 && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:showModels?10:0}}>
|
||||||
|
<div style={{...S.head,marginBottom:0}}>MODELLE ({d.models.length})</div>
|
||||||
|
<button onClick={()=>setShowModels(p=>!p)} style={{...S.btn('#888888'),fontSize:10}}>
|
||||||
|
{showModels ? '▲ einklappen' : '▼ anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showModels && [...d.models].sort((a,b)=>b.downloads-a.downloads).map(m => (
|
||||||
|
<div key={m.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
|
||||||
|
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1,minWidth:160}}>
|
||||||
|
{m.title}
|
||||||
|
</span>
|
||||||
|
<span style={{color:'#4ecdc4',fontFamily:"'Space Mono',monospace",fontSize:11}}>↓{fmtInt(m.downloads)}</span>
|
||||||
|
<span style={{color:'#ff6b9d',fontFamily:"'Space Mono',monospace",fontSize:11}}>♥{fmtInt(m.likes)}</span>
|
||||||
|
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:11}}>👁{fmtInt(m.views)}</span>
|
||||||
|
<span style={{color:'#c084fc',fontFamily:"'Space Mono',monospace",fontSize:11}}>{m.points?.toFixed?.(1) ?? m.points}pt</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Statistik({ mobile, user, toast }) {
|
||||||
const [fromDate, setFromDate] = useState('');
|
const [fromDate, setFromDate] = useState('');
|
||||||
const [toDate, setToDate] = useState(todayLocal());
|
const [toDate, setToDate] = useState(todayLocal());
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
@@ -269,7 +516,7 @@ export default function Statistik({ mobile }) {
|
|||||||
|
|
||||||
{/* ── Tabs ── */}
|
{/* ── Tabs ── */}
|
||||||
<div style={{display:'flex',gap:4,marginBottom:16}}>
|
<div style={{display:'flex',gap:4,marginBottom:16}}>
|
||||||
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
|
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen'],['makerworld','🖨️ MakerWorld']].map(([id,label])=>(
|
||||||
<button key={id} onClick={()=>setTab(id)} style={{
|
<button key={id} onClick={()=>setTab(id)} style={{
|
||||||
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
|
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
|
||||||
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
|
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
|
||||||
@@ -504,6 +751,11 @@ export default function Statistik({ mobile }) {
|
|||||||
{tab==='orders' && (
|
{tab==='orders' && (
|
||||||
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
|
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ══ MAKERWORLD ══ */}
|
||||||
|
{tab==='makerworld' && (
|
||||||
|
<MakerworldTab isAdmin={user?.role==='admin'} toast={toast}/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user