导航页现在会自动检测浏览历史插件是否已安装: - 扩展在用户配置的服务地址上动态注册 content script (Chrome: chrome.scripting.registerContentScripts, Firefox: browser.contentScripts.register), 于 document_start 给 <html> 打上 data-navi-ext 标记, 不在其他站点运行。 - history-server 始终把「安装浏览历史插件」放在「历史访问」第一条 (入口 url=/extension/install.html),启动时也会重建一次, 不再需要等下一次同步。 - web/navi.js 读到标记后隐藏该入口(连 .item-wrapper 一起隐藏, 避免留下空网格位)。 - 新增 /extension/install.html:自动识别浏览器,给出对应安装包与步骤; 支持在 data 卷放 extension-store.json 配置商店链接, 有商店链接时显示真正的「一键安装」。 - 段落名匹配同时兼容中英文(常用服务/Favorites、历史访问/History), 修掉了公开示例 conf.yml 用英文名时重建逻辑完全失效的问题。 - 扩展升到 1.1.0(新增 scripting 权限与 detect.js)。 Note: Chrome/Edge 不允许从网站直接安装非商店扩展, 因此自托管场景下只能下载后手动加载;发布到商店后才会出现一键安装。
260 lines
8.7 KiB
JavaScript
260 lines
8.7 KiB
JavaScript
// JianF Nav Sync — Chrome/Edge MV3 service worker
|
|
// 定期用 chrome.history.search 拉取最近访问,本地过滤搜索引擎后批量上报。
|
|
'use strict';
|
|
|
|
const ALARM_NAME = 'navi-sync';
|
|
const SYNC_MINUTES = 0.5; // 30 秒一轮
|
|
const FETCH_WINDOW = 2 * 60 * 60 * 1000; // 每次拉取过去 2 小时
|
|
const MAX_BATCH = 200; // 单批最多上报条数
|
|
const DEDUP_SECONDS = 600; // 10 分钟内同一 URL 只记一次
|
|
|
|
const SEARCH_HOSTS = new Set([
|
|
'google.com', 'www.google.com', 'googleusercontent.com',
|
|
'bing.com', 'www.bing.com', 'cn.bing.com',
|
|
'baidu.com', 'www.baidu.com', 'm.baidu.com',
|
|
'duckduckgo.com', 'www.duckduckgo.com', 'lite.duckduckgo.com',
|
|
'sogou.com', 'www.sogou.com',
|
|
'so.com', 'www.so.com',
|
|
'yahoo.com', 'www.yahoo.com', 'search.yahoo.com',
|
|
'ecosia.org', 'www.ecosia.org',
|
|
'qwant.com', 'www.qwant.com',
|
|
'startpage.com', 'www.startpage.com',
|
|
'brave.com', 'search.brave.com',
|
|
'yandex.com', 'yandex.ru',
|
|
'360.cn', 'www.360.cn',
|
|
'sm.cn', 'quark.sm.cn',
|
|
'searx.be', 'searxng.site', 'paulgo.io',
|
|
]);
|
|
|
|
let lastSyncTime = 0;
|
|
let pendingItems = [];
|
|
|
|
// ── 检测标记:只向用户配置的导航站注入 content script ──
|
|
// 已安装的浏览器上,导航站会拿到 data-navi-ext 属性,从而隐藏安装入口。
|
|
const DETECT_SCRIPT_ID = 'navi-detect';
|
|
const DETECT_FILE = 'detect.js';
|
|
|
|
function hostMatchPattern(serverUrl) {
|
|
try {
|
|
const host = new URL(serverUrl).hostname;
|
|
return host ? '*://' + host + '/*' : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function syncDetectScript(serverUrl) {
|
|
if (!chrome.scripting || !chrome.scripting.registerContentScripts) return;
|
|
try {
|
|
const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] });
|
|
if (existing && existing.length) {
|
|
await chrome.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] });
|
|
}
|
|
} catch (e) { /* 从未注册过 */ }
|
|
|
|
const pattern = hostMatchPattern(serverUrl);
|
|
if (!pattern) return;
|
|
try {
|
|
await chrome.scripting.registerContentScripts([{
|
|
id: DETECT_SCRIPT_ID,
|
|
matches: [pattern],
|
|
js: [DETECT_FILE],
|
|
runAt: 'document_start',
|
|
allFrames: false,
|
|
persistAcrossSessions: true,
|
|
}]);
|
|
} catch (e) {
|
|
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
|
|
}
|
|
}
|
|
|
|
// ── 初始化 ──
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
ensureAlarm();
|
|
chrome.storage.local.get(['pending', 'lastSyncTime', 'serverUrl'], (data) => {
|
|
pendingItems = data.pending || [];
|
|
lastSyncTime = data.lastSyncTime || 0;
|
|
syncDetectScript(data.serverUrl);
|
|
});
|
|
});
|
|
|
|
chrome.runtime.onStartup.addListener(() => {
|
|
chrome.storage.local.get(['serverUrl'], (d) => syncDetectScript(d.serverUrl));
|
|
});
|
|
|
|
function ensureAlarm() {
|
|
chrome.alarms.get(ALARM_NAME, (alarm) => {
|
|
if (!alarm) chrome.alarms.create(ALARM_NAME, { periodInMinutes: SYNC_MINUTES });
|
|
});
|
|
}
|
|
|
|
// ── 本地过滤:搜索引擎与无意义页面 ──
|
|
function isJunk(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
|
|
const host = u.hostname.toLowerCase().replace(/^www\./, '');
|
|
if (SEARCH_HOSTS.has(host)) return true;
|
|
if (host.endsWith('.search.yahoo.com')) return true;
|
|
// 常见搜索引擎结果特征:路径 /search、/s、/results 且带 q 参数
|
|
const q = u.searchParams.get('q');
|
|
const path = u.pathname.toLowerCase();
|
|
if (q && (path === '/search' || path === '/s' || path === '/results' || path === '/web')) return true;
|
|
// 浏览器内部页 / 空白页 / 下载页
|
|
if (u.hostname === 'newtab' || u.hostname === 'chrome-search' ||
|
|
url.startsWith('chrome://') || url.startsWith('edge://') ||
|
|
url.startsWith('about:')) return true;
|
|
return false;
|
|
} catch (e) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function hostFrom(url) {
|
|
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
|
|
}
|
|
|
|
// ── 拉取最近浏览历史(增量)──
|
|
async function collectRecent(previousSyncTime = 0) {
|
|
const initialCutoff = Date.now() - DEDUP_SECONDS * 1000;
|
|
const cutoff = previousSyncTime > 0 ? previousSyncTime : initialCutoff;
|
|
const since = Math.max(0, Date.now() - FETCH_WINDOW, cutoff);
|
|
const items = await new Promise((resolve) => {
|
|
chrome.history.search({ text: '', startTime: since, maxResults: 10000 }, resolve);
|
|
});
|
|
const seen = new Set();
|
|
const collected = [];
|
|
|
|
// 按时间从新到旧去重:同一 host+path 最近 10 分钟内只保留一次
|
|
for (const item of items) {
|
|
if (isJunk(item.url)) continue;
|
|
const key = hostFrom(item.url) + '|' + (item.url.split('?')[0] || '');
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const ts = item.lastVisitTime || Date.now();
|
|
if (ts <= cutoff) continue; // 只上报上次成功同步之后的新访问
|
|
collected.push({
|
|
url: item.url,
|
|
title: item.title || '',
|
|
timestamp: Math.floor(ts / 1000),
|
|
visit_count: item.visitCount || 1,
|
|
});
|
|
if (collected.length >= MAX_BATCH) break;
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
// ── 定时同步 ──
|
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name === ALARM_NAME) syncNow();
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
if (msg && msg.action === 'sync') {
|
|
syncNow().then((r) => sendResponse(r)).catch((e) => sendResponse({ ok: false, error: e.message }));
|
|
return true;
|
|
}
|
|
if (msg && msg.action === 'getConfig') {
|
|
chrome.storage.local.get(['serverUrl', 'syncToken', 'deviceName'], (d) => sendResponse(d));
|
|
return true;
|
|
}
|
|
if (msg && msg.action === 'setConfig') {
|
|
const data = msg.data || {};
|
|
chrome.storage.local.set(data, () => {
|
|
syncDetectScript(data.serverUrl);
|
|
sendResponse({ ok: true });
|
|
});
|
|
return true;
|
|
}
|
|
if (msg && msg.action === 'clearPending') {
|
|
pendingItems = [];
|
|
chrome.storage.local.set({ pending: [] }, () => sendResponse({ ok: true }));
|
|
return true;
|
|
}
|
|
if (msg && msg.action === 'getStatus') {
|
|
chrome.storage.local.get(['serverUrl', 'syncToken'], (d) => {
|
|
sendResponse({
|
|
serverConfigured: !!(d.serverUrl),
|
|
pendingCount: pendingItems.length,
|
|
device: msg.deviceName || '',
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
});
|
|
|
|
async function syncNow() {
|
|
ensureAlarm();
|
|
const syncStartedAt = Date.now();
|
|
const stored = await new Promise((resolve) => {
|
|
chrome.storage.local.get(
|
|
['serverUrl', 'syncToken', 'deviceName', 'pending', 'lastSyncTime'],
|
|
resolve,
|
|
);
|
|
});
|
|
const { serverUrl, syncToken, deviceName } = stored;
|
|
if (!serverUrl) return { ok: false, reason: 'not configured' };
|
|
|
|
// 每次从持久化存储恢复游标和失败队列,兼容 MV3 service worker 休眠重启。
|
|
pendingItems = Array.isArray(stored.pending) ? stored.pending : [];
|
|
lastSyncTime = Number(stored.lastSyncTime) || 0;
|
|
let recent;
|
|
try { recent = await collectRecent(lastSyncTime); } catch (e) { recent = []; }
|
|
const merged = dedup(recent.concat(pendingItems));
|
|
if (merged.length === 0) {
|
|
lastSyncTime = syncStartedAt;
|
|
await new Promise((res) => chrome.storage.local.set({ lastSyncTime }, res));
|
|
return { ok: true, skipped: true, reason: 'nothing new' };
|
|
}
|
|
|
|
const body = JSON.stringify({
|
|
items: merged.map((it) => ({
|
|
url: it.url,
|
|
title: it.title || '',
|
|
device: deviceName || defaultDevice(),
|
|
timestamp: it.timestamp,
|
|
visit_count: it.visit_count || 1,
|
|
})),
|
|
});
|
|
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (syncToken) headers['Authorization'] = 'Bearer ' + syncToken;
|
|
|
|
try {
|
|
const resp = await fetch(serverUrl.replace(/\/$/, '') + '/api/history/sync', {
|
|
method: 'POST', headers, body,
|
|
});
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
pendingItems = merged;
|
|
await new Promise((res) => chrome.storage.local.set({ pending: merged }, res));
|
|
return { ok: false, status: resp.status, error: text.slice(0, 200) };
|
|
}
|
|
const data = await resp.json().catch(() => ({}));
|
|
pendingItems = [];
|
|
lastSyncTime = syncStartedAt;
|
|
await new Promise((res) => chrome.storage.local.set({ pending: [], lastSyncTime }, res));
|
|
return { ok: true, count: merged.length, total: data.total || 0 };
|
|
} catch (e) {
|
|
// 网络失败:保留队列下次重试
|
|
await new Promise((res) => chrome.storage.local.set({ pending: merged }, res));
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
function dedup(list) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const it of list) {
|
|
const key = it.url;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(it);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function defaultDevice() {
|
|
return 'desktop-browser';
|
|
}
|