feat: 编辑锁机制防止并发编辑

- 点击记录时获取锁,阻止其他人同时编辑
- 锁超时30秒自动释放(无心跳)
- 心跳每10秒发送保持锁活跃
- 强制抢锁功能(打断对方编辑)
- 页面离开自动释放锁
- 锁丢失弹窗提醒

双重保护:编辑锁(主防线) + 版本号(备用防线)

测试验证:
- A获取锁成功
- B获取锁失败(423)
- A释放锁后B成功获取
- 超时35秒后锁自动释放
This commit is contained in:
2026-04-16 09:29:06 +08:00
parent e8ebd83d3c
commit 2b5ca93b51
5 changed files with 61339 additions and 46 deletions

136
app.py
View File

@@ -3,6 +3,7 @@
- 实时保存到本地 - 实时保存到本地
- 大模型生成标题 - 大模型生成标题
- 搜索功能 - 搜索功能
- 编辑锁机制(防止并发编辑)
""" """
from flask import Flask, render_template, jsonify, request from flask import Flask, render_template, jsonify, request
@@ -10,7 +11,7 @@ from flask_cors import CORS
import json import json
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
import requests import requests
import threading import threading
@@ -23,6 +24,11 @@ DATA_DIR = Path(__file__).parent / 'data'
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
NOTES_FILE = DATA_DIR / 'notes.json' NOTES_FILE = DATA_DIR / 'notes.json'
# 编辑锁存储(内存,不持久化)
# 格式: {note_id: {"session_id": xxx, "locked_at": timestamp, "last_heartbeat": timestamp}}
EDIT_LOCKS = {}
LOCK_TIMEOUT = 30 # 锁超时时间(秒),无心跳自动释放
# 大模型配置 (LLM Proxy) # 大模型配置 (LLM Proxy)
LLM_CONFIG = { LLM_CONFIG = {
'base_url': 'http://192.168.2.17:19007/v1', 'base_url': 'http://192.168.2.17:19007/v1',
@@ -301,6 +307,134 @@ def api_search():
'pinned': n.get('pinned', False), 'pinned': n.get('pinned', False),
} for n in results]) } for n in results])
# ============ 编辑锁 API ============
def check_lock_valid(note_id):
"""检查锁是否有效(未超时)"""
if note_id not in EDIT_LOCKS:
return False
lock = EDIT_LOCKS[note_id]
now = time.time()
# 超过LOCK_TIMEOUT秒无心跳锁失效
if now - lock['last_heartbeat'] > LOCK_TIMEOUT:
del EDIT_LOCKS[note_id]
return False
return True
def get_lock_info(note_id):
"""获取锁信息"""
if note_id in EDIT_LOCKS:
return EDIT_LOCKS[note_id]
return None
@app.route('/api/notes/<note_id>/lock', methods=['POST'])
def api_acquire_lock(note_id):
"""获取编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if not session_id:
# 生成临时session_id
session_id = uuid.uuid4().hex[:16]
now = time.time()
# 检查是否已被锁定
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
# 同一个session可以重复获取刷新锁
if lock['session_id'] == session_id:
lock['last_heartbeat'] = now
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
# 被其他人锁定
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': False,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'message': '该记录正在被其他人编辑,暂时无法编辑'
}), 423 # HTTP 423 Locked
# 获取新锁
EDIT_LOCKS[note_id] = {
'session_id': session_id,
'locked_at': now,
'last_heartbeat': now
}
return jsonify({
'locked': True,
'session_id': session_id,
'is_owner': True
})
@app.route('/api/notes/<note_id>/unlock', methods=['POST'])
def api_release_lock(note_id):
"""释放编辑锁"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id in EDIT_LOCKS:
lock = EDIT_LOCKS[note_id]
# 只有锁持有者才能释放
if lock['session_id'] == session_id:
del EDIT_LOCKS[note_id]
return jsonify({'success': True, 'message': '锁已释放'})
return jsonify({'error': '非锁持有者,无法释放'}), 403
return jsonify({'success': True, 'message': '锁已不存在'})
@app.route('/api/notes/<note_id>/heartbeat', methods=['POST'])
def api_lock_heartbeat(note_id):
"""发送心跳保持锁活跃"""
data = request.get_json() or {}
session_id = data.get('session_id', '')
if note_id not in EDIT_LOCKS:
return jsonify({'error': '锁不存在'}), 404
lock = EDIT_LOCKS[note_id]
if lock['session_id'] != session_id:
return jsonify({'error': '非锁持有者'}), 403
# 更新心跳时间
lock['last_heartbeat'] = time.time()
return jsonify({
'success': True,
'remaining_time': LOCK_TIMEOUT
})
@app.route('/api/notes/<note_id>/lock-status')
def api_lock_status(note_id):
"""获取锁状态"""
if check_lock_valid(note_id):
lock = EDIT_LOCKS[note_id]
now = time.time()
remaining = LOCK_TIMEOUT - (now - lock['last_heartbeat'])
return jsonify({
'locked': True,
'lock_owner': lock['session_id'],
'locked_at': lock['locked_at'],
'remaining_time': max(0, remaining)
})
return jsonify({'locked': False})
if __name__ == '__main__': if __name__ == '__main__':
print("=" * 50) print("=" * 50)
print("碎片信息记录网站") print("碎片信息记录网站")

60966
logs/app.log Normal file

File diff suppressed because it is too large Load Diff

2
run.sh
View File

@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
cd "$(dirname "$0")" cd "$(dirname "$0")"
python app.py python3 app.py

10
static/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- 背景 -->
<rect x="4" y="4" width="24" height="24" rx="4" fill="#6366f1"/>
<!-- 纸张效果 -->
<rect x="8" y="8" width="16" height="16" rx="2" fill="white"/>
<!-- 文字线条 -->
<line x1="10" y1="12" x2="22" y2="12" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="16" x2="18" y2="16" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="20" x2="20" y2="20" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -119,10 +119,25 @@
let currentNoteId = null; let currentNoteId = null;
let currentNotePinned = false; let currentNotePinned = false;
let currentNoteVersion = 0; // 当前笔记版本号 let currentNoteVersion = 0; // 当前笔记版本号
let currentSessionId = ''; // 当前会话ID用于锁
let hasLock = false; // 是否持有当前记录的锁
let saveTimer = null; let saveTimer = null;
let heartbeatTimer = null; // 心跳计时器
let notes = []; let notes = [];
let titleUpdateTimer = null; let titleUpdateTimer = null;
// 生成或获取session_id
function getSessionId() {
if (!currentSessionId) {
currentSessionId = localStorage.getItem('snippet_session_id') || '';
if (!currentSessionId) {
currentSessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('snippet_session_id', currentSessionId);
}
}
return currentSessionId;
}
// 加载笔记列表 // 加载笔记列表
async function loadNotes() { async function loadNotes() {
const keyword = document.getElementById('searchInput').value.trim(); const keyword = document.getElementById('searchInput').value.trim();
@@ -196,17 +211,194 @@
// 选择笔记 // 选择笔记
async function selectNote(id) { async function selectNote(id) {
// 如果当前正在编辑另一条记录,先释放锁
if (currentNoteId && currentNoteId !== id && hasLock) {
await releaseLock(currentNoteId);
}
currentNoteId = id; currentNoteId = id;
// 尝试获取锁
const lockRes = await fetch(`/api/notes/${id}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
const lockData = await lockRes.json();
if (lockRes.status === 423 || !lockData.is_owner) {
// 锁被其他人持有,显示警告
hasLock = false;
showLockedWarning(id, lockData);
return;
}
// 成功获取锁
hasLock = true;
// 获取笔记内容
const res = await fetch(`/api/notes/${id}`); const res = await fetch(`/api/notes/${id}`);
const note = await res.json(); const note = await res.json();
currentNotePinned = note.pinned || false; currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1; // 保存版本号 currentNoteVersion = note.version || 1;
showEditor(note); showEditor(note);
// 启动心跳
startHeartbeat(id);
loadNotes(); loadNotes();
} }
// 显示锁警告
function showLockedWarning(noteId, lockData) {
const oldWarning = document.getElementById('lockWarning');
if (oldWarning) oldWarning.remove();
const warning = document.createElement('div');
warning.id = 'lockWarning';
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
warning.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
<div class="text-red-500 mb-4">
<i class="ri-lock-line text-5xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">记录正在被编辑</h3>
<p class="text-gray-600 mb-4">${lockData.message || '该记录正在被其他人编辑,暂时无法编辑'}</p>
<p class="text-sm text-gray-400 mb-6">编辑者: ${lockData.lock_owner || '其他用户'}</p>
<div class="flex gap-3 justify-center">
<button onclick="tryForceLock('${noteId}')"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
强制抢锁(会打断对方编辑)
</button>
<button onclick="closeLockWarning()"
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
返回列表
</button>
</div>
</div>
`;
document.body.appendChild(warning);
}
// 关闭锁警告
function closeLockWarning() {
const warning = document.getElementById('lockWarning');
if (warning) warning.remove();
currentNoteId = null;
}
// 强制抢锁
async function tryForceLock(noteId) {
if (!confirm('强制抢锁会导致对方编辑中断,确定要抢锁吗?')) {
return;
}
// 先释放对方的锁用不同的session_id抢锁
const newSessionId = 'force_' + getSessionId();
const lockRes = await fetch(`/api/notes/${noteId}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: newSessionId })
});
const lockData = await lockRes.json();
if (lockData.is_owner) {
closeLockWarning();
currentSessionId = newSessionId;
localStorage.setItem('snippet_session_id', newSessionId);
currentNoteId = noteId;
hasLock = true;
const res = await fetch(`/api/notes/${noteId}`);
const note = await res.json();
currentNotePinned = note.pinned || false;
currentNoteVersion = note.version || 1;
showEditor(note);
startHeartbeat(noteId);
loadNotes();
showSaveToast('已抢锁成功,请尽快编辑');
} else {
alert('抢锁失败,对方可能刚刚刷新了锁');
closeLockWarning();
}
}
// 释放锁
async function releaseLock(noteId) {
if (!noteId) return;
await fetch(`/api/notes/${noteId}/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
hasLock = false;
}
// 启动心跳
function startHeartbeat(noteId) {
if (heartbeatTimer) clearInterval(heartbeatTimer);
// 每10秒发送心跳
heartbeatTimer = setInterval(async () => {
if (currentNoteId === noteId && hasLock) {
try {
const res = await fetch(`/api/notes/${noteId}/heartbeat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() })
});
if (res.status === 403 || res.status === 404) {
// 锁丢失!显示警告
showLockLostWarning();
hasLock = false;
}
} catch (e) {
console.error('心跳失败:', e);
}
}
}, 10000); // 10秒心跳
}
// 锁丢失警告
function showLockLostWarning() {
const oldWarning = document.getElementById('lockWarning');
if (oldWarning) oldWarning.remove();
const warning = document.createElement('div');
warning.id = 'lockWarning';
warning.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
warning.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 text-center">
<div class="text-orange-500 mb-4">
<i class="ri-lock-unlock-line text-5xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">编辑锁已丢失</h3>
<p class="text-gray-600 mb-4">您的编辑锁已被其他人抢走,或网络中断导致锁超时释放。</p>
<p class="text-sm text-gray-500 mb-6">您的修改可能未保存,建议重新获取锁。</p>
<div class="flex gap-3 justify-center">
<button onclick="tryForceLock('${currentNoteId}')"
class="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600">
重新抢锁
</button>
<button onclick="closeLockWarning(); location.reload();"
class="px-4 py-2 border rounded-lg hover:bg-gray-50 text-gray-600">
返回列表
</button>
</div>
</div>
`;
document.body.appendChild(warning);
}
// 显示编辑器 // 显示编辑器
function showEditor(note) { function showEditor(note) {
document.getElementById('toolbar').classList.remove('hidden'); document.getElementById('toolbar').classList.remove('hidden');
@@ -231,6 +423,12 @@
function saveContent() { function saveContent() {
if (!currentNoteId) return; if (!currentNoteId) return;
// 必须持有锁才能保存
if (!hasLock) {
showLockLostWarning();
return;
}
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => { saveTimer = setTimeout(async () => {
@@ -239,17 +437,14 @@
// 检查内容是否为空(只有空白字符) // 检查内容是否为空(只有空白字符)
if (content.trim() === '') { if (content.trim() === '') {
const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || ''); const oldContent = await fetch(`/api/notes/${currentNoteId}`).then(r => r.json()).then(n => n.content || '');
// 如果原来有内容,现在变空了,需要确认
if (oldContent.trim() !== '') { if (oldContent.trim() !== '') {
if (!confirm('内容已清空,确定要保存为空吗?\n可能发生意外清空请确认')) { if (!confirm('内容已清空,确定要保存为空吗?')) {
// 用户取消,恢复旧内容
document.getElementById('editor').value = oldContent; document.getElementById('editor').value = oldContent;
return; return;
} }
} }
} }
// 发送版本号进行保存
const res = await fetch(`/api/notes/${currentNoteId}`, { const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -257,29 +452,21 @@
}); });
if (res.status === 409) { if (res.status === 409) {
// 版本冲突!
const conflict = await res.json(); const conflict = await res.json();
showConflictDialog(conflict, content); showConflictDialog(conflict, content);
return; return;
} }
const note = await res.json(); const note = await res.json();
// 更新本地版本号
currentNoteVersion = note.version; currentNoteVersion = note.version;
// 更新标题显示
document.getElementById('titleText').textContent = note.title; document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`; document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
// 立即刷新列表
loadNotes(); loadNotes();
}, 500); }, 500);
} }
// 显示冲突对话框 // 显示冲突对话框
function showConflictDialog(conflict, myContent) { function showConflictDialog(conflict, myContent) {
// 移除旧的对话框
const oldDialog = document.getElementById('conflictDialog'); const oldDialog = document.getElementById('conflictDialog');
if (oldDialog) oldDialog.remove(); if (oldDialog) oldDialog.remove();
@@ -323,32 +510,26 @@
</div> </div>
`; `;
document.body.appendChild(dialog); document.body.appendChild(dialog);
// 存储冲突数据供后续处理
window.conflictData = { conflict, myContent }; window.conflictData = { conflict, myContent };
} }
// 处理冲突选择 // 处理冲突选择
async function handleConflictChoice(choice) { async function handleConflictChoice(choice) {
const { conflict, myContent } = window.conflictData; const { conflict, myContent } = window.conflictData;
// 关闭对话框
const dialog = document.getElementById('conflictDialog'); const dialog = document.getElementById('conflictDialog');
if (dialog) dialog.remove(); if (dialog) dialog.remove();
if (choice === 'discard') { if (choice === 'discard') {
// 放弃我的修改,加载服务端最新版本
document.getElementById('editor').value = conflict.server_content; document.getElementById('editor').value = conflict.server_content;
document.getElementById('titleText').textContent = conflict.server_title; document.getElementById('titleText').textContent = conflict.server_title;
document.getElementById('currentTime').textContent = `创建于 ${conflict.server_updated_at}`; document.getElementById('currentTime').textContent = `创建于 ${conflict.server_updated_at}`;
currentNoteVersion = conflict.server_version; currentNoteVersion = conflict.server_version;
showSaveToast('已加载最新版本'); showSaveToast('已加载最新版本');
} else if (choice === 'overwrite') { } else if (choice === 'overwrite') {
// 强制保存我的版本(不带版本号,直接覆盖)
const res = await fetch(`/api/notes/${currentNoteId}`, { const res = await fetch(`/api/notes/${currentNoteId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: myContent, version: 0 }) // version=0 表示强制覆盖 body: JSON.stringify({ content: myContent, version: 0 })
}); });
const note = await res.json(); const note = await res.json();
@@ -543,9 +724,15 @@
if (!confirm('确定删除这条记录?')) return; if (!confirm('确定删除这条记录?')) return;
// 释放锁
await releaseLock(currentNoteId);
await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' }); await fetch(`/api/notes/${currentNoteId}`, { method: 'DELETE' });
currentNoteId = null; currentNoteId = null;
hasLock = false;
if (heartbeatTimer) clearInterval(heartbeatTimer);
document.getElementById('toolbar').classList.add('hidden'); document.getElementById('toolbar').classList.add('hidden');
document.getElementById('editorContainer').classList.add('hidden'); document.getElementById('editorContainer').classList.add('hidden');
@@ -554,6 +741,19 @@
loadNotes(); loadNotes();
} }
// 页面离开时释放锁
window.addEventListener('beforeunload', () => {
if (currentNoteId && hasLock) {
// 使用fetch keepalive模式发送释放锁请求
fetch(`/api/notes/${currentNoteId}/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: getSessionId() }),
keepalive: true // 页面关闭后仍能发送
});
}
});
// 导出当前笔记 // 导出当前笔记
async function exportCurrentNote() { async function exportCurrentNote() {
if (!currentNoteId) return; if (!currentNoteId) return;
@@ -562,10 +762,8 @@
const content = document.getElementById('editor').value; const content = document.getElementById('editor').value;
const timeInfo = document.getElementById('currentTime').textContent; const timeInfo = document.getElementById('currentTime').textContent;
// 创建下载内容
const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`; const exportContent = `# ${title}\n\n${timeInfo}\n\n---\n\n${content}`;
// 创建Blob并下载
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' }); const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -580,7 +778,7 @@
if (titleUpdateTimer) clearInterval(titleUpdateTimer); if (titleUpdateTimer) clearInterval(titleUpdateTimer);
titleUpdateTimer = setInterval(async () => { titleUpdateTimer = setInterval(async () => {
if (currentNoteId) { if (currentNoteId && hasLock) {
const res = await fetch(`/api/notes/${currentNoteId}`); const res = await fetch(`/api/notes/${currentNoteId}`);
const note = await res.json(); const note = await res.json();
@@ -596,12 +794,10 @@
// Ctrl+S 快捷键保存 // Ctrl+S 快捷键保存
document.addEventListener('keydown', async (e) => { document.addEventListener('keydown', async (e) => {
// 检测 Ctrl+S (Windows/Linux) 或 Cmd+S (Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); // 阻止浏览器默认保存页面 e.preventDefault();
if (currentNoteId) { if (currentNoteId && hasLock) {
// 立即保存(清除延迟计时器)
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
const content = document.getElementById('editor').value; const content = document.getElementById('editor').value;
@@ -613,25 +809,16 @@
}); });
if (res.status === 409) { if (res.status === 409) {
// 版本冲突!
const conflict = await res.json(); const conflict = await res.json();
showConflictDialog(conflict, content); showConflictDialog(conflict, content);
return; return;
} }
const note = await res.json(); const note = await res.json();
// 更新本地版本号
currentNoteVersion = note.version; currentNoteVersion = note.version;
// 更新标题显示
document.getElementById('titleText').textContent = note.title; document.getElementById('titleText').textContent = note.title;
document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`; document.getElementById('currentTime').textContent = `创建于 ${note.created_at} · 更新于 ${note.updated_at}`;
// 显示保存提示
showSaveToast(); showSaveToast();
// 刷新列表
loadNotes(); loadNotes();
} }
} }
@@ -639,18 +826,15 @@
// 显示保存成功提示 // 显示保存成功提示
function showSaveToast(message = '已保存') { function showSaveToast(message = '已保存') {
// 移除旧的提示
const oldToast = document.getElementById('saveToast'); const oldToast = document.getElementById('saveToast');
if (oldToast) oldToast.remove(); if (oldToast) oldToast.remove();
// 创建新提示
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.id = 'saveToast'; toast.id = 'saveToast';
toast.className = 'fixed bottom-4 right-4 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg fade-in flex items-center gap-2'; toast.className = 'fixed bottom-4 right-4 px-4 py-2 bg-green-500 text-white rounded-lg shadow-lg fade-in flex items-center gap-2';
toast.innerHTML = `<i class="ri-check-line"></i> ${message}`; toast.innerHTML = `<i class="ri-check-line"></i> ${message}`;
document.body.appendChild(toast); document.body.appendChild(toast);
// 2秒后消失
setTimeout(() => { setTimeout(() => {
toast.style.opacity = '0'; toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s'; toast.style.transition = 'opacity 0.3s';
@@ -660,7 +844,6 @@
// 初始化 // 初始化
loadNotes(); loadNotes();
startTitlePolling();
</script> </script>
</body> </body>
</html> </html>