|
|
@@ -0,0 +1,1705 @@
|
|
|
+'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);
|
|
|
+}
|
|
|
+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 = [];
|
|
|
+ req.on('data', (c) => chunks.push(c));
|
|
|
+ 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) {
|
|
|
+ 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 (!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;
|
|
|
+
|
|
|
+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(),
|
|
|
+ };
|
|
|
+ jobs.set(id, 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) {
|
|
|
+ activeCount++;
|
|
|
+ job.state = 'running';
|
|
|
+ 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');
|
|
|
+ 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 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;
|
|
|
+
|
|
|
+ await sleep(7000);
|
|
|
+ const start = 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;
|
|
|
+
|
|
|
+ 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 (!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 (!root) return sendJson(res, 400, { 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 (!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 = path.join(root, folderName);
|
|
|
+ // 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 = path.join(root, folderName);
|
|
|
+ let dishImage;
|
|
|
+ try {
|
|
|
+ dishImage = readAsDataUri(path.join(shopDir, dishFile));
|
|
|
+ } 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,
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function handleSaveConfig(body, res) {
|
|
|
+ const { root, folderName, shopName, prompts } = body;
|
|
|
+ if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
|
|
|
+ const dir = path.join(root, folderName, '_生成图片');
|
|
|
+ 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);
|
|
|
+ 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'],
|
|
|
+ '/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 === '/api/session') {
|
|
|
+ return sendJson(res, 200, session);
|
|
|
+ }
|
|
|
+ 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') : '';
|
|
|
+ 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,
|
|
|
+ cookie: session.cookie,
|
|
|
+ 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) => {
|
|
|
+ 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) 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);
|
|
|
+});
|
|
|
+
|
|
|
+server.listen(config.port, () => {
|
|
|
+ const url = `http://localhost:${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], () => {});
|
|
|
+});
|