crawl.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. 'use strict';
  2. // 采集引擎:按店铺批量抓取菜品原图。
  3. // 从 server.js 原样搬入:会话式 invokeApi、翻页、下载、断点跳过、店铺完成状态、可取消的任务循环。
  4. // 状态(crawlState/cancelFlag)收敛在本工厂内,通过返回的接口访问。
  5. const fs = require('fs');
  6. const path = require('path');
  7. const crypto = require('crypto');
  8. const https = require('https');
  9. const http = require('http');
  10. const { URL } = require('url');
  11. const { sendJson, sleep, sanitizeFileName, guessExtFromUrl, SKIP_DIR_NAMES } = require('../util.js');
  12. 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';
  13. function createCrawlService(ctx) {
  14. const { config, session, shops, workspace, logs } = ctx;
  15. let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
  16. let cancelFlag = false;
  17. // ---------- 采集状态持久化(重启后进度不丢) ----------
  18. const stateFilePath = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'crawl-state.json');
  19. function persistState() {
  20. if (crawlState.status === 'idle' && !crawlState.shops.length) return;
  21. try {
  22. const file = stateFilePath();
  23. fs.mkdirSync(path.dirname(file), { recursive: true });
  24. const temp = `${file}.${process.pid}.tmp`;
  25. const snapshot = { ...crawlState, log: crawlState.log.slice(-100) };
  26. fs.writeFileSync(temp, JSON.stringify({ version: 1, savedAt: new Date().toISOString(), state: snapshot }, null, 2), 'utf8');
  27. fs.renameSync(temp, file);
  28. } catch { /* 持久化失败不影响采集进行 */ }
  29. }
  30. function restoreState() {
  31. let parsed;
  32. try { parsed = JSON.parse(fs.readFileSync(stateFilePath(), 'utf8')); } catch { return; }
  33. const saved = parsed && parsed.version === 1 && parsed.state;
  34. if (!saved || typeof saved.status !== 'string') return;
  35. crawlState = {
  36. status: saved.status,
  37. startedAt: saved.startedAt || null,
  38. finishedAt: saved.finishedAt || null,
  39. forceFullCrawl: !!saved.forceFullCrawl,
  40. shops: Array.isArray(saved.shops) ? saved.shops : [],
  41. log: Array.isArray(saved.log) ? saved.log : [],
  42. };
  43. if (crawlState.status === 'running') {
  44. crawlState.status = 'interrupted';
  45. crawlState.finishedAt = Date.now();
  46. for (const s of crawlState.shops) {
  47. if (s.state === 'running' || s.state === 'pending') s.state = 'interrupted';
  48. }
  49. crawlState.log.push(logs.createLogEntry('服务重启,上次采集被中断;未完成的店铺可重新勾选采集', 'task', 'warn'));
  50. }
  51. }
  52. restoreState();
  53. function pushLog(msg) {
  54. const entry = logs.createLogEntry(msg, 'task', logs.detectLogLevel(msg));
  55. crawlState.log.push(entry);
  56. if (crawlState.log.length > 500) crawlState.log.shift();
  57. }
  58. function invokeApi(service, method, requestParams, extraMetas) {
  59. return new Promise((resolve, reject) => {
  60. const body = {
  61. id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
  62. method, service,
  63. params: { request: requestParams || {} },
  64. metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: session.getKsid() }, extraMetas || {}),
  65. ncp: '2.0.0', adapter: 'ncp',
  66. extendConfig: { docMode: 'master,,' },
  67. };
  68. const data = JSON.stringify(body);
  69. const options = {
  70. hostname: 'app-api.shop.ele.me', port: 443,
  71. path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`,
  72. method: 'POST',
  73. headers: {
  74. 'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
  75. Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
  76. Cookie: session.cookie || '', 'User-Agent': UA,
  77. 'Content-Length': Buffer.byteLength(data),
  78. },
  79. timeout: config.requestTimeoutMs,
  80. };
  81. const req = https.request(options, (res) => {
  82. const chunks = [];
  83. res.on('data', (c) => chunks.push(c));
  84. res.on('end', () => {
  85. const raw = Buffer.concat(chunks).toString('utf8');
  86. let json = null;
  87. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  88. resolve({ statusCode: res.statusCode, json, raw });
  89. });
  90. });
  91. req.on('error', reject);
  92. req.on('timeout', () => req.destroy(new Error('请求超时')));
  93. req.write(data);
  94. req.end();
  95. });
  96. }
  97. function isAuthError(resp) {
  98. if (resp.statusCode === 401 || resp.statusCode === 403) return true;
  99. const j = resp.json;
  100. if (!j) return false;
  101. if (j.result !== undefined) return false;
  102. return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
  103. }
  104. function downloadImage(urlStr, redirectsLeft) {
  105. if (redirectsLeft === undefined) redirectsLeft = 5;
  106. return new Promise((resolve, reject) => {
  107. let u;
  108. try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
  109. const mod = u.protocol === 'http:' ? http : https;
  110. mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
  111. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  112. res.resume();
  113. return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
  114. }
  115. if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
  116. const chunks = [];
  117. res.on('data', (c) => chunks.push(c));
  118. res.on('end', () => resolve(Buffer.concat(chunks)));
  119. }).on('error', reject);
  120. });
  121. }
  122. // ---------- 店铺完成状态(root 下 _膳绘_店铺状态.json) ----------
  123. function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
  124. function readCompletionState(root) {
  125. try {
  126. const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
  127. return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
  128. } catch { return {}; }
  129. }
  130. function writeCompletionState(root, shopsState) {
  131. const target = completionStatePath(root);
  132. const temp = `${target}.${process.pid}.tmp`;
  133. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops: shopsState }, null, 2), 'utf8');
  134. fs.renameSync(temp, target);
  135. }
  136. function markShopCompleted(root, shopEntry) {
  137. const shopsState = readCompletionState(root);
  138. shopsState[String(shopEntry.shopId)] = {
  139. shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
  140. completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
  141. skipped: shopEntry.skipped, total: shopEntry.total,
  142. };
  143. writeCompletionState(root, shopsState);
  144. }
  145. // ---------- 门店目录分配 ----------
  146. function listRootDirs(root) {
  147. return fs.readdirSync(root, { withFileTypes: true })
  148. .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
  149. .map((e) => e.name);
  150. }
  151. function resolveShopDir(root, shopId, shopName) {
  152. const dirs = listRootDirs(root);
  153. const suffix = '_' + String(shopId);
  154. const match = dirs.find((d) => d.endsWith(suffix));
  155. if (match) return { dir: path.join(root, match), folderName: match, reused: true };
  156. let maxSeq = 0;
  157. for (const d of dirs) {
  158. const mm = /^(\d+)_/.exec(d);
  159. if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
  160. }
  161. const seq = String(maxSeq + 1).padStart(3, '0');
  162. const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
  163. const dir = path.join(root, folderName);
  164. fs.mkdirSync(dir, { recursive: true });
  165. return { dir, folderName, reused: false };
  166. }
  167. // ---------- 任务循环 ----------
  168. async function startCrawl(body, res) {
  169. if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
  170. if (!session.cookie || !session.getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
  171. const root = String((body && body.root) || workspace.getWorkspaceRoot()).trim();
  172. if (!workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
  173. if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  174. return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
  175. }
  176. const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
  177. const selectedShops = shopIds.map((id) => shops.getList().find((s) => s.shopId === id)).filter(Boolean);
  178. if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
  179. const forceFullCrawl = Boolean(body && body.forceFullCrawl);
  180. const completed = readCompletionState(root);
  181. const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
  182. const pendingShops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
  183. const pageSize = Number(body && body.pageSize) || config.pageSize;
  184. const delayMs = Number(body && body.delayMs) || config.delayMs;
  185. cancelFlag = false;
  186. crawlState = {
  187. status: pendingShops.length ? 'running' : 'done', startedAt: Date.now(),
  188. finishedAt: pendingShops.length ? null : Date.now(), forceFullCrawl,
  189. shops: [
  190. ...skippedCompleted.map((s) => ({
  191. shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
  192. state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
  193. downloaded: 0, skipped: 0, failed: 0, error: null,
  194. })),
  195. ...pendingShops.map((s) => ({
  196. shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
  197. total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
  198. })),
  199. ],
  200. log: [],
  201. };
  202. pushLog(`开始抓取,实际抓取 ${pendingShops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`);
  203. persistState();
  204. sendJson(res, 200, { ok: true, crawlCount: pendingShops.length, skippedCompletedCount: skippedCompleted.length });
  205. if (!pendingShops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
  206. runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
  207. crawlState.status = 'error';
  208. crawlState.finishedAt = Date.now();
  209. pushLog('抓取流程异常终止: ' + (e.message || e));
  210. persistState();
  211. });
  212. }
  213. async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
  214. for (const shopEntry of crawlState.shops) {
  215. if (shopEntry.state === 'skipped_completed') continue;
  216. if (cancelFlag) {
  217. crawlState.status = 'stopped';
  218. crawlState.finishedAt = Date.now();
  219. pushLog('已停止');
  220. return;
  221. }
  222. shopEntry.state = 'running';
  223. pushLog(`[${shopEntry.shopName}] 开始`);
  224. let dirInfo;
  225. try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
  226. catch (e) {
  227. shopEntry.state = 'failed';
  228. shopEntry.error = '创建目录失败: ' + (e.message || e);
  229. pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
  230. continue;
  231. }
  232. shopEntry.folderName = dirInfo.folderName;
  233. pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
  234. const allItems = [];
  235. const seen = new Set();
  236. let nextId;
  237. let page = 0;
  238. const MAX_PAGES = 200;
  239. let authFailed = false;
  240. while (page < MAX_PAGES) {
  241. if (cancelFlag) break;
  242. page++;
  243. const requestParams = {
  244. shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
  245. searchKey: '', searchType: ['ITEM_NAME'],
  246. };
  247. if (nextId) requestParams.nextId = nextId;
  248. let resp;
  249. try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); }
  250. catch (e) {
  251. shopEntry.error = '请求失败: ' + (e.message || e);
  252. pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`);
  253. break;
  254. }
  255. if (isAuthError(resp)) {
  256. authFailed = true;
  257. shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie';
  258. pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`);
  259. break;
  260. }
  261. const items = (resp.json && resp.json.result && resp.json.result.result) || [];
  262. nextId = resp.json && resp.json.result && resp.json.result.nextId;
  263. if (!items.length) {
  264. pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
  265. break;
  266. }
  267. let newCount = 0;
  268. for (const it of items) {
  269. const gid = it.itemGlobalId;
  270. if (gid !== undefined && gid !== null && seen.has(gid)) continue;
  271. if (gid !== undefined && gid !== null) seen.add(gid);
  272. allItems.push(it);
  273. newCount++;
  274. }
  275. pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
  276. if (!nextId) break;
  277. await sleep(200);
  278. }
  279. shopEntry.total = allItems.length;
  280. if (authFailed && !allItems.length) {
  281. shopEntry.state = 'failed';
  282. continue;
  283. }
  284. pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
  285. const manifest = [];
  286. const usedNames = new Set();
  287. for (const it of allItems) {
  288. if (cancelFlag) break;
  289. const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
  290. const photoUrl = it.photoUrl || it.thumbnailUrl;
  291. if (!photoUrl) {
  292. shopEntry.skipped++;
  293. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
  294. continue;
  295. }
  296. let finalBase = sanitizeFileName(itemName);
  297. let n = 1;
  298. while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
  299. usedNames.add(finalBase);
  300. try {
  301. const buf = await downloadImage(photoUrl);
  302. const ext = guessExtFromUrl(photoUrl);
  303. const fileName = `${finalBase}.${ext}`;
  304. fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
  305. shopEntry.downloaded++;
  306. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
  307. } catch (e) {
  308. shopEntry.failed++;
  309. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
  310. pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
  311. }
  312. await sleep(delayMs);
  313. }
  314. try {
  315. fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
  316. if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
  317. } catch (e) {
  318. shopEntry.failed++;
  319. shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
  320. pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
  321. }
  322. shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
  323. pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
  324. persistState();
  325. }
  326. crawlState.status = cancelFlag ? 'stopped' : 'done';
  327. crawlState.finishedAt = Date.now();
  328. pushLog('抓取任务结束');
  329. persistState();
  330. }
  331. function stopCrawl(res) {
  332. if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
  333. cancelFlag = true;
  334. pushLog('收到停止请求,将在当前步骤完成后停止');
  335. persistState();
  336. sendJson(res, 200, { ok: true });
  337. }
  338. return {
  339. startCrawl,
  340. stopCrawl,
  341. getState: () => crawlState,
  342. getLog: () => crawlState.log,
  343. clearLog: () => { crawlState.log = []; },
  344. readCompletionState,
  345. flushState: persistState,
  346. };
  347. }
  348. module.exports = { createCrawlService };