import sharp from 'sharp'; import { existsSync } from 'fs'; import { resolve, sep } from 'path'; import { db, getListing, getListings } from './db.mjs'; import { calculateFees } from './fees.mjs'; const PLATFORM_PROFILES = { 'ebay-de': { name: 'eBay', titleMin: 12, titleMax: 80, descriptionMin: 80, recommendedPhotos: 4 }, vinted: { name: 'Vinted', titleMin: 8, titleMax: 100, descriptionMin: 45, recommendedPhotos: 3 }, 'ebay-ka': { name: 'Kleinanzeigen', titleMin: 10, titleMax: 65, descriptionMin: 60, recommendedPhotos: 4 }, etsy: { name: 'Etsy', titleMin: 20, titleMax: 140, descriptionMin: 120, recommendedPhotos: 5 }, }; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function wordTokens(value) { return new Set(normalizeText(value).toLowerCase().replace(/[^a-z0-9äöüß ]/gi, ' ').split(/\s+/).filter(token => token.length > 2)); } function jaccard(a, b) { const left = wordTokens(a); const right = wordTokens(b); if (!left.size || !right.size) return 0; let intersection = 0; for (const token of left) if (right.has(token)) intersection += 1; return intersection / (left.size + right.size - intersection); } function gradeFor(score) { if (score >= 92) return 'A'; if (score >= 82) return 'B'; if (score >= 70) return 'C'; if (score >= 55) return 'D'; return 'E'; } function issue({ code, severity = 'recommendation', category, title, detail, points = 0, field = null, action = null }) { return { code, severity, category, title, detail, points, field, action }; } function safeParseJson(value, fallback) { try { const parsed = JSON.parse(value); return parsed ?? fallback; } catch { return fallback; } } function findDuplicates(listing, allListings) { const candidates = []; const title = normalizeText(listing.title); for (const other of allListings) { if (Number(other.id) === Number(listing.id)) continue; const similarity = jaccard(title, other.title); const sameSku = listing.sku && other.sku && String(listing.sku).toLowerCase() === String(other.sku).toLowerCase(); if (sameSku || similarity >= 0.72) { candidates.push({ id: other.id, title: other.title, sku: other.sku, similarity: Math.round(similarity * 100), same_sku: !!sameSku }); } } return candidates.sort((a, b) => Number(b.same_sku) - Number(a.same_sku) || b.similarity - a.similarity).slice(0, 5); } function inspectCore(listing, allListings) { const issues = []; const title = normalizeText(listing.title); const description = normalizeText(listing.description); const photos = Array.isArray(listing.photos) ? listing.photos.filter(Boolean) : []; const tags = Array.isArray(listing.tags) ? listing.tags.filter(Boolean) : []; const profile = PLATFORM_PROFILES[listing.platform] || { name: listing.platform || 'Allgemein', titleMin: 10, titleMax: 100, descriptionMin: 60, recommendedPhotos: 3 }; if (!title) issues.push(issue({ code: 'missing_title', severity: 'error', category: 'Inhalt', title: 'Titel fehlt', detail: 'Ohne Titel kann der Artikel nicht zuverlässig veröffentlicht werden.', points: 20, field: 'title' })); else { if (title.length < profile.titleMin) issues.push(issue({ code: 'title_too_short', severity: 'warning', category: 'Inhalt', title: 'Titel ist sehr kurz', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.titleMin} Zeichen empfohlen.`, points: 6, field: 'title', action: 'ai_title' })); if (title.length > profile.titleMax) issues.push(issue({ code: 'title_too_long', severity: 'error', category: 'Plattform', title: 'Titel ist zu lang', detail: `Das aktive Vendoo-Profil sieht höchstens ${profile.titleMax} Zeichen vor.`, points: 10, field: 'title', action: 'ai_title' })); if (/\b(l@@k|mega|hammer|wow|!!!)\b/i.test(title) || /!{2,}/.test(title)) issues.push(issue({ code: 'title_spam', severity: 'recommendation', category: 'Inhalt', title: 'Titel wirkt werblich', detail: 'Sachliche Titel sind leichter lesbar und wirken vertrauenswürdiger.', points: 2, field: 'title', action: 'ai_title' })); } if (!description) issues.push(issue({ code: 'missing_description', severity: 'error', category: 'Inhalt', title: 'Beschreibung fehlt', detail: 'Zustand, Lieferumfang und relevante Besonderheiten sollten beschrieben werden.', points: 16, field: 'description', action: 'ai_description' })); else { if (description.length < profile.descriptionMin) issues.push(issue({ code: 'description_too_short', severity: 'warning', category: 'Inhalt', title: 'Beschreibung ist knapp', detail: `Für das ${profile.name}-Profil werden mindestens ${profile.descriptionMin} Zeichen empfohlen.`, points: 6, field: 'description', action: 'ai_description' })); if (!/[.!?]$/.test(description)) issues.push(issue({ code: 'description_unfinished', severity: 'recommendation', category: 'Inhalt', title: 'Beschreibung wirkt unvollständig', detail: 'Ein sauberer Abschluss verbessert die Lesbarkeit.', points: 1, field: 'description' })); } if (!photos.length) issues.push(issue({ code: 'missing_photos', severity: 'error', category: 'Bilder', title: 'Keine Bilder vorhanden', detail: 'Mindestens ein aussagekräftiges Originalbild ist erforderlich.', points: 20, field: 'photos' })); else if (photos.length < profile.recommendedPhotos) issues.push(issue({ code: 'few_photos', severity: 'warning', category: 'Bilder', title: 'Wenige Bilder', detail: `${profile.recommendedPhotos} oder mehr Perspektiven werden für dieses Profil empfohlen.`, points: clamp((profile.recommendedPhotos - photos.length) * 2, 2, 7), field: 'photos' })); if (!(Number(listing.price) > 0)) issues.push(issue({ code: 'missing_price', severity: 'error', category: 'Preis', title: 'Preis fehlt', detail: 'Ein positiver Verkaufspreis ist erforderlich.', points: 12, field: 'price' })); if (!normalizeText(listing.condition)) issues.push(issue({ code: 'missing_condition', severity: 'error', category: 'Metadaten', title: 'Zustand fehlt', detail: 'Der Zustand ist für Käufer und Plattformformulare wichtig.', points: 8, field: 'condition' })); if (!normalizeText(listing.category)) issues.push(issue({ code: 'missing_category', severity: 'error', category: 'Metadaten', title: 'Kategorie fehlt', detail: 'Eine passende Kategorie verbessert Auffindbarkeit und Publishing.', points: 8, field: 'category' })); if (!normalizeText(listing.brand)) issues.push(issue({ code: 'missing_brand', severity: 'recommendation', category: 'Metadaten', title: 'Marke nicht angegeben', detail: 'Marke ergänzen, sofern sie am Produkt sicher erkennbar ist.', points: 3, field: 'brand' })); if (!normalizeText(listing.color)) issues.push(issue({ code: 'missing_color', severity: 'recommendation', category: 'Metadaten', title: 'Farbe nicht angegeben', detail: 'Eine Farbangabe erleichtert Suche und Filterung.', points: 2, field: 'color' })); if (!tags.length) issues.push(issue({ code: 'missing_tags', severity: 'recommendation', category: 'Reichweite', title: 'Keine Suchbegriffe', detail: 'Passende Tags oder Suchbegriffe können die Auffindbarkeit verbessern.', points: 3, field: 'tags', action: 'ai_tags' })); if (!normalizeText(listing.sku)) issues.push(issue({ code: 'missing_sku', severity: 'warning', category: 'Organisation', title: 'SKU fehlt', detail: 'Eine eindeutige Artikelnummer erleichtert Lager und Publishing.', points: 4, field: 'sku' })); if (!normalizeText(listing.storage_location)) issues.push(issue({ code: 'missing_storage', severity: 'recommendation', category: 'Organisation', title: 'Kein Lagerort', detail: 'Ein Lagerort verhindert Suchaufwand nach dem Verkauf.', points: 2, field: 'storage_location' })); const duplicates = findDuplicates(listing, allListings); if (duplicates.some(item => item.same_sku)) issues.push(issue({ code: 'duplicate_sku', severity: 'error', category: 'Duplikate', title: 'Doppelte SKU möglich', detail: 'Mindestens ein anderer Artikel verwendet dieselbe SKU.', points: 10, field: 'sku' })); else if (duplicates.length) issues.push(issue({ code: 'possible_duplicate', severity: 'warning', category: 'Duplikate', title: 'Ähnlicher Artikel gefunden', detail: 'Titel und Produktdaten ähneln mindestens einem bestehenden Artikel.', points: 5 })); const penalty = issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); const score = clamp(100 - penalty, 0, 100); const blockers = issues.filter(entry => entry.severity === 'error'); return { listing_id: listing.id, score, grade: gradeFor(score), blockers: blockers.length, warnings: issues.filter(entry => entry.severity === 'warning').length, recommendations: issues.filter(entry => entry.severity === 'recommendation').length, ready: blockers.length === 0 && score >= 70, platform: { id: listing.platform || '', name: profile.name, profile }, issues, duplicates, facts: { title_length: title.length, description_length: description.length, photo_count: photos.length, tag_count: tags.length, }, listing: { id: listing.id, title: listing.title, sku: listing.sku, platform: listing.platform, status: listing.status, price: listing.price, photo: photos[0] || null, updated_at: listing.updated_at, created_at: listing.created_at, }, }; } async function inspectImages(report, listing, uploadRoot) { const details = []; for (const photo of (listing.photos || []).slice(0, 12)) { const relative = String(photo || '').replace(/^[/\\]+/, ''); const absolute = resolve(uploadRoot, relative); if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) { details.push({ path: relative, missing: true }); report.issues.push(issue({ code: 'photo_missing_file', severity: 'error', category: 'Bilder', title: 'Bilddatei fehlt', detail: `Die Datei ${relative || 'unbekannt'} wurde nicht gefunden.`, points: 8, field: 'photos' })); continue; } try { const meta = await sharp(absolute).metadata(); const width = Number(meta.width || 0); const height = Number(meta.height || 0); const megapixels = Math.round((width * height / 1_000_000) * 10) / 10; details.push({ path: relative, width, height, megapixels, format: meta.format || '' }); if (Math.min(width, height) < 700) report.issues.push(issue({ code: 'photo_low_resolution', severity: 'warning', category: 'Bilder', title: 'Niedrige Bildauflösung', detail: `${relative}: ${width} × ${height} px.`, points: 3, field: 'photos' })); if (width === height && width < 1000) report.issues.push(issue({ code: 'photo_small_square', severity: 'recommendation', category: 'Bilder', title: 'Kleines quadratisches Bild', detail: `${relative} ist quadratisch, aber relativ klein.`, points: 1, field: 'photos' })); } catch { details.push({ path: relative, unreadable: true }); report.issues.push(issue({ code: 'photo_unreadable', severity: 'error', category: 'Bilder', title: 'Bild nicht lesbar', detail: `${relative} konnte nicht analysiert werden.`, points: 6, field: 'photos' })); } } report.images = details; const penalty = report.issues.reduce((sum, entry) => sum + Number(entry.points || 0), 0); report.score = clamp(100 - penalty, 0, 100); report.grade = gradeFor(report.score); report.blockers = report.issues.filter(entry => entry.severity === 'error').length; report.warnings = report.issues.filter(entry => entry.severity === 'warning').length; report.recommendations = report.issues.filter(entry => entry.severity === 'recommendation').length; report.ready = report.blockers === 0 && report.score >= 70; return report; } function persistReport(report) { const result = db.prepare(`INSERT INTO quality_reports (listing_id, score, grade, ready, blockers, warnings, recommendations, report_json, analyzed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run( report.listing_id, report.score, report.grade, report.ready ? 1 : 0, report.blockers, report.warnings, report.recommendations, JSON.stringify(report) ); return Number(result.lastInsertRowid); } export function getLatestQualityReport(listingId) { const row = db.prepare('SELECT * FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 1').get(listingId); if (!row) return null; const report = safeParseJson(row.report_json, null); return report ? { ...report, report_id: row.id, analyzed_at: row.analyzed_at } : null; } export async function analyzeListingQuality(listingId, { uploadRoot, persist = true } = {}) { const listing = getListing(listingId); if (!listing) return null; const allListings = getListings(); let report = inspectCore(listing, allListings); if (uploadRoot) report = await inspectImages(report, listing, uploadRoot); const fee = calculateFees(listing.platform, Number(listing.price) || 0); report.financials = { price: Number(listing.price) || 0, fees: fee.fees || 0, estimated_net: fee.net || 0, note: fee.note || '', breakdown: fee.breakdown || '' }; report.analyzed_at = new Date().toISOString(); if (persist) report.report_id = persistReport(report); return report; } export function listQualityCenter({ query = '', status = 'all', grade = 'all', page = 1, pageSize = 10 } = {}) { const allListings = getListings(); const allReports = allListings.map(listing => { const latest = getLatestQualityReport(listing.id); const listingChangedAt = Date.parse(String(listing.updated_at || listing.created_at || '').replace(' ', 'T') + 'Z') || 0; const analyzedAt = Date.parse(String(latest?.analyzed_at || '').replace(' ', 'T') + 'Z') || 0; return latest && analyzedAt >= listingChangedAt ? latest : inspectCore(listing, allListings); }); let reports = [...allReports]; const q = normalizeText(query).toLowerCase(); if (q) reports = reports.filter(item => [item.listing?.title, item.listing?.sku, item.platform?.name].some(value => String(value || '').toLowerCase().includes(q))); if (status === 'ready') reports = reports.filter(item => item.ready); if (status === 'blocked') reports = reports.filter(item => item.blockers > 0); if (status === 'review') reports = reports.filter(item => !item.ready && item.blockers === 0); if (grade !== 'all') reports = reports.filter(item => item.grade === grade); reports.sort((a, b) => a.score - b.score || String(b.listing?.updated_at || b.listing?.created_at || '').localeCompare(String(a.listing?.updated_at || a.listing?.created_at || ''))); const safeSize = [5, 10, 15, 25].includes(Number(pageSize)) ? Number(pageSize) : 10; const total = reports.length; const totalPages = Math.max(1, Math.ceil(total / safeSize)); const safePage = clamp(Number(page) || 1, 1, totalPages); const items = reports.slice((safePage - 1) * safeSize, safePage * safeSize); const summary = { total: allReports.length, ready: allReports.filter(report => report.ready).length, blocked: allReports.filter(report => report.blockers > 0).length, average_score: allReports.length ? Math.round(allReports.reduce((sum, report) => sum + report.score, 0) / allReports.length) : 0, }; return { items, pagination: { page: safePage, page_size: safeSize, total, total_pages: totalPages }, summary }; } export function getQualityHistory(listingId, limit = 12) { return db.prepare('SELECT id, score, grade, ready, blockers, warnings, recommendations, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT ?').all(listingId, Math.min(Math.max(Number(limit) || 12, 1), 50)); } export function compareQualityReports(listingId) { const rows = db.prepare('SELECT id, score, grade, analyzed_at FROM quality_reports WHERE listing_id = ? ORDER BY id DESC LIMIT 2').all(listingId); return { latest: rows[0] || null, previous: rows[1] || null, delta: rows.length > 1 ? rows[0].score - rows[1].score : 0 }; }