313 lines
11 KiB
JavaScript
313 lines
11 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 属性,从而隐藏安装入口。
|
||
//
|
||
// MV3 与 MV2 的注册方式不同,必须分别处理(此前只写了 MV3 那条,
|
||
// Firefox 走 chrome.scripting 早退,于是 data-navi-ext 永不写入,
|
||
// 安装入口在 Firefox 上永远藏不住):
|
||
// · MV3 / Chromium:chrome.scripting.registerContentScripts,callback 风格
|
||
// · MV2 / Firefox :browser.contentScripts.register,返回 Promise,
|
||
// 注销只能靠 register() 返回的句柄上的 .unregister()(无按 id 注销的 API)
|
||
const DETECT_SCRIPT_ID = 'navi-detect';
|
||
const DETECT_FILE = 'detect.js';
|
||
// MV2 句柄:MV2 没有「按 id 查询已注册脚本」的 API,只能自己留住上次注册
|
||
// 返回的对象,换地址时调它的 unregister()。MV3 另有 storage 字段标记。
|
||
let mv2RegisteredHandle = null;
|
||
|
||
function extApi() {
|
||
// Firefox 注入 browser.* 兼 chrome.* 兼容层;优先用它拿 MV2 的 contentScripts。
|
||
return (typeof browser !== 'undefined' && browser && browser.contentScripts)
|
||
? browser
|
||
: (typeof chrome !== 'undefined' ? chrome : null);
|
||
}
|
||
|
||
function hostMatchPattern(serverUrl) {
|
||
try {
|
||
const host = new URL(serverUrl).hostname;
|
||
return host ? '*://' + host + '/*' : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// mv2HasContentScripts 判断当前运行的是 Firefox MV2 那条路。
|
||
function mv2HasContentScripts(api) {
|
||
return !!(api && api.contentScripts && typeof api.contentScripts.register === 'function');
|
||
}
|
||
|
||
async function unregisterMV2() {
|
||
if (!mv2RegisteredHandle) return;
|
||
const h = mv2RegisteredHandle;
|
||
mv2RegisteredHandle = null;
|
||
try {
|
||
if (typeof h.unregister === 'function') await h.unregister();
|
||
} catch (e) { /* 从未注册 / 已被注销 */ }
|
||
}
|
||
|
||
async function syncDetectScript(serverUrl) {
|
||
const api = extApi();
|
||
if (!api) return;
|
||
|
||
// ① Firefox MV2:contentScripts.register(Promise)
|
||
if (mv2HasContentScripts(api)) {
|
||
await unregisterMV2();
|
||
const pattern = hostMatchPattern(serverUrl);
|
||
if (!pattern) return;
|
||
try {
|
||
mv2RegisteredHandle = await api.contentScripts.register({
|
||
matches: [pattern],
|
||
js: [DETECT_FILE],
|
||
runAt: 'document_start',
|
||
allFrames: false,
|
||
});
|
||
} catch (e) {
|
||
// 静默降级:未装扩展时只是多显示一个安装入口,不影响同步
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ② Chromium MV3:chrome.scripting.registerContentScripts(callback)
|
||
if (!api.scripting || !api.scripting.registerContentScripts) return;
|
||
try {
|
||
const existing = await api.scripting.getRegisteredContentScripts({ ids: [DETECT_SCRIPT_ID] });
|
||
if (existing && existing.length) {
|
||
await api.scripting.unregisterContentScripts({ ids: [DETECT_SCRIPT_ID] });
|
||
}
|
||
} catch (e) { /* 从未注册过 */ }
|
||
|
||
const pattern = hostMatchPattern(serverUrl);
|
||
if (!pattern) return;
|
||
try {
|
||
await api.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';
|
||
}
|