crawl.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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. window.shanCrawlInit = function () {
  156. if (initialized) return;
  157. initialized = true;
  158. const modal = document.getElementById('modalAddShop');
  159. document.getElementById('btnToggleAdd').addEventListener('click', () => {
  160. modal.hidden = false;
  161. document.getElementById('manualInput').focus();
  162. });
  163. document.getElementById('btnCloseModal').addEventListener('click', () => { modal.hidden = true; });
  164. document.getElementById('btnCancelAdd').addEventListener('click', () => { modal.hidden = true; });
  165. modal.addEventListener('click', (e) => { if (e.target === modal) modal.hidden = true; });
  166. document.addEventListener('keydown', (e) => {
  167. if (e.key === 'Escape' && !modal.hidden) modal.hidden = true;
  168. });
  169. document.getElementById('advToggle').addEventListener('click', function () {
  170. const open = this.getAttribute('aria-expanded') === 'true';
  171. this.setAttribute('aria-expanded', String(!open));
  172. document.getElementById('advBody').classList.toggle('is-open', !open);
  173. });
  174. document.getElementById('btnClearLog').addEventListener('click', () => {
  175. document.getElementById('logPanel').innerHTML = '';
  176. });
  177. jsonFetch('/api/settings').then(s => {
  178. crawlRoot = s.rootDir;
  179. return refreshShops();
  180. });
  181. jsonFetch('/api/crawl/status').then((cs) => {
  182. renderCrawlState(cs);
  183. if (cs.status === 'running') startPolling();
  184. });
  185. document.getElementById('useManualBtn').addEventListener('click', () => {
  186. const text = document.getElementById('manualInput').value;
  187. if (!text.trim()) return showToast('请先填写手动清单', 'error');
  188. jsonFetch('/api/shops/manual', {
  189. method: 'POST', headers: { 'Content-Type': 'application/json' },
  190. body: JSON.stringify({ text }),
  191. }).then((data) => {
  192. state.shops = data.shops; renderShopTable();
  193. showToast(`已添加 ${data.addedCount} 家店铺`);
  194. }).catch((e) => showToast('添加失败: ' + e.message, 'error'));
  195. });
  196. document.getElementById('selectAllCb').addEventListener('change', (e) => {
  197. document.querySelectorAll('#shopTableBody input[type=checkbox]').forEach((cb) => { cb.checked = e.target.checked; });
  198. });
  199. document.getElementById('startBtn').addEventListener('click', () => {
  200. if (document.getElementById('stopBtn').style.display !== 'none') {
  201. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  202. return;
  203. }
  204. const shopIds = Array.from(document.querySelectorAll('#shopTableBody input[type=checkbox]'))
  205. .filter((cb) => cb.checked).map((cb) => Number(cb.dataset.shopId));
  206. if (!shopIds.length) return showToast('请先在店铺列表里勾选要抓取的店铺', 'error');
  207. const root = crawlRoot;
  208. jsonFetch('/api/crawl/start', {
  209. method: 'POST', headers: { 'Content-Type': 'application/json' },
  210. body: JSON.stringify({ root, shopIds, forceFullCrawl: document.getElementById('forceFullCrawl').checked }),
  211. }).then(() => { showToast('已开始抓取'); startPolling(); })
  212. .catch((e) => showToast('启动失败: ' + e.message, 'error'));
  213. });
  214. document.getElementById('stopBtn').addEventListener('click', () => {
  215. jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
  216. });
  217. };
  218. // 视图感知:离开采集页暂停状态轮询,回页时按服务端实际状态决定是否恢复
  219. //(app.js switchView 调用;shanCrawlInit 只在首次进入时绑事件,不会重复拉状态)
  220. window.shanCrawlSetActive = function (active) {
  221. if (active) {
  222. jsonFetch('/api/crawl/status').then((cs) => {
  223. renderCrawlState(cs);
  224. if (cs.status === 'running') startPolling();
  225. }).catch(() => {});
  226. } else if (pollTimer) {
  227. clearInterval(pollTimer);
  228. pollTimer = null;
  229. }
  230. };
  231. })();