spider_management.html 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. {% extends "base.html" %}
  2. {% block content %}
  3. <div class="flex h-screen overflow-hidden" id="spider-view">
  4. <!-- Sidebar -->
  5. {% include 'partials/sidebar.html' %}
  6. <!-- Main Content -->
  7. <div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-gray-900/80 relative">
  8. <!-- Top Header -->
  9. <header class="h-16 flex items-center justify-between px-6 z-20 bg-gray-900/80 backdrop-blur-md border-b border-blue-900/30">
  10. <button class="md:hidden text-gray-300 focus:outline-none" id="open-sidebar">
  11. <i class="fas fa-bars text-xl"></i>
  12. </button>
  13. <h1 class="text-lg md:text-2xl font-bold tech-title truncate ml-2">爬虫源管理</h1>
  14. <div class="flex items-center space-x-4">
  15. <div class="flex items-center space-x-2">
  16. <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold border border-cyan-400 shadow-md">A</div>
  17. <span class="hidden md:inline text-sm text-gray-300">{{ current_user.username }}</span>
  18. </div>
  19. </div>
  20. </header>
  21. <!-- Main Scrollable Area -->
  22. <main class="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth">
  23. <!-- Source List Section -->
  24. <div class="tech-panel p-6 rounded-xl mb-6">
  25. <div class="flex justify-between items-center mb-4">
  26. <h3 class="text-lg font-semibold text-cyan-200"><i class="fas fa-database mr-2"></i>爬虫源列表</h3>
  27. <div>
  28. <button onclick="openModal()" class="tech-button px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-500 mr-2"><i class="fas fa-plus mr-1"></i> 新增</button>
  29. <button id="refresh-sources-btn" class="text-sm text-blue-400 hover:text-white"><i class="fas fa-sync-alt mr-1"></i>刷新</button>
  30. </div>
  31. </div>
  32. <div class="overflow-x-auto">
  33. <table class="w-full text-left border-collapse">
  34. <thead>
  35. <tr class="text-gray-400 border-b border-blue-800">
  36. <th class="p-3">ID</th>
  37. <th class="p-3">名称</th>
  38. <th class="p-3">标识</th>
  39. <th class="p-3">类型</th>
  40. <th class="p-3">描述</th>
  41. <th class="p-3">状态</th>
  42. <th class="p-3">操作</th>
  43. </tr>
  44. </thead>
  45. <tbody id="source-table-body" class="text-gray-300">
  46. <!-- Sources will be inserted here -->
  47. </tbody>
  48. </table>
  49. </div>
  50. </div>
  51. </main>
  52. <!-- Edit Modal -->
  53. <div id="edit-modal" class="fixed inset-0 z-50 hidden bg-black bg-opacity-50 flex items-center justify-center">
  54. <div class="bg-gray-800 rounded-lg shadow-lg w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
  55. <div class="p-4 border-b border-gray-700 flex justify-between items-center">
  56. <h3 class="text-xl font-bold text-white" id="modal-title">新增爬虫源</h3>
  57. <button onclick="closeModal()" class="text-gray-400 hover:text-white"><i class="fas fa-times"></i></button>
  58. </div>
  59. <div class="p-6">
  60. <form id="source-form">
  61. <input type="hidden" id="source-id" name="id">
  62. <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
  63. <div>
  64. <label class="block text-gray-400 text-sm font-bold mb-2">名称</label>
  65. <input type="text" name="name" id="source-name" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" required>
  66. </div>
  67. <div>
  68. <label class="block text-gray-400 text-sm font-bold mb-2">代码标识</label>
  69. <input type="text" name="code_identifier" id="source-code" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" required>
  70. </div>
  71. </div>
  72. <div class="mb-4">
  73. <label class="block text-gray-400 text-sm font-bold mb-2">描述</label>
  74. <textarea name="description" id="source-desc" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white h-20"></textarea>
  75. </div>
  76. <div class="mb-4">
  77. <label class="block text-gray-400 text-sm font-bold mb-2">类型</label>
  78. <select name="type" id="source-type" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" onchange="toggleTypeFields()">
  79. <option value="script">自定义脚本 (app/spider/xxx.py)</option>
  80. <option value="generic">通用配置 (Generic Engine)</option>
  81. </select>
  82. </div>
  83. <!-- Generic Fields -->
  84. <div id="generic-fields" class="hidden space-y-4 border-t border-gray-700 pt-4">
  85. <h4 class="text-cyan-400 font-bold mb-2">通用爬虫配置</h4>
  86. <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  87. <div>
  88. <label class="block text-gray-400 text-sm mb-1">请求 URL</label>
  89. <input type="text" name="url" id="source-url" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" placeholder="https://example.com/search">
  90. </div>
  91. <div>
  92. <label class="block text-gray-400 text-sm mb-1">请求方法</label>
  93. <select name="method" id="source-method" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white">
  94. <option value="GET">GET</option>
  95. <option value="POST">POST</option>
  96. </select>
  97. </div>
  98. </div>
  99. <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
  100. <div>
  101. <label class="block text-gray-400 text-sm mb-1">搜索参数名</label>
  102. <input type="text" name="search_param_key" id="source-param-key" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" placeholder="q (default)">
  103. </div>
  104. </div>
  105. <div class="mt-2 mb-4 border-t border-gray-600 pt-2">
  106. <h4 class="text-sm font-bold text-gray-300 mb-2">分页配置</h4>
  107. <div class="flex items-center mb-2">
  108. <input type="checkbox" id="source-has-pagination" class="mr-2 rounded bg-gray-700 border-gray-600 text-cyan-600 focus:ring-cyan-600">
  109. <label for="source-has-pagination" class="text-gray-400 text-sm select-none cursor-pointer">启用分页</label>
  110. </div>
  111. <div id="pagination-fields" class="grid grid-cols-1 md:grid-cols-3 gap-4 hidden">
  112. <div>
  113. <label class="block text-gray-400 text-sm mb-1">分页参数名</label>
  114. <input type="text" id="source-pagination-param" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" placeholder="e.g. pn">
  115. </div>
  116. <div>
  117. <label class="block text-gray-400 text-sm mb-1">步长</label>
  118. <input type="number" id="source-pagination-step" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" value="10">
  119. </div>
  120. <div>
  121. <label class="block text-gray-400 text-sm mb-1">起始值</label>
  122. <input type="number" id="source-pagination-start" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white" value="0">
  123. </div>
  124. </div>
  125. </div>
  126. <div>
  127. <label class="block text-gray-400 text-sm mb-1">Headers (JSON 或 Key: Value 格式)</label>
  128. <textarea name="headers" id="source-headers" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white font-mono text-xs h-20" placeholder='{"User-Agent": "..."} 或
  129. User-Agent: Mozilla/5.0
  130. Accept: text/html'></textarea>
  131. </div>
  132. <div>
  133. <label class="block text-gray-400 text-sm mb-1">Selectors (JSON)</label>
  134. <textarea name="selectors" id="source-selectors" class="w-full p-2 bg-gray-700 rounded border border-gray-600 text-white font-mono text-xs h-32" placeholder='{
  135. "list": "div.result",
  136. "title": "h3 > a",
  137. "link": {"selector": "h3 > a", "attr": "href"},
  138. "abstract": "div.abstract"
  139. }'></textarea>
  140. <p class="text-xs text-gray-500 mt-1">支持 CSS 选择器。字段: list, title, link, abstract, source, cover</p>
  141. </div>
  142. </div>
  143. <div class="flex justify-end mt-6">
  144. <button type="button" onclick="closeModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-500 mr-2">取消</button>
  145. <button type="submit" class="px-4 py-2 bg-cyan-600 text-white rounded hover:bg-cyan-500">保存</button>
  146. </div>
  147. </form>
  148. </div>
  149. </div>
  150. </div>
  151. </div>
  152. </div>
  153. {% endblock %}
  154. {% block scripts %}
  155. <script>
  156. // Sidebar Toggle Logic
  157. const sidebar = document.getElementById('sidebar');
  158. const openSidebarBtn = document.getElementById('open-sidebar');
  159. const closeSidebarBtn = document.getElementById('close-sidebar');
  160. function toggleSidebar() {
  161. sidebar.classList.toggle('-translate-x-full');
  162. }
  163. if (openSidebarBtn) openSidebarBtn.addEventListener('click', toggleSidebar);
  164. if (closeSidebarBtn) closeSidebarBtn.addEventListener('click', toggleSidebar);
  165. // Source Management Logic
  166. $(document).ready(function() {
  167. loadSources();
  168. $('#refresh-sources-btn').click(loadSources);
  169. $('#source-form').submit(function(e) {
  170. e.preventDefault();
  171. saveSource();
  172. });
  173. $('#source-has-pagination').change(function() {
  174. if(this.checked) {
  175. $('#pagination-fields').removeClass('hidden');
  176. } else {
  177. $('#pagination-fields').addClass('hidden');
  178. }
  179. });
  180. });
  181. function loadSources() {
  182. $.get('/spider-source/api/list', function(sources) {
  183. const tbody = $('#source-table-body');
  184. tbody.empty();
  185. if (sources.length === 0) {
  186. tbody.append('<tr><td colspan="7" class="p-3 text-center text-gray-500">暂无爬虫源</td></tr>');
  187. return;
  188. }
  189. sources.forEach(source => {
  190. let statusBadge = source.status === 'active'
  191. ? '<span class="px-2 py-1 rounded text-xs bg-green-900 text-green-300">启用</span>'
  192. : '<span class="px-2 py-1 rounded text-xs bg-red-900 text-red-300">禁用</span>';
  193. let typeBadge = source.type === 'generic'
  194. ? '<span class="px-2 py-1 rounded text-xs bg-purple-900 text-purple-300">通用</span>'
  195. : '<span class="px-2 py-1 rounded text-xs bg-yellow-900 text-yellow-300">脚本</span>';
  196. let actionBtn = source.status === 'active'
  197. ? `<button onclick="toggleSource(${source.id})" class="text-red-400 hover:text-red-300 mr-2" title="禁用"><i class="fas fa-ban"></i></button>`
  198. : `<button onclick="toggleSource(${source.id})" class="text-green-400 hover:text-green-300 mr-2" title="启用"><i class="fas fa-check"></i></button>`;
  199. actionBtn += `<button onclick="editSource(${source.id})" class="text-blue-400 hover:text-blue-300 mr-2" title="编辑"><i class="fas fa-edit"></i></button>`;
  200. actionBtn += `<button onclick="deleteSource(${source.id})" class="text-gray-400 hover:text-red-500" title="删除"><i class="fas fa-trash"></i></button>`;
  201. const tr = `
  202. <tr class="border-b border-blue-900/30 hover:bg-blue-900/20 transition">
  203. <td class="p-3">#${source.id}</td>
  204. <td class="p-3 font-bold text-cyan-400">${source.name}</td>
  205. <td class="p-3 font-mono text-sm text-yellow-300">${source.code_identifier}</td>
  206. <td class="p-3">${typeBadge}</td>
  207. <td class="p-3 text-sm text-gray-400 truncate max-w-xs">${source.description || '-'}</td>
  208. <td class="p-3">${statusBadge}</td>
  209. <td class="p-3">${actionBtn}</td>
  210. </tr>
  211. `;
  212. tbody.append(tr);
  213. });
  214. });
  215. }
  216. function toggleSource(sourceId) {
  217. if(!confirm('确定要更改该爬虫源的状态吗?')) return;
  218. $.post(`/spider-source/api/toggle/${sourceId}`, function() { loadSources(); }).fail(function() { alert('操作失败'); });
  219. }
  220. function deleteSource(sourceId) {
  221. if(!confirm('确定要删除该爬虫源吗?')) return;
  222. $.post(`/spider-source/api/delete/${sourceId}`, function() { loadSources(); }).fail(function(xhr) { alert(xhr.responseJSON.error || '操作失败'); });
  223. }
  224. function openModal(source = null) {
  225. const modal = $('#edit-modal');
  226. const form = $('#source-form')[0];
  227. form.reset();
  228. if (source) {
  229. $('#modal-title').text('编辑爬虫源');
  230. $('#source-id').val(source.id);
  231. $('#source-name').val(source.name);
  232. $('#source-code').val(source.code_identifier);
  233. $('#source-desc').val(source.description);
  234. $('#source-type').val(source.type || 'script');
  235. // Generic fields
  236. $('#source-url').val(source.url);
  237. $('#source-method').val(source.method || 'GET');
  238. $('#source-param-key').val(source.search_param_key || 'q');
  239. $('#source-headers').val(source.headers);
  240. $('#source-selectors').val(source.selectors);
  241. // Pagination fields
  242. $('#source-has-pagination').prop('checked', source.has_pagination || false).trigger('change');
  243. $('#source-pagination-param').val(source.pagination_param || 'pn');
  244. $('#source-pagination-step').val(source.pagination_step !== undefined ? source.pagination_step : 10);
  245. $('#source-pagination-start').val(source.pagination_start !== undefined ? source.pagination_start : 0);
  246. } else {
  247. $('#modal-title').text('新增爬虫源');
  248. $('#source-id').val('');
  249. $('#source-type').val('script'); // Default
  250. // Reset pagination defaults
  251. $('#source-has-pagination').prop('checked', false).trigger('change');
  252. $('#source-pagination-param').val('pn');
  253. $('#source-pagination-step').val(10);
  254. $('#source-pagination-start').val(0);
  255. }
  256. toggleTypeFields();
  257. modal.removeClass('hidden');
  258. }
  259. function closeModal() {
  260. $('#edit-modal').addClass('hidden');
  261. }
  262. function toggleTypeFields() {
  263. const type = $('#source-type').val();
  264. if (type === 'generic') {
  265. $('#generic-fields').removeClass('hidden');
  266. } else {
  267. $('#generic-fields').addClass('hidden');
  268. }
  269. }
  270. function editSource(id) {
  271. $.get(`/spider-source/api/get/${id}`, function(source) {
  272. openModal(source);
  273. });
  274. }
  275. function saveSource() {
  276. let headersInput = $('#source-headers').val().trim();
  277. let selectorsInput = $('#source-selectors').val().trim();
  278. // Try to parse headers if it doesn't look like JSON
  279. if (headersInput && !headersInput.trim().startsWith('{')) {
  280. try {
  281. const lines = headersInput.split('\n');
  282. const headersObj = {};
  283. let currentKey = null;
  284. for (let i = 0; i < lines.length; i++) {
  285. let line = lines[i].trim();
  286. if (!line) continue;
  287. if (line.endsWith(':')) {
  288. currentKey = line.substring(0, line.length - 1).trim();
  289. } else if (currentKey) {
  290. headersObj[currentKey] = line;
  291. currentKey = null;
  292. } else {
  293. const firstColon = line.indexOf(':');
  294. if (firstColon > -1) {
  295. const key = line.substring(0, firstColon).trim();
  296. const value = line.substring(firstColon + 1).trim();
  297. headersObj[key] = value;
  298. }
  299. }
  300. }
  301. headersInput = JSON.stringify(headersObj, null, 4);
  302. } catch (e) {
  303. console.error("Header parsing error:", e);
  304. // Fallback to original input if parsing fails, let backend or JSON validation handle it
  305. }
  306. }
  307. const data = {
  308. id: $('#source-id').val(),
  309. name: $('#source-name').val(),
  310. code_identifier: $('#source-code').val(),
  311. description: $('#source-desc').val(),
  312. type: $('#source-type').val(),
  313. url: $('#source-url').val(),
  314. method: $('#source-method').val(),
  315. search_param_key: $('#source-param-key').val(),
  316. headers: headersInput,
  317. selectors: selectorsInput,
  318. has_pagination: $('#source-has-pagination').is(':checked'),
  319. pagination_param: $('#source-pagination-param').val(),
  320. pagination_step: $('#source-pagination-step').val(),
  321. pagination_start: $('#source-pagination-start').val()
  322. };
  323. $.ajax({
  324. url: '/spider-source/api/save',
  325. method: 'POST',
  326. contentType: 'application/json',
  327. data: JSON.stringify(data),
  328. success: function() {
  329. closeModal();
  330. loadSources();
  331. alert('保存成功');
  332. },
  333. error: function(xhr) {
  334. alert('保存失败: ' + (xhr.responseJSON.error || 'Unknown error'));
  335. }
  336. });
  337. }
  338. </script>
  339. {% endblock %}