import { defineConfig } from 'vite' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { spawnSync } from 'child_process' import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'crypto' import { svelte } from '@sveltejs/vite-plugin-svelte' const apiPlugin = () => ({ name: 'z51-file-api', configureServer(server) { const __dirname = path.dirname(fileURLToPath(import.meta.url)) const dataDir = path.resolve(__dirname, 'data') const emailGenRoot = path.resolve(__dirname, '..', 'email-gen') const emailGenApiUrl = process.env.EMAIL_GEN_API_URL || '' const configFile = path.resolve(dataDir, 'config.json') const tokensFile = path.resolve(dataDir, 'google-tokens.json') const getRedirectUri = (req) => { if (process.env.APP_BASE_URL) return `${process.env.APP_BASE_URL.replace(/\/$/, '')}/oauth/callback` const proto = req.headers['x-forwarded-proto'] || 'http' return `${proto}://${req.headers.host}/oauth/callback` } const getValidAccessToken = async () => { const tokens = readJson(tokensFile, {}) if (!tokens.refresh_token) return null if (tokens.access_token && tokens.expiry_date && tokens.expiry_date > Date.now() + 60000) return tokens.access_token const config = readJson(configFile, {}) if (!config.client_id || !config.client_secret) return null try { const resp = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.client_id, client_secret: config.client_secret, refresh_token: tokens.refresh_token, grant_type: 'refresh_token' }), }) const data = await resp.json() if (!data.access_token) return null const updated = { ...tokens, access_token: data.access_token, expiry_date: Date.now() + (data.expires_in || 3600) * 1000 } writeJson(tokensFile, updated) return updated.access_token } catch { return null } } const ensureDir = (dir) => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) } const readJson = (file, fallback) => { try { if (!fs.existsSync(file)) return fallback return JSON.parse(fs.readFileSync(file, 'utf-8')) } catch { return fallback } } const writeJson = (file, data) => { fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8') } const sanitizeProjectSlug = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '') function applyNowrap(html) { // Protect @{...} template expressions from nowrap processing const templatePlaceholders = [] let protected_ = html.replace(/@\{[^}]*\}/g, (m) => { templatePlaceholders.push(m) return `\u200BTPL${templatePlaceholders.length - 1}\u200B` }) function wrapShort(text) { return text.replace( /(?]*class="[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi, (_match, open, content, close) => { const processed = content.replace(/>([^<]+) '>' + wrapShort(t) + '<') const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m)) return open + firstText + close } ) return result .replace(/\u200Bspan\u200Bnwr\u200B/g, '') .replace(/\u200B\/span\u200Bnwr\u200B/g, '') .replace(/\u200BTPL(\d+)\u200B/g, (_, i) => templatePlaceholders[parseInt(i)]) } const getProjectDir = (name) => { const dir = path.resolve(dataDir, name) if (!dir.startsWith(dataDir + path.sep)) return null return dir } const MAX_BODY_SIZE = 30 * 1024 * 1024 const readBody = (req) => new Promise((resolve) => { let size = 0 let data = '' req.on('data', (chunk) => { size += chunk.length if (size <= MAX_BODY_SIZE) data += chunk }) req.on('end', () => { if (size > MAX_BODY_SIZE) return resolve({}) try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) } }) }) // --- Image uploads --- const uploadsDir = path.resolve(dataDir, 'uploads') // Feed cache (persists across requests) const feedCache = new Map() const feedPending = new Map() const FEED_CACHE_TTL = 3 * 60 * 60 * 1000 // Site availability cache: url → { ts, available } TTL 3h const availabilityCache = new Map() async function checkSiteAvailability(url) { if (!url) return true const cached = availabilityCache.get(url) if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return cached.available try { const resp = await fetch(url, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'ASPEKTER/1.0' } }) const html = await resp.text() // Schema.org is unreliable on z51.ru — check actual button text instead const available = !html.includes('>Ожидаем<') && !html.includes('schema.org/OutOfStock') availabilityCache.set(url, { ts: Date.now(), available }) return available } catch { return true } // on error assume available } // Pug render cache by hash (LRU, max 30 entries) const renderCache = new Map() const RENDER_CACHE_MAX = 30 function pugHash(slug, pug) { return createHash('md5').update(slug + '\0' + pug).digest('hex') } function setRenderCache(key, html) { if (renderCache.size >= RENDER_CACHE_MAX) { const oldest = renderCache.keys().next().value renderCache.delete(oldest) } renderCache.set(key, html) } // --- Auth system --- const systemDir = path.resolve(dataDir, '_system') ensureDir(systemDir) const usersFile = path.resolve(systemDir, 'users.json') const sessionsStore = new Map() // token -> { userId, expiresAt } const SESSION_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days // Периодическая очистка просроченных сессий (каждые 30 мин) setInterval(() => { const now = Date.now() for (const [token, session] of sessionsStore) { if (session.expiresAt < now) sessionsStore.delete(token) } }, 30 * 60 * 1000) function hashPassword(password) { const salt = randomBytes(16).toString('hex') const hash = scryptSync(password, salt, 64).toString('hex') return `${salt}:${hash}` } function verifyPassword(password, stored) { const [salt, hash] = stored.split(':') if (!salt || !hash) return false const test = scryptSync(password, salt, 64).toString('hex') try { return timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex')) } catch { return false } } function getUsers() { return readJson(usersFile, []) } function saveUsers(users) { writeJson(usersFile, users) } function createSession(userId) { const token = randomBytes(32).toString('hex') sessionsStore.set(token, { userId, expiresAt: Date.now() + SESSION_TTL }) return token } function getSession(token) { const s = sessionsStore.get(token) if (!s) return null if (s.expiresAt < Date.now()) { sessionsStore.delete(token); return null } return s } function getTokenFromReq(req) { const cookie = req.headers.cookie || '' const m = cookie.match(/(?:^|;\s*)z51_token=([^;]+)/) return m ? m[1] : null } function setTokenCookie(res, token) { res.setHeader('Set-Cookie', `z51_token=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 3600}`) } function clearTokenCookie(res) { res.setHeader('Set-Cookie', 'z51_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0') } // --- Audit logging --- const auditLogsDir = path.resolve(systemDir, 'logs') ensureDir(auditLogsDir) function auditLog(req, action, details = {}) { try { const now = new Date() const file = path.resolve(auditLogsDir, `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}.jsonl`) const entry = { ts: now.getTime(), userId: req.user?.id || details._userId || null, login: req.user?.login || details._login || null, action, project: details.project || null, details: { ...details }, ip: (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket?.remoteAddress || '', } delete entry.details._userId delete entry.details._login delete entry.details.project fs.appendFileSync(file, JSON.stringify(entry) + '\n') } catch (_) {} } // Cleanup old logs (>6 months) on startup try { const cutoff = new Date() cutoff.setMonth(cutoff.getMonth() - 6) const cutoffStr = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}` for (const f of fs.readdirSync(auditLogsDir).filter(f => f.endsWith('.jsonl'))) { if (f.replace('.jsonl', '') < cutoffStr) fs.unlinkSync(path.resolve(auditLogsDir, f)) } } catch (_) {} // Seed admin if no users exist if (getUsers().length === 0) { saveUsers([{ id: randomBytes(8).toString('hex'), login: 'admin', passwordHash: hashPassword('admin'), name: 'Администратор', role: 'admin', projects: ['*'], }]) } // Auth endpoints (before auth middleware) server.middlewares.use(async (req, res, next) => { if (req.url === '/api/auth/login' && req.method === 'POST') { const body = await readBody(req) const { login, password } = body if (!login || !password) return send(400, { error: 'missing_credentials' }) const users = getUsers() const user = users.find(u => u.login === login) if (!user || !verifyPassword(password, user.passwordHash)) { auditLog(req, 'login_failed', { _login: login }) return send(401, { error: 'invalid_credentials' }) } const token = createSession(user.id) setTokenCookie(res, token) auditLog(req, 'login', { _userId: user.id, _login: user.login }) return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects } }) } if (req.url === '/api/auth/logout' && req.method === 'POST') { const token = getTokenFromReq(req) if (token) { const session = getSession(token) if (session) { const user = getUsers().find(u => u.id === session.userId) if (user) auditLog(req, 'logout', { _userId: user.id, _login: user.login }) } sessionsStore.delete(token) } clearTokenCookie(res) return send(200, { ok: true }) } if (req.url === '/api/auth/me' && req.method === 'GET') { const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) return send(401, { error: 'not_authenticated' }) const user = getUsers().find(u => u.id === session.userId) if (!user) return send(401, { error: 'user_not_found' }) return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects, theme: user.theme || 'light' } }) } if (req.url === '/api/auth/preferences' && req.method === 'PUT') { const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) return send(401, { error: 'not_authenticated' }) const body = await readBody(req) const users = getUsers() const idx = users.findIndex(u => u.id === session.userId) if (idx === -1) return send(404, { error: 'user_not_found' }) if (body.theme === 'dark' || body.theme === 'light') users[idx].theme = body.theme saveUsers(users) return send(200, { ok: true }) } return next() function send(status, payload) { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } }) // Auth middleware — protect all /api/ routes (except auth endpoints) server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/') || req.url.startsWith('/api/auth/')) return next() const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) { res.statusCode = 401 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'not_authenticated' })) } const user = getUsers().find(u => u.id === session.userId) if (!user) { res.statusCode = 401 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'user_not_found' })) } req.user = user return next() }) // Admin endpoints — user management server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/admin/')) return next() if (req.user?.role !== 'admin') { res.statusCode = 403 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'forbidden' })) } const send = (status, payload) => { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } if (req.url === '/api/admin/users' && req.method === 'GET') { const users = getUsers().map(u => ({ id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects })) return send(200, { users }) } if (req.url === '/api/admin/users' && req.method === 'POST') { const body = await readBody(req) const { login, password, name, role, projects } = body if (!login || !password) return send(400, { error: 'missing_fields' }) const users = getUsers() if (users.some(u => u.login === login)) return send(409, { error: 'login_exists' }) const newUser = { id: randomBytes(8).toString('hex'), login, passwordHash: hashPassword(password), name: name || login, role: role || 'user', projects: projects || [], } users.push(newUser) saveUsers(users) return send(200, { user: { id: newUser.id, login: newUser.login, name: newUser.name, role: newUser.role, projects: newUser.projects } }) } const userMatch = req.url.match(/^\/api\/admin\/users\/([^/]+)$/) if (userMatch) { const userId = decodeURIComponent(userMatch[1]) const users = getUsers() const idx = users.findIndex(u => u.id === userId) if (idx === -1) return send(404, { error: 'user_not_found' }) if (req.method === 'PUT') { const body = await readBody(req) if (body.name !== undefined) users[idx].name = body.name if (body.role !== undefined) users[idx].role = body.role if (body.projects !== undefined) users[idx].projects = body.projects if (body.password) users[idx].passwordHash = hashPassword(body.password) if (body.login && body.login !== users[idx].login) { if (users.some((u, i) => i !== idx && u.login === body.login)) return send(409, { error: 'login_exists' }) users[idx].login = body.login } saveUsers(users) const u = users[idx] return send(200, { user: { id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects } }) } if (req.method === 'DELETE') { if (users[idx].id === req.user.id) return send(400, { error: 'cannot_delete_self' }) users.splice(idx, 1) saveUsers(users) return send(200, { ok: true }) } } // --- Audit logs --- if (req.url.startsWith('/api/admin/logs') && req.method === 'GET') { const params = new URLSearchParams(req.url.split('?')[1] || '') const filterUser = params.get('user') || '' const filterProject = params.get('project') || '' const filterAction = params.get('action') || '' const from = parseInt(params.get('from')) || 0 const to = parseInt(params.get('to')) || Date.now() const page = Math.max(1, parseInt(params.get('page')) || 1) const limit = Math.min(200, Math.max(10, parseInt(params.get('limit')) || 50)) // Determine which JSONL files to read const fromDate = new Date(from || Date.now() - 30 * 24 * 60 * 60 * 1000) const toDate = new Date(to) const logFiles = [] try { for (const f of fs.readdirSync(auditLogsDir).filter(f => f.endsWith('.jsonl')).sort()) { const ym = f.replace('.jsonl', '') const fileMonth = new Date(ym + '-01') const fileEnd = new Date(fileMonth) fileEnd.setMonth(fileEnd.getMonth() + 1) if (fileEnd >= fromDate && fileMonth <= toDate) logFiles.push(f) } } catch (_) {} const entries = [] for (const f of logFiles) { try { const lines = fs.readFileSync(path.resolve(auditLogsDir, f), 'utf8').split('\n').filter(Boolean) for (const line of lines) { try { const entry = JSON.parse(line) if (entry.ts < (from || 0) || entry.ts > to) continue if (filterUser && entry.login !== filterUser) continue if (filterProject && entry.project !== filterProject) continue if (filterAction && entry.action !== filterAction) continue entries.push(entry) } catch (_) {} } } catch (_) {} } entries.sort((a, b) => b.ts - a.ts) // newest first const total = entries.length const offset = (page - 1) * limit const paged = entries.slice(offset, offset + limit) return send(200, { entries: paged, total, page, limit }) } return send(404, { error: 'not_found' }) }) server.middlewares.use(async (req, res, next) => { // Serve uploaded images at /uploads/... if (req.url?.startsWith('/uploads/')) { const filePath = path.resolve(uploadsDir, decodeURIComponent(req.url.replace('/uploads/', ''))) if (!filePath.startsWith(uploadsDir)) { res.statusCode = 403; return res.end('Forbidden') } if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') } const ext = path.extname(filePath).toLowerCase() const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream') res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') return fs.createReadStream(filePath).pipe(res) } // Serve local project images at /images/... if (req.url?.startsWith('/images/')) { const imagesBase = path.resolve(dataDir, 'images') const filePath = path.resolve(imagesBase, decodeURIComponent(req.url.replace('/images/', ''))) if (!filePath.startsWith(imagesBase)) { res.statusCode = 403; return res.end('Forbidden') } if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') } const ext = path.extname(filePath).toLowerCase() const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream') res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') return fs.createReadStream(filePath).pipe(res) } return next() }) server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/oauth/callback')) return next() const params = new URLSearchParams(req.url.replace('/oauth/callback?', '').replace('/oauth/callback', '')) const code = params.get('code'), error = params.get('error') const closeHtml = (result) => `` if (error || !code) { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8') return res.end(closeHtml({ ok: false, error: error || 'no_code' })) } const config = readJson(configFile, {}) try { const resp = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: config.client_id, client_secret: config.client_secret, redirect_uri: getRedirectUri(req), grant_type: 'authorization_code' }), }) const tokens = await resp.json() writeJson(tokensFile, { access_token: tokens.access_token || '', refresh_token: tokens.refresh_token || readJson(tokensFile, {}).refresh_token || '', expiry_date: Date.now() + (tokens.expires_in || 3600) * 1000, }) res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8') res.end(closeHtml({ ok: true })) } catch { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8') res.end(closeHtml({ ok: false, error: 'token_exchange_failed' })) } }) server.middlewares.use(async (req, res, next) => { if (!req.url.startsWith('/api/')) return next() ensureDir(dataDir) const send = (status, payload) => { // Auto audit-log all successful mutations if (status >= 200 && status < 300 && req.method !== 'GET') { const url = req.url.split('?')[0] const projMatch = url.match(/^\/api\/project\/([^/]+)\/(.+)$/) const action = projMatch ? projMatch[2].replace(/\//g, '_') : url.replace(/^\/api\//, '').replace(/\//g, '_') const project = projMatch ? decodeURIComponent(projMatch[1]) : null auditLog(req, action, { project }) } res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } const userCanAccessProject = (projectName) => { const u = req.user if (!u) return false if (u.role === 'admin' || (u.projects && u.projects.includes('*'))) return true return u.projects && u.projects.includes(projectName) } if (req.method === 'GET' && req.url === '/api/projects') { const HIDDEN_DIRS = new Set(['uploads', 'images', 'node_modules', '.git', '_system', 'drafts']) const projects = fs .readdirSync(dataDir, { withFileTypes: true }) .filter((d) => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name)) .filter((d) => userCanAccessProject(d.name)) .map((d) => d.name) return send(200, { projects }) } if (req.url === '/api/last-project') { const lastFile = path.resolve(dataDir, 'lastProject.json') if (req.method === 'GET') { const last = readJson(lastFile, { name: '' }) return send(200, { name: last.name || '' }) } if (req.method === 'PUT') { const body = await readBody(req) const name = String(body.name || '').trim() writeJson(lastFile, { name }) return send(200, { ok: true }) } } if (req.method === 'POST' && req.url === '/api/projects') { const body = await readBody(req) const name = String(body.name || '').trim() if (!name) return send(400, { error: 'missing_name' }) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) const defaultBlock = path.resolve(__dirname, 'public', 'Block.pug') const blockText = fs.existsSync(defaultBlock) ? fs.readFileSync(defaultBlock, 'utf-8') : '' fs.writeFileSync(path.resolve(dir, 'block.pug'), blockText, 'utf-8') writeJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} }) writeJson(path.resolve(dir, 'draft.json'), []) writeJson(path.resolve(dir, 'presets.json'), []) writeJson(path.resolve(dir, 'letters.json'), { list: [], currentId: '' }) ensureDir(path.resolve(dir, 'letters')) writeJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' }) ensureDir(path.resolve(dir, 'notes')) writeJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' }) return send(200, { ok: true }) } // Project access gate const projectAccessMatch = req.url.match(/^\/api\/project\/([^/]+)/) if (projectAccessMatch) { const projName = decodeURIComponent(projectAccessMatch[1]) if (!userCanAccessProject(projName)) return send(403, { error: 'no_project_access' }) } // Letters directory (shared across all users) const getUserLettersDir = (projectDir) => { const d = path.resolve(projectDir, 'letters') ensureDir(d) return d } const getUserLettersFile = (projectDir) => { return path.resolve(projectDir, 'letters.json') } const lettersMatch = req.url.match(/^\/api\/project\/([^/]+)\/letters$/) if (lettersMatch) { const name = decodeURIComponent(lettersMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) const lettersFile = getUserLettersFile(dir) if (req.method === 'GET') { const letters = readJson(lettersFile, { list: [], currentId: '' }) return send(200, letters) } if (req.method === 'PUT') { const body = await readBody(req) const next = { list: Array.isArray(body.list) ? body.list : [], currentId: typeof body.currentId === 'string' ? body.currentId : '', } writeJson(lettersFile, next) return send(200, { ok: true }) } } const historyMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter\/([^/]+)\/history$/) if (historyMatch) { const name = decodeURIComponent(historyMatch[1]) const id = decodeURIComponent(historyMatch[2]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const lettersDir = getUserLettersDir(dir) const histFile = path.resolve(lettersDir, `${id}.history.json`) if (req.method === 'GET') { return send(200, { history: readJson(histFile, []) }) } if (req.method === 'PUT') { const body = await readBody(req) const snapshot = body?.snapshot if (!snapshot) return send(400, { error: 'missing_snapshot' }) const history = readJson(histFile, []) history.unshift(snapshot) if (history.length > 20) history.splice(20) writeJson(histFile, history) return send(200, { ok: true }) } } const letterMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter(?:\/([^/]+))?$/) if (letterMatch) { const name = decodeURIComponent(letterMatch[1]) const id = letterMatch[2] ? decodeURIComponent(letterMatch[2]) : '' const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const lettersDir = getUserLettersDir(dir) if (req.method === 'GET' && id) { const file = path.resolve(lettersDir, `${id}.json`) const letter = readJson(file, null) return send(200, { letter }) } if (req.method === 'DELETE' && id) { const file = path.resolve(lettersDir, `${id}.json`) if (fs.existsSync(file)) fs.unlinkSync(file) return send(200, { ok: true }) } if (req.method === 'PUT') { const body = await readBody(req) const letterId = String(body.id || '').trim() if (!letterId) return send(400, { error: 'missing_id' }) const file = path.resolve(lettersDir, `${letterId}.json`) const existing = readJson(file, null) const userLogin = req.user?.login || 'unknown' if (!existing) { body.createdBy = userLogin } else if (!body.createdBy) { body.createdBy = existing.createdBy || userLogin } body.updatedBy = userLogin writeJson(file, body || {}) // Update letters index with createdBy/updatedBy try { const lettersIndex = readJson(path.resolve(lettersDir, '..', 'letters.json'), { list: [] }) const li = (lettersIndex.list || []).find(l => l.id === letterId) if (li) { li.createdBy = body.createdBy li.updatedBy = body.updatedBy writeJson(path.resolve(lettersDir, '..', 'letters.json'), lettersIndex) } } catch (_) {} return send(200, { ok: true }) } } const notesMatch = req.url.match(/^\/api\/project\/([^/]+)\/notes$/) if (notesMatch) { const name = decodeURIComponent(notesMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) const notesFile = path.resolve(dir, 'notes.json') if (req.method === 'GET') { const notes = readJson(notesFile, { list: [], currentId: '' }) return send(200, notes) } if (req.method === 'PUT') { const body = await readBody(req) const next = { list: Array.isArray(body.list) ? body.list : [], currentId: typeof body.currentId === 'string' ? body.currentId : '', } writeJson(notesFile, next) return send(200, { ok: true }) } } const noteMatch = req.url.match(/^\/api\/project\/([^/]+)\/note(?:\/([^/]+))?$/) if (noteMatch) { const name = decodeURIComponent(noteMatch[1]) const id = noteMatch[2] ? decodeURIComponent(noteMatch[2]) : '' const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const notesDir = path.resolve(dir, 'notes') ensureDir(notesDir) if (req.method === 'GET' && id) { const file = path.resolve(notesDir, `${id}.json`) const note = readJson(file, null) return send(200, { note }) } if (req.method === 'DELETE' && id) { const file = path.resolve(notesDir, `${id}.json`) if (fs.existsSync(file)) fs.unlinkSync(file) return send(200, { ok: true }) } if (req.method === 'PUT') { const body = await readBody(req) const noteId = String(body.id || '').trim() if (!noteId) return send(400, { error: 'missing_id' }) const file = path.resolve(notesDir, `${noteId}.json`) writeJson(file, body || {}) return send(200, { ok: true }) } } const renderMatch = req.url.match(/^\/api\/project\/([^/]+)\/render-email$/) if (renderMatch && req.method === 'POST') { const projectName = decodeURIComponent(renderMatch[1]) const body = await readBody(req) const slug = sanitizeProjectSlug(body.projectSlug) const pug = String(body.pug || '') if (!slug) return send(400, { error: 'missing_project_slug', details: 'Укажи папку проекта в email-gen' }) if (!pug.trim()) return send(400, { error: 'missing_pug', details: 'PUG пустой, нечего генерировать' }) // Start feed loading in parallel with render (await before Mindbox processing) const projDir = getProjectDir(projectName) const projSettings = projDir ? readJson(path.resolve(projDir, 'settings.json'), {}) : {} const feedUrl = projSettings.feedUrl || '' const feedPromise = feedUrl ? getFeedProducts(feedUrl).catch(() => null) : null // Check render cache by Pug hash const hash = pugHash(slug, pug) if (body.noCache) console.log('[render] noCache=true, skipping cache') let rawHtml = body.noCache ? null : renderCache.get(hash) if (!rawHtml) { if (emailGenApiUrl) { try { const forward = await fetch(`${emailGenApiUrl.replace(/\/$/, '')}/render`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectSlug: slug, pug }), signal: AbortSignal.timeout(45000), }) const payload = await forward.json().catch(() => ({})) if (!forward.ok) { return send(forward.status, { error: payload.error || 'render_failed', details: payload.details || 'Не удалось получить ответ от email-gen api', }) } rawHtml = payload.html || '' } catch (error) { return send(500, { error: 'email_gen_api_unreachable', details: error?.message || 'email-gen api недоступен', }) } } else { // Local render fallback if (!fs.existsSync(emailGenRoot)) return send(500, { error: 'email_gen_not_found', details: 'Папка email-gen не найдена рядом с проектом' }) const emailProjectDir = path.resolve(emailGenRoot, 'emails', slug) if (!emailProjectDir.startsWith(path.resolve(emailGenRoot, 'emails'))) { return send(400, { error: 'invalid_project_slug', details: 'Некорректное имя проекта email-gen' }) } if (!fs.existsSync(emailProjectDir)) { return send(404, { error: 'email_project_not_found', details: `Проект "${slug}" не найден в email-gen/emails` }) } const lettersDir = path.resolve(emailProjectDir, 'letters') ensureDir(lettersDir) fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8') const previewTemplateName = '__builder_preview__.pug' const previewTemplatePath = path.resolve(emailProjectDir, previewTemplateName) fs.writeFileSync( previewTemplatePath, ['extends ./html.pug', '', 'block content', ' include ./letters/let.pug', ''].join('\n'), 'utf-8' ) const renderScript = ` const path = require('path'); const fs = require('fs'); const Email = require('email-templates'); async function run() { const project = process.argv[1]; const root = process.argv[2]; const email = new Email(); const html = await email.render({ path: project + '/__builder_preview__', juiceResources: { preserveImportant: true, applyStyleTags: true, removeStyleTags: true, preserveMediaQueries: true, webResources: { relativeTo: path.resolve(root, 'emails', project) } }, }, { pretty: true }); const outDir = path.resolve(root, 'public'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const outPath = path.resolve(outDir, 'index.html'); fs.writeFileSync(outPath, String(html).replace('#MAILRU_PREHEADER_TAG#', ''), 'utf-8'); } run().catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); ` const run = spawnSync(process.execPath, ['-e', renderScript, slug, emailGenRoot], { cwd: emailGenRoot, encoding: 'utf-8', shell: false, timeout: 120000, }) if (fs.existsSync(previewTemplatePath)) fs.unlinkSync(previewTemplatePath) if (run.error || run.status !== 0) { return send(500, { error: 'render_failed', details: (run.stderr || run.stdout || run.error?.message || 'Ошибка генерации').trim(), }) } const previewFile = path.resolve(emailGenRoot, 'public', 'index.html') if (!fs.existsSync(previewFile)) { return send(500, { error: 'preview_not_found', details: 'email-gen не создал public/index.html' }) } rawHtml = fs.readFileSync(previewFile, 'utf-8') } // Cache the rendered HTML setRenderCache(hash, rawHtml) } // end of if (!rawHtml) — cache miss // Ensure feed is loaded before Mindbox processing if (feedPromise) await feedPromise // Process Mindbox tags server-side const mindbox = await processMindboxTags(rawHtml, feedUrl) // Process RetailCRM tags if configured const retailcrmCfg = projSettings.retailcrmConfig const previewHtml = retailcrmCfg?.url ? await processRetailCrmTags(mindbox.html, retailcrmCfg) : mindbox.html const finalHtml = applyNowrap(previewHtml) const finalRawHtml = applyNowrap(rawHtml) return send(200, { html: finalRawHtml, previewHtml: finalHtml, unavailableProducts: mindbox.unavailable, generatedAt: new Date().toISOString(), }) } if (req.url === '/api/config') { if (req.method === 'GET') { const c = readJson(configFile, {}) return send(200, { client_id: c.client_id || '', hasSecret: Boolean(c.client_secret), upload_base_url: c.upload_base_url || '', }) } if (req.method === 'PUT') { const body = await readBody(req) const existing = readJson(configFile, {}) writeJson(configFile, { client_id: String(body.client_id ?? existing.client_id ?? '').trim(), client_secret: String(body.client_secret || existing.client_secret || '').trim(), upload_base_url: String(body.upload_base_url ?? existing.upload_base_url ?? '').trim(), }) return send(200, { ok: true }) } } if (req.method === 'POST' && req.url === '/api/upload-image') { const body = await readBody(req) if (!body.imageData) return send(400, { error: 'Нет данных изображения' }) const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/) if (!dataMatch) return send(400, { error: 'Неверный формат изображения' }) const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } const ext = TYPES[dataMatch[1]] if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` }) const buffer = Buffer.from(dataMatch[2], 'base64') if (buffer.length > 20 * 1024 * 1024) return send(400, { error: 'Файл слишком большой (макс. 20 МБ)' }) const slug = (body.projectName || 'default').replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') const key = `${slug}/${Date.now()}-${randomBytes(4).toString('hex')}.${ext}` const filePath = path.resolve(uploadsDir, key) try { fs.mkdirSync(path.dirname(filePath), { recursive: true }) fs.writeFileSync(filePath, buffer) const config = readJson(configFile, {}) const baseUrl = config.upload_base_url ? config.upload_base_url.replace(/\/$/, '') : '' const url = baseUrl ? `${baseUrl}/uploads/${key}` : `/uploads/${key}` return send(200, { url }) } catch (e) { return send(500, { error: e?.message || 'Ошибка сохранения файла' }) } } // --- Link checking endpoint --- if (req.method === 'POST' && req.url === '/api/check-links') { const body = await readBody(req) const urls = Array.isArray(body.urls) ? body.urls.slice(0, 50) : [] if (!urls.length) return send(400, { error: 'no_urls' }) const results = await Promise.allSettled( urls.map(async (url) => { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal, headers: { 'User-Agent': 'ASPEKTER-LinkChecker/1.0' }, }) clearTimeout(timeout) return { url, status: res.status, ok: res.ok, redirected: res.redirected, finalUrl: res.url } } catch (e) { return { url, status: 0, ok: false, error: e?.message || 'Ошибка соединения' } } }) ) return send(200, { results: results.map(r => r.status === 'fulfilled' ? r.value : { url: '', status: 0, ok: false, error: 'error' }) }) } // --- Product feed endpoint (cached, lookup by IDs) --- function getFeedProducts(feedUrl) { const cached = feedCache.get(feedUrl) if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return Promise.resolve(cached.products) // Deduplicate in-flight requests, but expire stale pending after 2 min if (feedPending.has(feedUrl)) { const p = feedPending.get(feedUrl) if (Date.now() - p._startedAt < 120000) return p feedPending.delete(feedUrl) } const promise = _fetchFeedProducts(feedUrl) .finally(() => feedPending.delete(feedUrl)) promise._startedAt = Date.now() feedPending.set(feedUrl, promise) return promise } async function _fetchFeedProducts(feedUrl) { const resp = await fetch(feedUrl, { signal: AbortSignal.timeout(90000), headers: { 'User-Agent': 'ASPEKTER-FeedReader/1.0' }, }) if (!resp.ok) throw new Error(`Фид вернул ${resp.status}`) // Handle windows-1251 encoding const buf = await resp.arrayBuffer() const contentType = resp.headers.get('content-type') || '' let text if (contentType.includes('1251') || contentType.includes('windows')) { const decoder = new TextDecoder('windows-1251') text = decoder.decode(buf) } else { // Try to detect from XML declaration const preview = new TextDecoder('ascii').decode(buf.slice(0, 200)) const encMatch = preview.match(/encoding=["']([^"']+)["']/i) const enc = encMatch ? encMatch[1] : 'utf-8' try { text = new TextDecoder(enc).decode(buf) } catch { text = new TextDecoder('utf-8').decode(buf) } } const products = new Map() const offerRegex = /]*)>([\s\S]*?)<\/offer>/gi let m while ((m = offerRegex.exec(text)) !== null) { const id = m[1], attrs = m[2], block = m[3] const availAttr = attrs.match(/available="([^"]*)"/i) const available = availAttr ? availAttr[1].toLowerCase() === 'true' : true const tag = (t) => { const x = block.match(new RegExp(`<${t}[^>]*>([\\s\\S]*?)<\\/${t}>`,'i')); return x ? x[1].trim() : '' } const name = tag('name') || tag('title') const seriesMatch = name.match(/[«""]([^»""]+)[»""]/) products.set(id, { id, available, name, price: tag('price'), oldPrice: tag('oldprice'), image: tag('picture'), url: tag('url'), description: tag('description'), categoryId: tag('categoryId'), series: seriesMatch ? seriesMatch[1] : '', denomination: tag('denomination'), year: tag('year'), dia: tag('dia'), material: tag('material'), country: tag('country'), condition: tag('condition'), weight: tag('Weight') || tag('weight'), assay: tag('assay'), vendorCode: tag('vendorCode'), reverseImage: tag('reversePictureUrl'), salePercent: tag('SalePercent') || tag('salepercent'), }) } feedCache.set(feedUrl, { ts: Date.now(), products }) return products } // --- Server-side Mindbox tag processing --- function resolveProductProp(product, propPath) { if (!product) return '' const prop = propPath.toLowerCase() if (prop === 'name') return product.name || '' if (prop === 'url') return product.url || '' if (prop === 'pictureurl' || prop === 'picture' || prop === 'imageurl') return product.image || '' if (prop === 'price') return product.price || '' if (prop === 'oldprice') return product.oldPrice || '' if (prop === 'description') return product.description || '' const cfMatch = propPath.match(/^(?:customfield|additionaldata)\.(\w+)$/i) if (cfMatch) { const cf = cfMatch[1].toLowerCase() if (cf === 'denomination' || cf === 'nominal') return product.denomination || '' if (cf === 'year' || cf === 'god') return product.year || '' if (cf === 'dia' || cf === 'diameter' || cf === 'diametr') return product.dia || '' if (cf === 'material') return product.material || '' if (cf === 'country' || cf === 'strana') return product.country || '' if (cf === 'condition' || cf === 'sohrannost' || cf === 'soxrannost') return product.condition || '' if (cf === 'weight' || cf === 'ves') return product.weight || '' if (cf === 'assay' || cf === 'proba') return product.assay || '' if (cf === 'vendorcode' || cf === 'artikul') return product.vendorCode || '' if (cf === 'reversepictureurl') return product.reverseImage || '' if (cf === 'salepercent' || cf === 'sale') return product.salePercent || '' return product[cfMatch[1]] || product[cf] || '' } return '' } async function processMindboxTags(html, feedUrl) { const noResult = { html, unavailable: [] } if (!feedUrl) return noResult let work = html.replace(/'/g, "'").replace(/'/g, "'") // Remove @{for...}@{end for} blocks FIRST so recommendation IDs don't leak work = work.replace(/@\{\s*for\s[^}]*\}[\s\S]*?@\{\s*end\s+for\s*\}/gi, '') const idRegex = /GetByValue\(["'](\d+)["']\)/g const ids = new Set() let m while ((m = idRegex.exec(work)) !== null) ids.add(m[1]) if (!ids.size) return noResult let allProducts try { allProducts = await getFeedProducts(feedUrl) } catch (e) { return noResult } const products = {} const unavailable = [] for (const id of ids) { const p = allProducts.get(String(id)) if (p) { products[id] = p if (!p.available) unavailable.push({ id, name: p.name }) } } if (!Object.keys(products).length) return noResult let result = work result = result.replace(/@\{\s*if\s[^}]*\}/gi, '') result = result.replace(/@\{\s*end\s+if\s*\}/gi, '') result = result.replace(/\$\{\s*formatDecimal\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => { const p = products[id]; if (!p) return '' const val = resolveProductProp(p, propPath) return val ? Number(val).toLocaleString('ru-RU') : '' }) result = result.replace(/\$\{\s*ResizeImage\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => { return resolveProductProp(products[id], propPath) }) result = result.replace(/\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}/gi, (_, id, propPath) => { return resolveProductProp(products[id], propPath) }) result = result.replace(/-\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}%/gi, (_, id, propPath) => { const val = resolveProductProp(products[id], propPath) return val ? `-${val}%` : '' }) result = result.replace(/\$\{\s*category\.\w+\s*\}/gi, '') result = result.replace(/@\{[^}]*\}/g, '') result = result.replace(/\$\{\s*[^}]*(?:Products\.|ResizeImage|formatDecimal)[^}]*\}/g, '') // Add "Нет в наличии" overlay for unavailable products in preview const badge = '
Нет в наличии
' for (const item of unavailable) { const img = products[item.id]?.image if (img) { const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const imgRegex = new RegExp(`(]*src=["']${imgEsc}["'][^>]*>)`, 'i') result = result.replace(imgRegex, `
$1${badge}
`) } } return { html: result, unavailable } } async function processRetailCrmTags(html, cfg) { if (!cfg?.url || !cfg?.apiKey) return html let work = html.replace(/'/g, "'").replace(/'/g, "'") // Extract externalIds from entity_by_id('Product', 'ID', ...) const idRegex = /entity_by_id\(['"]Product['"],\s*['"](\d+)['"]/g const ids = new Set() let m while ((m = idRegex.exec(work)) !== null) ids.add(m[1]) if (!ids.size) { return work.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '') } let products = {} try { products = await fetchRetailCrmByIds(cfg, [...ids]) } catch (e) { /* ignore */ } // Split work into segments by {% set productCrm = entity_by_id(...) %} // Each segment belongs to one product context const setTagRegex = /\{%\s*set\s+\w+\s*=\s*entity_by_id\(['"]Product['"],\s*['"](\d+)['"][^%]*%\}/gi const segments = [] let lastIndex = 0, lastId = null, sm while ((sm = setTagRegex.exec(work)) !== null) { if (lastId !== null) segments.push({ id: lastId, text: work.slice(lastIndex, sm.index) }) else if (sm.index > 0) segments.push({ id: null, text: work.slice(0, sm.index) }) lastId = sm[1] lastIndex = sm.index + sm[0].length } segments.push({ id: lastId, text: work.slice(lastIndex) }) const processSegment = (text, p) => { // Remove {% for ... %} and {% endfor %} keeping inner content text = text.replace(/\{%\s*for\s+[^%]*%\}/gi, '').replace(/\{%\s*endfor\s*%\}/gi, '') if (!p) { return text.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '') } const price = p.price ? Number(p.price).toLocaleString('ru-RU') : '' // Replace image URL text = text.replace(/\{\{\s*productCrm\.imageUrl\s*\}\}/gi, p.image || '') text = text.replace(/\{\{\s*productCrm\.mainImage\.url\s*\}\}/gi, p.image || '') // Replace product URL text = text.replace(/\{\{\s*productCrm\.url\s*\}\}/gi, p.url || '') // Replace offer name text = text.replace(/\{\{\s*offer\.name\s*\}\}/gi, p.name || '') // Replace price text = text.replace(/\{\{\s*offer\.offerPrice\([^)]*\)\.price\s*\}\}/gi, price) text = text.replace(/\{\{\s*offer\.price\s*\}\}/gi, price) // Remove remaining tags text = text.replace(/\{%[^%]*%\}/g, '').replace(/\{\{[^}]*\}\}/g, '') // Add "Нет в наличии" badge for unavailable products if (!p.available && p.image) { const badge = '
Нет в наличии
' const imgEsc = p.image.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') text = text.replace(new RegExp(`(]*src=["']${imgEsc}["'][^>]*>)`, 'i'), `
$1${badge}
`) } return text } return segments.map(({ id, text }) => processSegment(text, id ? products[id] : null)).join('') } // --- RetailCRM API helpers --- function normalizeRetailCrmProduct(p) { const offer = (p.offers || [])[0] || {} return { id: String(p.id), name: p.name || '', price: String(offer.price || p.minPrice || ''), oldPrice: '', image: p.imageUrl || (offer.images || [])[0] || '', url: p.url || '', description: p.description || '', categoryId: String((p.groups || [])[0]?.externalId || ''), series: '', available: true, // will be overridden by site availability check manufacturer: p.manufacturer || '', } } async function fetchRetailCrmByIds(cfg, ids) { const params = new URLSearchParams({ apiKey: cfg.apiKey, limit: 100 }) ids.forEach(id => params.append('filter[ids][]', id)) const resp = await fetch(`${cfg.url}/api/v5/store/products?${params}`, { signal: AbortSignal.timeout(15000), }) if (!resp.ok) throw new Error(`RetailCRM: ${resp.status}`) const data = await resp.json() if (!data.success) throw new Error(data.errorMsg || 'RetailCRM error') const normalized = (data.products || []).map(normalizeRetailCrmProduct) // Check site availability in parallel (cached 3h) await Promise.all(normalized.map(async p => { if (p.url) p.available = await checkSiteAvailability(p.url) })) const result = {} for (const p of normalized) result[p.id] = p return result } async function searchRetailCrm(cfg, query, url) { const params = new URLSearchParams({ apiKey: cfg.apiKey, limit: 20 }) if (url) { params.set('filter[url]', url) } else if (query) { params.set('filter[name]', query) } const resp = await fetch(`${cfg.url}/api/v5/store/products?${params}`, { signal: AbortSignal.timeout(15000), }) if (!resp.ok) throw new Error(`RetailCRM: ${resp.status}`) const data = await resp.json() if (!data.success) throw new Error(data.errorMsg || 'RetailCRM error') // If URL search returned nothing, fall back to name search if (url && !(data.products || []).length && query) { return searchRetailCrm(cfg, query, null) } return (data.products || []).map(normalizeRetailCrmProduct) } // --- Feed refresh (clear cache + re-fetch, with diff) --- const feedRefreshMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-refresh$/) if (feedRefreshMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedRefreshMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const retailcrm = projSettings.retailcrmConfig const feedUrl = projSettings.feedUrl if (retailcrm?.url && retailcrm?.apiKey) { // RetailCRM: just test connection try { const params = new URLSearchParams({ apiKey: retailcrm.apiKey, limit: 20 }) const resp = await fetch(`${retailcrm.url}/api/v5/store/products?${params}`, { signal: AbortSignal.timeout(15000) }) const data = await resp.json() if (!data.success) return send(500, { error: data.errorMsg || 'RetailCRM error' }) return send(200, { count: data.pagination?.totalCount || 0, added: 0, removed: 0 }) } catch (e) { return send(500, { error: e?.message }) } } if (!feedUrl) return send(400, { error: 'Фид не настроен' }) // Remember old IDs before refresh const oldCached = feedCache.get(feedUrl) const oldIds = oldCached ? new Set(oldCached.products.keys()) : null feedCache.delete(feedUrl) feedPending.delete(feedUrl) try { const products = await getFeedProducts(feedUrl) const newIds = new Set(products.keys()) const diff = { count: products.size } if (oldIds) { const added = [...newIds].filter(id => !oldIds.has(id)) const removed = [...oldIds].filter(id => !newIds.has(id)) diff.added = added.length diff.removed = removed.length diff.addedProducts = added.slice(0, 10).map(id => { const p = products.get(id) return { id, name: p?.name || '' } }) } return send(200, diff) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } const feedLookupMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-lookup$/) if (feedLookupMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedLookupMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const body = await readBody(req) const ids = Array.isArray(body.ids) ? body.ids : [] const retailcrm = projSettings.retailcrmConfig if (retailcrm?.url && retailcrm?.apiKey) { try { const result = await fetchRetailCrmByIds(retailcrm, ids) return send(200, { products: result }) } catch (e) { return send(500, { error: e?.message }) } } const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'Фид не настроен' }) try { const allProducts = await getFeedProducts(feedUrl) const result = {} for (const id of ids) { const p = allProducts.get(String(id)) if (p) result[id] = p } return send(200, { products: result }) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } // --- Feed suggest replacements --- const feedSuggestMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-suggest$/) if (feedSuggestMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedSuggestMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const retailcrm = projSettings.retailcrmConfig if (retailcrm?.url && retailcrm?.apiKey) { const body = await readBody(req) const search = String(body.search || body.productId || '').trim() const productUrl = String(body.url || '').trim() try { const products = await searchRetailCrm(retailcrm, search, productUrl || null) const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String)) const suggestions = products.filter(p => !excludeIds.has(p.id)).map(p => ({ id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice, image: p.image, url: p.url, series: p.series, categoryId: p.categoryId, })) return send(200, { source: { id: search, name: search }, suggestions }) } catch (e) { return send(500, { error: e?.message }) } } const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'Фид не настроен' }) const body = await readBody(req) const productId = String(body.productId || '') const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String)) const search = String(body.search || '').toLowerCase().trim() if (!productId) return send(400, { error: 'missing productId' }) try { const allProducts = await getFeedProducts(feedUrl) const source = allProducts.get(productId) if (!source) return send(404, { error: 'Товар не найден в фиде' }) const candidates = [] for (const [id, p] of allProducts) { if (id === productId || excludeIds.has(id) || !p.available) continue // If search query provided, filter by name/id match if (search && !(p.name || '').toLowerCase().includes(search) && !id.includes(search)) continue let score = 0 if (!search) { // Auto-suggest: score by category + series + name similarity if (source.categoryId && p.categoryId === source.categoryId) score += 10 if (source.series && p.series) { if (p.series === source.series) { score += 30 } else { // Partial series match: compare base series (before " — ") const srcBase = source.series.split(/\s*—\s*/)[0].trim().toLowerCase() const pBase = p.series.split(/\s*—\s*/)[0].trim().toLowerCase() if (srcBase.length >= 3 && srcBase === pBase) score += 25 } } // Name word overlap: count significant shared words (4+ chars) if (score <= 10) { const srcWords = new Set((source.name || '').toLowerCase().split(/[\s,.()\-«»"]+/).filter(w => w.length >= 4)) const pWords = (p.name || '').toLowerCase().split(/[\s,.()\-«»"]+/).filter(w => w.length >= 4) const overlap = pWords.filter(w => srcWords.has(w)).length if (overlap >= 2) score += overlap * 3 } if (score === 0) continue } candidates.push({ ...p, score }) } candidates.sort((a, b) => b.score - a.score || (Number(b.price) || 0) - (Number(a.price) || 0)) const suggestions = candidates.slice(0, 20).map(p => ({ id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice, image: p.image, url: p.url, series: p.series, categoryId: p.categoryId, })) return send(200, { source: { id: source.id, name: source.name }, suggestions }) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } // --- Auto-assemble product blocks --- const autoAssembleMatch = req.url.match(/^\/api\/project\/([^/]+)\/auto-assemble$/) if (autoAssembleMatch && req.method === 'POST') { const projectName = decodeURIComponent(autoAssembleMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) try { const body = await readBody(req) const type = body.type || 'new' // 'new' | 'sale' const layout = Array.isArray(body.layout) ? body.layout.filter(n => n >= 1 && n <= 3) : null const target = layout ? layout.reduce((s, n) => s + n, 0) : Math.min(Math.max(parseInt(body.target) || 10, 3), 24) const forceExcludeIds = new Set(Array.isArray(body.excludeIds) ? body.excludeIds.map(String) : []) const anchorIds = Array.isArray(body.anchorIds) ? body.anchorIds.map(String) : null const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const mixinRules = projSettings.mixinRules || [] const productMixins = [...new Set(mixinRules.filter(r => r.type === 'mixin-ids').map(r => r.mixin))] if (!productMixins.length) return send(400, { error: 'Нет миксинов товаров в настройках проекта' }) // Map slot count → mixin name const getMixinSlots = (name) => { if (/3/.test(name)) return 3 if (/2/.test(name)) return 2 return 1 } const slotMap = {} for (const m of productMixins) { const s = getMixinSlots(m) if (!slotMap[s]) slotMap[s] = m } const slotKeys = Object.keys(slotMap).map(Number) const maxSlots = slotKeys.length > 0 ? Math.max(...slotKeys) : 3 const getMixinForCount = (count) => { for (let c = count; c >= 1; c--) { if (slotMap[c]) return slotMap[c] } return productMixins[0] } const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'feedUrl не настроен для проекта' }) const feedMap = await getFeedProducts(feedUrl) if (!feedMap || feedMap.size === 0) return send(400, { error: 'Фид пустой или недоступен' }) const allProducts = Array.from(feedMap.values()) // Material detection (needed early for excludeMaterials filter) const getMat = (p) => { const s = ((p.material || '') + ' ' + (p.name || '')).toLowerCase() if (/золот|gold|\bau\b/.test(s)) return 'gold' if (/серебр|silver|\bag\b/.test(s)) return 'silver' if (/платин|platinum|\bpt\b/.test(s)) return 'platinum' if (/биметалл|bimetal/.test(s)) return 'bimetal' if (/медь|медн|copper|бронза|bronze|латунь/.test(s)) return 'copper' return 'base' } // Category sets for type filtering const BANKNOTE_CATS = new Set(['8','31','32','33','34','35','36','37','38','59','60','61','84','85','86']) const COIN_CATS = new Set(['1','18','19','20','22','23','24','25','26','27','97','98','99']) const COPY_CATS = new Set(['7','28','77','78','79','80']) // Accessories / non-collectibles — always excluded const EXCLUDE_CATS = new Set([ '9','10','12','16','17', // Награды, Иконы, Аксессуары, Наборы, Значки '39','40','41','42','43', // Награды (подкатегории) '44','45','46','47','48','49','50','51', // Альбомы, Листы, Холдеры, Капсулы, Коробки, Литература '54','55','56','57','58', // Открытки, прочее '64','65','66','67','68','69','70','71','72','73','74','75','76', // Значки подкатегории '81','82','83', // Копии наград '53', // Знаки, значки '103','108','109','110','111', // Акции, Марки '128','137', // Дорожные чеки, Лотерейные билеты (AT) '139','143','144','146','155', // Лотерейные (KB), Конверты, Открытки ]) // Determine product type by categoryId + name const getProductType = (p) => { const cid = String(p.categoryId || '') if (BANKNOTE_CATS.has(cid)) return 'banknote' if (COPY_CATS.has(cid)) return 'copy' if (COIN_CATS.has(cid)) return 'coin' // Fallback: check name for non-coins const n = (p.name || '').toLowerCase() if (/банкнот|купюр/.test(n)) return 'banknote' if (/копия|реплика/.test(n)) return 'copy' if (/почтов\S*\s*марк|stamp|конверт|открытк|лотерейн|акция\s+на\s|облигаци|плакет|нашивк|вексел/.test(n)) return 'other' if (/билет\S*\s+(ммм|банк|лотер)/i.test(n)) return 'other' if (/сертификат|дорожный чек|дорожн\S*\s*чек|чек\S*\s*банк|ваучер|талон|облигац|вексел|расписк|лотере[яи]|купон/.test(n)) return 'banknote' if (/^знак\s|^значок|нашивк|^медаль\s|^жетон\s|^плакет/.test(n)) return 'other' // Coins always have material/weight; paper doesn't if (!(p.material || '').trim() && !(p.weight || '').trim()) return 'other' return 'coin' } const priceMin = parseFloat(body.priceMin) || 0 const priceMax = parseFloat(body.priceMax) || 0 const productType = body.productType || 'coin' const excludeMaterials = new Set(Array.isArray(body.excludeMaterials) ? body.excludeMaterials : []) let candidates = allProducts.filter(p => { if (!p.available || !p.id) return false if (forceExcludeIds.has(String(p.id))) return false if (EXCLUDE_CATS.has(String(p.categoryId || ''))) return false if (productType !== 'any' && getProductType(p) !== productType) return false const price = parseFloat(p.price) || 0 if (priceMin > 0 && price < priceMin) return false if (priceMax > 0 && price > priceMax) return false // Exclude precious metals if requested if (excludeMaterials.size > 0) { const mat = getMat(p) if (excludeMaterials.has(mat)) return false } return true }) if (type === 'sale') { candidates = candidates.filter(p => parseFloat(p.salePercent) > 0) candidates.sort((a, b) => parseFloat(b.salePercent) - parseFloat(a.salePercent)) } else { // 'new': sort by numeric ID descending (higher ID = newer) candidates.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0)) // Exclude items with permanent discounts (salePercent > 10%) candidates = candidates.filter(p => { const sp = parseFloat(p.salePercent) return !sp || sp <= 10 }) } // Limit to top candidates by primary criterion if (!layout) { const topN = Math.min(candidates.length, target * 10) candidates = candidates.slice(0, topN) } // ── Helpers ────────────────────────────────────────────────────────── // Uniqueness key: strip year + mint mark so "1 рейхспфенниг 1937 А" == "1 рейхспфенниг 1939 J" const getProductKey = (p) => { const rawName = (p.name || '').trim() // Prefer explicit series from field if (p.series) return p.series.toLowerCase().trim() // Extract series from «...» or "..." quotes const quotedMatch = rawName.match(/[«""]([^»""]{3,})[»""]/) if (quotedMatch) return quotedMatch[1].toLowerCase().trim() // Extract name after dash separator: "1 доллар 1879 года S США — Доллар Моргана" const dashMatch = rawName.match(/[—–-]\s*([А-ЯЁA-Z][а-яёa-z А-ЯЁA-Z]{3,})$/) if (dashMatch) return dashMatch[1].toLowerCase().trim() // Fallback: strip year + mint marks let name = rawName .replace(/\b\d{4}\b/g, '') .replace(/\b[A-ZА-ЯЁ]{1,4}\b/g, '') .replace(/копия/gi, '') .replace(/\s+/g, ' ').trim() return name.slice(0, 25).toLowerCase() } // Era bucket based on year const getEra = (year) => { const y = parseInt(year) if (!y || isNaN(y)) return 'unknown' if (y < 1800) return 'ancient' // до 1800 — антика if (y < 1900) return 'c19' // XIX век if (y < 1941) return 'early20' // начало XX if (y < 1946) return 'wwii' // ВОВ if (y < 1992) return 'soviet' // СССР/соцлагерь if (y < 2011) return 'post91' // 1992-2010 return 'modern' // 2011+ } // Price bracket const getPriceBucket = (price) => { const p = parseFloat(price) || 0 if (p < 400) return 'cheap' if (p < 2000) return 'mid' if (p < 10000) return 'exp' return 'premium' } // Diameter bucket: визуальное соответствие монет в ряду const getDiaBucket = (dia) => { const d = parseFloat(dia) || 0 if (d === 0) return 'unknown' if (d < 25) return 'small' // мелкие: копейки, сены, пфенниги if (d < 36) return 'medium' // средние: большинство оборотных монет return 'large' // крупные: талеры, 1oz silver, памятные } // Подтип для островного серебра: animals vs popculture vs other const getIslandSubtype = (p) => { const s = ((p.series || '') + ' ' + (p.name || '')).toLowerCase() if (/медвед|лев|волк|тигр|орёл|орел|черепах|сова|пеликан|акул|дельфин|конь|лошад|слон|пантер|пума|гиен|ягуар|бизон|кенгур|рыб|краб/.test(s)) return 'wildlife' if (/dc comics|marvel|star wars|звёздные войны|звездные войны|looney|disney|форсаж|гарри поттер|властелин|бэтмен|флэш|супермен|человек-паук|iron man|transformers/.test(s)) return 'popculture' if (/дракон|единорог|феникс|griffin|griffon|грифон|мифолог/.test(s)) return 'fantasy' return 'other' } // Macro-region const getRegion = (country) => { const c = (country || '').toLowerCase() if (/россия|рсфср/.test(c)) return 'russia' if (/российская империя|русская финляндия/.test(c)) return 'rus_empire' if (/ссср/.test(c)) return 'ussr' if (/япония/.test(c)) return 'japan' if (/китай/.test(c)) return 'china' if (/германия|пруссия|австрия|швейцария|франция|италия|испания|великобритания|нидерланды|бельгия|швеция|дания|норвегия|польша|финляндия/.test(c)) return 'europe' if (/сша|канада/.test(c)) return 'america_n' if (/мексика|гватемала|сальвадор|бразилия|аргентина|чили|перу|колумбия|боливия|куба/.test(c)) return 'america_l' if (/турция|османская|египет|иран|афганистан/.test(c)) return 'near_east' if (/индия|пакистан/.test(c)) return 'south_asia' if (/ниуэ|самоа|тувалу|токелау|барбадос|кука|палау|фиджи|кирибати|науру|тонга|вануату/.test(c)) return 'island_mint' return 'other' } // Style key: coins with same key go in one block const getStyleKey = (p) => { const era = getEra(p.year) const mat = getMat(p) const bucket = getPriceBucket(p.price) const region = getRegion(p.country) const dia = getDiaBucket(p.dia) // Серия = высший приоритет if (p.series) return `series_${p.series.toLowerCase().trim()}_${bucket}` // Россия/СССР/Империя серебро → группа по эпохе + цена + диаметр if ((region === 'russia' || region === 'ussr' || region === 'rus_empire') && mat === 'silver') { return `silver_${region}_${era}_${bucket}_${dia}` } // Островные монетные дворы: разбиваем по подтипу (wildlife / popculture / fantasy / other) + диаметр if (region === 'island_mint' && mat === 'silver' && era === 'modern') { const sub = getIslandSubtype(p) return `island_silver_${sub}_${bucket}_${dia}` } // Канада/США современное коллекционное серебро (1oz) — тоже в подтип if (region === 'america_n' && mat === 'silver' && era === 'modern' && dia === 'large') { const sub = getIslandSubtype(p) return `island_silver_${sub}_${bucket}_${dia}` } // Старые европейские дорогие монеты → по эпохе + диаметр if (region === 'europe' && mat === 'silver' && (era === 'c19' || era === 'early20' || era === 'ancient') && (bucket === 'exp' || bucket === 'premium')) { return `europe_old_silver_${era}_${bucket}_${dia}` } // Остальное: регион + эпоха + материал + цена + диаметр return `${region}_${era}_${mat}_${bucket}_${dia}` } // ── Group candidates by style key ───────────────────────────────────── const styleGroups = {} for (const p of candidates) { const key = getStyleKey(p) if (!styleGroups[key]) styleGroups[key] = [] styleGroups[key].push(p) } // Sort style groups: largest first (most candidates = most likely to fill blocks) const sortedGroups = Object.entries(styleGroups) .sort((a, b) => b[1].length - a[1].length) // Fisher-Yates shuffle helper const fisherYates = (arr) => { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]] } return arr } // ── Build blocks within each style group ───────────────────────────── const allRawBlocks = [] for (const [styleKey, items] of sortedGroups) { // Shuffle within each group → each regeneration picks different coins // But if group is series-based, keep series-mates together (sort by series first) let remaining if (styleKey.startsWith('series_')) { // Series group: sort by series then shuffle within each series const bySeries = {} for (const p of items) { const s = p.series || '_' if (!bySeries[s]) bySeries[s] = [] bySeries[s].push(p) } remaining = Object.values(bySeries).flatMap(arr => fisherYates(arr)) } else { remaining = fisherYates(items.slice()) } while (remaining.length > 0) { const blockItems = [] const usedKeys = new Set() const usedPrices = [] const anchorSeries = remaining[0]?.series || null const anchorPrice = parseFloat(remaining[0]?.price) || 0 // Build candidate scan order: same-price items first (within ±5%), then rest const samePrice = remaining.filter(p => { const pp = parseFloat(p.price) || 0 return anchorPrice > 0 && Math.abs(pp - anchorPrice) / anchorPrice <= 0.05 }) const otherPrice = remaining.filter(p => { const pp = parseFloat(p.price) || 0 return !(anchorPrice > 0 && Math.abs(pp - anchorPrice) / anchorPrice <= 0.05) }) const scanOrder = [...samePrice, ...otherPrice] for (let i = 0; i < scanOrder.length && blockItems.length < maxSlots; i++) { const p = scanOrder[i] const key = getProductKey(p) const price = parseFloat(p.price) || 0 if (usedKeys.has(key)) continue // Prefer same-series: skip different series if same-series items still available if (anchorSeries && blockItems.length < maxSlots - 1 && p.series && p.series !== anchorSeries) { const sameSeries = scanOrder.slice(i + 1).filter(x => x.series === anchorSeries && !usedKeys.has(getProductKey(x))).length if (sameSeries > 0) continue } // Price variance max 3x within block if (usedPrices.length > 0) { const prices = [...usedPrices, price].filter(x => x > 0) if (Math.max(...prices) / Math.max(Math.min(...prices), 1) > 3) continue } blockItems.push(p) usedKeys.add(key) if (price > 0) usedPrices.push(price) } if (!blockItems.length) break const slotCount = Math.min(blockItems.length, maxSlots) const label = blockItems[0].country || styleKey allRawBlocks.push({ styleKey, country: label, items: blockItems.slice(0, slotCount) }) const used = new Set(blockItems.map(p => p.id)) remaining.splice(0, remaining.length, ...remaining.filter(p => !used.has(p.id))) } } // ── Build result blocks ──────────────────────────────────────────── const result = [] const usedIds = new Set() let totalItems = 0 // ── Similarity score: how well do two coins pair ── const similarity = (a, b) => { let score = 0 const aSeries = (a.series || '').toLowerCase().trim() const bSeries = (b.series || '').toLowerCase().trim() if (aSeries && bSeries && aSeries === bSeries) score += 50 const aDenom = (a.denomination || '').toLowerCase().trim() const bDenom = (b.denomination || '').toLowerCase().trim() if (aDenom && bDenom && aDenom === bDenom) score += 20 if ((a.country || '') === (b.country || '')) score += 30 if (getEra(a.year) === getEra(b.year)) score += 15 if (getMat(a) === getMat(b)) score += 15 if (getDiaBucket(a.dia) === getDiaBucket(b.dia)) score += 10 const aPrice = parseFloat(a.price) || 0 const bPrice = parseFloat(b.price) || 0 if (aPrice > 0 && bPrice > 0) { const ratio = Math.max(aPrice, bPrice) / Math.max(Math.min(aPrice, bPrice), 1) if (ratio <= 2) score += 10 else if (ratio <= 3) score += 5 } return score } // ── Conflict check: too similar to show together ── const stripName = (p) => (p.name || '').replace(/\b\d{4}\b/g, '').replace(/\b[A-ZА-ЯЁ]{1,2}\b/g, '').replace(/\s+/g, ' ').trim().toLowerCase() const hasConflict = (a, b) => { if (getProductKey(a) === getProductKey(b)) return true if (stripName(a) === stripName(b)) return true const aDenom = (a.denomination || '').trim(), bDenom = (b.denomination || '').trim() const aYear = (a.year || '').trim(), bYear = (b.year || '').trim() if (aDenom && aDenom === bDenom && aYear && aYear === bYear) return true return false } // ── Scan letter history for recently used IDs ── const idLastUsed = {} try { const lettersDir = path.resolve(projDir, 'letters') const letterFiles = fs.existsSync(lettersDir) ? fs.readdirSync(lettersDir).filter(f => f.endsWith('.json') && !f.endsWith('.history.json') && f !== 'letters.json') : [] const cutoff = Date.now() - 25 * 24 * 60 * 60 * 1000 for (const fname of letterFiles) { try { const letter = JSON.parse(fs.readFileSync(path.resolve(lettersDir, fname), 'utf8')) const letterDate = new Date(letter.updatedAt || letter.date || 0).getTime() if (letterDate < cutoff) continue for (const block of (letter.blocks || [])) { const content = block.content || '' for (const m of content.matchAll(/["'](\d{3,8})["']/g)) { const pid = m[1] if (!idLastUsed[pid] || letterDate > idLastUsed[pid]) idLastUsed[pid] = letterDate } } } catch (_) {} } } catch (_) {} const nowTs = Date.now() const getUsedDaysAgo = (id) => { const ts = idLastUsed[String(id)] return ts != null ? Math.floor((nowTs - ts) / (24 * 60 * 60 * 1000)) : null } // ── Single coin replacement mode ── if (anchorIds && anchorIds.length > 0) { const idToProduct = {} for (const p of allProducts) idToProduct[p.id] = p const anchors = anchorIds.map(id => idToProduct[id]).filter(Boolean) if (anchors.length > 0) { // Collect anchor properties for hard filters const anchorCountries = new Set(anchors.map(a => (a.country || '').trim()).filter(Boolean)) const anchorRegions = new Set(anchors.map(a => getRegion(a.country))) const anchorSeries = new Set(anchors.map(a => (a.series || '').toLowerCase().trim()).filter(Boolean)) const anchorMats = new Set(anchors.map(a => getMat(a))) const anchorPrices = anchors.map(a => parseFloat(a.price) || 0).filter(p => p > 0) const avgAnchorPrice = anchorPrices.length ? anchorPrices.reduce((s, p) => s + p, 0) / anchorPrices.length : 0 const scoreAll = candidates .filter(p => !forceExcludeIds.has(String(p.id))) .map(p => { const avgScore = anchors.reduce((s, a) => s + similarity(a, p), 0) / anchors.length const conflict = anchors.some(a => hasConflict(a, p)) return { p, score: conflict ? -1 : avgScore } }) .filter(s => s.score >= 0) .sort((a, b) => b.score - a.score) // Tier 1: same series (strongest match) let pool = scoreAll.filter(s => { const ps = (s.p.series || '').toLowerCase().trim() return ps && anchorSeries.has(ps) }) // Tier 2: same country + same material + similar price (±2x) if (pool.length < 3) { pool = scoreAll.filter(s => { if (!anchorCountries.has((s.p.country || '').trim())) return false if (!anchorMats.has(getMat(s.p))) return false const pp = parseFloat(s.p.price) || 0 if (avgAnchorPrice > 0 && pp > 0) { const ratio = Math.max(pp, avgAnchorPrice) / Math.min(pp, avgAnchorPrice) if (ratio > 2) return false } return true }) } // Tier 3: same country (any material) if (pool.length < 3) { pool = scoreAll.filter(s => anchorCountries.has((s.p.country || '').trim())) } // Tier 4: same region + same material if (pool.length < 3) { pool = scoreAll.filter(s => { return anchorRegions.has(getRegion(s.p.country)) && anchorMats.has(getMat(s.p)) }) } // Tier 5: same region if (pool.length < 3) { pool = scoreAll.filter(s => anchorRegions.has(getRegion(s.p.country))) } // Last resort: top-scored overall (but only if score >= 30) if (pool.length === 0) { pool = scoreAll.filter(s => s.score >= 30) } // Pick random from top-5 of the chosen pool const top = pool.slice(0, Math.min(5, pool.length)) const pick = top.length > 0 ? top[Math.floor(Math.random() * top.length)].p : null if (pick) { const p = pick return send(200, { replacement: { id: p.id, name: p.name||'', price: p.price||'', oldPrice: p.oldPrice||'', image: p.image||'', salePercent: p.salePercent||'', usedDaysAgo: getUsedDaysAgo(p.id) } }) } return send(200, { replacement: null }) } } if (layout) { // Layout mode: country grouping + scoring within group + diversity // Sort candidates by freshness (newest first) candidates.sort((a, b) => (parseInt(b.id) || 0) - (parseInt(a.id) || 0)) // Deduplicate by product key const seenKeys = new Set() candidates = candidates.filter(p => { const key = getProductKey(p) if (seenKeys.has(key)) return false seenKeys.add(key) return true }) // ── Group by country ── const byCountry = {} for (const p of candidates) { const c = (p.country || 'Другое').trim() if (!byCountry[c]) byCountry[c] = [] byCountry[c].push(p) } // Sort countries by item count desc const countryGroups = Object.entries(byCountry) .sort((a, b) => b[1].length - a[1].length) .map(([country, items]) => ({ country, items, region: getRegion(items[0]?.country) })) // ── Pick best N coins from a group using anchor + scoring + diversity ── const pickFromGroup = (items, size) => { const available = items.filter(p => !usedIds.has(p.id)) if (available.length < size) return null // Anchor = random from top-5 freshest (for variety on regenerate) const anchorPool = available.slice(0, Math.min(5, available.length)) const anchor = anchorPool[Math.floor(Math.random() * anchorPool.length)] if (size === 1) return [anchor] // Score others against anchor const scored = available.filter(p => p !== anchor) .map(p => ({ p, score: similarity(anchor, p) })) .sort((a, b) => b.score - a.score) const picked = [anchor] for (const { p } of scored) { if (picked.length >= size) break if (picked.some(existing => hasConflict(existing, p))) continue picked.push(p) } return picked.length >= size ? picked : null } // ── Build region groups as fallback ── const byRegion = {} for (const g of countryGroups) { if (!byRegion[g.region]) byRegion[g.region] = [] byRegion[g.region].push(...g.items) } const regionGroups = Object.entries(byRegion) .sort((a, b) => b[1].length - a[1].length) .map(([region, items]) => ({ region, items })) // ── Fill layout slots ── let countryIdx = Math.floor(Math.random() * countryGroups.length) for (const slotSize of layout) { let picked = null // 1) Try country groups (round-robin) for (let attempt = 0; attempt < countryGroups.length; attempt++) { const gi = (countryIdx + attempt) % countryGroups.length picked = pickFromGroup(countryGroups[gi].items, slotSize) if (picked) { countryIdx = (gi + 1) % countryGroups.length break } } // 2) Try region groups if (!picked) { for (const rg of regionGroups) { picked = pickFromGroup(rg.items, slotSize) if (picked) break } } // 3) Last resort: just take freshest available if (!picked) { picked = candidates.filter(p => !usedIds.has(p.id)).slice(0, slotSize) } if (!picked.length) break picked.forEach(p => usedIds.add(p.id)) result.push({ mixin: getMixinForCount(picked.length), ids: picked.map(p => p.id), country: picked[0]?.country || '', styleKey: '', }) totalItems += picked.length } } else { // Original auto mode: interleave style groups const blocksByStyle = {} for (const b of allRawBlocks) { if (!blocksByStyle[b.styleKey]) blocksByStyle[b.styleKey] = [] blocksByStyle[b.styleKey].push(b) } const styleQueues = Object.values(blocksByStyle).sort((a, b) => b.length - a.length) while (totalItems < target) { let added = false for (const queue of styleQueues) { if (totalItems >= target) break if (!queue.length) continue const block = queue.shift() const ids = block.items.map(p => p.id).filter(id => !usedIds.has(id)) if (!ids.length) continue ids.forEach(id => usedIds.add(id)) const label = block.country || block.styleKey result.push({ mixin: getMixinForCount(ids.length), ids, country: label, styleKey: block.styleKey }) totalItems += ids.length added = true } if (!added) break } } // Enrich with product details for preview const idToProduct = {} for (const p of allProducts) idToProduct[p.id] = p const blocksOut = result.map(b => ({ mixin: b.mixin, ids: b.ids, country: b.country, styleKey: b.styleKey || '', products: b.ids.map(id => { const p = idToProduct[id] || {} return { id, name: p.name || '', price: p.price || '', oldPrice: p.oldPrice || '', image: p.image || '', salePercent: p.salePercent || '', usedDaysAgo: getUsedDaysAgo(id) } }), })) return send(200, { blocks: blocksOut, total: totalItems }) } catch (e) { return send(500, { error: e?.message || 'Ошибка автоподбора' }) } } // --- FTP/SFTP endpoints --- const ftpMatch = req.url.match(/^\/api\/project\/([^/]+)\/ftp\/(test|upload|list)$/) if (ftpMatch && req.method === 'POST') { const projectName = decodeURIComponent(ftpMatch[1]) const ftpAction = ftpMatch[2] const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const imageStorage = projSettings.imageStorage || 'ftp' const localImageBaseUrl = process.env.LOCAL_IMAGE_BASE_URL || '' // ── Local image storage ── if (imageStorage === 'local') { const imagesDir = path.resolve(dataDir, 'images', projectName) if (ftpAction === 'test') { return send(200, { ok: true, message: `Локальное хранение: ${imagesDir}` }) } if (ftpAction === 'upload') { const body = await readBody(req) if (!body.imageData) return send(400, { error: 'Нет данных изображения' }) const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/) if (!dataMatch) return send(400, { error: 'Неверный формат' }) const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } const ext = TYPES[dataMatch[1]] if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` }) const buffer = Buffer.from(dataMatch[2], 'base64') const folder = String(body.folder || '').trim() const fileName = String(body.fileName || '').trim() if (!folder) return send(400, { error: 'Не указана папка' }) if (!fileName) return send(400, { error: 'Не указано имя файла' }) const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') || 'file' const localDir = path.resolve(imagesDir, folder) const localFile = path.resolve(localDir, `${safeName}.${ext}`) try { fs.mkdirSync(localDir, { recursive: true }) fs.writeFileSync(localFile, buffer) const publicUrl = `${localImageBaseUrl}/${projectName}/${folder}/${safeName}.${ext}` return send(200, { url: publicUrl }) } catch (e) { return send(500, { error: 'Ошибка сохранения: ' + (e?.message || '') }) } } if (ftpAction === 'list') { const body = await readBody(req) const folder = String(body.folder || '').trim() if (!folder) return send(400, { error: 'Не указана папка' }) const localDir = path.resolve(imagesDir, folder) try { if (!fs.existsSync(localDir)) return send(200, { files: [] }) const entries = fs.readdirSync(localDir).filter(f => /\.(png|jpe?g|gif|webp)$/i.test(f)) const files = entries.map(name => ({ name, url: `${localImageBaseUrl}/${projectName}/${folder}/${name}`, })) return send(200, { files }) } catch (e) { return send(500, { error: e?.message || 'Ошибка чтения' }) } } return send(400, { error: 'Неизвестное действие' }) } const fc = projSettings.ftpConfig || {} if (!fc.host) return send(400, { error: 'FTP не настроен для этого проекта' }) const connectFtp = async () => { const { Client } = await import('basic-ftp') const client = new Client() client.ftp.verbose = false client.ftp.timeout = 15000 await client.access({ host: fc.host, port: parseInt(fc.port) || 21, user: fc.user, password: fc.password, secure: false, }) return client } const connectSftp = async () => { const SftpClient = (await import('ssh2-sftp-client')).default const sftp = new SftpClient() await sftp.connect({ host: fc.host, port: parseInt(fc.port) || 22, username: fc.user, password: fc.password, readyTimeout: 15000, }) return sftp } if (ftpAction === 'test') { try { if (fc.protocol === 'sftp') { const sftp = await connectSftp() const exists = await sftp.exists(fc.remotePath || '/') await sftp.end() return send(200, { ok: true, message: `Подключено. Путь ${fc.remotePath || '/'} ${exists ? 'существует' : 'не найден (будет создан)'}` }) } else { const client = await connectFtp() await client.ensureDir(fc.remotePath || '/') client.close() return send(200, { ok: true, message: 'Подключено. Путь доступен.' }) } } catch (e) { return send(500, { error: 'connection_failed', details: e?.message || 'Не удалось подключиться' }) } } if (ftpAction === 'upload') { const body = await readBody(req) if (!body.imageData) return send(400, { error: 'Нет данных изображения' }) const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/) if (!dataMatch) return send(400, { error: 'Неверный формат' }) const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } const ext = TYPES[dataMatch[1]] if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` }) const buffer = Buffer.from(dataMatch[2], 'base64') const folder = String(body.folder || '').trim() const fileName = String(body.fileName || '').trim() if (!folder) return send(400, { error: 'Не указана папка (дата письма)' }) if (!fileName) return send(400, { error: 'Не указано имя файла' }) const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') || 'file' const remoteDir = `${fc.remotePath}/${folder}` const remoteFile = `${remoteDir}/${safeName}.${ext}` const steps = [] try { if (fc.protocol === 'sftp') { steps.push('sftp: connecting') const sftp = await connectSftp() steps.push('sftp: mkdir ' + remoteDir) await sftp.mkdir(remoteDir, true) steps.push('sftp: uploading ' + remoteFile) await sftp.put(buffer, remoteFile) steps.push('sftp: done') await sftp.end() } else { const client = await connectFtp() await client.ensureDir(remoteDir) const tmpFile = path.resolve(dataDir, `.ftp-tmp-${Date.now()}.${ext}`) try { fs.writeFileSync(tmpFile, buffer) await client.uploadFrom(tmpFile, `${safeName}.${ext}`) } finally { try { fs.unlinkSync(tmpFile) } catch {} client.close() } } const publicUrl = `${fc.baseUrl}/${folder}/${safeName}.${ext}` return send(200, { url: publicUrl, steps }) } catch (e) { return send(500, { error: 'upload_failed', details: e?.message || 'Ошибка загрузки', steps }) } } if (ftpAction === 'list') { const body = await readBody(req) const folder = String(body.folder || '').trim() if (!folder) return send(400, { error: 'Не указана папка' }) const remoteDir = `${fc.remotePath}/${folder}` try { let files = [] if (fc.protocol === 'sftp') { const sftp = await connectSftp() const exists = await sftp.exists(remoteDir) if (exists) { const listing = await sftp.list(remoteDir) files = listing .filter(f => f.type === '-' && /\.(png|jpe?g|gif|webp)$/i.test(f.name)) .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` })) } await sftp.end() } else { const client = await connectFtp() try { const listing = await client.list(remoteDir) files = listing .filter(f => f.type === 1 && /\.(png|jpe?g|gif|webp)$/i.test(f.name)) .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` })) } catch { /* folder doesn't exist yet */ } client.close() } return send(200, { files, folder }) } catch (e) { return send(500, { error: 'list_failed', details: e?.message || 'Ошибка получения списка' }) } } } if (req.method === 'GET' && req.url === '/api/sheets/auth-status') { const config = readJson(configFile, {}) const tokens = readJson(tokensFile, {}) return send(200, { configured: Boolean(config.client_id && config.client_secret), authorized: Boolean(tokens.refresh_token) }) } if (req.method === 'GET' && req.url === '/api/sheets/auth') { const config = readJson(configFile, {}) if (!config.client_id || !config.client_secret) return send(400, { error: 'not_configured' }) const oauthParams = new URLSearchParams({ client_id: config.client_id, redirect_uri: getRedirectUri(req), response_type: 'code', scope: 'https://www.googleapis.com/auth/spreadsheets', access_type: 'offline', prompt: 'consent', }) res.statusCode = 302 res.setHeader('Location', `https://accounts.google.com/o/oauth2/v2/auth?${oauthParams}`) return res.end() } const sheetsReadMatch = req.url?.match(/^\/api\/sheets\/read\?(.+)$/) if (sheetsReadMatch && req.method === 'GET') { const p = new URLSearchParams(sheetsReadMatch[1]) const spreadsheetId = p.get('spreadsheetId') || '', sheetName = p.get('sheetName') || '' if (!spreadsheetId || !sheetName) return send(400, { error: 'missing_params' }) const accessToken = await getValidAccessToken() if (!accessToken) return send(401, { error: 'not_authorized' }) const range = encodeURIComponent(`${sheetName}!A:Z`) try { const resp = await fetch( `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?ranges=${range}&fields=sheets.data.rowData.values(formattedValue,hyperlink,textFormatRuns.format.link,chipRuns.chip.richLinkProperties)`, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(15000) }, ) if (!resp.ok) { const err = await resp.json().catch(() => ({})) return send(resp.status, { error: err?.error?.message || 'sheets_error' }) } const data = await resp.json() const sheet = data.sheets?.[0]?.data?.[0] const rowData = sheet?.rowData || [] const values = rowData.map(r => (r.values || []).map(c => c?.formattedValue || '')) const hyperlinks = rowData.map(r => (r.values || []).map(c => { if (c?.hyperlink) return c.hyperlink const runs = c?.textFormatRuns if (runs) { for (const run of runs) { if (run?.format?.link?.uri) return run.format.link.uri } } const chips = c?.chipRuns if (chips) { for (const cr of chips) { if (cr?.chip?.richLinkProperties?.uri) return cr.chip.richLinkProperties.uri } } return '' })) return send(200, { values, hyperlinks }) } catch (err) { return send(500, { error: 'fetch_failed', details: err?.message || 'Не удалось обратиться к Google Sheets API' }) } } if (req.method === 'POST' && req.url === '/api/sheets/write') { const body = await readBody(req) const { spreadsheetId, sheetName, col, row, value } = body if (!spreadsheetId || !sheetName || !col || !row) return send(400, { error: 'missing_params' }) const accessToken = await getValidAccessToken() if (!accessToken) return send(401, { error: 'not_authorized' }) const range = encodeURIComponent(`${sheetName}!${col}${row}`) try { const resp = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}?valueInputOption=USER_ENTERED`, { method: 'PUT', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: [[value]] }), signal: AbortSignal.timeout(15000), }) if (!resp.ok) { const err = await resp.json().catch(() => ({})) return send(resp.status, { error: err?.error?.message || 'write_error' }) } return send(200, { ok: true }) } catch (err) { return send(500, { error: 'fetch_failed', details: err?.message }) } } // --- Stats --- const statsMatch = req.url.match(/^\/api\/project\/([^/]+)\/stats$/) if (statsMatch) { const name = decodeURIComponent(statsMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const statsPath = path.resolve(dir, 'stats.json') if (req.method === 'GET') { return send(200, { entries: readJson(statsPath, []) }) } if (req.method === 'POST') { const body = await readBody(req) const entry = body?.entry if (!entry?.letterId) return send(400, { error: 'missing letterId' }) entry.userId = req.user?.id || '' entry.userName = req.user?.name || '' const entries = readJson(statsPath, []) const idx = entries.findIndex(e => e.letterId === entry.letterId && e.userId === entry.userId) if (idx >= 0) { entries[idx] = { ...entries[idx], ...entry } } else { entries.push(entry) } fs.writeFileSync(statsPath, JSON.stringify(entries, null, 2)) return send(200, { ok: true }) } } const allStatsMatch = req.method === 'GET' && req.url === '/api/stats' if (allStatsMatch) { const HIDDEN_DIRS = new Set(['uploads', 'images', 'node_modules', '.git', '_system', 'drafts']) const projects = fs.readdirSync(dataDir, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name)) .map(d => d.name) const all = {} for (const p of projects) { const sp = path.resolve(dataDir, p, 'stats.json') const entries = readJson(sp, []) if (entries.length) all[p] = entries } return send(200, { stats: all }) } const match = req.url.match(/^\/api\/project\/([^/]+)(\/(block|settings|draft|presets))?$/) if (!match) return send(404, { error: 'not_found' }) const name = decodeURIComponent(match[1]) const action = match[3] const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) if (req.method === 'GET' && !action) { const blockPath = path.resolve(dir, 'block.pug') const meta = readJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' }) const settings = readJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} }) // Маскируем FTP-пароль if (settings.ftpConfig?.password) { settings.ftpConfig = { ...settings.ftpConfig, password: '', hasPassword: true } } const userDraftFile = path.resolve(dir, 'drafts', `${req.user?.id || '_default'}.json`) const draft = readJson(userDraftFile, []) const presets = readJson(path.resolve(dir, 'presets.json'), []) const letters = readJson(getUserLettersFile(dir), { list: [], currentId: '' }) const notes = readJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' }) const blockText = fs.existsSync(blockPath) ? fs.readFileSync(blockPath, 'utf-8') : '' return send(200, { name, meta, settings, draft, presets, letters, notes, blockText }) } if (req.method === 'PUT' && action) { const body = await readBody(req) if (action === 'block') { fs.writeFileSync(path.resolve(dir, 'block.pug'), body.blockText || '', 'utf-8') writeJson(path.resolve(dir, 'meta.json'), { sourceName: body.sourceName || 'Block.pug' }) return send(200, { ok: true }) } if (action === 'block-custom') { fs.writeFileSync(path.resolve(dir, 'block-custom.pug'), body.content || '', 'utf-8') return send(200, { ok: true }) } if (action === 'settings') { const incoming = body || { globalSpacing: 40, blocks: {} } // Сохраняем существующий FTP-пароль если не передан новый if (incoming.ftpConfig && !incoming.ftpConfig.password) { const existing = readJson(path.resolve(dir, 'settings.json'), {}) if (existing.ftpConfig?.password) { incoming.ftpConfig.password = existing.ftpConfig.password } } writeJson(path.resolve(dir, 'settings.json'), incoming) return send(200, { ok: true }) } if (action === 'draft') { const draftsDir = path.resolve(dir, 'drafts') ensureDir(draftsDir) writeJson(path.resolve(draftsDir, `${req.user?.id || '_default'}.json`), body || []) return send(200, { ok: true }) } if (action === 'presets') { writeJson(path.resolve(dir, 'presets.json'), body || []) return send(200, { ok: true }) } } return send(405, { error: 'method_not_allowed' }) }) }, }) // https://vite.dev/config/ export default defineConfig({ plugins: [svelte(), apiPlugin()], server: { allowedHosts: ['app.aspekter.ru', 'aspekter.ru', '.aspekter.ru'], watch: { usePolling: true, interval: 300, }, proxy: { '/typograf': { target: 'http://typograf.artlebedev.ru', changeOrigin: true, rewrite: (path) => path.replace(/^\/typograf/, ''), }, '/speller': { target: 'https://speller.yandex.net', changeOrigin: true, rewrite: (path) => path.replace(/^\/speller/, ''), }, }, }, })