插件 manifest_version v3版本
background.js
// 监听网页更新事件
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
// 确保是页面加载完成,并且tab.url存在
if (changeInfo.status === 'complete' && tab.url) {
// 确保 V3 Service Worker 中 tab.title 或 tab.url 可能为 undefined 的情况
// 尽管 onUpdated 触发 complete,但 URL 可能还在加载,或者 title 还没获取到
// 为避免错误,可以稍微延迟一下
// 或者更可靠的方式是在 content script 中获取,但这会增加复杂度
// 这里为了简单,我们继续使用tab.url和tab.title,但增加一个检查
if (tab.url && tab.title) {
const data = {
url: tab.url,
title: tab.title,
timestamp: new Date().toISOString()
};
// 发送到Python服务端
fetch('http://localhost:6500/api/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).catch(err => {
console.error('记录失败:', err);
// 也许可以考虑在这里给用户一个提示,或者重试机制
});
} else {
console.warn(`Tab ${tabId} updated, but URL or Title is missing. URL: ${tab.url}, Title: ${tab.title}`);
// 可以在这里尝试在下一tick再获取一次,或者直接忽略
// 例如:chrome.runtime.lastError 检查fetch操作是否出错
}
}
});
// 监听来自popup或其他地方的消息
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "openAdminPage") {
// 在 V3 Service Worker 中,直接创建标签的方法不变
chrome.tabs.create({ url: request.url || 'http://localhost:5000' });
// 如果希望知道创建是否成功,可以添加一个callback
// sendResponse({ success: true }); // 表示消息已被处理
}
// 返回 true 表示异步处理 sendResponse,防止callback被提前垃圾回收
return true;
});
// Service Worker 的生命周期是事件驱动的。
// 当没有事件发生时,它会休眠。
// 您可以在此处添加一些 Service Worker 相关的逻辑,例如安装时的一些设置。
chrome.runtime.onInstalled.addListener(() => {
console.log('URL Recorder Extension Installed/Updated.');
// 可以在此处设置一些初始值,例如:
// chrome.storage.sync.set({ enableRecording: true });
});
// 注意: 您可能需要重新加载您的插件来应用这些更改。
// 并且,由于 V3 的 Service Worker 是事件驱动的,
// `chrome.tabs.onUpdated` 可能不会像 V2 的 persistent background page 那样一直活跃。
// 如果遇到问题,可能需要更复杂的策略,例如使用 content scripts 来主动收集信息,
// 再通过 message passing 发送给 service worker。
// 但对于简单的URL记录,这个改动应该足够了。
manifest.json
{
"manifest_version": 3,
"name": "URL Recorder",
"version": "1.1",
"description": "记录浏览历史并同步到服务端",
"permissions": [
"tabs", // 允许访问标签页信息
"storage", // 允许使用存储API (如果需要存储一些配置的话)
"scripting" // 允许执行脚本(在V3中,即使不注入,有些tab事件的处理也可能间接需要)
],
"host_permissions": [
"http://localhost:6500/*" // 允许与该URL通信
],
"background": {
"service_worker": "background.js" // V3 使用 service_worker
},
"action": { // V2的 browser_action 被 action 替换
"default_popup": "popup.html",
"default_icon": {
"16": "icon.png",
"48": "icon.png",
"128": "icon.png"
}
}
}
popup.html
<!DOCTYPE html>
<html>
<head>
<title>URL Recorder</title>
</head>
<body>
<h3>历史记录管理</h3>
<a href="http://localhost:6500" target="_blank">打开管理页面</a>
<script>
// 如果需要通过popup来控制background.js,可以在这里添加脚本
// 例如:当用户点击链接时,可以发送一个消息给 background script
document.querySelector('a').addEventListener('click', (event) => {
// 阻止默认跳转,然后通过background script打开
// event.preventDefault();
// chrome.runtime.sendMessage({ action: "openAdminPage", url: 'http://localhost:5000' });
// 在这个例子中,链接直接跳转也能工作,所以无需阻止默认行为。
console.log("Admin page link clicked.");
});
</script>
</body>
</html>
py服务端
requirements.txt 可能不全 有BUG 剩下的 找不到可以问AI
flask==1.1.4
markupsafe<2.0.0
werkzeug<2.0.0
jinja2==2.11.3 # 明确指定兼容版本
flask-cors==3.0.10 # 这是最后一个支持 Python 3.5 的主要版本
app.py
from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
import sqlite3
from datetime import datetime
# **注意:测试端口改为 6501**
APP_PORT = 6501
app = Flask(__name__)
CORS(app) # 允许所有跨域请求
# 初始化数据库(SQLite)
def init_db():
conn = sqlite3.connect('database.db')
c = conn.cursor()
# 确保 history 表存在
c.execute('''CREATE TABLE IF NOT EXISTS history
(id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT,
timestamp DATETIME)''')
conn.commit()
conn.close()
# 记录浏览历史API (保持不变,但现在运行在 6501 端口)
@app.route('/api/record', methods=['POST'])
def record_history():
if not request.json or 'url' not in request.json:
return jsonify({'error': 'Missing data'}), 400
data = {
'url': request.json['url'],
'title': request.json.get('title', ''),
'timestamp': datetime.now().isoformat()
}
conn = sqlite3.connect('database.db')
c = conn.cursor()
try:
# 使用 INSERT OR IGNORE 更优雅地处理UNIQUE约束
c.execute("INSERT OR IGNORE INTO history (url, title, timestamp) VALUES (?, ?, ?)",
(data['url'], data['title'], data['timestamp']))
if c.rowcount == 0:
return jsonify({'error': 'URL already exists (Ignored)'}), 409
conn.commit()
return jsonify({'status': 'success'}), 201
except Exception as e:
# 其他异常处理
return jsonify({'error': str(e)}), 500
finally:
conn.close()
# **修改后的:获取历史记录API (支持分页和搜索)**
@app.route('/api/history', methods=['GET'])
def get_history():
conn = sqlite3.connect('database.db')
conn.row_factory = sqlite3.Row
c = conn.cursor()
# --- 1. 获取和验证分页参数 ---
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 100, type=int)
# 限制每页数量,防止恶意请求过大的数据量
PER_PAGE_MAX = 500
if per_page < 1 or per_page > PER_PAGE_MAX:
per_page = 100
# --- 2. 获取和处理搜索参数 ---
search_term = request.args.get('search', '')
# --- 3. 构造 WHERE 和 ORDER BY 子句 ---
where_conditions = []
param_list = []
if search_term:
# 模糊搜索 URL 或 Title
where_conditions.append("(url LIKE ? OR title LIKE ?)")
search_like = f"%{search_term}%"
param_list.extend([search_like, search_like])
where_clause = "WHERE " + " AND ".join(where_conditions) if where_conditions else ""
# 获取排序参数
sort_by = request.args.get('sort', 'timestamp') # 默认排序
order = request.args.get('order', 'desc') # 默认倒序
valid_columns = ['id', 'url', 'title', 'timestamp']
sort_by = sort_by if sort_by in valid_columns else 'timestamp'
order = order if order.lower() in ['asc', 'desc'] else 'desc'
order_clause = f"ORDER BY {sort_by} {order}"
# --- 4. 获取总记录数 (带搜索条件) ---
count_query = f"SELECT COUNT(*) FROM history {where_clause}"
c.execute(count_query, param_list)
total_records = c.fetchone()[0]
# --- 5. 构造分页查询 ---
offset = (page - 1) * per_page
# 纠正页码,防止超出范围
last_page = (total_records + per_page - 1) // per_page if total_records > 0 else 1
if page < 1:
page = 1
if page > last_page:
page = last_page
offset = (page - 1) * per_page
limit_clause = f"LIMIT {per_page} OFFSET {offset}"
# 最终查询
main_query = f"SELECT * FROM history {where_clause} {order_clause} {limit_clause}"
c.execute(main_query, param_list)
rows = c.fetchall()
conn.close()
# --- 6. 返回分页结果 ---
return jsonify({
'data': [dict(row) for row in rows],
'meta': {
'total_records': total_records,
'per_page': per_page,
'current_page': page,
'last_page': last_page
}
})
# 批量删除API (保持不变)
@app.route('/api/delete', methods=['POST'])
def delete_records():
try:
data = request.get_json()
if not data or 'ids' not in data:
return jsonify({'error': 'Missing IDs'}), 400
ids = data['ids']
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
return jsonify({'error': 'IDs must be a list of integers'}), 400
# 安全参数化查询
placeholders = ','.join(['?'] * len(ids))
query = "DELETE FROM history WHERE id IN ({})".format(placeholders)
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute(query, ids)
conn.commit()
conn.close()
return jsonify({'deleted': len(ids)}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
# 管理页面(不再传递数据,让前端JS去请求API)
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=APP_PORT, debug=True)
templates\index.html
<!DOCTYPE html>
<html>
<head>
<title>浏览历史管理</title>
<style>
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: 8px; border: 1px solid #ddd; word-wrap: break-word; }
th { cursor: default; background: #f5f5f5; }
/* 针对可点击的排序列 */
th[onclick], #id-sortable-header {
cursor: pointer;
}
.controls { margin-bottom: 20px; display: flex; gap: 15px; align-items: center; }
.pagination { margin-top: 15px; display: flex; gap: 10px; align-items: center; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 排序指示图标 */
.sort-icon::after { margin-left: 5px; content: ' \2195'; opacity: 0.4; }
.sorted-asc::after { content: ' \25B2'; color: green; opacity: 1; }
.sorted-desc::after { content: ' \25BC'; color: red; opacity: 1; }
</style>
</head>
<body>
<h1>浏览历史记录</h1>
<div class="controls">
<input type="text" id="search-input" placeholder="搜索 URL 或标题">
<button id="search-btn">搜索</button>
<label for="per-page-select">每页显示:</label>
<select id="per-page-select">
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
<button id="delete-selected">删除选中</button>
</div>
<table>
<thead>
<tr>
<!-- ID 列:复选框和排序分离 -->
<th style="width: 100px;">
<input type="checkbox" id="select-all">
<span id="id-sortable-header" data-column="id">ID <span id="sort-id" class="sort-icon"></span></span>
</th>
<!-- 其他列仍使用 onclick -->
<th data-column="url">URL <span id="sort-url" class="sort-icon"></span></th>
<th data-column="title">标题 <span id="sort-title" class="sort-icon"></span></th>
<th data-column="timestamp">访问时间 <span id="sort-timestamp" class="sort-icon"></span></th>
<th style="width: 50px;"></th> <!-- 占位列 -->
</tr>
</thead>
<tbody id="history-body">
<!-- 数据将通过 JavaScript 填充 -->
</tbody>
</table>
<div class="pagination">
<button id="prev-btn" disabled>上一页</button>
<span id="page-info"></span>
<button id="next-btn" disabled>下一页</button>
<span id="total-info"></span>
</div>
<script>
// --- 状态管理 ---
const API_URL = '/api/history';
let currentState = {
page: 1,
// 从本地存储加载每页数量,默认 100
per_page: localStorage.getItem('per_page') ? parseInt(localStorage.getItem('per_page')) : 100,
search: '',
sort: 'timestamp', // 默认按时间排序
order: 'desc' // 默认倒序
};
// --- DOM 元素引用 ---
const historyBody = document.getElementById('history-body');
const selectAllCheckbox = document.getElementById('select-all');
const deleteBtn = document.getElementById('delete-selected');
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const perPageSelect = document.getElementById('per-page-select');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
const pageInfoSpan = document.getElementById('page-info');
const totalInfoSpan = document.getElementById('total-info');
// 初始化每页数量选择框
if (perPageSelect.querySelector(`option[value="${currentState.per_page}"]`)) {
perPageSelect.value = currentState.per_page;
} else {
currentState.per_page = 100;
}
// --- 核心数据获取函数 ---
async function fetchHistory() {
historyBody.innerHTML = '<tr><td colspan="5">加载中...</td></tr>';
selectAllCheckbox.checked = false; // 加载新数据时取消全选
const url = new URL(API_URL, window.location.origin);
url.searchParams.set('page', currentState.page);
url.searchParams.set('per_page', currentState.per_page);
url.searchParams.set('sort', currentState.sort);
url.searchParams.set('order', currentState.order);
if (currentState.search) {
url.searchParams.set('search', currentState.search);
}
try {
const response = await fetch(url.toString());
const result = await response.json();
if (response.ok) {
renderTable(result.data);
renderPagination(result.meta);
updateSortIndicators();
} else {
console.error("加载数据 API 错误:", result);
historyBody.innerHTML = `<tr><td colspan="5">API 错误: ${JSON.stringify(result)}</td></tr>`;
}
} catch (error) {
console.error("加载数据请求失败:", error);
historyBody.innerHTML = '<tr><td colspan="5">加载历史记录失败,请检查后端服务是否运行在 6501 端口。</td></tr>';
}
}
// --- 渲染函数 ---
function renderTable(historyItems) {
historyBody.innerHTML = '';
if (historyItems.length === 0) {
historyBody.innerHTML = '<tr><td colspan="5">没有找到记录。</td></tr>';
return;
}
historyItems.forEach(item => {
const row = historyBody.insertRow();
row.innerHTML = `
<td>
<!-- value 必须是数据库 ID -->
<input type="checkbox" class="row-checkbox" value="${item.id}">
${item.id}
</td>
<td><a href="${item.url}" title="${item.url}" target="_blank">${truncate(item.url, 50)}</a></td>
<td>${truncate(item.title || '无标题', 30)}</td>
<td>${item.timestamp}</td>
<td></td>
`;
});
}
function renderPagination(meta) {
const { total_records, per_page, current_page, last_page } = meta;
const start = total_records > 0 ? (current_page - 1) * per_page + 1 : 0;
let end = current_page * per_page;
if (end > total_records) {
end = total_records;
}
pageInfoSpan.textContent = `第 ${current_page} / ${last_page} 页`;
totalInfoSpan.textContent = `(显示 ${start}-${end} 条,共 ${total_records} 条)`;
prevBtn.disabled = current_page <= 1;
nextBtn.disabled = current_page >= last_page;
}
// --- 排序处理 (已修复逻辑) ---
function sortTable(column) {
if (currentState.sort === column) {
// 如果是同一列,切换方向
currentState.order = currentState.order === 'asc' ? 'desc' : 'asc';
} else {
// 如果是新列,确定新的初始排序方向。
currentState.sort = column;
// 默认策略:
// 如果新列是 'timestamp',从 'desc' 开始(最新)
// 否则,从 'asc' 开始(ID 等从小到大,更明显)
currentState.order = (column === 'timestamp') ? 'desc' : 'asc';
}
currentState.page = 1;
fetchHistory();
}
function updateSortIndicators() {
document.querySelectorAll('.sort-icon').forEach(icon => {
icon.className = 'sort-icon';
});
const currentIcon = document.getElementById(`sort-${currentState.sort}`);
if (currentIcon) {
currentIcon.classList.add(currentState.order === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
}
// --- 事件处理函数 ---
function handlePageChange(direction) {
if (direction === 'next') {
currentState.page += 1;
} else if (direction === 'prev') {
currentState.page -= 1;
}
fetchHistory();
}
function handleSearch() {
currentState.search = searchInput.value.trim();
currentState.page = 1;
fetchHistory();
}
function handlePerPageChange(e) {
currentState.per_page = parseInt(e.target.value);
localStorage.setItem('per_page', currentState.per_page);
currentState.page = 1;
fetchHistory();
}
// --- 批量删除逻辑 (保持不变) ---
async function handleDeleteSelected() {
const checkboxes = document.querySelectorAll('#history-body .row-checkbox:checked');
const ids = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (ids.length > 0) {
if (!confirm(`确定要删除选中的 ${ids.length} 条记录吗?`)) return;
try {
const response = await fetch('/api/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const data = await response.json();
if (data.deleted > 0) {
alert(`成功删除了 ${data.deleted} 条记录。`);
fetchHistory();
} else {
alert('删除请求成功,但没有记录被删除。');
}
} catch (error) {
console.error('删除请求失败:', error);
alert('删除请求失败,请检查网络或后端服务。');
}
} else {
alert('请选择至少一条记录。');
}
}
// --- 实用工具函数 ---
function truncate(str, n) {
if (str && str.length > n) {
return str.substring(0, n - 1) + '...';
}
return str;
}
// --- 初始化和绑定事件 ---
document.addEventListener('DOMContentLoaded', function() {
// 绑定分页和搜索事件
prevBtn.addEventListener('click', () => handlePageChange('prev'));
nextBtn.addEventListener('click', () => handlePageChange('next'));
searchBtn.addEventListener('click', handleSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleSearch();
});
perPageSelect.addEventListener('change', handlePerPageChange);
deleteBtn.addEventListener('click', handleDeleteSelected);
// 全选/反选逻辑 (作用于当前页)
selectAllCheckbox.addEventListener('change', function(e) {
document.querySelectorAll('#history-body .row-checkbox').forEach(checkbox => {
checkbox.checked = e.target.checked;
});
});
// 绑定 ID 排序事件 (修复 ID 排序问题)
const idHeader = document.getElementById('id-sortable-header');
idHeader.addEventListener('click', () => {
sortTable('id');
});
// 将 sortTable 暴露给全局 (用于其他 th 的 onclick)
window.sortTable = sortTable;
// 初次加载数据
fetchHistory();
});
</script>
</body>
</html>
如果不能用 就告诉我 我发个压缩包得了 !