server.js 72 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915
  1. 'use strict';
  2. const http = require('http');
  3. const https = require('https');
  4. const fs = require('fs');
  5. const path = require('path');
  6. const crypto = require('crypto');
  7. const { execFile } = require('child_process');
  8. const { URL } = require('url');
  9. const jpeg = require('jpeg-js');
  10. const { PNG } = require('pngjs');
  11. const { getImageSize } = require('./imageMeta.js');
  12. let config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
  13. function saveConfig() {
  14. const temp = path.join(__dirname, 'config.json.tmp');
  15. fs.writeFileSync(temp, JSON.stringify(config, null, 2), 'utf8');
  16. fs.renameSync(temp, path.join(__dirname, 'config.json'));
  17. }
  18. const WORKSPACE_ROOT = path.join(path.dirname(__dirname), 'workspace');
  19. function getWorkspaceRoot() {
  20. const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
  21. return path.resolve(configuredRoot);
  22. }
  23. const HOST = process.env.HOST || '127.0.0.1';
  24. const MAX_BODY_BYTES = 32 * 1024 * 1024;
  25. function isWorkspaceRoot(candidate) {
  26. try {
  27. return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot();
  28. } catch {
  29. return false;
  30. }
  31. }
  32. function isWorkspacePath(candidate) {
  33. try {
  34. const resolved = path.resolve(String(candidate));
  35. const root = getWorkspaceRoot();
  36. return resolved === root || resolved.startsWith(root + path.sep);
  37. } catch {
  38. return false;
  39. }
  40. }
  41. function safeShopDir(root, folderName) {
  42. if (!isWorkspaceRoot(root) || typeof folderName !== 'string' ||
  43. !folderName || folderName === '.' || folderName === '..' ||
  44. folderName.includes('/') || folderName.includes('\\')) return null;
  45. const rootDir = path.resolve(root);
  46. const shopDir = path.resolve(rootDir, folderName);
  47. return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
  48. }
  49. fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
  50. const SESSION_PATH = path.join(__dirname, 'session.json');
  51. const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
  52. const MODELS = [
  53. { id: 'gpt-image-2', label: 'GPT Image 2(默认)' },
  54. { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' },
  55. { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' },
  56. { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' },
  57. { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' },
  58. { id: 'wan2.6-image', label: '通义万相 wan2.6-image' },
  59. ];
  60. const MODEL_IDS = new Set(MODELS.map((m) => m.id));
  61. // These seedream variants don't support the '1K' tier the UI's size dropdown offers by
  62. // default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) -
  63. // map it up to '2K' rather than sending an invalid enum value.
  64. const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']);
  65. const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
  66. const MIME_BY_EXT = {
  67. '.jpg': 'image/jpeg',
  68. '.jpeg': 'image/jpeg',
  69. '.png': 'image/png',
  70. '.webp': 'image/webp',
  71. '.gif': 'image/gif',
  72. };
  73. const POSTER_CANVAS_SPECS = {
  74. banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 },
  75. signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 },
  76. };
  77. 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';
  78. const SKIP_DIR_NAMES = new Set(['膳绘']);
  79. // ---------- small utils ----------
  80. function sendJson(res, statusCode, obj) {
  81. const data = JSON.stringify(obj);
  82. res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
  83. res.end(data);
  84. }
  85. function sleep(ms) {
  86. return new Promise((resolve) => setTimeout(resolve, ms));
  87. }
  88. function sanitizeFileName(name) {
  89. return String(name).replace(/[\\/:*?"<>|]/g, '_').trim() || 'file';
  90. }
  91. function readBody(req) {
  92. return new Promise((resolve, reject) => {
  93. const chunks = [];
  94. let size = 0;
  95. req.on('data', (c) => chunks.push(c));
  96. req.on('data', (c) => {
  97. size += c.length;
  98. if (size > MAX_BODY_BYTES) {
  99. req.destroy(new Error('请求体过大'));
  100. reject(new Error('请求体过大'));
  101. }
  102. });
  103. req.on('end', () => {
  104. if (!chunks.length) return resolve({});
  105. try {
  106. resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
  107. } catch (e) {
  108. reject(e);
  109. }
  110. });
  111. req.on('error', reject);
  112. });
  113. }
  114. // ============================================================
  115. // CRAWLER MODULE — 闪购菜品图爬取
  116. // ============================================================
  117. let session = { cookie: '' };
  118. function extractCookieValue(cookie, name) {
  119. const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
  120. if (!match) return '';
  121. try { return decodeURIComponent(match[1]); } catch { return match[1]; }
  122. }
  123. function getKsid() { return extractCookieValue(session.cookie, 'ksid'); }
  124. function loadSession() {
  125. try {
  126. const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8'));
  127. session.cookie = String(saved.cookie || '');
  128. } catch { /* no saved session */ }
  129. }
  130. function saveSession() {
  131. fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8');
  132. }
  133. loadSession();
  134. function invokeApi(service, method, requestParams, extraMetas) {
  135. return new Promise((resolve, reject) => {
  136. const body = {
  137. id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
  138. method, service,
  139. params: { request: requestParams || {} },
  140. metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: getKsid() }, extraMetas || {}),
  141. ncp: '2.0.0', adapter: 'ncp',
  142. extendConfig: { docMode: 'master,,' },
  143. };
  144. const data = JSON.stringify(body);
  145. const options = {
  146. hostname: 'app-api.shop.ele.me', port: 443,
  147. path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`,
  148. method: 'POST',
  149. headers: {
  150. 'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
  151. Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
  152. Cookie: session.cookie || '', 'User-Agent': UA,
  153. 'Content-Length': Buffer.byteLength(data),
  154. },
  155. timeout: config.requestTimeoutMs,
  156. };
  157. const req = https.request(options, (res) => {
  158. const chunks = [];
  159. res.on('data', (c) => chunks.push(c));
  160. res.on('end', () => {
  161. const raw = Buffer.concat(chunks).toString('utf8');
  162. let json = null;
  163. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  164. resolve({ statusCode: res.statusCode, json, raw });
  165. });
  166. });
  167. req.on('error', reject);
  168. req.on('timeout', () => req.destroy(new Error('请求超时')));
  169. req.write(data);
  170. req.end();
  171. });
  172. }
  173. function isAuthError(resp) {
  174. if (resp.statusCode === 401 || resp.statusCode === 403) return true;
  175. const j = resp.json;
  176. if (!j) return false;
  177. if (j.result !== undefined) return false;
  178. return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
  179. }
  180. function downloadImage(urlStr, redirectsLeft) {
  181. if (redirectsLeft === undefined) redirectsLeft = 5;
  182. return new Promise((resolve, reject) => {
  183. let u;
  184. try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
  185. const mod = u.protocol === 'http:' ? http : https;
  186. mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
  187. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  188. res.resume();
  189. return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
  190. }
  191. if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
  192. const chunks = [];
  193. res.on('data', (c) => chunks.push(c));
  194. res.on('end', () => resolve(Buffer.concat(chunks)));
  195. }).on('error', reject);
  196. });
  197. }
  198. let shopList = [];
  199. function loadManualShops() {
  200. try {
  201. const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8'));
  202. if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效');
  203. const shopsById = new Map();
  204. for (const shop of data.shops) {
  205. if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue;
  206. shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' });
  207. }
  208. shopList = Array.from(shopsById.values());
  209. } catch (e) {
  210. if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`);
  211. }
  212. }
  213. function saveManualShops(shops) {
  214. const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`;
  215. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
  216. fs.renameSync(temp, MANUAL_SHOPS_PATH);
  217. }
  218. function mergeShops(newOnes) {
  219. const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop]));
  220. for (const shop of newOnes) shopsById.set(shop.shopId, shop);
  221. return Array.from(shopsById.values());
  222. }
  223. loadManualShops();
  224. function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
  225. function readCompletionState(root) {
  226. try {
  227. const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
  228. return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
  229. } catch { return {}; }
  230. }
  231. function writeCompletionState(root, shops) {
  232. const target = completionStatePath(root);
  233. const temp = `${target}.${process.pid}.tmp`;
  234. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
  235. fs.renameSync(temp, target);
  236. }
  237. function markShopCompleted(root, shopEntry) {
  238. const shops = readCompletionState(root);
  239. shops[String(shopEntry.shopId)] = {
  240. shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
  241. completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
  242. skipped: shopEntry.skipped, total: shopEntry.total,
  243. };
  244. writeCompletionState(root, shops);
  245. }
  246. function handleShopsManual(body, res) {
  247. const lines = String((body && body.text) || '').split(/\r?\n/);
  248. const parsed = [];
  249. for (const line of lines) {
  250. const t = line.trim();
  251. if (!t) continue;
  252. const parts = t.split(/[\s,,]+/).filter(Boolean);
  253. const idPart = parts.find((p) => /^\d+$/.test(p));
  254. if (!idPart) continue;
  255. const shopId = Number(idPart);
  256. const shopName = parts.filter((p) => p !== idPart).join(' ') || String(shopId);
  257. parsed.push({ shopId, shopName, source: 'manual' });
  258. }
  259. const previousShops = shopList;
  260. const mergedShops = mergeShops(parsed);
  261. try { saveManualShops(mergedShops); shopList = mergedShops; }
  262. catch (e) { shopList = previousShops; throw e; }
  263. sendJson(res, 200, { shops: shopList, addedCount: parsed.length });
  264. }
  265. function handleShopsList(root, res) {
  266. if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  267. const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
  268. sendJson(res, 200, {
  269. shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
  270. });
  271. }
  272. function listRootDirs(root) {
  273. return fs.readdirSync(root, { withFileTypes: true })
  274. .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
  275. .map((e) => e.name);
  276. }
  277. function resolveShopDir(root, shopId, shopName) {
  278. const dirs = listRootDirs(root);
  279. const suffix = '_' + String(shopId);
  280. const match = dirs.find((d) => d.endsWith(suffix));
  281. if (match) return { dir: path.join(root, match), folderName: match, reused: true };
  282. let maxSeq = 0;
  283. for (const d of dirs) {
  284. const mm = /^(\d+)_/.exec(d);
  285. if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
  286. }
  287. const seq = String(maxSeq + 1).padStart(3, '0');
  288. const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
  289. const dir = path.join(root, folderName);
  290. fs.mkdirSync(dir, { recursive: true });
  291. return { dir, folderName, reused: false };
  292. }
  293. let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
  294. let cancelFlag = false;
  295. const systemLogs = [];
  296. let logSeq = 0;
  297. function detectLogLevel(message) {
  298. if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error';
  299. if (/停止|跳过|警告|warn/i.test(message)) return 'warn';
  300. if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success';
  301. return 'info';
  302. }
  303. function createLogEntry(message, source = 'task', level = 'info') {
  304. const now = new Date();
  305. return {
  306. seq: ++logSeq,
  307. id: `${source}-${logSeq}`,
  308. source,
  309. level,
  310. time: now.toTimeString().slice(0, 8),
  311. timestamp: now.getTime(),
  312. message: String(message),
  313. };
  314. }
  315. function pushLog(msg) {
  316. const entry = createLogEntry(msg, 'task', detectLogLevel(msg));
  317. crawlState.log.push(entry);
  318. if (crawlState.log.length > 500) crawlState.log.shift();
  319. }
  320. function pushSystemLog(msg, level = 'info') {
  321. systemLogs.push(createLogEntry(msg, 'system', level));
  322. if (systemLogs.length > 500) systemLogs.shift();
  323. }
  324. function getMergedLogs() {
  325. return [...crawlState.log, ...systemLogs]
  326. .sort((a, b) => b.seq - a.seq)
  327. .slice(0, 500);
  328. }
  329. async function startCrawl(body, res) {
  330. if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
  331. if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
  332. const root = String((body && body.root) || getWorkspaceRoot()).trim();
  333. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
  334. if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  335. return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
  336. }
  337. const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
  338. const selectedShops = shopIds.map((id) => shopList.find((s) => s.shopId === id)).filter(Boolean);
  339. if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
  340. const forceFullCrawl = Boolean(body && body.forceFullCrawl);
  341. const completed = readCompletionState(root);
  342. const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
  343. const shops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
  344. const pageSize = Number(body && body.pageSize) || config.pageSize;
  345. const delayMs = Number(body && body.delayMs) || config.delayMs;
  346. cancelFlag = false;
  347. crawlState = {
  348. status: shops.length ? 'running' : 'done', startedAt: Date.now(),
  349. finishedAt: shops.length ? null : Date.now(), forceFullCrawl,
  350. shops: [
  351. ...skippedCompleted.map((s) => ({
  352. shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
  353. state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
  354. downloaded: 0, skipped: 0, failed: 0, error: null,
  355. })),
  356. ...shops.map((s) => ({
  357. shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
  358. total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
  359. })),
  360. ],
  361. log: [],
  362. };
  363. pushLog(`开始抓取,实际抓取 ${shops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`);
  364. sendJson(res, 200, { ok: true, crawlCount: shops.length, skippedCompletedCount: skippedCompleted.length });
  365. if (!shops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
  366. runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
  367. crawlState.status = 'error';
  368. crawlState.finishedAt = Date.now();
  369. pushLog('抓取流程异常终止: ' + (e.message || e));
  370. });
  371. }
  372. async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
  373. for (const shopEntry of crawlState.shops) {
  374. if (shopEntry.state === 'skipped_completed') continue;
  375. if (cancelFlag) {
  376. crawlState.status = 'stopped';
  377. crawlState.finishedAt = Date.now();
  378. pushLog('已停止');
  379. return;
  380. }
  381. shopEntry.state = 'running';
  382. pushLog(`[${shopEntry.shopName}] 开始`);
  383. let dirInfo;
  384. try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
  385. catch (e) {
  386. shopEntry.state = 'failed';
  387. shopEntry.error = '创建目录失败: ' + (e.message || e);
  388. pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
  389. continue;
  390. }
  391. shopEntry.folderName = dirInfo.folderName;
  392. pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
  393. const allItems = [];
  394. const seen = new Set();
  395. let nextId;
  396. let page = 0;
  397. const MAX_PAGES = 200;
  398. let authFailed = false;
  399. while (page < MAX_PAGES) {
  400. if (cancelFlag) break;
  401. page++;
  402. const requestParams = {
  403. shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
  404. searchKey: '', searchType: ['ITEM_NAME'],
  405. };
  406. if (nextId) requestParams.nextId = nextId;
  407. let resp;
  408. try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); }
  409. catch (e) {
  410. shopEntry.error = '请求失败: ' + (e.message || e);
  411. pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`);
  412. break;
  413. }
  414. if (isAuthError(resp)) {
  415. authFailed = true;
  416. shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie';
  417. pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`);
  418. break;
  419. }
  420. const items = (resp.json && resp.json.result && resp.json.result.result) || [];
  421. nextId = resp.json && resp.json.result && resp.json.result.nextId;
  422. if (!items.length) {
  423. pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
  424. break;
  425. }
  426. let newCount = 0;
  427. for (const it of items) {
  428. const gid = it.itemGlobalId;
  429. if (gid !== undefined && gid !== null && seen.has(gid)) continue;
  430. if (gid !== undefined && gid !== null) seen.add(gid);
  431. allItems.push(it);
  432. newCount++;
  433. }
  434. pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
  435. if (!nextId) break;
  436. await sleep(200);
  437. }
  438. shopEntry.total = allItems.length;
  439. if (authFailed && !allItems.length) {
  440. shopEntry.state = 'failed';
  441. continue;
  442. }
  443. pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
  444. const manifest = [];
  445. const usedNames = new Set();
  446. for (const it of allItems) {
  447. if (cancelFlag) break;
  448. const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
  449. const photoUrl = it.photoUrl || it.thumbnailUrl;
  450. if (!photoUrl) {
  451. shopEntry.skipped++;
  452. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
  453. continue;
  454. }
  455. let finalBase = sanitizeFileName(itemName);
  456. let n = 1;
  457. while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
  458. usedNames.add(finalBase);
  459. try {
  460. const buf = await downloadImage(photoUrl);
  461. const ext = guessExtFromUrl(photoUrl);
  462. const fileName = `${finalBase}.${ext}`;
  463. fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
  464. shopEntry.downloaded++;
  465. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
  466. } catch (e) {
  467. shopEntry.failed++;
  468. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
  469. pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
  470. }
  471. await sleep(delayMs);
  472. }
  473. try {
  474. fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
  475. if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
  476. } catch (e) {
  477. shopEntry.failed++;
  478. shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
  479. pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
  480. }
  481. shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
  482. pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
  483. }
  484. crawlState.status = cancelFlag ? 'stopped' : 'done';
  485. crawlState.finishedAt = Date.now();
  486. pushLog('抓取任务结束');
  487. }
  488. function stopCrawl(res) {
  489. if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
  490. cancelFlag = true;
  491. pushLog('收到停止请求,将在当前步骤完成后停止');
  492. sendJson(res, 200, { ok: true });
  493. }
  494. // ============================================================
  495. // GENERATOR MODULE — 店铺视觉图生成
  496. // ============================================================
  497. const OVERLAY_POSITION_LABELS = {
  498. 'top-left': '左上角',
  499. 'top-right': '右上角',
  500. 'bottom-left': '左下角',
  501. 'bottom-right': '右下角',
  502. };
  503. function findExistingAsset(shopDir, baseName) {
  504. const dir = path.join(shopDir, '_生成图片');
  505. for (const ext of IMAGE_EXT) {
  506. const p = path.join(dir, baseName + ext);
  507. if (fs.existsSync(p)) return p;
  508. }
  509. return null;
  510. }
  511. const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
  512. const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
  513. function readAsDataUri(filePath) {
  514. const buf = fs.readFileSync(filePath);
  515. let ext = path.extname(filePath).toLowerCase().replace('.', '');
  516. if (ext === 'jpg') ext = 'jpeg';
  517. return `data:image/${ext};base64,${buf.toString('base64')}`;
  518. }
  519. // Builds the reference-image list and final prompt for a generation request, optionally
  520. // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
  521. // can use either of two modes:
  522. // - 'ai' (default): the asset is passed as an extra reference image and the model is asked
  523. // to paint it into the frame itself.
  524. // - 'composite': the model is only asked to leave the target corner visually clean; the
  525. // actual pixel fusion is done afterwards by compositeOverlaysOntoImage() with no LLM call.
  526. function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
  527. const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
  528. const images = [];
  529. for (const f of (refDishFiles || []).slice(0, 3)) {
  530. try {
  531. images.push(readAsDataUri(path.join(shopDir, f)));
  532. } catch {
  533. // skip unreadable reference image
  534. }
  535. }
  536. const warnings = [];
  537. const overlayClauses = [];
  538. const compositeOverlays = [];
  539. if (addLogo) {
  540. const logoPath = findExistingLogo(shopDir);
  541. if (logoPath) {
  542. const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
  543. if (logoMode === 'composite') {
  544. compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
  545. overlayClauses.push(
  546. opts.sourceImageFirst
  547. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
  548. : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
  549. );
  550. } else {
  551. try {
  552. images.push(readAsDataUri(logoPath));
  553. overlayClauses.push(
  554. opts.sourceImageFirst
  555. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
  556. : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
  557. );
  558. } catch {
  559. warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
  560. }
  561. }
  562. } else {
  563. warnings.push('未找到可用LOGO,请先生成');
  564. }
  565. }
  566. if (addSticker) {
  567. const stickerPath = findExistingSticker(shopDir);
  568. if (stickerPath) {
  569. const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
  570. if (stickerMode === 'composite') {
  571. compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
  572. overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
  573. } else {
  574. try {
  575. images.push(readAsDataUri(stickerPath));
  576. overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
  577. } catch {
  578. warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
  579. }
  580. }
  581. } else {
  582. warnings.push('未找到可用贴纸,请先生成');
  583. }
  584. }
  585. let prompt = basePrompt;
  586. if (overlayClauses.length) {
  587. prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
  588. }
  589. return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
  590. }
  591. // ---------- sticker: chroma-key background removal (pure-JS, no image editor needed) ----------
  592. // The generation model has no true alpha-channel/transparent-background output, so for the
  593. // "贴纸" (sticker) asset we ask it to render on a flat key-color background and cut that
  594. // color out ourselves, producing a real transparent PNG.
  595. const STICKER_KEY_COLOR = [255, 0, 255]; // pure magenta, rarely present in food photography
  596. const STICKER_THRESHOLD = 60;
  597. const STICKER_FEATHER = 40;
  598. function decodeToRGBA(buf) {
  599. if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
  600. const png = PNG.sync.read(buf);
  601. return { width: png.width, height: png.height, data: png.data };
  602. }
  603. const img = jpeg.decode(buf, { useTArray: true });
  604. return { width: img.width, height: img.height, data: img.data };
  605. }
  606. function chromaKeyToTransparentPng(decoded, keyColor, threshold, feather) {
  607. const { width, height, data } = decoded;
  608. const png = new PNG({ width, height });
  609. const [kr, kg, kb] = keyColor;
  610. for (let i = 0; i < width * height; i++) {
  611. const o = i * 4;
  612. const r = data[o];
  613. const g = data[o + 1];
  614. const b = data[o + 2];
  615. const dist = Math.sqrt((r - kr) ** 2 + (g - kg) ** 2 + (b - kb) ** 2);
  616. let alpha;
  617. if (dist <= threshold) alpha = 0;
  618. else if (dist >= threshold + feather) alpha = 255;
  619. else alpha = Math.round(((dist - threshold) / feather) * 255);
  620. png.data[o] = r;
  621. png.data[o + 1] = g;
  622. png.data[o + 2] = b;
  623. png.data[o + 3] = alpha;
  624. }
  625. return PNG.sync.write(png);
  626. }
  627. // ---------- pure image-processing overlay compositing (no LLM call) ----------
  628. // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
  629. // image directly onto the generated result via alpha blending, entirely in-process.
  630. function resizeRGBA(src, srcW, srcH, dstW, dstH) {
  631. if (srcW === dstW && srcH === dstH) return src;
  632. const dst = Buffer.alloc(dstW * dstH * 4);
  633. for (let y = 0; y < dstH; y++) {
  634. const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
  635. const y0 = Math.floor(srcYf);
  636. const y1 = Math.min(srcH - 1, y0 + 1);
  637. const wy = srcYf - y0;
  638. for (let x = 0; x < dstW; x++) {
  639. const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
  640. const x0 = Math.floor(srcXf);
  641. const x1 = Math.min(srcW - 1, x0 + 1);
  642. const wx = srcXf - x0;
  643. const o = (y * dstW + x) * 4;
  644. for (let c = 0; c < 4; c++) {
  645. const p00 = src[(y0 * srcW + x0) * 4 + c];
  646. const p10 = src[(y0 * srcW + x1) * 4 + c];
  647. const p01 = src[(y1 * srcW + x0) * 4 + c];
  648. const p11 = src[(y1 * srcW + x1) * 4 + c];
  649. const top = p00 + (p10 - p00) * wx;
  650. const bottom = p01 + (p11 - p01) * wx;
  651. dst[o + c] = Math.round(top + (bottom - top) * wy);
  652. }
  653. }
  654. }
  655. return dst;
  656. }
  657. const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
  658. // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
  659. // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
  660. // output are the same {width, height, data} shape produced by decodeToRGBA().
  661. function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
  662. const targetW = Math.max(1, Math.round(base.width * widthRatio));
  663. const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
  664. const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
  665. const margin = Math.round(base.width * 0.04);
  666. let x, y;
  667. if (position === 'top-left') { x = margin; y = margin; }
  668. else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
  669. else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
  670. else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
  671. x = Math.max(0, Math.min(x, base.width - targetW));
  672. y = Math.max(0, Math.min(y, base.height - targetH));
  673. const out = Buffer.from(base.data);
  674. for (let j = 0; j < targetH; j++) {
  675. const by = y + j;
  676. if (by < 0 || by >= base.height) continue;
  677. for (let i = 0; i < targetW; i++) {
  678. const bx = x + i;
  679. if (bx < 0 || bx >= base.width) continue;
  680. const so = (j * targetW + i) * 4;
  681. const alpha = resized[so + 3] / 255;
  682. if (alpha <= 0) continue;
  683. const bo = (by * base.width + bx) * 4;
  684. out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
  685. out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
  686. out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
  687. }
  688. }
  689. return { width: base.width, height: base.height, data: out };
  690. }
  691. function encodeRGBAToPng(decoded) {
  692. const png = new PNG({ width: decoded.width, height: decoded.height });
  693. Buffer.from(decoded.data).copy(png.data);
  694. return PNG.sync.write(png);
  695. }
  696. // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
  697. // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
  698. // original buffer/ext (with a warning appended to job.warning) if the base image can't be
  699. // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
  700. function applyCompositeOverlays(job, buf, ext) {
  701. if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
  702. let base;
  703. try {
  704. base = decodeToRGBA(buf);
  705. } catch (e) {
  706. job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
  707. return { buf, ext };
  708. }
  709. for (const ov of job.compositeOverlays) {
  710. try {
  711. const overlay = decodeToRGBA(fs.readFileSync(ov.path));
  712. base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
  713. } catch (e) {
  714. const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
  715. job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
  716. }
  717. }
  718. return { buf: encodeRGBAToPng(base), ext: 'png' };
  719. }
  720. function guessExtFromUrl(urlStr) {
  721. try {
  722. const u = new URL(urlStr);
  723. let ext = path.extname(u.pathname).replace('.', '').toLowerCase();
  724. if (ext === 'jpeg') ext = 'jpg';
  725. if (['jpg', 'png', 'gif', 'webp'].includes(ext)) return ext;
  726. } catch {
  727. // fall through
  728. }
  729. return 'jpg';
  730. }
  731. // ---------- api.lingkeai.ai client ----------
  732. // Base host is fixed to config.apiBase (https://api.lingkeai.ai) on purpose:
  733. // several responses from this API contain "tips"/error text suggesting other
  734. // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
  735. // external source and is never used to pick a request target.
  736. function apiRequest(method, urlPath, bodyObj) {
  737. return new Promise((resolve, reject) => {
  738. const base = new URL(config.apiBase);
  739. const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
  740. const options = {
  741. hostname: base.hostname,
  742. port: base.port || 443,
  743. path: urlPath,
  744. method,
  745. headers: {
  746. Authorization: `Bearer ${config.apiKey}`,
  747. 'Content-Type': 'application/json; charset=utf-8',
  748. },
  749. timeout: 300000,
  750. };
  751. if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
  752. const req = https.request(options, (res) => {
  753. const chunks = [];
  754. res.on('data', (c) => chunks.push(c));
  755. res.on('end', () => {
  756. const raw = Buffer.concat(chunks).toString('utf8');
  757. let json = null;
  758. try {
  759. json = JSON.parse(raw);
  760. } catch {
  761. // leave json null, raw kept for diagnostics
  762. }
  763. resolve({ statusCode: res.statusCode, json, raw });
  764. });
  765. });
  766. req.on('error', reject);
  767. req.on('timeout', () => req.destroy(new Error('请求超时')));
  768. if (data) req.write(data);
  769. req.end();
  770. });
  771. }
  772. function downloadBuffer(urlStr, redirectsLeft = 5) {
  773. return new Promise((resolve, reject) => {
  774. let u;
  775. try {
  776. u = new URL(urlStr);
  777. } catch (e) {
  778. return reject(new Error('无效的结果图片地址: ' + urlStr));
  779. }
  780. const mod = u.protocol === 'http:' ? http : https;
  781. mod
  782. .get(u, (res) => {
  783. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  784. res.resume();
  785. return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
  786. }
  787. if (res.statusCode !== 200) {
  788. res.resume();
  789. return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
  790. }
  791. const chunks = [];
  792. res.on('data', (c) => chunks.push(c));
  793. res.on('end', () => resolve(Buffer.concat(chunks)));
  794. })
  795. .on('error', reject);
  796. });
  797. }
  798. // ---------- job queue ----------
  799. const jobs = new Map();
  800. const pendingQueue = [];
  801. let activeCount = 0;
  802. const persistedTasks = new Map();
  803. let taskStoreLoaded = false;
  804. function getTaskStoreFile() {
  805. return path.join(getWorkspaceRoot(), '.genpic', 'tasks.json');
  806. }
  807. function taskSnapshot(job) {
  808. const { images, ...safeJob } = job;
  809. return {
  810. ...safeJob,
  811. imageCount: Array.isArray(images) ? images.length : 0,
  812. updatedAt: Date.now(),
  813. };
  814. }
  815. function persistJob(job) {
  816. if (!job || !job.id) return;
  817. persistedTasks.set(job.id, taskSnapshot(job));
  818. const storeFile = getTaskStoreFile();
  819. fs.mkdirSync(path.dirname(storeFile), { recursive: true });
  820. const tempPath = `${storeFile}.${process.pid}.tmp`;
  821. fs.writeFileSync(tempPath, JSON.stringify({
  822. version: 1,
  823. updatedAt: new Date().toISOString(),
  824. tasks: Array.from(persistedTasks.values()),
  825. }, null, 2));
  826. fs.renameSync(tempPath, storeFile);
  827. }
  828. function loadPersistedJobs() {
  829. if (taskStoreLoaded) return;
  830. taskStoreLoaded = true;
  831. const storeFile = getTaskStoreFile();
  832. let parsed;
  833. try {
  834. parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
  835. } catch {
  836. return;
  837. }
  838. if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
  839. for (const raw of parsed.tasks) {
  840. if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
  841. const job = {
  842. ...raw,
  843. images: [],
  844. compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
  845. };
  846. if (!MODEL_IDS.has(job.model)) job.model = config.model;
  847. jobs.set(job.id, job);
  848. persistedTasks.set(job.id, taskSnapshot(job));
  849. }
  850. for (const job of jobs.values()) {
  851. if (job.state === 'queued') {
  852. job.state = 'failed';
  853. job.finishedAt = Date.now();
  854. job.error = '服务重启前任务仍在排队,请重新提交';
  855. persistJob(job);
  856. } else if (job.state === 'running' || job.state === 'submitted') {
  857. if (!job.taskId) {
  858. job.state = 'failed';
  859. job.finishedAt = Date.now();
  860. job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
  861. persistJob(job);
  862. } else {
  863. runJob(job);
  864. }
  865. }
  866. }
  867. }
  868. function createJob(opts) {
  869. const id = crypto.randomUUID();
  870. const job = {
  871. id,
  872. root: opts.root,
  873. folderName: opts.folderName,
  874. kind: opts.kind,
  875. shopName: opts.shopName,
  876. dishName: opts.dishName || null,
  877. model: MODEL_IDS.has(opts.model) ? opts.model : config.model,
  878. prompt: opts.prompt,
  879. size: opts.size,
  880. aspectRatio: opts.aspectRatio,
  881. images: opts.images || [],
  882. outputDir: opts.outputDir,
  883. outputBaseName: opts.outputBaseName,
  884. compositeOverlays: opts.compositeOverlays || [],
  885. state: 'queued',
  886. progress: '0%',
  887. taskId: null,
  888. cost: null,
  889. resultUrl: null,
  890. remoteUrl: null,
  891. width: null,
  892. height: null,
  893. fileSizeBytes: null,
  894. error: null,
  895. warning: opts.warning || null,
  896. createdAt: Date.now(),
  897. startedAt: null,
  898. finishedAt: null,
  899. };
  900. jobs.set(id, job);
  901. persistJob(job);
  902. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  903. pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
  904. if (activeCount < config.maxConcurrentJobs) {
  905. startJob(job);
  906. } else {
  907. pendingQueue.push(id);
  908. }
  909. return job;
  910. }
  911. function startJob(job) {
  912. runJob(job);
  913. }
  914. function runJob(job) {
  915. activeCount++;
  916. job.state = 'running';
  917. job.startedAt = job.startedAt || Date.now();
  918. persistJob(job);
  919. processJob(job)
  920. .catch((e) => {
  921. job.state = 'failed';
  922. job.error = job.error || e.message || String(e);
  923. })
  924. .finally(() => {
  925. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  926. if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
  927. else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
  928. job.finishedAt = Date.now();
  929. persistJob(job);
  930. activeCount--;
  931. const nextId = pendingQueue.shift();
  932. if (nextId) {
  933. const nextJob = jobs.get(nextId);
  934. if (nextJob) startJob(nextJob);
  935. }
  936. });
  937. }
  938. // Builds the model-specific `params` object for the /v1/media/generate request. Different
  939. // image models on this API expose different (and sometimes confusingly named) parameters -
  940. // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
  941. // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
  942. function buildParamsForModel(modelId, job) {
  943. const params = {};
  944. if (modelId === 'gpt-image-2') {
  945. params.size = 'auto';
  946. params.quality = 'auto';
  947. params.resolution = job.size || '1K';
  948. params.n = 1;
  949. params.response_format = 'url';
  950. if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
  951. } else if (modelId === 'gpt-image-2-guan') {
  952. params.size = 'auto';
  953. params.quality = 'auto';
  954. } else if (modelId === 'wan2.6-image') {
  955. params.size = job.aspectRatio || job.size || 'auto';
  956. params.prompt_extend = true;
  957. } else {
  958. if (job.size) params.size = NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
  959. if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
  960. }
  961. let images = job.images;
  962. if (modelId === 'wan2.6-image' && images.length > 4) {
  963. job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
  964. images = images.slice(0, 4);
  965. }
  966. if (images.length === 1) params.images = images[0];
  967. else if (images.length > 1) params.images = images;
  968. return params;
  969. }
  970. async function processJob(job) {
  971. const resuming = !!job.taskId;
  972. if (!resuming) {
  973. const body = {
  974. model: job.model,
  975. prompt: job.prompt,
  976. params: buildParamsForModel(job.model, job),
  977. };
  978. const submit = await apiRequest('POST', '/v1/media/generate', body);
  979. const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
  980. if (submit.statusCode !== 200 || !submit.json || !taskId) {
  981. throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
  982. }
  983. job.taskId = taskId;
  984. persistJob(job);
  985. }
  986. await sleep(resuming ? 1000 : 7000);
  987. const start = job.startedAt || Date.now();
  988. const TIMEOUT_MS = 10 * 60 * 1000;
  989. for (;;) {
  990. if (Date.now() - start > TIMEOUT_MS) {
  991. job.state = 'failed';
  992. job.error = '轮询超时(10分钟未完成)';
  993. return;
  994. }
  995. const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
  996. ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
  997. : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
  998. const statusResp = await apiRequest('GET', statusPath);
  999. const status = statusResp.json;
  1000. if (!status) {
  1001. await sleep(5000);
  1002. continue;
  1003. }
  1004. job.progress = String(status.progress ?? job.progress);
  1005. if (status.cost !== undefined) job.cost = status.cost;
  1006. persistJob(job);
  1007. if (status.is_final) {
  1008. if (status.state === 'success' && status.result_url) {
  1009. job.remoteUrl = status.result_url;
  1010. const buf = await downloadBuffer(status.result_url);
  1011. let finalBuf, ext;
  1012. finalBuf = buf;
  1013. ext = guessExtFromUrl(status.result_url);
  1014. ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
  1015. fs.mkdirSync(job.outputDir, { recursive: true });
  1016. const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
  1017. fs.writeFileSync(savePath, finalBuf);
  1018. const dims = getImageSize(finalBuf) || {};
  1019. job.width = dims.width || null;
  1020. job.height = dims.height || null;
  1021. job.fileSizeBytes = finalBuf.length;
  1022. job.resultPath = savePath;
  1023. job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
  1024. const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
  1025. ? path.dirname(job.outputDir)
  1026. : job.outputDir;
  1027. appendCreationHistory(creationHistoryDir, {
  1028. id: job.id,
  1029. root: job.root,
  1030. folderName: job.folderName,
  1031. shopName: job.shopName,
  1032. kind: job.kind,
  1033. name: job.dishName || job.kind,
  1034. dishName: job.dishName,
  1035. fileName: path.basename(savePath),
  1036. relativePath: path.relative(creationHistoryDir, savePath),
  1037. remoteUrl: job.remoteUrl,
  1038. model: job.model,
  1039. cost: job.cost,
  1040. width: job.width,
  1041. height: job.height,
  1042. fileSizeBytes: job.fileSizeBytes,
  1043. generatedAt: new Date().toISOString(),
  1044. });
  1045. job.state = 'success';
  1046. } else {
  1047. job.state = 'failed';
  1048. job.error = status.error || status.status || '生成失败';
  1049. job.cost = status.cost ?? job.cost;
  1050. }
  1051. return;
  1052. }
  1053. await sleep(5000);
  1054. }
  1055. }
  1056. // ---------- scan helpers ----------
  1057. function fileInfo(p) {
  1058. const buf = fs.readFileSync(p);
  1059. const dims = getImageSize(buf) || {};
  1060. return {
  1061. url: '/files?path=' + encodeURIComponent(p),
  1062. width: dims.width || null,
  1063. height: dims.height || null,
  1064. fileSizeBytes: buf.length,
  1065. };
  1066. }
  1067. function scanGenerated(shopDir) {
  1068. const dir = path.join(shopDir, '_生成图片');
  1069. const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} };
  1070. if (!fs.existsSync(dir)) return result;
  1071. for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) {
  1072. for (const ext of IMAGE_EXT) {
  1073. const p = path.join(dir, base + ext);
  1074. if (fs.existsSync(p)) {
  1075. try {
  1076. result[kind] = fileInfo(p);
  1077. } catch {
  1078. // ignore unreadable file
  1079. }
  1080. break;
  1081. }
  1082. }
  1083. }
  1084. const dishDir = path.join(dir, '菜品图');
  1085. if (fs.existsSync(dishDir)) {
  1086. for (const f of fs.readdirSync(dishDir)) {
  1087. const ext = path.extname(f).toLowerCase();
  1088. if (!IMAGE_EXT.has(ext)) continue;
  1089. const name = f.slice(0, -ext.length);
  1090. try {
  1091. result.dishes[name] = fileInfo(path.join(dishDir, f));
  1092. } catch {
  1093. // ignore unreadable file
  1094. }
  1095. }
  1096. }
  1097. return result;
  1098. }
  1099. function loadCreationHistory(outputDir) {
  1100. const historyPath = path.join(outputDir, 'generation-history.json');
  1101. if (!fs.existsSync(historyPath)) return [];
  1102. try {
  1103. const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
  1104. return Array.isArray(records) ? records : [];
  1105. } catch {
  1106. return [];
  1107. }
  1108. }
  1109. function appendCreationHistory(outputDir, record) {
  1110. fs.mkdirSync(outputDir, { recursive: true });
  1111. const historyPath = path.join(outputDir, 'generation-history.json');
  1112. const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
  1113. records.push(record);
  1114. const tempPath = historyPath + '.tmp';
  1115. fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
  1116. fs.renameSync(tempPath, historyPath);
  1117. }
  1118. function handleScan(query, res) {
  1119. const root = query.root;
  1120. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
  1121. if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  1122. return sendJson(res, 400, { error: '目录不存在: ' + root });
  1123. }
  1124. let selectedFolders = null;
  1125. if (query.folders) {
  1126. try {
  1127. selectedFolders = JSON.parse(query.folders);
  1128. } catch {
  1129. return sendJson(res, 400, { error: '指定店铺参数无效' });
  1130. }
  1131. if (!Array.isArray(selectedFolders) || !selectedFolders.length ||
  1132. selectedFolders.some((name) => typeof name !== 'string' || !name ||
  1133. name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) {
  1134. return sendJson(res, 400, { error: '指定店铺参数无效' });
  1135. }
  1136. selectedFolders = [...new Set(selectedFolders)];
  1137. }
  1138. let entries;
  1139. try {
  1140. entries = fs.readdirSync(root, { withFileTypes: true });
  1141. } catch (e) {
  1142. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  1143. }
  1144. const shops = [];
  1145. for (const ent of entries) {
  1146. if (!ent.isDirectory()) continue;
  1147. if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue;
  1148. if (selectedFolders && !selectedFolders.includes(ent.name)) continue;
  1149. const folderName = ent.name;
  1150. const shopDir = path.join(root, folderName);
  1151. let files;
  1152. try {
  1153. files = fs.readdirSync(shopDir, { withFileTypes: true });
  1154. } catch {
  1155. continue;
  1156. }
  1157. const dishes = [];
  1158. for (const f of files) {
  1159. if (!f.isFile()) continue;
  1160. const ext = path.extname(f.name).toLowerCase();
  1161. if (!IMAGE_EXT.has(ext)) continue;
  1162. dishes.push({
  1163. file: f.name,
  1164. name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''),
  1165. url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)),
  1166. });
  1167. }
  1168. if (!dishes.length) continue;
  1169. dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
  1170. let overrides = {};
  1171. const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json');
  1172. if (fs.existsSync(cfgPath)) {
  1173. try {
  1174. overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1175. } catch {
  1176. // ignore malformed override file
  1177. }
  1178. }
  1179. const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, '');
  1180. shops.push({
  1181. folderName,
  1182. shopName: overrides.shopName || parsedName,
  1183. dishes,
  1184. promptOverrides: overrides.prompts || {},
  1185. generated: scanGenerated(shopDir),
  1186. });
  1187. }
  1188. shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh'));
  1189. sendJson(res, 200, { root, shops });
  1190. }
  1191. const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']);
  1192. function getCreationKind(directoryName, fileName) {
  1193. if (directoryName === '菜品图') return 'dish';
  1194. if (/^logo\./i.test(fileName)) return 'logo';
  1195. if (/^(banner|店内海报)/i.test(fileName)) return 'banner';
  1196. if (/^招牌头图/i.test(fileName)) return 'signage';
  1197. if (/^贴纸/i.test(fileName)) return 'sticker';
  1198. return 'other';
  1199. }
  1200. function handleCreations(query, res) {
  1201. const root = query.root;
  1202. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1203. const resolvedRoot = path.resolve(String(root));
  1204. if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
  1205. return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
  1206. }
  1207. const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600);
  1208. const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000;
  1209. const items = [];
  1210. let entries;
  1211. try {
  1212. entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
  1213. } catch (e) {
  1214. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  1215. }
  1216. for (const entry of entries) {
  1217. if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue;
  1218. const shopDir = path.join(resolvedRoot, entry.name);
  1219. const outputDir = path.join(shopDir, '_生成图片');
  1220. if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue;
  1221. let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, '');
  1222. const cfgPath = path.join(outputDir, 'prompt-config.json');
  1223. if (fs.existsSync(cfgPath)) {
  1224. try {
  1225. const config = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1226. shopName = config.shopName || shopName;
  1227. } catch {}
  1228. }
  1229. const nestedHistoryRecords = loadCreationHistory(path.join(outputDir, '菜品图'))
  1230. .map((record) => ({
  1231. ...record,
  1232. relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')),
  1233. }));
  1234. const historyRecords = [...loadCreationHistory(outputDir), ...nestedHistoryRecords];
  1235. const seenHistoryIds = new Set();
  1236. const historyPaths = new Set();
  1237. for (const record of historyRecords) {
  1238. if (record.id && seenHistoryIds.has(record.id)) continue;
  1239. if (record.id) seenHistoryIds.add(record.id);
  1240. if (!record.remoteUrl || !record.fileName) continue;
  1241. const generatedAt = record.generatedAt || record.modifiedAt;
  1242. const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0;
  1243. if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue;
  1244. const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName);
  1245. if (!kind || kind === 'other') continue;
  1246. const relativePath = path.normalize(record.relativePath || record.fileName);
  1247. historyPaths.add(relativePath);
  1248. items.push({
  1249. folderName: entry.name,
  1250. shopName: record.shopName || shopName,
  1251. kind,
  1252. name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)),
  1253. fileName: record.fileName,
  1254. url: record.remoteUrl,
  1255. remoteUrl: record.remoteUrl,
  1256. localPath: path.join(outputDir, relativePath),
  1257. source: 'remote',
  1258. model: record.model,
  1259. cost: record.cost,
  1260. modifiedAt: generatedAt,
  1261. modifiedTimeMs: generatedTimeMs,
  1262. width: record.width,
  1263. height: record.height,
  1264. fileSizeBytes: record.fileSizeBytes,
  1265. });
  1266. }
  1267. for (const relativeDir of ['.', '菜品图']) {
  1268. const currentDir = path.join(outputDir, relativeDir);
  1269. if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue;
  1270. let files;
  1271. try {
  1272. files = fs.readdirSync(currentDir, { withFileTypes: true });
  1273. } catch {
  1274. continue;
  1275. }
  1276. for (const file of files) {
  1277. if (!file.isFile()) continue;
  1278. const ext = path.extname(file.name).toLowerCase();
  1279. if (!IMAGE_EXT.has(ext)) continue;
  1280. const kind = getCreationKind(relativeDir, file.name);
  1281. if (kind === 'other') continue;
  1282. const fullPath = path.join(currentDir, file.name);
  1283. const relativePath = path.relative(outputDir, fullPath);
  1284. if (historyPaths.has(path.normalize(relativePath))) continue;
  1285. let stat;
  1286. try {
  1287. stat = fs.statSync(fullPath);
  1288. } catch {
  1289. continue;
  1290. }
  1291. if (stat.mtimeMs < retentionCutoff) continue;
  1292. items.push({
  1293. folderName: entry.name,
  1294. shopName,
  1295. kind,
  1296. name: path.basename(file.name, ext),
  1297. fileName: file.name,
  1298. url: '/files?path=' + encodeURIComponent(fullPath),
  1299. modifiedAt: stat.mtime.toISOString(),
  1300. modifiedTimeMs: stat.mtimeMs,
  1301. fileSizeBytes: stat.size,
  1302. });
  1303. }
  1304. }
  1305. }
  1306. items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs);
  1307. sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) });
  1308. }
  1309. function handleFiles(query, res) {
  1310. const p = query.path;
  1311. if (!p) {
  1312. res.writeHead(400);
  1313. return res.end('missing path');
  1314. }
  1315. const resolved = path.resolve(p);
  1316. if (!isWorkspacePath(resolved)) {
  1317. res.writeHead(403);
  1318. return res.end('forbidden');
  1319. }
  1320. if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
  1321. res.writeHead(404);
  1322. return res.end('not found');
  1323. }
  1324. const ext = path.extname(resolved).toLowerCase();
  1325. if (!IMAGE_EXT.has(ext)) {
  1326. res.writeHead(403);
  1327. return res.end('forbidden');
  1328. }
  1329. res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' });
  1330. fs.createReadStream(resolved).pipe(res);
  1331. }
  1332. async function handleGenerateBrand(body, res) {
  1333. const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body;
  1334. const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' };
  1335. if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
  1336. return sendJson(res, 400, { error: '参数不完整' });
  1337. }
  1338. const shopDir = safeShopDir(root, folderName);
  1339. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1340. // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
  1341. const shouldAddLogo = !!addLogo && kind !== 'logo';
  1342. const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
  1343. addLogo: shouldAddLogo,
  1344. logoPosition,
  1345. logoMode,
  1346. });
  1347. const job = createJob({
  1348. kind,
  1349. root,
  1350. folderName,
  1351. shopName,
  1352. model,
  1353. prompt: finalPrompt,
  1354. size: size || '1K',
  1355. aspectRatio: aspectRatio || undefined,
  1356. images,
  1357. outputDir: path.join(shopDir, '_生成图片'),
  1358. outputBaseName: outNameMap[kind],
  1359. warning,
  1360. compositeOverlays,
  1361. });
  1362. sendJson(res, 200, { jobId: job.id });
  1363. }
  1364. async function handleGenerateDish(body, res) {
  1365. const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body;
  1366. if (!root || !folderName || !dishFile || !prompt) {
  1367. return sendJson(res, 400, { error: '参数不完整' });
  1368. }
  1369. const shopDir = safeShopDir(root, folderName);
  1370. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1371. const dishPath = path.resolve(shopDir, dishFile);
  1372. if (!isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
  1373. return sendJson(res, 403, { error: '菜品文件无效' });
  1374. }
  1375. let dishImage;
  1376. try {
  1377. dishImage = readAsDataUri(dishPath);
  1378. } catch (e) {
  1379. return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
  1380. }
  1381. const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, [], {
  1382. addLogo: !!addLogo,
  1383. logoPosition,
  1384. logoMode,
  1385. addSticker: !!addSticker,
  1386. stickerPosition,
  1387. stickerMode,
  1388. sourceImageFirst: true,
  1389. });
  1390. const job = createJob({
  1391. kind: 'dish',
  1392. root,
  1393. folderName,
  1394. shopName,
  1395. dishName,
  1396. model,
  1397. prompt: finalPrompt,
  1398. size: size || '1K',
  1399. aspectRatio: '1:1',
  1400. images: [dishImage, ...overlayImages],
  1401. outputDir: path.join(shopDir, '_生成图片', '菜品图'),
  1402. outputBaseName: sanitizeFileName(dishName),
  1403. warning,
  1404. compositeOverlays,
  1405. });
  1406. sendJson(res, 200, { jobId: job.id });
  1407. }
  1408. function handleJob(pathname, res) {
  1409. const id = pathname.split('/').pop();
  1410. const job = jobs.get(id);
  1411. if (!job) return sendJson(res, 404, { error: 'job not found' });
  1412. sendJson(res, 200, {
  1413. id: job.id,
  1414. kind: job.kind,
  1415. dishName: job.dishName,
  1416. state: job.state,
  1417. progress: job.progress,
  1418. cost: job.cost,
  1419. resultUrl: job.resultUrl,
  1420. width: job.width,
  1421. height: job.height,
  1422. fileSizeBytes: job.fileSizeBytes,
  1423. error: job.error,
  1424. warning: job.warning,
  1425. });
  1426. }
  1427. function publicTask(job) {
  1428. return {
  1429. id: job.id,
  1430. type: 'generation',
  1431. status: job.state,
  1432. root: job.root,
  1433. folderName: job.folderName,
  1434. shopName: job.shopName,
  1435. dishName: job.dishName,
  1436. name: job.dishName || job.kind,
  1437. kind: job.kind,
  1438. model: job.model,
  1439. providerTaskId: job.taskId,
  1440. progress: job.progress,
  1441. cost: job.cost,
  1442. resultUrl: job.resultUrl,
  1443. remoteUrl: job.remoteUrl,
  1444. width: job.width,
  1445. height: job.height,
  1446. fileSizeBytes: job.fileSizeBytes,
  1447. error: job.error,
  1448. warning: job.warning,
  1449. createdAt: job.createdAt,
  1450. startedAt: job.startedAt,
  1451. finishedAt: job.finishedAt,
  1452. updatedAt: job.updatedAt || job.createdAt,
  1453. };
  1454. }
  1455. function handleTasks(query, res) {
  1456. if (query.root && !isWorkspaceRoot(query.root)) {
  1457. return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1458. }
  1459. const root = getWorkspaceRoot();
  1460. const tasks = Array.from(jobs.values())
  1461. .filter((job) => path.resolve(job.root) === root)
  1462. .sort((a, b) => b.createdAt - a.createdAt)
  1463. .map(publicTask);
  1464. const summary = tasks.reduce((acc, task) => {
  1465. acc[task.status] = (acc[task.status] || 0) + 1;
  1466. return acc;
  1467. }, { all: tasks.length });
  1468. sendJson(res, 200, { root, summary, tasks });
  1469. }
  1470. async function handleSaveConfig(body, res) {
  1471. const { root, folderName, shopName, prompts } = body;
  1472. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  1473. const shopDir = safeShopDir(root, folderName);
  1474. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1475. const dir = path.join(shopDir, '_生成图片');
  1476. fs.mkdirSync(dir, { recursive: true });
  1477. const cfgPath = path.join(dir, 'prompt-config.json');
  1478. let existing = {};
  1479. if (fs.existsSync(cfgPath)) {
  1480. try {
  1481. existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1482. } catch {
  1483. // start fresh if corrupt
  1484. }
  1485. }
  1486. const merged = {
  1487. shopName: shopName !== undefined ? shopName : existing.shopName,
  1488. prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
  1489. };
  1490. fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
  1491. sendJson(res, 200, { ok: true });
  1492. }
  1493. async function handleSavePosterCanvas(body, res) {
  1494. const { root, folderName, kind, imageDataUrl } = body;
  1495. const spec = POSTER_CANVAS_SPECS[kind];
  1496. if (!root || !folderName || !spec || !imageDataUrl) {
  1497. return sendJson(res, 400, { error: '参数不完整' });
  1498. }
  1499. if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
  1500. return sendJson(res, 400, { error: '店铺目录名无效' });
  1501. }
  1502. const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
  1503. if (!dataUrlMatch) {
  1504. return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
  1505. }
  1506. const base64 = dataUrlMatch[1];
  1507. if (!base64 || base64.length > 10 * 1024 * 1024) {
  1508. return sendJson(res, 400, { error: '画板图数据无效或过大' });
  1509. }
  1510. let imageBuffer;
  1511. try {
  1512. imageBuffer = Buffer.from(base64, 'base64');
  1513. } catch {
  1514. return sendJson(res, 400, { error: '画板图数据无法读取' });
  1515. }
  1516. const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  1517. if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
  1518. return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
  1519. }
  1520. const dimensions = getImageSize(imageBuffer);
  1521. if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
  1522. return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
  1523. }
  1524. const resolvedRoot = path.resolve(root);
  1525. if (!isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1526. const shopDir = path.resolve(resolvedRoot, folderName);
  1527. if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
  1528. return sendJson(res, 400, { error: '店铺目录无效' });
  1529. }
  1530. const dir = path.join(shopDir, '_生成图片');
  1531. const savePath = path.join(dir, spec.fileName);
  1532. try {
  1533. fs.mkdirSync(dir, { recursive: true });
  1534. fs.writeFileSync(savePath, imageBuffer);
  1535. } catch (e) {
  1536. return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
  1537. }
  1538. sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
  1539. }
  1540. function handleOpenFolder(body, res) {
  1541. const { root, folderName } = body;
  1542. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  1543. const dir = path.join(root, folderName, '_生成图片');
  1544. try {
  1545. fs.mkdirSync(dir, { recursive: true });
  1546. } catch (e) {
  1547. return sendJson(res, 500, { error: '无法创建目录: ' + e.message });
  1548. }
  1549. // explorer.exe frequently exits with a non-zero code even when it opens the window fine,
  1550. // so its callback result is ignored rather than treated as a failure.
  1551. execFile('explorer', [dir], () => {});
  1552. sendJson(res, 200, { ok: true });
  1553. }
  1554. // ---------- static frontend ----------
  1555. const STATIC_MAP = {
  1556. '/': ['index.html', 'text/html; charset=utf-8'],
  1557. '/index.html': ['index.html', 'text/html; charset=utf-8'],
  1558. '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
  1559. '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
  1560. '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
  1561. '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
  1562. '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
  1563. '/style.css': ['style.css', 'text/css; charset=utf-8'],
  1564. };
  1565. const STATIC_TYPES = {
  1566. '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
  1567. '.ico': 'image/x-icon', '.css': 'text/css', '.js': 'application/javascript',
  1568. };
  1569. function serveStatic(pathname, res) {
  1570. const entry = STATIC_MAP[pathname];
  1571. if (entry) {
  1572. const full = path.join(__dirname, 'public', entry[0]);
  1573. fs.readFile(full, (err, data) => {
  1574. if (err) { res.writeHead(404); return res.end('Not found'); }
  1575. res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' });
  1576. res.end(data);
  1577. });
  1578. return;
  1579. }
  1580. if (pathname.startsWith('/assets/')) {
  1581. const rel = pathname.replace('/assets/', '');
  1582. if (rel.includes('..') || rel.includes('/')) { res.writeHead(403); return res.end('Forbidden'); }
  1583. const full = path.join(__dirname, 'public', 'assets', rel);
  1584. if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end('Not found'); }
  1585. const ext = path.extname(full).toLowerCase();
  1586. res.writeHead(200, { 'Content-Type': STATIC_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
  1587. fs.createReadStream(full).pipe(res);
  1588. return;
  1589. }
  1590. res.writeHead(404);
  1591. res.end('Not found');
  1592. }
  1593. // ---------- server ----------
  1594. const server = http.createServer((req, res) => {
  1595. let parsedUrl;
  1596. try {
  1597. parsedUrl = new URL(req.url, `http://${req.headers.host}`);
  1598. } catch {
  1599. res.writeHead(400);
  1600. return res.end('bad url');
  1601. }
  1602. const pathname = parsedUrl.pathname;
  1603. const query = Object.fromEntries(parsedUrl.searchParams);
  1604. const respond500 = (e) => sendJson(res, 500, { error: e.message || String(e) });
  1605. if (req.method === 'GET' && pathname === '/api/default-root') {
  1606. return sendJson(res, 200, { root: getWorkspaceRoot() });
  1607. }
  1608. if (req.method === 'GET' && pathname === '/healthz') {
  1609. return sendJson(res, 200, { ok: true, workspace: getWorkspaceRoot() });
  1610. }
  1611. if (req.method === 'GET' && pathname === '/readyz') {
  1612. return sendJson(res, 200, {
  1613. ok: !!config.apiKey,
  1614. workspaceReady: fs.existsSync(getWorkspaceRoot()),
  1615. crawlReady: !!getKsid(),
  1616. });
  1617. }
  1618. if (req.method === 'GET' && pathname === '/api/tasks') {
  1619. try {
  1620. return handleTasks(query, res);
  1621. } catch (e) {
  1622. return respond500(e);
  1623. }
  1624. }
  1625. if (req.method === 'GET' && pathname === '/api/session') {
  1626. return sendJson(res, 200, {
  1627. cookieConfigured: !!session.cookie,
  1628. ksidConfigured: !!getKsid(),
  1629. });
  1630. }
  1631. if (req.method === 'POST' && pathname === '/api/session') {
  1632. return readBody(req)
  1633. .then((body) => {
  1634. session.cookie = String((body && body.cookie) || '');
  1635. saveSession();
  1636. pushSystemLog('采集 Cookie 已更新', 'success');
  1637. sendJson(res, 200, { ok: true });
  1638. })
  1639. .catch(respond500);
  1640. }
  1641. if (req.method === 'GET' && pathname === '/api/shops') {
  1642. const encodedRoot = parsedUrl.searchParams.get('root');
  1643. const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
  1644. if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1645. return handleShopsList(root, res);
  1646. }
  1647. if (req.method === 'POST' && pathname === '/api/shops/manual') {
  1648. return readBody(req).then((body) => handleShopsManual(body, res)).catch(respond500);
  1649. }
  1650. if (req.method === 'POST' && pathname === '/api/crawl/start') {
  1651. return readBody(req).then((body) => startCrawl(body, res)).catch(respond500);
  1652. }
  1653. if (req.method === 'POST' && pathname === '/api/crawl/stop') {
  1654. return stopCrawl(res);
  1655. }
  1656. if (req.method === 'GET' && pathname === '/api/crawl/status') {
  1657. return sendJson(res, 200, crawlState);
  1658. }
  1659. if (req.method === 'GET' && pathname === '/api/logs') {
  1660. return sendJson(res, 200, { logs: getMergedLogs() });
  1661. }
  1662. if (req.method === 'POST' && pathname === '/api/logs/clear') {
  1663. crawlState.log = [];
  1664. systemLogs.length = 0;
  1665. pushSystemLog('日志已清空', 'warn');
  1666. return sendJson(res, 200, { ok: true });
  1667. }
  1668. if (req.method === 'GET' && pathname === '/api/models') {
  1669. return sendJson(res, 200, { models: MODELS, default: config.model });
  1670. }
  1671. if (req.method === 'GET' && pathname === '/api/settings') {
  1672. return sendJson(res, 200, {
  1673. apiKey: config.apiKey ? config.apiKey.slice(0, 8) + '••••••••' : '',
  1674. apiBase: config.apiBase,
  1675. model: config.model,
  1676. rootDir: getWorkspaceRoot(),
  1677. pageSize: config.pageSize,
  1678. delayMs: config.delayMs,
  1679. cookieConfigured: !!session.cookie && !!getKsid(),
  1680. port: config.port,
  1681. });
  1682. }
  1683. if (req.method === 'POST' && pathname === '/api/settings') {
  1684. return readBody(req).then((body) => {
  1685. const previousApiKey = config.apiKey;
  1686. const previousCookie = session.cookie;
  1687. if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey;
  1688. if (body.model && MODEL_IDS.has(body.model)) config.model = body.model;
  1689. if (body.rootDir) {
  1690. const rootDir = path.resolve(String(body.rootDir).trim());
  1691. if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
  1692. return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir });
  1693. }
  1694. config.rootDir = rootDir;
  1695. }
  1696. if (body.pageSize !== undefined) {
  1697. const pageSize = Number(body.pageSize);
  1698. if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
  1699. return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' });
  1700. }
  1701. config.pageSize = pageSize;
  1702. }
  1703. if (body.delayMs !== undefined) {
  1704. const delayMs = Number(body.delayMs);
  1705. if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) {
  1706. return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' });
  1707. }
  1708. config.delayMs = delayMs;
  1709. }
  1710. saveConfig();
  1711. if (body.cookie !== undefined) {
  1712. session.cookie = body.cookie;
  1713. saveSession();
  1714. }
  1715. pushSystemLog(
  1716. `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` +
  1717. (previousApiKey !== config.apiKey ? ',API Key 已更新' : '') +
  1718. (body.cookie !== undefined && previousCookie !== session.cookie ? ',Cookie 已更新' : ''),
  1719. 'success'
  1720. );
  1721. sendJson(res, 200, { ok: true });
  1722. }).catch(respond500);
  1723. }
  1724. if (req.method === 'GET' && pathname === '/api/scan') {
  1725. try {
  1726. return handleScan(query, res);
  1727. } catch (e) {
  1728. return respond500(e);
  1729. }
  1730. }
  1731. if (req.method === 'GET' && pathname === '/api/creations') {
  1732. try {
  1733. return handleCreations(query, res);
  1734. } catch (e) {
  1735. return respond500(e);
  1736. }
  1737. }
  1738. if (req.method === 'GET' && pathname === '/files') {
  1739. try {
  1740. return handleFiles(query, res);
  1741. } catch (e) {
  1742. return respond500(e);
  1743. }
  1744. }
  1745. if (req.method === 'POST' && pathname === '/api/generate/brand') {
  1746. return readBody(req).then((body) => handleGenerateBrand(body, res)).catch(respond500);
  1747. }
  1748. if (req.method === 'POST' && pathname === '/api/generate/dish') {
  1749. return readBody(req).then((body) => handleGenerateDish(body, res)).catch(respond500);
  1750. }
  1751. if (req.method === 'GET' && pathname.startsWith('/api/job/')) {
  1752. try {
  1753. return handleJob(pathname, res);
  1754. } catch (e) {
  1755. return respond500(e);
  1756. }
  1757. }
  1758. if (req.method === 'POST' && pathname === '/api/save-config') {
  1759. return readBody(req).then((body) => handleSaveConfig(body, res)).catch(respond500);
  1760. }
  1761. if (req.method === 'POST' && pathname === '/api/save-poster-canvas') {
  1762. return readBody(req).then((body) => handleSavePosterCanvas(body, res)).catch(respond500);
  1763. }
  1764. if (req.method === 'POST' && pathname === '/api/open-folder') {
  1765. return readBody(req).then((body) => {
  1766. if (body.root && !isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1767. const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
  1768. const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
  1769. const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
  1770. if (!targetDir || !isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
  1771. const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
  1772. execFile(command, [targetDir], () => {});
  1773. sendJson(res, 200, { ok: true, opened: targetDir });
  1774. }).catch(respond500);
  1775. }
  1776. return serveStatic(pathname, res);
  1777. });
  1778. loadPersistedJobs();
  1779. server.listen(config.port, HOST, () => {
  1780. const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${config.port}`;
  1781. pushSystemLog(`服务已启动:${url}`, 'success');
  1782. console.log(`门店装修工具已启动: ${url}`);
  1783. // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
  1784. // the empty "" argument is the window-title placeholder start expects before the URL.
  1785. execFile('cmd', ['/c', 'start', '', url], () => {});
  1786. });