- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем - email-gen: Node.js рендерер Pug→HTML через email-templates + Juice - email-gen-api: HTTP сервер рендеринга (порт 8787) - coin-scout: сервис подбора монет из фидов - Docker Compose для dev/prod - Nginx конфиг с SSL для app.aspekter.ru
2381 lines
113 KiB
JavaScript
2381 lines
113 KiB
JavaScript
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(
|
||
/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s| )+(\S+)/gu,
|
||
'\u200Bspan\u200Bnwr\u200B$1\u00A0$2\u200B/span\u200Bnwr\u200B'
|
||
)
|
||
}
|
||
const result = protected_.replace(
|
||
/(<span[^>]*class="[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi,
|
||
(_match, open, content, close) => {
|
||
const processed = content.replace(/>([^<]+)</g, (m, t) => '>' + wrapShort(t) + '<')
|
||
const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m))
|
||
return open + firstText + close
|
||
}
|
||
)
|
||
return result
|
||
.replace(/\u200Bspan\u200Bnwr\u200B/g, '<span style="white-space:nowrap">')
|
||
.replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
|
||
.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) =>
|
||
`<html><body><script>localStorage.setItem('z51-oauth-result',JSON.stringify(${JSON.stringify(result)}));window.close();</script></body></html>`
|
||
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#', '<vk-snippet-end/>'), '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 = /<offer\s+id="([^"]*)"([^>]*)>([\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 = '<div style="position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;z-index:1;font-family:sans-serif;">Нет в наличии</div>'
|
||
for (const item of unavailable) {
|
||
const img = products[item.id]?.image
|
||
if (img) {
|
||
const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const imgRegex = new RegExp(`(<img[^>]*src=["']${imgEsc}["'][^>]*>)`, 'i')
|
||
result = result.replace(imgRegex, `<div style="position:relative;display:inline-block;">$1${badge}</div>`)
|
||
}
|
||
}
|
||
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 = '<div style="position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;z-index:1;font-family:sans-serif;">Нет в наличии</div>'
|
||
const imgEsc = p.image.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
text = text.replace(new RegExp(`(<img[^>]*src=["']${imgEsc}["'][^>]*>)`, 'i'),
|
||
`<div style="position:relative;display:inline-block;">$1${badge}</div>`)
|
||
}
|
||
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/, ''),
|
||
},
|
||
},
|
||
},
|
||
})
|