crawl.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. 'use strict';
  2. (function () {
  3. const state = {
  4. shops: [],
  5. folderNameByShopId: new Map(),
  6. statusByShopId: new Map(),
  7. };
  8. let pollTimer = null;
  9. let pollFailStreak = 0;
  10. let crawlRoot = '';
  11. let initialized = false;
  12. function showToast(msg, type) {
  13. const wrap = document.getElementById('toast-wrap');
  14. const el = document.createElement('div');
  15. el.className = 'toast toast-' + (type || 'success');
  16. el.textContent = msg;
  17. wrap.appendChild(el);
  18. setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s'; }, 2400);
  19. setTimeout(() => { if (el.parentNode) wrap.removeChild(el); }, 2800);
  20. }
  21. function jsonFetch(url, opts) {
  22. return fetch(url, opts).then((r) =>
  23. r.json().then((data) => {
  24. if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
  25. return data;
  26. })
  27. );
  28. }
  29. // 店铺名/目录名来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
  30. function esc(value) {
  31. const div = document.createElement('div');
  32. div.textContent = String(value ?? '');
  33. return div.innerHTML;
  34. }
  35. function renderShopTable() {
  36. const body = document.getElementById('shopTableBody');
  37. // 表格每 1.5s 随轮询全量重建,重建前记住用户已手动取消勾选的店铺,避免勾选被重置
  38. const uncheckedBefore = new Set();
  39. body.querySelectorAll('input[type=checkbox]').forEach((cb) => {
  40. if (!cb.checked) uncheckedBefore.add(cb.dataset.shopId);
  41. });
  42. body.innerHTML = '';
  43. document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
  44. const countEl = document.getElementById('shopCount');
  45. if (countEl) countEl.textContent = state.shops.length + ' 家';
  46. for (const shop of state.shops) {
  47. const tr = document.createElement('tr');
  48. const tdCb = document.createElement('td');
  49. const cb = document.createElement('input');
  50. cb.type = 'checkbox'; cb.checked = !uncheckedBefore.has(String(shop.shopId)); cb.dataset.shopId = String(shop.shopId);
  51. tdCb.appendChild(cb); tr.appendChild(tdCb);
  52. const tdName = document.createElement('td');
  53. tdName.innerHTML = `<div class="shop-name">${esc(shop.shopName)}</div>`;
  54. tr.appendChild(tdName);
  55. const tdId = document.createElement('td');
  56. tdId.textContent = shop.shopId;
  57. tdId.className = 'num';
  58. tr.appendChild(tdId);
  59. const tdStatus = document.createElement('td');
  60. const st = state.statusByShopId.get(shop.shopId) || (shop.completed ? { state: 'skipped_completed' } : null);
  61. tdStatus.innerHTML = st ? statusBadgeHtml(st) : '<span class="badge">待抓取</span>';
  62. tr.appendChild(tdStatus);
  63. const saved = shop.completed;
  64. const folderName = state.folderNameByShopId.get(shop.shopId) || (saved ? saved.folderName : null);
  65. const tdFolder = document.createElement('td');
  66. tdFolder.className = 'col-folder';
  67. tdFolder.innerHTML = folderName
  68. ? `<span class="badge badge-ok">${esc(folderName)}</span>`
  69. : '<span class="badge">—</span>';
  70. tr.appendChild(tdFolder);
  71. tr.dataset.shopId = String(shop.shopId);
  72. body.appendChild(tr);
  73. }
  74. }
  75. function statusBadgeHtml(st) {
  76. const label = {
  77. pending: '排队中', running: '抓取中',
  78. done: `完成 ${st.downloaded}/${st.total || 0}`,
  79. skipped_completed: '已完成',
  80. failed: '失败', stopped: '已停止', interrupted: '已中断',
  81. }[st.state] || st.state;
  82. const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
  83. return `<span class="badge ${cls}">${esc(label)}</span>`;
  84. }
  85. const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
  86. function renderCrawlState(cs) {
  87. const badge = document.getElementById('crawlStatusBadge');
  88. const labelMap = { idle: '空闲', running: '抓取中', done: '已完成', stopped: '已停止', error: '出错', interrupted: '已中断' };
  89. const clsMap = { running: 'badge-warn', done: 'badge-ok', error: 'badge-fail', stopped: 'badge-warn', interrupted: 'badge-warn' };
  90. badge.textContent = labelMap[cs.status] || cs.status;
  91. badge.className = 'badge ' + (clsMap[cs.status] || '');
  92. const startBtn = document.getElementById('startBtn');
  93. const stopBtn = document.getElementById('stopBtn');
  94. const startIcon = document.getElementById('startBtnIcon');
  95. const startText = document.getElementById('startBtnText');
  96. startBtn.disabled = cs.status === 'running';
  97. stopBtn.style.display = cs.status === 'running' ? '' : 'none';
  98. if (cs.status === 'running') {
  99. startIcon.innerHTML = '<span class="spinner spinner-inline"></span>';
  100. startText.textContent = '抓取中… 点击停止';
  101. startBtn.classList.remove('btn-primary');
  102. startBtn.classList.add('btn-ghost');
  103. startBtn.disabled = false;
  104. } else {
  105. startIcon.innerHTML = ICON_PLAY;
  106. startText.textContent = '开始抓取';
  107. startBtn.classList.remove('btn-ghost');
  108. startBtn.classList.add('btn-primary');
  109. }
  110. state.statusByShopId.clear();
  111. state.folderNameByShopId.clear();
  112. for (const s of cs.shops) {
  113. state.statusByShopId.set(s.shopId, s);
  114. if (s.folderName) state.folderNameByShopId.set(s.shopId, s.folderName);
  115. }
  116. renderShopTable();
  117. const list = document.getElementById('progressList');
  118. list.innerHTML = '';
  119. if (!cs.shops.length) {
  120. list.innerHTML = '<div class="empty-hint"><div class="empty-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h16"/></svg></div><p>暂无任务</p></div>';
  121. }
  122. for (const s of cs.shops) {
  123. const row = document.createElement('div');
  124. row.className = 'progress-row';
  125. const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
  126. row.innerHTML = `
  127. <div class="left"><span class="badge badge-info">采集</span><span class="name">${esc(s.shopName)}</span><span class="meta">· 菜品图</span></div>
  128. <div class="right">
  129. <div class="progress-bar"><span style="width:${pct}%;"></span></div>
  130. <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
  131. ${statusBadgeHtml(s)}
  132. </div>`;
  133. list.appendChild(row);
  134. }
  135. if (cs.status !== 'running' && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  136. }
  137. function startPolling() {
  138. if (pollTimer) clearInterval(pollTimer);
  139. const tick = () => jsonFetch('/api/crawl/status').then((cs) => {
  140. pollFailStreak = 0;
  141. renderCrawlState(cs);
  142. }).catch(() => {
  143. // 静默重试,但连续失败要给一次提示(服务可能重启),之后仍自动恢复
  144. pollFailStreak++;
  145. if (pollFailStreak === 3) showToast('抓取状态轮询失败(服务可能已重启),恢复后自动继续', 'error');
  146. });
  147. tick();
  148. pollTimer = setInterval(tick, 1500);
  149. }
  150. function refreshShops() {
  151. const root = crawlRoot;
  152. const query = root ? `?root=${btoa(unescape(encodeURIComponent(root))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` : '';
  153. return jsonFetch(`/api/shops${query}`).then((data) => { state.shops = data.shops || []; renderShopTable(); });
  154. }
  155. // 采集通道:按服务端探测结果给一行提示(客户端端口 / Cookie 是否就绪)
  156. function renderChannelHint(info) {
  157. const el = document.getElementById('crawlChannelHint');
  158. if (!el) return;
  159. let msg;
  160. if (info.channel === 'client') {
  161. msg = info.clientUsable
  162. ? '将仅通过客户端采集(调试端口 9669 已就绪)'
  163. : '客户端未启动或调试端口(9669)未开:此模式下无法开始抓取';
  164. } else if (info.channel === 'cookie') {
  165. msg = info.cookieConfigured
  166. ? '将仅通过 Cookie 采集'
  167. : '尚未填写 Cookie:请在「设置 → 采集配置」粘贴';
  168. } else if (info.clientUsable) {
  169. msg = '已检测到客户端:将走客户端通道,失联时自动改用 Cookie';
  170. } else if (info.cookieConfigured) {
  171. msg = '未检测到客户端(9669 未开):将使用 Cookie 通道';
  172. } else {
  173. msg = '未检测到客户端,也未填写 Cookie:开始抓取前请至少配置其一';
  174. }
  175. el.textContent = msg;
  176. }
  177. function refreshChannelInfo() {
  178. return jsonFetch('/api/crawl/channel').then(renderChannelHint).catch(() => {});
  179. }
  180. window.shanCrawlInit = function () {
  181. if (initialized) return;
  182. initialized = true;
  183. const modal = document.getElementById('modalAddShop');
  184. document.getElementById('btnToggleAdd').addEventListener('click', () => {
  185. modal.hidden = false;
  186. document.getElementById('manualInput').focus();
  187. });
  188. document.getElementById('btnCloseModal').addEventListener('click', () => { modal.hidden = true; });
  189. document.getElementById('btnCancelAdd').addEventListener('click', () => { modal.hidden = true; });
  190. modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
  191. document.addEventListener('keydown', (e) => {
  192. if (e.key === 'Escape' && !modal.hidden) modal.hidden = true;
  193. });
  194. document.getElementById('btnClearLog').addEventListener('click', () => {
  195. document.getElementById('logPanel').innerHTML = '';
  196. });
  197. jsonFetch('/api/settings').then(s => {
  198. crawlRoot = s.rootDir;
  199. if (s.crawlChannel) document.getElementById('crawlChannel').value = s.crawlChannel;
  200. refreshChannelInfo();
  201. return refreshShops();
  202. });
  203. jsonFetch('/api/crawl/status').then((cs) => {
  204. renderCrawlState(cs);
  205. if (cs.status === 'running') startPolling();
  206. });
  207. document.getElementById('crawlChannel').addEventListener('change', function () {
  208. const value = this.value;
  209. jsonFetch('/api/settings', {
  210. method: 'POST', headers: { 'Content-Type': 'application/json' },
  211. body: JSON.stringify({ crawlChannel: value }),
  212. }).then(() => {
  213. showToast('采集通道已保存');
  214. refreshChannelInfo();
  215. }).catch((e) => showToast('保存失败: ' + e.message, 'error'));
  216. });
  217. document.getElementById('useManualBtn').addEventListener('click', () => {
  218. const text = document.getElementById('manualInput').value;
  219. if (!text.trim()) return showToast('请先填写手动清单', 'error');
  220. jsonFetch('/api/shops/manual', {
  221. method: 'POST', headers: { 'Content-Type': 'application/json' },
  222. body: JSON.stringify({ text }),
  223. }).then((data) => {
  224. state.shops = data.shops; renderShopTable();
  225. showToast(`已添加 ${data.addedCount} 家店铺`);
  226. }).catch((e) => showToast('添加失败: ' + e.message, 'error'));
  227. });
  228. document.getElementById('selectAllCb').addEventListener('change', (e) => {
  229. document.querySelectorAll('#shopTableBody input[type=checkbox]').forEach((cb) => { cb.checked = e.target.checked; });
  230. });
  231. document.getElementById('startBtn').addEventListener('click', () => {
  232. if (document.getElementById('stopBtn').style.display !== 'none') {
  233. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  234. return;
  235. }
  236. const shopIds = Array.from(document.querySelectorAll('#shopTableBody input[type=checkbox]'))
  237. .filter((cb) => cb.checked).map((cb) => Number(cb.dataset.shopId));
  238. if (!shopIds.length) return showToast('请先在店铺列表里勾选要抓取的店铺', 'error');
  239. const root = crawlRoot;
  240. jsonFetch('/api/crawl/start', {
  241. method: 'POST', headers: { 'Content-Type': 'application/json' },
  242. body: JSON.stringify({ root, shopIds, forceFullCrawl: document.getElementById('forceFullCrawl').checked }),
  243. }).then(() => { showToast('已开始抓取'); startPolling(); })
  244. .catch((e) => showToast('启动失败: ' + e.message, 'error'));
  245. });
  246. document.getElementById('stopBtn').addEventListener('click', () => {
  247. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  248. });
  249. };
  250. // 视图感知:离开采集页暂停状态轮询,回页时按服务端实际状态决定是否恢复
  251. //(app.js switchView 调用;shanCrawlInit 只在首次进入时绑事件,不会重复拉状态)
  252. window.shanCrawlSetActive = function (active) {
  253. if (active) {
  254. refreshChannelInfo();
  255. jsonFetch('/api/crawl/status').then((cs) => {
  256. renderCrawlState(cs);
  257. if (cs.status === 'running') startPolling();
  258. }).catch(() => {});
  259. } else if (pollTimer) {
  260. clearInterval(pollTimer);
  261. pollTimer = null;
  262. }
  263. };
  264. })();