feat: MakerWorld komplett auf Bookmarklet-Ansatz umgestellt (kein Puppeteer/Login mehr)
This commit is contained in:
@@ -10,8 +10,6 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"ws": "^8.17.1",
|
"ws": "^8.17.1",
|
||||||
"puppeteer-core": "^22.0.0",
|
"puppeteer-core": "^22.0.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
||||||
"mqtt": "^5.10.1"
|
"mqtt": "^5.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,14 +613,25 @@ 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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS makerworld_accounts (
|
CREATE TABLE IF NOT EXISTS makerworld_ingest_tokens (
|
||||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
username TEXT NOT NULL,
|
token TEXT NOT NULL UNIQUE,
|
||||||
password_enc TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -1,84 +1,55 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const crypto = require('crypto');
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const { encrypt, decrypt } = require('../../crypto-helper');
|
|
||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── MakerWorld Creator Center Statistik — pro Nutzer eigener Account ────────
|
// ── MakerWorld Creator Center Statistik — Bookmarklet-Ansatz ─────────────────
|
||||||
// Jeder Nutzer hinterlegt seine EIGENEN MakerWorld-Zugangsdaten und sieht nur
|
// Nach mehreren gescheiterten Versuchen (Cloudflare blockt sowohl reinen
|
||||||
// seine eigenen Creator-Center-Zahlen. Dafür bekommt jeder Nutzer ein eigenes,
|
// fetch() als auch stealth-gepatchtes Puppeteer-Chromium, vermutlich per
|
||||||
// persistentes Chromium-Profil (eigene Cookies/Session, komplett getrennt von
|
// TLS-Fingerprinting) gibt es hier bewusst KEINE Server-seitige Automatisierung
|
||||||
// den anderen Nutzern), das unter /data liegt und Container-Neustarts übersteht.
|
// 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
|
// Authentifizierung: das Bookmarklet läuft im Kontext von makerworld.com
|
||||||
// JS-Challenge (cf_clearance) selbstständig — kein manuelles Cookie-Kopieren
|
// (fremde Origin) und hat daher keinen Zugriff auf den DickenDock-Login-Token.
|
||||||
// im Normalfall. Nur wenn die eigentliche Login-Session abläuft und MakerWorld
|
// Stattdessen bekommt jeder Nutzer ein eigenes, zufälliges Ingest-Token, das
|
||||||
// einen E-Mail-Verifizierungscode verlangt, pausiert der Vorgang und wartet auf
|
// fest im Bookmarklet-Code steht — das ersetzt hier die reguläre Anmeldung.
|
||||||
// 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.
|
|
||||||
|
|
||||||
const DATA_URL = 'https://makerworld.com/de/my/data-overview/model';
|
const cacheByUser = new Map(); // userId -> { data, fetchedAt } (Lazy-Load aus DB)
|
||||||
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)
|
|
||||||
|
|
||||||
function getCache(userId) {
|
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);
|
return cacheByUser.get(userId);
|
||||||
}
|
}
|
||||||
|
function persistCache(userId, data, fetchedAt) {
|
||||||
// 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) {
|
|
||||||
db.prepare(`
|
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 (?, ?, ?)
|
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 getOrCreateToken(userId) {
|
||||||
function extractNextData(html) {
|
let row = db.prepare('SELECT token FROM makerworld_ingest_tokens WHERE user_id=?').get(userId);
|
||||||
const marker = '<script id="__NEXT_DATA__"';
|
if (!row) {
|
||||||
const markerIdx = html.indexOf(marker);
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
if (markerIdx === -1) return null;
|
db.prepare('INSERT INTO makerworld_ingest_tokens (user_id, token) VALUES (?, ?)').run(userId, token);
|
||||||
const tagEnd = html.indexOf('>', markerIdx) + 1;
|
row = { token };
|
||||||
if (tagEnd === 0) return null;
|
}
|
||||||
const scriptEnd = html.indexOf('</script>', tagEnd);
|
return row.token;
|
||||||
if (scriptEnd === -1) return null;
|
|
||||||
try { return JSON.parse(html.slice(tagEnd, scriptEnd)); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatisticalData(parsed) {
|
// Formt den vom Bookmarklet gesendeten statisticalData-Ausschnitt in unser Format
|
||||||
const statisticalData = parsed?.props?.pageProps?.statisticalData;
|
function mapStatisticalData(statisticalData) {
|
||||||
if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
|
if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
|
||||||
const s = statisticalData.summary;
|
const s = statisticalData.summary;
|
||||||
return {
|
return {
|
||||||
@@ -110,427 +81,57 @@ function mapStatisticalData(parsed) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnose/Fallback: Abruf OHNE Browser, nur reiner HTTP-fetch() mit dem
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
// ü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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Browser-Helfer ────────────────────────────────────────────────────────────
|
// GET /stats – eigene, zuletzt per Bookmarklet empfangene Daten
|
||||||
// Nach einem abgebrochenen Vorgang (Container-Neustart mitten im Betrieb,
|
router.get('/stats', authenticate, (req, res) => {
|
||||||
// Absturz o.ä.) kann Chromium veraltete Sperrdateien im Profil hinterlassen —
|
const cache = getCache(req.user.id);
|
||||||
// dann hängt der nächste Start ewig, statt einen Fehler zu werfen. Vor jedem
|
res.json({ configured: !!cache.fetchedAt, data: cache.data, fetchedAt: cache.fetchedAt || null });
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/refresh', authenticate, async (req, res) => {
|
// GET /ingest-token – eigenes (ggf. neu erzeugtes) Token + Server-Origin, für den Bookmarklet-Code
|
||||||
const userId = req.user.id;
|
router.get('/ingest-token', authenticate, (req, res) => {
|
||||||
const cache = getCache(userId);
|
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 {
|
try {
|
||||||
const result = await fetchStatsViaBrowser(userId, true);
|
const data = mapStatisticalData(statisticalData);
|
||||||
res.json(result);
|
const fetchedAt = Date.now();
|
||||||
} catch (e) {
|
const cache = getCache(row.user_id);
|
||||||
cache.error = e.message;
|
cache.data = data; cache.fetchedAt = fetchedAt;
|
||||||
res.json({ data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false });
|
persistCache(row.user_id, data, fetchedAt);
|
||||||
}
|
console.log(`[MakerWorld:${row.user_id}] Daten per Bookmarklet empfangen: ${data.downloads} Downloads, ${data.likes} Likes`);
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
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) {
|
} catch (e) {
|
||||||
console.log(`[MakerWorld:${userId}] Raw-Fetch lieferte Seite, aber Mapping fehlgeschlagen: ${e.message}`);
|
res.status(400).json({ error: 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 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -199,114 +199,38 @@ function OrdersInPeriod({ fromDate, toDate }) {
|
|||||||
|
|
||||||
// MakerWorld Creator-Center Statistik
|
// MakerWorld Creator-Center Statistik
|
||||||
function MakerworldTab({ toast }) {
|
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 [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 [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 = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
|
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
|
||||||
};
|
};
|
||||||
const loadLoginStatus = () => {
|
const loadToken = () => {
|
||||||
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
|
api('/tools/makerworld/ingest-token').then(setTokenInfo).catch(()=>{});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); loadLoginStatus(); }, []);
|
useEffect(() => { load(); loadToken(); }, []);
|
||||||
|
|
||||||
// Solange auf einen Code gewartet wird, alle paar Sekunden pollen
|
const regenerateToken = async () => {
|
||||||
useEffect(() => {
|
if (!window.confirm('Neues Bookmarklet erzeugen? Das alte funktioniert danach nicht mehr — du musst es neu ins Lesezeichen legen.')) return;
|
||||||
if (!state?.needsCode) return;
|
setRegenerating(true);
|
||||||
const iv = setInterval(() => { load(); loadLoginStatus(); }, 5000);
|
|
||||||
return () => clearInterval(iv);
|
|
||||||
}, [state?.needsCode]);
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
try {
|
||||||
const r = await api('/tools/makerworld/refresh', { method:'POST' });
|
const r = await api('/tools/makerworld/ingest-token/regenerate', { method:'POST' });
|
||||||
setState(p => ({ ...p, ...r }));
|
setTokenInfo(r);
|
||||||
loadLoginStatus();
|
toast?.('Neues Bookmarklet erzeugt — bitte neu einrichten');
|
||||||
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'); }
|
} catch(e) { toast?.(e.message,'error'); }
|
||||||
finally { setRefreshing(false); }
|
finally { setRegenerating(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCredentials = async () => {
|
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);}})();` : '';
|
||||||
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 removeCredentials = async () => {
|
const copyBookmarklet = async () => {
|
||||||
try {
|
try { await navigator.clipboard.writeText(bookmarkletCode); toast?.('Bookmarklet-Code kopiert'); }
|
||||||
await api('/tools/makerworld/credentials', { method:'DELETE' });
|
catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); }
|
||||||
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); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>;
|
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);
|
const min = Math.round((Date.now()-ts)/60000);
|
||||||
if (min < 1) return 'gerade eben';
|
if (min < 1) return 'gerade eben';
|
||||||
if (min < 60) return `vor ${min} Min.`;
|
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 (
|
return (
|
||||||
<div>
|
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Admin: Zugangsdaten */}
|
|
||||||
{!state?.needsCode && (
|
|
||||||
<div style={S.card}>
|
<div style={S.card}>
|
||||||
<div style={S.head}>MEINE MAKERWORLD-ZUGANGSDATEN</div>
|
<div style={S.head}>EINRICHTUNG (EINMALIG)</div>
|
||||||
{loginStatus && (
|
|
||||||
<div style={{...S.sub,marginBottom:10}}>
|
<div style={{...S.sub,marginBottom:10}}>
|
||||||
{loginStatus.configured
|
Kein Login, kein Passwort nötig — nur ein Lesezeichen. Öffne die Seite{' '}
|
||||||
? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}`
|
<code style={{color:'#4ecdc4'}}>makerworld.com/de/my/data-overview/model</code>{' '}
|
||||||
: 'Noch keine Zugangsdaten hinterlegt.'}
|
in deinem eigenen Browser (wo du eh eingeloggt bist), klicke dann auf das Lesezeichen —
|
||||||
{loginStatus.lastError && <div style={{color:'#f87171',marginTop:4}}>⚠ {loginStatus.lastError}</div>}
|
die aktuell auf der Seite geladenen Zahlen werden direkt an DickenDock geschickt.
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{showCookieFallback && (
|
<div style={{...S.sub,marginBottom:6,fontWeight:700,color:'rgba(255,255,255,0.6)'}}>Am PC:</div>
|
||||||
<div style={{marginTop:14,paddingTop:14,borderTop:'1px solid rgba(255,255,255,0.07)'}}>
|
<div style={{...S.sub,marginBottom:10}}>
|
||||||
<div style={{...S.sub,marginBottom:8}}>
|
Lesezeichenleiste anzeigen, dann diesen Link dorthin ziehen:
|
||||||
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>
|
</div>
|
||||||
<textarea value={cookieInput} onChange={e=>setCookieInput(e.target.value)}
|
<div style={{marginBottom:14}}>
|
||||||
placeholder="lang=de; cf_clearance=...; token=...; ..."
|
<a href={bookmarkletCode} onClick={e=>e.preventDefault()} draggable="true"
|
||||||
rows={3} style={{...S.inp,resize:'vertical',fontSize:10,marginBottom:8}}/>
|
style={{...S.btn('#4ecdc4'),display:'inline-block',cursor:'grab',textDecoration:'none'}}>
|
||||||
<button onClick={saveCookie} disabled={savingCookie||!cookieInput.trim()}
|
📌 DickenDock Sync
|
||||||
style={{...S.btn('#4ecdc4'),opacity:(savingCookie||!cookieInput.trim())?0.5:1}}>
|
</a>
|
||||||
{savingCookie?'…':'💾 Cookie übernehmen & testen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state?.error && (
|
<div style={{...S.sub,marginBottom:6,fontWeight:700,color:'rgba(255,255,255,0.6)'}}>Am Handy:</div>
|
||||||
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}>
|
<div style={{...S.sub,marginBottom:10}}>
|
||||||
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11,marginBottom:8}}>
|
Erst irgendeine Seite als Lesezeichen speichern, dann dieses Lesezeichen bearbeiten und
|
||||||
⚠ {state.error}{d ? ' — zeige zuletzt bekannten Stand.' : ''}
|
als Namen z.B. "DickenDock Sync" eintragen — als URL den kopierten Code einfügen (ersetzt
|
||||||
|
die bisherige URL komplett):
|
||||||
</div>
|
</div>
|
||||||
<button onClick={viewDebugScreenshot} disabled={loadingScreenshot}
|
<button onClick={copyBookmarklet} style={{...S.btn('#4ecdc4')}}>
|
||||||
style={{...S.btn('#888888'),opacity:loadingScreenshot?0.5:1}}>
|
📋 Bookmarklet-Code kopieren
|
||||||
{loadingScreenshot?'…':'🖼 Debug-Screenshot ansehen'}
|
</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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user