crawl.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. 'use strict';
  2. // 采集引擎:按店铺批量抓取菜品原图。
  3. // 从 server.js 原样搬入:翻页、下载、断点跳过、店铺完成状态、可取消的任务循环。
  4. // 状态(crawlState/cancelFlag)收敛在本工厂内,通过返回的接口访问。
  5. //
  6. // 采集通道(v2):
  7. // · client —— 请求放进淘宝闪购客户端页面上下文发出(CDP :9669),登录态随客户端走,无需 Cookie;
  8. // · cookie —— 原直连通道,靠手填 Cookie(ksid)鉴权,保留作为兜底;
  9. // · auto —— 优先客户端,客户端不可用/中途失联时自动回落 Cookie。
  10. // 两通道的请求体/响应形状完全一致,仅传输层不同;图片文件下载不走通道(CDN 直下,无需登录态)。
  11. const fs = require('fs');
  12. const path = require('path');
  13. const crypto = require('crypto');
  14. const https = require('https');
  15. const http = require('http');
  16. const { URL } = require('url');
  17. const { sendJson, sleep, sanitizeFileName, guessExtFromUrl, SKIP_DIR_NAMES } = require('../util.js');
  18. const kit = require('../../napos/kit.js');
  19. const GOODS_INVOKE_PATH = '/nevermore.goods/invoke';
  20. const GOODS_SERVICE = 'ItemPhotoProcessService';
  21. const GOODS_METHOD = 'getItemPhotoGallery';
  22. const MAX_PAGES = 200;
  23. 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';
  24. function createCrawlService(ctx) {
  25. const { config, session, shops, workspace, logs } = ctx;
  26. let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
  27. let cancelFlag = false;
  28. // 通道:channelMode 是用户选择(auto/client/cookie);useClient 是本次运行实际生效通道(auto 下可中途回落)
  29. let channelMode = 'auto';
  30. let useClient = false;
  31. // ---------- 采集状态持久化(重启后进度不丢) ----------
  32. const stateFilePath = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'crawl-state.json');
  33. function persistState() {
  34. if (crawlState.status === 'idle' && !crawlState.shops.length) return;
  35. try {
  36. const file = stateFilePath();
  37. fs.mkdirSync(path.dirname(file), { recursive: true });
  38. const temp = `${file}.${process.pid}.tmp`;
  39. const snapshot = { ...crawlState, log: crawlState.log.slice(-100) };
  40. fs.writeFileSync(temp, JSON.stringify({ version: 1, savedAt: new Date().toISOString(), state: snapshot }, null, 2), 'utf8');
  41. fs.renameSync(temp, file);
  42. } catch { /* 持久化失败不影响采集进行 */ }
  43. }
  44. function restoreState() {
  45. let parsed;
  46. try { parsed = JSON.parse(fs.readFileSync(stateFilePath(), 'utf8')); } catch { return; }
  47. const saved = parsed && parsed.version === 1 && parsed.state;
  48. if (!saved || typeof saved.status !== 'string') return;
  49. crawlState = {
  50. status: saved.status,
  51. startedAt: saved.startedAt || null,
  52. finishedAt: saved.finishedAt || null,
  53. forceFullCrawl: !!saved.forceFullCrawl,
  54. channel: saved.channel || null,
  55. shops: Array.isArray(saved.shops) ? saved.shops : [],
  56. log: Array.isArray(saved.log) ? saved.log : [],
  57. };
  58. if (crawlState.status === 'running') {
  59. crawlState.status = 'interrupted';
  60. crawlState.finishedAt = Date.now();
  61. for (const s of crawlState.shops) {
  62. if (s.state === 'running' || s.state === 'pending') s.state = 'interrupted';
  63. }
  64. crawlState.log.push(logs.createLogEntry('服务重启,上次采集被中断;未完成的店铺可重新勾选采集', 'task', 'warn'));
  65. }
  66. }
  67. restoreState();
  68. function pushLog(msg) {
  69. const entry = logs.createLogEntry(msg, 'task', logs.detectLogLevel(msg));
  70. crawlState.log.push(entry);
  71. if (crawlState.log.length > 500) crawlState.log.shift();
  72. }
  73. /** 两通道共用的请求体(与真实抓包一致:ksid 在 metas 里,一次性 id) */
  74. function buildRequestBody(service, method, requestParams, ksid, extraMetas) {
  75. return {
  76. id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
  77. method, service,
  78. params: { request: requestParams || {} },
  79. metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid }, extraMetas || {}),
  80. ncp: '2.0.0', adapter: 'ncp',
  81. extendConfig: { docMode: 'master,,' },
  82. };
  83. }
  84. // ---- Cookie 直连通道(原实现,兜底保留) ----
  85. async function httpInvoke(service, method, requestParams, extraMetas) {
  86. return new Promise((resolve, reject) => {
  87. const body = buildRequestBody(service, method, requestParams, session.getKsid(), extraMetas);
  88. const data = JSON.stringify(body);
  89. const options = {
  90. hostname: 'app-api.shop.ele.me', port: 443,
  91. path: `${GOODS_INVOKE_PATH}?method=${encodeURIComponent(service + '.' + method)}`,
  92. method: 'POST',
  93. headers: {
  94. 'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
  95. Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
  96. Cookie: session.cookie || '', 'User-Agent': UA,
  97. 'Content-Length': Buffer.byteLength(data),
  98. },
  99. timeout: config.requestTimeoutMs,
  100. };
  101. const req = https.request(options, (res) => {
  102. const chunks = [];
  103. res.on('data', (c) => chunks.push(c));
  104. res.on('end', () => {
  105. const raw = Buffer.concat(chunks).toString('utf8');
  106. let json = null;
  107. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  108. resolve({ statusCode: res.statusCode, json, raw });
  109. });
  110. });
  111. req.on('error', reject);
  112. req.on('timeout', () => req.destroy(new Error('请求超时')));
  113. req.write(data);
  114. req.end();
  115. });
  116. }
  117. // ---- 客户端通道(CDP):请求由客户端页面上下文发出,Cookie/UA 全是真 ----
  118. function makeClientInvoker(api, ksid) {
  119. return async function (service, method, requestParams, extraMetas) {
  120. const url = `https://app-api.shop.ele.me${GOODS_INVOKE_PATH}?method=${encodeURIComponent(service + '.' + method)}`;
  121. const res = await kit.cdp.pageFetch(api, {
  122. method: 'POST', url, allowWrite: false,
  123. body: buildRequestBody(service, method, requestParams, ksid, extraMetas),
  124. });
  125. if (res.error || res.timeout) {
  126. throw new Error('客户端内请求失败: ' + (res.error || '超时(可能被风控挂起,请在客户端检查)'));
  127. }
  128. return {
  129. statusCode: res.status,
  130. json: res.parsed ? res.data : null,
  131. raw: res.parsed ? '' : String(res.data || ''),
  132. risk: !!res.risk, authFail: !!res.authFail,
  133. };
  134. };
  135. }
  136. /**
  137. * 翻页拉全一个店铺的菜品列表(不含下载)。
  138. * @param {Function} invoke (service, method, requestParams, extraMetas) => resp,由通道提供
  139. * @returns {{items:Array, authFailed:boolean, risk:boolean, error:?string}}
  140. * error 只作记录不中断:已拉到的部分照常下载(Cookie 通道保留原行为)
  141. */
  142. async function fetchAllPages(shopEntry, invoke, pageSize) {
  143. const allItems = [];
  144. const seen = new Set();
  145. let nextId;
  146. let page = 0;
  147. let authFailed = false;
  148. let risk = false;
  149. let error = null;
  150. while (page < MAX_PAGES) {
  151. if (cancelFlag) break;
  152. page++;
  153. const requestParams = {
  154. shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
  155. searchKey: '', searchType: ['ITEM_NAME'],
  156. };
  157. if (nextId) requestParams.nextId = nextId;
  158. let resp;
  159. try { resp = await invoke(GOODS_SERVICE, GOODS_METHOD, requestParams, { shopId: shopEntry.shopId }); }
  160. catch (e) {
  161. error = '请求失败: ' + (e.message || e);
  162. pushLog(`[${shopEntry.shopName}] ${error}`);
  163. break;
  164. }
  165. if (resp.risk || resp.authFail || isAuthError(resp)) {
  166. authFailed = true;
  167. risk = !!resp.risk;
  168. pushLog(`[${shopEntry.shopName}] ${risk ? '触发风控/验证码' : '鉴权失败'}(登录态可能已失效)`);
  169. break;
  170. }
  171. const items = (resp.json && resp.json.result && resp.json.result.result) || [];
  172. nextId = resp.json && resp.json.result && resp.json.result.nextId;
  173. if (!items.length) {
  174. pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
  175. break;
  176. }
  177. let newCount = 0;
  178. for (const it of items) {
  179. const gid = it.itemGlobalId;
  180. if (gid !== undefined && gid !== null && seen.has(gid)) continue;
  181. if (gid !== undefined && gid !== null) seen.add(gid);
  182. allItems.push(it);
  183. newCount++;
  184. }
  185. pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
  186. if (!nextId) break;
  187. await sleep(200);
  188. }
  189. return { items: allItems, authFailed, risk, error };
  190. }
  191. /**
  192. * 客户端通道拉取:每店一个 CDP 会话(复用于本店全部翻页),
  193. * 连接坏了只损失当前店,下一店自动重连。
  194. * @returns 页结果;null 表示应回落 Cookie 通道;shopFailed=true 表示本店已判失败(无 Cookie 可回落)
  195. */
  196. async function fetchViaClient(shopEntry, pageSize) {
  197. try {
  198. return await kit.cdp.withPageFor(kit.DEFAULT_PORT, 'shop', async (api) => {
  199. const ksid = await kit.cdp.findKsid(api);
  200. if (!ksid) throw new Error('客户端未登录(读不到登录态 ksid),请先在客户端登录');
  201. return fetchAllPages(shopEntry, makeClientInvoker(api, ksid), pageSize);
  202. }, { needExact: false, autoOpen: false });
  203. } catch (e) {
  204. const msg = e.message || String(e);
  205. if (!(channelMode === 'auto' && session.cookie && session.getKsid())) {
  206. shopEntry.state = 'failed';
  207. shopEntry.error = '客户端通道失败: ' + msg;
  208. pushLog(`[${shopEntry.shopName}] ${shopEntry.error}`);
  209. return { items: [], authFailed: false, risk: false, error: shopEntry.error, shopFailed: true };
  210. }
  211. pushLog(`[${shopEntry.shopName}] 客户端通道失败: ${msg}`);
  212. return null;
  213. }
  214. }
  215. function isAuthError(resp) {
  216. if (resp.statusCode === 401 || resp.statusCode === 403) return true;
  217. const j = resp.json;
  218. if (!j) return false;
  219. if (j.result !== undefined) return false;
  220. return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
  221. }
  222. function downloadImage(urlStr, redirectsLeft) {
  223. if (redirectsLeft === undefined) redirectsLeft = 5;
  224. return new Promise((resolve, reject) => {
  225. let u;
  226. try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
  227. const mod = u.protocol === 'http:' ? http : https;
  228. mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
  229. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  230. res.resume();
  231. return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
  232. }
  233. if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
  234. const chunks = [];
  235. res.on('data', (c) => chunks.push(c));
  236. res.on('end', () => resolve(Buffer.concat(chunks)));
  237. }).on('error', reject);
  238. });
  239. }
  240. // ---------- 店铺完成状态(root 下 _膳绘_店铺状态.json) ----------
  241. function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
  242. function readCompletionState(root) {
  243. try {
  244. const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
  245. return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
  246. } catch { return {}; }
  247. }
  248. function writeCompletionState(root, shopsState) {
  249. const target = completionStatePath(root);
  250. const temp = `${target}.${process.pid}.tmp`;
  251. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops: shopsState }, null, 2), 'utf8');
  252. fs.renameSync(temp, target);
  253. }
  254. function markShopCompleted(root, shopEntry) {
  255. const shopsState = readCompletionState(root);
  256. shopsState[String(shopEntry.shopId)] = {
  257. shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
  258. completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
  259. skipped: shopEntry.skipped, total: shopEntry.total,
  260. };
  261. writeCompletionState(root, shopsState);
  262. }
  263. // ---------- 门店目录分配 ----------
  264. function listRootDirs(root) {
  265. return fs.readdirSync(root, { withFileTypes: true })
  266. .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
  267. .map((e) => e.name);
  268. }
  269. function resolveShopDir(root, shopId, shopName) {
  270. const dirs = listRootDirs(root);
  271. const suffix = '_' + String(shopId);
  272. const match = dirs.find((d) => d.endsWith(suffix));
  273. if (match) return { dir: path.join(root, match), folderName: match, reused: true };
  274. let maxSeq = 0;
  275. for (const d of dirs) {
  276. const mm = /^(\d+)_/.exec(d);
  277. if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
  278. }
  279. const seq = String(maxSeq + 1).padStart(3, '0');
  280. const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
  281. const dir = path.join(root, folderName);
  282. fs.mkdirSync(dir, { recursive: true });
  283. return { dir, folderName, reused: false };
  284. }
  285. // ---------- 任务循环 ----------
  286. async function startCrawl(body, res) {
  287. if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
  288. // 通道解析:body.channel(单次指定)> config.crawlChannel(设置)> auto
  289. const channelPref = ['auto', 'client', 'cookie'].includes(body && body.channel) ? body.channel : (config.crawlChannel || 'auto');
  290. const cookieOk = !!(session.cookie && session.getKsid());
  291. const probeResult = await kit.cdp.probe().catch(() => ({ ok: false }));
  292. const clientOk = !!probeResult.ok;
  293. if (channelPref === 'client' && !clientOk) {
  294. return sendJson(res, 400, { error: '客户端通道不可用:请先启动淘宝闪购客户端(调试端口 9669),或改用 Cookie 通道' });
  295. }
  296. if (channelPref === 'cookie' && !cookieOk) {
  297. return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie(设置 → 采集配置)' });
  298. }
  299. if (channelPref === 'auto' && !clientOk && !cookieOk) {
  300. return sendJson(res, 400, { error: '未检测到客户端(调试端口 9669 未开),也未填写 Cookie——两种通道至少配置其一' });
  301. }
  302. channelMode = channelPref;
  303. useClient = channelPref === 'client' || (channelPref === 'auto' && clientOk);
  304. const root = String((body && body.root) || workspace.getWorkspaceRoot()).trim();
  305. if (!workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
  306. if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  307. return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
  308. }
  309. const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
  310. const selectedShops = shopIds.map((id) => shops.getList().find((s) => s.shopId === id)).filter(Boolean);
  311. if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
  312. const forceFullCrawl = Boolean(body && body.forceFullCrawl);
  313. const completed = readCompletionState(root);
  314. const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
  315. const pendingShops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
  316. const pageSize = Number(body && body.pageSize) || config.pageSize;
  317. const delayMs = Number(body && body.delayMs) || config.delayMs;
  318. cancelFlag = false;
  319. crawlState = {
  320. status: pendingShops.length ? 'running' : 'done', startedAt: Date.now(),
  321. finishedAt: pendingShops.length ? null : Date.now(), forceFullCrawl,
  322. channel: useClient ? 'client' : 'cookie',
  323. shops: [
  324. ...skippedCompleted.map((s) => ({
  325. shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
  326. state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
  327. downloaded: 0, skipped: 0, failed: 0, error: null,
  328. })),
  329. ...pendingShops.map((s) => ({
  330. shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
  331. total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
  332. })),
  333. ],
  334. log: [],
  335. };
  336. pushLog(`开始抓取,实际抓取 ${pendingShops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root},通道=${useClient ? '客户端(CDP :9669)' : 'Cookie 直连'}${channelPref === 'auto' ? '(自动)' : ''}`);
  337. persistState();
  338. sendJson(res, 200, { ok: true, crawlCount: pendingShops.length, skippedCompletedCount: skippedCompleted.length });
  339. if (!pendingShops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
  340. runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
  341. crawlState.status = 'error';
  342. crawlState.finishedAt = Date.now();
  343. pushLog('抓取流程异常终止: ' + (e.message || e));
  344. persistState();
  345. });
  346. }
  347. async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
  348. for (const shopEntry of crawlState.shops) {
  349. if (shopEntry.state === 'skipped_completed') continue;
  350. if (cancelFlag) {
  351. crawlState.status = 'stopped';
  352. crawlState.finishedAt = Date.now();
  353. pushLog('已停止');
  354. return;
  355. }
  356. shopEntry.state = 'running';
  357. pushLog(`[${shopEntry.shopName}] 开始`);
  358. let dirInfo;
  359. try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
  360. catch (e) {
  361. shopEntry.state = 'failed';
  362. shopEntry.error = '创建目录失败: ' + (e.message || e);
  363. pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
  364. continue;
  365. }
  366. shopEntry.folderName = dirInfo.folderName;
  367. pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
  368. // ---- 翻页:按生效通道拉取(客户端优先,可回落 Cookie) ----
  369. let pageResult = null;
  370. if (useClient) {
  371. pageResult = await fetchViaClient(shopEntry, pageSize);
  372. if (pageResult === null) {
  373. useClient = false;
  374. crawlState.channel = 'cookie';
  375. pushLog('本店起改用 Cookie 通道继续');
  376. } else if (pageResult.shopFailed) {
  377. persistState();
  378. continue;
  379. }
  380. }
  381. if (!pageResult) pageResult = await fetchAllPages(shopEntry, httpInvoke, pageSize);
  382. const allItems = pageResult.items;
  383. shopEntry.total = allItems.length;
  384. if (pageResult.error && !pageResult.authFailed) shopEntry.error = pageResult.error;
  385. if (pageResult.authFailed && !allItems.length) {
  386. shopEntry.state = 'failed';
  387. shopEntry.error = pageResult.risk
  388. ? (useClient ? '触发风控/验证码,请在客户端完成验证后重试' : '触发风控,请稍后重试或更新 Cookie')
  389. : (useClient ? '客户端登录态失效,请在客户端重新登录后重试' : '会话可能已过期,请重新登录后更新 Cookie');
  390. continue;
  391. }
  392. pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
  393. const manifest = [];
  394. const usedNames = new Set();
  395. for (const it of allItems) {
  396. if (cancelFlag) break;
  397. const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
  398. const photoUrl = it.photoUrl || it.thumbnailUrl;
  399. if (!photoUrl) {
  400. shopEntry.skipped++;
  401. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
  402. continue;
  403. }
  404. let finalBase = sanitizeFileName(itemName);
  405. let n = 1;
  406. while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
  407. usedNames.add(finalBase);
  408. try {
  409. const buf = await downloadImage(photoUrl);
  410. const ext = guessExtFromUrl(photoUrl);
  411. const fileName = `${finalBase}.${ext}`;
  412. fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
  413. shopEntry.downloaded++;
  414. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
  415. } catch (e) {
  416. shopEntry.failed++;
  417. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
  418. pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
  419. }
  420. await sleep(delayMs);
  421. }
  422. try {
  423. fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
  424. if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
  425. } catch (e) {
  426. shopEntry.failed++;
  427. shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
  428. pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
  429. }
  430. shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
  431. pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
  432. persistState();
  433. }
  434. crawlState.status = cancelFlag ? 'stopped' : 'done';
  435. crawlState.finishedAt = Date.now();
  436. pushLog('抓取任务结束');
  437. persistState();
  438. }
  439. function stopCrawl(res) {
  440. if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
  441. cancelFlag = true;
  442. pushLog('收到停止请求,将在当前步骤完成后停止');
  443. persistState();
  444. sendJson(res, 200, { ok: true });
  445. }
  446. return {
  447. startCrawl,
  448. stopCrawl,
  449. getState: () => crawlState,
  450. getLog: () => crawlState.log,
  451. clearLog: () => { crawlState.log = []; },
  452. readCompletionState,
  453. flushState: persistState,
  454. };
  455. }
  456. module.exports = { createCrawlService };