From 1de2a0af547fdd879c8f25b6e7cf68391db46e63 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Tue, 11 Aug 2026 13:20:14 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.2:=20=E8=87=AA=E5=8A=A8=E7=88=AC=E5=8F=96?= =?UTF-8?q?=E8=AF=95=E7=88=AC=E5=8F=96=E5=8A=9F=E8=83=BD=20+=20=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2/=E5=9B=BE=E7=89=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=93=8D=E4=BD=9C=E4=BF=A1=E6=81=AF=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE(=E6=A8=A1=E5=BC=8F/=E6=97=B6=E9=97=B4/=E7=BD=91?= =?UTF-8?q?=E5=9D=80/=E6=9D=A5=E6=BA=90=E9=93=BE=E6=8E=A5/=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E7=AD=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++ app.py | 44 ++++++++- engine.py | 233 ++++++++++++++++++++++++++++++++++++++-------- static/app.js | 133 +++++++++++++++++++++++++- static/index.html | 28 +++++- static/style.css | 15 +++ 6 files changed, 417 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e96cdfd..0fe6c4e 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,19 @@ ### 4. 自动爬取模式 给定一个起始网址,系统自动从页面里发现链接、按规则筛选后 BFS 爬取: +- **🧪 试爬取**:先用起始网址试跑一次,展示规则筛选后的链接清单(将爬取哪些、排除哪些及原因),确认规则符合预期后再正式开爬 - **包含规则**:只爬包含指定子串(或正则)的链接 - **排除规则**:跳过匹配的链接(如 login、/tag/) - **仅同域名**:限制在起始网站内 - **最大页数 / 最大深度**:控制爬取规模 - 其余参数(间隔、重试、图片、通知)同批量模式 +### 5. 资源操作信息(元数据) +每个爬取的网页和图片都自动生成 `.meta.json` 操作信息文件: +- **网页**(`<文件名>.meta.json`):爬取模式(批量/定时/自动)、爬取时间、爬取网址、**来源链接**(自动模式下从哪个页面发现)、爬取深度、任务/运行 ID、页面标题、状态、尝试次数、文件列表、图片明细 +- **图片集**(`<文件名>_img/meta.json`):所属页面、来源链接、每张图片的原始 URL / 大小 / 下载时间 +- 详情页结果表中点「📋 元数据」即可在线查看 + ## 输出文件 每个任务输出到独立目录(默认 `out/<任务ID>/`): @@ -69,6 +76,8 @@ | POST | `/api/tasks//pause` | 暂停 | | POST | `/api/tasks//resume` | 恢复 | | POST | `/api/tasks//stop` | 终止 | +| POST | `/api/probe` | 试爬取(表单规则预览链接清单) | +| POST | `/api/tasks//probe` | 对已保存的自动任务试爬取 | | GET | `/api/runs/` | 运行详情(结果+日志) | | GET | `/api/runs//logs?offset=N` | 增量日志 | | GET | `/api/file?task_id=&path=` | 读取输出文件(HTML/TXT/图片) | diff --git a/app.py b/app.py index 3a7550b..4efad97 100644 --- a/app.py +++ b/app.py @@ -10,7 +10,7 @@ from datetime import datetime from flask import Flask, jsonify, request, send_file, send_from_directory import store -from engine import CrawlJob +from engine import CrawlJob, probe_links from scheduler import Scheduler, cron_next, interval_delta HERE = os.path.dirname(os.path.abspath(__file__)) @@ -337,6 +337,48 @@ def api_stats(): }) +# ---------------- API: 试爬取 ---------------- + +@app.route("/api/probe", methods=["POST"]) +def api_probe(): + """试爬取: 按表单给出的规则探测起始页, 返回将爬取的链接清单""" + body = request.get_json(force=True) or {} + seed = (body.get("seed_url") or "").strip() + if not seed: + return jsonify({"error": "请填写起始网址"}), 400 + if not seed.startswith("http"): + seed = "https://" + seed + result = probe_links( + seed, + include=[x.strip() for x in (body.get("include") or []) if x.strip()], + exclude=[x.strip() for x in (body.get("exclude") or []) if x.strip()], + same_domain=body.get("same_domain", True), + use_regex=bool(body.get("use_regex", False)), + timeout=int(body.get("timeout") or 45), + ) + return jsonify(result) + + +@app.route("/api/tasks//probe", methods=["POST"]) +def api_task_probe(tid): + """对已保存的自动任务执行试爬取 (使用保存的规则)""" + task = store.get_task(tid) + if not task: + return jsonify({"error": "任务不存在"}), 404 + if task.get("mode") != "auto": + return jsonify({"error": "仅自动爬取任务支持试爬取"}), 400 + auto = task.get("auto", {}) + result = probe_links( + auto.get("seed_url", ""), + include=auto.get("include", []), + exclude=auto.get("exclude", []), + same_domain=auto.get("same_domain", True), + use_regex=bool(auto.get("use_regex", False)), + timeout=int((task.get("config") or {}).get("timeout", 60)), + ) + return jsonify(result) + + # ---------------- API: 运行记录与文件 ---------------- @app.route("/api/runs/") diff --git a/engine.py b/engine.py index 5946b17..491ad39 100644 --- a/engine.py +++ b/engine.py @@ -3,6 +3,8 @@ 通用爬虫引擎 (Playwright + stealth + 系统 Chrome) - 批量模式: 逐条爬取网址列表 - 自动模式: 从起始网址按匹配规则自动发现链接并 BFS 爬取 +- 试爬取: 仅抓取起始页, 列出按规则将爬取的链接(不保存文件) +- 每个页面/图片生成 .meta.json 操作信息(模式/时间/网址/来源链接/深度等) - 支持: 随机延迟 / 重试 / 图片下载 / 暂停恢复 / 终止 / 配置热更新 / cookie 复用 """ import json @@ -40,7 +42,6 @@ def is_challenge_page(title, html): for mark in _CHALLENGE_MARKS: if mark in t or mark in low: return True - # 极小页面 + 无正文结构 -> 疑似验证壳 if len(html) < 5000 and ("= 2 and len(html) > 1000: + return True, title, html + else: + stable = 0 + last_title = title + return True, page.title(), page.content() + + +def filter_links(hrefs, seed_url, include=None, exclude=None, + same_domain=True, use_regex=False): + """按规则过滤链接, 返回 (included, excluded); excluded 含排除原因""" + include = include or [] + exclude = exclude or [] + included, excluded = [], [] + seed_host = urllib.parse.urlparse(seed_url).hostname or "" + for h in hrefs: + if not str(h).startswith("http"): + continue + if same_domain and seed_host: + host = urllib.parse.urlparse(h).hostname or "" + if host != seed_host and not host.endswith("." + seed_host): + excluded.append({"url": h, "reason": "不在同域名内"}) + continue + if use_regex: + if include and not any(re.search(p, h) for p in include): + excluded.append({"url": h, "reason": "未匹配包含规则"}) + continue + hit = next((p for p in exclude if re.search(p, h)), None) + if hit: + excluded.append({"url": h, "reason": f"命中排除规则: {hit}"}) + continue + else: + if include and not any(p.lower() in h.lower() for p in include): + excluded.append({"url": h, "reason": "未匹配包含规则"}) + continue + hit = next((p for p in exclude if p.lower() in h.lower()), None) + if hit: + excluded.append({"url": h, "reason": f"命中排除规则: {hit}"}) + continue + included.append(h) + return included, excluded + + +def probe_links(seed_url, include=None, exclude=None, + same_domain=True, use_regex=False, timeout=45): + """试爬取: 抓取起始页并列出按规则将爬取的链接 (不保存任何文件)""" + result = {"ok": False, "error": "", "seed_url": seed_url, "title": "", + "crawled_at": "", "total_links": 0, + "total_included": 0, "total_excluded": 0, + "included": [], "excluded": []} + try: + p = sync_playwright().start() + browser = p.chromium.launch( + headless=True, executable_path=CHROME, + args=["--disable-blink-features=AutomationControlled", + "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + ) + ctx = browser.new_context( + user_agent=DEFAULT_UA, + viewport={"width": 1920, "height": 1080}, + locale="en-US", + ) + Stealth().apply_stealth_sync(ctx) + page = ctx.new_page() + try: + page.goto(seed_url, wait_until="domcontentloaded", timeout=timeout * 1000) + _ok, title, html = _settle_wait(page, timeout) + if is_challenge_page(title, html): + result["error"] = f"起始页被反爬拦截: title={title!r}" + else: + try: + hrefs = page.evaluate( + "() => Array.from(document.querySelectorAll('a[href]')).map(a => a.href)" + ) + except Exception: + hrefs = [] + included, excluded = filter_links( + hrefs, seed_url, include, exclude, same_domain, use_regex) + result.update( + ok=True, title=title, crawled_at=store.now_str(), + total_links=len(hrefs), + total_included=len(included), total_excluded=len(excluded), + included=included[:200], excluded=excluded[:200], + ) + except Exception as e: + result["error"] = f"试爬取失败: {e}" + finally: + try: + browser.close() + except Exception: + pass + try: + p.stop() + except Exception: + pass + except Exception as e: + result["error"] = f"浏览器启动失败: {e}" + return result + + class CrawlJob: """一次爬取执行 (独立线程运行)""" @@ -195,8 +312,8 @@ class CrawlJob: text = "" return title, html, text - def _crawl_images(self, ctx, page, out_dir, base): - """下载页面图片, 返回 [{file, url, size}]""" + def _crawl_images(self, ctx, page, out_dir, base, page_url, source_url): + """下载页面图片并生成图片集 meta.json, 返回 [{file,url,size,download_time}]""" try: urls = page.evaluate( "() => Array.from(document.querySelectorAll('img'))" @@ -222,26 +339,78 @@ class CrawlJob: os.makedirs(img_dir, exist_ok=True) with open(os.path.join(img_dir, fname), "wb") as f: f.write(resp.body()) - saved.append({"file": f"{base}_img/{fname}", "url": u, "size": len(resp.body())}) + saved.append({ + "file": f"{base}_img/{fname}", "url": u, + "size": len(resp.body()), "download_time": store.now_str(), + }) except Exception: continue + if saved: + try: + meta = { + "type": "images", + "mode": self.run.get("mode"), + "task_id": self.task["id"], + "task_name": self.task.get("name", ""), + "run_id": self.run.get("id"), + "crawl_time": store.now_str(), + "page_url": page_url, + "source_url": source_url, + "images": saved, + } + with open(os.path.join(img_dir, "meta.json"), "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + except Exception: + pass return saved - def _retry_crawl(self, page, ctx, url, idx, out_dir): - """带重试的单页爬取, 返回结果 entry""" + def _write_page_meta(self, out_dir, base, entry): + """为每个爬取页面生成操作信息 meta.json""" + meta = { + "type": "page", + "mode": self.run.get("mode"), + "task_id": self.task["id"], + "task_name": self.task.get("name", ""), + "run_id": self.run.get("id"), + "crawl_time": entry.get("crawl_time", ""), + "url": entry.get("url", ""), + "source_url": entry.get("source_url", ""), + "depth": entry.get("depth"), + "title": entry.get("title", ""), + "status": entry.get("status", ""), + "error": entry.get("error", ""), + "attempts": entry.get("attempts", 1), + "html_file": entry.get("html_file", ""), + "txt_file": entry.get("txt_file", ""), + "images": entry.get("images", []), + } + try: + with open(os.path.join(out_dir, base + ".meta.json"), "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _retry_crawl(self, page, ctx, url, idx, out_dir, source_url="", depth=None): + """带重试的单页爬取, 返回结果 entry (含 meta 信息)""" timeout = int(self._cfg("timeout", 60)) retries = int(self._cfg("retry_count", 2)) retry_wait = float(self._cfg("retry_interval", 3)) crawl_images = bool(self._cfg("crawl_images", False)) - entry = {"url": url, "title": "", "status": "FAIL", "error": "", - "html_file": "", "txt_file": "", "images": []} base = safe_name(url, idx) + entry = { + "url": url, "title": "", "status": "FAIL", "error": "", + "html_file": "", "txt_file": "", "meta_file": base + ".meta.json", + "crawl_time": store.now_str(), + "source_url": source_url, "depth": depth, + "images": [], "attempts": 0, + } for attempt in range(retries + 1): if self._stop.is_set(): entry["error"] = "任务已终止" break self._wait_if_paused() + entry["attempts"] += 1 try: title, html, text = self._crawl_one(page, url, timeout) html_path = os.path.join(out_dir, base + ".html") @@ -251,13 +420,15 @@ class CrawlJob: with open(txt_path, "w", encoding="utf-8") as f: f.write(text) entry.update(title=title, status="OK", - html_file=base + ".html", txt_file=base + ".txt") + html_file=base + ".html", txt_file=base + ".txt", + error="", crawl_time=store.now_str()) if crawl_images: - entry["images"] = self._crawl_images(ctx, page, out_dir, base) + entry["images"] = self._crawl_images(ctx, page, out_dir, base, url, source_url) self._log("info", f"OK {title[:50]!r} html={len(html)//1024}KB 图片={len(entry['images'])}") break except Exception as e: entry["error"] = str(e) + entry["crawl_time"] = store.now_str() self._log("warn", f"第{attempt + 1}次失败 {url}: {e}") if attempt < retries: self._wait_if_paused() @@ -267,6 +438,7 @@ class CrawlJob: break self._wait_if_paused() time.sleep(0.3) + self._write_page_meta(out_dir, base, entry) return entry def _delay(self): @@ -317,7 +489,6 @@ class CrawlJob: self._log("error", f"邮件通知失败: {msg}") except Exception as e: self._log("error", f"邮件通知异常: {e}") - self._persist() self._log("info", f"任务结束: {run['status']} 成功{run['stats'].get('ok', 0)} 失败{run['stats'].get('fail', 0)}") self._persist() @@ -353,37 +524,18 @@ class CrawlJob: def _discover_links(self, page): """从当前页面提取符合规则的链接""" auto = self.task.get("auto", {}) - include = [x.strip() for x in (auto.get("include") or []) if x.strip()] - exclude = [x.strip() for x in (auto.get("exclude") or []) if x.strip()] - use_regex = bool(auto.get("use_regex", False)) - same_domain = auto.get("same_domain", True) - seed_host = urllib.parse.urlparse(auto.get("seed_url", "")).hostname or "" try: hrefs = page.evaluate( "() => Array.from(document.querySelectorAll('a[href]')).map(a => a.href)" ) except Exception: return [] - out = [] - for h in hrefs: - if not str(h).startswith("http"): - continue - if same_domain and seed_host: - host = urllib.parse.urlparse(h).hostname or "" - if host != seed_host and not host.endswith("." + seed_host): - continue - if use_regex: - if include and not any(re.search(p, h) for p in include): - continue - if any(re.search(p, h) for p in exclude): - continue - else: - if include and not any(p.lower() in h.lower() for p in include): - continue - if any(p.lower() in h.lower() for p in exclude): - continue - out.append(h) - return out + included, _excluded = filter_links( + hrefs, auto.get("seed_url", ""), + auto.get("include", []), auto.get("exclude", []), + auto.get("same_domain", True), bool(auto.get("use_regex", False)), + ) + return included def _crawl_auto(self): run = self.run @@ -398,14 +550,14 @@ class CrawlJob: self._persist() p, browser, ctx, page, cookie_file = self._open_browser() - queue = [(seed, 0)] + queue = [(seed, 0, "")] # (url, depth, 来源链接) visited = set() queued = set([seed]) idx = 0 try: while queue and not self._stop.is_set(): self._wait_if_paused() - url, depth = queue.pop(0) + url, depth, src = queue.pop(0) if url in visited: continue if len(visited) >= max_pages: @@ -415,7 +567,8 @@ class CrawlJob: run["progress"]["current_url"] = url run["progress"]["done"] = len(visited) self._persist() - entry = self._retry_crawl(page, ctx, url, idx, out_dir) + entry = self._retry_crawl(page, ctx, url, idx, out_dir, + source_url=src, depth=depth) run["results"].append(entry) self._bump_stats(entry) self._persist() @@ -423,7 +576,7 @@ class CrawlJob: for link in self._discover_links(page): if link not in visited and link not in queued: queued.add(link) - queue.append((link, depth + 1)) + queue.append((link, depth + 1, url)) if entry["status"] == "OK": self._delay() finally: diff --git a/static/app.js b/static/app.js index e20d662..7db8285 100644 --- a/static/app.js +++ b/static/app.js @@ -359,6 +359,7 @@ function renderDetail() { ` : ``} + ${t.mode === "auto" ? `` : ""} `; @@ -389,13 +390,18 @@ function renderRunPanel(t, run) { const txt = r.txt_file ? `TXT` : "—"; const imgs = (r.images || []).length ? `🖼️ ${(r.images || []).length}` : "—"; + const ctime = r.crawl_time ? `${esc(r.crawl_time.slice(5, 19))}` : "—"; + const meta = r.meta_file + ? `📋 元数据` : "—"; return ` ${i + 1} ${r.status} ${esc(r.title)} ${esc(r.url)} + ${ctime} ${html}${txt}${imgs} - ${esc((r.error || "").slice(0, 60))} + ${meta} + ${esc((r.error || "").slice(0, 50))} `; }).join(""); const thumbs = results.flatMap((r) => r.images || []).slice(0, 60); @@ -421,7 +427,7 @@ function renderRunPanel(t, run) { ${results.length ? `
- + ${rows}
#状态标题网址HTMLTXT图片错误
#状态标题网址爬取时间HTMLTXT图片元数据错误
` : '
暂无结果
'} @@ -475,6 +481,120 @@ function stopLogPoll() { if (state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; } } +/* ---------------- 试爬取 ---------------- */ +function collectProbeFromForm() { + const f = $("taskForm"); + return { + seed_url: f.elements["seed_url"].value.trim(), + include: splitLines(f.elements["include"].value), + exclude: splitLines(f.elements["exclude"].value), + same_domain: f.elements["same_domain"].checked, + use_regex: f.elements["use_regex"].checked, + timeout: parseInt(f.elements["timeout"].value) || 60, + }; +} + +async function runProbe(params, btn) { + if (btn) { btn.disabled = true; btn.textContent = "⏳ 探测中..."; } + try { + const r = await api("/api/probe", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + renderProbe(r); + } catch (e) { toast(e.message, true); } + finally { + if (btn) { btn.disabled = false; btn.textContent = "🧪 试爬取"; } + } +} + +async function probeTask(tid) { + try { + const r = await api(`/api/tasks/${tid}/probe`, { method: "POST" }); + renderProbe(r); + } catch (e) { toast(e.message, true); } +} + +function renderProbe(r) { + const body = $("probeBody"); + if (!r.ok) { + body.innerHTML = `
❌ ${esc(r.error || "试爬取失败")}
`; + showModal("probeModal"); + return; + } + const included = r.included || []; + const excluded = r.excluded || []; + const incHtml = included.length + ? included.map((u) => `
${esc(u)}
`).join("") + : '
没有符合条件的链接,请调整规则
'; + const excHtml = excluded.length + ? excluded.map((x) => `
${esc(x.url)}${esc(x.reason)}
`).join("") + : '
'; + const moreInc = r.total_included > included.length + ? `
... 还有 ${r.total_included - included.length} 条未显示
` : ""; + const moreExc = r.total_excluded > excluded.length + ? `
... 还有 ${r.total_excluded - excluded.length} 条未显示
` : ""; + body.innerHTML = ` +
+ 页面标题: ${esc(r.title)} + 抓取时间: ${esc(r.crawled_at)} +
+
+ 发现链接: ${r.total_links} + ✅ 将爬取: ${r.total_included} + 🚫 被排除: ${r.total_excluded} +
+
✅ 符合规则、将爬取的链接(显示前 ${included.length} 条):
+
${incHtml}${moreInc}
+
🚫 被排除的链接及原因(显示前 ${excluded.length} 条):
+
${excHtml}${moreExc}
`; + showModal("probeModal"); +} + +/* ---------------- 元数据查看 ---------------- */ +const META_FIELDS = [ + ["type", "资源类型"], ["mode", "爬取模式"], ["task_name", "任务名称"], + ["task_id", "任务ID"], ["run_id", "运行ID"], ["crawl_time", "爬取时间"], + ["url", "爬取网址"], ["source_url", "来源链接"], ["depth", "爬取深度"], + ["title", "页面标题"], ["status", "状态"], ["attempts", "尝试次数"], + ["error", "错误信息"], ["html_file", "HTML文件"], ["txt_file", "文本文件"], + ["page_url", "所属页面"], +]; + +async function showMeta(taskId, metaFile) { + try { + const data = await api(`/api/file?task_id=${taskId}&path=${encodeURIComponent(metaFile)}`); + const rows = META_FIELDS.filter(([k]) => data[k] !== undefined && data[k] !== "" && data[k] !== null) + .map(([k, label]) => { + let v = data[k]; + if (k === "status") v = `${esc(v)}`; + if (k === "mode") v = `${MODE_LABEL[v] || esc(v)}`; + if (k === "type") v = v === "images" ? "🖼️ 图片集" : "📄 网页"; + if (k === "depth") v = v === 0 ? "0(起始页)" : esc(v); + return `${label}${v}`; + }).join(""); + // 来源链接空值也显示(起始页无来源) + const srcRow = `来源链接${data.source_url ? esc(data.source_url) : "—(起始页,无来源)"}`; + const finalRows = rows.includes(srcRow) ? rows : rows + srcRow; + let imgs = ""; + if (data.images && data.images.length) { + imgs = ` +
🖼️ 图片明细(${data.images.length} 张):
+
+ + ${data.images.map((im) => ` + + + + `).join("")} +
文件原始URL大小下载时间
${esc(im.file)}${esc(im.url)}${im.size ? Math.round(im.size / 1024) + " KB" : "—"}${esc(im.download_time || "")}
+
`; + } + $("metaBody").innerHTML = `${finalRows}
${imgs}`; + showModal("metaModal"); + } catch (e) { toast(e.message, true); } +} + /* ---------------- 文件预览 ---------------- */ function previewFile(tid, path, title) { $("previewTitle").textContent = title || "预览"; @@ -513,6 +633,11 @@ try { $("btnNew").onclick = openCreate; $("btnNew2").onclick = openCreate; $("btnRefresh").onclick = loadTasks; +$("btnProbe").onclick = () => { + const p = collectProbeFromForm(); + if (!p.seed_url) { toast("请先填写起始网址", true); return; } + runProbe(p, $("btnProbe")); +}; document.querySelectorAll("#modeTabs .tab").forEach((b) => { b.onclick = () => { switchMode(b.dataset.mode, false); }; @@ -523,6 +648,8 @@ $("taskForm").elements["schedule_type"].onchange = syncScheduleUI; document.querySelectorAll("[data-close]").forEach((b) => b.onclick = safeCloseTaskModal); document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () => { stopLogPoll(); hideModal("detailModal"); }); document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); }); +document.querySelectorAll("[data-close-probe]").forEach((b) => b.onclick = () => hideModal("probeModal")); +document.querySelectorAll("[data-close-meta]").forEach((b) => b.onclick = () => hideModal("metaModal")); /* 表单改动监听 -> 脏标记 */ $("taskForm").addEventListener("input", () => { formDirty = true; }); @@ -549,7 +676,7 @@ document.addEventListener("keydown", (e) => { } stopLogPoll(); $("previewFrame").src = "about:blank"; - ["detailModal", "previewModal"].forEach((id) => $(id).classList.add("hidden")); + ["detailModal", "probeModal", "metaModal", "previewModal"].forEach((id) => $(id).classList.add("hidden")); } }); diff --git a/static/index.html b/static/index.html index af6347e..bcbffa0 100644 --- a/static/index.html +++ b/static/index.html @@ -102,7 +102,11 @@
@@ -139,6 +143,28 @@
+ + + + + +