HarbourSOFT-LAB commited on
Commit
fa8a1be
·
verified ·
1 Parent(s): 3661b15

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +224 -84
index.html CHANGED
@@ -1,189 +1,329 @@
1
  <!DOCTYPE html>
2
- <html lang="zh">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1.0" />
6
  <title>Phishing Email Detection (Transformers.js + ONNX)</title>
7
  <style>
8
- body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
9
- textarea { width: 100%; height: 200px; margin: 12px 0; }
10
- button { padding: 10px 16px; background: #4CAF50; color: #fff; border: 0; cursor: pointer; border-radius: 6px; }
11
- button.secondary { background: #777; }
12
- #result { margin-top: 16px; padding: 12px; background: #f4f4f4; border-radius: 6px; white-space: pre-wrap; }
13
- #loading { margin-top: 16px; }
14
- #email-input-section { margin-top: 16px; }
15
- #log { background: #0b1021; color: #b8d0ff; padding: 12px; border-radius: 6px; height: 180px; overflow: auto; font-size: 12px; }
16
- .row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
17
- progress { width: 280px; height: 12px; }
18
- .muted { color: #666; font-size: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </style>
20
  </head>
21
  <body>
22
- <h1>Phishing Email Detection</h1>
23
-
24
- <p>是否加载钓鱼检测模型?</p>
25
- <div class="row">
26
- <button id="load-model-btn">是,开始加载模型</button>
27
- <button id="cancel-load-btn" class="secondary" style="display:none;">取消(仅重置UI)</button>
28
- </div>
29
-
30
- <div id="loading" style="display:none;">
31
  <div class="row">
32
- <progress id="progress" value="0" max="100"></progress>
33
- <span id="progress-label">0%</span>
 
34
  </div>
35
- <div class="muted">首次运行会下载模型与分词器等文件,时间取决于网络与浏览器缓存。</div>
36
- </div>
37
 
38
- <div id="email-input-section" style="display:none;">
39
- <textarea id="email-input" placeholder="把邮件内容粘贴到这里……"></textarea><br />
40
- <button id="detect-btn">开始检测</button>
41
- <div id="result"></div>
42
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- <h3>调试日志</h3>
45
- <div id="log"></div>
 
 
 
 
 
 
 
 
46
 
47
  <script type="module">
48
- // ===== 配置区 =====
49
- // 确保使用“仓库ID”,不要填 .onnx 文件直链!
 
 
50
  const MODEL_ID = 'onnx-community/phishing-email-detection-distilbert_v2.4.1-ONNX';
51
- // 优先使用 v3(@huggingface/transformers),失败则回退到 v2(@xenova/transformers
52
  const CDN_PRIMARY = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0';
53
  const CDN_FALLBACK = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';
54
 
55
- // ===== DOM =====
56
- const loadBtn = document.getElementById('load-model-btn');
57
- const cancelBtn = document.getElementById('cancel-load-btn');
58
- const emailSec = document.getElementById('email-input-section');
59
- const detectBtn = document.getElementById('detect-btn');
60
- const resultDiv = document.getElementById('result');
61
- const loading = document.getElementById('loading');
62
- const pbar = document.getElementById('progress');
63
- const plabel = document.getElementById('progress-label');
64
- const logDiv = document.getElementById('log');
65
-
66
- // ===== 状态 =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  let pipe = null;
68
  let cancelled = false;
69
 
 
 
 
70
  function log(...args) {
71
  const line = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' ');
72
  logDiv.textContent += line + '\n';
73
  logDiv.scrollTop = logDiv.scrollHeight;
 
74
  console.log('[LOG]', ...args);
75
  }
 
76
  function setProgress(evt) {
77
  if (evt?.status === 'progress') {
78
  const pct = Math.max(0, Math.min(100, Math.round(evt.progress || 0)));
79
  pbar.value = pct;
80
- plabel.textContent = `${pct}% ${evt?.name || evt?.file || ''}`;
81
  } else if (evt?.status) {
82
- log(`status: ${evt.status} ${evt?.name || evt?.file || ''}`);
83
  }
84
  }
 
85
  function showError(err) {
86
  const msg = err?.stack || err?.message || String(err);
87
  log('❌ ERROR:', msg);
88
  }
89
 
90
- // 捕获全局未处理错误,方便定位
91
- window.addEventListener('error', e => log('window.error:', e.message, e.filename, e.lineno + ':' + e.colno));
92
- window.addEventListener('unhandledrejection', e => log('unhandledrejection:', e.reason?.message || e.reason));
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  async function importTransformers() {
95
  try {
96
- log('尝试加载库:', CDN_PRIMARY);
97
  return await import(CDN_PRIMARY);
98
  } catch (e1) {
99
  showError(e1);
100
- log('主库导入失败,改用回退版本:', CDN_FALLBACK);
101
  return await import(CDN_FALLBACK);
102
  }
103
  }
104
 
 
 
 
 
 
 
 
105
  loadBtn.addEventListener('click', async () => {
106
  try {
107
  cancelled = false;
108
  loadBtn.style.display = 'none';
109
  cancelBtn.style.display = 'inline-block';
110
- loading.style.display = 'block';
111
  pbar.value = 0; plabel.textContent = '0%';
112
 
113
  const mod = await importTransformers();
114
- const { pipeline, env } = mod;
115
- log('Transformers.js 版本已加载。');
116
 
117
- // 可选:指定 WebGPU/wasm。默认自动选择;如想强制 WebGPU,可改为 'webgpu'
118
  const device = 'auto';
119
 
120
- // 量化权重(更小更快)。如果仓库提供 q4,就会加载它(该仓库提供多种量化权重)。:contentReference[oaicite:6]{index=6}
121
  const options = {
122
  dtype: 'q4',
123
  device,
124
- // 显示下载进度
125
  progress_callback: setProgress,
126
  };
127
 
128
- log('开始通过仓库ID加载模型:', MODEL_ID);
129
  pipe = await pipeline('text-classification', MODEL_ID, options);
130
 
131
  if (cancelled) {
132
- log('注意:取消只会重置UI,后台加载无法真正中断(库暂不支持)。'); // 说明限制
133
  }
134
 
135
- loading.style.display = 'none';
136
  cancelBtn.style.display = 'none';
137
  emailSec.style.display = 'block';
138
- log('✅ 模型加载完成。');
139
  } catch (err) {
140
- loading.style.display = 'none';
141
  cancelBtn.style.display = 'none';
142
  loadBtn.style.display = 'inline-block';
143
  showError(err);
144
 
145
- // 常见定位:是否 404(多半是仓库ID写错或没权限)
146
  if ((err?.message || '').includes('404')) {
147
- log('可能的原因:仓库ID不存在/拼写错误/私有仓库无权限。当前使用:', MODEL_ID);
148
- log('建议改用公开可用且结构完整的仓库:onnx-community/phishing-email-detection-distilbert_v2.4.1-ONNX');
149
  }
150
-
151
- // 文档:pipeline 要求模型在 Hub 上且 ONNX 权重在 onnx/ 子目录。:contentReference[oaicite:7]{index=7}
152
- log('参考:Transformers.js pipeline 需仓库ID(含 onnx/ 权重与配置文件)。');
153
  }
154
  });
155
 
156
  cancelBtn.addEventListener('click', () => {
157
- // 仅重置 UI;Transformers.js 暂不支持真正中断 pipeline() 的加载。:contentReference[oaicite:8]{index=8}
158
  cancelled = true;
159
  loadBtn.style.display = 'inline-block';
160
  cancelBtn.style.display = 'none';
161
- loading.style.display = 'none';
162
- log('已取消(仅UI)。如果后台仍在下载,请稍等其失败或完成。');
163
  });
164
 
 
 
165
  detectBtn.addEventListener('click', async () => {
166
  try {
167
- const emailText = document.getElementById('email-input').value.trim();
 
168
  if (!emailText) {
169
- resultDiv.textContent = '请输入要分类的文本。';
170
  return;
171
  }
172
  if (!pipe) {
173
- resultDiv.textContent = '模型尚未加载成功。';
174
  return;
175
  }
176
- resultDiv.textContent = '推理中…';
177
- const out = await pipe(emailText); // 默认返回 top-1
178
- // 同时把完整结构打印到日志,便于核对标签名称与分数
179
- log('推理结果:', out);
 
180
  const first = Array.isArray(out) ? out[0] : out;
181
- resultDiv.textContent = `Prediction: ${first.label}\nScore: ${first.score?.toFixed?.(4) ?? first.score}`;
 
 
 
 
 
 
 
 
 
182
  } catch (err) {
183
  showError(err);
184
- resultDiv.textContent = '检测失败,详见调试日志。';
185
  }
186
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  </script>
188
  </body>
189
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
  <title>Phishing Email Detection (Transformers.js + ONNX)</title>
7
  <style>
8
+ :root{
9
+ --bg: #0b1021;
10
+ --panel: #0f1531;
11
+ --panel-2: #0c122b;
12
+ --text: #e9eeff;
13
+ --muted: #9fb3ff;
14
+ --accent: #7aa2ff;
15
+ --success: #2ecc71;
16
+ --warn: #f39c12;
17
+ --danger: #e74c3c;
18
+ --border: 1px solid rgba(255,255,255,.08);
19
+ --radius: 14px;
20
+ --shadow: 0 10px 30px rgba(0,0,0,.35);
21
+ }
22
+ *{box-sizing:border-box}
23
+ html,body{height:100%}
24
+ body{
25
+ margin:0; background: radial-gradient(1200px 800px at 10% -10%, #1a2252 0%, transparent 60%),
26
+ radial-gradient(1200px 800px at 110% 10%, #1b2d61 0%, transparent 60%),
27
+ var(--bg);
28
+ color:var(--text); font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif;
29
+ padding: 24px; display:flex; flex-direction:column; gap:24px;
30
+ }
31
+ header{
32
+ display:flex; justify-content:space-between; align-items:center; gap:16px;
33
+ padding: 18px 20px; border-radius: var(--radius); backdrop-filter: blur(6px);
34
+ background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
35
+ border: var(--border); box-shadow: var(--shadow);
36
+ }
37
+ h1{font-size: clamp(22px, 3.2vw, 28px); margin:0; letter-spacing:.2px}
38
+ .subtitle{color:var(--muted); font-size:14px}
39
+
40
+ .grid{display:grid; grid-template-columns:1.1fr .9fr; gap:22px}
41
+ @media (max-width: 960px){ .grid{grid-template-columns:1fr; } }
42
+
43
+ .card{background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
44
+ border:var(--border); border-radius:var(--radius); box-shadow:var(--shadow); padding:18px}
45
+
46
+ textarea{width:100%; height:220px; resize:vertical; background:var(--panel);
47
+ color:var(--text); border-radius:12px; border:var(--border); padding:12px 14px; outline:none}
48
+
49
+ .row{display:flex; align-items:center; gap:10px; flex-wrap:wrap}
50
+
51
+ button{appearance:none; border:0; border-radius:12px; padding:10px 14px; cursor:pointer; font-weight:600;
52
+ background: linear-gradient(180deg, #7aa2ff, #4e77ff); color:white; box-shadow: 0 6px 20px rgba(78,119,255,.35)}
53
+ button.secondary{background: linear-gradient(180deg, #7780a6, #5a6284); box-shadow:none}
54
+ button.ghost{background:transparent; border:var(--border); color:var(--text)}
55
+ button:disabled{opacity:.55; cursor:not-allowed}
56
+
57
+ .muted{color:var(--muted); font-size:12px}
58
+
59
+ .progress-wrap{display:flex; align-items:center; gap:10px}
60
+ progress{width:280px; height:12px; accent-color:#7aa2ff}
61
+
62
+ #result{margin-top:12px; padding:14px; background:var(--panel-2); border:var(--border); border-radius:12px; white-space:pre-wrap}
63
+
64
+ .badge{display:inline-flex; align-items:center; gap:6px; font-size:12px; border-radius:999px; padding:6px 10px;
65
+ background:#1a234a; border:var(--border)}
66
+ .badge.success{background: rgba(46, 204, 113, .12); border: 1px solid rgba(46, 204, 113, .35); color:#b9f6d0}
67
+ .badge.warn{background: rgba(243, 156, 18, .12); border: 1px solid rgba(243, 156, 18, .35); color:#ffe2b9}
68
+ .badge.danger{background: rgba(231, 76, 60, .12); border: 1px solid rgba(231, 76, 60, .35); color:#ffbdb4}
69
+
70
+ #log{background:#060a1d; color:#b8d0ff; padding:12px; border-radius:12px; height:200px; overflow:auto; font-size:12px; border:var(--border)}
71
+
72
+ .footer-note{opacity:.8; font-size:12px}
73
+ .spacer{flex:1}
74
  </style>
75
  </head>
76
  <body>
77
+ <header>
78
+ <div>
79
+ <h1>Phishing Email Detection</h1>
80
+ <div class="subtitle">Transformers.js + ONNX Runtime (browser)</div>
81
+ </div>
 
 
 
 
82
  <div class="row">
83
+ <button id="load-model-btn">Load model</button>
84
+ <button id="cancel-load-btn" class="secondary" style="display:none;">Cancel (UI reset)</button>
85
+ <button id="clear-log-btn" class="ghost">Clear log</button>
86
  </div>
87
+ </header>
 
88
 
89
+ <div class="grid">
90
+ <section class="card">
91
+ <p>Would you like to download and initialize the phishing-detection model now?</p>
92
+ <div id="loading" style="display:none; margin-bottom:8px;">
93
+ <div class="progress-wrap">
94
+ <progress id="progress" value="0" max="100"></progress>
95
+ <span id="progress-label" class="muted">0%</span>
96
+ </div>
97
+ <div class="muted">First run will download model artifacts and tokenizer files. Duration depends on network and browser cache.</div>
98
+ </div>
99
+
100
+ <div id="email-input-section" style="display:none;">
101
+ <textarea id="email-input" placeholder="Paste email text here…"></textarea>
102
+ <div class="row">
103
+ <button id="detect-btn">Run Detection</button>
104
+ <button id="copy-btn" class="ghost">Copy Result</button>
105
+ <span id="status-chip" class="badge" style="display:none;"></span>
106
+ </div>
107
+ <div id="result" aria-live="polite"></div>
108
+ </div>
109
+ </section>
110
 
111
+ <aside class="card">
112
+ <h3 style="margin-top:6px">Debug Log</h3>
113
+ <div id="log" aria-live="polite"></div>
114
+ <div class="row" style="margin-top:10px">
115
+ <span class="footer-note">Tip: open DevTools for network details.</span>
116
+ <span class="spacer"></span>
117
+ <button id="save-log-btn" class="ghost">Save Log</button>
118
+ </div>
119
+ </aside>
120
+ </div>
121
 
122
  <script type="module">
123
+ // ==========================
124
+ // Configuration
125
+ // ==========================
126
+ // Use a Hugging Face Hub repository ID (NOT a direct .onnx URL)
127
  const MODEL_ID = 'onnx-community/phishing-email-detection-distilbert_v2.4.1-ONNX';
128
+ // Prefer v3 (@huggingface/transformers). Fallback to v2 (@xenova/transformers)
129
  const CDN_PRIMARY = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0';
130
  const CDN_FALLBACK = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';
131
 
132
+ // Human-friendly label mapping. We WILL NOT display raw LABEL_* tokens.
133
+ const labelMap = {
134
+ 'LABEL_0': 'Legitimate email',
135
+ 'LABEL_1': 'Phishing email',
136
+ 'LABEL_2': 'Legitimate URL',
137
+ 'LABEL_3': 'Phishing URL',
138
+ // In case a model uses different strings (e.g., 'SAFE', 'SCAM'), add them here as needed
139
+ };
140
+
141
+ // ==========================
142
+ // DOM
143
+ // ==========================
144
+ const $ = (sel) => document.querySelector(sel);
145
+ const loadBtn = $('#load-model-btn');
146
+ const cancelBtn = $('#cancel-load-btn');
147
+ const clearBtn = $('#clear-log-btn');
148
+ const emailSec = $('#email-input-section');
149
+ const detectBtn = $('#detect-btn');
150
+ const copyBtn = $('#copy-btn');
151
+ const resultDiv = $('#result');
152
+ const loadingUI = $('#loading');
153
+ const pbar = $('#progress');
154
+ const plabel = $('#progress-label');
155
+ const logDiv = $('#log');
156
+ const saveLog = $('#save-log-btn');
157
+ const statusChip= $('#status-chip');
158
+
159
+ // ==========================
160
+ // State
161
+ // ==========================
162
  let pipe = null;
163
  let cancelled = false;
164
 
165
+ // ==========================
166
+ // Utilities
167
+ // ==========================
168
  function log(...args) {
169
  const line = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' ');
170
  logDiv.textContent += line + '\n';
171
  logDiv.scrollTop = logDiv.scrollHeight;
172
+ // Also mirror in console for convenience
173
  console.log('[LOG]', ...args);
174
  }
175
+
176
  function setProgress(evt) {
177
  if (evt?.status === 'progress') {
178
  const pct = Math.max(0, Math.min(100, Math.round(evt.progress || 0)));
179
  pbar.value = pct;
180
+ plabel.textContent = `${pct}% ${evt?.name || evt?.file || ''}`.trim();
181
  } else if (evt?.status) {
182
+ log(`status: ${evt.status} ${evt?.name || evt?.file || ''}`.trim());
183
  }
184
  }
185
+
186
  function showError(err) {
187
  const msg = err?.stack || err?.message || String(err);
188
  log('❌ ERROR:', msg);
189
  }
190
 
191
+ function humanLabel(raw) {
192
+ // Normalize and map labels to human-friendly values
193
+ if (!raw) return 'Unknown';
194
+ return labelMap[raw] || labelMap[raw.toUpperCase()] || raw; // fallback to original if unmapped
195
+ }
196
+
197
+ function setChip(kind, text) {
198
+ statusChip.className = 'badge';
199
+ if (kind) statusChip.classList.add(kind);
200
+ statusChip.textContent = text;
201
+ statusChip.style.display = 'inline-flex';
202
+ }
203
+
204
+ function clearChip(){ statusChip.style.display = 'none'; }
205
 
206
  async function importTransformers() {
207
  try {
208
+ log('Trying to import:', CDN_PRIMARY);
209
  return await import(CDN_PRIMARY);
210
  } catch (e1) {
211
  showError(e1);
212
+ log('Primary import failed. Falling back to:', CDN_FALLBACK);
213
  return await import(CDN_FALLBACK);
214
  }
215
  }
216
 
217
+ // Global error capture for easier debugging
218
+ window.addEventListener('error', e => log('window.error:', e.message, e.filename, `${e.lineno}:${e.colno}`));
219
+ window.addEventListener('unhandledrejection', e => log('unhandledrejection:', e.reason?.message || e.reason));
220
+
221
+ // ==========================
222
+ // Events
223
+ // ==========================
224
  loadBtn.addEventListener('click', async () => {
225
  try {
226
  cancelled = false;
227
  loadBtn.style.display = 'none';
228
  cancelBtn.style.display = 'inline-block';
229
+ loadingUI.style.display = 'block';
230
  pbar.value = 0; plabel.textContent = '0%';
231
 
232
  const mod = await importTransformers();
233
+ const { pipeline } = mod; // v2 and v3 both expose pipeline
234
+ log('Transformers.js loaded.');
235
 
236
+ // Select device (auto | webgpu | wasm). Auto lets library choose the best available.
237
  const device = 'auto';
238
 
239
+ // Prefer quantized weights where available for smaller, faster downloads.
240
  const options = {
241
  dtype: 'q4',
242
  device,
 
243
  progress_callback: setProgress,
244
  };
245
 
246
+ log('Loading model from repo id:', MODEL_ID);
247
  pipe = await pipeline('text-classification', MODEL_ID, options);
248
 
249
  if (cancelled) {
250
+ log('Note: Cancel only resets the UI; it cannot interrupt an in-flight pipeline load.');
251
  }
252
 
253
+ loadingUI.style.display = 'none';
254
  cancelBtn.style.display = 'none';
255
  emailSec.style.display = 'block';
256
+ log('✅ Model ready.');
257
  } catch (err) {
258
+ loadingUI.style.display = 'none';
259
  cancelBtn.style.display = 'none';
260
  loadBtn.style.display = 'inline-block';
261
  showError(err);
262
 
 
263
  if ((err?.message || '').includes('404')) {
264
+ log('Repository not found / typo / or private with no access. Current:', MODEL_ID);
 
265
  }
266
+ log('Reminder: Transformers.js pipeline expects a Hub repo with ONNX weights under an onnx/ directory plus required configs.');
 
 
267
  }
268
  });
269
 
270
  cancelBtn.addEventListener('click', () => {
 
271
  cancelled = true;
272
  loadBtn.style.display = 'inline-block';
273
  cancelBtn.style.display = 'none';
274
+ loadingUI.style.display = 'none';
275
+ log('Canceled (UI only). If downloads are in-flight, they may finish or fail later.');
276
  });
277
 
278
+ clearBtn.addEventListener('click', () => { logDiv.textContent = ''; });
279
+
280
  detectBtn.addEventListener('click', async () => {
281
  try {
282
+ clearChip();
283
+ const emailText = /** @type {HTMLTextAreaElement} */(document.getElementById('email-input')).value.trim();
284
  if (!emailText) {
285
+ resultDiv.textContent = 'Please paste some email text to classify.';
286
  return;
287
  }
288
  if (!pipe) {
289
+ resultDiv.textContent = 'Model is not loaded yet.';
290
  return;
291
  }
292
+ resultDiv.textContent = 'Running inference…';
293
+ const out = await pipe(emailText); // usually returns top-1 result
294
+ log('inference output:', out);
295
+
296
+ // Handle output shape from v2/v3 (either array or single object)
297
  const first = Array.isArray(out) ? out[0] : out;
298
+ const niceLabel = humanLabel(first.label);
299
+ const score = typeof first.score === 'number' ? first.score : Number(first.score ?? NaN);
300
+ const confidence = Number.isFinite(score) ? (score * 100).toFixed(2) + '%' : String(first.score);
301
+
302
+ let riskKind = 'success';
303
+ if (/phishing/i.test(niceLabel)) riskKind = 'danger';
304
+ else if (/url/i.test(niceLabel)) riskKind = 'warn';
305
+ setChip(riskKind, niceLabel);
306
+
307
+ resultDiv.textContent = `Prediction: ${niceLabel}\nConfidence: ${confidence}`;
308
  } catch (err) {
309
  showError(err);
310
+ resultDiv.textContent = 'Detection failed. See Debug Log for details.';
311
  }
312
  });
313
+
314
+ copyBtn.addEventListener('click', async () => {
315
+ const text = resultDiv.textContent.trim();
316
+ if (!text) return;
317
+ try { await navigator.clipboard.writeText(text); setChip('success', 'Copied'); setTimeout(clearChip, 1200); }
318
+ catch { setChip('warn', 'Copy failed'); setTimeout(clearChip, 1400); }
319
+ });
320
+
321
+ saveLog.addEventListener('click', () => {
322
+ const blob = new Blob([logDiv.textContent], { type: 'text/plain' });
323
+ const url = URL.createObjectURL(blob);
324
+ const a = Object.assign(document.createElement('a'), { href: url, download: 'phishing-detector-log.txt' });
325
+ document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
326
+ });
327
  </script>
328
  </body>
329
  </html>