crawl.js 14 KB

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