'use strict'; const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execFile } = require('child_process'); const { URL } = require('url'); const jpeg = require('jpeg-js'); const { PNG } = require('pngjs'); const { getImageSize } = require('./imageMeta.js'); let config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8')); function saveConfig() { const temp = path.join(__dirname, 'config.json.tmp'); fs.writeFileSync(temp, JSON.stringify(config, null, 2), 'utf8'); fs.renameSync(temp, path.join(__dirname, 'config.json')); } const WORKSPACE_ROOT = path.join(path.dirname(__dirname), 'workspace'); function getWorkspaceRoot() { const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT; return path.resolve(configuredRoot); } const HOST = process.env.HOST || '127.0.0.1'; const MAX_BODY_BYTES = 32 * 1024 * 1024; function isWorkspaceRoot(candidate) { try { return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot(); } catch { return false; } } function isWorkspacePath(candidate) { try { const resolved = path.resolve(String(candidate)); const root = getWorkspaceRoot(); return resolved === root || resolved.startsWith(root + path.sep); } catch { return false; } } function safeShopDir(root, folderName) { if (!isWorkspaceRoot(root) || typeof folderName !== 'string' || !folderName || folderName === '.' || folderName === '..' || folderName.includes('/') || folderName.includes('\\')) return null; const rootDir = path.resolve(root); const shopDir = path.resolve(rootDir, folderName); return shopDir.startsWith(rootDir + path.sep) ? shopDir : null; } fs.mkdirSync(getWorkspaceRoot(), { recursive: true }); const SESSION_PATH = path.join(__dirname, 'session.json'); const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json'); const MODELS = [ { id: 'gpt-image-2', label: 'GPT Image 2(默认)' }, { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' }, { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' }, { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' }, { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' }, { id: 'wan2.6-image', label: '通义万相 wan2.6-image' }, ]; const MODEL_IDS = new Set(MODELS.map((m) => m.id)); // These seedream variants don't support the '1K' tier the UI's size dropdown offers by // default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) - // map it up to '2K' rather than sending an invalid enum value. const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']); const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); const MIME_BY_EXT = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', }; const POSTER_CANVAS_SPECS = { banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 }, signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 }, }; const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const SKIP_DIR_NAMES = new Set(['膳绘']); // ---------- small utils ---------- function sendJson(res, statusCode, obj) { const data = JSON.stringify(obj); res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(data); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function sanitizeFileName(name) { return String(name).replace(/[\\/:*?"<>|]/g, '_').trim() || 'file'; } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => chunks.push(c)); req.on('data', (c) => { size += c.length; if (size > MAX_BODY_BYTES) { req.destroy(new Error('请求体过大')); reject(new Error('请求体过大')); } }); req.on('end', () => { if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); } }); req.on('error', reject); }); } // ============================================================ // CRAWLER MODULE — 闪购菜品图爬取 // ============================================================ let session = { cookie: '' }; function extractCookieValue(cookie, name) { const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); if (!match) return ''; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } function getKsid() { return extractCookieValue(session.cookie, 'ksid'); } function loadSession() { try { const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8')); session.cookie = String(saved.cookie || ''); } catch { /* no saved session */ } } function saveSession() { fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8'); } loadSession(); function invokeApi(service, method, requestParams, extraMetas) { return new Promise((resolve, reject) => { const body = { id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(), method, service, params: { request: requestParams || {} }, metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: getKsid() }, extraMetas || {}), ncp: '2.0.0', adapter: 'ncp', extendConfig: { docMode: 'master,,' }, }; const data = JSON.stringify(body); const options = { hostname: 'app-api.shop.ele.me', port: 443, path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`, method: 'POST', headers: { 'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json', Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/', Cookie: session.cookie || '', 'User-Agent': UA, 'Content-Length': Buffer.byteLength(data), }, timeout: config.requestTimeoutMs, }; const req = https.request(options, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); let json = null; try { json = JSON.parse(raw); } catch { /* keep raw */ } resolve({ statusCode: res.statusCode, json, raw }); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('请求超时'))); req.write(data); req.end(); }); } function isAuthError(resp) { if (resp.statusCode === 401 || resp.statusCode === 403) return true; const j = resp.json; if (!j) return false; if (j.result !== undefined) return false; return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j)); } function downloadImage(urlStr, redirectsLeft) { if (redirectsLeft === undefined) redirectsLeft = 5; return new Promise((resolve, reject) => { let u; try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); } const mod = u.protocol === 'http:' ? http : https; mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) { res.resume(); return resolve(downloadImage(res.headers.location, redirectsLeft - 1)); } if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks))); }).on('error', reject); }); } let shopList = []; function loadManualShops() { try { const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8')); if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效'); const shopsById = new Map(); for (const shop of data.shops) { if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue; shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' }); } shopList = Array.from(shopsById.values()); } catch (e) { if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`); } } function saveManualShops(shops) { const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8'); fs.renameSync(temp, MANUAL_SHOPS_PATH); } function mergeShops(newOnes) { const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop])); for (const shop of newOnes) shopsById.set(shop.shopId, shop); return Array.from(shopsById.values()); } loadManualShops(); function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); } function readCompletionState(root) { try { const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8')); return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {}; } catch { return {}; } } function writeCompletionState(root, shops) { const target = completionStatePath(root); const temp = `${target}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8'); fs.renameSync(temp, target); } function markShopCompleted(root, shopEntry) { const shops = readCompletionState(root); shops[String(shopEntry.shopId)] = { shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName, completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded, skipped: shopEntry.skipped, total: shopEntry.total, }; writeCompletionState(root, shops); } function handleShopsManual(body, res) { const lines = String((body && body.text) || '').split(/\r?\n/); const parsed = []; for (const line of lines) { const t = line.trim(); if (!t) continue; const parts = t.split(/[\s,,]+/).filter(Boolean); const idPart = parts.find((p) => /^\d+$/.test(p)); if (!idPart) continue; const shopId = Number(idPart); const shopName = parts.filter((p) => p !== idPart).join(' ') || String(shopId); parsed.push({ shopId, shopName, source: 'manual' }); } const previousShops = shopList; const mergedShops = mergeShops(parsed); try { saveManualShops(mergedShops); shopList = mergedShops; } catch (e) { shopList = previousShops; throw e; } sendJson(res, 200, { shops: shopList, addedCount: parsed.length }); } function handleShopsList(root, res) { if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); const completed = root && fs.existsSync(root) ? readCompletionState(root) : {}; sendJson(res, 200, { shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })), }); } function listRootDirs(root) { return fs.readdirSync(root, { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name)) .map((e) => e.name); } function resolveShopDir(root, shopId, shopName) { const dirs = listRootDirs(root); const suffix = '_' + String(shopId); const match = dirs.find((d) => d.endsWith(suffix)); if (match) return { dir: path.join(root, match), folderName: match, reused: true }; let maxSeq = 0; for (const d of dirs) { const mm = /^(\d+)_/.exec(d); if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10)); } const seq = String(maxSeq + 1).padStart(3, '0'); const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`; const dir = path.join(root, folderName); fs.mkdirSync(dir, { recursive: true }); return { dir, folderName, reused: false }; } let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] }; let cancelFlag = false; const systemLogs = []; let logSeq = 0; function detectLogLevel(message) { if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error'; if (/停止|跳过|警告|warn/i.test(message)) return 'warn'; if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success'; return 'info'; } function createLogEntry(message, source = 'task', level = 'info') { const now = new Date(); return { seq: ++logSeq, id: `${source}-${logSeq}`, source, level, time: now.toTimeString().slice(0, 8), timestamp: now.getTime(), message: String(message), }; } function pushLog(msg) { const entry = createLogEntry(msg, 'task', detectLogLevel(msg)); crawlState.log.push(entry); if (crawlState.log.length > 500) crawlState.log.shift(); } function pushSystemLog(msg, level = 'info') { systemLogs.push(createLogEntry(msg, 'system', level)); if (systemLogs.length > 500) systemLogs.shift(); } function getMergedLogs() { return [...crawlState.log, ...systemLogs] .sort((a, b) => b.seq - a.seq) .slice(0, 500); } async function startCrawl(body, res) { if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' }); if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' }); const root = String((body && body.root) || getWorkspaceRoot()).trim(); if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' }); if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { return sendJson(res, 400, { error: '输出根目录不存在: ' + root }); } const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : []; const selectedShops = shopIds.map((id) => shopList.find((s) => s.shopId === id)).filter(Boolean); if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' }); const forceFullCrawl = Boolean(body && body.forceFullCrawl); const completed = readCompletionState(root); const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]); const shops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]); const pageSize = Number(body && body.pageSize) || config.pageSize; const delayMs = Number(body && body.delayMs) || config.delayMs; cancelFlag = false; crawlState = { status: shops.length ? 'running' : 'done', startedAt: Date.now(), finishedAt: shops.length ? null : Date.now(), forceFullCrawl, shops: [ ...skippedCompleted.map((s) => ({ shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null, state: 'skipped_completed', total: completed[String(s.shopId)].total || 0, downloaded: 0, skipped: 0, failed: 0, error: null, })), ...shops.map((s) => ({ shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending', total: 0, downloaded: 0, skipped: 0, failed: 0, error: null, })), ], log: [], }; pushLog(`开始抓取,实际抓取 ${shops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`); sendJson(res, 200, { ok: true, crawlCount: shops.length, skippedCompletedCount: skippedCompleted.length }); if (!shops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载'); runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => { crawlState.status = 'error'; crawlState.finishedAt = Date.now(); pushLog('抓取流程异常终止: ' + (e.message || e)); }); } async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) { for (const shopEntry of crawlState.shops) { if (shopEntry.state === 'skipped_completed') continue; if (cancelFlag) { crawlState.status = 'stopped'; crawlState.finishedAt = Date.now(); pushLog('已停止'); return; } shopEntry.state = 'running'; pushLog(`[${shopEntry.shopName}] 开始`); let dirInfo; try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); } catch (e) { shopEntry.state = 'failed'; shopEntry.error = '创建目录失败: ' + (e.message || e); pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`); continue; } shopEntry.folderName = dirInfo.folderName; pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`); const allItems = []; const seen = new Set(); let nextId; let page = 0; const MAX_PAGES = 200; let authFailed = false; while (page < MAX_PAGES) { if (cancelFlag) break; page++; const requestParams = { shopId: shopEntry.shopId, pageSize, queryType: 'SHOP', searchKey: '', searchType: ['ITEM_NAME'], }; if (nextId) requestParams.nextId = nextId; let resp; try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); } catch (e) { shopEntry.error = '请求失败: ' + (e.message || e); pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`); break; } if (isAuthError(resp)) { authFailed = true; shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie'; pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`); break; } const items = (resp.json && resp.json.result && resp.json.result.result) || []; nextId = resp.json && resp.json.result && resp.json.result.nextId; if (!items.length) { pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`); break; } let newCount = 0; for (const it of items) { const gid = it.itemGlobalId; if (gid !== undefined && gid !== null && seen.has(gid)) continue; if (gid !== undefined && gid !== null) seen.add(gid); allItems.push(it); newCount++; } pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`); if (!nextId) break; await sleep(200); } shopEntry.total = allItems.length; if (authFailed && !allItems.length) { shopEntry.state = 'failed'; continue; } pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`); const manifest = []; const usedNames = new Set(); for (const it of allItems) { if (cancelFlag) break; const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim(); const photoUrl = it.photoUrl || it.thumbnailUrl; if (!photoUrl) { shopEntry.skipped++; manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' }); continue; } let finalBase = sanitizeFileName(itemName); let n = 1; while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; } usedNames.add(finalBase); try { const buf = await downloadImage(photoUrl); const ext = guessExtFromUrl(photoUrl); const fileName = `${finalBase}.${ext}`; fs.writeFileSync(path.join(dirInfo.dir, fileName), buf); shopEntry.downloaded++; manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' }); } catch (e) { shopEntry.failed++; manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) }); pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`); } await sleep(delayMs); } try { fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8'); if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry); } catch (e) { shopEntry.failed++; shopEntry.error = `保存抓取状态失败: ${e.message || e}`; pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`); } shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done'; pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`); } crawlState.status = cancelFlag ? 'stopped' : 'done'; crawlState.finishedAt = Date.now(); pushLog('抓取任务结束'); } function stopCrawl(res) { if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' }); cancelFlag = true; pushLog('收到停止请求,将在当前步骤完成后停止'); sendJson(res, 200, { ok: true }); } // ============================================================ // GENERATOR MODULE — 店铺视觉图生成 // ============================================================ const OVERLAY_POSITION_LABELS = { 'top-left': '左上角', 'top-right': '右上角', 'bottom-left': '左下角', 'bottom-right': '右下角', }; function findExistingAsset(shopDir, baseName) { const dir = path.join(shopDir, '_生成图片'); for (const ext of IMAGE_EXT) { const p = path.join(dir, baseName + ext); if (fs.existsSync(p)) return p; } return null; } const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo'); const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸'); function readAsDataUri(filePath) { const buf = fs.readFileSync(filePath); let ext = path.extname(filePath).toLowerCase().replace('.', ''); if (ext === 'jpg') ext = 'jpeg'; return `data:image/${ext};base64,${buf.toString('base64')}`; } // Builds the reference-image list and final prompt for a generation request, optionally // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay // can use either of two modes: // - 'ai' (default): the asset is passed as an extra reference image and the model is asked // to paint it into the frame itself. // - 'composite': the model is only asked to leave the target corner visually clean; the // actual pixel fusion is done afterwards by compositeOverlaysOntoImage() with no LLM call. function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) { const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {}; const images = []; for (const f of (refDishFiles || []).slice(0, 3)) { try { images.push(readAsDataUri(path.join(shopDir, f))); } catch { // skip unreadable reference image } } const warnings = []; const overlayClauses = []; const compositeOverlays = []; if (addLogo) { const logoPath = findExistingLogo(shopDir); if (logoPath) { const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right']; if (logoMode === 'composite') { compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' }); overlayClauses.push( opts.sourceImageFirst ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。` : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)` ); } else { try { images.push(readAsDataUri(logoPath)); overlayClauses.push( opts.sourceImageFirst ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。` : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}` ); } catch { warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO'); } } } else { warnings.push('未找到可用LOGO,请先生成'); } } if (addSticker) { const stickerPath = findExistingSticker(shopDir); if (stickerPath) { const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left']; if (stickerMode === 'composite') { compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' }); overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`); } else { try { images.push(readAsDataUri(stickerPath)); overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`); } catch { warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸'); } } } else { warnings.push('未找到可用贴纸,请先生成'); } } let prompt = basePrompt; if (overlayClauses.length) { prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`; } return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays }; } // ---------- sticker: chroma-key background removal (pure-JS, no image editor needed) ---------- // The generation model has no true alpha-channel/transparent-background output, so for the // "贴纸" (sticker) asset we ask it to render on a flat key-color background and cut that // color out ourselves, producing a real transparent PNG. const STICKER_KEY_COLOR = [255, 0, 255]; // pure magenta, rarely present in food photography const STICKER_THRESHOLD = 60; const STICKER_FEATHER = 40; function decodeToRGBA(buf) { if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) { const png = PNG.sync.read(buf); return { width: png.width, height: png.height, data: png.data }; } const img = jpeg.decode(buf, { useTArray: true }); return { width: img.width, height: img.height, data: img.data }; } function chromaKeyToTransparentPng(decoded, keyColor, threshold, feather) { const { width, height, data } = decoded; const png = new PNG({ width, height }); const [kr, kg, kb] = keyColor; for (let i = 0; i < width * height; i++) { const o = i * 4; const r = data[o]; const g = data[o + 1]; const b = data[o + 2]; const dist = Math.sqrt((r - kr) ** 2 + (g - kg) ** 2 + (b - kb) ** 2); let alpha; if (dist <= threshold) alpha = 0; else if (dist >= threshold + feather) alpha = 255; else alpha = Math.round(((dist - threshold) / feather) * 255); png.data[o] = r; png.data[o + 1] = g; png.data[o + 2] = b; png.data[o + 3] = alpha; } return PNG.sync.write(png); } // ---------- pure image-processing overlay compositing (no LLM call) ---------- // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker // image directly onto the generated result via alpha blending, entirely in-process. function resizeRGBA(src, srcW, srcH, dstW, dstH) { if (srcW === dstW && srcH === dstH) return src; const dst = Buffer.alloc(dstW * dstH * 4); for (let y = 0; y < dstH; y++) { const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5)); const y0 = Math.floor(srcYf); const y1 = Math.min(srcH - 1, y0 + 1); const wy = srcYf - y0; for (let x = 0; x < dstW; x++) { const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5)); const x0 = Math.floor(srcXf); const x1 = Math.min(srcW - 1, x0 + 1); const wx = srcXf - x0; const o = (y * dstW + x) * 4; for (let c = 0; c < 4; c++) { const p00 = src[(y0 * srcW + x0) * 4 + c]; const p10 = src[(y0 * srcW + x1) * 4 + c]; const p01 = src[(y1 * srcW + x0) * 4 + c]; const p11 = src[(y1 * srcW + x1) * 4 + c]; const top = p00 + (p10 - p00) * wx; const bottom = p01 + (p11 - p01) * wx; dst[o + c] = Math.round(top + (bottom - top) * wy); } } } return dst; } const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 }; // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio) // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the // output are the same {width, height, data} shape produced by decodeToRGBA(). function compositeOverlayOntoBase(base, overlay, position, widthRatio) { const targetW = Math.max(1, Math.round(base.width * widthRatio)); const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW)); const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH); const margin = Math.round(base.width * 0.04); let x, y; if (position === 'top-left') { x = margin; y = margin; } else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; } else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; } else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default) x = Math.max(0, Math.min(x, base.width - targetW)); y = Math.max(0, Math.min(y, base.height - targetH)); const out = Buffer.from(base.data); for (let j = 0; j < targetH; j++) { const by = y + j; if (by < 0 || by >= base.height) continue; for (let i = 0; i < targetW; i++) { const bx = x + i; if (bx < 0 || bx >= base.width) continue; const so = (j * targetW + i) * 4; const alpha = resized[so + 3] / 255; if (alpha <= 0) continue; const bo = (by * base.width + bx) * 4; out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha)); out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha)); out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha)); } } return { width: base.width, height: base.height, data: out }; } function encodeRGBAToPng(decoded) { const png = new PNG({ width: decoded.width, height: decoded.height }); Buffer.from(decoded.data).copy(png.data); return PNG.sync.write(png); } // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the // original buffer/ext (with a warning appended to job.warning) if the base image can't be // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support). function applyCompositeOverlays(job, buf, ext) { if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext }; let base; try { base = decodeToRGBA(buf); } catch (e) { job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图'; return { buf, ext }; } for (const ov of job.compositeOverlays) { try { const overlay = decodeToRGBA(fs.readFileSync(ov.path)); base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2); } catch (e) { const label = ov.type === 'logo' ? 'LOGO' : '贴纸'; job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`; } } return { buf: encodeRGBAToPng(base), ext: 'png' }; } function guessExtFromUrl(urlStr) { try { const u = new URL(urlStr); let ext = path.extname(u.pathname).replace('.', '').toLowerCase(); if (ext === 'jpeg') ext = 'jpg'; if (['jpg', 'png', 'gif', 'webp'].includes(ext)) return ext; } catch { // fall through } return 'jpg'; } // ---------- api.lingkeai.ai client ---------- // Base host is fixed to config.apiBase (https://api.lingkeai.ai) on purpose: // several responses from this API contain "tips"/error text suggesting other // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an // external source and is never used to pick a request target. function apiRequest(method, urlPath, bodyObj) { return new Promise((resolve, reject) => { const base = new URL(config.apiBase); const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null; const options = { hostname: base.hostname, port: base.port || 443, path: urlPath, method, headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json; charset=utf-8', }, timeout: 300000, }; if (data) options.headers['Content-Length'] = Buffer.byteLength(data); const req = https.request(options, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); let json = null; try { json = JSON.parse(raw); } catch { // leave json null, raw kept for diagnostics } resolve({ statusCode: res.statusCode, json, raw }); }); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('请求超时'))); if (data) req.write(data); req.end(); }); } function downloadBuffer(urlStr, redirectsLeft = 5) { return new Promise((resolve, reject) => { let u; try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的结果图片地址: ' + urlStr)); } const mod = u.protocol === 'http:' ? http : https; mod .get(u, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) { res.resume(); return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1)); } if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode)); } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks))); }) .on('error', reject); }); } // ---------- job queue ---------- const jobs = new Map(); const pendingQueue = []; let activeCount = 0; const persistedTasks = new Map(); let taskStoreLoaded = false; function getTaskStoreFile() { return path.join(getWorkspaceRoot(), '.genpic', 'tasks.json'); } function taskSnapshot(job) { const { images, ...safeJob } = job; return { ...safeJob, imageCount: Array.isArray(images) ? images.length : 0, updatedAt: Date.now(), }; } function persistJob(job) { if (!job || !job.id) return; persistedTasks.set(job.id, taskSnapshot(job)); const storeFile = getTaskStoreFile(); fs.mkdirSync(path.dirname(storeFile), { recursive: true }); const tempPath = `${storeFile}.${process.pid}.tmp`; fs.writeFileSync(tempPath, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), tasks: Array.from(persistedTasks.values()), }, null, 2)); fs.renameSync(tempPath, storeFile); } function loadPersistedJobs() { if (taskStoreLoaded) return; taskStoreLoaded = true; const storeFile = getTaskStoreFile(); let parsed; try { parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8')); } catch { return; } if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return; for (const raw of parsed.tasks) { if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue; const job = { ...raw, images: [], compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [], }; if (!MODEL_IDS.has(job.model)) job.model = config.model; jobs.set(job.id, job); persistedTasks.set(job.id, taskSnapshot(job)); } for (const job of jobs.values()) { if (job.state === 'queued') { job.state = 'failed'; job.finishedAt = Date.now(); job.error = '服务重启前任务仍在排队,请重新提交'; persistJob(job); } else if (job.state === 'running' || job.state === 'submitted') { if (!job.taskId) { job.state = 'failed'; job.finishedAt = Date.now(); job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交'; persistJob(job); } else { runJob(job); } } } } function createJob(opts) { const id = crypto.randomUUID(); const job = { id, root: opts.root, folderName: opts.folderName, kind: opts.kind, shopName: opts.shopName, dishName: opts.dishName || null, model: MODEL_IDS.has(opts.model) ? opts.model : config.model, prompt: opts.prompt, size: opts.size, aspectRatio: opts.aspectRatio, images: opts.images || [], outputDir: opts.outputDir, outputBaseName: opts.outputBaseName, compositeOverlays: opts.compositeOverlays || [], state: 'queued', progress: '0%', taskId: null, cost: null, resultUrl: null, remoteUrl: null, width: null, height: null, fileSizeBytes: null, error: null, warning: opts.warning || null, createdAt: Date.now(), startedAt: null, finishedAt: null, }; jobs.set(id, job); persistJob(job); const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`; pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`); if (activeCount < config.maxConcurrentJobs) { startJob(job); } else { pendingQueue.push(id); } return job; } function startJob(job) { runJob(job); } function runJob(job) { activeCount++; job.state = 'running'; job.startedAt = job.startedAt || Date.now(); persistJob(job); processJob(job) .catch((e) => { job.state = 'failed'; job.error = job.error || e.message || String(e); }) .finally(() => { const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`; if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success'); else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error'); job.finishedAt = Date.now(); persistJob(job); activeCount--; const nextId = pendingQueue.shift(); if (nextId) { const nextJob = jobs.get(nextId); if (nextJob) startJob(nextJob); } }); } // Builds the model-specific `params` object for the /v1/media/generate request. Different // image models on this API expose different (and sometimes confusingly named) parameters - // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag. function buildParamsForModel(modelId, job) { const params = {}; if (modelId === 'gpt-image-2') { params.size = 'auto'; params.quality = 'auto'; params.resolution = job.size || '1K'; params.n = 1; params.response_format = 'url'; if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio; } else if (modelId === 'gpt-image-2-guan') { params.size = 'auto'; params.quality = 'auto'; } else if (modelId === 'wan2.6-image') { params.size = job.aspectRatio || job.size || 'auto'; params.prompt_extend = true; } else { if (job.size) params.size = NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size; if (job.aspectRatio) params.aspect_ratio = job.aspectRatio; } let images = job.images; if (modelId === 'wan2.6-image' && images.length > 4) { job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张'; images = images.slice(0, 4); } if (images.length === 1) params.images = images[0]; else if (images.length > 1) params.images = images; return params; } async function processJob(job) { const resuming = !!job.taskId; if (!resuming) { const body = { model: job.model, prompt: job.prompt, params: buildParamsForModel(job.model, job), }; const submit = await apiRequest('POST', '/v1/media/generate', body); const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id)); if (submit.statusCode !== 200 || !submit.json || !taskId) { throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500))); } job.taskId = taskId; persistJob(job); } await sleep(resuming ? 1000 : 7000); const start = job.startedAt || Date.now(); const TIMEOUT_MS = 10 * 60 * 1000; for (;;) { if (Date.now() - start > TIMEOUT_MS) { job.state = 'failed'; job.error = '轮询超时(10分钟未完成)'; return; } const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan') ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}` : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`; const statusResp = await apiRequest('GET', statusPath); const status = statusResp.json; if (!status) { await sleep(5000); continue; } job.progress = String(status.progress ?? job.progress); if (status.cost !== undefined) job.cost = status.cost; persistJob(job); if (status.is_final) { if (status.state === 'success' && status.result_url) { job.remoteUrl = status.result_url; const buf = await downloadBuffer(status.result_url); let finalBuf, ext; finalBuf = buf; ext = guessExtFromUrl(status.result_url); ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext)); fs.mkdirSync(job.outputDir, { recursive: true }); const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`); fs.writeFileSync(savePath, finalBuf); const dims = getImageSize(finalBuf) || {}; job.width = dims.width || null; job.height = dims.height || null; job.fileSizeBytes = finalBuf.length; job.resultPath = savePath; job.resultUrl = '/files?path=' + encodeURIComponent(savePath); const creationHistoryDir = path.basename(job.outputDir) === '菜品图' ? path.dirname(job.outputDir) : job.outputDir; appendCreationHistory(creationHistoryDir, { id: job.id, root: job.root, folderName: job.folderName, shopName: job.shopName, kind: job.kind, name: job.dishName || job.kind, dishName: job.dishName, fileName: path.basename(savePath), relativePath: path.relative(creationHistoryDir, savePath), remoteUrl: job.remoteUrl, model: job.model, cost: job.cost, width: job.width, height: job.height, fileSizeBytes: job.fileSizeBytes, generatedAt: new Date().toISOString(), }); job.state = 'success'; } else { job.state = 'failed'; job.error = status.error || status.status || '生成失败'; job.cost = status.cost ?? job.cost; } return; } await sleep(5000); } } // ---------- scan helpers ---------- function fileInfo(p) { const buf = fs.readFileSync(p); const dims = getImageSize(buf) || {}; return { url: '/files?path=' + encodeURIComponent(p), width: dims.width || null, height: dims.height || null, fileSizeBytes: buf.length, }; } function scanGenerated(shopDir) { const dir = path.join(shopDir, '_生成图片'); const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} }; if (!fs.existsSync(dir)) return result; for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) { for (const ext of IMAGE_EXT) { const p = path.join(dir, base + ext); if (fs.existsSync(p)) { try { result[kind] = fileInfo(p); } catch { // ignore unreadable file } break; } } } const dishDir = path.join(dir, '菜品图'); if (fs.existsSync(dishDir)) { for (const f of fs.readdirSync(dishDir)) { const ext = path.extname(f).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; const name = f.slice(0, -ext.length); try { result.dishes[name] = fileInfo(path.join(dishDir, f)); } catch { // ignore unreadable file } } } return result; } function loadCreationHistory(outputDir) { const historyPath = path.join(outputDir, 'generation-history.json'); if (!fs.existsSync(historyPath)) return []; try { const records = JSON.parse(fs.readFileSync(historyPath, 'utf8')); return Array.isArray(records) ? records : []; } catch { return []; } } function appendCreationHistory(outputDir, record) { fs.mkdirSync(outputDir, { recursive: true }); const historyPath = path.join(outputDir, 'generation-history.json'); const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id); records.push(record); const tempPath = historyPath + '.tmp'; fs.writeFileSync(tempPath, JSON.stringify(records, null, 2)); fs.renameSync(tempPath, historyPath); } function handleScan(query, res) { const root = query.root; if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' }); if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) { return sendJson(res, 400, { error: '目录不存在: ' + root }); } let selectedFolders = null; if (query.folders) { try { selectedFolders = JSON.parse(query.folders); } catch { return sendJson(res, 400, { error: '指定店铺参数无效' }); } if (!Array.isArray(selectedFolders) || !selectedFolders.length || selectedFolders.some((name) => typeof name !== 'string' || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) { return sendJson(res, 400, { error: '指定店铺参数无效' }); } selectedFolders = [...new Set(selectedFolders)]; } let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { return sendJson(res, 400, { error: '无法读取目录: ' + e.message }); } const shops = []; for (const ent of entries) { if (!ent.isDirectory()) continue; if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue; if (selectedFolders && !selectedFolders.includes(ent.name)) continue; const folderName = ent.name; const shopDir = path.join(root, folderName); let files; try { files = fs.readdirSync(shopDir, { withFileTypes: true }); } catch { continue; } const dishes = []; for (const f of files) { if (!f.isFile()) continue; const ext = path.extname(f.name).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; dishes.push({ file: f.name, name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''), url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)), }); } if (!dishes.length) continue; dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh')); let overrides = {}; const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json'); if (fs.existsSync(cfgPath)) { try { overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { // ignore malformed override file } } const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, ''); shops.push({ folderName, shopName: overrides.shopName || parsedName, dishes, promptOverrides: overrides.prompts || {}, generated: scanGenerated(shopDir), }); } shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh')); sendJson(res, 200, { root, shops }); } const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']); function getCreationKind(directoryName, fileName) { if (directoryName === '菜品图') return 'dish'; if (/^logo\./i.test(fileName)) return 'logo'; if (/^(banner|店内海报)/i.test(fileName)) return 'banner'; if (/^招牌头图/i.test(fileName)) return 'signage'; if (/^贴纸/i.test(fileName)) return 'sticker'; return 'other'; } function handleCreations(query, res) { const root = query.root; if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); const resolvedRoot = path.resolve(String(root)); if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot }); } const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600); const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000; const items = []; let entries; try { entries = fs.readdirSync(resolvedRoot, { withFileTypes: true }); } catch (e) { return sendJson(res, 400, { error: '无法读取目录: ' + e.message }); } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue; const shopDir = path.join(resolvedRoot, entry.name); const outputDir = path.join(shopDir, '_生成图片'); if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue; let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, ''); const cfgPath = path.join(outputDir, 'prompt-config.json'); if (fs.existsSync(cfgPath)) { try { const config = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); shopName = config.shopName || shopName; } catch {} } const nestedHistoryRecords = loadCreationHistory(path.join(outputDir, '菜品图')) .map((record) => ({ ...record, relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')), })); const historyRecords = [...loadCreationHistory(outputDir), ...nestedHistoryRecords]; const seenHistoryIds = new Set(); const historyPaths = new Set(); for (const record of historyRecords) { if (record.id && seenHistoryIds.has(record.id)) continue; if (record.id) seenHistoryIds.add(record.id); if (!record.remoteUrl || !record.fileName) continue; const generatedAt = record.generatedAt || record.modifiedAt; const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0; if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue; const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName); if (!kind || kind === 'other') continue; const relativePath = path.normalize(record.relativePath || record.fileName); historyPaths.add(relativePath); items.push({ folderName: entry.name, shopName: record.shopName || shopName, kind, name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)), fileName: record.fileName, url: record.remoteUrl, remoteUrl: record.remoteUrl, localPath: path.join(outputDir, relativePath), source: 'remote', model: record.model, cost: record.cost, modifiedAt: generatedAt, modifiedTimeMs: generatedTimeMs, width: record.width, height: record.height, fileSizeBytes: record.fileSizeBytes, }); } for (const relativeDir of ['.', '菜品图']) { const currentDir = path.join(outputDir, relativeDir); if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue; let files; try { files = fs.readdirSync(currentDir, { withFileTypes: true }); } catch { continue; } for (const file of files) { if (!file.isFile()) continue; const ext = path.extname(file.name).toLowerCase(); if (!IMAGE_EXT.has(ext)) continue; const kind = getCreationKind(relativeDir, file.name); if (kind === 'other') continue; const fullPath = path.join(currentDir, file.name); const relativePath = path.relative(outputDir, fullPath); if (historyPaths.has(path.normalize(relativePath))) continue; let stat; try { stat = fs.statSync(fullPath); } catch { continue; } if (stat.mtimeMs < retentionCutoff) continue; items.push({ folderName: entry.name, shopName, kind, name: path.basename(file.name, ext), fileName: file.name, url: '/files?path=' + encodeURIComponent(fullPath), modifiedAt: stat.mtime.toISOString(), modifiedTimeMs: stat.mtimeMs, fileSizeBytes: stat.size, }); } } } items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs); sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) }); } function handleFiles(query, res) { const p = query.path; if (!p) { res.writeHead(400); return res.end('missing path'); } const resolved = path.resolve(p); if (!isWorkspacePath(resolved)) { res.writeHead(403); return res.end('forbidden'); } if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { res.writeHead(404); return res.end('not found'); } const ext = path.extname(resolved).toLowerCase(); if (!IMAGE_EXT.has(ext)) { res.writeHead(403); return res.end('forbidden'); } res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' }); fs.createReadStream(resolved).pipe(res); } async function handleGenerateBrand(body, res) { const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body; const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' }; if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) { return sendJson(res, 400, { error: '参数不完整' }); } const shopDir = safeShopDir(root, folderName); if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' }); // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable const shouldAddLogo = !!addLogo && kind !== 'logo'; const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, { addLogo: shouldAddLogo, logoPosition, logoMode, }); const job = createJob({ kind, root, folderName, shopName, model, prompt: finalPrompt, size: size || '1K', aspectRatio: aspectRatio || undefined, images, outputDir: path.join(shopDir, '_生成图片'), outputBaseName: outNameMap[kind], warning, compositeOverlays, }); sendJson(res, 200, { jobId: job.id }); } async function handleGenerateDish(body, res) { const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body; if (!root || !folderName || !dishFile || !prompt) { return sendJson(res, 400, { error: '参数不完整' }); } const shopDir = safeShopDir(root, folderName); if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' }); const dishPath = path.resolve(shopDir, dishFile); if (!isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) { return sendJson(res, 403, { error: '菜品文件无效' }); } let dishImage; try { dishImage = readAsDataUri(dishPath); } catch (e) { return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message }); } const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, [], { addLogo: !!addLogo, logoPosition, logoMode, addSticker: !!addSticker, stickerPosition, stickerMode, sourceImageFirst: true, }); const job = createJob({ kind: 'dish', root, folderName, shopName, dishName, model, prompt: finalPrompt, size: size || '1K', aspectRatio: '1:1', images: [dishImage, ...overlayImages], outputDir: path.join(shopDir, '_生成图片', '菜品图'), outputBaseName: sanitizeFileName(dishName), warning, compositeOverlays, }); sendJson(res, 200, { jobId: job.id }); } function handleJob(pathname, res) { const id = pathname.split('/').pop(); const job = jobs.get(id); if (!job) return sendJson(res, 404, { error: 'job not found' }); sendJson(res, 200, { id: job.id, kind: job.kind, dishName: job.dishName, state: job.state, progress: job.progress, cost: job.cost, resultUrl: job.resultUrl, width: job.width, height: job.height, fileSizeBytes: job.fileSizeBytes, error: job.error, warning: job.warning, }); } function publicTask(job) { return { id: job.id, type: 'generation', status: job.state, root: job.root, folderName: job.folderName, shopName: job.shopName, dishName: job.dishName, name: job.dishName || job.kind, kind: job.kind, model: job.model, providerTaskId: job.taskId, progress: job.progress, cost: job.cost, resultUrl: job.resultUrl, remoteUrl: job.remoteUrl, width: job.width, height: job.height, fileSizeBytes: job.fileSizeBytes, error: job.error, warning: job.warning, createdAt: job.createdAt, startedAt: job.startedAt, finishedAt: job.finishedAt, updatedAt: job.updatedAt || job.createdAt, }; } function handleTasks(query, res) { if (query.root && !isWorkspaceRoot(query.root)) { return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); } const root = getWorkspaceRoot(); const tasks = Array.from(jobs.values()) .filter((job) => path.resolve(job.root) === root) .sort((a, b) => b.createdAt - a.createdAt) .map(publicTask); const summary = tasks.reduce((acc, task) => { acc[task.status] = (acc[task.status] || 0) + 1; return acc; }, { all: tasks.length }); sendJson(res, 200, { root, summary, tasks }); } async function handleSaveConfig(body, res) { const { root, folderName, shopName, prompts } = body; if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' }); const shopDir = safeShopDir(root, folderName); if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' }); const dir = path.join(shopDir, '_生成图片'); fs.mkdirSync(dir, { recursive: true }); const cfgPath = path.join(dir, 'prompt-config.json'); let existing = {}; if (fs.existsSync(cfgPath)) { try { existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { // start fresh if corrupt } } const merged = { shopName: shopName !== undefined ? shopName : existing.shopName, prompts: { ...(existing.prompts || {}), ...(prompts || {}) }, }; fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8'); sendJson(res, 200, { ok: true }); } async function handleSavePosterCanvas(body, res) { const { root, folderName, kind, imageDataUrl } = body; const spec = POSTER_CANVAS_SPECS[kind]; if (!root || !folderName || !spec || !imageDataUrl) { return sendJson(res, 400, { error: '参数不完整' }); } if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) { return sendJson(res, 400, { error: '店铺目录名无效' }); } const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl); if (!dataUrlMatch) { return sendJson(res, 400, { error: '画板图必须为 PNG 格式' }); } const base64 = dataUrlMatch[1]; if (!base64 || base64.length > 10 * 1024 * 1024) { return sendJson(res, 400, { error: '画板图数据无效或过大' }); } let imageBuffer; try { imageBuffer = Buffer.from(base64, 'base64'); } catch { return sendJson(res, 400, { error: '画板图数据无法读取' }); } const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) { return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' }); } const dimensions = getImageSize(imageBuffer); if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) { return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` }); } const resolvedRoot = path.resolve(root); if (!isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); const shopDir = path.resolve(resolvedRoot, folderName); if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) { return sendJson(res, 400, { error: '店铺目录无效' }); } const dir = path.join(shopDir, '_生成图片'); const savePath = path.join(dir, spec.fileName); try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(savePath, imageBuffer); } catch (e) { return sendJson(res, 500, { error: '保存画板图失败: ' + e.message }); } sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length }); } function handleOpenFolder(body, res) { const { root, folderName } = body; if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' }); const dir = path.join(root, folderName, '_生成图片'); try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return sendJson(res, 500, { error: '无法创建目录: ' + e.message }); } // explorer.exe frequently exits with a non-zero code even when it opens the window fine, // so its callback result is ignored rather than treated as a failure. execFile('explorer', [dir], () => {}); sendJson(res, 200, { ok: true }); } // ---------- static frontend ---------- const STATIC_MAP = { '/': ['index.html', 'text/html; charset=utf-8'], '/index.html': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'application/javascript; charset=utf-8'], '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'], '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'], '/history.js': ['history.js', 'application/javascript; charset=utf-8'], '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css; charset=utf-8'], }; const STATIC_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.css': 'text/css', '.js': 'application/javascript', }; function serveStatic(pathname, res) { const entry = STATIC_MAP[pathname]; if (entry) { const full = path.join(__dirname, 'public', entry[0]); fs.readFile(full, (err, data) => { if (err) { res.writeHead(404); return res.end('Not found'); } res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' }); res.end(data); }); return; } if (pathname.startsWith('/assets/')) { const rel = pathname.replace('/assets/', ''); if (rel.includes('..') || rel.includes('/')) { res.writeHead(403); return res.end('Forbidden'); } const full = path.join(__dirname, 'public', 'assets', rel); if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end('Not found'); } const ext = path.extname(full).toLowerCase(); res.writeHead(200, { 'Content-Type': STATIC_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); fs.createReadStream(full).pipe(res); return; } res.writeHead(404); res.end('Not found'); } // ---------- server ---------- const server = http.createServer((req, res) => { let parsedUrl; try { parsedUrl = new URL(req.url, `http://${req.headers.host}`); } catch { res.writeHead(400); return res.end('bad url'); } const pathname = parsedUrl.pathname; const query = Object.fromEntries(parsedUrl.searchParams); const respond500 = (e) => sendJson(res, 500, { error: e.message || String(e) }); if (req.method === 'GET' && pathname === '/api/default-root') { return sendJson(res, 200, { root: getWorkspaceRoot() }); } if (req.method === 'GET' && pathname === '/healthz') { return sendJson(res, 200, { ok: true, workspace: getWorkspaceRoot() }); } if (req.method === 'GET' && pathname === '/readyz') { return sendJson(res, 200, { ok: !!config.apiKey, workspaceReady: fs.existsSync(getWorkspaceRoot()), crawlReady: !!getKsid(), }); } if (req.method === 'GET' && pathname === '/api/tasks') { try { return handleTasks(query, res); } catch (e) { return respond500(e); } } if (req.method === 'GET' && pathname === '/api/session') { return sendJson(res, 200, { cookieConfigured: !!session.cookie, ksidConfigured: !!getKsid(), }); } if (req.method === 'POST' && pathname === '/api/session') { return readBody(req) .then((body) => { session.cookie = String((body && body.cookie) || ''); saveSession(); pushSystemLog('采集 Cookie 已更新', 'success'); sendJson(res, 200, { ok: true }); }) .catch(respond500); } if (req.method === 'GET' && pathname === '/api/shops') { const encodedRoot = parsedUrl.searchParams.get('root'); const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : ''; if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); return handleShopsList(root, res); } if (req.method === 'POST' && pathname === '/api/shops/manual') { return readBody(req).then((body) => handleShopsManual(body, res)).catch(respond500); } if (req.method === 'POST' && pathname === '/api/crawl/start') { return readBody(req).then((body) => startCrawl(body, res)).catch(respond500); } if (req.method === 'POST' && pathname === '/api/crawl/stop') { return stopCrawl(res); } if (req.method === 'GET' && pathname === '/api/crawl/status') { return sendJson(res, 200, crawlState); } if (req.method === 'GET' && pathname === '/api/logs') { return sendJson(res, 200, { logs: getMergedLogs() }); } if (req.method === 'POST' && pathname === '/api/logs/clear') { crawlState.log = []; systemLogs.length = 0; pushSystemLog('日志已清空', 'warn'); return sendJson(res, 200, { ok: true }); } if (req.method === 'GET' && pathname === '/api/models') { return sendJson(res, 200, { models: MODELS, default: config.model }); } if (req.method === 'GET' && pathname === '/api/settings') { return sendJson(res, 200, { apiKey: config.apiKey ? config.apiKey.slice(0, 8) + '••••••••' : '', apiBase: config.apiBase, model: config.model, rootDir: getWorkspaceRoot(), pageSize: config.pageSize, delayMs: config.delayMs, cookieConfigured: !!session.cookie && !!getKsid(), port: config.port, }); } if (req.method === 'POST' && pathname === '/api/settings') { return readBody(req).then((body) => { const previousApiKey = config.apiKey; const previousCookie = session.cookie; if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey; if (body.model && MODEL_IDS.has(body.model)) config.model = body.model; if (body.rootDir) { const rootDir = path.resolve(String(body.rootDir).trim()); if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir }); } config.rootDir = rootDir; } if (body.pageSize !== undefined) { const pageSize = Number(body.pageSize); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) { return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' }); } config.pageSize = pageSize; } if (body.delayMs !== undefined) { const delayMs = Number(body.delayMs); if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) { return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' }); } config.delayMs = delayMs; } saveConfig(); if (body.cookie !== undefined) { session.cookie = body.cookie; saveSession(); } pushSystemLog( `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` + (previousApiKey !== config.apiKey ? ',API Key 已更新' : '') + (body.cookie !== undefined && previousCookie !== session.cookie ? ',Cookie 已更新' : ''), 'success' ); sendJson(res, 200, { ok: true }); }).catch(respond500); } if (req.method === 'GET' && pathname === '/api/scan') { try { return handleScan(query, res); } catch (e) { return respond500(e); } } if (req.method === 'GET' && pathname === '/api/creations') { try { return handleCreations(query, res); } catch (e) { return respond500(e); } } if (req.method === 'GET' && pathname === '/files') { try { return handleFiles(query, res); } catch (e) { return respond500(e); } } if (req.method === 'POST' && pathname === '/api/generate/brand') { return readBody(req).then((body) => handleGenerateBrand(body, res)).catch(respond500); } if (req.method === 'POST' && pathname === '/api/generate/dish') { return readBody(req).then((body) => handleGenerateDish(body, res)).catch(respond500); } if (req.method === 'GET' && pathname.startsWith('/api/job/')) { try { return handleJob(pathname, res); } catch (e) { return respond500(e); } } if (req.method === 'POST' && pathname === '/api/save-config') { return readBody(req).then((body) => handleSaveConfig(body, res)).catch(respond500); } if (req.method === 'POST' && pathname === '/api/save-poster-canvas') { return readBody(req).then((body) => handleSavePosterCanvas(body, res)).catch(respond500); } if (req.method === 'POST' && pathname === '/api/open-folder') { return readBody(req).then((body) => { if (body.root && !isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' }); const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片'); const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || ''); const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null); if (!targetDir || !isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir }); const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open'; execFile(command, [targetDir], () => {}); sendJson(res, 200, { ok: true, opened: targetDir }); }).catch(respond500); } return serveStatic(pathname, res); }); loadPersistedJobs(); server.listen(config.port, HOST, () => { const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${config.port}`; pushSystemLog(`服务已启动:${url}`, 'success'); console.log(`门店装修工具已启动: ${url}`); // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c; // the empty "" argument is the window-title placeholder start expects before the URL. execFile('cmd', ['/c', 'start', '', url], () => {}); });